diff --git a/.gitignore b/.gitignore index 99107eee..8c71dd10 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ node_modules/ # Build outputs and generated artifacts dist/ build/ +!qiming-file-server/src/utils/build/ +!qiming-file-server/src/utils/build/** out/ target/ .umi/ diff --git a/development-environment-startup-manual.md b/development-environment-startup-manual.md index 3fc5f8e2..d02905b1 100644 --- a/development-environment-startup-manual.md +++ b/development-environment-startup-manual.md @@ -1819,3 +1819,270 @@ Path = ...\qimingclaw\node_modules\.pnpm\electron@...\electron... ``` 说明已有一份 qimingclaw 开发进程在运行,不要重复启动。需要重启时,先关闭当前 Electron 窗口,再重新执行第 9.5 节启动命令。 + +## 10. macOS 本机启动补充 + +本节用于 macOS 本机开发,默认工作目录为: + +```bash +cd /Users/qiming/qiming-work +``` + +服务器端 Milvus、Elasticsearch、rcoder 的启动方式仍按第 2 章执行;macOS 本机主要差异是命令写法、端口检查方式,以及 Homebrew 安装的外部命令路径。 + +### 10.1 基础工具检查 + +```bash +java -version +node -v +pnpm -v +rustc --version +cargo --version +deno --version +uv --version +``` + +如果没有全局 Maven,但已安装 IntelliJ IDEA,可以使用 IDEA 内置 Maven: + +```bash +MAVEN="/Applications/IntelliJ IDEA.app/Contents/plugins/maven/lib/maven3/bin/mvn" +"$MAVEN" -version +``` + +确认 Homebrew: + +```bash +/opt/homebrew/bin/brew --version +``` + +### 10.2 安装并验证 Tika 外部工具 + +`qiming-backend` 使用 Apache Tika 解析文件时,会在启动阶段探测 `ffmpeg`、`exiftool`、`sox`、`tesseract`。这些不是 Java 依赖,需要系统命令能被后端进程找到。 + +安装: + +```bash +/opt/homebrew/bin/brew install ffmpeg exiftool sox tesseract +``` + +验证: + +```bash +export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" + +command -v ffmpeg +command -v exiftool +command -v sox +command -v tesseract + +ffmpeg -version | sed -n '1p' +exiftool -ver +sox --version +tesseract --version | sed -n '1p' +``` + +后端启动日志里如果看到下面内容,说明 Tika 已经能找到这些命令: + +```text +exit value for ffmpeg: 0 +exit value for exiftool: 0 +exit value for sox: 0 +hasTesseract (path: [tesseract]): true +``` + +如果仍看到 `Cannot run program "ffmpeg"`、`Cannot run program "exiftool"`、`Cannot run program "sox"`、`Cannot run program "tesseract"`,优先检查启动后端的进程 PATH。IDEA 从 Dock 启动时经常拿不到 shell 里的 `/opt/homebrew/bin`,需要在 Run Configuration 的 Environment variables 里加入: + +```text +PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin +``` + +### 10.3 本机确认 MySQL 和 Redis + +当前根手册规划 MySQL 使用 `3308`,Redis 使用 `16379`: + +```bash +nc -vz localhost 3308 +nc -vz localhost 16379 +mysql -h127.0.0.1 -P3308 -uroot -p你的MySQL密码 agent_platform -e "SELECT 1;" +redis-cli -p 16379 -a 你的Redis密码 ping +``` + +正常结果: + +```text +MySQL 查询返回 1 +Redis 返回 PONG +``` + +### 10.4 建立 Milvus SSH 隧道 + +新开一个终端,保持不关闭: + +```bash +ssh -N -L 19530:127.0.0.1:19530 root@服务器IP +``` + +另开终端验证: + +```bash +nc -vz localhost 19530 +``` + +### 10.5 验证服务器 Elasticsearch 和 rcoder + +```bash +curl -f http://服务器IP:19200 +curl "http://服务器IP:19200/_cluster/health?pretty" + +nc -vz 服务器IP 18087 +nc -vz 服务器IP 18088 +``` + +### 10.6 启动 qiming-backend + +如果使用 IDEA 启动,推荐在 `PlatformApiApplication` 的 Run Configuration 里设置 Environment variables: + +```text +PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin +SPRING_PROFILES_ACTIVE=dev +SERVER_PORT=18081 +DB_HOST=localhost +DB_NAME=agent_platform +DB_USERNAME=root +DB_PASSWORD=你的MySQL密码 +REDIS_HOST=localhost +REDIS_PORT=16379 +REDIS_PASSWORD=你的Redis密码 +REDIS_DB=1 +MILVUS_URI=http://服务器IP:19530/ +ES_URL=http://服务器IP:19200 +BUILD_SERVER_URL=http://localhost:16000/api +CODE_EXECUTE_URL=http://localhost:18020/api/run_code_with_log +MCP_PROXY_URL=http://localhost:18020 +AI_AGENT_URL=http://服务器IP:18087 +DOCKER_PROXY_ENABLE=true +DOCKER_PROXY_URL=http://服务器IP:18088 +OUTER_PORT=11076 +``` + +如果用终端启动: + +```bash +cd /Users/qiming/qiming-work/qiming-backend + +export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" +export SPRING_PROFILES_ACTIVE="dev" +export SERVER_PORT="18081" + +export DB_HOST="localhost" +export DB_NAME="agent_platform" +export DB_USERNAME="root" +export DB_PASSWORD="你的MySQL密码" + +export REDIS_HOST="localhost" +export REDIS_PORT="16379" +export REDIS_PASSWORD="你的Redis密码" +export REDIS_DB="1" + +export MILVUS_URI="http://服务器IP:19530/" +export ES_URL="http://服务器IP:19200" +export ES_USERNAME="" +export ES_PASSWORD="" +export ES_API_KEY="" + +export BUILD_SERVER_URL="http://localhost:16000/api" +export CODE_EXECUTE_URL="http://localhost:18020/api/run_code_with_log" +export MCP_PROXY_URL="http://localhost:18020" +export AI_AGENT_URL="http://服务器IP:18087" +export DOCKER_PROXY_ENABLE="true" +export DOCKER_PROXY_URL="http://服务器IP:18088" +export OUTER_PORT="11076" + +MAVEN="/Applications/IntelliJ IDEA.app/Contents/plugins/maven/lib/maven3/bin/mvn" +"$MAVEN" package -Dmaven.test.skip=true -pl app-platform-bootstrap/app-platform-web-bootstrap -am -Pdev +java -jar app-platform-bootstrap/app-platform-web-bootstrap/target/app-platform-web-bootstrap-0.0.1-SNAPSHOT.jar +``` + +验证: + +```bash +nc -vz localhost 18081 +curl -I http://localhost:18081/doc.html +``` + +### 10.7 启动 qiming 前端 + +```bash +cd /Users/qiming/qiming-work/qiming +pnpm install +pnpm dev +``` + +`qiming/.env` 已固定 `PORT=18000`,直接执行 `pnpm dev` 即可。 + +验证: + +```bash +nc -vz localhost 18000 +open http://localhost:18000 +``` + +### 10.8 启动 qiming-file-server + +```bash +cd /Users/qiming/qiming-work/qiming-file-server +npm install +npm run dev +``` + +验证: + +```bash +curl -f http://localhost:16000/health +``` + +### 10.9 启动 mcp-proxy + +```bash +cd /Users/qiming/qiming-work/qiming-mcp-proxy +cargo install --path ./mcp-proxy --force + +export MCP_PROXY_PORT="18020" +mcp-proxy +``` + +验证: + +```bash +curl -f http://localhost:18020/health +``` + +### 10.10 验证 run_code_rmcp + +```bash +cd /Users/qiming/qiming-work/qiming-run_code_rmcp + +run_code_rmcp --show-logs js -c "function handler(){ console.log('js ok'); return 'hello qiming'; }" +run_code_rmcp --show-logs python -c "def handler(args=None): print('python ok'); return 'hello python'" +``` + +正常应看到: + +```text +Result: "hello qiming" +Result: "hello python" +``` + +### 10.11 启动完成后统一检查 + +```bash +nc -vz localhost 18000 +nc -vz localhost 18081 +nc -vz localhost 16000 +nc -vz localhost 18020 +nc -vz localhost 19530 + +curl -I http://localhost:18081/doc.html +curl -f http://localhost:16000/health +curl -f http://localhost:18020/health +``` diff --git a/qiming-backend/app-platform-bootstrap/app-platform-web-bootstrap/src/main/resources/application-dev.yml b/qiming-backend/app-platform-bootstrap/app-platform-web-bootstrap/src/main/resources/application-dev.yml index e38468a4..ae065d1b 100644 --- a/qiming-backend/app-platform-bootstrap/app-platform-web-bootstrap/src/main/resources/application-dev.yml +++ b/qiming-backend/app-platform-bootstrap/app-platform-web-bootstrap/src/main/resources/application-dev.yml @@ -27,10 +27,10 @@ spring: datasource: # 主数据源 (MySQL) master: - url: jdbc:mysql://${DB_HOST:localhost}:3308/${DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false + url: jdbc:mysql://${DB_HOST:localhost}:3306/${DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false driver-class-name: com.mysql.cj.jdbc.Driver username: ${DB_USERNAME:root} - password: ${DB_PASSWORD:root} + password: ${DB_PASSWORD:Byyagz@121} # 以下为 Druid 连接池配置 (如果使用) druid: initial-size: 5 @@ -48,10 +48,10 @@ spring: filters: stat # 启用 stat filter 用于监控 # Doris 数据源 doris: - url: jdbc:mysql://${DORIS_HOST:localhost}:3308/${DORIS_DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false + url: jdbc:mysql://${DORIS_HOST:localhost}:3306/${DORIS_DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false driver-class-name: com.mysql.cj.jdbc.Driver username: ${DORIS_USERNAME:root} - password: ${DORIS_PASSWORD:root} + password: ${DORIS_PASSWORD:Byyagz@121} # Doris 表配置 table: replication-num: 1 # 副本数,生产环境通常为3,开发环境可以为1 @@ -68,7 +68,7 @@ spring: data: redis: host: ${REDIS_HOST:localhost} - port: ${REDIS_PORT:16379} + port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:Byyagz@121} database: ${REDIS_DB:1} lettuce: diff --git a/qiming-backend/app-platform-bootstrap/app-platform-web-bootstrap/src/main/resources/logback-custom.xml b/qiming-backend/app-platform-bootstrap/app-platform-web-bootstrap/src/main/resources/logback-custom.xml index d90c2f57..1e7cad78 100644 --- a/qiming-backend/app-platform-bootstrap/app-platform-web-bootstrap/src/main/resources/logback-custom.xml +++ b/qiming-backend/app-platform-bootstrap/app-platform-web-bootstrap/src/main/resources/logback-custom.xml @@ -48,7 +48,6 @@ - @@ -68,4 +67,4 @@ - \ No newline at end of file + diff --git a/qiming-file-server/src/utils/build/buildProjectUtils.js b/qiming-file-server/src/utils/build/buildProjectUtils.js new file mode 100644 index 00000000..ba974af9 --- /dev/null +++ b/qiming-file-server/src/utils/build/buildProjectUtils.js @@ -0,0 +1,276 @@ +import { exec } from "child_process"; +import path from "path"; +import fs from "fs"; +import { log, logBuild } from "../log/logUtils.js"; +import BuildErrorParser from "../error/buildErrorParser.js"; +import config from "../../appConfig/index.js"; +import { + BusinessError, + SystemError, + FileError, + ResourceError, +} from "../error/errorHandler.js"; +import { installDependencies } from "../buildDependency/dependencyManager.js"; +import { + extractIsolationContext, + resolveProjectPath, +} from "../common/projectPathUtils.js"; + +const distTargetDir = config.DIST_TARGET_DIR; + +// 构建并发控制(无队列) +const buildingProjects = new Set(); // 正在构建中的项目 +let currentBuilds = 0; // 当前并行构建数 + +// 将dist目录拷贝到指定目录 +async function copyBuildOutputToTarget({ + req, + projectPath, + projectId, + outStream, +}) { + try { + const sourceDir = path.join(projectPath, "dist"); + const targetBase = distTargetDir; + const targetDir = path.join(targetBase, projectId, "dist"); + + if (!fs.existsSync(sourceDir)) { + const msg = `dist directory not found: ${sourceDir}`; + log(projectId, "WARN", msg, { projectId }); + outStream && outStream.write(`${msg}\n`); + return; + } + + // 确保目标父目录存在 + if (!fs.existsSync(targetBase)) { + fs.mkdirSync(targetBase, { recursive: true }); + } + + // 先清空旧目录 + if (fs.existsSync(targetDir)) { + await fs.promises.rm(targetDir, { recursive: true, force: true }); + } + + // 复制 dist -> 目标目录 + if (fs.promises.cp) { + await fs.promises.cp(sourceDir, targetDir, { recursive: true }); + } else { + // Node 版本不支持 fs.promises.cp 时的降级方案 + const copyRecursiveSync = (src, dest) => { + const stat = fs.statSync(src); + if (stat.isDirectory()) { + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + for (const entry of fs.readdirSync(src)) { + copyRecursiveSync(path.join(src, entry), path.join(dest, entry)); + } + } else { + fs.copyFileSync(src, dest); + } + }; + copyRecursiveSync(sourceDir, targetDir); + } + + const okMsg = `dist directory copied to: ${targetDir}`; + log(projectId, "INFO", okMsg, { projectId }); + outStream && outStream.write(`${okMsg}\n`); + } catch (err) { + const errMsg = `Failed to copy dist directory: ${err.message}`; + log(projectId, "ERROR", errMsg, { projectId }); + outStream && outStream.write(`${errMsg}\n`); + throw err; + } +} + +//执行build脚本 +function runBuildScript(projectId, projectPath, scriptName, extraArgs = []) { + return new Promise((resolve, reject) => { + const argsPart = + Array.isArray(extraArgs) && extraArgs.length > 0 + ? " -- " + extraArgs.map((s) => String(s)).join(" ") + : ""; + const command = `cd ${projectPath} && pnpm run ${scriptName}${argsPart}`; + logBuild(projectId, "INFO", "Execute command", { command }); + // 同步输出到普通日志也打印一次,便于从统一日志流检索 + try { + log(projectId, "INFO", "Execute build script", { command, cwd: projectPath }); + } catch (_) {} + + exec( + command, + { + env: process.env, // 继承父进程的环境变量,包括 pnpm 配置 + maxBuffer: 10 * 1024 * 1024, // 10MB 缓冲区 + }, + (error, stdout, stderr) => { + if (error) { + logBuild(projectId, "ERROR", "Execution error", { + error: error.message, + stderr, + }); + + // 使用错误解析器提供用户友好的错误信息 + const errorParser = new BuildErrorParser(); + const errorMessage = stderr || error.message; + const userFriendlyMessage = errorParser.parseBuildError( + errorMessage, + projectId + ); + + // 创建包含用户友好信息的构建错误 + const buildError = new SystemError(userFriendlyMessage, { + originalError: error.message, + command: command, + }); + + return reject(buildError); + } + logBuild(projectId, "INFO", "Script execution completed", { stdout }); + resolve(stdout); + }); + }); +} + +/** + * 构建项目 + * @param {Object} req 请求对象 + * @param {string} projectId 项目ID + * @returns {Promise} 构建结果 + */ +async function buildProject(req, projectId) { + const isolationContext = extractIsolationContext(req?.query || {}); + const projectPath = resolveProjectPath(projectId, isolationContext); + const jsonFilePath = path.join(projectPath, "package.json"); + + const exists = fs.existsSync(jsonFilePath); + if (!exists) { + throw new ResourceError("Project missing package.json file", { + projectId, + projectPath, + }); + } + + let jsonContent; + try { + jsonContent = JSON.parse(fs.readFileSync(jsonFilePath, "utf8")); + } catch (error) { + throw new FileError("package.json file format error", { + projectId, + jsonFilePath, + originalError: error.message, + }); + } + + const jsonScripts = jsonContent.scripts; + const buildScript = jsonScripts.build; + if (!buildScript) { + log(projectId, "WARN", "Project missing build script", { + projectId, + requestId: req.requestId, + }); + throw new BusinessError("Project missing build script", { projectId }); + } + + // 项目级互斥:同一项目仅允许一个构建 + if (buildingProjects.has(projectId)) { + throw new BusinessError("This project is being built", { projectId }); + } + + // 全局并发限制 + const max = Number.isFinite(config.MAX_BUILD_CONCURRENCY) + ? config.MAX_BUILD_CONCURRENCY + : 20; + if (currentBuilds >= max) { + throw new BusinessError("Concurrency is full, please try again later", { + currentBuilds, + maxConcurrency: max, + }); + } + + // 直接执行同步构建(空闲) + buildingProjects.add(projectId); + currentBuilds += 1; + try { + // 读取并规范化 basePath(仅对 Vite 有效) + let basePath = ""; + if (req && req.query && typeof req.query.basePath === "string") { + basePath = req.query.basePath; + } + if (basePath) { + if (!basePath.startsWith("/")) basePath = "/" + basePath; + if (!basePath.endsWith("/")) basePath = basePath + "/"; + } + + const buildExtraArgs = []; + // 若脚本包含 vite,则传入 --base + if ( + typeof buildScript === "string" && + buildScript.includes("vite") && + basePath + ) { + buildExtraArgs.push("--base", basePath); + } + + // 若使用 Vite 构建,追加 --debug 以输出调试信息 + if (typeof buildScript === "string" && buildScript.includes("vite")) { + buildExtraArgs.push("--debug"); + } + + // 安装依赖 + log(projectId, "INFO", "Start installing dependencies", { projectId }); + await installDependencies(req, projectId, projectPath); + + // 执行构建:Vite 直接使用 pnpm exec,避免 npm-run 参数分隔影响 + if (typeof buildScript === "string" && buildScript.includes("vite")) { + const viteArgs = ["exec", "vite", "build", ...buildExtraArgs, "--debug"]; + const command = `cd ${projectPath} && pnpm ${viteArgs.join(" ")}`; + logBuild(projectId, "INFO", "Execute command(direct vite)", { command }); + try { + log(projectId, "INFO", "Execute build script(direct vite)", { + command, + cwd: projectPath, + }); + } catch (_) {} + await new Promise((resolve, reject) => { + exec(command, (error, stdout, stderr) => { + if (error) { + logBuild(projectId, "ERROR", "Execution error", { + error: error.message, + stderr, + }); + const errorParser = new BuildErrorParser(); + const errorMessage = stderr || error.message; + const userFriendlyMessage = errorParser.parseBuildError( + errorMessage, + projectId + ); + const buildError = new SystemError(userFriendlyMessage, { + originalError: error.message, + command, + }); + return reject(buildError); + } + logBuild(projectId, "INFO", "Script execution completed", { stdout }); + resolve(stdout); + }); + }); + } else { + log(projectId, "INFO", "Start synchronously executing build script", { projectId }); + await runBuildScript(projectId, projectPath, "build", buildExtraArgs); + } + + // 拷贝 dist + await copyBuildOutputToTarget({ req, projectPath, projectId }); + return { + success: true, + message: "Build completed", + projectId, + }; + } finally { + buildingProjects.delete(projectId); + currentBuilds -= 1; + } +} + +export { buildProject, copyBuildOutputToTarget, runBuildScript }; diff --git a/qiming-file-server/src/utils/build/keepAliveDevUtils.js b/qiming-file-server/src/utils/build/keepAliveDevUtils.js new file mode 100644 index 00000000..0ba9abe5 --- /dev/null +++ b/qiming-file-server/src/utils/build/keepAliveDevUtils.js @@ -0,0 +1,71 @@ +import { isPortListening } from "../buildArg/portUtils.js"; +import { log } from "../log/logUtils.js"; +import { deleteRunningProcess, isProcessRunning } from "./processManager.js"; +import { isProjectAlive } from "../buildJudge/aliveJudgeUtils.js"; +import { restartDevServer } from "./restartDevUtils.js"; +import { startDevServer } from "./startDevUtils.js"; +import { stopDevServer } from "./stopDevUtils.js"; + +/** + * 保持开发服务器活跃 + * @param {Object} req 请求对象 + * @param {string} projectId 项目ID + * @param {number|string} pid 进程ID + * @param {number|string} port 端口号 + * @returns {Promise} 检查结果 + */ +async function keepAliveDevServer(req, projectId, pid, port, basePath) { + const pidNum = Number(pid); + const portNum = Number(port); + + log(projectId, "INFO", "Start checking development server status", { + projectId, + pid: pidNum, + port: portNum, + requestId: req.requestId, + }); + + // 检查项目是否存活 + const projectAlive = await isProjectAlive(projectId, portNum, basePath); + if (projectAlive) { + log(projectId, "INFO", "Development server is alive, returning success", { + projectId, + pid: pidNum, + port: portNum, + requestId: req.requestId, + }); + + return { + success: true, + message: "Development server is alive", + projectId, + pid: pidNum, + port: portNum, + }; + } + + log(projectId, "INFO", "Development server is not alive, restarting", { + projectId, + pid: pidNum, + port: portNum, + requestId: req.requestId, + }); + + deleteRunningProcess(projectId); + + if(pidNum > 0) { + await stopDevServer(req, projectId, pidNum, { + strict: false, + waitForStop: true, + }); + } + + const result = await startDevServer(req, projectId); + return { + ...result, + action: "start", + }; + +} + +export { keepAliveDevServer }; diff --git a/qiming-file-server/src/utils/build/processManager.js b/qiming-file-server/src/utils/build/processManager.js new file mode 100644 index 00000000..0f631b0a --- /dev/null +++ b/qiming-file-server/src/utils/build/processManager.js @@ -0,0 +1,920 @@ +import { spawn } from "child_process"; +import fs from "fs"; +import path from "path"; +import { log, getLogDir, getCSTDateString, getCSTTimestampString } from "../log/logUtils.js"; +import logCacheManager from "../log/logCacheManager.js"; +import { ProcessError, BusinessError, ValidationError } from "../error/errorHandler.js"; +import ERROR_CODES from "../error/errorCodes.js"; +import { sanitizeSensitivePaths } from "../common/sensitiveUtils.js"; +import config from "../../appConfig/index.js"; +import { + waitPortListening, + waitPortFromPid, + waitPortFromLog, + getPidsByPort, +} from "../buildArg/portUtils.js"; +import ExtraArgsUtils from "../buildArg/extraArgsUtils.js"; +import { ensureDevBinariesExecutable } from "../buildPermission/permissionManager.js"; +import { installDependencies } from "../buildDependency/dependencyManager.js"; +import portPool from "../buildArg/portPool.js"; +import { isProjectAlive } from "../buildJudge/aliveJudgeUtils.js"; + +/** + * 从日志文件中提取执行命令后的所有行 + * 支持多种构建工具和框架: + * - 构建工具: vite, webpack, rollup, parcel, esbuild, tsc, ts-node + * - 框架: next, nuxt, astro, svelte, remix + * - 包管理器: pnpm, npm, yarn, bun + * - 脚本执行: node, 自定义脚本 + * + * @param {string} logPath 日志文件路径 + * @param {string} projectId 项目ID + * @param {number} pid 进程ID + * @returns {string} 执行命令后的所有输出(已脱敏) + */ +function extractCommandOutputFromLog(logPath, projectId, pid) { + let commandOutput = "开发服务器启动失败"; + try { + if (fs.existsSync(logPath)) { + const logContent = fs.readFileSync(logPath, "utf8"); + const lines = logContent.split("\n"); + + // 查找执行命令的行,支持多种构建工具和框架 + let commandStartIndex = -1; + + // 第一轮:优先查找明确的命令执行行 + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if ( + // 直接命令格式: > vite, > webpack, > next, > nuxt 等 + line.match( + /^>\s*(vite|webpack|next|nuxt|rollup|parcel|esbuild|tsc|ts-node|astro|svelte|remix)/ + ) || + // 包管理器运行命令: > pnpm run dev, > npm run start 等 + line.match( + /^>\s*(pnpm|npm|yarn|bun)\s+(run\s+)?(dev|start|serve|build|watch|start:dev)/ + ) || + // 直接执行脚本: > node server.js, > node index.js 等 + line.match(/^>\s*node\s+\S+\.(js|ts|mjs)$/) || + // 自定义脚本执行标记 + line.includes("开始执行脚本: dev") || + line.includes("开始执行脚本: start") || + line.includes("开始执行脚本: serve") || + line.includes("开始执行脚本: build") + ) { + commandStartIndex = i; + break; + } + } + + // 第二轮:如果没有找到命令行,查找构建工具输出标记 + if (commandStartIndex === -1) { + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if ( + // 构建工具特定输出标记 + line.includes("VITE") || + line.includes("Webpack") || + line.includes("Next.js") || + line.includes("Nuxt") || + line.includes("Rollup") || + line.includes("Parcel") || + line.includes("Astro") || + line.includes("Svelte") || + line.includes("Remix") || + // 开发服务器启动标记 + line.includes("Local:") || + line.includes("Network:") || + line.includes("ready in") || + line.includes("compiled successfully") || + line.includes("dev server running") || + line.includes("server started") + ) { + commandStartIndex = i; + break; + } + } + } + + // 第三轮:如果仍然没有找到,查找错误输出标记 + if (commandStartIndex === -1) { + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if ( + line.includes("Error") || + line.includes("error") || + line.includes("failed") || + line.includes("Cannot find") || + line.includes("ERR_") || + line.includes("Command failed") || + line.includes("ELIFECYCLE") || + line.includes("npm ERR!") || + line.includes("pnpm ERR!") + ) { + commandStartIndex = i; + break; + } + } + } + + if (commandStartIndex >= 0) { + // 提取命令执行后的所有行(包含错误行本身) + const outputLines = lines.slice(commandStartIndex); + commandOutput = outputLines.join("\n").trim(); + + // 如果输出为空,返回默认信息 + if (!commandOutput) { + commandOutput = "命令已执行,但无输出信息"; + } + } else { + // 如果没有找到命令开始标记,返回所有日志内容 + commandOutput = logContent.trim(); + } + + // 脱敏处理:移除敏感路径信息 + commandOutput = sanitizeSensitivePaths(commandOutput); + + // 如果输出太长,截取最后的部分(保留更多信息) + if (commandOutput.length > 1000) { + commandOutput = commandOutput.substring(commandOutput.length - 1000); + } + } + } catch (readError) { + log(projectId, "WARN", "读取日志文件失败", { + projectId, + pid: pid, + error: readError.message, + }); + } + + return commandOutput; +} + +// 进程注册表:维护运行dev的项目 +// key: projectId +// value: { pid, logPath, startedAt, port} +const runningDevProcesses = new Map(); + +// 项目级启动锁,避免并发重复启动同一项目 +const startingProjects = new Set(); + +/** + * 获取运行中的进程信息 + * @param {string} projectId 项目ID + * @returns {Object|null} 进程信息 + */ +function getRunningProcess(projectId) { + return runningDevProcesses.get(projectId) || null; +} + +/** + * 设置运行中的进程信息 + * @param {string} projectId 项目ID + * @param {Object} processInfo 进程信息 + */ +function setRunningProcess(projectId, processInfo) { + runningDevProcesses.set(projectId, processInfo); +} + +/** + * 删除运行中的进程信息 + * @param {string} projectId 项目ID + */ +function deleteRunningProcess(projectId) { + runningDevProcesses.delete(projectId); +} + +/** + * 检查项目是否正在启动中 + * @param {string} projectId 项目ID + * @returns {boolean} + */ +function isProjectStarting(projectId) { + return startingProjects.has(projectId); +} + +/** + * 添加项目到启动锁 + * @param {string} projectId 项目ID + */ +function addStartingProject(projectId) { + startingProjects.add(projectId); +} + +/** + * 从启动锁中移除项目 + * @param {string} projectId 项目ID + */ +function removeStartingProject(projectId) { + startingProjects.delete(projectId); +} + +/** + * 使用spawn非阻塞启动开发服务器,并把日志写入文件 + * @param {Object} options 启动选项 + * @param {Object} options.req 请求对象 + * @param {string} options.projectId 项目ID + * @param {string} options.projectPath 项目路径 + * @param {string} options.devScript dev脚本内容 + * @param {Object} options.envExtra 额外的环境变量 + * @param {Array} options.extraArgs 额外的参数 + * @returns {Object} { pid, logPath, port } + */ +async function startDev_NonBlocking({ + req, + projectId, + projectPath, + devScript, +}) { + const lowerScript = (devScript || "").toLowerCase(); + const isVite = lowerScript.includes("vite"); + const isNext = lowerScript.includes("next"); + + if (!isVite && !isNext) { + throw new BusinessError("不支持的脚本类型" + devScript + ",请使用vite或next脚本", { + projectId, + code: ERROR_CODES.INVALID_SCRIPT_TYPE, + }); + } + + // 创建日志目录 + const logDir = getLogDir(projectId); + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + // 主日志文件 + const today = getCSTDateString(); // 格式: YYYY-MM-DD (东八区) + const logPath = path.join(logDir, `dev-${today}.log`); + + // 临时日志用于端口解析 + const tempLogPath = path.join( + logDir, + `dev-temp-${Date.now().toString()}.log` + ); + + let outStream, tempOutStream, child; + let streamsClosed = false; + let childExited = false; + let allocatedPort = null; // 跟踪分配的端口,用于失败时释放 + + // 标记缓存是否已失效(避免频繁删除缓存) + let cacheInvalidated = false; + + // 创建安全的写入函数 + const safeWrite = (stream, data, streamName, flush = false) => { + // 分别检查流的状态,而不是使用共享的 streamsClosed + if (!stream || stream.destroyed) { + // 只有在需要时才输出调试信息,避免日志过多 + if (stream && stream.destroyed) { + log(projectId, "DEBUG", `${streamName}流已销毁,跳过写入`, { + destroyed: true, + }); + } + return false; + } + + // 如果全局标记已关闭,也不写入 + if (streamsClosed) { + return false; + } + + try { + // 在data前拼接时间戳 [2025/10/12 11:51:09] (东八区) + const timestamp = getCSTTimestampString(); + const dataWithTimestamp = `[${timestamp}] ` + data; + const result = stream.write(dataWithTimestamp); + + // 如果需要立即刷新(关键日志),使用 cork/uncork 机制强制刷新缓冲区 + if (flush && typeof stream.cork === 'function' && typeof stream.uncork === 'function') { + // cork() 暂停写入,uncork() 立即刷新所有缓冲数据 + // 这里我们先 cork 再 uncork,强制刷新当前缓冲区 + stream.cork(); + // 使用 setImmediate 确保在下一个事件循环刷新 + setImmediate(() => { + try { + if (!stream.destroyed) { + stream.uncork(); + } + } catch (e) { + // 忽略 uncork 错误 + } + }); + } + + // 如果缓存已启用,标记缓存失效(只标记一次) + // 避免频繁删除缓存,让下次读取时自动重新加载 + if (logCacheManager.isEnabled() && !cacheInvalidated) { + logCacheManager.delete(String(projectId)); + cacheInvalidated = true; + } + + return result; + } catch (err) { + log(projectId, "WARN", `${streamName}写入错误`, { + error: err.message, + code: err.code, + streamName: streamName, + }); + // 只有在严重错误时才关闭所有流 + // 临时日志的错误不应该影响主日志 + if (err.code === "ERR_STREAM_WRITE_AFTER_END" || err.code === "EPIPE") { + // 只关闭出错的流,不影响其他流 + try { + if (stream && !stream.destroyed) { + stream.end(); + } + } catch (closeErr) { + log(projectId, "WARN", `关闭${streamName}流时出错`, { error: closeErr.message }); + } + } + return false; + } + }; + + // 安全关闭流的函数 + const safeCloseStreams = () => { + if (streamsClosed) return; + streamsClosed = true; + + try { + if (outStream && !outStream.destroyed) { + outStream.end(); + } + } catch (err) { + log(projectId, "WARN", "关闭主日志流时出错", { error: err.message }); + } + + try { + if (tempOutStream && !tempOutStream.destroyed) { + tempOutStream.end(); + } + } catch (err) { + log(projectId, "WARN", "关闭临时日志流时出错", { error: err.message }); + } + }; + + // 添加流错误处理 + const handleStreamError = (streamName, err) => { + log(projectId, "WARN", `${streamName}流错误`, { error: err.message }); + // 如果是写入错误且子进程已退出,则关闭流 + if ( + (err.code === "ERR_STREAM_WRITE_AFTER_END" || err.code === "EPIPE") && + childExited + ) { + safeCloseStreams(); + } + }; + + try { + // 在启动前确保可执行权限 + try { + await ensureDevBinariesExecutable(projectPath); + } catch (_) {} + + // 创建日志文件写入流 + outStream = fs.createWriteStream(logPath, { flags: "a" }); + tempOutStream = fs.createWriteStream(tempLogPath, { flags: "a" }); + + // 组合为单个子进程内串行执行:先 dev-inject,再 vite-plugin-design-mode + // 使用 set +e 忽略错误,确保两个命令都会执行,无论第一个是否成功 + // 注意:即使预处理命令失败,也不会阻塞后续依赖安装和 dev 启动,仅做“尽力而为”的处理 + const preCmd = "set +e ; pnpm dlx @xagi/dev-inject@latest install --framework ; pnpm dlx @xagi/vite-plugin-design-mode@latest install ; set -e"; + + safeWrite(outStream, preCmd, "Main log"); + safeWrite(tempOutStream, preCmd, "Temp log"); + + // 在安装依赖之前先执行预处理命令(失败不影响后续流程) + await new Promise((resolve) => { + try { + const preProcess = spawn("bash", ["-lc", preCmd], { + cwd: projectPath, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + }); + + preProcess.stdout.on("data", (data) => { + const msg = data.toString(); + safeWrite(outStream, msg, "Main log"); + safeWrite(tempOutStream, msg, "Temp log"); + }); + + preProcess.stderr.on("data", (data) => { + const msg = data.toString(); + safeWrite(outStream, msg, "Main log"); + safeWrite(tempOutStream, msg, "Temp log"); + }); + + preProcess.on("error", (err) => { + const errorMessage = `预处理命令执行出错(忽略并继续后续流程): ${err.message}\n`; + safeWrite(outStream, errorMessage, "Main log", true); + safeWrite(tempOutStream, errorMessage, "Temp log", true); + resolve(); + }); + + preProcess.on("close", (code) => { + if (code !== 0) { + const errorMessage = `Preprocessing command exit code is ${code} (ignore and continue subsequent process)\n`; + safeWrite(outStream, errorMessage, "主日志", true); + safeWrite(tempOutStream, errorMessage, "临时日志", true); + } + resolve(); + }); + } catch (error) { + const errorMessage = `预处理命令启动失败(忽略并继续后续流程): ${error.message}\n`; + safeWrite(outStream, errorMessage, "Main log", true); + safeWrite(tempOutStream, errorMessage, "Temp log", true); + resolve(); + } + }); + + // 安装依赖(日志会同时写入主日志和临时日志) + try { + await installDependencies(req, projectId, projectPath, { + outStream, + tempOutStream, + safeWrite, + }); + } catch (error) { + // 安装失败时记录错误并抛出(safeWrite 会自动添加时间戳)- 立即刷新到磁盘 + const errorMessage = `Dependency installation failed: ${error.message}\n`; + safeWrite(outStream, errorMessage, "Main log", true); // flush = true + safeWrite(tempOutStream, errorMessage, "Temp log", true); // flush = true + throw error; + } + + // 在日志文件开头写入开始执行 + const startMessage = `Start executing script: dev\n`; + safeWrite(outStream, startMessage, "Main log"); + safeWrite(tempOutStream, startMessage, "Temp log"); + + // 构建 dev 命令参数 + const npmArgs = []; + + // 额外参数和环境变量(端口将在内部从端口池获取) + const { extraArgs, envExtra, port } = await ExtraArgsUtils.processExtraArgs({ + devScript, + projectId, + req, + }); + + // 记录分配的端口,用于失败时释放 + allocatedPort = port; + + // 透传额外参数到脚本 + if (extraArgs && extraArgs.length > 0) { + npmArgs.push(...extraArgs); + } + + const escapeArg = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`; + const extraArgsEscaped = (npmArgs && npmArgs.length > 0) + ? npmArgs.map(escapeArg).join(" ") + : ""; + + // 记录参数信息以便调试 + if (extraArgsEscaped) { + log(projectId, "INFO", "Generated extra arguments", { + extraArgs: extraArgs, + npmArgs: npmArgs, + extraArgsEscaped: extraArgsEscaped, + }); + } + + // 检测命令是否需要通过 npx 执行 + // 如果命令是纯命令名(如 vite, next)而不是路径,应该使用 npx + const needsNpx = (script) => { + if (!script || typeof script !== "string") return false; + const trimmed = script.trim(); + // 如果包含路径分隔符(/ 或 \),说明是路径,不需要 npx + if (trimmed.includes("/") || trimmed.includes("\\")) return false; + // 提取第一个词(命令名) + const firstWord = trimmed.split(/\s+/)[0]; + // 如果是常见的构建工具命令名,且不是路径,需要 npx + const commandsNeedingNpx = ["vite", "next", "webpack", "rollup", "parcel", "esbuild", "tsc", "ts-node", "astro", "svelte", "remix", "nuxt"]; + return commandsNeedingNpx.includes(firstWord); + }; + + let fullCommand; + let execCommand = ""; // 用于记录实际执行的命令 + if (isVite) { + // 移除脚本中已存在的 --host / --base(包含等号或跟随值),避免重复冲突 + const sanitizeCliFlags = (script, flagsToRemove) => { + if (!script || typeof script !== "string") return script; + let result = script; + for (const flag of flagsToRemove) { + // 1) 移除 --flag=value 形式 + const eqPattern = new RegExp(`${flag.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}=\\S+`, "g"); + result = result.replace(eqPattern, ""); + // 2) 移除 --flag 形式, 为非以 - 开头的单词(含路径) + const spacedPattern = new RegExp(`${flag.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}\s+([^\s-][^\s]*)`, "g"); + result = result.replace(spacedPattern, ""); + // 3) 移除仅有 --flag(无值) + const barePattern = new RegExp(`${flag.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}\b`, "g"); + result = result.replace(barePattern, ""); + } + // 归一多余空白 + return result.replace(/\s{2,}/g, " ").trim(); + }; + + const cleanedScript = sanitizeCliFlags(devScript, ["--host", "--base"]); + const appended = extraArgsEscaped ? ` ${extraArgsEscaped}` : ""; + // 如果需要 npx,使用 npx 执行命令 + const commandPrefix = needsNpx(cleanedScript) ? "npx" : ""; + execCommand = commandPrefix ? `${commandPrefix} ${cleanedScript}${appended}` : `${cleanedScript}${appended}`; + // 使用 exec 替换 shell 进程,确保 child.pid 直接对应 vite 进程 + fullCommand = `exec ${execCommand}`; + } else if (isNext) { + const appended = extraArgsEscaped ? ` ${extraArgsEscaped}` : ""; + // 如果需要 npx,使用 npx 执行命令 + const commandPrefix = needsNpx(devScript) ? "npx" : ""; + execCommand = commandPrefix ? `${commandPrefix} ${devScript}${appended}` : `${devScript}${appended}`; + fullCommand = `exec ${execCommand}`; + } else { + const extraForNpm = extraArgsEscaped ? ` -- ${extraArgsEscaped}` : ""; + execCommand = `pnpm run dev${extraForNpm}`; + fullCommand = `exec ${execCommand}`; + } + + // 将实际执行的命令写入日志文件 + if (execCommand) { + const commandMessage = `> ${execCommand}\n`; + safeWrite(outStream, commandMessage, "Main log"); + safeWrite(tempOutStream, commandMessage, "Temp log"); + } + + // 打印将要执行的完整命令与上下文,便于排查 + try { + log(projectId, "INFO", "Start child process, sequentially execute preprocessing and start dev:", { + command: fullCommand, + cwd: projectPath, + devScript, + extraArgs, + envExtraKeys: Object.keys(envExtra || {}), + }); + } catch (_) {} + + // 使用 shell 执行,以便在同一子进程中串行执行两步 + child = spawn("sh", ["-c", fullCommand], { + cwd: projectPath, + env: { + PATH: process.env.PATH, // 必需:找到命令 + //HOME: process.env.HOME, // 用户配置目录 + NODE_ENV: 'development', //不能指定production,否则hmr会失效 + ...envExtra, // 项目特定变量 + }, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + // 添加日志流的错误处理 + outStream.on("error", (err) => { + log(projectId, "ERROR", "Main log stream error", { + error: err.message, + code: err.code, + path: logPath, + destroyed: outStream.destroyed, + }); + handleStreamError("Main log", err); + }); + tempOutStream.on("error", (err) => { + log(projectId, "ERROR", "Temp log stream error", { + error: err.message, + code: err.code, + path: tempLogPath, + destroyed: tempOutStream.destroyed, + }); + handleStreamError("Temp log", err); + }); + + // 安全地重定向子进程输出到双日志文件 + if (child.stdout) { + child.stdout.on("data", (data) => { + // 使用安全的写入函数,避免向已关闭的流写入 + const mainWriteOk = safeWrite(outStream, data, "Main log"); + const tempWriteOk = safeWrite(tempOutStream, data, "Temp log"); + + // 如果任一写入失败,记录详细信息(但不影响另一个流) + if (!mainWriteOk || !tempWriteOk) { + log(projectId, "DEBUG", "Log writing status", { + mainWriteOk, + tempWriteOk, + mainDestroyed: outStream ? outStream.destroyed : null, + tempDestroyed: tempOutStream ? tempOutStream.destroyed : null, + streamsClosed, + }); + } + }); + child.stdout.on("error", (err) => { + log(projectId, "WARN", "Child process stdout error", { error: err.message }); + }); + child.stdout.on("end", () => { + // stdout 流结束时,不立即关闭日志流,等待子进程完全退出 + log(projectId, "INFO", "子进程stdout流已结束", { pid: child.pid }); + }); + } + + if (child.stderr) { + child.stderr.on("data", (data) => { + // 使用安全的写入函数,避免向已关闭的流写入 + const mainWriteOk = safeWrite(outStream, data, "Main log"); + const tempWriteOk = safeWrite(tempOutStream, data, "Temp log"); + + // 如果任一写入失败,记录详细信息(但不影响另一个流) + if (!mainWriteOk || !tempWriteOk) { + log(projectId, "DEBUG", "Log writing status(stderr)", { + mainWriteOk, + tempWriteOk, + mainDestroyed: outStream ? outStream.destroyed : null, + tempDestroyed: tempOutStream ? tempOutStream.destroyed : null, + streamsClosed, + }); + } + }); + child.stderr.on("error", (err) => { + log(projectId, "WARN", "Child process stderr error", { error: err.message }); + }); + child.stderr.on("end", () => { + // stderr 流结束时,不立即关闭日志流,等待子进程完全退出 + log(projectId, "INFO", "Child process stderr stream ended", { pid: child.pid }); + }); + } + + // 监听子进程退出事件,安全关闭日志流 + child.on("exit", (code, signal) => { + childExited = true; + log(projectId, "INFO", "Child process exited", { + pid: child.pid, + code, + signal, + }); + // 延迟关闭流,确保所有数据都已写入 + setTimeout(() => { + safeCloseStreams(); + }, 200); + }); + + child.on("error", (err) => { + log(projectId, "WARN", "Child process error", { error: err.message }); + childExited = true; + log(projectId, "ERROR", "Child process startup failed", { + pid: child.pid, + error: err.message, + }); + safeCloseStreams(); + }); + + runningDevProcesses.set(projectId, { + pid: child.pid, + logPath, + startedAt: Date.now(), + }); + + child.unref();// 解除子进程与父进程的关联,子进程可以独立运行 + + // 如果使用了 exec 命令(替换进程),需要等待 exec 完成进程替换 + // exec 会在 preCmd 成功后替换 shell 进程为实际服务进程(如 vite) + // 这种情况下 child.pid 会直接对应服务进程,但仍需稍等确保替换完成 + const usesExec = fullCommand && fullCommand.includes("exec "); + if (usesExec) { + // 等待 200ms 确保 exec 完成进程替换 + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + log(projectId, "INFO", "Using port pool allocated port and current process ID", { + projectId, + pid: child.pid, + port: port + }); + + // 轮询等待项目对外可访问,最多 30s + const basePathFromReq = + (req && req.query && req.query.basePath) || + (req && req.body && req.body.basePath) || + undefined; + const resolvedBasePath = basePathFromReq || "/"; + const maxAliveWaitMs = 30000; + const alivePollIntervalMs = 1000; + const aliveCheckTimeoutPerRequest = 1500; + + let projectAlive = false; + const aliveStartedAt = Date.now(); + // 启动后先等待 1s 再开始轮询,给框架预留初始化时间 + await new Promise((resolve) => setTimeout(resolve, alivePollIntervalMs)); + while (Date.now() - aliveStartedAt < maxAliveWaitMs) { + projectAlive = await isProjectAlive(projectId, port, basePathFromReq, { + timeoutMs: aliveCheckTimeoutPerRequest, + }); + if (projectAlive) { + break; + } + await new Promise((resolve) => setTimeout(resolve, alivePollIntervalMs)); + } + + if (!projectAlive) { + const waitSeconds = Math.round(maxAliveWaitMs / 1000); + log(projectId, "WARN", "Development server is not accessible within the limited time", { + port, + pid: child.pid, + basePath: resolvedBasePath, + waitSeconds, + }); + } else { + log(projectId, "INFO", "Project accessibility verification passed", { + port, + basePath: resolvedBasePath, + elapsedMs: Date.now() - aliveStartedAt, + }); + } + + // 回填端口和实际进程ID + const info = runningDevProcesses.get(projectId); + if (info) { + info.port = port; + info.pid = child.pid; // 进程组pid + runningDevProcesses.set(projectId, info); + } + + log(projectId, "INFO", "Development server startup successfully", { + projectId, + pid: child.pid, + port: port, + }); + + //要返回进程组pid和端口,因为后续需要通过进程组pid来停止进程 + return { pid: child.pid, port: port }; + } catch (error) { + // 启动失败,释放已分配的端口 + if (allocatedPort) { + portPool.release(String(projectId)); + log(projectId, "INFO", "Startup failed, port released", { + port: allocatedPort, + error: error.message + }); + } + // 重新抛出错误 + throw error; + } finally { + // 只有在发生异常或子进程启动失败时才立即清理资源 + // 正常情况下,流会在子进程退出时自动关闭 + if (childExited || !child || !child.pid) { + safeCloseStreams(); + } + } +} + +/** + * 停止指定的进程 + * @param {string} projectId 项目ID + * @param {number} pid 进程ID + * @returns {Promise} 是否成功停止 + */ +async function killProcess(projectId, pid) { + const pidNum = Number(pid); + if (!Number.isFinite(pidNum)) { + return false; + } + + // 首先检查进程是否存在 + if (!isProcessRunning(pidNum)) { + log(projectId, "INFO", "Process does not exist", { pid: pidNum }); + runningDevProcesses.delete(projectId); + return true; + } + + let killed = false; + let killMethod = ""; + + try { + // 优先杀进程组(detached) + process.kill(-pidNum); + killed = true; + killMethod = "Process group"; + log(projectId, "INFO", "通过进程组杀死进程", { pid: pidNum }); + } catch (e) { + if (e && (e.code === "ESRCH" || e.errno === "ESRCH")) { + // 进程组不存在,不能认为已杀死;继续尝试对单个进程发送信号 + killed = false; + killMethod = "Process group(not exist)"; + log(projectId, "INFO", "Process group does not exist, back to try single process kill", { pid: pidNum }); + } else { + log(projectId, "WARN", "Failed to kill process group", { + pid: pidNum, + error: e.message, + }); + } + } + + if (!killed) { + try { + process.kill(pidNum); + killed = true; + killMethod = "单个进程"; + log(projectId, "INFO", "Kill process through single process", { pid: pidNum }); + } catch (e) { + if (e && (e.code === "ESRCH" || e.errno === "ESRCH")) { + killed = true; + killMethod = "Single process(not exist)"; + log(projectId, "INFO", "Process does not exist,视为已停止", { pid: pidNum }); + } else { + log(projectId, "ERROR", "Failed to kill process", { + pid: pidNum, + error: e.message, + }); + } + } + } + + // 验证进程是否真的被杀死了 + if (killed) { + // 等待一小段时间让进程完全退出 + await new Promise((resolve) => setTimeout(resolve, 100)); + + if (!isProcessRunning(pidNum)) { + log(projectId, "INFO", "Process stopped successfully", { + pid: pidNum, + method: killMethod, + }); + runningDevProcesses.delete(projectId); + return true; + } else { + log(projectId, "WARN", "Process still exists, kill may have failed", { + pid: pidNum, + method: killMethod, + }); + return false; + } + } + + return killed; +} + +/** + * 检查进程是否正在运行 + * @param {number} pid 进程ID + * @returns {boolean} + */ +function isProcessRunning(pid) { + try { + process.kill(pid, 0); // 发送信号0检查进程是否存在 + return true; + } catch (err) { + if (err && (err.code === 'EPERM' || err.code === 'EACCES')) { + // 进程存在,但当前用户无权限发送信号 + return true; + } + if (err && err.code === 'ESRCH') { + // 进程不存在 + return false; + } + // 其他错误,保守返回 false,或根据需要上报日志 + return false; + } +} + +/** + * 等待进程停止 + * @param {string} projectId 项目ID + * @param {number} pid 进程ID + * @returns {Promise} { stopped: boolean, attempts: number } + */ +async function waitForProcessStop(projectId, pid) { + let attempts = 0; + const maxAttempts = config.DEV_SERVER_STOP_MAX_ATTEMPTS; + while (isProcessRunning(pid) && attempts < maxAttempts) { + await new Promise((resolve) => + setTimeout(resolve, config.DEV_SERVER_STOP_CHECK_INTERVAL) + ); + attempts++; + } + + const stopped = !isProcessRunning(pid); + return { stopped, attempts }; +} + +/** + * 列出所有运行中的进程 + * @returns {Array} 进程列表 + */ +function listRunningProcesses() { + const list = Array.from(runningDevProcesses.entries()).map(([id, info]) => ({ + projectId: id, + pid: info.pid, + type: info.type, + startedAt: info.startedAt, + port: info.port, + })); + return list; +} + +export { + getRunningProcess, + setRunningProcess, + deleteRunningProcess, + isProjectStarting, + addStartingProject, + removeStartingProject, + startDev_NonBlocking, + killProcess, + isProcessRunning, + waitForProcessStop, + listRunningProcesses, +}; diff --git a/qiming-file-server/src/utils/build/restartDevUtils.js b/qiming-file-server/src/utils/build/restartDevUtils.js new file mode 100644 index 00000000..4681432f --- /dev/null +++ b/qiming-file-server/src/utils/build/restartDevUtils.js @@ -0,0 +1,129 @@ +import path from "path"; +import fs from "fs"; +import { log } from "../log/logUtils.js"; +import { BusinessError, FileError, ResourceError } from "../error/errorHandler.js"; +import { + isProjectStarting, + addStartingProject, + removeStartingProject, + startDev_NonBlocking, +} from "./processManager.js"; +import ERROR_CODES from "../error/errorCodes.js"; +import { stopDevServer } from "./stopDevUtils.js"; +import { removeNodeModules } from "../buildDependency/dependencyManager.js"; +import { createPnpmNpmrc } from "../common/npmrcUtils.js"; +import { + extractIsolationContext, + resolveProjectPath, +} from "../common/projectPathUtils.js"; + +// 重启开发服务器 +async function restartDevServer(req, projectId) { + log(projectId, "INFO", "Start restarting development server", { + projectId, + requestId: req.requestId, + }); + + // 检查项目是否存在 + const isolationContext = extractIsolationContext(req?.query || {}); + const projectPath = resolveProjectPath(projectId, isolationContext); + const jsonFilePath = path.join(projectPath, "package.json"); + const exists = fs.existsSync(jsonFilePath); + if (!exists) { + log(projectId, "WARN", "Project missing package.json file", { + projectId, + requestId: req.requestId, + }); + throw new ResourceError("Project missing package.json file", { + projectId, + projectPath, + }); + } + + let jsonContent; + try { + jsonContent = JSON.parse(fs.readFileSync(jsonFilePath, "utf8")); + } catch (error) { + throw new FileError("package.json file format error", { + projectId, + jsonFilePath, + originalError: error.message, + }); + } + + const jsonScripts = jsonContent.scripts; + const devScript = jsonScripts.dev; + if (!devScript) { + log(projectId, "WARN", "Project missing dev script", { + projectId, + requestId: req.requestId, + }); + throw new BusinessError("Project missing dev script", { projectId }); + } + + // 如果项目正在启动中,等待完成 + // if (isProjectStarting(projectId)) { + // throw new BusinessError("Project is starting, please try again later", { + // projectId, + // code: ERROR_CODES.PROJECT_STARTING, + // }); + // } + + addStartingProject(projectId); + + try { + // 1. 停止现有的开发服务器 + const pidFromQuery = req.query.pid; + + await stopDevServer(req, projectId, pidFromQuery, { + strict: false, // 非严格模式,停止失败不抛出异常 + waitForStop: true, // 等待进程完全停止 + }); + + // 2. 删除node_modules + log(projectId, "INFO", "Start deleting node_modules and lock file", { + projectId, + requestId: req.requestId, + }); + await removeNodeModules(projectPath, projectId); + + // 3. 创建.npmrc文件 + log(projectId, "INFO", "Create .npmrc file", { + projectId, + requestId: req.requestId, + }); + await createPnpmNpmrc(projectPath, projectId); + + // 4. 启动dev服务器(依赖安装会在 startDev_NonBlocking 中执行) + log(projectId, "INFO", "Start starting dev server", { + projectId, + requestId: req.requestId, + }); + + const { pid, port: actualPort } = await startDev_NonBlocking({ + req, + projectId, + projectPath, + devScript, + }); + + log(projectId, "INFO", "Dev server restart completed", { + projectId, + pid, + port: actualPort, + requestId: req.requestId, + }); + + return { + success: true, + message: "Development server restart successfully", + projectId, + pid, + port: actualPort, + }; + } finally { + removeStartingProject(projectId); + } +} + +export { restartDevServer }; diff --git a/qiming-file-server/src/utils/build/startDevUtils.js b/qiming-file-server/src/utils/build/startDevUtils.js new file mode 100644 index 00000000..1bb945db --- /dev/null +++ b/qiming-file-server/src/utils/build/startDevUtils.js @@ -0,0 +1,150 @@ +import path from "path"; +import fs from "fs"; +import { log } from "../log/logUtils.js"; +import { BusinessError, FileError, ResourceError } from "../error/errorHandler.js"; +import ERROR_CODES from "../error/errorCodes.js"; +import { + getRunningProcess, + isProjectStarting, + addStartingProject, + removeStartingProject, + startDev_NonBlocking, +} from "./processManager.js"; +import { removeNodeModules } from "../buildDependency/dependencyManager.js"; +import { + extractIsolationContext, + resolveProjectPath, +} from "../common/projectPathUtils.js"; + +// 启动开发服务器 +async function startDevServer(req, projectId) { + + // if (isProjectStarting(projectId)) { + // throw new BusinessError("该项目正在启动中,请稍后重试", { + // projectId, + // code: ERROR_CODES.PROJECT_STARTING, + // }); + // } + + addStartingProject(projectId); + try { + log(projectId, "INFO", "Start starting development server", { + projectId, + requestId: req.requestId, + }); + const isolationContext = extractIsolationContext(req?.query || {}); + const projectPath = resolveProjectPath(projectId, isolationContext); + const jsonFilePath = path.join(projectPath, "package.json"); + + const exists = fs.existsSync(jsonFilePath); + if (!exists) { + log(projectId, "WARN", "Project missing package.json file", { + projectId, + requestId: req.requestId, + }); + throw new ResourceError("Project missing package.json file", { + projectId, + projectPath, + }); + } + + let jsonContent; + try { + jsonContent = JSON.parse(fs.readFileSync(jsonFilePath, "utf8")); + } catch (error) { + throw new FileError("package.json file format error", { + projectId, + jsonFilePath, + originalError: error.message, + }); + } + + const jsonScripts = jsonContent.scripts; + const devScript = jsonScripts.dev; + if (!devScript) { + log(projectId, "WARN", "Project missing dev script", { + projectId, + requestId: req.requestId, + }); + throw new BusinessError("Project missing dev script, please add dev script in package.json", { projectId }); + } + + // Linux 环境下:检测 libc 类型与已安装 Rollup 原生包是否匹配,若不匹配则清理依赖 + try { + const isLinux = process.platform === "linux"; + if (isLinux) { + const report = typeof process.report?.getReport === "function" ? process.report.getReport() : null; + const glibcVersion = report && report.header && report.header.glibcVersionRuntime; + const isMusl = !glibcVersion; // 没有 glibc 版本通常意味着 musl(如 Alpine) + + const pnpmDir = path.join(projectPath, "node_modules", ".pnpm"); + if (fs.existsSync(pnpmDir)) { + const entries = await fs.promises.readdir(pnpmDir, { withFileTypes: true }); + const hasRollupGnu = entries.some((ent) => ent.isDirectory() && (ent.name || "").includes("@rollup+rollup-linux-x64-gnu")); + const hasRollupMusl = entries.some((ent) => ent.isDirectory() && (ent.name || "").includes("@rollup+rollup-linux-x64-musl")); + + // 在 musl 系统上若装了 gnu 变体,或在 glibc 系统上若装了 musl 变体,则清理 + const mismatch = (isMusl && hasRollupGnu) || (!isMusl && hasRollupMusl); + if (mismatch) { + log(projectId, "WARN", "Detected Rollup native package does not match libc, clean dependencies and reinstall", { + projectId, + isMusl, + glibcVersion: glibcVersion || null, + }); + await removeNodeModules(projectPath, projectId); + } + } + } + } catch (e) { + log(projectId, "WARN", "Linux native package matching detection failed (ignore continue)", { + error: e && e.message, + }); + } + + // 尝试为后续 dev 进程注入回退环境,优先使用 WASM/JS,避免 .node 装载 + try { + process.env.ROLLUP_WASM = process.env.ROLLUP_WASM || "1"; + process.env.ROLLUP_DISABLE_NATIVE = process.env.ROLLUP_DISABLE_NATIVE || "1"; + } catch (_) {} + + // 如果已在运行,则直接返回信息 + // if (getRunningProcess(projectId)) { + // const p = getRunningProcess(projectId); + // log(projectId, "INFO", "项目已在运行,直接返回信息", { + // projectId, + // requestId: req.requestId, + // }); + // return { + // success: true, + // message: "已在运行", + // projectId, + // pid: p.pid, + // port: p.port, + // }; + // } + + log(projectId, "INFO", "Start executing dev script in non-blocking mode", { + projectId, + requestId: req.requestId, + }); + + const { pid, port: actualPort } = await startDev_NonBlocking({ + req, + projectId, + projectPath, + devScript, + }); + return { + success: true, + message: "Development server started", + projectId, + pid, + port: actualPort, + }; + } finally { + // 无论成功、失败都清理锁 + removeStartingProject(projectId); + } +} + +export { startDevServer }; diff --git a/qiming-file-server/src/utils/build/stopDevUtils.js b/qiming-file-server/src/utils/build/stopDevUtils.js new file mode 100644 index 00000000..91de35de --- /dev/null +++ b/qiming-file-server/src/utils/build/stopDevUtils.js @@ -0,0 +1,428 @@ +import { ValidationError, ProcessError } from "../error/errorHandler.js"; +import { + killProcess, + getRunningProcess, + waitForProcessStop, +} from "./processManager.js"; +import { log, getLogDir } from "../log/logUtils.js"; +import logCacheManager from "../log/logCacheManager.js"; +import portPool from "../buildArg/portPool.js"; +import fs from "fs"; +import path from "path"; +import { execSync } from "child_process"; + + + +function findPidsByProjectId(projectId) { + try { + const cmd = "ps -Ao pid,command -ww"; + const output = execSync(cmd, { encoding: "utf8" }); + const lines = output.split("\n"); + const pids = []; + const preciseNeedle = "/" + String(projectId); + + // 匹配包含 /{projectId} 的路径片段,避免误匹配 + for (const line of lines) { + if (!line) continue; + if (line.includes(preciseNeedle)) { + const trimmed = line.trim(); + const firstSpace = trimmed.indexOf(" "); + const pidStr = firstSpace > 0 ? trimmed.slice(0, firstSpace) : trimmed; + const pidNum = Number(pidStr); + if (Number.isFinite(pidNum)) { + pids.push(pidNum); + } + } + } + + // 回退:使用宽松的包含 projectId 匹配(可能带来噪声) + if (pids.length === 0) { + for (const line of lines) { + if (!line) continue; + if (line.includes(String(projectId))) { + const trimmed = line.trim(); + const firstSpace = trimmed.indexOf(" "); + const pidStr = firstSpace > 0 ? trimmed.slice(0, firstSpace) : trimmed; + const pidNum = Number(pidStr); + if (Number.isFinite(pidNum)) { + pids.push(pidNum); + } + } + } + } + + return Array.from(new Set(pids)); + } catch (_) { + return []; + } +} + +/** + * 停止开发服务器 + * @param {Object} req 请求对象 + * @param {string} projectId 项目ID + * @param {number|string} pid 进程ID(可选,如果不提供会从运行表中获取) + * @param {Object} options 选项 + * @param {boolean} options.strict 是否严格模式,严格模式下停止失败会抛出异常 + * @param {boolean} options.waitForStop 是否等待进程完全停止 + * @returns {Promise} 停止结果 + * + * 功能说明: + * 1. 确定要停止的进程ID + * 2. 停止进程 + * 3. 等待进程完全停止(可选) + * 4. 清理项目下的所有临时日志文件(dev-temp-*.log) + */ +async function stopDevServerByPid(req, projectId, pid, options = {}) { + const { strict = true, waitForStop = false } = options; + + log(projectId, "INFO", "Start stopping development server", { + projectId, + pid, + strict, + waitForStop, + requestId: req.requestId, + }); + + let pidToKill = null; + let existingProcess = null; + + // 1. 确定要停止的进程ID + if (pid !== undefined && pid !== null) { + const pidNum = Number(pid); + if (Number.isFinite(pidNum)) { + pidToKill = pidNum; + log(projectId, "INFO", "Stop development server using incoming pid", { + projectId, + pid: pidToKill, + requestId: req.requestId, + }); + } else { + log(projectId, "WARN", "Incoming pid is invalid", { + projectId, + invalidPid: pid, + requestId: req.requestId, + }); + + if (strict) { + throw new ValidationError("Process ID is invalid", { field: "pid", value: pid }); + } + } + } + + // 如果传入的pid无效或未提供,从运行表中获取 + if (!pidToKill) { + existingProcess = getRunningProcess(projectId); + if (existingProcess) { + pidToKill = existingProcess.pid; + log(projectId, "INFO", "Get pid to stop development server from running table", { + projectId, + pid: pidToKill, + requestId: req.requestId, + }); + } else { + log(projectId, "INFO", "No development server process found to stop", { + projectId, + requestId: req.requestId, + }); + + if (strict) { + throw new ProcessError("No running development server process found", { projectId }); + } + + // 即使未找到进程,也尝试释放端口(可能进程已意外退出) + portPool.release(String(projectId)); + log(projectId, "INFO", "Port released (process not running)", { + projectId, + requestId: req.requestId, + }); + + return { + success: true, + message: "No running process found", + projectId, + pid: null, + }; + } + } + + // 2. 停止进程 + const killed = await killProcess(projectId, pidToKill); + + if (killed) { + log(projectId, "INFO", "Development server stopped", { + projectId, + pid: pidToKill, + requestId: req.requestId, + }); + } else { + log(projectId, "WARN", "Failed to stop development server", { + projectId, + pid: pidToKill, + requestId: req.requestId, + }); + + if (strict) { + throw new ProcessError("Failed to stop process", { projectId, pid: pidToKill }); + } + } + + // 3. 等待进程完全停止(可选) + if (waitForStop && killed) { + const { stopped, attempts } = await waitForProcessStop( + projectId, + pidToKill + ); + + if (!stopped) { + log(projectId, "WARN", "Process stop timeout", { + projectId, + pid: pidToKill, + attempts, + requestId: req.requestId, + }); + + if (strict) { + throw new ProcessError("Process stop timeout", { + projectId, + pid: pidToKill, + attempts, + }); + } + } else { + log(projectId, "INFO", "Process confirmed stopped", { + projectId, + pid: pidToKill, + attempts, + requestId: req.requestId, + }); + } + } + + // 4. 清理项目下的所有临时日志文件 + try { + const logDir = getLogDir(projectId); + if (fs.existsSync(logDir)) { + const files = fs.readdirSync(logDir); + const tempFiles = files.filter( + (file) => file.startsWith("dev-temp-") && file.endsWith(".log") + ); + + if (tempFiles.length > 0) { + let deletedCount = 0; + for (const tempFile of tempFiles) { + try { + const tempFilePath = path.join(logDir, tempFile); + fs.unlinkSync(tempFilePath); + deletedCount++; + } catch (err) { + log(projectId, "WARN", "Failed to delete temporary log file", { + projectId, + tempFile, + error: err.message, + requestId: req.requestId, + }); + } + } + + log(projectId, "INFO", "Temporary log file cleanup completed", { + projectId, + deletedCount, + totalTempFiles: tempFiles.length, + requestId: req.requestId, + }); + } + } + } catch (err) { + log(projectId, "WARN", "Error cleaning temporary log files", { + projectId, + error: err.message, + requestId: req.requestId, + }); + } + + // 5. 删除该项目的日志缓存 + try { + if (logCacheManager.isEnabled()) { + logCacheManager.delete(projectId); + log(projectId, "INFO", "Log cache cleaned", { + projectId, + requestId: req.requestId, + }); + } + } catch (err) { + log(projectId, "WARN", "Error cleaning log cache", { + projectId, + error: err.message, + requestId: req.requestId, + }); + } + + // 6. 停止成功后释放端口 + if (killed) { + portPool.release(String(projectId)); + log(projectId, "INFO", "Port released", { + projectId, + requestId: req.requestId, + }); + } + + return { + success: true, + message: killed ? "Stopped" : "Failed to stop but continue execution", + projectId, + pid: pidToKill, + }; +} + +async function stopDevServerByProjectId(req, projectId, options = {}) { + const { strict = true, waitForStop = false } = options; + + log(projectId, "INFO", "Start stopping development server(stop all by projectId)", { + projectId, + strict, + waitForStop, + requestId: req.requestId, + }); + + // 忽略传入的 pid,按 projectId 通过系统进程检索 + const uniquePids = findPidsByProjectId(projectId); + const candidates = uniquePids.map((pid) => ({ pid })); + + if (!candidates || candidates.length === 0) { + log(projectId, "INFO", "No development server process found to stop", { + projectId, + requestId: req.requestId, + }); + + // if (strict) { + // throw new ProcessError("未找到运行中的开发服务器进程", { projectId }); + // } + + // 即使未找到进程,也尝试释放端口(可能进程已意外退出) + portPool.release(String(projectId)); + log(projectId, "INFO", "Port released (process not running)", { + projectId, + requestId: req.requestId, + }); + + return { + success: true, + message: "No running process found", + projectId, + pid: null, + }; + } + + const results = []; + + for (const thePid of uniquePids) { + const killed = await killProcess(projectId, thePid); + results.push({ pid: thePid, killed }); + + if (waitForStop && killed) { + const { stopped, attempts } = await waitForProcessStop(projectId, thePid); + log(projectId, stopped ? "INFO" : "WARN", stopped ? "Process confirmed stopped" : "Process stop timeout", { + projectId, + pid: thePid, + attempts, + requestId: req.requestId, + }); + + if (!stopped && strict) { + throw new ProcessError("Process stop timeout", { + projectId, + pid: thePid, + attempts, + }); + } + } else if (!killed && strict) { + throw new ProcessError("Failed to stop process", { projectId, pid: thePid }); + } + } + + // 清理项目下的所有临时日志文件 + try { + const logDir = getLogDir(projectId); + if (fs.existsSync(logDir)) { + const files = fs.readdirSync(logDir); + const tempFiles = files.filter( + (file) => file.startsWith("dev-temp-") && file.endsWith(".log") + ); + + if (tempFiles.length > 0) { + let deletedCount = 0; + for (const tempFile of tempFiles) { + try { + const tempFilePath = path.join(logDir, tempFile); + fs.unlinkSync(tempFilePath); + deletedCount++; + } catch (err) { + log(projectId, "WARN", "Failed to delete temporary log file", { + projectId, + tempFile, + error: err.message, + requestId: req.requestId, + }); + } + } + + log(projectId, "INFO", "Temporary log file cleanup completed", { + projectId, + deletedCount, + totalTempFiles: tempFiles.length, + requestId: req.requestId, + }); + } + } + } catch (err) { + log(projectId, "WARN", "Error cleaning temporary log files", { + projectId, + error: err.message, + requestId: req.requestId, + }); + } + + // 删除该项目的日志缓存 + try { + if (logCacheManager.isEnabled()) { + logCacheManager.delete(projectId); + log(projectId, "INFO", "Log cache cleaned", { + projectId, + requestId: req.requestId, + }); + } + } catch (err) { + log(projectId, "WARN", "Error cleaning log cache", { + projectId, + error: err.message, + requestId: req.requestId, + }); + } + + const allKilled = results.every((r) => r.killed === true); + + // 停止成功后释放端口 + if (allKilled && results.length > 0) { + portPool.release(String(projectId)); + log(projectId, "INFO", "Port released", { + projectId, + requestId: req.requestId, + }); + } + + return { + success: true, + message: allKilled ? "Stopped" : "Partially stopped but continue execution", + projectId, + pid: null, + killedPids: results, + }; +} + + +async function stopDevServer(req, projectId, pid, options = {}) { + return await stopDevServerByProjectId(req, projectId, options); +} + +export { stopDevServer }; diff --git a/qiming-file-server/src/utils/build/syntaxCheckUtils.js b/qiming-file-server/src/utils/build/syntaxCheckUtils.js new file mode 100644 index 00000000..55c7763a --- /dev/null +++ b/qiming-file-server/src/utils/build/syntaxCheckUtils.js @@ -0,0 +1,847 @@ +import { spawn } from "child_process"; +import fs from "fs"; +import path from "path"; +import { log } from "../log/logUtils.js"; + +/** + * 在启动开发服务器前进行语法检查 + * 根据项目配置自动选择检查方式: + * - TypeScript 项目:运行 tsc --noEmit + * - 纯 JavaScript 项目:使用 esbuild 进行快速语法检查 + * - HTML 文件:使用 html-validate 进行 HTML 语法检查 + * + * @param {string} projectId 项目ID + * @param {string} projectPath 项目路径 + * @param {number} timeoutMs 超时时间(毫秒) + * @returns {Promise} { passed: boolean, error?: string, method?: string } + */ +async function runSyntaxCheck(projectId, projectPath, timeoutMs = 15000) { + try { + // 检查项目类型 + const projectType = detectProjectType(projectPath); + + log(projectId, "INFO", "Start syntax check", { + projectId, + projectType, + timeoutMs, + }); + + const results = []; + + // 1. 代码检查(TypeScript/JavaScript) + if (projectType === "typescript") { + const tsResult = await runTypeScriptCheck(projectId, projectPath, timeoutMs); + results.push(tsResult); + + // 如果 TS 检查失败,立即返回 + if (!tsResult.passed) { + return tsResult; + } + } else if (projectType === "javascript") { + const jsResult = await runJavaScriptCheck(projectId, projectPath, timeoutMs); + results.push(jsResult); + + // 如果 JS 检查失败,立即返回 + if (!jsResult.passed) { + return jsResult; + } + } + + // 2. HTML 文件检查 + const htmlResult = await runHtmlCheck(projectId, projectPath, timeoutMs); + results.push(htmlResult); + + // 如果 HTML 检查失败,返回失败 + if (!htmlResult.passed) { + return htmlResult; + } + + // 所有检查都通过 + if (results.length === 0) { + log(projectId, "INFO", "Skip syntax check: no source code files detected", { + projectId, + }); + return { passed: true, method: "skipped" }; + } + + // 返回综合结果 + const methods = results.map(r => r.method).filter(Boolean).join(", "); + const totalDuration = results.reduce((sum, r) => sum + (r.duration || 0), 0); + + log(projectId, "INFO", "All syntax checks passed", { + projectId, + methods, + totalDuration, + }); + + return { + passed: true, + method: methods, + duration: totalDuration, + }; + } catch (error) { + log(projectId, "WARN", "Syntax check execution failed", { + projectId, + error: error.message, + }); + // 检查失败不阻止启动 + return { passed: true, method: "error", error: error.message }; + } +} + +/** + * 检测项目类型 + * @param {string} projectPath 项目路径 + * @returns {string} "typescript" | "javascript" | "unknown" + */ +function detectProjectType(projectPath) { + // 检查是否有 tsconfig.json + const tsconfigPath = path.join(projectPath, "tsconfig.json"); + if (fs.existsSync(tsconfigPath)) { + // 检查是否有 .ts 或 .tsx 文件 + const hasTsFiles = hasFilesWithExtension(projectPath, [".ts", ".tsx"]); + if (hasTsFiles) { + return "typescript"; + } + } + + // 检查是否有 .js 或 .jsx 文件 + const hasJsFiles = hasFilesWithExtension(projectPath, [".js", ".jsx"]); + if (hasJsFiles) { + return "javascript"; + } + + return "unknown"; +} + +/** + * 检查目录下是否存在指定扩展名的文件 + * @param {string} dir 目录路径 + * @param {Array} extensions 扩展名列表 + * @param {number} maxDepth 最大搜索深度 + * @returns {boolean} + */ +function hasFilesWithExtension(dir, extensions, maxDepth = 3) { + try { + // 排除的目录 + const excludeDirs = ["node_modules", ".git", "dist", "build", ".next", ".nuxt"]; + + function searchDir(currentDir, depth) { + if (depth > maxDepth) return false; + + const entries = fs.readdirSync(currentDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + + if (entry.isDirectory()) { + if (!excludeDirs.includes(entry.name)) { + if (searchDir(fullPath, depth + 1)) { + return true; + } + } + } else if (entry.isFile()) { + const ext = path.extname(entry.name); + if (extensions.includes(ext)) { + return true; + } + } + } + + return false; + } + + return searchDir(dir, 0); + } catch (error) { + return false; + } +} + +/** + * 运行 TypeScript 类型检查 + * @param {string} projectId 项目ID + * @param {string} projectPath 项目路径 + * @param {number} timeoutMs 超时时间 + * @returns {Promise} + */ +async function runTypeScriptCheck(projectId, projectPath, timeoutMs) { + return new Promise((resolve) => { + const startTime = Date.now(); + + const tsconfigPath = path.join(projectPath, "tsconfig.json"); + const checkConfigPath = path.join(projectPath, "tsconfig.check.json"); + let createdCheckConfig = false; + let createdTempTsconfig = false; + + // 创建专门用于语法检查的配置 + const createCheckConfig = (hasTsconfig) => { + const config = { + // 明确指定要检查的所有文件模式 + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.js", + "src/**/*.jsx", + "*.ts", + "*.tsx" + ], + "exclude": ["node_modules", "dist", "build", ".next", ".nuxt", "**/*.spec.ts", "**/*.test.ts"] + }; + + // 如果用户有 tsconfig.json,继承它的 compilerOptions + if (hasTsconfig) { + config.extends = "./tsconfig.json"; + log(projectId, "INFO", "Check configuration will inherit user's tsconfig.json", { + projectId, + }); + } else { + // 如果没有 tsconfig.json,需要提供完整的 compilerOptions + config.compilerOptions = { + "target": "ESNext", + "lib": ["ESNext", "DOM"], + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": false, + "skipLibCheck": true, + "noEmit": true, + "allowJs": true, + "checkJs": false + }; + } + + return config; + }; + + const hasTsconfig = fs.existsSync(tsconfigPath); + + // 始终创建 tsconfig.check.json 用于检查 + try { + const checkConfig = createCheckConfig(hasTsconfig); + fs.writeFileSync(checkConfigPath, JSON.stringify(checkConfig, null, 2), "utf8"); + createdCheckConfig = true; + + log(projectId, "INFO", "Create temporary tsconfig.check.json for syntax check", { + projectId, + hasTsconfig, + extends: checkConfig.extends || "无", + include: checkConfig.include, + }); + } catch (error) { + log(projectId, "WARN", "Failed to create tsconfig.check.json", { + projectId, + error: error.message, + }); + + // 降级方案:如果无法创建检查配置且没有 tsconfig.json,创建临时的 + if (!hasTsconfig) { + try { + fs.writeFileSync(tsconfigPath, JSON.stringify(createCheckConfig(false), null, 2), "utf8"); + createdTempTsconfig = true; + log(projectId, "INFO", "Create temporary tsconfig.json as fallback", { + projectId, + }); + } catch (e) { + log(projectId, "ERROR", "Failed to create any TypeScript configuration file", { + projectId, + error: e.message, + }); + } + } + } + + // 优先使用项目本地的 tsc,如果不存在则使用 npx + const localTscPath = path.join(projectPath, "node_modules", ".bin", "tsc"); + const usesLocalTsc = fs.existsSync(localTscPath); + + const command = usesLocalTsc ? localTscPath : "npx"; + // 使用 --project 参数指定检查配置文件 + const configToUse = createdCheckConfig ? "tsconfig.check.json" : "tsconfig.json"; + const args = usesLocalTsc + ? ["--project", configToUse, "--noEmit", "--skipLibCheck"] + : ["--yes", "tsc", "--project", configToUse, "--noEmit", "--skipLibCheck"]; + + log(projectId, "INFO", "Run TypeScript type check", { + projectId, + command, + args: args.join(" "), + usesLocalTsc, + configFile: configToUse, + }); + + const child = spawn(command, args, { + cwd: projectPath, + shell: true, + timeout: timeoutMs, + }); + + let stdout = ""; + let stderr = ""; + + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + child.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + + // 清理临时文件的辅助函数 + const cleanupTempConfig = () => { + // 清理检查配置文件 + if (createdCheckConfig) { + try { + if (fs.existsSync(checkConfigPath)) { + fs.unlinkSync(checkConfigPath); + log(projectId, "INFO", "Cleaned temporary tsconfig.check.json", { + projectId, + }); + } + } catch (error) { + log(projectId, "WARN", "Failed to clean tsconfig.check.json", { + projectId, + error: error.message, + }); + } + } + + // 清理临时创建的 tsconfig.json(降级方案) + if (createdTempTsconfig) { + try { + if (fs.existsSync(tsconfigPath)) { + fs.unlinkSync(tsconfigPath); + log(projectId, "INFO", "Cleaned temporary tsconfig.json", { + projectId, + }); + } + } catch (error) { + log(projectId, "WARN", "Failed to clean tsconfig.json", { + projectId, + error: error.message, + }); + } + } + }; + + child.on("close", (code) => { + const duration = Date.now() - startTime; + + // 清理临时配置 + cleanupTempConfig(); + + if (code === 0) { + log(projectId, "INFO", "TypeScript type check passed", { + projectId, + duration, + }); + resolve({ passed: true, method: "typescript", duration }); + } else { + // 提取错误信息 + const errorOutput = stderr || stdout; + const errorSummary = extractTypeScriptErrors(errorOutput); + + log(projectId, "ERROR", "TypeScript type check failed", { + projectId, + code, + duration, + errorSummary, + }); + + resolve({ + passed: false, + method: "typescript", + error: errorSummary, + fullOutput: errorOutput.substring(0, 2000), // 限制长度 + duration, + }); + } + }); + + child.on("error", (error) => { + // 清理临时配置 + cleanupTempConfig(); + + log(projectId, "WARN", "TypeScript check execution failed", { + projectId, + error: error.message, + }); + // 执行失败不阻止启动 + resolve({ passed: true, method: "typescript-error", error: error.message }); + }); + + // 超时处理 + setTimeout(() => { + try { + child.kill(); + // 清理临时配置 + cleanupTempConfig(); + + log(projectId, "WARN", "TypeScript check timeout", { + projectId, + timeoutMs, + }); + resolve({ passed: true, method: "typescript-timeout" }); + } catch (e) { + cleanupTempConfig(); + resolve({ passed: true, method: "typescript-timeout-error" }); + } + }, timeoutMs); + }); +} + +/** + * 运行 JavaScript 语法检查(使用 esbuild) + * @param {string} projectId 项目ID + * @param {string} projectPath 项目路径 + * @param {number} timeoutMs 超时时间 + * @returns {Promise} + */ +async function runJavaScriptCheck(projectId, projectPath, timeoutMs) { + return new Promise((resolve) => { + const startTime = Date.now(); + + // 查找入口文件 + const entryFiles = findEntryFiles(projectPath); + + if (entryFiles.length === 0) { + log(projectId, "INFO", "Skip JavaScript syntax check: no entry files found", { + projectId, + }); + resolve({ passed: true, method: "javascript-skipped" }); + return; + } + + // 使用 esbuild 进行快速语法检查(不输出文件) + const args = [ + "--yes", + "esbuild", + ...entryFiles, + "--bundle", + "--write=false", + "--outdir=/tmp", + "--format=esm", + ]; + + log(projectId, "INFO", "Run JavaScript syntax check", { + projectId, + entryFiles, + }); + + const child = spawn("npx", args, { + cwd: projectPath, + shell: true, + timeout: timeoutMs, + }); + + let stdout = ""; + let stderr = ""; + + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + child.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + + child.on("close", (code) => { + const duration = Date.now() - startTime; + + if (code === 0) { + log(projectId, "INFO", "JavaScript syntax check passed", { + projectId, + duration, + }); + resolve({ passed: true, method: "javascript", duration }); + } else { + const errorOutput = stderr || stdout; + const errorSummary = errorOutput.substring(0, 1000); + + log(projectId, "ERROR", "JavaScript syntax check failed", { + projectId, + code, + duration, + errorSummary, + }); + + resolve({ + passed: false, + method: "javascript", + error: errorSummary, + fullOutput: errorOutput.substring(0, 2000), + duration, + }); + } + }); + + child.on("error", (error) => { + log(projectId, "WARN", "JavaScript check execution failed", { + projectId, + error: error.message, + }); + resolve({ passed: true, method: "javascript-error", error: error.message }); + }); + + // 超时处理 + setTimeout(() => { + try { + child.kill(); + log(projectId, "WARN", "JavaScript check timeout", { + projectId, + timeoutMs, + }); + resolve({ passed: true, method: "javascript-timeout" }); + } catch (e) { + resolve({ passed: true, method: "javascript-timeout-error" }); + } + }, timeoutMs); + }); +} + +/** + * 查找入口文件 + * @param {string} projectPath 项目路径 + * @returns {Array} 入口文件路径列表 + */ +function findEntryFiles(projectPath) { + const possibleEntries = [ + "src/main.js", + "src/main.jsx", + "src/main.ts", + "src/main.tsx", + "src/index.js", + "src/index.jsx", + "src/index.ts", + "src/index.tsx", + "src/app.js", + "src/app.jsx", + "src/App.js", + "src/App.jsx", + "index.js", + "index.jsx", + "index.ts", + "index.tsx", + ]; + + const entries = []; + for (const entry of possibleEntries) { + const fullPath = path.join(projectPath, entry); + if (fs.existsSync(fullPath)) { + entries.push(entry); + } + } + + return entries; +} + +/** + * 确保项目有 HTML 验证配置文件(如果不存在则创建宽松配置) + * @param {string} projectPath 项目路径 + * @returns {boolean} 是否创建了新配置 + */ +function ensureHtmlValidateConfig(projectPath) { + const configPath = path.join(projectPath, ".htmlvalidate.json"); + + // 如果已经存在配置文件,不覆盖 + if (fs.existsSync(configPath)) { + return false; + } + + // 创建宽松的默认配置 + const defaultConfig = { + "extends": ["html-validate:recommended"], + "rules": { + // 允许 void elements 不自闭合(HTML5 标准) + "void-style": "off", + // 允许不需要 SRI + "require-sri": "off", + // 允许尾部空白 + "no-trailing-whitespace": "off", + // 允许内联样式(开发时常用) + "no-inline-style": "off", + // 允许重复的 ID(某些框架会动态处理) + "no-dup-id": "warn", + // 允许未使用的 disable 指令 + "no-unused-disable": "off" + } + }; + + try { + fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2), "utf8"); + return true; + } catch (error) { + // 创建失败不影响检查,使用默认规则 + return false; + } +} + +/** + * 运行 HTML 语法检查 + * @param {string} projectId 项目ID + * @param {string} projectPath 项目路径 + * @param {number} timeoutMs 超时时间 + * @returns {Promise} + */ +async function runHtmlCheck(projectId, projectPath, timeoutMs) { + return new Promise((resolve) => { + const startTime = Date.now(); + + // 查找 HTML 文件 + const htmlFiles = findHtmlFiles(projectPath); + + if (htmlFiles.length === 0) { + log(projectId, "INFO", "Skip HTML syntax check: no HTML files found", { + projectId, + }); + resolve({ passed: true, method: "html-skipped" }); + return; + } + + // 确保有宽松的验证配置 + const configCreated = ensureHtmlValidateConfig(projectPath); + if (configCreated) { + log(projectId, "INFO", "Automatically create宽松的 HTML 验证配置", { + projectId, + configPath: ".htmlvalidate.json", + }); + } + + // 使用 html-validate 进行 HTML 语法检查 + const args = [ + "--yes", + "html-validate", + ...htmlFiles, + "--formatter=text", + ]; + + log(projectId, "INFO", "Run HTML syntax check", { + projectId, + htmlFiles, + fileCount: htmlFiles.length, + }); + + const child = spawn("npx", args, { + cwd: projectPath, + shell: true, + timeout: timeoutMs, + }); + + let stdout = ""; + let stderr = ""; + + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + child.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + + child.on("close", (code) => { + const duration = Date.now() - startTime; + + if (code === 0) { + log(projectId, "INFO", "HTML syntax check passed", { + projectId, + duration, + fileCount: htmlFiles.length, + }); + resolve({ passed: true, method: "html", duration }); + } else { + const errorOutput = stdout || stderr; + const errorSummary = extractHtmlErrors(errorOutput); + + log(projectId, "ERROR", "HTML syntax check failed", { + projectId, + code, + duration, + errorSummary, + }); + + resolve({ + passed: false, + method: "html", + error: errorSummary, + fullOutput: errorOutput.substring(0, 2000), + duration, + }); + } + }); + + child.on("error", (error) => { + log(projectId, "WARN", "HTML check execution failed", { + projectId, + error: error.message, + }); + // 执行失败不阻止启动 + resolve({ passed: true, method: "html-error", error: error.message }); + }); + + // 超时处理 + setTimeout(() => { + try { + child.kill(); + log(projectId, "WARN", "HTML check timeout", { + projectId, + timeoutMs, + }); + resolve({ passed: true, method: "html-timeout" }); + } catch (e) { + resolve({ passed: true, method: "html-timeout-error" }); + } + }, timeoutMs); + }); +} + +/** + * 查找 HTML 文件 + * @param {string} projectPath 项目路径 + * @returns {Array} HTML 文件相对路径列表 + */ +function findHtmlFiles(projectPath) { + const htmlFiles = []; + + // 常见的 HTML 文件位置 + const possibleLocations = [ + "index.html", + "public/index.html", + "src/index.html", + "dist/index.html", + ]; + + // 检查常见位置 + for (const location of possibleLocations) { + const fullPath = path.join(projectPath, location); + if (fs.existsSync(fullPath)) { + htmlFiles.push(location); + } + } + + // 如果没找到,递归搜索 public 和 src 目录 + if (htmlFiles.length === 0) { + const dirsToSearch = ["public", "src", "."]; + + for (const dir of dirsToSearch) { + const dirPath = path.join(projectPath, dir); + if (fs.existsSync(dirPath)) { + const found = searchHtmlFilesInDir(dirPath, projectPath, 2); + htmlFiles.push(...found); + } + } + } + + // 去重 + return [...new Set(htmlFiles)]; +} + +/** + * 递归搜索目录中的 HTML 文件 + * @param {string} dir 要搜索的目录 + * @param {string} basePath 基础路径(用于生成相对路径) + * @param {number} maxDepth 最大搜索深度 + * @returns {Array} HTML 文件相对路径列表 + */ +function searchHtmlFilesInDir(dir, basePath, maxDepth = 2) { + const htmlFiles = []; + const excludeDirs = ["node_modules", ".git", "dist", "build", ".next", ".nuxt"]; + + function search(currentDir, depth) { + if (depth > maxDepth) return; + + try { + const entries = fs.readdirSync(currentDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + + if (entry.isDirectory()) { + if (!excludeDirs.includes(entry.name)) { + search(fullPath, depth + 1); + } + } else if (entry.isFile() && entry.name.endsWith(".html")) { + // 生成相对路径 + const relativePath = path.relative(basePath, fullPath); + htmlFiles.push(relativePath); + } + } + } catch (error) { + // 忽略读取错误 + } + } + + search(dir, 0); + return htmlFiles; +} + +/** + * 从 HTML 错误输出中提取关键错误信息 + * @param {string} output 错误输出 + * @returns {string} 错误摘要 + */ +function extractHtmlErrors(output) { + if (!output) return "Unknown error"; + + const lines = output.split("\n"); + const errorLines = []; + + for (const line of lines) { + const trimmed = line.trim(); + + // 提取 html-validate 的错误行 + // 格式: "error: ..." 或 "✖ ..." 或包含文件路径和行号 + if ( + trimmed.includes("error:") || + trimmed.includes("✖") || + /\.html:\d+:\d+/.test(trimmed) || + trimmed.includes("Element") || + trimmed.includes("Attribute") + ) { + errorLines.push(trimmed); + if (errorLines.length >= 10) break; // 保留前10个错误 + } + } + + if (errorLines.length > 0) { + return errorLines.join("\n"); + } + + // 如果没有找到特定格式的错误,返回前几行 + return lines.slice(0, 15).join("\n").trim().substring(0, 800); +} + +/** + * 从 TypeScript 错误输出中提取关键错误信息 + * @param {string} output 错误输出 + * @returns {string} 错误摘要 + */ +function extractTypeScriptErrors(output) { + if (!output) return "Unknown error"; + + const lines = output.split("\n"); + const errorLines = []; + + for (const line of lines) { + // 提取错误行(包含文件路径和错误信息) + if (line.includes("error TS") || line.includes("): error")) { + errorLines.push(line.trim()); + if (errorLines.length >= 5) break; // 只保留前5个错误 + } + } + + if (errorLines.length > 0) { + return errorLines.join("\n"); + } + + // 如果没有找到特定格式的错误,返回前几行 + return lines.slice(0, 10).join("\n").trim().substring(0, 500); +} + +module.exports = { + runSyntaxCheck, + detectProjectType, + findHtmlFiles, + ensureHtmlValidateConfig, +}; + diff --git a/qiming/src/examples/README.md b/qiming/src/examples/README.md index 6297cb74..d8379b1c 100644 --- a/qiming/src/examples/README.md +++ b/qiming/src/examples/README.md @@ -95,7 +95,7 @@ export default ExamplePage; ## 访问方式 -1. 直接访问:`http://localhost:8000/examples` +1. 直接访问:`http://localhost:18000/examples` 2. 从示例索引页面选择具体示例 3. 支持直接访问具体示例页面 diff --git a/qimingclaw/crates/agent-electron-client/resources/lanproxy/binaries/qiming-lanproxy-aarch64-apple-darwin b/qimingclaw/crates/agent-electron-client/resources/lanproxy/binaries/qiming-lanproxy-aarch64-apple-darwin old mode 100644 new mode 100755 diff --git a/qimingclaw/crates/agent-electron-client/scripts/prepare/prepare-qimingcode.js b/qimingclaw/crates/agent-electron-client/scripts/prepare/prepare-qimingcode.js index 1df7f681..53837da3 100644 --- a/qimingclaw/crates/agent-electron-client/scripts/prepare/prepare-qimingcode.js +++ b/qimingclaw/crates/agent-electron-client/scripts/prepare/prepare-qimingcode.js @@ -2,11 +2,12 @@ /** * qimingcode 多平台集成:准备 resources/qimingcode/{platform}/bin/ * - * 两种模式: + * 三种模式: * 1) 本地 dist 复制(设置 QIMINGCODE_DIST_DIR 环境变量,开发调试用) * QIMINGCODE_DIST_DIR=~/workspace/qimingcode/packages/opencode/dist npm run prepare:qimingcode - * 2) GitHub Release 下载(默认,CI/正式构建用) - * npm run prepare:qimingcode + * 2) 本地源码构建(默认开发模式):自动查找同级 qimingcode 仓库,缺 dist 时用 Bun 构建当前平台 + * 3) GitHub Release 下载(CI/正式构建兜底) + * QIMINGCODE_ALLOW_REMOTE=1 npm run prepare:qimingcode * * 打包时 electron-builder extraResources 将 resources/qimingcode 打包到应用内 * 运行时 getQimingCodeBundledBinPath() 解析对应平台二进制 @@ -17,6 +18,8 @@ * * 环境变量: * QIMINGCODE_DIST_DIR — qimingcode 本地构建产物目录(设置后走本地复制模式) + * QIMINGCODE_SOURCE_DIR — qimingcode 本地源码仓库目录(默认查找 ../../../qimingcode) + * QIMINGCODE_ALLOW_REMOTE — 允许本地源码不可用时从 GitHub Release 下载 * QIMINGCODE_REPO — GitHub 仓库(默认 qiming-ai/qimingcode) * GITHUB_TOKEN — GitHub token(私有仓库或提高速率限制用) */ @@ -228,14 +231,123 @@ function copyFromDist(key) { function getLocalDistDir() { const candidates = [ process.env.QIMINGCODE_DIST_DIR, - path.resolve(projectRoot, '..', '..', '..', 'qimingcode', 'packages', 'opencode', 'dist'), + getLocalSourceDir() ? path.join(getLocalSourceDir(), 'packages', 'opencode', 'dist') : null, path.join(process.env.HOME || '/root', 'workspace/qimingcode/packages/opencode/dist'), ].filter(Boolean); return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0]; } -// ==================== 模式 2: GitHub Release 下载 ==================== +function getLocalSourceDir() { + const candidates = [ + process.env.QIMINGCODE_SOURCE_DIR, + path.resolve(projectRoot, '..', '..', '..', 'qimingcode'), + path.join(process.env.HOME || '/root', 'workspace/qimingcode'), + ].filter(Boolean); + + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, 'packages', 'opencode', 'package.json'))) { + return candidate; + } + } + + return null; +} + +function getBunCommand() { + if (process.env.BUN_BIN) return process.env.BUN_BIN; + try { + return execFileSync('which', ['bun'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + return null; + } +} + +function getLocalBuildEnv(sourceDir, opencodeDir, bun) { + const bunDir = path.dirname(bun); + const localModelsJson = path.join(opencodeDir, 'assets', 'models.json'); + const env = { + ...process.env, + OPENCODE_VERSION: QIMINGCODE_VERSION, + PATH: `${bunDir}${path.delimiter}${process.env.PATH || ''}`, + ...getLocalRuntimeEnv(sourceDir), + }; + + if (!process.env.MODELS_DEV_API_JSON && fs.existsSync(localModelsJson)) { + env.MODELS_DEV_API_JSON = localModelsJson; + console.log(`[prepare-qimingcode] 使用本地模型快照: ${localModelsJson}`); + } + + return env; +} + +function getLocalRuntimeEnv(sourceDir = getLocalSourceDir() || projectRoot) { + const localRuntimeDir = path.join(sourceDir, '.local-runtime'); + return { + XDG_CACHE_HOME: process.env.XDG_CACHE_HOME || path.join(localRuntimeDir, 'cache'), + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME || path.join(localRuntimeDir, 'config'), + XDG_DATA_HOME: process.env.XDG_DATA_HOME || path.join(localRuntimeDir, 'data'), + XDG_STATE_HOME: process.env.XDG_STATE_HOME || path.join(localRuntimeDir, 'state'), + OPENCODE_DATA_DIR: process.env.OPENCODE_DATA_DIR || path.join(localRuntimeDir, 'data', 'opencode'), + }; +} + +function ensureLocalDist(key) { + const distDir = getLocalDistDir(); + const distName = PLATFORM_MAP[key]; + if (!distName) return false; + + const binDir = path.join(distDir, distName, 'bin'); + const existing = getBinaryCandidates(key) + .map((candidate) => path.join(binDir, candidate)) + .find((candidatePath) => fs.existsSync(candidatePath)); + if (existing) return true; + + const sourceDir = getLocalSourceDir(); + if (!sourceDir) return false; + + const bun = getBunCommand(); + if (!bun) { + console.error('[prepare-qimingcode] 找到本地 qimingcode 源码,但未找到 Bun。'); + console.error('[prepare-qimingcode] 请先安装 Bun,或设置 BUN_BIN=/path/to/bun 后重试。'); + return false; + } + + console.log(`[prepare-qimingcode] 本地 dist 缺失,使用源码构建当前平台: ${sourceDir}`); + const opencodeDir = path.join(sourceDir, 'packages', 'opencode'); + const hasRootNodeModules = fs.existsSync(path.join(sourceDir, 'node_modules')); + const hasOpencodeNodeModules = fs.existsSync(path.join(opencodeDir, 'node_modules')); + if (!hasRootNodeModules || !hasOpencodeNodeModules) { + console.log('[prepare-qimingcode] 安装 qimingcode 依赖 (bun install --ignore-scripts)...'); + execFileSync(bun, ['install', '--ignore-scripts'], { cwd: sourceDir, stdio: 'inherit' }); + } + + const buildEnv = getLocalBuildEnv(sourceDir, opencodeDir, bun); + + try { + console.log('[prepare-qimingcode] 修复 node-pty helper 权限...'); + execFileSync(bun, ['run', 'fix-node-pty'], { cwd: opencodeDir, stdio: 'inherit', env: buildEnv }); + } catch (err) { + console.warn(`[prepare-qimingcode] node-pty 权限修复失败,将继续构建: ${err.message}`); + } + + console.log('[prepare-qimingcode] 构建 qimingcode 当前平台二进制...'); + execFileSync( + bun, + ['run', 'script/build.ts', '--single', '--skip-embed-web-ui', '--skip-install'], + { + cwd: opencodeDir, + stdio: 'inherit', + env: buildEnv, + }, + ); + + return getBinaryCandidates(key) + .map((candidate) => path.join(binDir, candidate)) + .some((candidatePath) => fs.existsSync(candidatePath)); +} + +// ==================== 模式 2: GitHub Release 下载(显式兜底) ==================== /** * 下载文件到缓存目录 @@ -568,7 +680,11 @@ function verifyBinaryVersion(binaryPath, expectedVersion, key, hash) { } try { - const output = execFileSync(binaryPath, ['-v'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + const output = execFileSync(binaryPath, ['-v'], { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, ...getLocalRuntimeEnv() }, + }).trim(); if (output !== expectedVersion) { console.warn( `[prepare-qimingcode] ${key}: ⚠️ 二进制内部版本 ${output} 与期望版本 ${expectedVersion} 不一致(release tag 版本与二进制版本不同步)`, @@ -594,8 +710,13 @@ function codesign(binaryPath, key) { async function main() { const allPlatforms = process.argv.includes('--all') || process.argv.includes('--all-platforms'); - const useLocalDist = !!process.env.QIMINGCODE_DIST_DIR || fs.existsSync(getLocalDistDir()); - const mode = useLocalDist ? '本地 dist 复制' : 'GitHub Release 下载'; + const allowRemote = process.env.QIMINGCODE_ALLOW_REMOTE === '1'; + const localSourceDir = getLocalSourceDir(); + const mode = localSourceDir || process.env.QIMINGCODE_DIST_DIR + ? '本地优先(dist/源码构建)' + : allowRemote + ? 'GitHub Release 下载' + : '本地优先(未找到本地源码)'; fs.mkdirSync(resDir, { recursive: true }); @@ -615,7 +736,18 @@ async function main() { let fail = 0; for (const key of keys) { - const success = useLocalDist ? copyFromDist(key) : await downloadFromRelease(key); + let success = false; + + if (ensureLocalDist(key)) { + success = copyFromDist(key); + } else if (allowRemote) { + console.warn('[prepare-qimingcode] 本地 qimingcode 产物不可用,按 QIMINGCODE_ALLOW_REMOTE=1 使用远端兜底'); + success = await downloadFromRelease(key); + } else { + console.error('[prepare-qimingcode] 本地 qimingcode 产物不可用,且未允许远端下载。'); + console.error('[prepare-qimingcode] 请确保同级存在 qimingcode 仓库,或设置 QIMINGCODE_SOURCE_DIR / QIMINGCODE_DIST_DIR。'); + } + if (success) { ok++; } else { diff --git a/qimingclaw/crates/agent-electron-client/src/main/bootstrap/startup.ts b/qimingclaw/crates/agent-electron-client/src/main/bootstrap/startup.ts index ab6c6662..3958af59 100644 --- a/qimingclaw/crates/agent-electron-client/src/main/bootstrap/startup.ts +++ b/qimingclaw/crates/agent-electron-client/src/main/bootstrap/startup.ts @@ -5,6 +5,7 @@ import { startComputerServer } from "../services/computerServer"; import { mcpProxyManager, DEFAULT_MCP_PROXY_CONFIG, + pruneDisabledDefaultMcpServers, } from "../services/packages/mcp"; import { getConfiguredPorts } from "../services/startupPorts"; import { DEPS_SYNC_TIMEOUT } from "@shared/constants"; @@ -55,13 +56,14 @@ export async function runStartupTasks(): Promise { if (savedConfig) { try { const parsed = JSON.parse(savedConfig.value); - // 合并默认服务器(如 chrome-devtools),确保内置 MCP 服务始终存在 + // 合并可用的默认服务器;旧版本持久化的 chrome-devtools npx 默认项 + // 在本地未安装且未显式启用时会被过滤,避免启动时阻塞 60s。 const merged = { ...parsed, - mcpServers: { + mcpServers: pruneDisabledDefaultMcpServers({ ...DEFAULT_MCP_PROXY_CONFIG.mcpServers, ...(parsed.mcpServers || {}), - }, + }), }; mcpProxyManager.setConfig(merged); } catch (e) { diff --git a/qimingclaw/crates/agent-electron-client/src/main/services/packages/mcp.ts b/qimingclaw/crates/agent-electron-client/src/main/services/packages/mcp.ts index 9c337cfa..789c5c16 100644 --- a/qimingclaw/crates/agent-electron-client/src/main/services/packages/mcp.ts +++ b/qimingclaw/crates/agent-electron-client/src/main/services/packages/mcp.ts @@ -368,15 +368,71 @@ export function resolveServersConfig( // ========== Types ========== +const CHROME_DEVTOOLS_MCP_NAME = "chrome-devtools"; +const CHROME_DEVTOOLS_MCP_PACKAGE = "chrome-devtools-mcp"; + +function findLocalChromeDevtoolsMcpBin(): string | null { + const binName = isWindows() + ? `${CHROME_DEVTOOLS_MCP_PACKAGE}.cmd` + : CHROME_DEVTOOLS_MCP_PACKAGE; + const candidates = [ + path.join( + os.homedir(), + APP_DATA_DIR_NAME, + "node_modules", + ".bin", + binName, + ), + path.join(process.cwd(), "node_modules", ".bin", binName), + ]; + + return candidates.find((candidate) => fs.existsSync(candidate)) ?? null; +} + +function shouldEnableDefaultChromeDevtoolsMcp(): boolean { + return ( + process.env.QIMINGCLAW_ENABLE_CHROME_DEVTOOLS_MCP === "1" || + !!findLocalChromeDevtoolsMcpBin() + ); +} + +function getDefaultMcpServers(): Record { + if (!shouldEnableDefaultChromeDevtoolsMcp()) return {}; + + const localBin = findLocalChromeDevtoolsMcpBin(); + return { + [CHROME_DEVTOOLS_MCP_NAME]: localBin + ? { + command: localBin, + args: [], + persistent: true, + } + : { + command: "npx", + args: ["-y", `${CHROME_DEVTOOLS_MCP_PACKAGE}@latest`], + persistent: true, + }, + }; +} + +function isLegacyDefaultChromeDevtoolsEntry( + name: string, + entry: McpServerEntry, +): boolean { + return ( + name === CHROME_DEVTOOLS_MCP_NAME && + !isRemoteEntry(entry) && + entry.command === "npx" && + Array.isArray(entry.args) && + entry.args.length === 2 && + entry.args[0] === "-y" && + entry.args[1] === `${CHROME_DEVTOOLS_MCP_PACKAGE}@latest` + ); +} + /** 默认 mcpServers 配置 */ export const DEFAULT_MCP_PROXY_CONFIG: McpServersConfig = { - mcpServers: { - "chrome-devtools": { - command: "npx", - args: ["-y", "chrome-devtools-mcp@latest"], - persistent: true, - }, - }, + mcpServers: getDefaultMcpServers(), }; /** @@ -437,6 +493,24 @@ export function isRemoteEntry( return "url" in entry; } +export function pruneDisabledDefaultMcpServers( + servers: Record = {}, +): Record { + if (shouldEnableDefaultChromeDevtoolsMcp()) return servers; + + const result: Record = {}; + for (const [name, entry] of Object.entries(servers)) { + if (isLegacyDefaultChromeDevtoolsEntry(name, entry)) { + log.info( + "[McpProxy] Default chrome-devtools MCP disabled (not installed locally; set QIMINGCLAW_ENABLE_CHROME_DEVTOOLS_MCP=1 to force npx)", + ); + continue; + } + result[name] = entry; + } + return result; +} + /** mcpServers 配置(传给 qiming-mcp-stdio-proxy 的 JSON) */ export interface McpServersConfig { mcpServers: Record; @@ -1298,18 +1372,17 @@ export async function syncMcpConfigToProxyAndReload( // realOnly 为空时(用户删除了所有动态 MCP)不提前返回, // 继续执行以确保 bridge 仅运行默认服务(chrome-devtools) - // 始终以默认服务为基础,再叠加动态 MCP: - // - 用户删除所有动态 MCP → merged 仅含 chrome-devtools - // - 用户删除部分动态 MCP → merged 含 chrome-devtools + 剩余动态 MCP - // - 用户新增动态 MCP → merged 含 chrome-devtools + 所有动态 MCP + // 以可用的默认服务为基础,再叠加动态 MCP。默认 chrome-devtools + // 只有本地已安装或显式启用时才注入,避免 npx @latest 在启动时阻塞 60s。 const merged: Record = { - ...DEFAULT_MCP_PROXY_CONFIG.mcpServers, + ...getDefaultMcpServers(), ...realOnly, }; + const prunedMerged = pruneDisabledDefaultMcpServers(merged); // 为所有 MCP 服务器注入基础环境变量(包括 PATH) const prepareStartedAt = Date.now(); - const mergedWithEnv = injectBaseEnvToMcpServers(merged); + const mergedWithEnv = injectBaseEnvToMcpServers(prunedMerged); // Bridge 只管理 persistent 服务(如 chrome-devtools),动态 MCP 不进 bridge。 // 变更检测和重启均只针对 persistent servers,避免动态 MCP 变化时重启 chrome-devtools。 diff --git a/qimingclaw/crates/agent-electron-client/src/main/services/system/dependencies.ts b/qimingclaw/crates/agent-electron-client/src/main/services/system/dependencies.ts index fd475ab7..1a107cd3 100644 --- a/qimingclaw/crates/agent-electron-client/src/main/services/system/dependencies.ts +++ b/qimingclaw/crates/agent-electron-client/src/main/services/system/dependencies.ts @@ -1448,10 +1448,26 @@ export async function checkQimingFileServerBundled(): Promise<{ export async function checkClaudeCodeAcpBundled(): Promise<{ available: boolean; version?: string; + binPath?: string; }> { const bundledDir = getClaudeCodeAcpBundledDir(); if (!bundledDir) { - log.info("[checkClaudeCodeAcpBundled] Bundled not found"); + const local = await detectNpmPackage( + "claude-code-acp-ts", + "claude-code-acp-ts", + ); + if (local.installed) { + log.info( + `[checkClaudeCodeAcpBundled] Bundled not found; using npm-local package: ${local.binPath ?? "unknown"}, version=${local.version ?? "unknown"}`, + ); + return { + available: true, + version: local.version, + binPath: local.binPath, + }; + } + + log.info("[checkClaudeCodeAcpBundled] Bundled and npm-local package not found"); return { available: false }; } const pkgPath = path.join(bundledDir, "package.json"); @@ -1462,10 +1478,10 @@ export async function checkClaudeCodeAcpBundled(): Promise<{ log.info( `[checkClaudeCodeAcpBundled] Bundled available: ${bundledDir}, version=${version ?? "unknown"}`, ); - return { available: true, version }; + return { available: true, version, binPath: bundledDir }; } catch (e) { log.warn("[checkClaudeCodeAcpBundled] Failed to read package.json:", e); - return { available: true }; + return { available: true, binPath: bundledDir }; } } @@ -1960,7 +1976,21 @@ export async function checkAllDependencies(options?: { item.status = "missing"; } } else { - item.status = "missing"; + const result = await detectNpmPackage(dep.name, dep.binName); + item.version = result.version; + item.binPath = result.binPath; + if (!result.installed) { + item.status = "missing"; + } else if (dep.installVersion) { + const installed = (result.version ?? "0").replace(/^v/, ""); + const target = dep.installVersion.replace(/^v/, ""); + item.status = + installed === "0" || compareVersions(installed, target) < 0 + ? "outdated" + : "installed"; + } else { + item.status = "installed"; + } } break; } diff --git a/qimingclaw/crates/agent-electron-client/src/renderer/App.tsx b/qimingclaw/crates/agent-electron-client/src/renderer/App.tsx index dc848ee2..c1aacc0e 100644 --- a/qimingclaw/crates/agent-electron-client/src/renderer/App.tsx +++ b/qimingclaw/crates/agent-electron-client/src/renderer/App.tsx @@ -486,13 +486,15 @@ function App() { const deps = result?.results ?? []; const hasMissingOrError = deps.some( - (d: { status: string }) => - d.status === "missing" || d.status === "error", + (d: { status: string; required?: boolean }) => + d.required !== false && + (d.status === "missing" || d.status === "error"), ); const missingDeps = deps .filter( - (d: { status: string }) => - d.status === "missing" || d.status === "error", + (d: { status: string; required?: boolean }) => + d.required !== false && + (d.status === "missing" || d.status === "error"), ) .map((d: { name: string; status: string }) => d.name); diff --git a/qimingclaw/crates/agent-electron-client/src/renderer/components/pages/SessionsPage.tsx b/qimingclaw/crates/agent-electron-client/src/renderer/components/pages/SessionsPage.tsx index f199c878..49aba3c0 100644 --- a/qimingclaw/crates/agent-electron-client/src/renderer/components/pages/SessionsPage.tsx +++ b/qimingclaw/crates/agent-electron-client/src/renderer/components/pages/SessionsPage.tsx @@ -20,6 +20,7 @@ import { syncCookieAndGetNewSessionUrl, syncCookieAndGetChatUrl, persistTicketCookie, + normalizeLocalBackendRedirect, } from "../../services/utils/sessionUrl"; import { logger } from "../../services/utils/logService"; import { APP_DISPLAY_NAME } from "@shared/constants"; @@ -89,19 +90,47 @@ function SessionsPage({ } }; const onWillRedirect = (e: any) => { + const normalizedUrl = normalizeLocalBackendRedirect( + e.newURL, + webviewUrl, + ); + if (normalizedUrl) { + logger.warn( + "[SessionsPage][WebviewNav] normalized local backend redirect", + "SessionsPage", + { + from: e.oldURL, + to: e.newURL, + normalizedUrl, + }, + ); + e.preventDefault?.(); + el.loadURL?.(normalizedUrl); + return; + } + logger.info("[SessionsPage][WebviewNav] will-redirect", "SessionsPage", { from: e.oldURL, to: e.newURL, }); }; + const onDidFailLoad = (e: any) => { + logger.warn("[SessionsPage][WebviewNav] did-fail-load", "SessionsPage", { + url: e.validatedURL || e.url, + errorCode: e.errorCode, + errorDescription: e.errorDescription, + }); + }; el.addEventListener("did-navigate", onDidNavigate); el.addEventListener("did-navigate-in-page", onDidNavigate); el.addEventListener("will-redirect", onWillRedirect); + el.addEventListener("did-fail-load", onDidFailLoad); return () => { el.removeEventListener("did-navigate", onDidNavigate); el.removeEventListener("did-navigate-in-page", onDidNavigate); el.removeEventListener("will-redirect", onWillRedirect); + el.removeEventListener("did-fail-load", onDidFailLoad); }; }, [view, webviewUrl]); // eslint-disable-line react-hooks/exhaustive-deps diff --git a/qimingclaw/crates/agent-electron-client/src/renderer/services/utils/sessionUrl.test.ts b/qimingclaw/crates/agent-electron-client/src/renderer/services/utils/sessionUrl.test.ts index e5b20e13..de1f0e87 100644 --- a/qimingclaw/crates/agent-electron-client/src/renderer/services/utils/sessionUrl.test.ts +++ b/qimingclaw/crates/agent-electron-client/src/renderer/services/utils/sessionUrl.test.ts @@ -3,6 +3,7 @@ import { buildRedirectUrl, buildNewSessionUrl, buildChatSessionUrl, + normalizeLocalBackendRedirect, syncSessionCookie, syncCookieAndGetRedirectUrl, syncCookieAndGetNewSessionUrl, @@ -87,6 +88,51 @@ describe("buildChatSessionUrl", () => { }); }); +describe("normalizeLocalBackendRedirect", () => { + it("normalizes local redirects that lost protocol and port", () => { + expect( + normalizeLocalBackendRedirect( + "https://localhost/home/chat/5/26?hideMenu=true", + "http://localhost:18081/api/sandbox/config/redirect/6?hideMenu=true", + ), + ).toBe("http://localhost:18081/home/chat/5/26?hideMenu=true"); + }); + + it("normalizes localhost and 127.0.0.1 mismatches to the configured backend", () => { + expect( + normalizeLocalBackendRedirect( + "https://localhost/home/chat/5/26?hideMenu=true", + "http://127.0.0.1:18081/api/sandbox/config/redirect/6?hideMenu=true", + ), + ).toBe("http://127.0.0.1:18081/home/chat/5/26?hideMenu=true"); + }); + + it("does not rewrite already-correct local redirects", () => { + expect( + normalizeLocalBackendRedirect( + "http://localhost:18081/home/chat/5/26?hideMenu=true", + "http://localhost:18081/api/sandbox/config/redirect/6?hideMenu=true", + ), + ).toBeNull(); + }); + + it("does not rewrite remote domains", () => { + expect( + normalizeLocalBackendRedirect( + "https://example.com/home/chat/5/26?hideMenu=true", + "https://example.com/api/sandbox/config/redirect/6?hideMenu=true", + ), + ).toBeNull(); + }); + + it("returns null for invalid URLs", () => { + expect(normalizeLocalBackendRedirect("not-url", "http://localhost:18081")) + .toBeNull(); + expect(normalizeLocalBackendRedirect("https://localhost", "not-url")) + .toBeNull(); + }); +}); + describe("syncSessionCookie", () => { it("不设 domain 和 secure,由主进程根据 URL scheme 判断", async () => { await syncSessionCookie("https://app.example.com:8080/path", "tok123"); diff --git a/qimingclaw/crates/agent-electron-client/src/renderer/services/utils/sessionUrl.ts b/qimingclaw/crates/agent-electron-client/src/renderer/services/utils/sessionUrl.ts index 25f9e624..e1db948b 100644 --- a/qimingclaw/crates/agent-electron-client/src/renderer/services/utils/sessionUrl.ts +++ b/qimingclaw/crates/agent-electron-client/src/renderer/services/utils/sessionUrl.ts @@ -66,6 +66,46 @@ export function buildChatSessionUrl(domain: string, sessionId: string): string { return `${normalizedDomain}/api/sandbox/config/redirect/chat/${sessionId}?hideMenu=true`; } +function isLocalBackendHost(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1"; +} + +/** + * Some local backend builds redirect `http://localhost:18081/...` to + * `https://localhost/...`, losing both the protocol and port. Normalize only + * that local-only case so embedded webviews keep using the configured backend. + */ +export function normalizeLocalBackendRedirect( + redirectUrl: string, + expectedBackendUrl: string, +): string | null { + try { + const redirect = new URL(redirectUrl); + const expected = new URL(expectedBackendUrl); + + if ( + !isLocalBackendHost(expected.hostname) || + !isLocalBackendHost(redirect.hostname) + ) { + return null; + } + + if ( + redirect.protocol === expected.protocol && + redirect.port === expected.port + ) { + return null; + } + + redirect.protocol = expected.protocol; + redirect.host = expected.host; + const normalized = redirect.toString(); + return normalized === redirectUrl ? null : normalized; + } catch { + return null; + } +} + export async function syncSessionCookie( domain: string, token: string, diff --git a/qimingcode/packages/opencode/bin/qimingcode b/qimingcode/packages/opencode/bin/qimingcode old mode 100644 new mode 100755 diff --git a/qimingcode/packages/opencode/script/build.ts b/qimingcode/packages/opencode/script/build.ts index ebf19962..21bef5f2 100644 --- a/qimingcode/packages/opencode/script/build.ts +++ b/qimingcode/packages/opencode/script/build.ts @@ -257,9 +257,11 @@ for (const item of targets) { if (Script.release) { for (const key of Object.keys(binaries)) { - if (key.includes("linux")) { - await $`tar -czf ../../${key}.tar.gz *`.cwd(`dist/${key}/bin`) - } else { + // Electron's prepare-qimingcode.js downloads .tar.gz assets for every + // platform, including macOS and Windows. Keep zip as an additional + // Windows-friendly artifact, but always publish the tarball contract. + await $`tar -czf ../../${key}.tar.gz *`.cwd(`dist/${key}/bin`) + if (key.includes("windows")) { await $`zip -r ../../${key}.zip *`.cwd(`dist/${key}/bin`) } } diff --git a/qimingcode/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/qimingcode/packages/opencode/src/cli/cmd/tui/context/theme.tsx index ca6c0a6c..bffa7a1c 100644 --- a/qimingcode/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/qimingcode/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -24,7 +24,7 @@ import nightowl from "./theme/nightowl.json" with { type: "json" } import nord from "./theme/nord.json" with { type: "json" } import osakaJade from "./theme/osaka-jade.json" with { type: "json" } import onedark from "./theme/one-dark.json" with { type: "json" } -import opencode from "./theme/opencode.json" with { type: "json" } +import opencode from "./theme/vesper.json" with { type: "json" } import orng from "./theme/orng.json" with { type: "json" } import lucentOrng from "./theme/lucent-orng.json" with { type: "json" } import palenight from "./theme/palenight.json" with { type: "json" }