#!/usr/bin/env node 'use strict'; // 培训版单文件入口:同一个可执行程序按子命令扮演工作台、监听 Runtime 和好友轮询 Worker。 // 编译后 process.execPath 指向 exe 自身,因此包根、工作区根和前端目录都以 exe 所在目录为准。 const fs = require('fs'); const path = require('path'); const NODE_HOSTS = new Set(['node', 'node.exe', 'bun', 'bun.exe']); function isSourceRun() { return NODE_HOSTS.has(path.basename(process.execPath).toLowerCase()); } function resolveBaseDir() { return isSourceRun() ? path.resolve(__dirname, '..') : path.dirname(process.execPath); } function applyTrainingEnv() { const baseDir = resolveBaseDir(); const compiled = !isSourceRun(); // A compiled delivery must be self-contained. Never inherit a developer // machine's package root, otherwise login persistence can target the wrong // .env.local instead of the directory beside the executable. if (compiled) { process.env.QIWEI_PACKAGE_ROOT = baseDir; process.env.QIWEI_WORKSPACE_ROOT = baseDir; process.env.QIWEI_OUTPUTS_DIR = path.join(baseDir, 'outputs'); process.env.CLAUDE_CODE_WORKDIR = baseDir; } else { process.env.QIWEI_PACKAGE_ROOT ||= baseDir; process.env.QIWEI_WORKSPACE_ROOT ||= baseDir; process.env.QIWEI_OUTPUTS_DIR ||= path.join(baseDir, 'outputs'); process.env.CLAUDE_CODE_WORKDIR ||= baseDir; } process.env.QIWEI_AGENT_CONVERSATION_MODE ||= 'session'; if (compiled) { process.env.QIWEI_RUNTIME_LAUNCHER ||= 'self'; process.env.QIWEI_DASHBOARD_STATIC_DIR ||= path.join(baseDir, 'web'); } const runtimeConfig = path.join(baseDir, 'qiwei.runtime.config.mjs'); if (fs.existsSync(runtimeConfig)) process.env.QIWEI_RUNTIME_CONFIG ||= runtimeConfig; fs.mkdirSync(path.join(baseDir, 'outputs', 'runtime'), { recursive: true }); try { process.chdir(baseDir); } catch {} hydrateEnvFromLocalFiles(); process.env.QIWEI_LOGIN_FLOW_UI ||= 'true'; return { baseDir, compiled }; } // 把 exe 同级的 .env.local 灌进 process.env。页面里保存的 token、现场手填的凭据, // 重启后必须对所有只读 process.env 的模块可见,不能只给 credentials.js 看见。 function hydrateEnvFromLocalFiles() { try { const { readEnvFiles } = require('../mcp/src/core/credentials'); const fileEnv = readEnvFiles(); for (const [key, value] of Object.entries(fileEnv)) { process.env[key] = String(value); } } catch {} } function parsePort(argv) { const index = argv.indexOf('--port'); const raw = index >= 0 ? Number(argv[index + 1]) : Number(process.env.QIWEI_DASHBOARD_PORT || 4320); if (!Number.isInteger(raw) || raw < 1 || raw > 65535) throw new Error('端口无效,请使用 --port 4320 这类有效端口'); return raw; } function openBrowser(url) { const { spawn } = require('child_process'); // 部分 Windows 环境的 PATH 缺少 System32,用 ComSpec 定位 cmd.exe 而不是依赖 PATH 查找。 const comspec = process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe'; const [command, args] = process.platform === 'win32' ? [comspec, ['/d', '/s', '/c', 'start', '', url]] : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]]; try { spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true }).unref(); } catch (error) { process.stderr.write(`自动打开浏览器失败,请手动访问 ${url}(${error.message})\n`); } } function probeWorkbench(port, workspaceId, timeoutMs = 500) { const http = require('http'); return new Promise(resolve => { const request = http.get({ hostname: '127.0.0.1', port, path: '/api/health', headers: { accept: 'application/json' }, }, response => { let body = ''; response.setEncoding('utf8'); response.on('data', chunk => { if (body.length < 64 * 1024) body += chunk; }); response.on('end', () => { try { const payload = JSON.parse(body); resolve( response.statusCode === 200 && payload?.status === 'ok' && Number(payload?.data?.port) === port && payload?.data?.workspaceId === workspaceId ); } catch { resolve(false); } }); }); request.setTimeout(timeoutMs, () => request.destroy()); request.on('error', () => resolve(false)); }); } function canListen(port) { const net = require('net'); return new Promise(resolve => { const server = net.createServer(); server.unref(); server.once('error', () => resolve(false)); server.listen(port, '127.0.0.1', () => { server.close(error => resolve(!error)); }); }); } async function selectWorkbenchPort(requestedPort, allowFallback, workspaceId) { const candidates = allowFallback ? Array.from({ length: Math.min(10, 65536 - requestedPort) }, (_, index) => requestedPort + index) : [requestedPort]; const probes = await Promise.all( candidates.map(async port => ({ port, running: await probeWorkbench(port, workspaceId) })) ); const running = probes.find(item => item.running); if (running) return { port: running.port, reuse: true }; for (const port of candidates) { if (await canListen(port)) return { port, reuse: false }; } const message = allowFallback ? `端口 ${candidates[0]}-${candidates[candidates.length - 1]} 均被占用,请关闭占用程序后重试` : `端口 ${requestedPort} 已被其他程序占用,请关闭占用程序后重试`; throw new Error(message); } function reuseRunningWorkbench(port, argv) { const url = `http://127.0.0.1:${port}/#status`; process.stdout.write(`[培训包] 工作台已在运行,正在打开:${url}\n`); if (!argv.includes('--no-open')) openBrowser(url); } function runtimeStatePath(baseDir) { return path.join(baseDir, 'outputs', 'runtime', 'qiwei-runtime.json'); } function defaultFlowPort(dashboardPort) { // Keep the documented single-instance default (4320 -> 4310). When a second // package is forced onto 4321+, give it a matching flow port so its QR/verify // context cannot be served by an older package still listening on 4310. const offset = Math.max(0, Number(dashboardPort) - 4320); return Math.min(65535, 4310 + offset); } async function selectFlowPort(dashboardPort) { const configured = Number(String(process.env.QIWEI_FLOW_PORT || '').trim()); const requestedPort = Number.isInteger(configured) && configured >= 1 && configured <= 65535 ? configured : defaultFlowPort(dashboardPort); const candidates = Array.from( { length: Math.min(10, 65536 - requestedPort) }, (_, index) => requestedPort + index, ); for (const port of candidates) { if (await canListen(port)) return port; } throw new Error(`登录流程端口 ${candidates[0]}-${candidates[candidates.length - 1]} 均被占用,请关闭旧工作台后重试`); } function isProcessAlive(pid) { const numeric = Number(pid); if (!Number.isInteger(numeric) || numeric <= 0) return false; try { process.kill(numeric, 0); return true; } catch { return false; } } function ensureCallbackRuntime(baseDir) { let state = {}; try { state = JSON.parse(fs.readFileSync(runtimeStatePath(baseDir), 'utf8')); } catch {} if (['starting', 'running'].includes(state.status) && isProcessAlive(state.pid)) { process.stdout.write(`[培训包] 监听 Runtime 已在运行 (pid=${state.pid})\n`); return; } const { spawn } = require('child_process'); const { callbackRuntimeLauncher } = require('../mcp/src/core/runtime-launcher'); const launcher = callbackRuntimeLauncher(['start', '--no-dashboard']); const outFd = fs.openSync(path.join(baseDir, 'outputs', 'runtime', 'runtime.stdout.log'), 'a'); const errFd = fs.openSync(path.join(baseDir, 'outputs', 'runtime', 'runtime.stderr.log'), 'a'); try { const child = spawn(launcher.command, launcher.args, { cwd: baseDir, detached: true, stdio: ['ignore', outFd, errFd], windowsHide: true, env: { ...process.env }, }); child.on('error', error => process.stderr.write(`[培训包] 拉起监听 Runtime 失败:${error.message}\n`)); child.unref(); process.stdout.write(`[培训包] 已拉起监听 Runtime (pid=${child.pid})\n`); } finally { fs.closeSync(outFd); fs.closeSync(errFd); } } async function commandStart(argv, context) { const requestedPort = parsePort(argv); const portConfigured = argv.includes('--port') || Boolean(String(process.env.QIWEI_DASHBOARD_PORT || '').trim()); const { workspaceIdentity } = require('../mcp/src/core/runtime-context'); const workspaceId = workspaceIdentity(context.baseDir); const selected = await selectWorkbenchPort(requestedPort, !portConfigured, workspaceId); const port = selected.port; process.env.QIWEI_DASHBOARD_PORT = String(port); process.env.QIWEI_FLOW_PORT = String(await selectFlowPort(port)); if (selected.reuse) { reuseRunningWorkbench(port, argv); return; } const { startServer } = require('../mcp/src/dashboard/server'); try { await startServer(port); } catch (error) { // Two rapid double-clicks can both probe before the first server starts. // Recheck after EADDRINUSE so the second launch becomes an idempotent open. if (error?.code === 'EADDRINUSE' && await probeWorkbench(port, workspaceId)) { reuseRunningWorkbench(port, argv); return; } if (error?.code === 'EADDRINUSE') { throw new Error(`端口 ${port} 已被其他程序占用,请关闭占用程序后重试`); } throw error; } ensureCallbackRuntime(context.baseDir); // Version check is deliberately asynchronous and best-effort: CDN/network // outages must never delay login, listening, or the dashboard first paint. void require('../mcp/src/core/update-service').checkForUpdate({ root: context.baseDir, timeoutMs: 3500 }).catch(() => {}); const url = `http://127.0.0.1:${port}/#status`; process.stdout.write([ '', `企微培训工作台已启动:${url}`, `登录流程地址:http://127.0.0.1:${process.env.QIWEI_FLOW_PORT}/`, `数据目录:${path.join(context.baseDir, 'outputs')}`, '', '现场检查顺序:填写 Fmode token(若未自动检测到)→ 开通席位 → 企微扫码登录 → 账号在线 → 白名单 → 开启监听。', '关闭本窗口即停止工作台;监听 Runtime 需运行 stop 子命令结束。', '', ].join('\n')); if (!argv.includes('--no-open')) openBrowser(url); } async function commandRuntime(argv) { const { runCli } = await import('../runtime/callback-service/src/index.mjs'); await runCli(argv.length ? argv : ['start']); } async function commandWorker(argv) { const target = argv[0] || 'friend'; if (target !== 'friend') throw new Error(`未知 worker:${target}`); require('../scripts/friend-polling-worker.js'); } async function commandDashboard(argv) { const port = parsePort(argv); process.env.QIWEI_DASHBOARD_PORT = String(port); const { startServer } = require('../mcp/src/dashboard/server'); await startServer(port); process.stdout.write(`企微工作台已启动:http://127.0.0.1:${port}/#agent\n`); } async function commandUpdate(argv, context) { const updates = require('../mcp/src/core/update-service'); const action = argv[0] || 'check'; if (action === 'check') { const result = await updates.checkForUpdate({ root: context.baseDir }); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; } if (action === 'apply') { if (!require('../mcp/src/core/update-service').isCompiledDelivery()) { throw new Error('开发源码模式仅支持版本检查,交付包更新请在 Bun 工作台中执行'); } const checked = await updates.checkForUpdate({ root: context.baseDir }); if (checked.status !== 'update_available') { process.stdout.write(`${JSON.stringify(checked, null, 2)}\n`); return; } const downloaded = await updates.downloadUpdate(checked, { root: context.baseDir }); updates.stopRuntimeBeforeUpdate(context.baseDir); const helper = updates.spawnUpdateHelper(downloaded.archive, context.baseDir); process.stdout.write(`${JSON.stringify({ status: 'update_scheduled', version: downloaded.version, helperPid: helper.pid }, null, 2)}\n`); return; } if (action === 'update-helper') { const result = await updates.runUpdateHelper(argv.slice(1)); process.stdout.write(`${JSON.stringify({ status: 'updated', version: result.version }, null, 2)}\n`); return; } throw new Error(`未知更新命令:${action}(可用:check、apply)`); } async function commandMcp() { // The compiled executable embeds the same MCP server as the source package. // Keep stdio untouched: MCP clients use it as the protocol transport. require('../mcp/src/server.js'); } function runVoiceCommand(executable, args) { const { spawn } = require('child_process'); return new Promise((resolve, reject) => { const child = spawn(executable, args, { windowsHide: true }); let stdout = ''; let stderr = ''; child.stdout.on('data', chunk => { stdout += String(chunk); }); child.stderr.on('data', chunk => { stderr += String(chunk); }); child.on('error', reject); child.on('close', code => { if (code === 0) resolve({ stdout, stderr }); else reject(new Error(`${path.basename(executable)} 退出码 ${code}:${stderr.slice(-500)}`)); }); }); } async function smokeVoiceBinaries(diagnostics) { const os = require('os'); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-voice-binaries-')); const files = { wav: path.join(root, 'sample.wav'), pcm: path.join(root, 'sample.pcm'), silk: path.join(root, 'sample.silk'), decoded: path.join(root, 'decoded.pcm'), }; const bins = diagnostics.binaries; try { await runVoiceCommand(bins.ffmpeg.path, ['-version']); await runVoiceCommand(bins.ffprobe.path, ['-version']); await runVoiceCommand(bins.ffmpeg.path, [ '-y', '-f', 'lavfi', '-i', 'sine=frequency=880:sample_rate=24000:duration=0.25', '-ac', '1', '-ar', '24000', files.wav, ]); const probe = await runVoiceCommand(bins.ffprobe.path, [ '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', files.wav, ]); if (!(Number(probe.stdout.trim()) > 0)) throw new Error('ffprobe 未识别生成的 WAV'); await runVoiceCommand(bins.ffmpeg.path, [ '-y', '-i', files.wav, '-vn', '-ac', '1', '-ar', '24000', '-c:a', 'pcm_s16le', '-f', 's16le', files.pcm, ]); await runVoiceCommand(bins.encoder.path, [files.pcm, files.silk, '-tencent']); await runVoiceCommand(bins.decoder.path, [files.silk, files.decoded, '-Fs_API', '24000']); if (!fs.statSync(files.silk).size || !fs.statSync(files.decoded).size) { throw new Error('SILK 编解码产物为空'); } return { wavBytes: fs.statSync(files.wav).size, silkBytes: fs.statSync(files.silk).size, decodedPcmBytes: fs.statSync(files.decoded).size }; } finally { fs.rmSync(root, { recursive: true, force: true }); } } async function commandVoiceBinaries(argv = []) { const { diagnoseVoiceBinaries } = require('../mcp/src/core/voice-binaries'); const diagnostics = diagnoseVoiceBinaries(); process.stdout.write(`${JSON.stringify(diagnostics, null, 2)}\n`); if (!diagnostics.ready) { process.exitCode = 1; return; } if (argv.includes('--smoke')) { const result = await smokeVoiceBinaries(diagnostics); process.stdout.write(`[ok] voice-binaries smoke: ${JSON.stringify(result)}\n`); } } function usage() { return [ '企微培训工作台(单文件版)', '', '用法:', ' qiwei-workbench 启动工作台与企微监听,并打开浏览器', ' qiwei-workbench --no-open 启动但不自动打开浏览器', ' qiwei-workbench --port 4321 指定端口', ' qiwei-workbench runtime status 查看监听 Runtime 状态', ' qiwei-workbench runtime stop 停止监听 Runtime', ' qiwei-workbench update check 检查 Bun 交付包版本', ' qiwei-workbench update apply 下载并安排更新(保留本地数据)', ' qiwei-workbench dashboard 只启动工作台,不拉起监听', ' qiwei-workbench mcp 启动 qiwei-assistant MCP Server', ' qiwei-workbench voice-binaries --smoke 验证随包语音依赖与 SILK 转码', '', ].join('\n'); } async function main() { const argv = process.argv.slice(2); if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(`${usage()}\n`); return; } const context = applyTrainingEnv(); const [first, ...rest] = argv; switch (first) { case 'runtime': await commandRuntime(rest); return; case 'worker': await commandWorker(rest); return; case 'dashboard': await commandDashboard(rest); return; case 'mcp': await commandMcp(); return; case 'voice-binaries': await commandVoiceBinaries(rest); return; case 'update': await commandUpdate(rest, context); return; case 'update-helper': await require('../mcp/src/core/update-service').runUpdateHelper(argv.slice(1)); return; case 'start': case undefined: await commandStart(rest, context); return; default: if (first.startsWith('--')) { await commandStart(argv, context); return; } throw new Error(`未知命令:${first}\n\n${usage()}`); } } if (require.main === module) { main().catch(error => { process.stderr.write(`启动失败:${error.message}\n`); process.exit(1); }); } module.exports = { canListen, defaultFlowPort, selectFlowPort, selectWorkbenchPort, };