chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

23
qiming-file-server/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
nohup.out
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr

View File

@@ -0,0 +1,160 @@
# AI Assistant Instructions for qiming-file-server Project
## Project Overview
qiming-file-server 是一个跨平台的文件服务部署工具,支持 Windows、Linux、macOS 操作系统,提供 start/stop/restart 命令行操作。
## Core Commands
```bash
qiming-file-server start # 启动服务 (默认 env.production)
qiming-file-server start --env dev # 启动服务 (开发环境)
qiming-file-server stop # 停止服务
qiming-file-server restart # 重启服务
qiming-file-server status # 查看服务状态
```
## Project Structure
```
qiming-file-server/
├── src/
│ ├── cli.js # CLI 入口 (跨平台兼容)
│ ├── server.js # 主服务器
│ ├── routes/
│ │ └── router.js # 路由定义 (含 /health 端点)
│ ├── utils/
│ │ ├── serviceManager.js # 服务管理 (跨平台)
│ │ ├── envUtils.js # 环境变量工具
│ │ ├── build/ # 构建相关工具
│ │ ├── buildArg/ # 构建参数工具
│ │ ├── buildDependency/ # 依赖管理
│ │ ├── buildJudge/ # 构建判断工具
│ │ ├── buildPermission/ # 权限管理
│ │ ├── common/ # 公共工具
│ │ ├── computer/ # 计算机工具
│ │ ├── error/ # 错误处理
│ │ └── log/ # 日志工具
│ ├── appConfig/ # 应用配置 (环境变量等)
│ ├── config/
│ │ └── swagger/ # Swagger 文档配置
│ ├── env.development # 开发环境配置
│ ├── env.production # 生产环境配置
│ └── env.test # 测试环境配置
├── scripts/
│ ├── start-prod.js # 生产环境启动脚本
│ ├── start-dev.js # 开发环境启动脚本
│ └── pnpm-check.sh # pnpm 检查脚本
├── tests/ # 测试文件
└── package.json # 项目配置
```
## Key Implementation Details
### 1. Cross-Platform Compatibility
所有代码必须支持 Windows、Linux、macOS
- **路径处理**: 使用 `path.join()`, `path.resolve()`, `os.tmpdir()`, `os.homedir()`
- **进程管理**: 使用 `tree-kill` 杀进程组Windows 使用 `taskkill`Linux/macOS 使用 `kill` 信号
- **命令执行**: 使用 `cross-spawn` 执行 shell 命令
- **文件操作**: 使用 `fs-extra` 增强文件系统操作
### 2. Environment Configuration
- 环境变量优先级: 命令行参数 > 环境变量文件 > 默认值
- 支持通过 `--env` 参数指定环境 (development/production/test)
- 支持通过 `--env-file` 指定自定义配置文件
- 配置通过 `src/appConfig/index.js` 统一管理,环境文件位于 `src/env.*`
### 3. CLI Design
- 使用 `commander.js` 解析命令行参数
- 命令结构: `qiming-file-server <command> [options]`
- 支持的命令: start, stop, restart, status
- 全局选项: `--env`, `--port`, `--config`, `--help`
### 4. Health Check Endpoint
健康检查端点位于 `/health`,返回格式:
```json
{
"status": "ok",
"timestamp": 1738600000000,
"uptime": 3600,
"version": "1.0.0",
"platform": "darwin"
}
```
### 5. Service Management
- PID 文件存储在临时目录 (`os.tmpdir()`)
- 启动时检查服务是否已运行
- 停止时支持优雅退出 (30秒超时)
- 重启组合 stop + start 操作
## Development Guidelines
### Code Style
- 使用中文注释(根据项目规则)
- 函数必须添加 JSDoc 注释
- 跨平台代码必须包含平台检测逻辑
- 错误处理必须包含详细日志
### Testing
- 单元测试放置在 `tests/unit/` 目录
- 测试框架使用 Jest
- 关键函数必须编写单元测试
### Logging
- 使用 `src/utils/log/logUtils.js` 中的 `log()` 函数
- 日志级别: error, warn, info, debug
- 日志文件按日期分割,存储在 `LOG_BASE_DIR`
## Common Tasks
### Adding a New Route
1.`src/routes/` 目录下创建新的路由文件
2.`router.js` 中挂载路由
3. 添加相应的服务逻辑到 `src/service/` 目录
### Adding a New CLI Command
1.`src/cli.js` 中使用 `commander` 定义命令
2.`src/utils/serviceManager.js` 中实现服务逻辑
3. 更新 `README.md` 文档
### Modifying Configuration
1.`src/env.production` / `src/env.development` / `src/env.test` 中添加环境变量
2.`config/index.js` 中读取配置
3. 更新 `README.md` 文档
## Build and Deploy
```bash
# 开发环境运行
npm run dev
# 生产环境运行
npm run prod
# 运行测试
npm run test
# 发布到 npm
npm publish
```
## Important Notes
- 所有平台相关代码必须使用条件判断 (`process.platform`)
- 避免使用 Unix 特定命令grep, sed, awk 等)
- Windows 环境使用 `cross-spawn` 执行命令
- 确保日志目录有写入权限

View File

@@ -0,0 +1,147 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.2.3] - 2026-03-06
### Changed
- **发布与包管理**
- 移除 `packageManager``preinstall` 的 pnpm 强约束,支持使用 npm 安装与发布
- `prepublishOnly` 改为 `npm run build`,便于 npm 发布流程
## [1.2.2] - 2026-03-06
### Added
- **性能耗时日志**
- 所有关键 API 接口(项目初始化、更新、目录/文件列表)添加 INFO 级别耗时日志,包含 `elapsedMs` 数值字段
- 中间步骤(备份、解压、文件写入、清理等)添加 DEBUG 级别耗时日志
- 错误路径统一添加 `elapsedMs` 字段,便于排查超时和异常耗时问题
### Changed
- **日志级别优化**
- 中间步骤日志(备份、解压、文件写入等"开始"类日志)从 INFO 降级为 DEBUG减少生产环境日志噪音
- 通过 `LOG_LEVEL` 环境变量控制日志输出级别error > warn > info > debug
- **日志格式统一**
- 所有耗时字段统一使用 `elapsedMs`(数值类型,单位毫秒),便于日志聚合和监控系统解析
- 移除冗余的重复日志输出
### Fixed
- **downloadAllFiles 耗时统计修正**
- 将耗时记录从压缩准备阶段移至 `archive.on("end")` 事件,准确反映实际压缩完成时间
## [1.2.0] - 2026-02-06
### Added
- **CLI 参数解析增强**
- 支持在命令行中传递 `--KEY=VALUE` 格式的参数
- 自动将这些参数写入 `process.env` 环境变量
- 可以在启动服务时通过命令行覆盖环境变量配置
### Changed
- **命令灵活性提升**
- `start``restart` 命令现在允许接受未知选项
- 允许传递额外的自定义参数给服务
## [1.1.1] - 2026-02-04
### Added
- **CLI 版本查询增强**
- 新增 `-v` 参数作为查看版本的快捷方式(支持 `-v, --version`
## [1.1.0] - 2026-02-03
### Changed
- **ES Module 迁移**
- 全面迁移到 ES Module (ESM) 语法
- 所有 `require` 替换为 `import`
- 所有 `module.exports` 替换为 `export`
- 更新 Node.js 版本要求至 >= 22.0.0
- `package.json` 添加 `"type": "module"`
- **构建流程更新**
- 新增 `scripts/build.js` 使用 esbuild 打包
- 输出格式设置为 ESM
- 支持代码压缩和发布优化
### Added
- **LOG_BASE_DIR 容错增强**
- 当配置路径无法创建时自动回退到系统临时目录
- 避免因路径问题导致服务启动失败
## [1.0.0] - 2025-02-03
### Added
- **CLI 工具支持**
- 新增 `qiming-file-server` CLI 命令行工具
- 支持 `start``stop``restart``status` 命令
- 支持 `--port``--env``--config` 等命令行参数
- 支持通过命令行参数覆盖环境变量配置
- **跨平台支持**
- 支持 Windows、Linux、macOS 三大平台
- 使用 `cross-spawn` 确保跨平台命令执行
- 使用 `tree-kill` 实现跨平台进程终止
- PID 文件管理支持多平台临时目录
- **健康检查端点**
- 新增 `/health` HTTP 端点
- 返回服务状态、版本、运行时间、内存使用等信息
- **环境变量配置**
- 完整的配置文件加载机制
- 支持 `env.development``env.production``env.test`
- 详细的路径配置项说明
- CLI 专用配置项
- **项目文档**
- 新增 `docs/ENV.md` 完整的环境变量配置指南
- 更新 `README.md` 包含 CLI 使用说明
- 新增 `AGENTS.md``CLAUDE.md` 软链接
- **测试**
- 新增 `tests/unit/cli.test.js` 单元测试
- 覆盖服务管理器、环境变量工具、跨平台兼容性等
### Changed
- **包配置**
- `package.json` 新增 `bin` 字段支持 CLI 全局安装
- 新增 `commander``cross-spawn``fs-extra``tree-kill` 依赖
### Fixed
- **启动脚本**
- 修复 `scripts/start-cli.js` 实际加载 server.js 的问题
- 修复 `router.js` 中 package.json 路径解析问题
### Security
- 使用 `path.resolve``fs.readFileSync` 替代 `require` 确保安全性
## [0.1.0] - 2024-XX-XX
Initial release of qiming-file-server project.
### Added
- 项目基础结构
- Express 服务器配置
- 构建路由、项目路由、代码路由
- pnpm 磁盘空间优化功能
- 日志缓存管理

View File

@@ -0,0 +1 @@
AGENTS.md

201
qiming-file-server/LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,320 @@
# qiming-file-server
Cross-platform file service deployment tooling for Windows, Linux, and macOS.
## Features
- **CLI**: `start`, `stop`, `restart`, `status`
- **Cross-platform**: Windows, Linux, and macOS
- **Configuration**: Environment variables and CLI flags
- **Health endpoint**: `GET /health` for liveness checks
- **PID file**: Tracks and manages the server process
## Installation
### Local development
```bash
git clone <repository-url>
cd qiming-file-server
npm install
# Development mode
npm run dev
# Production mode (local)
npm run prod
```
### Global CLI install
```bash
# From the project root
npm install -g .
# Then run from anywhere
qiming-file-server --help
```
### Requirements
- Node.js >= 22.0.0 (native ES modules)
- zip/unzip (for project archives)
- pnpm (recommended) or npm/yarn
## CLI
Available commands:
### Basics
```bash
# Start (defaults to env.production)
qiming-file-server start
# Start with a specific env
qiming-file-server start --env development
qiming-file-server start --env production
qiming-file-server start --env test
# Stop
qiming-file-server stop
# Force stop
qiming-file-server stop --force
# Restart
qiming-file-server restart
# Status
qiming-file-server status
```
### Advanced
```bash
# Custom port
qiming-file-server start --port 8080
# Custom config file
qiming-file-server start --config /path/to/config.json
# Combined
qiming-file-server start --env development --port 3000
```
### npm scripts
```bash
npm run cli:start
npm run cli:start:dev # development
npm run cli:start:prod # production
npm run cli:start:test # test
npm run cli:stop
npm run cli:restart
npm run cli:status
```
## Environment variables
Full reference: [Environment variables](./docs/ENV.md)
### Quick example
```bash
# Defaults from env.production (typical)
qiming-file-server start --env production --port 60000
# Override paths (trim to what you need)
qiming-file-server start --env production --port 60000 \
PROJECT_SOURCE_DIR=/data/projects \
DIST_TARGET_DIR=/var/www/html \
UPLOAD_PROJECT_DIR=/data/uploads
```
### Core path variables
| Variable | Purpose |
| -------- | ------- |
| `INIT_PROJECT_DIR` | Scaffold / init project directory |
| `UPLOAD_PROJECT_DIR` | Uploaded project archives |
| `PROJECT_SOURCE_DIR` | Project source tree |
| `DIST_TARGET_DIR` | Build output (e.g. nginx root) |
| `LOG_BASE_DIR` | Log directory root |
| `COMPUTER_WORKSPACE_DIR` | “Computer” workspace |
| `COMPUTER_LOG_DIR` | “Computer” logs |
More options and scenarios: [Environment variables](./docs/ENV.md)
### CLI precedence
```bash
# Port: CLI > env > default
qiming-file-server start --env production --port 8080
```
## Health check
The server exposes `GET /health` for monitoring and probes.
### Request
```bash
curl http://localhost:60000/health
```
### Example response
```json
{
"status": "ok",
"timestamp": 1738600000000,
"uptime": 3600,
"version": "1.0.0",
"platform": "darwin",
"nodeVersion": "v22.0.0",
"pid": 12345,
"memory": {
"heapUsed": 25.5,
"heapTotal": 50.0,
"rss": 100.0,
"external": 5.0
},
"env": "production"
}
```
### Response fields
| Field | Type | Description |
| ----- | ---- | ----------- |
| status | string | `"ok"` when healthy |
| timestamp | number | Unix time (ms) |
| uptime | number | Uptime in seconds |
| version | string | Server version |
| platform | string | `darwin` / `linux` / `win32` |
| nodeVersion | string | Node.js version |
| pid | number | Process ID |
| memory | object | Memory usage (MB) |
| env | string | Active environment name |
## Cross-platform notes
### Windows
- PID file under `%TEMP%\qiming-file-server\`
- Stop uses `taskkill /F /PID`
- Paths use backslashes `\`
### Linux / macOS
- PID file under `/tmp/qiming-file-server/`
- Stop uses signals (SIGTERM / SIGKILL)
- Paths use `/`
### General
- Paths built with `path.join()`
- Temp dir from `os.tmpdir()`
- Shell commands via `cross-spawn`
- Process trees via `tree-kill`
## pnpm disk usage
Created, uploaded, or copied projects get an optimized `.npmrc` to reduce pnpm disk footprint.
### Automatic injection
`.npmrc` is applied when:
- **Create project** (`/create-project`)
- **Upload project** (`/upload-project`)
- **Copy project** (`/copy-project`)
### Inspect disk usage
```bash
npm run pnpm:check
npm run pnpm:check:dev # development
npm run pnpm:check:prod # production
npm run pnpm:check:test # test
bash scripts/pnpm-check.sh /path/to/projects
```
### Prune unused packages
```bash
npm run pnpm:prune
npm run pnpm:prune:log
```
### Scheduled prune (built-in)
The app can run a cron-style prune job. Configure with env vars, for example:
```yaml
# docker-compose.yml or .env
environment:
PNPM_PRUNE_ENABLED: "true"
PNPM_PRUNE_SCHEDULE: "0 2 * * 0"
PNPM_PRUNE_TIMEZONE: "Asia/Shanghai"
PNPM_PRUNE_RUN_ON_START: "false"
```
**Example schedules:**
```bash
"0 2 * * 0" # Sunday 02:00
"0 3 * * *" # Daily 03:00
"0 2 1 * *" # 1st of month 02:00
"0 */6 * * *" # Every 6 hours
```
### Expected benefits
- Lower disk use when many projects share dependencies (often a large share of savings vs naive installs)
- Faster installs when using a nearby registry mirror
- Automated `.npmrc`; periodic store maintenance when enabled
## Troubleshooting
### Server will not start
1. Check the port is free
2. Check log directory permissions
3. Confirm env files exist
```bash
qiming-file-server start --env development
```
### Server will not stop
```bash
qiming-file-server stop --force
ps aux | grep qiming-file-server
kill -9 <pid>
```
### Health check fails
1. Confirm the process is running
2. Confirm host/port
3. Check firewall rules
```bash
qiming-file-server status
curl http://localhost:60000/health
```
## Development
### Adding a command
In `src/cli.js`, use Commander:
```javascript
program
.command("newcommand")
.description("Description of the new command")
.option("--option", "Option description")
.action((options) => {
// handler
});
```
### Adding a setting
1. Add variables to `src/env.development` / `src/env.production` / `src/env.test`
2. Wire them through `src/appConfig/index.js` as needed
3. Document the change
## License
ISC

View File

@@ -0,0 +1,675 @@
# Environment variables — full reference
qiming-file-server can be configured entirely through environment variables. This document lists every supported variable.
## Precedence
From highest to lowest:
1. **CLI arguments**
2. **Process environment variables**
3. **Env files** (`.env.production`, `.env.development`, `.env.test` — loaded according to project conventions; see `src/env.*`)
4. **Built-in defaults**
## Env files
Files under `src/` are selected based on `NODE_ENV`:
| Mode | File | Purpose |
| ---- | ---- | ------- |
| development | `env.development` | Local development |
| production | `env.production` | Production |
| test | `env.test` | Tests |
## Core settings
### Server
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `NODE_ENV` | `development` | `development` / `production` / `test` |
| `PORT` | `60000` | HTTP listen port |
| `REQUEST_BODY_LIMIT` | `2000mb` | Express body size limit |
### Logging
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `LOG_BASE_DIR` | — | Base directory for log files |
| `LOG_LEVEL` | `debug` | `error` / `warn` / `info` / `debug` |
| `LOG_CONSOLE_ENABLED` | `true` | Mirror logs to console |
| `LOG_PREFIX_API` | `api` | Prefix for API logs |
| `LOG_PREFIX_BUILD` | `build` | Prefix for build logs |
### Project paths
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `INIT_PROJECT_NAME_REACT` | `react-vite-template` | Built-in scaffold name for React projects |
| `INIT_PROJECT_NAME_VUE3` | `vue3-vite-template` | Built-in scaffold name for Vue3 projects |
| `INIT_PROJECT_DIR` | — | Scaffold / init project directory |
| `UPLOAD_PROJECT_DIR` | — | Directory for uploaded project archives |
| `PROJECT_SOURCE_DIR` | — | Unpacked / working project sources |
| `DIST_TARGET_DIR` | — | Build output directory (e.g. served by nginx) |
| `COMPUTER_WORKSPACE_DIR` | — | “Computer” feature workspace |
| `COMPUTER_LOG_DIR` | — | “Computer” feature logs |
### Build
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `MAX_BUILD_CONCURRENCY` | `20` | Max concurrent builds |
| `DEV_SERVER_PORT_TIMEOUT` | `5000` | Dev-server port probe timeout (ms) |
| `DEV_SERVER_STOP_TIMEOUT` | `5000` | Dev-server stop timeout (ms) |
| `DEV_SERVER_STOP_CHECK_INTERVAL` | `100` | Poll interval while stopping dev server (ms) |
| `DEV_SERVER_STOP_MAX_ATTEMPTS` | `50` | Max stop retries |
### Uploads
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `MAX_INLINE_FILE_SIZE_BYTES` | `1048576` | Max size (bytes) to return inline; larger files are not inlined |
| `UPLOAD_MAX_FILE_SIZE_BYTES` | `1048576000` | Max archive upload size (bytes) |
| `UPLOAD_ALLOWED_EXTENSIONS` | `.zip` | Allowed extensions (comma-separated) |
| `UPLOAD_SINGLE_FILE_SIZE_BYTES` | `1048576000` | Max single upload size (bytes) |
### Traversal / filters
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `TRAVERSE_EXCLUDE_DIRS` | `dist,node_modules,.pnpm-store,__MACOSX,.attachments` | Comma-separated dirs to skip when traversing |
| `BACKUP_TRAVERSE_EXCLUDE_FILES` | `pnpm-lock.yaml,yarn.lock,package-lock.json` | Comma-separated files to skip in backup traversal |
| `CONTENT_TRAVERSE_EXCLUDE_FILES` | `AGENT.md,AGENTS.md,CLAUDE.md,pnpm-lock.yaml,yarn.lock,package-lock.json` | Comma-separated files to skip for content API |
| `INLINE_IMAGE_EXTENSIONS` | `.png,.jpg,.jpeg,.gif,.bmp,.svg,.ico,.webp,.avif` | Image extensions eligible for inline serving |
| `TOP_LEVEL_NOISE_PATTERNS` | `__MACOSX,Thumbs.db,node_modules,.pnpm-store,.attachments` | Comma-separated noise patterns for top-level flattening after unzip |
### pnpm prune scheduler
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `PNPM_PRUNE_ENABLED` | `true` | Enable scheduled `pnpm store` maintenance |
| `PNPM_PRUNE_SCHEDULE` | `0 2 * * *` | Cron expression |
| `PNPM_PRUNE_TIMEZONE` | `Asia/Shanghai` | Time zone for the schedule |
| `PNPM_PRUNE_RUN_ON_START` | `false` | Run once immediately on startup |
**Example cron expressions:**
| Expression | Meaning |
| ---------- | ------- |
| `0 2 * * 0` | Every Sunday 02:00 |
| `0 3 * * *` | Every day 03:00 |
| `0 2 1 * *` | 1st of month 02:00 |
| `0 */6 * * *` | Every 6 hours |
### Log cache
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `LOG_CACHE_ENABLED` | `true` | Enable in-memory log cache |
| `LOG_CACHE_DURATION` | `180000` | TTL (ms); default 3 minutes |
| `LOG_CACHE_MAX_ENTRIES` | `100` | Max cached entries |
| `LOG_CACHE_MAX_FILE_SIZE` | `2097152` | Max cached file size (bytes); default 2 MB |
### CLI-only
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `CLI_PID_DIR` | system temp | Directory for PID file |
| `CLI_PID_FILE` | `server.pid` | PID filename |
| `CLI_STOP_TIMEOUT` | `30000` | Graceful stop timeout (ms) |
| `CLI_CHECK_INTERVAL` | `500` | Poll interval when stopping (ms) |
| `CLI_LOG_DIR` | system temp | CLI log directory |
## Extra CLI environment
| Variable | Description |
| -------- | ----------- |
| `CONFIG_FILE` | Optional path to an extra env file (CLI) |
## Examples
### Use production defaults but override paths
In real deployments you often keep `env.production` as the base and only override paths:
#### Example A: production + custom paths
```bash
qiming-file-server start \
--env production \
--port 60000 \
PROJECT_SOURCE_DIR=/data/my-projects \
DIST_TARGET_DIR=/var/www/my-app \
UPLOAD_PROJECT_DIR=/data/uploads \
LOG_BASE_DIR=/var/log/qiming
```
#### Example B: multiple instances on one host
```bash
# Instance 1 — main
qiming-file-server start \
--env production \
--port 60001 \
PROJECT_SOURCE_DIR=/data/main-site \
DIST_TARGET_DIR=/var/www/main-site
# Instance 2 — staging
qiming-file-server start \
--env production \
--port 60002 \
PROJECT_SOURCE_DIR=/data/test-site \
DIST_TARGET_DIR=/var/www/test-site
# Instance 3 — internal
qiming-file-server start \
--env production \
--port 60003 \
PROJECT_SOURCE_DIR=/data/internal \
DIST_TARGET_DIR=/var/www/internal
```
> Only pass `--port` and the paths you need; everything else comes from `env.production`.
#### Example C: override file
```bash
cat > env.override << 'EOF'
# Path overrides for production
PROJECT_SOURCE_DIR=/data/override-projects
DIST_TARGET_DIR=/var/www/override-nginx
UPLOAD_PROJECT_DIR=/data/override-uploads
LOG_BASE_DIR=/var/log/override
EOF
qiming-file-server start --env production --env-file ./env.override
```
#### Example D: Kubernetes ConfigMap
```bash
kubectl create configmap qiming-overrides \
--from-literal=PROJECT_SOURCE_DIR=/data/k8s-projects \
--from-literal=DIST_TARGET_DIR=/usr/share/nginx/apps \
--from-literal=UPLOAD_PROJECT_DIR=/data/k8s-uploads \
--from-literal=LOG_BASE_DIR=/var/log/qiming
```
Reference it in the Deployment:
```yaml
envFrom:
- configMapRef:
name: qiming-overrides
```
#### Example E: full path set (illustrative)
Below lists path-related variables you might set. Trim to what you actually need:
```bash
# Production with defaults from env.production, paths overridden
qiming-file-server start \
--env production \
--port 60000
# Or set paths explicitly
qiming-file-server start \
--env production \
--port 60000 \
INIT_PROJECT_NAME_REACT=react-vite-template \
INIT_PROJECT_NAME_VUE3=vue3-vite-template \
INIT_PROJECT_DIR=/app/project_init \
UPLOAD_PROJECT_DIR=/app/project_zips \
PROJECT_SOURCE_DIR=/app/project_workspace \
DIST_TARGET_DIR=/app/project_nginx \
LOG_BASE_DIR=/app/logs/project_logs \
COMPUTER_WORKSPACE_DIR=/app/computer-project-workspace \
COMPUTER_LOG_DIR=/app/logs/computer_logs
```
#### Path variables as documented for `env.production`
| Variable | Example default | Description |
| -------- | --------------- | ----------- |
| `INIT_PROJECT_NAME_REACT` | `react-vite-template` | Built-in scaffold name for React projects |
| `INIT_PROJECT_NAME_VUE3` | `vue3-vite-template` | Built-in scaffold name for Vue3 projects |
| `INIT_PROJECT_DIR` | `/app/project_init` | Init project directory |
| `UPLOAD_PROJECT_DIR` | `/app/project_zips` | Uploaded zips |
| `PROJECT_SOURCE_DIR` | `/app/project_workspace` | Project workspace |
| `DIST_TARGET_DIR` | `/app/project_nginx` | Build output (nginx root) |
| `LOG_BASE_DIR` | `/app/logs/project_logs` | Log root |
| `COMPUTER_WORKSPACE_DIR` | `/app/computer-project-workspace` | Computer workspace |
| `COMPUTER_LOG_DIR` | `/app/logs/computer_logs` | Computer logs |
#### Path-only snippets
```bash
# 1) Only change project sources
qiming-file-server start \
--env production \
--port 60000 \
PROJECT_SOURCE_DIR=/data/my-projects
# 2) Several paths
qiming-file-server start \
--env production \
--port 60000 \
PROJECT_SOURCE_DIR=/data/projects \
DIST_TARGET_DIR=/var/www/html \
UPLOAD_PROJECT_DIR=/data/uploads
# 3) Full path set
qiming-file-server start \
--env production \
--port 60000 \
INIT_PROJECT_NAME_REACT=my-react-template \
INIT_PROJECT_NAME_VUE3=my-vue3-template \
INIT_PROJECT_DIR=/data/init \
UPLOAD_PROJECT_DIR=/data/zips \
PROJECT_SOURCE_DIR=/data/workspace \
DIST_TARGET_DIR=/var/www/nginx \
LOG_BASE_DIR=/var/logs/project_logs \
COMPUTER_WORKSPACE_DIR=/data/computer \
COMPUTER_LOG_DIR=/var/logs/computer
```
> `--port` selects the HTTP port; unset path keys keep their values from `env.production`.
#### Verify overrides
```bash
qiming-file-server status
curl http://localhost:60000/health | jq
# Check startup logs for loaded env (messages are in English, e.g. "Loaded environment files: ...")
grep -E "Loaded environment|Environment configuration file" /var/log/qiming/server.log
```
### CLI overrides precedence
CLI wins over env files and plain env:
```bash
qiming-file-server start --port 8080
qiming-file-server start --env production
qiming-file-server start --env production --port 8080
```
### Shell environment
#### Linux / macOS
```bash
export PORT=8080
export NODE_ENV=production
export LOG_LEVEL=info
# Persist in ~/.bashrc or ~/.zshrc
echo 'export PORT=8080' >> ~/.bashrc
source ~/.bashrc
```
#### Windows
```cmd
set PORT=8080
set NODE_ENV=production
setx PORT 8080
setx NODE_ENV production
```
### Docker / Compose
#### docker-compose.yml
```yaml
version: '3.8'
services:
qiming-file-server:
image: qiming-file-server:latest
container_name: qiming-file-server
restart: unless-stopped
environment:
- NODE_ENV=production
- PORT=60000
- LOG_BASE_DIR=/app/logs
- LOG_LEVEL=info
- LOG_CONSOLE_ENABLED=false
- PROJECT_SOURCE_DIR=/app/projects
- DIST_TARGET_DIR=/app/nginx
- UPLOAD_PROJECT_DIR=/app/uploads
- INIT_PROJECT_DIR=/app/init
- COMPUTER_WORKSPACE_DIR=/app/computer
- COMPUTER_LOG_DIR=/app/computer-logs
- MAX_BUILD_CONCURRENCY=20
- PNPM_PRUNE_ENABLED=true
- PNPM_PRUNE_SCHEDULE=0 3 * * *
- PNPM_PRUNE_TIMEZONE=Asia/Shanghai
- LOG_CACHE_ENABLED=true
- LOG_CACHE_DURATION=180000
- LOG_CACHE_MAX_ENTRIES=100
- LOG_CACHE_MAX_FILE_SIZE=2097152
volumes:
- ./projects:/app/projects
- ./logs:/app/logs
- ./nginx:/app/nginx
- ./uploads:/app/uploads
- ./init:/app/init
- ./computer:/app/computer
ports:
- "60000:60000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:60000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
```
#### Dockerfile
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN mkdir -p /app/logs /app/projects /app/nginx /app/uploads /app/init /app/computer
EXPOSE 60000
CMD ["node", "scripts/start-cli.js"]
```
### Sample env files
#### .env.production
```bash
# ==================== Server ====================
NODE_ENV=production
PORT=60000
REQUEST_BODY_LIMIT=2000mb
# ==================== Logging ====================
LOG_BASE_DIR=/app/logs
LOG_LEVEL=info
LOG_CONSOLE_ENABLED=false
LOG_PREFIX_API=api
LOG_PREFIX_BUILD=build
# ==================== Paths ====================
INIT_PROJECT_NAME_REACT=react-vite-template
INIT_PROJECT_NAME_VUE3=vue3-vite-template
INIT_PROJECT_DIR=/app/project_init
UPLOAD_PROJECT_DIR=/app/project_zips
PROJECT_SOURCE_DIR=/app/project_workspace
DIST_TARGET_DIR=/app/project_nginx
COMPUTER_WORKSPACE_DIR=/app/computer-project-workspace
COMPUTER_LOG_DIR=/app/logs/computer_logs
# ==================== Build ====================
MAX_BUILD_CONCURRENCY=20
DEV_SERVER_PORT_TIMEOUT=5000
DEV_SERVER_STOP_TIMEOUT=5000
DEV_SERVER_STOP_CHECK_INTERVAL=100
DEV_SERVER_STOP_MAX_ATTEMPTS=50
# ==================== Uploads ====================
MAX_INLINE_FILE_SIZE_BYTES=1048576
UPLOAD_MAX_FILE_SIZE_BYTES=1048576000
UPLOAD_ALLOWED_EXTENSIONS=.zip
UPLOAD_SINGLE_FILE_SIZE_BYTES=1048576000
# ==================== Traversal / filters ====================
TRAVERSE_EXCLUDE_DIRS=dist,node_modules,.pnpm-store,__MACOSX,.attachments
BACKUP_TRAVERSE_EXCLUDE_FILES=pnpm-lock.yaml,yarn.lock,package-lock.json
CONTENT_TRAVERSE_EXCLUDE_FILES=AGENT.md,AGENTS.md,CLAUDE.md,pnpm-lock.yaml,yarn.lock,package-lock.json
INLINE_IMAGE_EXTENSIONS=.png,.jpg,.jpeg,.gif,.bmp,.svg,.ico,.webp,.avif
TOP_LEVEL_NOISE_PATTERNS=__MACOSX,Thumbs.db,node_modules,.pnpm-store,.attachments
# ==================== pnpm prune ====================
PNPM_PRUNE_ENABLED=true
PNPM_PRUNE_SCHEDULE=0 2 * * *
PNPM_PRUNE_TIMEZONE=Asia/Shanghai
PNPM_PRUNE_RUN_ON_START=false
# ==================== Log cache ====================
LOG_CACHE_ENABLED=true
LOG_CACHE_DURATION=180000
LOG_CACHE_MAX_ENTRIES=100
LOG_CACHE_MAX_FILE_SIZE=2097152
# ==================== CLI ====================
CLI_PID_DIR=/tmp/qiming-file-server
CLI_STOP_TIMEOUT=30000
CLI_CHECK_INTERVAL=500
CLI_LOG_DIR=/tmp/qiming-file-server/logs
```
#### .env.development
```bash
# ==================== Server ====================
NODE_ENV=development
PORT=60000
REQUEST_BODY_LIMIT=2000mb
# ==================== Logging ====================
LOG_BASE_DIR=./logs
LOG_LEVEL=debug
LOG_CONSOLE_ENABLED=true
LOG_PREFIX_API=api
LOG_PREFIX_BUILD=build
# ==================== Paths ====================
INIT_PROJECT_NAME_REACT=react-vite-template
INIT_PROJECT_NAME_VUE3=vue3-vite-template
INIT_PROJECT_DIR=./project_init
UPLOAD_PROJECT_DIR=./project_zips
PROJECT_SOURCE_DIR=./project_workspace
DIST_TARGET_DIR=./project_nginx
COMPUTER_WORKSPACE_DIR=./computer-project-workspace
COMPUTER_LOG_DIR=./computer_logs
# ==================== Build ====================
MAX_BUILD_CONCURRENCY=20
DEV_SERVER_PORT_TIMEOUT=5000
DEV_SERVER_STOP_TIMEOUT=5000
DEV_SERVER_STOP_CHECK_INTERVAL=100
DEV_SERVER_STOP_MAX_ATTEMPTS=50
# ==================== Uploads ====================
MAX_INLINE_FILE_SIZE_BYTES=1048576
UPLOAD_MAX_FILE_SIZE_BYTES=1048576000
UPLOAD_ALLOWED_EXTENSIONS=.zip
UPLOAD_SINGLE_FILE_SIZE_BYTES=1048576000
# ==================== Traversal / filters ====================
TRAVERSE_EXCLUDE_DIRS=dist,node_modules,.pnpm-store,__MACOSX,.attachments
BACKUP_TRAVERSE_EXCLUDE_FILES=pnpm-lock.yaml,yarn.lock,package-lock.json
CONTENT_TRAVERSE_EXCLUDE_FILES=AGENT.md,AGENTS.md,CLAUDE.md,pnpm-lock.yaml,yarn.lock,package-lock.json
INLINE_IMAGE_EXTENSIONS=.png,.jpg,.jpeg,.gif,.bmp,.svg,.ico,.webp,.avif
TOP_LEVEL_NOISE_PATTERNS=__MACOSX,Thumbs.db,node_modules,.pnpm-store,.attachments
# ==================== pnpm prune ====================
PNPM_PRUNE_ENABLED=false
PNPM_PRUNE_SCHEDULE=0 2 * * *
PNPM_PRUNE_TIMEZONE=Asia/Shanghai
PNPM_PRUNE_RUN_ON_START=false
# ==================== Log cache ====================
LOG_CACHE_ENABLED=true
LOG_CACHE_DURATION=180000
LOG_CACHE_MAX_ENTRIES=100
LOG_CACHE_MAX_FILE_SIZE=2097152
# ==================== CLI ====================
CLI_PID_DIR=./tmp
CLI_STOP_TIMEOUT=30000
CLI_CHECK_INTERVAL=500
CLI_LOG_DIR=./tmp/logs
```
### Kubernetes example
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: qiming-file-server-config
data:
NODE_ENV: "production"
PORT: "60000"
LOG_BASE_DIR: "/app/logs"
LOG_LEVEL: "info"
LOG_CONSOLE_ENABLED: "false"
PROJECT_SOURCE_DIR: "/app/projects"
DIST_TARGET_DIR: "/app/nginx"
UPLOAD_PROJECT_DIR: "/app/uploads"
INIT_PROJECT_DIR: "/app/init"
COMPUTER_WORKSPACE_DIR: "/app/computer"
COMPUTER_LOG_DIR: "/app/computer-logs"
MAX_BUILD_CONCURRENCY: "20"
PNPM_PRUNE_ENABLED: "true"
PNPM_PRUNE_SCHEDULE: "0 3 * * *"
PNPM_PRUNE_TIMEZONE: "Asia/Shanghai"
LOG_CACHE_ENABLED: "true"
LOG_CACHE_DURATION: "180000"
LOG_CACHE_MAX_ENTRIES: "100"
LOG_CACHE_MAX_FILE_SIZE: "2097152"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: qiming-file-server
spec:
replicas: 1
selector:
matchLabels:
app: qiming-file-server
template:
metadata:
labels:
app: qiming-file-server
spec:
containers:
- name: qiming-file-server
image: qiming-file-server:latest
ports:
- containerPort: 60000
envFrom:
- configMapRef:
name: qiming-file-server-config
volumeMounts:
- name: projects
mountPath: /app/projects
- name: logs
mountPath: /app/logs
- name: nginx
mountPath: /app/nginx
- name: uploads
mountPath: /app/uploads
- name: init
mountPath: /app/init
- name: computer
mountPath: /app/computer
livenessProbe:
httpGet:
path: /health
port: 60000
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 60000
initialDelaySeconds: 5
periodSeconds: 10
volumes:
- name: projects
hostPath:
path: /data/projects
- name: logs
hostPath:
path: /data/logs
- name: nginx
hostPath:
path: /data/nginx
- name: uploads
hostPath:
path: /data/uploads
- name: init
hostPath:
path: /data/init
- name: computer
hostPath:
path: /data/computer
```
## Verifying configuration
```bash
curl http://localhost:60000/health
```
The JSON body includes the active environment name (e.g. `"env": "production"`).
## Troubleshooting
### Changes not applied
1. Confirm the correct env file path and `NODE_ENV`.
2. Variable names are case-sensitive.
3. Validate YAML/JSON if you embed config in manifests.
4. Read server logs for load errors.
### Port already in use
If `PORT` is taken, startup fails. Pick another port or free the listener.
### Permissions
Ensure log and data directories are writable:
```bash
chmod -R 755 /path/to/logs
mkdir -p /path/to/logs /path/to/projects
```
### Docker env not visible
Pass variables with `environment:` or `envFrom:`:
```yaml
environment:
- NODE_ENV=production
- PORT=60000
envFrom:
- configMapRef:
name: my-config
```
## Related docs
- [README.md](../README.md) — Overview
- [PNPM_CHECK.md](./PNPM_CHECK.md) — pnpm disk usage helper

View File

@@ -0,0 +1,106 @@
# pnpm-check usage
## Overview
The `pnpm-check` script inspects and analyzes pnpm disk usage. It supports multiple environment configurations.
## How to run
### Option 1: npm scripts (recommended)
The script reads the project directory from the current `NODE_ENV` configuration.
```bash
# Auto-detect current environment (defaults to development)
npm run pnpm:check
# Development
npm run pnpm:check:dev
# Production
npm run pnpm:check:prod
# Test
npm run pnpm:check:test
```
### Option 2: Wrapper script
```bash
# Use current environment variables
node scripts/pnpm-check-wrapper.js
# Pin environment
NODE_ENV=production node scripts/pnpm-check-wrapper.js
```
### Option 3: Bash script directly
```bash
# Pass project directory explicitly
bash scripts/pnpm-check.sh /path/to/projects
# Via environment variable
PROJECT_SOURCE_DIR=/path/to/projects bash scripts/pnpm-check.sh
```
## Output
### 1. pnpm version
```
✅ pnpm version: 10.18.2
```
Shows the installed pnpm version.
### 2. pnpm store
```
📁 pnpm Store path:
/Users/xxx/.pnpm-store
Store size: 2.5G
```
- **Store path**: Global content-addressable store location
- **Store size**: **Actual disk usage**; all projects share this store
### 3. Store status
```
📊 pnpm Store status:
Packages in the store are untouched
```
Shows how packages in the store are used (`pnpm store status`).
### 4. Per-project `node_modules` size
```
📦 Per-project node_modules (apparent size):
⚠️ Note: du double-counts hard links; real usage is much lower
[project-a] 500MB (includes hard-link double counting)
[project-b] 500MB (includes hard-link double counting)
```
⚠️ **Important**: Figures come from `du` and **double-count hard links**; real usage is usually much lower.
### 5. `.pnpm` folder size
```
🗂️ Per-project .pnpm folders (apparent size):
⚠️ Note: entries in .pnpm are hard links; little extra space per project
[project-a] 400MB (hard links, shared in store)
[project-b] 400MB (hard links, shared in store)
```
⚠️ **Important**: Files under `.pnpm` are hard links; they do not each consume separate space on disk.
### 6. Real disk usage
```
💾 Filesystem usage (df):
Filesystem: /dev/disk1 | Used: 150GB | Avail: 200GB | Use%: 43%
```
Using `df` on the filesystem is the most reliable way to see actual free space.

View File

@@ -0,0 +1,318 @@
# Quick start
This guide helps you get started with the qiming-file-server CLI.
## 1. Local testing
From the project root:
```bash
# Install dependencies
pnpm install
# Start the server (development)
pnpm run cli:start:dev
# Check status
pnpm run cli:status
# Health check
curl http://localhost:60000/health
# Stop the server
pnpm run cli:stop
```
## 2. Global install
```bash
# Global install
npm install -g .
# or
pnpm add -g .
# Then run from any directory
qiming-file-server start --env development
qiming-file-server status
qiming-file-server stop
```
## 3. Commands
| Command | Description |
| ------- | ----------- |
| `qiming-file-server start` | Start (default: production) |
| `qiming-file-server start --env dev` | Start (development) |
| `qiming-file-server stop` | Stop |
| `qiming-file-server restart` | Restart |
| `qiming-file-server status` | Status |
| `qiming-file-server --help` | Help |
## 4. Health check
```bash
curl http://localhost:60000/health
```
### Example response
```json
{
"status": "ok",
"timestamp": 1738600000000,
"uptime": 3600,
"version": "1.0.0",
"platform": "darwin",
"nodeVersion": "v22.0.0",
"pid": 12345,
"memory": {
"heapUsed": 25.5,
"heapTotal": 50.0,
"rss": 100.0,
"external": 5.0
},
"env": "production"
}
```
## 5. Environment variables
### Defaults
```bash
# Use defaults from env.production
qiming-file-server start --env production --port 60000
```
### Override paths
```bash
# Override path-related variables as needed
qiming-file-server start --env production --port 60000 \
PROJECT_SOURCE_DIR=/data/projects \
DIST_TARGET_DIR=/var/www/html \
UPLOAD_PROJECT_DIR=/data/uploads
```
See the full reference: [Environment variables](./ENV.md)
## 6. Troubleshooting
### Server will not start
Check if the port is in use:
```bash
lsof -i :60000
# or
netstat -tlnp | grep 60000
```
### Stop fails
Force stop:
```bash
qiming-file-server stop --force
```
### Logs / sanity check
```bash
qiming-file-server status
curl http://localhost:60000/health
```
## 7. Docker
```yaml
# docker-compose.yml
version: "3.8"
services:
qiming-file-server:
image: qiming-file-server:latest
container_name: qiming-file-server
restart: unless-stopped
environment:
- NODE_ENV=production
- PORT=60000
- PROJECT_SOURCE_DIR=/app/projects
- DIST_TARGET_DIR=/var/www/html
- UPLOAD_PROJECT_DIR=/app/uploads
- LOG_BASE_DIR=/app/logs
volumes:
- ./projects:/app/projects
- ./logs:/app/logs
ports:
- "60000:60000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:60000/health"]
interval: 30s
timeout: 10s
retries: 3
```
## 8. Tests
The repo includes several ways to exercise the CLI.
### 8.1 Run all tests
```bash
pnpm run test:run
pnpm run test:unit
pnpm run test:integration
```
### 8.2 CLI unit tests
```bash
npx jest tests/unit/cli.test.js
pnpm run test:unit -- --testPathPattern=cli.test.js
```
**Coverage areas:**
| Group | Topics |
| ----- | ------ |
| Service Manager | PID file, process state, service status |
| Environment Utils | Env resolution, typing, CLI parsing |
| Cross-Platform | Platform detection, paths |
| Config | CLI-specific config validation |
### 8.3 Manual checks
#### Development
```bash
pnpm run cli:start:dev
pnpm run cli:status
curl http://localhost:60000/health
pnpm run cli:stop
```
#### Production
```bash
pnpm run cli:start:prod
pnpm run cli:status
curl http://localhost:60000/health
pnpm run cli:restart
pnpm run cli:stop
```
#### Custom config
```bash
qiming-file-server start --env development --port 60001
qiming-file-server start --env development --port 60002 \
PROJECT_SOURCE_DIR=/data/test-projects \
DIST_TARGET_DIR=/var/www/test-nginx
qiming-file-server status
qiming-file-server stop
```
### 8.4 Health endpoint
```bash
curl http://localhost:60000/health
curl http://localhost:60000/health | jq
curl http://localhost:60000/health | jq '.status'
curl http://localhost:60000/health | jq '.uptime'
curl http://localhost:60000/health | jq '.memory'
```
### 8.5 Cross-platform
#### macOS / Linux
```bash
qiming-file-server start --env production
ps aux | grep qiming-file-server
qiming-file-server stop
```
#### Windows
```cmd
qiming-file-server start --env production
tasklist | findstr qiming-file-server
qiming-file-server stop --force
```
### 8.6 Automation script (`test-cli.sh`)
Create `test-cli.sh`:
```bash
#!/bin/bash
# qiming-file-server CLI smoke test
set -e
echo "=== qiming-file-server CLI test ==="
PORT=60000
HEALTH_URL="http://localhost:${PORT}/health"
test_command() {
local cmd=$1
local description=$2
echo "Test: ${description}"
echo "Command: ${cmd}"
eval "${cmd}"
echo "✓ done"
echo ""
}
echo "Cleaning up..."
qiming-file-server stop 2>/dev/null || true
sleep 1
test_command "qiming-file-server start --env production --port ${PORT}" "start server"
sleep 3
echo "Test: health check"
curl -s "${HEALTH_URL}" | jq '.status'
echo "✓ health OK"
echo ""
test_command "qiming-file-server status" "status"
test_command "qiming-file-server restart" "restart"
sleep 3
echo "Test: health after restart"
curl -s "${HEALTH_URL}" | jq '.status'
echo "✓ health OK after restart"
echo ""
test_command "qiming-file-server stop" "stop"
echo "=== All tests passed ==="
```
Run it:
```bash
chmod +x test-cli.sh
./test-cli.sh
```
## 9. Related docs
- [README.md](../README.md) — Main readme
- [ENV.md](./ENV.md) — Environment variables
- [CHANGELOG.md](../CHANGELOG.md) — Changelog

View File

@@ -0,0 +1,165 @@
# test-cli.sh usage
`test-cli.sh` is the CLI automation test script for qiming-file-server. It verifies locally that `qiming-file-server` can start, report status, answer health checks, restart, and stop.
---
## Quick start
```bash
# Grant execute permission (first time only)
chmod +x scripts/test-cli.sh
# Default: pnpm install + pnpm link, then test
./scripts/test-cli.sh
# Run dist directly (no global link)
./scripts/test-cli.sh --direct
# Already installed globally; skip install steps
./scripts/test-cli.sh --installed
```
---
## Requirements
| Dependency | Notes |
|------------|--------|
| bash | Script runtime |
| curl | Health endpoint requests |
| jq | JSON parsing |
| pnpm | Required for modes 1 and 2 (optional for mode 3) |
| node | v22+ recommended |
Install example (macOS):
```bash
brew install curl jq
# Install pnpm / node per project requirements
```
---
## Three test modes
### Mode 1: Default (pnpm link)
- **Command**: `./scripts/test-cli.sh` (no extra flags)
- **Behavior**:
- Runs `pnpm install` in the project root
- Runs `pnpm run build` to compile into `dist/`
- Runs `pnpm link --global` so `qiming-file-server` is available globally
- Tests using the global `qiming-file-server` command
- **Use when**: Local development, first-time verification, or when not installed globally
### Mode 2: Direct run (`--direct`)
- **Command**: `./scripts/test-cli.sh --direct`
- **Behavior**:
- Does not run `pnpm link`; runs `node dist/cli.js` directly
- Requires a built project: `dist/` exists and `dist/cli.js` exists
- **Prerequisite**: Run `pnpm run build` first
- **Use when**: You do not want to change global commands and only want to test the current build
### Mode 3: Installed (`--installed`)
- **Command**: `./scripts/test-cli.sh --installed`
- **Behavior**:
- No install, no build, no link; calls the installed `qiming-file-server` directly
- Fails with a hint if `qiming-file-server` is not found
- **Prerequisite**: Installed via `npm install -g qiming-file-server` or `pnpm link --global`
- **Use when**: Verifying a global install, CI, or an environment where the CLI is already installed
---
## Command-line options
| Option | Short | Description | Default |
|--------|-------|-------------|---------|
| `--port <port>` | `-p` | Port for the test server | `60000` |
| `--direct` | — | Mode 2: run dist with Node directly | — |
| `--installed` | — | Mode 3: use globally installed CLI | — |
| `--project-dir <dir>` | — | Project directory | `./test-projects` |
| `--nginx-dir <dir>` | — | Nginx directory | `./test-nginx` |
| `--upload-dir <dir>` | — | Upload directory | `./test-uploads` |
| `--help` | `-h` | Show help | — |
---
## Environment variables
These override defaults (equivalent to CLI options where applicable):
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Server port | `60000` |
| `PROJECT_DIR` | Project directory | `./test-projects` |
| `NGINX_DIR` | Nginx directory | `./test-nginx` |
| `UPLOAD_DIR` | Upload directory | `./test-uploads` |
Example:
```bash
PORT=60001 ./scripts/test-cli.sh --installed
```
---
## Test flow
The script runs in order:
1. **Test 1: Start**`start --env production --port <PORT>`, wait ~3 seconds.
2. **Test 2: Health**`GET http://localhost:<PORT>/health`, assert `status === "ok"` in JSON.
3. **Test 3: Status**`status`, check output looks normal.
4. **Test 4: Restart**`restart`, wait ~3 seconds.
5. **Test 5: Health after restart**`GET /health` again.
6. **Test 6: Stop**`stop`.
On exit or error, cleanup runs (stop service; mode 1 also runs `pnpm unlink --global`).
---
## Examples
```bash
# Default mode, default port 60000
./scripts/test-cli.sh
# Mode 2, custom port 60001
./scripts/test-cli.sh --direct --port 60001
# Mode 3, custom port
./scripts/test-cli.sh --installed -p 60001
# Custom project/nginx/upload dirs
./scripts/test-cli.sh --project-dir ./my-projects --nginx-dir ./my-nginx --upload-dir ./my-uploads
# Help
./scripts/test-cli.sh --help
```
---
## FAQ
**Q: `dist` missing or `dist/cli.js` missing?**
A: Run `pnpm run build` before `--direct`, or use the default mode (builds automatically).
**Q: `qiming-file-server` command not found?**
A: For `--installed`, install globally first, e.g. `npm install -g qiming-file-server` or `pnpm link --global` in this repo. Or use default mode or `--direct`.
**Q: Port in use?**
A: Use `-p`/`--port` or `PORT`, e.g. `./scripts/test-cli.sh -p 60001`.
**Q: Missing curl / jq?**
A: The script checks dependencies and reports. On macOS: `brew install curl jq`.
---
## Related docs
- [QUICK_START.md](./QUICK_START.md) — Quick start
- [ENV.md](./ENV.md) — Environment and configuration
- [CLAUDE.md](../CLAUDE.md) / [AGENTS.md](../AGENTS.md) — Project layout and conventions

View File

@@ -0,0 +1,41 @@
export default {
testEnvironment: "node",
// 测试文件匹配模式
testMatch: ["**/tests/**/*.test.js", "**/__tests__/**/*.test.js"],
// 覆盖率收集
collectCoverageFrom: [
"src/**/*.js",
"!src/server.js",
"!src/config/**",
"!**/node_modules/**",
],
// 覆盖率阈值
coverageThreshold: {
global: {
branches: 50,
functions: 50,
lines: 50,
statements: 50,
},
},
// 覆盖率报告格式
coverageReporters: ["text", "lcov", "html"],
// 测试超时时间(毫秒)
testTimeout: 10000,
// 显示详细信息
verbose: true,
// 清除模拟
clearMocks: true,
resetMocks: true,
restoreMocks: true,
// 设置测试环境变量
setupFilesAfterEnv: ["<rootDir>/tests/setup.js"],
};

7695
qiming-file-server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
{
"name": "qiming-file-server",
"displayName": "qiming-file-server",
"version": "1.2.9",
"description": "Cross-platform file service deployment tool with start/stop/restart CLI commands",
"type": "module",
"main": "index.js",
"bin": {
"qiming-file-server": "./dist/cli.js"
},
"files": [
"dist"
],
"engines": {
"node": ">=22.0.0"
},
"os": [
"darwin",
"linux",
"win32"
],
"scripts": {
"start": "nodemon src/server.js",
"dev": "nodemon scripts/start-dev.js",
"test": "nodemon scripts/start-test.js",
"prod": "node scripts/start-prod.js",
"test:run": "NODE_OPTIONS='--experimental-vm-modules' jest --coverage",
"test:watch": "jest --watch",
"test:unit": "jest --testPathPattern=tests/unit",
"test:integration": "jest --testPathPattern=tests/integration",
"start:test": "nodemon scripts/start-test.js",
"pnpm:check:dev": "NODE_ENV=development node scripts/pnpm-check-wrapper.js",
"pnpm:check:prod": "NODE_ENV=production node scripts/pnpm-check-wrapper.js",
"pnpm:check:test": "NODE_ENV=test node scripts/pnpm-check-wrapper.js",
"pnpm:prune": "pnpm store prune",
"pnpm:prune:manual": "node scripts/pnpm-prune-manual.js",
"pnpm:status": "pnpm store status",
"cli:start": "node src/cli.js start",
"cli:stop": "node src/cli.js stop",
"cli:restart": "node src/cli.js restart",
"cli:status": "node src/cli.js status",
"cli:start:dev": "node src/cli.js start --env development",
"cli:start:prod": "node src/cli.js start --env production",
"cli:start:test": "node src/cli.js start --env test",
"build": "node scripts/build.js",
"prepublishOnly": "npm run build"
},
"author": "",
"license": "ISC",
"keywords": [
"file-server",
"deployer",
"cli",
"cross-platform",
"qiming"
],
"repository": {
"type": "git",
"url": ""
},
"bugs": {
"url": ""
},
"homepage": "",
"dependencies": {
"archiver": "^5.3.2",
"commander": "^12.0.0",
"concurrently": "^9.2.1",
"cross-spawn": "^7.0.6",
"dotenv": "^17.2.2",
"express": "^5.1.0",
"fs-extra": "^11.2.0",
"iconv-lite": "^0.7.0",
"isbinaryfile": "^5.0.6",
"json-bigint": "^1.0.0",
"mime-types": "^3.0.1",
"multer": "^2.0.2",
"node-cron": "^3.0.3",
"pm2": "^6.0.11",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0",
"tree-kill": "^1.2.2",
"uuid": "^13.0.0",
"yauzl": "^3.2.0"
},
"devDependencies": {
"@types/jest": "^29.5.12",
"esbuild": "^0.24.2",
"jest": "^29.7.0",
"nodemon": "^3.1.10",
"supertest": "^6.3.4"
}
}

5025
qiming-file-server/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,217 @@
#!/usr/bin/env node
/**
* qiming-file-server 发 npm 前编译脚本ESM
*
* 使用方法:
* - 发布(默认): node scripts/build.js
* - 本地调试: node scripts/build.js --all
*
* env 文件位于 src/,构建时复制 env.development、env.production 到 dist/
* 使用 --all 参数会额外复制其余 src/env.*(如 env.test
*/
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const distRoot = path.join(projectRoot, "dist");
const pkgPath = path.join(projectRoot, "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
const version = pkg.version;
// 获取命令行参数
const args = process.argv.slice(2);
const debugMode = args.includes("--all");
/**
* 递归查找目录下所有 .js 文件
*/
function findJsFiles(dir, files = []) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
const fullPath = path.join(dir, e.name);
if (e.isDirectory()) findJsFiles(fullPath, files);
else if (e.name.endsWith(".js")) files.push(fullPath);
}
return files;
}
/**
* 使用 esbuild 压缩 JS 文件
* @param {string} dir 目标目录
* @param {object} esbuild esbuild 实例
*/
async function compressJsFiles(esbuild, dir) {
if (!fs.existsSync(dir)) return;
const jsFiles = findJsFiles(dir);
let totalSaved = 0;
for (const file of jsFiles) {
const code = fs.readFileSync(file, "utf8");
const result = await esbuild.transform(code, {
minify: true,
target: "node22",
});
fs.writeFileSync(file, result.code, "utf8");
const originalSize = Buffer.byteLength(code, "utf8");
const compressedSize = Buffer.byteLength(result.code, "utf8");
const saved = originalSize - compressedSize;
totalSaved += saved;
console.log(`[build] 压缩 ${path.relative(distRoot, file)}: ${(originalSize / 1024).toFixed(1)}KB -> ${(compressedSize / 1024).toFixed(1)}KB`);
}
console.log(`[build] 压缩 ${path.relative(distRoot, dir)} 完成,节省 ${(totalSaved / 1024).toFixed(1)}KB`);
}
/**
* 复制单个文件
*/
function copyFileSync(src, dest) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
}
/**
* 复制目录内容(不包含顶层目录)
* srcDir/abc/xyz.js -> destDir/abc/xyz.js
*/
function copyDirContentsSync(srcDir, destDir) {
if (!fs.existsSync(srcDir)) return;
fs.mkdirSync(destDir, { recursive: true });
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
for (const e of entries) {
const srcPath = path.join(srcDir, e.name);
const destPath = path.join(destDir, e.name);
if (e.isDirectory()) {
copyDirContentsSync(srcPath, destPath);
} else if (e.isFile() && e.name.endsWith(".js")) {
fs.copyFileSync(srcPath, destPath);
}
}
}
async function build() {
const esbuild = (await import("esbuild")).default;
// ---------- 0. 清理旧目录 ----------
const oldSrcDir = path.join(distRoot, "src");
if (fs.existsSync(oldSrcDir)) {
fs.rmSync(oldSrcDir, { recursive: true, force: true });
console.log("[build] Old src/ directory deleted");
}
// ---------- 1. 打包 CLI ----------
await esbuild.build({
entryPoints: [path.join(projectRoot, "src", "cli.js")],
bundle: true,
platform: "node",
target: "node22",
outfile: path.join(distRoot, "cli.js"),
format: "esm",
external: ["commander", "cross-spawn", "fs-extra", "tree-kill"],
define: { __BUILD_VERSION__: JSON.stringify(version) },
minify: true,
sourcemap: false,
});
console.log("[build] dist/cli.js output");
// ---------- 2. 复制 src 下的子目录到 dist 根目录 ----------
// appConfig/* -> dist/appConfig/* 应用配置(环境变量等)
// config/* -> dist/config/* Swagger/API 文档
// routes/* -> dist/routes/*
// scheduler/* -> dist/scheduler/*
// service/* -> dist/service/*
// utils/* -> dist/utils/*
// server.js -> dist/server.js单独复制
const srcRoot = path.join(projectRoot, "src");
// 复制子目录(与 src 结构同步appConfig、routes、scheduler、service、utils
const subdirs = ["appConfig", "routes", "scheduler", "service", "utils"];
for (const subdir of subdirs) {
const srcPath = path.join(srcRoot, subdir);
const destPath = path.join(distRoot, subdir);
copyDirContentsSync(srcPath, destPath);
console.log(`[build] ${subdir}/ -> dist/${subdir}/ copied`);
}
// 单独复制 src/config/ 下的 swagger 相关文件(移除遗留的 dist/config/index.js应用配置已迁至 appConfig
const srcConfigPath = path.join(srcRoot, "config");
const destConfigPath = path.join(distRoot, "config");
const legacyConfigIndex = path.join(destConfigPath, "index.js");
if (fs.existsSync(legacyConfigIndex)) fs.rmSync(legacyConfigIndex);
copyDirContentsSync(srcConfigPath, destConfigPath);
console.log(`[build] config/ -> dist/config/ copied`);
// 单独复制 server.js 到 dist 根目录(无需修改导入路径,源码已用 ./appConfig
const serverJsSrc = path.join(srcRoot, "server.js");
const serverJsDest = path.join(distRoot, "server.js");
copyFileSync(serverJsSrc, serverJsDest);
console.log("[build] server.js -> dist/ copied");
// ---------- 3. 压缩 dist 下所有 JS 文件CLI 除外) ----------
const distJsFiles = findJsFiles(distRoot);
let totalSaved = 0;
for (const file of distJsFiles) {
// 跳过已压缩的 cli.js
if (path.basename(file) === "cli.js") continue;
const code = fs.readFileSync(file, "utf8");
const result = await esbuild.transform(code, {
minify: true,
target: "node22",
});
fs.writeFileSync(file, result.code, "utf8");
const originalSize = Buffer.byteLength(code, "utf8");
const compressedSize = Buffer.byteLength(result.code, "utf8");
const saved = originalSize - compressedSize;
totalSaved += saved;
console.log(`[build] 压缩 ${path.relative(distRoot, file)}: ${(originalSize / 1024).toFixed(1)}KB -> ${(compressedSize / 1024).toFixed(1)}KB`);
}
console.log(`[build] Compression completed, saving ${(totalSaved / 1024).toFixed(1)}KB`);
// ---------- 4. 复制 env 文件(从 src/ 到 dist/ ----------
// 发布包需包含 development + production以便 CLI --env production 能正常启动
const envToShip = ["env.development", "env.production"];
for (const envFile of envToShip) {
const src = path.join(srcRoot, envFile);
if (fs.existsSync(src)) {
fs.copyFileSync(src, path.join(distRoot, envFile));
console.log(`[build] ${envFile} -> dist/ copied`);
}
}
// 调试模式下额外复制其余 env.*(如 env.test
if (debugMode) {
const envFiles = fs.readdirSync(srcRoot).filter((f) => f.startsWith("env."));
for (const envFile of envFiles) {
if (!envToShip.includes(envFile)) {
fs.copyFileSync(path.join(srcRoot, envFile), path.join(distRoot, envFile));
console.log(`[build] ${envFile} -> dist/ copied`);
}
}
}
// ---------- 5. 输出启动说明 ----------
console.log("");
console.log("[build] =========================================");
console.log("[build] Build completed!");
console.log("[build] =========================================");
console.log("[build] Start service: cd dist && node server.js");
console.log("[build] Or use CLI: node cli.js start");
console.log("");
}
build().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,287 @@
#!/usr/bin/env node
/**
* 检查指定包是否在 pnpm store 中,以及下载原因诊断工具
*
* 使用方法:
* node scripts/check-package-in-store.js @radix-ui/react-toggle
* node scripts/check-package-in-store.js @radix-ui/react-toggle 1.0.3
*/
import { execSync, spawn } from "child_process";
import path from "path";
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const packageName = process.argv[2];
const packageVersion = process.argv[3];
if (!packageName) {
console.error("❌ Please provide package name");
console.log("\nUsage:");
console.log(" node scripts/check-package-in-store.js <package-name> [version]");
console.log("\nExamples:");
console.log(" node scripts/check-package-in-store.js @radix-ui/react-toggle");
console.log(" node scripts/check-package-in-store.js @radix-ui/react-toggle 1.0.3");
process.exit(1);
}
console.log("======================================");
console.log("pnpm Store package diagnosis tool");
console.log("======================================");
console.log("");
// 1. 获取 store 路径
console.log("1⃣ Check pnpm Store path:");
console.log("----------------------------------------");
let storePath;
try {
storePath = execSync("pnpm store path", {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
console.log(`✅ Store path: ${storePath}`);
// 检查 store 结构
const filesDir = path.join(storePath, "files");
const indexDir = path.join(storePath, "index");
if (filesDir && indexDir) {
console.log(` Store structure: pnpm v10 (hash sharding storage)`);
console.log(` - files/ directory: store hash files`);
console.log(` - index/ directory: store index information`);
}
} catch (error) {
console.error(`❌ Failed to get store path: ${error.message}`);
process.exit(1);
}
console.log("");
// 2. 检查 store 中是否有该包
console.log("2⃣ Check if the package exists in the Store:");
console.log("----------------------------------------");
let foundDirs = "";
let packageFound = false;
try {
// pnpm v10 store structure: store/v10/files/ and store/v10/index/
// files/ directory is the hash sharding directory (00-ff), each sharding directory is the hash named file
// index/ directory stores index information
const encodedPackageName = packageName
.replace(/@/g, "%40")
.replace(/\//g, "%2f");
const filesDir = path.join(storePath, "files");
const indexDir = path.join(storePath, "index");
console.log(` Search package: ${packageName}`);
console.log(` Encoded package name: ${encodedPackageName}`);
console.log(` Store files directory: ${filesDir}`);
console.log(` Store index directory: ${indexDir}`);
console.log(" (Note: pnpm v10 uses hash file storage, packages are named by hash values)");
console.log("");
// 方法1: 使用 pnpm store status 检查(最可靠的方法)
console.log(" Method 1: Use pnpm store status check...");
try {
const statusOutput = execSync("pnpm store status", {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 10000,
});
// 检查输出中是否包含该包的信息
if (statusOutput.includes(packageName) || statusOutput.includes(encodedPackageName)) {
console.log(" ✅ pnpm store status shows the package related information:");
const relevantLines = statusOutput
.split("\n")
.filter(line => line.includes(packageName) || line.includes(encodedPackageName))
.slice(0, 5);
relevantLines.forEach(line => console.log(` ${line}`));
packageFound = true;
} else {
console.log(" ⚠️ pnpm store status did not find the direct information of the package");
}
} catch (e) {
console.log(` ⚠️ Failed to execute pnpm store status: ${e.message}`);
}
console.log("");
// 方法2: 在 index 目录中查找(索引可能包含包名信息)
console.log(" Method 2: Find in the index directory...");
try {
const findCommand = `find "${indexDir}" -type f -exec grep -l "${encodedPackageName}" {} \\; 2>/dev/null | head -10`;
const indexFiles = execSync(findCommand, {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (indexFiles) {
console.log(" ✅ Found related files in the index:");
indexFiles.split("\n").slice(0, 5).forEach(file => {
console.log(` ${file}`);
});
packageFound = true;
} else {
console.log(" ⚠️ index directory did not find related files");
}
} catch (e) {
console.log(` ⚠️ Failed to search index directory: ${e.message}`);
}
console.log("");
// 方法3: 尝试在整个 store 中查找包含包名的任何内容
console.log(" Method 3: Search for any content containing the package name in the store...");
try {
const findCommand = `grep -r "${encodedPackageName}" "${indexDir}" 2>/dev/null | head -5`;
const grepResults = execSync(findCommand, {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (grepResults) {
console.log(" ✅ Found content containing the package name:");
grepResults.split("\n").forEach(line => {
const parts = line.split(":");
if (parts.length > 1) {
console.log(` File: ${parts[0]}`);
console.log(` Content: ${parts.slice(1).join(":").substring(0, 100)}...`);
}
});
packageFound = true;
}
} catch (e) {
// grep 没找到结果时会返回非零退出码,这是正常的
if (e.status !== 1) {
console.log(` ⚠️ Failed to search: ${e.message}`);
}
}
// 总结查找结果
console.log(" 📊 Summary of search results:");
if (packageFound) {
console.log(" ✅ Found the related information of the package in the store");
} else {
console.log(` ❌ Did not find the package in the store: ${packageName}`);
console.log(" 💡 This may be the reason for the download: the package is not in the store");
}
} catch (error) {
console.log(`⚠️ Failed during check: ${error.message}`);
}
console.log("");
// 3. 检查 store 状态
console.log("3⃣ Check Store status:");
console.log("----------------------------------------");
try {
const statusOutput = execSync("pnpm store status", {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
console.log(statusOutput);
} catch (error) {
console.log(`⚠️ Failed to get store status: ${error.message}`);
if (error.message.includes("ENOENT")) {
console.log(" 💡 Store index may be damaged,建议运行: pnpm store prune");
}
}
console.log("");
// 4. 检查包的依赖信息(如果提供了项目路径)
console.log("4⃣ Diagnose possible reasons:");
console.log("----------------------------------------");
const reasons = [];
if (!packageFound) {
reasons.push({
reason: "Package not in Store",
description: "This is the first time to install the package, or the package has never been used by other projects",
solution: "This is a normal behavior, after installation, the package will be added to the store, and subsequent projects can reuse it",
});
} else if (packageVersion) {
const dirs = foundDirs.split("\n").filter(Boolean);
const hasExactVersion = dirs.some((dir) => {
try {
const pkgJsonPath = path.join(dir, "package.json");
const pkgJsonContent = readFileSync(pkgJsonPath, "utf8");
const pkgJson = JSON.parse(pkgJsonContent);
return pkgJson.version === packageVersion;
} catch (e) {
return false;
}
});
if (!hasExactVersion) {
reasons.push({
reason: "Version mismatch",
description: `Store has the package, but the version is not ${packageVersion}`,
solution: "Different projects use different versions of the package, this is normal",
});
}
}
// 检查镜像源配置
try {
const registry = execSync("pnpm config get registry", {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
console.log(`📦 Current mirror source: ${registry}`);
if (registry.includes("npmmirror.com")) {
reasons.push({
reason: "Mirror source problem",
description: "When using the domestic mirror source, some packages may need to be re-downloaded",
solution: "This is normal, the mirror source synchronization may have a delay",
});
}
} catch (e) {
// 忽略
}
if (reasons.length === 0) {
console.log("✅ No obvious problems found");
console.log(" 💡 If still downloading, it may be:");
console.log(" - Package integrity verification failed");
console.log(" - Dependency resolution requires a specific version");
console.log(" - Hard link failed, fallback to download");
} else {
console.log("Possible reasons:");
reasons.forEach((item, index) => {
console.log(`\n${index + 1}. ${item.reason}`);
console.log(` Description: ${item.description}`);
console.log(` Solution: ${item.solution}`);
});
}
console.log("");
// 5. 建议的进一步检查
console.log("5⃣ Further checks recommended:");
console.log("----------------------------------------");
const encodedPackageNameForHelp = packageName
.replace(/@/g, "%40")
.replace(/\//g, "%2f");
console.log("1. View detailed installation logs (add --loglevel=debug):");
console.log(` pnpm install --loglevel=debug 2>&1 | grep -i "${packageName}"`);
console.log("");
console.log("2. Check the lock file of the project:");
console.log(` cat pnpm-lock.yaml | grep -A 5 "${packageName}"`);
console.log("");
console.log("3. Manually check the store content (traverse all hash shards):");
console.log(` # 方法1: 使用 find 递归查找`);
console.log(` find "${storePath}/files" -type d -name "*${encodedPackageNameForHelp}*" 2>/dev/null`);
console.log(` find "${storePath}/index" -type f -name "*${encodedPackageNameForHelp}*" 2>/dev/null`);
console.log("");
console.log(` # Method 2: Traverse the hash sharding directory (if you know the approximate hash value):`);
console.log(` for dir in "${storePath}/files"/{00..ff}; do`);
console.log(` [ -d "$dir" ] && ls -la "$dir" 2>/dev/null | grep -q "${encodedPackageNameForHelp}" && echo "找到在: $dir";`);
console.log(` done`);
console.log("");
console.log("4. Check the dependency of the package:");
console.log(` pnpm why ${packageName}`);
console.log("");
console.log("5. Use pnpm command to check (if the package is installed):");
console.log(` pnpm list ${packageName}`);
console.log(` pnpm store status | grep -i "${packageName}"`);
console.log("");

View File

@@ -0,0 +1,199 @@
#!/usr/bin/env node
/**
* 检查 pnpm store 使用情况
* 验证环境变量是否正确传递给子进程
*/
import { spawn, exec } from "child_process";
import { promisify } from "util";
const execPromise = promisify(exec);
console.log("========================================");
console.log("pnpm Store usage check");
console.log("========================================\n");
async function checkPnpmConfig() {
console.log("1. Check pnpm configuration:");
console.log("----------------------------------------");
try {
console.log("📦 Current Node.js process pnpm related environment variables:");
const pnpmEnvVars = Object.keys(process.env)
.filter(
(key) =>
key.toLowerCase().includes("pnpm") ||
key === "PATH" ||
key === "HOME"
)
.sort();
if (pnpmEnvVars.length === 0) {
console.log(" (No pnpm related environment variables found)");
} else {
pnpmEnvVars.forEach((key) => {
const value = process.env[key];
const displayValue =
value.length > 100 ? value.substring(0, 100) + "..." : value;
console.log(` ${key} = ${displayValue}`);
});
}
console.log("");
console.log(
"📦 Get pnpm configuration through spawn (inherit process.env):"
);
await new Promise((resolve, reject) => {
const child = spawn("sh", ["-c", "pnpm config list"], {
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (data) => {
output += data.toString();
});
child.on("exit", (code) => {
if (code === 0) {
console.log(output);
resolve();
} else {
reject(new Error(`Exit code: ${code}`));
}
});
});
console.log("📦 Get pnpm configuration through spawn (no env):");
await new Promise((resolve, reject) => {
const child = spawn("sh", ["-c", "pnpm config list"], {
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (data) => {
output += data.toString();
});
child.on("exit", (code) => {
if (code === 0) {
console.log(output);
resolve();
} else {
reject(new Error(`Exit code: ${code}`));
}
});
});
} catch (error) {
console.log(` ❌ 错误: ${error.message}`);
}
}
async function checkStoreLocation() {
console.log("\n2. 检查 Store 位置:");
console.log("----------------------------------------");
try {
const { stdout } = await execPromise("pnpm store path", {
env: process.env,
});
const storePath = stdout.trim();
console.log(`📁 Store 路径: ${storePath}`);
try {
const { stdout: sizeOutput } = await execPromise(
`du -sh "${storePath}" 2>/dev/null || echo "无法计算"`
);
const size = sizeOutput.split("\t")[0];
console.log(`💾 Store 大小: ${size}`);
} catch (e) {
console.log("💾 Store 大小: 无法计算");
}
} catch (error) {
console.log(`❌ Error: ${error.message}`);
}
}
async function testSpawnWithEnv() {
console.log("\n3. Test spawn environment variable passing:");
console.log("----------------------------------------");
console.log("✅ Test 1: spawn pass env: process.env");
await new Promise((resolve) => {
const child = spawn(
"sh",
["-c", 'echo "Registry: $(pnpm config get registry)"'],
{
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
}
);
child.stdout.on("data", (data) => {
console.log(` ${data.toString().trim()}`);
});
child.on("exit", () => resolve());
});
console.log("\n❌ Test 2: spawn no env");
await new Promise((resolve) => {
const child = spawn(
"sh",
["-c", 'echo "Registry: $(pnpm config get registry)"'],
{
stdio: ["ignore", "pipe", "pipe"],
}
);
child.stdout.on("data", (data) => {
console.log(` ${data.toString().trim()}`);
});
child.on("exit", () => resolve());
});
}
async function analyzeInstallProgress() {
console.log("\n4. pnpm install progress indicator description:");
console.log("----------------------------------------");
console.log(`
📊 Progress indicator meaning:
- resolved: Total number of resolved dependencies (determined version number and dependency relationship)
- reused: Number of packages reused through hard links from the store
- downloaded: Number of packages downloaded from the central repository
- added: Number of packages added to node_modules
✅ Ideal state: downloaded = 0, means completely using local store
❓ Why resolved != reused?
Different packages may be:
1. Virtual packages (virtual references of peer dependencies)
2. Symbolic links (links to other packages)
3. Local packages (packages in workspace)
4. Optional dependencies (packages skipped based on platform conditions)
💡 Key indicators:
- downloaded = 0 → ✅ Environment variables take effect, using store
- downloaded > 0 → ❌ Some packages downloaded from the network
`);
}
async function main() {
try {
await checkPnpmConfig();
await checkStoreLocation();
await testSpawnWithEnv();
await analyzeInstallProgress();
console.log("\n========================================");
console.log("Check completed!");
console.log("========================================\n");
} catch (error) {
console.error("Execution error:", error);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env node
/**
* pnpm-check 脚本的 Node.js 包装器
* 自动加载项目配置并传递给 bash 脚本
*/
import { spawn } from "child_process";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const config = (await import("../src/appConfig/index.js")).default;
const projectSourceDir = config.PROJECT_SOURCE_DIR;
const scriptPath = path.join(__dirname, "pnpm-check.sh");
console.log(`🔧 Current environment: ${config.NODE_ENV}`);
console.log(`📂 Project directory: ${projectSourceDir}`);
console.log("");
const child = spawn("bash", [scriptPath, projectSourceDir], {
stdio: "inherit",
env: {
...process.env,
PROJECT_SOURCE_DIR: projectSourceDir,
NODE_ENV: config.NODE_ENV,
},
});
child.on("error", (error) => {
console.error("❌ Execution failed:", error.message);
process.exit(1);
});
child.on("exit", (code) => {
process.exit(code || 0);
});

View File

@@ -0,0 +1,423 @@
#!/bin/bash
# pnpm disk usage inspection script
echo "======================================"
echo "pnpm disk usage analyzer"
echo "======================================"
echo ""
# Ensure pnpm is installed
if ! command -v pnpm &> /dev/null; then
echo "❌ pnpm not found; install it first"
echo " npm install -g pnpm"
exit 1
fi
echo "✅ pnpm version: $(pnpm --version)"
echo ""
# Read PROJECT_SOURCE_DIR from env file
get_project_dir_from_env() {
local env_name=${1:-"development"}
local script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
local project_root="$(dirname "$script_dir")"
local env_file="$project_root/env.$env_name"
if [ -f "$env_file" ]; then
local project_dir=$(grep "^PROJECT_SOURCE_DIR=" "$env_file" | cut -d'=' -f2)
if [ -n "$project_dir" ]; then
echo "$project_dir"
return 0
fi
fi
return 1
}
# Store path
echo "📁 pnpm store path:"
STORE_PATH=$(pnpm store path 2>/dev/null)
STORE_FILESYSTEM=""
if [ -n "$STORE_PATH" ]; then
echo " $STORE_PATH"
if [ -d "$STORE_PATH" ]; then
STORE_SIZE=$(du -sh "$STORE_PATH" 2>/dev/null | awk '{print $1}')
echo " Store size: $STORE_SIZE"
STORE_FILESYSTEM=$(df "$STORE_PATH" 2>/dev/null | tail -n 1 | awk '{print $1}')
if [ -n "$STORE_FILESYSTEM" ]; then
echo " Filesystem: $STORE_FILESYSTEM"
fi
fi
else
echo " ⚠️ Could not resolve store path"
fi
echo ""
# Store status
echo "📊 pnpm store status:"
STORE_STATUS_OUTPUT=$(pnpm store status 2>&1)
STORE_STATUS_EXIT_CODE=$?
if [ $STORE_STATUS_EXIT_CODE -eq 0 ]; then
echo "$STORE_STATUS_OUTPUT"
else
if echo "$STORE_STATUS_OUTPUT" | grep -q "ENOENT"; then
echo " ⚠️ Store index missing or corrupted"
echo " 💡 Try:"
echo " pnpm store prune"
else
echo " ⚠️ Could not read store status"
echo " Error: $(echo "$STORE_STATUS_OUTPUT" | head -n 1)"
fi
fi
echo ""
# Project directory resolution
# Priority: 1) CLI arg 2) PROJECT_SOURCE_DIR 3) env file 4) prompt
PROJECT_DIR=""
AUTO_DETECTED_DIR=""
SOURCE_INFO=""
if [ -n "$1" ]; then
AUTO_DETECTED_DIR="$1"
SOURCE_INFO="command-line argument"
elif [ -n "$PROJECT_SOURCE_DIR" ]; then
AUTO_DETECTED_DIR="$PROJECT_SOURCE_DIR"
SOURCE_INFO="env PROJECT_SOURCE_DIR"
else
ENV_NAME="${NODE_ENV:-development}"
AUTO_DETECTED_DIR=$(get_project_dir_from_env "$ENV_NAME")
if [ -z "$AUTO_DETECTED_DIR" ]; then
for env in development production test; do
AUTO_DETECTED_DIR=$(get_project_dir_from_env "$env")
if [ -n "$AUTO_DETECTED_DIR" ]; then
ENV_NAME="$env"
break
fi
done
fi
if [ -n "$AUTO_DETECTED_DIR" ]; then
SOURCE_INFO="config file env.$ENV_NAME"
fi
fi
if [ -n "$AUTO_DETECTED_DIR" ]; then
echo ""
echo "📂 Detected project directory (source: $SOURCE_INFO)"
echo " Path: $AUTO_DETECTED_DIR"
echo ""
read -p "👉 Press Enter to use this path, or type a different path: " USER_INPUT
if [ -n "$USER_INPUT" ]; then
PROJECT_DIR="$USER_INPUT"
echo "📝 Using entered project directory: $PROJECT_DIR"
else
PROJECT_DIR="$AUTO_DETECTED_DIR"
echo "✅ Using detected project directory"
fi
else
echo ""
echo "⚠️ Could not auto-detect project directory"
echo ""
echo "💡 You can:"
echo " - Pass a path: $0 /path/to/projects"
echo " - Set env: PROJECT_SOURCE_DIR=/path/to/projects $0"
echo " - Set NODE_ENV: NODE_ENV=development $0"
echo ""
read -p "📝 Project directory path (Enter to skip project scan): " USER_INPUT
if [ -n "$USER_INPUT" ]; then
PROJECT_DIR="$USER_INPUT"
echo "📂 Using entered project directory"
else
echo ""
echo "Skipping project scan..."
fi
fi
if [ -n "$PROJECT_DIR" ] && [ ! -d "$PROJECT_DIR" ]; then
echo ""
echo "⚠️ Project directory does not exist: $PROJECT_DIR"
echo ""
echo "Skipping project scan..."
PROJECT_DIR=""
fi
if [ -n "$PROJECT_DIR" ]; then
echo ""
echo "🔍 Scanning project directory: $PROJECT_DIR"
echo ""
if [ -d "$PROJECT_DIR" ]; then
PROJECT_FILESYSTEM=$(df "$PROJECT_DIR" 2>/dev/null | tail -n 1 | awk '{print $1}')
if [ -n "$PROJECT_FILESYSTEM" ]; then
echo "💾 Filesystem check:"
echo " Project filesystem: $PROJECT_FILESYSTEM"
if [ -n "$STORE_FILESYSTEM" ]; then
echo " Store filesystem: $STORE_FILESYSTEM"
echo ""
if [ "$PROJECT_FILESYSTEM" = "$STORE_FILESYSTEM" ]; then
echo " ✅ Project and store are on the same filesystem"
echo " 💡 Hard links work; disk savings apply"
else
echo " ⚠️ Project and store are on different filesystems"
echo " ❌ Hard links cannot cross filesystems; pnpm will copy files"
fi
else
echo " ⚠️ Could not determine store filesystem"
fi
echo ""
fi
fi
echo "📦 Per-project node_modules (apparent size):"
echo " ⚠️ Note: du double-counts hard links; real usage is lower"
TOTAL_SIZE_KB=0
COUNT=0
MAX_DISPLAY=5
while IFS= read -r dir; do
SIZE_HUMAN=$(du -sh "$dir" 2>/dev/null | awk '{print $1}')
SIZE_KB=$(du -sk "$dir" 2>/dev/null | awk '{print $1}')
PROJECT_NAME=$(echo "$dir" | sed "s|$PROJECT_DIR/||" | sed 's|/node_modules||')
if [ $COUNT -lt $MAX_DISPLAY ]; then
echo " [$PROJECT_NAME] $SIZE_HUMAN (includes hard-link double counting)"
fi
TOTAL_SIZE_KB=$((TOTAL_SIZE_KB + SIZE_KB))
((COUNT++))
done < <(find "$PROJECT_DIR" -name "node_modules" -type d -maxdepth 3 2>/dev/null)
if [ $COUNT -gt $MAX_DISPLAY ]; then
echo " ... $((COUNT - MAX_DISPLAY)) more not shown"
fi
if [ $TOTAL_SIZE_KB -gt 0 ]; then
if [ $TOTAL_SIZE_KB -gt 1048576 ]; then
TOTAL_SIZE_HUMAN=$(awk "BEGIN {printf \"%.1fG\", $TOTAL_SIZE_KB/1048576}")
elif [ $TOTAL_SIZE_KB -gt 1024 ]; then
TOTAL_SIZE_HUMAN=$(awk "BEGIN {printf \"%.1fM\", $TOTAL_SIZE_KB/1024}")
else
TOTAL_SIZE_HUMAN="${TOTAL_SIZE_KB}K"
fi
echo " Total apparent size: $TOTAL_SIZE_HUMAN (real usage is lower)"
fi
echo " Found $COUNT node_modules directories"
echo ""
echo "🗂️ Per-project .pnpm folders (apparent size):"
echo " ⚠️ Note: entries under .pnpm are hard links; little extra space per copy"
PNPM_COUNT=0
PNPM_TOTAL_SIZE_KB=0
while IFS= read -r dir; do
SIZE_HUMAN=$(du -sh "$dir" 2>/dev/null | awk '{print $1}')
SIZE_KB=$(du -sk "$dir" 2>/dev/null | awk '{print $1}')
PROJECT_NAME=$(echo "$dir" | sed "s|$PROJECT_DIR/||" | sed 's|/node_modules/.pnpm||')
if [ $PNPM_COUNT -lt $MAX_DISPLAY ]; then
echo " [$PROJECT_NAME] $SIZE_HUMAN (hard links, shared in store)"
fi
PNPM_TOTAL_SIZE_KB=$((PNPM_TOTAL_SIZE_KB + SIZE_KB))
((PNPM_COUNT++))
done < <(find "$PROJECT_DIR" -type d -path "*/node_modules/.pnpm" -maxdepth 4 2>/dev/null)
if [ $PNPM_COUNT -gt $MAX_DISPLAY ]; then
echo " ... $((PNPM_COUNT - MAX_DISPLAY)) more not shown"
fi
if [ $PNPM_TOTAL_SIZE_KB -gt 0 ]; then
if [ $PNPM_TOTAL_SIZE_KB -gt 1048576 ]; then
PNPM_TOTAL_SIZE_HUMAN=$(awk "BEGIN {printf \"%.1fG\", $PNPM_TOTAL_SIZE_KB/1048576}")
elif [ $PNPM_TOTAL_SIZE_KB -gt 1024 ]; then
PNPM_TOTAL_SIZE_HUMAN=$(awk "BEGIN {printf \"%.1fM\", $PNPM_TOTAL_SIZE_KB/1024}")
else
PNPM_TOTAL_SIZE_HUMAN="${PNPM_TOTAL_SIZE_KB}K"
fi
echo " Total apparent size: $PNPM_TOTAL_SIZE_HUMAN (all hard-linked to store)"
fi
echo " Found $PNPM_COUNT .pnpm directories"
echo ""
echo "💾 Filesystem usage (df):"
echo " Whole filesystem is more accurate than summing du:"
df -h "$PROJECT_DIR" | tail -n 1 | awk '{print " Filesystem: "$1, "| Used: "$3, "| Avail: "$4, "| Use: "$5}'
echo ""
fi
echo "======================================"
echo "💡 Important:"
echo "======================================"
echo ""
echo "⚠️ du counts hard-linked files multiple times!"
echo " Files under each projects .pnpm are hard links into the store."
echo " Real disk use ≈ store size + small symlink/metadata overhead."
echo " Often ~3050% of what naive du sums suggest."
echo ""
echo "✅ Ways to see real usage:"
echo " 1. pnpm store size (shown above)"
echo " 2. df -h for the whole filesystem"
echo " 3. Compare df before and after installs"
echo ""
echo "======================================"
echo "💡 Recommendations:"
echo "======================================"
echo ""
echo "1. Prune unused packages from the store:"
echo " pnpm store prune"
echo ""
echo "2. Prefer one store and projects on the same filesystem:"
echo " Keep projects and the store on the same volume when possible"
echo ""
echo "3. Tune per-project .npmrc for your registry and link strategy"
echo ""
if [ -n "$PROJECT_DIR" ] && [ -d "$PROJECT_DIR" ]; then
echo ""
read -p "🔍 Verify hard links between two projects? (y to start, Enter to skip): " VERIFY_HARDLINK
if [[ "$VERIFY_HARDLINK" =~ ^[Yy]$ ]]; then
echo ""
echo "======================================"
echo "🔗 Hard link verification"
echo "======================================"
echo ""
echo "💡 Project layout: $PROJECT_DIR/{projectId}"
echo ""
echo "📋 Project IDs under this directory (up to 5):"
PROJECT_IDS=$(find "$PROJECT_DIR" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2>/dev/null | head -5)
if [ -n "$PROJECT_IDS" ]; then
echo "$PROJECT_IDS" | while read -r pid; do
echo " - $pid"
done
else
echo " (none)"
fi
echo ""
read -p "👉 First project ID: " PROJECT_1_ID
if [ -z "$PROJECT_1_ID" ]; then
echo "❌ Project ID cannot be empty"
else
read -p "👉 Second project ID: " PROJECT_2_ID
if [ -z "$PROJECT_2_ID" ]; then
echo "❌ Project ID cannot be empty"
elif [ "$PROJECT_1_ID" = "$PROJECT_2_ID" ]; then
echo "❌ Enter two different project IDs"
else
echo ""
PROJECT_1="$PROJECT_DIR/$PROJECT_1_ID"
PROJECT_2="$PROJECT_DIR/$PROJECT_2_ID"
PROJECT_1_NAME="$PROJECT_1_ID"
PROJECT_2_NAME="$PROJECT_2_ID"
if [ ! -d "$PROJECT_1" ]; then
echo "❌ Project directory not found: $PROJECT_1"
elif [ ! -d "$PROJECT_2" ]; then
echo "❌ Project directory not found: $PROJECT_2"
else
echo "🔍 Comparing projects:"
echo "$PROJECT_1_NAME"
echo "$PROJECT_2_NAME"
echo ""
PNPM_DIR_1="$PROJECT_1/node_modules/.pnpm"
PNPM_DIR_2="$PROJECT_2/node_modules/.pnpm"
if [ ! -d "$PNPM_DIR_1" ]; then
echo "⚠️ $PROJECT_1_NAME: no node_modules/.pnpm"
echo " Path: $PNPM_DIR_1"
elif [ ! -d "$PNPM_DIR_2" ]; then
echo "⚠️ $PROJECT_2_NAME: no node_modules/.pnpm"
echo " Path: $PNPM_DIR_2"
else
echo "🔎 Looking for overlapping dependencies..."
PACKAGES_1=($(find "$PNPM_DIR_1" -maxdepth 1 -type d -name "*@*" 2>/dev/null | xargs -I {} basename {}))
PACKAGES_2=($(find "$PNPM_DIR_2" -maxdepth 1 -type d -name "*@*" 2>/dev/null | xargs -I {} basename {}))
COMMON_PACKAGES=()
for pkg1 in "${PACKAGES_1[@]}"; do
for pkg2 in "${PACKAGES_2[@]}"; do
if [ "$pkg1" = "$pkg2" ]; then
COMMON_PACKAGES+=("$pkg1")
break
fi
done
done
if [ ${#COMMON_PACKAGES[@]} -eq 0 ]; then
echo "⚠️ No shared packages between the two projects"
else
echo "📦 Found ${#COMMON_PACKAGES[@]} shared package(s); verifying..."
echo ""
VERIFIED_COUNT=0
SAME_INODE_COUNT=0
for pkg in "${COMMON_PACKAGES[@]:0:5}"; do
PKG_FILE_1=$(find "$PNPM_DIR_1/$pkg" -name "package.json" -path "*/node_modules/*/package.json" 2>/dev/null | head -n 1)
PKG_FILE_2=$(find "$PNPM_DIR_2/$pkg" -name "package.json" -path "*/node_modules/*/package.json" 2>/dev/null | head -n 1)
if [ -n "$PKG_FILE_1" ] && [ -f "$PKG_FILE_1" ] && [ -n "$PKG_FILE_2" ] && [ -f "$PKG_FILE_2" ]; then
INODE_1=$(ls -i "$PKG_FILE_1" | awk '{print $1}')
INODE_2=$(ls -i "$PKG_FILE_2" | awk '{print $1}')
PKG_DISPLAY=$(echo "$pkg" | sed 's/@[^@]*$//')
if [ "$INODE_1" = "$INODE_2" ]; then
echo "$PKG_DISPLAY: inode=$INODE_1 (same)"
((SAME_INODE_COUNT++))
else
echo "$PKG_DISPLAY: $INODE_1 vs $INODE_2 (different)"
fi
((VERIFIED_COUNT++))
fi
done
echo ""
echo "======================================"
if [ $VERIFIED_COUNT -eq 0 ]; then
echo "⚠️ Could not verify package files"
elif [ $SAME_INODE_COUNT -eq $VERIFIED_COUNT ]; then
echo "✅ Hard links look good"
echo " Checked $VERIFIED_COUNT package(s); all inodes match"
echo " Same physical files → disk savings from deduplication"
elif [ $SAME_INODE_COUNT -eq 0 ]; then
echo "❌ Hard links not shared"
echo " Checked $VERIFIED_COUNT package(s); inodes all differ"
echo ""
echo " Possible causes:"
echo " - Projects on different filesystems"
echo " - Filesystem without working hard links (e.g. some network mounts)"
echo " - pnpm / store configuration"
else
echo "⚠️ Mixed results"
echo " Checked $VERIFIED_COUNT package(s); $SAME_INODE_COUNT inode match(es)"
echo " May indicate inconsistent config or partial reinstalls"
fi
fi
fi
fi
fi
fi
echo ""
fi
fi
echo "✅ Done"

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env node
/**
* 手动执行 pnpm store prune 的脚本
*
* 使用方法:
* node scripts/pnpm-prune-manual.js
*/
import { executePruneManually } from "../src/scheduler/pnpmPruneScheduler.js";
async function main() {
console.log("====================================");
console.log("Manually execute pnpm store prune");
console.log("====================================");
console.log("");
try {
await executePruneManually();
console.log("");
console.log("✅ Execution completed");
process.exit(0);
} catch (error) {
console.error("");
console.error("❌ Execution failed:", error.message);
process.exit(1);
}
}
// If running this script directly
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/").replace(/^.*[\/\\]/, ""))) {
main();
}
export { main };

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env node
/**
* CLI 环境启动脚本ESM
* 由 qiming-file-server CLI 命令调用
*/
if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
console.log("[CLI] 启动服务 - 环境: " + process.env.NODE_ENV);
await import("../src/server.js");

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
process.env.NODE_ENV = "development";
await import("../src/server.js");

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
process.env.NODE_ENV = "production";
await import("../src/server.js");

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
process.env.NODE_ENV = "test";
await import("../src/server.js");

View File

@@ -0,0 +1,436 @@
#!/bin/bash
#===============================================================================
# qiming-file-server CLI 自动化测试脚本
#
# 功能: 自动化测试 qiming-file-server CLI 的启动、状态查询、健康检查、重启、停止等功能
#
# 使用方法:
# chmod +x test-cli.sh
# ./test-cli.sh # 模式一: pnpm link 全局命令
# ./test-cli.sh --direct # 模式二: 直接用 node 运行 dist 目录
# ./test-cli.sh --installed # 模式三: 已全局安装,跳过所有安装步骤
#
# 环境要求:
# - bash shell
# - curl
# - jq (用于格式化 JSON 输出)
# - pnpm
# - node (v22+)
#
# 脚本行为:
# 模式一 (默认): pnpm install + pnpm link 后测试
# 模式二 (--direct): 直接用 node 运行 dist/,无需全局链接
# 模式三 (--installed): 已全局安装,直接测试,跳过所有安装步骤
#===============================================================================
set -e
# 项目根目录(脚本位于 scripts/ 下,根目录为上级目录)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
DIST_DIR="${PROJECT_ROOT}/dist"
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 测试模式: "link", "direct" 或 "installed"
TEST_MODE="link"
# 日志函数
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
log_section() {
echo ""
echo -e "${CYAN}========================================${NC}"
echo -e "${CYAN} $1${NC}"
echo -e "${CYAN}========================================${NC}"
echo ""
}
#-------------------------------------------------------------------------------
# 模式一: 本地安装 - 安装依赖并全局链接,使 qiming-file-server 命令可用
#-------------------------------------------------------------------------------
do_local_install() {
log_info "模式一: 本地安装模式"
log_info "进入项目根目录 ${PROJECT_ROOT}"
cd "${PROJECT_ROOT}"
log_info "执行 pnpm install..."
pnpm install
log_info "执行 pnpm run build编译 CLI 到 dist/..."
pnpm run build
log_info "执行 pnpm link --global全局链接..."
pnpm link --global
log_success "本地安装完成qiming-file-server 命令已可用"
}
#-------------------------------------------------------------------------------
# 模式二: 直接运行 - 检查 dist 目录是否存在,直接用 node 运行
#-------------------------------------------------------------------------------
check_direct_mode() {
if [ ! -d "${DIST_DIR}" ]; then
log_error "dist 目录不存在,请先运行: npm run build"
log_info "或者使用默认模式: ./test-cli.sh"
exit 1
fi
if [ ! -f "${DIST_DIR}/cli.js" ]; then
log_error "dist/cli.js 不存在,请先运行: npm run build"
exit 1
fi
log_success "模式二: 直接运行模式"
log_info "将使用 'node ${DIST_DIR}/cli.js' 执行测试"
}
#-------------------------------------------------------------------------------
# 模式三: 已安装 - 跳过所有安装步骤,直接使用全局命令测试
#-------------------------------------------------------------------------------
check_installed_mode() {
if ! command -v qiming-file-server &> /dev/null; then
log_error "未找到 qiming-file-server 命令,请先全局安装:"
log_info " npm install -g qiming-file-server"
log_info "或者使用其他模式:"
log_info " ./test-cli.sh # 模式一: pnpm link"
log_info " ./test-cli.sh --direct # 模式二: node 直接运行"
exit 1
fi
log_success "模式三: 已安装模式"
log_info "检测到 qiming-file-server 全局命令"
log_info "将直接使用 'qiming-file-server' 执行测试"
}
# 测试函数
test_command() {
local cmd=$1
local description=$2
log_info "测试: ${description}"
log_info "命令: ${cmd}"
eval "${cmd}"
local exit_code=$?
if [ $exit_code -eq 0 ]; then
log_success "✓ 完成: ${description}"
return 0
else
log_error "✗ 失败: ${description} (退出码: ${exit_code})"
return 1
fi
}
# 默认配置
PORT=${PORT:-60000}
HEALTH_URL="http://localhost:${PORT}/health"
PROJECT_DIR=${PROJECT_DIR:-./test-projects}
NGINX_DIR=${NGINX_DIR:-./test-nginx}
UPLOAD_DIR=${UPLOAD_DIR:-./test-uploads}
# 显示配置
show_config() {
log_section "测试配置"
echo " 测试模式: ${TEST_MODE_LABEL}"
echo " 端口: ${PORT}"
echo " 健康检查: ${HEALTH_URL}"
echo " 项目目录: ${PROJECT_DIR}"
echo " Nginx目录: ${NGINX_DIR}"
echo " 上传目录: ${UPLOAD_DIR}"
echo ""
}
# 获取模式标签
get_mode_label() {
case "${TEST_MODE}" in
link)
TEST_MODE_LABEL="pnpm link 全局命令"
;;
direct)
TEST_MODE_LABEL="node 直接运行"
;;
installed)
TEST_MODE_LABEL="已全局安装"
;;
esac
}
# 检查依赖
check_dependencies() {
log_info "检查依赖..."
local missing_deps=()
if ! command -v curl &> /dev/null; then
missing_deps+=("curl")
fi
if ! command -v jq &> /dev/null; then
missing_deps+=("jq")
fi
# 模式一和模式二需要 pnpm
if [ "${TEST_MODE}" != "installed" ]; then
if ! command -v pnpm &> /dev/null; then
missing_deps+=("pnpm")
fi
fi
if ! command -v node &> /dev/null; then
missing_deps+=("node")
fi
if [ ${#missing_deps[@]} -gt 0 ]; then
log_error "缺少依赖: ${missing_deps[*]}"
log_info "请安装后再运行,例如: brew install ${missing_deps[*]}"
exit 1
fi
log_success "依赖已满足"
}
# 获取当前模式下的命令前缀
get_cmd_prefix() {
case "${TEST_MODE}" in
link)
echo "qiming-file-server"
;;
direct)
echo "node ${DIST_DIR}/cli.js"
;;
installed)
echo "qiming-file-server"
;;
esac
}
# 清理函数:停止服务
cleanup() {
log_info "清理环境(停止服务)..."
local cmd_prefix=$(get_cmd_prefix)
${cmd_prefix} stop 2>/dev/null || true
sleep 1
}
# 退出时清理
cleanup_exit() {
local exit_code=$?
# 只在模式一下取消全局链接
if [ "${TEST_MODE}" = "link" ]; then
log_info "取消全局链接: pnpm unlink --global"
(cd "${PROJECT_ROOT}" && pnpm unlink --global) 2>/dev/null || true
fi
if [ $exit_code -ne 0 ]; then
exit $exit_code
fi
}
# 测试健康检查端点
test_health_endpoint() {
log_info "测试健康检查端点..."
local response=$(curl -s "${HEALTH_URL}")
local status=$(echo "${response}" | jq -r '.status' 2>/dev/null || echo "error")
if [ "${status}" = "ok" ]; then
log_success "健康检查通过"
log_info "响应: ${response}"
return 0
else
log_error "健康检查失败,状态: ${status}"
log_info "响应: ${response}"
return 1
fi
}
# 测试服务状态查询
test_status_command() {
log_info "测试服务状态查询..."
local cmd_prefix=$(get_cmd_prefix)
${cmd_prefix} status
log_success "状态查询完成"
}
# 主测试流程
run_tests() {
get_mode_label
show_config
check_dependencies
log_section "开始测试"
# 根据模式执行不同的初始化
case "${TEST_MODE}" in
link)
do_local_install
;;
direct)
check_direct_mode
;;
installed)
check_installed_mode
;;
esac
# 清理环境(停止可能残留的服务)
cleanup
# ========== 测试 1: 启动服务 ==========
log_section "测试 1: 启动服务"
local cmd_prefix=$(get_cmd_prefix)
test_command "${cmd_prefix} start --env production --port ${PORT}" "启动服务"
sleep 3
# ========== 测试 2: 健康检查 ==========
log_section "测试 2: 健康检查"
test_health_endpoint
# ========== 测试 3: 状态查询 ==========
log_section "测试 3: 状态查询"
test_status_command
# ========== 测试 4: 重启服务 ==========
log_section "测试 4: 重启服务"
test_command "${cmd_prefix} restart" "重启服务"
sleep 3
# ========== 测试 5: 重启后健康检查 ==========
log_section "测试 5: 重启后健康检查"
test_health_endpoint
# ========== 测试 6: 停止服务 ==========
log_section "测试 6: 停止服务"
test_command "${cmd_prefix} stop" "停止服务"
# ========== 完成 ==========
log_section "测试完成"
log_success "所有 CLI 测试通过!"
# 根据模式显示不同的后续提示
echo ""
case "${TEST_MODE}" in
link)
echo "已测试全局命令 'qiming-file-server'"
;;
direct)
echo "已测试 'node ${DIST_DIR}/cli.js' 直接运行"
;;
installed)
echo "已测试全局安装版本"
;;
esac
echo ""
}
# 显示帮助信息
show_help() {
echo "qiming-file-server CLI 测试脚本"
echo ""
echo "用法: $0 [选项]"
echo ""
echo "选项:"
echo " -p, --port <端口> 指定测试端口 (默认: 60000)"
echo " --direct 模式二: 直接用 node 运行 dist/"
echo " --installed 模式三: 已全局安装,跳过所有安装步骤"
echo " --project-dir <目录> 指定项目目录"
echo " --nginx-dir <目录> 指定 Nginx 目录"
echo " --upload-dir <目录> 指定上传目录"
echo " -h, --help 显示帮助信息"
echo ""
echo "三种测试模式:"
echo " 1. 默认模式 (pnpm link): ./test-cli.sh"
echo " - 执行 pnpm install + pnpm run build + pnpm link"
echo " - 使用全局命令 'qiming-file-server'"
echo ""
echo " 2. 直接运行模式: ./test-cli.sh --direct"
echo " - 无需 pnpm link"
echo " - 使用 'node dist/cli.js' 运行"
echo ""
echo " 3. 已安装模式: ./test-cli.sh --installed"
echo " - 跳过所有安装步骤"
echo " - 直接使用已全局安装的命令"
echo ""
echo "示例:"
echo " $0 # 模式一: pnpm link 全局命令"
echo " $0 --direct # 模式二: node 直接运行"
echo " $0 --installed # 模式三: 已全局安装"
echo " $0 --port 60001 # 指定端口"
echo " $0 --installed -p 60001 # 组合使用"
echo ""
}
# 解析命令行参数
parse_args() {
while [[ $# -gt 0 ]]; do
case $1 in
-p|--port)
PORT="$2"
shift 2
;;
--direct)
TEST_MODE="direct"
shift
;;
--installed)
TEST_MODE="installed"
shift
;;
--project-dir)
PROJECT_DIR="$2"
shift 2
;;
--nginx-dir)
NGINX_DIR="$2"
shift 2
;;
--upload-dir)
UPLOAD_DIR="$2"
shift 2
;;
-h|--help)
show_help
exit 0
;;
*)
log_error "未知参数: $1"
show_help
exit 1
;;
esac
done
}
# 脚本入口
main() {
parse_args "$@"
# 无论正常结束、失败或 Ctrl+C退出时都执行清理
trap 'cleanup_exit' EXIT
run_tests
}
# 执行主函数
main "$@"

View File

@@ -0,0 +1,162 @@
// 应用配置模块ESM环境变量与运行时配置与 src/config 的 Swagger 等区分
import path from "path";
import fs from "fs";
import os from "os";
import { fileURLToPath } from "url";
import dotenv from "dotenv";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config();
/** 解析日志根目录:若配置的路径无法创建(如本机无 /app则回退到系统临时目录 */
function resolveLogBaseDir() {
const raw = process.env.LOG_BASE_DIR;
const fallback = path.join(os.tmpdir(), "qiming-file-server", "project_logs");
if (!raw) return fallback;
try {
fs.mkdirSync(raw, { recursive: true });
return raw;
} catch (_) {
try {
fs.mkdirSync(fallback, { recursive: true });
} catch (__) {}
return fallback;
}
}
// 获取环境变量,默认为 development
const env = process.env.NODE_ENV || "development";
// 加载环境变量文件env 位于 appConfig 同级目录src/env.* 或 dist/env.*
function loadEnvFile(envName) {
const envFile = path.join(__dirname, "..", `env.${envName}`);
if (fs.existsSync(envFile)) {
dotenv.config({ path: envFile });
console.log(`Environment configuration file env.${envName} loaded`);
} else {
const message = `Environment configuration file env.${envName} does not exist, please create the corresponding environment configuration file and try again`;
console.error(message);
throw new Error(message);
}
}
// 加载对应环境的配置文件
loadEnvFile(env);
// 从环境变量构建配置对象
const config = {
NODE_ENV: env,
PORT: parseInt(process.env.PORT),
INIT_PROJECT_NAME_REACT:
process.env.INIT_PROJECT_NAME_REACT || "react-vite-template",
INIT_PROJECT_NAME_VUE3:
process.env.INIT_PROJECT_NAME_VUE3 || "vue3-vite-template",
INIT_PROJECT_DIR: process.env.INIT_PROJECT_DIR,
PROJECT_SOURCE_DIR: process.env.PROJECT_SOURCE_DIR,
DIST_TARGET_DIR: process.env.DIST_TARGET_DIR,
UPLOAD_PROJECT_DIR: process.env.UPLOAD_PROJECT_DIR,
MAX_BUILD_CONCURRENCY: process.env.MAX_BUILD_CONCURRENCY
? parseInt(process.env.MAX_BUILD_CONCURRENCY, 10)
: undefined,
MAX_INLINE_FILE_SIZE_BYTES: process.env.MAX_INLINE_FILE_SIZE_BYTES
? parseInt(process.env.MAX_INLINE_FILE_SIZE_BYTES, 10)
: undefined,
UPLOAD_MAX_FILE_SIZE_BYTES: process.env.UPLOAD_MAX_FILE_SIZE_BYTES
? parseInt(process.env.UPLOAD_MAX_FILE_SIZE_BYTES, 10)
: undefined,
UPLOAD_ALLOWED_EXTENSIONS: process.env.UPLOAD_ALLOWED_EXTENSIONS
? process.env.UPLOAD_ALLOWED_EXTENSIONS.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
: [],
UPLOAD_SINGLE_FILE_SIZE_BYTES: process.env.UPLOAD_SINGLE_FILE_SIZE_BYTES
? parseInt(process.env.UPLOAD_SINGLE_FILE_SIZE_BYTES, 10)
: undefined,
DOWNLOAD_MAX_FILE_SIZE_BYTES: process.env.DOWNLOAD_MAX_FILE_SIZE_BYTES
? parseInt(process.env.DOWNLOAD_MAX_FILE_SIZE_BYTES, 10)
: undefined,
REQUEST_BODY_LIMIT: process.env.REQUEST_BODY_LIMIT,
TRAVERSE_EXCLUDE_DIRS: process.env.TRAVERSE_EXCLUDE_DIRS
? process.env.TRAVERSE_EXCLUDE_DIRS.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [],
BACKUP_TRAVERSE_EXCLUDE_FILES: process.env.BACKUP_TRAVERSE_EXCLUDE_FILES
? process.env.BACKUP_TRAVERSE_EXCLUDE_FILES.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [],
CONTENT_TRAVERSE_EXCLUDE_FILES: process.env.CONTENT_TRAVERSE_EXCLUDE_FILES
? process.env.CONTENT_TRAVERSE_EXCLUDE_FILES.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [],
INLINE_IMAGE_EXTENSIONS: process.env.INLINE_IMAGE_EXTENSIONS
? process.env.INLINE_IMAGE_EXTENSIONS.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
: [],
TOP_LEVEL_NOISE_PATTERNS: process.env.TOP_LEVEL_NOISE_PATTERNS
? process.env.TOP_LEVEL_NOISE_PATTERNS.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [],
LOG_BASE_DIR: resolveLogBaseDir(),
LOG_LEVEL: process.env.LOG_LEVEL
? process.env.LOG_LEVEL.toLowerCase()
: undefined,
LOG_PREFIX_API: process.env.LOG_PREFIX_API,
LOG_PREFIX_BUILD: process.env.LOG_PREFIX_BUILD,
LOG_CONSOLE_ENABLED:
typeof process.env.LOG_CONSOLE_ENABLED === "string"
? process.env.LOG_CONSOLE_ENABLED.toLowerCase() === "true"
: undefined,
LOG_CACHE_ENABLED:
typeof process.env.LOG_CACHE_ENABLED === "string"
? process.env.LOG_CACHE_ENABLED.toLowerCase() === "true"
: undefined,
LOG_CACHE_DURATION: process.env.LOG_CACHE_DURATION
? parseInt(process.env.LOG_CACHE_DURATION, 10)
: undefined,
LOG_CACHE_MAX_ENTRIES: process.env.LOG_CACHE_MAX_ENTRIES
? parseInt(process.env.LOG_CACHE_MAX_ENTRIES, 10)
: undefined,
LOG_CACHE_MAX_FILE_SIZE: process.env.LOG_CACHE_MAX_FILE_SIZE
? parseInt(process.env.LOG_CACHE_MAX_FILE_SIZE, 10)
: undefined,
DEV_SERVER_PORT_TIMEOUT: process.env.DEV_SERVER_PORT_TIMEOUT
? parseInt(process.env.DEV_SERVER_PORT_TIMEOUT, 10)
: undefined,
DEV_SERVER_STOP_TIMEOUT: process.env.DEV_SERVER_STOP_TIMEOUT
? parseInt(process.env.DEV_SERVER_STOP_TIMEOUT, 10)
: undefined,
DEV_SERVER_STOP_CHECK_INTERVAL: process.env.DEV_SERVER_STOP_CHECK_INTERVAL
? parseInt(process.env.DEV_SERVER_STOP_CHECK_INTERVAL, 10)
: undefined,
DEV_SERVER_STOP_MAX_ATTEMPTS: process.env.DEV_SERVER_STOP_MAX_ATTEMPTS
? parseInt(process.env.DEV_SERVER_STOP_MAX_ATTEMPTS, 10)
: undefined,
COMPUTER_WORKSPACE_DIR: process.env.COMPUTER_WORKSPACE_DIR,
COMPUTER_LOG_DIR: process.env.COMPUTER_LOG_DIR,
CLI_SERVICE_NAME: "qiming-file-server",
CLI_PID_DIR: process.env.CLI_PID_DIR || (
process.platform === "win32"
? path.join(process.env.TEMP || "", "qiming-file-server")
: path.join("/tmp", "qiming-file-server")
),
CLI_PID_FILE: "server.pid",
CLI_STOP_TIMEOUT: process.env.CLI_STOP_TIMEOUT
? parseInt(process.env.CLI_STOP_TIMEOUT, 10)
: 30000,
CLI_CHECK_INTERVAL: process.env.CLI_CHECK_INTERVAL
? parseInt(process.env.CLI_CHECK_INTERVAL, 10)
: 500,
CLI_LOG_DIR: process.env.CLI_LOG_DIR || (
process.platform === "win32"
? path.join(process.env.TEMP || "", "qiming-file-server", "logs")
: path.join("/tmp", "qiming-file-server", "logs")
),
CLI_IS_WINDOWS: process.platform === "win32",
};
export default config;

View File

@@ -0,0 +1,220 @@
#!/usr/bin/env node
/**
* qiming-file-server CLI 入口
*
* 设计原则:
* - CLI 只负责参数解析与输出
* - 服务生命周期逻辑统一收敛到 serviceManager
*/
import { Command } from "commander";
import { createRequire } from "module";
import {
SERVICE_CONFIG,
startService,
stopService,
restartService,
getServiceStatus,
getPidFilePath,
formatUptime,
} from "./utils/serviceManager.js";
const esmRequire = createRequire(import.meta.url);
const version = typeof __BUILD_VERSION__ !== "undefined" ? __BUILD_VERSION__ : esmRequire("../package.json").version;
const program = new Command();
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
};
function log(message, color = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`);
}
function error(message) {
console.error(`${colors.red}ERROR: ${message}${colors.reset}`);
}
function success(message) {
console.log(`${colors.green}${message}${colors.reset}`);
}
function info(message) {
console.log(`${colors.blue}${message}${colors.reset}`);
}
/**
* 统一执行命令动作并设置退出码
* @param {Function} action 命令动作
* @returns {Function} commander action
*/
function runCommandAction(action) {
return async (...args) => {
try {
const result = await action(...args);
if (result && result.success === false) {
error(result.message || 'Command failed');
process.exitCode = 1;
} else {
process.exitCode = 0;
}
} catch (err) {
error(err?.message || 'Command failed with unexpected error');
process.exitCode = 1;
}
};
}
/**
* start 命令
*
* @param {Object} options - commander 参数
* @returns {Promise<Object>} 执行结果
*/
async function runStartCommand(options) {
const result = await startService({
env: options.env,
port: options.port,
config: options.config,
timeout: Number(options.timeout),
startTimeout: Number(options.startTimeout),
});
if (result.success) {
success(`Service started (PID: ${result.pid})`);
}
return result;
}
/**
* stop 命令
*
* @param {Object} options - commander 参数
* @returns {Promise<Object>} 执行结果
*/
async function runStopCommand(options) {
const result = await stopService({
force: options.force,
timeout: Number(options.timeout),
});
if (result.success) {
success(result.message || 'Service stopped');
}
return result;
}
/**
* restart 命令
*
* @param {Object} options - commander 参数
* @returns {Promise<Object>} 执行结果
*/
async function runRestartCommand(options) {
const result = await restartService({
env: options.env,
port: options.port,
config: options.config,
timeout: Number(options.timeout),
startTimeout: Number(options.startTimeout),
});
if (result.success) {
success(result.message || 'Service restarted');
}
return result;
}
/**
* status 命令
*
* @returns {Object} 执行结果
*/
function runStatusCommand() {
const status = getServiceStatus();
info(`${SERVICE_CONFIG.name} service status:`);
console.log('');
console.log(` Service name: ${SERVICE_CONFIG.name}`);
console.log(` Running status: ${status.running ? 'Running' : 'Stopped'}`);
console.log(` Message: ${status.message}`);
console.log(` PID file: ${getPidFilePath()}`);
if (status.pidInfo) {
console.log(` Process ID: ${status.pidInfo.pid}`);
console.log(` Environment: ${status.pidInfo.env || 'Unknown'}`);
console.log(` Port: ${status.pidInfo.port || 'Unknown'}`);
console.log(` Version: ${status.pidInfo.version || version}`);
console.log(` Platform: ${status.pidInfo.platform || process.platform}`);
console.log(` Started at: ${status.pidInfo.startedAt || 'Unknown'}`);
console.log(` Uptime: ${formatUptime(status.pidInfo.startedAt)}`);
}
console.log('');
return {
success: status.running,
message: status.message,
};
}
// 主程序
function main() {
program
.name('qiming-file-server')
.description('Cross-platform file service deployment tool, supporting start/stop/restart/status')
.version(version, '-v, --version', 'Display version number')
.helpOption('-h, --help', 'Display help information');
program
.command('start').allowUnknownOption()
.description('Start service')
.option('--env <environment>', '环境: development|production|test', 'production')
.option('--port <port>', 'Service port')
.option('--config <path>', 'Custom configuration file path')
.option('--timeout <ms>', '停止旧进程等待超时(毫秒)', `${SERVICE_CONFIG.defaultStopTimeout}`)
.option('--start-timeout <ms>', '启动健康检查超时(毫秒)', `${SERVICE_CONFIG.defaultStartTimeout}`)
.action(runCommandAction(runStartCommand));
program
.command('stop')
.description('Stop service')
.option('--force', 'Force stop')
.option('--timeout <ms>', '停止服务等待超时(毫秒)', `${SERVICE_CONFIG.defaultStopTimeout}`)
.action(runCommandAction(runStopCommand));
program
.command('restart').allowUnknownOption()
.description('Restart service')
.option('--env <environment>', '环境: development|production|test', 'production')
.option('--port <port>', 'Service port')
.option('--config <path>', 'Custom configuration file path')
.option('--timeout <ms>', '停止旧进程等待超时(毫秒)', `${SERVICE_CONFIG.defaultStopTimeout}`)
.option('--start-timeout <ms>', '启动健康检查超时(毫秒)', `${SERVICE_CONFIG.defaultStartTimeout}`)
.action(runCommandAction(runRestartCommand));
program
.command('status')
.description('View service status')
.action(runCommandAction(runStatusCommand));
program
.command('help')
.description('Display help information')
.action(() => {
program.outputHelp();
});
program.parse(process.argv);
if (!process.argv.slice(2).length) {
program.outputHelp();
process.exitCode = 0;
}
}
main();

View File

@@ -0,0 +1,58 @@
import swaggerJsdoc from "swagger-jsdoc";
import config from "../appConfig/index.js";
const options = {
definition: {
openapi: "3.0.0",
info: {
title: "Application Builder API",
version: "1.0.0",
description: "Application builder API documentation",
contact: {
name: "API Support",
},
},
servers: [
{
url: `http://localhost:${config.PORT}`,
description: `${config.NODE_ENV} environment`,
},
],
tags: [
{ name: "Build", description: "Build related interfaces" },
{ name: "Project", description: "Project management interfaces" },
{ name: "Code", description: "Code submission interfaces" },
],
components: {
schemas: {
Error: {
type: "object",
properties: {
success: { type: "boolean", example: false },
error: {
type: "object",
properties: {
type: { type: "string", example: "VALIDATION_ERROR" },
message: { type: "string", example: "Project ID cannot be empty" },
timestamp: { type: "string", format: "date-time" },
requestId: { type: "string" },
details: { type: "object" },
},
},
},
},
Success: {
type: "object",
properties: {
success: { type: "boolean", example: true },
message: { type: "string" },
},
},
},
},
},
apis: ["./src/config/swagger/*.js"],
};
const specs = swaggerJsdoc(options);
export default specs;

View File

@@ -0,0 +1,222 @@
/**
* @swagger
* tags:
* name: Build
* description: Build and dev server management APIs
*/
/**
* @swagger
* /api/build/start-dev:
* get:
* summary: Start the dev server
* tags: [Build]
* parameters:
* - in: query
* name: projectId
* required: true
* schema:
* type: string
* description: Project ID
* - in: query
* name: basePath
* schema:
* type: string
* description: Base path (Vite projects only)
* responses:
* 200:
* description: Dev server started
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* message:
* type: string
* projectId:
* type: string
* pid:
* type: number
* port:
* type: number
* 400:
* description: Invalid parameters
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
/**
* @swagger
* /api/build/build:
* get:
* summary: Build the project
* tags: [Build]
* parameters:
* - in: query
* name: projectId
* required: true
* schema:
* type: string
* description: Project ID
* - in: query
* name: basePath
* schema:
* type: string
* description: Base path (Vite projects only)
* responses:
* 200:
* description: Build successful
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* message:
* type: string
* projectId:
* type: string
* 400:
* description: Invalid parameters or max concurrency reached
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
/**
* @swagger
* /api/build/stop-dev:
* get:
* summary: Stop the dev server
* tags: [Build]
* parameters:
* - in: query
* name: projectId
* required: true
* schema:
* type: string
* description: Project ID
* - in: query
* name: pid
* required: true
* schema:
* type: number
* description: Process ID
* responses:
* 200:
* description: Dev server stopped
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* message:
* type: string
* projectId:
* type: string
* pid:
* type: number
*/
/**
* @swagger
* /api/build/restart-dev:
* get:
* summary: Restart the dev server
* tags: [Build]
* parameters:
* - in: query
* name: projectId
* required: true
* schema:
* type: string
* description: Project ID
* - in: query
* name: pid
* schema:
* type: number
* description: Process ID (optional)
* - in: query
* name: basePath
* schema:
* type: string
* description: Base path (Vite projects only)
* responses:
* 200:
* description: Dev server restarted
*/
/**
* @swagger
* /api/build/list-dev:
* get:
* summary: List running dev servers
* tags: [Build]
* responses:
* 200:
* description: List retrieved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* list:
* type: array
* items:
* type: object
* properties:
* projectId:
* type: string
* pid:
* type: number
* port:
* type: number
* startedAt:
* type: number
*/
/**
* @swagger
* /api/build/parse-build-error:
* post:
* summary: Parse build error output
* tags: [Build]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - projectId
* - errorMessage
* properties:
* projectId:
* type: string
* errorMessage:
* type: string
* responses:
* 200:
* description: Parsed successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* message:
* type: string
*/
export default {};

View File

@@ -0,0 +1,134 @@
/**
* @swagger
* tags:
* name: Code
* description: Code file management APIs
*/
/**
* @swagger
* /api/project/submit-files-update:
* post:
* summary: Submit file updates and restart the dev server
* tags: [Code]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - projectId
* - codeVersion
* - files
* properties:
* projectId:
* type: string
* description: Project ID
* codeVersion:
* type: string
* description: Code version
* files:
* type: array
* description: Files to update
* items:
* type: object
* properties:
* name:
* type: string
* contents:
* type: string
* binary:
* type: boolean
* sizeExceeded:
* type: boolean
* basePath:
* type: string
* description: Base path (optional)
* pid:
* type: number
* description: Process ID (optional)
* responses:
* 200:
* description: Submitted successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* message:
* type: string
* projectId:
* type: string
* pid:
* type: number
* port:
* type: number
* restarted:
* type: boolean
* 400:
* description: Invalid parameters
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
/**
* @swagger
* /api/project/upload-single-file:
* post:
* summary: Upload a single file
* tags: [Code]
* requestBody:
* required: true
* content:
* multipart/form-data:
* schema:
* type: object
* required:
* - projectId
* - codeVersion
* - file
* - filePath
* properties:
* projectId:
* type: string
* description: Project ID
* codeVersion:
* type: string
* description: Code version
* filePath:
* type: string
* description: Relative path of the file inside the project
* file:
* type: string
* format: binary
* description: File contents
* responses:
* 200:
* description: Upload successful
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* message:
* type: string
* projectId:
* type: string
* restarted:
* type: boolean
* 400:
* description: Invalid parameters
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
export default {};

View File

@@ -0,0 +1,267 @@
/**
* @swagger
* tags:
* name: Project
* description: Project management APIs
*/
/**
* @swagger
* /api/project/create-project:
* post:
* summary: Create a new project
* tags: [Project]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - projectId
* properties:
* projectId:
* type: string
* description: Project ID
* responses:
* 200:
* description: Project created successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* message:
* type: string
* projectPath:
* type: string
* 400:
* description: Invalid parameters
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
/**
* @swagger
* /api/project/upload-start-dev:
* post:
* summary: Upload project archive and start the dev server
* tags: [Project]
* requestBody:
* required: true
* content:
* multipart/form-data:
* schema:
* type: object
* required:
* - projectId
* - file
* properties:
* projectId:
* type: string
* description: Project ID
* file:
* type: string
* format: binary
* description: Project archive (.zip)
* basePath:
* type: string
* description: Base path (Vite projects only)
* responses:
* 200:
* description: Uploaded and dev server started
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* message:
* type: string
* projectId:
* type: string
* pid:
* type: number
* port:
* type: number
* 400:
* description: Invalid parameters or file format
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
/**
* @swagger
* /api/project/get-project-content:
* get:
* summary: Get project content
* tags: [Project]
* parameters:
* - in: query
* name: projectId
* required: true
* schema:
* type: string
* description: Project ID
* responses:
* 200:
* description: Project content retrieved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* files:
* type: array
* items:
* type: object
* properties:
* name:
* type: string
* contents:
* type: string
* binary:
* type: boolean
* sizeExceeded:
* type: boolean
* 400:
* description: Invalid parameters
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
/**
* @swagger
* /api/project/backup-current-version:
* post:
* summary: Backup the current version
* tags: [Project]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - projectId
* - codeVersion
* properties:
* projectId:
* type: string
* description: Project ID
* codeVersion:
* type: string
* description: Code version
* responses:
* 200:
* description: Backup successful
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* message:
* type: string
* zipPath:
* type: string
* 400:
* description: Invalid parameters
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
/**
* @swagger
* /api/project/export-project:
* get:
* summary: Export project as a ZIP archive
* tags: [Project]
* parameters:
* - in: query
* name: projectId
* required: true
* schema:
* type: string
* description: Project ID
* responses:
* 200:
* description: Project ZIP file download
* content:
* application/zip:
* schema:
* type: string
* format: binary
* 400:
* description: Invalid parameters
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
/**
* @swagger
* /api/project/get-project-content-by-version:
* get:
* summary: Get project content for a specific version
* tags: [Project]
* parameters:
* - in: query
* name: projectId
* required: true
* schema:
* type: string
* description: Project ID
* - in: query
* name: codeVersion
* required: true
* schema:
* type: string
* description: Code version
* responses:
* 200:
* description: Project content retrieved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* files:
* type: array
* items:
* type: object
* properties:
* name:
* type: string
* contents:
* type: string
* binary:
* type: boolean
* sizeExceeded:
* type: boolean
* 400:
* description: Invalid parameters
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
export default {};

View File

@@ -0,0 +1,85 @@
# 开发环境配置
NODE_ENV=development
PORT=16000
# 日志配置
LOG_BASE_DIR=/app/logs/project_logs
LOG_LEVEL=debug
LOG_CONSOLE_ENABLED=true
LOG_PREFIX_API=api
LOG_PREFIX_BUILD=build
# 系统内置初始化工程
INIT_PROJECT_NAME_REACT=react-vite-template
INIT_PROJECT_NAME_VUE3=vue3-vite-template
INIT_PROJECT_DIR=/app/project_init
#上传的项目压缩包所在路径
UPLOAD_PROJECT_DIR=/app/project_zips
#项目源文件所在路径
PROJECT_SOURCE_DIR=/app/project_workspace
#dist拷贝到的目标目录,nginx加载
DIST_TARGET_DIR=/app/project_nginx
#build最大并发数
MAX_BUILD_CONCURRENCY=20
#单文件大小限制,如果超大就不返回内容(用于判断查询文件内容时是否超大;1M)
MAX_INLINE_FILE_SIZE_BYTES=1048576
# 上传限制(用户上传压缩包;1000M)
UPLOAD_MAX_FILE_SIZE_BYTES=1048576000
UPLOAD_ALLOWED_EXTENSIONS=.zip
# 单文件上传大小限制(用于上传工程单文件;1000M
UPLOAD_SINGLE_FILE_SIZE_BYTES=1048576000
# 打包下载文件大小限制字节100M
DOWNLOAD_MAX_FILE_SIZE_BYTES=104857600
# 请求体大小限制Express 格式)
REQUEST_BODY_LIMIT=2000mb
# 目录遍历排除(返回内容、归档等)
TRAVERSE_EXCLUDE_DIRS=dist,node_modules,.pnpm-store,__MACOSX,.attachments
# 归档遍历排除文件
BACKUP_TRAVERSE_EXCLUDE_FILES=pnpm-lock.yaml,yarn.lock,package-lock.json
# 返回项目内容遍历排除文件
CONTENT_TRAVERSE_EXCLUDE_FILES=AGENT.md,AGENTS.md,CLAUDE.md,pnpm-lock.yaml,yarn.lock,package-lock.json
# 允许内联返回内容的图片扩展名
INLINE_IMAGE_EXTENSIONS=.png,.jpg,.jpeg,.gif,.bmp,.svg,.ico,.webp,.avif
# 顶层噪声条目(用于解压后扁平化判断)
TOP_LEVEL_NOISE_PATTERNS=__MACOSX,Thumbs.db,node_modules,.pnpm-store,.attachments
# 开发服务器配置
DEV_SERVER_PORT_TIMEOUT=5000
DEV_SERVER_STOP_TIMEOUT=5000
DEV_SERVER_STOP_CHECK_INTERVAL=100
DEV_SERVER_STOP_MAX_ATTEMPTS=50
# pnpm清理配置
PNPM_PRUNE_ENABLED=true
PNPM_PRUNE_SCHEDULE=0 2 * * *
PNPM_PRUNE_TIMEZONE=Asia/Shanghai
PNPM_PRUNE_RUN_ON_START=false
# 日志缓存配置
# 是否启用日志缓存
LOG_CACHE_ENABLED=true
# 缓存过期时间毫秒默认3分钟
LOG_CACHE_DURATION=180000
# 最大缓存项目数量
LOG_CACHE_MAX_ENTRIES=100
# 最大缓存文件大小字节默认2MB超过此大小的文件不缓存
LOG_CACHE_MAX_FILE_SIZE=2097152
# computer 工作目录
COMPUTER_WORKSPACE_DIR=/app/computer-project-workspace
# computer 日志目录
COMPUTER_LOG_DIR=/app/logs/computer_logs

View File

@@ -0,0 +1,85 @@
# 生产环境配置
NODE_ENV=production
PORT=60000
# 日志配置
LOG_BASE_DIR=/app/logs/project_logs
LOG_LEVEL=debug
LOG_CONSOLE_ENABLED=true
LOG_PREFIX_API=api
LOG_PREFIX_BUILD=build
# 系统内置初始化工程
INIT_PROJECT_NAME_REACT=react-vite-template
INIT_PROJECT_NAME_VUE3=vue3-vite-template
INIT_PROJECT_DIR=/app/project_init
#上传的项目压缩包所在路径
UPLOAD_PROJECT_DIR=/app/project_zips
#项目源文件所在路径
PROJECT_SOURCE_DIR=/app/project_workspace
#dist拷贝到的目标目录,nginx加载
DIST_TARGET_DIR=/app/project_nginx
#build最大并发数
MAX_BUILD_CONCURRENCY=20
#单文件大小限制,如果超大就不返回内容(用于判断查询文件内容时是否超大;1M)
MAX_INLINE_FILE_SIZE_BYTES=1048576
# 上传限制(用户上传压缩包;1000M)
UPLOAD_MAX_FILE_SIZE_BYTES=1048576000
UPLOAD_ALLOWED_EXTENSIONS=.zip
# 单文件上传大小限制(用于上传工程单文件;1000M
UPLOAD_SINGLE_FILE_SIZE_BYTES=1048576000
# 打包下载文件大小限制字节100M
DOWNLOAD_MAX_FILE_SIZE_BYTES=104857600
# 请求体大小限制Express 格式)
REQUEST_BODY_LIMIT=2000mb
# 目录遍历排除(返回内容、归档等)
TRAVERSE_EXCLUDE_DIRS=dist,node_modules,.pnpm-store,__MACOSX,.attachments
# 归档遍历排除文件
BACKUP_TRAVERSE_EXCLUDE_FILES=pnpm-lock.yaml,yarn.lock,package-lock.json
# 返回项目内容遍历排除文件
CONTENT_TRAVERSE_EXCLUDE_FILES=pnpm-lock.yaml,yarn.lock,package-lock.json
# 允许内联返回内容的图片扩展名
INLINE_IMAGE_EXTENSIONS=.png,.jpg,.jpeg,.gif,.bmp,.svg,.ico,.webp,.avif
# 顶层噪声条目(用于解压后扁平化判断)
TOP_LEVEL_NOISE_PATTERNS=__MACOSX,Thumbs.db,node_modules,.pnpm-store,.attachments
# 开发服务器配置
DEV_SERVER_PORT_TIMEOUT=5000
DEV_SERVER_STOP_TIMEOUT=5000
DEV_SERVER_STOP_CHECK_INTERVAL=100
DEV_SERVER_STOP_MAX_ATTEMPTS=50
# pnpm清理配置
PNPM_PRUNE_ENABLED=true
PNPM_PRUNE_SCHEDULE=0 2 * * *
PNPM_PRUNE_TIMEZONE=Asia/Shanghai
PNPM_PRUNE_RUN_ON_START=false
# 日志缓存配置
# 是否启用日志缓存
LOG_CACHE_ENABLED=true
# 缓存过期时间毫秒默认3分钟
LOG_CACHE_DURATION=180000
# 最大缓存项目数量
LOG_CACHE_MAX_ENTRIES=100
# 最大缓存文件大小字节默认2MB超过此大小的文件不缓存
LOG_CACHE_MAX_FILE_SIZE=2097152
# computer 工作目录
COMPUTER_WORKSPACE_DIR=/app/computer-project-workspace
# computer 日志目录
COMPUTER_LOG_DIR=/app/logs/computer_logs

View File

@@ -0,0 +1,85 @@
# 生产环境配置
NODE_ENV=test
PORT=60000
# 日志配置
LOG_BASE_DIR=/app/logs/project_logs
LOG_LEVEL=debug
LOG_CONSOLE_ENABLED=true
LOG_PREFIX_API=api
LOG_PREFIX_BUILD=build
# 系统内置初始化工程
INIT_PROJECT_NAME_REACT=react-vite-template
INIT_PROJECT_NAME_VUE3=vue3-vite-template
INIT_PROJECT_DIR=/app/project_init
#上传的项目压缩包所在路径
UPLOAD_PROJECT_DIR=/app/project_zips
#项目源文件所在路径
PROJECT_SOURCE_DIR=/app/project_workspace
#dist拷贝到的目标目录,nginx加载
DIST_TARGET_DIR=/app/project_nginx
#build最大并发数
MAX_BUILD_CONCURRENCY=20
#单文件大小限制,如果超大就不返回内容(用于判断查询文件内容时是否超大;1M)
MAX_INLINE_FILE_SIZE_BYTES=1048576
# 上传限制(用户上传压缩包;1000M)
UPLOAD_MAX_FILE_SIZE_BYTES=1048576000
UPLOAD_ALLOWED_EXTENSIONS=.zip
# 单文件上传大小限制(用于上传工程单文件;1000M
UPLOAD_SINGLE_FILE_SIZE_BYTES=1048576000
# 打包下载文件大小限制字节100M
DOWNLOAD_MAX_FILE_SIZE_BYTES=104857600
# 请求体大小限制Express 格式)
REQUEST_BODY_LIMIT=2000mb
# 目录遍历排除(返回内容、归档等)
TRAVERSE_EXCLUDE_DIRS=dist,node_modules,.pnpm-store,__MACOSX,.attachments
# 归档遍历排除文件
BACKUP_TRAVERSE_EXCLUDE_FILES=pnpm-lock.yaml,yarn.lock,package-lock.json
# 返回项目内容遍历排除文件
CONTENT_TRAVERSE_EXCLUDE_FILES=AGENT.md,AGENTS.md,CLAUDE.md,pnpm-lock.yaml,yarn.lock,package-lock.json
# 允许内联返回内容的图片扩展名
INLINE_IMAGE_EXTENSIONS=.png,.jpg,.jpeg,.gif,.bmp,.svg,.ico,.webp,.avif
# 顶层噪声条目(用于解压后扁平化判断)
TOP_LEVEL_NOISE_PATTERNS=__MACOSX,Thumbs.db,node_modules,.pnpm-store,.attachments
# 开发服务器配置
DEV_SERVER_PORT_TIMEOUT=5000
DEV_SERVER_STOP_TIMEOUT=5000
DEV_SERVER_STOP_CHECK_INTERVAL=100
DEV_SERVER_STOP_MAX_ATTEMPTS=50
# pnpm清理配置
PNPM_PRUNE_ENABLED=true
PNPM_PRUNE_SCHEDULE=0 2 * * *
PNPM_PRUNE_TIMEZONE=Asia/Shanghai
PNPM_PRUNE_RUN_ON_START=false
# 日志缓存配置
# 是否启用日志缓存
LOG_CACHE_ENABLED=true
# 缓存过期时间毫秒默认3分钟
LOG_CACHE_DURATION=180000
# 最大缓存项目数量
LOG_CACHE_MAX_ENTRIES=100
# 最大缓存文件大小字节默认2MB超过此大小的文件不缓存
LOG_CACHE_MAX_FILE_SIZE=2097152
# computer 工作目录
COMPUTER_WORKSPACE_DIR=/app/computer-project-workspace
# computer 日志目录
COMPUTER_LOG_DIR=/app/logs/computer_logs

View File

@@ -0,0 +1,231 @@
import express from "express";
import { startDevServer } from "../utils/build/startDevUtils.js";
import { restartDevServer } from "../utils/build/restartDevUtils.js";
import { keepAliveDevServer } from "../utils/build/keepAliveDevUtils.js";
import { stopDevServer } from "../utils/build/stopDevUtils.js";
import { buildProject } from "../utils/build/buildProjectUtils.js";
import { listRunningProcesses } from "../utils/build/processManager.js";
import BuildErrorParser from "../utils/error/buildErrorParser.js";
import { getDevLog } from "../utils/log/getDevLogUtils.js";
import logCacheManager from "../utils/log/logCacheManager.js";
import portPool from "../utils/buildArg/portPool.js";
import {
ValidationError,
BusinessError,
SystemError,
ProcessError,
asyncHandler,
} from "../utils/error/errorHandler.js";
const buildRouter = express.Router();
// 路由配置
const routes = [
{
path: "/start-dev",
method: "get",
handler: asyncHandler(async (req, res) => {
const projectId = req.query.projectId;
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
// 设置长超时时间:依赖安装可能需要较长时间
req.setTimeout(600000); // 10分钟
res.setTimeout(600000); // 10分钟
const result = await startDevServer(req, String(projectId));
res.json(result);
}),
},
{
path: "/keep-alive",
method: "get",
handler: asyncHandler(async (req, res) => {
const projectId = req.query.projectId;
const pid = req.query.pid;
const port = req.query.port;
const basePath = req.query.basePath;
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
if (!pid) {
throw new ValidationError("Process ID cannot be empty", { field: "pid" });
}
if (!port) {
throw new ValidationError("Port cannot be empty", { field: "port" });
}
if (!basePath) {
throw new ValidationError("basePath cannot be empty", { field: "basePath" });
}
const result = await keepAliveDevServer(
req,
String(projectId),
pid,
port,
basePath,
);
res.json(result);
}),
},
{
path: "/build",
method: "get",
handler: asyncHandler(async (req, res) => {
const projectId = req.query.projectId;
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
// 设置长超时时间:构建可能需要较长时间
req.setTimeout(600000); // 10分钟
res.setTimeout(600000); // 10分钟
const result = await buildProject(req, String(projectId));
res.json(result);
}),
},
{
path: "/stop-dev",
method: "get",
handler: asyncHandler(async (req, res) => {
const projectId = req.query.projectId;
const pid = req.query.pid;
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
if (!pid) {
throw new ValidationError("Process ID cannot be empty", { field: "pid" });
}
const result = await stopDevServer(req, String(projectId), pid, {
strict: true,
});
res.json(result);
}),
},
{
path: "/restart-dev",
method: "get",
handler: asyncHandler(async (req, res) => {
const projectId = req.query.projectId;
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
// 设置长超时时间:重启包含依赖安装,可能需要较长时间
req.setTimeout(600000); // 10分钟
res.setTimeout(600000); // 10分钟
const result = await restartDevServer(req, String(projectId));
res.json(result);
}),
},
{
path: "/list-dev",
method: "get",
handler: (req, res) => {
const list = listRunningProcesses();
const result = { success: true, list };
res.json(result);
},
},
{
path: "/parse-build-error",
method: "post",
handler: asyncHandler(async (req, res) => {
const { projectId, errorMessage } = req.body;
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
if (!errorMessage) {
throw new ValidationError("Error message cannot be empty", {
field: "errorMessage",
});
}
const errorParser = new BuildErrorParser();
const userFriendlyMessage = errorParser.parseBuildError(
errorMessage,
String(projectId)
);
res.json({
success: true,
message: userFriendlyMessage,
});
}),
},
{
path: "/get-dev-log",
method: "get",
handler: asyncHandler(async (req, res) => {
const projectId = req.query.projectId;
const startIndex = req.query.startIndex;
const logType = req.query.logType || "temp";
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
// 解析起始行号默认为1
const startLine = startIndex ? parseInt(startIndex, 10) : 1;
if (isNaN(startLine) || startLine < 1) {
throw new ValidationError("Start index must be a positive integer (starting from 1)", {
field: "startIndex",
value: startIndex,
});
}
const result = await getDevLog(String(projectId), startLine, logType);
res.json(result);
}),
},
{
path: "/get-log-cache-stats",
method: "get",
handler: (req, res) => {
const stats = logCacheManager.getStats();
res.json({
success: true,
message: "Get log cache statistics successfully",
stats,
});
},
},
{
path: "/clear-all-log-cache",
method: "get",
handler: (req, res) => {
logCacheManager.clear();
res.json({
success: true,
message: "All log caches have been cleared",
});
},
},
{
path: "/port-pool-status",
method: "get",
handler: (req, res) => {
const status = portPool.getStatus();
res.json({
success: true,
message: "Get port pool status successfully",
...status,
});
},
},
];
// 注册路由
routes.forEach((route) => {
buildRouter[route.method](route.path, route.handler);
});
export default buildRouter;

View File

@@ -0,0 +1,222 @@
import express from "express";
import multer from "multer";
import { asyncHandler, ValidationError } from "../utils/error/errorHandler.js";
import codeService from "../service/codeService.js";
import { log } from "../utils/log/logUtils.js";
import config from "../appConfig/index.js";
import { extractIsolationContext } from "../utils/common/projectPathUtils.js";
const codeRouter = express.Router();
// 配置multer用于内存存储适合小文件
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: config.UPLOAD_SINGLE_FILE_SIZE_BYTES, // 从环境变量读取文件大小限制
},
});
// 路由配置
const routes = [
{
path: "/specified-files-update",
method: "post",
handler: asyncHandler(async (req, res) => {
const { projectId, codeVersion, files } = req.body || {};
const isolationContext = extractIsolationContext(req.body || {});
log(projectId, "INFO", "Partial files update", {
projectId,
codeVersion,
filesCount: files ? files.length : 0,
});
// 解码文件内容(前端使用 encodeURIComponent 编码)
if (files && Array.isArray(files)) {
files.forEach((fileOp) => {
if (!fileOp) return;
// 只要有 contents 就解码
if (typeof fileOp.contents === "string" && fileOp.contents) {
try {
fileOp.contents = decodeURIComponent(fileOp.contents);
} catch (err) {
log(projectId, "WARN", "Decode file content failed", {
fileName: fileOp.path,
error: err.message,
});
// 如果解码失败,保持原样(可能前端没有编码)
}
}
});
}
const result = await codeService.specifiedFilesUpdate(
String(projectId),
String(codeVersion),
files,
req,
isolationContext
);
res.status(200).json(result);
}),
},
{
path: "/all-files-update",
method: "post",
handler: asyncHandler(async (req, res) => {
const { projectId, codeVersion, files, basePath, pid } = req.body || {};
const isolationContext = extractIsolationContext(req.body || {});
log(projectId, "INFO", "Submit files", {
projectId,
codeVersion,
basePath,
pid,
});
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
if (codeVersion === undefined || codeVersion === null) {
throw new ValidationError("codeVersion cannot be empty", {
field: "codeVersion",
});
}
if (!Array.isArray(files)) {
throw new ValidationError("files must be an array", { field: "files" });
}
// 解码文件内容(前端使用 encodeURIComponent 编码)
if (files && Array.isArray(files)) {
files.forEach((file) => {
if (file && typeof file.contents === "string" && file.contents) {
try {
file.contents = decodeURIComponent(file.contents);
} catch (err) {
log(projectId, "WARN", "Decode file content failed", {
fileName: file.name,
error: err.message,
});
// 如果解码失败,保持原样(可能前端没有编码)
}
}
});
}
const result = await codeService.allFilesUpdate(
String(projectId),
String(codeVersion),
files,
req,
isolationContext
);
res.status(200).json(result);
}),
},
{
path: "/upload-single-file",
method: "post",
middleware: upload.single("file"),
handler: asyncHandler(async (req, res) => {
const { projectId, codeVersion, filePath } = req.body || {};
const isolationContext = extractIsolationContext(req.body || {});
const file = req.file; // 从 multer 中间件获取上传的文件
log(projectId, "INFO", "上传单个文件", {
projectId,
codeVersion,
filePath,
});
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
if (codeVersion === undefined || codeVersion === null) {
throw new ValidationError("codeVersion cannot be empty", {
field: "codeVersion",
});
}
if (!file) {
throw new ValidationError("File cannot be empty", { field: "file" });
}
if (!filePath || typeof filePath !== "string") {
throw new ValidationError("File path cannot be empty", { field: "filePath" });
}
// 记录接收到的文件信息,用于调试
log(projectId, "INFO", "Received file information", {
originalname: file.originalname,
mimetype: file.mimetype,
size: file.size,
bufferLength: file.buffer ? file.buffer.length : 0,
bufferIsBuffer: Buffer.isBuffer(file.buffer),
});
// 构建文件对象统一使用buffermulter memoryStorage对所有文件类型都提供buffer
const fileObj = {
buffer: file.buffer,
originalname: file.originalname,
mimetype: file.mimetype,
size: file.size,
};
const result = await codeService.uploadSingleFile(
String(projectId),
String(codeVersion),
fileObj,
filePath,
req,
isolationContext
);
res.status(200).json(result);
}),
},
{
path: "/rollback-version",
method: "post",
handler: asyncHandler(async (req, res) => {
const { projectId, codeVersion, rollbackTo } = req.body || {};
const isolationContext = extractIsolationContext(req.body || {});
log(projectId, "INFO", "Rollback version", {
projectId,
codeVersion,
rollbackTo,
});
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
if (codeVersion === undefined || codeVersion === null) {
throw new ValidationError("codeVersion cannot be empty", {
field: "codeVersion",
});
}
if (rollbackTo === undefined || rollbackTo === null) {
throw new ValidationError("rollbackTo cannot be empty", {
field: "rollbackTo",
});
}
const result = await codeService.rollbackVersion(
String(projectId),
String(codeVersion),
String(rollbackTo),
req,
isolationContext
);
res.status(200).json(result);
}),
},
];
// 注册路由
routes.forEach((route) => {
if (route.middleware) {
// 如果有中间件,先注册中间件再注册处理器
codeRouter[route.method](route.path, route.middleware, route.handler);
} else {
// 没有中间件,直接注册处理器
codeRouter[route.method](route.path, route.handler);
}
});
export default codeRouter;

View File

@@ -0,0 +1,414 @@
import express from "express";
import multer from "multer";
import fs from "fs";
import path from "path";
import { ValidationError, asyncHandler } from "../utils/error/errorHandler.js";
import { log } from "../utils/log/logUtils.js";
import config from "../appConfig/index.js";
import { createWorkspace, pushSkillsToWorkspace, } from "../utils/computer/computerUtils.js";
import {
getFileList,
updateFiles,
uploadFile,
uploadFiles,
downloadAllFiles,
} from "../utils/computer/computerFileUtils.js";
const computerRouter = express.Router();
// 使用磁盘存储,便于后续解压 zip
const upload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => {
try {
// 将临时上传目录放到 COMPUTER_WORKSPACE_DIR/<userId>/<cId>/.tmp 下
const baseDir = config.COMPUTER_WORKSPACE_DIR;
if (!baseDir) {
return cb(
new Error("COMPUTER_WORKSPACE_DIR is not configured, cannot determine upload temporary directory")
);
}
const userId = req.body?.userId || "unknown";
const cId = req.body?.cId || "unknown";
const tmpUploadDir = path.join(
baseDir,
String(userId),
String(cId),
".tmp"
);
if (!fs.existsSync(tmpUploadDir)) {
fs.mkdirSync(tmpUploadDir, { recursive: true });
}
cb(null, tmpUploadDir);
} catch (err) {
cb(err);
}
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname) || ".zip";
const baseName = path.basename(file.originalname, ext);
const uniqueSuffix = `${Date.now()}_${Math.round(Math.random() * 1e6)}`;
cb(null, `${baseName}_${uniqueSuffix}${ext}`);
},
}),
limits: {
fileSize: config.UPLOAD_MAX_FILE_SIZE_BYTES,
},
});
// Multer 错误处理中间件(复用通用模式)
function multerErrorHandler(err, req, res, next) {
if (err && err.name === "MulterError") {
return next(err);
}
if (err && (err.name === "ValidationError" || err instanceof ValidationError)) {
return next(err);
}
next(err);
}
const routes = [
{
path: "/create-workspace",
method: "post",
middleware: upload.single("file"),
handler: asyncHandler(async (req, res) => {
const { userId, cId } = req.body || {};
const file = req.file || null;
const logId = `computer:${userId}:${cId}`;
log(logId, "INFO", "Create workspace request", {
userId,
cId,
hasFile: !!file,
fileName: file?.originalname,
fileSize: file?.size,
});
// 兼容老逻辑:仅处理上传 file不处理 skillUrls
const result = await createWorkspace(userId, cId, file);
res.status(200).json({
success: true,
...result,
});
}),
},
{
path: "/create-workspace-v2",
method: "post",
middleware: upload.single("file"),
handler: asyncHandler(async (req, res) => {
const { userId, cId, skillUrls } = req.body || {};
const file = req.file || null;
const logId = `computer:${userId}:${cId}`;
// multipart/form-data 中数组可能被序列化成 JSON 字符串
let normalizedSkillUrls = skillUrls;
if (typeof skillUrls === "string") {
try {
const parsed = JSON.parse(skillUrls);
normalizedSkillUrls = Array.isArray(parsed) ? parsed : [skillUrls];
} catch {
normalizedSkillUrls = [skillUrls];
}
}
log(logId, "INFO", "Create workspace v2 request", {
userId,
cId,
hasFile: !!file,
fileName: file?.originalname,
fileSize: file?.size,
skillUrlsCount: Array.isArray(normalizedSkillUrls) ? normalizedSkillUrls.length : 0,
});
const result = await createWorkspace(userId, cId, file, normalizedSkillUrls);
res.status(200).json({
success: true,
...result,
});
}),
},
{
path: "/push-skills-to-workspace",
method: "post",
middleware: upload.single("file"),
handler: asyncHandler(async (req, res) => {
const { userId, cId } = req.body || {};
const file = req.file || null;
const logId = `computer:${userId}:${cId}`;
log(logId, "INFO", "推送技能到工作空间请求", {
userId,
cId,
hasFile: !!file,
fileName: file?.originalname,
fileSize: file?.size,
});
// 兼容老逻辑:仅处理上传 file不处理 skillUrls
const result = await pushSkillsToWorkspace(userId, cId, file);
res.status(200).json({
success: true,
...result,
});
}),
},
{
path: "/push-skills-to-workspace-v2",
method: "post",
middleware: upload.single("file"),
handler: asyncHandler(async (req, res) => {
const { userId, cId, skillUrls } = req.body || {};
const file = req.file || null;
const logId = `computer:${userId}:${cId}`;
// multipart/form-data 中数组可能被序列化成 JSON 字符串
let normalizedSkillUrls = skillUrls;
if (typeof skillUrls === "string") {
try {
const parsed = JSON.parse(skillUrls);
normalizedSkillUrls = Array.isArray(parsed) ? parsed : [skillUrls];
} catch {
normalizedSkillUrls = [skillUrls];
}
}
log(logId, "INFO", "推送技能到工作空间请求(v2)", {
userId,
cId,
hasFile: !!file,
fileName: file?.originalname,
fileSize: file?.size,
skillUrlsCount: Array.isArray(normalizedSkillUrls) ? normalizedSkillUrls.length : 0,
});
const result = await pushSkillsToWorkspace(userId, cId, file, normalizedSkillUrls);
res.status(200).json({
success: true,
...result,
});
}),
},
{
path: "/get-file-list",
method: "get",
handler: asyncHandler(async (req, res) => {
const { userId, cId, proxyPath } = req.query;
const result = await getFileList(userId, cId, proxyPath);
res.status(200).json({ success: true, ...result });
}),
},
{
path: "/files-update",
method: "post",
handler: asyncHandler(async (req, res) => {
const { userId, cId, files } = req.body || {};
const logId = `computer:${userId}:${cId}`;
log(logId, "INFO", "Files update", {
userId,
cId,
filesCount: files ? files.length : 0,
});
// 解码文件内容(前端使用 encodeURIComponent 编码)
if (files && Array.isArray(files)) {
files.forEach((fileOp) => {
if (!fileOp) return;
// 只要有 contents 就解码
if (typeof fileOp.contents === "string" && fileOp.contents) {
try {
fileOp.contents = decodeURIComponent(fileOp.contents);
} catch (err) {
log(logId, "WARN", "Decode file content failed", {
fileName: fileOp.name,
error: err.message,
});
// 如果解码失败,保持原样(可能前端没有编码)
}
}
});
}
const result = await updateFiles(userId, cId, files);
res.status(200).json(result);
}),
},
{
path: "/upload-file",
method: "post",
middleware: upload.single("file"),
handler: asyncHandler(async (req, res) => {
const { userId, cId, filePath } = req.body || {};
const file = req.file; // 从 multer 中间件获取上传的文件
const logId = `computer:${userId}:${cId}`;
log(logId, "INFO", "Upload single file", {
userId,
cId,
filePath,
});
// 从磁盘读取文件内容diskStorage 模式下 file.buffer 不存在)
const fileBuffer = await fs.promises.readFile(file.path);
// 构建文件对象,支持文本和二进制文件
const fileObj = {
buffer: fileBuffer,
originalname: file.originalname,
mimetype: file.mimetype,
size: file.size,
};
try {
const result = await uploadFile(userId, cId, fileObj, filePath);
res.status(200).json(result);
} finally {
// 清理临时文件
if (fs.existsSync(file.path)) {
await fs.promises.unlink(file.path);
}
}
}),
},
{
path: "/upload-files",
method: "post",
middleware: upload.array("files"),
handler: asyncHandler(async (req, res) => {
const { userId, cId, filePaths } = req.body || {};
const files = req.files || []; // 从 multer 中间件获取上传的文件数组
const logId = `computer:${userId}:${cId}`;
// 标准化 filePaths 为数组格式,兼容单文件上传时可能传入字符串的情况
const normalizedFilePaths = Array.isArray(filePaths)
? filePaths
: typeof filePaths === "string"
? [filePaths]
: filePaths;
log(logId, "INFO", "Batch upload files request", {
userId,
cId,
filesCount: files.length,
filePathsCount: Array.isArray(normalizedFilePaths) ? normalizedFilePaths.length : 0,
});
// 从磁盘读取所有文件内容diskStorage 模式下 file.buffer 不存在)
const fileObjects = [];
const tempFilePaths = [];
try {
for (const file of files) {
tempFilePaths.push(file.path);
const fileBuffer = await fs.promises.readFile(file.path);
fileObjects.push({
buffer: fileBuffer,
originalname: file.originalname,
mimetype: file.mimetype,
size: file.size,
});
}
// 调用批量上传工具函数
const result = await uploadFiles(userId, cId, fileObjects, normalizedFilePaths);
res.status(200).json(result);
} finally {
// 清理所有临时文件
for (const tempPath of tempFilePaths) {
if (fs.existsSync(tempPath)) {
try {
await fs.promises.unlink(tempPath);
} catch (error) {
log(logId, "WARN", "Clean temporary file failed", {
tempPath,
error: error.message,
});
}
}
}
}
}),
},
{
path: "/download-all-files",
method: "get",
handler: asyncHandler(async (req, res) => {
const { userId, cId } = req.query || {};
const logId = `computer:${userId}:${cId}`;
log(logId, "INFO", "Download all files request", {
userId,
cId,
});
const { archive, zipFileName } = await downloadAllFiles(
userId,
cId
);
res.setHeader("Content-Type", "application/zip");
// 兼容中文文件名
const encodedName = encodeURIComponent(zipFileName);
res.setHeader(
"Content-Disposition",
`attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`
);
archive.on("error", (err) => {
// 让全局错误处理中间件接管
res.destroy(err);
});
archive.pipe(res);
archive.finalize();
}),
},
];
routes.forEach((route) => {
if (route.middleware) {
// 如果有中间件,先注册中间件再注册处理器
computerRouter[route.method](route.path, route.middleware, route.handler);
} else if (route.customHandler) {
// 对于有自定义处理器的路由
const middlewares = [];
// 先添加multer中间件
if (route.handler) {
middlewares.push(route.handler);
}
// 再添加文件名解码中间件(如果有)
if (route.decodeMiddleware) {
middlewares.push(route.decodeMiddleware);
}
// 添加错误处理中间件
middlewares.push(multerErrorHandler);
// 最后添加自定义处理器
middlewares.push(route.customHandler);
// 注册所有中间件
computerRouter[route.method](route.path, ...middlewares);
} else {
// 普通路由直接注册处理器
computerRouter[route.method](route.path, route.handler);
}
});
export default computerRouter;

View File

@@ -0,0 +1,552 @@
import express from "express";
import multer from "multer";
import path from "path";
import fs from "fs";
import iconv from "iconv-lite";
import projectService from "../service/projectService.js";
import {
getProjectContent,
getProjectContentByVersion,
} from "../utils/project/getContentUtils.js";
import { uploadAttachmentFile } from "../utils/project/uploadAttachmentFileUtils.js";
import { copyProject } from "../utils/project/copyProjectUtils.js";
import config from "../appConfig/index.js";
import { log } from "../utils/log/logUtils.js";
import {
extractIsolationContext,
resolveProjectPath,
} from "../utils/common/projectPathUtils.js";
import {
ValidationError,
SystemError,
asyncHandler,
} from "../utils/error/errorHandler.js";
const projectRouter = express.Router();
// 配置multer用于文件上传
const storage = multer.diskStorage({
destination: function (req, file, cb) {
// 先保存到临时目录,后续在路由处理器中移动到项目目录
const uploadDir = path.join(config.UPLOAD_PROJECT_DIR, "temp");
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e6);
cb(
null,
file.fieldname + "-" + uniqueSuffix + path.extname(file.originalname)
);
},
});
const upload = multer({
storage: storage,
fileFilter: function (req, file, cb) {
const ext = path.extname(file.originalname).toLowerCase();
if (config.UPLOAD_ALLOWED_EXTENSIONS.includes(ext)) {
cb(null, true);
} else {
// 将文件类型错误转换为 ValidationError
cb(
new ValidationError("File type not allowed", {
fileExtension: ext,
allowedExtensions: config.UPLOAD_ALLOWED_EXTENSIONS,
}),
false
);
}
},
limits: {
fileSize: config.UPLOAD_MAX_FILE_SIZE_BYTES,
},
});
// 为上传附件文件创建独立的multer配置支持更多文件类型
const attachmentStorage = multer.diskStorage({
destination: function (req, file, cb) {
const uploadDir = path.join(config.UPLOAD_PROJECT_DIR, "temp");
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e6);
cb(
null,
file.fieldname + "-" + uniqueSuffix + path.extname(file.originalname)
);
},
});
// 允许的附件文件扩展名常见文档、图片、PDF等
const ATTACHMENT_ALLOWED_EXTENSIONS = [
".pdf",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
".txt",
".md",
".csv",
".json",
".xml",
".png",
".jpg",
".jpeg",
".gif",
".bmp",
".svg",
".ico",
".webp",
".avif",
".zip",
".rar",
".7z",
".tar",
".gz",
".mp4",
".avi",
".mov",
".wmv",
".flv",
".mp3",
".wav",
".ogg",
".m4a",
];
const uploadAttachment = multer({
storage: attachmentStorage,
fileFilter: function (req, file, cb) {
const ext = path.extname(file.originalname).toLowerCase();
if (ATTACHMENT_ALLOWED_EXTENSIONS.includes(ext)) {
cb(null, true);
} else {
// 将文件类型错误转换为 ValidationError
cb(
new ValidationError("附件文件类型不被允许", {
fileExtension: ext,
allowedExtensions: ATTACHMENT_ALLOWED_EXTENSIONS,
}),
false
);
}
},
limits: {
fileSize: config.UPLOAD_MAX_FILE_SIZE_BYTES,
},
});
// 文件名解码中间件,用于正确处理中文文件名
function decodeFileNameMiddleware(req, res, next) {
if (req.file && req.file.originalname) {
try {
// 保存原始文件名用于日志
const beforeDecode = req.file.originalname;
// 尝试将文件名从 latin1 解码为 UTF-8
// 这是一个常见的编码问题,当文件名包含中文字符时
const decodedName = iconv.decode(
Buffer.from(req.file.originalname, "latin1"),
"utf8"
);
req.file.originalname = decodedName;
log("system", "INFO", "File name decoded successfully", {
before: beforeDecode,
after: decodedName,
});
} catch (err) {
// 如果解码失败,记录日志但继续处理
log("system", "WARN", "File name decoded failed", {
originalName: req.file.originalname,
error: err.message,
});
}
}
next();
}
// 路由配置
const routes = [
{
path: "/push-skills-to-workspace",
method: "post",
handler: upload.single("file"),
customHandler: asyncHandler(async (req, res) => {
const { projectId, skillUrls } = req.body || {};
const file = req.file || null;
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
const isolationContext = extractIsolationContext(req.body || {});
const result = await projectService.pushSkillsToWorkspace(
String(projectId),
file,
skillUrls,
isolationContext
);
res.status(200).json({ success: true, ...result });
}),
},
{
path: "/create-project",
method: "post",
handler: asyncHandler(async (req, res) => {
const { projectId, templateType } = req.body;
const isolationContext = extractIsolationContext(req.body || {});
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
const result = await projectService.createProject(
String(projectId),
templateType,
isolationContext
);
res.status(200).json(result);
}),
},
{
path: "/upload-project",
method: "post",
handler: upload.single("file"),
customHandler: asyncHandler(async (req, res) => {
const { projectId, codeVersion, pid, basePath } = req.body;
const isolationContext = extractIsolationContext(req.body || {});
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
if (!codeVersion) {
throw new ValidationError("Code version cannot be empty", { field: "codeVersion" });
}
if (!req.file) {
throw new ValidationError("Please upload a zip file", { field: "zipFile" });
}
// 处理文件上传(移动到项目目录)
const uploadResult = await projectService.handleFileUpload(
String(projectId),
codeVersion,
req.file
);
// 更新req.file.path为新的路径
req.file.path = uploadResult.filePath;
try {
const result = await projectService.uploadProject(
String(projectId),
req.file.path,
req,
codeVersion,
pid,
basePath,
isolationContext
);
res.status(200).json(result);
} catch (err) {
// 失败时清理项目目录uploadProject内部已经处理这里作为额外保障
try {
await projectService.cleanupProjectDirectory(
String(projectId),
isolationContext
);
} catch (cleanupErr) {
log(projectId, "ERROR", "Route layer cleanup project directory failed", {
projectId,
error: cleanupErr.message,
});
}
// 清理上传的文件
if (req.file && fs.existsSync(req.file.path)) {
try {
fs.unlinkSync(req.file.path);
} catch (cleanupErr) {
log(projectId, "ERROR", "Clean upload file failed", {
projectId,
error: cleanupErr.message,
});
}
}
throw err; // 重新抛出错误,让全局错误处理器处理
}
}),
},
{
path: "/get-project-content",
method: "get",
handler: asyncHandler(async (req, res) => {
const { projectId, command, proxyPath } = req.query;
const isolationContext = extractIsolationContext(req.query || {});
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
// 构建项目路径
const projectPath = resolveProjectPath(projectId, isolationContext);
// 检查项目目录是否存在
if (!fs.existsSync(projectPath)) {
throw new ValidationError("Project does not exist", { field: "projectId" });
}
// 获取项目内容
try {
const result = await getProjectContent(
projectPath,
command,
proxyPath
);
res.status(200).json({ success: true, ...result });
} catch (err) {
const message = err?.message || "Query failed";
res.status(500).json({ success: false, message });
}
}),
},
{
path: "/get-project-content-by-version",
method: "get",
handler: asyncHandler(async (req, res) => {
const { projectId, codeVersion, command, proxyPath } = req.query;
const isolationContext = extractIsolationContext(req.query || {});
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
if (!codeVersion) {
throw new ValidationError("Code version cannot be empty", { field: "codeVersion" });
}
try {
const result = await getProjectContentByVersion(
String(projectId),
codeVersion,
command,
proxyPath,
isolationContext
);
res.status(200).json({ success: true, ...result });
} catch (err) {
const message = err?.message || "Query failed";
res.status(500).json({ success: false, message });
}
}),
},
{
path: "/backup-current-version",
method: "post",
handler: asyncHandler(async (req, res) => {
const { projectId, codeVersion } = req.body;
const isolationContext = extractIsolationContext(req.body || {});
const result = await projectService.backupCurrentVersion(
String(projectId),
codeVersion,
isolationContext
);
res.status(200).json({ success: true, ...result });
}),
},
{
path: "/export-project",
method: "post",
handler: asyncHandler(async (req, res) => {
const { projectId, codeVersion, exportType, config } = req.body;
const isolationContext = extractIsolationContext(req.body || {});
const result = await projectService.exportProject(
String(projectId),
codeVersion,
exportType,
config,
isolationContext
);
// 检查zip文件是否存在
if (!fs.existsSync(result.zipPath)) {
throw new SystemError("Exported zip file does not exist", {
zipPath: result.zipPath,
});
}
// 设置响应头,指定文件下载
const fileName = path.basename(result.zipPath);
res.setHeader("Content-Type", "application/zip");
res.setHeader(
"Content-Disposition",
`attachment; filename="${fileName}"`
);
res.sendFile(result.zipPath, (err) => {
if (err) {
log(projectId, "ERROR", "Send zip file failed", {
projectId,
error: err.message,
});
if (!res.headersSent) {
res.status(500).json({ success: false, message: "File send failed" });
}
} else {
log(projectId, "INFO", "Zip file send successfully", {
projectId,
zipPath: result.zipPath,
});
}
});
}),
},
{
path: "/delete-project",
method: "get",
handler: asyncHandler(async (req, res) => {
const { projectId, pid } = req.query;
const isolationContext = extractIsolationContext(req.query || {});
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
const result = await projectService.deleteProject(
String(projectId),
pid,
req,
isolationContext
);
res.status(200).json(result);
}),
},
{
path: "/upload-attachment-file",
method: "post",
handler: uploadAttachment.single("file"),
decodeMiddleware: decodeFileNameMiddleware,
customHandler: asyncHandler(async (req, res) => {
const { projectId, fileName } = req.body;
const isolationContext = extractIsolationContext(req.body || {});
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
if (!req.file) {
throw new ValidationError("Please upload a file", { field: "file" });
}
try {
const result = await uploadAttachmentFile(
String(projectId),
req.file,
fileName,
isolationContext
);
res.status(200).json({ success: true, ...result });
} catch (err) {
// 失败时清理上传的文件
if (req.file && fs.existsSync(req.file.path)) {
try {
fs.unlinkSync(req.file.path);
} catch (cleanupErr) {
log(projectId, "ERROR", "Clean upload file failed", {
projectId,
error: cleanupErr.message,
});
}
}
throw err; // 重新抛出错误,让全局错误处理器处理
}
}),
},
{
path: "/copy-project",
method: "post",
handler: asyncHandler(async (req, res) => {
const { sourceProjectId, targetProjectId } = req.body;
const sourceIsolationContext = extractIsolationContext({
tenantId: req.body?.sourceTenantId || req.body?.tenantId,
spaceId: req.body?.sourceSpaceId || req.body?.spaceId,
isolationType: req.body?.sourceIsolationType || req.body?.isolationType,
});
const targetIsolationContext = extractIsolationContext({
tenantId: req.body?.targetTenantId || req.body?.tenantId,
spaceId: req.body?.targetSpaceId || req.body?.spaceId,
isolationType: req.body?.targetIsolationType || req.body?.isolationType,
});
if (!sourceProjectId) {
throw new ValidationError("Source project ID cannot be empty", {
field: "sourceProjectId",
});
}
if (!targetProjectId) {
throw new ValidationError("Target project ID cannot be empty", {
field: "targetProjectId",
});
}
const result = await copyProject(
String(sourceProjectId),
String(targetProjectId),
{
sourceIsolationContext,
targetIsolationContext,
}
);
res.status(200).json(result);
}),
},
];
// Multer错误处理中间件
function multerErrorHandler(err, req, res, next) {
if (err instanceof multer.MulterError) {
// Multer错误会被全局错误处理器捕获并处理
return next(err);
}
// 如果是 ValidationError直接传递给错误处理器
if (err.name === "ValidationError" || err instanceof ValidationError) {
return next(err);
}
next(err);
}
// 注册路由
routes.forEach((route) => {
if (route.customHandler) {
// 对于有自定义处理器的路由
const middlewares = [];
// 先添加multer中间件
if (route.handler) {
middlewares.push(route.handler);
}
// 再添加文件名解码中间件(如果有)
if (route.decodeMiddleware) {
middlewares.push(route.decodeMiddleware);
}
// 添加错误处理中间件
middlewares.push(multerErrorHandler);
// 最后添加自定义处理器
middlewares.push(route.customHandler);
// 注册所有中间件
projectRouter[route.method](route.path, ...middlewares);
} else {
// 普通路由直接注册处理器
projectRouter[route.method](route.path, route.handler);
}
});
export default projectRouter;

View File

@@ -0,0 +1,56 @@
import express from "express";
import buildRouter from "./buildRoutes.js";
import projectRouter from "./projectRoutes.js";
import codeRouter from "./codeRoutes.js";
import computerRouter from "./computerRoutes.js";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// 读取 package.json 获取版本号(使用绝对路径确保跨场景兼容)
let version = "1.0.0";
try {
const packageJsonPath = path.resolve(__dirname, "..", "..", "package.json");
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
version = packageJson.version || "1.0.0";
} catch (err) {
// 使用默认值
}
const router = express.Router();
router.get("/", (req, res) => {
res.send("Hello");
});
router.get("/health", (req, res) => {
const uptimeSeconds = Math.floor(process.uptime());
const memoryUsage = process.memoryUsage();
const memory = {
heapUsed: Math.round(memoryUsage.heapUsed / 1024 / 1024 * 100) / 100,
heapTotal: Math.round(memoryUsage.heapTotal / 1024 / 1024 * 100) / 100,
rss: Math.round(memoryUsage.rss / 1024 / 1024 * 100) / 100,
external: Math.round(memoryUsage.external / 1024 / 1024 * 100) / 100,
};
const healthData = {
status: "ok",
timestamp: Date.now(),
uptime: uptimeSeconds,
version: version,
platform: process.platform,
nodeVersion: process.version,
pid: process.pid,
memory: memory,
env: process.env.NODE_ENV || "unknown",
};
res.json(healthData);
});
router.use("/api/build", buildRouter);
router.use("/api/project", projectRouter);
router.use("/api/project", codeRouter);
router.use("/api/computer", computerRouter);
export default router;

View File

@@ -0,0 +1,245 @@
/**
* pnpm store prune 定时任务调度器ESM
*/
import cron from "node-cron";
import { exec } from "child_process";
import fs from "fs";
import path from "path";
import { log } from "../utils/log/logUtils.js";
class PnpmPruneScheduler {
constructor(config = {}) {
// 默认配置:每周日凌晨 2 点执行
this.config = {
enabled: process.env.PNPM_PRUNE_ENABLED !== 'false', // 默认启用(只有明确设置为 'false' 才禁用)
schedule: process.env.PNPM_PRUNE_SCHEDULE || '0 2 * * 0', // Cron 表达式
timezone: process.env.PNPM_PRUNE_TIMEZONE || 'Asia/Shanghai',
runOnStart: process.env.PNPM_PRUNE_RUN_ON_START === 'true', // 启动时是否立即执行一次(明确设置为 'true' 才启用)
...config,
};
this.task = null;
this.isRunning = false;
}
/**
* 启动定时任务
*/
start() {
if (!this.config.enabled) {
log('scheduler', 'INFO', 'pnpm prune scheduler is disabled (PNPM_PRUNE_ENABLED=false)');
return;
}
// 验证 cron 表达式
if (!cron.validate(this.config.schedule)) {
log('scheduler', 'ERROR', `Invalid cron expression: ${this.config.schedule}`);
return;
}
log('scheduler', 'INFO', 'pnpm prune scheduler is started', {
schedule: this.config.schedule,
timezone: this.config.timezone,
});
// 创建定时任务
this.task = cron.schedule(
this.config.schedule,
() => {
this.executePrune();
},
{
scheduled: true,
timezone: this.config.timezone,
}
);
// 如果配置了启动时执行,则立即执行一次
if (this.config.runOnStart) {
log('scheduler', 'INFO', 'Immediately execute pnpm prune on startup');
setTimeout(() => {
this.executePrune();
}, 5000); // Delay 5 seconds to ensure the application is fully started
}
}
/**
* 停止定时任务
*/
stop() {
if (this.task) {
this.task.stop();
log('scheduler', 'INFO', 'pnpm prune scheduler is stopped');
}
}
/**
* 执行 pnpm store prune
*/
async executePrune() {
if (this.isRunning) {
log('scheduler', 'WARN', 'pnpm prune is running, skipping this schedule');
return;
}
this.isRunning = true;
log('scheduler', 'INFO', '====================================');
log('scheduler', 'INFO', 'Start executing pnpm store prune');
log('scheduler', 'INFO', '====================================');
try {
// 获取清理前的状态
const beforeStatus = await this.getStoreStatus();
if (beforeStatus) {
log('scheduler', 'INFO', 'Before status', beforeStatus);
}
// 执行清理
const result = await this.runCommand('pnpm store prune');
if (result.success) {
log('scheduler', 'INFO', '✅ pnpm store prune executed successfully');
if (result.stdout) {
log('scheduler', 'INFO', result.stdout);
}
// 获取清理后的状态
const afterStatus = await this.getStoreStatus();
if (afterStatus) {
log('scheduler', 'INFO', 'After status', afterStatus);
}
} else {
log('scheduler', 'ERROR', '❌ pnpm store prune executed failed', {
error: result.error,
});
}
} catch (error) {
log('scheduler', 'ERROR', 'pnpm prune executed exception', {
error: error.message,
});
} finally {
this.isRunning = false;
log('scheduler', 'INFO', '====================================');
log('scheduler', 'INFO', 'pnpm store prune executed successfully');
log('scheduler', 'INFO', '====================================\n');
}
}
/**
* 获取 store 状态
*/
async getStoreStatus() {
try {
const pathResult = await this.runCommand('pnpm store path');
if (!pathResult.success) {
return null;
}
const storePath = pathResult.stdout.trim();
// 获取 store 大小
const sizeResult = await this.runCommand(`du -sh "${storePath}"`);
const storeSize = sizeResult.success
? sizeResult.stdout.trim().split('\t')[0]
: 'unknown';
return {
path: storePath,
size: storeSize,
};
} catch (error) {
return null;
}
}
/**
* 执行命令
*/
runCommand(command) {
return new Promise((resolve) => {
exec(
command,
{
maxBuffer: 10 * 1024 * 1024,
env: process.env, // 继承父进程的环境变量,包括 pnpm 配置
},
(error, stdout, stderr) => {
if (error) {
resolve({
success: false,
error: error.message,
stderr: stderr,
});
} else {
resolve({
success: true,
stdout: stdout,
});
}
});
});
}
/**
* 获取下次执行时间
*/
getNextRun() {
// node-cron 没有直接提供下次执行时间的方法
// 这里只是返回配置
return {
schedule: this.config.schedule,
timezone: this.config.timezone,
};
}
}
// 单例实例
let schedulerInstance = null;
/**
* 获取调度器实例
*/
function getScheduler(config) {
if (!schedulerInstance) {
schedulerInstance = new PnpmPruneScheduler(config);
}
return schedulerInstance;
}
/**
* 启动调度器
*/
function startScheduler(config) {
const scheduler = getScheduler(config);
scheduler.start();
return scheduler;
}
/**
* 停止调度器
*/
function stopScheduler() {
if (schedulerInstance) {
schedulerInstance.stop();
}
}
/**
* 手动执行一次 pnpm store prune
* @param {Object} config - 可选配置
* @returns {Promise<void>}
*/
async function executePruneManually(config = {}) {
const scheduler = getScheduler(config);
await scheduler.executePrune();
}
export {
PnpmPruneScheduler,
getScheduler,
startScheduler,
stopScheduler,
executePruneManually,
};

View File

@@ -0,0 +1,253 @@
import express from "express";
import JSONbig from "json-bigint";
import swaggerUi from "swagger-ui-express";
import swaggerSpecs from "./config/swagger.js";
import config from "./appConfig/index.js";
import { log, logger } from "./utils/log/logUtils.js";
import logCacheManager from "./utils/log/logCacheManager.js";
import { errorHandler, notFoundHandler } from "./utils/error/errorHandler.js";
import router from "./routes/router.js";
import { cleanupInitProjectOnStartup } from "./utils/project/initProjectCleanupUtils.js";
import { startScheduler, stopScheduler } from "./scheduler/pnpmPruneScheduler.js";
import path from "path";
const app = express();
// 解析 JSON 请求体,使用 json-bigint 处理大整数
app.use(
express.json({
limit: config.REQUEST_BODY_LIMIT,
reviver: (key, value) => {
// 对于大整数,保持为字符串
if (typeof value === "number" && !Number.isSafeInteger(value)) {
return value.toString();
}
return value;
},
})
);
// 解析 URL 编码的请求体
app.use(
express.urlencoded({ extended: true, limit: config.REQUEST_BODY_LIMIT })
);
// 使用日志中间件
app.use(logger);
// 帮助方法:对 URL 路径做尽可能多次解码(处理客户端双重编码等情况)
const safeDecodePath = (p) => {
let prev = p;
try {
// 连续解码直到不再变化或解码失败
while (true) {
const decoded = decodeURIComponent(prev);
if (decoded === prev) break;
prev = decoded;
}
} catch (e) {
// 非法编码时直接返回当前结果,避免抛错
}
return prev;
};
// 静态文件服务:提供页面工程文件的直接访问
// 格式1: /api/page/static/<projectId>/<path/to/file> -> config.PROJECT_SOURCE_DIR
app.use("/api/page/static/:projectId", (req, res, next) => {
const { projectId } = req.params;
// req.path 是挂载点之后的路径,形如 "/path/to/file"
let filePath = req.path || "/";
if (!projectId || filePath === "/") {
return res.status(404).send("Not Found");
}
// 设置 CORS 头,允许跨域访问静态资源(必须在 sendFile 之前设置)
const origin = req.headers.origin;
// 如果请求包含 Origin使用具体的 origin否则使用 *
// 注意:当使用 credentials 时,不能使用 *,必须使用具体的 origin
const allowOrigin = origin || "*";
res.header("Access-Control-Allow-Origin", allowOrigin);
res.header("Access-Control-Allow-Methods", "HEAD,GET,POST,PUT,DELETE,OPTIONS");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control, Fragment");
res.header("Access-Control-Expose-Headers", "Content-Type");
if (origin) {
res.header("Access-Control-Allow-Credentials", "true");
res.header("Vary", "Origin");
}
// 处理 OPTIONS 预检请求
if (req.method === "OPTIONS") {
return res.sendStatus(200);
}
// 去掉前导 "/",转成相对路径
filePath = filePath.replace(/^\/+/, "");
// 对可能被多次编码的路径做安全解码(支持中文等)
const decodedPath = safeDecodePath(filePath);
const fullPath = path.join(config.PROJECT_SOURCE_DIR, projectId, decodedPath);
// 使用 headers 选项确保 CORS 头被保留
const corsHeaders = {
"Access-Control-Allow-Origin": allowOrigin,
"Access-Control-Allow-Methods": "HEAD,GET,POST,PUT,DELETE,OPTIONS",
"Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control, Fragment",
};
if (origin) {
corsHeaders["Access-Control-Allow-Credentials"] = "true";
corsHeaders["Vary"] = "Origin";
}
return res.sendFile(
fullPath,
{
// dotfiles: "ignore",
dotfiles: "allow",
headers: corsHeaders,
},
(err) => {
if (err) return next();
}
);
});
// 静态文件服务:提供桌面文件的直接访问
// 格式2: /api/computer/static/<userId>/<cId>/<path/to/file> -> config.COMPUTER_WORKSPACE_DIR
app.use("/api/computer/static/:userId/:cId", (req, res, next) => {
const { userId, cId } = req.params;
let filePath = req.path || "/";
if (!userId || !cId || filePath === "/") {
return res.status(404).send("Not Found");
}
// 设置 CORS 头,允许跨域访问静态资源(必须在 sendFile 之前设置)
const origin = req.headers.origin;
// 如果请求包含 Origin使用具体的 origin否则使用 *
// 注意:当使用 credentials 时,不能使用 *,必须使用具体的 origin
const allowOrigin = origin || "*";
res.header("Access-Control-Allow-Origin", allowOrigin);
res.header("Access-Control-Allow-Methods", "HEAD,GET,POST,PUT,DELETE,OPTIONS");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control, Range, If-Range");
res.header("Access-Control-Expose-Headers", "Content-Type, Content-Length, Content-Range, Accept-Ranges, ETag, Last-Modified");
if (origin) {
res.header("Access-Control-Allow-Credentials", "true");
res.header("Vary", "Origin");
}
// 处理 OPTIONS 预检请求
if (req.method === "OPTIONS") {
return res.sendStatus(200);
}
// 去掉前导 "/",确保是相对路径
filePath = filePath.replace(/^\/+/, "");
// 安全多次解码,解决 "%25E4%25BD%25A0" 这种双重编码的中文
const decodedPath = safeDecodePath(filePath);
const fullPath = path.join(
config.COMPUTER_WORKSPACE_DIR,
userId,
cId,
decodedPath
);
// 使用 headers 选项确保 CORS 头被保留
const corsHeaders = {
"Access-Control-Allow-Origin": allowOrigin,
"Access-Control-Allow-Methods": "HEAD,GET,POST,PUT,DELETE,OPTIONS",
"Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control, Range, If-Range",
"Access-Control-Expose-Headers": "Content-Type, Content-Length, Content-Range, Accept-Ranges, ETag, Last-Modified",
};
if (origin) {
corsHeaders["Access-Control-Allow-Credentials"] = "true";
corsHeaders["Vary"] = "Origin";
}
return res.sendFile(
fullPath,
{
// dotfiles: "ignore",
dotfiles: "allow",
headers: corsHeaders,
},
(err) => {
if (err) return next();
}
);
});
// Swagger API 文档
app.use(
"/api-docs",
swaggerUi.serve,
swaggerUi.setup(swaggerSpecs, {
customCss: ".swagger-ui .topbar { display: none }",
customSiteTitle: "qiming-file-server API Documentation",
})
);
// 使用路由配置
app.use(router);
// 404处理中间件必须在所有路由之后
app.use(notFoundHandler);
// 全局错误处理中间件(必须在最后)
app.use(errorHandler);
// 启动服务器
const server = app.listen(config.PORT, async () => {
log(
"default",
"INFO",
`Server is running on port ${config.PORT} (${config.NODE_ENV} mode)`
);
// 项目启动时清理初始化项目文件夹,目的是更新初始化包时,只需要更新zip包
await cleanupInitProjectOnStartup(config);
// 启动 pnpm prune 定时任务
try {
startScheduler();
} catch (error) {
log("default", "ERROR", `pnpm prune scheduled task failed to start: ${error.message}`);
}
});
// 设置服务器超时时间 10 分钟(默认是 120 秒)
// 对于长时间运行的操作(如依赖安装、构建)是必要的
server.timeout = 600000; // 10 分钟
server.keepAliveTimeout = 610000; // 略大于 timeout
server.headersTimeout = 620000; // 略大于 keepAliveTimeout
// 优雅退出处理
const gracefulShutdown = (signal) => {
log("default", "INFO", `Received ${signal} signal, preparing graceful exit...`);
// 停止定时任务
stopScheduler();
// 清理日志缓存管理器(清理定时器)
try {
logCacheManager.destroy();
log("default", "INFO", "Log cache manager cleared");
} catch (error) {
log("default", "ERROR", `Failed to clear log cache manager: ${error.message}`);
}
// 关闭服务器
server.close(() => {
log("default", "INFO", "Server closed");
process.exit(0);
});
// 如果 30 秒后还未退出,强制退出
setTimeout(() => {
log("default", "ERROR", "Force exit (timeout)");
process.exit(1);
}, 30000);
};
// 监听退出信号
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

View File

@@ -0,0 +1,855 @@
import path from "path";
import fs from "fs";
import config from "../appConfig/index.js";
import { restartDevServer } from "../utils/build/restartDevUtils.js";
import { log } from "../utils/log/logUtils.js";
import {
ValidationError,
SystemError,
ResourceError,
FileError,
} from "../utils/error/errorHandler.js";
import { sanitizeSensitivePaths } from "../utils/common/sensitiveUtils.js";
import {
backupProjectToZip,
restoreProjectFromZip,
pruneMissingFiles,
removeEmptyDirectories,
} from "../utils/project/backupUtils.js";
import { extractSingleFileFromZip } from "../utils/common/zipUtils.js";
import {
shouldRestartForSingleFile,
shouldRestartDevServer,
} from "../utils/buildJudge/restartJudgeUtils.js";
import { resolveProjectPath } from "../utils/common/projectPathUtils.js";
/**
* 按行对比旧内容与新内容,返回合并后的内容和变更行数
* - 行内容不同视为修改
* - 旧文件多出的行视为删除
* - 新文件多出的行视为新增
* - 保持原文件换行符风格(\n 或 \r\n
*/
function diffContentByLines(existingContent, newContentStr) {
const oldLines = existingContent.split(/\r?\n/);
const newLines = newContentStr.split(/\r?\n/);
const oldLen = oldLines.length;
const newLen = newLines.length;
const minLen = Math.min(oldLen, newLen);
let changesCount = 0;
// 1) 对齐范围内的行:不同则视为修改
for (let idx = 0; idx < minLen; idx++) {
if (oldLines[idx] !== newLines[idx]) {
oldLines[idx] = newLines[idx];
changesCount++;
}
}
// 2) old 比 new 长的部分:多出来的行视为删除,从后往前删
if (oldLen > newLen) {
for (let idx = oldLen - 1; idx >= newLen; idx--) {
oldLines.splice(idx, 1);
changesCount++;
}
}
// 3) new 比 old 长的部分:多出来的行视为新增,按顺序追加
if (newLen > oldLen) {
for (let idx = oldLen; idx < newLen; idx++) {
oldLines.push(newLines[idx]);
changesCount++;
}
}
const newline = existingContent.includes("\r\n") ? "\r\n" : "\n";
const finalContent = oldLines.join(newline);
return { finalContent, changesCount };
}
/**
* 直接使用新内容替换旧内容
* - 若内容完全一致,则 changesCount 为 0
* - 预留备用:当前未在业务中使用
*/
function replaceContentDirectly(existingContent, newContentStr) {
if (existingContent === newContentStr) {
return { finalContent: existingContent, changesCount: 0 };
}
return { finalContent: newContentStr, changesCount: -1 };
}
/**
* 部分文件更新:支持新增、删除、重命名、修改操作
* @param {string} projectId 项目ID
* @param {string} codeVersion 代码版本号
* @param {Array} files 文件操作列表
* @param {Object} req 请求对象
* @returns {Object} 更新结果
*/
async function specifiedFilesUpdate(
projectId,
codeVersion,
files,
req,
isolationContext = {}
) {
const startTime = Date.now();
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
if (codeVersion === undefined || codeVersion === null) {
throw new ValidationError("codeVersion cannot be empty", {
field: "codeVersion",
});
}
const versionNum = Number(codeVersion);
if (!Number.isFinite(versionNum)) {
throw new ValidationError("codeVersion must be a number", {
field: "codeVersion",
});
}
if (!Array.isArray(files)) {
throw new ValidationError("files must be an array", { field: "files" });
}
// 验证文件操作结构
for (let i = 0; i < files.length; i++) {
const fileOp = files[i];
if (!fileOp || typeof fileOp.operation !== "string") {
throw new ValidationError(`files[${i}].operation cannot be empty`, {
field: `files[${i}].operation`,
});
}
// 使用 name 作为文件路径字段
if (!fileOp.name || typeof fileOp.name !== "string") {
throw new ValidationError(`files[${i}].name cannot be empty`, {
field: `files[${i}].name`,
});
}
const operation = fileOp.operation.toLowerCase();
if (!["create", "delete", "rename", "modify"].includes(operation)) {
throw new ValidationError(
`files[${i}].operation must be one of create, delete, rename or modify`,
{ field: `files[${i}].operation` }
);
}
// 验证特定操作所需的字段
if (operation === "rename" && !fileOp.renameFrom) {
throw new ValidationError(
`files[${i}].renameFrom cannot be empty (rename operation requires)`,
{ field: `files[${i}].renameFrom` }
);
}
if (operation === "modify") {
if (typeof fileOp.contents !== "string") {
throw new ValidationError(
`files[${i}].contents must be a string (modify operation requires)`,
{ field: `files[${i}].contents` }
);
}
}
}
const projectPath = resolveProjectPath(projectId, isolationContext);
if (!fs.existsSync(projectPath)) {
log(projectId, "ERROR", "Project does not exist", { projectId, projectPath });
throw new ResourceError("Project does not exist", { projectId });
}
let backupZipPath = "";
try {
// 1) 备份
const backupDir = path.join(config.UPLOAD_PROJECT_DIR, projectId);
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
const zipName = `${projectId}-v${versionNum}.zip`;
backupZipPath = path.join(backupDir, zipName);
log(projectId, "DEBUG", "Start backing up project", { projectId, backupZipPath });
await backupProjectToZip(projectId, projectPath, backupZipPath);
log(projectId, "INFO", "Project backed up successfully", {
projectId,
zipPath: backupZipPath,
});
// 2) 处理文件操作
try {
log(projectId, "DEBUG", "Start processing file operations", { projectId, filesCount: files.length });
for (const fileOp of files) {
const operation = fileOp.operation.toLowerCase();
// 使用 name 作为文件路径
const fileName = fileOp.name;
const normalizedPath = path.normalize(fileName).replace(/^[\/\\]+/, "");
const targetPath = path.join(projectPath, normalizedPath);
// 安全检查:确保目标路径在项目目录内
const resolvedTargetPath = path.resolve(targetPath);
const resolvedProjectPath = path.resolve(projectPath);
if (!resolvedTargetPath.startsWith(resolvedProjectPath + path.sep) &&
resolvedTargetPath !== resolvedProjectPath) {
log(projectId, "WARN", "Unsafe file path, skipping", {
filePath: normalizedPath,
resolvedPath: resolvedTargetPath,
});
continue;
}
switch (operation) {
case "create": {
// 创建新文件
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
const contents = fileOp.contents || "";
await fs.promises.writeFile(targetPath, contents, "utf8");
log(projectId, "INFO", "File created successfully", {
filePath: normalizedPath,
});
break;
}
case "delete": {
// 删除文件
if (fs.existsSync(targetPath)) {
await fs.promises.unlink(targetPath);
log(projectId, "INFO", "File deleted successfully", {
filePath: normalizedPath,
});
} else {
log(projectId, "WARN", "File to delete does not exist", {
filePath: normalizedPath,
});
}
break;
}
case "rename": {
// 重命名文件
const renameFrom = fileOp.renameFrom;
if (!renameFrom || typeof renameFrom !== "string") {
log(projectId, "WARN", "Rename operation missing renameFrom", {
filePath: normalizedPath,
});
break;
}
const normalizedFrom = path.normalize(renameFrom).replace(/^[\/\\]+/, "");
const oldPath = path.join(projectPath, normalizedFrom);
const resolvedOldPath = path.resolve(oldPath);
// 安全检查
if (!resolvedOldPath.startsWith(resolvedProjectPath + path.sep) &&
resolvedOldPath !== resolvedProjectPath) {
log(projectId, "WARN", "Unsafe rename source path, skipping", {
renameFrom: normalizedFrom,
resolvedPath: resolvedOldPath,
});
break;
}
if (fs.existsSync(oldPath)) {
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
await fs.promises.rename(oldPath, targetPath);
log(projectId, "INFO", "File renamed successfully", {
oldPath: normalizedFrom,
newPath: normalizedPath,
});
} else {
log(projectId, "WARN", "File to rename does not exist", {
renameFrom: normalizedFrom,
});
}
break;
}
case "modify": {
// 修改文件:前端传入完整 contents这里根据新旧内容按行生成增删改
if (!fs.existsSync(targetPath)) {
log(projectId, "WARN", "File to modify does not exist", {
filePath: normalizedPath,
});
break;
}
// 读取现有文件内容
const existingContent = await fs.promises.readFile(
targetPath,
"utf8"
);
const newContentStr =
typeof fileOp.contents === "string" ? fileOp.contents : "";
// 使用按行 diff 的方式生成最终内容
const { finalContent, changesCount } = diffContentByLines(
existingContent,
newContentStr
);
// 1) 若内容完全一致,不覆写文件,避免触发 HMR
if (changesCount === 0) {
log(projectId, "INFO", "File content unchanged, skipping write", {
filePath: normalizedPath,
});
break;
}
// 2) 写入修改后的内容
await fs.promises.writeFile(targetPath, finalContent, "utf8");
log(projectId, "INFO", "File modified successfully", {
filePath: normalizedPath,
changesCount,
});
break;
}
default: {
log(projectId, "WARN", "Unsupported operation type", {
operation,
filePath: normalizedPath,
});
break;
}
}
}
// 3) 清理空目录
log(projectId, "DEBUG", "Start cleaning empty directories", { projectId });
try {
await removeEmptyDirectories(
projectPath,
config.TRAVERSE_EXCLUDE_DIRS || []
);
} catch (e) {
log(projectId, "WARN", "Failed to clean empty directories", {
projectId,
error: e && e.message,
});
}
log(projectId, "INFO", "Specified files updated successfully", {
projectId,
filesCount: files.length,
elapsedMs: Date.now() - startTime,
});
return {
success: true,
message: "Specified files updated successfully",
projectId,
filesCount: files.length,
};
} catch (e) {
log(projectId, "ERROR", "Failed to process file operations", {
projectId,
error: e && e.message,
elapsedMs: Date.now() - startTime,
});
throw e;
}
} catch (backupErr) {
// 备份阶段失败:不执行回滚(无备份可回滚),直接抛出
if (!backupErr.isOperational) {
throw new SystemError("Failed to backup project", {
projectId,
originalError: backupErr && backupErr.message,
});
}
throw backupErr;
}
}
async function allFilesUpdate(
projectId,
codeVersion,
files,
req,
isolationContext = {}
) {
const startTime = Date.now();
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
const versionNum = Number(codeVersion);
if (!Number.isFinite(versionNum)) {
throw new ValidationError("codeVersion must be a number", {
field: "codeVersion",
});
}
if (!Array.isArray(files)) {
throw new ValidationError("files must be an array", { field: "files" });
}
const projectPath = resolveProjectPath(projectId, isolationContext);
if (!fs.existsSync(projectPath)) {
log(projectId, "ERROR", "Project does not exist", { projectId, projectPath });
throw new ResourceError("Project does not exist", { projectId });
}
let backupZipPath = "";
try {
// 1) 备份
const backupDir = path.join(config.UPLOAD_PROJECT_DIR, projectId);
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
const zipName = `${projectId}-v${versionNum}.zip`;
backupZipPath = path.join(backupDir, zipName);
log(projectId, "DEBUG", "Start backing up project", { projectId, backupZipPath });
await backupProjectToZip(projectId, projectPath, backupZipPath);
// 2) 写入文件
try {
log(projectId, "DEBUG", "Start writing files", { projectId, filesCount: files.length });
for (const file of files) {
if (!file || typeof file.name !== "string") continue;
const targetPath = path.join(projectPath, file.name);
// 处理文件重命名:如果存在 renameFrom需要先重命名原文件
if (file.renameFrom && typeof file.renameFrom === "string") {
const oldPath = path.join(projectPath, file.renameFrom);
if (fs.existsSync(oldPath)) {
// 确保目标目录存在(跨目录重命名时需要)
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
await fs.promises.rename(oldPath, targetPath);
log(projectId, "INFO", "File renamed successfully", {
projectId,
oldPath: file.renameFrom,
newPath: file.name,
});
continue; // 重命名后跳过后续的写入操作
}
}
const isBinary = file.binary === true;
const isText = file.binary === false;
const sizeExceeded = !!file.sizeExceeded;
const hasContents =
typeof file.contents === "string" && file.contents.length > 0;
// 处理二进制文件如果已经存在就不写入不存在才需要根据hasContents写入
if (isBinary) {
// 如果文件已存在,跳过写入
if (fs.existsSync(targetPath)) {
log(projectId, "INFO", "Binary file already exists, skipping write", {
filePath: file.name,
});
continue;
}
// 文件不存在,且 hasContents 为 true才写入
if (hasContents) {
// 二进制文件有 base64 编码的内容,需要解码为 Buffer 并写入
try {
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
const buffer = Buffer.from(file.contents, "base64");
await fs.promises.writeFile(targetPath, buffer);
log(projectId, "INFO", "Binary file written successfully", {
filePath: file.name,
});
} catch (e) {
log(projectId, "ERROR", "Failed to write binary file", {
filePath: file.name,
error: e && e.message,
});
}
} else {
// 二进制文件没有内容(可能是大文件),且文件不存在
log(projectId, "WARN", "Binary file does not exist and has no content, skipping", {
filePath: file.name,
});
}
continue;
}
// 处理文本文件
const shouldReplace =
isText && (!sizeExceeded || (sizeExceeded && hasContents));
if (!shouldReplace) {
continue;
}
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
await fs.promises.writeFile(targetPath, file.contents || "", "utf8");
}
} catch (e) {
log(projectId, "ERROR", "Failed to write files", {
projectId,
error: e && e.message,
elapsedMs: Date.now() - startTime,
});
throw e;
}
// 3) 清理缺失文件与空目录
try {
log(projectId, "DEBUG", "Start cleaning missing files and empty directories", { projectId });
const keepSet = new Set(
files
.filter((f) => f && typeof f.name === "string")
.map((f) => path.normalize(f.name))
);
await pruneMissingFiles(
projectPath,
keepSet,
config.TRAVERSE_EXCLUDE_DIRS || []
);
await removeEmptyDirectories(
projectPath,
config.TRAVERSE_EXCLUDE_DIRS || []
);
} catch (e) {
log(projectId, "ERROR", "Failed to clean missing files, starting rollback", {
projectId,
error: e && e.message,
elapsedMs: Date.now() - startTime,
});
throw e;
}
log(projectId, "INFO", "Files submitted successfully", {
projectId,
filesCount: files.length,
elapsedMs: Date.now() - startTime,
});
return {
success: true,
message: "Files submitted successfully",
projectId,
restarted: false,
};
} catch (backupErr) {
// 备份阶段失败:不执行回滚(无备份可回滚),直接抛出
if (!backupErr.isOperational) {
throw new SystemError("Failed to backup old version", {
projectId,
originalError: backupErr && backupErr.message,
});
}
throw backupErr;
}
}
/**
* 上传单个文件到指定路径
* @param {string} projectId 项目ID
* @param {string} codeVersion 代码版本号
* @param {Object} file 文件对象 (包含文件内容和元数据)
* @param {string} filePath 文件在项目中的相对路径
* @param {Object} req 请求对象
* @returns {Object} 上传结果
*/
async function uploadSingleFile(
projectId,
codeVersion,
file,
filePath,
req,
isolationContext = {}
) {
const startTime = Date.now();
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
const versionNum = Number(codeVersion);
if (!Number.isFinite(versionNum)) {
throw new ValidationError("codeVersion must be a number", {
field: "codeVersion",
});
}
if (!file) {
throw new ValidationError("File cannot be empty", { field: "file" });
}
if (!filePath || typeof filePath !== "string") {
throw new ValidationError("File path cannot be empty", { field: "filePath" });
}
const projectPath = resolveProjectPath(projectId, isolationContext);
if (!fs.existsSync(projectPath)) {
log(projectId, "ERROR", "Project does not exist", { projectId, projectPath });
throw new ResourceError("Project does not exist", { projectId });
}
// 规范化文件路径,确保是相对路径
const normalizedPath = path.normalize(filePath).replace(/^[\/\\]+/, "");
const targetPath = path.join(projectPath, normalizedPath);
// 安全检查:确保目标路径在项目目录内
const resolvedTargetPath = path.resolve(targetPath);
const resolvedProjectPath = path.resolve(projectPath);
if (!resolvedTargetPath.startsWith(resolvedProjectPath)) {
throw new ValidationError("File path is not safe, cannot exceed project directory", {
field: "filePath",
providedPath: filePath,
resolvedPath: resolvedTargetPath,
});
}
let backupZipPath = "";
try {
// 1) 备份
const backupDir = path.join(config.UPLOAD_PROJECT_DIR, projectId);
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
const zipName = `${projectId}-v${versionNum}.zip`;
backupZipPath = path.join(backupDir, zipName);
log(projectId, "DEBUG", "Start backing up project", { projectId, backupZipPath });
await backupProjectToZip(projectId, projectPath, backupZipPath);
log(projectId, "INFO", `Project backed up: ${backupZipPath}`, {
projectId,
zipPath: backupZipPath,
});
// 2) 写入文件
try {
// 确保目标目录存在
log(projectId, "DEBUG", "Start writing uploaded file", { projectId, filePath: normalizedPath });
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
// 写入文件内容统一使用buffermulter memoryStorage对所有文件类型都提供buffer
if (!file.buffer) {
throw new ValidationError("File content format is incorrect, missing buffer", {
field: "file",
});
}
// 记录写入前的文件信息,用于调试
log(projectId, "INFO", "Prepare to write file", {
targetPath,
bufferLength: file.buffer.length,
expectedSize: file.size,
bufferIsBuffer: Buffer.isBuffer(file.buffer),
sizeMatch: file.buffer.length === file.size,
});
// 直接写入BufferNode.js会自动以二进制模式写入
await fs.promises.writeFile(targetPath, file.buffer);
log(projectId, "INFO", "File uploaded successfully", {
projectId,
filePath: normalizedPath,
targetPath: resolvedTargetPath,
fileSize: file.buffer ? file.buffer.length : 0,
elapsedMs: Date.now() - startTime,
});
// 判断是否需要重启开发服务器
// const needRestart = shouldRestartForSingleFile(path.basename(normalizedPath));
const needRestart = false;
if (needRestart) {
try {
const restartResult = await restartDevServer(req, projectId);
log(projectId, "INFO", "Restart development server successfully", {
projectId,
pid: restartResult.pid,
port: restartResult.port,
});
return {
success: true,
message: "File uploaded and restarted development server successfully",
projectId,
filePath: normalizedPath,
targetPath: resolvedTargetPath,
fileSize: file.buffer ? file.buffer.length : 0,
pid: restartResult.pid,
port: restartResult.port,
restarted: true,
};
} catch (e) {
log(projectId, "ERROR", "Failed to restart development server", {
projectId,
filePath: normalizedPath,
error: e && e.message,
});
}
} else {
log(projectId, "INFO", "File modification does not require restarting development server", {
projectId,
filePath: normalizedPath,
});
return {
success: true,
message: "File uploaded successfully, no need to restart development server",
projectId,
restarted: false,
};
}
} catch (e) {
log(projectId, "ERROR", "Failed to write file", {
projectId,
filePath: normalizedPath,
error: e && e.message,
elapsedMs: Date.now() - startTime,
});
throw e;
}
} catch (backupErr) {
// 备份阶段失败:不执行回滚(无备份可回滚),直接抛出
if (!backupErr.isOperational) {
throw new SystemError("Failed to backup project", {
projectId,
filePath: normalizedPath,
originalError: backupErr && backupErr.message,
});
}
throw backupErr;
}
}
/**
* 回滚项目到指定版本
* @param {string} projectId 项目ID
* @param {string} codeVersion 当前代码版本号
* @param {string} rollbackTo 要回滚到的版本号
* @param {Object} req 请求对象
* @returns {Object} 回滚结果
*/
async function rollbackVersion(
projectId,
codeVersion,
rollbackTo,
req,
isolationContext = {}
) {
const startTime = Date.now();
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
const versionNum = Number(codeVersion);
if (!Number.isFinite(versionNum)) {
throw new ValidationError("codeVersion must be a number", {
field: "codeVersion",
});
}
const rollbackToNum = Number(rollbackTo);
if (!Number.isFinite(rollbackToNum)) {
throw new ValidationError("rollbackTo must be a number", {
field: "rollbackTo",
});
}
if (rollbackToNum < 0) {
throw new ValidationError("rollbackTo cannot be less than 0", {
field: "rollbackTo",
});
}
if (rollbackToNum >= versionNum) {
throw new ValidationError("rollbackTo must be less than current codeVersion", {
field: "rollbackTo",
});
}
const projectPath = resolveProjectPath(projectId, isolationContext);
if (!fs.existsSync(projectPath)) {
log(projectId, "ERROR", "Project does not exist", { projectId, projectPath });
throw new ResourceError("Project does not exist", { projectId });
}
// 检查要回滚到的版本的备份文件是否存在
const backupDir = path.join(config.UPLOAD_PROJECT_DIR, projectId);
const rollbackZipName = `${projectId}-v${rollbackToNum}.zip`;
const rollbackZipPath = path.join(backupDir, rollbackZipName);
if (!fs.existsSync(rollbackZipPath)) {
log(projectId, "ERROR", "Rollback version backup file does not exist", {
projectId,
rollbackTo: rollbackToNum,
zipPath: rollbackZipPath,
});
throw new ResourceError("Rollback version backup file does not exist", {
projectId,
rollbackTo: rollbackToNum,
});
}
let currentBackupZipPath = "";
try {
// 1) 备份当前版本
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
const currentZipName = `${projectId}-v${versionNum}.zip`;
currentBackupZipPath = path.join(backupDir, currentZipName);
// 如果当前版本的备份已存在,跳过备份(避免覆盖)
if (!fs.existsSync(currentBackupZipPath)) {
await backupProjectToZip(projectId, projectPath, currentBackupZipPath);
log(projectId, "INFO", "Current version backed up", {
projectId,
zipPath: currentBackupZipPath,
});
} else {
log(projectId, "INFO", "Current version backup already exists, skipping backup", {
projectId,
zipPath: currentBackupZipPath,
});
}
// 2) 从指定版本恢复项目
log(projectId, "DEBUG", "Start restoring project from backup", { projectId, rollbackToNum, rollbackZipPath });
await restoreProjectFromZip(projectId, projectPath, rollbackZipPath);
log(projectId, "INFO", "Project rolled back successfully", {
projectId,
newVersion: versionNum,
toVersion: rollbackToNum,
rollbackZipPath,
elapsedMs: Date.now() - startTime,
});
return {
success: true,
message: "Project rolled back successfully",
newVersion: versionNum,
rollbackTo: rollbackToNum,
};
} catch (restoreErr) {
log(projectId, "ERROR", "Failed to rollback project", {
projectId,
rollbackTo: rollbackToNum,
error: restoreErr && restoreErr.message,
elapsedMs: Date.now() - startTime,
});
// 如果恢复失败,尝试从当前版本备份恢复(如果存在)
if (currentBackupZipPath && fs.existsSync(currentBackupZipPath)) {
try {
log(projectId, "INFO", "Failed to rollback, trying to restore current version", {
projectId,
backupPath: currentBackupZipPath,
});
await restoreProjectFromZip(projectId, projectPath, currentBackupZipPath);
log(projectId, "INFO", "Current version restored", {
projectId,
});
} catch (recoveryErr) {
log(projectId, "ERROR", "Failed to restore current version", {
projectId,
error: recoveryErr && recoveryErr.message,
});
}
}
if (!restoreErr.isOperational) {
throw new SystemError("Failed to rollback project", {
projectId,
rollbackTo: rollbackToNum,
originalError: restoreErr && restoreErr.message,
});
}
throw restoreErr;
}
}
export { specifiedFilesUpdate, allFilesUpdate, uploadSingleFile, rollbackVersion };
export default {
specifiedFilesUpdate,
allFilesUpdate,
uploadSingleFile,
rollbackVersion,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
import { buildPortArgsForScript } from "./portUtils.js";
import { log } from "../log/logUtils.js";
import { ValidationError } from "../error/errorHandler.js";
import portPool from "./portPool.js";
/**
* 额外参数工具类
* 封装 port 参数、basePath 参数和 environment variables 的处理逻辑
*/
class ExtraArgsUtils {
/**
* 处理启动参数和 environment variables
* @param {Object} options 配置选项
* @param {string} options.devScript dev脚本内容
* @param {string} options.projectId 项目ID
* @param {Object} options.req 请求对象用于读取basePath
* @returns {Promise<Object>} { extraArgs, envExtra, port }
*/
static async processExtraArgs({ devScript, projectId, req }) {
const extraArgs = [];
const envExtra = {};
const lower = (devScript || "").toLowerCase();
const isVite = lower.includes("vite");
const isNext = lower.includes("next");
// 仅支持 vite 和 next.js其余直接返回空
if (!isVite && !isNext) {
return { extraArgs, envExtra };
}
// -- basePath --
if (req) {
let basePath = "";
// 从请求中读取 basePath
if (req.body && req.body.basePath) {
// 支持字符串类型或通过 express 解析的其他类型
basePath = String(req.body.basePath);
} else if (req.query && req.query.basePath) {
// 支持字符串类型或通过 express 解析的其他类型
basePath = String(req.query.basePath);
}
// 获取 projectId 用于日志(从 query 或 body 中)
const projectId = (req.query && req.query.projectId) || (req.body && req.body.projectId) || "unknown";
if (basePath) {
log(projectId, "INFO", "Read basePath", {
basePath: basePath,
source: req.body && req.body.basePath ? "body" : "query"
});
}
// 规范化 basePath以 / 开头和结尾
if (basePath && basePath.trim()) {
basePath = basePath.trim();
if (!basePath.startsWith("/")) {
basePath = "/" + basePath;
}
if (!basePath.endsWith("/")) {
basePath = basePath + "/";
}
if (isVite) {
// Vite 使用 --base 参数
extraArgs.push("--base", basePath);
} else if (isNext) {
// Next 不支持 --base改为通过环境变量传递
envExtra.NEXT_PUBLIC_BASE_PATH = basePath;
envExtra.BASE_PATH = basePath; // 兼容项目自定义读取
}
}
}
// -- port --
// 从端口池分配端口(如果已分配会自动复用)
const port = portPool.allocate(String(projectId));
const portArgs = buildPortArgsForScript(devScript, port);
if (portArgs.length > 0) {
extraArgs.push(...portArgs);
}
// -- host --
if (isVite) {
const host = "0.0.0.0";
extraArgs.push("--host", host);
}
return { extraArgs, envExtra, port };
}
}
export default ExtraArgsUtils;

View File

@@ -0,0 +1,153 @@
import { log } from "../log/logUtils.js";
/**
* 端口池管理器(内存级别)
* 适用于 Docker 容器环境:容器重启后所有进程和端口都会释放
* 使用 Set 管理可用端口池分配O(1)释放O(1)
*/
class PortPool {
constructor() {
// 端口范围配置
this.portRangeStart = 4000;
this.portRangeEnd = 55000;
// 保留端口
//this.reservedPorts = new Set([60000]);
this.reservedRangeStart = 8000;
this.reservedRangeEnd = 9000;
// 可用端口池Set 结构O(1) 取出和放入)
this.availablePorts = new Set();
// 已分配的端口 Map: projectId -> port
this.allocatedPorts = new Map();
// 初始化可用端口池
this._initializePortPool();
}
/**
* 初始化可用端口池
* @private
*/
_initializePortPool() {
for (let port = this.portRangeStart; port <= this.portRangeEnd; port++) {
if (port >= this.reservedRangeStart && port <= this.reservedRangeEnd) {
continue; // 跳过保留范围
}
// if (this.reservedPorts.has(port)) {
// continue;
// }
this.availablePorts.add(port);
}
const reservedRangeCount = this.reservedRangeEnd - this.reservedRangeStart + 1;
log("SYSTEM", "INFO", "Port pool initialized", {
portRange: `${this.portRangeStart}-${this.portRangeEnd}`,
totalPorts: this.availablePorts.size,
reservedCount: reservedRangeCount
});
}
/**
* 为项目分配端口
* @param {string} projectId 项目ID
* @returns {number} 分配的端口号
*/
allocate(projectId) {
// 如果该项目已经分配过端口,直接返回
if (this.allocatedPorts.has(projectId)) {
const existingPort = this.allocatedPorts.get(projectId);
log(projectId, "INFO", "Project already has allocated port, reuse", { port: existingPort });
return existingPort;
}
// 从可用池中取出一个端口
if (this.availablePorts.size === 0) {
throw new Error(`Port pool exhausted: no available ports in range ${this.portRangeStart}-${this.portRangeEnd}`);
}
// Set 的迭代器第一个值即为要分配的端口Set 保持插入顺序)
const port = this.availablePorts.values().next().value;
// 从可用池移除
this.availablePorts.delete(port);
// 记录分配
this.allocatedPorts.set(projectId, port);
log(projectId, "INFO", "Port pool allocated port", {
port,
totalAllocated: this.allocatedPorts.size,
remainingAvailable: this.availablePorts.size
});
return port;
}
/**
* 释放项目的端口(归还到可用池)
* @param {string} projectId 项目ID
*/
release(projectId) {
const port = this.allocatedPorts.get(projectId);
if (port) {
// 从已分配中移除
this.allocatedPorts.delete(projectId);
// 归还到可用池
this.availablePorts.add(port);
log(projectId, "INFO", "Port pool released port", {
port,
totalAllocated: this.allocatedPorts.size,
remainingAvailable: this.availablePorts.size
});
}
}
/**
* 获取项目当前分配的端口
* @param {string} projectId 项目ID
* @returns {number|null} 端口号未分配则返回null
*/
getPort(projectId) {
return this.allocatedPorts.get(projectId) || null;
}
/**
* 获取端口池状态
* @returns {Object} 端口池状态信息
*/
getStatus() {
return {
portRange: `${this.portRangeStart}-${this.portRangeEnd}`,
totalAllocated: this.allocatedPorts.size,
allocations: Array.from(this.allocatedPorts.entries()).map(([projectId, port]) => ({
projectId,
port
}))
};
}
/**
* 清空端口池(仅用于测试或维护)
* 将所有已分配端口归还到可用池
*/
clear() {
// 将已分配端口归还到可用池
for (const port of this.allocatedPorts.values()) {
this.availablePorts.add(port);
}
this.allocatedPorts.clear();
log("SYSTEM", "INFO", "Port pool cleared and reset", {
availablePorts: this.availablePorts.size,
allocatedPorts: this.allocatedPorts.size
});
}
}
// 单例模式:全局共享一个端口池实例
const portPool = new PortPool();
export default portPool;

View File

@@ -0,0 +1,793 @@
import fs from "fs";
import net from "net";
import { execSync } from "child_process";
import { log } from "../log/logUtils.js";
// 从脚本字符串解析端口号(支持 --port/-p 以及内联 PORT=
function parsePortFromScript(script) {
if (!script || typeof script !== "string") return undefined;
let m = script.match(/--port\s+(\d{2,5})/);
if (m) return Number(m[1]);
m = script.match(/-p\s+(\d{2,5})/);
if (m) return Number(m[1]);
m = script.match(/PORT\s*=\s*(\d{2,5})/);
if (m) return Number(m[1]);
return undefined;
}
// 获取可用端口(传入首选端口,不可用则递增寻找)
function getAvailablePort(preferred) {
return new Promise((resolve) => {
const start = Number(preferred) || 3000;
const maxAttempts = 2000; // 端口向上探测范围
function tryPort(p, attempts) {
if (attempts > maxAttempts) {
// 超出探测范围,回退到起始端口返回
resolve(start);
return;
}
// 先用 lsof 检查是否已被占用(覆盖 IPv4/IPv6 监听差异)
if (isPortListening(p)) {
tryPort(p + 1, attempts + 1);
return;
}
const server = net.createServer();
server.unref();
let resolved = false;
server.on("error", () => {
if (resolved) return;
resolved = true;
// 被占用或其他错误,尝试下一个
tryPort(p + 1, attempts + 1);
});
server.listen(p, () => {
if (resolved) {
try {
server.close();
} catch (_) {}
return;
}
resolved = true;
const assigned = server.address().port;
server.close(() => resolve(assigned));
});
}
tryPort(start, 0);
});
}
// 判断端口是否处于监听状态优先ss然后netstat最后lsof
function isPortListening(port) {
// 策略1: 优先使用 ss 命令Linux性能最好
if (tryIsPortListeningUsingSs(port)) {
return true;
}
// 策略2: 使用 netstat 命令(跨平台,性能较好)
if (tryIsPortListeningUsingNetstat(port)) {
return true;
}
return false;
// 策略3: 兜底使用 lsof 命令(兼容性最好)
//return tryIsPortListeningUsingLsof(port);
}
// 使用 ss 命令检查端口是否监听
function tryIsPortListeningUsingSs(port) {
try {
// ss -ltn 显示监听的 TCP 端口
// 输出格式: State Recv-Q Send-Q Local Address:Port Peer Address:Port
const cmd = `ss -ltn 2>/dev/null | grep ":${Number(port)} "`;
const buf = execSync(cmd, {
stdio: ["ignore", "pipe", "ignore"],
shell: "/bin/sh",
timeout: 2000, // 2秒超时
killSignal: "SIGKILL"
});
const out = String(buf || "");
// 检查是否有 LISTEN 状态的端口
return out.trim().length > 0 && out.includes("LISTEN");
} catch (_) {
return false;
}
}
// 使用 netstat 命令检查端口是否监听
function tryIsPortListeningUsingNetstat(port) {
try {
let cmd;
// 根据操作系统选择不同的 netstat 命令
if (process.platform === "linux") {
// Linux: netstat -ltn
cmd = `netstat -ltn 2>/dev/null | grep ":${Number(port)} "`;
} else if (process.platform === "darwin") {
// macOS: netstat -an
cmd = `netstat -an 2>/dev/null | grep "LISTEN" | grep "\\.${Number(port)} "`;
} else {
// 其他系统暂不支持
return false;
}
const buf = execSync(cmd, {
stdio: ["ignore", "pipe", "ignore"],
shell: "/bin/sh",
timeout: 2000, // 2秒超时
killSignal: "SIGKILL"
});
const out = String(buf || "");
return out.trim().length > 0;
} catch (_) {
return false;
}
}
// 使用 lsof 命令检查端口是否监听(兜底方案)
function tryIsPortListeningUsingLsof(port) {
try {
const cmd = `lsof -Pi :${Number(port)} -sTCP:LISTEN -n`;
const buf = execSync(cmd, {
stdio: ["ignore", "pipe", "ignore"],
timeout: 2000, // 2秒超时
killSignal: "SIGKILL"
});
const out = String(buf || "");
return out.trim().length > 0;
} catch (_) {
return false;
}
}
// 获取进程的所有子进程ID兼容Linux和macOS
function getChildPids(pid) {
try {
// 方法1: 使用pgrep -P (Linux和macOS都支持)
try {
const cmd = `pgrep -P ${pid}`;
const buf = execSync(cmd, {
stdio: ["ignore", "pipe", "ignore"],
timeout: 2000, // 2秒超时
killSignal: "SIGKILL"
});
const out = String(buf || "");
const childPids = out
.trim()
.split("\n")
.filter((p) => p)
.map((p) => Number(p));
if (childPids.length > 0) {
return childPids;
}
} catch (e) {
// pgrep失败尝试其他方法
}
// 方法2: 使用ps命令查找子进程 (兼容性更好)
try {
const cmd = `ps -eo pid,ppid | awk '$2==${pid} {print $1}'`;
const buf = execSync(cmd, {
stdio: ["ignore", "pipe", "ignore"],
timeout: 2000, // 2秒超时
killSignal: "SIGKILL"
});
const out = String(buf || "");
const childPids = out
.trim()
.split("\n")
.filter((p) => p)
.map((p) => Number(p));
return childPids;
} catch (e) {
// ps命令也失败
}
// 方法3: Linux特有的/proc文件系统
if (process.platform === "linux") {
try {
const childrenFile = `/proc/${pid}/task/${pid}/children`;
if (fs.existsSync(childrenFile)) {
const content = fs.readFileSync(childrenFile, "utf8");
const childPids = content
.trim()
.split(" ")
.filter((p) => p)
.map((p) => Number(p));
return childPids;
}
} catch (e) {
// /proc方法失败
}
}
return [];
} catch (e) {
return [];
}
}
// 通过进程ID查询该进程及其子进程监听的端口返回端口和对应的进程ID
function getPortsByPid(pid, projectId = "default") {
try {
// 首先检查进程是否存在
const checkCmd = `ps -p ${pid} -o pid=`;
try {
execSync(checkCmd, {
stdio: ["ignore", "pipe", "ignore"],
timeout: 2000, // 2秒超时
killSignal: "SIGKILL"
});
} catch (e) {
// 进程不存在
return [];
}
// 使用递归查找获取端口映射
const portPidMap = getPortsRecursively(pid, new Set(), projectId);
// 返回端口数组(按端口号排序)
return Array.from(portPidMap.keys()).sort((a, b) => a - b);
} catch (e) {
return [];
}
}
// 递归查找进程及其所有后代进程监听的端口
function getPortsRecursively(pid, visitedPids = new Set(), projectId = "default") {
const portPidMap = new Map();
// 避免循环引用
if (visitedPids.has(pid)) {
return portPidMap;
}
visitedPids.add(pid);
try {
// 查询当前进程的端口
const currentPorts = getPortsBySinglePid(pid, projectId);
currentPorts.forEach((port) => {
portPidMap.set(port, pid);
});
// 如果当前进程有端口,直接返回(优先返回最近的进程)
if (currentPorts.length > 0) {
return portPidMap;
}
// 递归查询子进程的端口
const childPids = getChildPids(pid);
for (const childPid of childPids) {
const childPortMap = getPortsRecursively(childPid, visitedPids, projectId);
childPortMap.forEach((childPid, port) => {
portPidMap.set(port, childPid);
});
}
return portPidMap;
} catch (e) {
return portPidMap;
}
}
// 通过进程ID查询该进程及其子进程监听的端口返回端口和进程ID的映射
function getPortsAndPidsByPid(pid, projectId = "default") {
try {
log(projectId, "DEBUG", `Check if process exists`, { pid });
// 首先检查进程是否存在
const checkCmd = `ps -p ${pid} -o pid=`;
try {
execSync(checkCmd, {
stdio: ["ignore", "pipe", "ignore"],
timeout: 2000, // 2秒超时
killSignal: "SIGKILL"
});
log(projectId, "DEBUG", `Process exists, start recursive query ports`, { pid });
} catch (e) {
// 进程不存在
log(projectId, "DEBUG", `Process does not exist`, { pid });
return new Map();
}
// 递归查找所有后代进程的端口
return getPortsRecursively(pid, new Set(), projectId);
} catch (e) {
const errMsg = e && e.message ? e.message : String(e);
log(projectId, "ERROR", `getPortsAndPidsByPid exception`, { pid, error: errMsg });
return new Map();
}
}
// 查询单个进程的端口(内部函数)
function getPortsBySinglePid(pid, projectId = "default") {
const startTime = Date.now();
// 策略1: 优先使用 ss 命令Linux性能最好
const portsFromSs = tryGetPortsUsingSs(pid, projectId);
if (portsFromSs.length > 0) {
const duration = Date.now() - startTime;
log(projectId, "INFO", `Using ss to query ports`, {
pid,
ports: portsFromSs,
duration: `${duration}ms`,
method: "ss"
});
return portsFromSs;
}
// 策略2: 使用 netstat 命令(跨平台,性能较好)
const portsFromNetstat = tryGetPortsUsingNetstat(pid, projectId);
if (portsFromNetstat.length > 0) {
const duration = Date.now() - startTime;
log(projectId, "INFO", `Using netstat to query ports`, {
pid,
ports: portsFromNetstat,
duration: `${duration}ms`,
method: "netstat"
});
return portsFromNetstat;
}
return [];
// 策略3: 兜底使用 lsof 命令(兼容性最好,但可能造成进程卡死)
/*
const portsFromLsof = tryGetPortsUsingLsof(pid, projectId);
const duration = Date.now() - startTime;
if (portsFromLsof.length > 0) {
log(projectId, "INFO", `Using lsof to query ports`, {
pid,
ports: portsFromLsof,
duration: `${duration}ms`,
method: "lsof"
});
} else {
log(projectId, "INFO", `No ports found`, {
pid,
duration: `${duration}ms`,
methods: "ss->netstat->lsof"
});
}
return portsFromLsof;
*/
}
// 使用 ss 命令获取端口Linux
function tryGetPortsUsingSs(pid, projectId = "default") {
const startTime = Date.now();
try {
log(projectId, "DEBUG", `Try using ss command to query ports`, { pid });
// ss -ltnp 显示监听的 TCP 端口及进程信息
// 输出格式: State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
const cmd = `ss -ltnp 2>/dev/null | grep "pid=${pid},"`;
const buf = execSync(cmd, {
stdio: ["ignore", "pipe", "ignore"],
shell: "/bin/sh",
timeout: 3000, // 3秒超时
killSignal: "SIGKILL"
});
const out = String(buf || "");
const lines = out.trim().split("\n");
const ports = [];
for (const line of lines) {
if (!line) continue;
// 解析 Local Address:Port 列,格式如: 0.0.0.0:3000 或 [::]:3000 或 127.0.0.1:3000
const portMatch = line.match(/(?:0\.0\.0\.0|\[::\]|127\.0\.0\.1|\*):(\d+)/);
if (portMatch) {
const port = Number(portMatch[1]);
if (port && !ports.includes(port)) {
ports.push(port);
}
}
}
const duration = Date.now() - startTime;
log(projectId, "DEBUG", `ss command executed successfully`, { pid, portsCount: ports.length, duration: `${duration}ms` });
return ports;
} catch (e) {
const duration = Date.now() - startTime;
const errMsg = e && e.message ? e.message : String(e);
log(projectId, "DEBUG", `ss command execution failed`, { pid, error: errMsg, duration: `${duration}ms` });
return [];
}
}
// 使用 netstat 命令获取端口(跨平台)
function tryGetPortsUsingNetstat(pid, projectId = "default") {
const startTime = Date.now();
try {
let cmd;
// 根据操作系统选择不同的 netstat 命令
if (process.platform === "linux") {
log(projectId, "DEBUG", `Try using netstat command to query ports`, { pid });
// Linux: netstat -ltnp
cmd = `netstat -ltnp 2>/dev/null | grep "${pid}/"`;
} else if (process.platform === "darwin") {
// macOS: netstat 不支持 -p需要先用 netstat 获取端口,再验证进程
// 这里直接跳过,让 lsof 处理 macOS 的情况
log(projectId, "DEBUG", `macOS system skip netstat`, { pid });
return [];
} else {
// 其他系统暂不支持
log(projectId, "DEBUG", `${process.platform} system does not support netstat`, { pid });
return [];
}
const buf = execSync(cmd, {
stdio: ["ignore", "pipe", "ignore"],
shell: "/bin/sh",
timeout: 3000, // 3秒超时
killSignal: "SIGKILL"
});
const out = String(buf || "");
const lines = out.trim().split("\n");
const ports = [];
for (const line of lines) {
if (!line) continue;
// 解析 Local Address 列,格式如: 0.0.0.0:3000 或 :::3000 或 127.0.0.1:3000
const portMatch = line.match(/(?:0\.0\.0\.0|:::|\*:|127\.0\.0\.1:)(\d+)/);
if (portMatch) {
const port = Number(portMatch[1]);
if (port && !ports.includes(port)) {
ports.push(port);
}
}
}
const duration = Date.now() - startTime;
log(projectId, "DEBUG", `netstat command executed successfully`, { pid, portsCount: ports.length, duration: `${duration}ms` });
return ports;
} catch (e) {
const duration = Date.now() - startTime;
const errMsg = e && e.message ? e.message : String(e);
log(projectId, "DEBUG", `netstat command execution failed`, { pid, error: errMsg, duration: `${duration}ms` });
return [];
}
}
// 使用 lsof 命令获取端口(兜底方案)
function tryGetPortsUsingLsof(pid, projectId = "default") {
const startTime = Date.now();
try {
log(projectId, "DEBUG", `Try using lsof command to query ports`, { pid });
const cmd = `lsof -Pan -p ${pid} -iTCP -sTCP:LISTEN -n`;
const buf = execSync(cmd, {
stdio: ["ignore", "pipe", "ignore"],
timeout: 3000, // 3秒超时
killSignal: "SIGKILL"
});
const out = String(buf || "");
const lines = out.trim().split("\n");
// 跳过标题行
if (lines.length <= 1) {
const duration = Date.now() - startTime;
log(projectId, "DEBUG", `lsof command did not find listening ports`, { pid, duration: `${duration}ms` });
return [];
}
const ports = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// 解析lsof输出格式: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
// NAME列包含端口信息格式如: *:3000 (LISTEN) 或 localhost:3000 (LISTEN)
const nameMatch = line.match(
/(\*|localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)\s*\(LISTEN\)/
);
if (nameMatch) {
const port = Number(nameMatch[2]);
if (port && !ports.includes(port)) {
ports.push(port);
}
}
}
const duration = Date.now() - startTime;
log(projectId, "DEBUG", `lsof command executed successfully`, { pid, portsCount: ports.length, duration: `${duration}ms` });
return ports;
} catch (e) {
const duration = Date.now() - startTime;
const errMsg = e && e.message ? e.message : String(e);
log(projectId, "DEBUG", `lsof command execution failed`, { pid, error: errMsg, duration: `${duration}ms` });
return [];
}
}
// 查询监听特定端口的所有进程ID
function getPidsByPort(port) {
try {
const cmd = `lsof -ti:${port}`;
const buf = execSync(cmd, {
stdio: ["ignore", "pipe", "ignore"],
timeout: 2000, // 2秒超时
killSignal: "SIGKILL"
});
const out = String(buf || "");
const pids = out
.trim()
.split("\n")
.filter((pid) => pid)
.map((pid) => Number(pid));
return pids;
} catch (e) {
return [];
}
}
// 等待进程开始监听端口带超时返回端口和对应的进程ID
function waitPortFromPid(pid, timeoutMs = 10000, intervalMs = 500, projectId = "default") {
return new Promise((resolve) => {
let resolved = false;
let attempts = 0;
const maxAttempts = Math.ceil(timeoutMs / intervalMs);
log(projectId, "INFO", `Start waiting for listening port`, {
pid,
timeoutMs,
intervalMs,
maxAttempts
});
function checkOnce() {
if (resolved) return;
attempts++;
log(projectId, "INFO", `Query listening port (第${attempts}/${maxAttempts}次)`, {
pid,
attempts
});
try {
log(projectId, "DEBUG", `Start calling getPortsAndPidsByPid`, { pid });
const queryStartTime = Date.now();
const portPidMap = getPortsAndPidsByPid(pid, projectId);
const queryDuration = Date.now() - queryStartTime;
log(projectId, "DEBUG", `getPortsAndPidsByPid called successfully`, {
pid,
mapSize: portPidMap.size,
duration: `${queryDuration}ms`
});
if (portPidMap.size > 0) {
resolved = true;
// 返回第一个监听的端口和对应的进程ID
const firstPort = Array.from(portPidMap.keys()).sort(
(a, b) => a - b
)[0];
const actualPid = portPidMap.get(firstPort);
log(projectId, "INFO", `Successfully detected listening port`, {
pid,
actualPid,
port: firstPort,
attempts,
allPorts: Array.from(portPidMap.keys())
});
resolve({ port: firstPort, pid: Number(actualPid) });
return;
}
log(projectId, "DEBUG", `No listening port found in this query`, { pid, attempts });
} catch (e) {
const errMsg = e && e.message ? e.message : String(e);
log(projectId, "ERROR", `Exception occurred when querying ports`, {
pid,
attempts,
error: errMsg,
stack: e && e.stack ? e.stack : ""
});
}
}
// 初次尝试
checkOnce();
if (resolved) return;
const interval = setInterval(() => {
if (resolved) return;
checkOnce();
if (resolved) {
clearInterval(interval);
clearTimeout(timer);
}
}, intervalMs);
const timer = setTimeout(() => {
if (resolved) return;
resolved = true;
clearInterval(interval);
log(projectId, "WARN", `Waiting for listening port timeout`, {
pid,
timeoutMs,
attempts
});
resolve(undefined);
}, timeoutMs);
});
}
// 轮询等待端口进入监听状态
function waitPortListening(port, timeoutMs = 10000, intervalMs = 300) {
return new Promise((resolve) => {
const startedAt = Date.now();
function tick() {
if (isPortListening(port)) {
resolve(true);
return;
}
if (Date.now() - startedAt >= timeoutMs) {
resolve(false);
return;
}
setTimeout(tick, intervalMs);
}
tick();
});
}
// 从日志文件中提取首次出现的端口号,带超时
function waitPortFromLog(logPath, timeoutMs = 15000) {
return new Promise((resolve) => {
let resolved = false;
function extractPortFromContent(content) {
// 按行分割,从最新内容开始查找端口
const lines = content.split("\n").reverse();
for (const line of lines) {
// 跳过时间戳行(包含 [YYYY/M/D H:MM:SS] 格式)
if (line.match(/^\[[\d\/\s:]+\]/)) continue;
// 跳过明确错误/占用语句,避免误把错误中的 :端口 解析为成功端口
if (/(EADDRINUSE|address already in use|Error:\s*listen)/i.test(line)) {
continue;
}
// 跳过错误对象字段,如 "port: 10001"、"code: 'EADDRINUSE'" 等
if (/^(\s|\t)*(code|errno|syscall|address|port)\s*:/i.test(line)) {
continue;
}
// 1. 优先匹配 URL 格式: http://localhost:5173/ 或 https://127.0.0.1:3000
const urlMatch = line.match(/https?:\/\/[^\s:]+:(\d{2,5})/);
if (urlMatch) return Number(urlMatch[1]);
// 2. 匹配带有动词的端口声明,避免匹配裸 "port: 3000"
const portTextMatch = line.match(
/(?:listening|running|started)[^\n]*\bport\b\s*:?\s*(\d{2,5})/i
);
if (portTextMatch) return Number(portTextMatch[1]);
// 3. 匹配 "listening on :3000" 或 "server running on :8080"
const listenMatch = line.match(
/(?:listening|running|started)\s+on\s*:(\d{2,5})/i
);
if (listenMatch) return Number(listenMatch[1]);
// 4. 匹配 "Local: http://localhost:3000" 或 "Network: http://192.168.1.1:3000"
const localMatch = line.match(
/(?:Local|Network|local|network):\s*https?:\/\/[^\s:]+:(\d{2,5})/i
);
if (localMatch) return Number(localMatch[1]);
// 5. Next.js: ready - started server on 0.0.0.0:3000, url: ...
const nextReadyMatch = line.match(
/ready\s*-\s*started\s*server[^:]*:(\d{2,5})/i
);
if (nextReadyMatch) return Number(nextReadyMatch[1]);
// 取消泛化兜底:避免把报错里的 :端口 当成成功端口
}
return undefined;
}
function checkOnce() {
if (resolved) return;
try {
if (!fs.existsSync(logPath)) return;
const stats = fs.statSync(logPath);
const readSize = Math.min(64 * 1024, stats.size);
const fd = fs.openSync(logPath, "r");
const buffer = Buffer.alloc(readSize);
fs.readSync(fd, buffer, 0, readSize, stats.size - readSize);
fs.closeSync(fd);
const text = buffer.toString("utf8");
const port = extractPortFromContent(text);
if (port) {
resolved = true;
resolve(port);
}
} catch (_) {}
}
// 初次尝试
checkOnce();
if (resolved) return;
const interval = setInterval(() => {
if (resolved) return;
checkOnce();
if (resolved) {
clearInterval(interval);
clearTimeout(timer);
}
}, 500);
const timer = setTimeout(() => {
if (resolved) return;
resolved = true;
clearInterval(interval);
resolve(undefined);
}, timeoutMs);
});
}
// 根据脚本推断应传递的端口参数
function buildPortArgsForScript(script, port) {
const args = [];
const p = String(port);
if (!script || typeof script !== "string") return args;
const lower = script.toLowerCase();
// Vite: 如果指定了端口,强制使用该端口,并开启严格模式(端口占用则失败)
if (lower.includes("vite")) {
if (port) {
args.push("--port", p, "--strictPort");
}
return args;
}
// 常见工具vite、webpack-dev-server、vitepress、umi、serve
if (
lower.includes("webpack") ||
lower.includes("vitepress") ||
lower.includes("umi") ||
lower.includes("serve")
) {
args.push("--port", p);
return args;
}
// next/nuxt 一般支持 -p/--port
if (lower.includes("next") || lower.includes("nuxt")) {
// Next/Nuxt 使用 CLI 端口参数,避免使用环境变量 PORT
args.push("-p", p);
return args;
}
// 兜底:--port
args.push("--port", p);
return args;
}
export {
parsePortFromScript,
getAvailablePort,
waitPortFromLog,
buildPortArgsForScript,
isPortListening,
waitPortListening,
getPortsByPid,
waitPortFromPid,
getPidsByPort,
getChildPids,
getPortsAndPidsByPid,
};

View File

@@ -0,0 +1,279 @@
import { exec, spawn } from "child_process";
import path from "path";
import fs from "fs";
import { log } from "../log/logUtils.js";
/**
* 获取文件修改时间
* @param {string} filePath 文件路径
* @returns {number} 修改时间戳(毫秒)
*/
function getFileMtime(filePath) {
try {
//同步读取文件的元信息,并返回该文件的最后修改时间的时间戳(毫秒)
return fs.statSync(filePath).mtimeMs;
} catch (e) {
return 0;
}
}
/**
* 判断是否应该安装依赖
* @param {string} projectPath 项目路径
* @returns {boolean}
*/
function shouldInstallDeps(projectPath) {
const pkgPath = path.join(projectPath, "package.json");
const lockPathNpm = path.join(projectPath, "package-lock.json");
const lockPathYarn = path.join(projectPath, "yarn.lock");
const nodeModulesPath = path.join(projectPath, "node_modules");
const nodeModulesExists = fs.existsSync(nodeModulesPath);
if (!nodeModulesExists) return true;
const pkgMtime = getFileMtime(pkgPath);
const lockMtime = Math.max(
getFileMtime(lockPathNpm),
getFileMtime(lockPathYarn)
);
const nodeModulesMtime = getFileMtime(nodeModulesPath);
// 如果 package.json 或锁文件比 node_modules 更新,则需要安装
return Math.max(pkgMtime, lockMtime) > nodeModulesMtime;
}
/**
* 检查并删除 node_modules 文件夹和 lock 文件
* @param {string} projectPath - 项目路径
* @param {string} projectId - 项目ID可选用于日志
* @returns {Promise<void>}
*/
async function removeNodeModules(projectPath, projectId = null) {
const nodeModulesPath = path.join(projectPath, "node_modules");
const logId = projectId || path.basename(projectPath);
if (fs.existsSync(nodeModulesPath)) {
log(logId, "INFO", "Found node_modules folder, deleting", {
projectPath,
nodeModulesPath,
});
try {
await fs.promises.rm(nodeModulesPath, { recursive: true, force: true });
log(logId, "INFO", "node_modules folder deleted successfully", {
projectPath,
nodeModulesPath,
});
} catch (error) {
log(logId, "WARN", `Failed to delete node_modules folder: ${error.message}`, {
projectPath,
nodeModulesPath,
error: error.message,
});
}
}
// 删除 lock 文件
const lockFiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
for (const lockFile of lockFiles) {
const lockFilePath = path.join(projectPath, lockFile);
if (fs.existsSync(lockFilePath)) {
log(logId, "INFO", `Found ${lockFile} file, deleting`, {
projectPath,
lockFilePath,
});
try {
await fs.promises.unlink(lockFilePath);
log(logId, "INFO", `${lockFile} file deleted successfully`, {
projectPath,
lockFilePath,
});
} catch (error) {
log(logId, "WARN", `Failed to delete ${lockFile} file: ${error.message}`, {
projectPath,
lockFilePath,
error: error.message,
});
}
}
}
}
/**
* 安装项目依赖
* @param {Object} req 请求对象
* @param {string} projectId 项目ID
* @param {string} projectPath 项目路径
* @param {Object} options 可选参数
* @param {Object} options.outStream 主日志流(可选)
* @param {Object} options.tempOutStream 临时日志流(可选)
* @param {Function} options.safeWrite 安全写入函数(可选)
* @returns {Promise<string>} 安装输出
*/
function installDependencies(req, projectId, projectPath, options = {}) {
const { outStream, tempOutStream, safeWrite } = options;
// 如果提供了日志流,使用 spawn 获取实时输出
if (outStream && tempOutStream && safeWrite) {
return new Promise((resolve, reject) => {
// 优化 pnpm install 命令,提升在 Docker/容器环境中的性能
// --prefer-offline: 优先使用本地缓存,减少网络请求
// --reporter=silent: 静默模式,不输出进度信息,大幅提升 pipe 性能
// 注意:--loglevel=error 只显示错误,避免频繁的 I/O 操作
const command = `cd ${projectPath} && pnpm install --prefer-offline`;
// 记录开始时间
const startTime = Date.now();
// 写入开始安装的日志safeWrite 会自动添加时间戳)
const startMessage = `Start installing dependencies\nCommand: pnpm install --prefer-offline\n`;
safeWrite(outStream, startMessage, "Main log");
safeWrite(tempOutStream, startMessage, "Temp log");
// 心跳定时器每5秒输出一次让用户知道还在进行中
let heartbeatCount = 0;
const heartbeatInterval = setInterval(() => {
heartbeatCount++;
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const heartbeatMsg = `Installing dependencies... (Elapsed time: ${elapsed} seconds)\n`;
safeWrite(outStream, heartbeatMsg, "Main log");
safeWrite(tempOutStream, heartbeatMsg, "Temp log");
}, 5000); // 每5秒
// 使用 spawn 获取实时输出
// 重要:必须传递环境变量,这样 pnpm 才能使用配置的 store 路径、镜像等配置
const child = spawn("sh", ["-c", command], {
cwd: projectPath,
env: process.env, // 继承父进程的环境变量,包括 pnpm 配置
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
// 实时输出到日志流
if (child.stdout) {
child.stdout.on("data", (data) => {
const dataStr = data.toString();
stdout += dataStr;
safeWrite(outStream, dataStr, "Main log");
safeWrite(tempOutStream, dataStr, "Temp log");
});
}
if (child.stderr) {
child.stderr.on("data", (data) => {
const dataStr = data.toString();
stderr += dataStr;
safeWrite(outStream, dataStr, "Main log");
safeWrite(tempOutStream, dataStr, "Temp log");
});
}
child.on("exit", (code, signal) => {
// 清除心跳定时器
clearInterval(heartbeatInterval);
// 计算总耗时
const totalTime = ((Date.now() - startTime) / 1000).toFixed(2);
if (code !== 0) {
const errorMessage = `Dependency installation failed (Elapsed time: ${totalTime} seconds)\nExit code: ${code}, Signal: ${signal}\n${stderr || 'No error information'}\n`;
safeWrite(outStream, errorMessage, "Main log");
safeWrite(tempOutStream, errorMessage, "Temp log");
return reject(
new Error(`Dependency installation failed: Exit code ${code}, Signal ${signal}\n${stderr}`)
);
}
// 即使退出码为0也检查stderr中是否有错误信息
if (stderr && stderr.includes("Error") && !stderr.includes("warning")) {
const warnMessage = `Warning occurred during dependency installation: ${stderr}\n`;
safeWrite(outStream, warnMessage, "Main log");
safeWrite(tempOutStream, warnMessage, "Temp log");
}
// 成功完成,输出摘要
const successMessage = `✓ Dependency installation successful (Elapsed time: ${totalTime} seconds)\n`;
safeWrite(outStream, successMessage, "Main log");
safeWrite(tempOutStream, successMessage, "Temp log");
// 记录摘要信息(包括 stdout 和 stderr 的内容)
if (stdout || stderr) {
const summaryMessage = `Installation details:\n${stdout || '(No standard output)'}${stderr ? '\nWarning information:\n' + stderr : ''}\n`;
safeWrite(outStream, summaryMessage, "Main log");
safeWrite(tempOutStream, summaryMessage, "Temp log");
} else {
// 静默模式下可能没有输出,主动说明
const noOutputMsg = `(Silent mode: No detailed output, dependency successfully linked from store to node_modules)\n`;
safeWrite(outStream, noOutputMsg, "Main log");
safeWrite(tempOutStream, noOutputMsg, "Temp log");
}
resolve(stdout);
});
child.on("error", (error) => {
// 清除心跳定时器
clearInterval(heartbeatInterval);
const totalTime = ((Date.now() - startTime) / 1000).toFixed(2);
const errorMessage = `Dependency installation process error (Elapsed time: ${totalTime} seconds): ${error.message}\n`;
safeWrite(outStream, errorMessage, "Main log");
safeWrite(tempOutStream, errorMessage, "Temp log");
reject(new Error(`Dependency installation failed: ${error.message}`));
});
});
}
// 如果没有提供日志流,使用原来的 exec 方式(保持向后兼容)
return new Promise((resolve, reject) => {
// 优化 pnpm install 命令,提升性能
const command = `cd ${projectPath} && pnpm install --prefer-offline --reporter=silent --loglevel=error`;
log(projectId, "INFO", "Start executing dependency installation command", {
command,
projectPath,
});
exec(
command,
{
maxBuffer: 10 * 1024 * 1024,
env: process.env, // 继承父进程的环境变量,包括 pnpm 配置
},
(error, stdout, stderr) => {
if (error) {
log(projectId, "ERROR", "Dependency installation failed", {
error: error.message,
code: error.code,
stderr: stderr || error.message,
stdout: stdout || "",
});
return reject(
new Error(
`Dependency installation failed: ${error.message}\n${stderr || error.message}`
)
);
}
// 即使退出码为0也检查stderr中是否有错误信息
if (stderr && stderr.includes("Error") && !stderr.includes("warning")) {
log(projectId, "WARN", "Warning or error occurred during dependency installation", {
stderr,
});
// 可以选择继续执行或拒绝
}
log(projectId, "INFO", "Dependency installation completed", {
stdout: stdout.substring(0, 500), // 只记录前500个字符
});
resolve(stdout);
});
});
}
export { getFileMtime, shouldInstallDeps, installDependencies, removeNodeModules };

View File

@@ -0,0 +1,76 @@
import http from "http";
import https from "https";
import { URL } from "url";
import { log } from "../log/logUtils.js";
/**
* 通过对本地端口发起 HTTP 请求判断项目是否存活
* 返回 2xx/3xx 视为存活,其余或超时/异常视为不存活
*/
async function isProjectAlive(projectId, port, basePath, options = {}) {
const timeoutMs = Number(options.timeoutMs) || 1500;
const portNum = Number(port);
if (!Number.isFinite(portNum) || portNum <= 0) return false;
// 规范化 basePath确保以 / 开头
const normalizedBase = basePath
? ("/" + String(basePath).replace(/^\/+/, ""))
: "/";
// 默认用 http://127.0.0.1:port/basePath
const urlStr = `http://127.0.0.1:${portNum}${normalizedBase}`;
return new Promise((resolve) => {
let finished = false;
const urlObj = new URL(urlStr);
const client = urlObj.protocol === "https:" ? https : http;
const req = client.request(
{
method: "GET",
hostname: urlObj.hostname,
port: urlObj.port,
path: urlObj.pathname + urlObj.search,
headers: { "User-Agent": "xagi-keepalive-check" },
},
(res) => {
if (finished) return;
finished = true;
// 2xx/3xx 认为服务存活
const alive = res.statusCode >= 200 && res.statusCode < 300;
log(projectId, "INFO", "Alive detection HTTP response", { statusCode: res.statusCode, url: urlStr, alive });
// 消耗数据并结束
res.resume();
resolve(alive);
}
);
req.on("error", (err) => {
if (finished) return;
finished = true;
log(projectId, "WARN", "Alive detection HTTP request error", { error: err && err.message, url: urlStr, });
resolve(false);
});
req.setTimeout(timeoutMs, () => {
if (finished) return;
finished = true;
log(projectId, "INFO", "Alive detection HTTP request timeout", { url: urlStr, timeoutMs });
req.destroy();
resolve(false);
});
try {
req.end();
} catch (_) {
if (!finished) {
finished = true;
resolve(false);
}
}
});
}
export { isProjectAlive };

View File

@@ -0,0 +1,74 @@
import { log } from "../log/logUtils.js";
/**
* 判断单个文件是否需要重启开发服务器
* @param {string} fileName 文件名
* @returns {boolean} 是否需要重启
*/
function shouldRestartForSingleFile(fileName) {
if (!fileName || typeof fileName !== "string") {
return false;
}
// 定义需要重启的文件类型和路径模式
const restartRequiredPatterns = [
// 配置文件
/package\.json$/i,
/vite\.config\.(js|ts|mjs|cjs)$/i,
/webpack\.config\.(js|ts|mjs|cjs)$/i,
/rollup\.config\.(js|ts|mjs|cjs)$/i,
/next\.config\.(js|ts|mjs|cjs)$/i,
/nuxt\.config\.(js|ts|mjs|cjs)$/i,
/tailwind\.config\.(js|ts|mjs|cjs)$/i,
/postcss\.config\.(js|ts|mjs|cjs)$/i,
/babel\.config\.(js|ts|mjs|cjs)$/i,
/tsconfig\.json$/i,
/\.env$/i,
/\.env\..*$/i,
// 依赖相关
/yarn\.lock$/i,
/package-lock\.json$/i,
/pnpm-lock\.yaml$/i,
];
// 检查是否匹配需要重启的模式
for (const pattern of restartRequiredPatterns) {
if (pattern.test(fileName)) {
log(null, "INFO", `Detected file that needs to be restarted: ${fileName}`, {
fileName,
pattern: pattern.toString(),
});
return true;
}
}
return false;
}
/**
* 判断是否需要重启开发服务器
* 基于文件类型和修改内容来判断是否需要重启
* @param {Array} files 修改的文件列表
* @returns {boolean} 是否需要重启
*/
function shouldRestartDevServer(files) {
if (!Array.isArray(files) || files.length === 0) {
return false;
}
// 检查是否有任何文件匹配需要重启的模式
for (const file of files) {
if (!file || typeof file.name !== "string") {
continue;
}
if (shouldRestartForSingleFile(file.name)) {
return true;
}
}
return false;
}
export { shouldRestartForSingleFile, shouldRestartDevServer };

View File

@@ -0,0 +1,128 @@
import { exec } from "child_process";
import path from "path";
import fs from "fs";
import { log } from "../log/logUtils.js";
/**
* 递归修复目录下所有可执行文件的权限
* @param {string} dir 目录路径
*/
async function fixExecutablePermissionsRecursive(dir) {
try {
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// 递归处理子目录
await fixExecutablePermissionsRecursive(fullPath);
} else if (entry.isFile()) {
// 检查是否为可执行文件(通过文件名或扩展名判断)
const isExecutable =
entry.name.includes("bin") ||
entry.name.endsWith(".exe") ||
!entry.name.includes(".") ||
entry.name.match(/^(esbuild|vite|webpack|rollup)$/);
if (isExecutable) {
try {
await fs.promises.chmod(fullPath, 0o755);
// 移除隔离标记
await new Promise((resolve) => {
exec(`xattr -d com.apple.quarantine "${fullPath}"`, () =>
resolve()
);
});
} catch (_) {}
}
}
}
} catch (_) {}
}
/**
* 修复可执行权限:为 node_modules/.bin 及被指向的目标脚本添加可执行权限
* @param {string} projectPath 项目路径
*/
async function ensureDevBinariesExecutable(projectPath) {
try {
const nodeModulesDir = path.join(projectPath, "node_modules");
if (!fs.existsSync(nodeModulesDir)) return;
// 1. 处理 .bin 目录
const binDir = path.join(nodeModulesDir, ".bin");
if (fs.existsSync(binDir)) {
const entries = await fs.promises.readdir(binDir, {
withFileTypes: true,
});
for (const entry of entries) {
if (!entry.isFile()) continue;
const filePath = path.join(binDir, entry.name);
// 给 .bin 下的文件加执行权限
try {
await fs.promises.chmod(filePath, 0o755);
} catch (_) {}
// 处理文件内容为相对路径的情况(例如仅包含 ../vite/bin/vite.js
try {
const content = await fs.promises.readFile(filePath, "utf8");
const trimmed = content.trim();
const looksLikeSinglePath =
trimmed && !trimmed.startsWith("#!/") && !trimmed.includes("\n");
if (looksLikeSinglePath) {
const targetAbs = path.resolve(path.dirname(filePath), trimmed);
if (fs.existsSync(targetAbs)) {
// 先给目标加执行权限
try {
await fs.promises.chmod(targetAbs, 0o755);
} catch (_) {}
// 将占位文件替换为指向目标的符号链接,确保相对路径相对于 .bin 目录
try {
const relativeFromBin = path.relative(
path.dirname(filePath),
targetAbs
);
await fs.promises.unlink(filePath).catch(() => {});
await fs.promises.symlink(relativeFromBin, filePath);
} catch (_) {
// 如果创建符号链接失败,退化为写入一个带 shebang 的 shim
const shim = `#!/usr/bin/env bash\nDIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nexec \"$DIR/${trimmed}\" \"$@\"\n`;
try {
await fs.promises.writeFile(filePath, shim, "utf8");
await fs.promises.chmod(filePath, 0o755);
} catch (_) {}
}
}
}
} catch (_) {}
// 尝试移除隔离标记macOS
try {
await new Promise((resolve) => {
exec(`xattr -d com.apple.quarantine "${filePath}"`, () =>
resolve()
);
});
} catch (_) {}
}
}
// 2. 处理常见的二进制包目录(如 @esbuild、esbuild 等)
const binaryPackages = ["@esbuild", "esbuild", "vite", "webpack", "rollup"];
for (const pkg of binaryPackages) {
const pkgDir = path.join(nodeModulesDir, pkg);
if (fs.existsSync(pkgDir)) {
await fixExecutablePermissionsRecursive(pkgDir);
}
}
} catch (e) {
// 仅记录,不阻断启动流程
log(null, "WARN", "Problem occurred when fixing executable permissions (ignore and continue)", {
error: e && e.message,
});
}
}
export { fixExecutablePermissionsRecursive, ensureDevBinariesExecutable };

View File

@@ -0,0 +1,97 @@
import fs from "fs";
import path from "path";
const AGENT_ROOT_MAP = {
agents: ".agents",
claudecode: ".claude",
opencode: ".opencode",
codex: ".codex",
};
const ALL_AGENT_TYPES = Object.keys(AGENT_ROOT_MAP);
const PRIMARY_AGENT_TYPE = "agents";
async function removePathIfExists(targetPath) {
if (!fs.existsSync(targetPath)) return;
await fs.promises.rm(targetPath, { recursive: true, force: true });
}
async function ensureDir(dirPath) {
if (fs.existsSync(dirPath)) return;
await fs.promises.mkdir(dirPath, { recursive: true });
}
async function copyDirectory(srcDir, destDir) {
const stat = await fs.promises.lstat(srcDir);
if (stat.isDirectory()) {
await ensureDir(destDir);
const entries = await fs.promises.readdir(srcDir);
for (const entry of entries) {
await copyDirectory(path.join(srcDir, entry), path.join(destDir, entry));
}
} else {
await ensureDir(path.dirname(destDir));
await fs.promises.copyFile(srcDir, destDir);
}
}
/**
* 仅确保主 agent.claude目录存在业务逻辑只写这一份
*/
async function ensurePrimaryAgentDirs(workspaceRoot) {
const rootDir = path.join(workspaceRoot, AGENT_ROOT_MAP[PRIMARY_AGENT_TYPE]);
const skillsDir = path.join(rootDir, "skills");
const agentsDir = path.join(rootDir, "agents");
await ensureDir(rootDir);
await ensureDir(skillsDir);
await ensureDir(agentsDir);
return { rootDir, skillsDir, agentsDir, agentTypes: ALL_AGENT_TYPES };
}
/**
* 将主 agent 目录内容同步到其他 agent 目录
*/
async function syncAgents(workspaceRoot) {
const primary = await ensurePrimaryAgentDirs(workspaceRoot);
for (const agentType of ALL_AGENT_TYPES) {
if (agentType === PRIMARY_AGENT_TYPE) continue;
const targetRoot = path.join(workspaceRoot, AGENT_ROOT_MAP[agentType]);
const targetSkills = path.join(targetRoot, "skills");
const targetAgents = path.join(targetRoot, "agents");
await ensureDir(targetRoot);
await removePathIfExists(targetSkills);
await ensureDir(targetSkills);
if (fs.existsSync(primary.skillsDir)) {
const entries = await fs.promises.readdir(primary.skillsDir, {
withFileTypes: true,
});
for (const entry of entries) {
const srcPath = path.join(primary.skillsDir, entry.name);
const dstPath = path.join(targetSkills, entry.name);
await copyDirectory(srcPath, dstPath);
}
}
await removePathIfExists(targetAgents);
await ensureDir(targetAgents);
if (fs.existsSync(primary.agentsDir)) {
const entries = await fs.promises.readdir(primary.agentsDir, {
withFileTypes: true,
});
for (const entry of entries) {
const srcPath = path.join(primary.agentsDir, entry.name);
const dstPath = path.join(targetAgents, entry.name);
await copyDirectory(srcPath, dstPath);
}
}
}
return { agentTypes: ALL_AGENT_TYPES };
}
export {
ensurePrimaryAgentDirs,
syncAgents,
};

View File

@@ -0,0 +1,72 @@
import fs from "fs";
import path from "path";
import { log, getCSTDateTimeString } from "../log/logUtils.js";
/**
* 为项目创建优化的 .npmrc 配置文件
* @param {string} projectPath - 项目路径
* @param {string} projectId - 项目ID用于日志
* @returns {Promise<Object>} 创建结果
*/
async function createPnpmNpmrc(projectPath, projectId = null) {
const logId = projectId || path.basename(projectPath);
const npmrcPath = path.join(projectPath, ".npmrc");
// .npmrc 配置内容
const npmrcContent = `# pnpm 磁盘空间优化配置
# 自动生成于 ${getCSTDateTimeString()}
package-import-method=hardlink
auto-install-peers=true
registry=https://registry.npmmirror.com
`;
try {
// 检查 .npmrc 是否已存在
if (fs.existsSync(npmrcPath)) {
log(logId, "INFO", ".npmrc file already exists, skip creation", {
projectPath,
npmrcPath,
});
return {
success: true,
created: false,
message: ".npmrc file already exists",
npmrcPath,
};
}
// 创建 .npmrc 文件
await fs.promises.writeFile(npmrcPath, npmrcContent, "utf8");
log(logId, "INFO", ".npmrc file created successfully", {
projectPath,
npmrcPath,
});
return {
success: true,
created: true,
message: ".npmrc file created successfully",
npmrcPath,
};
} catch (error) {
log(logId, "WARN", `.npmrc file creation failed: ${error.message}`, {
projectPath,
npmrcPath,
error: error.message,
});
// 创建 .npmrc 失败不应该阻止主流程,只记录警告
return {
success: false,
created: false,
message: `.npmrc file creation failed: ${error.message}`,
error: error.message,
};
}
}
export { createPnpmNpmrc };

View File

@@ -0,0 +1,43 @@
import path from "path";
import config from "../../appConfig/index.js";
function normalizeValue(value) {
if (value === undefined || value === null) return "";
const str = String(value).trim();
return str;
}
function extractIsolationContext(source = {}) {
return {
tenantId: normalizeValue(source.tenantId),
spaceId: normalizeValue(source.spaceId),
isolationType: normalizeValue(source.isolationType),
};
}
function shouldUseIsolationPath(isolationContext = {}) {
const tenantId = normalizeValue(isolationContext.tenantId);
const spaceId = normalizeValue(isolationContext.spaceId);
const isolationType = normalizeValue(isolationContext.isolationType);
return Boolean(tenantId && spaceId && isolationType);
}
function resolveProjectPath(projectId, isolationContext = {}) {
const normalizedProjectId = normalizeValue(projectId);
if (!normalizedProjectId) {
return path.join(config.PROJECT_SOURCE_DIR, "");
}
if (shouldUseIsolationPath(isolationContext)) {
return path.join(
config.PROJECT_SOURCE_DIR,
normalizeValue(isolationContext.tenantId),
normalizeValue(isolationContext.spaceId),
normalizedProjectId
);
}
return path.join(config.PROJECT_SOURCE_DIR, normalizedProjectId);
}
export { extractIsolationContext, shouldUseIsolationPath, resolveProjectPath };

View File

@@ -0,0 +1,56 @@
/**
* 敏感信息处理工具
*/
/**
* 通用脱敏函数 - 移除敏感路径信息
* @param {string} text 需要脱敏的文本
* @returns {string} 脱敏后的文本
*/
function sanitizeSensitivePaths(text) {
if (!text || typeof text !== "string") {
return text;
}
let sanitized = text;
// 获取敏感路径列表
const sensitivePaths = [
process.cwd(), // 当前工作目录
process.env.HOME, // 用户主目录
process.env.USERPROFILE, // Windows用户目录
// 环境变量中的敏感路径
process.env.LOG_BASE_DIR,
process.env.INIT_PROJECT_DIR,
process.env.UPLOAD_PROJECT_DIR,
process.env.PROJECT_SOURCE_DIR,
process.env.DIST_TARGET_DIR,
].filter(Boolean); // 过滤掉空值
// 替换敏感路径为空字符串
sensitivePaths.forEach((sensitivePath) => {
if (sensitivePath) {
// 转义特殊字符用于正则表达式
const escapedPath = sensitivePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(escapedPath, "gi");
sanitized = sanitized.replace(regex, "");
}
});
// 处理路径中的敏感部分(如 /workspace/rcoder-server
const sensitivePatterns = [
/\/workspace\/rcoder-server/g,
/\/home\/[^\/]+\/workspace\/rcoder-server/g,
/\/opt\/project_[^\/]+/g,
/\/Users\/[^\/]+\/Work\/[^\/]+/g,
];
sensitivePatterns.forEach((pattern) => {
sanitized = sanitized.replace(pattern, "");
});
return sanitized;
}
export { sanitizeSensitivePaths };

View File

@@ -0,0 +1,276 @@
import fs from "fs";
import path from "path";
import yauzl from "yauzl";
import { FileError } from "../error/errorHandler.js";
/**
* 解压zip文件到指定目录
* @param {string} zipPath - zip文件路径
* @param {string} extractPath - 解压目标路径
* @returns {Promise<void>}
*/
function extractZip(zipPath, extractPath) {
return new Promise((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
if (err) {
return reject(
new FileError(`无法打开压缩包: ${err.message}`, {
zipPath,
originalError: err.message,
})
);
}
zipfile.readEntry();
zipfile.on("entry", (entry) => {
if (/\/$/.test(entry.fileName)) {
// 目录条目
const dirPath = path.join(extractPath, entry.fileName);
try {
fs.mkdirSync(dirPath, { recursive: true });
} catch (mkdirErr) {
// 线上实测:某些 zip 中会出现「文件路径和目录路径冲突」或其他奇怪目录结构,
// 在深层路径上 mkdir 时可能抛出 ENOTDIR / EEXIST / 其它错误。
// 这类错误通常不影响我们真正关心的业务文件(如 skills 下的代码),
// 如果直接 reject会导致整个工作空间创建失败500
//
// 为了提升健壮性,这里统一降级为「跳过当前目录条目并继续后续解压」,同时输出 warning 方便排查。
console.warn(
"[extractZip] mkdir for directory entry failed, skip this dir",
{
dirPath,
code: mkdirErr.code,
message: mkdirErr.message,
}
);
zipfile.readEntry();
return;
}
zipfile.readEntry();
} else {
// 文件条目
zipfile.openReadStream(entry, (err, readStream) => {
if (err) {
return reject(
new FileError(`Failed to read zip entry: ${err.message}`, {
entry: entry.fileName,
originalError: err.message,
})
);
}
const filePath = path.join(extractPath, entry.fileName);
const dirPath = path.dirname(filePath);
try {
// 确保目录存在
fs.mkdirSync(dirPath, { recursive: true });
} catch (mkdirErr) {
// 同样存在文件/目录冲突或异常目录结构的情况。
// 为了避免单个异常条目导致整个解压失败,这里统一跳过该文件条目,仅记录 warning 日志。
console.warn(
"[extractZip] mkdir for file entry failed, skip this file",
{
dirPath,
filePath,
code: mkdirErr.code,
message: mkdirErr.message,
}
);
zipfile.readEntry();
return;
}
// 如果目标路径已经存在且是目录,说明 zip 里这个条目与现有目录冲突
// 为了避免 EISDIR 错误,这里直接跳过该条目,并继续解压后续内容
try {
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
zipfile.readEntry();
return;
}
} catch (statErr) {
return reject(
new FileError(`Failed to check file status: ${statErr.message}`, {
filePath,
originalError: statErr.message,
})
);
}
const writeStream = fs.createWriteStream(filePath);
readStream.pipe(writeStream);
writeStream.on("close", () => {
zipfile.readEntry();
});
writeStream.on("error", (err) => {
reject(
new FileError(`Failed to write file: ${err.message}`, {
filePath,
originalError: err.message,
})
);
});
});
}
});
zipfile.on("end", () => {
resolve();
});
zipfile.on("error", (err) => {
reject(
new FileError(`Error occurred during unzip: ${err.message}`, {
zipPath,
originalError: err.message,
})
);
});
});
});
}
// 缓存zip文件的目录索引避免重复遍历
// 键格式: zipPath:mtime:size值: Map<normalizedPath, entry>
const zipIndexCache = new Map();
const MAX_CACHE_SIZE = 50;
/**
* 获取zip文件的目录索引缓存
* @param {string} zipPath - zip文件路径
* @returns {Promise<Map<string, yauzl.Entry>|null>} 返回文件名到Entry的映射失败返回null
*/
function getZipIndex(zipPath) {
return new Promise((resolve) => {
try {
// 检查缓存
const stats = fs.statSync(zipPath);
const cacheKey = `${zipPath}:${stats.mtimeMs}:${stats.size}`;
if (zipIndexCache.has(cacheKey)) {
return resolve(zipIndexCache.get(cacheKey));
}
yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
if (err) {
return resolve(null);
}
const index = new Map();
zipfile.readEntry();
zipfile.on("entry", (entry) => {
// 标准化路径并存储
const normalizedPath = entry.fileName.replace(/\\/g, "/");
if (!/\/$/.test(entry.fileName)) {
// 只索引文件,不索引目录
index.set(normalizedPath, entry);
}
zipfile.readEntry();
});
zipfile.on("end", () => {
zipfile.close();
// 缓存索引(限制缓存大小,避免内存泄漏)
if (zipIndexCache.size >= MAX_CACHE_SIZE) {
// 删除最旧的缓存项(简单策略:清空一半)
const keysToDelete = Array.from(zipIndexCache.keys()).slice(0, Math.floor(MAX_CACHE_SIZE / 2));
keysToDelete.forEach(key => zipIndexCache.delete(key));
}
zipIndexCache.set(cacheKey, index);
resolve(index);
});
zipfile.on("error", () => {
zipfile.close();
resolve(null);
});
});
} catch (e) {
resolve(null);
}
});
}
/**
* 从zip文件中提取单个文件优化版使用索引快速定位
* @param {string} zipPath - zip文件路径
* @param {string} filePathInZip - 文件在zip中的路径相对路径
* @param {string} targetPath - 提取到的目标路径
* @returns {Promise<boolean>} 如果文件存在并成功提取返回true否则返回false
*/
function extractSingleFileFromZip(zipPath, filePathInZip, targetPath) {
return new Promise(async (resolve) => {
try {
// 先获取索引,快速定位文件
const index = await getZipIndex(zipPath);
if (!index) {
return resolve(false);
}
const normalizedPathInZip = filePathInZip.replace(/^[\/\\]+/, "").replace(/\\/g, "/");
if (!index.has(normalizedPathInZip)) {
// 文件不在索引中直接返回false避免打开zip文件
return resolve(false);
}
// 文件存在,使用 lazyEntries: false 一次性加载所有条目,然后直接定位
yauzl.open(zipPath, { lazyEntries: false }, (err, zipfile) => {
if (err) {
return resolve(false);
}
// 在所有条目中查找目标文件使用索引已确认存在这里只是获取entry对象
let targetEntry = null;
for (const entry of zipfile.entries) {
const entryPath = entry.fileName.replace(/\\/g, "/");
if (entryPath === normalizedPathInZip && !/\/$/.test(entry.fileName)) {
targetEntry = entry;
break;
}
}
if (!targetEntry) {
zipfile.close();
return resolve(false);
}
// 直接打开目标文件的流
zipfile.openReadStream(targetEntry, (err, readStream) => {
if (err || !readStream) {
zipfile.close();
return resolve(false);
}
const dirPath = path.dirname(targetPath);
try {
fs.mkdirSync(dirPath, { recursive: true });
} catch (mkdirErr) {
zipfile.close();
return resolve(false);
}
const writeStream = fs.createWriteStream(targetPath);
readStream.pipe(writeStream);
writeStream.on("close", () => {
zipfile.close();
resolve(true);
});
writeStream.on("error", () => {
zipfile.close();
resolve(false);
});
});
});
} catch (e) {
resolve(false);
}
});
}
export { extractZip, extractSingleFileFromZip };

View File

@@ -0,0 +1,979 @@
import fs from "fs";
import path from "path";
import archiver from "archiver";
import config from "../../appConfig/index.js";
import { log } from "../log/logUtils.js";
import { ValidationError, SystemError } from "../error/errorHandler.js";
const DEFAULT_DOWNLOAD_MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024;
const DOWNLOAD_MAX_FILE_SIZE_BYTES =
config.DOWNLOAD_MAX_FILE_SIZE_BYTES || DEFAULT_DOWNLOAD_MAX_FILE_SIZE_BYTES;
async function traverseDirectory(targetDir, basePath, logId, proxyPath) {
const files = [];
const entries = await fs.promises.readdir(targetDir, { withFileTypes: true });
entries.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
for (const entry of entries) {
const fullPath = path.join(targetDir, entry.name);
if (entry.name.startsWith(".")) continue;
const excludeFiles = config.CONTENT_TRAVERSE_EXCLUDE_FILES || [];
if (excludeFiles.includes(entry.name)) continue;
if (entry.isDirectory() && config.TRAVERSE_EXCLUDE_DIRS.includes(entry.name)) {
continue;
}
if (entry.isDirectory()) {
const sub = await traverseDirectory(fullPath, basePath, logId, proxyPath);
if (sub.length === 0) {
// 空目录,返回目录信息
const referencePath = basePath || targetDir;
// 统一使用正斜杠,保证 Windows/Linux 跨平台一致性及 URL 正确性
const relativePath = path.relative(referencePath, fullPath).replace(/\\/g, "/");
files.push({
name: relativePath,
isDir: true,
});
} else {
files.push(...sub);
}
} else {
try {
const referencePath = basePath || targetDir;
// 统一使用正斜杠,保证 Windows/Linux 跨平台一致性及 URL 正确性
const relativePath = path.relative(referencePath, fullPath).replace(/\\/g, "/");
const isLink = entry.isSymbolicLink();
// 生成文件代理URL对路径段进行编码以支持空格、中文等特殊字符
let fileProxyUrl = null;
if (proxyPath) {
const encodedPath = relativePath
.split("/")
.map((seg) => encodeURIComponent(seg))
.join("/");
fileProxyUrl = `${proxyPath}/${encodedPath}`;
}
const fileInfo = {
name: relativePath,
isDir: false,
fileProxyUrl: fileProxyUrl,
isLink: isLink,
};
files.push(fileInfo);
} catch (error) {
log(logId, "WARN", `处理文件失败: ${fullPath}`, { error: error.message });
}
}
}
return files;
}
/**
* 计算目录中允许打包下载的文件总大小(字节)
* 过滤规则与 downloadAllFiles 保持一致
* @param {string} targetDir 目标目录绝对路径
* @param {string[]} excludeFiles 需排除的文件名列表
* @param {string[]} excludeDirs 需排除的目录名列表
* @param {string} logId 日志ID
* @param {string} relativeDir 相对目录(递归内部使用)
* @returns {Promise<number>} 允许下载的总字节数
*/
async function calculateDownloadableDirectorySize(
targetDir,
excludeFiles,
excludeDirs,
logId,
relativeDir = ""
) {
const entries = await fs.promises.readdir(path.join(targetDir, relativeDir), {
withFileTypes: true,
});
let totalSize = 0;
for (const entry of entries) {
const nextRelativePath = relativeDir
? path.join(relativeDir, entry.name)
: entry.name;
const segments = nextRelativePath.split(path.sep).filter(Boolean);
// 1. 任一路径片段以 "." 开头,则忽略(隐藏文件/目录)
if (segments.some((seg) => seg.startsWith("."))) {
continue;
}
// 2. 检查文件名是否在排除列表中
const fileName = segments[segments.length - 1];
if (excludeFiles.includes(fileName)) {
continue;
}
// 3. 检查任一路径片段是否为排除的目录
if (segments.some((seg) => excludeDirs.includes(seg))) {
continue;
}
const fullPath = path.join(targetDir, nextRelativePath);
let stats;
try {
stats = await fs.promises.lstat(fullPath);
} catch (error) {
log(logId, "WARN", "Error occurred when getting file stats, skipping", {
filePath: nextRelativePath.replace(/\\/g, "/"),
error: error.message,
});
continue;
}
// 4. 跳过符号链接和硬链接
if (stats.isSymbolicLink() || stats.nlink > 1) {
continue;
}
if (stats.isDirectory()) {
totalSize += await calculateDownloadableDirectorySize(
targetDir,
excludeFiles,
excludeDirs,
logId,
nextRelativePath
);
} else if (stats.isFile()) {
totalSize += stats.size;
}
}
return totalSize;
}
/**
* 获取文件列表
* @param {string|number} userId 用户ID
* @param {string|number} cId 会话ID
* @returns {Promise<{files: Array}>}
*/
async function getFileList(userId, cId, proxyPath) {
const startTime = Date.now();
const logId = `computer:${userId}:${cId}`;
const workspaceRoot = config.COMPUTER_WORKSPACE_DIR;
if (!userId) {
throw new ValidationError("userId 不能为空", { field: "userId" });
}
if (!cId) {
throw new ValidationError("cId 不能为空", { field: "cId" });
}
const normalizedUserId = String(userId);
const normalizedCId = String(cId);
const targetDir = path.join(workspaceRoot, normalizedUserId, normalizedCId);
if (!fs.existsSync(targetDir)) {
log(logId, "INFO", "Directory does not exist, returning empty list", {
targetDir,
userId: normalizedUserId,
cId: normalizedCId,
});
return { files: [] };
}
log(logId, "DEBUG", "Start getting user file list", {
targetDir,
userId: normalizedUserId,
cId: normalizedCId,
});
try {
const files = await traverseDirectory(targetDir, targetDir, logId, proxyPath);
log(logId, "INFO", "User file list obtained successfully", {
fileCount: files.length,
targetDir,
userId: normalizedUserId,
cId: normalizedCId,
elapsedMs: Date.now() - startTime,
});
return { files };
} catch (error) {
log(logId, "ERROR", "Failed to get user file list", {
targetDir,
userId: normalizedUserId,
cId: normalizedCId,
error: error.message,
elapsedMs: Date.now() - startTime,
});
throw new SystemError(`Failed to get file list: ${error.message}`, {
targetDir,
originalError: error.message,
});
}
}
/**
* 更新文件:支持新增、删除、重命名、修改操作
* @param {string|number} userId 用户ID
* @param {string|number} cId 会话ID
* @param {Array} files 文件操作列表
* @returns {Promise<Object>} 更新结果
*/
async function updateFiles(userId, cId, files) {
const startTime = Date.now();
const logId = `computer:${userId}:${cId}`;
const workspaceRoot = config.COMPUTER_WORKSPACE_DIR;
if (!userId) {
throw new ValidationError("userId cannot be empty", { field: "userId" });
}
if (!cId) {
throw new ValidationError("cId cannot be empty", { field: "cId" });
}
if (!Array.isArray(files)) {
throw new ValidationError("files must be an array", { field: "files" });
}
const normalizedUserId = String(userId);
const normalizedCId = String(cId);
const targetDir = path.join(workspaceRoot, normalizedUserId, normalizedCId);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
// 验证文件操作结构
for (let i = 0; i < files.length; i++) {
const fileOp = files[i];
if (!fileOp || typeof fileOp.operation !== "string") {
throw new ValidationError(`files[${i}].operation cannot be empty`, {
field: `files[${i}].operation`,
});
}
if (!fileOp.name || typeof fileOp.name !== "string") {
throw new ValidationError(`files[${i}].name cannot be empty`, {
field: `files[${i}].name`,
});
}
const operation = fileOp.operation.toLowerCase();
if (!["create", "delete", "rename", "modify"].includes(operation)) {
throw new ValidationError(
`files[${i}].operation must be one of create, delete, rename or modify`,
{ field: `files[${i}].operation` }
);
}
// 验证特定操作所需的字段
if (operation === "rename" && !fileOp.renameFrom) {
throw new ValidationError(
`files[${i}].renameFrom cannot be empty (rename operation requires)`,
{ field: `files[${i}].renameFrom` }
);
}
if (operation === "modify" && fileOp.isDir !== true) {
if (typeof fileOp.contents !== "string") {
throw new ValidationError(
`files[${i}].contents must be a string (modify operation requires)`,
{ field: `files[${i}].contents` }
);
}
}
}
log(logId, "DEBUG", "Start updating user files", {
userId: normalizedUserId,
cId: normalizedCId,
filesCount: files.length,
});
try {
// 处理文件操作
for (const fileOp of files) {
const operation = fileOp.operation.toLowerCase();
const fileName = fileOp.name;
const normalizedPath = path.normalize(fileName).replace(/^[\/\\]+/, "");
const targetPath = path.join(targetDir, normalizedPath);
// 安全检查:确保目标路径在用户目录内
const resolvedTargetPath = path.resolve(targetPath);
const resolvedTargetDir = path.resolve(targetDir);
if (
!resolvedTargetPath.startsWith(resolvedTargetDir + path.sep) &&
resolvedTargetPath !== resolvedTargetDir
) {
log(logId, "WARN", "File path is not secure, skipping", {
filePath: normalizedPath,
resolvedPath: resolvedTargetPath,
});
continue;
}
switch (operation) {
case "create": {
// 创建新文件或目录
if (fileOp.isDir === true) {
// 创建目录前检查是否已存在
if (fs.existsSync(targetPath)) {
const stat = await fs.promises.stat(targetPath);
if (stat.isFile()) {
throw new ValidationError("Cannot create directory, file with the same name already exists", {
filePath: normalizedPath,
});
}
// 目录已存在,跳过创建
log(logId, "INFO", "Directory already exists, skipping creation", {
filePath: normalizedPath,
});
break;
}
await fs.promises.mkdir(targetPath, { recursive: true });
log(logId, "INFO", "Directory created successfully", {
filePath: normalizedPath,
});
break;
}
// 创建文件前检查是否已存在
if (fs.existsSync(targetPath)) {
const stat = await fs.promises.stat(targetPath);
if (stat.isDirectory()) {
throw new ValidationError("Cannot create file, directory with the same name already exists", {
filePath: normalizedPath,
});
}
// 文件已存在,跳过创建
log(logId, "INFO", "File already exists, skipping creation", {
filePath: normalizedPath,
});
break;
}
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
const contents = fileOp.contents || "";
await fs.promises.writeFile(targetPath, contents, "utf8");
log(logId, "INFO", "File created successfully", {
filePath: normalizedPath,
});
break;
}
case "delete": {
// 删除文件或目录
if (fs.existsSync(targetPath)) {
const stat = await fs.promises.stat(targetPath);
if (stat.isDirectory()) {
// 删除目录(递归删除)
await fs.promises.rm(targetPath, { recursive: true, force: true });
log(logId, "INFO", "Directory deleted successfully", {
filePath: normalizedPath,
});
} else {
// 删除文件
await fs.promises.unlink(targetPath);
log(logId, "INFO", "File deleted successfully", {
filePath: normalizedPath,
});
}
} else {
log(logId, "WARN", "The file or directory to be deleted does not exist", {
filePath: normalizedPath,
});
}
break;
}
case "rename": {
// 重命名文件或目录
const renameFrom = fileOp.renameFrom;
if (!renameFrom || typeof renameFrom !== "string") {
log(logId, "WARN", "Rename operation missing renameFrom", {
filePath: normalizedPath,
});
break;
}
const normalizedFrom = path
.normalize(renameFrom)
.replace(/^[\/\\]+/, "");
const sourcePath = path.join(targetDir, normalizedFrom);
// 安全检查:确保源路径在用户目录内
const resolvedSourcePath = path.resolve(sourcePath);
if (
!resolvedSourcePath.startsWith(resolvedTargetDir + path.sep) &&
resolvedSourcePath !== resolvedTargetDir
) {
log(logId, "WARN", "Source path is not secure, skipping rename", {
sourcePath: normalizedFrom,
targetPath: normalizedPath,
});
break;
}
if (fs.existsSync(sourcePath)) {
const stat = await fs.promises.stat(sourcePath);
const isDirectory = stat.isDirectory();
// 确保目标路径的父目录存在(文件和目录重命名都需要)
await fs.promises.mkdir(path.dirname(targetPath), {
recursive: true,
});
await fs.promises.rename(sourcePath, targetPath);
log(logId, "INFO", isDirectory ? "Directory renamed successfully" : "File renamed successfully", {
sourcePath: normalizedFrom,
targetPath: normalizedPath,
});
} else {
log(logId, "WARN", "The file or directory to be renamed does not exist", {
sourcePath: normalizedFrom,
});
}
break;
}
case "modify": {
// 修改文件:直接写入新内容
if (!fs.existsSync(targetPath)) {
log(logId, "WARN", "The file to be modified does not exist", {
filePath: normalizedPath,
});
break;
}
// 如果是目录,跳过修改
const modifyStat = await fs.promises.stat(targetPath);
if (modifyStat.isDirectory()) {
log(logId, "INFO", "The target is a directory, skipping modification", {
filePath: normalizedPath,
});
break;
}
const newContentStr =
typeof fileOp.contents === "string" ? fileOp.contents : "";
// 读取现有文件内容进行比较
const existingContent = await fs.promises.readFile(
targetPath,
"utf8"
);
// 若内容完全一致,不覆写文件
if (existingContent === newContentStr) {
log(logId, "INFO", "File content has no changes, skipping write", {
filePath: normalizedPath,
});
break;
}
// 写入修改后的内容
await fs.promises.writeFile(targetPath, newContentStr, "utf8");
log(logId, "INFO", "File modified successfully", {
filePath: normalizedPath,
});
break;
}
default: {
log(logId, "WARN", "Unsupported operation type", {
operation,
filePath: normalizedPath,
});
break;
}
}
}
log(logId, "INFO", "User files updated successfully", {
userId: normalizedUserId,
cId: normalizedCId,
filesCount: files.length,
elapsedMs: Date.now() - startTime,
});
return {
success: true,
message: "User files updated successfully",
userId: normalizedUserId,
cId: normalizedCId,
filesCount: files.length,
};
} catch (error) {
log(logId, "ERROR", "User files updated failed", {
userId: normalizedUserId,
cId: normalizedCId,
error: error.message,
elapsedMs: Date.now() - startTime,
});
throw new SystemError(`User files updated failed: ${error.message}`, {
userId: normalizedUserId,
cId: normalizedCId,
originalError: error.message,
});
}
}
/**
* 上传单个文件到用户工作目录
* @param {string|number} userId 用户ID
* @param {string|number} cId 会话ID
* @param {Object} file 文件对象 (包含文件内容和元数据)
* @param {string} filePath 文件在用户目录中的相对路径
* @returns {Promise<Object>} 上传结果
*/
async function uploadFile(userId, cId, file, filePath) {
const startTime = Date.now();
const logId = `computer:${userId}:${cId}`;
const workspaceRoot = config.COMPUTER_WORKSPACE_DIR;
if (!userId) {
throw new ValidationError("userId cannot be empty", { field: "userId" });
}
if (!cId) {
throw new ValidationError("cId cannot be empty", { field: "cId" });
}
if (!file) {
throw new ValidationError("file cannot be empty", { field: "file" });
}
if (!filePath || typeof filePath !== "string") {
throw new ValidationError("filePath cannot be empty", { field: "filePath" });
}
const normalizedUserId = String(userId);
const normalizedCId = String(cId);
const targetDir = path.join(workspaceRoot, normalizedUserId, normalizedCId);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
// 规范化文件路径,确保是相对路径
const normalizedPath = path.normalize(filePath).replace(/^[\/\\]+/, "");
const targetPath = path.join(targetDir, normalizedPath);
// 安全检查:确保目标路径在用户目录内
const resolvedTargetPath = path.resolve(targetPath);
const resolvedTargetDir = path.resolve(targetDir);
if (
!resolvedTargetPath.startsWith(resolvedTargetDir + path.sep) &&
resolvedTargetPath !== resolvedTargetDir
) {
throw new ValidationError("File path is not secure, cannot exceed user directory", {
field: "filePath",
providedPath: filePath,
resolvedPath: resolvedTargetPath,
});
}
try {
// 确保目标目录存在
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
// 写入文件内容
if (file.buffer) {
// 如果是二进制文件buffer
await fs.promises.writeFile(targetPath, file.buffer);
} else if (typeof file.contents === "string") {
// 如果是文本文件
await fs.promises.writeFile(targetPath, file.contents, "utf8");
} else {
throw new ValidationError("File content format is incorrect", {
field: "file",
hasBuffer: !!file.buffer,
hasContents: typeof file.contents,
});
}
log(logId, "INFO", "File uploaded successfully", {
userId: normalizedUserId,
cId: normalizedCId,
filePath: normalizedPath,
targetPath: resolvedTargetPath,
fileSize: file.buffer
? file.buffer.length
: file.contents
? file.contents.length
: 0,
elapsedMs: Date.now() - startTime,
});
return {
success: true,
message: "File uploaded successfully",
fileSize: file.buffer
? file.buffer.length
: file.contents
? file.contents.length
: 0,
};
} catch (error) {
log(logId, "ERROR", "File upload failed", {
userId: normalizedUserId,
cId: normalizedCId,
filePath: normalizedPath,
error: error.message,
elapsedMs: Date.now() - startTime,
});
throw new SystemError(`File upload failed: ${error.message}`, {
userId: normalizedUserId,
cId: normalizedCId,
filePath: normalizedPath,
originalError: error.message,
});
}
}
/**
* 批量上传文件到用户工作目录
* @param {string|number} userId 用户ID
* @param {string|number} cId 会话ID
* @param {Array<Object>} files 文件对象数组,每个文件对象包含:
* - buffer: Buffer (二进制文件) 或 contents: string (文本文件)
* - originalname: string 原始文件名
* - mimetype: string MIME类型
* - size: number 文件大小
* @param {Array<string>} filePaths 文件路径数组与files数组一一对应
* @returns {Promise<Object>} 批量上传结果
*/
async function uploadFiles(userId, cId, files, filePaths) {
const startTime = Date.now();
const logId = `computer:${userId}:${cId}`;
if (!userId) {
throw new ValidationError("userId cannot be empty", { field: "userId" });
}
if (!cId) {
throw new ValidationError("cId cannot be empty", { field: "cId" });
}
if (!Array.isArray(files)) {
throw new ValidationError("files must be an array", { field: "files" });
}
if (!Array.isArray(filePaths)) {
throw new ValidationError("filePaths must be an array", { field: "filePaths" });
}
if (files.length !== filePaths.length) {
throw new ValidationError(
`File count (${files.length}) does not match path count (${filePaths.length})`,
{ field: "filePaths" }
);
}
log(logId, "DEBUG", "Start batch uploading files", {
userId,
cId,
filesCount: files.length,
});
const results = [];
try {
// 处理每个文件
for (let i = 0; i < files.length; i++) {
const file = files[i];
const filePath = filePaths[i];
if (!file) {
log(logId, "WARN", "Empty file object encountered in batch upload, skipping", {
index: i,
filePath,
});
results.push({
success: false,
filePath,
error: "Empty file object",
});
continue;
}
if (!filePath || typeof filePath !== "string") {
log(logId, "WARN", "Invalid file path in batch upload, skipping", {
index: i,
originalname: file.originalname,
});
results.push({
success: false,
filePath: filePath || "",
originalname: file.originalname,
error: "Invalid file path",
});
continue;
}
try {
const result = await uploadFile(userId, cId, file, filePath);
results.push({
success: true,
filePath,
originalname: file.originalname,
...result,
});
} catch (error) {
log(logId, "ERROR", "Single file upload failed in batch upload", {
filePath,
originalname: file.originalname,
error: error.message,
});
results.push({
success: false,
filePath,
originalname: file.originalname,
error: error.message,
});
}
}
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
log(logId, "INFO", "Batch upload files completed", {
userId,
cId,
totalCount: files.length,
successCount,
failCount,
elapsedMs: Date.now() - startTime,
});
return {
success: true,
message: "Batch upload completed",
totalCount: files.length,
successCount,
failCount,
results,
};
} catch (error) {
log(logId, "ERROR", "Batch upload files failed", {
userId,
cId,
error: error.message,
elapsedMs: Date.now() - startTime,
});
throw new SystemError(`Batch upload files failed: ${error.message}`, {
userId,
cId,
originalError: error.message,
});
}
}
/**
* 下载所有文件
* - 压缩目录COMPUTER_WORKSPACE_DIR/<userId>/<cId>/
* - 顶层目录名userId_cId
* - 过滤规则与 get-file-list 路由保持一致:
* 1. 忽略所有隐藏文件/目录(任一路径片段以 "." 开头)
* 2. 排除 CONTENT_TRAVERSE_EXCLUDE_FILES 配置的文件
* 3. 排除 TRAVERSE_EXCLUDE_DIRS 配置的目录(如 node_modules
* 4. 跳过符号链接Symbolic Links
* 5. 跳过硬链接Hard Linksnlink > 1 的文件)
*
* @param {string|number} userId 用户ID
* @param {string|number} cId 会话ID
* @returns {Promise<{ archive: import("archiver").Archiver, zipFileName: string }>}
*/
async function downloadAllFiles(userId, cId) {
const startTime = Date.now();
const logId = `computer:${userId}:${cId}`;
const workspaceRoot = config.COMPUTER_WORKSPACE_DIR;
if (!userId) {
throw new ValidationError("userId cannot be empty", { field: "userId" });
}
if (!cId) {
throw new ValidationError("cId cannot be empty", { field: "cId" });
}
if (!workspaceRoot) {
throw new SystemError("COMPUTER_WORKSPACE_DIR is not configured, cannot create zip");
}
const normalizedUserId = String(userId);
const normalizedCId = String(cId);
const targetDir = path.join(workspaceRoot, normalizedUserId, normalizedCId);
if (!fs.existsSync(targetDir)) {
// 目录不存在时,返回一个仅包含顶层目录的空压缩包
const zipFileName = `${normalizedUserId}_${normalizedCId}.zip`;
log(logId, "WARN", "Workspace directory does not exist, returning empty zip", {
targetDir,
userId: normalizedUserId,
cId: normalizedCId,
zipFileName,
});
const archive = archiver("zip", {
zlib: { level: 9 },
});
// 创建一个空的顶层目录条目
archive.append(null, {
name: `${normalizedUserId}_${normalizedCId}/`,
type: "directory",
});
archive.on("warning", (err) => {
if (err.code === "ENOENT") {
log(logId, "WARN", "Encountered file problem when creating empty zip", {
message: err.message,
code: err.code,
});
} else {
log(logId, "ERROR", "Encountered warning when creating empty zip", {
message: err.message,
code: err.code,
});
throw err;
}
});
archive.on("error", (err) => {
log(logId, "ERROR", "Failed to create empty zip", {
message: err.message,
});
});
return { archive, zipFileName };
}
const zipFileName = `${normalizedUserId}_${normalizedCId}.zip`;
log(logId, "DEBUG", "Start creating workspace directory zip", {
targetDir,
zipFileName,
});
const archive = archiver("zip", {
zlib: { level: 9 },
});
// 获取排除配置
const excludeFiles = config.CONTENT_TRAVERSE_EXCLUDE_FILES || [];
const excludeDirs = config.TRAVERSE_EXCLUDE_DIRS || [];
const downloadableSize = await calculateDownloadableDirectorySize(
targetDir,
excludeFiles,
excludeDirs,
logId
);
if (downloadableSize > DOWNLOAD_MAX_FILE_SIZE_BYTES) {
const maxSizeMb = DOWNLOAD_MAX_FILE_SIZE_BYTES / 1024 / 1024;
const currentSizeMb = (downloadableSize / 1024 / 1024).toFixed(2);
log(logId, "WARN", "Download rejected due to oversized workspace", {
targetDir,
downloadableSize,
maxSizeBytes: DOWNLOAD_MAX_FILE_SIZE_BYTES,
});
throw new ValidationError(
`Download failed: total file size ${currentSizeMb}MB exceeds limit ${maxSizeMb}MB`,
{
field: "downloadSize",
downloadableSize,
maxSizeBytes: DOWNLOAD_MAX_FILE_SIZE_BYTES,
}
);
}
// 过滤文件/目录,与 get-file-list 路由保持一致
archive.directory(
targetDir,
`${normalizedUserId}_${normalizedCId}`,
(entry) => {
const name = entry.name || "";
const segments = name.split(/[\/\\]/).filter(Boolean);
// 1. 任一路径片段以 "." 开头,则忽略(隐藏文件/目录)
if (segments.some((seg) => seg.startsWith("."))) {
return false;
}
// 2. 检查文件名是否在排除列表中
const fileName = segments[segments.length - 1];
if (excludeFiles.includes(fileName)) {
return false;
}
// 3. 检查任一路径片段是否为排除的目录
if (segments.some((seg) => excludeDirs.includes(seg))) {
return false;
}
// 4. 检测并跳过链接文件(符号链接和硬链接)
try {
const fullPath = path.join(targetDir, name);
const stats = fs.lstatSync(fullPath);
// 跳过符号链接
if (stats.isSymbolicLink()) {
return false;
}
// 跳过硬链接nlink > 1 表示文件有多个硬链接)
if (stats.nlink > 1) {
return false;
}
} catch (error) {
// 如果无法获取文件信息(如文件不存在、权限问题等),记录警告但继续处理
log(logId, "WARN", "Error occurred when detecting link file, skipping", {
filePath: name,
error: error.message,
});
return false;
}
return entry;
}
);
archive.on("warning", (err) => {
// 一些非致命错误(如文件不存在)记录日志即可
if (err.code === "ENOENT") {
log(logId, "WARN", "Encountered file problem when creating zip", {
message: err.message,
code: err.code,
});
} else {
log(logId, "ERROR", "Encountered warning when creating zip", {
message: err.message,
code: err.code,
});
throw err;
}
});
archive.on("error", (err) => {
log(logId, "ERROR", "Failed to create zip", {
message: err.message,
elapsedMs: Date.now() - startTime,
});
});
archive.on("end", () => {
log(logId, "INFO", "Workspace directory zip created successfully", {
targetDir,
zipFileName,
elapsedMs: Date.now() - startTime,
});
});
return { archive, zipFileName };
}
export { getFileList, updateFiles, uploadFile, uploadFiles, downloadAllFiles };

View File

@@ -0,0 +1,872 @@
import fs from "fs";
import path from "path";
import config from "../../appConfig/index.js";
import { extractZip } from "../common/zipUtils.js";
import {
ValidationError,
SystemError,
FileError,
} from "../error/errorHandler.js";
import { log } from "../log/logUtils.js";
import {
ensurePrimaryAgentDirs,
syncAgents,
} from "../common/AgentWorkspaceUtils.js";
/**
* 规范化 skillUrls 参数,兼容数组/JSON 字符串/单字符串
* @param {unknown} skillUrls
* @returns {string[]}
*/
function normalizeSkillUrls(skillUrls) {
if (!skillUrls) return [];
if (Array.isArray(skillUrls)) {
return skillUrls
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter(Boolean);
}
if (typeof skillUrls === "string") {
const trimmed = skillUrls.trim();
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter(Boolean);
}
} catch {
// 非 JSON 字符串,按单个 URL 处理
}
return [trimmed];
}
return [];
}
/**
* 下载 URL 到本地文件
* @param {string} url
* @param {string} destinationPath
* @param {string} logId
*/
async function downloadUrlToFile(url, destinationPath, logId) {
let response;
try {
response = await fetch(url);
} catch (error) {
throw new FileError(`Failed to download skill zip from url: ${url}`, {
url,
reason: error.message,
});
}
if (!response.ok) {
throw new FileError(`Failed to download skill zip from url: ${url}`, {
url,
status: response.status,
statusText: response.statusText,
});
}
const contentType = response.headers.get("content-type") || "";
if (
contentType &&
!contentType.includes("zip") &&
!contentType.includes("octet-stream")
) {
log(logId, "WARN", "Downloaded skill url content-type is not typical zip", {
url,
contentType,
});
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
await fs.promises.writeFile(destinationPath, buffer);
}
/**
* 确保工作空间根目录存在:$COMPUTER_WORKSPACE_DIR
*/
async function ensureWorkspaceRoot(logId = "computer") {
const workspaceRoot = config.COMPUTER_WORKSPACE_DIR;
if (!workspaceRoot) {
throw new ValidationError("COMPUTER_WORKSPACE_DIR configuration does not exist", {
field: "COMPUTER_WORKSPACE_DIR",
});
}
if (!fs.existsSync(workspaceRoot)) {
await fs.promises.mkdir(workspaceRoot, { recursive: true });
log(logId, "INFO", "Created user workspace root directory", { workspaceRoot });
}
return workspaceRoot;
}
/**
* 递归查找指定目录(如果不是直接在根目录)
* @param {string} rootDir - 根目录
* @param {string} dirName - 要查找的目录名(如 "skills" 或 "agents"
* @returns {Promise<string|null>} 找到的目录路径,如果不存在则返回 null
*/
async function findDir(rootDir, dirName) {
const directDir = path.join(rootDir, dirName);
if (fs.existsSync(directDir) && (await fs.promises.lstat(directDir)).isDirectory()) {
return directDir;
}
const entries = await fs.promises.readdir(rootDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const subDir = path.join(rootDir, entry.name);
const candidate = path.join(subDir, dirName);
if (fs.existsSync(candidate) && (await fs.promises.lstat(candidate)).isDirectory()) {
return candidate;
}
}
return null;
}
/**
* 递归查找 skills 目录(如果不是直接在根目录)
* @deprecated 使用 findDir(rootDir, "skills") 代替
*/
async function findSkillsDir(rootDir) {
return findDir(rootDir, "skills");
}
const DYNAMIC_ADD_LOCK = ".dynamic_add.lock";
/**
* 检查 skill 目录是否含有 .dynamic_add.lock有则不应删除
*/
function hasDynamicAddLock(skillDirPath) {
const lockPath = path.join(skillDirPath, DYNAMIC_ADD_LOCK);
return fs.existsSync(lockPath) && fs.statSync(lockPath).isFile();
}
/**
* 删除目录(如果存在)
*/
async function removeDirIfExists(targetDir) {
if (!fs.existsSync(targetDir)) return;
await fs.promises.rm(targetDir, { recursive: true, force: true });
}
/**
* 安全移动目录(跨设备时回退为 copy
*/
async function moveDirectory(srcDir, destDir) {
try {
await fs.promises.rename(srcDir, destDir);
} catch (err) {
if (err.code === "EXDEV") {
// 跨设备,使用 copy + rm
async function copyRecursive(src, dest) {
const stat = await fs.promises.lstat(src);
if (stat.isDirectory()) {
await fs.promises.mkdir(dest, { recursive: true });
const items = await fs.promises.readdir(src);
for (const item of items) {
await copyRecursive(
path.join(src, item),
path.join(dest, item)
);
}
} else {
await fs.promises.copyFile(src, dest);
}
}
await copyRecursive(srcDir, destDir);
await fs.promises.rm(srcDir, { recursive: true, force: true });
} else {
throw err;
}
}
}
/**
* 创建工作空间并(可选)处理上传的 zip提取 skills 目录
* @param {string|number} userId
* @param {string|number} cId
* @param {Object|null} file multer 文件对象zip可以为空
* @param {string[]|string|undefined} skillUrls 技能 zip 下载地址数组
*/
async function createWorkspace(userId, cId, file, skillUrls) {
const startTime = Date.now();
const logId = `computer:${userId}:${cId}`;
const normalizedSkillUrls = normalizeSkillUrls(skillUrls);
const downloadedZipPaths = [];
const extractRoots = [];
const updatedSkillSet = new Set();
if (!userId) {
throw new ValidationError("userId cannot be empty", { field: "userId" });
}
if (!cId) {
throw new ValidationError("cId cannot be empty", { field: "cId" });
}
const workspaceRoot = await ensureWorkspaceRoot(logId);
const tmpRoot = path.join(
workspaceRoot,
String(userId),
String(cId),
".tmp"
);
// 目标:$COMPUTER_WORKSPACE_DIR/userId/cId/
const userWorkspaceRoot = path.join(
workspaceRoot,
String(userId),
String(cId)
);
const {
skillsDir: targetSkillsDir,
agentsDir: targetAgentsDir,
agentTypes: normalizedAgentTypes,
} =
await ensurePrimaryAgentDirs(userWorkspaceRoot);
// 始终:保证工作空间目录存在,并清空(删除)现有 skills 和 agents 目录
if (!fs.existsSync(userWorkspaceRoot)) {
await fs.promises.mkdir(userWorkspaceRoot, { recursive: true });
}
// 清除工作目录中的 skills 和 agents 目录
// skills若 skill 子目录含 .dynamic_add.lock 则保留
const preservedSkillsTemp = path.join(
tmpRoot,
`preserved_skills_${Date.now()}_${Math.round(Math.random() * 1e6)}`
);
if (fs.existsSync(targetSkillsDir)) {
const skillEntries = await fs.promises.readdir(targetSkillsDir, {
withFileTypes: true,
});
const toPreserve = skillEntries.filter(
(e) =>
e.isDirectory() &&
hasDynamicAddLock(path.join(targetSkillsDir, e.name))
);
if (toPreserve.length > 0) {
await fs.promises.mkdir(preservedSkillsTemp, { recursive: true });
for (const e of toPreserve) {
const src = path.join(targetSkillsDir, e.name);
const dest = path.join(preservedSkillsTemp, e.name);
await moveDirectory(src, dest);
}
log(logId, "INFO", "保留含 .dynamic_add.lock 的 skill", {
preserved: toPreserve.map((e) => e.name),
});
}
}
await removeDirIfExists(targetSkillsDir);
await removeDirIfExists(targetAgentsDir);
// 清空后立即重建空目录
await fs.promises.mkdir(targetSkillsDir, { recursive: true });
await fs.promises.mkdir(targetAgentsDir, { recursive: true });
// 恢复保留的 skills
if (fs.existsSync(preservedSkillsTemp)) {
const preserved = await fs.promises.readdir(preservedSkillsTemp, {
withFileTypes: true,
});
for (const e of preserved) {
if (e.isDirectory()) {
const src = path.join(preservedSkillsTemp, e.name);
const dest = path.join(targetSkillsDir, e.name);
await moveDirectory(src, dest);
}
}
await removeDirIfExists(preservedSkillsTemp);
}
const skillsExistsAfter = fs.existsSync(targetSkillsDir);
const agentsExistsAfter = fs.existsSync(targetAgentsDir);
log(logId, "INFO", "Deleted old skills and agents directories completed", {
userId,
cId,
targetSkillsDir,
targetAgentsDir,
agentTypes: normalizedAgentTypes,
skillsExists: skillsExistsAfter,
agentsExists: agentsExistsAfter,
});
// 如果没有上传文件也没有 URL不写入 skills 和 agents
if (!file && normalizedSkillUrls.length === 0) {
await syncAgents(userWorkspaceRoot);
log(logId, "INFO", "Created workspace (no uploaded file, no skills and agents)", {
userId,
cId,
workspaceRoot,
skillsDir: null,
agentsDir: null,
agentTypes: normalizedAgentTypes,
elapsedMs: Date.now() - startTime,
});
return {
message: "Workspace created (no uploaded file, no skills and agents)",
workspaceRoot,
};
}
// 有上传文件时,要求是 zip
if (file) {
if (!file.path) {
throw new ValidationError("Uploaded file has no valid path", { field: "file.path" });
}
const ext = path.extname(file.originalname || file.filename || "").toLowerCase();
if (ext !== ".zip") {
throw new ValidationError("Only zip files are supported", {
field: "file",
originalName: file.originalname,
});
}
}
log(logId, "DEBUG", "Start processing workspace resources", {
userId,
cId,
workspaceRoot,
hasUploadedZip: !!file,
skillUrlsCount: normalizedSkillUrls.length,
});
try {
if (!fs.existsSync(tmpRoot)) {
await fs.promises.mkdir(tmpRoot, { recursive: true });
}
const updatedDirs = [];
// 处理上传 zip支持 skills/agents
if (file) {
const uploadExtractRoot = path.join(
tmpRoot,
`skill_extract_${Date.now()}_${Math.round(Math.random() * 1e6)}`
);
extractRoots.push(uploadExtractRoot);
await fs.promises.mkdir(uploadExtractRoot, { recursive: true });
log(logId, "DEBUG", "Start extracting uploaded zip file", {
extractRoot: uploadExtractRoot,
});
await extractZip(file.path, uploadExtractRoot);
log(logId, "DEBUG", "Uploaded zip file extracted successfully", {
extractRoot: uploadExtractRoot,
});
// 查找压缩包中的 skills 和 agents 目录
const skillsDir = await findDir(uploadExtractRoot, "skills");
const agentsDir = await findDir(uploadExtractRoot, "agents");
// 如果压缩包中有 skills 目录,就写入(逐个 skill 移动)
if (skillsDir) {
await fs.promises.mkdir(targetSkillsDir, { recursive: true });
const skillEntries = await fs.promises.readdir(skillsDir, {
withFileTypes: true,
});
for (const e of skillEntries) {
if (!e.isDirectory()) continue;
const srcPath = path.join(skillsDir, e.name);
const destPath = path.join(targetSkillsDir, e.name);
if (fs.existsSync(destPath)) {
await removeDirIfExists(destPath);
}
await moveDirectory(srcPath, destPath);
updatedSkillSet.add(e.name);
}
updatedDirs.push("skills");
log(logId, "INFO", "skills updated to workspace", {
userId,
cId,
workspaceRoot,
targetSkillsDir,
agentTypes: normalizedAgentTypes,
});
} else {
log(logId, "INFO", "skills directory not found in uploaded zip, skipping", {
userId,
cId,
extractRoot: uploadExtractRoot,
});
}
// 如果压缩包中有 agents 目录,就写入
// Windows目标路径若已存在目录此前 mkdir 建过空目录rename 会 EPERM须先删除
if (agentsDir) {
await removeDirIfExists(targetAgentsDir);
await moveDirectory(agentsDir, targetAgentsDir);
updatedDirs.push("agents");
log(logId, "INFO", "agents updated to workspace", {
userId,
cId,
workspaceRoot,
targetAgentsDir,
agentTypes: normalizedAgentTypes,
});
} else {
log(logId, "INFO", "agents directory not found in uploaded zip, skipping", {
userId,
cId,
extractRoot: uploadExtractRoot,
});
}
}
// 处理 skillUrls每个 zip 解压后直接是 skillName 目录)
for (let i = 0; i < normalizedSkillUrls.length; i += 1) {
const skillUrl = normalizedSkillUrls[i];
const downloadedZipPath = path.join(
tmpRoot,
`skill_url_${Date.now()}_${i}_${Math.round(Math.random() * 1e6)}.zip`
);
downloadedZipPaths.push(downloadedZipPath);
const urlExtractRoot = path.join(
tmpRoot,
`skill_url_extract_${Date.now()}_${i}_${Math.round(Math.random() * 1e6)}`
);
extractRoots.push(urlExtractRoot);
await fs.promises.mkdir(urlExtractRoot, { recursive: true });
log(logId, "INFO", "Start download skill zip from url", {
userId,
cId,
skillUrl,
});
await downloadUrlToFile(skillUrl, downloadedZipPath, logId);
log(logId, "INFO", "Skill zip downloaded, start extracting", {
userId,
cId,
skillUrl,
downloadedZipPath,
});
await extractZip(downloadedZipPath, urlExtractRoot);
const extractedEntries = await fs.promises.readdir(urlExtractRoot, {
withFileTypes: true,
});
const rootDirs = extractedEntries.filter(
(entry) => entry.isDirectory() && !entry.name.startsWith(".")
);
const skillsRootDir = rootDirs.find((entry) => entry.name === "skills");
const candidateSkillDirs = skillsRootDir
? (await fs.promises.readdir(path.join(urlExtractRoot, "skills"), { withFileTypes: true }))
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
.map((entry) => ({
name: entry.name,
sourcePath: path.join(urlExtractRoot, "skills", entry.name),
}))
: rootDirs.map((entry) => ({
name: entry.name,
sourcePath: path.join(urlExtractRoot, entry.name),
}));
if (candidateSkillDirs.length === 0) {
log(logId, "WARN", "No skill directory found after extracting skill url zip", {
userId,
cId,
skillUrl,
extractRoot: urlExtractRoot,
});
continue;
}
await fs.promises.mkdir(targetSkillsDir, { recursive: true });
for (const skillDir of candidateSkillDirs) {
const destSkillPath = path.join(targetSkillsDir, skillDir.name);
if (fs.existsSync(destSkillPath)) {
await removeDirIfExists(destSkillPath);
}
await moveDirectory(skillDir.sourcePath, destSkillPath);
updatedSkillSet.add(skillDir.name);
log(logId, "INFO", "Skill from url updated to workspace", {
userId,
cId,
skillUrl,
skillName: skillDir.name,
destSkillPath,
});
}
if (!updatedDirs.includes("skills")) {
updatedDirs.push("skills");
}
}
// 如果上传 zip 和 URL 技能都没找到有效目录,记录警告但不中断流程
if (updatedDirs.length === 0) {
log(logId, "WARN", "No valid skills or agents directories found", {
userId,
cId,
hasUploadedZip: !!file,
skillUrlsCount: normalizedSkillUrls.length,
});
}
const updatedSkills = Array.from(updatedSkillSet);
const message =
updatedDirs.length > 0
? `Workspace created successfully, ${updatedDirs.join(" and ")} updated`
: "Workspace created successfully (skills and agents directories not found)";
log(logId, "INFO", message, {
userId,
cId,
updatedDirs,
updatedSkills,
agentTypes: normalizedAgentTypes,
elapsedMs: Date.now() - startTime,
});
await syncAgents(userWorkspaceRoot);
return {
message,
workspaceRoot,
updatedSkills,
};
} catch (error) {
log(logId, "ERROR", "Failed to process workspace resources", {
userId,
cId,
error: error.message,
elapsedMs: Date.now() - startTime,
});
if (
error instanceof ValidationError ||
error instanceof FileError ||
error instanceof SystemError
) {
throw error;
}
throw new SystemError(`Failed to create workspace: ${error.message}`, {
userId,
cId,
});
} finally {
// 清理临时目录和临时 zip 文件
try {
for (const extractRoot of extractRoots) {
if (fs.existsSync(extractRoot)) {
await fs.promises.rm(extractRoot, { recursive: true, force: true });
}
}
} catch (e) {
log(logId, "WARN", "Failed to clean up temporary extracted zip", {
error: e.message,
});
}
// 清理下载的 zip 文件
for (const downloadedZipPath of downloadedZipPaths) {
try {
if (fs.existsSync(downloadedZipPath)) {
await fs.promises.unlink(downloadedZipPath);
}
} catch (e) {
log(logId, "WARN", "Failed to clean up downloaded skill zip file", {
downloadedZipPath,
error: e.message,
});
}
}
// 清理上传的 zip 文件
try {
if (file && file.path && fs.existsSync(file.path)) {
await fs.promises.unlink(file.path);
}
} catch (e) {
log(logId, "WARN", "Failed to clean up uploaded zip file", {
tempZipPath: file?.path,
error: e.message,
});
}
}
}
/**
* 推送技能到工作空间
* file 为 zip 压缩包,其中应包含 skills 目录skills 目录下为具体 skill 子目录
* skillUrls 为技能 zip 下载地址数组,解压后可为 skills/<skillName> 或直接 <skillName>
* 如有同名 skill 则覆盖,否则新增;不处理 agents 目录
* @param {string|number} userId
* @param {string|number} cId
* @param {Object|null} file multer 文件对象zip
* @param {string[]|string|undefined} skillUrls 技能 zip 下载地址数组
*/
async function pushSkillsToWorkspace(userId, cId, file, skillUrls) {
const startTime = Date.now();
const logId = `computer:${userId}:${cId}`;
const normalizedSkillUrls = normalizeSkillUrls(skillUrls);
const downloadedZipPaths = [];
const extractRoots = [];
if (!userId) {
throw new ValidationError("userId cannot be empty", { field: "userId" });
}
if (!cId) {
throw new ValidationError("cId cannot be empty", { field: "cId" });
}
if (!file && normalizedSkillUrls.length === 0) {
throw new ValidationError("file or skillUrls cannot both be empty", {
field: "file|skillUrls",
});
}
if (file) {
if (!file.path) {
throw new ValidationError("Uploaded file has no valid path", { field: "file.path" });
}
const ext = path.extname(file.originalname || file.filename || "").toLowerCase();
if (ext !== ".zip") {
throw new ValidationError("Only zip files are supported", {
field: "file",
originalName: file?.originalname,
});
}
}
const workspaceRoot = await ensureWorkspaceRoot(logId);
const tmpRoot = path.join(
workspaceRoot,
String(userId),
String(cId),
".tmp"
);
const userWorkspaceRoot = path.join(
workspaceRoot,
String(userId),
String(cId)
);
const { skillsDir: targetSkillsDir, agentTypes: normalizedAgentTypes } =
await ensurePrimaryAgentDirs(userWorkspaceRoot);
try {
if (!fs.existsSync(userWorkspaceRoot)) {
await fs.promises.mkdir(userWorkspaceRoot, { recursive: true });
}
if (!fs.existsSync(targetSkillsDir)) {
await fs.promises.mkdir(targetSkillsDir, { recursive: true });
}
if (!fs.existsSync(tmpRoot)) {
await fs.promises.mkdir(tmpRoot, { recursive: true });
}
const updatedSkills = [];
const updatedSkillSet = new Set();
// 处理上传 zip要求 zip 中包含 skills 目录
if (file) {
const extractRoot = path.join(
tmpRoot,
`skill_push_${Date.now()}_${Math.round(Math.random() * 1e6)}`
);
extractRoots.push(extractRoot);
await fs.promises.mkdir(extractRoot, { recursive: true });
log(logId, "DEBUG", "Start extracting skill zip file", { extractRoot });
await extractZip(file.path, extractRoot);
log(logId, "DEBUG", "Skill zip file extracted successfully", { extractRoot });
const skillsDir = await findDir(extractRoot, "skills");
if (!skillsDir) {
log(logId, "WARN", "skills directory not found in uploaded zip", {
userId,
cId,
extractRoot,
});
} else {
const skillEntries = await fs.promises.readdir(skillsDir, {
withFileTypes: true,
});
const skillDirs = skillEntries.filter(
(e) => e.isDirectory() && !e.name.startsWith(".")
);
if (skillDirs.length === 0) {
log(logId, "WARN", "skills directory in uploaded zip has no skill subdirectories", {
userId,
cId,
skillsDir,
});
} else {
for (const skillDir of skillDirs) {
const srcSkillPath = path.join(skillsDir, skillDir.name);
const destSkillPath = path.join(targetSkillsDir, skillDir.name);
if (fs.existsSync(destSkillPath)) {
await removeDirIfExists(destSkillPath);
}
await moveDirectory(srcSkillPath, destSkillPath);
updatedSkillSet.add(skillDir.name);
log(logId, "INFO", "skill pushed to workspace from uploaded zip", {
userId,
cId,
skillName: skillDir.name,
destSkillPath,
});
}
}
}
}
// 处理 skillUrls解压后可为 skills/<skillName> 或直接 <skillName>
for (let i = 0; i < normalizedSkillUrls.length; i += 1) {
const skillUrl = normalizedSkillUrls[i];
const downloadedZipPath = path.join(
tmpRoot,
`skill_push_url_${Date.now()}_${i}_${Math.round(Math.random() * 1e6)}.zip`
);
downloadedZipPaths.push(downloadedZipPath);
const urlExtractRoot = path.join(
tmpRoot,
`skill_push_url_extract_${Date.now()}_${i}_${Math.round(Math.random() * 1e6)}`
);
extractRoots.push(urlExtractRoot);
await fs.promises.mkdir(urlExtractRoot, { recursive: true });
log(logId, "INFO", "Start download skill zip for push from url", {
userId,
cId,
skillUrl,
});
await downloadUrlToFile(skillUrl, downloadedZipPath, logId);
await extractZip(downloadedZipPath, urlExtractRoot);
const extractedEntries = await fs.promises.readdir(urlExtractRoot, {
withFileTypes: true,
});
const rootDirs = extractedEntries.filter(
(entry) => entry.isDirectory() && !entry.name.startsWith(".")
);
const skillsRootDir = rootDirs.find((entry) => entry.name === "skills");
const candidateSkillDirs = skillsRootDir
? (await fs.promises.readdir(path.join(urlExtractRoot, "skills"), { withFileTypes: true }))
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
.map((entry) => ({
name: entry.name,
sourcePath: path.join(urlExtractRoot, "skills", entry.name),
}))
: rootDirs.map((entry) => ({
name: entry.name,
sourcePath: path.join(urlExtractRoot, entry.name),
}));
if (candidateSkillDirs.length === 0) {
log(logId, "WARN", "No skill directory found after extracting push skill url zip", {
userId,
cId,
skillUrl,
extractRoot: urlExtractRoot,
});
continue;
}
for (const skillDir of candidateSkillDirs) {
const destSkillPath = path.join(targetSkillsDir, skillDir.name);
if (fs.existsSync(destSkillPath)) {
await removeDirIfExists(destSkillPath);
}
await moveDirectory(skillDir.sourcePath, destSkillPath);
updatedSkillSet.add(skillDir.name);
log(logId, "INFO", "skill pushed to workspace from url zip", {
userId,
cId,
skillUrl,
skillName: skillDir.name,
destSkillPath,
});
}
}
for (const skillName of updatedSkillSet) {
updatedSkills.push(skillName);
}
const message =
updatedSkills.length > 0
? `Pushed ${updatedSkills.length} skills: ${updatedSkills.join(", ")}`
: "No valid skill directories found in file or skillUrls";
log(logId, "INFO", message, {
userId,
cId,
updatedSkills,
agentTypes: normalizedAgentTypes,
elapsedMs: Date.now() - startTime,
});
await syncAgents(userWorkspaceRoot);
return {
message,
workspaceRoot,
updatedSkills,
};
} catch (error) {
log(logId, "ERROR", "Failed to push skill to workspace", {
userId,
cId,
error: error.message,
elapsedMs: Date.now() - startTime,
});
if (
error instanceof ValidationError ||
error instanceof FileError ||
error instanceof SystemError
) {
throw error;
}
throw new SystemError(`Failed to push skill to workspace: ${error.message}`, {
userId,
cId,
});
} finally {
try {
for (const extractRoot of extractRoots) {
if (fs.existsSync(extractRoot)) {
await fs.promises.rm(extractRoot, { recursive: true, force: true });
}
}
} catch (e) {
log(logId, "WARN", "Failed to clean up temporary extracted zip", {
error: e.message,
});
}
for (const downloadedZipPath of downloadedZipPaths) {
try {
if (fs.existsSync(downloadedZipPath)) {
await fs.promises.unlink(downloadedZipPath);
}
} catch (e) {
log(logId, "WARN", "Failed to clean up downloaded skill zip file", {
downloadedZipPath,
error: e.message,
});
}
}
try {
if (file && file.path && fs.existsSync(file.path)) {
await fs.promises.unlink(file.path);
}
} catch (e) {
log(logId, "WARN", "Failed to clean up uploaded zip file", {
tempZipPath: file?.path,
error: e.message,
});
}
}
}
export { createWorkspace, pushSkillsToWorkspace };

View File

@@ -0,0 +1,403 @@
/**
* 环境变量工具模块
*
* 功能:
* - 从命令行参数加载环境变量
* - 支持通过命令行参数覆盖环境变量
* - 支持 --env-file 指定自定义配置文件
* - 处理 Windows 环境变量大小写不敏感问题
*
* 优先级:
* 1. 命令行参数 (最高)
* 2. 环境变量文件 (.env.production 等)
* 3. 系统环境变量
* 4. 默认值 (最低)
*/
import path from "path";
import fs from "fs-extra";
import dotenv from "dotenv";
/**
* 检测当前操作系统是否为 Windows
*
* @returns {boolean} 是否为 Windows 系统
*/
function isWindows() {
return process.platform === 'win32';
}
/**
* 规范化环境变量名
*
* Windows 环境变量不区分大小写,统一转为大写
*
* @param {string} name - 环境变量名
* @returns {string} 规范化后的变量名
*/
function normalizeEnvName(name) {
// Windows 环境变量不区分大小写,统一转为大写
if (isWindows()) {
return name.toUpperCase();
}
return name;
}
/**
* 获取环境变量值
*
* 支持 Windows 环境变量大小写不敏感
*
* @param {string} name - 环境变量名
* @param {*} defaultValue - 默认值
* @returns {*} 环境变量值或默认值
*/
function getEnv(name, defaultValue = undefined) {
const normalizedName = normalizeEnvName(name);
// 优先从 process.env 获取
if (process.env[normalizedName] !== undefined) {
return process.env[normalizedName];
}
// 回退到原始名称(兼容某些库)
if (name !== normalizedName && process.env[name] !== undefined) {
return process.env[name];
}
return defaultValue;
}
/**
* 获取布尔类型的环境变量值
*
* @param {string} name - 环境变量名
* @param {boolean} defaultValue - 默认值
* @returns {boolean} 布尔值
*/
function getBoolEnv(name, defaultValue = false) {
const value = getEnv(name);
if (value === undefined) {
return defaultValue;
}
// 转换为小写后比较
const lowerValue = String(value).toLowerCase();
return lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
}
/**
* 获取数字类型的环境变量值
*
* @param {string} name - 环境变量名
* @param {number} defaultValue - 默认值
* @returns {number|null} 数字值或 null如果解析失败
*/
function getNumberEnv(name, defaultValue = undefined) {
const value = getEnv(name);
if (value === undefined) {
return defaultValue;
}
const parsed = Number(value);
if (Number.isNaN(parsed)) {
console.warn(`Environment variable ${name} value "${value}" is not a valid number`);
return defaultValue;
}
return parsed;
}
/**
* 解析环境类型
*
* @param {string} env - 环境名称
* @returns {string} 标准化的环境名称
*/
function parseEnvType(env) {
if (!env) {
return 'production';
}
const normalizedEnv = env.toLowerCase().trim();
// 标准化环境名称
const envMap = {
'dev': 'development',
'test': 'test',
'prod': 'production',
'production': 'production',
'development': 'development',
'staging': 'staging',
};
return envMap[normalizedEnv] || 'production';
}
/**
* 加载环境文件
*
* 根据环境类型加载对应的 .env 文件
*
* @param {string} env - 环境类型
* @param {Object} options - 选项
* @param {string} [options.basePath] - 基础路径(默认当前工作目录)
* @returns {Object} 加载结果
*/
function loadEnvFile(env, options = {}) {
const basePath = options.basePath || process.cwd();
const envType = parseEnvType(env);
// 环境文件名映射
const envFileNames = [
`.env.${envType}`, // .env.production
`.env.${envType}.local`, // .env.production.local优先
'.env.local',
'.env',
];
const loadedFiles = [];
const result = {
loaded: false,
files: loadedFiles,
env: envType,
};
for (const fileName of envFileNames) {
const filePath = path.join(basePath, fileName);
if (fs.existsSync(filePath)) {
try {
// 使用 dotenv 解析环境文件
const parsed = dotenv.config({
path: filePath,
override: false, // 不覆盖已存在的环境变量
});
if (parsed.error) {
console.warn(`Load environment file ${fileName} failed: ${parsed.error.message}`);
} else {
loadedFiles.push(filePath);
result.loaded = true;
}
} catch (err) {
console.warn(`Load environment file ${fileName} failed: ${err.message}`);
}
}
}
if (loadedFiles.length > 0) {
console.log(`Loaded environment files: ${loadedFiles.join(', ')}`);
}
return result;
}
/**
* 加载自定义配置文件
*
* 支持 JSON 和 .env 格式的配置文件
*
* @param {string} configPath - 配置文件路径
* @returns {Object} 加载结果
*/
function loadCustomConfigFile(configPath) {
const result = {
loaded: false,
path: configPath,
data: {},
};
if (!configPath) {
return result;
}
if (!fs.existsSync(configPath)) {
console.warn(`Configuration file does not exist: ${configPath}`);
return result;
}
try {
const ext = path.extname(configPath).toLowerCase();
if (ext === '.json') {
// JSON 格式
const content = fs.readFileSync(configPath, 'utf8');
result.data = JSON.parse(content);
result.loaded = true;
} else if (ext === '.env' || ext === '') {
// .env 格式
const dotenvResult = dotenv.config({
path: configPath,
override: true,
});
if (!dotenvResult.error) {
result.loaded = true;
// 从 process.env 提取加载的值
Object.keys(dotenvResult.parsed || {}).forEach(key => {
result.data[key] = process.env[key];
});
}
} else {
console.warn(`不支持的配置文件格式: ${ext}`);
}
console.log(`Loaded configuration file: ${configPath}`);
} catch (err) {
console.error(`Load configuration file failed: ${err.message}`);
}
return result;
}
/**
* 从命令行参数加载环境变量
*
* 解析 process.argv提取环境变量设置
*
* @returns {Object} 提取的环境变量对象
*/
function loadEnvFromArgv() {
const result = {};
// 命令行参数模式: --<name>=<value> 或 --<name> <value>
const argv = process.argv.slice(2);
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
// 检查是否以 -- 开头
if (arg.startsWith('--')) {
let key = arg.slice(2);
let value = null;
// 检查是否有 = 分隔符
if (key.includes('=')) {
const parts = key.split('=');
key = parts[0];
value = parts.slice(1).join('=');
} else {
// 检查下一个参数是否是值(非以 -- 开头的参数)
if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
value = argv[i + 1];
i++; // 跳过下一个参数
} else {
value = 'true'; // 布尔标志
}
}
// 排除已知的 CLI 选项
const cliOptions = ['env', 'port', 'config', 'force', 'help', 'version'];
if (!cliOptions.includes(key)) {
result[normalizeEnvName(key)] = value;
}
}
}
return result;
}
/**
* 应用环境变量到 process.env
*
* 优先级: CLI 参数 > 自定义配置 > 环境文件 > 系统环境变量
*
* @param {Object} options - 选项
* @param {string} [options.env] - 环境类型
* @param {string} [options.config] - 自定义配置文件路径
* @param {string} [options.port] - 端口号
* @param {string} [options.basePath] - 基础路径
* @returns {Object} 最终环境配置
*/
function applyEnv(options = {}) {
const { env, config, port, basePath } = options;
// 1. 加载环境文件
const envResult = loadEnvFile(env, { basePath });
// 2. 加载自定义配置文件
const configResult = loadCustomConfigFile(config);
// 3. 设置环境类型
const envType = envResult.env;
process.env.NODE_ENV = envType;
// 4. 应用端口
if (port) {
process.env.PORT = String(port);
}
// 5. 应用自定义配置文件的值
if (configResult.loaded) {
Object.entries(configResult.data).forEach(([key, value]) => {
const normalizedKey = normalizeEnvName(key);
// CLI 参数优先级最高,不覆盖
if (process.env[normalizedKey] === undefined) {
process.env[normalizedKey] = value;
}
});
}
// 6. 应用命令行参数
const argvEnv = loadEnvFromArgv();
Object.entries(argvEnv).forEach(([key, value]) => {
process.env[key] = value;
});
// 返回最终配置
return {
env: envType,
port: process.env.PORT,
config: configResult.loaded ? configResult.path : null,
files: envResult.files,
};
}
/**
* 创建环境配置摘要
*
* 用于日志输出
*
* @returns {string} 配置摘要字符串
*/
function createEnvSummary() {
const summary = {
env: process.env.NODE_ENV || 'unknown',
port: process.env.PORT || 'default',
platform: process.platform,
nodeVersion: process.version,
};
return JSON.stringify(summary, null, 2);
}
// ESM 导出
export {
isWindows,
normalizeEnvName,
getEnv,
getBoolEnv,
getNumberEnv,
parseEnvType,
loadEnvFile,
loadCustomConfigFile,
loadEnvFromArgv,
applyEnv,
createEnvSummary,
};
// 如果直接运行此文件,显示当前环境配置
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/").replace(/^.*[\/\\]/, ""))) {
console.log('\nCurrent environment configuration:\n');
console.log(createEnvSummary());
console.log('\nDetailed environment variables:\n');
console.log('NODE_ENV:', process.env.NODE_ENV);
console.log('PORT:', process.env.PORT);
console.log('CONFIG_FILE:', process.env.CONFIG_FILE);
console.log('');
}

View File

@@ -0,0 +1,339 @@
import { log } from "../log/logUtils.js";
import path from "path";
import { sanitizeSensitivePaths } from "../common/sensitiveUtils.js";
/**
* 构建错误解析器
* 用于解析构建错误并提供用户友好的错误信息和修复建议
*/
class BuildErrorParser {
constructor() {
// 常见错误类型和对应的修复建议
this.errorPatterns = [
{
name: "Regular expression HTML tag escape error",
pattern: /html\.match\(\/<title>\(\.\*\?\)<\/title>\/i\)/,
suggestion:
"In regular expressions, the angle brackets of HTML tags need to be escaped. Please modify `</title>` to `</title>`",
example: {
wrong: "html.match(/<title>(.*?)</title>/i)",
correct: "html.match(/<title>(.*?)<\\/title>/i)",
},
},
{
name: "JavaScript syntax error",
pattern: /Parse error|SyntaxError|Unexpected token/,
suggestion: "Check the code syntax, ensure that the parentheses, quotes, semicolons, etc. are correctly paired",
example: null,
},
{
name: "Module import error",
pattern: /Cannot resolve module|Module not found/,
suggestion: "Check the import path to ensure that the module file exists",
example: null,
},
{
name: "TypeScript type error",
pattern: /Type error|Type '.*' is not assignable/,
suggestion: "Check the variable type definition, ensure that the type matches",
example: null,
},
{
name: "Dependency missing error",
pattern: /Cannot find module|Module not found/,
suggestion: "Run `pnpm install` to install the missing dependency package",
example: null,
},
];
}
/**
* 解析构建错误信息并生成用户友好的指导信息
* @param {string} errorMessage - 构建错误消息
* @param {string} projectId - 项目ID
* @returns {string} 用户友好的错误指导信息
*/
parseBuildError(errorMessage, projectId) {
try {
log(projectId, "INFO", "Start parsing build error", { errorMessage });
// 提取文件路径和位置信息
const fileInfo = this.extractFileInfo(errorMessage);
// 提取错误类型和描述
const errorDetails = this.extractErrorDetails(errorMessage);
// 匹配错误模式并提供建议
const suggestions = this.getErrorSuggestions(errorMessage);
// 生成用户友好的错误指导信息
const userFriendlyMessage = this.generateUserFriendlyMessage(
errorDetails,
fileInfo,
suggestions,
errorMessage
);
log(projectId, "INFO", "Build error parsing completed", {
errorType: errorDetails.type,
fileName: fileInfo?.path ? path.basename(fileInfo.path) : null,
suggestionsCount: suggestions.length,
});
return userFriendlyMessage;
} catch (error) {
log(projectId, "ERROR", "Exception occurred when parsing build error", {
error: error.message,
stack: error.stack,
});
return "Build failed, please check the detailed error information in the build log, or contact technical support.";
}
}
/**
* 提取文件信息
* @param {string} errorMessage - 错误消息
* @returns {Object|null} 文件信息
*/
extractFileInfo(errorMessage) {
// 匹配文件路径和行号
const fileMatch = errorMessage.match(/file:\s*([^\n]+):(\d+):(\d+)/);
if (!fileMatch) {
return null;
}
const [, filePath, lineNumber, columnNumber] = fileMatch;
return {
path: filePath.trim(),
line: parseInt(lineNumber),
column: parseInt(columnNumber),
relativePath: this.getRelativePath(filePath.trim()),
};
}
/**
* 提取错误详情
* @param {string} errorMessage - 错误消息
* @returns {Object} 错误详情
*/
extractErrorDetails(errorMessage) {
// 匹配错误类型
const errorTypeMatch = errorMessage.match(
/(Parse error|SyntaxError|TypeError|ReferenceError|Unexpected token)/
);
const errorType = errorTypeMatch ? errorTypeMatch[1] : "Build error";
// 提取错误描述
let errorMessage_clean = errorMessage;
// 尝试提取更具体的错误描述
const descriptionMatch = errorMessage.match(
/(?:Parse error|SyntaxError|TypeError|ReferenceError)[^:]*:\s*([^\n]+)/
);
if (descriptionMatch) {
errorMessage_clean = descriptionMatch[1].trim();
} else {
// 提取第一行有意义的错误信息
const lines = errorMessage.split("\n");
for (const line of lines) {
if (line.trim() && !line.includes("file:") && !line.includes("at ")) {
errorMessage_clean = line.trim();
break;
}
}
}
return {
type: errorType,
message: errorMessage_clean,
};
}
/**
* 获取错误修复建议
* @param {string} errorMessage - 错误消息
* @returns {Array} 建议列表
*/
getErrorSuggestions(errorMessage) {
const suggestions = [];
// 匹配预定义的错误模式
for (const pattern of this.errorPatterns) {
if (pattern.pattern.test(errorMessage)) {
suggestions.push({
type: pattern.name,
message: pattern.suggestion,
priority: "high",
example: pattern.example,
});
}
}
// 如果没有匹配到特定模式,提供通用建议
if (suggestions.length === 0) {
suggestions.push({
type: "General suggestion",
message: "Please carefully check the files and line numbers mentioned in the error information, ensure that the code syntax is correct",
priority: "medium",
});
}
// 添加文件检查建议
const fileInfo = this.extractFileInfo(errorMessage);
if (fileInfo) {
const fileName = path.basename(fileInfo.path);
suggestions.push({
type: "File check",
message: `Please check the code near line ${fileInfo.line} column ${fileInfo.column} in file ${fileName}`,
priority: "high",
});
}
return suggestions;
}
/**
* 提取代码上下文
* @param {string} errorMessage - 错误消息
* @returns {Object|null} 代码上下文
*/
extractCodeContext(errorMessage) {
const lines = errorMessage.split("\n");
const context = {
before: [],
error: null,
after: [],
};
let foundErrorLine = false;
let lineNumber = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 匹配行号格式: "82: const titleMatch = html.match(/<title>(.*?)</title>/i)"
const lineMatch = line.match(/^\s*(\d+):\s*(.*)$/);
if (lineMatch) {
const [, num, content] = lineMatch;
lineNumber = parseInt(num);
if (!foundErrorLine) {
context.before.push({ line: lineNumber, content: content.trim() });
} else {
context.after.push({ line: lineNumber, content: content.trim() });
}
}
// 查找错误标记行 "^"
if (line.includes("^") && !foundErrorLine) {
foundErrorLine = true;
// 错误行是上一行
if (context.before.length > 0) {
const errorLine = context.before.pop();
context.error = errorLine;
}
}
}
// 限制上下文行数
context.before = context.before.slice(-3); // 最多3行
context.after = context.after.slice(0, 3); // 最多3行
return context.before.length > 0 ||
context.error ||
context.after.length > 0
? context
: null;
}
/**
* 获取相对路径
* @param {string} absolutePath - 绝对路径
* @returns {string} 相对路径
*/
getRelativePath(absolutePath) {
// 脱敏处理:移除敏感路径信息
const sanitizedPath = this.sanitizePath(absolutePath);
// 尝试提取项目相关的相对路径
const projectMatch = sanitizedPath.match(
/project_workspace\/[^\/]+\/(.+)$/
);
if (projectMatch) {
return projectMatch[1];
}
// 如果无法提取,返回文件名
const pathParts = sanitizedPath.split("/");
return pathParts[pathParts.length - 1];
}
/**
* 脱敏路径信息
* @param {string} path - 原始路径
* @returns {string} 脱敏后的路径
*/
sanitizePath(path) {
return sanitizeSensitivePaths(path);
}
/**
* 生成用户友好的错误指导信息
* @param {Object} errorDetails - 错误详情
* @param {Object} fileInfo - 文件信息
* @param {Array} suggestions - 修复建议
* @param {string} originalError - 原始错误信息
* @returns {string} 用户友好的错误指导信息
*/
generateUserFriendlyMessage(
errorDetails,
fileInfo,
suggestions,
originalError
) {
let message = "Build failed!\n\n";
// 添加错误类型和基本描述
message += `Error type: ${errorDetails.type}\n`;
message += `Error description: ${errorDetails.message}\n\n`;
// 添加文件位置信息
if (fileInfo) {
const fileName = path.basename(fileInfo.path);
message += `📍 Error location:\n`;
message += ` File: ${fileName}\n`;
message += ` Line: ${fileInfo.line}, Column: ${fileInfo.column}\n\n`;
}
// 添加修复建议
if (suggestions.length > 0) {
message += `🔧 Repair suggestions:\n`;
suggestions.forEach((suggestion, index) => {
message += ` ${index + 1}. ${suggestion.message}\n`;
// 如果有代码示例,添加到建议中
if (suggestion.example) {
message += ` Wrong code: ${suggestion.example.wrong}\n`;
message += ` Correct code: ${suggestion.example.correct}\n`;
}
});
message += "\n";
}
// 添加通用指导
message += `💡 Operation steps:\n`;
message += ` 1. Please modify the code according to the above suggestions\n`;
message += ` 2. Save the file and rebuild the project\n`;
message += ` 3. If the problem still exists, please check other related files\n\n`;
// 添加联系信息
message += `📞 Need help?\n`;
message += ` If you cannot solve this problem, please contact technical support and provide complete error information.`;
return message;
}
}
export default BuildErrorParser;

View File

@@ -0,0 +1,7 @@
// 错误码常量ESM
export default {
PROJECT_STARTING: "PROJECT_STARTING",
INVALID_SCRIPT_TYPE: "INVALID_SCRIPT_TYPE",
SYNTAX_CHECK_FAILED: "SYNTAX_CHECK_FAILED",
DEV_SERVER_START_FAILED: "DEV_SERVER_START_FAILED",
};

View File

@@ -0,0 +1,335 @@
// 错误处理工具模块ESM
import { log, getCSTTimestampString } from "../log/logUtils.js";
import { sanitizeSensitivePaths } from "../common/sensitiveUtils.js";
import config from "../../appConfig/index.js";
// 错误类型枚举
const ERROR_TYPES = {
VALIDATION_ERROR: "VALIDATION_ERROR", // 验证错误
BUSINESS_ERROR: "BUSINESS_ERROR", // 业务逻辑错误
SYSTEM_ERROR: "SYSTEM_ERROR", // 系统错误
NETWORK_ERROR: "NETWORK_ERROR", // 网络错误
FILE_ERROR: "FILE_ERROR", // 文件操作错误
PROCESS_ERROR: "PROCESS_ERROR", // 进程操作错误
PERMISSION_ERROR: "PERMISSION_ERROR", // 权限错误
RESOURCE_ERROR: "RESOURCE_ERROR", // 资源错误
UNKNOWN_ERROR: "UNKNOWN_ERROR", // 未知错误
};
// HTTP状态码映射
const HTTP_STATUS_MAP = {
[ERROR_TYPES.VALIDATION_ERROR]: 400,
[ERROR_TYPES.BUSINESS_ERROR]: 400,
[ERROR_TYPES.PERMISSION_ERROR]: 403,
[ERROR_TYPES.RESOURCE_ERROR]: 404,
[ERROR_TYPES.SYSTEM_ERROR]: 500,
[ERROR_TYPES.NETWORK_ERROR]: 502,
[ERROR_TYPES.FILE_ERROR]: 500,
[ERROR_TYPES.PROCESS_ERROR]: 500,
[ERROR_TYPES.UNKNOWN_ERROR]: 500,
};
// 自定义错误类
class AppError extends Error {
constructor(
message,
type = ERROR_TYPES.UNKNOWN_ERROR,
statusCode = null,
details = null
) {
super(message);
this.name = "AppError";
this.type = type;
this.statusCode = statusCode || HTTP_STATUS_MAP[type] || 500;
this.details = details;
this.timestamp = getCSTTimestampString(); // 东八区时间
this.isOperational = true; // 标记为可操作的错误
// 保持堆栈跟踪
Error.captureStackTrace(this, this.constructor);
}
}
// 验证错误类
class ValidationError extends AppError {
constructor(message, details = null) {
super(message, ERROR_TYPES.VALIDATION_ERROR, 400, details);
this.name = "ValidationError";
}
}
// 业务错误类
class BusinessError extends AppError {
constructor(message, details = null) {
super(message, ERROR_TYPES.BUSINESS_ERROR, 400, details);
this.name = "BusinessError";
}
}
// 系统错误类
class SystemError extends AppError {
constructor(message, details = null) {
super(message, ERROR_TYPES.SYSTEM_ERROR, 500, details);
this.name = "SystemError";
}
}
// 资源错误类
class ResourceError extends AppError {
constructor(message, details = null) {
super(message, ERROR_TYPES.RESOURCE_ERROR, 404, details);
this.name = "ResourceError";
}
}
// 权限错误类
class PermissionError extends AppError {
constructor(message, details = null) {
super(message, ERROR_TYPES.PERMISSION_ERROR, 403, details);
this.name = "PermissionError";
}
}
// 文件错误类
class FileError extends AppError {
constructor(message, details = null) {
super(message, ERROR_TYPES.FILE_ERROR, 500, details);
this.name = "FileError";
}
}
// 进程错误类
class ProcessError extends AppError {
constructor(message, details = null) {
super(message, ERROR_TYPES.PROCESS_ERROR, 500, details);
this.name = "ProcessError";
}
}
// 脱敏错误堆栈信息
function sanitizeErrorStack(stack) {
return sanitizeSensitivePaths(stack);
}
// 脱敏错误详情信息
function sanitizeErrorDetails(details) {
if (!details) {
return details;
}
// 如果是字符串,直接脱敏
if (typeof details === "string") {
return sanitizeSensitivePaths(details);
}
// 如果是对象,递归处理
if (typeof details === "object") {
const sanitized = {};
for (const [key, value] of Object.entries(details)) {
if (typeof value === "string") {
sanitized[key] = sanitizeSensitivePaths(value);
} else if (typeof value === "object") {
sanitized[key] = sanitizeErrorDetails(value);
} else {
sanitized[key] = value;
}
}
return sanitized;
}
return details;
}
// 统一错误响应格式
function formatErrorResponse(error, requestId = null) {
const response = {
success: false,
code: error.code || (error.details && error.details.code) || "UNKNOWN_ERROR",
error: {
type: error.type || ERROR_TYPES.UNKNOWN_ERROR,
message: error.message || "Unknown error",
timestamp:
error.timestamp || getCSTTimestampString(), // CST time
requestId: requestId,
},
};
// 构建错误已经包含用户友好的指导信息,无需额外处理
// 在开发环境下添加更多调试信息
if (process.env.NODE_ENV === "development") {
response.error.stack = sanitizeErrorStack(error.stack);
response.error.details = sanitizeErrorDetails(error.details);
} else if (error.details) {
// 生产环境下只添加必要的详细信息
response.error.details = sanitizeErrorDetails(error.details);
}
return response;
}
// 错误处理中间件
function errorHandler(err, req, res, next) {
let error = err;
const requestId = req.requestId || "unknown";
const projectId = req.body?.projectId || req.query?.projectId || "default";
// 如果不是AppError实例转换为AppError
if (!(error instanceof AppError)) {
// 处理特定类型的错误
if (error.name === "ValidationError") {
error = new ValidationError(error.message, error.details);
} else if (error.name === "MulterError") {
if (error.code === "LIMIT_FILE_SIZE") {
const maxMb =
Math.round((config.UPLOAD_MAX_FILE_SIZE_BYTES / 1024 / 1024) * 10) /
10;
error = new ValidationError("File size exceeds limit", {
maxSize: `${maxMb}MB`,
});
} else if (error.code === "LIMIT_FILE_COUNT") {
error = new ValidationError("File count exceeds limit");
} else if (error.code === "LIMIT_UNEXPECTED_FILE") {
error = new ValidationError(
"File field name error, please use 'file' field to upload file",
{
expectedField: "file",
receivedField: error.field,
}
);
} else if (error.code === "LIMIT_PART_COUNT") {
error = new ValidationError("Form field count exceeds limit");
} else if (error.code === "LIMIT_FIELD_KEY") {
error = new ValidationError("Field name length exceeds limit");
} else if (error.code === "LIMIT_FIELD_VALUE") {
error = new ValidationError("Field value length exceeds limit");
} else if (error.code === "LIMIT_FIELD_COUNT") {
error = new ValidationError("Form field count exceeds limit");
} else {
error = new ValidationError("File upload error: " + error.message, {
code: error.code,
field: error.field,
});
}
} else if (error.code === "ENOENT") {
error = new ResourceError("File or directory not found");
} else if (error.code === "EACCES") {
error = new PermissionError("Permission denied");
} else if (error.code === "ECONNREFUSED") {
error = new SystemError("Connection refused", { code: error.code });
} else {
// 未知错误转换为系统错误
error = new SystemError(error.message || "Internal server error", {
originalError: error.name,
code: error.code,
});
}
}
// 记录错误日志
const logLevel = error.statusCode >= 500 ? "ERROR" : "WARN";
log(projectId, logLevel, `Error handling: ${error.message}`, {
requestId,
errorType: error.type,
statusCode: error.statusCode,
url: req.originalUrl,
method: req.method,
userAgent: req.headers["user-agent"],
ip: req.ip,
stack: error.stack,
details: error.details,
});
// 发送错误响应
const errorResponse = formatErrorResponse(error, requestId);
res.status(error.statusCode).json(errorResponse);
}
// 404处理中间件
function notFoundHandler(req, res, next) {
const error = new ResourceError(`Path not found: ${req.originalUrl}`);
const requestId = req.requestId || "unknown";
log("default", "WARN", `404 error: ${req.originalUrl}`, {
requestId,
method: req.method,
ip: req.ip,
userAgent: req.headers["user-agent"],
});
const errorResponse = formatErrorResponse(error, requestId);
res.status(404).json(errorResponse);
}
// 异步错误包装器
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
// 错误分类函数
function classifyError(error) {
if (error instanceof AppError) {
return error.type;
}
// 根据错误特征分类
if (error.code === "ENOENT") return ERROR_TYPES.RESOURCE_ERROR;
if (error.code === "EACCES") return ERROR_TYPES.PERMISSION_ERROR;
if (error.code === "ECONNREFUSED") return ERROR_TYPES.NETWORK_ERROR;
if (error.name === "ValidationError") return ERROR_TYPES.VALIDATION_ERROR;
return ERROR_TYPES.UNKNOWN_ERROR;
}
// 错误统计
const errorStats = {
counts: new Map(),
lastReset: Date.now(),
};
function recordError(error) {
const type = classifyError(error);
const count = errorStats.counts.get(type) || 0;
errorStats.counts.set(type, count + 1);
}
function getErrorStats() {
return {
counts: Object.fromEntries(errorStats.counts),
lastReset: errorStats.lastReset,
totalErrors: Array.from(errorStats.counts.values()).reduce(
(sum, count) => sum + count,
0
),
};
}
function resetErrorStats() {
errorStats.counts.clear();
errorStats.lastReset = Date.now();
}
export {
ERROR_TYPES,
AppError,
ValidationError,
BusinessError,
SystemError,
ResourceError,
PermissionError,
FileError,
ProcessError,
formatErrorResponse,
errorHandler,
notFoundHandler,
asyncHandler,
classifyError,
sanitizeSensitivePaths,
sanitizeErrorStack,
sanitizeErrorDetails,
recordError,
getErrorStats,
resetErrorStats,
};

View File

@@ -0,0 +1,199 @@
import fs from "fs";
import path from "path";
import { getLogDir, getCSTDateString } from "./logUtils.js";
import logCacheManager from "./logCacheManager.js";
import { SystemError } from "../error/errorHandler.js";
import { sanitizeSensitivePaths } from "../common/sensitiveUtils.js";
/**
* 从日志文件读取内容(支持缓存)
* @param {string} projectId 项目ID
* @param {string} logFileName 日志文件名
* @param {number} arrayStartIndex 数组起始索引从0开始
* @param {number} startIndex 用户视角起始行号从1开始
* @returns {Object} 日志读取结果
*/
function readLogFileWithCache(projectId, logFileName, arrayStartIndex, startIndex) {
try {
let lines;
let cacheHit = false;
let fileTooLarge = false;
const logDir = getLogDir(String(projectId));
const logFilePath = path.join(logDir, logFileName);
// 尝试从缓存获取
if (logCacheManager.isEnabled()) {
const cached = logCacheManager.get(String(projectId), logFilePath);
if (cached) {
lines = cached.lines;
cacheHit = true;
}
}
// 如果缓存未命中,从文件读取
if (!lines) {
// 检查文件大小
const stats = fs.statSync(logFilePath);
const fileSize = stats.size;
const logContent = fs.readFileSync(logFilePath, "utf8");
lines = logContent.split("\n");
// 更新缓存(如果文件不超过大小限制)
if (logCacheManager.isEnabled()) {
const cached = logCacheManager.set(String(projectId), logFilePath, logContent);
if (!cached && fileSize > logCacheManager.maxFileSize) {
fileTooLarge = true;
}
}
}
// 从指定行号开始截取到文件结束
const relevantLines = lines.slice(arrayStartIndex);
// 构建返回的日志数据,包含行号和内容
const logs = relevantLines.map((content, index) => ({
line: startIndex + index, // 行号从1开始
content: sanitizeSensitivePaths(content), // 对日志内容进行脱敏处理
}));
return {
success: true,
message: cacheHit ? "Get log successfully (cache)" : fileTooLarge ? "Get log successfully (file too large, not cached)" : "Get log successfully",
logs: logs,
totalLines: lines.length,
startIndex: startIndex,
cacheHit: cacheHit, // 标记是否命中缓存
fileTooLarge: fileTooLarge, // 标记文件是否过大
logFileName: logFileName, // 日志文件名
};
} catch (error) {
throw new SystemError("Failed to read log file", {
projectId,
logFileName: logFileName,
error: error.message,
});
}
}
/**
* 获取开发服务器日志
* @param {string} projectId 项目ID
* @param {number} startIndex 起始行号从1开始用户视角
* @returns {Object} 日志查询结果
*/
async function getDevLogFromMainLog(projectId, startIndex = 1) {
// 将用户行号从1开始转换为数组索引从0开始
const arrayStartIndex = Math.max(0, startIndex - 1);
// 获取项目日志目录
const logDir = getLogDir(String(projectId));
if (!fs.existsSync(logDir)) {
return {
success: true,
message: "Log directory does not exist",
logs: [],
totalLines: 0,
startLine: startIndex,
};
}
// 查找当日主日志文件(仅匹配 dev-YYYY-MM-DD.log
const files = fs.readdirSync(logDir);
const today = getCSTDateString(); // 格式: YYYY-MM-DD (东八区)
const targetFileName = `dev-${today}.log`;
const mainFiles = files.filter((file) => file === targetFileName);
if (mainFiles.length === 0) {
return {
success: true,
message: "Main log file not found",
logs: [],
totalLines: 0,
startLine: startIndex,
};
}
// 按文件名排序,取最新的文件(文件名包含日期)
mainFiles.sort((a, b) => {
// 提取文件名中的日期部分进行比较
const dateA = a.match(/dev-(.+)\.log/)?.[1];
const dateB = b.match(/dev-(.+)\.log/)?.[1];
if (!dateA || !dateB) return 0;
return dateB.localeCompare(dateA); // 降序排列,最新的在前
});
const latestMainFile = mainFiles[0];
return readLogFileWithCache(projectId, latestMainFile, arrayStartIndex, startIndex);
}
/**
* 获取开发服务器日志
* @param {string} projectId 项目ID
* @param {number} startIndex 起始行号从1开始用户视角
* @returns {Object} 日志查询结果
*/
async function getDevLogFromTempLog(projectId, startIndex = 1) {
// 将用户行号从1开始转换为数组索引从0开始
const arrayStartIndex = Math.max(0, startIndex - 1);
// 获取项目日志目录
const logDir = getLogDir(String(projectId));
if (!fs.existsSync(logDir)) {
return {
success: true,
message: "Log directory does not exist",
logs: [],
totalLines: 0,
startLine: startIndex,
};
}
// 查找所有临时日志文件
const files = fs.readdirSync(logDir);
const tempFiles = files.filter(
(file) => file.startsWith("dev-temp-") && file.endsWith(".log")
);
if (tempFiles.length === 0) {
return {
success: true,
message: "Temporary log file not found",
logs: [],
totalLines: 0,
startLine: startIndex,
};
}
// 按文件名排序,取最新的文件(文件名包含时间戳)
tempFiles.sort((a, b) => {
// 提取文件名中的时间戳进行比较
const timestampA = a.match(/dev-temp-(\d+)\.log/)?.[1];
const timestampB = b.match(/dev-temp-(\d+)\.log/)?.[1];
return Number(timestampB) - Number(timestampA); // 降序排列,最新的在前
});
const latestTempFile = tempFiles[0];
return readLogFileWithCache(projectId, latestTempFile, arrayStartIndex, startIndex);
}
/**
* 获取开发服务器日志
* @param {string} projectId 项目ID
* @param {number} startIndex 起始行号从1开始用户视角
* @returns {Object} 日志查询结果
*/
async function getDevLog(projectId, startIndex = 1, logType) {
if (logType === "main") {
return await getDevLogFromMainLog(projectId, startIndex);
} else {
return await getDevLogFromTempLog(projectId, startIndex);
}
}
export { getDevLog, getDevLogFromMainLog, getDevLogFromTempLog };

View File

@@ -0,0 +1,256 @@
// 日志缓存管理器ESM
import fs from "fs";
import path from "path";
import config from "../../appConfig/index.js";
/**
* 日志缓存管理器
* 功能:
* 1. 缓存整个日志文件内容
* 2. 每个子项目只缓存一个文件dev日志
* 3. 支持缓存过期时间
* 4. 提供缓存的增删改查接口
*/
class LogCacheManager {
constructor() {
// 缓存存储结构:{ projectId: { lines: Array, totalLines: number, timestamp: number, filePath: string } }
// 只保存 lines 数组,不保存 content 字符串,节省内存
this.cache = new Map();
// 从 config 对象读取配置
this.enabled = config.LOG_CACHE_ENABLED;
this.cacheDuration = config.LOG_CACHE_DURATION;
this.maxCacheEntries = config.LOG_CACHE_MAX_ENTRIES;
this.maxFileSize = config.LOG_CACHE_MAX_FILE_SIZE;
// 定期清理过期缓存(每分钟检查一次)
if (this.enabled) {
this.cleanupInterval = setInterval(() => {
this._cleanupExpiredCache();
}, 60000);
}
}
/**
* 判断缓存是否启用
*/
isEnabled() {
return this.enabled;
}
/**
* 获取缓存
* @param {string} projectId 项目ID
* @param {string} filePath 文件路径
* @returns {Object|null} 缓存数据或null
*/
get(projectId, filePath) {
if (!this.enabled) {
return null;
}
const cacheKey = String(projectId);
const cached = this.cache.get(cacheKey);
if (!cached) {
return null;
}
// 检查文件路径是否匹配
// 如果不匹配(比如跨天了,从 dev-2025-11-20.log 变成 dev-2025-11-21.log
// 删除旧缓存,释放内存
if (cached.filePath !== filePath) {
this.cache.delete(cacheKey);
return null;
}
// 缓存命中时续期(更新时间戳)
// 即使过期了,只要被访问就续期,不删除
// 只有定期清理任务才会删除长期未访问的缓存
const now = Date.now();
cached.timestamp = now;
return {
lines: cached.lines,
totalLines: cached.totalLines,
timestamp: cached.timestamp,
};
}
/**
* 设置缓存
* @param {string} projectId 项目ID
* @param {string} filePath 文件路径
* @param {string} content 文件内容
* @returns {boolean} 是否成功缓存
*/
set(projectId, filePath, content) {
if (!this.enabled) {
return false;
}
// 检查文件大小限制(字节)
const contentSize = Buffer.byteLength(content, 'utf8');
if (contentSize > this.maxFileSize) {
console.log(`Log file too large, not cached: ${projectId}, size: ${(contentSize / 1024 / 1024).toFixed(2)}MB, limit: ${(this.maxFileSize / 1024 / 1024).toFixed(2)}MB`);
return false;
}
const cacheKey = String(projectId);
// 检查缓存数量限制
if (!this.cache.has(cacheKey) && this.cache.size >= this.maxCacheEntries) {
// 删除最旧的缓存
const oldestKey = this._findOldestCacheKey();
if (oldestKey) {
this.cache.delete(oldestKey);
}
}
// 分割行并缓存(只保存 lines不保存 content节省内存
const lines = content.split("\n");
this.cache.set(cacheKey, {
lines,
totalLines: lines.length,
timestamp: Date.now(),
filePath,
});
return true;
}
/**
* 删除缓存
* @param {string} projectId 项目ID
*/
delete(projectId) {
const cacheKey = String(projectId);
this.cache.delete(cacheKey);
}
/**
* 清理所有缓存
*/
clear() {
this.cache.clear();
}
/**
* 清理过期缓存
*/
_cleanupExpiredCache() {
const now = Date.now();
const expiredKeys = [];
for (const [key, value] of this.cache.entries()) {
if (now - value.timestamp > this.cacheDuration) {
expiredKeys.push(key);
}
}
expiredKeys.forEach((key) => {
this.cache.delete(key);
});
if (expiredKeys.length > 0) {
console.log(`Cleaned ${expiredKeys.length} expired log caches`);
}
}
/**
* 找到最旧的缓存key
*/
_findOldestCacheKey() {
let oldestKey = null;
let oldestTimestamp = Infinity;
for (const [key, value] of this.cache.entries()) {
if (value.timestamp < oldestTimestamp) {
oldestTimestamp = value.timestamp;
oldestKey = key;
}
}
return oldestKey;
}
/**
* 获取缓存统计信息
*/
getStats() {
// 计算总缓存大小和最大文件大小
let totalCacheSize = 0;
let maxFileSizeInCache = 0;
for (const [key, value] of this.cache.entries()) {
if (value.lines && Array.isArray(value.lines)) {
let currentFileSize = 0;
// 计算每个缓存项的大小(所有行的字节数总和)
for (const line of value.lines) {
const lineSize = Buffer.byteLength(line, 'utf8');
currentFileSize += lineSize;
totalCacheSize += lineSize;
}
// 更新最大文件大小
if (currentFileSize > maxFileSizeInCache) {
maxFileSizeInCache = currentFileSize;
}
}
}
return {
enabled: this.enabled,
cacheSize: this.cache.size,
maxCacheEntries: this.maxCacheEntries,
cacheDuration: this.cacheDuration,
maxFileSizeMB: (maxFileSizeInCache / 1024 / 1024).toFixed(2),
totalCacheSizeMB: (totalCacheSize / 1024 / 1024).toFixed(2),
NODE_ENV: config.NODE_ENV,
LOG_CACHE_ENABLED: config.LOG_CACHE_ENABLED,
};
}
/**
* 销毁管理器(清理定时器和缓存)
* 用于服务优雅关闭时
*/
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
this.clear();
}
}
// 使用延迟初始化的单例模式
// 在第一次访问时才创建实例,此时环境变量已经完全加载
let instance = null;
function getLogCacheManager() {
if (!instance) {
instance = new LogCacheManager();
if (instance.enabled) {
console.log("Log cache enabled");
}
}
return instance;
}
// 创建代理对象,自动转发所有方法调用到实际实例
const logCacheManager = new Proxy({}, {
get(target, prop) {
const manager = getLogCacheManager();
const value = manager[prop];
// 如果是方法,绑定 this 到实际实例
if (typeof value === 'function') {
return value.bind(manager);
}
return value;
}
});
export default logCacheManager;

View File

@@ -0,0 +1,324 @@
// 日志工具模块ESM
import fs from "fs";
import path from "path";
import config from "../../appConfig/index.js";
function getLogDir(projectId) {
const base = config.LOG_BASE_DIR;
const idStr = String(projectId);
// computer 相关日志:使用 COMPUTER_LOG_DIR/<userId>/<cId>
// 约定 projectId 形如: computer:<userId>:<cId>
if (idStr.startsWith("computer:")) {
const [, userId, cId] = idStr.split(":");
const computerBase = config.COMPUTER_LOG_DIR || base;
return path.join(
computerBase,
String(userId || "unknown"),
String(cId || "unknown")
);
}
return path.join(base, idStr);
}
// 获取东八区UTC+8时间
// 返回格式: YYYY-MM-DD HH:mm:ss
function getCSTDateTimeString() {
const now = new Date();
// 使用 Intl.DateTimeFormat 直接格式化东八区时间,更可靠
const formatter = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const parts = formatter.formatToParts(now);
const year = parts.find(p => p.type === "year").value;
const month = parts.find(p => p.type === "month").value;
const day = parts.find(p => p.type === "day").value;
const hours = parts.find(p => p.type === "hour").value;
const minutes = parts.find(p => p.type === "minute").value;
const seconds = parts.find(p => p.type === "second").value;
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
// 获取东八区日期字符串(用于日志文件名)
// 返回格式: YYYY-MM-DD
function getCSTDateString() {
return getCSTDateTimeString().split(" ")[0];
}
// 获取格式化的东八区时间戳(用于日志显示)
// 返回格式: YYYY/MM/DD HH:mm:ss
function getCSTTimestampString() {
return getCSTDateTimeString().replace(/-/g, "/");
}
// 生成唯一请求ID
function generateRequestId() {
return Math.random().toString(36).substr(2, 9);
}
// 获取客户端IP地址
function getClientIP(req) {
return (
req.ip ||
req.connection.remoteAddress ||
req.socket.remoteAddress ||
(req.connection.socket ? req.connection.socket.remoteAddress : null) ||
req.headers["x-forwarded-for"]?.split(",")[0] ||
"unknown"
);
}
// 格式化日志输出
function formatLogMessage(level, message, meta = {}) {
const timestamp = getCSTTimestampString();
const metaStr =
Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : "";
return `[${timestamp}] [${level.toUpperCase()}] ${message}${metaStr}`;
}
// 日志级别枚举
const LOG_LEVELS = {
ERROR: 0,
WARN: 1,
INFO: 2,
DEBUG: 3,
};
// 当前日志级别
const CURRENT_LOG_LEVEL = (function () {
const level = (config.LOG_LEVEL || "info").toLowerCase();
switch (level) {
case "error":
return LOG_LEVELS.ERROR;
case "warn":
return LOG_LEVELS.WARN;
case "debug":
return LOG_LEVELS.DEBUG;
case "info":
default:
return LOG_LEVELS.INFO;
}
})();
// 获取API日志文件路径
function getLogFilePath(projectId, prefix) {
const logDir = getLogDir(String(projectId));
const today = getCSTDateString(); // 格式: YYYY-MM-DD (东八区)
// 确保日志目录存在
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
return path.join(logDir, `${prefix}-${today}.log`);
}
// 基于项目名缓存 WriteStream按天切换文件时自动轮转
const projectIdToLogStream = new Map(); // key: projectId, value: { stream, filePath }
function getLogWriteStream(projectId, prefix) {
const stringProjectId = String(projectId);
const targetFilePath = getLogFilePath(stringProjectId, prefix);
const cached = projectIdToLogStream.get(stringProjectId);
if (
cached &&
cached.filePath === targetFilePath &&
!cached.stream.destroyed
) {
return cached.stream;
}
// 如果已有旧流且文件路径变更或已销毁,关闭旧流
if (cached && cached.stream && !cached.stream.destroyed) {
try {
cached.stream.end();
} catch (_) {}
}
const newStream = fs.createWriteStream(targetFilePath, {
flags: "a",
encoding: "utf8",
});
newStream.on("error", (err) => {
console.error("API日志写入流错误:", err && err.message ? err.message : err);
});
newStream.on("close", () => {
const current = projectIdToLogStream.get(stringProjectId);
if (current && current.stream === newStream) {
projectIdToLogStream.delete(stringProjectId);
}
});
projectIdToLogStream.set(stringProjectId, {
stream: newStream,
filePath: targetFilePath,
});
return newStream;
}
// 写入日志到文件
function writeToLogFile(projectId, prefix, level, message, meta = {}) {
try {
const logMessage = formatLogMessage(level, message, meta) + "\n";
const stream = getLogWriteStream(String(projectId), prefix);
const ok = stream.write(logMessage);
if (!ok) {
// 简单回压提示
console.warn(`日志写入回压: ${projectId}`);
}
} catch (error) {
console.error("写入日志文件失败:", error.message);
}
}
// 日志输出函数默认输出到api_${date}.log
function log(projectId, level, message, meta = {}) {
const upper = String(level).toUpperCase();
if (LOG_LEVELS[upper] <= CURRENT_LOG_LEVEL) {
if (config.LOG_CONSOLE_ENABLED) {
console.log(formatLogMessage(upper, message, meta));
}
const finalProjectId = String(projectId || "default");
writeToLogFile(
finalProjectId,
config.LOG_PREFIX_API || "api",
upper,
message,
meta
);
}
}
//build日志输出
function logBuild(projectId, level, message, meta = {}) {
const upper = String(level).toUpperCase();
if (LOG_LEVELS[upper] <= CURRENT_LOG_LEVEL) {
if (config.LOG_CONSOLE_ENABLED) {
console.log(formatLogMessage(upper, message, meta));
}
const finalProjectId = String(projectId || "default");
writeToLogFile(
finalProjectId,
config.LOG_PREFIX_BUILD || "build",
upper,
message,
meta
);
writeToLogFile(
finalProjectId,
config.LOG_PREFIX_API || "api",
upper,
message,
meta
);
}
}
// 日志中间件
function logger(req, res, next) {
const startTime = Date.now();
const requestId = generateRequestId();
const clientIP = getClientIP(req);
// 为请求添加唯一ID
req.requestId = requestId;
// 记录请求开始
const requestLogMessage = `${req.method}-[${req.requestId}] -Request ${
req.originalUrl || req.url
} -`;
const requestLogMeta = {
requestId,
clientIP,
userAgent: req.headers["user-agent"] || "unknown",
contentType: req.headers["content-type"] || "unknown",
};
// 根据请求方法分别处理 projectId
let projectId = "default";
if (req.method === "GET") {
projectId = req.query.projectId || "default";
} else if (
req.method === "POST" ||
req.method === "PUT" ||
req.method === "PATCH"
) {
projectId = req.body && req.body.projectId ? req.body.projectId : "default";
}
log(projectId, "INFO", requestLogMessage, requestLogMeta);
// 监听响应完成事件
res.on("finish", () => {
const endTime = Date.now();
const responseTime = endTime - startTime;
// 根据状态码确定日志级别
const level =
res.statusCode >= 400 ? "ERROR" : res.statusCode >= 300 ? "WARN" : "INFO";
const responseLogMessage = `${req.method}-[${req.requestId}] -Response(${
res.statusCode
}) ${req.originalUrl || req.url} - `;
const responseLogMeta = {
requestId,
clientIP,
responseTime: `${responseTime}ms`,
contentLength: res.get("content-length") || "unknown",
statusCode: res.statusCode,
};
log(projectId, level, responseLogMessage, responseLogMeta);
});
// 监听响应关闭事件(处理异常情况)
res.on("close", () => {
if (!res.finished) {
const endTime = Date.now();
const responseTime = endTime - startTime;
const closeLogMessage = `${req.method}-[${req.requestId}] -Response(${
res.statusCode
}) ${req.originalUrl || req.url} - Connection closed`;
const closeLogMeta = {
requestId,
clientIP,
responseTime: `${responseTime}ms`,
contentLength: res.get("content-length") || "unknown",
statusCode: res.statusCode,
};
log(projectId, "ERROR", closeLogMessage, closeLogMeta);
}
});
next();
}
export {
log,
logBuild,
logger,
getLogDir,
generateRequestId,
getClientIP,
formatLogMessage,
getLogFilePath,
writeToLogFile,
getCSTDateTimeString,
getCSTDateString,
getCSTTimestampString,
LOG_LEVELS,
CURRENT_LOG_LEVEL,
};

View File

@@ -0,0 +1,388 @@
import fs from "fs";
import path from "path";
import archiver from "archiver";
import yauzl from "yauzl";
import config from "../../appConfig/index.js";
import { log } from "../log/logUtils.js";
import { FileError } from "../error/errorHandler.js";
import { sanitizeSensitivePaths } from "../common/sensitiveUtils.js";
/**
* 按排除规则将目录复制到目标目录
* @param {string} srcDir 源目录
* @param {string} destDir 目标目录
*/
async function copyDirectoryFiltered(srcDir, destDir) {
const excludeDirNames = new Set(config.TRAVERSE_EXCLUDE_DIRS || []);
const excludeFileNames = new Set(config.BACKUP_TRAVERSE_EXCLUDE_FILES || []);
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
if (entry.isDirectory()) {
if (excludeDirNames.has(entry.name)) {
continue;
}
await fs.promises.mkdir(destPath, { recursive: true });
await copyDirectoryFiltered(srcPath, destPath);
} else if (entry.isFile()) {
if (excludeFileNames.has(entry.name)) {
continue;
}
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
await fs.promises.copyFile(srcPath, destPath);
}
}
}
/**
* 将项目目录备份为zip文件按配置排除目录
* @param {string} projectId 项目ID
* @param {string} projectPath 项目源路径
* @param {string} outZipPath 输出zip文件路径
* @returns {Promise<string>} 生成的zip文件路径
*/
async function backupProjectToZip(projectId, projectPath, outZipPath) {
const startTime = Date.now();
const tempBase = path.join(config.UPLOAD_PROJECT_DIR, projectId);
if (!fs.existsSync(tempBase)) {
fs.mkdirSync(tempBase, { recursive: true });
}
const tempDir = path.join(tempBase, `backup_temp_${Date.now()}`);
await fs.promises.mkdir(tempDir, { recursive: true });
try {
log(projectId, "DEBUG", "Start copying project files to temporary directory", { projectPath, tempDir });
await copyDirectoryFiltered(projectPath, tempDir);
log(projectId, "DEBUG", "Project files copied, start compressing", { tempDir, outZipPath });
// 使用 archiver 进行压缩,避免依赖系统 zip
await fs.promises.mkdir(path.dirname(outZipPath), { recursive: true });
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(outZipPath);
const archive = archiver("zip", { zlib: { level: 9 } });
output.on("close", () => resolve());
output.on("error", (err) =>
reject(
new FileError("Backup zip compression failed", {
projectId,
projectPath,
outZipPath,
originalError: err && err.message,
})
)
);
archive.on("warning", (err) => {
if (err && err.code === "ENOENT") {
// 记录告警但不失败
log(projectId, "WARN", `Compression warning: ${err.message}`, {
projectId,
outZipPath,
});
} else if (err) {
reject(
new FileError("Backup zip compression failed", {
projectId,
projectPath,
outZipPath,
originalError: err && err.message,
})
);
}
});
archive.on("error", (err) =>
reject(
new FileError("Backup zip compression failed", {
projectId,
projectPath,
outZipPath,
originalError: err && err.message,
})
)
);
archive.pipe(output);
archive.directory(tempDir + "/", false);
archive.finalize();
});
log(projectId, "INFO", `Project backup completed: ${outZipPath}`, {
projectId,
outZipPath,
elapsedMs: Date.now() - startTime,
});
return outZipPath;
} finally {
// 清理临时目录
try {
await fs.promises.rm(tempDir, { recursive: true, force: true });
} catch (_) {}
}
}
/**
* 从zip备份回滚项目目录
* 策略:
* - 清空现有项目目录(保留根目录本身和被排除的目录/文件)
* - 将zip内容解压回项目目录
* @param {string} projectId 项目ID
* @param {string} projectPath 项目路径
* @param {string} zipPath zip备份文件路径
*/
async function restoreProjectFromZip(projectId, projectPath, zipPath) {
const startTime = Date.now();
// 获取排除列表(与备份时使用的规则一致)
const excludeDirNames = new Set(config.TRAVERSE_EXCLUDE_DIRS || []);
const excludeFileNames = new Set(config.BACKUP_TRAVERSE_EXCLUDE_FILES || []);
// 清空目录内容,但保留被排除的目录和文件
log(projectId, "DEBUG", "Start clearing project directory (keep excluded items)", { projectPath });
const entries = await fs.promises.readdir(projectPath, {
withFileTypes: true,
});
for (const entry of entries) {
const fullPath = path.join(projectPath, entry.name);
// 跳过被排除的目录
if (entry.isDirectory() && excludeDirNames.has(entry.name)) {
continue;
}
// 跳过被排除的文件
if (entry.isFile() && excludeFileNames.has(entry.name)) {
continue;
}
// 删除不在排除列表中的文件和目录
try {
await fs.promises.rm(fullPath, { recursive: true, force: true });
} catch (e) {
// 忽略个别删除失败,继续
}
}
// 使用 yauzl 解压 zip 到项目目录,避免依赖系统 unzip
log(projectId, "DEBUG", "Start restoring project files from zip", { zipPath, projectPath });
await new Promise((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true }, (openErr, zipFile) => {
if (openErr || !zipFile) {
return reject(
new FileError("Rollback unzip failed", {
projectId,
projectPath,
zipPath,
originalError: openErr && openErr.message,
})
);
}
const resolvedProjectPath = path.resolve(projectPath);
zipFile.readEntry();
zipFile.on("entry", (entry) => {
// 安全处理路径,防止路径穿越
const normalized = path
.normalize(entry.fileName)
.replace(/^([/\\]+)+/, "");
const targetPath = path.join(projectPath, normalized);
const resolvedTargetPath = path.resolve(targetPath);
if (
!resolvedTargetPath.startsWith(resolvedProjectPath + path.sep) &&
resolvedTargetPath !== resolvedProjectPath
) {
zipFile.readEntry();
return;
}
if (/\\$|\/$/.test(entry.fileName) || entry.fileName.endsWith("/")) {
// 目录条目
fs.promises
.mkdir(resolvedTargetPath, { recursive: true })
.then(() => zipFile.readEntry())
.catch((e) => {
zipFile.close();
reject(
new FileError("Rollback unzip failed", {
projectId,
projectPath,
zipPath,
originalError:
e && e.message
? sanitizeSensitivePaths(e.message)
: e && e.message,
})
);
});
return;
}
// 文件条目
zipFile.openReadStream(entry, (streamErr, readStream) => {
if (streamErr || !readStream) {
zipFile.close();
return reject(
new FileError("Rollback unzip failed", {
projectId,
projectPath,
zipPath,
originalError: streamErr && streamErr.message,
})
);
}
fs.promises
.mkdir(path.dirname(resolvedTargetPath), { recursive: true })
.then(() => {
const writeStream = fs.createWriteStream(resolvedTargetPath);
readStream.pipe(writeStream);
writeStream.on("close", () => zipFile.readEntry());
writeStream.on("error", (e) => {
zipFile.close();
reject(
new FileError("Rollback unzip failed", {
projectId,
projectPath,
zipPath,
originalError:
e && e.message
? sanitizeSensitivePaths(e.message)
: e && e.message,
})
);
});
})
.catch((e) => {
zipFile.close();
reject(
new FileError("Rollback unzip failed", {
projectId,
projectPath,
zipPath,
originalError:
e && e.message
? sanitizeSensitivePaths(e.message)
: e && e.message,
})
);
});
});
});
zipFile.on("end", () => {
zipFile.close();
resolve();
});
zipFile.on("error", (e) => {
zipFile.close();
reject(
new FileError("Rollback unzip failed", {
projectId,
projectPath,
zipPath,
originalError:
e && e.message
? sanitizeSensitivePaths(e.message)
: e && e.message,
})
);
});
});
});
log(projectId, "INFO", `Project restored from backup: ${zipPath}`, {
projectId,
projectPath,
zipPath,
elapsedMs: Date.now() - startTime,
});
}
/**
* 删除缺失的文件
* @param {string} baseDir 基础目录
* @param {Set<string>} keepRelativePaths 需要保留的文件路径集合
* @param {Array<string>} excludeDirNames 需要排除的目录名集合
*/
async function pruneMissingFiles(baseDir, keepRelativePaths, excludeDirNames) {
const excludeSet = new Set(excludeDirNames || []);
// 需要保护的文件名(返回内容时排除的文件,例如 AGENT.md/CLAUDE.md清理缺失文件时也不能删除
const protectedFileNames = new Set(
(config.CONTENT_TRAVERSE_EXCLUDE_FILES || []).map((name) => String(name).trim())
);
async function walkAndPrune(currentDir) {
const entries = await fs.promises.readdir(currentDir, {
withFileTypes: true,
});
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
const relativePath = path.relative(baseDir, fullPath);
if (entry.isDirectory()) {
// 跳过配置中的排除目录
if (excludeSet.has(entry.name)) {
continue;
}
await walkAndPrune(fullPath);
} else if (entry.isFile()) {
// 1. 隐藏文件(以 . 开头)不能删除
if (entry.name.startsWith(".")) {
continue;
}
// 2. 内容排除列表中的文件(如 AGENT.md / CLAUDE.md不能删除
if (protectedFileNames.has(entry.name)) {
continue;
}
// 其余文件如果不在本次提交的保留列表中,则删除
if (!keepRelativePaths.has(relativePath)) {
try {
await fs.promises.unlink(fullPath);
} catch (_) {}
}
}
}
}
await walkAndPrune(baseDir);
}
/**
* 删除空目录
* @param {string} baseDir 基础目录
* @param {Array<string>} excludeDirNames 需要排除的目录名集合
*/
async function removeEmptyDirectories(baseDir, excludeDirNames) {
const excludeSet = new Set(excludeDirNames || []);
async function postOrderRemove(dir) {
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (excludeSet.has(entry.name)) {
continue;
}
await postOrderRemove(fullPath);
}
}
// 再次读取,若为空则删除(根目录不删)
const after = await fs.promises.readdir(dir);
if (after.length === 0 && path.resolve(dir) !== path.resolve(baseDir)) {
try {
await fs.promises.rmdir(dir);
} catch (_) {}
}
}
await postOrderRemove(baseDir);
}
export {
copyDirectoryFiltered,
backupProjectToZip,
restoreProjectFromZip,
pruneMissingFiles,
removeEmptyDirectories,
};

View File

@@ -0,0 +1,142 @@
import fs from "fs";
import path from "path";
import { log } from "../log/logUtils.js";
import { copyDirectoryFiltered } from "./backupUtils.js";
import {
ValidationError,
BusinessError,
SystemError,
} from "../error/errorHandler.js";
import { createPnpmNpmrc } from "../common/npmrcUtils.js";
import { resolveProjectPath } from "../common/projectPathUtils.js";
/**
* 复制项目
* @param {string} sourceProjectId - 源项目ID
* @param {string} targetProjectId - 目标项目ID
* @returns {Promise<Object>} 复制结果
*/
async function copyProject(
sourceProjectId,
targetProjectId,
isolationContexts = {}
) {
if (!sourceProjectId) {
throw new ValidationError("Source project ID cannot be empty", {field: "sourceProjectId",});
}
if (!targetProjectId) {
throw new ValidationError("Target project ID cannot be empty", {field: "targetProjectId",});
}
const sourceIsolationContext = isolationContexts.sourceIsolationContext || {};
const targetIsolationContext = isolationContexts.targetIsolationContext || {};
const sourceProjectPath = resolveProjectPath(
sourceProjectId,
sourceIsolationContext
);
const targetProjectPath = resolveProjectPath(
targetProjectId,
targetIsolationContext
);
// 检查源项目是否存在
if (!fs.existsSync(sourceProjectPath)) {
throw new BusinessError(`Source project ${sourceProjectId} does not exist`, {
sourceProjectId,
sourceProjectPath,
});
}
// 检查目标项目是否已存在
if (fs.existsSync(targetProjectPath)) {
throw new BusinessError(`Target project ${targetProjectId} already exists`, {
targetProjectId,
targetProjectPath,
});
}
try {
log(targetProjectId, "INFO", `Start copying project from ${sourceProjectId} to ${targetProjectId}`, {
sourceProjectId,
targetProjectId,
});
// 创建目标项目目录
fs.mkdirSync(targetProjectPath, { recursive: true });
log(targetProjectId, "INFO", `Target project directory created successfully: ${targetProjectPath}`, {
targetProjectId,
});
// 复制源项目内容到目标项目目录
const entries = await fs.promises.readdir(sourceProjectPath, {
withFileTypes: true,
});
for (const entry of entries) {
const srcPath = path.join(sourceProjectPath, entry.name);
const destPath = path.join(targetProjectPath, entry.name);
if (entry.isDirectory()) {
await fs.promises.mkdir(destPath, { recursive: true });
// 使用 copyDirectoryFiltered 来复制目录内容,排除不必要的文件
await copyDirectoryFiltered(srcPath, destPath);
} else if (entry.isFile()) {
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
await fs.promises.copyFile(srcPath, destPath);
}
}
log(targetProjectId, "INFO", `Project copied successfully: ${targetProjectId}`, {
sourceProjectId,
targetProjectId,
});
// 为目标项目创建 .npmrc 配置文件
await createPnpmNpmrc(targetProjectPath, targetProjectId);
return {
success: true,
message: `Project ${sourceProjectId} successfully copied to ${targetProjectId}`,
sourceProjectId,
targetProjectId,
targetProjectPath,
};
} catch (error) {
log(targetProjectId, "ERROR", `Copy project failed: ${error.message}`, {
sourceProjectId,
targetProjectId,
});
// 失败时清理目标项目目录
if (fs.existsSync(targetProjectPath)) {
try {
await fs.promises.rm(targetProjectPath, { recursive: true, force: true });
log(targetProjectId, "INFO", "Copy failed, target project directory cleaned", {
targetProjectId,
});
} catch (cleanupError) {
log(targetProjectId, "ERROR", `Clean target project directory failed: ${cleanupError.message}`, {
targetProjectId,
originalError: cleanupError.message,
});
}
}
// 如果错误不是自定义的错误类型,包装为系统错误
if (!error.isOperational) {
throw new SystemError(`Copy project failed: ${error.message}`, {
sourceProjectId,
targetProjectId,
sourceProjectPath,
targetProjectPath,
originalError: error.message,
});
}
throw error;
}
}
export { copyProject };

View File

@@ -0,0 +1,159 @@
import fs from "fs";
import path from "path";
import { log } from "../log/logUtils.js";
/**
* 检测前端框架
* @param {string} projectPath 项目路径
* @returns {string} "react" | "vue{major}" | "vue" | "other"
*/
function detectFrontendFramework(projectPath) {
try {
// 检查 package.json 是否存在
const packageJsonPath = path.join(projectPath, "package.json");
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(
fs.readFileSync(packageJsonPath, "utf-8")
);
const dependencies = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
// 检查是否有 react 依赖
if (dependencies.react || dependencies["react-dom"]) {
return "react";
}
// 检查是否有 vue 依赖
if (dependencies.vue || dependencies["vue-router"] || dependencies["@vue/cli-service"]) {
// 尝试从多个 Vue 相关依赖中识别主版本,优先返回 vue2/vue3
const versionCandidates = [
dependencies.vue,
dependencies["vue-router"],
dependencies["@vue/cli-service"],
];
for (const versionRaw of versionCandidates) {
if (typeof versionRaw !== "string") {
continue;
}
const majorVersion = parseVueMajorVersion(versionRaw);
if (Number.isFinite(majorVersion)) {
return `vue${majorVersion}`;
}
}
// 兜底:识别到 Vue 但无法判断版本
return "vue";
}
}
return "other";
} catch (error) {
log(null, "WARN", `Detect frontend framework failed: ${error.message}`, {
projectPath,
error: error.message,
});
return "other";
}
}
/**
* 解析 Vue 主版本号
* @param {string} versionString Vue 版本字符串
* @returns {number|null}
*/
function parseVueMajorVersion(versionString) {
if (!versionString) {
return null;
}
let normalized = String(versionString).trim().toLowerCase();
if (!normalized) {
return null;
}
// npm alias 形式npm:vue@^3.4.0
if (normalized.startsWith("npm:")) {
const aliasMatched = normalized.match(/^npm:[^@]+@(.+)$/);
if (aliasMatched && aliasMatched[1]) {
normalized = aliasMatched[1];
}
}
// semver 常见形式3.4.0 / ^3.4.0 / ~2.7.16 / >=3 / <=2 / 2.x / v3.2.0 / 3
// 取第一个独立数字段,避免误取诸如 github 分支名中的数字片段
const semverMatched = normalized.match(/(?:^|[^\d])v?(\d+)(?:\.|x|\b)/);
if (semverMatched) {
const major = Number(semverMatched[1]);
return Number.isFinite(major) ? major : null;
}
// 其余非标准格式workspace/file/link/git/url不强判
return null;
}
/**
* 检测开发框架
* @param {string} projectPath 项目路径
* @returns {string} "vite" | "nextjs" | "other"
*/
function detectDevFramework(projectPath) {
try {
// 检查是否有 next.config 文件(优先级高于 vite
const nextConfigPatterns = [
"next.config.js",
"next.config.ts",
"next.config.mjs",
"next.config.cjs",
];
for (const configFile of nextConfigPatterns) {
const configPath = path.join(projectPath, configFile);
if (fs.existsSync(configPath)) {
return "nextjs";
}
}
// 检查是否有 vite.config 文件
const viteConfigPatterns = [
"vite.config.js",
"vite.config.ts",
"vite.config.mjs",
"vite.config.cjs",
];
for (const configFile of viteConfigPatterns) {
const configPath = path.join(projectPath, configFile);
if (fs.existsSync(configPath)) {
return "vite";
}
}
return "other";
} catch (error) {
log(null, "WARN", `Detect development framework failed: ${error.message}`, {
projectPath,
error: error.message,
});
return "other";
}
}
/**
* 获取项目框架信息
* @param {string} projectPath 项目路径
* @returns {Object} { frontendFramework: string, devFramework: string }
*/
function getFrameworkInfo(projectPath) {
const frontendFramework = detectFrontendFramework(projectPath);
const devFramework = detectDevFramework(projectPath);
return {
frontendFramework,
devFramework,
};
}
export { detectFrontendFramework, detectDevFramework, getFrameworkInfo };

View File

@@ -0,0 +1,358 @@
import fs from "fs";
import path from "path";
import config from "../../appConfig/index.js";
import { log } from "../log/logUtils.js";
import {
ValidationError,
SystemError,
ResourceError,
} from "../error/errorHandler.js";
import { extractZip } from "../common/zipUtils.js";
import { getFrameworkInfo } from "./frameworkDetectorUtils.js";
import {
resolveProjectPath,
shouldUseIsolationPath,
} from "../common/projectPathUtils.js";
/**
* 检查文件是否为二进制文件
*/
async function isBinaryFile(filePath) {
try {
const buffer = fs.readFileSync(filePath);
// 检查是否包含null字节
if (buffer.includes(0)) {
return true;
}
// 检查是否包含非ASCII字符
const text = buffer.toString("utf-8");
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i);
if (
charCode < 32 &&
charCode !== 9 &&
charCode !== 10 &&
charCode !== 13
) {
return true;
}
}
return false;
} catch (error) {
return false;
}
}
/**
* 检查文件是否为图片文件
*/
function isImageFile(filePath) {
const ext = path.extname(filePath).toLowerCase();
return [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"].includes(
ext
);
}
/**
* 递归遍历目录,获取所有文件信息
* @param {string} dirPath 目录路径
* @param {string} projectId 项目ID
* @param {string} basePath 计算相对路径的基准路径,默认为 PROJECT_SOURCE_DIR + projectId
* @returns {Array} 文件信息数组
*/
async function traverseDirectory(targetDir, basePath, projectId, proxyPath) {
const files = [];
const entries = await fs.promises.readdir(targetDir, { withFileTypes: true });
// 对文件条目进行排序,确保返回顺序一致
// 先按类型排序(目录在前,文件在后),然后按名称排序
entries.sort((a, b) => {
// 如果一个是目录,一个是文件,目录排在前面
if (a.isDirectory() && !b.isDirectory()) {
return -1;
}
if (!a.isDirectory() && b.isDirectory()) {
return 1;
}
// 如果类型相同,按名称排序(不区分大小写)
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
for (const entry of entries) {
const fullPath = path.join(targetDir, entry.name);
// 跳过隐藏文件(以 . 开头的文件)
if (entry.name.startsWith(".")) {
continue;
}
// 跳过指定的排除文件(优先使用内容专用排除列表)
const contentExcludeFiles =
config.CONTENT_TRAVERSE_EXCLUDE_FILES ||
[];
if (contentExcludeFiles.includes(entry.name)) {
continue;
}
// 跳过指定的排除目录
if (
entry.isDirectory() &&
config.TRAVERSE_EXCLUDE_DIRS.includes(entry.name)
) {
continue;
}
if (entry.isDirectory()) {
// 递归处理子目录
const subFiles = await traverseDirectory(fullPath, basePath, projectId, proxyPath);
files.push(...subFiles);
} else {
// 处理文件
try {
const stats = await fs.promises.stat(fullPath);
// 使用传入的 basePath 或默认的 PROJECT_SOURCE_DIR + projectId
const referencePath =
basePath || resolveProjectPath(projectId);
const relativePath = path.relative(referencePath, fullPath);
const isBinary = await isBinaryFile(fullPath);
// 判断是否为当前项目目录
// const isCurrentProject = !basePath ||
// path.resolve(basePath) ===
// path.resolve(path.join(config.PROJECT_SOURCE_DIR, projectId));
// 判断是否为历史版本目录
// const isHistoryVersion = basePath &&
// path.resolve(basePath) ===
// path.resolve(path.join(config.PROJECT_SOURCE_DIR, "_his", projectId));
// 生成文件代理URL
const fileProxyUrl = `${proxyPath}/${relativePath}`;
const fileInfo = {
name: relativePath,
binary: isBinary,
sizeExceeded: stats.size > config.MAX_INLINE_FILE_SIZE_BYTES,
contents: "",
fileProxyUrl: fileProxyUrl,
};
// 如果文件大小未超过阈值
if (!fileInfo.sizeExceeded) {
if (fileInfo.binary) {
// 二进制文件转换为base64
if (isImageFile(fullPath)) {
const buffer = fs.readFileSync(fullPath);
fileInfo.contents = buffer.toString("base64");
}
// 非图片的二进制文件不返回内容
} else {
// 文本文件直接读取内容
fileInfo.contents = fs.readFileSync(fullPath, "utf-8");
}
}
files.push(fileInfo);
} catch (error) {
log(projectId, "WARN", `Process file failed: ${fullPath} - ${error.message}`, {
filePath: fullPath,
error: error.message,
});
}
}
}
return files;
}
/**
* 获取项目内容
* @param {string} projectPath 项目路径
* @param {string} command 命令参数,如果为 'cpage_config' 则返回 cpage_config.json否则过滤掉它
* @returns {Object} 包含文件列表的结果对象
*/
async function getProjectContent(projectPath, command, proxyPath) {
const startTime = Date.now();
const projectId = path.basename(projectPath);
try {
log(projectId, "INFO", "Start getting project content", { projectPath, command });
log(projectId, "DEBUG", "Start traversing project directory", { projectPath });
const files = await traverseDirectory(
projectPath,
projectPath,
projectId,
proxyPath
);
log(projectId, "DEBUG", "Project directory traversal completed", { projectPath, fileCount: files.length });
// 根据 command 参数过滤 cpage_config.json
let filteredFiles = files;
if (command !== "cpage_config") {
filteredFiles = files.filter((file) => file.name !== "cpage_config.json");
}
// 获取框架信息
log(projectId, "DEBUG", "Start detecting framework information", { projectPath });
const frameworkInfo = getFrameworkInfo(projectPath);
const result = {
files: filteredFiles,
...frameworkInfo,
};
log(
projectId,
"INFO",
`Project content obtained, total ${filteredFiles.length} files`,
{
projectPath,
fileCount: filteredFiles.length,
command,
elapsedMs: Date.now() - startTime,
}
);
return result;
} catch (error) {
log(projectId, "ERROR", `Get project content failed: ${error.message}`, {
projectPath,
originalError: error.message,
elapsedMs: Date.now() - startTime,
});
throw new SystemError(`Get project content failed: ${error.message}`, {
projectPath,
originalError: error.message,
});
}
}
/**
* 根据版本号获取项目内容
* @param {string} projectId 项目ID
* @param {string} codeVersion 代码版本
* @param {string} command 命令参数,如果为 'cpage_config' 则返回 cpage_config.json否则过滤掉它
* @returns {Object} 包含文件列表的结果对象
*/
async function getProjectContentByVersion(
projectId,
codeVersion,
command,
proxyPath,
isolationContext = {}
) {
const startTime = Date.now();
const versionNum = Number(codeVersion);
if (!Number.isFinite(versionNum)) {
throw new ValidationError("Code version must be a number", { field: "codeVersion" });
}
// 构建备份文件路径
const backupDir = path.join(config.UPLOAD_PROJECT_DIR, projectId);
const backupZipPath = path.join(backupDir, `${projectId}-v${versionNum}.zip`);
// 检查备份文件是否存在
if (!fs.existsSync(backupZipPath)) {
throw new ResourceError(`Backup file for version ${versionNum} does not exist`, {
projectId,
codeVersion: versionNum,
backupZipPath,
});
}
// 创建临时解压目录
const tempExtractDir = shouldUseIsolationPath(isolationContext)
? path.join(
config.PROJECT_SOURCE_DIR,
String(isolationContext.tenantId),
String(isolationContext.spaceId),
"_his",
projectId
)
: path.join(config.PROJECT_SOURCE_DIR, "_his", projectId);
try {
// 清理临时目录(如果存在)
if (fs.existsSync(tempExtractDir)) {
await fs.promises.rm(tempExtractDir, { recursive: true, force: true });
}
// 创建临时目录
await fs.promises.mkdir(tempExtractDir, { recursive: true });
log(projectId, "INFO", `Start extracting backup file for version ${versionNum}`, {
projectId,
codeVersion: versionNum,
backupZipPath,
tempExtractDir,
});
// 解压备份文件到临时目录
log(projectId, "DEBUG", "Start extracting version backup file", { projectId, backupZipPath, tempExtractDir });
await extractZip(backupZipPath, tempExtractDir);
log(projectId, "DEBUG", `Version ${versionNum} backup file extraction completed`, {
projectId,
codeVersion: versionNum,
tempExtractDir,
});
// 获取项目内容 - 使用统一的遍历函数,传入解压目录作为基准路径
log(projectId, "DEBUG", "Start traversing version directory", { projectId, tempExtractDir });
const files = await traverseDirectory(
tempExtractDir,
tempExtractDir,
projectId,
proxyPath
);
// 根据 command 参数过滤 cpage_config.json
let filteredFiles = files;
if (command !== "cpage_config") {
filteredFiles = files.filter((file) => file.name !== "cpage_config.json");
}
const result = { files: filteredFiles };
log(projectId, "INFO", `Version ${versionNum} project content obtained`, {
projectId,
codeVersion: versionNum,
fileCount: result.files ? result.files.length : 0,
command,
elapsedMs: Date.now() - startTime,
});
return result;
} finally {
// 清理临时目录
try {
if (fs.existsSync(tempExtractDir)) {
await fs.promises.rm(tempExtractDir, { recursive: true, force: true });
log(projectId, "INFO", `Temporary directory cleaned: ${tempExtractDir}`, {
projectId,
codeVersion: versionNum,
});
}
} catch (cleanupError) {
log(projectId, "WARN", `Clean temporary directory failed: ${cleanupError.message}`, {
projectId,
codeVersion: versionNum,
tempExtractDir,
error: cleanupError.message,
});
}
}
}
export {
getProjectContent,
getProjectContentByVersion,
traverseDirectory,
isBinaryFile,
isImageFile,
};

View File

@@ -0,0 +1,99 @@
import fs from "fs";
import path from "path";
import { log } from "../log/logUtils.js";
/**
* 删除初始化项目文件夹
* @param {string} initProjectDir - 初始化项目目录路径
* @param {string} initProjectName - 初始化项目名称
* @returns {Promise<boolean>} - 删除是否成功
*/
async function deleteInitProjectFolder(initProjectDir, initProjectName) {
try {
const targetPath = path.join(initProjectDir, initProjectName);
// 检查目标路径是否存在
if (!fs.existsSync(targetPath)) {
log("default", "INFO", `Initialization project directory does not exist: ${targetPath}`);
return true;
}
// 检查是否为目录
const stats = fs.statSync(targetPath);
if (!stats.isDirectory()) {
log("default", "WARN", `Target path is not a directory: ${targetPath}`);
return false;
}
// 递归删除目录
fs.rmSync(targetPath, { recursive: true, force: true });
log("default", "INFO", `Successfully deleted initialization project directory: ${targetPath}`);
return true;
} catch (error) {
log("default", "ERROR", `Delete initialization project directory failed: ${error.message}`);
log(
"default",
"ERROR",
`Target path: ${path.join(initProjectDir, initProjectName)}`
);
return false;
}
}
/**
* 在项目启动时清理初始化项目文件夹
* @param {Object} config - 配置对象
* @returns {Promise<boolean>} - 清理是否成功
*/
async function cleanupInitProjectOnStartup(config) {
try {
const {
INIT_PROJECT_DIR,
INIT_PROJECT_NAME_REACT,
INIT_PROJECT_NAME_VUE3,
} = config;
if (!INIT_PROJECT_DIR) {
log("default", "WARN", "INIT_PROJECT_DIR configuration missing");
return false;
}
const templateNameSet = new Set(
[
INIT_PROJECT_NAME_REACT,
INIT_PROJECT_NAME_VUE3,
].filter(Boolean)
);
if (templateNameSet.size === 0) {
log("default", "WARN", "Initialization template name configuration missing");
return false;
}
log("default", "INFO", "Start cleaning initialization project directory...");
log("default", "INFO", `Target directory: ${INIT_PROJECT_DIR}`);
log("default", "INFO", `Template names: ${Array.from(templateNameSet).join(", ")}`);
let allSuccess = true;
for (const templateName of templateNameSet) {
const success = await deleteInitProjectFolder(INIT_PROJECT_DIR, templateName);
if (!success) {
allSuccess = false;
}
}
if (allSuccess) {
log("default", "INFO", "Initialization project directory cleanup completed");
} else {
log("default", "ERROR", "Initialization project directory cleanup failed");
}
return allSuccess;
} catch (error) {
log("default", "ERROR", `Clean initialization project directory failed: ${error.message}`);
return false;
}
}
export { deleteInitProjectFolder, cleanupInitProjectOnStartup };

View File

@@ -0,0 +1,147 @@
import fs from "fs";
import path from "path";
import config from "../../appConfig/index.js";
import { log } from "../log/logUtils.js";
import { ValidationError, SystemError, FileError } from "../error/errorHandler.js";
import { resolveProjectPath } from "../common/projectPathUtils.js";
/**
* 上传附件文件到项目的 .attachments 目录
* @param {string} projectId 项目ID
* @param {Object} file multer文件对象
* @param {string} fileName 可选,指定存储的文件名
* @returns {Object} 包含fileName和relativePath的结果对象
*/
async function uploadAttachmentFile(
projectId,
file,
fileName = null,
isolationContext = {}
) {
try {
// 验证参数
if (!projectId) {
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
}
if (!file) {
throw new ValidationError("File cannot be empty", { field: "file" });
}
if (!file.path) {
throw new ValidationError("File path is invalid", { field: "file.path" });
}
log(projectId, "INFO", "Start uploading attachment file", {
projectId,
fileName,
originalName: file.originalname,
tempPath: file.path,
});
// 构建项目目录路径
const projectDir = resolveProjectPath(projectId, isolationContext);
// 检查项目目录是否存在
if (!fs.existsSync(projectDir)) {
throw new ValidationError("Project does not exist", { field: "projectId" });
}
// 构建附件目录路径
const attachmentsDir = path.join(projectDir, ".attachments");
// 创建附件目录(如果不存在)
if (!fs.existsSync(attachmentsDir)) {
await fs.promises.mkdir(attachmentsDir, { recursive: true });
log(projectId, "INFO", "Create attachment directory", { attachmentsDir });
}
// 确定最终的文件名
let finalFileName;
if (fileName) {
finalFileName = fileName;
} else {
finalFileName = file.originalname;
}
let finalFilePath = path.join(attachmentsDir, finalFileName);
// 检查文件是否已存在,如果存在则生成唯一文件名
if (fs.existsSync(finalFilePath)) {
const timestamp = Date.now();
const randomSuffix = Math.round(Math.random() * 1e6);
const fileExtension = path.extname(finalFileName);
const baseName = path.basename(finalFileName, fileExtension);
finalFileName = `${baseName}_${timestamp}_${randomSuffix}${fileExtension}`;
finalFilePath = path.join(attachmentsDir, finalFileName);
}
// 移动文件从临时目录到附件目录
// 使用copyFile + unlink代替rename以支持跨设备跨挂载点的文件移动
try {
await fs.promises.rename(file.path, finalFilePath);
} catch (renameError) {
// 如果rename失败通常是跨设备错误则使用copyFile
if (renameError.code === "EXDEV") {
log(projectId, "INFO", "Cross-device move, using copy method", {
tempPath: file.path,
finalPath: finalFilePath,
});
await fs.promises.copyFile(file.path, finalFilePath);
await fs.promises.unlink(file.path);
} else {
throw renameError;
}
}
// 计算相对于项目目录的相对路径
const relativePath = path.relative(projectDir, finalFilePath);
log(projectId, "INFO", "Attachment file uploaded successfully", {
projectId,
originalName: file.originalname,
fileName: finalFileName,
relativePath,
finalFilePath,
});
return {
fileName: finalFileName,
relativePath,
};
} catch (error) {
// 如果出错,尝试清理临时文件
if (file && file.path && fs.existsSync(file.path)) {
try {
await fs.promises.unlink(file.path);
log(projectId, "INFO", "Clean temporary file", { tempPath: file.path });
} catch (cleanupError) {
log(projectId, "WARN", "Clean temporary file failed", {
tempPath: file.path,
error: cleanupError.message,
});
}
}
log(projectId, "ERROR", "Upload attachment file failed", {
projectId,
originalName: file?.originalname,
error: error.message,
});
// 重新抛出错误
if (
error instanceof ValidationError ||
error instanceof SystemError ||
error instanceof FileError
) {
throw error;
}
throw new SystemError(`Upload attachment file failed: ${error.message}`, {
projectId,
originalError: error.message,
});
}
}
export { uploadAttachmentFile };

View File

@@ -0,0 +1,973 @@
/**
* 服务管理器模块
*
* 功能: 提供跨平台的服务启动、停止、重启、状态查询功能
*
* 跨平台支持:
* - Windows: 使用 taskkill 命令
* - Linux/macOS: 使用 kill 信号
*
* PID 文件管理:
* - 使用 os.tmpdir() 获取临时目录
* - Windows: %TEMP%/qiming-file-server/server.pid
* - Linux/macOS: /tmp/qiming-file-server/server.pid
*/
import path from "path";
import os from "os";
import fs from "fs-extra";
import { spawn } from "cross-spawn";
import treeKill from "tree-kill";
import { fileURLToPath } from "url";
import { execFileSync } from "child_process";
import http from "http";
import { createRequire } from "module";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const esmRequire = createRequire(import.meta.url);
// 服务配置
const SERVICE_CONFIG = {
// 服务名称
name: 'qiming-file-server',
// PID 文件目录
pidDir: path.join(os.tmpdir(), 'qiming-file-server'),
// PID 文件名
pidFileName: 'server.pid',
lockFileName: 'start.lock',
// 默认停止超时时间(毫秒)
defaultStopTimeout: 30000,
// 默认启动健康检查超时时间(毫秒)
defaultStartTimeout: 30000,
// 启动锁陈旧超时时间(毫秒)
staleLockTimeout: 120000,
// 检查进程间隔(毫秒)
checkInterval: 500,
};
/**
* 获取 PID 文件完整路径
*
* 使用 os.tmpdir() 确保跨平台兼容性
*
* @returns {string} PID 文件路径
*/
function getPidFilePath() {
return path.join(SERVICE_CONFIG.pidDir, SERVICE_CONFIG.pidFileName);
}
/**
* 获取启动锁文件完整路径
*
* @returns {string} 锁文件路径
*/
function getLockFilePath() {
return path.join(SERVICE_CONFIG.pidDir, SERVICE_CONFIG.lockFileName);
}
/**
* 解析服务入口脚本路径(兼容 src 与 dist
*
* @returns {string} server.js 完整路径
*/
function resolveServerScriptPath() {
const candidates = [
path.join(__dirname, '..', 'server.js'),
path.join(__dirname, 'server.js'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return candidates[0];
}
/**
* 读取 package 版本号(兼容 src 与 dist
*
* @returns {string} 版本号
*/
function resolvePackageVersion() {
const candidates = [
'../../package.json',
'../package.json',
];
for (const relPath of candidates) {
try {
const pkg = esmRequire(relPath);
if (pkg?.version) {
return pkg.version;
}
} catch (err) {
// 尝试下一个候选路径
}
}
return 'unknown';
}
/**
* 读取 PID 文件
*
* 读取并解析 PID 文件内容
*
* @returns {Object|null} PID 信息对象,如果文件不存在或解析失败则返回 null
* @property {number} pid - 进程 ID
* @property {string} startedAt - 启动时间 ISO 字符串
* @property {string} env - 环境名称
* @property {string} port - 端口号
* @property {string} version - 版本号
* @property {string} platform - 操作系统平台
*/
function readPidFile() {
try {
const pidPath = getPidFilePath();
// 检查文件是否存在
if (!fs.existsSync(pidPath)) {
return null;
}
// 读取并解析文件内容
const content = fs.readFileSync(pidPath, 'utf8');
const pidInfo = JSON.parse(content);
// 验证 PID 信息完整性
if (!pidInfo || typeof pidInfo.pid !== 'number') {
return null;
}
return pidInfo;
} catch (err) {
// 文件不存在或解析失败
if (err.code !== 'ENOENT') {
console.error(`Read PID file failed: ${err.message}`);
}
return null;
}
}
/**
* 写入 PID 文件
*
* 将进程信息写入 PID 文件
*
* @param {Object} pidInfo - PID 信息对象
* @param {number} pidInfo.pid - 进程 ID
* @param {string} pidInfo.startedAt - 启动时间 ISO 字符串
* @param {string} [pidInfo.env] - 环境名称
* @param {string} [pidInfo.port] - 端口号
* @param {string} [pidInfo.version] - 版本号
* @param {string} [pidInfo.platform] - 操作系统平台
*/
function writePidFile(pidInfo) {
try {
const pidPath = getPidFilePath();
// 确保目录存在
fs.ensureDirSync(SERVICE_CONFIG.pidDir);
// 写入文件
fs.writeFileSync(pidPath, JSON.stringify(pidInfo, null, 2));
console.debug(`PID file written: ${pidPath}`);
} catch (err) {
console.error(`Write PID file failed: ${err.message}`);
throw err;
}
}
/**
* 删除 PID 文件
*
* 清理 PID 文件(服务停止时调用)
*/
function deletePidFile() {
try {
const pidPath = getPidFilePath();
if (fs.existsSync(pidPath)) {
fs.removeSync(pidPath);
console.debug(`PID file deleted: ${pidPath}`);
}
} catch (err) {
console.error(`Delete PID file failed: ${err.message}`);
}
}
/**
* 检查进程是否正在运行
*
* 使用 process.kill(pid, 0) 检测进程是否存在
*
* @param {number} pid - 进程 ID
* @returns {boolean} 进程是否正在运行
*/
function isProcessRunning(pid) {
try {
// 发送信号 0 来检查进程是否存在
process.kill(pid, 0);
return true;
} catch (err) {
// ESRCH: 进程不存在
// EPERM: 进程存在但无权限(仍视为存在)
if (err.code === 'ESRCH') {
return false;
}
// 其他错误保守返回 true
return true;
}
}
/**
* 获取进程命令行(跨平台)
*
* @param {number} pid - 进程 ID
* @returns {string} 进程命令行,读取失败时返回空字符串
*/
function getProcessCommand(pid) {
try {
if (isWindows()) {
const command = execFileSync(
'powershell',
[
'-NoProfile',
'-Command',
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
);
return command.trim();
}
const command = execFileSync(
'ps',
['-p', String(pid), '-o', 'command='],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
);
return command.trim();
} catch (err) {
return '';
}
}
/**
* 校验进程是否为当前服务进程
*
* @param {number} pid - 进程 ID
* @returns {boolean} 是否为目标服务进程
*/
function isTargetServiceProcess(pid) {
if (!isProcessRunning(pid)) {
return false;
}
const command = getProcessCommand(pid);
if (!command) {
// 无法读取命令行时不判定为目标进程,避免误杀非目标进程
return false;
}
const lower = command.toLowerCase();
const serviceScriptName = path.basename(path.join(__dirname, '..', 'server.js')).toLowerCase();
const serviceName = SERVICE_CONFIG.name.toLowerCase();
return lower.includes(serviceScriptName) || lower.includes(serviceName);
}
/**
* 获取启动锁,防止并发启动导致竞态
*
* @returns {number} 锁文件描述符
*/
function acquireStartLock() {
fs.ensureDirSync(SERVICE_CONFIG.pidDir);
const lockPath = getLockFilePath();
const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const payload = JSON.stringify(
{ pid: process.pid, token, createdAt: new Date().toISOString() },
null,
2
);
try {
const fd = fs.openSync(lockPath, 'wx');
fs.writeSync(fd, payload);
return { fd, token };
} catch (err) {
if (err.code !== 'EEXIST') {
throw err;
}
// 无人值守场景下自动清理陈旧锁,避免永远阻塞启动
try {
const raw = fs.readFileSync(lockPath, 'utf8');
const lockInfo = JSON.parse(raw);
const holderPid = Number(lockInfo?.pid);
const createdAt = lockInfo?.createdAt ? new Date(lockInfo.createdAt).getTime() : 0;
const lockAge = Date.now() - createdAt;
const holderAlive = Number.isFinite(holderPid) && holderPid > 0 && isProcessRunning(holderPid);
const expired = !createdAt || Number.isNaN(lockAge) || lockAge > SERVICE_CONFIG.staleLockTimeout;
if (!holderAlive || expired) {
fs.removeSync(lockPath);
const fd = fs.openSync(lockPath, 'wx');
fs.writeSync(fd, payload);
console.warn('Detected stale start lock, auto cleaned');
return { fd, token };
}
} catch (parseErr) {
// 锁文件损坏时直接重建
fs.removeSync(lockPath);
const fd = fs.openSync(lockPath, 'wx');
fs.writeSync(fd, payload);
console.warn('Detected invalid start lock, auto cleaned');
return { fd, token };
}
throw err;
}
}
/**
* 释放启动锁
*
* @param {Object|null} lockHandle - 锁信息对象
*/
function releaseStartLock(lockHandle) {
const lockFd = lockHandle?.fd;
const lockToken = lockHandle?.token;
try {
if (lockFd !== null && lockFd !== undefined) {
fs.closeSync(lockFd);
}
} catch (err) {
// 忽略关闭错误
}
try {
const lockPath = getLockFilePath();
if (fs.existsSync(lockPath)) {
let shouldRemove = false;
if (!lockToken) {
shouldRemove = true;
} else {
try {
const raw = fs.readFileSync(lockPath, 'utf8');
const lockInfo = JSON.parse(raw);
shouldRemove = lockInfo?.token === lockToken;
} catch (err) {
// 无法解析时保守清理,避免锁残留
shouldRemove = true;
}
}
if (shouldRemove) {
fs.removeSync(lockPath);
}
}
} catch (err) {
// 忽略清理错误
}
}
/**
* 检测当前操作系统是否为 Windows
*
* @returns {boolean} 是否为 Windows 系统
*/
function isWindows() {
return process.platform === 'win32';
}
/**
* 启动前确保没有旧服务进程残留
*
* 处理策略:
* 1. 若 PID 文件不存在或进程已退出:直接清理并返回
* 2. 若进程存在:先尝试优雅停止,再尝试强制停止
* 3. 最终统一清理 PID 文件,避免后续启动被旧状态阻塞
*
* @param {number} [timeout=SERVICE_CONFIG.defaultStopTimeout] - 停止等待超时(毫秒)
* @returns {Promise<Object>} 处理结果
* @property {boolean} success - 是否成功
* @property {string} message - 处理结果消息
*/
async function ensureNoRunningServiceBeforeStart(timeout = SERVICE_CONFIG.defaultStopTimeout) {
const pidInfo = readPidFile();
// 无 PID 文件,无需处理
if (!pidInfo) {
return {
success: true,
message: 'No existing service process',
};
}
// PID 对应进程不存在,清理残留 PID 文件
if (!isTargetServiceProcess(pidInfo.pid)) {
console.log(`Found stale PID file (PID: ${pidInfo.pid}), clean it...`);
deletePidFile();
return {
success: true,
message: 'Stale PID file cleaned',
};
}
console.log(`Existing service process detected (PID: ${pidInfo.pid}), stopping before start...`);
// 先尝试优雅停止
const gracefulStopped = await stopProcess(pidInfo.pid, false);
if (gracefulStopped) {
const exited = await waitForProcessStop(pidInfo.pid, timeout);
if (exited) {
deletePidFile();
return {
success: true,
message: `Existing process ${pidInfo.pid} stopped gracefully`,
};
}
}
// 优雅停止失败后,强制停止
console.warn(`Graceful stop timeout or failed for PID ${pidInfo.pid}, force stop...`);
const forceStopped = await stopProcess(pidInfo.pid, true);
if (!forceStopped) {
return {
success: false,
message: `Failed to stop existing process ${pidInfo.pid}`,
};
}
const forceExited = await waitForProcessStop(pidInfo.pid, timeout);
if (!forceExited) {
return {
success: false,
message: `Existing process ${pidInfo.pid} did not exit after force stop`,
};
}
deletePidFile();
return {
success: true,
message: `Existing process ${pidInfo.pid} stopped forcibly`,
};
}
/**
* 停止进程(跨平台实现)
*
* Windows 使用 taskkill 命令
* Linux/macOS 使用 kill 信号
*
* @param {number} pid - 进程 ID
* @param {boolean} [force=false] - 是否强制停止SIGKILL
* @returns {Promise<boolean>} 是否成功停止
*/
async function stopProcess(pid, force = false) {
return new Promise((resolve) => {
// 如果进程不存在,直接返回成功
if (!isProcessRunning(pid)) {
console.debug(`Process ${pid} does not exist, already stopped`);
resolve(true);
return;
}
const signal = force ? 'SIGKILL' : 'SIGTERM';
const killMethod = isWindows() ? 'taskkill' : 'tree-kill';
console.debug(`Use ${killMethod} to stop process ${pid} (signal: ${signal})`);
if (isWindows()) {
// Windows: 使用 taskkill 命令
const args = force ? ['/F', '/PID', String(pid)] : ['/PID', String(pid)];
const child = spawn('taskkill', args, {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let output = '';
child.stdout.on('data', (data) => {
output += data.toString();
});
child.stderr.on('data', (data) => {
output += data.toString();
});
child.on('error', (err) => {
console.error(`Stop process failed: ${err.message}`);
resolve(false);
});
child.on('close', (code) => {
if (code === 0) {
console.debug(`Process ${pid} stopped`);
resolve(true);
} else {
console.warn(`taskkill exit code: ${code}, output: ${output}`);
// 如果非强制模式,尝试强制停止
if (!force) {
stopProcess(pid, true).then(resolve);
} else {
resolve(false);
}
}
});
} else {
// Linux/macOS: 使用 tree-kill确保杀死整个进程树
treeKill(pid, signal, (err) => {
if (err) {
if (err.code === 'ESRCH') {
// 进程不存在
console.debug(`Process ${pid} does not exist`);
resolve(true);
} else {
console.error(`Stop process failed: ${err.message}`);
resolve(false);
}
} else {
console.debug(`Process ${pid} stopped`);
resolve(true);
}
});
}
});
}
/**
* 等待进程停止
*
* 轮询检查进程是否已停止
*
* @param {number} pid - 进程 ID
* @param {number} [timeout=SERVICE_CONFIG.defaultStopTimeout] - 超时时间(毫秒)
* @returns {Promise<boolean>} 是否在超时前停止
*/
async function waitForProcessStop(pid, timeout = SERVICE_CONFIG.defaultStopTimeout) {
const startTime = Date.now();
while (isProcessRunning(pid)) {
// 检查是否超时
if (Date.now() - startTime > timeout) {
console.warn(`Wait for process ${pid} to stop timeout (${timeout}ms)`);
return false;
}
// 等待检查间隔
await new Promise((resolve) =>
setTimeout(resolve, SERVICE_CONFIG.checkInterval)
);
}
const elapsed = Date.now() - startTime;
console.debug(`Process ${pid} stopped after ${elapsed}ms`);
return true;
}
/**
* 等待服务健康检查通过
*
* 轮询访问 /health直到返回 2xx 或超时
*
* @param {string|number} port - 服务端口
* @param {number} [timeout=SERVICE_CONFIG.defaultStartTimeout] - 超时时间(毫秒)
* @returns {Promise<boolean>} 是否在超时前通过健康检查
*/
async function waitForServiceHealth(port, timeout = SERVICE_CONFIG.defaultStartTimeout) {
const targetPort = Number(port);
if (!Number.isFinite(targetPort) || targetPort <= 0) {
return false;
}
const startTime = Date.now();
while (Date.now() - startTime <= timeout) {
const healthy = await new Promise((resolve) => {
const req = http.get(
{
host: '127.0.0.1',
port: targetPort,
path: '/health',
timeout: Math.min(2000, SERVICE_CONFIG.checkInterval * 4),
},
(res) => {
resolve(res.statusCode >= 200 && res.statusCode < 300);
res.resume();
}
);
req.on('timeout', () => {
req.destroy();
resolve(false);
});
req.on('error', () => resolve(false));
});
if (healthy) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, SERVICE_CONFIG.checkInterval));
}
return false;
}
/**
* 启动服务
*
* 启动 qiming-file-server 服务进程
*
* @param {Object} options - 启动选项
* @param {string} [options.env] - 环境名称
* @param {string} [options.port] - 端口号
* @param {string} [options.config] - 配置文件路径
* @returns {Promise<Object>} 启动结果
* @property {boolean} success - 是否成功
* @property {number} pid - 进程 ID
* @property {string} message - 状态消息
*/
async function startService(options = {}) {
const { env, port, config } = options;
let lockHandle = null;
console.log(`Start service ${SERVICE_CONFIG.name}...`);
try {
lockHandle = acquireStartLock();
} catch (err) {
if (err.code === 'EEXIST') {
return {
success: false,
pid: null,
message: 'Another start operation is in progress, please retry later',
};
}
return {
success: false,
pid: null,
message: `Acquire start lock failed: ${err.message}`,
};
}
try {
// 启动前先清理/停止历史进程,确保本次启动可继续进行
const stopTimeout = Number(options.timeout) || SERVICE_CONFIG.defaultStopTimeout;
const preStartResult = await ensureNoRunningServiceBeforeStart(stopTimeout);
if (!preStartResult.success) {
return {
success: false,
pid: null,
message: `Service start blocked: ${preStartResult.message}`,
};
}
// 构建环境变量
const envVars = { ...process.env };
if (env) {
envVars.NODE_ENV = env;
console.log(`Environment: ${env}`);
}
if (port) {
envVars.PORT = port;
console.log(`Port: ${port}`);
}
if (config) {
envVars.CONFIG_FILE = config;
console.log(`Configuration file: ${config}`);
}
// 构建启动参数
const serverScript = resolveServerScriptPath();
const args = [];
// 使用 cross-spawn 确保跨平台兼容性
const child = spawn('node', [serverScript, ...args], {
env: envVars,
stdio: ['pipe', 'pipe', 'pipe'],
detached: true,
cwd: process.cwd(),
});
// 处理子进程输出
child.stdout.on('data', (data) => {
process.stdout.write(data);
});
child.stderr.on('data', (data) => {
process.stderr.write(data);
});
child.on('error', (err) => {
console.error(`Start service failed: ${err.message}`);
});
// 等待服务启动
await new Promise((resolve) => setTimeout(resolve, 2000));
// 检查服务是否成功启动
if (!isProcessRunning(child.pid)) {
return {
success: false,
pid: null,
message: 'Service start failed',
};
}
// 写入 PID 文件
const pidInfo = {
pid: child.pid,
startedAt: new Date().toISOString(),
env: env || process.env.NODE_ENV || 'production',
port: port || process.env.PORT || '60000',
version: resolvePackageVersion(),
platform: process.platform,
};
writePidFile(pidInfo);
// 健康检查通过后再认定启动成功
const healthTimeout = Number(options.startTimeout) || SERVICE_CONFIG.defaultStartTimeout;
const healthy = await waitForServiceHealth(pidInfo.port, healthTimeout);
if (!healthy) {
console.error(`Service health check timeout (${healthTimeout}ms), stop failed instance...`);
await stopProcess(child.pid, true);
deletePidFile();
return {
success: false,
pid: null,
message: `Service health check timeout (${healthTimeout}ms)`,
};
}
// 解除子进程关联,使其独立运行
child.unref();
console.log(`Service started (PID: ${child.pid})`);
console.log(`Service address: http://localhost:${pidInfo.port}`);
return {
success: true,
pid: child.pid,
message: 'Service started successfully',
};
} finally {
releaseStartLock(lockHandle);
}
}
/**
* 停止服务
*
* 停止 qiming-file-server 服务进程
*
* @param {Object} options - 停止选项
* @param {boolean} [options.force=false] - 是否强制停止
* @param {number} [options.timeout] - 超时时间(毫秒)
* @returns {Promise<Object>} 停止结果
* @property {boolean} success - 是否成功
* @property {string} message - 状态消息
*/
async function stopService(options = {}) {
const { force = false, timeout = SERVICE_CONFIG.defaultStopTimeout } = options;
console.log(`Stop service ${SERVICE_CONFIG.name}...`);
// 读取 PID 文件
const pidInfo = readPidFile();
if (!pidInfo) {
return {
success: false,
message: 'Service not found',
};
}
// 检查进程是否正在运行
if (!isProcessRunning(pidInfo.pid)) {
console.log('Service process has stopped, clean PID file...');
deletePidFile();
return {
success: true,
message: 'Service has stopped (process has exited)',
};
}
// 停止进程
const stopped = await stopProcess(pidInfo.pid, force);
if (!stopped) {
return {
success: false,
message: 'Stop service failed',
};
}
// 等待进程完全退出
const exited = await waitForProcessStop(pidInfo.pid, timeout);
// 清理 PID 文件
deletePidFile();
if (exited) {
return {
success: true,
message: 'Service has stopped',
};
} else {
return {
success: false,
message: 'Service stop timeout',
};
}
}
/**
* 重启服务
*
* 先停止服务,再启动服务
*
* @param {Object} options - 重启选项(传递给 startService 和 stopService
* @returns {Promise<Object>} 重启结果
*/
async function restartService(options = {}) {
console.log(`Restart service ${SERVICE_CONFIG.name}...`);
// 先停止服务
const stopResult = await stopService(options);
// 如果停止失败但服务可能未运行,继续尝试启动
if (!stopResult.success && stopResult.message !== 'Service not found') {
console.warn(`Stop service failed: ${stopResult.message}`);
}
// 等待一段时间
await new Promise((resolve) => setTimeout(resolve, 2000));
// 启动服务
const startResult = await startService(options);
if (startResult.success) {
return {
success: true,
pid: startResult.pid,
message: 'Service has restarted',
};
} else {
return {
success: false,
pid: null,
message: `Restart failed: ${startResult.message}`,
};
}
}
/**
* 获取服务状态
*
* @returns {Object} 服务状态
* @property {boolean} running - 服务是否运行中
* @property {Object|null} pidInfo - PID 信息对象
* @property {string} message - 状态消息
*/
function getServiceStatus() {
const pidInfo = readPidFile();
if (!pidInfo) {
return {
running: false,
pidInfo: null,
message: 'Service not running',
};
}
const running = isProcessRunning(pidInfo.pid);
if (running) {
return {
running: true,
pidInfo: pidInfo,
message: 'Service running',
};
} else {
return {
running: false,
pidInfo: pidInfo,
message: 'Service process does not exist',
};
}
}
/**
* 格式化运行时间
*
* @param {string} startedAt - 启动时间 ISO 字符串
* @returns {string} 格式化的运行时间字符串
*/
function formatUptime(startedAt) {
try {
const start = new Date(startedAt);
const now = new Date();
// 检查日期是否有效
if (isNaN(start.getTime())) {
return 'unknown';
}
const uptime = Math.floor((now - start) / 1000);
const hours = Math.floor(uptime / 3600);
const minutes = Math.floor((uptime % 3600) / 60);
const seconds = uptime % 60;
if (hours > 0) {
return `${hours} hours ${minutes} minutes ${seconds} seconds`;
} else if (minutes > 0) {
return `${minutes} minutes ${seconds} seconds`;
} else {
return `${seconds} seconds`;
}
} catch (err) {
return 'unknown';
}
}
// ESM 导出
export {
SERVICE_CONFIG,
getPidFilePath,
getLockFilePath,
resolveServerScriptPath,
resolvePackageVersion,
readPidFile,
writePidFile,
deletePidFile,
isProcessRunning,
isTargetServiceProcess,
acquireStartLock,
releaseStartLock,
ensureNoRunningServiceBeforeStart,
stopProcess,
waitForProcessStop,
waitForServiceHealth,
startService,
stopService,
restartService,
getServiceStatus,
isWindows,
formatUptime,
};

View File

@@ -0,0 +1,275 @@
# 测试文档
## 测试框架
本项目使用 **Jest** 作为测试框架,配合 **Supertest** 进行 API 集成测试。
## 测试目录结构
```
tests/
├── setup.js # 测试环境配置
├── unit/ # 单元测试
│ ├── errorHandler.test.js
│ ├── restartJudgeUtils.test.js
│ └── dependencyManager.test.js
└── integration/ # 集成测试(待添加)
└── api.test.js
```
## 运行测试
### 运行所有测试
```bash
npm test
```
### 运行测试并监听文件变化
```bash
npm run test:watch
```
### 只运行单元测试
```bash
npm run test:unit
```
### 只运行集成测试
```bash
npm run test:integration
```
### 查看测试覆盖率
测试覆盖率报告会自动生成在 `coverage/` 目录下。
```bash
# 运行测试后查看覆盖率
npm test
# 在浏览器中查看详细报告
open coverage/lcov-report/index.html
```
## 测试覆盖率目标
当前设置的覆盖率阈值:
- **分支覆盖率**: 50%
- **函数覆盖率**: 50%
- **行覆盖率**: 50%
- **语句覆盖率**: 50%
## 编写测试
### 单元测试示例
```javascript
// tests/unit/myModule.test.js
const { myFunction } = require("../../src/utils/myModule");
describe("我的模块测试", () => {
test("应该返回正确的结果", () => {
const result = myFunction("input");
expect(result).toBe("expected output");
});
test("应该处理错误情况", () => {
expect(() => {
myFunction(null);
}).toThrow("错误信息");
});
});
```
### 集成测试示例
```javascript
// tests/integration/api.test.js
const request = require("supertest");
const app = require("../../src/server");
describe("API 集成测试", () => {
test("GET /api/build/list-dev 应该返回进程列表", async () => {
const response = await request(app).get("/api/build/list-dev").expect(200);
expect(response.body.success).toBe(true);
expect(Array.isArray(response.body.list)).toBe(true);
});
});
```
## 测试工具函数
`tests/setup.js` 中提供了全局测试工具:
```javascript
// 生成测试用的 projectId
const projectId = global.testUtils.generateProjectId();
// 生成测试用的版本号
const version = global.testUtils.generateVersion();
// 等待函数
await global.testUtils.sleep(1000); // 等待1秒
```
## 测试最佳实践
### 1. 测试命名
- 使用清晰的描述性名称
- 使用 `describe` 分组相关测试
- 使用 `test``it` 描述单个测试用例
### 2. 测试隔离
- 每个测试应该独立运行
- 使用 `beforeEach``afterEach` 清理状态
- 避免测试之间的依赖关系
### 3. 测试覆盖
- 测试正常情况
- 测试边界条件
- 测试错误情况
- 测试异常输入
### 4. Mock 和 Stub
```javascript
// Mock 外部依赖
jest.mock("../../src/utils/externalService");
// Mock 函数
const mockFn = jest.fn().mockReturnValue("mocked value");
// 验证调用
expect(mockFn).toHaveBeenCalledWith("expected argument");
expect(mockFn).toHaveBeenCalledTimes(1);
```
### 5. 异步测试
```javascript
// 使用 async/await
test("异步操作", async () => {
const result = await asyncFunction();
expect(result).toBe("expected");
});
// 使用 Promise
test("Promise 操作", () => {
return promiseFunction().then((result) => {
expect(result).toBe("expected");
});
});
```
## 常用断言
```javascript
// 相等性
expect(value).toBe(expected); // ===
expect(value).toEqual(expected); // 深度相等
// 真值
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// 数字
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3.5);
expect(value).toBeLessThan(5);
expect(value).toBeLessThanOrEqual(4.5);
// 字符串
expect(string).toMatch(/pattern/);
expect(string).toContain("substring");
// 数组
expect(array).toContain(item);
expect(array).toHaveLength(3);
// 对象
expect(object).toHaveProperty("key");
expect(object).toHaveProperty("key", "value");
// 异常
expect(() => {
throw new Error("error");
}).toThrow("error");
```
## 调试测试
### 1. 运行单个测试文件
```bash
npm test -- tests/unit/errorHandler.test.js
```
### 2. 运行匹配的测试
```bash
npm test -- --testNamePattern="应该正确创建"
```
### 3. 使用 Node 调试器
```bash
node --inspect-brk node_modules/.bin/jest --runInBand
```
## 持续集成
测试应该在每次提交前运行:
```bash
# 提交前运行测试
npm test
# 如果测试通过,再提交代码
git add .
git commit -m "feat: 添加新功能"
```
## 测试环境配置
测试环境使用独立的配置:
- 端口: 10003
- 日志: 禁用控制台输出
- 超时: 10 秒
这些配置在 `tests/setup.js` 中设置。
## 注意事项
1. **不要提交失败的测试** - 确保所有测试通过后再提交
2. **保持测试快速** - 单元测试应该在毫秒级完成
3. **定期更新测试** - 代码变更时同步更新测试
4. **编写有意义的测试** - 测试应该验证真实的业务逻辑
5. **避免测试实现细节** - 测试行为,而非实现
## 贡献测试
欢迎为项目添加更多测试!优先添加:
1. 核心业务逻辑的单元测试
2. API 端点的集成测试
3. 边界条件和错误处理测试
4. 工具函数的测试
## 相关资源
- [Jest 文档](https://jestjs.io/docs/getting-started)
- [Supertest 文档](https://github.com/visionmedia/supertest)
- [测试最佳实践](https://github.com/goldbergyoni/javascript-testing-best-practices)

View File

@@ -0,0 +1,33 @@
// Jest 测试环境设置
// 设置测试环境变量
process.env.NODE_ENV = "test";
process.env.PORT = "10003";
process.env.LOG_CONSOLE_ENABLED = "false";
// 增加测试超时时间ESM 兼容写法)
if (typeof jest !== "undefined") {
jest.setTimeout(10000);
}
// 全局测试工具
globalThis.testUtils = {
// 生成测试用的 projectId
generateProjectId: () => `test-project-${Date.now()}`,
// 生成测试用的版本号
generateVersion: () => Math.floor(Math.random() * 100),
// 等待函数
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
};
// 测试前清理
beforeAll(() => {
console.log("🧪 开始测试...");
});
// 测试后清理
afterAll(() => {
console.log("✅ 测试完成!");
});

View File

@@ -0,0 +1,406 @@
/**
* CLI 模块测试
*
* 测试 qiming-file-server CLI 相关的功能
*
* 覆盖范围:
* - serviceManager.js 服务管理器
* - envUtils.js 环境变量工具
* - PID 文件操作
* - 跨平台兼容性
*/
import path from "path";
import fs from "fs-extra";
import os from "os";
// 测试配置文件路径
const testConfig = {
testPidDir: path.join(os.tmpdir(), "qiming-file-server-test"),
testPidFile: path.join(
os.tmpdir(),
"qiming-file-server-test",
"server.pid"
),
};
describe("CLI Service Manager", () => {
let serviceManager;
beforeAll(async () => {
const module = await import("../../src/utils/serviceManager.js");
serviceManager = module.default || module;
});
afterEach(() => {
try {
if (fs.existsSync(testConfig.testPidFile)) {
fs.removeSync(testConfig.testPidFile);
}
} catch (err) {
// 忽略清理错误
}
});
describe("getPidFilePath", () => {
it("应该返回有效的 PID 文件路径", () => {
const pidPath = serviceManager.getPidFilePath();
expect(pidPath).toBeDefined();
expect(typeof pidPath).toBe("string");
expect(pidPath.length).toBeGreaterThan(0);
});
it("应该在临时目录中", () => {
const pidPath = serviceManager.getPidFilePath();
const tmpDir = os.tmpdir();
expect(pidPath.startsWith(tmpDir)).toBe(true);
});
});
describe("isWindows", () => {
it("应该正确检测当前操作系统", () => {
const isWin = serviceManager.isWindows();
const currentPlatform = process.platform;
if (currentPlatform === "win32") {
expect(isWin).toBe(true);
} else {
expect(isWin).toBe(false);
}
});
});
describe("isProcessRunning", () => {
it("应该正确判断不存在的进程", () => {
const result = serviceManager.isProcessRunning(999999999);
expect(result).toBe(false);
});
it("应该正确判断当前进程", () => {
const result = serviceManager.isProcessRunning(process.pid);
expect(result).toBe(true);
});
});
describe("readPidFile", () => {
it("应该返回 null 如果文件不存在", () => {
const result = serviceManager.readPidFile();
expect(result === null || result === undefined).toBe(true);
});
});
describe("writePidFile and readPidFile", () => {
it("应该能够写入和读取 PID 文件", () => {
const testPidInfo = {
pid: 12345,
startedAt: new Date().toISOString(),
env: "test",
port: "60000",
version: "1.0.0",
platform: process.platform,
};
const testPidPath = path.join(testConfig.testPidDir, "server.pid");
fs.ensureDirSync(testConfig.testPidDir);
fs.writeFileSync(testPidPath, JSON.stringify(testPidInfo, null, 2));
const content = fs.readFileSync(testPidPath, "utf8");
const readPidInfo = JSON.parse(content);
expect(readPidInfo.pid).toBe(12345);
expect(readPidInfo.env).toBe("test");
expect(readPidInfo.port).toBe("60000");
fs.removeSync(testConfig.testPidDir);
});
});
describe("formatUptime", () => {
it("应该正确格式化运行时间", () => {
const startedAt = new Date(Date.now() - 3600000).toISOString();
const result = serviceManager.formatUptime(startedAt);
expect(result).toContain("小时");
});
it("应该处理无效的日期", () => {
const result = serviceManager.formatUptime("invalid-date");
expect(result).toBe("未知");
});
});
describe("getServiceStatus", () => {
it("应该返回正确的服务状态", () => {
const status = serviceManager.getServiceStatus();
expect(status).toHaveProperty("running");
expect(status).toHaveProperty("pidInfo");
expect(status).toHaveProperty("message");
expect(typeof status.running).toBe("boolean");
});
});
});
describe("CLI Environment Utils", () => {
let envUtils;
beforeAll(async () => {
const module = await import("../../src/utils/envUtils.js");
envUtils = module.default || module;
});
describe("isWindows", () => {
it("应该正确检测 Windows 平台", () => {
const isWin = envUtils.isWindows();
const currentPlatform = process.platform;
expect(isWin).toBe(currentPlatform === "win32");
});
});
describe("normalizeEnvName", () => {
it("Windows 环境应该转为大写", () => {
if (process.platform === "win32") {
const result = envUtils.normalizeEnvName("test_env");
expect(result).toBe("TEST_ENV");
} else {
const result = envUtils.normalizeEnvName("test_env");
expect(result).toBe("test_env");
}
});
it("应该处理空字符串", () => {
const result = envUtils.normalizeEnvName("");
expect(result).toBe("");
});
});
describe("getEnv", () => {
beforeEach(() => {
process.env.TEST_VAR = "test-value";
});
afterEach(() => {
delete process.env.TEST_VAR;
});
it("应该返回已设置的环境变量", () => {
const result = envUtils.getEnv("TEST_VAR");
expect(result).toBe("test-value");
});
it("应该返回默认值如果未设置", () => {
const result = envUtils.getEnv("NON_EXISTENT_VAR", "default");
expect(result).toBe("default");
});
});
describe("getBoolEnv", () => {
beforeEach(() => {
process.env.TEST_BOOL_TRUE = "true";
process.env.TEST_BOOL_FALSE = "false";
process.env.TEST_BOOL_ONE = "1";
});
afterEach(() => {
delete process.env.TEST_BOOL_TRUE;
delete process.env.TEST_BOOL_FALSE;
delete process.env.TEST_BOOL_ONE;
});
it("应该正确解析 true 值", () => {
expect(envUtils.getBoolEnv("TEST_BOOL_TRUE")).toBe(true);
});
it("应该正确解析 1 值", () => {
expect(envUtils.getBoolEnv("TEST_BOOL_ONE")).toBe(true);
});
it("应该正确解析 false 值", () => {
expect(envUtils.getBoolEnv("TEST_BOOL_FALSE")).toBe(false);
});
it("应该返回默认值如果未设置", () => {
expect(envUtils.getBoolEnv("NON_EXISTENT", true)).toBe(true);
expect(envUtils.getBoolEnv("NON_EXISTENT", false)).toBe(false);
});
});
describe("getNumberEnv", () => {
beforeEach(() => {
process.env.TEST_NUMBER = "123";
process.env.TEST_NAN = "abc";
});
afterEach(() => {
delete process.env.TEST_NUMBER;
delete process.env.TEST_NAN;
});
it("应该正确解析数字", () => {
const result = envUtils.getNumberEnv("TEST_NUMBER");
expect(result).toBe(123);
});
it("应该返回默认值对于 NaN", () => {
const result = envUtils.getNumberEnv("TEST_NAN", 0);
expect(result).toBe(0);
});
it("应该返回默认值如果未设置", () => {
const result = envUtils.getNumberEnv("NON_EXISTENT", 42);
expect(result).toBe(42);
});
});
describe("parseEnvType", () => {
it("应该正确解析 dev", () => {
expect(envUtils.parseEnvType("dev")).toBe("development");
});
it("应该正确解析 prod", () => {
expect(envUtils.parseEnvType("prod")).toBe("production");
});
it("应该正确解析 test", () => {
expect(envUtils.parseEnvType("test")).toBe("test");
});
it("应该处理大小写", () => {
expect(envUtils.parseEnvType("DEV")).toBe("development");
expect(envUtils.parseEnvType("PROD")).toBe("production");
});
it("应该处理空值", () => {
expect(envUtils.parseEnvType("")).toBe("production");
expect(envUtils.parseEnvType(null)).toBe("production");
});
});
describe("loadEnvFromArgv", () => {
it("应该解析命令行参数", () => {
const originalArgv = process.argv;
process.argv = ["node", "test", "--test-key", "test-value"];
const result = envUtils.loadEnvFromArgv();
process.argv = originalArgv;
expect(result).toBeDefined();
});
});
});
describe("CLI Cross-Platform Compatibility", () => {
describe("Platform Detection", () => {
it("应该正确检测 darwin (macOS)", () => {
if (process.platform === "darwin") {
expect(process.platform).toBe("darwin");
}
});
it("应该正确检测 linux", () => {
if (process.platform === "linux") {
expect(process.platform).toBe("linux");
}
});
it("应该正确检测 win32", () => {
if (process.platform === "win32") {
expect(process.platform).toBe("win32");
}
});
});
describe("Path Handling", () => {
it("应该使用 path.join 进行路径拼接", () => {
const result = path.join("dir", "subdir", "file.js");
expect(result).toMatch(/dir[\/\\]subdir[\/\\]file\.js/);
});
it("应该使用 os.tmpdir() 获取临时目录", () => {
const tmpDir = os.tmpdir();
expect(tmpDir).toBeDefined();
expect(tmpDir.length).toBeGreaterThan(0);
});
it("应该使用 os.homedir() 获取主目录", () => {
const homeDir = os.homedir();
expect(homeDir).toBeDefined();
expect(homeDir.length).toBeGreaterThan(0);
});
});
describe("Environment Variables", () => {
it("应该能够读取环境变量", () => {
process.env.TEST_READ = "test";
expect(process.env.TEST_READ).toBe("test");
delete process.env.TEST_READ;
});
it("应该支持设置和删除环境变量", () => {
process.env.TEMP_TEST_VAR = "temp-value";
expect(process.env.TEMP_TEST_VAR).toBe("temp-value");
delete process.env.TEMP_TEST_VAR;
expect(process.env.TEMP_TEST_VAR).toBeUndefined();
});
});
});
describe("CLI Config", () => {
let config;
beforeAll(async () => {
const module = await import("../../src/appConfig/index.js");
config = module.default || module;
});
describe("CLI Configuration", () => {
it("应该包含 CLI 服务名称配置", () => {
expect(config.CLI_SERVICE_NAME).toBe("qiming-file-server");
});
it("应该包含 CLI PID 目录配置", () => {
expect(config.CLI_PID_DIR).toBeDefined();
expect(typeof config.CLI_PID_DIR).toBe("string");
});
it("应该包含 CLI 停止超时配置", () => {
expect(config.CLI_STOP_TIMEOUT).toBeDefined();
expect(typeof config.CLI_STOP_TIMEOUT).toBe("number");
});
it("应该包含 CLI 检查间隔配置", () => {
expect(config.CLI_CHECK_INTERVAL).toBeDefined();
expect(typeof config.CLI_CHECK_INTERVAL).toBe("number");
});
it("应该包含 Windows 平台检测配置", () => {
expect(config.CLI_IS_WINDOWS).toBeDefined();
expect(typeof config.CLI_IS_WINDOWS).toBe("boolean");
});
});
describe("Standard Configuration", () => {
it("应该包含标准配置项", () => {
expect(config.NODE_ENV).toBeDefined();
expect(config.PORT).toBeDefined();
expect(config.PROJECT_SOURCE_DIR).toBeDefined();
});
});
});

View File

@@ -0,0 +1,70 @@
import path from "path";
import { fileURLToPath } from "url";
import fs from "fs";
import {
getFileMtime,
shouldInstallDeps,
} from "../../src/utils/buildDependency/dependencyManager.js";
// 在 ESM 模块中获取当前文件的实际路径
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
describe("依赖管理器测试", () => {
describe("getFileMtime", () => {
test("存在的文件应该返回时间戳", () => {
// 使用当前测试文件作为测试对象,确保它存在且可访问
const filePath = path.join(__dirname, "dependencyManager.test.js");
const mtime = getFileMtime(filePath);
expect(typeof mtime).toBe("number");
expect(mtime).toBeGreaterThan(0);
});
test("不存在的文件应该返回0", () => {
const filePath = "/path/to/nonexistent/file.txt";
const mtime = getFileMtime(filePath);
expect(mtime).toBe(0);
});
});
describe("shouldInstallDeps", () => {
const testProjectPath = "/tmp/test-project-deps";
beforeEach(() => {
if (fs.existsSync(testProjectPath)) {
fs.rmSync(testProjectPath, { recursive: true, force: true });
}
fs.mkdirSync(testProjectPath, { recursive: true });
});
afterEach(() => {
if (fs.existsSync(testProjectPath)) {
fs.rmSync(testProjectPath, { recursive: true, force: true });
}
});
test("不存在 node_modules 应该返回true", () => {
fs.writeFileSync(path.join(testProjectPath, "package.json"), "{}");
const result = shouldInstallDeps(testProjectPath);
expect(result).toBe(true);
});
test("存在 node_modules 但 package.json 更新应该返回true", async () => {
const nodeModulesPath = path.join(testProjectPath, "node_modules");
fs.mkdirSync(nodeModulesPath);
await new Promise((resolve) => setTimeout(resolve, 10));
fs.writeFileSync(path.join(testProjectPath, "package.json"), "{}");
const result = shouldInstallDeps(testProjectPath);
expect(result).toBe(true);
});
test("package.json 和 node_modules 都存在且时间正常应该返回false", async () => {
fs.writeFileSync(path.join(testProjectPath, "package.json"), "{}");
await new Promise((resolve) => setTimeout(resolve, 10));
const nodeModulesPath = path.join(testProjectPath, "node_modules");
fs.mkdirSync(nodeModulesPath);
const result = shouldInstallDeps(testProjectPath);
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,96 @@
import {
ValidationError,
BusinessError,
SystemError,
ResourceError,
FileError,
ProcessError,
formatErrorResponse,
classifyError,
} from "../../src/utils/error/errorHandler.js";
describe("错误处理工具测试", () => {
describe("自定义错误类", () => {
test("ValidationError 应该正确创建", () => {
const error = new ValidationError("测试错误", { field: "test" });
expect(error.name).toBe("ValidationError");
expect(error.message).toBe("测试错误");
expect(error.statusCode).toBe(400);
expect(error.details).toEqual({ field: "test" });
expect(error.isOperational).toBe(true);
});
test("BusinessError 应该正确创建", () => {
const error = new BusinessError("业务错误");
expect(error.name).toBe("BusinessError");
expect(error.statusCode).toBe(400);
});
test("SystemError 应该正确创建", () => {
const error = new SystemError("系统错误");
expect(error.name).toBe("SystemError");
expect(error.statusCode).toBe(500);
});
test("ResourceError 应该正确创建", () => {
const error = new ResourceError("资源不存在");
expect(error.name).toBe("ResourceError");
expect(error.statusCode).toBe(404);
});
test("FileError 应该正确创建", () => {
const error = new FileError("文件错误");
expect(error.name).toBe("FileError");
expect(error.statusCode).toBe(500);
});
test("ProcessError 应该正确创建", () => {
const error = new ProcessError("进程错误");
expect(error.name).toBe("ProcessError");
expect(error.statusCode).toBe(500);
});
});
describe("formatErrorResponse", () => {
test("应该正确格式化错误响应", () => {
const error = new ValidationError("测试错误", { field: "test" });
const response = formatErrorResponse(error, "req-123");
expect(response.success).toBe(false);
expect(response.error.type).toBe("VALIDATION_ERROR");
expect(response.error.message).toBe("测试错误");
expect(response.error.requestId).toBe("req-123");
});
test("应该包含 timestamp", () => {
const error = new BusinessError("测试");
const response = formatErrorResponse(error);
expect(response.error.timestamp).toBeDefined();
});
});
describe("classifyError", () => {
test("应该正确分类 ValidationError", () => {
const error = new ValidationError("测试");
expect(classifyError(error)).toBe("VALIDATION_ERROR");
});
test("应该正确分类 ENOENT 错误", () => {
const error = new Error("文件不存在");
error.code = "ENOENT";
expect(classifyError(error)).toBe("RESOURCE_ERROR");
});
test("应该正确分类 EACCES 错误", () => {
const error = new Error("权限不足");
error.code = "EACCES";
expect(classifyError(error)).toBe("PERMISSION_ERROR");
});
test("应该正确分类未知错误", () => {
const error = new Error("未知错误");
expect(classifyError(error)).toBe("UNKNOWN_ERROR");
});
});
});

View File

@@ -0,0 +1,96 @@
import {
shouldRestartForSingleFile,
shouldRestartDevServer,
} from "../../src/utils/buildJudge/restartJudgeUtils.js";
describe("重启判断工具测试", () => {
describe("shouldRestartForSingleFile", () => {
test("package.json 应该需要重启", () => {
expect(shouldRestartForSingleFile("package.json")).toBe(true);
});
test("vite.config.js 应该需要重启", () => {
expect(shouldRestartForSingleFile("vite.config.js")).toBe(true);
expect(shouldRestartForSingleFile("vite.config.ts")).toBe(true);
});
test("webpack.config.js 应该需要重启", () => {
expect(shouldRestartForSingleFile("webpack.config.js")).toBe(true);
});
test(".env 文件应该需要重启", () => {
expect(shouldRestartForSingleFile(".env")).toBe(true);
expect(shouldRestartForSingleFile(".env.development")).toBe(true);
});
test("index.html 应该需要重启", () => {
expect(shouldRestartForSingleFile("index.html")).toBe(true);
});
test("入口文件应该需要重启", () => {
expect(shouldRestartForSingleFile("src/main.js")).toBe(true);
expect(shouldRestartForSingleFile("src/index.ts")).toBe(true);
expect(shouldRestartForSingleFile("main.jsx")).toBe(true);
expect(shouldRestartForSingleFile("App.tsx")).toBe(true);
});
test("lock 文件应该需要重启", () => {
expect(shouldRestartForSingleFile("package-lock.json")).toBe(true);
expect(shouldRestartForSingleFile("yarn.lock")).toBe(true);
expect(shouldRestartForSingleFile("pnpm-lock.yaml")).toBe(true);
});
test("普通组件文件不需要重启", () => {
expect(shouldRestartForSingleFile("src/components/Button.tsx")).toBe(
false
);
expect(shouldRestartForSingleFile("src/utils/helper.js")).toBe(false);
expect(shouldRestartForSingleFile("src/styles/main.css")).toBe(false);
});
test("空字符串不需要重启", () => {
expect(shouldRestartForSingleFile("")).toBe(false);
});
test("非字符串参数不需要重启", () => {
expect(shouldRestartForSingleFile(null)).toBe(false);
expect(shouldRestartForSingleFile(undefined)).toBe(false);
expect(shouldRestartForSingleFile(123)).toBe(false);
});
});
describe("shouldRestartDevServer", () => {
test("包含需要重启的文件应该返回true", () => {
const files = [
{ name: "src/components/Button.tsx" },
{ name: "package.json" },
{ name: "src/App.tsx" },
];
expect(shouldRestartDevServer(files)).toBe(true);
});
test("都是普通文件应该返回false", () => {
const files = [
{ name: "src/components/Button.tsx" },
{ name: "src/utils/helper.js" },
{ name: "src/styles/main.css" },
];
expect(shouldRestartDevServer(files)).toBe(false);
});
test("空数组应该返回false", () => {
expect(shouldRestartDevServer([])).toBe(false);
});
test("非数组参数应该返回false", () => {
expect(shouldRestartDevServer(null)).toBe(false);
expect(shouldRestartDevServer(undefined)).toBe(false);
expect(shouldRestartDevServer("test")).toBe(false);
});
test("文件对象缺少name属性应该被忽略", () => {
const files = [{ contents: "test" }, { name: "package.json" }];
expect(shouldRestartDevServer(files)).toBe(true);
});
});
});

View File

@@ -0,0 +1,34 @@
import {
uploadAttachmentFile,
} from "../../src/utils/project/uploadAttachmentFileUtils.js";
import {
ValidationError,
} from "../../src/utils/error/errorHandler.js";
describe("uploadAttachmentFileUtils", () => {
describe("参数验证测试", () => {
test("项目ID为空应该抛出 ValidationError", async () => {
const mockFile = {
originalname: "test.txt",
path: "/tmp/test.txt",
size: 4,
};
await expect(uploadAttachmentFile("", mockFile)).rejects.toThrow(ValidationError);
});
test("文件对象为空应该抛出 ValidationError", async () => {
await expect(uploadAttachmentFile("test-project", null)).rejects.toThrow(ValidationError);
});
test("文件路径为空应该抛出 ValidationError", async () => {
const mockFile = {
originalname: "test.txt",
path: "",
size: 4,
};
await expect(uploadAttachmentFile("test-project", mockFile)).rejects.toThrow(ValidationError);
});
});
});