import cst from './_const.mjs'
import codec from './_execProcessCodec.mjs'
import cp from 'child_process'
import get from 'lodash-es/get.js'
import genPm from './genPm.mjs'
import isearr from './isearr.mjs'
import isbol from './isbol.mjs'
import isfun from './isfun.mjs'
import isestr from './isestr.mjs'
import isnum from './isnum.mjs'
import strleft from './strleft.mjs'
import strright from './strright.mjs'
import arrHas from './arrHas.mjs'
import execProcessKillPid from './execProcessKillPid.mjs'
/**
* 呼叫執行檔執行
*
* Unit Test: {@link https://github.com/yuda-lyu/wsemi/blob/master/test/execProcess.test.mjs Github}
* @memberOf wsemi
* @param {String} prog 輸入執行檔或程式語言位置字串,若為註冊系統的全域指令,例如可直接給'Python',腳本需自行接收呼叫引數,並將回傳資料轉json字串後print/log到dos視窗,即可由nodejs接收
* @param {String|Array} args 輸入腳本檔案位置字串或參數
* @param {Object} [opt={}] 輸入設定物件
* @param {String} [opt.mode='spawn'] 輸入執行模式字串,可選'spawn'、'exec'、'execFile',預設'spawn'。'spawn'不經shell直接執行,輸出以串流處理,適合大量輸出或長時間執行,prog與args不需也不可加引號;'exec'經shell執行,prog與args以空白串接成指令字串,適合須用到shell語法之情形,陣列型args之元素含空白且未含雙引號者會自動以雙引號包住,prog含空白亦同,已含雙引號者視為呼叫端自行處理而原樣串接,字串型args視為呼叫端已自組之指令列,原樣串接不加引號;'execFile'不經shell直接執行可執行檔,args須為陣列,給字串會被當成單一參數。三種模式之stdout與stderr皆以Buffer接收後走同一條解碼路徑
* @param {Function} [opt.cbStdout=null] 輸入回調stdout函數,預設null。收到的是經串流解碼之字串,多位元組字元跨chunk時會等到完整才給,故不會收到空字串;codeCmd為'auto'時回調為盡力判定,該串流首次遇到非utf-8位元組後才改用系統字碼頁,之前已回調之字串不追溯,最終結果以resolve或reject之字串為準
* @param {Function} [opt.cbStderr=null] 輸入回調stderr函數,預設null,行為同cbStdout
* @param {String} [opt.codeCmd='utf8'] 輸入解碼stdout與stderr之編碼字串,預設'utf8'。可給TextDecoder支援之標籤,例如'utf8'、'big5'、'gbk'、'gb18030'、'shift_jis'、'euc-kr'、'euc-jp'、'latin1'、'utf-16le',亦可給Windows字碼頁編號寫法如'cp950'、'950'、'cp936'、'cp932'、'cp949'、'cp437'、'cp1252';給'system'表示用當前作業系統字碼頁,win32以chcp查詢一次後快取,其他平台為utf-8;給'auto'表示先以utf-8嚴格解碼,遇非utf-8位元組之串流改用系統字碼頁,適合不確定子程序輸出編碼之情形,例如Windows原生指令與以ANSI字碼頁輸出之打包程式。非有效字串視為未設定而用預設值,有效字串但不支援之編碼則於執行前reject並帶該標籤。輸出開頭之utf-8 BOM會被移除
* @param {Boolean} [opt.useChcp=false] 輸入是否於指令前置chcp 65001,僅'exec'模式生效,預設false。可使主控台工具改以utf-8輸出,副作用是其訊息語言可能變為英文;使用時codeCmd請維持預設'utf8'或給'auto',不要搭配'system'
* @param {Number} [opt.timeout=null] 輸入逾時毫秒數,預設null表示不限制;逾時觸發時會調用execProcessKillPid強制關閉子進程及其子孫程序,並reject回傳逾時訊息
* @returns {Promise} 回傳Promise,resolve回傳stdout與stderr依到達順序合併之字串,不另加換行,子程序自己的換行原樣保留;reject回傳錯誤訊息,離開碼非0時為`code[N]:\n訊息`,逾時為`timeout[Nms]:\n訊息`
* @example
* //need test in nodejs
*
* if (true) {
* let prog = 'taskkill'
* let args = ['/pid', '{pid}', '/T', '/F']
* let r = await execProcess(prog, args) //預設spawn
* }
*
* if (true) {
* //Windows原生指令輸出為系統字碼頁(中文系統為big5), 給codeCmd:'auto'可自動判定, 或直接給'big5'
* let r = await execProcess('tasklist', ['/FI', 'IMAGENAME eq node.exe'], { codeCmd: 'auto' })
* }
*
* if (true) {
* //execFile與spawn之args須為陣列, 每個元素為一個參數; 給字串會被當成單一參數而失敗
* let prog = `C:\\Program Files\\7-Zip\\7z.exe`
* let pw = 'AbcD1234'
* let args = ['a', 'abc.7z', 'abc.txt', `-p${pw}`]
* execProcess(prog, args, { mode: 'execFile' })
* .then(function(data) {
* console.log('then', data)
* })
* .catch(function(data) {
* console.log('catch', data)
* })
* }
*
*/
function execProcess(prog, args, opt = {}) {
//check
if (!isearr(args) && !isestr(args)) {
throw new Error(`args is not an effective array or string`)
}
let bArgsStr = isestr(args) //字串型args於exec模式視為呼叫端已自組之指令列
if (isestr(args)) {
args = [args]
}
//mode
// spawn: 非同步執行命令,適合處理大量資料或長時間執行的程式,輸出以串流方式處理。spawnSync 為 spawn 的同步版本。
// exec: 在 shell 中非同步執行命令,輸出被緩衝,適合輸出量較小的情況。execSync 為 exec 的同步版本。
// execFile: 直接執行可執行檔案,不經過 shell,非同步執行,適合執行已知的可執行檔案。execFileSync 為 execFile 的同步版本。
let mode = get(opt, 'mode')
if (mode !== 'spawn' && mode !== 'exec' && mode !== 'execFile') {
mode = 'spawn'
}
// console.log('mode',mode)
//cbStdout
let cbStdout = get(opt, 'cbStdout')
//cbStderr
let cbStderr = get(opt, 'cbStderr')
//codeCmd, 正規化為{ mode, label }, 非有效字串為utf-8, 不支援之標籤為null於下方reject
let codeCmd = get(opt, 'codeCmd')
let cc = codec.normalizeCodeCmd(codeCmd)
//useChcp
let useChcp = get(opt, 'useChcp')
if (!isbol(useChcp)) {
useChcp = false
}
//timeout
let timeout = get(opt, 'timeout')
if (!isnum(timeout) || timeout <= 0) {
timeout = null
}
else {
timeout = Math.min(timeout, cst.TIMER_TIME_MAX) //夾至計時器上限, 見_const.mjs
}
//pm
let pm = genPm()
//check
if (!arrHas(mode, ['spawn', 'exec', 'execFile'])) {
pm.reject(`invalid mode[${mode}]`)
return pm
}
if (cc === null) {
//不支援之編碼於執行前reject, 不得等到data事件才拋, 因事件處理器內之拋錯不在try catch範圍也不會進error事件, 會拉倒整個程序
pm.reject(`codeCmd[${codeCmd}] is not a supported encoding`)
return pm
}
//start, 建立子進程並接線, labelSystem為系統字碼頁標籤, 僅codeCmd為'auto'或'system'時需要
let start = (labelSystem) => {
//r
let r = null
try {
if (mode === 'spawn') {
// console.log('mode',mode)
let cr = strleft(prog, 1)
let cl = strright(prog, 1)
if (cr === `"` || cl === `"` || cr === `'` || cl === `'`) {
throw new Error('prog of spawn doens not need to add quotes')
}
r = cp.spawn(prog, args, {
windowsHide: true, //執行的主程序若沒有主控台調用執行程序就不會有視窗, 但若通過pm2執行會有, 須設定windowsHide=true
shell: false,
}) //spwan的prog與args內檔案, 都不需要用單/雙引號括住, 已內建處理機制, 額外添加單/雙引號會導致錯誤
}
else if (mode === 'exec') {
// console.log('mode',mode)
let cpre = ''
if (useChcp) {
cpre = `cmd /c chcp 65001>nul &&`
}
//quote, 含空白且未含雙引號者以雙引號包住, 使陣列型args之元素與spawn/execFile之參數語意一致(否則shell會把'IMAGENAME eq X'拆成三個參數); 已含雙引號者視為呼叫端自行處理; 字串型args為呼叫端自組之指令列, 原樣串接
let quote = (s) => {
s = String(s)
if (/\s/.test(s) && !s.includes('"')) {
return `"${s}"`
}
return s
}
let cprog = quote(prog)
let cargs = bArgsStr ? args[0] : args.map(quote).join(' ')
// console.log(`${cpre} ${cprog} ${cargs} & exit`)
r = cp.exec(`${cpre} ${cprog} ${cargs} & exit`, {
windowsHide: true, //執行的主程序若沒有主控台調用執行程序就不會有視窗, 但若通過pm2執行會有, 須設定windowsHide=true
encoding: 'buffer', //以Buffer接收, 與spawn走同一條解碼路徑; 若交給node之encoding, 其不支援big5等且不認識之編碼會被靜默忽略
})
}
else if (mode === 'execFile') {
// console.log('mode',mode)
r = cp.execFile(prog, args, {
windowsHide: true, //執行的主程序若沒有主控台調用執行程序就不會有視窗, 但若通過pm2執行會有, 須設定windowsHide=true
encoding: 'buffer', //同exec
})
}
}
catch (err) {
pm.reject(err)
return
}
//decOut, decErr, stdout與stderr各持一個串流解碼器, 多位元組字元跨chunk時由解碼器暫存, 不會被切成U+FFFD
let label = cc.label
if (cc.mode === 'system') {
label = labelSystem
}
else if (cc.mode === 'auto') {
label = 'auto'
}
let decOut = codec.createDecoder(label, { labelSystem })
let decErr = codec.createDecoder(label, { labelSystem })
//segs, 依到達順序保存各chunk解碼後之字串, 'auto'時另保存原始chunk, 供該串流改判為系統字碼頁後重播
let segs = []
let retain = (cc.mode === 'auto')
//onData, 解碼後推入segs並回呼, 空字串(該chunk僅含尚未完整之多位元組序列)不回呼
let onData = (which, dec, cb) => {
return (data) => {
let buf = data
if (!Buffer.isBuffer(buf)) {
buf = Buffer.from(String(buf))
}
let text = dec.write(buf)
segs.push({ which, text, buf: retain ? buf : null })
if (text !== '' && isfun(cb)) {
cb(text)
}
}
}
//stdout data
r.stdout.on('data', onData('out', decOut, cbStdout))
//stderr data, 太多程式把warning或log輸出到stderr, 已無法視為有err發生
r.stderr.on('data', onData('err', decErr, cbStderr))
//buildMsg, 於close時收尾: 各解碼器flush, 'auto'且已改判之串流以全新解碼器重播原始chunk, 最後依到達順序合併, 不另加換行(chunk切點由作業系統決定, 附加換行會落在子程序輸出的任意位置)
let buildMsg = () => {
let flush = (which, dec, cb) => {
let text = dec.end()
if (text === '') {
return
}
segs.push({ which, text, buf: null })
if (isfun(cb)) {
cb(text)
}
}
flush('out', decOut, cbStdout)
flush('err', decErr, cbStderr)
if (retain) {
//改判前已以utf-8解出之片段須一併改判, 且系統字碼頁之多位元組字元可能跨chunk, 故以單一串流解碼器自頭重播該串流全部chunk; 已回呼之字串不追溯
let replay = (which, dec) => {
if (!dec.switched) {
return
}
let fd = codec.createDecoder(dec.label)
let last = null
for (let seg of segs) {
if (seg.which !== which) {
continue
}
seg.text = (seg.buf !== null) ? fd.write(seg.buf) : ''
last = seg
}
let text = fd.end()
if (text !== '' && last !== null) {
last.text += text
}
}
replay('out', decOut)
replay('err', decErr)
}
let cmsg = ''
for (let seg of segs) {
cmsg += seg.text
}
return cmsg
}
// //exit, 會比close先觸發故不使用
// r.on('exit', (code) => {
// // console.log('exit code', code)
// })
//timer, 若有設定timeout, 逾時觸發時調用execProcessKillPid殺整棵程序樹
let timer = null
let bTimeout = false
if (timeout !== null) {
timer = setTimeout(() => {
bTimeout = true
if (r && r.pid) {
//fire-and-forget, 殺掉子進程後close/error事件會接著觸發, 由bTimeout決定reject訊息
execProcessKillPid(r.pid)
.catch(() => {}) //pid可能已結束, 忽略error
}
}, timeout)
}
//close
r.on('close', (code) => {
// console.log('close code', code)
if (timer !== null) {
clearTimeout(timer)
}
let cmsg = buildMsg()
if (bTimeout) {
pm.reject(`timeout[${timeout}ms]:\n${cmsg}`)
}
else if (code !== 0) {
pm.reject(`code[${code}]:\n${cmsg}`)
}
else {
pm.resolve(cmsg)
}
})
//error, 監聽(例如執行spawn本身)錯誤訊息, 可能為'找不到可執行檔','權限不足','系統資源用盡'等
r.on('error', (err) => {
if (timer !== null) {
clearTimeout(timer)
}
pm.reject(err)
})
}
//系統字碼頁僅'auto'與'system'需要, 首次會等chcp查詢完才啟動子進程, 之後為快取; 固定標籤同步啟動, 與既有時序相同
if (cc.mode === 'fixed') {
start(null)
}
else {
codec.getSystemCodePage()
.then(start)
.catch((err) => {
pm.reject(err)
})
}
return pm
}
export default execProcess