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 d7c66e95..92ec51d8 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) */ import { execSync, execFileSync, spawnSync, spawn } from 'node:child_process'; import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, renameSync, readdirSync, readlinkSync, appendFileSync, statSync, unlinkSync } from 'node:fs'; @@ -124,6 +124,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 */ } @@ -191,11 +195,42 @@ function killDuplicatePm2GodDaemons(home: string = PM2_HOME): boolean { return true; } -function runPm2(args: string[], inherit = true, home: string = PM2_HOME): void { - execSync(`${pm2Bin()} ${args.join(' ')}`, { +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) { + // 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[] { @@ -825,7 +860,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; } @@ -1054,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 */ } } @@ -1166,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 @@ -1196,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. @@ -1242,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}-`)) { @@ -1325,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) { @@ -1376,6 +1388,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)); } @@ -2586,7 +2599,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 查看自启状态 unset 清除 worker 预算覆盖,恢复按机器 CPU/内存自动推导 diff --git a/src/cli/pm2-command.ts b/src/cli/pm2-command.ts index a2a14087..62df11d8 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,18 @@ export function buildPm2SpawnCommand( nodePath: string = process.execPath, ): SpawnCommand { if (platform === 'win32' && pm2Script !== 'pm2') { + if (pm2Script.toLowerCase().endsWith('.cmd')) { + // 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] }; } return { command: pm2Script, args }; diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 4b8eaec9..a6c66763 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; @@ -1498,6 +1500,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: { @@ -1509,7 +1512,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. @@ -2442,6 +2445,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: { @@ -2451,7 +2455,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 c90a2186..55fa219c 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'; @@ -215,7 +216,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 = { @@ -248,9 +249,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 2308c57f..57201f08 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -588,6 +588,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 653d4921..5b56445f 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -591,6 +591,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..7e4b29b1 100644 --- a/test/pm2-command.test.ts +++ b/test/pm2-command.test.ts @@ -20,6 +20,30 @@ describe('buildPm2SpawnCommand', () => { }); }); + 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, + ['status'], + 'win32', + String.raw`D:\Application\nodejs\node.exe`, + )).toEqual({ + 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, + }); + }); + it('keeps direct pm2 command unchanged on Windows', () => { expect(buildPm2SpawnCommand('pm2', ['status'], 'win32', 'node.exe')).toEqual({ command: 'pm2',