From 6f4f625c562a91ac95fe4a6f8c85851ec84b1238 Mon Sep 17 00:00:00 2001 From: shen <> Date: Sat, 6 Jun 2026 17:42:27 +0800 Subject: [PATCH] Initial commit: iOS Build Server - FastAPI backend with build queue, WebSocket logs, task management - Vue 3 frontend with build/config/history views - Xcode project build automation with IPA export - Fix: initialize build_dir before try block to ensure cleanup on early failure --- .env.example | 43 + .gitignore | 32 + README.md | 514 +++ backend/__init__.py | 0 backend/config.py | 67 + backend/database.py | 39 + backend/main.py | 75 + backend/models.py | 50 + backend/routers/__init__.py | 0 backend/routers/apps.py | 27 + backend/routers/auth.py | 19 + backend/routers/config.py | 398 +++ backend/routers/tasks.py | 158 + backend/schemas.py | 59 + backend/services/__init__.py | 0 backend/services/build_queue.py | 80 + backend/services/build_service.py | 590 ++++ backend/services/log_streamer.py | 86 + deploy.sh | 459 +++ deploy/install-service.sh | 145 + frontend/index.html | 12 + frontend/package-lock.json | 3649 ++++++++++++++++++++ frontend/package.json | 24 + frontend/src/App.vue | 107 + frontend/src/__tests__/App.test.js | 128 + frontend/src/__tests__/BuildView.test.js | 142 + frontend/src/__tests__/ConfigView.test.js | 164 + frontend/src/__tests__/HistoryView.test.js | 179 + frontend/src/composables/useWebSocket.js | 42 + frontend/src/main.js | 7 + frontend/src/router/index.js | 18 + frontend/src/views/BuildView.vue | 338 ++ frontend/src/views/ConfigView.vue | 804 +++++ frontend/src/views/HistoryView.vue | 195 ++ frontend/vite.config.js | 19 + frontend/vitest.config.js | 10 + pytest.ini | 5 + requirements-dev.txt | 3 + requirements.txt | 5 + start.sh | 57 + tests/conftest.py | 59 + tests/test_api_apps.py | 21 + tests/test_api_auth.py | 19 + tests/test_api_config.py | 146 + tests/test_api_health.py | 8 + tests/test_api_tasks.py | 114 + tests/test_build_queue.py | 118 + tests/test_build_service.py | 292 ++ tests/test_downloads.py | 151 + tests/test_edge_cases.py | 160 + tests/test_websocket.py | 131 + 51 files changed, 9968 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/__init__.py create mode 100644 backend/config.py create mode 100644 backend/database.py create mode 100644 backend/main.py create mode 100644 backend/models.py create mode 100644 backend/routers/__init__.py create mode 100644 backend/routers/apps.py create mode 100644 backend/routers/auth.py create mode 100644 backend/routers/config.py create mode 100644 backend/routers/tasks.py create mode 100644 backend/schemas.py create mode 100644 backend/services/__init__.py create mode 100644 backend/services/build_queue.py create mode 100644 backend/services/build_service.py create mode 100644 backend/services/log_streamer.py create mode 100755 deploy.sh create mode 100755 deploy/install-service.sh create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/__tests__/App.test.js create mode 100644 frontend/src/__tests__/BuildView.test.js create mode 100644 frontend/src/__tests__/ConfigView.test.js create mode 100644 frontend/src/__tests__/HistoryView.test.js create mode 100644 frontend/src/composables/useWebSocket.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/views/BuildView.vue create mode 100644 frontend/src/views/ConfigView.vue create mode 100644 frontend/src/views/HistoryView.vue create mode 100644 frontend/vite.config.js create mode 100644 frontend/vitest.config.js create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100755 start.sh create mode 100644 tests/conftest.py create mode 100644 tests/test_api_apps.py create mode 100644 tests/test_api_auth.py create mode 100644 tests/test_api_config.py create mode 100644 tests/test_api_health.py create mode 100644 tests/test_api_tasks.py create mode 100644 tests/test_build_queue.py create mode 100644 tests/test_build_service.py create mode 100644 tests/test_downloads.py create mode 100644 tests/test_edge_cases.py create mode 100644 tests/test_websocket.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1255727 --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# === iOS 自动打包服务配置 === +# 复制此文件为 .env 并修改为实际值 + +# ---- 项目路径 ---- +# iOS 源码项目根目录(也用于 AutoPacking 脚本路径) +PROJECT_ROOT=/Users/shen/Work/Code/Readoor + +# 打包输出基础目录(每次打包会在此目录下创建子目录) +BUILD_BASE_DIR=/Users/shen/Documents + +# ---- 分支源码管理 ---- +# 分支源码根目录,每个分支一个子目录(如 ReadoorBranches/main、ReadoorBranches/dev) +GIT_SOURCE_BASE=/Users/shen/Work/Code/ReadoorBranches + +# Git 远程仓库地址,分支目录不存在时自动 clone +GIT_REMOTE_URL=git@github.com:your-org/readoor.git + +# ---- 服务端口 ---- +BACKEND_PORT=8000 + +# ---- 管理员账号 ---- +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 + +# ---- 并发与清理 ---- +# 最大并行打包数 +MAX_CONCURRENT_BUILDS=2 + +# 打包目录保留时间(小时) +BUILD_DIR_RETENTION_HOURS=24 + +# ---- Watchdog 健康监测 ---- +# 健康检查间隔(秒) +WATCHDOG_INTERVAL=30 + +# 健康检查 HTTP 超时(秒) +WATCHDOG_TIMEOUT=10 + +# 连续重启次数上限,超过后进入冷却 +MAX_RESTART_ATTEMPTS=3 + +# 冷却时间(秒),连续重启超限后等待多久再重试 +COOLDOWN_SECONDS=300 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc2e9e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# 环境变量(含密码) +.env + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ + +# 数据库 +*.db + +# 日志 +logs/ + +# 服务器 PID +.server.pid +.watchdog.pid + +# 前端 +node_modules/ +frontend/node_modules/ +backend/static/ + +# macOS +.DS_Store + +# IDE +.idea/ +.vscode/ +*.swp +*.swo diff --git a/README.md b/README.md new file mode 100644 index 0000000..3aa55fe --- /dev/null +++ b/README.md @@ -0,0 +1,514 @@ +# iOS 自动打包服务 + +基于 FastAPI + Vue 3 的 iOS 应用自动打包服务,支持 Ad_Hoc 和 App_Store 两种打包类型,提供 Web 界面操作、实时日志流、构建历史管理和健康监测。 + +## 目录 + +- [环境要求](#环境要求) +- [快速开始](#快速开始) +- [配置说明](#配置说明) +- [服务管理](#服务管理) +- [Watchdog 健康监测](#watchdog-健康监测) +- [macOS 开机自启](#macos-开机自启) +- [开发模式](#开发模式) +- [自动化测试](#自动化测试) +- [项目结构](#项目结构) +- [常见问题](#常见问题) + +--- + +## 环境要求 + +| 依赖 | 版本要求 | 说明 | +|------|----------|------| +| macOS | 11+ | 必须,xcodebuild 依赖 | +| Xcode | 14+ | 必须,含 Command Line Tools | +| Python | 3.9+ | 后端运行 | +| Node.js | 18+ | 前端构建 | +| CocoaPods | - | 项目已有 Podfile 时需要 | + +## 快速开始 + +### 1. 克隆项目 + +```bash +git clone BuildServer +cd BuildServer +``` + +### 2. 配置环境变量 + +```bash +cp .env.example .env +vim .env +``` + +**必须修改的配置项:** + +```bash +# iOS 源码项目路径(包含 readoor.xcworkspace 的目录) +PROJECT_ROOT=/path/to/your/Readoor + +# 打包输出目录 +BUILD_BASE_DIR=/path/to/build/output + +# 管理员密码(建议修改) +ADMIN_PASSWORD=your_secure_password +``` + +### 3. 构建与启动 + +```bash +# 一键构建(安装依赖 + 构建前端) +./deploy.sh build + +# 启动服务 +./deploy.sh start +``` + +启动后访问 `http://<服务器IP>:8000` 即可使用。 + +### 4.(可选)初始化分支源码目录 + +如果需要按分支打包,先准备分支源码目录: + +```bash +# 创建分支源码根目录 +mkdir -p /path/to/ReadoorBranches + +# 手动 clone 主分支(后续自动 pull 更新) +git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main +``` + +其他分支无需手动 clone,首次打包时会自动从远程仓库 clone。 + +### 5.(可选)启用健康监测 + +```bash +./deploy.sh watchdog +``` + +### 6.(可选)设为开机自启 + +```bash +./deploy/install-service.sh install +``` + +--- + +## 配置说明 + +所有配置通过项目根目录的 `.env` 文件管理,修改后重启服务生效。 + +### 项目路径 + +| 变量 | 示例 | 说明 | +|------|------|------| +| `PROJECT_ROOT` | `/Users/shen/Work/Code/Readoor` | iOS 源码项目根目录(AutoPacking 脚本路径) | +| `BUILD_BASE_DIR` | `/Users/shen/Documents` | 打包产物输出基础目录 | +| `GIT_SOURCE_BASE` | `/path/to/ReadoorBranches` | 分支源码根目录,每个分支一个子目录 | +| `GIT_REMOTE_URL` | `git@github.com:org/repo.git` | 远程仓库地址,分支目录不存在时自动 clone | + +### 服务配置 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `BACKEND_PORT` | `8000` | 后端服务端口 | +| `ADMIN_USERNAME` | `admin` | 管理员用户名 | +| `ADMIN_PASSWORD` | `admin123` | 管理员密码 | + +### 打包配置 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `MAX_CONCURRENT_BUILDS` | `2` | 最大并行打包数 | +| `BUILD_DIR_RETENTION_HOURS` | `24` | 打包目录保留时间(小时),超期自动清理 | + +### Watchdog 配置 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `WATCHDOG_INTERVAL` | `30` | 健康检查间隔(秒) | +| `WATCHDOG_TIMEOUT` | `10` | HTTP 健康检查超时(秒) | +| `MAX_RESTART_ATTEMPTS` | `3` | 连续重启上限,超过后进入冷却 | +| `COOLDOWN_SECONDS` | `300` | 冷却时间(秒) | + +--- + +## 服务管理 + +所有服务管理通过 `deploy.sh` 完成: + +```bash +./deploy.sh +``` + +### 命令一览 + +| 命令 | 说明 | +|------|------| +| `build` | 构建前端、创建 Python 虚拟环境、安装所有依赖 | +| `start` | 后台启动服务 | +| `stop` | 停止服务和 watchdog | +| `restart` | 重启服务 | +| `status` | 查看服务和 watchdog 运行状态 | +| `test` | 一键运行全部测试(后端 + 前端) | +| `watchdog` | 启动健康监测(后台守护) | +| `watchdog-stop` | 停止健康监测 | +| `watchdog-fg` | 前台运行健康监测(调试用) | + +### 常用操作 + +```bash +# 首次部署 +./deploy.sh build && ./deploy.sh start + +# 查看状态 +./deploy.sh status + +# 更新代码后重新部署 +git pull +./deploy.sh build +./deploy.sh restart + +# 启用健康监测 +./deploy.sh watchdog + +# 停止所有服务 +./deploy.sh stop +``` + +### 日志 + +日志文件位于 `logs/` 目录: + +``` +logs/ +├── server.log # 服务运行日志 +├── watchdog.log # Watchdog 操作日志(重启记录等) +├── server-stdout.log # launchd 模式下的标准输出 +├── server-stderr.log # launchd 模式下的标准错误 +├── watchdog-stdout.log # launchd watchdog 标准输出 +└── watchdog-stderr.log # launchd watchdog 标准错误 +``` + +--- + +## Watchdog 健康监测 + +Watchdog 定期检查服务健康状态,发现异常自动重启。 + +### 检测逻辑 + +``` +每 N 秒执行一次 + │ + ├─ 进程是否存在? (kill -0) + │ └─ 否 → 重启 + │ + └─ HTTP /api/health 返回 200? + ├─ 是 → 正常,继续轮询 + └─ 否 → 重启 +``` + +### 重启保护 + +- 连续失败次数 <= `MAX_RESTART_ATTEMPTS`:立即重启 +- 连续失败次数 > `MAX_RESTART_ATTEMPTS`:进入冷却期(`COOLDOWN_SECONDS` 秒),冷却结束后重置计数器重新尝试 + +### 运行方式 + +```bash +# 后台守护(推荐) +./deploy.sh watchdog + +# 前台运行(调试,可看到实时输出) +./deploy.sh watchdog-fg + +# 查看 watchdog 日志 +tail -f logs/watchdog.log + +# 停止 watchdog +./deploy.sh watchdog-stop +``` + +--- + +## macOS 开机自启 + +通过 macOS 原生的 launchd 实现开机自启和进程守护。 + +### 安装 + +```bash +# 前置条件:先完成构建 +./deploy.sh build + +# 安装服务 +./deploy/install-service.sh install +``` + +安装后会注册两个 launchd 服务: + +| 服务 | 说明 | +|------|------| +| `com.readoor.buildserver` | 主服务,进程崩溃自动拉起 | +| `com.readoor.buildserver.watchdog` | 健康监测,检测 HTTP 卡死并重启 | + +### 卸载 + +```bash +./deploy/install-service.sh uninstall +``` + +### 手动管理 launchd 服务 + +```bash +# 查看服务状态 +launchctl list | grep readoor + +# 手动停止 +launchctl unload ~/Library/LaunchAgents/com.readoor.buildserver.plist +launchctl unload ~/Library/LaunchAgents/com.readoor.buildserver.watchdog.plist + +# 手动启动 +launchctl load ~/Library/LaunchAgents/com.readoor.buildserver.plist +launchctl load ~/Library/LaunchAgents/com.readoor.buildserver.watchdog.plist +``` + +--- + +## 开发模式 + +一键启动本地开发环境(后端 + 前端同时运行): + +```bash +./start.sh +``` + +- 前端界面:`http://localhost:3000`(自动代理 API 请求到后端) +- 后端 API:`http://localhost:8000` +- 前端修改自动热更新,后端修改自动 reload +- `Ctrl+C` 同时停止所有服务 + +--- + +## 自动化测试 + +### 运行测试 + +```bash +# 一键运行全部测试(后端 89 + 前端 35 = 124 个) +./deploy.sh test + +# 只运行匹配的测试 +./deploy.sh test -k test_login + +# 单独运行后端测试 +.venv/bin/python -m pytest tests/ -v + +# 单独运行前端测试 +cd frontend && npx vitest run +``` + +### 测试覆盖 + +| 测试文件 | 覆盖内容 | 数量 | +|----------|----------|------| +| `test_api_health.py` | 健康检查 | 1 | +| `test_api_auth.py` | 登录认证 | 3 | +| `test_api_config.py` | App/Scheme/Branch/Server/Build 配置 CRUD | 20 | +| `test_api_tasks.py` | 任务创建/列表/详情/取消 | 9 | +| `test_api_apps.py` | 打包选择接口 | 3 | +| `test_websocket.py` | 日志流订阅/发送/完成/清理 | 10 | +| `test_downloads.py` | dSYM/混淆映射/二维码下载 | 11 | +| `test_build_service.py` | 源码更新/拷贝/配置生成/目录清理 | 11 | +| `test_build_queue.py` | 并发控制/任务执行/取消 | 7 | +| `test_edge_cases.py` | 边界情况/无效参数/配置备份 | 14 | +| `App.test.js` | 登录/退出/导航 | 8 | +| `BuildView.vue.test.js` | 打包表单/任务列表/提交 | 8 | +| `ConfigView.vue.test.js` | 配置管理/分支增删/设置保存 | 8 | +| `HistoryView.vue.test.js` | 历史列表/过滤/下载/二维码 | 11 | + +--- + +## 项目结构 + +``` +BuildServer/ +├── .env.example # 环境变量模板 +├── .env # 实际配置(不入库) +├── .gitignore +├── README.md # 项目文档 +├── deploy.sh # 部署与管理脚本 +├── start.sh # 开发模式启动脚本(一键启动前后端) +├── requirements.txt # Python 依赖 +├── requirements-dev.txt # 测试依赖(pytest, httpx) +├── pytest.ini # pytest 配置 +│ +├── deploy/ # 部署相关 +│ └── install-service.sh # macOS launchd 服务安装 +│ +├── tests/ # 后端测试(pytest) +│ ├── conftest.py # 测试 fixtures +│ ├── test_api_*.py # API 接口测试 +│ ├── test_websocket.py # WebSocket 测试 +│ ├── test_downloads.py # 文件下载测试 +│ ├── test_build_service.py # 打包服务测试 +│ ├── test_build_queue.py # 队列测试 +│ └── test_edge_cases.py # 边界情况测试 +│ +├── backend/ # 后端(FastAPI) +│ ├── main.py # 入口,路由注册,静态文件服务 +│ ├── config.py # 配置加载(读取 .env) +│ ├── database.py # SQLAlchemy 数据库初始化 +│ ├── models.py # 数据库模型(Task, BuildConfig) +│ ├── schemas.py # Pydantic 请求/响应模型 +│ ├── routers/ +│ │ ├── auth.py # 登录认证 +│ │ ├── config.py # 配置管理(App/Scheme/Server CRUD) +│ │ ├── apps.py # 打包选择(只读) +│ │ └── tasks.py # 任务管理(创建/列表/取消/下载) +│ └── services/ +│ ├── build_service.py # 打包核心流程 +│ ├── build_queue.py # 异步任务队列 +│ └── log_streamer.py # WebSocket 日志流 +│ +└── frontend/ # 前端(Vue 3 + Vite) + ├── vite.config.js # 构建配置,开发代理 + ├── vitest.config.js # 前端测试配置 + ├── index.html + └── src/ + ├── App.vue # 根组件(导航/登录) + ├── router/index.js # 路由 + ├── composables/ + │ └── useWebSocket.js + ├── views/ + │ ├── BuildView.vue # 打包页面 + │ ├── HistoryView.vue # 历史记录 + │ └── ConfigView.vue # 管理后台 + └── __tests__/ # 前端测试(vitest) + ├── App.test.js + ├── BuildView.test.js + ├── ConfigView.test.js + └── HistoryView.test.js +``` + +--- + +## 常见问题 + +### Q: 打包时提示 "未找到 xcodebuild" + +确保安装了 Xcode 和 Command Line Tools: + +```bash +xcode-select --install +xcodebuild -version +``` + +### Q: 前端页面打不开 + +检查前端是否已构建: + +```bash +ls backend/static/index.html # 应该存在 +# 如果不存在: +./deploy.sh build +./deploy.sh restart +``` + +### Q: 端口被占用 + +修改 `.env` 中的 `BACKEND_PORT`,然后重启: + +```bash +vim .env +./deploy.sh restart +``` + +### Q: 打包目录占满磁盘 + +调整保留时间或手动清理: + +```bash +# 缩短保留时间 +vim .env # BUILD_DIR_RETENTION_HOURS=12 +./deploy.sh restart + +# 手动清理所有打包目录 +rm -rf /path/to/build/output/build_readoor_* +``` + +### Q: Watchdog 频繁重启 + +查看 watchdog 日志定位原因: + +```bash +tail -50 logs/watchdog.log +tail -50 logs/server.log +``` + +常见原因: +- `PROJECT_ROOT` 路径不存在 +- 磁盘空间不足 +- Python 依赖缺失(重新执行 `./deploy.sh build`) + +### Q: 如何修改管理员密码 + +编辑 `.env`: + +```bash +ADMIN_PASSWORD=new_password +./deploy.sh restart +``` + +### Q: 多台 Mac 部署 + +每台 Mac 上: + +```bash +git clone +cd BuildServer +cp .env.example .env +# 编辑 .env 配置路径和密码 +./deploy.sh build +./deploy.sh start +./deploy.sh watchdog +``` + +各机器独立运行,配置互不影响。 + +### Q: 如何按分支打包 + +1. 在 `.env` 中配置 `GIT_SOURCE_BASE` 和 `GIT_REMOTE_URL` +2. 在管理页面「分支管理」中添加需要打包的分支(默认已有 `main`) +3. 打包时在「代码分支」下拉框中选择分支 +4. 首次打包某个分支时会自动从远程 clone,后续打包会自动 `git pull` 更新 +5. 每个分支有独立的源码目录,支持并行打包不同分支 + +### Q: 分支目录占满磁盘怎么办 + +```bash +# 查看各分支目录大小 +du -sh /path/to/ReadoorBranches/* + +# 删除不用的分支目录 +rm -rf /path/to/ReadoorBranches/old-branch +``` + +### Q: 如何运行测试 + +```bash +# 一键运行全部测试(后端 + 前端) +./deploy.sh test + +# 只运行后端测试 +.venv/bin/python -m pytest tests/ -v + +# 只运行前端测试 +cd frontend && npx vitest run + +# 运行指定测试 +./deploy.sh test -k test_login +``` diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..3a5456b --- /dev/null +++ b/backend/config.py @@ -0,0 +1,67 @@ +"""项目配置 — 从环境变量读取,支持 .env 文件""" +import os +from pathlib import Path + +# 自动加载 .env 文件(如果存在) +_env_file = Path(__file__).parent.parent / ".env" +if _env_file.exists(): + with open(_env_file) as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip()) + +# 项目根目录(iOS 源码,也作为 AutoPacking 脚本来源) +PROJECT_ROOT = Path(os.getenv("PROJECT_ROOT", "/Users/shen/Work/Code/Readoor")) + +# AutoPacking 目录 +AUTOPACKING_DIR = PROJECT_ROOT / "AutoPacking" + +# config.json 路径 +CONFIG_JSON_PATH = AUTOPACKING_DIR / "config.json" + +# 打包基础目录 +BUILD_BASE_DIR = Path(os.getenv("BUILD_BASE_DIR", "/Users/shen/Documents")) + +# Git 分支源码目录 +# GIT_SOURCE_BASE: 分支源码的根目录,每个分支一个子目录 +# GIT_REMOTE_URL: 远程仓库地址,分支目录不存在时自动 clone +GIT_SOURCE_BASE = Path(os.getenv("GIT_SOURCE_BASE", str(PROJECT_ROOT.parent / "ReadoorBranches"))) +GIT_REMOTE_URL = os.getenv("GIT_REMOTE_URL", "") + + +def get_source_dir(branch: str) -> Path: + """获取指定分支的源码目录""" + return GIT_SOURCE_BASE / branch + +# 需要拷贝的目录和文件 +COPY_ITEMS = [ + "readoor", + "readoor.xcodeproj", + "readoorTests", + "AutoPacking", + "podfile", + "Pods", + "Podfile.lock", + "readoor.xcworkspace", +] + +# 服务端口 +BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8000")) + +# 数据库路径 +DATABASE_URL = f"sqlite:///{Path(__file__).parent / 'build_server.db'}" + +# WebSocket 日志队列最大长度 +LOG_QUEUE_MAX_SIZE = 1000 + +# 默认最大并行打包数 +DEFAULT_MAX_CONCURRENT_BUILDS = int(os.getenv("MAX_CONCURRENT_BUILDS", "2")) + +# 默认打包目录保留时间(小时) +DEFAULT_BUILD_DIR_RETENTION_HOURS = int(os.getenv("BUILD_DIR_RETENTION_HOURS", "24")) + +# 管理员账号配置 +ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin") +ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123") diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..cb6c290 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,39 @@ +"""数据库配置""" +from sqlalchemy import create_engine, text +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from .config import DATABASE_URL + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + """获取数据库会话""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def _migrate_db(): + """简单的数据库迁移:为旧表添加缺失的列""" + migrations = [ + ("tasks", "branch", "VARCHAR DEFAULT 'main'"), + ] + with engine.connect() as conn: + for table, column, col_type in migrations: + try: + conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")) + conn.commit() + except Exception: + pass # 列已存在则忽略 + + +def init_db(): + """初始化数据库""" + Base.metadata.create_all(bind=engine) + _migrate_db() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..81793d2 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,75 @@ +"""FastAPI 主入口""" +from contextlib import asynccontextmanager +from pathlib import Path +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + +from .database import init_db +from .routers import config, apps, tasks, auth +from .services.log_streamer import log_streamer +from .config import BACKEND_PORT + +STATIC_DIR = Path(__file__).parent / "static" + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期""" + init_db() + yield + + +app = FastAPI( + title="iOS 自动打包服务", + version="1.0.0", + lifespan=lifespan, +) + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 路由 +app.include_router(auth.router) +app.include_router(config.router) +app.include_router(apps.router) +app.include_router(tasks.router) + + +@app.websocket("/ws/tasks/{task_id}") +async def websocket_logs(websocket: WebSocket, task_id: str): + """WebSocket 实时日志""" + await websocket.accept() + try: + async for msg in log_streamer.subscribe(task_id): + await websocket.send_json(msg) + except WebSocketDisconnect: + pass + except Exception: + pass + + +@app.get("/api/health") +async def health(): + """健康检查""" + return {"status": "ok", "port": BACKEND_PORT} + + +# 静态文件服务(生产模式:前端 build 产物由 FastAPI 直接 serving) +if STATIC_DIR.exists(): + app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="assets") + + @app.get("/{full_path:path}") + async def serve_spa(request: Request, full_path: str): + """SPA catch-all:非 API 路由统一返回 index.html""" + file_path = STATIC_DIR / full_path + if file_path.is_file(): + return FileResponse(str(file_path)) + return FileResponse(str(STATIC_DIR / "index.html")) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..aef4c87 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,50 @@ +"""数据库模型""" +from datetime import datetime +from sqlalchemy import Column, String, Boolean, Text, TIMESTAMP +from .database import Base + + +class Task(Base): + """打包任务表""" + __tablename__ = "tasks" + + id = Column(String, primary_key=True) + app_id = Column(String, nullable=False) + app_name = Column(String, nullable=False) + build_type = Column(String, nullable=False) + scheme_id = Column(String, nullable=False) + scheme_name = Column(String, nullable=False) + obfuscation = Column(Boolean, default=False) + branch = Column(String, default="main") + + # 状态 + status = Column(String, default="pending") # pending/running/completed/failed/cancelled + current_step = Column(String) + + # 时间 + created_at = Column(TIMESTAMP, default=datetime.utcnow) + started_at = Column(TIMESTAMP) + completed_at = Column(TIMESTAMP) + + # 产物 + ipa_path = Column(String) + oss_url = Column(String) + dsym_path = Column(String) + obfuscation_maps_path = Column(String) + qr_code_path = Column(String) + build_dir = Column(String) + + # 错误 + error_message = Column(Text) + + # 配置快照 + config_json = Column(Text) + + +class BuildConfig(Base): + """打包配置表""" + __tablename__ = "build_config" + + key = Column(String, primary_key=True) + value = Column(String) + updated_at = Column(TIMESTAMP, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routers/apps.py b/backend/routers/apps.py new file mode 100644 index 0000000..1f9291b --- /dev/null +++ b/backend/routers/apps.py @@ -0,0 +1,27 @@ +"""打包选择 API""" +from fastapi import APIRouter + +from .config import load_config + +router = APIRouter(prefix="/api", tags=["apps"]) + + +@router.get("/apps") +async def get_apps_for_build(): + """获取 apps 列表(供打包选择)""" + config = load_config() + return config.get("apps", {}) + + +@router.get("/schemes") +async def get_schemes_for_build(): + """获取 schemes 列表(供打包选择)""" + config = load_config() + return config.get("schemes", {}) + + +@router.get("/branches") +async def get_branches_for_build(): + """获取分支列表(供打包选择)""" + config = load_config() + return config.get("branches", ["main"]) diff --git a/backend/routers/auth.py b/backend/routers/auth.py new file mode 100644 index 0000000..efefc33 --- /dev/null +++ b/backend/routers/auth.py @@ -0,0 +1,19 @@ +"""认证 API""" +from fastapi import APIRouter +from ..schemas import LoginRequest, LoginResponse +from ..config import ADMIN_USERNAME, ADMIN_PASSWORD + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/login", response_model=LoginResponse) +async def login(request: LoginRequest): + """管理员登录""" + if request.username == ADMIN_USERNAME and request.password == ADMIN_PASSWORD: + return LoginResponse( + token="admin-token", + username=request.username, + is_admin=True + ) + from fastapi import HTTPException + raise HTTPException(status_code=401, detail="用户名或密码错误") diff --git a/backend/routers/config.py b/backend/routers/config.py new file mode 100644 index 0000000..e0d6510 --- /dev/null +++ b/backend/routers/config.py @@ -0,0 +1,398 @@ +"""配置管理 API""" +import asyncio +import json +import shutil +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from ..database import get_db +from ..models import BuildConfig +from ..schemas import BuildConfigUpdate +from ..config import CONFIG_JSON_PATH, DEFAULT_MAX_CONCURRENT_BUILDS, DEFAULT_BUILD_DIR_RETENTION_HOURS + +router = APIRouter(prefix="/api/config", tags=["config"]) + +_config_lock = asyncio.Lock() + +# 默认上传配置 +DEFAULT_UPLOAD = { + "mode": "oss", + "oss": { + "access_key_id": "", + "access_key_secret": "", + "endpoint": "oss-cn-beijing.aliyuncs.com", + "bucket_name": "", + "base_url": "", + }, + "webdav": { + "server_url": "", + "username": "", + "password": "", + "base_path": "/ios-builds", + "public_url": "", + }, + "dingtalk": { + "enabled": False, + "webhook_url": "", + "secret": "", + }, +} + +# 默认服务器环境配置 +DEFAULT_SERVERS = { + "测试环境": { + "api": "https://api3-dev.readoor.cn", + "assDom": "applinks:dev-data1.readoor.cn", + "universalLink": "https://dev-data1.readoor.cn" + }, + "正式环境": { + "api": "https://api3.readoor.cn", + "assDom": "applinks:data1.readoor.cn", + "universalLink": "https://data1.readoor.cn" + }, + "华师大环境": { + "api": "https://api3.ecnupress.com.cn", + "assDom": "applinks:data1.ecnupress.com.cn", + "universalLink": "https://data1.ecnupress.com.cn" + }, + "外教环境": { + "api": "https://weread-api3.sflep.com/api3", + "assDom": "applinks:wereadossda.sflep.com", + "universalLink": "https://wereadossda.sflep.com" + } +} + + +def load_config() -> dict: + """读取 config.json""" + if not CONFIG_JSON_PATH.exists(): + return {"apps": {}, "schemes": {}, "servers": DEFAULT_SERVERS, "branches": ["main"], "upload": DEFAULT_UPLOAD} + with open(CONFIG_JSON_PATH, "r", encoding="utf-8") as f: + config = json.load(f) + # 确保必要字段存在 + if "servers" not in config: + config["servers"] = DEFAULT_SERVERS + if "branches" not in config: + config["branches"] = ["main"] + if "upload" not in config: + config["upload"] = DEFAULT_UPLOAD + return config + + +def save_config(config: dict): + """保存 config.json(自动备份)""" + if CONFIG_JSON_PATH.exists(): + backup_path = CONFIG_JSON_PATH.with_suffix(".json.bak") + shutil.copy2(CONFIG_JSON_PATH, backup_path) + with open(CONFIG_JSON_PATH, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + + +def get_build_config_value(db: Session, key: str, default=None) -> str: + """获取打包配置值""" + config = db.query(BuildConfig).filter(BuildConfig.key == key).first() + return config.value if config else default + + +@router.get("") +async def get_full_config(): + """获取完整 config.json""" + return load_config() + + +@router.put("") +async def update_full_config(config: dict): + """更新整个 config.json""" + async with _config_lock: + save_config(config) + return {"message": "配置已更新"} + + +@router.get("/apps") +async def get_apps(): + """获取 apps 配置""" + config = load_config() + return config.get("apps", {}) + + +@router.post("/apps") +async def create_app(app: dict): + """新增 app""" + async with _config_lock: + config = load_config() + apps = config.get("apps", {}) + + # 自动生成 ID + numeric_keys = [int(k) for k in apps.keys() if k.isdigit()] + new_id = str(max(numeric_keys) + 1) if numeric_keys else "1" + + apps[new_id] = app + config["apps"] = apps + save_config(config) + return {"id": new_id, "message": "App 已创建"} + + +@router.put("/apps/{app_id}") +async def update_app(app_id: str, app: dict): + """更新指定 app""" + async with _config_lock: + config = load_config() + apps = config.get("apps", {}) + + if app_id not in apps: + raise HTTPException(status_code=404, detail="App 不存在") + + apps[app_id] = app + config["apps"] = apps + save_config(config) + return {"message": "App 已更新"} + + +@router.delete("/apps/{app_id}") +async def delete_app(app_id: str): + """删除指定 app""" + async with _config_lock: + config = load_config() + apps = config.get("apps", {}) + + if app_id not in apps: + raise HTTPException(status_code=404, detail="App 不存在") + + del apps[app_id] + config["apps"] = apps + save_config(config) + return {"message": "App 已删除"} + + +@router.get("/schemes") +async def get_schemes(): + """获取 schemes 配置""" + config = load_config() + return config.get("schemes", {}) + + +@router.post("/schemes") +async def create_scheme(scheme: dict): + """新增 scheme""" + async with _config_lock: + config = load_config() + schemes = config.get("schemes", {}) + + numeric_keys = [int(k) for k in schemes.keys() if k.isdigit()] + new_id = str(max(numeric_keys) + 1) if numeric_keys else "1" + + schemes[new_id] = scheme + config["schemes"] = schemes + save_config(config) + return {"id": new_id, "message": "Scheme 已创建"} + + +@router.put("/schemes/{scheme_id}") +async def update_scheme(scheme_id: str, scheme: dict): + """更新指定 scheme""" + async with _config_lock: + config = load_config() + schemes = config.get("schemes", {}) + + if scheme_id not in schemes: + raise HTTPException(status_code=404, detail="Scheme 不存在") + + schemes[scheme_id] = scheme + config["schemes"] = schemes + save_config(config) + return {"message": "Scheme 已更新"} + + +@router.delete("/schemes/{scheme_id}") +async def delete_scheme(scheme_id: str): + """删除指定 scheme""" + async with _config_lock: + config = load_config() + schemes = config.get("schemes", {}) + + if scheme_id not in schemes: + raise HTTPException(status_code=404, detail="Scheme 不存在") + + del schemes[scheme_id] + config["schemes"] = schemes + save_config(config) + return {"message": "Scheme 已删除"} + + +# 分支管理 API +@router.get("/branches") +async def get_branches(): + """获取分支列表""" + config = load_config() + return config.get("branches", ["main"]) + + +@router.post("/branches") +async def add_branch(data: dict): + """新增分支""" + name = data.get("name", "").strip() + if not name: + raise HTTPException(status_code=400, detail="分支名称不能为空") + + async with _config_lock: + config = load_config() + branches = config.get("branches", ["main"]) + if name in branches: + raise HTTPException(status_code=400, detail="分支已存在") + branches.append(name) + config["branches"] = branches + save_config(config) + return {"message": "分支已添加"} + + +@router.delete("/branches/{branch_name}") +async def delete_branch(branch_name: str): + """删除分支""" + async with _config_lock: + config = load_config() + branches = config.get("branches", ["main"]) + if branch_name not in branches: + raise HTTPException(status_code=404, detail="分支不存在") + branches.remove(branch_name) + config["branches"] = branches + save_config(config) + return {"message": "分支已删除"} + + +@router.get("/build") +async def get_build_settings(db: Session = Depends(get_db)): + """获取打包设置""" + return { + "max_concurrent_builds": int(get_build_config_value(db, "max_concurrent_builds", DEFAULT_MAX_CONCURRENT_BUILDS)), + "build_dir_retention_hours": int(get_build_config_value(db, "build_dir_retention_hours", DEFAULT_BUILD_DIR_RETENTION_HOURS)), + "build_base_dir": get_build_config_value(db, "build_base_dir", "/Users/shen/Documents"), + } + + +@router.put("/build") +async def update_build_settings(settings: BuildConfigUpdate, db: Session = Depends(get_db)): + """更新打包设置""" + if settings.max_concurrent_builds is not None: + config = db.query(BuildConfig).filter(BuildConfig.key == "max_concurrent_builds").first() + if config: + config.value = str(settings.max_concurrent_builds) + else: + db.add(BuildConfig(key="max_concurrent_builds", value=str(settings.max_concurrent_builds))) + + if settings.build_dir_retention_hours is not None: + config = db.query(BuildConfig).filter(BuildConfig.key == "build_dir_retention_hours").first() + if config: + config.value = str(settings.build_dir_retention_hours) + else: + db.add(BuildConfig(key="build_dir_retention_hours", value=str(settings.build_dir_retention_hours))) + + if settings.build_base_dir is not None: + config = db.query(BuildConfig).filter(BuildConfig.key == "build_base_dir").first() + if config: + config.value = settings.build_base_dir + else: + db.add(BuildConfig(key="build_base_dir", value=settings.build_base_dir)) + + db.commit() + return {"message": "打包设置已更新"} + + +# 上传配置 API +@router.get("/upload") +async def get_upload_config(): + """获取上传配置""" + config = load_config() + return config.get("upload", DEFAULT_UPLOAD) + + +@router.put("/upload") +async def update_upload_config(upload_data: dict): + """更新上传配置""" + async with _config_lock: + config = load_config() + config["upload"] = upload_data + save_config(config) + return {"message": "上传配置已更新"} + + +# 服务器环境管理 API +@router.get("/servers") +async def get_servers(): + """获取服务器环境配置""" + config = load_config() + return config.get("servers", DEFAULT_SERVERS) + + +@router.post("/servers") +async def create_server(server_data: dict): + """新增服务器环境""" + async with _config_lock: + config = load_config() + servers = config.get("servers", DEFAULT_SERVERS) + + name = server_data.get("name", "").strip() + if not name: + raise HTTPException(status_code=400, detail="环境名称不能为空") + if name in servers: + raise HTTPException(status_code=400, detail="环境名称已存在") + + servers[name] = { + "api": server_data.get("api", ""), + "assDom": server_data.get("assDom", ""), + "universalLink": server_data.get("universalLink", ""), + } + config["servers"] = servers + save_config(config) + return {"message": "服务器环境已创建"} + + +@router.put("/servers/{server_name}") +async def update_server(server_name: str, server_data: dict): + """更新服务器环境""" + async with _config_lock: + config = load_config() + servers = config.get("servers", DEFAULT_SERVERS) + + if server_name not in servers: + raise HTTPException(status_code=404, detail="环境不存在") + + # 如果名称变了,需要删除旧的 + new_name = server_data.get("name", server_name).strip() + if new_name != server_name: + if new_name in servers: + raise HTTPException(status_code=400, detail="新环境名称已存在") + servers[new_name] = servers.pop(server_name) + else: + new_name = server_name + + servers[new_name] = { + "api": server_data.get("api", ""), + "assDom": server_data.get("assDom", ""), + "universalLink": server_data.get("universalLink", ""), + } + config["servers"] = servers + save_config(config) + return {"message": "服务器环境已更新"} + + +@router.delete("/servers/{server_name}") +async def delete_server(server_name: str): + """删除服务器环境""" + async with _config_lock: + config = load_config() + servers = config.get("servers", DEFAULT_SERVERS) + + if server_name not in servers: + raise HTTPException(status_code=404, detail="环境不存在") + + # 检查是否有 App 在使用此环境 + apps = config.get("apps", {}) + for app in apps.values(): + if app.get("server") == server_name: + raise HTTPException(status_code=400, detail=f"无法删除:有 App 正在使用此环境") + + del servers[server_name] + config["servers"] = servers + save_config(config) + return {"message": "服务器环境已删除"} diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py new file mode 100644 index 0000000..2bae8ce --- /dev/null +++ b/backend/routers/tasks.py @@ -0,0 +1,158 @@ +"""任务 API""" +import os +import shutil +import tempfile +import uuid +from datetime import datetime +from typing import List +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse +from starlette.background import BackgroundTask +from sqlalchemy.orm import Session + +from ..database import get_db +from ..models import Task +from ..schemas import TaskCreate, TaskResponse + +router = APIRouter(prefix="/api/tasks", tags=["tasks"]) + + +@router.post("", response_model=TaskResponse) +async def create_task(task: TaskCreate, db: Session = Depends(get_db)): + """创建打包任务""" + from .config import load_config + + config = load_config() + apps = config.get("apps", {}) + schemes = config.get("schemes", {}) + + if task.app_id not in apps: + raise HTTPException(status_code=400, detail="App 不存在") + if task.scheme_id not in schemes: + raise HTTPException(status_code=400, detail="Scheme 不存在") + + app = apps[task.app_id] + scheme = schemes[task.scheme_id] + + task_id = str(uuid.uuid4()) + db_task = Task( + id=task_id, + app_id=task.app_id, + app_name=app.get("name", ""), + build_type=task.build_type, + scheme_id=task.scheme_id, + scheme_name=scheme.get("name", ""), + obfuscation=task.obfuscation, + branch=task.branch, + status="pending", + ) + db.add(db_task) + db.commit() + db.refresh(db_task) + + # 启动打包任务 + from ..services.build_queue import build_queue + await build_queue.submit(task_id) + + return db_task + + +@router.get("", response_model=List[TaskResponse]) +async def list_tasks( + status: str = None, + limit: int = 20, + offset: int = 0, + db: Session = Depends(get_db) +): + """获取任务列表""" + query = db.query(Task) + if status: + query = query.filter(Task.status == status) + tasks = query.order_by(Task.created_at.desc()).offset(offset).limit(limit).all() + return tasks + + +@router.get("/{task_id}", response_model=TaskResponse) +async def get_task(task_id: str, db: Session = Depends(get_db)): + """获取任务详情""" + task = db.query(Task).filter(Task.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + return task + + +@router.delete("/{task_id}") +async def cancel_task(task_id: str, db: Session = Depends(get_db)): + """取消任务""" + task = db.query(Task).filter(Task.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + if task.status in ("completed", "failed", "cancelled"): + raise HTTPException(status_code=400, detail="任务已完成或已取消") + + task.status = "cancelled" + db.commit() + + # 尝试取消运行中的任务 + from ..services.build_queue import build_queue + build_queue.cancel(task_id) + + return {"message": "任务已取消"} + + +@router.get("/{task_id}/dsym") +async def download_dsym(task_id: str, db: Session = Depends(get_db)): + """下载 dSYM 文件""" + task = db.query(Task).filter(Task.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + if not task.dsym_path: + raise HTTPException(status_code=404, detail="dSYM 文件不存在") + + if not os.path.exists(task.dsym_path): + raise HTTPException(status_code=404, detail="dSYM 文件已被清理") + + return FileResponse(task.dsym_path, filename=os.path.basename(task.dsym_path)) + + +@router.get("/{task_id}/obfuscation-maps") +async def download_obfuscation_maps(task_id: str, db: Session = Depends(get_db)): + """下载混淆映射表""" + task = db.query(Task).filter(Task.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + if not task.obfuscation_maps_path: + raise HTTPException(status_code=404, detail="混淆映射表不存在") + + maps_path = task.obfuscation_maps_path + if not os.path.exists(maps_path): + raise HTTPException(status_code=404, detail="混淆映射表已被清理") + + # 如果是目录,打包成 zip + if os.path.isdir(maps_path): + fd, zip_path = tempfile.mkstemp(suffix=".zip") + os.close(fd) + shutil.make_archive(zip_path.replace(".zip", ""), "zip", maps_path) + return FileResponse( + zip_path, + filename=f"{task.app_name}_混淆映射.zip", + background=BackgroundTask(os.remove, zip_path), + ) + + return FileResponse(maps_path, filename=os.path.basename(maps_path)) + + +@router.get("/{task_id}/qrcode") +async def download_qrcode(task_id: str, db: Session = Depends(get_db)): + """下载二维码图片""" + task = db.query(Task).filter(Task.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + if not task.qr_code_path: + raise HTTPException(status_code=404, detail="二维码不存在") + + if not os.path.exists(task.qr_code_path): + raise HTTPException(status_code=404, detail="二维码文件已被清理") + + return FileResponse(task.qr_code_path, media_type="image/png") diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..c0c2a7b --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,59 @@ +"""Pydantic 模型""" +from datetime import datetime +from typing import Optional +from pydantic import BaseModel + + +# 打包任务 +class TaskCreate(BaseModel): + app_id: str + build_type: str + scheme_id: str + obfuscation: bool = False + branch: str = "main" + + +class TaskResponse(BaseModel): + id: str + app_id: str + app_name: str + build_type: str + scheme_id: str + scheme_name: str + obfuscation: bool + branch: str + status: str + current_step: Optional[str] + created_at: Optional[datetime] + started_at: Optional[datetime] + completed_at: Optional[datetime] + ipa_path: Optional[str] + oss_url: Optional[str] + dsym_path: Optional[str] + obfuscation_maps_path: Optional[str] + qr_code_path: Optional[str] + build_dir: Optional[str] + error_message: Optional[str] + config_json: Optional[str] + + class Config: + from_attributes = True + + +# 登录 +class LoginRequest(BaseModel): + username: str + password: str + + +class LoginResponse(BaseModel): + token: str + username: str + is_admin: bool + + +# 打包配置 +class BuildConfigUpdate(BaseModel): + max_concurrent_builds: Optional[int] = None + build_dir_retention_hours: Optional[int] = None + build_base_dir: Optional[str] = None diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/build_queue.py b/backend/services/build_queue.py new file mode 100644 index 0000000..5d58c31 --- /dev/null +++ b/backend/services/build_queue.py @@ -0,0 +1,80 @@ +"""并行打包队列管理""" +import asyncio +from typing import Dict, Callable, Optional +from datetime import datetime + +from .log_streamer import log_streamer + + +class BuildQueue: + """打包任务队列""" + + def __init__(self, max_concurrent: int = 2): + self._max_concurrent = max_concurrent + self._semaphore = asyncio.Semaphore(max_concurrent) + self._queue: asyncio.Queue = asyncio.Queue() + self._running_tasks: Dict[str, asyncio.Task] = {} + self._workers: list = [] + self._started = False + + async def start(self): + """启动工作线程""" + if self._started: + return + self._started = True + for i in range(self._max_concurrent): + worker = asyncio.create_task(self._worker(f"worker-{i}")) + self._workers.append(worker) + + async def _worker(self, name: str): + """工作协程""" + while True: + task_id, build_func = await self._queue.get() + try: + await self._semaphore.acquire() + try: + self._running_tasks[task_id] = asyncio.current_task() + await build_func(task_id) + finally: + self._running_tasks.pop(task_id, None) + self._semaphore.release() + except Exception as e: + await log_streamer.emit_error(task_id, f"任务异常: {str(e)}") + finally: + self._queue.task_done() + + async def submit(self, task_id: str, build_func: Optional[Callable] = None): + """提交打包任务""" + if not self._started: + await self.start() + + if build_func is None: + from .build_service import run_build_task + build_func = run_build_task + + await self._queue.put((task_id, build_func)) + await log_streamer.emit(task_id, "任务已加入队列,等待执行...") + + def cancel(self, task_id: str): + """取消任务""" + task = self._running_tasks.get(task_id) + if task and not task.done(): + task.cancel() + + def update_max_concurrent(self, new_max: int): + """更新最大并发数""" + self._max_concurrent = new_max + # 注意:运行时修改需要重建 semaphore,这里简化处理 + # 实际使用时建议重启服务生效 + + @property + def queue_size(self) -> int: + return self._queue.qsize() + + @property + def running_count(self) -> int: + return len(self._running_tasks) + + +# 全局单例 +build_queue = BuildQueue() diff --git a/backend/services/build_service.py b/backend/services/build_service.py new file mode 100644 index 0000000..e3332bd --- /dev/null +++ b/backend/services/build_service.py @@ -0,0 +1,590 @@ +"""打包服务核心""" +import asyncio +import json +import os +import shutil +from datetime import datetime +from pathlib import Path + +from ..config import ( + PROJECT_ROOT, + AUTOPACKING_DIR, + BUILD_BASE_DIR, + COPY_ITEMS, + DEFAULT_BUILD_DIR_RETENTION_HOURS, + GIT_REMOTE_URL, + get_source_dir, +) +from .log_streamer import log_streamer + + +def _cleanup_build_dir(build_dir: Path): + """删除整个打包目录""" + try: + if build_dir.exists(): + shutil.rmtree(build_dir) + except Exception: + pass + + +def _cleanup_old_builds(db): + """清理超过保留时间的打包目录""" + from ..models import BuildConfig + config = db.query(BuildConfig).filter(BuildConfig.key == "build_dir_retention_hours").first() + hours = int(config.value) if config else DEFAULT_BUILD_DIR_RETENTION_HOURS + cutoff = datetime.utcnow().timestamp() - hours * 3600 + + base = BUILD_BASE_DIR + if not base.exists(): + return + for d in base.iterdir(): + if d.is_dir() and d.name.startswith("build_readoor_"): + try: + if d.stat().st_mtime < cutoff: + shutil.rmtree(d) + except Exception: + pass + + +def _db_update(db, task, **fields): + """同步更新 task 字段并 commit(在线程中调用)""" + for k, v in fields.items(): + setattr(task, k, v) + db.commit() + + +async def update_source(task_id: str, source_dir: Path, branch: str): + """确保分支源码目录存在且为最新""" + if not source_dir.exists(): + # 首次:从远程 clone + if not GIT_REMOTE_URL: + raise Exception(f"源码目录不存在且未配置 GIT_REMOTE_URL: {source_dir}") + source_dir.parent.mkdir(parents=True, exist_ok=True) + await log_streamer.emit(task_id, f"克隆仓库: {GIT_REMOTE_URL} → {branch}") + process = await asyncio.create_subprocess_exec( + "git", "clone", "-b", branch, "--single-branch", GIT_REMOTE_URL, str(source_dir), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + async for line in process.stdout: + decoded = line.decode("utf-8", errors="replace").strip() + if decoded: + await log_streamer.emit(task_id, decoded) + await process.wait() + if process.returncode != 0: + raise Exception(f"git clone 失败: {branch}") + else: + # 已存在:fetch + checkout + pull + await log_streamer.emit(task_id, f"更新分支源码: {branch}") + + # fetch + process = await asyncio.create_subprocess_exec( + "git", "fetch", "origin", + cwd=str(source_dir), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + async for line in process.stdout: + decoded = line.decode("utf-8", errors="replace").strip() + if decoded: + await log_streamer.emit(task_id, decoded) + await process.wait() + if process.returncode != 0: + raise Exception("git fetch 失败") + + # checkout + process = await asyncio.create_subprocess_exec( + "git", "checkout", branch, + cwd=str(source_dir), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + async for line in process.stdout: + decoded = line.decode("utf-8", errors="replace").strip() + if decoded: + await log_streamer.emit(task_id, decoded) + await process.wait() + if process.returncode != 0: + raise Exception(f"git checkout {branch} 失败") + + # pull + process = await asyncio.create_subprocess_exec( + "git", "pull", "origin", branch, + cwd=str(source_dir), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + async for line in process.stdout: + decoded = line.decode("utf-8", errors="replace").strip() + if decoded: + await log_streamer.emit(task_id, decoded) + await process.wait() + if process.returncode != 0: + raise Exception(f"git pull {branch} 失败") + + await log_streamer.emit(task_id, f"源码已就绪: {source_dir}") + + +async def run_build_task(task_id: str): + """执行打包任务""" + from ..database import SessionLocal + from ..models import Task + + db = SessionLocal() + try: + task = await asyncio.to_thread( + lambda: db.query(Task).filter(Task.id == task_id).first() + ) + if not task: + return + + # 创建日志队列(必须在 emit 之前) + log_streamer.create_queue(task_id) + + # 更新状态 + await asyncio.to_thread(_db_update, db, task, + status="running", started_at=datetime.utcnow()) + + await log_streamer.emit_step(task_id, "开始打包") + + # 清理过期的打包目录 + await asyncio.to_thread(_cleanup_old_builds, db) + + build_dir = None + try: + # 1. 更新分支源码并拷贝 + source_dir = get_source_dir(task.branch) + await asyncio.to_thread(_db_update, db, task, current_step="copy") + if GIT_REMOTE_URL: + # 配置了远程仓库,从分支目录获取源码 + await update_source(task_id, source_dir, task.branch) + elif source_dir.exists(): + # 分支目录已存在,直接使用 + await log_streamer.emit(task_id, f"使用分支源码: {source_dir}") + else: + # 未配置远程仓库且分支目录不存在,使用默认源码目录 + source_dir = PROJECT_ROOT + await log_streamer.emit(task_id, f"未配置 GIT_REMOTE_URL,使用默认源码: {source_dir}") + build_dir = await copy_source_code(task_id, task, source_dir) + await asyncio.to_thread(_db_update, db, task, build_dir=str(build_dir)) + + # 2. 生成配置 + await asyncio.to_thread(_db_update, db, task, current_step="config") + config_data = await generate_config(task_id, task, build_dir) + await asyncio.to_thread(_db_update, db, task, + config_json=json.dumps(config_data, ensure_ascii=False)) + + # 3. 替换项目配置 + await asyncio.to_thread(_db_update, db, task, current_step="patch") + await patch_project(task_id, task, config_data, build_dir) + + # 4. 代码混淆(可选) + if task.obfuscation: + await asyncio.to_thread(_db_update, db, task, current_step="obfuscation") + await run_obfuscation(task_id, task, build_dir, source_dir) + + # 5. 构建项目 + await asyncio.to_thread(_db_update, db, task, current_step="build") + ipa_path = await build_project(task_id, task, build_dir) + + # 查找 dSYM + dsym_path = await find_dsym(task_id, build_dir) + + # 查找混淆映射表 + obf_maps_path = build_dir / "obfuscation_maps" + + update_fields = {"ipa_path": str(ipa_path)} + if dsym_path: + update_fields["dsym_path"] = str(dsym_path) + if obf_maps_path.exists(): + update_fields["obfuscation_maps_path"] = str(obf_maps_path) + await asyncio.to_thread(_db_update, db, task, **update_fields) + + # 6. 上传分发(仅 Ad_Hoc) + if task.build_type == "Ad_Hoc": + await asyncio.to_thread(_db_update, db, task, current_step="upload") + oss_url, qr_code_path = await upload_ipa(task_id, task, config_data, ipa_path) + upload_fields = {} + if oss_url: + upload_fields["oss_url"] = oss_url + if qr_code_path: + upload_fields["qr_code_path"] = qr_code_path + if upload_fields: + await asyncio.to_thread(_db_update, db, task, **upload_fields) + + # 完成 + await asyncio.to_thread(_db_update, db, task, + status="completed", completed_at=datetime.utcnow(), + current_step=None) + + await log_streamer.emit_step(task_id, "打包完成") + await log_streamer.emit(task_id, f"IPA: {ipa_path}") + if task.oss_url: + await log_streamer.emit(task_id, f"下载链接: {task.oss_url}") + + # 清理打包目录(保留产物文件,删除源码拷贝) + await log_streamer.emit(task_id, "清理临时文件...") + await asyncio.to_thread(_cleanup_build_dir, build_dir) + + except asyncio.CancelledError: + await asyncio.to_thread(_db_update, db, task, + status="cancelled", completed_at=datetime.utcnow()) + await log_streamer.emit(task_id, "任务已取消") + if build_dir and build_dir.exists(): + await asyncio.to_thread(_cleanup_build_dir, build_dir) + + except Exception as e: + await asyncio.to_thread(_db_update, db, task, + status="failed", completed_at=datetime.utcnow(), + error_message=str(e)) + await log_streamer.emit_error(task_id, f"打包失败: {str(e)}") + if build_dir and build_dir.exists(): + await asyncio.to_thread(_cleanup_build_dir, build_dir) + + finally: + log_streamer.complete(task_id) + + finally: + db.close() + + +async def copy_source_code(task_id: str, task, source_dir: Path) -> Path: + """从分支源码目录拷贝代码到打包目录""" + await log_streamer.emit_step(task_id, "拷贝代码") + + build_dir = BUILD_BASE_DIR / f"build_readoor_{task_id}" + if build_dir.exists(): + shutil.rmtree(build_dir) + build_dir.mkdir(parents=True) + + for item in COPY_ITEMS: + src = source_dir / item + dst = build_dir / item + if src.is_dir(): + await log_streamer.emit(task_id, f"拷贝目录: {item}") + shutil.copytree(src, dst, ignore=shutil.ignore_patterns(".git", "DerivedData")) + elif src.is_file(): + await log_streamer.emit(task_id, f"拷贝文件: {item}") + shutil.copy2(src, dst) + + await log_streamer.emit(task_id, "代码拷贝完成") + return build_dir + + +async def generate_config(task_id: str, task, build_dir: Path) -> dict: + """生成打包配置""" + await log_streamer.emit_step(task_id, "生成配置") + + from ..routers.config import load_config + + config = load_config() + apps = config.get("apps", {}) + schemes = config.get("schemes", {}) + + app = apps.get(task.app_id, {}) + scheme = schemes.get(task.scheme_id, {}) + + # 读取版本号 + start_build_path = AUTOPACKING_DIR / "start_build_app.py" + app_ver = "2.180.0" + build_ver = "2.180.0.0" + + if start_build_path.exists(): + content = start_build_path.read_text() + for line in content.split("\n"): + if line.startswith("App_Ver"): + app_ver = line.split('"')[1] if '"' in line else app_ver + elif line.startswith("Build_Ver"): + build_ver = line.split('"')[1] if '"' in line else build_ver + + # 构建配置 + config_data = { + "VERSION": app_ver, + "BUILD_VERSION": build_ver, + "SERVER": app.get("server", ""), + "API": app.get("API", ""), + "APPID_NAME": app.get("name", ""), + "APPID": app.get("AppGuid", app.get("AppId", "")), + "SCHEME": scheme.get("name", ""), + "OSS_FLODER": scheme.get("ossFloder", ""), + "BUILD_TYPE": task.build_type, + "ENABLE_OBFUSCATION": task.obfuscation, + } + + # 证书配置 + certificates = app.get("certificates", {}) + cert = certificates.get(task.build_type, {}) + + if cert: + config_data["CERTIFICATE"] = cert.get("cer", "") + config_data["PROVISIONING_PROFILE"] = cert.get("pro", "") + config_data["BUNDLE_ID"] = cert.get("name", "") + config_data["THEME"] = cert.get("theme", "") + + # 其他配置 + for key in ["weixinlogin", "weixinpay", "tencent", "AssDom", "UniversalLink", "AlivcLicenseKey"]: + if key in app: + config_data[key] = app[key] + + # 写入配置文件 + config_output_path = build_dir / "config_output.json" + with open(config_output_path, "w", encoding="utf-8") as f: + json.dump(config_data, f, indent=2, ensure_ascii=False) + + await log_streamer.emit(task_id, "配置已生成") + return config_data + + +async def patch_project(task_id: str, task, config_data: dict, build_dir: Path): + """替换项目配置""" + await log_streamer.emit_step(task_id, "替换项目配置") + + # 根据 scheme 选择不同的替换逻辑 + if config_data.get("SCHEME") == "readoorDict": + cmd = [ + "python3", + str(AUTOPACKING_DIR / "replace_build_info.py"), + "--dict-config", + json.dumps(config_data, ensure_ascii=False), + ] + else: + cmd = [ + "python3", + str(AUTOPACKING_DIR / "replace_build_info.py"), + "--config", + json.dumps(config_data, ensure_ascii=False), + ] + + # 使用 subprocess 执行替换 + process = await asyncio.create_subprocess_exec( + *cmd, + cwd=str(build_dir), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + + async for line in process.stdout: + decoded = line.decode("utf-8", errors="replace").strip() + if decoded: + await log_streamer.emit(task_id, decoded) + + await process.wait() + + if process.returncode != 0: + raise Exception("项目配置替换失败") + + await log_streamer.emit(task_id, "项目配置替换完成") + + +async def run_obfuscation(task_id: str, task, build_dir: Path, source_root: Path): + """执行代码混淆""" + await log_streamer.emit_step(task_id, "代码混淆") + + env = os.environ.copy() + env["TARGET_NAME"] = task.scheme_name + env["SKIP_OBF_PHASE"] = "1" + + # 自检 + self_check_script = AUTOPACKING_DIR / "obfuscation" / "scripts" / "obfuscation_self_check.sh" + process = await asyncio.create_subprocess_exec( + "bash", str(self_check_script), str(source_root), + cwd=str(build_dir), + env=env, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + + async for line in process.stdout: + decoded = line.decode("utf-8", errors="replace").strip() + if decoded: + await log_streamer.emit(task_id, decoded) + + await process.wait() + + if process.returncode != 0: + raise Exception("混淆自检失败") + + await log_streamer.emit(task_id, "混淆自检通过") + + # 执行混淆 + obfuscate_script = AUTOPACKING_DIR / "obfuscation" / "obfuscate_symbols.py" + process = await asyncio.create_subprocess_exec( + "python3", str(obfuscate_script), "--archive-mode", + cwd=str(build_dir), + env=env, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + + async for line in process.stdout: + decoded = line.decode("utf-8", errors="replace").strip() + if decoded: + await log_streamer.emit(task_id, decoded) + + await process.wait() + + if process.returncode != 0: + raise Exception("代码混淆失败") + + await log_streamer.emit(task_id, "代码混淆完成") + + +async def build_project(task_id: str, task, build_dir: Path) -> Path: + """构建项目""" + await log_streamer.emit_step(task_id, "构建项目") + + scheme = task.scheme_name + export_path = build_dir / "build" + archive_path = export_path / f"{scheme}.xcarchive" + workspace_path = build_dir / "readoor.xcworkspace" + + export_path.mkdir(parents=True, exist_ok=True) + + # 清理 + await log_streamer.emit(task_id, "清理项目...") + clean_cmd = ( + f"xcodebuild clean -workspace {workspace_path.name} " + f"-scheme {scheme} " + f"-configuration Release " + f"-derivedDataPath {export_path / 'derived_data'}" + ) + process = await asyncio.create_subprocess_shell( + clean_cmd, + cwd=str(build_dir), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + async for line in process.stdout: + decoded = line.decode("utf-8", errors="replace").strip() + if decoded: + await log_streamer.emit(task_id, decoded) + await process.wait() + + # Archive + await log_streamer.emit(task_id, "开始 Archive...") + archive_cmd = ( + f"xcodebuild archive -workspace {workspace_path.name} " + f"-scheme {scheme} " + f"-configuration Release " + f"-archivePath {archive_path} " + f"-derivedDataPath {export_path / 'derived_data'} " + f"-destination generic/platform=ios -quiet" + ) + process = await asyncio.create_subprocess_shell( + archive_cmd, + cwd=str(build_dir), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + async for line in process.stdout: + decoded = line.decode("utf-8", errors="replace").strip() + if decoded: + await log_streamer.emit(task_id, decoded) + await process.wait() + + if process.returncode != 0: + raise Exception("Archive 失败") + + # 导出 IPA + await log_streamer.emit(task_id, "导出 IPA...") + export_plist = build_dir / "exportOptions.plist" + export_cmd = ( + f"xcodebuild -exportArchive " + f"-archivePath {archive_path} " + f"-exportPath {export_path} " + f"-exportOptionsPlist {export_plist}" + ) + process = await asyncio.create_subprocess_shell( + export_cmd, + cwd=str(build_dir), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + async for line in process.stdout: + decoded = line.decode("utf-8", errors="replace").strip() + if decoded: + await log_streamer.emit(task_id, decoded) + await process.wait() + + if process.returncode != 0: + raise Exception("导出 IPA 失败") + + # 查找 IPA 文件 + ipa_files = list(export_path.glob("*.ipa")) + if not ipa_files: + raise Exception("未找到 IPA 文件") + + ipa_path = ipa_files[0] + await log_streamer.emit(task_id, f"IPA 已导出: {ipa_path.name}") + return ipa_path + + +async def find_dsym(task_id: str, build_dir: Path) -> Path: + """查找 dSYM 文件""" + derived_data = build_dir / "build" / "derived_data" + if not derived_data.exists(): + return None + + for dsym in derived_data.rglob("*.dSYM"): + return dsym + return None + + +async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path) -> tuple: + """上传 IPA,返回 (download_url, qr_code_path)""" + await log_streamer.emit_step(task_id, "上传分发平台") + + qr_code_path = None + app_guid = config_data.get("APPID", "") + + # 从 config.json 读取上传配置并注入 + from ..routers.config import load_config + full_config = load_config() + config_data["_upload_config"] = full_config.get("upload", {}) + + # 使用现有的 upload_iap 脚本 + upload_script = AUTOPACKING_DIR / "upload_iap.py" + if not upload_script.exists(): + await log_streamer.emit_warning(task_id, "上传脚本不存在,跳过上传") + return None, None + + cmd = [ + "python3", + str(upload_script), + "--config", + json.dumps(config_data, ensure_ascii=False), + ] + + process = await asyncio.create_subprocess_exec( + *cmd, + cwd=str(PROJECT_ROOT), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + + oss_url = None + async for line in process.stdout: + decoded = line.decode("utf-8", errors="replace").strip() + if decoded: + await log_streamer.emit(task_id, decoded) + # 解析上传结果 + if decoded.startswith("UPLOAD_RESULT:"): + try: + result = json.loads(decoded[len("UPLOAD_RESULT:"):]) + oss_url = result.get("download_url") + qr_code_path = result.get("qr_code_path") + except json.JSONDecodeError: + pass + + await process.wait() + + # 兜底:查找二维码文件 + if not qr_code_path and app_guid: + expected_qr = PROJECT_ROOT / "build" / f"{app_guid}.png" + if expected_qr.exists(): + qr_code_path = str(expected_qr) + + if process.returncode != 0: + await log_streamer.emit_warning(task_id, "上传可能失败") + + return oss_url, qr_code_path diff --git a/backend/services/log_streamer.py b/backend/services/log_streamer.py new file mode 100644 index 0000000..27e2357 --- /dev/null +++ b/backend/services/log_streamer.py @@ -0,0 +1,86 @@ +"""WebSocket 日志流""" +import asyncio +import json +from datetime import datetime +from typing import Dict, AsyncGenerator + + +class LogStreamer: + """日志流管理器""" + + def __init__(self): + self._queues: Dict[str, asyncio.Queue] = {} + self._subscribers: Dict[str, list] = {} + + def create_queue(self, task_id: str) -> asyncio.Queue: + """创建任务的日志队列""" + queue = asyncio.Queue(maxsize=1000) + self._queues[task_id] = queue + return queue + + async def emit(self, task_id: str, message: str, level: str = "info"): + """发送日志消息""" + if task_id not in self._queues: + return + + log_entry = { + "timestamp": datetime.now().isoformat(), + "level": level, + "message": message, + } + + try: + self._queues[task_id].put_nowait(log_entry) + except asyncio.QueueFull: + # 队列满了,丢弃最旧的消息 + try: + self._queues[task_id].get_nowait() + self._queues[task_id].put_nowait(log_entry) + except asyncio.QueueEmpty: + pass + + async def emit_step(self, task_id: str, step: str): + """发送步骤标记""" + await self.emit(task_id, f"[{step}]", level="step") + + async def emit_error(self, task_id: str, error: str): + """发送错误消息""" + await self.emit(task_id, error, level="error") + + async def emit_warning(self, task_id: str, warning: str): + """发送警告消息""" + await self.emit(task_id, warning, level="warn") + + async def subscribe(self, task_id: str) -> AsyncGenerator[dict, None]: + """订阅任务日志""" + queue = self._queues.get(task_id) + if not queue: + return + + while True: + try: + msg = await asyncio.wait_for(queue.get(), timeout=30) + if msg is None: # 结束信号 + break + yield msg + except asyncio.TimeoutError: + # 发送心跳 + yield {"timestamp": datetime.now().isoformat(), "level": "heartbeat", "message": ""} + except Exception: + break + + def complete(self, task_id: str): + """标记任务日志结束""" + if task_id in self._queues: + try: + self._queues[task_id].put_nowait(None) + except asyncio.QueueFull: + pass + + def cleanup(self, task_id: str): + """清理任务日志队列""" + self._queues.pop(task_id, None) + + +# 全局单例 +log_streamer = LogStreamer() diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..35460a4 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,459 @@ +#!/bin/bash +# iOS 自动打包服务 — 部署与管理脚本 +# 用法: ./deploy.sh {build|start|stop|restart|status|watchdog|watchdog-stop} + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +PID_FILE="$SCRIPT_DIR/.server.pid" +WATCHDOG_PID_FILE="$SCRIPT_DIR/.watchdog.pid" +LOG_DIR="$SCRIPT_DIR/logs" +VENV_DIR="$SCRIPT_DIR/.venv" + +# 加载 .env +if [ -f .env ]; then + set -a; source .env; set +a +fi + +BACKEND_PORT="${BACKEND_PORT:-8000}" +WATCHDOG_INTERVAL="${WATCHDOG_INTERVAL:-30}" # 健康检查间隔(秒) +WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒) +MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却 +COOLDOWN_SECONDS="${COOLDOWN_SECONDS:-300}" # 冷却时间(秒) + +# ========== 颜色输出 ========== +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; } + +# 日志同时写文件和终端 +log_to_file() { + local level="$1"; shift + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" >> "$LOG_DIR/watchdog.log" +} + +# ========== 环境检查 ========== +check_env() { + if [ ! -f .env ]; then + error ".env 文件不存在" + echo " 请执行: cp .env.example .env && vim .env" + exit 1 + fi + + if ! command -v python3 &>/dev/null; then + error "未找到 python3" + exit 1 + fi + + if ! command -v node &>/dev/null; then + error "未找到 node" + exit 1 + fi + + # 检查 Xcode + if ! command -v xcodebuild &>/dev/null; then + warn "未找到 xcodebuild,打包功能将不可用" + fi +} + +# ========== Python 虚拟环境 ========== +setup_venv() { + if [ ! -d "$VENV_DIR" ]; then + info "创建 Python 虚拟环境..." + python3 -m venv "$VENV_DIR" + fi + source "$VENV_DIR/bin/activate" + pip install -r requirements.txt -q +} + +# ========== 构建前端 ========== +do_build() { + # build 只需要 Python 和 Node,不要求 .env + if ! command -v python3 &>/dev/null; then + error "未找到 python3"; exit 1 + fi + if ! command -v node &>/dev/null; then + error "未找到 node"; exit 1 + fi + + # 首次部署时自动创建 .env + if [ ! -f .env ]; then + info "首次部署,创建 .env 配置文件..." + cp .env.example .env + warn "已创建 .env,请按需修改配置(当前为默认值)" + fi + + info "安装前端依赖..." + cd "$SCRIPT_DIR/frontend" + npm install --silent + + info "构建前端..." + npm run build + cd "$SCRIPT_DIR" + + setup_venv + + info "构建完成" +} + +# ========== 启动服务 ========== +do_start() { + check_env + + if [ -f "$PID_FILE" ]; then + local pid + pid=$(cat "$PID_FILE") + if kill -0 "$pid" 2>/dev/null; then + warn "服务已在运行 (PID: $pid)" + return 0 + fi + rm -f "$PID_FILE" + fi + + # 确保前端已构建 + if [ ! -d "$SCRIPT_DIR/backend/static" ]; then + warn "前端未构建,先执行 build..." + do_build + fi + + setup_venv + + mkdir -p "$LOG_DIR" + + info "启动后端服务 (端口: $BACKEND_PORT)..." + nohup python3 -m uvicorn backend.main:app \ + --host 0.0.0.0 \ + --port "$BACKEND_PORT" \ + --workers 1 \ + > "$LOG_DIR/server.log" 2>&1 & + + echo $! > "$PID_FILE" + sleep 1 + + if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + info "服务已启动 (PID: $(cat "$PID_FILE"))" + info "访问地址: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo 'localhost'):$BACKEND_PORT" + else + error "启动失败,请查看日志: $LOG_DIR/server.log" + rm -f "$PID_FILE" + return 1 + fi +} + +# ========== 停止服务 ========== +do_stop() { + if [ ! -f "$PID_FILE" ]; then + warn "服务未在运行" + return 0 + fi + + local pid + pid=$(cat "$PID_FILE") + if kill -0 "$pid" 2>/dev/null; then + info "停止服务 (PID: $pid)..." + kill "$pid" + # 等待进程退出 + for i in $(seq 1 10); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.5 + done + # 还没退出就强杀 + if kill -0 "$pid" 2>/dev/null; then + warn "进程未响应,强制终止..." + kill -9 "$pid" + fi + info "服务已停止" + else + warn "进程已不存在" + fi + rm -f "$PID_FILE" +} + +# ========== 查看状态 ========== +do_status() { + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + info "服务运行中 (PID: $(cat "$PID_FILE")), 端口: $BACKEND_PORT" + else + warn "服务未运行" + rm -f "$PID_FILE" 2>/dev/null + fi + + if [ -f "$WATCHDOG_PID_FILE" ] && kill -0 "$(cat "$WATCHDOG_PID_FILE")" 2>/dev/null; then + info "Watchdog 运行中 (PID: $(cat "$WATCHDOG_PID_FILE")), 检查间隔: ${WATCHDOG_INTERVAL}s" + else + warn "Watchdog 未运行" + rm -f "$WATCHDOG_PID_FILE" 2>/dev/null + fi +} + +# ========== 健康检查 ========== +check_health() { + # 检查进程是否存在 + if [ ! -f "$PID_FILE" ] || ! kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "process_down" + return + fi + + # 检查 HTTP 是否响应 + local http_code + http_code=$(curl -s -o /dev/null -w "%{http_code}" \ + --connect-timeout "$WATCHDOG_TIMEOUT" \ + --max-time "$WATCHDOG_TIMEOUT" \ + "http://127.0.0.1:$BACKEND_PORT/api/health" 2>/dev/null || echo "000") + + if [ "$http_code" != "200" ]; then + echo "http_$http_code" + return + fi + + echo "ok" +} + +# ========== Watchdog(前台模式,用于调试) ========== +do_watchdog_fg() { + info "Watchdog 启动(前台模式),检查间隔: ${WATCHDOG_INTERVAL}s" + info "按 Ctrl+C 停止" + + local restart_count=0 + local last_restart_time=0 + + while true; do + local result + result=$(check_health) + + if [ "$result" = "ok" ]; then + restart_count=0 + else + local now + now=$(date +%s) + warn "健康检查失败: $result" + + # 冷却判断:连续重启超过上限,暂停一段时间 + if [ "$restart_count" -ge "$MAX_RESTART_ATTEMPTS" ]; then + local elapsed=$((now - last_restart_time)) + if [ "$elapsed" -lt "$COOLDOWN_SECONDS" ]; then + local wait=$((COOLDOWN_SECONDS - elapsed)) + warn "连续重启 ${restart_count} 次,冷却中,${wait}s 后重试..." + log_to_file "WARN" "Cooldown: ${restart_count} restarts, waiting ${wait}s" + sleep "$wait" + continue + else + warn "冷却结束,重新尝试..." + restart_count=0 + fi + fi + + warn "正在重启服务..." + log_to_file "WARN" "Restarting service (cause: $result, attempt: $((restart_count+1)))" + + do_stop + sleep 2 + do_start + + restart_count=$((restart_count + 1)) + last_restart_time=$(date +%s) + + if [ "$restart_count" -ge "$MAX_RESTART_ATTEMPTS" ]; then + error "已连续重启 ${restart_count} 次,进入冷却..." + log_to_file "ERROR" "Entered cooldown after $restart_count restarts" + fi + fi + + sleep "$WATCHDOG_INTERVAL" + done +} + +# ========== Watchdog(后台守护) ========== +do_watchdog() { + mkdir -p "$LOG_DIR" + + if [ -f "$WATCHDOG_PID_FILE" ] && kill -0 "$(cat "$WATCHDOG_PID_FILE")" 2>/dev/null; then + warn "Watchdog 已在运行 (PID: $(cat "$WATCHDOG_PID_FILE"))" + return 0 + fi + + info "启动 Watchdog(后台模式),检查间隔: ${WATCHDOG_INTERVAL}s" + + nohup bash -c " + cd '$SCRIPT_DIR' + source .env 2>/dev/null + exec bash '$(realpath "$0")' _watchdog_loop + " >> "$LOG_DIR/watchdog.log" 2>&1 & + + echo $! > "$WATCHDOG_PID_FILE" + sleep 1 + + if kill -0 "$(cat "$WATCHDOG_PID_FILE")" 2>/dev/null; then + info "Watchdog 已启动 (PID: $(cat "$WATCHDOG_PID_FILE"))" + else + error "Watchdog 启动失败" + rm -f "$WATCHDOG_PID_FILE" + return 1 + fi +} + +# 内部循环入口(由后台 watchdog 进程调用) +do_watchdog_loop() { + local restart_count=0 + local last_restart_time=0 + + log_to_file "INFO" "Watchdog started, interval=${WATCHDOG_INTERVAL}s" + + while true; do + local result + result=$(check_health) + + if [ "$result" = "ok" ]; then + restart_count=0 + else + local now + now=$(date +%s) + log_to_file "WARN" "Health check failed: $result" + + if [ "$restart_count" -ge "$MAX_RESTART_ATTEMPTS" ]; then + local elapsed=$((now - last_restart_time)) + if [ "$elapsed" -lt "$COOLDOWN_SECONDS" ]; then + local wait=$((COOLDOWN_SECONDS - elapsed)) + log_to_file "WARN" "Cooldown: ${restart_count} restarts, waiting ${wait}s" + sleep "$wait" + continue + else + log_to_file "INFO" "Cooldown ended, resetting counter" + restart_count=0 + fi + fi + + restart_count=$((restart_count + 1)) + last_restart_time=$(date +%s) + log_to_file "WARN" "Restarting service (attempt: $restart_count)" + + # 停止旧进程 + if [ -f "$PID_FILE" ]; then + local old_pid + old_pid=$(cat "$PID_FILE") + kill "$old_pid" 2>/dev/null + sleep 2 + kill -9 "$old_pid" 2>/dev/null || true + rm -f "$PID_FILE" + fi + + # 重新启动 + source "$VENV_DIR/bin/activate" 2>/dev/null + mkdir -p "$LOG_DIR" + nohup python3 -m uvicorn backend.main:app \ + --host 0.0.0.0 \ + --port "$BACKEND_PORT" \ + --workers 1 \ + >> "$LOG_DIR/server.log" 2>&1 & + echo $! > "$PID_FILE" + + sleep 3 + if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + log_to_file "INFO" "Service restarted successfully (PID: $(cat "$PID_FILE"))" + else + log_to_file "ERROR" "Service restart failed" + fi + fi + + sleep "$WATCHDOG_INTERVAL" + done +} + +# ========== 停止 Watchdog ========== +do_watchdog_stop() { + if [ ! -f "$WATCHDOG_PID_FILE" ]; then + warn "Watchdog 未在运行" + return 0 + fi + + local pid + pid=$(cat "$WATCHDOG_PID_FILE") + if kill -0 "$pid" 2>/dev/null; then + info "停止 Watchdog (PID: $pid)..." + kill "$pid" + sleep 1 + kill -9 "$pid" 2>/dev/null || true + info "Watchdog 已停止" + else + warn "Watchdog 进程已不存在" + fi + rm -f "$WATCHDOG_PID_FILE" +} + +# ========== 运行测试 ========== +do_test() { + local failed=0 + + # 确保依赖已安装 + if [ ! -d "$VENV_DIR" ]; then + setup_venv + else + source "$VENV_DIR/bin/activate" + fi + + if [ ! -d "frontend/node_modules" ]; then + cd frontend && npm install --silent && cd .. + fi + + # 后端测试 + info "运行后端测试..." + echo "────────────────────────────────────────" + python3 -m pytest tests/ -v "$@" || failed=1 + echo "" + + # 前端测试 + info "运行前端测试..." + echo "────────────────────────────────────────" + cd frontend && npx vitest run "$@" || failed=1 + cd "$SCRIPT_DIR" + echo "" + + # 结果 + if [ "$failed" -eq 0 ]; then + info "全部测试通过" + else + error "存在失败的测试" + exit 1 + fi +} + +# ========== 主入口 ========== +case "${1:-}" in + build) do_build ;; + start) do_start ;; + stop) do_stop; do_watchdog_stop ;; + restart) do_stop; do_start ;; + status) do_status ;; + test) shift; do_test "$@" ;; + watchdog) do_watchdog ;; + watchdog-stop) do_watchdog_stop ;; + watchdog-fg) do_watchdog_fg ;; + _watchdog_loop) do_watchdog_loop ;; + *) + echo "用法: $0 {build|start|stop|restart|status|test|watchdog|watchdog-stop}" + echo "" + echo " build 构建前端、安装依赖" + echo " start 启动服务(后台运行)" + echo " stop 停止服务(同时停止 watchdog)" + echo " restart 重启服务" + echo " status 查看运行状态" + echo " test 运行全部测试(后端 + 前端)" + echo " watchdog 启动健康监测(后台守护,自动重启故障服务)" + echo " watchdog-stop 停止健康监测" + echo " watchdog-fg 前台运行健康监测(调试用)" + echo "" + echo "环境变量(可在 .env 中配置):" + echo " WATCHDOG_INTERVAL 健康检查间隔(秒,默认 30)" + echo " WATCHDOG_TIMEOUT 健康检查超时(秒,默认 10)" + echo " MAX_RESTART_ATTEMPTS 连续重启上限(默认 3)" + echo " COOLDOWN_SECONDS 超过重启上限后冷却时间(秒,默认 300)" + exit 1 + ;; +esac diff --git a/deploy/install-service.sh b/deploy/install-service.sh new file mode 100755 index 0000000..94d1cd8 --- /dev/null +++ b/deploy/install-service.sh @@ -0,0 +1,145 @@ +#!/bin/bash +# 安装/卸载 macOS launchd 服务(主服务 + watchdog 健康监测) +# 用法: ./deploy/install-service.sh {install|uninstall} + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# 加载 .env +if [ -f "$PROJECT_DIR/.env" ]; then + set -a; source "$PROJECT_DIR/.env"; set +a +fi + +PORT="${BACKEND_PORT:-8000}" +VENV_DIR="$PROJECT_DIR/.venv" +LOG_DIR="$PROJECT_DIR/logs" + +PLIST_APP="com.readoor.buildserver" +PLIST_WD="com.readoor.buildserver.watchdog" +PLIST_APP_TARGET="$HOME/Library/LaunchAgents/$PLIST_APP.plist" +PLIST_WD_TARGET="$HOME/Library/LaunchAgents/$PLIST_WD.plist" + +do_install() { + if [ ! -d "$VENV_DIR" ]; then + echo "请先执行 ./deploy.sh build" + exit 1 + fi + if [ ! -d "$PROJECT_DIR/backend/static" ]; then + echo "请先执行 ./deploy.sh build" + exit 1 + fi + + mkdir -p "$LOG_DIR" + + # ---- 主服务 plist ---- + cat > "$PLIST_APP_TARGET" < + + + + Label + $PLIST_APP + ProgramArguments + + $VENV_DIR/bin/python3 + -m + uvicorn + backend.main:app + --host + 0.0.0.0 + --port + $PORT + --workers + 1 + + WorkingDirectory + $PROJECT_DIR + EnvironmentVariables + + PATH + $VENV_DIR/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + StandardOutPath + $LOG_DIR/server-stdout.log + StandardErrorPath + $LOG_DIR/server-stderr.log + + +EOF + + # ---- Watchdog plist ---- + cat > "$PLIST_WD_TARGET" < + + + + Label + $PLIST_WD + ProgramArguments + + $PROJECT_DIR/deploy.sh + _watchdog_loop + + WorkingDirectory + $PROJECT_DIR + EnvironmentVariables + + PATH + $VENV_DIR/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + RunAtLoad + + KeepAlive + + StandardOutPath + $LOG_DIR/watchdog-stdout.log + StandardErrorPath + $LOG_DIR/watchdog-stderr.log + + +EOF + + # 加载服务 + launchctl unload "$PLIST_WD_TARGET" 2>/dev/null || true + launchctl unload "$PLIST_APP_TARGET" 2>/dev/null || true + launchctl load "$PLIST_APP_TARGET" + launchctl load "$PLIST_WD_TARGET" + + echo "服务已安装并启动" + echo " 主服务: http://localhost:$PORT" + echo " Watchdog: 每 ${WATCHDOG_INTERVAL:-30}s 健康检查,故障自动重启" + echo " 日志目录: $LOG_DIR" + echo "" + echo "卸载命令: $0 uninstall" +} + +do_uninstall() { + if [ -f "$PLIST_WD_TARGET" ]; then + launchctl unload "$PLIST_WD_TARGET" 2>/dev/null || true + rm -f "$PLIST_WD_TARGET" + echo "Watchdog 已卸载" + fi + if [ -f "$PLIST_APP_TARGET" ]; then + launchctl unload "$PLIST_APP_TARGET" 2>/dev/null || true + rm -f "$PLIST_APP_TARGET" + echo "主服务已卸载" + fi +} + +case "${1:-}" in + install) do_install ;; + uninstall) do_uninstall ;; + *) + echo "用法: $0 {install|uninstall}" + exit 1 + ;; +esac diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..364d4c2 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + iOS 自动打包服务 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a41211c --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3649 @@ +{ + "name": "build-server-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "build-server-frontend", + "version": "1.0.0", + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.2.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "@vue/test-utils": "^2.4.11", + "jsdom": "^29.1.1", + "vite": "^5.0.0", + "vitest": "^4.1.8" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz", + "integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.35", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz", + "integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.35.tgz", + "integrity": "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.35", + "@vue/compiler-dom": "3.5.35", + "@vue/compiler-ssr": "3.5.35", + "@vue/shared": "3.5.35", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.35.tgz", + "integrity": "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz", + "integrity": "sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.35.tgz", + "integrity": "sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.35.tgz", + "integrity": "sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.35", + "@vue/runtime-core": "3.5.35", + "@vue/shared": "3.5.35", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.35.tgz", + "integrity": "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.35", + "@vue/shared": "3.5.35" + }, + "peerDependencies": { + "vue": "3.5.35" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz", + "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.11.tgz", + "integrity": "sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz", + "integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz", + "integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.35", + "@vue/compiler-sfc": "3.5.35", + "@vue/runtime-dom": "3.5.35", + "@vue/server-renderer": "3.5.35", + "@vue/shared": "3.5.35" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.3.tgz", + "integrity": "sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8c96f6d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "build-server-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.2.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "@vue/test-utils": "^2.4.11", + "jsdom": "^29.1.1", + "vite": "^5.0.0", + "vitest": "^4.1.8" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..d9c5cbc --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/frontend/src/__tests__/App.test.js b/frontend/src/__tests__/App.test.js new file mode 100644 index 0000000..474968a --- /dev/null +++ b/frontend/src/__tests__/App.test.js @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import App from '../App.vue' + +global.fetch = vi.fn() + +// Mock localStorage +const localStorageMock = (() => { + let store = {} + return { + getItem: vi.fn(k => store[k] || null), + setItem: vi.fn((k, v) => { store[k] = v }), + removeItem: vi.fn(k => { delete store[k] }), + clear: vi.fn(() => { store = {} }), + } +})() +Object.defineProperty(global, 'localStorage', { value: localStorageMock }) + +function createMockRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: { template: '
' } }], + }) +} + +describe('App.vue', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorageMock.getItem.mockImplementation(k => null) + }) + + it('显示标题', async () => { + const router = createMockRouter() + const wrapper = mount(App, { + global: { plugins: [router] }, + }) + expect(wrapper.text()).toContain('iOS 自动打包服务') + }) + + it('未登录时显示登录按钮', async () => { + const router = createMockRouter() + const wrapper = mount(App, { + global: { plugins: [router] }, + }) + expect(wrapper.find('.btn-login').exists()).toBe(true) + }) + + it('已登录时显示退出按钮', async () => { + localStorageMock.getItem.mockReturnValue('true') + const router = createMockRouter() + const wrapper = mount(App, { + global: { plugins: [router] }, + }) + expect(wrapper.find('.btn-logout').exists()).toBe(true) + expect(wrapper.find('.btn-login').exists()).toBe(false) + }) + + it('点击登录按钮弹出登录框', async () => { + const router = createMockRouter() + const wrapper = mount(App, { + global: { plugins: [router] }, + }) + await wrapper.find('.btn-login').trigger('click') + expect(wrapper.find('.login-modal').exists()).toBe(true) + }) + + it('登录成功后更新状态', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ token: 'admin-token', is_admin: true }), + }) + + const router = createMockRouter() + const wrapper = mount(App, { + global: { plugins: [router] }, + }) + + await wrapper.find('.btn-login').trigger('click') + await wrapper.find('.login-modal input[type="text"]').setValue('admin') + await wrapper.find('.login-modal input[type="password"]').setValue('admin123') + await wrapper.find('.login-btn').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.find('.btn-logout').exists()).toBe(true) + }) + + it('登录失败显示错误', async () => { + fetch.mockResolvedValueOnce({ ok: false }) + window.alert = vi.fn() + + const router = createMockRouter() + const wrapper = mount(App, { + global: { plugins: [router] }, + }) + + await wrapper.find('.btn-login').trigger('click') + await wrapper.find('.login-modal input[type="text"]').setValue('admin') + await wrapper.find('.login-modal input[type="password"]').setValue('wrong') + await wrapper.find('.login-btn').trigger('click') + await wrapper.vm.$nextTick() + + expect(window.alert).toHaveBeenCalledWith('用户名或密码错误') + }) + + it('点击退出清除登录状态', async () => { + localStorageMock.getItem.mockReturnValue('true') + const router = createMockRouter() + const wrapper = mount(App, { + global: { plugins: [router] }, + }) + + await wrapper.find('.btn-logout').trigger('click') + expect(wrapper.find('.btn-login').exists()).toBe(true) + expect(localStorageMock.removeItem).toHaveBeenCalledWith('isAdmin') + }) + + it('已登录时显示管理导航', async () => { + localStorageMock.getItem.mockReturnValue('true') + const router = createMockRouter() + const wrapper = mount(App, { + global: { plugins: [router] }, + }) + const links = wrapper.findAll('.nav a') + const texts = links.map(l => l.text()) + expect(texts).toContain('管理') + }) +}) diff --git a/frontend/src/__tests__/BuildView.test.js b/frontend/src/__tests__/BuildView.test.js new file mode 100644 index 0000000..5507ed5 --- /dev/null +++ b/frontend/src/__tests__/BuildView.test.js @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import BuildView from '../views/BuildView.vue' + +global.fetch = vi.fn() + +// Mock WebSocket as a class +class MockWebSocket { + constructor(url) { this.url = url; this.onmessage = null; } + close() {} +} +global.WebSocket = MockWebSocket + +describe('BuildView.vue', () => { + beforeEach(() => { + vi.clearAllMocks() + fetch.mockImplementation((url) => { + const responses = { + '/api/apps': { '1': { name: '测试App', server: '测试环境' } }, + '/api/schemes': { '1': { name: 'testScheme' } }, + '/api/branches': ['main', 'dev'], + '/api/tasks': [], + } + return Promise.resolve({ + json: () => Promise.resolve(responses[url] || {}), + }) + }) + }) + + it('显示打包配置面板', async () => { + const wrapper = mount(BuildView) + await flushPromises() + + expect(wrapper.text()).toContain('打包配置') + expect(wrapper.text()).toContain('选择 App') + expect(wrapper.text()).toContain('打包类型') + expect(wrapper.text()).toContain('代码分支') + expect(wrapper.text()).toContain('代码混淆') + }) + + it('加载 apps 和 schemes', async () => { + const wrapper = mount(BuildView) + await flushPromises() + + const appOptions = wrapper.findAll('select')[0].findAll('option') + expect(appOptions.length).toBeGreaterThan(1) + }) + + it('加载分支列表并显示下拉', async () => { + const wrapper = mount(BuildView) + await flushPromises() + + const branchSelect = wrapper.findAll('select').find(s => { + const options = s.findAll('option') + return options.some(o => o.text() === 'main') + }) + expect(branchSelect).toBeTruthy() + }) + + it('默认值正确', async () => { + const wrapper = mount(BuildView) + await flushPromises() + + expect(wrapper.vm.form.build_type).toBe('Ad_Hoc') + expect(wrapper.vm.form.obfuscation).toBe(false) + expect(wrapper.vm.form.branch).toBe('main') + }) + + it('提交打包任务', async () => { + fetch.mockImplementation((url, opts) => { + if (url === '/api/tasks' && opts?.method === 'POST') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ id: 'test-id', status: 'pending' }), + }) + } + const responses = { + '/api/apps': { '1': { name: '测试App', server: '测试环境' } }, + '/api/schemes': { '1': { name: 'testScheme' } }, + '/api/branches': ['main', 'dev'], + '/api/tasks': [], + } + return Promise.resolve({ + json: () => Promise.resolve(responses[url] || {}), + }) + }) + + const wrapper = mount(BuildView) + await flushPromises() + + await wrapper.setData({ form: { ...wrapper.vm.form, app_id: '1' } }) + await wrapper.find('.btn-primary').trigger('click') + await flushPromises() + + expect(fetch).toHaveBeenCalledWith('/api/tasks', expect.objectContaining({ + method: 'POST', + })) + }) + + it('未选择 App 时提示', async () => { + window.alert = vi.fn() + const wrapper = mount(BuildView) + await flushPromises() + + await wrapper.find('.btn-primary').trigger('click') + expect(window.alert).toHaveBeenCalledWith('请选择 App') + }) + + it('显示任务队列', async () => { + fetch.mockImplementation((url) => { + const responses = { + '/api/apps': { '1': { name: '测试App', server: '测试环境' } }, + '/api/schemes': { '1': { name: 'testScheme' } }, + '/api/branches': ['main', 'dev'], + '/api/tasks': [ + { id: '1', app_name: 'App1', build_type: 'Ad_Hoc', scheme_name: 'sch', status: 'pending', created_at: '2024-01-01T00:00:00' }, + { id: '2', app_name: 'App2', build_type: 'App_Store', scheme_name: 'sch', status: 'completed', created_at: '2024-01-01T00:00:00' }, + ], + } + return Promise.resolve({ + json: () => Promise.resolve(responses[url] || {}), + }) + }) + + const wrapper = mount(BuildView) + await flushPromises() + + const taskItems = wrapper.findAll('.task-item') + expect(taskItems.length).toBe(1) // 只显示未完成的任务(pending),completed 已过滤 + }) + + it('状态文本正确', async () => { + const wrapper = mount(BuildView) + await flushPromises() + + expect(wrapper.vm.statusText('pending')).toBe('等待中') + expect(wrapper.vm.statusText('running')).toBe('打包中') + expect(wrapper.vm.statusText('completed')).toBe('已完成') + expect(wrapper.vm.statusText('failed')).toBe('失败') + expect(wrapper.vm.statusText('cancelled')).toBe('已取消') + }) +}) diff --git a/frontend/src/__tests__/ConfigView.test.js b/frontend/src/__tests__/ConfigView.test.js new file mode 100644 index 0000000..8f231e7 --- /dev/null +++ b/frontend/src/__tests__/ConfigView.test.js @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import ConfigView from '../views/ConfigView.vue' + +global.fetch = vi.fn() + +// Mock localStorage +const localStorageMock = (() => { + let store = {} + return { + getItem: vi.fn(k => store[k] || null), + setItem: vi.fn((k, v) => { store[k] = v }), + removeItem: vi.fn(k => { delete store[k] }), + clear: vi.fn(() => { store = {} }), + } +})() +Object.defineProperty(global, 'localStorage', { value: localStorageMock }) + +function mockFetch(url) { + const responses = { + '/api/config/apps': { '1': { name: '测试App', server: '测试环境', AppGuid: 'guid' } }, + '/api/config/schemes': { '1': { name: 'testScheme', ossFloder: 'test' } }, + '/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } }, + '/api/config/branches': ['main', 'dev'], + '/api/config/build': { max_concurrent_builds: 2, build_dir_retention_hours: 24, build_base_dir: '/tmp' }, + '/api/config': { apps: {}, schemes: {}, branches: ['main'] }, + } + return Promise.resolve({ + json: () => Promise.resolve(responses[url] || {}), + }) +} + +describe('ConfigView.vue', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorageMock.clear() + localStorageMock.getItem.mockReturnValue(null) + fetch.mockImplementation(mockFetch) + }) + + it('未登录时显示权限提示', async () => { + const wrapper = mount(ConfigView) + await flushPromises() + expect(wrapper.text()).toContain('需要管理员权限') + }) + + it('已登录时显示管理页面', async () => { + localStorageMock.getItem.mockReturnValue('true') + const wrapper = mount(ConfigView) + await flushPromises() + expect(wrapper.text()).toContain('配置管理') + }) + + it('显示侧边栏菜单', async () => { + localStorageMock.getItem.mockReturnValue('true') + const wrapper = mount(ConfigView) + await flushPromises() + + const menuItems = wrapper.findAll('.sidebar-menu li') + const texts = menuItems.map(li => li.text()) + expect(texts).toContain('服务器环境') + expect(texts).toContain('Apps 配置') + expect(texts).toContain('Schemes 配置') + expect(texts).toContain('分支管理') + expect(texts).toContain('打包设置') + }) + + it('默认显示服务器环境 tab', async () => { + localStorageMock.getItem.mockReturnValue('true') + const wrapper = mount(ConfigView) + await flushPromises() + expect(wrapper.text()).toContain('服务器环境配置') + }) + + it('切换到分支管理 tab', async () => { + localStorageMock.getItem.mockReturnValue('true') + const wrapper = mount(ConfigView) + await flushPromises() + + const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理') + await branchesMenu.trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('main') + expect(wrapper.text()).toContain('dev') + }) + + it('添加分支', async () => { + fetch.mockImplementation((url, opts) => { + if (url === '/api/config/branches' && opts?.method === 'POST') { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ message: 'ok' }) }) + } + return mockFetch(url) + }) + + localStorageMock.getItem.mockReturnValue('true') + const wrapper = mount(ConfigView) + await flushPromises() + + const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理') + await branchesMenu.trigger('click') + await wrapper.vm.$nextTick() + + const input = wrapper.find('input[placeholder="输入分支名称"]') + await input.setValue('new-branch') + + const addBtn = wrapper.findAll('.btn-primary').find(b => b.text() === '添加分支') + await addBtn.trigger('click') + await flushPromises() + + expect(fetch).toHaveBeenCalledWith('/api/config/branches', expect.objectContaining({ + method: 'POST', + })) + }) + + it('删除分支', async () => { + window.confirm = vi.fn(() => true) + fetch.mockImplementation((url, opts) => { + if (url.includes('/api/config/branches/') && opts?.method === 'DELETE') { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ message: 'ok' }) }) + } + return mockFetch(url) + }) + + localStorageMock.getItem.mockReturnValue('true') + const wrapper = mount(ConfigView) + await flushPromises() + + const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理') + await branchesMenu.trigger('click') + await wrapper.vm.$nextTick() + + const deleteBtns = wrapper.findAll('.action-btn.delete') + if (deleteBtns.length > 0) { + await deleteBtns[0].trigger('click') + await flushPromises() + expect(window.confirm).toHaveBeenCalled() + } + }) + + it('保存打包设置', async () => { + fetch.mockImplementation((url, opts) => { + if (url === '/api/config/build' && opts?.method === 'PUT') { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ message: 'ok' }) }) + } + return mockFetch(url) + }) + window.alert = vi.fn() + + localStorageMock.getItem.mockReturnValue('true') + const wrapper = mount(ConfigView) + await flushPromises() + + const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置') + await buildMenu.trigger('click') + await wrapper.vm.$nextTick() + + const saveBtn = wrapper.findAll('.btn-primary').find(b => b.text() === '保存设置') + await saveBtn.trigger('click') + await flushPromises() + + expect(window.alert).toHaveBeenCalledWith('设置已保存') + }) +}) diff --git a/frontend/src/__tests__/HistoryView.test.js b/frontend/src/__tests__/HistoryView.test.js new file mode 100644 index 0000000..c14064c --- /dev/null +++ b/frontend/src/__tests__/HistoryView.test.js @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import HistoryView from '../views/HistoryView.vue' + +global.fetch = vi.fn() +window.open = vi.fn() + +const mockTasks = [ + { id: '1', app_name: 'App1', build_type: 'Ad_Hoc', scheme_name: 'sch1', status: 'completed', created_at: '2024-01-01T10:00:00', dsym_path: '/path/dsym', oss_url: 'https://oss.com/app1.ipa', qr_code_path: '/path/qr.png' }, + { id: '2', app_name: 'App2', build_type: 'App_Store', scheme_name: 'sch2', status: 'failed', created_at: '2024-01-02T10:00:00', error_message: '构建失败' }, + { id: '3', app_name: 'App3', build_type: 'Ad_Hoc', scheme_name: 'sch1', status: 'pending', created_at: '2024-01-03T10:00:00' }, +] + +function createMockRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: { template: '
' } }, + { path: '/build', component: { template: '
' } }, + ], + }) +} + +describe('HistoryView.vue', () => { + beforeEach(() => { + vi.clearAllMocks() + fetch.mockResolvedValue({ + json: () => Promise.resolve(mockTasks), + }) + }) + + it('显示打包历史标题', async () => { + const router = createMockRouter() + const wrapper = mount(HistoryView, { + global: { plugins: [router] }, + }) + await flushPromises() + expect(wrapper.text()).toContain('打包历史') + }) + + it('加载并显示任务列表', async () => { + const router = createMockRouter() + const wrapper = mount(HistoryView, { + global: { plugins: [router] }, + }) + await flushPromises() + + const rows = wrapper.findAll('tbody tr') + expect(rows.length).toBe(2) // App2(failed) 默认隐藏 + }) + + it('显示任务信息', async () => { + const router = createMockRouter() + const wrapper = mount(HistoryView, { + global: { plugins: [router] }, + }) + await flushPromises() + + expect(wrapper.text()).toContain('App1') + // App2 是 failed 状态,默认隐藏 + expect(wrapper.text()).not.toContain('App2') + expect(wrapper.text()).toContain('Ad_Hoc') + }) + + it('默认隐藏失败和已取消的任务', async () => { + const router = createMockRouter() + const wrapper = mount(HistoryView, { + global: { plugins: [router] }, + }) + await flushPromises() + + // 只显示 App1(completed)和 App3(pending),App2(failed)被隐藏 + const rows = wrapper.findAll('tbody tr') + expect(rows.length).toBe(2) + expect(wrapper.text()).toContain('App1') + expect(wrapper.text()).toContain('App3') + expect(wrapper.text()).toContain('等待中') + }) + + it('按打包类型过滤', async () => { + const router = createMockRouter() + const wrapper = mount(HistoryView, { + global: { plugins: [router] }, + }) + await flushPromises() + + const selects = wrapper.findAll('.filter-select') + await selects[0].setValue('Ad_Hoc') + await wrapper.vm.$nextTick() + + const rows = wrapper.findAll('tbody tr') + expect(rows.length).toBe(2) // App1 和 App3 + }) + + it('按状态过滤', async () => { + const router = createMockRouter() + const wrapper = mount(HistoryView, { + global: { plugins: [router] }, + }) + await flushPromises() + + const selects = wrapper.findAll('.filter-select') + await selects[1].setValue('failed') + await wrapper.vm.$nextTick() + + const rows = wrapper.findAll('tbody tr') + expect(rows.length).toBe(1) // App2 + }) + + it('已完成任务显示下载按钮', async () => { + const router = createMockRouter() + const wrapper = mount(HistoryView, { + global: { plugins: [router] }, + }) + await flushPromises() + + // 第一行(App1, completed)应该有 dSYM 和下载按钮 + const firstRow = wrapper.findAll('tbody tr')[0] + const buttons = firstRow.findAll('.action-btn') + const buttonTexts = buttons.map(b => b.text()) + expect(buttonTexts).toContain('dSYM') + expect(buttonTexts).toContain('下载') + expect(buttonTexts).toContain('二维码') + }) + + it('空列表显示提示', async () => { + fetch.mockResolvedValue({ json: () => Promise.resolve([]) }) + const router = createMockRouter() + const wrapper = mount(HistoryView, { + global: { plugins: [router] }, + }) + await flushPromises() + + expect(wrapper.text()).toContain('暂无打包记录') + }) + + it('状态文本正确', async () => { + const router = createMockRouter() + const wrapper = mount(HistoryView, { + global: { plugins: [router] }, + }) + await flushPromises() + + expect(wrapper.vm.statusText('pending')).toBe('等待中') + expect(wrapper.vm.statusText('running')).toBe('打包中') + expect(wrapper.vm.statusText('completed')).toBe('已完成') + expect(wrapper.vm.statusText('failed')).toBe('失败') + }) + + it('点击查看日志跳转', async () => { + const router = createMockRouter() + router.push = vi.fn() + const wrapper = mount(HistoryView, { + global: { plugins: [router] }, + }) + await flushPromises() + + const logBtn = wrapper.findAll('.action-btn').find(b => b.text() === '日志') + await logBtn.trigger('click') + + expect(router.push).toHaveBeenCalledWith({ path: '/build', query: { taskId: '1' } }) + }) + + it('点击二维码弹出弹窗', async () => { + const router = createMockRouter() + const wrapper = mount(HistoryView, { + global: { plugins: [router] }, + }) + await flushPromises() + + const qrBtn = wrapper.findAll('.action-btn').find(b => b.text() === '二维码') + await qrBtn.trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.find('.qr-modal').exists()).toBe(true) + expect(wrapper.text()).toContain('下载二维码') + }) +}) diff --git a/frontend/src/composables/useWebSocket.js b/frontend/src/composables/useWebSocket.js new file mode 100644 index 0000000..b8ccfba --- /dev/null +++ b/frontend/src/composables/useWebSocket.js @@ -0,0 +1,42 @@ +import { ref, onUnmounted } from 'vue' + +export function useWebSocket(taskId) { + const logs = ref([]) + const connected = ref(false) + let ws = null + + const connect = () => { + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:' + ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`) + + ws.onopen = () => { + connected.value = true + } + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) + if (msg.level !== 'heartbeat') { + logs.value.push(msg) + } + } + + ws.onclose = () => { + connected.value = false + } + + ws.onerror = () => { + connected.value = false + } + } + + const disconnect = () => { + if (ws) { + ws.close() + ws = null + } + } + + onUnmounted(disconnect) + + return { logs, connected, connect, disconnect } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..efe493a --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,7 @@ +import { createApp } from 'vue' +import App from './App.vue' +import router from './router' + +const app = createApp(App) +app.use(router) +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..4292b81 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,18 @@ +import { createRouter, createWebHistory } from 'vue-router' +import BuildView from '../views/BuildView.vue' +import HistoryView from '../views/HistoryView.vue' +import ConfigView from '../views/ConfigView.vue' + +const routes = [ + { path: '/', redirect: '/build' }, + { path: '/build', name: 'Build', component: BuildView }, + { path: '/history', name: 'History', component: HistoryView }, + { path: '/admin', name: 'Admin', component: ConfigView }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +export default router diff --git a/frontend/src/views/BuildView.vue b/frontend/src/views/BuildView.vue new file mode 100644 index 0000000..056708d --- /dev/null +++ b/frontend/src/views/BuildView.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/frontend/src/views/ConfigView.vue b/frontend/src/views/ConfigView.vue new file mode 100644 index 0000000..52e4066 --- /dev/null +++ b/frontend/src/views/ConfigView.vue @@ -0,0 +1,804 @@ + + + + + diff --git a/frontend/src/views/HistoryView.vue b/frontend/src/views/HistoryView.vue new file mode 100644 index 0000000..7b86117 --- /dev/null +++ b/frontend/src/views/HistoryView.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..a6bbac6 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 3000, + proxy: { + '/api': 'http://localhost:8000', + '/ws': { + target: 'ws://localhost:8000', + ws: true, + }, + }, + }, + build: { + outDir: '../backend/static', + }, +}) diff --git a/frontend/vitest.config.js b/frontend/vitest.config.js new file mode 100644 index 0000000..cc39b3c --- /dev/null +++ b/frontend/vitest.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + test: { + environment: 'jsdom', + globals: true, + }, +}) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..e259e6a --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_functions = test_* +asyncio_mode = auto diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..74fd95d --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=7.0.0 +httpx>=0.24.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6ee34ec --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +sqlalchemy>=2.0.0 +pydantic>=2.0.0 +python-multipart>=0.0.6 diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..eaf74a6 --- /dev/null +++ b/start.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# 一键启动本地开发环境(后端 + 前端) +# 用法: ./start.sh + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# 加载 .env +if [ -f .env ]; then + set -a; source .env; set +a +fi + +PORT="${BACKEND_PORT:-8000}" + +# 确保 .env 存在 +if [ ! -f .env ]; then + echo "首次运行,创建 .env 配置文件..." + cp .env.example .env +fi + +# 确保后端依赖 +if [ ! -d ".venv" ]; then + echo "创建 Python 虚拟环境..." + python3 -m venv .venv +fi +source .venv/bin/activate +pip install -r requirements.txt -q + +# 确保前端依赖 +if [ ! -d "frontend/node_modules" ]; then + echo "安装前端依赖..." + cd frontend && npm install --silent && cd .. +fi + +echo "=== iOS 自动打包服务(开发模式)===" +echo " 前端: http://localhost:3000" +echo " 后端: http://localhost:$PORT" +echo " 按 Ctrl+C 停止所有服务" +echo "" + +# 清理子进程 +cleanup() { + kill $BACKEND_PID $FRONTEND_PID 2>/dev/null + wait $BACKEND_PID $FRONTEND_PID 2>/dev/null +} +trap cleanup INT TERM + +# 启动后端 +python3 -m uvicorn backend.main:app --host 0.0.0.0 --port "$PORT" --reload & +BACKEND_PID=$! + +# 启动前端 +cd frontend && npm run dev & +FRONTEND_PID=$! +cd "$SCRIPT_DIR" + +wait diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..827d75d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,59 @@ +"""测试公共配置""" +import os +import json +import tempfile +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +# 使用临时数据库,避免污染开发数据库 +_test_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False) +_test_db.close() +os.environ["DATABASE_URL"] = f"sqlite:///{_test_db.name}" + +from backend.main import app +from backend.database import init_db, engine, Base + + +@pytest.fixture(autouse=True) +def setup_db(): + """每个测试前重建数据库""" + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) + yield + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture +def client(): + """FastAPI 测试客户端""" + return TestClient(app) + + +@pytest.fixture +def tmp_config(tmp_path, monkeypatch): + """使用临时 config.json""" + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps({ + "apps": { + "1": { + "name": "测试App", + "server": "测试环境", + "AppGuid": "test-guid", + "API": "https://test.api.com", + "certificates": { + "Ad_Hoc": {"name": "com.test.app", "cer": "test-cert", "pro": "test-pro", "theme": "test"} + } + } + }, + "schemes": { + "1": {"name": "testScheme", "ossFloder": "test"} + }, + "servers": { + "测试环境": {"api": "https://test.api.com", "assDom": "applinks:test.com", "universalLink": "https://test.com"} + }, + "branches": ["main", "dev"], + })) + monkeypatch.setattr("backend.routers.config.CONFIG_JSON_PATH", config_path) + return config_path diff --git a/tests/test_api_apps.py b/tests/test_api_apps.py new file mode 100644 index 0000000..7973808 --- /dev/null +++ b/tests/test_api_apps.py @@ -0,0 +1,21 @@ +"""打包选择接口测试(只读)""" + + +def test_get_apps(client, tmp_config): + resp = client.get("/api/apps") + assert resp.status_code == 200 + assert "1" in resp.json() + + +def test_get_schemes(client, tmp_config): + resp = client.get("/api/schemes") + assert resp.status_code == 200 + assert "1" in resp.json() + + +def test_get_branches(client, tmp_config): + resp = client.get("/api/branches") + assert resp.status_code == 200 + branches = resp.json() + assert "main" in branches + assert "dev" in branches diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py new file mode 100644 index 0000000..e6f303d --- /dev/null +++ b/tests/test_api_auth.py @@ -0,0 +1,19 @@ +"""认证接口测试""" + + +def test_login_success(client): + resp = client.post("/api/auth/login", json={"username": "admin", "password": "admin123"}) + assert resp.status_code == 200 + data = resp.json() + assert data["token"] == "admin-token" + assert data["is_admin"] is True + + +def test_login_wrong_password(client): + resp = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"}) + assert resp.status_code == 401 + + +def test_login_wrong_username(client): + resp = client.post("/api/auth/login", json={"username": "nobody", "password": "admin123"}) + assert resp.status_code == 401 diff --git a/tests/test_api_config.py b/tests/test_api_config.py new file mode 100644 index 0000000..d7dbb49 --- /dev/null +++ b/tests/test_api_config.py @@ -0,0 +1,146 @@ +"""配置管理接口测试""" +import json + + +def test_get_full_config(client, tmp_config): + resp = client.get("/api/config") + assert resp.status_code == 200 + data = resp.json() + assert "apps" in data + assert "schemes" in data + assert "branches" in data + + +# ---- Apps ---- + +def test_get_apps(client, tmp_config): + resp = client.get("/api/config/apps") + assert resp.status_code == 200 + assert "1" in resp.json() + + +def test_create_app(client, tmp_config): + resp = client.post("/api/config/apps", json={"name": "新App", "AppGuid": "new-guid"}) + assert resp.status_code == 200 + assert resp.json()["id"] == "2" + + # 验证已创建 + apps = client.get("/api/config/apps").json() + assert "2" in apps + assert apps["2"]["name"] == "新App" + + +def test_update_app(client, tmp_config): + resp = client.put("/api/config/apps/1", json={"name": "改名App", "AppGuid": "test-guid"}) + assert resp.status_code == 200 + assert client.get("/api/config/apps").json()["1"]["name"] == "改名App" + + +def test_delete_app(client, tmp_config): + resp = client.delete("/api/config/apps/1") + assert resp.status_code == 200 + assert "1" not in client.get("/api/config/apps").json() + + +def test_update_nonexistent_app(client, tmp_config): + resp = client.put("/api/config/apps/999", json={"name": "x"}) + assert resp.status_code == 404 + + +# ---- Schemes ---- + +def test_get_schemes(client, tmp_config): + resp = client.get("/api/config/schemes") + assert resp.status_code == 200 + assert "1" in resp.json() + + +def test_create_scheme(client, tmp_config): + resp = client.post("/api/config/schemes", json={"name": "newScheme", "ossFloder": "new"}) + assert resp.status_code == 200 + assert resp.json()["id"] == "2" + + +def test_delete_scheme(client, tmp_config): + resp = client.delete("/api/config/schemes/1") + assert resp.status_code == 200 + + +# ---- Branches ---- + +def test_get_branches(client, tmp_config): + resp = client.get("/api/config/branches") + assert resp.status_code == 200 + branches = resp.json() + assert "main" in branches + assert "dev" in branches + + +def test_add_branch(client, tmp_config): + resp = client.post("/api/config/branches", json={"name": "release/1.0"}) + assert resp.status_code == 200 + branches = client.get("/api/config/branches").json() + assert "release/1.0" in branches + + +def test_add_duplicate_branch(client, tmp_config): + resp = client.post("/api/config/branches", json={"name": "main"}) + assert resp.status_code == 400 + + +def test_add_empty_branch(client, tmp_config): + resp = client.post("/api/config/branches", json={"name": ""}) + assert resp.status_code == 400 + + +def test_delete_branch(client, tmp_config): + resp = client.delete("/api/config/branches/dev") + assert resp.status_code == 200 + branches = client.get("/api/config/branches").json() + assert "dev" not in branches + + +def test_delete_nonexistent_branch(client, tmp_config): + resp = client.delete("/api/config/branches/notexist") + assert resp.status_code == 404 + + +# ---- Servers ---- + +def test_get_servers(client, tmp_config): + resp = client.get("/api/config/servers") + assert resp.status_code == 200 + assert "测试环境" in resp.json() + + +def test_create_server(client, tmp_config): + resp = client.post("/api/config/servers", json={ + "name": "新环境", + "api": "https://new.api.com", + "assDom": "applinks:new.com", + "universalLink": "https://new.com", + }) + assert resp.status_code == 200 + assert "新环境" in client.get("/api/config/servers").json() + + +def test_delete_server_in_use(client, tmp_config): + # 测试环境被 App 1 使用,应该删除失败 + resp = client.delete("/api/config/servers/测试环境") + assert resp.status_code == 400 + + +# ---- Build Settings ---- + +def test_get_build_settings(client, tmp_config): + resp = client.get("/api/config/build") + assert resp.status_code == 200 + data = resp.json() + assert "max_concurrent_builds" in data + assert "build_dir_retention_hours" in data + + +def test_update_build_settings(client, tmp_config): + resp = client.put("/api/config/build", json={"max_concurrent_builds": 4}) + assert resp.status_code == 200 + assert client.get("/api/config/build").json()["max_concurrent_builds"] == 4 diff --git a/tests/test_api_health.py b/tests/test_api_health.py new file mode 100644 index 0000000..5c22455 --- /dev/null +++ b/tests/test_api_health.py @@ -0,0 +1,8 @@ +"""健康检查测试""" + + +def test_health(client): + resp = client.get("/api/health") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" diff --git a/tests/test_api_tasks.py b/tests/test_api_tasks.py new file mode 100644 index 0000000..b613ff6 --- /dev/null +++ b/tests/test_api_tasks.py @@ -0,0 +1,114 @@ +"""任务接口测试""" +from unittest.mock import patch, AsyncMock + + +def test_list_tasks_empty(client, tmp_config): + resp = client.get("/api/tasks") + assert resp.status_code == 200 + assert resp.json() == [] + + +@patch("backend.services.build_queue.build_queue") +def test_create_task(mock_queue, client, tmp_config): + mock_queue.submit = AsyncMock() + resp = client.post("/api/tasks", json={ + "app_id": "1", + "build_type": "Ad_Hoc", + "scheme_id": "1", + "obfuscation": True, + "branch": "main", + }) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "pending" + assert data["app_name"] == "测试App" + assert data["scheme_name"] == "testScheme" + assert data["branch"] == "main" + assert data["build_type"] == "Ad_Hoc" + + +@patch("backend.services.build_queue.build_queue") +def test_create_task_default_branch(mock_queue, client, tmp_config): + mock_queue.submit = AsyncMock() + resp = client.post("/api/tasks", json={ + "app_id": "1", + "build_type": "Ad_Hoc", + "scheme_id": "1", + }) + assert resp.status_code == 200 + assert resp.json()["branch"] == "main" + + +@patch("backend.services.build_queue.build_queue") +def test_create_task_invalid_app(mock_queue, client, tmp_config): + mock_queue.submit = AsyncMock() + resp = client.post("/api/tasks", json={ + "app_id": "999", + "build_type": "Ad_Hoc", + "scheme_id": "1", + }) + assert resp.status_code == 400 + + +@patch("backend.services.build_queue.build_queue") +def test_create_task_invalid_scheme(mock_queue, client, tmp_config): + mock_queue.submit = AsyncMock() + resp = client.post("/api/tasks", json={ + "app_id": "1", + "build_type": "Ad_Hoc", + "scheme_id": "999", + }) + assert resp.status_code == 400 + + +@patch("backend.services.build_queue.build_queue") +def test_list_tasks_after_create(mock_queue, client, tmp_config): + mock_queue.submit = AsyncMock() + client.post("/api/tasks", json={ + "app_id": "1", + "build_type": "Ad_Hoc", + "scheme_id": "1", + }) + tasks = client.get("/api/tasks").json() + assert len(tasks) == 1 + assert tasks[0]["app_name"] == "测试App" + + +@patch("backend.services.build_queue.build_queue") +def test_get_task_detail(mock_queue, client, tmp_config): + mock_queue.submit = AsyncMock() + create_resp = client.post("/api/tasks", json={ + "app_id": "1", + "build_type": "Ad_Hoc", + "scheme_id": "1", + }) + task_id = create_resp.json()["id"] + + resp = client.get(f"/api/tasks/{task_id}") + assert resp.status_code == 200 + assert resp.json()["id"] == task_id + + +def test_get_nonexistent_task(client, tmp_config): + resp = client.get("/api/tasks/nonexistent") + assert resp.status_code == 404 + + +@patch("backend.services.build_queue.build_queue") +def test_cancel_task(mock_queue, client, tmp_config): + mock_queue.submit = AsyncMock() + mock_queue.cancel = lambda x: None + + create_resp = client.post("/api/tasks", json={ + "app_id": "1", + "build_type": "Ad_Hoc", + "scheme_id": "1", + }) + task_id = create_resp.json()["id"] + + resp = client.delete(f"/api/tasks/{task_id}") + assert resp.status_code == 200 + + # 验证状态已更新 + task = client.get(f"/api/tasks/{task_id}").json() + assert task["status"] == "cancelled" diff --git a/tests/test_build_queue.py b/tests/test_build_queue.py new file mode 100644 index 0000000..9562f64 --- /dev/null +++ b/tests/test_build_queue.py @@ -0,0 +1,118 @@ +"""打包队列测试""" +import asyncio +import pytest +from backend.services.build_queue import BuildQueue + + +@pytest.fixture +def queue(): + return BuildQueue(max_concurrent=2) + + +def test_initial_state(queue): + """初始状态""" + assert queue.queue_size == 0 + assert queue.running_count == 0 + + +def test_submit_starts_workers(queue): + """提交任务后自动启动 worker""" + + async def dummy_build(task_id): + pass + + async def run(): + await queue.submit("t1", dummy_build) + # 等待任务完成 + await asyncio.sleep(0.1) + assert queue._started + assert len(queue._workers) == 2 + + asyncio.get_event_loop().run_until_complete(run()) + + +def test_task_executes(queue): + """任务被执行""" + executed = [] + + async def mock_build(task_id): + executed.append(task_id) + + async def run(): + await queue.submit("t1", mock_build) + await asyncio.sleep(0.2) + return executed + + result = asyncio.get_event_loop().run_until_complete(run()) + assert "t1" in result + + +def test_concurrent_limit(queue): + """并发数不超过限制""" + running = [] + max_concurrent = 0 + + async def slow_build(task_id): + nonlocal max_concurrent + running.append(task_id) + max_concurrent = max(max_concurrent, len(running)) + await asyncio.sleep(0.3) + running.remove(task_id) + + async def run(): + tasks = [] + for i in range(5): + tasks.append(queue.submit(f"t{i}", slow_build)) + await asyncio.gather(*tasks) + await asyncio.sleep(1) # 等所有任务完成 + return max_concurrent + + result = asyncio.get_event_loop().run_until_complete(run()) + assert result <= 2 # max_concurrent = 2 + + +def test_cancel_running_task(queue): + """取消运行中的任务""" + cancelled = False + + async def long_build(task_id): + nonlocal cancelled + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + cancelled = True + raise + + async def run(): + await queue.submit("t1", long_build) + await asyncio.sleep(0.1) # 等任务开始 + queue.cancel("t1") + await asyncio.sleep(0.2) # 等取消生效 + return cancelled + + result = asyncio.get_event_loop().run_until_complete(run()) + assert result is True + + +def test_cancel_nonexistent_task(queue): + """取消不存在的任务(不应报错)""" + queue.cancel("nonexistent") + + +def test_multiple_tasks(queue): + """多个任务顺序执行""" + completed = [] + + async def mock_build(task_id): + await asyncio.sleep(0.05) + completed.append(task_id) + + async def run(): + for i in range(4): + await queue.submit(f"t{i}", mock_build) + await asyncio.sleep(1) + return completed + + result = asyncio.get_event_loop().run_until_complete(run()) + assert len(result) == 4 + assert set(result) == {"t0", "t1", "t2", "t3"} diff --git a/tests/test_build_service.py b/tests/test_build_service.py new file mode 100644 index 0000000..f824eb6 --- /dev/null +++ b/tests/test_build_service.py @@ -0,0 +1,292 @@ +"""打包服务核心逻辑测试""" +import asyncio +import json +import os +import time +import shutil +import tempfile +from pathlib import Path +from unittest.mock import patch, AsyncMock, MagicMock + +import pytest + +from backend.services.build_service import ( + update_source, + copy_source_code, + generate_config, + _cleanup_old_builds, +) +from backend.services.log_streamer import LogStreamer + + +@pytest.fixture +def log_streamer(): + return LogStreamer() + + +@pytest.fixture +def tmp_dirs(tmp_path): + """创建临时源码目录和打包目录""" + source_dir = tmp_path / "source" + source_dir.mkdir() + (source_dir / "readoor").mkdir() + (source_dir / "readoor" / "AppDelegate.swift").write_text("// app") + (source_dir / "readoor.xcodeproj").mkdir() + (source_dir / "Pods").mkdir() + (source_dir / "Podfile.lock").write_text("PODFILE CHECKSUM: abc") + (source_dir / "readoor.xcworkspace").mkdir() + (source_dir / "AutoPacking").mkdir() + (source_dir / "readoorTests").mkdir() + (source_dir / "podfile").write_text("pod 'AFNetworking'") + + build_dir = tmp_path / "build" + return source_dir, build_dir + + +def _make_mock_process(returncode=0, output=b"ok\n"): + """创建 mock 进程,支持 async for stdout""" + proc = AsyncMock() + proc.returncode = returncode + proc.wait = AsyncMock(return_value=returncode) + + async def iter_stdout(): + yield output + + proc.stdout = iter_stdout() + return proc + + +# ---- update_source ---- + +async def test_update_source_clone(tmp_path, log_streamer): + """目录不存在时执行 clone""" + source_dir = tmp_path / "branches" / "main" + source_dir.parent.mkdir() + + with patch("backend.services.build_service.GIT_REMOTE_URL", "git@github.com:test/repo.git"): + with patch("asyncio.create_subprocess_exec", return_value=_make_mock_process()) as mock_exec: + await update_source("t1", source_dir, "main") + mock_exec.assert_called_once() + args = mock_exec.call_args[0] + assert "git" in args + assert "clone" in args + + +async def test_update_source_clone_no_remote(tmp_path, log_streamer): + """目录不存在且未配置远程仓库时抛异常""" + source_dir = tmp_path / "branches" / "main" + + with patch("backend.services.build_service.GIT_REMOTE_URL", ""): + with pytest.raises(Exception, match="GIT_REMOTE_URL"): + await update_source("t1", source_dir, "main") + + +async def test_update_source_pull_existing(tmp_path, log_streamer): + """目录已存在时执行 fetch + checkout + pull""" + source_dir = tmp_path / "branches" / "dev" + source_dir.parent.mkdir(parents=True) + source_dir.mkdir() + (source_dir / ".git").mkdir() + + call_count = 0 + + async def mock_exec(*args, **kwargs): + nonlocal call_count + call_count += 1 + return _make_mock_process() + + with patch("asyncio.create_subprocess_exec", side_effect=mock_exec): + await update_source("t1", source_dir, "dev") + + assert call_count == 3 # fetch, checkout, pull + + +async def test_update_source_fetch_failure(tmp_path, log_streamer): + """fetch 失败时抛异常""" + source_dir = tmp_path / "branches" / "dev" + source_dir.parent.mkdir(parents=True) + source_dir.mkdir() + + async def mock_exec(*args, **kwargs): + return _make_mock_process(returncode=1, output=b"error\n") + + with patch("asyncio.create_subprocess_exec", side_effect=mock_exec): + with pytest.raises(Exception, match="git fetch 失败"): + await update_source("t1", source_dir, "dev") + + +# ---- copy_source_code ---- + +async def test_copy_source_code(tmp_dirs, log_streamer): + """正常拷贝源码""" + source_dir, build_dir_parent = tmp_dirs + + task = MagicMock() + task.branch = "main" + + with patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent): + result = await copy_source_code("t1", task, source_dir) + + assert result.exists() + assert (result / "readoor" / "AppDelegate.swift").exists() + assert (result / "Pods").exists() + assert (result / "Podfile.lock").exists() + assert (result / "readoor.xcworkspace").exists() + + +async def test_copy_source_code_excludes_git(tmp_dirs, log_streamer): + """拷贝时排除 .git 目录""" + source_dir, build_dir_parent = tmp_dirs + (source_dir / "readoor" / ".git").mkdir() + + task = MagicMock() + + with patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent): + result = await copy_source_code("t1", task, source_dir) + + assert not (result / "readoor" / ".git").exists() + + +async def test_copy_source_code_overwrites_existing(tmp_dirs, log_streamer): + """打包目录已存在时覆盖""" + source_dir, build_dir_parent = tmp_dirs + old_dir = build_dir_parent / "build_readoor_t1" + old_dir.mkdir(parents=True) + (old_dir / "old_file.txt").write_text("old") + + task = MagicMock() + + with patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent): + result = await copy_source_code("t1", task, source_dir) + + assert not (result / "old_file.txt").exists() + assert (result / "readoor").exists() + + +# ---- generate_config ---- + +async def test_generate_config(tmp_path, log_streamer): + """生成打包配置""" + build_dir = tmp_path / "build" + build_dir.mkdir() + + task = MagicMock() + task.app_id = "1" + task.scheme_id = "1" + task.build_type = "Ad_Hoc" + task.obfuscation = True + + mock_config = { + "apps": { + "1": { + "name": "测试App", + "server": "测试环境", + "AppGuid": "guid-123", + "API": "https://test.api.com", + "certificates": { + "Ad_Hoc": {"name": "com.test.app", "cer": "cert", "pro": "pro", "theme": "t"} + }, + } + }, + "schemes": {"1": {"name": "testScheme", "ossFloder": "test"}}, + } + + with patch("backend.services.build_service.AUTOPACKING_DIR", tmp_path / "AutoPacking"): + with patch("backend.routers.config.load_config", return_value=mock_config): + result = await generate_config("t1", task, build_dir) + + assert result["APPID"] == "guid-123" + assert result["SCHEME"] == "testScheme" + assert result["BUILD_TYPE"] == "Ad_Hoc" + assert result["BUNDLE_ID"] == "com.test.app" + assert result["CERTIFICATE"] == "cert" + + config_file = build_dir / "config_output.json" + assert config_file.exists() + saved = json.loads(config_file.read_text()) + assert saved["APPID"] == "guid-123" + + +async def test_generate_config_reads_version(tmp_path, log_streamer): + """从 start_build_app.py 读取版本号""" + build_dir = tmp_path / "build" + build_dir.mkdir() + + autopacking = tmp_path / "AutoPacking" + autopacking.mkdir() + (autopacking / "start_build_app.py").write_text( + 'App_Ver = "3.0.1"\nBuild_Ver = "3.0.1.0"\n' + ) + + task = MagicMock() + task.app_id = "1" + task.scheme_id = "1" + task.build_type = "Ad_Hoc" + task.obfuscation = False + + mock_config = { + "apps": {"1": {"name": "App", "certificates": {}}}, + "schemes": {"1": {"name": "sch", "ossFloder": "f"}}, + } + + with patch("backend.services.build_service.AUTOPACKING_DIR", autopacking): + with patch("backend.routers.config.load_config", return_value=mock_config): + result = await generate_config("t1", task, build_dir) + + assert result["VERSION"] == "3.0.1" + assert result["BUILD_VERSION"] == "3.0.1.0" + + +# ---- _cleanup_old_builds ---- + +def test_cleanup_old_builds(tmp_path): + """清理过期打包目录""" + from backend.database import SessionLocal, init_db + from backend.models import BuildConfig + + init_db() + db = SessionLocal() + + config = db.query(BuildConfig).filter(BuildConfig.key == "build_dir_retention_hours").first() + if config: + config.value = "1" + else: + db.add(BuildConfig(key="build_dir_retention_hours", value="1")) + db.commit() + + old_dir = tmp_path / "build_readoor_old" + old_dir.mkdir() + # 设置修改时间为 24 小时前(确保超过 1 小时保留期) + old_time = time.time() - 86400 + os.utime(old_dir, (old_time, old_time)) + + with patch("backend.services.build_service.BUILD_BASE_DIR", tmp_path): + _cleanup_old_builds(db) + + assert not old_dir.exists() + db.close() + + +def test_cleanup_keeps_recent(tmp_path): + """保留未过期的打包目录""" + from backend.database import SessionLocal, init_db + from backend.models import BuildConfig + + init_db() + db = SessionLocal() + + config = db.query(BuildConfig).filter(BuildConfig.key == "build_dir_retention_hours").first() + if config: + config.value = "24" + else: + db.add(BuildConfig(key="build_dir_retention_hours", value="24")) + db.commit() + + recent_dir = tmp_path / "build_readoor_recent" + recent_dir.mkdir() + + with patch("backend.services.build_service.BUILD_BASE_DIR", tmp_path): + _cleanup_old_builds(db) + + assert recent_dir.exists() + db.close() diff --git a/tests/test_downloads.py b/tests/test_downloads.py new file mode 100644 index 0000000..ff9a893 --- /dev/null +++ b/tests/test_downloads.py @@ -0,0 +1,151 @@ +"""文件下载接口测试""" +import os +import tempfile +from unittest.mock import patch, AsyncMock + +from backend.database import SessionLocal +from backend.models import Task + + +def _create_task_with_files(client, tmp_config, **extra_fields): + """创建一个带文件路径的任务""" + with patch("backend.services.build_queue.build_queue") as mock_queue: + mock_queue.submit = AsyncMock() + resp = client.post("/api/tasks", json={ + "app_id": "1", + "build_type": "Ad_Hoc", + "scheme_id": "1", + }) + task_id = resp.json()["id"] + + # 直接更新数据库中的文件路径 + db = SessionLocal() + task = db.query(Task).filter(Task.id == task_id).first() + for k, v in extra_fields.items(): + setattr(task, k, v) + db.commit() + db.close() + return task_id + + +# ---- dSYM 下载 ---- + +def test_download_dsym_not_exist(client, tmp_config): + """dSYM 路径未设置""" + task_id = _create_task_with_files(client, tmp_config) + resp = client.get(f"/api/tasks/{task_id}/dsym") + assert resp.status_code == 404 + + +def test_download_dsym_file_missing(client, tmp_config): + """dSYM 路径设置了但文件不存在""" + task_id = _create_task_with_files(client, tmp_config, dsym_path="/tmp/nonexistent.dSYM") + resp = client.get(f"/api/tasks/{task_id}/dsym") + assert resp.status_code == 404 + assert "已被清理" in resp.json()["detail"] + + +def test_download_dsym_success(client, tmp_config): + """dSYM 文件正常下载""" + # 创建临时文件 + fd, path = tempfile.mkstemp(suffix=".dSYM") + os.write(fd, b"fake dsym content") + os.close(fd) + + try: + task_id = _create_task_with_files(client, tmp_config, dsym_path=path) + resp = client.get(f"/api/tasks/{task_id}/dsym") + assert resp.status_code == 200 + assert resp.content == b"fake dsym content" + finally: + os.unlink(path) + + +def test_download_dsym_nonexistent_task(client, tmp_config): + resp = client.get("/api/tasks/nonexistent/dsym") + assert resp.status_code == 404 + + +# ---- 混淆映射表下载 ---- + +def test_download_obfuscation_maps_not_exist(client, tmp_config): + """混淆映射表路径未设置""" + task_id = _create_task_with_files(client, tmp_config) + resp = client.get(f"/api/tasks/{task_id}/obfuscation-maps") + assert resp.status_code == 404 + + +def test_download_obfuscation_maps_file_missing(client, tmp_config): + """路径设置了但文件不存在""" + task_id = _create_task_with_files(client, tmp_config, obfuscation_maps_path="/tmp/nonexistent") + resp = client.get(f"/api/tasks/{task_id}/obfuscation-maps") + assert resp.status_code == 404 + + +def test_download_obfuscation_maps_single_file(client, tmp_config): + """单文件下载""" + fd, path = tempfile.mkstemp(suffix=".json") + os.write(fd, b'{"mappings": []}') + os.close(fd) + + try: + task_id = _create_task_with_files(client, tmp_config, obfuscation_maps_path=path) + resp = client.get(f"/api/tasks/{task_id}/obfuscation-maps") + assert resp.status_code == 200 + assert resp.content == b'{"mappings": []}' + finally: + os.unlink(path) + + +def test_download_obfuscation_maps_directory(client, tmp_config): + """目录打包成 zip 下载""" + tmpdir = tempfile.mkdtemp() + with open(os.path.join(tmpdir, "map.json"), "w") as f: + f.write("{}") + + try: + task_id = _create_task_with_files( + client, tmp_config, + obfuscation_maps_path=tmpdir, + app_name="测试App", + ) + resp = client.get(f"/api/tasks/{task_id}/obfuscation-maps") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/zip" + # filename is URL-encoded, check the raw header + disposition = resp.headers["content-disposition"] + assert ".zip" in disposition + finally: + import shutil + shutil.rmtree(tmpdir) + + +# ---- 二维码下载 ---- + +def test_download_qrcode_not_exist(client, tmp_config): + """二维码路径未设置""" + task_id = _create_task_with_files(client, tmp_config) + resp = client.get(f"/api/tasks/{task_id}/qrcode") + assert resp.status_code == 404 + + +def test_download_qrcode_file_missing(client, tmp_config): + """路径设置了但文件不存在""" + task_id = _create_task_with_files(client, tmp_config, qr_code_path="/tmp/nonexistent.png") + resp = client.get(f"/api/tasks/{task_id}/qrcode") + assert resp.status_code == 404 + + +def test_download_qrcode_success(client, tmp_config): + """二维码正常下载""" + fd, path = tempfile.mkstemp(suffix=".png") + os.write(fd, b"\x89PNG fake") + os.close(fd) + + try: + task_id = _create_task_with_files(client, tmp_config, qr_code_path=path) + resp = client.get(f"/api/tasks/{task_id}/qrcode") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "image/png" + finally: + os.unlink(path) diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py new file mode 100644 index 0000000..3e020bc --- /dev/null +++ b/tests/test_edge_cases.py @@ -0,0 +1,160 @@ +"""边界情况测试""" +import json +import pytest + + +def test_invalid_json_body(client, tmp_config): + """无效 JSON 请求体""" + resp = client.post( + "/api/tasks", + content="not json", + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 422 + + +def test_missing_required_fields(client, tmp_config): + """缺少必填字段""" + resp = client.post("/api/tasks", json={"app_id": "1"}) + assert resp.status_code == 422 + + +def test_empty_app_id(client, tmp_config): + """空 app_id""" + resp = client.post("/api/tasks", json={ + "app_id": "", + "build_type": "Ad_Hoc", + "scheme_id": "1", + }) + assert resp.status_code == 400 + + +def test_server_name_with_special_chars(client, tmp_config): + """服务器名称包含特殊字符""" + resp = client.post("/api/config/servers", json={ + "name": "环境/测试", + "api": "https://test.com", + }) + assert resp.status_code == 200 + + +def test_branch_name_with_slash(client, tmp_config): + """分支名包含斜杠""" + resp = client.post("/api/config/branches", json={"name": "feature/my-branch"}) + assert resp.status_code == 200 + assert "feature/my-branch" in client.get("/api/config/branches").json() + + +def test_branch_name_with_space(client, tmp_config): + """分支名包含空格(应被 trim)""" + resp = client.post("/api/config/branches", json={"name": " "}) + assert resp.status_code == 400 + + +def test_config_backup_on_save(client, tmp_config): + """保存配置时自动备份""" + # 先写一次产生文件 + client.put("/api/config", json={"apps": {}, "schemes": {}, "branches": ["main"]}) + + # 再写一次,应该产生 .bak 文件 + client.put("/api/config", json={"apps": {"1": {"name": "test"}}, "schemes": {}, "branches": ["main"]}) + + backup = tmp_config.with_suffix(".json.bak") + assert backup.exists() + + +def test_sequential_config_writes(client, tmp_config): + """顺序多次写入配置""" + for i in range(5): + resp = client.post("/api/config/branches", json={"name": f"branch-{i}"}) + assert resp.status_code == 200 + + branches = client.get("/api/config/branches").json() + for i in range(5): + assert f"branch-{i}" in branches + + +def test_task_status_filter(client, tmp_config): + """按状态过滤任务""" + from unittest.mock import patch, AsyncMock + + with patch("backend.services.build_queue.build_queue") as mock_queue: + mock_queue.submit = AsyncMock() + client.post("/api/tasks", json={ + "app_id": "1", "build_type": "Ad_Hoc", "scheme_id": "1", + }) + + # 过滤 pending + resp = client.get("/api/tasks?status=pending") + assert resp.status_code == 200 + assert len(resp.json()) >= 1 + + # 过滤 running(应该没有) + resp = client.get("/api/tasks?status=running") + assert len(resp.json()) == 0 + + +def test_task_pagination(client, tmp_config): + """任务分页""" + from unittest.mock import patch, AsyncMock + + with patch("backend.services.build_queue.build_queue") as mock_queue: + mock_queue.submit = AsyncMock() + for _ in range(5): + client.post("/api/tasks", json={ + "app_id": "1", "build_type": "Ad_Hoc", "scheme_id": "1", + }) + + resp = client.get("/api/tasks?limit=2&offset=0") + assert len(resp.json()) == 2 + + resp = client.get("/api/tasks?limit=2&offset=2") + assert len(resp.json()) == 2 + + +def test_delete_server_not_in_use(client, tmp_config): + """删除未被使用的服务器""" + # 先添加一个不被使用的服务器 + client.post("/api/config/servers", json={"name": "临时环境", "api": "https://tmp.com"}) + resp = client.delete("/api/config/servers/临时环境") + assert resp.status_code == 200 + + +def test_update_full_config_replaces_all(client, tmp_config): + """整体替换配置""" + new_config = { + "apps": {"99": {"name": "新App"}}, + "schemes": {}, + "servers": {}, + "branches": ["release"], + } + resp = client.put("/api/config", json=new_config) + assert resp.status_code == 200 + + config = client.get("/api/config").json() + assert "99" in config["apps"] + assert config["branches"] == ["release"] + + +def test_scheme_id_auto_increment(client, tmp_config): + """Scheme ID 自动递增""" + client.post("/api/config/schemes", json={"name": "sch2", "ossFloder": "f2"}) + client.post("/api/config/schemes", json={"name": "sch3", "ossFloder": "f3"}) + schemes = client.get("/api/config/schemes").json() + assert "2" in schemes + assert "3" in schemes + + +def test_build_settings_validation(client, tmp_config): + """打包设置更新""" + # 更新为有效值 + resp = client.put("/api/config/build", json={"max_concurrent_builds": 3}) + assert resp.status_code == 200 + assert client.get("/api/config/build").json()["max_concurrent_builds"] == 3 + + # 更新部分字段 + resp = client.put("/api/config/build", json={"build_dir_retention_hours": 48}) + assert resp.status_code == 200 + settings = client.get("/api/config/build").json() + assert settings["build_dir_retention_hours"] == 48 + assert settings["max_concurrent_builds"] == 3 # 未修改的保持不变 diff --git a/tests/test_websocket.py b/tests/test_websocket.py new file mode 100644 index 0000000..5913fc2 --- /dev/null +++ b/tests/test_websocket.py @@ -0,0 +1,131 @@ +"""WebSocket 日志流测试""" +import asyncio +import pytest +from backend.services.log_streamer import LogStreamer + + +@pytest.fixture +def streamer(): + return LogStreamer() + + +async def test_emit_and_subscribe(streamer): + """测试发送和接收日志""" + streamer.create_queue("t1") + await streamer.emit("t1", "hello", level="info") + + received = [] + async for msg in streamer.subscribe("t1"): + received.append(msg) + break + + assert len(received) == 1 + assert received[0]["message"] == "hello" + assert received[0]["level"] == "info" + + +async def test_emit_step(streamer): + """测试步骤标记""" + streamer.create_queue("t2") + await streamer.emit_step("t2", "构建中") + + received = [] + async for msg in streamer.subscribe("t2"): + received.append(msg) + break + + assert received[0]["message"] == "[构建中]" + assert received[0]["level"] == "step" + + +async def test_emit_error(streamer): + """测试错误消息""" + streamer.create_queue("t3") + await streamer.emit_error("t3", "出错了") + + received = [] + async for msg in streamer.subscribe("t3"): + received.append(msg) + break + + assert received[0]["level"] == "error" + assert received[0]["message"] == "出错了" + + +async def test_emit_warning(streamer): + """测试警告消息""" + streamer.create_queue("t4") + await streamer.emit_warning("t4", "警告") + + received = [] + async for msg in streamer.subscribe("t4"): + received.append(msg) + break + + assert received[0]["level"] == "warn" + + +async def test_emit_to_nonexistent_queue(streamer): + """测试向不存在的队列发送消息(不应报错)""" + await streamer.emit("nonexistent", "msg") + + +async def test_complete_signal(streamer): + """测试完成信号终止订阅""" + streamer.create_queue("t5") + await streamer.emit("t5", "msg1") + streamer.complete("t5") + + received = [] + async for msg in streamer.subscribe("t5"): + received.append(msg) + + assert len(received) == 1 + assert received[0]["message"] == "msg1" + + +async def test_multiple_messages(streamer): + """测试多条消息顺序""" + streamer.create_queue("t6") + for i in range(5): + await streamer.emit("t6", f"msg{i}") + streamer.complete("t6") + + received = [] + async for msg in streamer.subscribe("t6"): + received.append(msg) + + assert len(received) == 5 + for i, msg in enumerate(received): + assert msg["message"] == f"msg{i}" + + +async def test_queue_full_drops_oldest(streamer): + """测试队列满时丢弃最旧消息""" + q = streamer.create_queue("t7") + for i in range(q.maxsize): + await streamer.emit("t7", f"old{i}") + await streamer.emit("t7", "new") + + messages = [] + while not q.empty(): + messages.append(await q.get()) + assert messages[-1]["message"] == "new" + + +async def test_cleanup(streamer): + """测试清理队列""" + streamer.create_queue("t8") + await streamer.emit("t8", "msg") + streamer.cleanup("t8") + assert "t8" not in streamer._queues + # 清理后再发送不应报错 + await streamer.emit("t8", "msg2") + + +async def test_subscribe_without_queue(streamer): + """测试订阅不存在的队列(应立即结束)""" + collected = [] + async for msg in streamer.subscribe("nonexistent"): + collected.append(msg) + assert collected == []