diff --git a/README.md b/README.md index e6e67ad..a282a58 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,29 @@ Codex App 原生协作工具会被转成页面上的子代理状态卡片: - npm - 已安装并登录需要使用的 Agent CLI: +源码方式运行需要 Node.js >= 18。Node.js 版本过低时,可先执行: + +```bash +# 已安装 nvm 时,使用当前 LTS 版本 +nvm install --lts +nvm use --lts +node -v + +# Ubuntu / Debian / WSL,可固定安装 Node.js 22;root 用户可去掉 sudo +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - +sudo apt-get install -y nodejs +node -v + +# RHEL / Rocky / AlmaLinux 8/9,可固定安装 Node.js 22;root 用户可去掉 sudo +curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash - +sudo dnf install -y nodejs || sudo yum install -y nodejs +node -v +``` + +CentOS 7 这类老系统通常停在 `glibc 2.17`,NodeSource Node.js 22 RPM 会要求 +`glibc >= 2.28`,不要在这台机器上硬装。老系统部署请使用下面的 +**Bun single executable** 方式。 + ```bash npm install -g @anthropic-ai/claude-code npm install -g @openai/codex @@ -162,6 +185,44 @@ npm start http://localhost:8002 ``` +### CentOS 7 / 老 glibc + +hapi 能在 CentOS 7 上运行,不是因为它在 CentOS 7 上安装了新版 Node,而是因为 +它在构建机用 `bun build --compile --target=bun-linux-x64-baseline` 产出 baseline +单文件二进制。cc-web 也按这个思路提供发布包构建。 + +在较新的 Linux 构建机或 CI 上执行: + +```bash +npm install +npm run build:single-exe +``` + +默认会生成: + +```text +dist-exe/bun-linux-x64-baseline/ +``` + +把这个目录整体拷贝到 CentOS 7 后直接运行: + +```bash +cd /opt/cc-web +chmod +x cc-web +PORT=8002 CC_WEB_PASSWORD='请改成强密码' ./cc-web +``` + +这个发布包只包含 cc-web 服务本体和前端资源,**不会把 Claude/Codex CLI 打进包里**。 +运行时仍调用宿主机上的 CLI: + +```bash +export CLAUDE_PATH=/usr/local/bin/claude +export CODEX_PATH=/usr/local/bin/codex +PORT=8002 CC_WEB_PASSWORD='请改成强密码' ./cc-web +``` + +如果不设置 `CLAUDE_PATH` / `CODEX_PATH`,默认仍从宿主机 `PATH` 查找 `claude` 和 `codex`。 + ### Windows ```cmd diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js index 67bf190..9f5f122 100644 --- a/lib/agent-runtime.js +++ b/lib/agent-runtime.js @@ -9,7 +9,7 @@ function createAgentRuntime(deps) { getDefaultCodexModel, loadCodexConfig, prepareCodexCustomRuntime, - ccwebMcpServerPath, + ccwebMcpServerArg, internalMcpUrl, internalMcpToken, nodePath, @@ -31,7 +31,7 @@ function createAgentRuntime(deps) { } function createCcwebMcpEnv(session, options = {}) { - if (!ccwebMcpServerPath || !internalMcpUrl || !internalMcpToken || !session?.id) return null; + if (!ccwebMcpServerArg || !internalMcpUrl || !internalMcpToken || !session?.id) return null; const rawHopCount = Number.parseInt(String(options.mcpContext?.hopCount || 0), 10); const hopCount = Number.isFinite(rawHopCount) ? Math.max(0, rawHopCount) : 0; return { @@ -48,7 +48,7 @@ function createAgentRuntime(deps) { args.push( '-c', 'mcp_servers.ccweb.type="stdio"', '-c', `mcp_servers.ccweb.command=${tomlString(nodePath || 'node')}`, - '-c', `mcp_servers.ccweb.args=${tomlStringArray([ccwebMcpServerPath])}`, + '-c', `mcp_servers.ccweb.args=${tomlStringArray([ccwebMcpServerArg])}`, '-c', `mcp_servers.ccweb.env_vars=${tomlStringArray(envVars)}`, '-c', 'mcp_servers.ccweb.startup_timeout_sec=10', '-c', 'mcp_servers.ccweb.tool_timeout_sec=60' diff --git a/lib/ccweb-mcp-server.js b/lib/ccweb-mcp-server.js index 862e46c..f451ce0 100644 --- a/lib/ccweb-mcp-server.js +++ b/lib/ccweb-mcp-server.js @@ -306,9 +306,7 @@ async function handleRequest(message) { } } -module.exports = { TOOLS }; - -if (require.main === module) { +function runStdioServer() { let lineBuffer = ''; process.stdin.setEncoding('utf8'); @@ -334,3 +332,9 @@ if (require.main === module) { process.exit(0); }); } + +module.exports = { TOOLS, runStdioServer }; + +if (require.main === module) { + runStdioServer(); +} diff --git a/lib/codex-app-worker-client.js b/lib/codex-app-worker-client.js index 5c14f72..023df9d 100644 --- a/lib/codex-app-worker-client.js +++ b/lib/codex-app-worker-client.js @@ -1,10 +1,12 @@ 'use strict'; const path = require('path'); -const { fork } = require('child_process'); +const { fork, spawn } = require('child_process'); function createCodexAppWorkerClient(options = {}) { const workerPath = options.workerPath || path.join(__dirname, 'codex-app-worker.js'); + const workerCommand = String(options.workerCommand || '').trim(); + const workerArgs = Array.isArray(options.workerArgs) ? options.workerArgs : []; const onNotification = typeof options.onNotification === 'function' ? options.onNotification : () => {}; const onServerRequest = typeof options.onServerRequest === 'function' ? options.onServerRequest : null; const onExit = typeof options.onExit === 'function' ? options.onExit : () => {}; @@ -40,11 +42,14 @@ function createCodexAppWorkerClient(options = {}) { workerExited = false; configured = false; appServerRunning = false; - worker = fork(workerPath, [], { + const spawnOptions = { cwd: options.cwd || process.cwd(), env: options.env || process.env, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], - }); + }; + worker = workerCommand + ? spawn(workerCommand, workerArgs, spawnOptions) + : fork(workerPath, [], spawnOptions); worker.on('message', (message = {}) => { if (Object.prototype.hasOwnProperty.call(message, 'id')) { diff --git a/package.json b/package.json index c95a526..d89c7ba 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "start": "node server.js", + "build:single-exe": "node scripts/build-single-exe.js", "regression": "node scripts/regression.js" }, "dependencies": { diff --git a/scripts/build-single-exe.js b/scripts/build-single-exe.js new file mode 100644 index 0000000..ee4ce99 --- /dev/null +++ b/scripts/build-single-exe.js @@ -0,0 +1,203 @@ +#!/usr/bin/env node +'use strict'; + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_TARGET = 'bun-linux-x64-baseline'; +const DEFAULT_OUTDIR = 'dist-exe'; +const DEFAULT_NAME = 'cc-web'; + +function parseArgs(argv) { + const options = { + target: DEFAULT_TARGET, + outdir: DEFAULT_OUTDIR, + name: DEFAULT_NAME, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const readValue = (name) => { + const inlinePrefix = `${name}=`; + if (arg.startsWith(inlinePrefix)) return arg.slice(inlinePrefix.length); + if (arg === name && index + 1 < argv.length) { + index += 1; + return argv[index]; + } + return null; + }; + + const target = readValue('--target'); + if (target !== null) { + options.target = target; + continue; + } + + const outdir = readValue('--outdir'); + if (outdir !== null) { + options.outdir = outdir; + continue; + } + + const name = readValue('--name'); + if (name !== null) { + options.name = name; + continue; + } + + if (arg === '-h' || arg === '--help') { + options.help = true; + continue; + } + + throw new Error(`未知参数: ${arg}`); + } + + return options; +} + +function printHelp() { + console.log(`用法: + npm run build:single-exe + node scripts/build-single-exe.js --target bun-linux-x64-baseline --outdir dist-exe --name cc-web + +说明: + 默认 target 是 ${DEFAULT_TARGET},用于兼容 CentOS 7 这类老 glibc Linux x64 系统。 + 该命令只打包 cc-web 服务本体;Claude/Codex CLI 仍在运行时从宿主机 PATH 或 CLAUDE_PATH/CODEX_PATH 调用。`); +} + +function isWindowsTarget(target) { + return /^bun-windows(?:-|$)/.test(String(target || '')); +} + +function resolveBinaryName(target, name) { + return isWindowsTarget(target) && !name.endsWith('.exe') ? `${name}.exe` : name; +} + +function copyFileIfExists(source, destination) { + if (!fs.existsSync(source)) return false; + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(source, destination); + return true; +} + +function copyDirIfExists(source, destination) { + if (!fs.existsSync(source)) return false; + fs.cpSync(source, destination, { recursive: true, force: true }); + return true; +} + +function writeRunningGuide(releaseDir, target, binaryName) { + const runCommand = isWindowsTarget(target) ? binaryName : `./${binaryName}`; + const content = `# cc-web single executable 运行说明 + +这个目录是 Bun single executable 发布包,面向 CentOS 7 / 老 glibc Linux 运行。 + +## 直接运行 + +\`\`\`bash +chmod +x ${binaryName} +PORT=8002 CC_WEB_PASSWORD='请改成强密码' ${runCommand} +\`\`\` + +## 调用宿主机 Claude/Codex CLI + +cc-web 不内置 Claude/Codex CLI。运行时会继续从宿主机 PATH 查找: + +\`\`\`bash +export CLAUDE_PATH=/usr/local/bin/claude +export CODEX_PATH=/usr/local/bin/codex +${runCommand} +\`\`\` + +如果不设置上述变量,默认命令名仍是 \`claude\` 和 \`codex\`。 + +## 目录说明 + +- \`public/\`:前端静态资源 +- \`config/\`:运行时配置,首次启动会写入登录和通知配置 +- \`sessions/\`:会话数据 +- \`logs/\`:运行日志 + +不要把已有生产环境的 \`config/\`、\`sessions/\` 误删或覆盖。 +`; + fs.writeFileSync(path.join(releaseDir, 'RUNNING.md'), content, 'utf8'); +} + +function copyRuntimeAssets(projectRoot, releaseDir, target, binaryName) { + copyDirIfExists(path.join(projectRoot, 'public'), path.join(releaseDir, 'public')); + + for (const file of ['.env.example', 'README.md', 'README.en.md', 'CHANGELOG.md', 'package.json']) { + copyFileIfExists(path.join(projectRoot, file), path.join(releaseDir, file)); + } + + for (const dir of ['.codex/skills', '.codex/prompts', '.agents/skills', '.agents/prompts']) { + copyDirIfExists(path.join(projectRoot, dir), path.join(releaseDir, dir)); + } + + for (const dir of ['config', 'sessions', 'logs']) { + fs.mkdirSync(path.join(releaseDir, dir), { recursive: true }); + } + + writeRunningGuide(releaseDir, target, binaryName); +} + +function runBunBuild(projectRoot, target, outfile) { + const bunCommand = process.versions.bun ? process.execPath : (process.env.BUN_BIN || 'bun'); + const args = [ + 'build', + '--compile', + '--no-compile-autoload-dotenv', + `--target=${target}`, + `--outfile=${outfile}`, + path.join(projectRoot, 'server.js'), + ]; + + console.log(`[build:single-exe] ${bunCommand} ${args.join(' ')}`); + const result = spawnSync(bunCommand, args, { + cwd: projectRoot, + env: process.env, + stdio: 'inherit', + }); + + if (result.error && result.error.code === 'ENOENT') { + throw new Error('未找到 bun。请先在构建机安装 Bun,或通过 BUN_BIN 指定 bun 可执行文件。'); + } + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`Bun 编译失败,退出码: ${result.status}`); + } +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const projectRoot = path.resolve(__dirname, '..'); + const target = options.target || DEFAULT_TARGET; + const binaryName = resolveBinaryName(target, options.name || DEFAULT_NAME); + const outdir = path.resolve(projectRoot, options.outdir || DEFAULT_OUTDIR); + const releaseDir = path.join(outdir, target); + const outfile = path.join(releaseDir, binaryName); + + fs.rmSync(releaseDir, { recursive: true, force: true }); + fs.mkdirSync(releaseDir, { recursive: true }); + + runBunBuild(projectRoot, target, outfile); + if (!isWindowsTarget(target)) fs.chmodSync(outfile, 0o755); + copyRuntimeAssets(projectRoot, releaseDir, target, binaryName); + + console.log(`[build:single-exe] 发布目录: ${releaseDir}`); + console.log(`[build:single-exe] 可执行文件: ${outfile}`); +} + +try { + main(); +} catch (err) { + console.error(`[build:single-exe] ERROR: ${err.message || err}`); + process.exit(1); +} diff --git a/server.js b/server.js index 0b3ee4e..13f59a9 100644 --- a/server.js +++ b/server.js @@ -12,8 +12,37 @@ const { createCodexAppRuntime } = require('./lib/codex-app-runtime'); const { createCodexRolloutStore } = require('./lib/codex-rollouts'); const { TOOLS: CCWEB_MCP_TOOLS } = require('./lib/ccweb-mcp-server'); +if (process.argv.includes('--ccweb-mcp-server')) { + require('./lib/ccweb-mcp-server').runStdioServer(); + return; +} + +if (process.argv.includes('--codex-app-worker')) { + require('./lib/codex-app-worker'); + return; +} + +function resolveAppDir() { + const explicit = String(process.env.CC_WEB_APP_DIR || '').trim(); + if (explicit) return path.resolve(explicit); + + const execDir = process.execPath ? path.dirname(process.execPath) : ''; + if (process.versions?.bun && execDir && fs.existsSync(path.join(execDir, 'public'))) { + return execDir; + } + + return __dirname; +} + +const APP_DIR = resolveAppDir(); +const IS_BUN_SINGLE_EXECUTABLE = !!process.versions?.bun + && process.execPath + && !/^bun(?:\.exe)?$/i.test(path.basename(process.execPath)); +const CCWEB_MCP_SERVER_ARG = '--ccweb-mcp-server'; +const CODEX_APP_WORKER_ARG = '--codex-app-worker'; + // Load .env -const envPath = path.join(__dirname, '.env'); +const envPath = path.join(APP_DIR, '.env'); if (fs.existsSync(envPath)) { for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) { const m = line.match(/^([^#=]+)=(.*)$/); @@ -33,11 +62,10 @@ const PORT = parseInt(process.env.PORT) || 8002; const CLAUDE_PATH = process.env.CLAUDE_PATH || 'claude'; const CODEX_PATH = process.env.CODEX_PATH || 'codex'; const INTERNAL_MCP_TOKEN = process.env.CC_WEB_INTERNAL_MCP_TOKEN || crypto.randomBytes(32).toString('hex'); -const CONFIG_DIR = process.env.CC_WEB_CONFIG_DIR || path.join(__dirname, 'config'); -const SESSIONS_DIR = process.env.CC_WEB_SESSIONS_DIR || path.join(__dirname, 'sessions'); -const PUBLIC_DIR = process.env.CC_WEB_PUBLIC_DIR || path.join(__dirname, 'public'); -const LOGS_DIR = process.env.CC_WEB_LOGS_DIR || path.join(__dirname, 'logs'); -const CCWEB_MCP_SERVER_PATH = path.join(__dirname, 'lib', 'ccweb-mcp-server.js'); +const CONFIG_DIR = process.env.CC_WEB_CONFIG_DIR || path.join(APP_DIR, 'config'); +const SESSIONS_DIR = process.env.CC_WEB_SESSIONS_DIR || path.join(APP_DIR, 'sessions'); +const PUBLIC_DIR = process.env.CC_WEB_PUBLIC_DIR || path.join(APP_DIR, 'public'); +const LOGS_DIR = process.env.CC_WEB_LOGS_DIR || path.join(APP_DIR, 'logs'); const ATTACHMENTS_DIR = path.join(SESSIONS_DIR, '_attachments'); const ATTACHMENT_TTL_MS = 7 * 24 * 60 * 60 * 1000; const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; @@ -1789,8 +1817,8 @@ function composerSkillRoots(options = {}) { if (codexHome) roots.push(path.join(codexHome, 'skills')); const homeDir = normalizeExistingDirPath(process.env.HOME || process.env.USERPROFILE || ''); if (homeDir) roots.push(path.join(homeDir, '.agents', 'skills')); - roots.push(path.join(__dirname, '.codex', 'skills')); - roots.push(path.join(__dirname, '.agents', 'skills')); + roots.push(path.join(APP_DIR, '.codex', 'skills')); + roots.push(path.join(APP_DIR, '.agents', 'skills')); roots.push('/etc/codex/skills'); const seen = new Set(); return roots.filter((root) => { @@ -1899,8 +1927,8 @@ function composerPromptRoots() { const roots = []; const codexHome = getCodexHomeDir(); if (codexHome) roots.push(path.join(codexHome, 'prompts')); - roots.push(path.join(__dirname, '.codex', 'prompts')); - roots.push(path.join(__dirname, '.agents', 'prompts')); + roots.push(path.join(APP_DIR, '.codex', 'prompts')); + roots.push(path.join(APP_DIR, '.agents', 'prompts')); return roots; } @@ -6613,7 +6641,7 @@ const { getDefaultCodexModel, loadCodexConfig, prepareCodexCustomRuntime, - ccwebMcpServerPath: CCWEB_MCP_SERVER_PATH, + ccwebMcpServerArg: CCWEB_MCP_SERVER_ARG, internalMcpUrl: `http://127.0.0.1:${PORT}/api/internal/mcp`, internalMcpToken: INTERNAL_MCP_TOKEN, nodePath: process.execPath, @@ -7577,7 +7605,7 @@ function codexAppTurnPermissionParams(session) { } function codexAppCcwebMcpEnv(session, options = {}) { - if (!session?.id || !INTERNAL_MCP_TOKEN || !CCWEB_MCP_SERVER_PATH) return null; + if (!session?.id || !INTERNAL_MCP_TOKEN) return null; const rawHopCount = Number.parseInt(String(options.mcpContext?.hopCount || 0), 10); const hopCount = Number.isFinite(rawHopCount) ? Math.max(0, rawHopCount) : 0; return { @@ -7594,7 +7622,7 @@ function codexAppThreadConfig(session, options = {}) { return { 'mcp_servers.ccweb': { command: process.execPath, - args: [CCWEB_MCP_SERVER_PATH], + args: [CCWEB_MCP_SERVER_ARG], env: ccwebMcpEnv, startup_timeout_sec: 10, tool_timeout_sec: 60, @@ -7707,12 +7735,17 @@ function getCodexAppClient() { onLog: (level, event, data) => plog(level, event, data), postInitialize: codexAppPostInitialize, }; + if (IS_BUN_SINGLE_EXECUTABLE) { + clientOptions.workerCommand = process.execPath; + clientOptions.workerArgs = [CODEX_APP_WORKER_ARG]; + } codexAppClient = CODEX_APP_WORKER_ENABLED ? createCodexAppWorkerClient(clientOptions) : createCodexAppServerClient(clientOptions); codexAppClientSignature = signature; plog('INFO', 'codex_app_client_created', { worker: CODEX_APP_WORKER_ENABLED, + workerLauncher: IS_BUN_SINGLE_EXECUTABLE ? 'single-executable' : 'source-file', command: path.basename(spec.command || ''), strippedEnvKeys: spec.strippedEnvKeys || [], }); @@ -8059,11 +8092,11 @@ function handleCodexAppAbortSession(sessionId, ws = null) { function handleCheckUpdate(ws) { const localVersion = (() => { try { - const cl = fs.readFileSync(path.join(__dirname, 'CHANGELOG.md'), 'utf8'); + const cl = fs.readFileSync(path.join(APP_DIR, 'CHANGELOG.md'), 'utf8'); const m = cl.match(/##\s*v([\d.]+)/) || cl.match(/\*\*v([\d.]+)\*\*/); if (m) return m[1]; } catch {} - try { return JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')).version || 'unknown'; } catch {} + try { return JSON.parse(fs.readFileSync(path.join(APP_DIR, 'package.json'), 'utf8')).version || 'unknown'; } catch {} return 'unknown'; })(); diff --git a/start.sh b/start.sh index 680552f..a256bcd 100755 --- a/start.sh +++ b/start.sh @@ -23,6 +23,34 @@ warn() { printf '[cc-web pm2] WARNING: %s\n' "$*" >&2 } +node_upgrade_hint() { + cat <<'EOF' +可选处理方式: + # 已安装 nvm 时,使用当前 LTS 版本 + nvm install --lts + nvm use --lts + node -v + + # Ubuntu / Debian / WSL,可固定安装 Node.js 22;root 用户可去掉 sudo + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + sudo apt-get install -y nodejs + node -v + + # RHEL / Rocky / AlmaLinux 8/9,可固定安装 Node.js 22;root 用户可去掉 sudo + curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash - + sudo dnf install -y nodejs || sudo yum install -y nodejs + node -v + + # CentOS 7 / glibc 2.17 不适合安装 NodeSource Node.js 22。 + # 推荐在较新的构建机生成 Bun baseline 单文件发布包,再拷贝到 CentOS 7 直接运行: + npm install + npm run build:single-exe + # 拷贝 dist-exe/bun-linux-x64-baseline/ 到 CentOS 7 后: + cd dist-exe/bun-linux-x64-baseline + PORT=8002 CC_WEB_PASSWORD='请改成强密码' ./cc-web +EOF +} + ensure_command() { local cmd="$1" local hint="$2" @@ -277,13 +305,17 @@ main() { [[ -f "${ENTRY_PATH}" ]] || fail "找不到入口文件: ${ENTRY_PATH}" - ensure_command node "请先安装 Node.js 18 或更高版本。" + ensure_command node "请先安装 Node.js 18 或更高版本。 + +$(node_upgrade_hint)" ensure_command npm "请先安装 npm。" local node_major node_major="$(node -p "Number(process.versions.node.split('.')[0])")" if [[ "${node_major}" -lt 18 ]]; then - fail "Node.js 版本过低,当前为 $(node -v),要求 >= 18。" + fail "Node.js 版本过低,当前为 $(node -v),要求 >= 18。 + +$(node_upgrade_hint)" fi check_runtime_config