execCli.mjs

import { spawn, execFileSync } from 'child_process'
import path from 'path'
import get from 'lodash-es/get.js'
import genPm from './genPm.mjs'
import delay from './delay.mjs'
import cint from './cint.mjs'
import isnum from './isnum.mjs'
import isarr from './isarr.mjs'
import isobj from './isobj.mjs'
import isfun from './isfun.mjs'
import isestr from './isestr.mjs'
import ispint from './ispint.mjs'
import isp0int from './isp0int.mjs'
import strleft from './strleft.mjs'
import strdelleft from './strdelleft.mjs'
import strTruncate from './strTruncate.mjs'
import fsIsFile from './fsIsFile.mjs'
import fsReadText from './fsReadText.mjs'
import execProcessKillPid from './execProcessKillPid.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轉為Windows安全的spawn參數
 * 策略優先順序:
 *   1. .exe → 直接spawn
 *   2. .cmd → 解析JS入口, 用node直接執行(繞過cmd.exe, 支援多行參數)
 *   3. .cmd但無法解析JS入口 → 透過cmd.exe /d /s /c執行(fallback, 不支援多行參數)
 *
 * @param {String} command 輸入執行檔名稱字串
 * @param {Array} args 輸入參數陣列
 * @returns {Object} 回傳spawn參數物件, 內含file, args, opt
 */
function buildSpawnArgs(command, args) {

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

    //resolved
    let resolved = resolveCommand(command)

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

    //.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 }
            }

            //入口為JS(.js/.cjs/.mjs/無副檔名) → 用node直接執行, 繞過cmd.exe, 支援多行參數
            return { file: process.execPath, args: [entry, ...args] }
        }

        //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 }
}


//optTruncate, 供strTruncate裁切失敗結果之stdout與stderr時, 於刪節號後標註原始總長度
let optTruncate = {
    funWithMsg: (str) => `(truncated, total ${str.length} chars)`,
}


/**
 * 建立驗證函式
 * 支援'nonempty', 'json', 'min:100'或自訂函式, 多規則可用逗號串接
 *
 * @param {String|Function} rule 輸入驗證規則字串或自訂函式
 * @returns {Function|null} 回傳驗證函式, 無有效規則回傳null
 */
function buildValidator(rule) {

    //自訂函式直接使用
    if (isfun(rule)) {
        return rule
    }

    //check
    if (!isestr(rule)) {
        return null
    }

    //checks
    let checks = rule.split(',').map((r) => r.trim()).filter(Boolean)
    if (checks.length === 0) {
        return null
    }

    return (stdout) => {
        for (let check of checks) {

            if (check === 'nonempty') {
                if (!isestr(stdout) || stdout.trim() === '') {
                    return false
                }
            }

            else if (check === 'json') {
                try {
                    JSON.parse(stdout)
                }
                catch {
                    return false
                }
            }

            else if (strleft(check, 4) === 'min:') {

                //規則本身無效(如min:abc) → 視為驗證失敗, 不靜默跳過
                let smin = strdelleft(check, 4)
                if (!isnum(smin)) {
                    return false
                }

                let min = cint(smin)
                if (!isestr(stdout) || stdout.length < min) {
                    return false
                }
            }

        }
        return true
    }
}


/**
 * 單次非同步呼叫(內部使用, 不含重試邏輯)
 *
 * @param {String} command 輸入執行檔名稱字串
 * @param {Array} [args=[]] 輸入參數陣列
 * @param {Object} [opt={}] 輸入設定物件
 * @returns {Promise} 回傳Promise, resolve回傳結果物件
 */
function execCliOnce(command, args = [], opt = {}) {

    //opt, 已由execCli前置校驗, 此處僅取預設值
    let {
        timeoutMs = 120000,
        cwd = process.cwd(),
        input = undefined,
        validate = undefined,
        maxBuffer = 10 * 1024 * 1024,
        onStdout = undefined,
        onStderr = undefined,
        env: envExtra = undefined,
    } = opt

    //validator
    let validator = buildValidator(validate)

    //startTime
    let startTime = Date.now()

    //pm
    let pm = genPm()

    //envSpawn, 併入process.env(同名以envExtra為準, 含PYTHONIOENCODING)
    //每次attempt於此重新求值並僅作用於該次子進程, 不動本進程process.env, 故並行調用可各自帶不同值
    let envSpawn = { ...process.env, PYTHONIOENCODING: 'utf-8' }
    if (envExtra !== undefined) {
        if (process.platform === 'win32') {
            //Windows環境變數大小寫不敏感但JS物件敏感, 須先移除大小寫不同之同名既有鍵,
            //否則呼叫端傳Path而process.env為PATH時兩鍵並存且Windows取原值, 注入靜默失效
            //此刪除僅限win32: POSIX環境變數大小寫敏感, Path與PATH為兩個獨立變數, 誤刪即退化
            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 }
    }

    //spawn
    let winSpawn = buildSpawnArgs(command, args)
    let proc = spawn(winSpawn.file, winSpawn.args, {
        cwd,
        stdio: ['pipe', 'pipe', 'pipe'],
        windowsHide: true, //執行的主程序若沒有主控台調用執行程序就不會有視窗, 但若通過pm2執行會有, 須設定windowsHide=true
        env: envSpawn,
        ...winSpawn.opt,
    })

    let stdout = ''
    let stderr = ''

    //TextDecoder, 跨chunk邊界正確解碼UTF-8, 避免多位元組中文字元被切成亂碼(U+FFFD)
    //改用全域TextDecoder而非node內建string_decoder, 避免打包時多引入一個node builtin
    let stdoutDecoder = new TextDecoder('utf-8')
    let stderrDecoder = new TextDecoder('utf-8')

    let settled = false
    let timedOut = false

    //stdout data, 經TextDecoder跨chunk解碼
    proc.stdout.on('data', (chunk) => {
        let str = stdoutDecoder.decode(chunk, { stream: true })
        if (!str) {
            return
        }
        if (isfun(onStdout)) {
            onStdout(str)
        }
        if (stdout.length < maxBuffer) {
            stdout += str
        }
    })

    //stderr data, 經TextDecoder跨chunk解碼
    proc.stderr.on('data', (chunk) => {
        let str = stderrDecoder.decode(chunk, { stream: true })
        if (!str) {
            return
        }
        if (isfun(onStderr)) {
            onStderr(str)
        }
        if (stderr.length < maxBuffer) {
            stderr += str
        }
    })

    //stdin, 子進程提早關閉stdin時write/end會觸發EPIPE, 需listen'error'才不會拋unhandled event
    proc.stdin.on('error', () => {})
    if (input !== undefined) {
        proc.stdin.write(input, 'utf8')
    }
    proc.stdin.end()

    //timer, 逾時觸發時調用execProcessKillPid殺整棵程序樹
    let timer = setTimeout(() => {
        if (settled) {
            return
        }
        timedOut = true

        //fire-and-forget, 殺掉子進程後close事件會接著觸發, 由timedOut決定回傳訊息
        execProcessKillPid(proc.pid)
            .catch(() => {}) //pid可能已結束, 忽略error

        //保險: kill後若close事件遲未觸發(kill被忽略/權限不足), 3s後強制resolve避免永久hang
        //unref確保正常情況(close立即觸發)不會因此延後process結束
        setTimeout(() => {
            if (settled) {
                return
            }
            settled = true
            pm.resolve({
                ok: false,
                stdout: strTruncate(stdout, 500, optTruncate),
                stderr: strTruncate(stderr, 500, optTruncate),
                code: null,
                error: `TIMEOUT after ${timeoutMs / 1000}s(子進程未能結束)`,
                durationMs: Date.now() - startTime,
                pid: proc.pid,
            })
        }, 3000).unref()

    }, timeoutMs)

    //error, 監聽(例如執行spawn本身)錯誤訊息, 可能為'找不到可執行檔'(ENOENT), '權限不足', '系統資源用盡'等
    proc.on('error', (err) => {
        if (settled) {
            return
        }
        settled = true
        clearTimeout(timer)
        pm.resolve({
            ok: false,
            stdout: '',
            stderr: '',
            code: null,
            error: `${err.code || 'UNKNOWN'}: ${err.message}`,
            durationMs: Date.now() - startTime,
            pid: proc.pid,
        })
    })

    //close
    proc.on('close', (code, signal) => {
        if (settled) {
            return
        }
        settled = true
        clearTimeout(timer)

        //flush decoder殘餘(valid UTF-8結尾通常為空, 保險起見仍flush)
        let fOut = stdoutDecoder.decode()
        if (fOut && stdout.length < maxBuffer) {
            stdout += fOut
        }
        let fErr = stderrDecoder.decode()
        if (fErr && stderr.length < maxBuffer) {
            stderr += fErr
        }

        //durationMs
        let durationMs = Date.now() - startTime

        //timeout
        if (timedOut) {
            pm.resolve({
                ok: false,
                stdout: strTruncate(stdout, 500, optTruncate),
                stderr: strTruncate(stderr, 500, optTruncate),
                code,
                error: `TIMEOUT after ${timeoutMs / 1000}s`,
                durationMs,
                pid: proc.pid,
            })
            return
        }

        //code
        if (code !== 0) {
            pm.resolve({
                ok: false,
                stdout: strTruncate(stdout, 500, optTruncate),
                stderr: strTruncate(stderr, 1000, optTruncate),
                code,
                error: signal ? `Signal: ${signal}` : `Exit code ${code}`,
                durationMs,
                pid: proc.pid,
            })
            return
        }

        //validator
        if (validator && !validator(stdout)) {
            pm.resolve({
                ok: false,
                stdout: strTruncate(stdout, 500, optTruncate),
                stderr: strTruncate(stderr, 500, optTruncate),
                code: 0,
                error: 'OUTPUT_VALIDATION_FAILED',
                durationMs,
                pid: proc.pid,
            })
            return
        }

        pm.resolve({
            ok: true,
            stdout,
            stderr,
            code: 0,
            error: '',
            durationMs,
            pid: proc.pid,
        })

    })

    return pm
}


/**
 * 通用CLI子程序調用,封裝逾時、進程樹清理、輸出驗證、結構化錯誤回報與自動重試
 * 各參數原樣傳遞給目標CLI,支援中文
 * Windows下npm全域安裝之命令為.cmd批次檔,Node.js spawn無法直接執行(CVE-2024-27980安全修正後回EINVAL),內部已參考cross-spawn自動解析.cmd入口並繞過cmd.exe,故可支援含多行文字之參數
 * 本函數不throw,一律以結果物件之ok與error欄位回報成敗
 *
 * Unit Test: {@link https://github.com/yuda-lyu/wsemi/blob/master/test/execCli.test.mjs Github}
 * @memberOf wsemi
 * @param {String} command 輸入執行檔名稱字串,例如'node'、'curl'、'claude'等,可為註冊系統的全域指令或執行檔路徑
 * @param {Array} [args=[]] 輸入參數字串陣列,內容原樣傳遞給目標CLI,預設[]
 * @param {Object} [opt={}] 輸入設定物件
 * @param {Number} [opt.timeoutMs=120000] 輸入逾時毫秒正整數,逾時將調用execProcessKillPid強制關閉子進程及其子孫程序,預設120000
 * @param {String} [opt.cwd=process.cwd()] 輸入子進程工作目錄字串,預設process.cwd()
 * @param {String} [opt.input=undefined] 輸入傳入子進程stdin之字串,預設undefined代表不寫入stdin
 * @param {String|Function} [opt.validate=undefined] 輸入stdout驗證規則字串或自訂驗證函數,規則字串支援'nonempty'(非空)、'json'(可JSON.parse)、'min:100'(長度下限),多規則可用逗號串接例如'nonempty,json',自訂函數為(stdout)=>Boolean,預設undefined代表不驗證
 * @param {Number} [opt.maxBuffer=10485760] 輸入stdout與stderr各自累積之最大字元數正整數,超過部分將不再累積,預設10485760(10MB)
 * @param {Function} [opt.onStdout=undefined] 輸入stdout串流回調函數,格式為(chunk)=>{},預設undefined
 * @param {Function} [opt.onStderr=undefined] 輸入stderr串流回調函數,格式為(chunk)=>{},預設undefined
 * @param {Object} [opt.env=undefined] 輸入本次調用額外注入之環境變數物件,會併入process.env之後(同名以此為準,含PYTHONIOENCODING),僅作用於該次子進程而不影響本進程之process.env,故並行調用可各自帶不同值;值為undefined代表移除該變數(可遮蔽繼承值),值為null會成為字面字串'null'故建議勿傳;Windows下環境變數大小寫不敏感,同名不同大小寫(如Path與PATH)亦視為同名覆蓋,預設undefined代表不覆寫任何變數
 * @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,遇ENOENT(命令不存在)或exit code 2(參數錯誤)視為不可重試而立即中止,預設0
 * @param {Number} [opt.retryDelayMs=5000] 輸入重試間隔毫秒正整數,實際間隔為retryDelayMs乘以重試次數且上限15000ms,預設5000
 * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(標準輸出字串)、stderr(標準錯誤字串)、code(離開碼)、error(錯誤訊息字串,成功時為空字串)、durationMs(耗時毫秒)、pid(子進程pid)、attempts(實際嘗試次數)
 * @example
 * //need test in nodejs
 *
 * async function test() {
 *
 *     let nodeBin = process.execPath
 *
 *     let r1 = await execCli(nodeBin, ['-e', 'process.stdout.write("hello")'])
 *     console.log('r1', r1.ok, r1.stdout)
 *     // => r1 true hello
 *
 *     let r2 = await execCli(nodeBin, ['-e', 'process.exit(3)'])
 *     console.log('r2', r2.ok, r2.code, r2.error)
 *     // => r2 false 3 Exit code 3
 *
 *     let r3 = await execCli(nodeBin, ['-e', 'process.stdin.on("data",(d)=>process.stdout.write(d))'], { input: '中文測試' })
 *     console.log('r3', r3.ok, r3.stdout)
 *     // => r3 true 中文測試
 *
 *     let r4 = await execCli(nodeBin, ['-e', 'process.stdout.write("abc")'], { validate: 'json' })
 *     console.log('r4', r4.ok, r4.error)
 *     // => r4 false OUTPUT_VALIDATION_FAILED
 *
 *     let r5 = await execCli(nodeBin, ['-e', 'setTimeout(()=>{},30000)'], { timeoutMs: 500 })
 *     console.log('r5', r5.ok, r5.error)
 *     // => r5 false TIMEOUT after 0.5s
 *
 *     let r6 = await execCli(nodeBin, ['-e', 'process.stdout.write(process.env.API_KEY)'], { env: { API_KEY: 'KEY_A' } })
 *     console.log('r6', r6.ok, r6.stdout, process.env.API_KEY)
 *     // => r6 true KEY_A undefined (僅作用於該次子進程, 本進程process.env不受影響)
 *
 * }
 * test()
 *     .catch((err) => {
 *         console.log(err)
 *     })
 *
 */
async function execCli(command, args = [], opt = {}) {

    //command必填字串: 本函數以error物件回報失敗(非throw風格), 故回傳統一error結構
    if (!isestr(command)) {
        return {
            ok: false,
            stdout: '',
            stderr: '',
            code: null,
            error: 'command 須為非空字串',
            durationMs: 0,
            attempts: 0,
        }
    }

    //args: 若提供須為陣列, 否則回退為空陣列
    if (!isarr(args)) {
        args = []
    }

    //opt: 若提供須為物件, 否則回退為空物件
    if (!isobj(opt)) {
        opt = {}
    }

    let { maxRetries = 0, retryDelayMs = 5000, ...onceOpt } = opt

    //cwd: 非空字串, 無效回退預設process.cwd()
    let cwd = get(onceOpt, 'cwd', null)
    if (!isestr(cwd)) {
        onceOpt.cwd = process.cwd()
    }
    else {
        onceOpt.cwd = cwd
    }

    //input: 傳入stdin的字串, 無效回退預設undefined(未提供 = 不寫stdin)
    let input = get(onceOpt, 'input', null)
    if (!isestr(input)) {
        onceOpt.input = undefined
    }
    else {
        onceOpt.input = input
    }

    //env: 逐次注入之環境變數物件, 無效回退預設undefined(未提供 = 不覆寫任何變數)
    let env = get(onceOpt, 'env', null)
    if (!isobj(env)) {
        onceOpt.env = undefined
    }
    else {
        onceOpt.env = env
    }

    //validate: 驗證規則字串, 無效回退預設undefined(未提供 = 不驗證)
    //注意: validate亦可為自訂函數(buildValidator支援), 故先放行function不覆蓋
    let validate = get(onceOpt, 'validate', null)
    if (isfun(validate)) {
        onceOpt.validate = validate
    }
    else if (!isestr(validate)) {
        onceOpt.validate = undefined
    }
    else {
        onceOpt.validate = validate
    }

    //onStdout / onStderr: 串流回調函數, 無效回退預設undefined(未提供 = 不回調)
    let onStdout = get(onceOpt, 'onStdout', null)
    if (!isfun(onStdout)) {
        onceOpt.onStdout = undefined
    }
    else {
        onceOpt.onStdout = onStdout
    }
    let onStderr = get(onceOpt, 'onStderr', null)
    if (!isfun(onStderr)) {
        onceOpt.onStderr = undefined
    }
    else {
        onceOpt.onStderr = onStderr
    }

    //maxRetries: 非負整數(可為0), 無效回退預設0
    if (!isp0int(maxRetries)) {
        maxRetries = 0
    }
    else {
        maxRetries = cint(maxRetries)
    }

    //retryDelayMs: 正整數, 無效回退預設5000
    if (!ispint(retryDelayMs)) {
        retryDelayMs = 5000
    }
    else {
        retryDelayMs = cint(retryDelayMs)
    }

    //timeoutMs: 正整數, 無效回退預設120000
    let timeoutMs = get(onceOpt, 'timeoutMs', null)
    if (!ispint(timeoutMs)) {
        onceOpt.timeoutMs = 120000
    }
    else {
        onceOpt.timeoutMs = cint(timeoutMs)
    }

    //maxBuffer: 正整數, 無效回退預設10MB
    let maxBuffer = get(onceOpt, 'maxBuffer', null)
    if (!ispint(maxBuffer)) {
        onceOpt.maxBuffer = 10 * 1024 * 1024
    }
    else {
        onceOpt.maxBuffer = cint(maxBuffer)
    }

    let lastResult
    let totalAttempts = 0

    for (let attempt = 0; attempt <= maxRetries; attempt++) {

        //delay, 重試間隔隨次數遞增, 上限15000ms
        if (attempt > 0) {
            let ms = Math.min(retryDelayMs * attempt, 15000)
            await delay(ms)
        }

        lastResult = await execCliOnce(command, args, onceOpt)
        totalAttempts = attempt + 1

        if (lastResult.ok) {
            lastResult.attempts = totalAttempts
            return lastResult
        }

        //不可重試的錯誤
        if (lastResult.error.includes('ENOENT')) {
            break //命令不存在, 重試無意義
        }
        if (lastResult.code === 2) {
            break //參數錯誤, 重試無意義
        }

    }

    lastResult.attempts = totalAttempts

    return lastResult
}


export default execCli