execCliCore.mjs

import { execFileSync } from 'child_process'
import path from 'path'
import get from 'lodash-es/get.js'
import isarr from './isarr.mjs'
import isestr from './isestr.mjs'
import fsIsFile from './fsIsFile.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


/**
 * 用where指令找到命令的實際路徑(.cmd / .exe)
 *
 * @param {String} cmd 輸入命令字串
 * @returns {String} 回傳解析後之命令路徑字串
 */
function resolveCommand(cmd) {

    //非Windows無須解析
    if (process.platform !== 'win32') {
        return cmd
    }

    //已帶副檔名, 無須解析
    if (/\.(cmd|exe|bat|ps1)$/i.test(cmd)) {
        return cmd
    }

    //絕對路徑, 無須解析
    if (path.isAbsolute(cmd)) {
        return cmd
    }

    try {

        //where, 取得命令實體路徑清單
        //stdio的stderr須為ignore, 否則查無命令時where會把系統語系(如Big5)之錯誤訊息直接吐到主控台
        let out = execFileSync('where', [cmd], {
            encoding: 'utf8',
            timeout: 5000,
            windowsHide: true,
            shell: false,
            stdio: ['ignore', 'pipe', 'ignore'],
        }).trim()
        let lines = out.split(/\r?\n/)

        //優先.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) {

    //轉義反斜線 + 雙引號 組合
    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後的相對路徑, 再以fsIsFile驗證實體檔存在
 * (只匹配.js會讓無副檔名入口落入cmd.exe fallback, 破壞多行prompt)
 * 回傳後由buildSpawnArgs依副檔名決定: .exe直接spawn, 其餘交給node
 *
 * @param {String} cmdPath 輸入.cmd檔案路徑字串
 * @returns {String|null} 回傳入口檔案路徑字串, 無法解析回傳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])

    //fsIsFile, 須為實體檔案(existsSync對資料夾亦回true, 故改用fsIsFile)
    if (fsIsFile(jsPath)) {
        return jsPath
    }

    return null
}


/**
 * 將command與args轉為可安全交給child_process.spawn之參數, 主要處理Windows下npm全域命令為.cmd批次檔而spawn無法直接執行之問題
 * 策略優先順序:
 *   1. .exe → 直接spawn
 *   2. .cmd → 解析JS入口, 用node直接執行(繞過cmd.exe, 支援多行參數)
 *   3. .cmd但無法解析JS入口 → 透過cmd.exe /d /s /c執行(fallback, 不支援多行參數)
 * 非Windows平台原樣回傳
 * 回傳物件三個分支一律帶opt鍵(無額外設定時為{}), 呼叫端可直接展開至spawn之選項物件
 *
 * @param {String} command 輸入執行檔名稱字串
 * @param {Array} [args=[]] 輸入參數陣列, 預設[]
 * @returns {Object} 回傳spawn參數物件, 內含file(執行檔), args(參數陣列), opt(spawn額外選項物件)
 */
function buildSpawnArgs(command, args = []) {

    //args
    if (!isarr(args)) {
        args = []
    }

    //非Windows直接使用
    if (process.platform !== 'win32') {
        return { file: command, args, opt: {} }
    }

    //resolved
    let resolved = resolveCommand(command)

    //.exe可直接spawn
    if (/\.exe$/i.test(resolved)) {
        return { file: resolved, args, opt: {} }
    }

    //.cmd/.bat → 嘗試解析出實際入口
    if (/\.(cmd|bat)$/i.test(resolved)) {

        let entry = parseJsEntryFromCmd(resolved)
        if (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: {} }
        }

        //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, 故並行調用可各自帶不同值
 * 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