产品化改造:完善本地集成与启动体验

This commit is contained in:
baiyanyun
2026-06-02 17:55:08 +08:00
parent a280774f50
commit 873cd6ef53
24 changed files with 3490 additions and 45 deletions

2
.gitignore vendored
View File

@@ -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/

View File

@@ -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
```

View File

@@ -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:

View File

@@ -48,7 +48,6 @@
<logger name="org.redisson" level="ERROR"/>
<logger name="com.alibaba" level="ERROR"/>
<logger name="com.xspaceagi.system.domain.service.impl.ScheduleTaskDomainServiceImpl" level="ERROR"/>
<!-- 缺省应用日志输出 -->
<root level="${LOG_LEVEL}">
<if condition='"${DEV_MODE}".equals("dev")'>

View File

@@ -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<Object>} 构建结果
*/
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 };

View File

@@ -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<Object>} 检查结果
*/
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 };

View File

@@ -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 <value> 形式,<value> 为非以 - 开头的单词(含路径)
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<boolean>} 是否成功停止
*/
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<Object>} { 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,
};

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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<Object>} 停止结果
*
* 功能说明:
* 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 };

View File

@@ -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<Object>} { 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<string>} 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<Object>}
*/
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<Object>}
*/
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<string>} 入口文件路径列表
*/
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<Object>}
*/
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<string>} 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<string>} 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,
};

View File

@@ -95,7 +95,7 @@ export default ExamplePage;
## 访问方式
1. 直接访问:`http://localhost:8000/examples`
1. 直接访问:`http://localhost:18000/examples`
2. 从示例索引页面选择具体示例
3. 支持直接访问具体示例页面

View File

@@ -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 {

View File

@@ -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<void> {
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) {

View File

@@ -368,15 +368,71 @@ export function resolveServersConfig(
// ========== Types ==========
/** 默认 mcpServers 配置 */
export const DEFAULT_MCP_PROXY_CONFIG: McpServersConfig = {
mcpServers: {
"chrome-devtools": {
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<string, McpServerEntry> {
if (!shouldEnableDefaultChromeDevtoolsMcp()) return {};
const localBin = findLocalChromeDevtoolsMcpBin();
return {
[CHROME_DEVTOOLS_MCP_NAME]: localBin
? {
command: localBin,
args: [],
persistent: true,
}
: {
command: "npx",
args: ["-y", "chrome-devtools-mcp@latest"],
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: getDefaultMcpServers(),
};
/**
@@ -437,6 +493,24 @@ export function isRemoteEntry(
return "url" in entry;
}
export function pruneDisabledDefaultMcpServers(
servers: Record<string, McpServerEntry> = {},
): Record<string, McpServerEntry> {
if (shouldEnableDefaultChromeDevtoolsMcp()) return servers;
const result: Record<string, McpServerEntry> = {};
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<string, McpServerEntry>;
@@ -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<string, McpServerEntry> = {
...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。

View File

@@ -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 {
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;
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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");

View File

@@ -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,

0
qimingcode/packages/opencode/bin/qimingcode Normal file → Executable file
View File

View File

@@ -257,9 +257,11 @@ for (const item of targets) {
if (Script.release) {
for (const key of Object.keys(binaries)) {
if (key.includes("linux")) {
// 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`)
} else {
if (key.includes("windows")) {
await $`zip -r ../../${key}.zip *`.cwd(`dist/${key}/bin`)
}
}

View File

@@ -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" }