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 execProcessKillPid from './execProcessKillPid.mjs'
import execCliCore from './execCliCore.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()之結果物件回報
*
* 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()
* @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
* @returns {Object} 回傳工作階段物件s,內含pid(子進程pid,spawn失敗為null)、exited(是否已結束布林值)、write(str)(寫入stdin,回傳Promise,resolve為是否寫入成功布林值,子進程已結束時靜默resolve(false))、writeLine(str)(補換行後寫入)、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 = {}) {
//args
if (!isarr(args)) {
args = []
}
//opt
if (!isobj(opt)) {
opt = {}
}
//cwd
let cwd = get(opt, 'cwd')
if (!isestr(cwd)) {
cwd = process.cwd()
}
//envExtra
let envExtra = get(opt, 'env')
if (!isobj(envExtra)) {
envExtra = undefined
}
//timeoutMs, 預設null不限制
let timeoutMs = get(opt, 'timeoutMs')
if (!ispint(timeoutMs)) {
timeoutMs = null
}
else {
timeoutMs = Math.min(timeoutMs, cst.TIMER_TIME_MAX) //夾至計時器上限, 見_const.mjs
}
//exitGraceMs
let exitGraceMs = get(opt, 'exitGraceMs')
if (!isp0int(exitGraceMs)) {
exitGraceMs = 2000
}
exitGraceMs = Math.min(exitGraceMs, cst.TIMER_TIME_MAX) //夾至計時器上限, 見_const.mjs
//killWaitMs
let killWaitMs = get(opt, 'killWaitMs')
if (!isp0int(killWaitMs)) {
killWaitMs = 3000
}
killWaitMs = Math.min(killWaitMs, cst.TIMER_TIME_MAX) //夾至計時器上限, 見_const.mjs
//stderrKeep
let stderrKeep = get(opt, 'stderrKeep')
if (!isp0int(stderrKeep)) {
stderrKeep = 4000
}
//callbacks
let onLine = get(opt, 'onLine')
let onStdout = get(opt, 'onStdout')
let onStderr = get(opt, 'onStderr')
let onExit = get(opt, 'onExit')
//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
if (isfun(onExit)) {
onExit(result)
}
pmExit.resolve(result)
}
//stopNoProc, 未能spawn時之stop
let stopNoProc = () => {
if (pmStop) {
return pmStop
}
pmStop = genPm()
pmExit.then((r) => {
pmStop.resolve(r)
})
return pmStop
}
//command必填字串: 不spawn, 以timer脫勾後settle使onExit於本函數回傳後才觸發
if (!isestr(command)) {
s.write = async () => false
s.writeLine = async () => false
s.stop = stopNoProc
setTimeout(() => {
settle({ error: 'command 須為非空字串' })
}, 1)
return s
}
//spawn
let sp = buildSpawnArgs(command, args)
let proc
try {
proc = spawn(sp.file, sp.args, {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true, //執行的主程序若沒有主控台調用執行程序就不會有視窗, 但若通過pm2執行會有, 須設定windowsHide=true
env: buildSpawnEnv(envExtra),
...sp.opt,
})
}
catch (err) {
//spawn同步拋錯(如參數無效EINVAL)
s.write = async () => false
s.writeLine = async () => false
s.stop = stopNoProc
setTimeout(() => {
settle({ error: `${err.code || 'UNKNOWN'}: ${err.message}` }) //待確認用err.toString()還是err.message更佳 bbb
}, 1)
return s
}
s.pid = proc.pid || null
//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 (isfun(onLine)) {
onLine(line)
}
idx = lineBuf.indexOf('\n')
}
if (flush && lineBuf !== '') {
let line = lineBuf
lineBuf = ''
if (line.endsWith('\r')) {
line = line.slice(0, -1)
}
if (isfun(onLine)) {
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 (isfun(onStdout)) {
onStdout(str)
}
emitLines(str, false)
})
//stderr data
proc.stderr.on('data', (chunk) => {
let str = stderrDecoder.decode(chunk, { stream: true })
if (!str) {
return
}
if (isfun(onStderr)) {
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
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
kill(`TIMEOUT after ${timeoutMs / 1000}s(子進程未能結束)`)
}, timeoutMs)
}
//error, 監聽(例如執行spawn本身)錯誤訊息, 可能為'找不到可執行檔'(ENOENT), '權限不足', '系統資源用盡'等
proc.on('error', (err) => {
settle({ error: `${err.code || 'UNKNOWN'}: ${err.message}` }) //待確認用err.toString()還是err.message更佳 bbb
})
//close
proc.on('close', (code, signal) => {
if (settled) {
return
}
//flush decoder殘餘與不完整行
let fOut = stdoutDecoder.decode()
if (fOut) {
if (isfun(onStdout)) {
onStdout(fOut)
}
emitLines(fOut, false)
}
emitLines('', true)
let fErr = stderrDecoder.decode()
if (fErr) {
if (isfun(onStderr)) {
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) {
pm.resolve(false)
return pm
}
try {
proc.stdin.write(String(str), 'utf8', (err) => {
pm.resolve(!err)
})
}
catch {
pm.resolve(false)
}
return pm
}
//writeLine
s.writeLine = (str) => {
return s.write(String(str) + '\n')
}
//stop, 關閉stdin → 等exitGraceMs → 未退出即樹殺 → 等exit; 多次呼叫回傳同一Promise
s.stop = () => {
if (pmStop) {
return pmStop
}
pmStop = genPm()
if (settled) {
pmStop.resolve(result)
return pmStop
}
stopping = true
try {
proc.stdin.end()
}
catch {}
graceTimer = setTimeout(() => {
if (settled) {
return
}
kill('STOP_TIMEOUT(子進程未能結束)')
}, exitGraceMs)
pmExit.then((r) => {
pmStop.resolve(r)
})
return pmStop
}
return s
}
export default execCliSession