import fs from 'fs'
import path from 'path'
import get from 'lodash-es/get.js'
import isarr from './isarr.mjs'
import isobj from './isobj.mjs'
import isestr from './isestr.mjs'
import fsReadText from './fsReadText.mjs'
// ── Windows .cmd/.bat 支援 ──
// npm全域安裝的命令在Windows上是.cmd批次檔, Node.js spawn無法直接執行
// (CVE-2024-27980安全修正後會回EINVAL)
// 而shell:true會導致含特殊字元的參數被cmd.exe錯誤解析
// 參考cross-spawn做法: 手動透過cmd.exe /d /s /c執行, 並正確轉義參數
// 參考來源: https://github.com/moxystudio/node-cross-spawn
/**
* 由環境變數物件取值, 鍵不分大小寫(Windows環境變數大小寫不敏感, 但{...process.env}展開後之物件鍵敏感, 如Path與PATH)
*
* @param {Object} env 輸入環境變數物件
* @param {String} key 輸入變數名稱字串
* @returns {*} 回傳變數值, 無則為undefined
*/
function getEnvValue(env, key) {
let k = Object.keys(env).find((k) => k.toUpperCase() === key)
return k === undefined ? undefined : env[k]
}
/**
* 判定路徑是否為檔案(跟隨連結), 用於命令搜尋: 一般檔案與指向檔案之符號連結為真; stat讀不到但本身為連結者亦為真(如WindowsApps之App Execution Alias, 其stat失敗而lstat為連結, where.exe亦列出); 資料夾與不存在者為假
* 不用fsIsFile: 其以lstat判定且連結一律為假, 會漏掉以連結安裝之執行檔
*
* @param {String} fp 輸入路徑字串
* @returns {Boolean} 回傳是否為檔案布林值
*/
function isFileFollowLink(fp) {
try {
return fs.statSync(fp).isFile()
}
catch (err) {}
try {
return fs.lstatSync(fp).isSymbolicLink()
}
catch (err) {}
return false
}
/**
* 判定路徑是否為實體檔案(跟隨連結, 懸空連結為假), 用於批次檔是否存在
*
* @param {String} fp 輸入路徑字串
* @returns {Boolean} 回傳是否為檔案布林值
*/
function isFileStat(fp) {
try {
return fs.statSync(fp).isFile()
}
catch (err) {}
return false
}
/**
* 依序列出命令之實體路徑: 先搜dirFirst(有給時)再依序搜PATH各目錄, 每個目錄先比對原名再依PATHEXT逐一補上副檔名
* 不呼叫where.exe: 其輸出為系統字碼頁(如Big5), 以utf-8解碼時含非ASCII字元之路徑(如中文使用者名稱下之%APPDATA%\npm, 或於中文資料夾內npm run時所加入之node_modules\.bin)會成亂碼而解析失敗; 亦免每次解析都啟動子程序(參考cross-spawn以which於JS內搜尋PATH)
*
* @param {String} cmd 輸入命令字串(不含目錄)
* @param {Object} o 輸入設定物件
* @param {String} [o.dirFirst=undefined] 輸入最先搜尋之目錄字串, 預設undefined代表不搜
* @param {Object} o.env 輸入環境變數物件, 取其PATH與PATHEXT
* @param {String} o.base 輸入PATH內相對目錄之基準目錄字串
* @param {Boolean} o.useExt 輸入是否依PATHEXT補副檔名布林值
* @returns {Array} 回傳實體路徑字串陣列, 無則為[]
*/
function whereList(cmd, { dirFirst, env, base, useExt }) {
//exts, 原名在前(同where: 名稱已含.亦補PATHEXT, 如zz.v1可找到zz.v1.cmd)
let exts = ['']
if (useExt) {
let pathext = getEnvValue(env, 'PATHEXT')
if (!isestr(pathext)) {
pathext = '.COM;.EXE;.BAT;.CMD'
}
for (let e of pathext.split(';')) {
e = e.trim()
if (e !== '') {
exts.push(e.toLowerCase())
}
}
}
//dirs, PATH以;分隔(引號內之;不分隔), 相對目錄以base為基準; 一律轉絕對路徑, 回傳之路徑方不依本進程之工作目錄
let dirs = []
if (isestr(dirFirst)) {
dirs.push(path.resolve(dirFirst))
}
let envPath = getEnvValue(env, 'PATH')
if (isestr(envPath)) {
for (let d of envPath.match(/(?:"[^"]*"|[^;"])+/g) || []) {
d = d.replace(/"/g, '').trim()
if (d !== '') {
dirs.push(path.resolve(base, d))
}
}
}
//rs
let rs = []
for (let d of dirs) {
for (let e of exts) {
let fp = path.join(d, cmd + e)
if (isFileFollowLink(fp) && !rs.some((r) => r.toLowerCase() === fp.toLowerCase())) {
rs.push(fp)
}
}
}
return rs
}
/**
* 找到命令的實際路徑(.cmd / .exe)
* 未帶副檔名者同where: 先搜本進程之工作目錄再搜本進程之PATH, 並依PATHEXT補副檔名
* 只給檔名之.cmd/.bat同cmd.exe(退路即由cmd.exe搜尋): 先搜子進程之工作目錄(子進程env有NoDefaultCurrentDirectoryInExePath時不搜)再搜子進程env之PATH, 不補副檔名
* 含目錄(絕對或相對)者原樣回傳
*
* @param {String} cmd 輸入命令字串
* @param {String} [cwd=undefined] 輸入子進程工作目錄字串, 預設undefined代表本進程之工作目錄
* @param {Object} [env=undefined] 輸入子進程環境變數物件, 預設undefined代表本進程之process.env
* @returns {String} 回傳解析後之命令路徑字串, 查無時回傳原命令字串
*/
function resolveCommand(cmd, cwd = undefined, env = undefined) {
//非Windows無須解析
if (process.platform !== 'win32') {
return cmd
}
//含目錄(絕對或相對路徑), 無須解析(where對含目錄之樣式亦查無而原樣回傳)
if (path.basename(cmd) !== cmd) {
return cmd
}
try {
//只給檔名之.cmd/.bat
if (/\.(cmd|bat)$/i.test(cmd)) {
let envChild = env !== undefined ? env : process.env
let dirChild = isestr(cwd) ? path.resolve(cwd) : process.cwd() //相對之cwd以本進程之工作目錄為基準(同spawn), 轉絕對路徑
let bNoCwd = getEnvValue(envChild, 'NODEFAULTCURRENTDIRECTORYINEXEPATH') !== undefined
let lines = whereList(cmd, { dirFirst: bNoCwd ? undefined : dirChild, env: envChild, base: dirChild, useExt: false })
return lines[0] || cmd
}
//已帶其他副檔名, 無須解析
if (/\.(exe|ps1)$/i.test(cmd)) {
return cmd
}
//whereList, 取得命令實體路徑清單(同where)
let lines = whereList(cmd, { dirFirst: process.cwd(), env: process.env, base: process.cwd(), useExt: true })
//優先.cmd, 次之.exe, 皆無則取首筆
let cmdFile = lines.find((l) => /\.cmd$/i.test(l))
if (cmdFile) {
return cmdFile
}
let exeFile = lines.find((l) => /\.exe$/i.test(l))
if (exeFile) {
return exeFile
}
return lines[0] || cmd
}
catch {
return cmd
}
}
/**
* 轉義cmd.exe的單一參數(cross-spawn escapeArgument邏輯)
* 參考: https://qntm.org/cmd
*
* @param {String} arg 輸入參數字串
* @returns {String} 回傳轉義後參數字串
*/
function escapeWinArg(arg) {
//String, 非字串元素比照node之spawn轉為字串(否則.replace拋TypeError)
arg = String(arg)
//轉義反斜線 + 雙引號 組合
arg = arg.replace(/(\\*)"/g, '$1$1\\"')
//轉義尾端反斜線(避免吃掉結尾引號)
arg = arg.replace(/(\\*)$/, '$1$1')
//用雙引號包裹
arg = `"${arg}"`
//轉義cmd.exe的metacharacters(在引號外用^)
arg = arg.replace(/[()%!^"<>&|]/g, '^$&')
return arg
}
/**
* 轉義cmd.exe的命令部分
*
* @param {String} cmd 輸入命令字串
* @returns {String} 回傳轉義後命令字串
*/
function escapeWinCmd(cmd) {
return cmd.replace(/[()%!^"<>&|;, ]/g, '^$&')
}
/**
* 從.cmd shim中解析出實際入口檔案路徑(可能為JS, 亦可能為原生.exe)
* npm全域安裝的.cmd格式固定, 末行為:
* ... "%_prog%" "%dp0%\node_modules\...\entry" %*
* 入口可能為.js / .cjs / .mjs / 無副檔名 / .exe(如opencode的bin/opencode.exe),
* 故一律抓引號內node_modules後的相對路徑, 再以isFileStat驗證實體檔存在
* (只匹配.js會讓無副檔名入口落入cmd.exe fallback, 破壞多行prompt)
* 回傳後由buildSpawnArgs依副檔名決定: .exe直接spawn, 其餘交給node
*
* 入口不存在時不回傳null(與「不是npm shim」區分), 而回傳exists為false; 是否據以判定為「未能啟動」另看isExecLine:
* 僅當入口出現於cmd-shim之執行行(行首或&之後, 可前置"%_prog%"與shebang參數, 以%*結尾)時為true, 自訂批次檔於IF EXIST等判斷句內引用同樣式者為false, 仍交cmd.exe退路
*
* @param {String} cmdPath 輸入.cmd檔案路徑字串
* @returns {Object|null} 回傳{ entry, exists, isExecLine }, entry為入口檔案路徑字串, exists為其是否為實體檔案, isExecLine為是否位於cmd-shim之執行行; 不含shim樣式或讀取失敗回傳null
*/
function parseJsEntryFromCmd(cmdPath) {
//fsReadText, 讀取失敗回傳{ error }, 故取success驗證
let rr = fsReadText(cmdPath)
let content = get(rr, 'success', '')
if (!isestr(content)) {
return null
}
//匹配"%dp0%\node_modules\...\entry": 捕捉到結尾引號前, 含任何副檔名或無副檔名
let m = content.match(/%dp0%\\(node_modules\\[^"]+)"/i)
if (!m) {
return null
}
//jsPath
let dir = path.dirname(cmdPath)
let jsPath = path.join(dir, m[1])
//isFileStat, 須為實體檔案(existsSync對資料夾亦回true); 跟隨連結, 以連結安裝之入口亦可(fsIsFile以lstat判定且連結一律為假)
let exists = isFileStat(jsPath)
//isExecLine, cmd-shim之執行行: 行首或&之後, 可前置"%_prog%"(及shebang參數), 入口以引號包住, 其後僅接%*; 捕捉值須與上方首個匹配相同
let mExec = content.match(/(?:^|&)[ \t]*(?:"%_prog%"[^\r\n]*?[ \t])?"%dp0%\\(node_modules\\[^"\r\n]+)"[ \t]+%\*[ \t]*\r?$/im)
let isExecLine = mExec !== null && mExec[1].toLowerCase() === m[1].toLowerCase()
return { entry: jsPath, exists, isExecLine }
}
/**
* 將command與args轉為可安全交給child_process.spawn之參數, 主要處理Windows下npm全域命令為.cmd批次檔而spawn無法直接執行之問題
* 策略優先順序:
* 1. .exe → 直接spawn
* 2. .cmd → 解析JS入口, 用node直接執行(繞過cmd.exe, 支援多行參數)
* 3. .cmd為npm shim(cmd-shim之執行行)而入口檔不存在 → 拋ENOENT(同不存在之批次檔)
* 4. 其餘.cmd → 透過cmd.exe /d /s /c執行(fallback, 不支援多行參數)
* 非Windows平台原樣回傳
* 回傳物件三個分支一律帶opt鍵(無額外設定時為{}), 呼叫端可直接展開至spawn之選項物件
*
* @param {String} command 輸入執行檔名稱字串
* @param {Array} [args=[]] 輸入參數陣列, 預設[]
* @param {Object} [opt={}] 輸入設定物件, 預設{}
* @param {String} [opt.cwd=undefined] 輸入子進程工作目錄字串, 用於解析只給檔名或相對路徑之.cmd/.bat並判定其是否存在, 預設undefined代表本進程之工作目錄
* @param {Object} [opt.env=undefined] 輸入子進程環境變數物件, 用於解析只給檔名之.cmd/.bat(取其PATH與NoDefaultCurrentDirectoryInExePath), 預設undefined代表本進程之process.env
* @returns {Object} 回傳spawn參數物件, 內含file(執行檔), args(參數陣列), opt(spawn額外選項物件); Windows下.cmd/.bat不存在時拋出code為'ENOENT'之錯誤(訊息同node之'spawn <路徑> ENOENT'); .cmd為npm shim(cmd-shim之執行行含"%dp0%\node_modules\...")而其入口檔不存在時亦拋出code為'ENOENT'之錯誤, 訊息為'spawn <入口檔路徑> ENOENT'
*/
function buildSpawnArgs(command, args = [], opt = {}) {
//args
if (!isarr(args)) {
args = []
}
//非Windows直接使用
if (process.platform !== 'win32') {
return { file: command, args, opt: {} }
}
//cwd
let cwd = get(opt, 'cwd', null)
if (!isestr(cwd)) {
cwd = undefined
}
//env
let env = get(opt, 'env', null)
if (!isobj(env)) {
env = undefined
}
//resolved
let resolved = resolveCommand(command, cwd, env)
//.exe可直接spawn
if (/\.exe$/i.test(resolved)) {
return { file: resolved, args, opt: {} }
}
//.cmd/.bat → 嘗試解析出實際入口
if (/\.(cmd|bat)$/i.test(resolved)) {
//fpBatch, 只給檔名者已由resolveCommand解析為絕對路徑, 仍為檔名即查無; 含目錄之相對路徑以子進程之工作目錄為準(cmd.exe退路亦於該目錄解析)
let fpBatch = ''
if (path.isAbsolute(resolved)) {
fpBatch = resolved
}
else if (path.basename(resolved) !== resolved) {
fpBatch = path.resolve(cwd || process.cwd(), resolved)
}
//不存在之批次檔: 不可交cmd.exe退路(cmd.exe本身可啟動, 會得離開碼1與系統字碼頁之訊息且被重試), 以與node之非同步ENOENT同形之錯誤拋出, 由呼叫端之try以結果物件回報(code null、pid undefined、不重試)
//node對.cmd/.bat直接spawn會同步拋EINVAL(CVE-2024-27980防護), 故不能改為直接spawn取得ENOENT
if (!isFileStat(fpBatch)) {
let e = new Error(`spawn ${resolved} ENOENT`)
e.code = 'ENOENT'
e.errno = -4058
e.syscall = `spawn ${resolved}`
e.path = resolved
throw e
}
let shim = parseJsEntryFromCmd(fpBatch)
if (shim !== null && shim.exists) {
let entry = shim.entry
//入口為原生.exe(如opencode的bin/opencode.exe) → 直接spawn
//切勿丟給node, 否則node會把PE二進位當JS解析而崩潰(MZ... SyntaxError)
if (/\.exe$/i.test(entry)) {
return { file: entry, args, opt: {} }
}
//入口為JS(.js/.cjs/.mjs/無副檔名) → 用node直接執行, 繞過cmd.exe, 支援多行參數
return { file: process.execPath, args: [entry, ...args], opt: {} }
}
//npm shim已辨識而入口不存在(全域套件資料夾被刪而shim殘留、安裝中斷): 同不存在之批次檔, 不交cmd.exe退路(cmd.exe會啟動而得離開碼1且被重試, .exe入口之訊息為系統字碼頁), 拋與node同形之ENOENT, 訊息指入口路徑(批次檔本身存在)
//僅限入口位於cmd-shim之執行行者(isExecLine), 自訂批次檔於判斷句內引用同樣式而另有退路者仍交cmd.exe
if (shim !== null && !shim.exists && shim.isExecLine) {
let e = new Error(`spawn ${shim.entry} ENOENT`)
e.code = 'ENOENT'
e.errno = -4058
e.syscall = `spawn ${shim.entry}`
e.path = shim.entry
throw e
}
//fallback: 透過cmd.exe執行(注意: 不支援多行參數)
let escaped = args.map((a) => escapeWinArg(a))
let cmdLine = `${escapeWinCmd(resolved)} ${escaped.join(' ')}`
let comspec = process.env.comspec || process.env.COMSPEC || 'cmd.exe'
return {
file: comspec,
args: ['/d', '/s', '/c', `"${cmdLine}"`],
opt: { windowsVerbatimArguments: true },
}
}
return { file: resolved, args, opt: {} }
}
/**
* 建立子進程之環境變數物件: 以process.env為底, 加入PYTHONIOENCODING=utf-8, 再併入envExtra(同名以envExtra為準)
* 每次呼叫重新求值且不動本進程process.env, 故並行調用可各自帶不同值
* PYTHONIOENCODING對一般python直譯器有效(sys.stdout.encoding變utf-8); 對PyInstaller打包之exe無效, 其以隔離模式嵌入直譯器且文件明載刻意忽略PYTHONIOENCODING與PYTHONUTF8, 該類程式須於打包時以-X utf8_mode=1指定, 或由本進程以execProcess之codeCmd('auto'或系統字碼頁)解碼其輸出
* Windows下環境變數大小寫不敏感但JS物件鍵敏感, 故併入前先移除大小寫不同之同名既有鍵, 否則呼叫端傳Path而process.env為PATH時兩鍵並存且Windows取原值, 注入靜默失效
* 此刪除僅限win32: POSIX環境變數大小寫敏感, Path與PATH為兩個獨立變數, 誤刪即退化
*
* @param {Object} [envExtra=undefined] 輸入額外注入之環境變數物件, 值為undefined代表移除該變數, 預設undefined代表不覆寫任何變數
* @returns {Object} 回傳可交給spawn之env物件
*/
function buildSpawnEnv(envExtra = undefined) {
let envSpawn = { ...process.env, PYTHONIOENCODING: 'utf-8' }
if (envExtra !== undefined) {
if (process.platform === 'win32') {
let ks = Object.keys(envExtra).map((k) => k.toLowerCase())
for (let k of Object.keys(envSpawn)) {
if (ks.includes(k.toLowerCase())) {
delete envSpawn[k]
}
}
}
envSpawn = { ...envSpawn, ...envExtra }
}
return envSpawn
}
/**
* execCli系列函數之共用核心, 提供可獨立使用之函數: buildSpawnArgs(將命令轉為Windows安全之spawn參數)與buildSpawnEnv(建立子進程環境變數)
*
* Unit Test: {@link https://github.com/yuda-lyu/wsemi/blob/master/test/execCliCore.test.mjs Github}
* @memberOf wsemi
* @example
* //need test in nodejs
*
* let { buildSpawnArgs, buildSpawnEnv } = execCliCore
*
* //buildSpawnArgs, Windows下npm全域命令(.cmd)會被解析為以node執行其JS入口
* let sp = buildSpawnArgs('node', ['-e', 'console.log(1)'])
* console.log(sp)
* // => { file: 'C:\\...\\node.exe', args: [ '-e', 'console.log(1)' ], opt: {} } (Windows)
* // => { file: 'node', args: [ '-e', 'console.log(1)' ], opt: {} } (非Windows)
*
* //spawn
* import { spawn } from 'child_process'
* let proc = spawn(sp.file, sp.args, { stdio: 'pipe', windowsHide: true, ...sp.opt })
*
* //buildSpawnEnv, 以process.env為底併入額外變數, 不動本進程
* let env = buildSpawnEnv({ API_KEY: 'abc' })
* console.log(env.API_KEY, env.PYTHONIOENCODING, process.env.API_KEY)
* // => abc utf-8 undefined
*
*/
let execCliCore = {
buildSpawnArgs,
buildSpawnEnv,
}
export default execCliCore