import cst from './_const.mjs'
import { spawn } from 'child_process'
import get from 'lodash-es/get.js'
import genPm from './genPm.mjs'
import isfun from './isfun.mjs'
import isarr from './isarr.mjs'
import isobj from './isobj.mjs'
import isestr from './isestr.mjs'
import ispint from './ispint.mjs'
import isp0int from './isp0int.mjs'
import cint from './cint.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, 共用核心
let { buildSpawnArgs, buildSpawnEnv } = execCliCore
/**
* 建立互動式stdio工作階段(session),用於stdio協定型之長駐CLI(如MCP之stdio伺服器、codex app-server等一行一則JSON之程式),與execCli「寫完input即關閉stdin並等待結束」之一次性契約不同,本函數維持stdin開啟,由呼叫端逐次write,直到呼叫stop()才關閉stdin並等待子進程自行退出,逾寬限期則以execProcessKillPid樹殺
* Windows下npm全域安裝之命令為.cmd批次檔,內部經execCliCore.buildSpawnArgs解析為以node執行其JS入口,不使用shell:true,故樹殺可確實涵蓋子孫程序
* 本函數不throw亦不reject,spawn失敗(如ENOENT)以onExit與stop()之結果物件回報,其error不含args與env之值;command、args、opt或其選項值不可讀(getter拋錯、已撤銷之Proxy等)時亦不spawn,同樣以onExit與stop()之結果回報(pid為null,error為'<錯誤碼>: <訊息>',無錯誤碼者為UNKNOWN,拋出非Error之值時訊息為該值之文字;onExit本身不可讀時僅以stop()回報)
*
* Unit Test: {@link https://github.com/yuda-lyu/wsemi/blob/master/test/execCliSession.test.mjs Github}
* @memberOf wsemi
* @param {String} command 輸入執行檔名稱字串,可為註冊系統的全域指令或執行檔路徑
* @param {Array} [args=[]] 輸入參數字串陣列,預設[]
* @param {Object} [opt={}] 輸入設定物件
* @param {String} [opt.cwd=process.cwd()] 輸入子進程工作目錄字串,預設process.cwd();不存在或非資料夾時以onExit與stop()之結果回報error為'ENOENT: cwd is not an existing folder: <cwd>'
* @param {Object} [opt.env=undefined] 輸入本次額外注入之環境變數物件,語意同execCli,預設undefined
* @param {Number} [opt.timeoutMs=null] 輸入整體逾時毫秒正整數,逾時即樹殺並於結果標timeout:true,預設null代表不限制(工作階段為長駐,不沿用execCli之120000)
* @param {Number} [opt.exitGraceMs=2000] 輸入stop()關閉stdin後等待子進程自行退出之寬限毫秒非負整數,逾期即樹殺,預設2000
* @param {Number} [opt.killWaitMs=3000] 輸入樹殺後等待exit事件之上限毫秒非負整數,逾期仍未退出則於結果標exited:false並結束等待,預設3000
* @param {Number} [opt.stderrKeep=4000] 輸入stderr保留尾端字元數非負整數,預設4000
* @param {Function} [opt.onLine=undefined] 輸入stdout逐行回調函數,格式為(line)=>{},line不含換行(\\r\\n與\\n皆視為換行),子進程結束時殘餘之不完整行亦會發出,預設undefined
* @param {Function} [opt.onStdout=undefined] 輸入stdout原始片段回調函數,格式為(chunk)=>{},已經UTF-8跨chunk解碼,供非行式協定(如LSP之Content-Length分幀)自行分幀,預設undefined
* @param {Function} [opt.onStderr=undefined] 輸入stderr片段回調函數,格式為(chunk)=>{},預設undefined
* @param {Function} [opt.onExit=undefined] 輸入子進程結束回調函數,格式為(result)=>{},result同stop()之回傳,預設undefined。onLine、onStdout、onStderr、onExit內拋出之錯誤不會被攔截,將成為未捕捉例外,請於回調內自行處理(該錯誤於回調返回後立即(微任務)原樣重拋,不影響其餘回調之送達、stop()之落定與結果)
* @param {Function} [opt.onTrace=null] 輸入觀察回調函數,於內部事件發生當下同步呼叫,傳入一個凍結之事件物件{ type, ... },供測試以事件(而非等待時間)同步與診斷;欄位皆為原始值,不含stdout與stderr之內文(只有長度);type為'spawn'時為子進程已建立(含pid,未能啟動者為null),'data'時為收到一段已解碼之輸出(含stream為'stdout'或'stderr'、len為該段字元數;子進程結束時殘餘之解碼不通知,settle之後亦不通知),'write'時為一次寫入stdin定案(含ok為是否寫入成功、len為字元數;因已結束、已stop()或stdin不可寫而拒絕者len為null,已settle後之寫入不通知),'stop'時為首次呼叫stop()而即將關閉stdin並起算寬限(含ms為exitGraceMs;已settle或未能spawn者不通知,重複呼叫不通知),'grace'時為寬限期已到而子進程仍未退出,即將樹殺(含ms為exitGraceMs),'timeout'時為timeoutMs整體逾時計時器觸發而即將樹殺(含ms為timeoutMs),'kill'時為即將以execProcessKillPid樹殺並起算killWaitMs(含pid、ms為killWaitMs),'settle'時為工作階段即將定案而於onExit與stop()落定之前通知(含code為離開碼數字或null、signal為終止訊號字串或null、exited、timeout、killed、error字串,欄位同結果物件,exited為false代表樹殺後逾killWaitMs仍未退出);輸入不可讀、command無效、spawn失敗者只有settle事件;opt.onTrace屬性本身不可讀(getter拋錯)與其他回調相同,視為輸入不可讀;回調不帶接收者呼叫,其拋錯與回傳值皆忽略,不影響工作階段,預設null
* @returns {Object} 回傳工作階段物件s,內含pid(子進程pid,spawn失敗為null)、exited(是否已結束布林值)、write(str)(寫入stdin,回傳Promise,resolve為是否寫入成功布林值,子進程已結束時靜默resolve(false))、writeLine(str)(補換行後寫入,str無法轉為字串時resolve(false))、stop()(關閉stdin並等待結束,回傳Promise,resolve回傳結果物件,內含code、signal、exited、timeout、killed、error、stderr、durationMs、pid;多次呼叫回傳同一Promise)
* @example
* //need test in nodejs
*
* async function test() {
*
* let nodeBin = process.execPath
*
* //逐行回顯程式, 收到EOF即結束
* let sc = `
* let rl = require('readline').createInterface({ input: process.stdin })
* rl.on('line', (l) => process.stdout.write('echo:' + l + '\\n'))
* rl.on('close', () => process.exit(0))
* `
*
* let lines = []
* let s = execCliSession(nodeBin, ['-e', sc], {
* onLine: (line) => {
* lines.push(line)
* },
* })
* await s.writeLine('abc')
* await s.writeLine('中文')
* let r = await s.stop() //關閉stdin, 子進程收到EOF自行退出
* console.log(lines, r.code, r.exited, r.killed)
* // => [ 'echo:abc', 'echo:中文' ] 0 true false
*
* //不理會EOF之程式, stop()逾寬限期即樹殺
* let s2 = execCliSession(nodeBin, ['-e', 'setInterval(() => {}, 1000)'], { exitGraceMs: 300 })
* let r2 = await s2.stop()
* console.log(r2.exited, r2.killed)
* // => true true
*
* }
* test()
* .catch((err) => {
* console.log(err)
* })
*
*/
function execCliSession(command, args = [], opt = {}) {
//輸入之讀取與判型一律於try內: 拋錯者(command、args、opt或其選項值之getter拋錯、已撤銷之Proxy等不可讀之輸入)不throw, 記於errInput, 於下方以onExit與stop()之結果回報(同spawn同步錯誤)
//是否出錯以布林旗標bErrInput判定, 不以errInput之值為哨兵(拋出之值可為null、undefined); 多處拋錯時記第一個
//各選項先讀為暫存v再判型, 判型通過才指定, 拋錯時保留預設值; 回調於此即判型(非函數者為undefined), 其後監聽器與計時器內之判型不再觸及呼叫端之值
let cwd = process.cwd()
let envExtra
let timeoutMs = null
let exitGraceMs = 2000
let killWaitMs = 3000
let stderrKeep = 4000
let onLine
let onStdout
let onStderr
let onExit
let onTrace
let bCommand = false
let bErrInput = false
let errInput
let setErrInput = (err) => {
if (!bErrInput) {
bErrInput = true
errInput = err
}
}
//opt, 不可讀者以空物件續讀(其後各項皆取預設值)
try {
if (!isobj(opt)) {
opt = {}
}
}
catch (err) {
setErrInput(err)
opt = {}
}
//callbacks, 先於其餘選項讀取且各自try: 可讀者保留(函數或undefined), 使其餘輸入不可讀時onExit仍可回報結果; 不可讀者為undefined(不被呼叫)
let readFn = (key) => {
try {
let f = get(opt, key)
return isfun(f) ? f : undefined
}
catch (err) {
setErrInput(err)
return undefined
}
}
onLine = readFn('onLine')
onStdout = readFn('onStdout')
onStderr = readFn('onStderr')
onExit = readFn('onExit')
//trace, 觀察回調(opt.onTrace)同其他回調先讀取並判型, 不可讀者視為輸入不可讀; 未給時為null
onTrace = readFn('onTrace')
let trace = onTrace === undefined ? null : getTrace({ onTrace })
//其餘輸入
try {
let v
//command
bCommand = isestr(command)
//args
if (!isarr(args)) {
args = []
}
//cwd
v = get(opt, 'cwd')
cwd = isestr(v) ? v : process.cwd()
//envExtra
v = get(opt, 'env')
envExtra = isobj(v) ? v : undefined
//timeoutMs, 預設null不限制; 夾至計時器上限, 見_const.mjs
v = get(opt, 'timeoutMs')
timeoutMs = ispint(v) ? Math.min(v, cst.TIMER_TIME_MAX) : null
//exitGraceMs, 夾至計時器上限, 見_const.mjs
v = get(opt, 'exitGraceMs')
exitGraceMs = Math.min(isp0int(v) ? v : 2000, cst.TIMER_TIME_MAX)
//killWaitMs, 夾至計時器上限, 見_const.mjs
v = get(opt, 'killWaitMs')
killWaitMs = Math.min(isp0int(v) ? v : 3000, cst.TIMER_TIME_MAX)
//stderrKeep, 轉為數字(支援字串型數字與包裝物件, 否則監聽器內之=== 0判定不成立而不截斷)
v = get(opt, 'stderrKeep')
stderrKeep = isp0int(v) ? cint(v) : 4000
}
catch (err) {
setErrInput(err)
}
//startTime
let startTime = Date.now()
//state
let settled = false
let result = null
let timedOut = false
let killed = false
let stopping = false
let stderrTail = ''
let lineBuf = ''
let timer = null
let graceTimer = null
let killTimer = null
let pmExit = genPm() //子進程結束(或確認無法結束)時resolve
let pmStop = null
//s
let s = {
pid: null,
exited: false,
write: null,
writeLine: null,
stop: null,
}
//settle, 只執行一次: 整理結果, 標記exited, 觸發onExit, 喚醒stop()
let settle = (r) => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
clearTimeout(graceTimer)
clearTimeout(killTimer)
result = {
code: null,
signal: null,
exited: true,
timeout: timedOut,
killed,
error: '',
stderr: stderrTail,
durationMs: Date.now() - startTime,
pid: s.pid,
...r,
}
s.exited = true
//settle事件, 於onExit與stop()落定之前通知
if (trace) {
trace('settle', {
code: result.code,
signal: result.signal,
exited: result.exited,
timeout: result.timeout,
killed: result.killed,
error: result.error,
})
}
if (onExit !== undefined) { //回調已於入口判型(函數或undefined), 監聽器與計時器內一律以!== undefined判定, 不再判型呼叫端之值(值於入口後撤銷時判型會拋出而中斷收尾)
callUser(onExit, result) //onExit拋錯不得中斷喚醒stop(), 否則stop()永久懸置
}
pmExit.resolve(result)
}
//stopNoProc, 未能spawn時之stop
let stopNoProc = () => {
if (pmStop) {
return pmStop
}
pmStop = genPm()
pmExit.then((r) => {
pmStop.resolve(r)
})
return pmStop
}
//輸入不可讀: 不spawn, 同spawn同步錯誤以timer脫勾後settle(error為'碼: 訊息', pid為null), settle經callUser呼叫可讀之onExit
if (bErrInput) {
let e = execSpawnError(errInput)
let m = execThrownMsg(e, errInput)
s.write = async () => false
s.writeLine = async () => false
s.stop = stopNoProc
setTimeout(() => {
settle({ error: `${e.code}: ${m}` })
}, 1)
return s
}
//command必填字串: 不spawn, 以timer脫勾後settle使onExit於本函數回傳後才觸發
if (!bCommand) {
s.write = async () => false
s.writeLine = async () => false
s.stop = stopNoProc
setTimeout(() => {
settle({ error: 'command 須為非空字串' })
}, 1)
return s
}
//spawn, buildSpawnArgs之拋錯亦於此回報
let proc
try {
let envSpawn = buildSpawnEnv(envExtra)
let sp = buildSpawnArgs(command, args, { cwd, env: envSpawn })
proc = spawn(sp.file, sp.args, {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true, //執行的主程序若沒有主控台調用執行程序就不會有視窗, 但若通過pm2執行會有, 須設定windowsHide=true
env: envSpawn,
...sp.opt,
})
}
catch (err) {
//spawn同步拋錯(如EPERM、ENAMETOOLONG、參數檢查錯誤、不存在之.cmd/.bat), 訊息經execSpawnError去除參數值, cwd不存在時改指cwd
let e = execSpawnError(err, { cwd })
s.write = async () => false
s.writeLine = async () => false
s.stop = stopNoProc
setTimeout(() => {
settle({ error: `${e.code}: ${e.message}` })
}, 1)
return s
}
s.pid = proc.pid || null
if (trace) {
trace('spawn', { pid: s.pid })
}
//error, 須於存取stdio前監聽: node對EMFILE、ENFILE不建立stdio即於nextTick發出'error', 先存取會拋TypeError且'error'無人監聽而拉倒程序
// 可能為'找不到可執行檔'(ENOENT), '權限不足', '系統資源用盡'等, 訊息經execSpawnError去除參數值
proc.on('error', (err) => {
let e = execSpawnError(err, { cwd })
settle({ error: `${e.code}: ${e.message}` })
})
//stdio, EMFILE、ENFILE時node未建立(undefined), 由上方error回報
if (!proc.stdout || !proc.stderr || !proc.stdin) {
s.write = async () => false
s.writeLine = async () => false
s.stop = stopNoProc
return s
}
//TextDecoder, 跨chunk邊界正確解碼UTF-8, 避免多位元組中文字元被切成亂碼(U+FFFD)
let stdoutDecoder = new TextDecoder('utf-8')
let stderrDecoder = new TextDecoder('utf-8')
//emitLines, 以\n切行, 去尾端\r, 殘餘保留至下次; flush時把殘餘不完整行一併發出
let emitLines = (str, flush) => {
lineBuf += str
let idx = lineBuf.indexOf('\n')
while (idx >= 0) {
let line = lineBuf.slice(0, idx)
lineBuf = lineBuf.slice(idx + 1)
if (line.endsWith('\r')) {
line = line.slice(0, -1)
}
if (onLine !== undefined) {
callUser(onLine, line) //拋錯不得中斷同一chunk其餘行之送達(否則延至下個chunk或收尾, 如JSON-RPC之回應卡至逾時)
}
idx = lineBuf.indexOf('\n')
}
if (flush && lineBuf !== '') {
let line = lineBuf
lineBuf = ''
if (line.endsWith('\r')) {
line = line.slice(0, -1)
}
if (onLine !== undefined) {
callUser(onLine, line)
}
}
}
//appendStderr, 保留尾端stderrKeep字元
let appendStderr = (str) => {
if (stderrKeep === 0) {
stderrTail = ''
return
}
stderrTail = (stderrTail + str).slice(-stderrKeep)
}
//stdout data
proc.stdout.on('data', (chunk) => {
let str = stdoutDecoder.decode(chunk, { stream: true })
if (!str) {
return
}
if (trace && !settled) {
trace('data', { stream: 'stdout', len: str.length })
}
if (onStdout !== undefined) {
callUser(onStdout, str) //拋錯不得使該chunk不進onLine
}
emitLines(str, false)
})
//stderr data
proc.stderr.on('data', (chunk) => {
let str = stderrDecoder.decode(chunk, { stream: true })
if (!str) {
return
}
if (trace && !settled) {
trace('data', { stream: 'stderr', len: str.length })
}
if (onStderr !== undefined) {
callUser(onStderr, str)
}
appendStderr(str)
})
//stdin, 子進程提早關閉stdin時write/end會觸發EPIPE, 需listen'error'才不會拋unhandled event
proc.stdin.on('error', () => {})
//kill, 樹殺並等待exit, 逾killWaitMs仍未退出則以exited:false結束等待
let kill = (errorIfStuck) => {
killed = true
if (trace) {
trace('kill', { pid: s.pid, ms: killWaitMs })
}
execProcessKillPid(proc.pid)
.catch(() => {}) //pid可能已結束, 忽略error
killTimer = setTimeout(() => {
if (settled) {
return
}
settle({ code: null, exited: false, error: errorIfStuck })
}, killWaitMs)
}
//timer, 整體逾時
if (timeoutMs !== null) {
timer = setTimeout(() => {
if (settled) {
return
}
timedOut = true
if (trace) {
trace('timeout', { ms: timeoutMs })
}
kill(`TIMEOUT after ${timeoutMs / 1000}s(子進程未能結束)`)
}, timeoutMs)
}
//close
proc.on('close', (code, signal) => {
if (settled) {
return
}
//flush decoder殘餘與不完整行; 回調經callUser, 拋錯不得中斷收尾(否則不settle, stop()於killWaitMs後誤報STOP_TIMEOUT且onExit不被呼叫)
let fOut = stdoutDecoder.decode()
if (fOut) {
if (onStdout !== undefined) {
callUser(onStdout, fOut)
}
emitLines(fOut, false)
}
emitLines('', true)
let fErr = stderrDecoder.decode()
if (fErr) {
if (onStderr !== undefined) {
callUser(onStderr, fErr)
}
appendStderr(fErr)
}
//error, 自行stop()所致之樹殺不視為錯誤
let error = ''
if (timedOut) {
error = `TIMEOUT after ${timeoutMs / 1000}s`
}
else if (!killed) {
if (signal) {
error = `Signal: ${signal}`
}
else if (code !== 0) {
error = `Exit code ${code}`
}
}
settle({ code, signal, error })
})
//write
s.write = (str) => {
let pm = genPm()
if (settled || stopping || proc.stdin.destroyed || !proc.stdin.writable) {
if (trace && !settled) {
trace('write', { ok: false, len: null })
}
pm.resolve(false)
return pm
}
try {
let sw = String(str)
proc.stdin.write(sw, 'utf8', (err) => {
if (trace && !settled) {
trace('write', { ok: !err, len: sw.length })
}
pm.resolve(!err)
})
}
catch {
if (trace && !settled) {
trace('write', { ok: false, len: null })
}
pm.resolve(false)
}
return pm
}
//writeLine
s.writeLine = (str) => {
//String須在try內, 與write對稱: 無法轉字串者(如Object.create(null)、已撤銷之Proxy)resolve(false)而不同步拋錯
let line
try {
line = String(str) + '\n'
}
catch {
let pm = genPm()
pm.resolve(false)
return pm
}
return s.write(line)
}
//stop, 關閉stdin → 等exitGraceMs → 未退出即樹殺 → 等exit; 多次呼叫回傳同一Promise
s.stop = () => {
if (pmStop) {
return pmStop
}
pmStop = genPm()
if (settled) {
pmStop.resolve(result)
return pmStop
}
stopping = true
if (trace) {
trace('stop', { ms: exitGraceMs })
}
try {
proc.stdin.end()
}
catch {}
graceTimer = setTimeout(() => {
if (settled) {
return
}
if (trace) {
trace('grace', { ms: exitGraceMs })
}
kill('STOP_TIMEOUT(子進程未能結束)')
}, exitGraceMs)
pmExit.then((r) => {
pmStop.resolve(r)
})
return pmStop
}
return s
}
export default execCliSession