From 249e3cc98527d9ec62cb9b285991514b6a42561f Mon Sep 17 00:00:00 2001 From: zhangguanghan Date: Tue, 16 Jun 2026 16:49:14 +0800 Subject: [PATCH 1/2] feat(windows): support autostart and daemon spawning --- src/autostart.ts | 188 +++++++++++++++++++++++++++++++++- src/cli.ts | 16 ++- src/cli/pm2-command.ts | 4 + src/core/worker-pool.ts | 10 +- src/dashboard.ts | 13 ++- src/i18n/en.ts | 1 + src/i18n/zh.ts | 1 + src/workflows/daemon-spawn.ts | 7 +- test/pm2-command.test.ts | 14 +++ 9 files changed, 237 insertions(+), 17 deletions(-) diff --git a/src/autostart.ts b/src/autostart.ts index 0ce8e93a..7e06772b 100644 --- a/src/autostart.ts +++ b/src/autostart.ts @@ -6,6 +6,8 @@ * Linux — installs a user systemd unit at ~/.config/systemd/user/botmux.service * and enables it (no sudo). Reminds the user to run * `loginctl enable-linger` if the unit needs to survive logout. + * Windows — installs a per-user Task Scheduler task, or falls back to the + * current user's Startup folder if task registration is denied. * * The unit invokes `node /dist/cli.js start`, which goes through * the same pm2 path as `botmux start`. PATH from the install-time shell is @@ -28,10 +30,12 @@ export interface AutostartOpts { const LABEL = 'com.botmux.daemon'; const SERVICE_NAME = 'botmux.service'; +const WINDOWS_TASK_NAME = 'botmux-daemon'; -function platform(): 'macos' | 'linux' | 'unsupported' { +function platform(): 'macos' | 'linux' | 'windows' | 'unsupported' { if (process.platform === 'darwin') return 'macos'; if (process.platform === 'linux') return 'linux'; + if (process.platform === 'win32') return 'windows'; return 'unsupported'; } @@ -314,16 +318,163 @@ function statusLinux(): void { console.log(`Linger: ${lingerEnabled() ? 'yes' : 'no(登出后服务会停)'}`); } +// ─── Windows (Task Scheduler / Startup folder) ───────────────────────────── + +function escapeCmdValue(s: string): string { + // Batch files expand %VAR% while parsing. Keep the captured PATH literal. + return s.replace(/\^/g, '^^').replace(/%/g, '%%'); +} + +function escapeVbsString(s: string): string { + return s.replace(/"/g, '""'); +} + +function windowsScriptPath(): string { + return join(homedir(), '.botmux', 'autostart.cmd'); +} + +function windowsStartupDir(): string { + return join( + process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), + 'Microsoft', + 'Windows', + 'Start Menu', + 'Programs', + 'Startup', + ); +} + +function windowsStartupLauncherPath(): string { + return join(windowsStartupDir(), 'botmux-autostart.vbs'); +} + +function windowsLogPath(opts: AutostartOpts, name: string): string { + return join(opts.logDir, name); +} + +function windowsScriptContent(opts: AutostartOpts): string { + const path = escapeCmdValue(currentPath()); + const cwd = opts.configDir; + const outLog = windowsLogPath(opts, 'autostart-out.log'); + const errLog = windowsLogPath(opts, 'autostart-err.log'); + return `@echo off +setlocal +set "PATH=${path}" +cd /d "${cwd}" +"${nodeBin()}" "${cliJs(opts)}" start >> "${outLog}" 2>> "${errLog}" +`; +} + +function windowsLauncherContent(scriptPath: string): string { + const script = escapeVbsString(scriptPath); + return `Set shell = CreateObject("WScript.Shell") +shell.Run Chr(34) & "${script}" & Chr(34), 0, False +`; +} + +function windowsTaskExists(): boolean { + const r = spawnSync('schtasks', ['/Query', '/TN', WINDOWS_TASK_NAME], { stdio: 'pipe' }); + return r.status === 0; +} + +function createWindowsTask(scriptPath: string): ReturnType { + return spawnSync( + 'schtasks', + ['/Create', '/TN', WINDOWS_TASK_NAME, '/SC', 'ONLOGON', '/TR', `"${scriptPath}"`, '/F'], + { stdio: 'pipe' }, + ); +} + +function writeWindowsStartupLauncher(scriptPath: string): string { + const launcher = windowsStartupLauncherPath(); + mkdirSync(dirname(launcher), { recursive: true }); + writeFileSync(launcher, windowsLauncherContent(scriptPath)); + return launcher; +} + +function enableWindows(opts: AutostartOpts): void { + const script = windowsScriptPath(); + mkdirSync(dirname(script), { recursive: true }); + mkdirSync(opts.logDir, { recursive: true }); + writeFileSync(script, windowsScriptContent(opts)); + console.log(`✅ 已写入 Windows 启动脚本: ${script}`); + + const r = createWindowsTask(script); + if (r.status === 0) { + console.log(`✅ 已创建/更新 Windows 任务计划: ${WINDOWS_TASK_NAME}`); + const launcher = windowsStartupLauncherPath(); + if (existsSync(launcher)) { + unlinkSync(launcher); + console.log(`✅ 已清理 Startup 回退启动器: ${launcher}`); + } + } else { + const msg = (r.stderr.toString() || r.stdout.toString()).trim(); + console.warn(`⚠️ 任务计划创建失败,改用当前用户 Startup 文件夹自启。`); + if (msg) console.warn(msg); + const launcher = writeWindowsStartupLauncher(script); + console.log(`✅ 已写入 Startup 启动器: ${launcher}`); + } + + console.log(` 下次登录 Windows 时自动启动。立即启动: botmux start`); +} + +function disableWindows(): void { + const r = spawnSync('schtasks', ['/Delete', '/TN', WINDOWS_TASK_NAME, '/F'], { stdio: 'pipe' }); + if (r.status === 0) { + console.log(`✅ 已删除 Windows 任务计划: ${WINDOWS_TASK_NAME}`); + } else { + console.warn(`⚠️ 删除任务计划返回非零(可能本来就未启用)`); + const msg = (r.stderr.toString() || r.stdout.toString()).trim(); + if (msg) console.warn(msg); + } + + const launcher = windowsStartupLauncherPath(); + if (existsSync(launcher)) { + unlinkSync(launcher); + console.log(`✅ 已删除 ${launcher}`); + } else { + console.log(`ℹ️ ${launcher} 不存在`); + } + + const script = windowsScriptPath(); + if (existsSync(script)) { + unlinkSync(script); + console.log(`✅ 已删除 ${script}`); + } else { + console.log(`ℹ️ ${script} 不存在`); + } + console.log(` pm2 daemon 仍在运行;要停止请跑 botmux stop`); +} + +function statusWindows(): void { + const script = windowsScriptPath(); + const launcher = windowsStartupLauncherPath(); + console.log(`平台: Windows (Task Scheduler / Startup folder)`); + console.log(`任务名称: ${WINDOWS_TASK_NAME}`); + console.log(`启动脚本: ${script}`); + console.log(`启动脚本存在: ${existsSync(script) ? 'yes' : 'no'}`); + console.log(`Startup 启动器: ${launcher}`); + console.log(`Startup 启动器存在: ${existsSync(launcher) ? 'yes' : 'no'}`); + + const r = spawnSync('schtasks', ['/Query', '/TN', WINDOWS_TASK_NAME, '/FO', 'LIST', '/V'], { stdio: 'pipe' }); + if (r.status === 0) { + console.log(`任务计划存在: yes`); + const text = r.stdout.toString().trim(); + if (text) console.log(text); + } else { + console.log(`任务计划存在: no`); + } +} + // ─── Public dispatch ───────────────────────────────────────────────────────── export function enableAutostart(opts: AutostartOpts): void { switch (platform()) { case 'macos': return enableMac(opts); case 'linux': return enableLinux(opts); + case 'windows': return enableWindows(opts); default: console.error(`❌ 当前平台 ${process.platform} 暂不支持 botmux autostart。`); - console.error(` Windows 用户可用任务计划程序 (Task Scheduler) 调用:`); - console.error(` ${nodeBin()} ${cliJs(opts)} start`); process.exit(1); } } @@ -332,6 +483,7 @@ export function disableAutostart(_opts: AutostartOpts): void { switch (platform()) { case 'macos': return disableMac(); case 'linux': return disableLinux(); + case 'windows': return disableWindows(); default: console.error(`❌ 当前平台 ${process.platform} 暂不支持 botmux autostart。`); process.exit(1); @@ -342,6 +494,7 @@ export function autostartStatus(_opts: AutostartOpts): void { switch (platform()) { case 'macos': return statusMac(); case 'linux': return statusLinux(); + case 'windows': return statusWindows(); default: console.log(`平台: ${process.platform} (不支持)`); } @@ -373,6 +526,35 @@ export function refreshAutostart(opts: AutostartOpts): boolean { } return true; } + case 'windows': { + const script = windowsScriptPath(); + const launcher = windowsStartupLauncherPath(); + if (!existsSync(script) && !existsSync(launcher) && !windowsTaskExists()) return false; + + mkdirSync(dirname(script), { recursive: true }); + mkdirSync(opts.logDir, { recursive: true }); + const next = windowsScriptContent(opts); + const prev = existsSync(script) ? readFileSync(script, 'utf-8') : ''; + let changed = prev !== next; + if (changed) writeFileSync(script, next); + + const task = createWindowsTask(script); + if (task.status === 0) { + if (existsSync(launcher)) { + unlinkSync(launcher); + changed = true; + } + return changed; + } + + const nextLauncher = windowsLauncherContent(script); + const prevLauncher = existsSync(launcher) ? readFileSync(launcher, 'utf-8') : ''; + if (prevLauncher !== nextLauncher) { + writeWindowsStartupLauncher(script); + changed = true; + } + return changed; + } default: return false; } } diff --git a/src/cli.ts b/src/cli.ts index e9cd7c66..83e26614 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,7 +15,7 @@ * botmux list --plain — plain table output (for piping / scripts) * botmux delete — close a session by ID prefix * botmux delete all — close all active sessions - * botmux autostart enable|disable|status — manage boot-time autostart (launchd / user systemd) + * botmux autostart enable|disable|status — manage boot-time autostart (launchd / user systemd / Windows Task Scheduler) * botmux worker-budget status|set|unset — inspect/override idle worker suspension budget */ import { execSync, execFileSync, spawnSync, spawn } from 'node:child_process'; @@ -126,6 +126,10 @@ function ensureConfigDir(): void { * may belong to an unrelated installation (e.g. IDE remote extensions). */ function pm2Bin(): string { + if (process.platform === 'win32') { + const cmd = join(PKG_ROOT, 'node_modules', '.bin', 'pm2.cmd'); + if (existsSync(cmd)) return cmd; + } try { return require.resolve('pm2/bin/pm2'); } catch { /* fall through */ } @@ -194,10 +198,13 @@ function killDuplicatePm2GodDaemons(home: string = PM2_HOME): boolean { } function runPm2(args: string[], inherit = true, home: string = PM2_HOME): void { - execSync(`${pm2Bin()} ${args.join(' ')}`, { + const pm2 = buildPm2SpawnCommand(pm2Bin(), args); + const r = spawnSync(pm2.command, pm2.args, { stdio: inherit ? 'inherit' : 'pipe', env: pm2Env(home), + shell: pm2.shell ?? false, }); + if (r.status !== 0) throw new Error(`pm2 ${args.join(' ')} failed with status ${r.status}`); } function loadBotsJson(): any[] { @@ -827,7 +834,7 @@ async function writeSingleBotConfig(): Promise { await finishOpenPlatformSetup(bot.larkAppId, botBrand(bot)); console.log(`下一步:`); console.log(` 1. botmux start 启动 daemon`); - console.log(` 2. botmux autostart enable 注册开机自启(推荐:${process.platform === 'darwin' ? 'mac launchd' : process.platform === 'linux' ? 'linux user systemd' : '当前平台暂不支持'},无需 sudo)`); + console.log(` 2. botmux autostart enable 注册开机自启(推荐:${process.platform === 'darwin' ? 'mac launchd' : process.platform === 'linux' ? 'linux user systemd' : process.platform === 'win32' ? 'Windows Task Scheduler' : '当前平台暂不支持'},无需 sudo)`); return true; } @@ -1378,6 +1385,7 @@ function cmdLogs(): void { const child = spawn(pm2.command, pm2.args, { stdio: 'inherit', env: pm2Env(), + shell: pm2.shell ?? false, }); child.on('exit', code => process.exit(code ?? 0)); } @@ -2588,7 +2596,7 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接 term-link [id] 获取活跃会话的「可操作终端」(带写 token)。不回显链接,改由 daemon 把可操作卡片私密发给 owner(群内仅你可见,话题/单聊回退 DM)。 单个活跃会话可省略 id - autostart enable 注册开机自启(macOS launchd / Linux user systemd,无需 sudo) + autostart enable 注册开机自启(macOS launchd / Linux user systemd / Windows Task Scheduler,无需 sudo) autostart disable 注销开机自启 autostart status 查看自启状态 worker-budget [status] 查看 idle worker 自动暂停预算 diff --git a/src/cli/pm2-command.ts b/src/cli/pm2-command.ts index a2a14087..95ef1810 100644 --- a/src/cli/pm2-command.ts +++ b/src/cli/pm2-command.ts @@ -1,6 +1,7 @@ export interface SpawnCommand { command: string; args: string[]; + shell?: boolean; } export function buildPm2SpawnCommand( @@ -10,6 +11,9 @@ export function buildPm2SpawnCommand( nodePath: string = process.execPath, ): SpawnCommand { if (platform === 'win32' && pm2Script !== 'pm2') { + if (pm2Script.toLowerCase().endsWith('.cmd')) { + return { command: pm2Script, args, shell: true }; + } return { command: nodePath, args: [pm2Script, ...args] }; } return { command: pm2Script, args }; diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 3f0090b3..a940d72d 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -2,7 +2,7 @@ * Worker pool — manages forking, killing, and lifecycle of worker processes. * Extracted from daemon.ts for modularity. */ -import { fork, execSync, type ChildProcess } from 'node:child_process'; +import { fork, execSync, type ChildProcess, type ForkOptions } from 'node:child_process'; import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; import { readFileSync, readdirSync, mkdirSync, existsSync, realpathSync } from 'node:fs'; @@ -46,6 +46,8 @@ import { claimPendingResponseCard, COMPLETED_REACTION_EMOJI_TYPE, markPendingRes import { buildTerminalUrl } from './terminal-url.js'; import { usageLimitStateKey, type CliUsageLimitState } from '../utils/cli-usage-limit.js'; +type WindowsForkOptions = ForkOptions & { windowsHide?: boolean }; + const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const WORKER_SIGTERM_BACKSTOP_MS = 2_000; @@ -1480,6 +1482,7 @@ export function forkWorker(ds: DaemonSession, prompt: string, resume = false): v const pathWithBotmux = `${botmuxBinDir}:${process.env.PATH ?? ''}`; const worker = fork(workerPath, [], { + windowsHide: true, stdio: ['ignore', 'pipe', 'pipe', 'ipc'], cwd, env: { @@ -1491,7 +1494,7 @@ export function forkWorker(ds: DaemonSession, prompt: string, resume = false): v LARK_APP_ID: botCfg.larkAppId, LARK_APP_SECRET: botCfg.larkAppSecret, }, - }); + } as WindowsForkOptions); // A fork-level failure (spawn ENOENT, etc.) emits 'error'; without a handler // the unhandled event crashes the daemon. Log and move on. @@ -2424,6 +2427,7 @@ export function forkAdoptWorker(ds: DaemonSession, opts?: { restoredFromMetadata const adoptCwd = rawAdoptCwd && existsSync(rawAdoptCwd) ? rawAdoptCwd : homedir(); if (adoptCwd !== rawAdoptCwd) logger.warn(`[${t}] adopt cwd "${rawAdoptCwd}" does not exist — falling back to ${adoptCwd}`); const worker = fork(workerPath, [], { + windowsHide: true, stdio: ['ignore', 'pipe', 'pipe', 'ipc'], cwd: adoptCwd, env: { @@ -2433,7 +2437,7 @@ export function forkAdoptWorker(ds: DaemonSession, opts?: { restoredFromMetadata LARK_APP_ID: botCfg.larkAppId, LARK_APP_SECRET: botCfg.larkAppSecret, }, - }); + } as WindowsForkOptions); // A fork-level failure emits 'error'; without a handler it crashes the daemon. worker.on('error', (err) => { diff --git a/src/dashboard.ts b/src/dashboard.ts index 4c25dfb6..96be797f 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -5,8 +5,9 @@ import { readFileSync, existsSync, chmodSync, mkdirSync, statSync, createReadStream, } from 'node:fs'; import { atomicWriteFileSync } from './utils/atomic-write.js'; -import { join, dirname, extname } from 'node:path'; +import { join, dirname, extname, resolve, relative, isAbsolute } from 'node:path'; import { homedir } from 'node:os'; +import { fileURLToPath } from 'node:url'; import { randomBytes, createHmac } from 'node:crypto'; import { logger } from './utils/logger.js'; import { config } from './config.js'; @@ -208,7 +209,7 @@ await Promise.all(registry.list().map(attachDaemon)); // ─── Static frontend ───────────────────────────────────────────────────────── // Path to the bundled frontend (sibling of dist/dashboard.js) -const __dirname = dirname(new URL(import.meta.url).pathname); +const __dirname = dirname(fileURLToPath(import.meta.url)); const WEB_DIR = join(__dirname, 'dashboard-web'); const MIME: Record = { @@ -241,9 +242,11 @@ function serveFileAbs(res: ServerResponse, fp: string): boolean { function serveStatic(_req: IncomingMessage, res: ServerResponse, pathname: string): boolean { const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, ''); - const fp = join(WEB_DIR, rel); - // Path-traversal guard: resolved path must stay inside WEB_DIR - if (!fp.startsWith(WEB_DIR + '/') && fp !== join(WEB_DIR, 'index.html')) return false; + const fp = resolve(WEB_DIR, rel); + const webRoot = resolve(WEB_DIR); + const relToRoot = relative(webRoot, fp); + // Path-traversal guard: resolved path must stay inside WEB_DIR. + if (relToRoot === '..' || relToRoot.startsWith('..\\') || relToRoot.startsWith('../') || isAbsolute(relToRoot)) return false; try { const st = statSync(fp); if (!st.isFile()) return false; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index adf8fd24..0e671f27 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -586,6 +586,7 @@ export const messages: Record = { 'setup.next_autostart': ' 2. botmux autostart enable enable on-boot autostart (recommended: {platformName}; no sudo)', 'setup.platform_mac': 'macOS launchd', 'setup.platform_linux': 'Linux user systemd', + 'setup.platform_windows': 'Windows Task Scheduler', 'setup.platform_other': 'current platform not supported', 'setup.wizard_title': '🤖 botmux setup wizard', 'setup.config_dir': 'Config dir: {path}', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 822e5634..aa42bd2c 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -589,6 +589,7 @@ export const messages: Record = { 'setup.next_autostart': ' 2. botmux autostart enable 注册开机自启(推荐:{platformName},无需 sudo)', 'setup.platform_mac': 'mac launchd', 'setup.platform_linux': 'linux user systemd', + 'setup.platform_windows': 'Windows Task Scheduler', 'setup.platform_other': '当前平台暂不支持', 'setup.wizard_title': '🤖 botmux 配置向导', 'setup.config_dir': '配置目录: {path}', diff --git a/src/workflows/daemon-spawn.ts b/src/workflows/daemon-spawn.ts index a10f194f..5020aed0 100644 --- a/src/workflows/daemon-spawn.ts +++ b/src/workflows/daemon-spawn.ts @@ -19,7 +19,7 @@ * code injects `forkWorkerJs` (defined below). */ -import { fork, type ChildProcess } from 'node:child_process'; +import { fork, type ChildProcess, type ForkOptions } from 'node:child_process'; import { createHash } from 'node:crypto'; import { appendFileSync, existsSync, mkdirSync } from 'node:fs'; import { atomicWriteFileSync } from '../utils/atomic-write.js'; @@ -42,6 +42,8 @@ import { } from './attempt-terminal.js'; import { logger } from '../utils/logger.js'; +type WindowsForkOptions = ForkOptions & { windowsHide?: boolean }; + // ─── IPC payloads (subset of WorkerToDaemon we care about) ──────────────── type WorkerEvent = @@ -91,10 +93,11 @@ export type WorkerSpawnOptions = { export const forkWorkerJsFactory: WorkerProcessFactory = { spawn(opts) { const child: ChildProcess = fork(opts.workerPath, [], { + windowsHide: true, stdio: ['ignore', 'pipe', 'pipe', 'ipc'], cwd: opts.cwd, env: opts.env, - }); + } as WindowsForkOptions); return { send: (m) => child.send(m as never), on: (event: string, cb: (...args: unknown[]) => void) => { diff --git a/test/pm2-command.test.ts b/test/pm2-command.test.ts index 878c5de0..85bb4083 100644 --- a/test/pm2-command.test.ts +++ b/test/pm2-command.test.ts @@ -20,6 +20,20 @@ describe('buildPm2SpawnCommand', () => { }); }); + it('runs package-local pm2.cmd directly through a Windows shell', () => { + const pm2Cmd = String.raw`D:\Application\npm-global\node_modules\botmux\node_modules\.bin\pm2.cmd`; + expect(buildPm2SpawnCommand( + pm2Cmd, + ['status'], + 'win32', + String.raw`D:\Application\nodejs\node.exe`, + )).toEqual({ + command: pm2Cmd, + args: ['status'], + shell: true, + }); + }); + it('keeps direct pm2 command unchanged on Windows', () => { expect(buildPm2SpawnCommand('pm2', ['status'], 'win32', 'node.exe')).toEqual({ command: 'pm2', From f1eef3aecb21169d83916b0fd38f89a4683ba304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Thu, 18 Jun 2026 11:41:42 +0800 Subject: [PATCH 2/2] fix(windows): quote pm2 .cmd args under shell:true + route all pm2 calls through buildPm2SpawnCommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shell:true 不会引用 command/args,Windows 下用户目录或安装路径含空格时 ecosystem 配置路径会被 cmd.exe 按空格切碎,pm2 start/restart 失败。改为在 buildPm2SpawnCommand 的 .cmd 分支对 command 与每个 arg 加双引号(cmd.exe /s 会在重解析时去掉),并把剩余的 execSync(`${pm2Bin()} …`)(jlist/delete/kill) 统一走 buildPm2SpawnCommand + spawnSync,保证 Windows .cmd 与含空格路径正确。 新增 pm2Capture 辅助函数捕获 stdout,runPm2 支持 timeout。补 1 个空格路径单测。 --- src/cli.ts | 67 +++++++++++++++++++++------------------- src/cli/pm2-command.ts | 11 ++++++- test/pm2-command.test.ts | 16 ++++++++-- 3 files changed, 59 insertions(+), 35 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 4f4e12ae..92ec51d8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -195,14 +195,42 @@ function killDuplicatePm2GodDaemons(home: string = PM2_HOME): boolean { return true; } -function runPm2(args: string[], inherit = true, home: string = PM2_HOME): void { +function runPm2(args: string[], inherit = true, home: string = PM2_HOME, timeoutMs?: number): void { const pm2 = buildPm2SpawnCommand(pm2Bin(), args); const r = spawnSync(pm2.command, pm2.args, { stdio: inherit ? 'inherit' : 'pipe', env: pm2Env(home), shell: pm2.shell ?? false, + timeout: timeoutMs, }); - if (r.status !== 0) throw new Error(`pm2 ${args.join(' ')} failed with status ${r.status}`); + if (r.status !== 0) { + // r.error is set when the process couldn't be spawned/timed out (status null); + // prefer it so failures don't surface as a bare "status null". + const detail = r.error?.message ?? `status ${r.status}`; + throw new Error(`pm2 ${args.join(' ')} failed: ${detail}`); + } +} + +/** + * Run a pm2 command and capture stdout. Routes through buildPm2SpawnCommand so + * it works on Windows (where pm2Bin() resolves to a `.cmd` that must run through + * a shell) as well as macOS/Linux. Throws on non-zero exit / spawn failure. + */ +function pm2Capture(args: string[], home: string = PM2_HOME, timeoutMs = 10_000): string { + const pm2 = buildPm2SpawnCommand(pm2Bin(), args); + const r = spawnSync(pm2.command, pm2.args, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env: pm2Env(home), + shell: pm2.shell ?? false, + timeout: timeoutMs, + }); + if (r.status !== 0) { + const detail = r.error?.message + ?? ((r.stderr ? String(r.stderr).trim() : '') || `status ${r.status}`); + throw new Error(`pm2 ${args.join(' ')} failed: ${detail}`); + } + return typeof r.stdout === 'string' ? r.stdout : ''; } function loadBotsJson(): any[] { @@ -1061,7 +1089,7 @@ function preflightNodeSanity(): void { console.warn(`⚠️ pm2 god daemon (pid ${pm2Pid}) 使用的 Node 二进制已失效: ${cleanPath}`); console.warn(` 自动杀掉 pm2 god 以便用当前 Node 重启...`); try { - execSync(`${pm2Bin()} kill`, { env: pm2Env(), stdio: 'pipe', timeout: 10_000 }); + runPm2(['kill'], false, PM2_HOME, 10_000); } catch { try { process.kill(pm2Pid, 'SIGKILL'); } catch { /* ignore */ } } @@ -1173,21 +1201,12 @@ function cleanupStaleDaemonDescriptors(): void { /** Delete all pm2 processes matching botmux / botmux-* under the given PM2_HOME. */ function deleteAllBotmuxProcesses(home: string = PM2_HOME): void { try { - const output = execSync(`${pm2Bin()} jlist`, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - env: pm2Env(home), - timeout: 10_000, - }); + const output = pm2Capture(['jlist'], home); const apps = JSON.parse(output) as any[]; for (const app of apps) { if (app.name === PM2_NAME || app.name.startsWith(`${PM2_NAME}-`)) { try { - execSync(`${pm2Bin()} delete ${app.name}`, { - stdio: ['pipe', 'pipe', 'pipe'], - env: pm2Env(home), - timeout: 10_000, - }); + runPm2(['delete', app.name], false, home, 10_000); } catch (e) { // Don't swallow silently — a failed delete here used to leave the // restart half-done with no trace. Surface it (the auto-restart @@ -1203,11 +1222,7 @@ function deleteAllBotmuxProcesses(home: string = PM2_HOME): void { function killPm2GodDaemon(home: string = PM2_HOME): void { try { - execSync(`${pm2Bin()} kill`, { - stdio: 'inherit', - env: pm2Env(home), - timeout: 15_000, - }); + runPm2(['kill'], true, home, 15_000); return; } catch { // Fall back to direct pid cleanup below. @@ -1249,12 +1264,7 @@ function cmdStop(): void { cleanupLegacyPm2(); let stopped = false; try { - const output = execSync(`${pm2Bin()} jlist`, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - env: pm2Env(), - timeout: 10_000, - }); + const output = pm2Capture(['jlist']); const apps = JSON.parse(output) as any[]; for (const app of apps) { if (app.name === PM2_NAME || app.name.startsWith(`${PM2_NAME}-`)) { @@ -1332,12 +1342,7 @@ function warnIfLegacyBotmuxAlive(): void { if (!legacyPid) return; try { process.kill(legacyPid, 0); } catch { return; } try { - const output = execSync(`${pm2Bin()} jlist`, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - env: pm2Env(legacyHome), - timeout: 10_000, - }); + const output = pm2Capture(['jlist'], legacyHome); const apps = JSON.parse(output) as any[]; const hasBotmux = apps.some(a => a.name === PM2_NAME || a.name.startsWith(`${PM2_NAME}-`)); if (hasBotmux) { diff --git a/src/cli/pm2-command.ts b/src/cli/pm2-command.ts index 95ef1810..62df11d8 100644 --- a/src/cli/pm2-command.ts +++ b/src/cli/pm2-command.ts @@ -12,7 +12,16 @@ export function buildPm2SpawnCommand( ): SpawnCommand { if (platform === 'win32' && pm2Script !== 'pm2') { if (pm2Script.toLowerCase().endsWith('.cmd')) { - return { command: pm2Script, args, shell: true }; + // Node's spawn with `{ shell: true }` does NOT quote the command or args — + // it joins them verbatim into the cmd.exe command line. Without quoting, a + // space anywhere (the pm2.cmd path under "C:\Program Files\…", or the + // ecosystem config path under "C:\Users\First Last\.botmux\…") gets + // word-split by cmd.exe and pm2 receives a truncated path. Wrap each token + // in double quotes; cmd.exe (/s) strips them when it re-parses, and the + // npm .cmd shim forwards the quoted args through %* intact. Windows paths + // can't contain `"`, so simple wrapping is sufficient here. + const quote = (s: string): string => `"${s}"`; + return { command: quote(pm2Script), args: args.map(quote), shell: true }; } return { command: nodePath, args: [pm2Script, ...args] }; } diff --git a/test/pm2-command.test.ts b/test/pm2-command.test.ts index 85bb4083..7e4b29b1 100644 --- a/test/pm2-command.test.ts +++ b/test/pm2-command.test.ts @@ -20,7 +20,7 @@ describe('buildPm2SpawnCommand', () => { }); }); - it('runs package-local pm2.cmd directly through a Windows shell', () => { + it('runs package-local pm2.cmd directly through a Windows shell (quoted)', () => { const pm2Cmd = String.raw`D:\Application\npm-global\node_modules\botmux\node_modules\.bin\pm2.cmd`; expect(buildPm2SpawnCommand( pm2Cmd, @@ -28,8 +28,18 @@ describe('buildPm2SpawnCommand', () => { 'win32', String.raw`D:\Application\nodejs\node.exe`, )).toEqual({ - command: pm2Cmd, - args: ['status'], + command: `"${pm2Cmd}"`, + args: ['"status"'], + shell: true, + }); + }); + + it('quotes pm2.cmd and args so spaces in the config path survive shell:true', () => { + const pm2Cmd = String.raw`C:\Users\First Last\AppData\Roaming\npm\node_modules\botmux\node_modules\.bin\pm2.cmd`; + const cfg = String.raw`C:\Users\First Last\.botmux\ecosystem.config.json`; + expect(buildPm2SpawnCommand(pm2Cmd, ['start', cfg], 'win32')).toEqual({ + command: `"${pm2Cmd}"`, + args: ['"start"', `"${cfg}"`], shell: true, }); });