import { spawn } from 'child_process'
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 optVal from './_optVal.mjs'
import strleft from './strleft.mjs'
import strdelleft from './strdelleft.mjs'
import strTruncate from './strTruncate.mjs'
import getErrorMessage from './getErrorMessage.mjs'
import errToStr from './_errToStr.mjs'
import execProcessKillPid from './execProcessKillPid.mjs'
import execCliCore from './execCliCore.mjs'
import execSpawnError from './_execSpawnError.mjs'
import execThrownMsg from './_execThrownMsg.mjs'
import callUser from './_callUser.mjs'
import getTrace from './getTrace.mjs'
//buildSpawnArgs, buildSpawnEnv, 共用核心已抽至execCliCore
let { buildSpawnArgs, buildSpawnEnv } = execCliCore
//opt類數值參數之規則(iseint, 帳本G17、R-OPT-VAL-01), 非預期改用預設
let RULE_MAX_RETRIES = { fn: 'execCli', name: 'opt.maxRetries', kind: 'eint', chk: { use: 'count' } }
let RULE_RETRY_DELAY_MS = { fn: 'execCli', name: 'opt.retryDelayMs', kind: 'eint', chk: { min: 1 } }
let RULE_TIMEOUT_MS = { fn: 'execCli', name: 'opt.timeoutMs', kind: 'eint', chk: { use: 'timer', min: 1 } }
let RULE_MAX_BUFFER = { fn: 'execCli', name: 'opt.maxBuffer', kind: 'eint', chk: { use: 'count', min: 1 } }
//pidOf, 觀察回調事件用之子進程pid(未能啟動者proc.pid為undefined, 事件欄位只放數字或null)
function pidOf(proc) {
return typeof proc.pid === 'number' ? proc.pid : null
}
//kNoRetry, 無法啟動子進程之同步錯誤所產生之結果物件(確定性失敗, 重試無意義), 供execCli判定不重試, 不擴充對外之結果物件欄位
let kNoRetry = new WeakSet()
//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={}] 輸入設定物件
* @param {Function|null} [trace=null] 輸入觀察回調之通知函數(getTrace之回傳), 格式為(type, info)=>{}, 預設null代表不通知
* @returns {Promise} 回傳Promise, resolve回傳結果物件
*/
function execCliOnce(command, args = [], opt = {}, trace = null) {
//opt, 已由execCli前置校驗, 此處僅取預設值
let {
timeoutMs = 120000,
cwd = process.cwd(),
input = undefined,
validate: validator = null, //已於parseInput經buildValidator建立(驗證函數或null), 此處(含重試)不再判型呼叫端之值
maxBuffer = 10 * 1024 * 1024,
onStdout = undefined,
onStderr = undefined,
env: envExtra = undefined,
} = opt
//startTime
let startTime = Date.now()
//pm
let pm = genPm()
//spawn, 同步拋錯(EPERM、UNKNOWN、ENAMETOOLONG、參數檢查錯誤等)以結果物件回報而不reject, 且屬確定性失敗不重試; buildSpawnArgs(含不存在之.cmd/.bat)與buildSpawnEnv之拋錯亦同
let proc
try {
//envSpawn, 併入process.env(同名以envExtra為準, 含PYTHONIOENCODING); 須在try內, env物件展開拋錯(如getter拋錯)亦以結果物件回報
//每次attempt於此重新求值並僅作用於該次子進程, 不動本進程process.env, 故並行調用可各自帶不同值
let envSpawn = buildSpawnEnv(envExtra)
let winSpawn = buildSpawnArgs(command, args, { cwd, env: envSpawn })
proc = spawn(winSpawn.file, winSpawn.args, {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true, //執行的主程序若沒有主控台調用執行程序就不會有視窗, 但若通過pm2執行會有, 須設定windowsHide=true
env: envSpawn,
...winSpawn.opt,
})
}
catch (err) {
let e = execSpawnError(err, { cwd })
let r = {
ok: false,
stdout: '',
stderr: '',
code: null,
error: `${e.code}: ${e.message}`,
durationMs: Date.now() - startTime,
pid: undefined, //與'error'事件(如ENOENT)之結果一致: 未能啟動者帶pid鍵且其值為undefined(JSDoc契約)
}
kNoRetry.add(r)
if (trace) {
trace('settle', { reason: 'error', code: null, signal: null })
}
pm.resolve(r)
return pm
}
//spawn, 子進程已建立(未能啟動者於其後由'error'事件回報, 此時pid為null)
if (trace) {
trace('spawn', { pid: pidOf(proc) })
}
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
let timer = null
//error, 須於存取stdio前監聽: node對EMFILE、ENFILE不建立stdio即於nextTick發出'error', 先存取會拋TypeError且'error'無人監聽而拉倒程序
// 可能為'找不到可執行檔'(ENOENT), '權限不足', '系統資源用盡'等, 訊息經execSpawnError去除參數值
proc.on('error', (err) => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
let e = execSpawnError(err, { cwd })
if (trace) {
trace('settle', { reason: 'error', code: null, signal: null })
}
pm.resolve({
ok: false,
stdout: '',
stderr: '',
code: null,
error: `${e.code}: ${e.message}`,
durationMs: Date.now() - startTime,
pid: proc.pid,
})
})
//stdio, EMFILE、ENFILE時node未建立(undefined), 由上方error回報
if (!proc.stdout || !proc.stderr || !proc.stdin) {
return pm
}
//stdout data, 經TextDecoder跨chunk解碼; 回調經callUser, 拋錯(不攔截, 改於下一輪重拋)不致中斷累積
proc.stdout.on('data', (chunk) => {
let str = stdoutDecoder.decode(chunk, { stream: true })
if (!str) {
return
}
if (onStdout !== undefined) { //已於parseInput判型(函數或undefined), 監聽器內不再判型呼叫端之值(值於入口後撤銷時判型會於監聽器內拋出)
callUser(onStdout, str)
}
let kept = stdout.length < maxBuffer
if (kept) {
stdout += str
}
if (trace && !settled) {
trace('data', { stream: 'stdout', len: str.length, size: stdout.length, kept })
if (kept && stdout.length >= maxBuffer) {
trace('full', { stream: 'stdout', size: stdout.length })
}
}
})
//stderr data, 經TextDecoder跨chunk解碼
proc.stderr.on('data', (chunk) => {
let str = stderrDecoder.decode(chunk, { stream: true })
if (!str) {
return
}
if (onStderr !== undefined) { //同onStdout
callUser(onStderr, str)
}
let kept = stderr.length < maxBuffer
if (kept) {
stderr += str
}
if (trace && !settled) {
trace('data', { stream: 'stderr', len: str.length, size: stderr.length, kept })
if (kept && stderr.length >= maxBuffer) {
trace('full', { stream: 'stderr', size: stderr.length })
}
}
})
//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殺整棵程序樹
timer = setTimeout(() => {
if (settled) {
return
}
timedOut = true
if (trace) {
trace('timeout', { ms: timeoutMs })
trace('kill', { pid: pidOf(proc) })
}
//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
if (trace) {
trace('settle', { reason: 'stuck', code: null, signal: null })
}
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)
//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) {
if (trace) {
trace('settle', { reason: 'timeout', code, signal })
}
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) {
if (trace) {
trace('settle', { reason: 'exit', code, signal })
}
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
}
//resolveOk
let resolveOk = () => {
if (trace) {
trace('settle', { reason: 'ok', code, signal })
}
pm.resolve({
ok: true,
stdout,
stderr,
code: 0,
error: '',
durationMs,
pid: proc.pid,
})
}
//無驗證
if (!validator) {
resolveOk()
return
}
//validator, 以原生Promise承接: 自訂函數同步拋錯或其Promise被reject視同驗證失敗(不成為未捕捉例外、不使本次呼叫懸置), 回傳Promise者以其結果判定, 真值為通過
//拋錯訊息以'validate threw: 訊息'前綴於stderr(error仍恰為OUTPUT_VALIDATION_FAILED, 下游以此判定驗證失敗)
//拋錯與否另以bThrew標記(拋出之值可為null、undefined, 不可拿值當旗標); 訊息取getErrorMessage, 為空者(null、undefined等)退為errToStr, 並截斷同stderr
new Promise((resolve) => {
resolve(validator(stdout))
})
.then((b) => {
return { pass: !!b, bThrew: false, err: null }
}, (err) => {
return { pass: false, bThrew: true, err }
})
.then(({ pass, bThrew, err }) => {
if (pass) {
resolveOk()
return
}
let se = strTruncate(stderr, 500, optTruncate)
if (bThrew) {
let m = getErrorMessage(err)
if (m === '') {
m = errToStr(err)
}
m = strTruncate(m, 500, optTruncate)
se = `validate threw: ${m}${se !== '' ? `\n${se}` : ''}`
}
if (trace) {
trace('settle', { reason: 'validate', code, signal })
}
pm.resolve({
ok: false,
stdout: strTruncate(stdout, 500, optTruncate),
stderr: se,
code: 0,
error: 'OUTPUT_VALIDATION_FAILED',
durationMs,
pid: proc.pid,
})
})
})
return pm
}
/**
* execCli之輸入判型與預設值(內部使用), 讀取或判型拋錯(getter拋錯、已撤銷之Proxy等不可讀之輸入)時直接拋出, 由execCli承接
*
* @param {*} command 輸入執行檔名稱
* @param {*} args 輸入參數陣列
* @param {*} opt 輸入設定物件
* @returns {Object} 回傳{ rInvalid }(command無效之結果物件)或{ args, maxRetries, retryDelayMs, onceOpt, trace }
*/
function parseInput(command, args, opt) {
//command必填字串: 本函數以error物件回報失敗(非throw風格), 故回傳統一error結構
if (!isestr(command)) {
return {
rInvalid: {
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)
// 通過isestr者(含包裝物件new String)於此轉為原始字串: stdin.write只收原始字串, 包裝物件會於spawn後(本try外)同步拋錯而reject且子進程殘留; 轉字串拋錯者於此以結果物件回報
let input = get(onceOpt, 'input', null)
if (!isestr(input)) {
onceOpt.input = undefined
}
else {
onceOpt.input = String(input)
}
//env: 逐次注入之環境變數物件, 無效回退預設undefined(未提供 = 不覆寫任何變數)
let env = get(onceOpt, 'env', null)
if (!isobj(env)) {
onceOpt.env = undefined
}
else {
onceOpt.env = env
}
//validate: 驗證規則字串或自訂函數, 於此經buildValidator判型並建立驗證函數一次(無有效規則為null = 不驗證), execCliOnce(含重試)直接使用而不再判型呼叫端之值
let validate = get(onceOpt, 'validate', null)
onceOpt.validate = buildValidator(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、retryDelayMs、timeoutMs、maxBuffer: opt類數值參數以iseint判定(帳本G17、R-OPT-VAL-01), 非預期(含Infinity、小數、超出範圍、'0x10'、包裝物件)視為未給、改用預設, 不夾限; debug警告讀onceOpt(opt展開之副本: 呼叫端之存取器於展開時已各讀一次, 副本為資料屬性, 重讀無副作用, 符合V11; 同onTrace只認自有可列舉鍵)
//maxRetries: 有效整數且為個數(0~4294967295, 可為0), 無效回退預設0(Infinity不再視為近乎無限重試)
maxRetries = optVal(maxRetries, 0, RULE_MAX_RETRIES, onceOpt)
//retryDelayMs: 有效整數且大於等於1, 無效回退預設5000; 不設計時器上限(實際間隔為retryDelayMs乘以重試次數且夾至15000後才交給計時器)
retryDelayMs = optVal(retryDelayMs, 5000, RULE_RETRY_DELAY_MS, onceOpt)
//timeoutMs: 有效整數且為計時毫秒(1~2147483647), 無效(含超過計時器上限)回退預設120000, 不再夾至上限
onceOpt.timeoutMs = optVal(get(onceOpt, 'timeoutMs', null), 120000, RULE_TIMEOUT_MS, onceOpt)
//maxBuffer: 有效整數且為個數(字元數, 1~4294967295; 同stderrKeep用count, 超過者字串本已無法容納), 無效回退預設10MB
onceOpt.maxBuffer = optVal(get(onceOpt, 'maxBuffer', null), 10 * 1024 * 1024, RULE_MAX_BUFFER, onceOpt)
//trace, 觀察回調(opt.onTrace), 取自onceOpt(opt展開之副本, 不再讀取呼叫端之opt), 置於其餘判型之後使既有失敗路徑不變; 未給時為null
let trace = getTrace(onceOpt)
return { args, maxRetries, retryDelayMs, onceOpt, trace }
}
/**
* 通用CLI子程序調用,封裝逾時、進程樹清理、輸出驗證、結構化錯誤回報與自動重試
* 各參數原樣傳遞給目標CLI,支援中文
* Windows下npm全域安裝之命令為.cmd批次檔,Node.js spawn無法直接執行(CVE-2024-27980安全修正後回EINVAL),內部已參考cross-spawn自動解析.cmd入口並繞過cmd.exe,故可支援含多行文字之參數
* 本函數不throw亦不reject,一律以結果物件之ok與error欄位回報成敗,error不含args與env之值(子程序自身之輸出另見stdout、stderr);command、args、opt或其選項值不可讀(getter拋錯、已撤銷之Proxy等)時亦以結果物件回報,error為'<錯誤碼>: <訊息>'(無錯誤碼者為UNKNOWN,拋出非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] 輸入逾時毫秒正整數,須為有效整數且介於1至2147483647(計時器上限;iseint之use:'timer',可為字串型數字),逾時將調用execProcessKillPid強制關閉子進程及其子孫程序,非預期之輸入(含Infinity、小數、超過2147483647、'0x10'、包裝物件)視為未給而改用預設,不夾至上限、不報錯,預設120000
* @param {String} [opt.cwd=process.cwd()] 輸入子進程工作目錄字串,預設process.cwd();不存在或非資料夾時以結果物件回報error為'ENOENT: cwd is not an existing folder: <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或回傳Promise(以其resolve值判定),回傳真值為通過;自訂函數拋錯或其Promise被reject視同驗證失敗(error為'OUTPUT_VALIDATION_FAILED',stderr前綴'validate threw: 錯誤訊息',訊息截斷至500字元);自訂函數回傳之Promise不受timeoutMs約束(子進程已結束),永不落定時本函數亦不落定,durationMs不含驗證時間,預設undefined代表不驗證
* @param {Number} [opt.maxBuffer=10485760] 輸入stdout與stderr各自累積之最大字元數正整數,須為有效整數且介於1至4294967295(iseint之use:'count',可為字串型數字),以收到之輸出片段為單位截止: 累積量未達上限時該片段整段累積(故最後一段可使累積量略超過上限),達上限後之片段不再累積,非預期之輸入(含Infinity、小數、超過4294967295、'0x10'、包裝物件)視為未給而改用預設,欲近乎不限制請給足夠大之有限值,預設10485760(10MB)
* @param {Function} [opt.onStdout=undefined] 輸入stdout串流回調函數,格式為(chunk)=>{},回調內拋出之錯誤不會被攔截,將成為未捕捉例外,請於回調內自行處理(該錯誤於回調返回後立即(微任務)原樣重拋,不影響本函數之輸出累積與結果),預設undefined
* @param {Function} [opt.onStderr=undefined] 輸入stderr串流回調函數,格式為(chunk)=>{},行為同onStdout,預設undefined
* @param {Object} [opt.env=undefined] 輸入本次調用額外注入之環境變數物件,會併入process.env之後(同名以此為準,含PYTHONIOENCODING),僅作用於該次子進程而不影響本進程之process.env,故並行調用可各自帶不同值;值為undefined代表移除該變數(可遮蔽繼承值),值為null會成為字面字串'null'故建議勿傳;Windows下環境變數大小寫不敏感,同名不同大小寫(如Path與PATH)亦視為同名覆蓋,預設undefined代表不覆寫任何變數
* @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,遇ENOENT(命令不存在、不存在之.cmd/.bat、npm shim之入口檔不存在)、exit code 2(參數錯誤)或無法啟動子進程之同步錯誤(如參數過長、權限不足、非有效執行檔、參數含空字元)視為不可重試而立即中止,須為有效整數且介於0至4294967295(iseint之use:'count',可為字串型數字),非預期之輸入(含Infinity、小數、負數、'0x10'、包裝物件)視為未給而改用預設0即不重試(Infinity不視為無限重試),欲多次重試請給有限值,預設0
* @param {Number} [opt.retryDelayMs=5000] 輸入重試間隔毫秒正整數,須為有效整數且大於等於1(可為字串型數字),實際間隔為retryDelayMs乘以重試次數且上限15000ms,非預期之輸入(含Infinity、小數、'0x10'、包裝物件)視為未給而改用預設,預設5000
* @param {Boolean} [opt.debug=false] 輸入是否於opt.maxRetries、opt.retryDelayMs、opt.timeoutMs、opt.maxBuffer為非預期之輸入而改用預設時輸出警告布林值,須為true才生效,警告為固定詞彙(不含輸入值),同一參數每個行程只警告一次,預設false
* @param {Function} [opt.funLog=null] 輸入警告輸出函數,格式為(msg, { fn, reason, key })=>{},未給則用console.warn,預設null
* @param {Function} [opt.onTrace=null] 輸入觀察回調函數,於內部事件發生當下同步呼叫,傳入一個凍結之事件物件{ type, ... },供測試以事件(而非等待時間)同步與診斷;欄位皆為原始值,不含stdout與stderr之內文(只有長度);type為'attempt'時為即將啟動第n次嘗試(含n,由1起算),'retry'時為上次嘗試失敗而即將等待ms毫秒後啟動第n次嘗試(含n、ms;n由2起算,ms即實際間隔),'spawn'時為子進程已建立(含pid,未能啟動者為null),'data'時為收到一段已解碼之輸出(含stream為'stdout'或'stderr'、len為該段字元數、size為處理後該串流已累積之字元數、kept為該段是否被累積;累積量已達maxBuffer者kept為false且size不變;子進程結束時殘餘之解碼不通知,settle之後亦不通知),'full'時為該串流累積量首次達到maxBuffer(含stream、size),'timeout'時為timeoutMs逾時計時器觸發而即將樹殺(含ms為timeoutMs),'kill'時為即將以execProcessKillPid樹殺(含pid),'settle'時為本次嘗試即將定案(含reason:'ok'為離開碼0且通過驗證,'exit'為非零離開碼或被訊號終止,'timeout'為逾時樹殺後close,'validate'為驗證失敗,'error'為子進程未能啟動,'stuck'為樹殺後3秒仍未收到close而強制定案;另含code為離開碼數字或null、signal為終止訊號字串或null),'done'時為整個呼叫即將回傳(含n為實際嘗試次數、ok、reason:成功為'ok',失敗為'spawn'(無法啟動子進程之同步錯誤)、'enoent'(命令不存在)、'code2'(離開碼2)等不可重試者,其餘含maxRetries為0者為'exhausted');command無效或opt不可讀而在啟動前即回報者無事件;回調不帶接收者呼叫,其拋錯與回傳值皆忽略,不影響本函數,預設null
* @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(標準輸出字串)、stderr(標準錯誤字串)、code(離開碼)、error(錯誤訊息字串,成功時為空字串)、durationMs(耗時毫秒)、pid(子進程pid)、attempts(實際嘗試次數)。子進程未能啟動時(spawn同步拋錯、'error'事件、不存在之.cmd/.bat、npm shim之入口檔不存在、cwd不存在、輸入不可讀等)code為null且結果帶pid鍵、其值為undefined;已啟動者(含逾時、訊號結束、非零離開、驗證失敗)皆帶pid數值;command非非空字串時不啟動,attempts為0且不帶pid鍵
* @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 = {}) {
//parseInput, 輸入之讀取與判型一律於此try內: 拋錯者(command、args、opt或其選項值之getter拋錯、已撤銷之Proxy等不可讀之輸入)以結果物件回報, 同spawn同步錯誤(code null、帶pid鍵且為undefined、attempts 1、不重試), 不reject
let startTime = Date.now()
let p
try {
p = parseInput(command, args, opt)
}
catch (err) {
let e = execSpawnError(err)
return {
ok: false,
stdout: '',
stderr: '',
code: null,
error: `${e.code}: ${execThrownMsg(e, err)}`,
durationMs: Date.now() - startTime,
pid: undefined, //同spawn同步錯誤: 未能啟動者帶pid鍵且其值為undefined(JSDoc契約)
attempts: 1,
}
}
if (p.rInvalid) {
return p.rInvalid
}
let { maxRetries, retryDelayMs, onceOpt, trace } = p
args = p.args
let lastResult
let totalAttempts = 0
for (let attempt = 0; attempt <= maxRetries; attempt++) {
//delay, 重試間隔隨次數遞增, 上限15000ms
if (attempt > 0) {
let ms = Math.min(retryDelayMs * attempt, 15000)
if (trace) {
trace('retry', { n: attempt + 1, ms })
}
await delay(ms)
}
if (trace) {
trace('attempt', { n: attempt + 1 })
}
lastResult = await execCliOnce(command, args, onceOpt, trace)
totalAttempts = attempt + 1
if (lastResult.ok) {
lastResult.attempts = totalAttempts
if (trace) {
trace('done', { n: totalAttempts, ok: true, reason: 'ok' })
}
return lastResult
}
//不可重試的錯誤
if (kNoRetry.has(lastResult)) {
break //無法啟動子進程之同步錯誤(參數過長、權限不足、非有效執行檔、參數檢查錯誤等), 重試無意義
}
if (lastResult.error.includes('ENOENT')) {
break //命令不存在, 重試無意義
}
if (lastResult.code === 2) {
break //參數錯誤, 重試無意義
}
}
lastResult.attempts = totalAttempts
//done, 失敗而結束的原因(判定順序同上方迴圈內之不可重試判斷): 'spawn'為無法啟動子進程之同步錯誤, 'enoent'為命令不存在, 'code2'為離開碼2, 其餘(含maxRetries為0)為'exhausted'
if (trace) {
let reason = 'exhausted'
if (kNoRetry.has(lastResult)) {
reason = 'spawn'
}
else if (lastResult.error.includes('ENOENT')) {
reason = 'enoent'
}
else if (lastResult.code === 2) {
reason = 'code2'
}
trace('done', { n: totalAttempts, ok: false, reason })
}
return lastResult
}
export default execCli