diff --git a/.npmrc b/.npmrc index 601e9c1..92fde96 100644 --- a/.npmrc +++ b/.npmrc @@ -1,101 +1,25 @@ -# 自动生成的镜像配置 (by setup-mirror.mjs) -# 生成时间: 2026-06-07T08:50:30.745Z +# --- begin auto-generated by setup-mirror.mjs --- +# 生成时间: 2026-06-24T19:18:59.425Z # npm registry registry=https://mirrors.huaweicloud.com/repository/npm/ -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# sharp 二进制镜像 - -# better-sqlite3 二进制镜像 - -# puppeteer chromium 镜像 - -# 用户自定义配置 -# npm registry - -# sharp 二进制镜像 - -# better-sqlite3 二进制镜像 - -# puppeteer chromium 镜像 - -# 用户自定义配置 -# npm registry - -# sharp 二进制镜像 - -# better-sqlite3 二进制镜像 - -# puppeteer chromium 镜像 - -# 用户自定义配置 -# npm registry - -# sharp 二进制镜像 - -# better-sqlite3 二进制镜像 - -# puppeteer chromium 镜像 - -# 用户自定义配置 -# npm registry - -# sharp 二进制镜像 +# 网络超时与重试(避免 GitHub 不稳定时无限等待) +fetch-timeout=60000 +fetch-retries=3 -# better-sqlite3 二进制镜像 +# electron 二进制镜像 +electron_mirror=https://mirrors.huaweicloud.com/repository/npm/-/binary/electron/ +electron_builder_binaries_mirror=https://mirrors.huaweicloud.com/repository/npm/-/binary/electron-builder-binaries/ # puppeteer chromium 镜像 - -# 用户自定义配置 -# npm registry +puppeteer_download_base_url=https://cdn.npmmirror.com/binaries/chrome-for-testing # sharp 二进制镜像 +sharp_binary_host=https://mirrors.huaweicloud.com/repository/npm/-/binary/sharp +sharp_libvips_binary_host=https://mirrors.huaweicloud.com/repository/npm/-/binary/sharp-libvips # better-sqlite3 二进制镜像 +better_sqlite3_binary_host=https://mirrors.huaweicloud.com/repository/npm/-/binary/better-sqlite3 -# puppeteer chromium 镜像 - -# 用户自定义配置 -# 强制 pnpm 生成 npm 兼容的扁平 node_modules(避免 .pnpm/ 符号链接结构)。 -# 原因:Electron 打包后 backend 以 ELECTRON_RUN_AS_NODE 模式从 resources/app/ 下运行, -# 通过 extraResources 拷贝的 node_modules 必须是扁平结构才能被 Node 的模块解析器正常向上查找; -# pnpm 的默认 isolated 链接器会让传递依赖丢失,导致 Cannot find module 报错。 +# --- end auto-generated by setup-mirror.mjs --- diff --git a/.puppeteerrc.cjs b/.puppeteerrc.cjs new file mode 100644 index 0000000..15d49eb --- /dev/null +++ b/.puppeteerrc.cjs @@ -0,0 +1,3 @@ +module.exports = { + chromeDownloadBaseUrl: 'https://cdn.npmmirror.com/binaries/chrome-for-testing', +}; diff --git a/scripts/deploy.mjs b/scripts/deploy.mjs index 761c69d..1cb0252 100644 --- a/scripts/deploy.mjs +++ b/scripts/deploy.mjs @@ -848,7 +848,13 @@ async function deployProject(options = {}) { } try { - run('npm', ['install', '--include=dev'], '安装后端依赖'); + run('npm', ['install', '--include=dev'], '安装后端依赖', { timeout: 1800000 }); + } catch (error) { + console.error('[deploy] npm install 失败,常见原因:'); + console.error('[deploy] 1. 网络不稳定导致 electron/puppeteer 二进制下载卡死'); + console.error('[deploy] 2. GitHub 在当前网络下不可达'); + console.error('[deploy] 可尝试:配置代理后重试,或手动执行 npm install --ignore-scripts 后单独安装二进制'); + throw error; } finally { if (puppeteerHostSet) { delete process.env.PUPPETEER_DOWNLOAD_HOST; @@ -1130,6 +1136,15 @@ function getServiceRunUser() { function buildServiceContent() { const serviceUser = getServiceRunUser(); + // 构建 PATH:用户 bin 目录 + systemd 默认目录,确保 systemd 环境能找到 opencode/node + const userHome = resolveHomeDirForUser(serviceUser); + const pathDirs = [ + path.join(userHome, '.local', 'bin'), + path.join(userHome, '.opencode', 'bin'), + '/usr/local/bin', + '/usr/bin', + '/bin', + ]; return [ '[Unit]', 'Description=Feishu OpenCode Bridge', @@ -1139,10 +1154,12 @@ function buildServiceContent() { 'Type=simple', `User=${serviceUser}`, `WorkingDirectory=${rootDir}`, - `ExecStart=${process.execPath} dist/index.js`, + `ExecStart=${process.execPath} dist/admin/index.js`, 'Restart=always', 'RestartSec=3', `EnvironmentFile=-${envPath}`, + `Environment=PATH=${pathDirs.join(':')}`, + `Environment=HOME=${userHome}`, `StandardOutput=append:${outLog}`, `StandardError=append:${errLog}`, '', @@ -1161,13 +1178,30 @@ async function installSystemdService() { console.log(`[deploy] 检测到 sudo 环境,将以 ${sudoUser} 身份执行部署...`); const sudoUserHome = resolveHomeDirForUser(sudoUser); + // 构建普通用户的 PATH(sudo secure_path 会覆盖,需显式恢复) + const userPathDirs = [ + path.join(sudoUserHome, '.local', 'bin'), + path.join(sudoUserHome, '.opencode', 'bin'), + '/usr/local/bin', + '/usr/bin', + '/bin', + ]; + const userPath = userPathDirs.join(':'); + // 以普通用户身份执行部署 - const deployResult = spawnSync('sudo', ['-u', sudoUser, 'node', path.join(scriptDir, 'deploy.mjs'), 'deploy'], { + // 用 env 命令显式注入 PATH,绕过 sudo secure_path 覆盖 + // 用绝对路径的 node,避免 PATH 里仍找不到 + const deployResult = spawnSync('sudo', [ + '-u', sudoUser, + 'env', `PATH=${userPath}`, `HOME=${sudoUserHome}`, + process.execPath, path.join(scriptDir, 'deploy.mjs'), 'deploy', + ], { cwd: rootDir, stdio: 'inherit', env: { ...process.env, HOME: sudoUserHome, + PATH: userPath, BRIDGE_SKIP_SUDO_WARNING: '1', }, }); diff --git a/scripts/deploy.sh b/scripts/deploy.sh old mode 100644 new mode 100755 diff --git a/scripts/process-manager.mjs b/scripts/process-manager.mjs index e49ca0e..979ec95 100644 --- a/scripts/process-manager.mjs +++ b/scripts/process-manager.mjs @@ -13,6 +13,7 @@ */ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { spawn, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; @@ -112,7 +113,7 @@ function findBridgeProcesses(excludeSelf = false, excludePid = null) { * @param {number} excludePid - 要排除的指定 PID * @returns {number[]} 进程 PID 列表 */ -function findOpenCodeProcesses(excludeSelf = false, excludePid = null) { +function findOpenCodeProcesses(excludeSelf = false, excludePid = null, serveOnly = false) { const pids = []; const currentPid = process.pid; @@ -137,7 +138,11 @@ function findOpenCodeProcesses(excludeSelf = false, excludePid = null) { } // opencode.exe 直接就是目标进程 if (match[1] === 'opencode.exe') { - pids.push(pid); + // Windows 下 tasklist 不含命令行参数,serveOnly 无法精确判断 + // 保守起见:serveOnly 时不通过 tasklist 匹配,改由 PID 文件判断 + if (!serveOnly) { + pids.push(pid); + } } else if (isOpenCodeProcessByCommand(pid)) { pids.push(pid); } @@ -162,7 +167,11 @@ function findOpenCodeProcesses(excludeSelf = false, excludePid = null) { const command = parts.slice(10).join(' '); // 匹配 OpenCode 进程 - if (isOpenCodeCommand(command)) { + if (serveOnly) { + if (isOpencodeServeCommand(command)) { + pids.push(pid); + } + } else if (isOpenCodeCommand(command)) { pids.push(pid); } } @@ -200,6 +209,22 @@ function isOpenCodeCommand(command) { return /\bopencode\b/.test(normalizedCmd) || normalizedCmd.includes('opencode-cli'); } +/** + * 精确匹配 `opencode serve` 进程(用于 serve 幂等检查) + * 排除交互式 opencode、opencode debug、opencode attach 等非 serve 命令 + */ +function isOpencodeServeCommand(command) { + const normalizedCmd = command.replace(/\\/g, '/'); + if (isBridgeCommand(normalizedCmd)) { + return false; + } + if (normalizedCmd.includes('opencode-bridge')) { + return false; + } + // 匹配 opencode serve(允许前面有路径,后面有 --port 等参数) + return /\bopencode\b\s+serve\b/.test(normalizedCmd); +} + function getProcessCommandLine(pid) { if (!isWindows()) { return null; @@ -510,7 +535,7 @@ function main() { if (alivePid !== null) { console.log(`[process-manager] opencode serve 运行中 (PID: ${alivePid})`); } else { - const scanPids = findOpenCodeProcesses(); + const scanPids = findOpenCodeProcesses(false, null, true); if (scanPids.length > 0) { console.log(`[process-manager] opencode serve 运行中(扫描到 PID: ${scanPids.join(', ')},但 PID 文件缺失)`); } else { @@ -536,7 +561,32 @@ function main() { * 返回 { type: 'node-script', nodeExe, script } 或 { type: 'shell', cmd: 'opencode' } */ function resolveOpenCodeExecutable() { + // 优先读 OPENCODE_AUTO_START_CMD(用户自定义绝对路径) + const customCmd = process.env.OPENCODE_AUTO_START_CMD?.trim(); + if (customCmd) { + const parts = customCmd.split(/\s+/); + const exePath = parts[0]; + const restArgs = parts.slice(1); + if (fs.existsSync(exePath)) { + return { type: 'direct', exe: exePath, extraArgs: restArgs }; + } + return { type: 'shell', cmd: customCmd }; + } + if (!isWindows()) { + // Linux/macOS: 检查常见安装路径 + const homeDir = os.homedir(); + const candidates = [ + path.join(homeDir, '.opencode', 'bin', 'opencode'), // 官方脚本安装 + path.join(homeDir, '.local', 'bin', 'opencode'), // npm i -g (用户级 prefix) + '/usr/local/bin/opencode', // npm i -g (root) + '/usr/bin/opencode', // 包管理器安装 + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return { type: 'direct', exe: candidate }; + } + } return { type: 'shell', cmd: 'opencode' }; } @@ -667,7 +717,8 @@ function startOpenCodeServe(options = {}) { } // 也通过进程扫描检查(防止 PID 文件丢失但进程还在的情况) - const scanPids = findOpenCodeProcesses(); + // 仅匹配 `opencode serve` 进程,排除交互式 opencode 避免误判 + const scanPids = findOpenCodeProcesses(false, null, true); if (scanPids.length > 0) { // 补写 PID 文件 try { @@ -727,7 +778,7 @@ function startOpenCodeServe(options = {}) { fs.closeSync(stdoutFd); fs.closeSync(stderrFd); } else if (exe.type === 'direct') { - const directArgs = ['serve']; + const directArgs = [...(exe.extraArgs || []), 'serve']; if (servePort !== null) directArgs.push('--port', String(servePort)); child = spawn(exe.exe, directArgs, { detached: true, diff --git a/scripts/setup-mirror.mjs b/scripts/setup-mirror.mjs index 355337a..551fe3d 100644 --- a/scripts/setup-mirror.mjs +++ b/scripts/setup-mirror.mjs @@ -83,12 +83,33 @@ async function selectBestMirror(mirrors) { * 生成 .npmrc 内容 */ function generateNpmrc(selected) { + const baseUrl = selected.registry.url.replace(/\/$/, ''); const lines = [ - '# 自动生成的镜像配置 (by setup-mirror.mjs)', + '# --- begin auto-generated by setup-mirror.mjs ---', `# 生成时间: ${new Date().toISOString()}`, '', '# npm registry', - `registry=${selected.registry.url}` + `registry=${selected.registry.url}`, + '', + '# 网络超时与重试(避免 GitHub 不稳定时无限等待)', + 'fetch-timeout=60000', + 'fetch-retries=3', + '', + '# electron 二进制镜像', + `electron_mirror=${baseUrl}/-/binary/electron/`, + `electron_builder_binaries_mirror=${baseUrl}/-/binary/electron-builder-binaries/`, + '', + '# puppeteer chromium 镜像', + `puppeteer_download_base_url=https://cdn.npmmirror.com/binaries/chrome-for-testing`, + '', + '# sharp 二进制镜像', + `sharp_binary_host=${baseUrl}/-/binary/sharp`, + `sharp_libvips_binary_host=${baseUrl}/-/binary/sharp-libvips`, + '', + '# better-sqlite3 二进制镜像', + `better_sqlite3_binary_host=${baseUrl}/-/binary/better-sqlite3`, + '', + '# --- end auto-generated by setup-mirror.mjs ---', ]; return lines.join('\n'); @@ -133,23 +154,37 @@ async function main() { console.log('\n📝 生成 .npmrc...'); const npmrcContent = generateNpmrc(selected); - // 保留用户自定义配置 + // 保留用户自定义配置(自动生成段之外的内容) const npmrcPath = '.npmrc'; if (existsSync(npmrcPath)) { - // 读取现有配置,移除自动生成的部分 const existing = readFileSync(npmrcPath, 'utf-8'); - const lines = existing.split('\n').filter(line => - !line.includes('# 自动生成的镜像配置') && - !line.includes('# 生成时间:') && - !line.includes('registry=') && - !line.includes('sharp_binary_host=') && - !line.includes('sharp_libvips_binary_host=') && - !line.includes('better_sqlite3_binary_host=') && - !line.includes('puppeteer_download_host=') && - !line.includes('node-linker=') - ); - - const customConfig = lines.join('\n').trim(); + const autoKeys = [ + 'registry=', 'fetch-timeout=', 'fetch-retries=', + 'electron_mirror=', 'electron_builder_binaries_mirror=', + 'puppeteer_download_base_url=', 'puppeteer_download_host=', + 'sharp_binary_host=', 'sharp_libvips_binary_host=', + 'better_sqlite3_binary_host=', 'node-linker=', + ]; + // 移除自动生成段落(新旧两种格式)和自动生成的键值行 + const customConfig = existing + .replace(/^# --- begin auto-generated[\s\S]*?# --- end auto-generated[^\n]*\n?/gm, '') + .replace(/^# 自动生成的镜像配置[\s\S]*?(?=\n\S|$)/gm, '') + .split('\n') + .filter(line => + !line.includes('# 生成时间:') && + !line.includes('# 用户自定义配置') && + !line.includes('# npm registry') && + !line.includes('# 网络超时') && + !line.includes('# electron 二进制镜像') && + !line.includes('# puppeteer chromium 镜像') && + !line.includes('# sharp 二进制镜像') && + !line.includes('# better-sqlite3 二进制镜像') && + !autoKeys.some(key => line.startsWith(key)) && + line.trim() !== '' + ) + .join('\n') + .trim(); + if (customConfig) { writeFileSync(npmrcPath, npmrcContent + '\n\n# 用户自定义配置\n' + customConfig + '\n'); } else { diff --git a/src/admin/bridge-manager.ts b/src/admin/bridge-manager.ts index 9f7278f..1e8062a 100644 --- a/src/admin/bridge-manager.ts +++ b/src/admin/bridge-manager.ts @@ -62,6 +62,9 @@ export class BridgeManager { try { console.log('[BridgeManager] 内嵌模式启动 Bridge...'); + // 标记为由 Admin 启动,防止 main() 重复创建 Admin Server + process.env.BRIDGE_SPAWNED_BY_ADMIN = '1'; + // 动态导入 Bridge 模块 const { startBridge } = await import('../index.js'); diff --git a/src/feishu/client.ts b/src/feishu/client.ts index b561ede..ea3e8c0 100644 --- a/src/feishu/client.ts +++ b/src/feishu/client.ts @@ -30,6 +30,7 @@ class FeishuClient extends EventEmitter { private eventDispatcher: lark.EventDispatcher; private cardActionHandler?: (event: FeishuCardActionEvent) => Promise; private cardUpdateQueue: Map> = new Map(); + private processedMessageIds: Set = new Set(); // 连接状态和心跳检测 private connectionState: ConnectionState = 'disconnected'; @@ -156,7 +157,8 @@ class FeishuClient extends EventEmitter { console.log('[飞书] 正在启动长连接...'); this.connectionState = 'connecting'; - // 注册消息接收事件 + this.eventDispatcher = this.createEventDispatcher(); + this.eventDispatcher.register({ 'im.message.receive_v1': (data) => { this.handleMessage(data as FeishuEventData); @@ -238,11 +240,22 @@ class FeishuClient extends EventEmitter { const message = data.message; const sender = data.sender; - // 忽略机器人自己发的消息 if (sender.sender_type === 'bot') { return; } + const msgId = message.message_id; + if (msgId) { + if (this.processedMessageIds.has(msgId)) { + return; + } + this.processedMessageIds.add(msgId); + if (this.processedMessageIds.size > 500) { + const first = this.processedMessageIds.values().next().value; + if (first) this.processedMessageIds.delete(first); + } + } + const msgType = message.message_type; let content = ''; let parsedContent: Record | null = null; diff --git a/src/lifecycle/main.ts b/src/lifecycle/main.ts index 918987b..296d7f9 100644 --- a/src/lifecycle/main.ts +++ b/src/lifecycle/main.ts @@ -2,6 +2,7 @@ import { VERSION } from '../utils/version.js'; import { initLogger } from '../utils/logger.js'; import { logStore } from '../store/log-store.js'; import { createAdminServer } from '../admin/admin-server.js'; +import { bridgeManager } from '../admin/bridge-manager.js'; import { feishuClient } from '../feishu/client.js'; import { loadAllConfigured, getSenderByPlatform, getCachedAdapter, getConfiguredPlatforms, clearCache } from '../platform/loader.js'; import { opencodeClient, type PermissionRequestEvent } from '../opencode/client.js'; @@ -198,16 +199,9 @@ export async function main( // 已迁移到 process-manager 管理,此处保留钩子供将来扩展 }; - // 监听主进程退出事件 process.on('exit', cleanupChildProcess); - process.on('SIGINT', () => { - cleanupChildProcess(); - process.exit(0); - }); - process.on('SIGTERM', () => { - cleanupChildProcess(); - process.exit(0); - }); + // SIGINT/SIGTERM 由下方 gracefulShutdown 统一处理,不在此重复注册 + // (重复注册会导致 process.exit(0) 跳过异步清理) // 3. 验证配置 try { @@ -241,9 +235,10 @@ export async function main( const adminPort = parseInt(process.env.ADMIN_PORT ?? _cs.get().ADMIN_PORT ?? '4098', 10); const adminServer = createAdminServer({ port: adminPort, - cronManager: undefined, // cronManager 在后面初始化 + cronManager: undefined, startedAt: new Date(), version: VERSION, + bridgeManager, }); adminServer.start(); console.log(`[Admin] 管理面板已启动: http://localhost:${adminPort}`); diff --git a/src/reliability/environment-doctor.ts b/src/reliability/environment-doctor.ts index c21bbb5..223cc32 100644 --- a/src/reliability/environment-doctor.ts +++ b/src/reliability/environment-doctor.ts @@ -1,10 +1,12 @@ import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; +import { resolveOpencodeExecutable } from '../utils/resolve-opencode.js'; const execFileAsync = promisify(execFile); @@ -216,11 +218,21 @@ async function hasCommandInPath(command: string): Promise { timeout: 1200, maxBuffer: 1024 * 1024, }); - return (result.stdout || '').trim().length > 0; + if ((result.stdout || '').trim().length > 0) { + return true; + } } catch (error) { console.error('[EnvironmentDoctor] command probe failed:', error instanceof Error ? error.message : String(error)); - return false; } + // which/where 失败时(如 systemd 环境 PATH 不全),检查常见安装路径 + if (command === 'opencode') { + const { exe } = resolveOpencodeExecutable(); + if (exe !== 'opencode') { + // 找到了绝对路径,说明已安装只是不在当前 PATH 里 + return true; + } + } + return false; } function getInstallHint(command: string, runtimeOs: EnvironmentOs): string { diff --git a/src/reliability/opencode-restart.ts b/src/reliability/opencode-restart.ts index 3475d31..3870d6f 100644 --- a/src/reliability/opencode-restart.ts +++ b/src/reliability/opencode-restart.ts @@ -5,6 +5,7 @@ import { spawn, spawnSync } from 'node:child_process'; import { opencodeConfig, reliabilityConfig } from '../config.js'; import { probeOpenCodeHealth } from './opencode-probe.js'; import { checkOpenCodeSingleInstance, type ProcessGuardResult } from './process-guard.js'; +import { resolveOpencodeExecutable } from '../utils/resolve-opencode.js'; type ProbeHealthFn = (options: { host: string; port: number }) => Promise<{ ok: boolean }>; type CheckSingleInstanceFn = (options: { @@ -198,7 +199,9 @@ async function defaultStartProcess(pidFilePath = './logs/opencode.pid'): Promise // 避免 Node 的 CREATE_NO_WINDOW 导致孙进程 opencode-windows-x64\bin\opencode.exe 弹黑窗。 pid = startOpencodeWindowsHidden(); } else { - const child = spawn('opencode', ['serve'], { + // Linux/macOS: 用路径查找,避免 systemd 等环境 PATH 找不到 opencode + const { exe, args: exeArgs } = resolveOpencodeExecutable(); + const child = spawn(exe, [...exeArgs, 'serve'], { detached: true, stdio: 'ignore', }); diff --git a/src/reliability/process-guard.ts b/src/reliability/process-guard.ts index 9dabca2..97e6e03 100644 --- a/src/reliability/process-guard.ts +++ b/src/reliability/process-guard.ts @@ -81,7 +81,7 @@ export async function checkOpenCodeSingleInstance( ? await options.processListProvider() : await listOpenCodeProcesses(processKeywords); - const keywordMatched = processList.filter(item => matchesProcessKeywords(item, processKeywords)); + const keywordMatched = processList.filter(item => matchesServeProcess(item, processKeywords)); const runningPidSet = new Set(keywordMatched.map(item => item.pid)); if (pidFromFile !== null && pidAlive) { @@ -284,7 +284,22 @@ export async function probeTcpPort( async function listOpenCodeProcesses(processKeywords: string[]): Promise { const snapshot = await listAllProcesses(); - return snapshot.filter(item => matchesProcessKeywords(item, processKeywords)); + return snapshot.filter(item => matchesServeProcess(item, processKeywords)); +} + +function matchesServeProcess(item: OpenCodeProcessInfo, processKeywords: string[]): boolean { + const command = item.command.toLowerCase(); + const hasKeyword = processKeywords.some(keyword => command.includes(keyword.toLowerCase())); + if (!hasKeyword) { + return false; + } + if (/\bserve\b/.test(command)) { + return true; + } + if (process.platform === 'win32' && !command.includes(' ')) { + return true; + } + return false; } function matchesProcessKeywords(item: OpenCodeProcessInfo, processKeywords: string[]): boolean { diff --git a/src/reliability/rescue-orchestrator.ts b/src/reliability/rescue-orchestrator.ts index fb4f959..7721f99 100644 --- a/src/reliability/rescue-orchestrator.ts +++ b/src/reliability/rescue-orchestrator.ts @@ -8,6 +8,7 @@ import { spawn } from 'node:child_process'; import { opencodeConfig, reliabilityConfig } from '../config.js'; import { probeOpenCodeHealth } from './opencode-probe.js'; import { decideRescuePolicy } from './rescue-policy.js'; +import { resolveOpencodeExecutable } from '../utils/resolve-opencode.js'; import { executeRescuePipeline } from './rescue-executor.js'; import { reportRecoveryContext } from './recovery-reporter.js'; import { FailureType, RescueState } from './types.js'; @@ -102,7 +103,8 @@ export const createRescueOrchestrator = ( return new Promise((resolve, reject) => { try { const isWindows = process.platform === 'win32'; - const child = spawn('opencode', [], { + const { exe, args: exeArgs } = resolveOpencodeExecutable(); + const child = spawn(exe, [...exeArgs, 'serve'], { detached: true, stdio: 'ignore', shell: isWindows, diff --git a/src/utils/resolve-opencode.ts b/src/utils/resolve-opencode.ts new file mode 100644 index 0000000..869e9fe --- /dev/null +++ b/src/utils/resolve-opencode.ts @@ -0,0 +1,46 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +/** + * 解析 opencode 可执行文件路径(跨平台) + * + * 优先级: + * 1. OPENCODE_AUTO_START_CMD 环境变量(用户自定义绝对路径) + * 2. 常见安装路径检查 + * 3. 回退到 'opencode'(依赖 PATH) + * + * 与 scripts/process-manager.mjs 的 resolveOpenCodeExecutable 保持一致逻辑。 + */ +export function resolveOpencodeExecutable(): { exe: string; args: string[] } { + // 1. 优先读 OPENCODE_AUTO_START_CMD(用户自定义绝对路径,可能含参数) + const customCmd = process.env.OPENCODE_AUTO_START_CMD?.trim(); + if (customCmd) { + const parts = customCmd.split(/\s+/); + const exePath = parts[0]; + const extraArgs = parts.slice(1); + if (fs.existsSync(exePath)) { + return { exe: exePath, args: extraArgs }; + } + // 路径不存在也返回,让 shell 尝试解析 + return { exe: customCmd, args: [] }; + } + + // 2. 检查常见安装路径 + const homeDir = os.homedir(); + const candidates = [ + path.join(homeDir, '.opencode', 'bin', 'opencode'), // 官方脚本安装 + path.join(homeDir, '.local', 'bin', 'opencode'), // npm i -g (用户级 prefix) + '/usr/local/bin/opencode', // npm i -g (root) + '/usr/bin/opencode', // 包管理器安装 + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return { exe: candidate, args: [] }; + } + } + + // 3. 回退:让 shell 自行解析 + return { exe: 'opencode', args: [] }; +} diff --git a/tests/process-guard.test.ts b/tests/process-guard.test.ts index 2350143..5f186d2 100644 --- a/tests/process-guard.test.ts +++ b/tests/process-guard.test.ts @@ -58,6 +58,47 @@ describe('process-guard', () => { expect(result.conflictPids).toEqual([202]); }); + it('交互式 opencode 与 serve 共存时不应误报 single-instance-violation', async () => { + const pidFilePath = path.join(tempDir, 'bridge.pid'); + await fs.writeFile(pidFilePath, '303', 'utf-8'); + + const result = await checkOpenCodeSingleInstance({ + pidFilePath, + host: '127.0.0.1', + port: 4096, + processAliveChecker: async pid => pid === 303, + processListProvider: async (): Promise => [ + { pid: 303, command: '/home/testuser/.opencode/bin/opencode serve --port 4096' }, + { pid: 404, command: 'opencode' }, + { pid: 505, command: 'opencode debug skill --print-logs' }, + ], + portProbe: async () => ({ isOpen: true, reason: 'connected' }), + }); + + expect(result.status).toBe('ok'); + expect(result.runningPids).toEqual([303]); + expect(result.conflictPids).toEqual([]); + }); + + it('仅有交互式 opencode(无 serve)时应返回 not-running', async () => { + const pidFilePath = path.join(tempDir, 'bridge.pid'); + + const result = await checkOpenCodeSingleInstance({ + pidFilePath, + host: '127.0.0.1', + port: 4096, + processAliveChecker: async () => false, + processListProvider: async (): Promise => [ + { pid: 606, command: 'opencode' }, + { pid: 707, command: 'opencode debug skill' }, + ], + portProbe: async () => ({ isOpen: false, reason: 'ECONNREFUSED' }), + }); + + expect(result.status).toBe('not-running'); + expect(result.runningPids).toEqual([]); + }); + it('并发触发救援锁时第二个请求应返回 lock-busy', async () => { const lockTargetPath = path.join(tempDir, 'rescue-mutex'); diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 0000000..a23eae0 --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + esbuild: true + vue-demi: true