产品化改造:完善本地集成与启动体验
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -25,6 +25,8 @@ node_modules/
|
|||||||
# Build outputs and generated artifacts
|
# Build outputs and generated artifacts
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
!qiming-file-server/src/utils/build/
|
||||||
|
!qiming-file-server/src/utils/build/**
|
||||||
out/
|
out/
|
||||||
target/
|
target/
|
||||||
.umi/
|
.umi/
|
||||||
|
|||||||
@@ -1819,3 +1819,270 @@ Path = ...\qimingclaw\node_modules\.pnpm\electron@...\electron...
|
|||||||
```
|
```
|
||||||
|
|
||||||
说明已有一份 qimingclaw 开发进程在运行,不要重复启动。需要重启时,先关闭当前 Electron 窗口,再重新执行第 9.5 节启动命令。
|
说明已有一份 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
|
||||||
|
```
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ spring:
|
|||||||
datasource:
|
datasource:
|
||||||
# 主数据源 (MySQL)
|
# 主数据源 (MySQL)
|
||||||
master:
|
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
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
username: ${DB_USERNAME:root}
|
username: ${DB_USERNAME:root}
|
||||||
password: ${DB_PASSWORD:root}
|
password: ${DB_PASSWORD:Byyagz@121}
|
||||||
# 以下为 Druid 连接池配置 (如果使用)
|
# 以下为 Druid 连接池配置 (如果使用)
|
||||||
druid:
|
druid:
|
||||||
initial-size: 5
|
initial-size: 5
|
||||||
@@ -48,10 +48,10 @@ spring:
|
|||||||
filters: stat # 启用 stat filter 用于监控
|
filters: stat # 启用 stat filter 用于监控
|
||||||
# Doris 数据源
|
# Doris 数据源
|
||||||
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
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
username: ${DORIS_USERNAME:root}
|
username: ${DORIS_USERNAME:root}
|
||||||
password: ${DORIS_PASSWORD:root}
|
password: ${DORIS_PASSWORD:Byyagz@121}
|
||||||
# Doris 表配置
|
# Doris 表配置
|
||||||
table:
|
table:
|
||||||
replication-num: 1 # 副本数,生产环境通常为3,开发环境可以为1
|
replication-num: 1 # 副本数,生产环境通常为3,开发环境可以为1
|
||||||
@@ -68,7 +68,7 @@ spring:
|
|||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: ${REDIS_HOST:localhost}
|
host: ${REDIS_HOST:localhost}
|
||||||
port: ${REDIS_PORT:16379}
|
port: ${REDIS_PORT:6379}
|
||||||
password: ${REDIS_PASSWORD:Byyagz@121}
|
password: ${REDIS_PASSWORD:Byyagz@121}
|
||||||
database: ${REDIS_DB:1}
|
database: ${REDIS_DB:1}
|
||||||
lettuce:
|
lettuce:
|
||||||
|
|||||||
@@ -48,7 +48,6 @@
|
|||||||
<logger name="org.redisson" level="ERROR"/>
|
<logger name="org.redisson" level="ERROR"/>
|
||||||
<logger name="com.alibaba" level="ERROR"/>
|
<logger name="com.alibaba" level="ERROR"/>
|
||||||
<logger name="com.xspaceagi.system.domain.service.impl.ScheduleTaskDomainServiceImpl" level="ERROR"/>
|
<logger name="com.xspaceagi.system.domain.service.impl.ScheduleTaskDomainServiceImpl" level="ERROR"/>
|
||||||
|
|
||||||
<!-- 缺省应用日志输出 -->
|
<!-- 缺省应用日志输出 -->
|
||||||
<root level="${LOG_LEVEL}">
|
<root level="${LOG_LEVEL}">
|
||||||
<if condition='"${DEV_MODE}".equals("dev")'>
|
<if condition='"${DEV_MODE}".equals("dev")'>
|
||||||
|
|||||||
276
qiming-file-server/src/utils/build/buildProjectUtils.js
Normal file
276
qiming-file-server/src/utils/build/buildProjectUtils.js
Normal 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 };
|
||||||
71
qiming-file-server/src/utils/build/keepAliveDevUtils.js
Normal file
71
qiming-file-server/src/utils/build/keepAliveDevUtils.js
Normal 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 };
|
||||||
920
qiming-file-server/src/utils/build/processManager.js
Normal file
920
qiming-file-server/src/utils/build/processManager.js
Normal 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,
|
||||||
|
};
|
||||||
129
qiming-file-server/src/utils/build/restartDevUtils.js
Normal file
129
qiming-file-server/src/utils/build/restartDevUtils.js
Normal 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 };
|
||||||
150
qiming-file-server/src/utils/build/startDevUtils.js
Normal file
150
qiming-file-server/src/utils/build/startDevUtils.js
Normal 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 };
|
||||||
428
qiming-file-server/src/utils/build/stopDevUtils.js
Normal file
428
qiming-file-server/src/utils/build/stopDevUtils.js
Normal 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 };
|
||||||
847
qiming-file-server/src/utils/build/syntaxCheckUtils.js
Normal file
847
qiming-file-server/src/utils/build/syntaxCheckUtils.js
Normal 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,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ export default ExamplePage;
|
|||||||
|
|
||||||
## 访问方式
|
## 访问方式
|
||||||
|
|
||||||
1. 直接访问:`http://localhost:8000/examples`
|
1. 直接访问:`http://localhost:18000/examples`
|
||||||
2. 从示例索引页面选择具体示例
|
2. 从示例索引页面选择具体示例
|
||||||
3. 支持直接访问具体示例页面
|
3. 支持直接访问具体示例页面
|
||||||
|
|
||||||
|
|||||||
0
qimingclaw/crates/agent-electron-client/resources/lanproxy/binaries/qiming-lanproxy-aarch64-apple-darwin
Normal file → Executable file
0
qimingclaw/crates/agent-electron-client/resources/lanproxy/binaries/qiming-lanproxy-aarch64-apple-darwin
Normal file → Executable file
@@ -2,11 +2,12 @@
|
|||||||
/**
|
/**
|
||||||
* qimingcode 多平台集成:准备 resources/qimingcode/{platform}/bin/
|
* qimingcode 多平台集成:准备 resources/qimingcode/{platform}/bin/
|
||||||
*
|
*
|
||||||
* 两种模式:
|
* 三种模式:
|
||||||
* 1) 本地 dist 复制(设置 QIMINGCODE_DIST_DIR 环境变量,开发调试用)
|
* 1) 本地 dist 复制(设置 QIMINGCODE_DIST_DIR 环境变量,开发调试用)
|
||||||
* QIMINGCODE_DIST_DIR=~/workspace/qimingcode/packages/opencode/dist npm run prepare:qimingcode
|
* QIMINGCODE_DIST_DIR=~/workspace/qimingcode/packages/opencode/dist npm run prepare:qimingcode
|
||||||
* 2) GitHub Release 下载(默认,CI/正式构建用)
|
* 2) 本地源码构建(默认开发模式):自动查找同级 qimingcode 仓库,缺 dist 时用 Bun 构建当前平台
|
||||||
* npm run prepare:qimingcode
|
* 3) GitHub Release 下载(CI/正式构建兜底)
|
||||||
|
* QIMINGCODE_ALLOW_REMOTE=1 npm run prepare:qimingcode
|
||||||
*
|
*
|
||||||
* 打包时 electron-builder extraResources 将 resources/qimingcode 打包到应用内
|
* 打包时 electron-builder extraResources 将 resources/qimingcode 打包到应用内
|
||||||
* 运行时 getQimingCodeBundledBinPath() 解析对应平台二进制
|
* 运行时 getQimingCodeBundledBinPath() 解析对应平台二进制
|
||||||
@@ -17,6 +18,8 @@
|
|||||||
*
|
*
|
||||||
* 环境变量:
|
* 环境变量:
|
||||||
* QIMINGCODE_DIST_DIR — qimingcode 本地构建产物目录(设置后走本地复制模式)
|
* QIMINGCODE_DIST_DIR — qimingcode 本地构建产物目录(设置后走本地复制模式)
|
||||||
|
* QIMINGCODE_SOURCE_DIR — qimingcode 本地源码仓库目录(默认查找 ../../../qimingcode)
|
||||||
|
* QIMINGCODE_ALLOW_REMOTE — 允许本地源码不可用时从 GitHub Release 下载
|
||||||
* QIMINGCODE_REPO — GitHub 仓库(默认 qiming-ai/qimingcode)
|
* QIMINGCODE_REPO — GitHub 仓库(默认 qiming-ai/qimingcode)
|
||||||
* GITHUB_TOKEN — GitHub token(私有仓库或提高速率限制用)
|
* GITHUB_TOKEN — GitHub token(私有仓库或提高速率限制用)
|
||||||
*/
|
*/
|
||||||
@@ -228,14 +231,123 @@ function copyFromDist(key) {
|
|||||||
function getLocalDistDir() {
|
function getLocalDistDir() {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
process.env.QIMINGCODE_DIST_DIR,
|
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'),
|
path.join(process.env.HOME || '/root', 'workspace/qimingcode/packages/opencode/dist'),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
|
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 {
|
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) {
|
if (output !== expectedVersion) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[prepare-qimingcode] ${key}: ⚠️ 二进制内部版本 ${output} 与期望版本 ${expectedVersion} 不一致(release tag 版本与二进制版本不同步)`,
|
`[prepare-qimingcode] ${key}: ⚠️ 二进制内部版本 ${output} 与期望版本 ${expectedVersion} 不一致(release tag 版本与二进制版本不同步)`,
|
||||||
@@ -594,8 +710,13 @@ function codesign(binaryPath, key) {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const allPlatforms = process.argv.includes('--all') || process.argv.includes('--all-platforms');
|
const allPlatforms = process.argv.includes('--all') || process.argv.includes('--all-platforms');
|
||||||
const useLocalDist = !!process.env.QIMINGCODE_DIST_DIR || fs.existsSync(getLocalDistDir());
|
const allowRemote = process.env.QIMINGCODE_ALLOW_REMOTE === '1';
|
||||||
const mode = useLocalDist ? '本地 dist 复制' : 'GitHub Release 下载';
|
const localSourceDir = getLocalSourceDir();
|
||||||
|
const mode = localSourceDir || process.env.QIMINGCODE_DIST_DIR
|
||||||
|
? '本地优先(dist/源码构建)'
|
||||||
|
: allowRemote
|
||||||
|
? 'GitHub Release 下载'
|
||||||
|
: '本地优先(未找到本地源码)';
|
||||||
|
|
||||||
fs.mkdirSync(resDir, { recursive: true });
|
fs.mkdirSync(resDir, { recursive: true });
|
||||||
|
|
||||||
@@ -615,7 +736,18 @@ async function main() {
|
|||||||
let fail = 0;
|
let fail = 0;
|
||||||
|
|
||||||
for (const key of keys) {
|
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) {
|
if (success) {
|
||||||
ok++;
|
ok++;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { startComputerServer } from "../services/computerServer";
|
|||||||
import {
|
import {
|
||||||
mcpProxyManager,
|
mcpProxyManager,
|
||||||
DEFAULT_MCP_PROXY_CONFIG,
|
DEFAULT_MCP_PROXY_CONFIG,
|
||||||
|
pruneDisabledDefaultMcpServers,
|
||||||
} from "../services/packages/mcp";
|
} from "../services/packages/mcp";
|
||||||
import { getConfiguredPorts } from "../services/startupPorts";
|
import { getConfiguredPorts } from "../services/startupPorts";
|
||||||
import { DEPS_SYNC_TIMEOUT } from "@shared/constants";
|
import { DEPS_SYNC_TIMEOUT } from "@shared/constants";
|
||||||
@@ -55,13 +56,14 @@ export async function runStartupTasks(): Promise<void> {
|
|||||||
if (savedConfig) {
|
if (savedConfig) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(savedConfig.value);
|
const parsed = JSON.parse(savedConfig.value);
|
||||||
// 合并默认服务器(如 chrome-devtools),确保内置 MCP 服务始终存在
|
// 合并可用的默认服务器;旧版本持久化的 chrome-devtools npx 默认项
|
||||||
|
// 在本地未安装且未显式启用时会被过滤,避免启动时阻塞 60s。
|
||||||
const merged = {
|
const merged = {
|
||||||
...parsed,
|
...parsed,
|
||||||
mcpServers: {
|
mcpServers: pruneDisabledDefaultMcpServers({
|
||||||
...DEFAULT_MCP_PROXY_CONFIG.mcpServers,
|
...DEFAULT_MCP_PROXY_CONFIG.mcpServers,
|
||||||
...(parsed.mcpServers || {}),
|
...(parsed.mcpServers || {}),
|
||||||
},
|
}),
|
||||||
};
|
};
|
||||||
mcpProxyManager.setConfig(merged);
|
mcpProxyManager.setConfig(merged);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -368,15 +368,71 @@ export function resolveServersConfig(
|
|||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
|
const CHROME_DEVTOOLS_MCP_NAME = "chrome-devtools";
|
||||||
|
const CHROME_DEVTOOLS_MCP_PACKAGE = "chrome-devtools-mcp";
|
||||||
|
|
||||||
|
function findLocalChromeDevtoolsMcpBin(): string | null {
|
||||||
|
const binName = isWindows()
|
||||||
|
? `${CHROME_DEVTOOLS_MCP_PACKAGE}.cmd`
|
||||||
|
: CHROME_DEVTOOLS_MCP_PACKAGE;
|
||||||
|
const candidates = [
|
||||||
|
path.join(
|
||||||
|
os.homedir(),
|
||||||
|
APP_DATA_DIR_NAME,
|
||||||
|
"node_modules",
|
||||||
|
".bin",
|
||||||
|
binName,
|
||||||
|
),
|
||||||
|
path.join(process.cwd(), "node_modules", ".bin", binName),
|
||||||
|
];
|
||||||
|
|
||||||
|
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldEnableDefaultChromeDevtoolsMcp(): boolean {
|
||||||
|
return (
|
||||||
|
process.env.QIMINGCLAW_ENABLE_CHROME_DEVTOOLS_MCP === "1" ||
|
||||||
|
!!findLocalChromeDevtoolsMcpBin()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultMcpServers(): Record<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_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 配置 */
|
/** 默认 mcpServers 配置 */
|
||||||
export const DEFAULT_MCP_PROXY_CONFIG: McpServersConfig = {
|
export const DEFAULT_MCP_PROXY_CONFIG: McpServersConfig = {
|
||||||
mcpServers: {
|
mcpServers: getDefaultMcpServers(),
|
||||||
"chrome-devtools": {
|
|
||||||
command: "npx",
|
|
||||||
args: ["-y", "chrome-devtools-mcp@latest"],
|
|
||||||
persistent: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -437,6 +493,24 @@ export function isRemoteEntry(
|
|||||||
return "url" in entry;
|
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) */
|
/** mcpServers 配置(传给 qiming-mcp-stdio-proxy 的 JSON) */
|
||||||
export interface McpServersConfig {
|
export interface McpServersConfig {
|
||||||
mcpServers: Record<string, McpServerEntry>;
|
mcpServers: Record<string, McpServerEntry>;
|
||||||
@@ -1298,18 +1372,17 @@ export async function syncMcpConfigToProxyAndReload(
|
|||||||
// realOnly 为空时(用户删除了所有动态 MCP)不提前返回,
|
// realOnly 为空时(用户删除了所有动态 MCP)不提前返回,
|
||||||
// 继续执行以确保 bridge 仅运行默认服务(chrome-devtools)
|
// 继续执行以确保 bridge 仅运行默认服务(chrome-devtools)
|
||||||
|
|
||||||
// 始终以默认服务为基础,再叠加动态 MCP:
|
// 以可用的默认服务为基础,再叠加动态 MCP。默认 chrome-devtools
|
||||||
// - 用户删除所有动态 MCP → merged 仅含 chrome-devtools
|
// 只有本地已安装或显式启用时才注入,避免 npx @latest 在启动时阻塞 60s。
|
||||||
// - 用户删除部分动态 MCP → merged 含 chrome-devtools + 剩余动态 MCP
|
|
||||||
// - 用户新增动态 MCP → merged 含 chrome-devtools + 所有动态 MCP
|
|
||||||
const merged: Record<string, McpServerEntry> = {
|
const merged: Record<string, McpServerEntry> = {
|
||||||
...DEFAULT_MCP_PROXY_CONFIG.mcpServers,
|
...getDefaultMcpServers(),
|
||||||
...realOnly,
|
...realOnly,
|
||||||
};
|
};
|
||||||
|
const prunedMerged = pruneDisabledDefaultMcpServers(merged);
|
||||||
|
|
||||||
// 为所有 MCP 服务器注入基础环境变量(包括 PATH)
|
// 为所有 MCP 服务器注入基础环境变量(包括 PATH)
|
||||||
const prepareStartedAt = Date.now();
|
const prepareStartedAt = Date.now();
|
||||||
const mergedWithEnv = injectBaseEnvToMcpServers(merged);
|
const mergedWithEnv = injectBaseEnvToMcpServers(prunedMerged);
|
||||||
|
|
||||||
// Bridge 只管理 persistent 服务(如 chrome-devtools),动态 MCP 不进 bridge。
|
// Bridge 只管理 persistent 服务(如 chrome-devtools),动态 MCP 不进 bridge。
|
||||||
// 变更检测和重启均只针对 persistent servers,避免动态 MCP 变化时重启 chrome-devtools。
|
// 变更检测和重启均只针对 persistent servers,避免动态 MCP 变化时重启 chrome-devtools。
|
||||||
|
|||||||
@@ -1448,10 +1448,26 @@ export async function checkQimingFileServerBundled(): Promise<{
|
|||||||
export async function checkClaudeCodeAcpBundled(): Promise<{
|
export async function checkClaudeCodeAcpBundled(): Promise<{
|
||||||
available: boolean;
|
available: boolean;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
binPath?: string;
|
||||||
}> {
|
}> {
|
||||||
const bundledDir = getClaudeCodeAcpBundledDir();
|
const bundledDir = getClaudeCodeAcpBundledDir();
|
||||||
if (!bundledDir) {
|
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 };
|
return { available: false };
|
||||||
}
|
}
|
||||||
const pkgPath = path.join(bundledDir, "package.json");
|
const pkgPath = path.join(bundledDir, "package.json");
|
||||||
@@ -1462,10 +1478,10 @@ export async function checkClaudeCodeAcpBundled(): Promise<{
|
|||||||
log.info(
|
log.info(
|
||||||
`[checkClaudeCodeAcpBundled] Bundled available: ${bundledDir}, version=${version ?? "unknown"}`,
|
`[checkClaudeCodeAcpBundled] Bundled available: ${bundledDir}, version=${version ?? "unknown"}`,
|
||||||
);
|
);
|
||||||
return { available: true, version };
|
return { available: true, version, binPath: bundledDir };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.warn("[checkClaudeCodeAcpBundled] Failed to read package.json:", 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";
|
item.status = "missing";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
item.status = "missing";
|
const result = await detectNpmPackage(dep.name, dep.binName);
|
||||||
|
item.version = result.version;
|
||||||
|
item.binPath = result.binPath;
|
||||||
|
if (!result.installed) {
|
||||||
|
item.status = "missing";
|
||||||
|
} else if (dep.installVersion) {
|
||||||
|
const installed = (result.version ?? "0").replace(/^v/, "");
|
||||||
|
const target = dep.installVersion.replace(/^v/, "");
|
||||||
|
item.status =
|
||||||
|
installed === "0" || compareVersions(installed, target) < 0
|
||||||
|
? "outdated"
|
||||||
|
: "installed";
|
||||||
|
} else {
|
||||||
|
item.status = "installed";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -486,13 +486,15 @@ function App() {
|
|||||||
|
|
||||||
const deps = result?.results ?? [];
|
const deps = result?.results ?? [];
|
||||||
const hasMissingOrError = deps.some(
|
const hasMissingOrError = deps.some(
|
||||||
(d: { status: string }) =>
|
(d: { status: string; required?: boolean }) =>
|
||||||
d.status === "missing" || d.status === "error",
|
d.required !== false &&
|
||||||
|
(d.status === "missing" || d.status === "error"),
|
||||||
);
|
);
|
||||||
const missingDeps = deps
|
const missingDeps = deps
|
||||||
.filter(
|
.filter(
|
||||||
(d: { status: string }) =>
|
(d: { status: string; required?: boolean }) =>
|
||||||
d.status === "missing" || d.status === "error",
|
d.required !== false &&
|
||||||
|
(d.status === "missing" || d.status === "error"),
|
||||||
)
|
)
|
||||||
.map((d: { name: string; status: string }) => d.name);
|
.map((d: { name: string; status: string }) => d.name);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
syncCookieAndGetNewSessionUrl,
|
syncCookieAndGetNewSessionUrl,
|
||||||
syncCookieAndGetChatUrl,
|
syncCookieAndGetChatUrl,
|
||||||
persistTicketCookie,
|
persistTicketCookie,
|
||||||
|
normalizeLocalBackendRedirect,
|
||||||
} from "../../services/utils/sessionUrl";
|
} from "../../services/utils/sessionUrl";
|
||||||
import { logger } from "../../services/utils/logService";
|
import { logger } from "../../services/utils/logService";
|
||||||
import { APP_DISPLAY_NAME } from "@shared/constants";
|
import { APP_DISPLAY_NAME } from "@shared/constants";
|
||||||
@@ -89,19 +90,47 @@ function SessionsPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onWillRedirect = (e: any) => {
|
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", {
|
logger.info("[SessionsPage][WebviewNav] will-redirect", "SessionsPage", {
|
||||||
from: e.oldURL,
|
from: e.oldURL,
|
||||||
to: e.newURL,
|
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", onDidNavigate);
|
||||||
el.addEventListener("did-navigate-in-page", onDidNavigate);
|
el.addEventListener("did-navigate-in-page", onDidNavigate);
|
||||||
el.addEventListener("will-redirect", onWillRedirect);
|
el.addEventListener("will-redirect", onWillRedirect);
|
||||||
|
el.addEventListener("did-fail-load", onDidFailLoad);
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener("did-navigate", onDidNavigate);
|
el.removeEventListener("did-navigate", onDidNavigate);
|
||||||
el.removeEventListener("did-navigate-in-page", onDidNavigate);
|
el.removeEventListener("did-navigate-in-page", onDidNavigate);
|
||||||
el.removeEventListener("will-redirect", onWillRedirect);
|
el.removeEventListener("will-redirect", onWillRedirect);
|
||||||
|
el.removeEventListener("did-fail-load", onDidFailLoad);
|
||||||
};
|
};
|
||||||
}, [view, webviewUrl]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [view, webviewUrl]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
buildRedirectUrl,
|
buildRedirectUrl,
|
||||||
buildNewSessionUrl,
|
buildNewSessionUrl,
|
||||||
buildChatSessionUrl,
|
buildChatSessionUrl,
|
||||||
|
normalizeLocalBackendRedirect,
|
||||||
syncSessionCookie,
|
syncSessionCookie,
|
||||||
syncCookieAndGetRedirectUrl,
|
syncCookieAndGetRedirectUrl,
|
||||||
syncCookieAndGetNewSessionUrl,
|
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", () => {
|
describe("syncSessionCookie", () => {
|
||||||
it("不设 domain 和 secure,由主进程根据 URL scheme 判断", async () => {
|
it("不设 domain 和 secure,由主进程根据 URL scheme 判断", async () => {
|
||||||
await syncSessionCookie("https://app.example.com:8080/path", "tok123");
|
await syncSessionCookie("https://app.example.com:8080/path", "tok123");
|
||||||
|
|||||||
@@ -66,6 +66,46 @@ export function buildChatSessionUrl(domain: string, sessionId: string): string {
|
|||||||
return `${normalizedDomain}/api/sandbox/config/redirect/chat/${sessionId}?hideMenu=true`;
|
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(
|
export async function syncSessionCookie(
|
||||||
domain: string,
|
domain: string,
|
||||||
token: string,
|
token: string,
|
||||||
|
|||||||
0
qimingcode/packages/opencode/bin/qimingcode
Normal file → Executable file
0
qimingcode/packages/opencode/bin/qimingcode
Normal file → Executable file
@@ -257,9 +257,11 @@ for (const item of targets) {
|
|||||||
|
|
||||||
if (Script.release) {
|
if (Script.release) {
|
||||||
for (const key of Object.keys(binaries)) {
|
for (const key of Object.keys(binaries)) {
|
||||||
if (key.includes("linux")) {
|
// Electron's prepare-qimingcode.js downloads .tar.gz assets for every
|
||||||
await $`tar -czf ../../${key}.tar.gz *`.cwd(`dist/${key}/bin`)
|
// platform, including macOS and Windows. Keep zip as an additional
|
||||||
} else {
|
// Windows-friendly artifact, but always publish the tarball contract.
|
||||||
|
await $`tar -czf ../../${key}.tar.gz *`.cwd(`dist/${key}/bin`)
|
||||||
|
if (key.includes("windows")) {
|
||||||
await $`zip -r ../../${key}.zip *`.cwd(`dist/${key}/bin`)
|
await $`zip -r ../../${key}.zip *`.cwd(`dist/${key}/bin`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import nightowl from "./theme/nightowl.json" with { type: "json" }
|
|||||||
import nord from "./theme/nord.json" with { type: "json" }
|
import nord from "./theme/nord.json" with { type: "json" }
|
||||||
import osakaJade from "./theme/osaka-jade.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 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 orng from "./theme/orng.json" with { type: "json" }
|
||||||
import lucentOrng from "./theme/lucent-orng.json" with { type: "json" }
|
import lucentOrng from "./theme/lucent-orng.json" with { type: "json" }
|
||||||
import palenight from "./theme/palenight.json" with { type: "json" }
|
import palenight from "./theme/palenight.json" with { type: "json" }
|
||||||
|
|||||||
Reference in New Issue
Block a user