产品化改造:完善本地集成与启动体验
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -25,6 +25,8 @@ node_modules/
|
||||
# Build outputs and generated artifacts
|
||||
dist/
|
||||
build/
|
||||
!qiming-file-server/src/utils/build/
|
||||
!qiming-file-server/src/utils/build/**
|
||||
out/
|
||||
target/
|
||||
.umi/
|
||||
|
||||
@@ -1819,3 +1819,270 @@ Path = ...\qimingclaw\node_modules\.pnpm\electron@...\electron...
|
||||
```
|
||||
|
||||
说明已有一份 qimingclaw 开发进程在运行,不要重复启动。需要重启时,先关闭当前 Electron 窗口,再重新执行第 9.5 节启动命令。
|
||||
|
||||
## 10. macOS 本机启动补充
|
||||
|
||||
本节用于 macOS 本机开发,默认工作目录为:
|
||||
|
||||
```bash
|
||||
cd /Users/qiming/qiming-work
|
||||
```
|
||||
|
||||
服务器端 Milvus、Elasticsearch、rcoder 的启动方式仍按第 2 章执行;macOS 本机主要差异是命令写法、端口检查方式,以及 Homebrew 安装的外部命令路径。
|
||||
|
||||
### 10.1 基础工具检查
|
||||
|
||||
```bash
|
||||
java -version
|
||||
node -v
|
||||
pnpm -v
|
||||
rustc --version
|
||||
cargo --version
|
||||
deno --version
|
||||
uv --version
|
||||
```
|
||||
|
||||
如果没有全局 Maven,但已安装 IntelliJ IDEA,可以使用 IDEA 内置 Maven:
|
||||
|
||||
```bash
|
||||
MAVEN="/Applications/IntelliJ IDEA.app/Contents/plugins/maven/lib/maven3/bin/mvn"
|
||||
"$MAVEN" -version
|
||||
```
|
||||
|
||||
确认 Homebrew:
|
||||
|
||||
```bash
|
||||
/opt/homebrew/bin/brew --version
|
||||
```
|
||||
|
||||
### 10.2 安装并验证 Tika 外部工具
|
||||
|
||||
`qiming-backend` 使用 Apache Tika 解析文件时,会在启动阶段探测 `ffmpeg`、`exiftool`、`sox`、`tesseract`。这些不是 Java 依赖,需要系统命令能被后端进程找到。
|
||||
|
||||
安装:
|
||||
|
||||
```bash
|
||||
/opt/homebrew/bin/brew install ffmpeg exiftool sox tesseract
|
||||
```
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
|
||||
|
||||
command -v ffmpeg
|
||||
command -v exiftool
|
||||
command -v sox
|
||||
command -v tesseract
|
||||
|
||||
ffmpeg -version | sed -n '1p'
|
||||
exiftool -ver
|
||||
sox --version
|
||||
tesseract --version | sed -n '1p'
|
||||
```
|
||||
|
||||
后端启动日志里如果看到下面内容,说明 Tika 已经能找到这些命令:
|
||||
|
||||
```text
|
||||
exit value for ffmpeg: 0
|
||||
exit value for exiftool: 0
|
||||
exit value for sox: 0
|
||||
hasTesseract (path: [tesseract]): true
|
||||
```
|
||||
|
||||
如果仍看到 `Cannot run program "ffmpeg"`、`Cannot run program "exiftool"`、`Cannot run program "sox"`、`Cannot run program "tesseract"`,优先检查启动后端的进程 PATH。IDEA 从 Dock 启动时经常拿不到 shell 里的 `/opt/homebrew/bin`,需要在 Run Configuration 的 Environment variables 里加入:
|
||||
|
||||
```text
|
||||
PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
|
||||
```
|
||||
|
||||
### 10.3 本机确认 MySQL 和 Redis
|
||||
|
||||
当前根手册规划 MySQL 使用 `3308`,Redis 使用 `16379`:
|
||||
|
||||
```bash
|
||||
nc -vz localhost 3308
|
||||
nc -vz localhost 16379
|
||||
mysql -h127.0.0.1 -P3308 -uroot -p你的MySQL密码 agent_platform -e "SELECT 1;"
|
||||
redis-cli -p 16379 -a 你的Redis密码 ping
|
||||
```
|
||||
|
||||
正常结果:
|
||||
|
||||
```text
|
||||
MySQL 查询返回 1
|
||||
Redis 返回 PONG
|
||||
```
|
||||
|
||||
### 10.4 建立 Milvus SSH 隧道
|
||||
|
||||
新开一个终端,保持不关闭:
|
||||
|
||||
```bash
|
||||
ssh -N -L 19530:127.0.0.1:19530 root@服务器IP
|
||||
```
|
||||
|
||||
另开终端验证:
|
||||
|
||||
```bash
|
||||
nc -vz localhost 19530
|
||||
```
|
||||
|
||||
### 10.5 验证服务器 Elasticsearch 和 rcoder
|
||||
|
||||
```bash
|
||||
curl -f http://服务器IP:19200
|
||||
curl "http://服务器IP:19200/_cluster/health?pretty"
|
||||
|
||||
nc -vz 服务器IP 18087
|
||||
nc -vz 服务器IP 18088
|
||||
```
|
||||
|
||||
### 10.6 启动 qiming-backend
|
||||
|
||||
如果使用 IDEA 启动,推荐在 `PlatformApiApplication` 的 Run Configuration 里设置 Environment variables:
|
||||
|
||||
```text
|
||||
PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
|
||||
SPRING_PROFILES_ACTIVE=dev
|
||||
SERVER_PORT=18081
|
||||
DB_HOST=localhost
|
||||
DB_NAME=agent_platform
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=你的MySQL密码
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=16379
|
||||
REDIS_PASSWORD=你的Redis密码
|
||||
REDIS_DB=1
|
||||
MILVUS_URI=http://服务器IP:19530/
|
||||
ES_URL=http://服务器IP:19200
|
||||
BUILD_SERVER_URL=http://localhost:16000/api
|
||||
CODE_EXECUTE_URL=http://localhost:18020/api/run_code_with_log
|
||||
MCP_PROXY_URL=http://localhost:18020
|
||||
AI_AGENT_URL=http://服务器IP:18087
|
||||
DOCKER_PROXY_ENABLE=true
|
||||
DOCKER_PROXY_URL=http://服务器IP:18088
|
||||
OUTER_PORT=11076
|
||||
```
|
||||
|
||||
如果用终端启动:
|
||||
|
||||
```bash
|
||||
cd /Users/qiming/qiming-work/qiming-backend
|
||||
|
||||
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
|
||||
export SPRING_PROFILES_ACTIVE="dev"
|
||||
export SERVER_PORT="18081"
|
||||
|
||||
export DB_HOST="localhost"
|
||||
export DB_NAME="agent_platform"
|
||||
export DB_USERNAME="root"
|
||||
export DB_PASSWORD="你的MySQL密码"
|
||||
|
||||
export REDIS_HOST="localhost"
|
||||
export REDIS_PORT="16379"
|
||||
export REDIS_PASSWORD="你的Redis密码"
|
||||
export REDIS_DB="1"
|
||||
|
||||
export MILVUS_URI="http://服务器IP:19530/"
|
||||
export ES_URL="http://服务器IP:19200"
|
||||
export ES_USERNAME=""
|
||||
export ES_PASSWORD=""
|
||||
export ES_API_KEY=""
|
||||
|
||||
export BUILD_SERVER_URL="http://localhost:16000/api"
|
||||
export CODE_EXECUTE_URL="http://localhost:18020/api/run_code_with_log"
|
||||
export MCP_PROXY_URL="http://localhost:18020"
|
||||
export AI_AGENT_URL="http://服务器IP:18087"
|
||||
export DOCKER_PROXY_ENABLE="true"
|
||||
export DOCKER_PROXY_URL="http://服务器IP:18088"
|
||||
export OUTER_PORT="11076"
|
||||
|
||||
MAVEN="/Applications/IntelliJ IDEA.app/Contents/plugins/maven/lib/maven3/bin/mvn"
|
||||
"$MAVEN" package -Dmaven.test.skip=true -pl app-platform-bootstrap/app-platform-web-bootstrap -am -Pdev
|
||||
java -jar app-platform-bootstrap/app-platform-web-bootstrap/target/app-platform-web-bootstrap-0.0.1-SNAPSHOT.jar
|
||||
```
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
nc -vz localhost 18081
|
||||
curl -I http://localhost:18081/doc.html
|
||||
```
|
||||
|
||||
### 10.7 启动 qiming 前端
|
||||
|
||||
```bash
|
||||
cd /Users/qiming/qiming-work/qiming
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
`qiming/.env` 已固定 `PORT=18000`,直接执行 `pnpm dev` 即可。
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
nc -vz localhost 18000
|
||||
open http://localhost:18000
|
||||
```
|
||||
|
||||
### 10.8 启动 qiming-file-server
|
||||
|
||||
```bash
|
||||
cd /Users/qiming/qiming-work/qiming-file-server
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
curl -f http://localhost:16000/health
|
||||
```
|
||||
|
||||
### 10.9 启动 mcp-proxy
|
||||
|
||||
```bash
|
||||
cd /Users/qiming/qiming-work/qiming-mcp-proxy
|
||||
cargo install --path ./mcp-proxy --force
|
||||
|
||||
export MCP_PROXY_PORT="18020"
|
||||
mcp-proxy
|
||||
```
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
curl -f http://localhost:18020/health
|
||||
```
|
||||
|
||||
### 10.10 验证 run_code_rmcp
|
||||
|
||||
```bash
|
||||
cd /Users/qiming/qiming-work/qiming-run_code_rmcp
|
||||
|
||||
run_code_rmcp --show-logs js -c "function handler(){ console.log('js ok'); return 'hello qiming'; }"
|
||||
run_code_rmcp --show-logs python -c "def handler(args=None): print('python ok'); return 'hello python'"
|
||||
```
|
||||
|
||||
正常应看到:
|
||||
|
||||
```text
|
||||
Result: "hello qiming"
|
||||
Result: "hello python"
|
||||
```
|
||||
|
||||
### 10.11 启动完成后统一检查
|
||||
|
||||
```bash
|
||||
nc -vz localhost 18000
|
||||
nc -vz localhost 18081
|
||||
nc -vz localhost 16000
|
||||
nc -vz localhost 18020
|
||||
nc -vz localhost 19530
|
||||
|
||||
curl -I http://localhost:18081/doc.html
|
||||
curl -f http://localhost:16000/health
|
||||
curl -f http://localhost:18020/health
|
||||
```
|
||||
|
||||
@@ -27,10 +27,10 @@ spring:
|
||||
datasource:
|
||||
# 主数据源 (MySQL)
|
||||
master:
|
||||
url: jdbc:mysql://${DB_HOST:localhost}:3308/${DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false
|
||||
url: jdbc:mysql://${DB_HOST:localhost}:3306/${DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
username: ${DB_USERNAME:root}
|
||||
password: ${DB_PASSWORD:root}
|
||||
password: ${DB_PASSWORD:Byyagz@121}
|
||||
# 以下为 Druid 连接池配置 (如果使用)
|
||||
druid:
|
||||
initial-size: 5
|
||||
@@ -48,10 +48,10 @@ spring:
|
||||
filters: stat # 启用 stat filter 用于监控
|
||||
# Doris 数据源
|
||||
doris:
|
||||
url: jdbc:mysql://${DORIS_HOST:localhost}:3308/${DORIS_DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false
|
||||
url: jdbc:mysql://${DORIS_HOST:localhost}:3306/${DORIS_DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
username: ${DORIS_USERNAME:root}
|
||||
password: ${DORIS_PASSWORD:root}
|
||||
password: ${DORIS_PASSWORD:Byyagz@121}
|
||||
# Doris 表配置
|
||||
table:
|
||||
replication-num: 1 # 副本数,生产环境通常为3,开发环境可以为1
|
||||
@@ -68,7 +68,7 @@ spring:
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:16379}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:Byyagz@121}
|
||||
database: ${REDIS_DB:1}
|
||||
lettuce:
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
<logger name="org.redisson" level="ERROR"/>
|
||||
<logger name="com.alibaba" level="ERROR"/>
|
||||
<logger name="com.xspaceagi.system.domain.service.impl.ScheduleTaskDomainServiceImpl" level="ERROR"/>
|
||||
|
||||
<!-- 缺省应用日志输出 -->
|
||||
<root level="${LOG_LEVEL}">
|
||||
<if condition='"${DEV_MODE}".equals("dev")'>
|
||||
|
||||
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. 从示例索引页面选择具体示例
|
||||
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/
|
||||
*
|
||||
* 两种模式:
|
||||
* 三种模式:
|
||||
* 1) 本地 dist 复制(设置 QIMINGCODE_DIST_DIR 环境变量,开发调试用)
|
||||
* QIMINGCODE_DIST_DIR=~/workspace/qimingcode/packages/opencode/dist npm run prepare:qimingcode
|
||||
* 2) GitHub Release 下载(默认,CI/正式构建用)
|
||||
* npm run prepare:qimingcode
|
||||
* 2) 本地源码构建(默认开发模式):自动查找同级 qimingcode 仓库,缺 dist 时用 Bun 构建当前平台
|
||||
* 3) GitHub Release 下载(CI/正式构建兜底)
|
||||
* QIMINGCODE_ALLOW_REMOTE=1 npm run prepare:qimingcode
|
||||
*
|
||||
* 打包时 electron-builder extraResources 将 resources/qimingcode 打包到应用内
|
||||
* 运行时 getQimingCodeBundledBinPath() 解析对应平台二进制
|
||||
@@ -17,6 +18,8 @@
|
||||
*
|
||||
* 环境变量:
|
||||
* QIMINGCODE_DIST_DIR — qimingcode 本地构建产物目录(设置后走本地复制模式)
|
||||
* QIMINGCODE_SOURCE_DIR — qimingcode 本地源码仓库目录(默认查找 ../../../qimingcode)
|
||||
* QIMINGCODE_ALLOW_REMOTE — 允许本地源码不可用时从 GitHub Release 下载
|
||||
* QIMINGCODE_REPO — GitHub 仓库(默认 qiming-ai/qimingcode)
|
||||
* GITHUB_TOKEN — GitHub token(私有仓库或提高速率限制用)
|
||||
*/
|
||||
@@ -228,14 +231,123 @@ function copyFromDist(key) {
|
||||
function getLocalDistDir() {
|
||||
const candidates = [
|
||||
process.env.QIMINGCODE_DIST_DIR,
|
||||
path.resolve(projectRoot, '..', '..', '..', 'qimingcode', 'packages', 'opencode', 'dist'),
|
||||
getLocalSourceDir() ? path.join(getLocalSourceDir(), 'packages', 'opencode', 'dist') : null,
|
||||
path.join(process.env.HOME || '/root', 'workspace/qimingcode/packages/opencode/dist'),
|
||||
].filter(Boolean);
|
||||
|
||||
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
|
||||
}
|
||||
|
||||
// ==================== 模式 2: GitHub Release 下载 ====================
|
||||
function getLocalSourceDir() {
|
||||
const candidates = [
|
||||
process.env.QIMINGCODE_SOURCE_DIR,
|
||||
path.resolve(projectRoot, '..', '..', '..', 'qimingcode'),
|
||||
path.join(process.env.HOME || '/root', 'workspace/qimingcode'),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(path.join(candidate, 'packages', 'opencode', 'package.json'))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBunCommand() {
|
||||
if (process.env.BUN_BIN) return process.env.BUN_BIN;
|
||||
try {
|
||||
return execFileSync('which', ['bun'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalBuildEnv(sourceDir, opencodeDir, bun) {
|
||||
const bunDir = path.dirname(bun);
|
||||
const localModelsJson = path.join(opencodeDir, 'assets', 'models.json');
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCODE_VERSION: QIMINGCODE_VERSION,
|
||||
PATH: `${bunDir}${path.delimiter}${process.env.PATH || ''}`,
|
||||
...getLocalRuntimeEnv(sourceDir),
|
||||
};
|
||||
|
||||
if (!process.env.MODELS_DEV_API_JSON && fs.existsSync(localModelsJson)) {
|
||||
env.MODELS_DEV_API_JSON = localModelsJson;
|
||||
console.log(`[prepare-qimingcode] 使用本地模型快照: ${localModelsJson}`);
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
function getLocalRuntimeEnv(sourceDir = getLocalSourceDir() || projectRoot) {
|
||||
const localRuntimeDir = path.join(sourceDir, '.local-runtime');
|
||||
return {
|
||||
XDG_CACHE_HOME: process.env.XDG_CACHE_HOME || path.join(localRuntimeDir, 'cache'),
|
||||
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME || path.join(localRuntimeDir, 'config'),
|
||||
XDG_DATA_HOME: process.env.XDG_DATA_HOME || path.join(localRuntimeDir, 'data'),
|
||||
XDG_STATE_HOME: process.env.XDG_STATE_HOME || path.join(localRuntimeDir, 'state'),
|
||||
OPENCODE_DATA_DIR: process.env.OPENCODE_DATA_DIR || path.join(localRuntimeDir, 'data', 'opencode'),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureLocalDist(key) {
|
||||
const distDir = getLocalDistDir();
|
||||
const distName = PLATFORM_MAP[key];
|
||||
if (!distName) return false;
|
||||
|
||||
const binDir = path.join(distDir, distName, 'bin');
|
||||
const existing = getBinaryCandidates(key)
|
||||
.map((candidate) => path.join(binDir, candidate))
|
||||
.find((candidatePath) => fs.existsSync(candidatePath));
|
||||
if (existing) return true;
|
||||
|
||||
const sourceDir = getLocalSourceDir();
|
||||
if (!sourceDir) return false;
|
||||
|
||||
const bun = getBunCommand();
|
||||
if (!bun) {
|
||||
console.error('[prepare-qimingcode] 找到本地 qimingcode 源码,但未找到 Bun。');
|
||||
console.error('[prepare-qimingcode] 请先安装 Bun,或设置 BUN_BIN=/path/to/bun 后重试。');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[prepare-qimingcode] 本地 dist 缺失,使用源码构建当前平台: ${sourceDir}`);
|
||||
const opencodeDir = path.join(sourceDir, 'packages', 'opencode');
|
||||
const hasRootNodeModules = fs.existsSync(path.join(sourceDir, 'node_modules'));
|
||||
const hasOpencodeNodeModules = fs.existsSync(path.join(opencodeDir, 'node_modules'));
|
||||
if (!hasRootNodeModules || !hasOpencodeNodeModules) {
|
||||
console.log('[prepare-qimingcode] 安装 qimingcode 依赖 (bun install --ignore-scripts)...');
|
||||
execFileSync(bun, ['install', '--ignore-scripts'], { cwd: sourceDir, stdio: 'inherit' });
|
||||
}
|
||||
|
||||
const buildEnv = getLocalBuildEnv(sourceDir, opencodeDir, bun);
|
||||
|
||||
try {
|
||||
console.log('[prepare-qimingcode] 修复 node-pty helper 权限...');
|
||||
execFileSync(bun, ['run', 'fix-node-pty'], { cwd: opencodeDir, stdio: 'inherit', env: buildEnv });
|
||||
} catch (err) {
|
||||
console.warn(`[prepare-qimingcode] node-pty 权限修复失败,将继续构建: ${err.message}`);
|
||||
}
|
||||
|
||||
console.log('[prepare-qimingcode] 构建 qimingcode 当前平台二进制...');
|
||||
execFileSync(
|
||||
bun,
|
||||
['run', 'script/build.ts', '--single', '--skip-embed-web-ui', '--skip-install'],
|
||||
{
|
||||
cwd: opencodeDir,
|
||||
stdio: 'inherit',
|
||||
env: buildEnv,
|
||||
},
|
||||
);
|
||||
|
||||
return getBinaryCandidates(key)
|
||||
.map((candidate) => path.join(binDir, candidate))
|
||||
.some((candidatePath) => fs.existsSync(candidatePath));
|
||||
}
|
||||
|
||||
// ==================== 模式 2: GitHub Release 下载(显式兜底) ====================
|
||||
|
||||
/**
|
||||
* 下载文件到缓存目录
|
||||
@@ -568,7 +680,11 @@ function verifyBinaryVersion(binaryPath, expectedVersion, key, hash) {
|
||||
}
|
||||
|
||||
try {
|
||||
const output = execFileSync(binaryPath, ['-v'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
||||
const output = execFileSync(binaryPath, ['-v'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, ...getLocalRuntimeEnv() },
|
||||
}).trim();
|
||||
if (output !== expectedVersion) {
|
||||
console.warn(
|
||||
`[prepare-qimingcode] ${key}: ⚠️ 二进制内部版本 ${output} 与期望版本 ${expectedVersion} 不一致(release tag 版本与二进制版本不同步)`,
|
||||
@@ -594,8 +710,13 @@ function codesign(binaryPath, key) {
|
||||
|
||||
async function main() {
|
||||
const allPlatforms = process.argv.includes('--all') || process.argv.includes('--all-platforms');
|
||||
const useLocalDist = !!process.env.QIMINGCODE_DIST_DIR || fs.existsSync(getLocalDistDir());
|
||||
const mode = useLocalDist ? '本地 dist 复制' : 'GitHub Release 下载';
|
||||
const allowRemote = process.env.QIMINGCODE_ALLOW_REMOTE === '1';
|
||||
const localSourceDir = getLocalSourceDir();
|
||||
const mode = localSourceDir || process.env.QIMINGCODE_DIST_DIR
|
||||
? '本地优先(dist/源码构建)'
|
||||
: allowRemote
|
||||
? 'GitHub Release 下载'
|
||||
: '本地优先(未找到本地源码)';
|
||||
|
||||
fs.mkdirSync(resDir, { recursive: true });
|
||||
|
||||
@@ -615,7 +736,18 @@ async function main() {
|
||||
let fail = 0;
|
||||
|
||||
for (const key of keys) {
|
||||
const success = useLocalDist ? copyFromDist(key) : await downloadFromRelease(key);
|
||||
let success = false;
|
||||
|
||||
if (ensureLocalDist(key)) {
|
||||
success = copyFromDist(key);
|
||||
} else if (allowRemote) {
|
||||
console.warn('[prepare-qimingcode] 本地 qimingcode 产物不可用,按 QIMINGCODE_ALLOW_REMOTE=1 使用远端兜底');
|
||||
success = await downloadFromRelease(key);
|
||||
} else {
|
||||
console.error('[prepare-qimingcode] 本地 qimingcode 产物不可用,且未允许远端下载。');
|
||||
console.error('[prepare-qimingcode] 请确保同级存在 qimingcode 仓库,或设置 QIMINGCODE_SOURCE_DIR / QIMINGCODE_DIST_DIR。');
|
||||
}
|
||||
|
||||
if (success) {
|
||||
ok++;
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { startComputerServer } from "../services/computerServer";
|
||||
import {
|
||||
mcpProxyManager,
|
||||
DEFAULT_MCP_PROXY_CONFIG,
|
||||
pruneDisabledDefaultMcpServers,
|
||||
} from "../services/packages/mcp";
|
||||
import { getConfiguredPorts } from "../services/startupPorts";
|
||||
import { DEPS_SYNC_TIMEOUT } from "@shared/constants";
|
||||
@@ -55,13 +56,14 @@ export async function runStartupTasks(): Promise<void> {
|
||||
if (savedConfig) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedConfig.value);
|
||||
// 合并默认服务器(如 chrome-devtools),确保内置 MCP 服务始终存在
|
||||
// 合并可用的默认服务器;旧版本持久化的 chrome-devtools npx 默认项
|
||||
// 在本地未安装且未显式启用时会被过滤,避免启动时阻塞 60s。
|
||||
const merged = {
|
||||
...parsed,
|
||||
mcpServers: {
|
||||
mcpServers: pruneDisabledDefaultMcpServers({
|
||||
...DEFAULT_MCP_PROXY_CONFIG.mcpServers,
|
||||
...(parsed.mcpServers || {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
mcpProxyManager.setConfig(merged);
|
||||
} catch (e) {
|
||||
|
||||
@@ -368,15 +368,71 @@ export function resolveServersConfig(
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
/** 默认 mcpServers 配置 */
|
||||
export const DEFAULT_MCP_PROXY_CONFIG: McpServersConfig = {
|
||||
mcpServers: {
|
||||
"chrome-devtools": {
|
||||
const CHROME_DEVTOOLS_MCP_NAME = "chrome-devtools";
|
||||
const CHROME_DEVTOOLS_MCP_PACKAGE = "chrome-devtools-mcp";
|
||||
|
||||
function findLocalChromeDevtoolsMcpBin(): string | null {
|
||||
const binName = isWindows()
|
||||
? `${CHROME_DEVTOOLS_MCP_PACKAGE}.cmd`
|
||||
: CHROME_DEVTOOLS_MCP_PACKAGE;
|
||||
const candidates = [
|
||||
path.join(
|
||||
os.homedir(),
|
||||
APP_DATA_DIR_NAME,
|
||||
"node_modules",
|
||||
".bin",
|
||||
binName,
|
||||
),
|
||||
path.join(process.cwd(), "node_modules", ".bin", binName),
|
||||
];
|
||||
|
||||
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
|
||||
}
|
||||
|
||||
function shouldEnableDefaultChromeDevtoolsMcp(): boolean {
|
||||
return (
|
||||
process.env.QIMINGCLAW_ENABLE_CHROME_DEVTOOLS_MCP === "1" ||
|
||||
!!findLocalChromeDevtoolsMcpBin()
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultMcpServers(): Record<string, McpServerEntry> {
|
||||
if (!shouldEnableDefaultChromeDevtoolsMcp()) return {};
|
||||
|
||||
const localBin = findLocalChromeDevtoolsMcpBin();
|
||||
return {
|
||||
[CHROME_DEVTOOLS_MCP_NAME]: localBin
|
||||
? {
|
||||
command: localBin,
|
||||
args: [],
|
||||
persistent: true,
|
||||
}
|
||||
: {
|
||||
command: "npx",
|
||||
args: ["-y", "chrome-devtools-mcp@latest"],
|
||||
args: ["-y", `${CHROME_DEVTOOLS_MCP_PACKAGE}@latest`],
|
||||
persistent: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isLegacyDefaultChromeDevtoolsEntry(
|
||||
name: string,
|
||||
entry: McpServerEntry,
|
||||
): boolean {
|
||||
return (
|
||||
name === CHROME_DEVTOOLS_MCP_NAME &&
|
||||
!isRemoteEntry(entry) &&
|
||||
entry.command === "npx" &&
|
||||
Array.isArray(entry.args) &&
|
||||
entry.args.length === 2 &&
|
||||
entry.args[0] === "-y" &&
|
||||
entry.args[1] === `${CHROME_DEVTOOLS_MCP_PACKAGE}@latest`
|
||||
);
|
||||
}
|
||||
|
||||
/** 默认 mcpServers 配置 */
|
||||
export const DEFAULT_MCP_PROXY_CONFIG: McpServersConfig = {
|
||||
mcpServers: getDefaultMcpServers(),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -437,6 +493,24 @@ export function isRemoteEntry(
|
||||
return "url" in entry;
|
||||
}
|
||||
|
||||
export function pruneDisabledDefaultMcpServers(
|
||||
servers: Record<string, McpServerEntry> = {},
|
||||
): Record<string, McpServerEntry> {
|
||||
if (shouldEnableDefaultChromeDevtoolsMcp()) return servers;
|
||||
|
||||
const result: Record<string, McpServerEntry> = {};
|
||||
for (const [name, entry] of Object.entries(servers)) {
|
||||
if (isLegacyDefaultChromeDevtoolsEntry(name, entry)) {
|
||||
log.info(
|
||||
"[McpProxy] Default chrome-devtools MCP disabled (not installed locally; set QIMINGCLAW_ENABLE_CHROME_DEVTOOLS_MCP=1 to force npx)",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
result[name] = entry;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** mcpServers 配置(传给 qiming-mcp-stdio-proxy 的 JSON) */
|
||||
export interface McpServersConfig {
|
||||
mcpServers: Record<string, McpServerEntry>;
|
||||
@@ -1298,18 +1372,17 @@ export async function syncMcpConfigToProxyAndReload(
|
||||
// realOnly 为空时(用户删除了所有动态 MCP)不提前返回,
|
||||
// 继续执行以确保 bridge 仅运行默认服务(chrome-devtools)
|
||||
|
||||
// 始终以默认服务为基础,再叠加动态 MCP:
|
||||
// - 用户删除所有动态 MCP → merged 仅含 chrome-devtools
|
||||
// - 用户删除部分动态 MCP → merged 含 chrome-devtools + 剩余动态 MCP
|
||||
// - 用户新增动态 MCP → merged 含 chrome-devtools + 所有动态 MCP
|
||||
// 以可用的默认服务为基础,再叠加动态 MCP。默认 chrome-devtools
|
||||
// 只有本地已安装或显式启用时才注入,避免 npx @latest 在启动时阻塞 60s。
|
||||
const merged: Record<string, McpServerEntry> = {
|
||||
...DEFAULT_MCP_PROXY_CONFIG.mcpServers,
|
||||
...getDefaultMcpServers(),
|
||||
...realOnly,
|
||||
};
|
||||
const prunedMerged = pruneDisabledDefaultMcpServers(merged);
|
||||
|
||||
// 为所有 MCP 服务器注入基础环境变量(包括 PATH)
|
||||
const prepareStartedAt = Date.now();
|
||||
const mergedWithEnv = injectBaseEnvToMcpServers(merged);
|
||||
const mergedWithEnv = injectBaseEnvToMcpServers(prunedMerged);
|
||||
|
||||
// Bridge 只管理 persistent 服务(如 chrome-devtools),动态 MCP 不进 bridge。
|
||||
// 变更检测和重启均只针对 persistent servers,避免动态 MCP 变化时重启 chrome-devtools。
|
||||
|
||||
@@ -1448,10 +1448,26 @@ export async function checkQimingFileServerBundled(): Promise<{
|
||||
export async function checkClaudeCodeAcpBundled(): Promise<{
|
||||
available: boolean;
|
||||
version?: string;
|
||||
binPath?: string;
|
||||
}> {
|
||||
const bundledDir = getClaudeCodeAcpBundledDir();
|
||||
if (!bundledDir) {
|
||||
log.info("[checkClaudeCodeAcpBundled] Bundled not found");
|
||||
const local = await detectNpmPackage(
|
||||
"claude-code-acp-ts",
|
||||
"claude-code-acp-ts",
|
||||
);
|
||||
if (local.installed) {
|
||||
log.info(
|
||||
`[checkClaudeCodeAcpBundled] Bundled not found; using npm-local package: ${local.binPath ?? "unknown"}, version=${local.version ?? "unknown"}`,
|
||||
);
|
||||
return {
|
||||
available: true,
|
||||
version: local.version,
|
||||
binPath: local.binPath,
|
||||
};
|
||||
}
|
||||
|
||||
log.info("[checkClaudeCodeAcpBundled] Bundled and npm-local package not found");
|
||||
return { available: false };
|
||||
}
|
||||
const pkgPath = path.join(bundledDir, "package.json");
|
||||
@@ -1462,10 +1478,10 @@ export async function checkClaudeCodeAcpBundled(): Promise<{
|
||||
log.info(
|
||||
`[checkClaudeCodeAcpBundled] Bundled available: ${bundledDir}, version=${version ?? "unknown"}`,
|
||||
);
|
||||
return { available: true, version };
|
||||
return { available: true, version, binPath: bundledDir };
|
||||
} catch (e) {
|
||||
log.warn("[checkClaudeCodeAcpBundled] Failed to read package.json:", e);
|
||||
return { available: true };
|
||||
return { available: true, binPath: bundledDir };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1960,7 +1976,21 @@ export async function checkAllDependencies(options?: {
|
||||
item.status = "missing";
|
||||
}
|
||||
} else {
|
||||
const result = await detectNpmPackage(dep.name, dep.binName);
|
||||
item.version = result.version;
|
||||
item.binPath = result.binPath;
|
||||
if (!result.installed) {
|
||||
item.status = "missing";
|
||||
} else if (dep.installVersion) {
|
||||
const installed = (result.version ?? "0").replace(/^v/, "");
|
||||
const target = dep.installVersion.replace(/^v/, "");
|
||||
item.status =
|
||||
installed === "0" || compareVersions(installed, target) < 0
|
||||
? "outdated"
|
||||
: "installed";
|
||||
} else {
|
||||
item.status = "installed";
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -486,13 +486,15 @@ function App() {
|
||||
|
||||
const deps = result?.results ?? [];
|
||||
const hasMissingOrError = deps.some(
|
||||
(d: { status: string }) =>
|
||||
d.status === "missing" || d.status === "error",
|
||||
(d: { status: string; required?: boolean }) =>
|
||||
d.required !== false &&
|
||||
(d.status === "missing" || d.status === "error"),
|
||||
);
|
||||
const missingDeps = deps
|
||||
.filter(
|
||||
(d: { status: string }) =>
|
||||
d.status === "missing" || d.status === "error",
|
||||
(d: { status: string; required?: boolean }) =>
|
||||
d.required !== false &&
|
||||
(d.status === "missing" || d.status === "error"),
|
||||
)
|
||||
.map((d: { name: string; status: string }) => d.name);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
syncCookieAndGetNewSessionUrl,
|
||||
syncCookieAndGetChatUrl,
|
||||
persistTicketCookie,
|
||||
normalizeLocalBackendRedirect,
|
||||
} from "../../services/utils/sessionUrl";
|
||||
import { logger } from "../../services/utils/logService";
|
||||
import { APP_DISPLAY_NAME } from "@shared/constants";
|
||||
@@ -89,19 +90,47 @@ function SessionsPage({
|
||||
}
|
||||
};
|
||||
const onWillRedirect = (e: any) => {
|
||||
const normalizedUrl = normalizeLocalBackendRedirect(
|
||||
e.newURL,
|
||||
webviewUrl,
|
||||
);
|
||||
if (normalizedUrl) {
|
||||
logger.warn(
|
||||
"[SessionsPage][WebviewNav] normalized local backend redirect",
|
||||
"SessionsPage",
|
||||
{
|
||||
from: e.oldURL,
|
||||
to: e.newURL,
|
||||
normalizedUrl,
|
||||
},
|
||||
);
|
||||
e.preventDefault?.();
|
||||
el.loadURL?.(normalizedUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("[SessionsPage][WebviewNav] will-redirect", "SessionsPage", {
|
||||
from: e.oldURL,
|
||||
to: e.newURL,
|
||||
});
|
||||
};
|
||||
const onDidFailLoad = (e: any) => {
|
||||
logger.warn("[SessionsPage][WebviewNav] did-fail-load", "SessionsPage", {
|
||||
url: e.validatedURL || e.url,
|
||||
errorCode: e.errorCode,
|
||||
errorDescription: e.errorDescription,
|
||||
});
|
||||
};
|
||||
|
||||
el.addEventListener("did-navigate", onDidNavigate);
|
||||
el.addEventListener("did-navigate-in-page", onDidNavigate);
|
||||
el.addEventListener("will-redirect", onWillRedirect);
|
||||
el.addEventListener("did-fail-load", onDidFailLoad);
|
||||
return () => {
|
||||
el.removeEventListener("did-navigate", onDidNavigate);
|
||||
el.removeEventListener("did-navigate-in-page", onDidNavigate);
|
||||
el.removeEventListener("will-redirect", onWillRedirect);
|
||||
el.removeEventListener("did-fail-load", onDidFailLoad);
|
||||
};
|
||||
}, [view, webviewUrl]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
buildRedirectUrl,
|
||||
buildNewSessionUrl,
|
||||
buildChatSessionUrl,
|
||||
normalizeLocalBackendRedirect,
|
||||
syncSessionCookie,
|
||||
syncCookieAndGetRedirectUrl,
|
||||
syncCookieAndGetNewSessionUrl,
|
||||
@@ -87,6 +88,51 @@ describe("buildChatSessionUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeLocalBackendRedirect", () => {
|
||||
it("normalizes local redirects that lost protocol and port", () => {
|
||||
expect(
|
||||
normalizeLocalBackendRedirect(
|
||||
"https://localhost/home/chat/5/26?hideMenu=true",
|
||||
"http://localhost:18081/api/sandbox/config/redirect/6?hideMenu=true",
|
||||
),
|
||||
).toBe("http://localhost:18081/home/chat/5/26?hideMenu=true");
|
||||
});
|
||||
|
||||
it("normalizes localhost and 127.0.0.1 mismatches to the configured backend", () => {
|
||||
expect(
|
||||
normalizeLocalBackendRedirect(
|
||||
"https://localhost/home/chat/5/26?hideMenu=true",
|
||||
"http://127.0.0.1:18081/api/sandbox/config/redirect/6?hideMenu=true",
|
||||
),
|
||||
).toBe("http://127.0.0.1:18081/home/chat/5/26?hideMenu=true");
|
||||
});
|
||||
|
||||
it("does not rewrite already-correct local redirects", () => {
|
||||
expect(
|
||||
normalizeLocalBackendRedirect(
|
||||
"http://localhost:18081/home/chat/5/26?hideMenu=true",
|
||||
"http://localhost:18081/api/sandbox/config/redirect/6?hideMenu=true",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not rewrite remote domains", () => {
|
||||
expect(
|
||||
normalizeLocalBackendRedirect(
|
||||
"https://example.com/home/chat/5/26?hideMenu=true",
|
||||
"https://example.com/api/sandbox/config/redirect/6?hideMenu=true",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid URLs", () => {
|
||||
expect(normalizeLocalBackendRedirect("not-url", "http://localhost:18081"))
|
||||
.toBeNull();
|
||||
expect(normalizeLocalBackendRedirect("https://localhost", "not-url"))
|
||||
.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncSessionCookie", () => {
|
||||
it("不设 domain 和 secure,由主进程根据 URL scheme 判断", async () => {
|
||||
await syncSessionCookie("https://app.example.com:8080/path", "tok123");
|
||||
|
||||
@@ -66,6 +66,46 @@ export function buildChatSessionUrl(domain: string, sessionId: string): string {
|
||||
return `${normalizedDomain}/api/sandbox/config/redirect/chat/${sessionId}?hideMenu=true`;
|
||||
}
|
||||
|
||||
function isLocalBackendHost(hostname: string): boolean {
|
||||
return hostname === "localhost" || hostname === "127.0.0.1";
|
||||
}
|
||||
|
||||
/**
|
||||
* Some local backend builds redirect `http://localhost:18081/...` to
|
||||
* `https://localhost/...`, losing both the protocol and port. Normalize only
|
||||
* that local-only case so embedded webviews keep using the configured backend.
|
||||
*/
|
||||
export function normalizeLocalBackendRedirect(
|
||||
redirectUrl: string,
|
||||
expectedBackendUrl: string,
|
||||
): string | null {
|
||||
try {
|
||||
const redirect = new URL(redirectUrl);
|
||||
const expected = new URL(expectedBackendUrl);
|
||||
|
||||
if (
|
||||
!isLocalBackendHost(expected.hostname) ||
|
||||
!isLocalBackendHost(redirect.hostname)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
redirect.protocol === expected.protocol &&
|
||||
redirect.port === expected.port
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
redirect.protocol = expected.protocol;
|
||||
redirect.host = expected.host;
|
||||
const normalized = redirect.toString();
|
||||
return normalized === redirectUrl ? null : normalized;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncSessionCookie(
|
||||
domain: string,
|
||||
token: string,
|
||||
|
||||
0
qimingcode/packages/opencode/bin/qimingcode
Normal file → Executable file
0
qimingcode/packages/opencode/bin/qimingcode
Normal file → Executable file
@@ -257,9 +257,11 @@ for (const item of targets) {
|
||||
|
||||
if (Script.release) {
|
||||
for (const key of Object.keys(binaries)) {
|
||||
if (key.includes("linux")) {
|
||||
// Electron's prepare-qimingcode.js downloads .tar.gz assets for every
|
||||
// platform, including macOS and Windows. Keep zip as an additional
|
||||
// Windows-friendly artifact, but always publish the tarball contract.
|
||||
await $`tar -czf ../../${key}.tar.gz *`.cwd(`dist/${key}/bin`)
|
||||
} else {
|
||||
if (key.includes("windows")) {
|
||||
await $`zip -r ../../${key}.zip *`.cwd(`dist/${key}/bin`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import nightowl from "./theme/nightowl.json" with { type: "json" }
|
||||
import nord from "./theme/nord.json" with { type: "json" }
|
||||
import osakaJade from "./theme/osaka-jade.json" with { type: "json" }
|
||||
import onedark from "./theme/one-dark.json" with { type: "json" }
|
||||
import opencode from "./theme/opencode.json" with { type: "json" }
|
||||
import opencode from "./theme/vesper.json" with { type: "json" }
|
||||
import orng from "./theme/orng.json" with { type: "json" }
|
||||
import lucentOrng from "./theme/lucent-orng.json" with { type: "json" }
|
||||
import palenight from "./theme/palenight.json" with { type: "json" }
|
||||
|
||||
Reference in New Issue
Block a user