commit 9dec38db604e8e089d83de8a8b27c6e3c95dde1d Author: chakcy <947105045@qq.com> Date: Sun May 10 10:56:52 2026 +0800 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..58b16ac --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# 虚拟机连接信息 +VM_NAME=ubuntu20.04 +VM_SSH_HOST=127.0.0.1 +VM_SSH_PORT=2222 +VM_SSH_USER=chakcy +VM_SSH_PASS=020709djh + +# 大模型 API(OpenAI 或兼容接口) +LLM_API_KEY=sk-7b300b4948d04f208d1781c830f773ca +LLM_BASE_URL=https://api.deepseek.com # 若用本地模型可改为 http://localhost:11434/v1 +LLM_MODEL=deepseek-v4-flash \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..462918f --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info +*.log +output.md + +# Virtual environments +.venv +.sdk + diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/README.md b/README.md new file mode 100644 index 0000000..549c28c --- /dev/null +++ b/README.md @@ -0,0 +1,314 @@ +# VirtualBox Client + +**VirtualBox 虚拟机自动化管理工具** — 通过 SSH 和 VirtualBox SDK 实现对 Ubuntu 20.04 虚拟机的全生命周期管理,支持 AI 智能任务执行、文件传输、脚本部署和图形化管理。 + +--- + +## ✨ 功能特性 + +- 🖥️ **虚拟机管理** — 启动、关闭、状态检测(VirtualBox SDK) +- 🔌 **纯 SSH 模式** — 不依赖 VirtualBox,直接通过 SSH 管理远程服务器 +- 🤖 **AI 智能代理** — 接入大语言模型(OpenAI API),通过自然语言描述自动执行运维任务 +- 📁 **文件传输** — 支持上传/下载文件、上传整个文件夹 +- 📂 **工作区浏览器** — 树形文件管理器,支持浏览、新建、重命名、复制、删除、下载文件夹(zip 打包) +- ⚡ **脚本执行** — 上传并执行 Shell 脚本,自动区分部署脚本(sudo)和普通脚本 +- 🎨 **图形界面** — 基于 PySide6 构建的现代化暗色主题 GUI +- 📋 **日志面板** — 带等级标识的彩色日志输出,支持自动滚动 + +--- + +## 📦 系统要求 + +| 依赖 | 说明 | +| -------------- | ------------------------ | +| Python | >= 3.14 | +| VirtualBox | 含 VirtualBox SDK(可选) | +| 操作系统 | Windows(主控端) | +| 目标虚拟机 | Ubuntu 20.04(受控端) | + +--- + +## 🚀 快速开始 + +### 1. 安装依赖 + +```powershell +# 同步 Python 依赖 +uv sync + +# 安装 VirtualBox SDK(仅 VirtualBox 模式需要) +# 确保已安装 VirtualBox,找到 sdk 路径后执行: +cd ./sdk/installer/python/ +uv run ./vboxapisetup.py install +``` + +> **📥 VirtualBox SDK 下载** +> +> 如果项目中尚未包含 SDK,请先下载 VirtualBox SDK 并解压到项目目录: +> +> ```powershell +> # 下载 VirtualBox SDK 7.2.8 +> curl -L https://download.virtualbox.org/virtualbox/7.2.8/VirtualBoxSDK-7.2.8-173730.zip -o VirtualBoxSDK.zip +> # 解压到项目根目录 +> tar -xf VirtualBoxSDK.zip +> ``` +> +> SDK 目录结构应包含 `sdk/installer/python/vboxapisetup.py`。 +> +> *注意:SDK 版本需与本地安装的 VirtualBox 版本匹配。* + +### 2. 配置环境变量 + +复制 `.env.example` 为 `.env`(或直接在 GUI 中填写): + +```env +# 虚拟机配置 +VM_NAME=ubuntu20.04 +VM_SSH_HOST=127.0.0.1 +VM_SSH_PORT=2222 +VM_SSH_USER=your_username +VM_SSH_PASS=your_password + +# 大模型配置(AI 代理模式需要) +LLM_API_KEY=sk-your-api-key +LLM_BASE_URL=https://api.openai.com +LLM_MODEL=gpt-4o +``` + +### 3. 启动方式 + +#### 🖥️ 图形界面 + +```powershell +uv run gui +``` + +![GUI Screenshot](docs/images/gui.png) *(screenshot placeholder)* + +#### ⌨️ 命令行示例 + +```powershell +uv run example +``` + +--- + +## 📖 使用指南 + +### 配置面板 + +左侧配置面板包含三个区域: + +1. **运行模式** — 切换「纯 SSH 模式」或「VirtualBox 模式」 +2. **虚拟机配置** — SSH 连接信息(主机、端口、用户名、密码) +3. **大模型配置** — AI 代理的 API Key、Base URL 和模型名称 + +### 任务执行面板 + +在「任务执行」Tab 中,输入自然语言描述,AI 代理将自动解析并执行: + +``` +在 /home/user/myproject 下有一个 "myproject" 项目, +请安装 nginx 并确认服务状态 +``` + +系统会自动: +1. 启动虚拟机(VirtualBox 模式) +2. 连接 SSH +3. 调用 LLM 生成执行计划 +4. 逐步执行命令并反馈日志 +5. 任务完成后询问是否关闭虚拟机 + +### 文件传输 + +在「文件传输」Tab 中: +- **上传** — 选择本地文件,指定远程路径,点击上传 +- **下载** — 输入远程路径,选择本地保存位置,点击下载 + +### 脚本执行 + +在「Script」Tab 中: +1. 点击「Browse」选择本地的 `.sh` 脚本文件 +2. 点击「Upload and Execute Script」 +3. 脚本会自动上传到 `/tmp/`,执行完成后下载日志并清理临时文件 +4. 以 `deploy_` 开头的脚本自动使用 `sudo` 权限执行 + +### 工作区浏览器 + +在「工作区」Tab 中,可以像本地文件管理器一样操作远程文件: +- **双击文件夹** — 懒加载展开子目录 +- **单击文件** — 显示文件详情 +- **右键菜单** — 重命名、复制、删除、下载 +- **工具栏** — 上传文件、上传文件夹、新建文件夹、回到根目录 + +--- + +## 🛠️ 脚本开发 + +### 添加新脚本 + +在 `src/virtualbox_client/scripts/` 下创建 Python 文件: + +```python +# src/virtualbox_client/scripts/my_script.py + +def run(): + print("Hello from my script!") +``` + +在 `pyproject.toml` 中注册: + +```toml +[project.scripts] +my-script = "virtualbox_client.scripts.my_script:run" +``` + +重新构建并执行: + +```powershell +uv sync +uv run my-script +``` + +### 命令式脚本执行 + +```powershell +# 直接通过命令行运行脚本模块 +uv run -m virtualbox_client.scripts.my_script +``` + +--- + +## 📁 项目结构 + +``` +virtualbox_client/ +├── src/virtualbox_client/ +│ ├── __init__.py +│ ├── gui.py # PySide6 图形界面 +│ ├── scripts/ +│ │ ├── __init__.py +│ │ ├── agent_task.py # AI 代理任务执行 +│ │ ├── example.py # 使用示例 +│ │ └── run_script.py # 脚本运行器 +│ └── utils/ +│ ├── __init__.py +│ ├── ssh_manager.py # 纯 SSH 管理器 +│ ├── vbox_ssh_manager.py # VirtualBox + SSH 管理器 +│ └── vm_agent.py # AI 代理核心 +├── resources/ +│ ├── chongming_dev.sh +│ ├── deploy_docker.sh +│ ├── deploy_git.sh +│ ├── deploy_nvm.sh +│ └── deploy_uv.sh +├── scripts/ +│ ├── add_script_runner.py +│ ├── add_script_tab.py +│ ├── check_syntax.py +│ ├── clean_pycache.py +│ ├── code_content.py +│ ├── fix_paths.py +│ └── fix_upload_folder.py +├── docs/ +│ ├── docker使用手册.md +│ ├── git使用手册.md +│ ├── nvm使用文档.md +│ └── uv使用文档.md +├── .env.example # 环境变量模板 +├── .gitignore +├── .python-version +├── gui.spec # PyInstaller 打包配置 +├── pyproject.toml # 项目配置与依赖 +└── README.md +``` + +--- + +## 🧩 技术架构 + +### 后端 + +- **VirtualBox SDK** (`vboxapi`) — 通过 Windows COM 接口管理虚拟机生命周期 +- **paramiko** — SSH 连接与远程命令执行 +- **openai** — 大语言模型 API 调用(AI 代理模式) +- **pywin32** — Windows COM 互操作 + +### 前端 + +- **PySide6** — Qt6 的 Python 绑定,提供现代化 GUI +- **QThread** — 后台线程管理,避免 UI 阻塞 +- **QTreeWidget** — 带懒加载的远程文件浏览器 + +### 工作模式 + +``` +┌─────────────────────────────────────────┐ +│ VirtualBox Client │ +├─────────────────┬───────────────────────┤ +│ VirtualBox 模式 │ 纯 SSH 模式 │ +│ │ │ +│ VirtualBox │ SSH │ +│ SDK (COM) │ Manager │ +│ ↓ │ ↓ │ +│ VBoxSSHManager │ SSHManager │ +│ ↓ │ ↓ │ +│ Ubuntu VM │ 远程服务器 │ +└─────────────────┴───────────────────────┘ +``` + +--- + +## 🐳 部署与打包 + +### 使用 PyInstaller 打包为可执行文件 + +```powershell +uv run pyinstaller gui.spec +``` + +打包后的可执行文件位于 `dist/` 目录。 + +--- + +## ⚙️ 环境搭建详情 + +| 命令 | 说明 | +| ----------------------------------------- | ----------------------------- | +| `uv sync` | 同步所有 Python 依赖 | +| `uv run ./sdk/installer/python/vboxapisetup.py install` | 安装 VirtualBox SDK | +| `uv run gui` | 启动图形界面 | +| `uv run example` | 运行命令行示例 | +| `uv run agent-task` | 运行 AI 代理任务 | +| `uv run run-script` | 运行脚本 | +| `uv run pyinstaller gui.spec` | 打包为 EXE | + +--- + +## 📝 文档 + +详细使用文档请参考 `docs/` 目录: + +- [Docker 使用手册](docs/docker使用手册.md) +- [Git 使用手册](docs/git使用手册.md) +- [NVM 使用文档](docs/nvm使用文档.md) +- [UV 使用文档](docs/uv使用文档.md) + +--- + +## 🔧 常见问题 + +**Q: 启动报错 `CoInitialize` 相关错误?** +A: 这是 Windows COM 初始化问题,通常出现在 VirtualBox 模式下。确保已正确安装 VirtualBox SDK。 + +**Q: SSH 连接失败?** +A: 检查目标虚拟机是否已启动、SSH 服务是否运行、端口转发是否配置正确(默认 2222)。 + +**Q: AI 代理无响应?** +A: 确保 API Key、Base URL 和模型名称配置正确,且网络可以访问 LLM 服务。 + +--- + +## 📄 许可证 + +本项目仅供学习和参考使用。 diff --git a/chongming.ico b/chongming.ico new file mode 100644 index 0000000..8e36272 Binary files /dev/null and b/chongming.ico differ diff --git a/docs/docker使用手册.md b/docs/docker使用手册.md new file mode 100644 index 0000000..9ed3cd7 --- /dev/null +++ b/docs/docker使用手册.md @@ -0,0 +1,754 @@ +# Docker 使用手册 + +## 📖 目录 + +- [1. Docker 简介](#1-docker-简介) +- [2. 安装 Docker](#2-安装-docker) +- [3. Docker 基本概念](#3-docker-基本概念) +- [4. 镜像管理](#4-镜像管理) +- [5. 容器管理](#5-容器管理) +- [6. Dockerfile 构建](#6-dockerfile-构建) +- [7. Docker Compose](#7-docker-compose) +- [8. 数据管理(Volume)](#8-数据管理volume) +- [9. 网络管理](#9-网络管理) +- [10. 常用命令速查](#10-常用命令速查) +- [11. 最佳实践与注意事项](#11-最佳实践与注意事项) + +--- + +## 1. Docker 简介 + +Docker 是一个开源的应用容器引擎,让开发者可以打包应用及依赖包到一个可移植的容器中,然后发布到任何流行的 Linux 或 Windows 机器上。 + +### 核心优势 + +- **轻量级**:容器共享宿主机内核,无需虚拟化完整 OS +- **快速启动**:秒级启动,相比虚拟机分钟级启动更快 +- **环境一致**:消除"在我机器上能跑"的问题 +- **高效资源**:内存、硬盘、CPU 占用远小于虚拟机 +- **易于迁移**:一次构建,随处运行 + +--- + +## 2. 安装 Docker + +### 2.1 Ubuntu / Debian + +```bash +# 更新软件包索引 +sudo apt update + +# 安装依赖 +sudo apt install -y apt-transport-https ca-certificates curl software-properties-common + +# 添加 Docker 官方 GPG 密钥 +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - + +# 添加 Docker 源 +sudo add-apt-repository -y "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" + +# 安装 Docker +sudo apt update +sudo apt install -y docker-ce docker-ce-cli containerd.io + +# 启动并设置开机自启 +sudo systemctl enable docker +sudo systemctl start docker + +# 将当前用户加入 docker 组(免 sudo 执行) +sudo usermod -aG docker $USER +``` + +### 2.2 CentOS / RHEL + +```bash +sudo yum install -y yum-utils +sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo +sudo yum install -y docker-ce docker-ce-cli containerd.io +sudo systemctl enable docker +sudo systemctl start docker +``` + +### 2.3 macOS + +下载 [Docker Desktop for Mac](https://hub.docker.com/editions/community/docker-ce-desktop-mac/) + +### 2.4 Windows + +下载 [Docker Desktop for Windows](https://hub.docker.com/editions/community/docker-ce-desktop-windows/) + +### 2.5 验证安装 + +```bash +docker --version +docker run hello-world +``` + +--- + +## 3. Docker 基本概念 + +| 概念 | 说明 | +|------|------| +| **镜像(Image)** | 一个只读的模板,包含创建容器的说明 | +| **容器(Container)** | 镜像的运行实例,可被启动、停止、删除 | +| **仓库(Repository)** | 集中存放镜像的地方,如 Docker Hub | +| **Dockerfile** | 自动化构建镜像的脚本文件 | +| **Volume** | 持久化存储数据卷 | +| **网络(Network)** | 容器间的通信通道 | + +### 三者的关系 + +``` +Dockerfile (构建) --> Image (镜像) --> run --> Container (容器) + ↑ + Docker Hub (仓库) +``` + +--- + +## 4. 镜像管理 + +### 4.1 搜索镜像 + +```bash +docker search nginx +docker search --filter stars=1000 ubuntu +``` + +### 4.2 拉取镜像 + +```bash +docker pull nginx:latest # 拉取最新版 +docker pull ubuntu:20.04 # 拉取指定标签 +docker pull registry.cn-hangzhou.aliyuncs.com/library/nginx # 国内镜像加速 +``` + +### 4.3 查看本地镜像 + +```bash +docker images # 查看所有镜像 +docker images -q # 只显示镜像 ID +docker image ls --filter dangling=true # 查看悬空镜像 +``` + +### 4.4 删除镜像 + +```bash +docker rmi nginx:latest # 删除指定镜像 +docker rmi -f # 强制删除 +docker image prune # 删除悬空镜像 +docker image prune -a # 删除所有未使用的镜像 +``` + +### 4.5 镜像标签与导出 + +```bash +# 打标签 +docker tag ubuntu:20.04 myubuntu:v1 + +# 导出镜像为 tar 文件 +docker save -o ubuntu.tar ubuntu:20.04 + +# 从 tar 文件导入镜像 +docker load -i ubuntu.tar +``` + +### 4.6 登录与推送 + +```bash +docker login # 登录 Docker Hub +docker push username/myapp:v1 # 推送镜像 +``` + +--- + +## 5. 容器管理 + +### 5.1 创建并启动容器 + +```bash +# 基本启动 +docker run nginx + +# 后台运行(-d) +docker run -d nginx + +# 端口映射(-p 宿主机端口:容器端口) +docker run -d -p 8080:80 nginx + +# 命名容器(--name) +docker run -d --name my-nginx -p 8080:80 nginx + +# 设置环境变量(-e) +docker run -d --name my-mysql -e MYSQL_ROOT_PASSWORD=123456 mysql:5.7 + +# 挂载数据卷(-v) +docker run -d -v /host/data:/container/data nginx + +# 自动删除(--rm,容器停止后自动删除) +docker run --rm -it ubuntu bash + +# 交互模式(-it) +docker run -it ubuntu bash +``` + +### 5.2 查看容器 + +```bash +docker ps # 查看运行中的容器 +docker ps -a # 查看所有容器(含已停止) +docker ps -q # 只显示容器 ID +docker container ls -a # 同 docker ps -a +``` + +### 5.3 停止、启动、重启容器 + +```bash +docker stop # 停止容器 +docker start # 启动已停止的容器 +docker restart # 重启容器 +docker pause # 暂停容器 +docker unpause # 恢复暂停的容器 +``` + +### 5.4 进入容器 + +```bash +# 推荐方式(exit 不会导致容器停止) +docker exec -it bash + +# 如果容器没有 bash,可以用 sh +docker exec -it sh + +# attach 方式(exit 会导致容器停止) +docker attach +``` + +### 5.5 查看日志 + +```bash +docker logs # 查看所有日志 +docker logs -f # 实时跟踪日志 +docker logs --tail 100 # 查看最近 100 行 +docker logs -t # 显示时间戳 +``` + +### 5.6 容器内部文件操作 + +```bash +# 从宿主机拷贝文件到容器 +docker cp /host/file.txt container_id:/container/path/ + +# 从容器拷贝文件到宿主机 +docker cp container_id:/container/file.txt /host/path/ +``` + +### 5.7 删除容器 + +```bash +docker rm # 删除已停止的容器 +docker rm -f # 强制删除运行中的容器 +docker container prune # 删除所有已停止的容器 +docker rm $(docker ps -aq) # 删除所有容器 +``` + +### 5.8 查看容器详细信息 + +```bash +docker inspect # 查看容器详细信息(JSON) +docker inspect --format '{{.NetworkSettings.IPAddress}}' +docker top # 查看容器中的进程 +docker stats # 查看所有容器的资源使用 +``` + +--- + +## 6. Dockerfile 构建 + +### 6.1 Dockerfile 指令详解 + +```dockerfile +# 指定基础镜像 +FROM ubuntu:20.04 + +# 维护者信息 +LABEL maintainer="yourname@example.com" + +# 设置环境变量 +ENV APP_HOME /app +ENV TZ Asia/Shanghai + +# 设置工作目录 +WORKDIR $APP_HOME + +# 运行命令(构建时执行) +RUN apt update && apt install -y python3 python3-pip nginx + +# 复制文件到镜像 +COPY ./app /app +ADD ./archive.tar.gz /tmp/ # ADD 支持自动解压 tar 和远程 URL + +# 暴露端口(仅声明,不实际映射) +EXPOSE 80 443 + +# 设置卷 +VOLUME ["/data", "/config"] + +# 运行用户 +USER appuser + +# 启动命令 +CMD ["python3", "app.py"] + +# 或使用 ENTRYPOINT(可追加参数) +ENTRYPOINT ["python3"] +CMD ["app.py"] # 作为 ENTRYPOINT 的默认参数 +``` + +### 6.2 构建镜像 + +```bash +docker build -t myapp:v1 . # 当前目录构建 +docker build -t myapp:v1 -f Dockerfile.prod . # 指定 Dockerfile +docker build --no-cache -t myapp:v1 . # 不使用缓存 +``` + +### 6.3 多阶段构建 + +```dockerfile +# 阶段一:编译 +FROM golang:1.19 AS builder +WORKDIR /app +COPY . . +RUN go build -o myapp + +# 阶段二:运行(极小镜像) +FROM alpine:latest +RUN apk add --no-cache ca-certificates +WORKDIR /root/ +COPY --from=builder /app/myapp . +EXPOSE 8080 +CMD ["./myapp"] +``` + +### 6.4 优化建议 + +```dockerfile +# ✅ 合并 RUN 命令,减少层数 +RUN apt update && \ + apt install -y curl nginx && \ + rm -rf /var/lib/apt/lists/* + +# ✅ 使用 .dockerignore 忽略不必要的文件 +# .dockerignore 文件内容: +# node_modules +# .git +# *.log +# Dockerfile +``` + +--- + +## 7. Docker Compose + +### 7.1 安装 Docker Compose + +```bash +# Linux +sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose +sudo chmod +x /usr/local/bin/docker-compose + +# Ubuntu 通过 apt +sudo apt install -y docker-compose + +# 验证 +docker-compose --version +``` + +### 7.2 docker-compose.yml 示例 + +```yaml +version: '3.8' + +services: + # Web 服务 + web: + image: nginx:latest + container_name: my-web + ports: + - "8080:80" + - "443:443" + volumes: + - ./html:/usr/share/nginx/html:ro + - ./nginx.conf:/etc/nginx/nginx.conf:ro + networks: + - app-net + depends_on: + - api + restart: always + + # API 服务 + api: + build: + context: ./api + dockerfile: Dockerfile + container_name: my-api + environment: + - DB_HOST=db + - DB_PORT=3306 + volumes: + - ./api:/app + networks: + - app-net + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + # 数据库 + db: + image: mysql:5.7 + container_name: my-db + environment: + MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-root123} + MYSQL_DATABASE: myapp + volumes: + - mysql-data:/var/lib/mysql + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + networks: + - app-net + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + restart: always + + # Redis 缓存 + redis: + image: redis:7-alpine + container_name: my-redis + ports: + - "6379:6379" + volumes: + - redis-data:/data + networks: + - app-net + restart: always + +volumes: + mysql-data: + driver: local + redis-data: + driver: local + +networks: + app-net: + driver: bridge +``` + +### 7.3 Compose 常用命令 + +```bash +# 启动所有服务(-d 后台运行) +docker-compose up -d + +# 构建并启动 +docker-compose up -d --build + +# 停止并删除容器 +docker-compose down + +# 停止并删除容器 + 数据卷 +docker-compose down -v + +# 查看日志 +docker-compose logs -f + +# 查看服务状态 +docker-compose ps + +# 重启服务 +docker-compose restart + +# 执行命令 +docker-compose exec web bash + +# 查看镜像 +docker-compose images + +# 拉取最新镜像 +docker-compose pull + +# 指定配置文件 +docker-compose -f docker-compose.prod.yml up -d +``` + +### 7.4 环境变量管理 + +```bash +# 创建 .env 文件 +echo "DB_PASSWORD=mysecret" > .env +echo "APP_ENV=production" >> .env + +# 在 compose 文件中使用 ${VAR_NAME} 引用 +# 默认值语法:${VAR_NAME:-default_value} +``` + +--- + +## 8. 数据管理(Volume) + +### 8.1 Volume 类型 + +| 类型 | 说明 | 适用场景 | +|------|------|----------| +| **命名卷** | docker 管理的卷,存储在 `/var/lib/docker/volumes/` | 持久化数据库数据 | +| **绑定挂载** | 挂载宿主机目录到容器 | 开发时热更新代码 | +| **tmpfs 挂载** | 存储在内存中 | 敏感数据,临时文件 | + +### 8.2 管理 Volume + +```bash +# 创建卷 +docker volume create my-volume + +# 查看卷 +docker volume ls +docker volume inspect my-volume + +# 删除卷 +docker volume rm my-volume +docker volume prune # 删除所有未使用的卷 + +# 使用卷运行容器 +docker run -d -v my-volume:/data nginx + +# 绑定挂载 +docker run -d -v /host/path:/container/path nginx + +# 只读挂载 +docker run -d -v /host/path:/container/path:ro nginx + +# tmpfs 挂载 +docker run -d --tmpfs /tmp:rw,noexec,nosuid,size=100m nginx +``` + +### 8.3 容器间共享数据 + +```bash +# 创建数据卷容器 +docker create -v /shared-data --name data-container ubuntu + +# 其他容器使用 --volumes-from 共享 +docker run -d --volumes-from data-container --name app1 nginx +docker run -d --volumes-from data-container --name app2 nginx +``` + +--- + +## 9. 网络管理 + +### 9.1 网络类型 + +| 网络模式 | 说明 | 适用场景 | +|----------|------|----------| +| **bridge** | 默认,通过虚拟网桥通信 | 单机容器间通信 | +| **host** | 直接使用宿主机网络 | 高性能场景 | +| **none** | 无网络 | 安全隔离 | +| **overlay** | 跨主机通信 | 多节点集群 | + +### 9.2 管理网络 + +```bash +# 创建网络 +docker network create --driver bridge my-network + +# 创建带子网的网络 +docker network create --subnet=172.20.0.0/16 --ip-range=172.20.10.0/24 my-network + +# 查看网络 +docker network ls +docker network inspect my-network + +# 连接容器到网络 +docker network connect my-network container_name + +# 断开容器网络 +docker network disconnect my-network container_name + +# 删除网络 +docker network rm my-network +docker network prune # 删除所有未使用的网络 +``` + +### 9.3 指定 IP 运行 + +```bash +docker run -d --network my-network --ip 172.20.10.10 nginx +``` + +### 9.4 容器间 DNS 解析 + +同一自定义网络下的容器,可以通过容器名直接通信: + +```bash +docker run -d --network my-network --name web nginx +docker run -d --network my-network --name app alpine ping web +``` + +--- + +## 10. 常用命令速查 + +### 10.1 镜像操作 + +| 命令 | 说明 | +|------|------| +| `docker images` | 列出镜像 | +| `docker pull ` | 拉取镜像 | +| `docker push ` | 推送镜像 | +| `docker rmi ` | 删除镜像 | +| `docker build -t .` | 构建镜像 | +| `docker tag ` | 打标签 | +| `docker save -o file.tar ` | 导出镜像 | +| `docker load -i file.tar` | 导入镜像 | +| `docker image prune` | 清理悬空镜像 | + +### 10.2 容器操作 + +| 命令 | 说明 | +|------|------| +| `docker ps -a` | 查看所有容器 | +| `docker run -d --name ` | 后台运行容器 | +| `docker exec -it bash` | 进入容器 | +| `docker logs -f ` | 查看日志 | +| `docker stop ` | 停止容器 | +| `docker start ` | 启动容器 | +| `docker restart ` | 重启容器 | +| `docker rm -f ` | 强制删除容器 | +| `docker cp :/path /local` | 拷贝文件 | +| `docker inspect ` | 查看详情 | +| `docker stats` | 查看资源占用 | +| `docker container prune` | 清理已停止容器 | + +### 10.3 系统维护 + +| 命令 | 说明 | +|------|------| +| `docker info` | Docker 系统信息 | +| `docker version` | 版本信息 | +| `docker system df` | 查看磁盘占用 | +| `docker system prune -a` | 清理所有未使用资源 | +| `docker system prune -a --volumes` | 清理全部(含卷) | + +--- + +## 11. 最佳实践与注意事项 + +### 11.1 安全建议 + +1. **不使用 root 用户运行容器**:在 Dockerfile 中使用 `USER` 指定非 root 用户 +2. **镜像最小化**:使用 alpine 等精简基础镜像 +3. **限制资源**: + ```bash + docker run -d \ + --memory="512m" \ + --cpus="1.0" \ + --memory-swap="1g" \ + --read-only \ + nginx + ``` +4. **定期扫描漏洞**:使用 `docker scan` 或 Trivy 扫描镜像 + +### 11.2 性能优化 + +1. **使用 `.dockerignore`**:避免将不必要的文件构建到镜像中 +2. **合并 RUN 指令**:减少镜像层数 +3. **利用构建缓存**:将不常变化的指令放在 Dockerfile 前面 +4. **选择合适的基础镜像**:alpine (~5MB) vs ubuntu (~200MB) + +### 11.3 日志与调试 + +```bash +# 限制容器日志大小(在 docker-compose.yml 中) +services: + app: + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +# 全局设置(daemon.json) +# /etc/docker/daemon.json +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + } +} +``` + +### 11.4 镜像加速器配置 + +```bash +# /etc/docker/daemon.json +{ + "registry-mirrors": [ + "https://registry.docker-cn.com", + "https://docker.mirrors.ustc.edu.cn" + ] +} +# 重启 Docker +sudo systemctl restart docker +``` + +### 11.5 清理空间 + +```bash +# 一键清理(慎用!会删除未使用的镜像、容器、网络、构建缓存) +docker system prune -a + +# 只清理构建缓存 +docker builder prune + +# 查看空间占用 +docker system df +``` + +### 11.6 常见问题排查 + +```bash +# 1. 容器无法启动 -> 查看日志 +docker logs + +# 2. 端口冲突 -> 查看占用 +sudo netstat -tlnp | grep 80 + +# 3. 磁盘空间不足 -> 清理 +docker system prune -a +docker volume prune + +# 4. 容器间无法通信 -> 检查网络 +docker network inspect + +# 5. 进入排查模式 +docker run -it --rm --network container:<目标容器名> nicolaka/netshoot +``` + +--- + +## 附录:资源链接 + +- [Docker 官方文档](https://docs.docker.com/) +- [Docker Hub](https://hub.docker.com/) +- [Docker Compose 官方文档](https://docs.docker.com/compose/) +- [Dockerfile 最佳实践](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/) +- [Play With Docker(在线实验)](https://labs.play-with-docker.com/) + +--- + +*本文档由 Docker 虚拟机管理助手生成,持续更新中...* diff --git a/docs/git使用手册.md b/docs/git使用手册.md new file mode 100644 index 0000000..b89b606 --- /dev/null +++ b/docs/git使用手册.md @@ -0,0 +1,431 @@ +# Git 使用手册 + +> 版本: 1.0 +> 适用系统: Ubuntu 20.04 / 通用 +> Git 版本: 2.25.1+ + +--- + +## 目录 + +1. [Git 简介](#1-git-简介) +2. [安装与配置](#2-安装与配置) +3. [基础操作](#3-基础操作) +4. [分支管理](#4-分支管理) +5. [远程仓库](#5-远程仓库) +6. [撤销与回退](#6-撤销与回退) +7. [标签管理](#7-标签管理) +8. [常见工作流程](#8-常见工作流程) +9. [常用命令速查表](#9-常用命令速查表) + +--- + +## 1. Git 简介 + +Git 是一个**分布式版本控制系统**,由 Linus Torvalds 于 2005 年创建。它能够高效地处理从小型到大型项目的版本管理,是目前最流行的版本控制工具。 + +### 核心特点 + +- **分布式**: 每个开发者本地都有完整的代码仓库 +- **高效**: 针对文件差异和分支操作进行了优化 +- **数据完整性**: 所有数据在存储前都会计算校验和 +- **支持非线性开发**: 轻量级分支,鼓励分支开发 + +--- + +## 2. 安装与配置 + +### 2.1 在 Ubuntu/Debian 上安装 + +```bash +# 更新软件包列表 +sudo apt update + +# 安装 Git +sudo apt install -y git + +# 验证安装 +git --version +``` + +### 2.2 在 CentOS/RHEL 上安装 + +```bash +sudo yum install -y git +``` + +### 2.3 基本配置 + +```bash +# 配置用户名(提交时记录) +git config --global user.name "Your Name" + +# 配置邮箱 +git config --global user.email "your_email@example.com" + +# 配置默认分支名(Git 2.28+) +git config --global init.defaultBranch main + +# 配置默认文本编辑器 +git config --global core.editor "vim" + +# 查看当前配置 +git config --list +``` + +> 配置文件的优先级: 项目级 `.git/config` > 全局 `~/.gitconfig` > 系统级 `/etc/gitconfig` + +--- + +## 3. 基础操作 + +### 3.1 创建仓库 + +```bash +# 在当前目录初始化新仓库 +git init + +# 克隆远程仓库 +git clone <仓库地址> +git clone <仓库地址> <本地目录名> +``` + +### 3.2 文件状态 + +Git 文件有三种状态:**已修改 (modified)**、**已暂存 (staged)**、**已提交 (committed)** + +```bash +# 查看文件状态 +git status + +# 以简洁模式查看 +git status -s +``` + +状态标识说明: +- `??` - 未跟踪的文件 +- `M` - 已修改 +- `A` - 已暂存 +- `D` - 已删除 + +### 3.3 添加与提交 + +```bash +# 添加文件到暂存区 +git add <文件名> # 添加指定文件 +git add . # 添加所有变更 +git add -A # 添加所有变更(包括删除) +git add -p # 交互式分段添加 + +# 提交到本地仓库 +git commit -m "提交说明" +git commit -am "提交说明" # 跳过 git add(仅对已跟踪文件有效) + +# 修改最后一次提交 +git commit --amend -m "新的提交说明" +``` + +### 3.4 查看历史 + +```bash +# 查看提交历史 +git log +git log --oneline # 简洁模式 +git log --graph # 图形化显示 +git log --all # 显示所有分支 +git log -p # 显示差异内容 +git log --author="名字" # 按作者筛选 +git log --since="2024-01-01" # 按时间筛选 + +# 查看某个文件的修改历史 +git log -p <文件名> +git blame <文件名> # 每行最后修改者 +``` + +--- + +## 4. 分支管理 + +### 4.1 创建与切换 + +```bash +# 查看分支列表 +git branch # 本地分支 +git branch -r # 远程分支 +git branch -a # 所有分支 + +# 创建分支 +git branch <分支名> + +# 切换分支 +git checkout <分支名> +git switch <分支名> # 新式语法 + +# 创建并切换分支 +git checkout -b <分支名> +git switch -c <分支名> + +# 重命名分支 +git branch -m <旧名> <新名> +``` + +### 4.2 合并与变基 + +```bash +# 合并指定分支到当前分支 +git merge <分支名> + +# 变基操作(将当前分支的提交移至目标分支顶端) +git rebase <分支名> + +# 交互式变基(合并、修改提交记录) +git rebase -i HEAD~3 # 修改最近 3 个提交 +``` + +> **合并 vs 变基**: 合并保留完整历史,变基使历史更线性。公共分支推荐使用合并,个人分支可使用变基。 + +### 4.3 删除分支 + +```bash +# 删除本地分支 +git branch -d <分支名> # 安全删除(会检查是否已合并) +git branch -D <分支名> # 强制删除 + +# 删除远程分支 +git push origin --delete <分支名> +``` + +--- + +## 5. 远程仓库 + +### 5.1 管理远程仓库 + +```bash +# 查看远程仓库 +git remote -v + +# 添加远程仓库 +git remote add origin <仓库地址> + +# 修改远程仓库地址 +git remote set-url origin <新地址> + +# 删除远程仓库 +git remote remove origin +``` + +### 5.2 推送与拉取 + +```bash +# 推送到远程仓库 +git push origin <分支名> +git push -u origin <分支名> # 首次推送,建立关联 +git push --all # 推送所有分支 +git push origin --tags # 推送所有标签 + +# 拉取远程更新 +git pull # 拉取并合并(相当于 fetch + merge) +git pull --rebase # 拉取并变基(推荐) +git fetch # 仅拉取,不自动合并 +``` + +### 5.3 处理冲突 + +```bash +# 当合并或拉取产生冲突时: +# 1. 编辑冲突文件,保留需要的内容 +# 2. 标记为已解决 +git add <已解决冲突的文件> + +# 3. 完成合并 +git commit + +# 中止合并 +git merge --abort +``` + +--- + +## 6. 撤销与回退 + +### 6.1 撤销工作区修改 + +```bash +# 恢复文件到最近一次提交的状态 +git checkout -- <文件名> +git restore <文件名> # 新式语法 +``` + +### 6.2 撤销暂存区 + +```bash +# 将文件从暂存区移出(保留工作区修改) +git reset HEAD <文件名> +git restore --staged <文件名> # 新式语法 +``` + +### 6.3 回退提交 + +```bash +# 软重置(保留工作区和暂存区修改) +git reset --soft HEAD~1 + +# 混合重置(保留工作区,清空暂存区)- 默认模式 +git reset --mixed HEAD~1 + +# 硬重置(丢弃所有修改)- 谨慎使用! +git reset --hard HEAD~1 + +# 回退到指定提交 +git reset --hard <提交哈希> + +# 生成一个新的反向提交来撤销修改 +git revert HEAD # 撤销最近一次提交 +git revert <提交哈希> +``` + +> `git reset` 与 `git revert` 的区别:`reset` 会删除提交历史,`revert` 会保留历史并新增一个反向提交。公共分支应使用 `revert`。 + +--- + +## 7. 标签管理 + +```bash +# 创建标签 +git tag <标签名> # 轻量标签 +git tag -a <标签名> -m "说明" # 附注标签 + +# 查看标签 +git tag +git tag -l "v1.*" # 匹配模式 + +# 推送标签到远程 +git push origin <标签名> +git push origin --tags + +# 删除标签 +git tag -d <标签名> +git push origin --delete <标签名> +``` + +--- + +## 8. 常见工作流程 + +### 8.1 Git Flow 工作流 + +适用于有固定发布周期的项目: + +- `main` - 主分支,存放稳定版本 +- `develop` - 开发分支 +- `feature/*` - 功能分支 +- `release/*` - 发布分支 +- `hotfix/*` - 紧急修复分支 + +### 8.2 GitHub Flow + +适用于持续交付的项目: + +1. 从 `main` 创建功能分支 +2. 提交修改 +3. 发起 Pull Request +4. 代码评审 + 自动化测试 +5. 合并回 `main` +6. 立即部署 + +### 8.3 日常开发流程示例 + +```bash +# 1. 更新主分支 +git checkout main +git pull + +# 2. 创建功能分支 +git checkout -b feature/my-feature + +# 3. 开发并提交 +git add . +git commit -m "feat: 添加新功能" + +# 4. 同步上游变更 +git fetch origin +git rebase origin/main + +# 5. 推送并创建 Pull Request +git push -u origin feature/my-feature +``` + +--- + +## 9. 常用命令速查表 + +| 操作 | 命令 | +|------|------| +| 初始化仓库 | `git init` | +| 克隆仓库 | `git clone ` | +| 查看状态 | `git status` | +| 添加文件 | `git add ` | +| 提交 | `git commit -m "msg"` | +| 查看日志 | `git log --oneline --graph` | +| 创建分支 | `git branch ` | +| 切换分支 | `git switch ` | +| 创建+切换 | `git switch -c ` | +| 合并分支 | `git merge ` | +| 推送 | `git push origin ` | +| 拉取 | `git pull --rebase` | +| 查看差异 | `git diff` | +| 暂存修改 | `git stash` | +| 恢复暂存 | `git stash pop` | +| 查看远程 | `git remote -v` | + +--- + +## 附录 + +### .gitignore 文件示例 + +```gitignore +# 编译输出 +*.o +*.class +*.exe +*.dll + +# 依赖目录 +node_modules/ +vendor/ +__pycache__/ + +# IDE 配置 +.vscode/ +.idea/ +*.swp + +# 系统文件 +.DS_Store +Thumbs.db + +# 环境配置 +.env +*.local +``` + +### 提交信息规范 + +推荐使用 [Conventional Commits](https://www.conventionalcommits.org/) 规范: + +``` +<类型>(<范围>): <简短描述> + +<详细描述> + +<尾部信息> +``` + +类型包括:`feat`、`fix`、`docs`、`style`、`refactor`、`test`、`chore` 等。 + +--- + +> 本文档持续更新中... +> 更多信息请访问 [Git 官方文档](https://git-scm.com/doc) diff --git a/docs/images/gui.png b/docs/images/gui.png new file mode 100644 index 0000000..45c3373 Binary files /dev/null and b/docs/images/gui.png differ diff --git a/docs/nvm使用文档.md b/docs/nvm使用文档.md new file mode 100644 index 0000000..a24ac0f --- /dev/null +++ b/docs/nvm使用文档.md @@ -0,0 +1,189 @@ +# NVM (Node Version Manager) 使用文档 + +## 一、什么是 NVM? + +NVM(Node Version Manager)是一个用于管理多个 Node.js 版本的工具。它可以让你在同一台机器上安装、切换和使用不同版本的 Node.js,非常适合需要兼顾不同项目 Node.js 版本需求的开发者。 + +--- + +## 二、安装 NVM + +### 2.1 一键安装 + +```bash +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash +``` + +### 2.2 加载 NVM + +安装完成后,需要重新加载终端或者执行以下命令使 nvm 生效: + +```bash +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" +[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" +``` + +### 2.3 验证安装 + +```bash +nvm --version +``` + +--- + +## 三、配置镜像源(加速 Node.js 下载) + +在国内使用 nvm 下载 Node.js 时,建议配置淘宝镜像源以提高下载速度。 + +### 临时配置(仅当前会话生效) + +```bash +export NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node +``` + +### 永久配置(写入 shell 配置文件) + +```bash +echo 'export NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node' >> ~/.bashrc +source ~/.bashrc +``` + +--- + +## 四、NVM 常用命令 + +### 4.1 安装 Node.js + +```bash +# 安装最新稳定版 +nvm install node + +# 安装指定版本 +nvm install 20.11.0 + +# 安装最新的 LTS 版本 +nvm install --lts +``` + +### 4.2 查看可安装的版本 + +```bash +# 列出所有可安装的版本 +nvm ls-remote + +# 列出所有 LTS 版本 +nvm ls-remote --lts +``` + +### 4.3 切换 Node.js 版本 + +```bash +# 使用指定版本 +nvm use 20.11.0 + +# 使用最新稳定版 +nvm use node + +# 使用最新的 LTS 版本 +nvm use --lts +``` + +### 4.4 查看已安装的版本 + +```bash +nvm ls +``` + +### 4.5 设置默认版本 + +```bash +# 设置系统默认使用的 Node.js 版本 +nvm alias default 20.11.0 + +# 将最新稳定版设为默认 +nvm alias default node +``` + +### 4.6 卸载 Node.js 版本 + +```bash +nvm uninstall 20.11.0 +``` + +--- + +## 五、版本管理实践 + +### 5.1 为项目指定 Node.js 版本 + +在项目根目录创建 `.nvmrc` 文件: + +```bash +# .nvmrc 文件内容示例 +20.11.0 +``` + +然后在该目录下运行 `nvm use` 即可自动切换到对应版本。 + +### 5.2 查看当前使用的版本 + +```bash +node -v +npm -v +``` + +### 5.3 版本别名管理 + +```bash +# 创建别名 +nvm alias myapp 20.11.0 + +# 使用别名切换 +nvm use myapp + +# 删除别名 +nvm unalias myapp +``` + +--- + +## 六、常见问题 + +### 6.1 命令找不到(command not found) + +如果重启终端后出现 `nvm: command not found`,请确保已将 nvm 加载命令添加到 `~/.bashrc`、`~/.zshrc` 或 `~/.profile` 文件中。 + +### 6.2 下载速度慢 + +配置淘宝镜像源即可解决: + +```bash +export NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node +``` + +### 6.3 权限问题 + +使用 nvm 安装的 Node.js 不需要 `sudo` 权限,如果遇到权限问题,请检查是否使用了系统自带的 Node.js。 + +--- + +## 七、常用命令速查表 + +| 命令 | 说明 | +|------|------| +| `nvm --version` | 查看 nvm 版本 | +| `nvm install ` | 安装指定版本 | +| `nvm install node` | 安装最新稳定版 | +| `nvm install --lts` | 安装最新 LTS 版本 | +| `nvm use ` | 切换使用指定版本 | +| `nvm ls` | 列出已安装版本 | +| `nvm ls-remote` | 列出远程所有版本 | +| `nvm alias default ` | 设置默认版本 | +| `nvm uninstall ` | 卸载指定版本 | +| `nvm current` | 显示当前使用的版本 | + +--- + +> 文档版本: v1.0 +> 最后更新: 2025年 diff --git a/docs/uv使用文档.md b/docs/uv使用文档.md new file mode 100644 index 0000000..7e62a22 --- /dev/null +++ b/docs/uv使用文档.md @@ -0,0 +1,424 @@ +# uv 使用文档 + +> uv 是一个用 Rust 编写的极速 Python 包管理与工具链管理器,由 Astral 公司(Ruff 作者)开发。 +> 它可替代 pip、pip-tools、pipx、poetry、pyenv、virtualenv 等多个工具。 + +--- + +## 目录 + +1. [简介](#1-简介) +2. [安装与配置](#2-安装与配置) +3. [核心命令速查](#3-核心命令速查) +4. [Python 版本管理](#4-python-版本管理) +5. [虚拟环境管理](#5-虚拟环境管理) +6. [包管理](#6-包管理) +7. [项目工作流](#7-项目工作流) +8. [镜像源配置](#8-镜像源配置) +9. [常见问题](#9-常见问题) + +--- + +## 1. 简介 + +### 1.1 什么是 uv? + +uv 是一个"大一统"的 Python 工具链,具备以下核心能力: + +| 功能 | 对应传统工具 | +|------|------------| +| Python 版本安装与管理 | pyenv | +| 虚拟环境创建 | venv / virtualenv | +| 包安装 | pip | +| 依赖锁定 | pip-tools / poetry | +| 工具包运行 | pipx | +| 脚本依赖管理 | 原生支持 | + +### 1.2 核心优势 + +- **极快速度**:比 pip 快 10-100 倍 +- **单一二进制**:无需 Python 即可安装 uv +- **兼容性强**:兼容 pip 的工作流和 requirements.txt +- **全面替代**:一个工具解决 Python 环境所有问题 + +--- + +## 2. 安装与配置 + +### 2.1 一键安装(推荐) + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +安装后重启终端或执行: + +```bash +source $HOME/.local/bin/env +``` + +### 2.2 其他安装方式 + +**macOS (Homebrew):** +```bash +brew install uv +``` + +**Linux (apt/手动):** +```bash +# 下载二进制 +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +**Windows (PowerShell):** +```powershell +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +### 2.3 验证安装 + +```bash +uv --version +# 输出示例: uv 0.11.11 (x86_64-unknown-linux-gnu) +``` + +### 2.4 配置国内镜像源(加速) + +创建配置文件 `~/.config/uv/uv.toml`: + +```toml +# Python 版本下载镜像源 +python-install-mirror = "https://registry.npmmirror.com/-/binary/python-build-standalone/" + +# pip 包下载镜像源(阿里云) +[[index]] +url = "https://mirrors.aliyun.com/pypi/simple/" +default = true +``` + +也可通过环境变量临时设置: + +```bash +export UV_PYTHON_INSTALL_MIRROR="https://registry.npmmirror.com/-/binary/python-build-standalone/" +``` + +--- + +## 3. 核心命令速查 + +| 命令 | 说明 | +|------|------| +| `uv python install <版本>` | 安装指定 Python 版本 | +| `uv python list` | 列出可用的 Python 版本 | +| `uv venv` | 创建虚拟环境 | +| `uv pip install <包>` | 安装 Python 包 | +| `uv pip sync requirements.txt` | 同步依赖 | +| `uv run <文件>` | 在虚拟环境中运行脚本 | +| `uv add <包>` | 向项目添加依赖 | +| `uv lock` | 生成锁定文件 | +| `uv tool run <工具>` | 运行工具包(类似 pipx) | +| `uv init` | 初始化新项目 | +| `uv build` | 构建分发包 | + +--- + +## 4. Python 版本管理 + +### 4.1 查看可安装的 Python 版本 + +```bash +# 列出所有可下载的版本 +uv python list --only-downloads + +# 列出已安装的版本 +uv python list +``` + +### 4.2 安装指定 Python 版本 + +```bash +# 安装最新 Python 3.12 +uv python install 3.12 + +# 安装具体版本 +uv python install 3.11.11 + +# 安装多个版本 +uv python install 3.10 3.11 3.12 +``` + +### 4.3 查看与切换当前 Python + +```bash +# 查看当前使用的 Python +uv python + +# 查找特定版本的路径 +uv python find 3.11 + +# 为项目固定 Python 版本 +uv python pin 3.12 +``` + +### 4.4 卸载 Python 版本 + +```bash +# uv 安装的 Python 存储在 ~/.local/share/uv/python/ +# 直接删除对应目录即可 +rm -rf ~/.local/share/uv/python/cpython-3.10* +``` + +--- + +## 5. 虚拟环境管理 + +### 5.1 创建虚拟环境 + +```bash +# 在当前目录创建 .venv(使用默认 Python) +uv venv + +# 指定 Python 版本创建 +uv venv --python 3.12 + +# 指定名称 +uv venv myenv +``` + +### 5.2 激活虚拟环境 + +```bash +# Linux / macOS +source .venv/bin/activate + +# Windows +.venv\Scripts\activate +``` + +> **提示**:uv 会自动检测并使用当前目录下的 `.venv`,无需手动激活也可通过 `uv run` 使用。 + +### 5.3 删除虚拟环境 + +```bash +# 直接删除 .venv 目录即可 +rm -rf .venv +``` + +--- + +## 6. 包管理 + +uv 兼容 pip 的全部功能,且速度更快。 + +### 6.1 安装包 + +```bash +# 安装单个包 +uv pip install requests + +# 安装指定版本 +uv pip install "flask>=2.0" + +# 从 requirements.txt 安装 +uv pip install -r requirements.txt + +# 从 pyproject.toml 安装 +uv pip install -e . +``` + +### 6.2 管理已安装的包 + +```bash +# 列出已安装的包 +uv pip list + +# 查看包信息 +uv pip show requests + +# 检查依赖冲突 +uv pip check + +# 卸载包 +uv pip uninstall requests +``` + +### 6.3 依赖锁定 + +```bash +# 根据 requirements.in 编译出锁定文件 +uv pip compile requirements.in -o requirements.txt + +# 同步环境到锁定状态 +uv pip sync requirements.txt +``` + +--- + +## 7. 项目工作流 + +### 7.1 初始化新项目 + +```bash +# 创建新项目 +uv init my_project +cd my_project + +# 添加依赖 +uv add requests flask + +# 安装所有依赖 +uv sync + +# 运行项目 +uv run python main.py +``` + +### 7.2 脚本依赖管理(Shebang 方式) + +在 Python 脚本顶部添加内联元数据,自动管理依赖: + +```python +# /// script +# dependencies = [ +# "requests>=2.28", +# "rich>=13.0", +# ] +# /// +import requests +from rich import print + +response = requests.get("https://api.github.com") +print(response.status_code) +``` + +运行方式: +```bash +uv run script.py +``` + +> uv 会自动创建隔离环境并安装依赖,无需手动管理! + +### 7.3 工具包管理(替代 pipx) + +```bash +# 安装工具包 +uv tool install ruff + +# 运行工具(无需安装) +uv tool run black --help + +# 列出已安装的工具 +uv tool list + +# 升级工具 +uv tool upgrade ruff + +# 卸载工具 +uv tool uninstall ruff +``` + +--- + +## 8. 镜像源配置 + +### 8.1 配置文件方式 + +编辑 `~/.config/uv/uv.toml`: + +```toml +# ========== Python 版本下载镜像 ========== +python-install-mirror = "https://registry.npmmirror.com/-/binary/python-build-standalone/" + +# ========== 包下载镜像源 ========== +[[index]] +url = "https://mirrors.aliyun.com/pypi/simple/" +default = true + +# 可配置多个备用镜像源 +[[index]] +url = "https://pypi.tuna.tsinghua.edu.cn/simple/" +``` + +### 8.2 环境变量方式 + +```bash +# Python 版本下载镜像 +export UV_PYTHON_INSTALL_MIRROR="https://registry.npmmirror.com/-/binary/python-build-standalone/" + +# 包下载镜像(临时覆盖) +export UV_INDEX_URL="https://mirrors.aliyun.com/pypi/simple/" +``` + +### 8.3 常用国内镜像源 + +| 镜像源 | Python 版本镜像 URL | PyPI 镜像 URL | +|--------|-------------------|--------------| +| 华为云 | `https://repo.huaweicloud.com/python` | `https://mirrors.huaweicloud.com/repository/pypi/simple` | +| 阿里云 | - | `https://mirrors.aliyun.com/pypi/simple/` | +| 清华大学 | `https://mirrors.tuna.tsinghua.edu.cn/python` | `https://pypi.tuna.tsinghua.edu.cn/simple/` | +| 中科大 | `https://mirrors.ustc.edu.cn/python` | `https://pypi.mirrors.ustc.edu.cn/simple/` | + +--- + +## 9. 常见问题 + +### Q1: 安装时遇到权限问题? + +```bash +# 确保 ~/.local/bin 在 PATH 中 +export PATH="$HOME/.local/bin:$PATH" +``` + +### Q2: 如何更新 uv? + +```bash +uv self update +``` + +### Q3: uv 和 pip 能否共存? + +完全可以。uv 兼容 pip 的工作流,两者可共存使用。建议逐步迁移到 uv。 + +### Q4: 如何查看帮助? + +```bash +# 通用帮助 +uv help + +# 子命令帮助 +uv python --help +uv pip --help +``` + +### Q5: 如何卸载 uv? + +```bash +# 删除二进制文件 +rm ~/.local/bin/uv ~/.local/bin/uvx + +# 删除配置 +rm -rf ~/.config/uv + +# 删除缓存 +rm -rf ~/.cache/uv + +# 删除安装的 Python 版本 +rm -rf ~/.local/share/uv +``` + +### Q6: 为什么我执行 `uv python install` 下载很慢? + +请检查是否已配置国内镜像源(参见第 8 节),或手动设置: + +```bash +export UV_PYTHON_INSTALL_MIRROR="https://registry.npmmirror.com/-/binary/python-build-standalone/" +``` + +--- + +## 参考资源 + +- [uv 官方文档](https://docs.astral.sh/uv/) +- [uv GitHub 仓库](https://github.com/astral-sh/uv) +- [华为云 Python 镜像](https://repo.huaweicloud.com/python) +- [阿里云 PyPI 镜像](https://mirrors.aliyun.com/pypi/simple/) diff --git a/gui.spec b/gui.spec new file mode 100644 index 0000000..d11a1d4 --- /dev/null +++ b/gui.spec @@ -0,0 +1,55 @@ +# -*- mode: python ; coding: utf-8 -*- + +import os +import sys + +# Python 的 base_prefix 路径 +python_dir = sys.base_prefix # e.g., C:\Users\chakcy\AppData\Roaming\uv\python\cpython-3.14-windows-x86_64-none + +# DLL 目录 +dlls_dir = os.path.join(python_dir, "DLLs") + +# 收集 SSL 相关的二进制文件 +ssl_binaries = [] +for f in ["_ssl.pyd", "libcrypto-3-x64.dll", "libssl-3-x64.dll"]: + src = os.path.join(dlls_dir, f) + if os.path.exists(src): + ssl_binaries.append((src, ".")) + print(f"Found: {src}") + +a = Analysis( + ['src\\virtualbox_client\\gui.py'], + pathex=[], + binaries=ssl_binaries, + datas=[], + hiddenimports=["ssl", "pywin32", "win32api", "win32security"], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='VirtualBoxClient', + icon=['chongming.ico'], + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5ac25c0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling"] + +[project] +name = "virtualbox-client" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "openai>=2.35.1", + "paramiko>=4.0.0", + "python-dotenv>=1.2.2", + "pywin32>=311", + "PySide6>=6.8.0", +] + +[dependency-groups] +dev = [ + "pyinstaller>=6.20.0", + "setuptools>=82.0.1", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/virtualbox_client"] + +[project.scripts] +example = "virtualbox_client.scripts.example:run" +agent-task = "virtualbox_client.scripts.agent_task:run" +run-script = "virtualbox_client.scripts.run_script:run" +gui = "virtualbox_client.gui:run" diff --git a/resources/chongming_dev.sh b/resources/chongming_dev.sh new file mode 100644 index 0000000..8b34384 --- /dev/null +++ b/resources/chongming_dev.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# chongming 开发环境一键启动脚本 (远程执行版) +# 用法: 上传到虚拟机 /tmp/ 后执行,会自动定位到项目根目录 +# 项目路径: ~/workspace/chongming + +set -e + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${GREEN}==> 初始化 chongming 开发环境${NC}" + +# 1. 定位项目根目录 +PROJECT_DIR=~/workspace/chongming +if [ ! -d "$PROJECT_DIR" ]; then + echo -e "${RED}错误:项目目录不存在: $PROJECT_DIR${NC}" + echo -e "${YELLOW}请先在虚拟机中创建项目目录并拉取代码:${NC}" + echo " mkdir -p ~/workspace && cd ~/workspace" + echo " git clone <仓库地址> chongming" + exit 1 +fi + +cd "$PROJECT_DIR" +echo -e "${GREEN}✔ 切换到项目目录: $(pwd)${NC}" + +# 确认 pyproject.toml 存在(防御性检查) +if [ ! -f "pyproject.toml" ]; then + echo -e "${RED}错误:项目目录中未找到 pyproject.toml:$(pwd)${NC}" + exit 1 +fi + +# 2. 加载 nvm(SSH 非交互式 shell 需要手动加载) +export NVM_DIR="$HOME/.nvm" +if [ -s "$NVM_DIR/nvm.sh" ]; then + . "$NVM_DIR/nvm.sh" 2>/dev/null +fi + +# 3. 检查 Node.js (版本要求 >=20.19) +if ! command -v node &> /dev/null; then + echo -e "${RED}错误:未找到 node,请安装 Node.js >= 20.19${NC}" + echo "推荐使用 nvm: curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash" + exit 1 +fi +NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) +if [ "$NODE_VERSION" -lt 20 ]; then + echo -e "${RED}错误:Node.js 版本过低 (当前 $(node -v)),需要 >=20.19${NC}" + exit 1 +fi +echo -e "${GREEN}✔ Node.js $(node -v)${NC}" + +# 4. 安装/检查 uv (项目推荐) +# SSH 非交互式 shell 的 PATH 可能不含 ~/.local/bin,手动添加 +export PATH="$HOME/.local/bin:$PATH" +if ! command -v uv &> /dev/null; then + echo -e "${YELLOW}未找到 uv,正在自动安装...${NC}" + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + if ! command -v uv &> /dev/null; then + echo -e "${RED}uv 安装失败,请手动安装:https://docs.astral.sh/uv/${NC}" + exit 1 + fi + echo -e "${GREEN}uv 安装成功${NC}" +fi + +# 5. 安装后端依赖 +echo -e "${GREEN}==> 安装后端依赖 (uv sync)${NC}" +cd ~/workspace/chongming +uv sync + +# 6. 安装前端依赖 +echo -e "${GREEN}==> 安装前端依赖 (npm install)${NC}" +cd ~/workspace/chongming/src/chongming-web +npm install +cd ../.. + +# 7. 启动后端服务(后台运行,使用 nohup 防止 SSH 断开后被 SIGHUP 杀死) +echo -e "${GREEN}==> 启动后端服务 (uv run serve)${NC}" +mkdir -p ~/workspace/chongming/logs +nohup uv run serve > ~/workspace/chongming/logs/backend.log 2>&1 & +BACKEND_PID=$! + +# 等待后端完全启动(健康检查) +echo "等待后端服务就绪(约3秒)..." +sleep 10 +# 可选:检查端口8000是否监听 +if ! ss -tln | grep -q ':8000'; then + echo -e "${YELLOW}警告:后端可能尚未完全启动,请稍后手动检查${NC}" +fi + +# 8. 生成前端 API 客户端(需要后端运行) +echo -e "${GREEN}==> 生成前端 API 客户端 (npm run openapi)${NC}" +cd src/chongming-web +npm run openapi || echo -e "${YELLOW}注意:生成 openapi 失败,请确认后端已运行在 http://localhost:8000${NC}" +cd ../.. + +# 9. 启动前端开发服务器(后台运行,使用 nohup 防止 SSH 断开后被 SIGHUP 杀死) +echo -e "${GREEN}==> 启动前端开发服务器 (npm run dev)${NC}" +cd src/chongming-web +nohup npm run dev > ~/workspace/chongming/logs/frontend.log 2>&1 & +FRONTEND_PID=$! +cd ../.. + +echo -e "前端地址: ${GREEN}http://localhost:5173${NC}" +echo -e "后端地址: ${GREEN}http://localhost:8000${NC}" +echo -e "后端日志: ~/workspace/chongming/logs/backend.log" +echo -e "前端日志: ~/workspace/chongming/logs/frontend.log" +echo -e "后端 PID: $BACKEND_PID" +echo -e "前端 PID: $FRONTEND_PID" + +# 记录 PID 到文件,方便后续停止服务 +echo -e "$BACKEND_PID\n$FRONTEND_PID" > ~/workspace/chongming/.dev_pids + +echo -e "${GREEN}✅ 开发环境已全部启动,脚本将退出,服务在后台持续运行${NC}" +echo -e "${YELLOW}要停止服务,请执行: kill \$(cat ~/workspace/chongming/.dev_pids)${NC}" diff --git a/resources/deploy_docker.sh b/resources/deploy_docker.sh new file mode 100644 index 0000000..a06c5a4 --- /dev/null +++ b/resources/deploy_docker.sh @@ -0,0 +1,189 @@ +#!/bin/bash +# +# deploy_docker.sh - Docker 一键部署脚本 +# 适用系统: Ubuntu 20.04 (Focal) +# 功能: 安装 Docker Engine 并配置国内镜像加速器 +# + +set -e + +# 颜色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# 检查是否以 root 运行 +check_root() { + if [ "$EUID" -ne 0 ]; then + log_warn "未以 root 身份运行,将使用 sudo 提权" + SUDO="sudo" + else + SUDO="" + fi +} + +# 步骤1: 清理旧 Docker 仓库配置(避免残余配置干扰) +cleanup_old_repo() { + log_info "步骤1: 清理旧 Docker 仓库配置..." + # 移除所有旧的 docker 源文件 + $SUDO rm -f /etc/apt/sources.list.d/docker.list /etc/apt/sources.list.d/docker*.list + # 移除旧的 docker gpg 密钥 + $SUDO rm -f /usr/share/keyrings/docker-archive-keyring.gpg + $SUDO apt-key del "7EA0A9C3F273FCD8" 2>/dev/null || true + log_info "旧 Docker 仓库配置已清理" +} + +# 步骤2: 添加 Docker 官方 GPG 密钥(使用阿里云镜像) +add_gpg_key() { + log_info "步骤2: 添加 Docker GPG 密钥..." + curl -fsSL https://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg -o /tmp/docker.gpg + # 安装到 keyrings 目录(供 signed-by 使用) + $SUDO mkdir -p /usr/share/keyrings + $SUDO gpg --batch --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg /tmp/docker.gpg 2>/dev/null || true + # 用 apt-key 添加(传统方式兼容) + $SUDO apt-key add /tmp/docker.gpg + rm -f /tmp/docker.gpg + log_info "GPG 密钥添加完成" +} + +# 步骤3: 添加 Docker APT 源(使用阿里云镜像,带 signed-by 引用) +add_docker_repo() { + log_info "步骤3: 添加 Docker APT 源..." + # 使用 signed-by 添加(指定密钥文件路径,避免 NO_PUBKEY 错误) + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://mirrors.aliyun.com/docker-ce/linux/ubuntu $(lsb_release -cs) stable" | $SUDO tee /etc/apt/sources.list.d/docker.list > /dev/null + log_info "Docker APT 源添加完成" +} + +# 步骤4: 更新 apt 并安装依赖(此时 Docker 源已配置好密钥) +install_dependencies() { + log_info "步骤4: 更新 apt 包索引并安装依赖..." + $SUDO apt update -y + $SUDO apt install -y \ + apt-transport-https \ + ca-certificates \ + curl \ + gnupg \ + lsb-release + log_info "依赖安装完成" +} + +# 步骤5: 安装 Docker Engine +install_docker() { + log_info "步骤5: 安装 Docker Engine(先装核心组件,再试可选组件)..." + + # 核心组件(必须成功) + $SUDO apt install -y docker-ce docker-ce-cli containerd.io || { + log_error "Docker 核心组件安装失败!" + exit 1 + } + + # 可选组件(失败不影响核心功能) + for pkg in docker-buildx-plugin docker-compose-plugin docker-ce-rootless-extras; do + if $SUDO apt install -y "$pkg" 2>/dev/null; then + log_info "✔ $pkg 安装成功" + else + log_warn "$pkg 安装失败(可选组件,可跳过)" + fi + done + + log_info "Docker Engine 安装完成" +} + +# 步骤6: 配置 Docker 镜像加速器 +configure_mirrors() { + log_info "步骤6: 配置 Docker 镜像加速器..." + $SUDO mkdir -p /etc/docker + cat > /tmp/daemon.json << 'EOF' +{ + "registry-mirrors": [ + "https://docker.m.daocloud.io", + "https://dockerproxy.com", + "https://docker.nju.edu.cn", + "https://docker.mirrors.ustc.edu.cn" + ] +} +EOF + $SUDO cp /tmp/daemon.json /etc/docker/daemon.json + rm -f /tmp/daemon.json + log_info "镜像加速器配置完成" +} + +# 步骤7: 重启 Docker 服务 +restart_docker() { + log_info "步骤7: 重启 Docker 服务..." + $SUDO systemctl restart docker + log_info "Docker 服务已重启" +} + +# 步骤8: 验证安装 +verify_installation() { + log_info "步骤8: 验证安装..." + + # 检查 Docker 版本 + if docker --version &>/dev/null; then + log_info "$(docker --version)" + else + log_error "Docker 未正确安装" + exit 1 + fi + + # 检查 Docker 服务状态 + if $SUDO systemctl is-active docker &>/dev/null; then + log_info "Docker 服务运行正常" + else + log_error "Docker 服务未运行" + exit 1 + fi + + # 检查镜像加速器 + if $SUDO docker info 2>/dev/null | grep -q "Registry Mirrors"; then + log_info "镜像加速器配置如下:" + $SUDO docker info 2>/dev/null | grep -A5 "Registry Mirrors" + fi + + # 运行 hello-world 测试 + log_info "运行 hello-world 测试..." + if $SUDO docker run --rm hello-world &>/dev/null; then + log_info "Docker 安装验证通过!" + else + log_warn "hello-world 测试未通过,请检查网络连接" + fi +} + +# 主函数 +main() { + echo "==========================================" + echo " Docker 一键部署脚本" + echo " 适用系统: Ubuntu 20.04" + echo "==========================================" + echo "" + + check_root + cleanup_old_repo + add_gpg_key + add_docker_repo + install_dependencies + install_docker + configure_mirrors + restart_docker + verify_installation + + echo "" + echo "==========================================" + echo -e "${GREEN} Docker 部署完成!${NC}" + echo "==========================================" + echo "" + echo "常用命令:" + echo " docker ps - 查看运行中的容器" + echo " docker images - 查看本地镜像" + echo " docker info - 查看 Docker 信息" + echo " docker run hello-world - 测试 Docker 安装" + echo "" +} + +main "$@" diff --git a/resources/deploy_git.sh b/resources/deploy_git.sh new file mode 100644 index 0000000..634ec0f --- /dev/null +++ b/resources/deploy_git.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# ============================================ +# deploy_git.sh - Git 安装部署脚本 +# 适用系统: Ubuntu 20.04 +# 功能: 一键安装 Git 并验证 +# ============================================ + +set -e # 遇到错误立即退出 + +echo "==========================================" +echo " 开始部署 Git ..." +echo "==========================================" + +# 步骤 1: 更新软件包列表 +echo "[Step 1/4] 更新软件包列表..." +sudo apt update -y + +# 步骤 2: 安装 Git +echo "[Step 2/4] 安装 Git..." +sudo apt install -y git + +# 步骤 3: 验证安装 +echo "[Step 3/4] 验证 Git 安装..." +if command -v git &>/dev/null; then + echo " Git 安装成功!" + git --version +else + echo " Git 安装失败!" + exit 1 +fi + +# 步骤 4: 配置基本信息(可选) +echo "[Step 4/4] 配置 Git 基本信息(可选)..." +echo " 提示:您可以使用以下命令配置用户名和邮箱:" +echo " git config --global user.name \"Your Name\"" +echo " git config --global user.email \"your_email@example.com\"" + +echo "" +echo "==========================================" +echo " Git 部署完成!" +echo " 版本: $(git --version 2>/dev/null || echo 'unknown')" +echo "==========================================" diff --git a/resources/deploy_nvm.sh b/resources/deploy_nvm.sh new file mode 100644 index 0000000..2b87f3a --- /dev/null +++ b/resources/deploy_nvm.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# ============================================================== +# NVM 部署脚本 - 一键安装 nvm + Node.js LTS 稳定版 + 换源加速 +# ============================================================== +# 适用系统: Ubuntu 20.04+ +# 说明: 使用国内镜像源加速,避免 GitHub raw 下载缓慢 +# 安装 Node.js LTS 版(比 latest 更小更快) +# +# 注意:脚本可能通过 sudo 以 root 身份执行, +# 因此需要检测原始用户并切换到该用户安装 nvm, +# 避免 nvm 装到 /root 下导致原用户看不到。 +# ============================================================= + +set -e + +echo "========================================" +echo " NVM 部署脚本 - 开始安装..." +echo "========================================" + +# ---------- 检测原始用户 ---------- +# 如果通过 sudo 运行,SUDO_USER 会保留原用户名 +if [ -n "$SUDO_USER" ]; then + REAL_USER="$SUDO_USER" + REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6) + if [ -z "$REAL_HOME" ]; then + REAL_HOME="/home/$REAL_USER" + fi +else + REAL_USER=$(whoami) + REAL_HOME="$HOME" +fi + +echo " 检测到实际用户: $REAL_USER" +echo " 用户目录: $REAL_HOME" +echo "" + +NVM_VERSION="v0.39.7" +NODE_LTS_VERSION="--lts" # 安装 LTS 最新版(比 install node 的 latest 版更小) + +# ---------- 辅助函数:以原始用户身份执行命令 ---------- +run_as_user() { + if [ "$(whoami)" != "$REAL_USER" ]; then + sudo -u "$REAL_USER" bash -c "$*" + else + bash -c "$*" + fi +} + +# 1. 安装 nvm(使用 gitee 镜像加速) +echo "[1/4] 正在安装 nvm ${NVM_VERSION}..." +# 优先用 gitee 镜像(国内快),失败则回滚到 GitHub 官方 +NVM_INSTALL_URL="https://gitee.com/mirrors/nvm/raw/${NVM_VERSION}/install.sh" +echo " 下载源: ${NVM_INSTALL_URL}" + +# 以原始用户身份下载并执行安装脚本 +rm -f /tmp/install_nvm.sh +if timeout 30 curl -fsSL "${NVM_INSTALL_URL}" -o /tmp/install_nvm.sh 2>/dev/null; then + echo " ✅ 从 Gitee 镜像下载成功" +else + echo " ⚠ Gitee 镜像下载失败,切换到 GitHub 源..." + NVM_INSTALL_URL="https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh" + curl -fsSL "${NVM_INSTALL_URL}" -o /tmp/install_nvm.sh +fi + +# 以原始用户身份执行安装(不要用 sudo) +chown "$REAL_USER":"$REAL_USER" /tmp/install_nvm.sh 2>/dev/null || true +sudo -u "$REAL_USER" bash /tmp/install_nvm.sh +rm -f /tmp/install_nvm.sh + +# 2. 加载 nvm 到当前 shell(以原始用户身份) +echo "[2/4] 正在加载 nvm 环境变量..." +export NVM_DIR="$REAL_HOME/.nvm" +if [ -s "$NVM_DIR/nvm.sh" ]; then + \. "$NVM_DIR/nvm.sh" + echo " nvm 已加载" +else + echo " ⚠ nvm.sh 未找到,可能安装过程中出现问题" + ls -la "$NVM_DIR" 2>/dev/null || echo " .nvm 目录不存在" +fi + +# 3. 配置淘宝镜像源(加速 Node.js 下载) +echo "[3/4] 正在配置淘宝镜像源..." +echo 'export NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node' >> "$REAL_HOME/.bashrc" +export NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node +echo " 镜像源已配置: NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node" + +# 4. 安装 Node.js LTS 版 +echo "[4/4] 正在安装 Node.js LTS 版本(稳定版)..." +echo " 下载源: ${NVM_NODEJS_ORG_MIRROR}" +# nvm install 必须以原始用户身份执行,否则 $NVM_DIR 不匹配 +sudo -u "$REAL_USER" bash -c " +export NVM_DIR='$REAL_HOME/.nvm' +[ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" +export NVM_NODEJS_ORG_MIRROR='$NVM_NODEJS_ORG_MIRROR' +nvm install $NODE_LTS_VERSION +" + +# 验证安装结果 +echo "" +echo "========================================" +echo " 安装完成!验证信息如下:" +echo "========================================" + +# 以原始用户身份验证 +sudo -u "$REAL_USER" bash -c " +export NVM_DIR='$REAL_HOME/.nvm' +[ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" +if command -v nvm &> /dev/null; then + echo ' nvm 版本 : \$(nvm --version)' +else + echo ' nvm 版本 : n/a' +fi +if command -v node &> /dev/null; then + echo ' node 版本: \$(node -v)' +else + echo ' node 版本: n/a' +fi +if command -v npm &> /dev/null; then + echo ' npm 版本 : \$(npm -v)' +else + echo ' npm 版本 : n/a' +fi +echo '' +echo ' 当前默认使用的 Node 版本:' +nvm ls 2>/dev/null || echo ' nvm ls 不可用' +" + +echo "" +echo "========================================" +echo " 提示:重新登录终端或执行:" +echo " source ~/.bashrc" +echo " 然后: nvm --version" +echo "========================================" diff --git a/resources/deploy_uv.sh b/resources/deploy_uv.sh new file mode 100644 index 0000000..4dc7daf --- /dev/null +++ b/resources/deploy_uv.sh @@ -0,0 +1,151 @@ +#!/bin/bash +# ============================================================= +# deploy_uv.sh - uv (Python 包管理工具) 一键安装与配置脚本 +# ============================================================= +# 适用系统: Ubuntu 20.04+ +# 功能: 安装 uv、配置国内镜像源加速 +# 说明: 先将安装脚本下载到本地再执行,避免管道问题; +# 如果官方源慢,自动切 GitHub Release 镜像。 +# ============================================================= + +set -e + +echo "========================================" +echo " 开始部署 uv - Python 包管理工具" +echo "========================================" + +# ---------- 1. 安装 uv ---------- +install_uv() { + echo "[1/4] 正在安装 uv..." + + # 先将安装脚本下载到 /tmp(而非管道直接执行) + echo " 正在下载安装脚本..." + + # 尝试从官方源下载(带超时) + if timeout 30 curl -fsSL https://astral.sh/uv/install.sh -o /tmp/uv-install.sh 2>/dev/null; then + echo " 下载源: https://astral.sh/uv/install.sh" + else + # 官方源超时,换 GitHub Release(国内 CDN 更快) + echo " 官方源下载超时,尝试 GitHub Release..." + UV_VERSION=$(curl -fsSL https://api.github.com/repos/astral-sh/uv/releases/latest 2>/dev/null | grep '"tag_name"' | cut -d'"' -f4) + if [ -z "$UV_VERSION" ]; then + UV_VERSION="0.6.14" # fallback 固定版本 + fi + echo " 正在下载 uv ${UV_VERSION} 预编译包..." + curl -fsSL "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${UV_VERSION}-x86_64-unknown-linux-gnu.tar.gz" -o /tmp/uv.tar.gz + # 解压并安装 + mkdir -p /tmp/uv-extract + tar xzf /tmp/uv.tar.gz -C /tmp/uv-extract + cp /tmp/uv-extract/uv-${UV_VERSION}-x86_64-unknown-linux-gnu/uv /tmp/uv-install-dir/ 2>/dev/null || true + # 直接复制到 local/bin + mkdir -p "$HOME/.local/bin" + cp /tmp/uv-extract/uv-${UV_VERSION}-x86_64-unknown-linux-gnu/uv "$HOME/.local/bin/uv" + chmod +x "$HOME/.local/bin/uv" + ln -sf "$HOME/.local/bin/uv" "$HOME/.local/bin/uvx" 2>/dev/null || true + rm -rf /tmp/uv.tar.gz /tmp/uv-extract + echo "✅ uv 安装成功(来自 GitHub Release)!" + return 0 + fi + + # 执行安装脚本 + echo " 正在执行安装..." + bash /tmp/uv-install.sh + rm -f /tmp/uv-install.sh + + if [ $? -ne 0 ]; then + echo "❌ uv 安装失败,请检查网络连接后重试。" + exit 1 + fi + + echo "✅ uv 安装成功!" +} + +# ---------- 2. 配置环境变量 ---------- +setup_env() { + echo "[2/4] 正在配置环境变量..." + + # 检测 shell 并添加 uv 到 PATH + SHELL_CONFIG="" + + if [ -n "$BASH_VERSION" ]; then + SHELL_CONFIG="$HOME/.bashrc" + elif [ -n "$ZSH_VERSION" ]; then + SHELL_CONFIG="$HOME/.zshrc" + else + SHELL_CONFIG="$HOME/.profile" + fi + + # 如果 PATH 中还没有 uv 路径,则添加 + if ! echo "$PATH" | grep -q "$HOME/.local/bin"; then + echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$SHELL_CONFIG" + echo "已添加到 $SHELL_CONFIG" + else + echo "$HOME/.local/bin 已在 PATH 中" + fi + + # 临时生效 + export PATH="$HOME/.local/bin:$PATH" + + echo "✅ 环境变量配置完成!" +} + +# ---------- 3. 创建 uv 配置文件(换源) ---------- +config_mirror() { + echo "[3/4] 正在配置国内镜像源(加速下载)..." + + CONFIG_DIR="$HOME/.config/uv" + CONFIG_FILE="$CONFIG_DIR/uv.toml" + + mkdir -p "$CONFIG_DIR" + + cat > "$CONFIG_FILE" << 'EOF' +# uv 全局配置 - 使用国内镜像源加速 + +# Python 版本下载镜像源(提高 `uv python install` 速度) +python-install-mirror = "https://registry.npmmirror.com/-/binary/python-build-standalone/" + +# pip 包下载镜像源(提高 `uv pip install` 速度) +[[index]] +url = "https://mirrors.aliyun.com/pypi/simple/" +default = true +EOF + + echo "配置文件已创建: $CONFIG_FILE" + echo "✅ 镜像源配置完成!" +} + +# ---------- 4. 验证安装 ---------- +verify_install() { + echo "[4/4] 正在验证安装..." + + if command -v uv &> /dev/null; then + UV_VER=$(uv --version) + echo "✅ uv 版本: $UV_VER" + else + echo "❌ uv 命令未找到,请重启终端或执行: source ~/.bashrc" + exit 1 + fi + + if command -v uvx &> /dev/null; then + echo "✅ uvx 已安装(用于运行工具包)" + fi + + echo "" + echo "========================================" + echo " 🎉 uv 部署完成!" + echo "========================================" + echo "" + echo "快速上手:" + echo " uv python install 3.12 # 安装 Python 3.12" + echo " uv venv # 创建虚拟环境" + echo " uv pip install requests # 安装包" + echo " uv run main.py # 运行脚本" + echo "" + echo "重启终端或执行: source $SHELL_CONFIG" +} + +# ---------- 主流程 ---------- +install_uv +setup_env +config_mirror +verify_install diff --git a/scripts/add_script_runner.py b/scripts/add_script_runner.py new file mode 100644 index 0000000..0b0599c --- /dev/null +++ b/scripts/add_script_runner.py @@ -0,0 +1,281 @@ +import os + +gui_path = 'src/virtualbox_client/gui.py' +with open(gui_path, 'r', encoding='utf-8') as f: + content = f.read() + +# ===== 1. 添加 ScriptWorker 类(放在 DownloadWorker 之后) ===== +old = """class WorkspaceWorker(QObject):""" + +new = """class ScriptWorker(QObject): + """上传本地脚本到虚拟机、执行并下载日志""" + log_signal = Signal(str, str) + finished_signal = Signal(bool) + error_signal = Signal(str) + + def __init__(self, config: dict, local_path: str): + super().__init__() + self.config = config + self.local_path = local_path + self.cancel_event = threading.Event() + + def cancel(self): + self.cancel_event.set() + + def run(self): + ssh_only = self.config.get("ssh_only", False) + _com_initialized = False + if not ssh_only: + try: + import ctypes + ctypes.windll.ole32.CoInitializeEx(None, 0) + _com_initialized = True + except Exception: + pass + + try: + if ssh_only: + from virtualbox_client.utils.ssh_manager import SSHManager + vm = SSHManager( + self.config["ssh_host"], self.config["ssh_port"], + self.config["ssh_user"], self.config["ssh_pass"], + ) + else: + from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager + vm = VBoxSSHManager( + self.config["vm_name"], self.config["ssh_host"], + self.config["ssh_port"], self.config["ssh_user"], + self.config["ssh_pass"], + ) + vm.set_cancel_event(self.cancel_event) + + if self.cancel_event.is_set(): + return + + import builtins + original_print = print + def custom_print(*args, **kwargs): + msg = " ".join(str(a) for a in args) + self.log_signal.emit(msg, "info") + original_print(*args, **kwargs) + builtins.print = custom_print + + try: + script_name = os.path.basename(self.local_path) + remote_path = f"/tmp/{script_name}" + log_path = f"/tmp/{script_name}.log" + local_log_path = f"{script_name}.log" + + # 1. 上传脚本 + self.log_signal.emit(f"📤 [1/3] 上传脚本: {self.local_path} -> {remote_path}", "info") + if not vm.upload_file(self.local_path, remote_path): + self.log_signal.emit("❌ 上传失败", "error") + return + + # 2. 执行脚本 + self.log_signal.emit(f"⚡ [2/3] 执行脚本: {remote_path}", "info") + cmd = ( + f"chmod +x {remote_path} && " + f"echo '{self.config['ssh_pass']}' | sudo -S {remote_path} 2>&1 | " + f"tee {log_path}" + ) + out, err, code = vm.exec_command(cmd, get_pty=False) + self.log_signal.emit(f"✔ 脚本执行完成,退出码: {code}", "info") + + # 3. 下载日志 + self.log_signal.emit(f"📥 [3/3] 下载日志: {log_path} -> {local_log_path}", "info") + if vm.download_file(log_path, local_log_path): + self.log_signal.emit(f"✔ 日志已保存到本地: {local_log_path}", "success") + else: + self.log_signal.emit("⚠ tee 日志下载失败,尝试保存终端输出", "warning") + if out: + with open(local_log_path, "w") as f: + f.write(out) + self.log_signal.emit(f"✔ 终端输出已保存到: {local_log_path}", "success") + + # 4. 清理远程临时文件 + vm.exec_command(f'rm -f "{remote_path}" "{log_path}"') + self.log_signal.emit("🧹 远程临时文件已清理", "info") + + self.log_signal.emit("🎉 脚本执行全部完成!", "success") + self.finished_signal.emit(True) + finally: + builtins.print = original_print + + except ImportError as e: + self.log_signal.emit(f"❌ 缺少依赖: {e}", "error") + self.error_signal.emit(str(e)) + except Exception as e: + self.log_signal.emit(f"❌ 脚本执行错误: {str(e)}", "error") + self.error_signal.emit(str(e)) + finally: + if _com_initialized: + try: + import ctypes + ctypes.windll.ole32.CoUninitialize() + except Exception: + pass + + +class WorkspaceWorker(QObject):""" + +content = content.replace(old, new) + +# ===== 2. 在 TaskPanel.setup_ui 的文件传输 tab 后面添加脚本执行 tab ===== +old_file_tab_end = """ file_layout.addStretch() + self.tab_widget.addTab(file_tab, "📁 文件传输")""" + +new_file_tab_end = """ file_layout.addStretch() + self.tab_widget.addTab(file_tab, "📁 文件传输") + + # ====== 脚本执行 Tab ====== + script_tab = QWidget() + script_layout = QVBoxLayout(script_tab) + script_layout.setContentsMargins(8, 8, 8, 8) + + script_group = QGroupBox("⚡ 上传并执行脚本") + script_group_layout = QVBoxLayout(script_group) + + script_path_layout = QHBoxLayout() + self.script_path_edit = QLineEdit(placeholderText="选择本地 .sh 脚本文件...") + script_path_layout.addWidget(self.script_path_edit) + self.script_browse_btn = QPushButton("📂 浏览") + self.script_browse_btn.setFixedWidth(80) + self.script_browse_btn.clicked.connect(self._browse_script) + script_path_layout.addWidget(self.script_browse_btn) + script_group_layout.addWidget(QLabel("本地脚本路径:")) + script_group_layout.addLayout(script_path_layout) + + self.run_script_btn = QPushButton("⚡ 上传并执行脚本") + self.run_script_btn.setStyleSheet(\""" + QPushButton { background-color: #8e44ad; color: white; padding: 10px 30px; + font-size: 14px; font-weight: bold; border: none; border-radius: 6px; } + QPushButton:hover { background-color: #9b59b6; } + QPushButton:disabled { background-color: #555; color: #999; } + \""") + self.run_script_btn.clicked.connect(self._run_script) + script_group_layout.addWidget(self.run_script_btn) + + script_layout.addWidget(script_group) + + # 执行说明 + desc_group = QGroupBox("📖 说明") + desc_layout = QVBoxLayout(desc_group) + desc_label = QLabel( + "1. 选择一个本地 .sh 脚本文件\\n" + "2. 点击按钮自动完成:\\n" + " 📤 上传脚本到虚拟机 /tmp/ 目录\\n" + " ⚡ 以 sudo 权限执行脚本\\n" + " 📥 下载执行日志到本地\\n" + " 🧹 清理远程临时文件" + ) + desc_label.setStyleSheet("color: #aaa; font-size: 12px; line-height: 1.6;") + desc_layout.addWidget(desc_label) + script_layout.addWidget(desc_group) + + script_layout.addStretch() + self.tab_widget.addTab(script_tab, "⚡ 脚本执行")""" + +content = content.replace(old_file_tab_end, new_file_tab_end) + +# ===== 3. 在 TaskPanel 的 _set_ui_running 中禁用脚本按钮 ===== +old_running = """ def _set_ui_running(self, running: bool): + self.run_btn.setEnabled(not running) + self.cancel_btn.setEnabled(running) + self.task_input.setEnabled(not running) + if self._config_panel: + self._config_panel.set_enabled(not running)""" + +new_running = """ def _set_ui_running(self, running: bool): + self.run_btn.setEnabled(not running) + self.cancel_btn.setEnabled(running) + self.task_input.setEnabled(not running) + self.run_script_btn.setEnabled(not running) + self.script_browse_btn.setEnabled(not running) + self.script_path_edit.setEnabled(not running) + if self._config_panel: + self._config_panel.set_enabled(not running)""" + +content = content.replace(old_running, new_running) + +# ===== 4. 在 TaskPanel 中添加 _browse_script 和 _run_script 方法 ===== +# 放在 _on_download_error 之后、class MainWindow 之前 +old_mainwindow = """class MainWindow(QMainWindow):""" + +new_methods = """ _script_thread = None + _script_worker = None + + def _cleanup_script(self): + if self._script_thread: + try: + self._script_thread.finished.disconnect() + except (TypeError, RuntimeError): + pass + if self._script_thread.isRunning(): + self._script_thread.quit() + if not self._script_thread.wait(5000): + self._script_thread.terminate() + self._script_thread.wait(3000) + self._script_thread = None + if self._script_worker: + try: + self._script_worker.log_signal.disconnect() + self._script_worker.finished_signal.disconnect() + self._script_worker.error_signal.disconnect() + except (TypeError, RuntimeError): + pass + self._script_worker = None + + def _browse_script(self): + file_path, _ = QFileDialog.getOpenFileName( + self, "选择要执行的脚本", "", "Shell 脚本 (*.sh);;所有文件 (*)" + ) + if file_path: + self.script_path_edit.setText(file_path) + + def _run_script(self): + if not self._config_panel or not self._config_panel.validate(): + return + local_path = self.script_path_edit.text().strip() + if not local_path: + QMessageBox.warning(self, "提示", "请选择要执行的脚本文件") + return + if not os.path.isfile(local_path): + QMessageBox.warning(self, "提示", f"本地脚本不存在: {local_path}") + return + + self._cleanup_script() + self._set_ui_running(True) + self.tab_widget.setCurrentIndex(0) # 切换到任务日志 tab + self.log_panel.clear_log() + + config = self._config_panel.get_config() + self._script_worker = ScriptWorker(config, local_path) + self._script_thread = QThread(self) + self._script_worker.moveToThread(self._script_thread) + + self._script_worker.log_signal.connect(self.log_panel.append_log) + self._script_worker.finished_signal.connect(self._on_script_finished) + self._script_worker.error_signal.connect(self._on_script_error) + self._script_thread.started.connect(self._script_worker.run) + self._script_thread.finished.connect(self._cleanup_script) + + self._script_thread.start() + + def _on_script_finished(self, success: bool): + self._set_ui_running(False) + if success: + self.log_panel.append_log("✅ 脚本执行已完成", "success") + + def _on_script_error(self, error_msg: str): + self._set_ui_running(False) + self.log_panel.append_log(f"❌ 脚本执行失败: {error_msg}", "error") + + +""" + old_mainwindow + +content = content.replace(old_mainwindow, new_methods) + +with open(gui_path, 'w', encoding='utf-8') as f: + f.write(content) +print('✅ gui.py 已集成脚本执行功能') diff --git a/scripts/add_script_tab.py b/scripts/add_script_tab.py new file mode 100644 index 0000000..953808e --- /dev/null +++ b/scripts/add_script_tab.py @@ -0,0 +1,275 @@ +# This script adds ScriptWorker and a script execution tab to gui.py +import os + +gui_path = "src/virtualbox_client/gui.py" +with open(gui_path, "r", encoding="utf-8") as f: + content = f.read() + +# 1. Add ScriptWorker before WorkspaceWorker +old_worker = "class WorkspaceWorker(QObject):" + +new_worker = """class ScriptWorker(QObject): + log_signal = Signal(str, str) + finished_signal = Signal(bool) + error_signal = Signal(str) + + def __init__(self, config: dict, local_path: str): + super().__init__() + self.config = config + self.local_path = local_path + self.cancel_event = threading.Event() + + def cancel(self): + self.cancel_event.set() + + def run(self): + ssh_only = self.config.get("ssh_only", False) + _com_initialized = False + if not ssh_only: + try: + import ctypes + ctypes.windll.ole32.CoInitializeEx(None, 0) + _com_initialized = True + except Exception: + pass + + try: + if ssh_only: + from virtualbox_client.utils.ssh_manager import SSHManager + vm = SSHManager( + self.config["ssh_host"], self.config["ssh_port"], + self.config["ssh_user"], self.config["ssh_pass"], + ) + else: + from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager + vm = VBoxSSHManager( + self.config["vm_name"], self.config["ssh_host"], + self.config["ssh_port"], self.config["ssh_user"], + self.config["ssh_pass"], + ) + vm.set_cancel_event(self.cancel_event) + + if self.cancel_event.is_set(): + return + + import builtins + original_print = print + def custom_print(*args, **kwargs): + msg = " ".join(str(a) for a in args) + self.log_signal.emit(msg, "info") + original_print(*args, **kwargs) + builtins.print = custom_print + + try: + import os as _os + script_name = _os.path.basename(self.local_path) + remote_path = "/tmp/" + script_name + log_path = "/tmp/" + script_name + ".log" + local_log_path = script_name + ".log" + + self.log_signal.emit("[1/3] Upload: " + self.local_path + " -> " + remote_path, "info") + if not vm.upload_file(self.local_path, remote_path): + self.log_signal.emit("Upload failed", "error") + return + + self.log_signal.emit("[2/3] Execute: " + remote_path, "info") + cmd = ( + "chmod +x " + remote_path + " && " + "echo '" + self.config["ssh_pass"] + "' | sudo -S " + remote_path + " 2>&1 | " + "tee " + log_path + ) + out, err, code = vm.exec_command(cmd, get_pty=False) + self.log_signal.emit("Exit code: " + str(code), "info") + + self.log_signal.emit("[3/3] Download log: " + log_path + " -> " + local_log_path, "info") + if vm.download_file(log_path, local_log_path): + self.log_signal.emit("Log saved: " + local_log_path, "success") + else: + self.log_signal.emit("Tee log download failed, saving terminal output", "warning") + if out: + with open(local_log_path, "w") as f: + f.write(out) + self.log_signal.emit("Output saved: " + local_log_path, "success") + + vm.exec_command("rm -f \\"" + remote_path + "\\" \\"" + log_path + "\\"") + self.log_signal.emit("Remote temp files cleaned", "info") + self.log_signal.emit("Script execution complete!", "success") + self.finished_signal.emit(True) + finally: + builtins.print = original_print + + except ImportError as e: + self.log_signal.emit("Missing dependency: " + str(e), "error") + self.error_signal.emit(str(e)) + except Exception as e: + self.log_signal.emit("Script error: " + str(e), "error") + self.error_signal.emit(str(e)) + finally: + if _com_initialized: + try: + import ctypes + ctypes.windll.ole32.CoUninitialize() + except Exception: + pass + + +""" + old_worker + +content = content.replace(old_worker, new_worker) + +# 2. Add script tab after file transfer tab +old_tab_end = ' file_layout.addStretch()\n self.tab_widget.addTab(file_tab, "\U0001f4c1 \u6587\u4ef6\u4f20\u8f93")' + +new_tab_end = ''' file_layout.addStretch() + self.tab_widget.addTab(file_tab, "\U0001f4c1 \u6587\u4ef6\u4f20\u8f93") + + # ====== Script Execution Tab ====== + script_tab = QWidget() + script_layout = QVBoxLayout(script_tab) + script_layout.setContentsMargins(8, 8, 8, 8) + + script_group = QGroupBox("Upload and Execute Script") + script_group_layout = QVBoxLayout(script_group) + + script_path_layout = QHBoxLayout() + self.script_path_edit = QLineEdit(placeholderText="Select local .sh script file...") + script_path_layout.addWidget(self.script_path_edit) + self.script_browse_btn = QPushButton("Browse") + self.script_browse_btn.setFixedWidth(80) + self.script_browse_btn.clicked.connect(self._browse_script) + script_path_layout.addWidget(self.script_browse_btn) + script_group_layout.addWidget(QLabel("Local Script:")) + script_group_layout.addLayout(script_path_layout) + + self.run_script_btn = QPushButton("Upload and Execute Script") + self.run_script_btn.setStyleSheet(""" + QPushButton { background-color: #8e44ad; color: white; padding: 10px 30px; + font-size: 14px; font-weight: bold; border: none; border-radius: 6px; } + QPushButton:hover { background-color: #9b59b6; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.run_script_btn.clicked.connect(self._run_script) + script_group_layout.addWidget(self.run_script_btn) + + script_layout.addWidget(script_group) + + desc_group = QGroupBox("How it works") + desc_layout = QVBoxLayout(desc_group) + desc_label = QLabel( + "1. Select a local .sh script file\\n" + "2. Click button to auto:\\n" + " Upload script to /tmp/\\n" + " Execute with sudo\\n" + " Download execution log\\n" + " Clean remote temp files" + ) + desc_label.setStyleSheet("color: #aaa; font-size: 12px; line-height: 1.6;") + desc_layout.addWidget(desc_label) + script_layout.addWidget(desc_group) + + script_layout.addStretch() + self.tab_widget.addTab(script_tab, "Script")''' + +content = content.replace(old_tab_end, new_tab_end) + +# 3. Update _set_ui_running +old_running = """ def _set_ui_running(self, running: bool): + self.run_btn.setEnabled(not running) + self.cancel_btn.setEnabled(running) + self.task_input.setEnabled(not running) + if self._config_panel: + self._config_panel.set_enabled(not running)""" + +new_running = """ def _set_ui_running(self, running: bool): + self.run_btn.setEnabled(not running) + self.cancel_btn.setEnabled(running) + self.task_input.setEnabled(not running) + self.run_script_btn.setEnabled(not running) + self.script_browse_btn.setEnabled(not running) + self.script_path_edit.setEnabled(not running) + if self._config_panel: + self._config_panel.set_enabled(not running)""" + +content = content.replace(old_running, new_running) + +# 4. Add methods before MainWindow +old_mw = "class MainWindow(QMainWindow):" + +new_mw = """ _script_thread = None + _script_worker = None + + def _cleanup_script(self): + if self._script_thread: + try: + self._script_thread.finished.disconnect() + except (TypeError, RuntimeError): + pass + if self._script_thread.isRunning(): + self._script_thread.quit() + if not self._script_thread.wait(5000): + self._script_thread.terminate() + self._script_thread.wait(3000) + self._script_thread = None + if self._script_worker: + try: + self._script_worker.log_signal.disconnect() + self._script_worker.finished_signal.disconnect() + self._script_worker.error_signal.disconnect() + except (TypeError, RuntimeError): + pass + self._script_worker = None + + def _browse_script(self): + file_path, _ = QFileDialog.getOpenFileName( + self, "Select script", "", "Shell scripts (*.sh);;All files (*)" + ) + if file_path: + self.script_path_edit.setText(file_path) + + def _run_script(self): + if not self._config_panel or not self._config_panel.validate(): + return + local_path = self.script_path_edit.text().strip() + if not local_path: + QMessageBox.warning(self, "Hint", "Please select a script file") + return + if not os.path.isfile(local_path): + QMessageBox.warning(self, "Hint", "File not found: " + local_path) + return + + self._cleanup_script() + self._set_ui_running(True) + self.tab_widget.setCurrentIndex(0) + self.log_panel.clear_log() + + config = self._config_panel.get_config() + self._script_worker = ScriptWorker(config, local_path) + self._script_thread = QThread(self) + self._script_worker.moveToThread(self._script_thread) + + self._script_worker.log_signal.connect(self.log_panel.append_log) + self._script_worker.finished_signal.connect(self._on_script_finished) + self._script_worker.error_signal.connect(self._on_script_error) + self._script_thread.started.connect(self._script_worker.run) + self._script_thread.finished.connect(self._cleanup_script) + + self._script_thread.start() + + def _on_script_finished(self, success: bool): + self._set_ui_running(False) + if success: + self.log_panel.append_log("Script execution completed", "success") + + def _on_script_error(self, error_msg: str): + self._set_ui_running(False) + self.log_panel.append_log("Script error: " + error_msg, "error") + + +""" + old_mw + +content = content.replace(old_mw, new_mw) + +with open(gui_path, "w", encoding="utf-8") as f: + f.write(content) + +print("Done") diff --git a/scripts/check_syntax.py b/scripts/check_syntax.py new file mode 100644 index 0000000..9be9a33 --- /dev/null +++ b/scripts/check_syntax.py @@ -0,0 +1,18 @@ +import ast +import sys + +files = [ + "src/virtualbox_client/gui.py", + "src/virtualbox_client/utils/vbox_ssh_manager.py", + "src/virtualbox_client/utils/vm_agent.py", +] + +for f in files: + try: + tree = ast.parse(open(f, encoding="utf-8").read()) + print(f"OK: {f.split(chr(47))[-1]} ({len(tree.body)} nodes)") + except SyntaxError as e: + print(f"FAIL: {f.split(chr(47))[-1]}: {e}") + sys.exit(1) + +print("All files pass syntax check") diff --git a/scripts/clean_pycache.py b/scripts/clean_pycache.py new file mode 100644 index 0000000..d402b80 --- /dev/null +++ b/scripts/clean_pycache.py @@ -0,0 +1,13 @@ +import os +import shutil + +# 需要遍历的目录 +root_dir = "./" +# 遍历目录 +for dirpath, dirnames, filenames in os.walk(root_dir): + if "__pycache__" in dirnames: + # 获取 __pycache__ 目录的全路径 + pycache_dir = os.path.join(dirpath, "__pycache__") + # 删除目录 + shutil.rmtree(pycache_dir) + print(f"Removed: {pycache_dir}") diff --git a/scripts/code_content.py b/scripts/code_content.py new file mode 100644 index 0000000..a241f36 --- /dev/null +++ b/scripts/code_content.py @@ -0,0 +1,45 @@ +import os +import sys + + +def generate_markdown_from_py_files(directory, output_file): + with open(output_file, "w", encoding="utf-8") as md_file: + for root, dirs, files in os.walk(directory): + # 排除 venv 目录 + dirs[:] = [d for d in dirs if d != ".venv"] + dirs[:] = [d for d in dirs if d != ".vscode"] + dirs[:] = [d for d in dirs if d != "node_modules"] + dirs[:] = [d for d in dirs if d != "build"] + dirs[:] = [d for d in dirs if d != "dist"] + dirs[:] = [d for d in dirs if d != ".sdk"] + for file in files: + if ( + file.endswith(".py") + or file.endswith(".rs") + or file.endswith(".toml") + or file.endswith(".md") + or file.endswith(".css") + or file.endswith(".vue") + or file.endswith(".ts") + or file.endswith(".json") + or file.endswith(".html") + or file.endswith(".sh") + or file == "Dockerfile" + ): + file_path = os.path.join(root, file) + if file == "output.md" or file == "package-lock.json": + continue + md_file.write(f"`{file_path}`\n") + md_file.write("```\n") + with open(file_path, "r", encoding="utf-8") as py_file: + md_file.write(py_file.read()) + md_file.write("\n```\n\n") + + +if __name__ == "__main__": + # 指定目录和输出文件名 + target_directory = sys.argv[1] # 替换为你的目标目录 + output_markdown_file = "output.md" # 输出的 Markdown 文件名 + + generate_markdown_from_py_files(target_directory, output_markdown_file) + print(f"Markdown 文件已生成:{output_markdown_file}") diff --git a/scripts/fix_paths.py b/scripts/fix_paths.py new file mode 100644 index 0000000..598a829 --- /dev/null +++ b/scripts/fix_paths.py @@ -0,0 +1,30 @@ +content = open("src/virtualbox_client/gui.py", encoding="utf-8").read() + +# 1. 修复 except 语法 +content = content.replace( + "except TypeError, RuntimeError:", "except (TypeError, RuntimeError):" +) + +# 2. 修复 _rename_item 中的路径分隔符 +old = """ dst_dir = os.path.dirname(src_path) + dst_path = os.path.join(dst_dir, new_name.strip())""" +new = """ dst_dir = src_path.rsplit('/', 1)[0] if '/' in src_path else src_path.rsplit('\\\\', 1)[0] + dst_path = dst_dir.rstrip('/') + '/' + new_name.strip()""" +content = content.replace(old, new) + +# 3. 修复 _copy_item 中的路径分隔符 +old2 = """ dst_dir = os.path.dirname(src_path) + dst_path = os.path.join(dst_dir, new_name)""" +new2 = """ dst_dir = src_path.rsplit('/', 1)[0] if '/' in src_path else src_path.rsplit('\\\\', 1)[0] + dst_path = dst_dir.rstrip('/') + '/' + new_name""" +content = content.replace(old2, new2) + +# 4. 修复 _upload_file 中的 remote_dir 路径 +old3 = """ remote_dir = os.path.dirname(remote_path) + vm.exec_command(f'mkdir -p "{remote_dir}"')""" +new3 = """ remote_dir = remote_path.rsplit('/', 1)[0] if '/' in remote_path else remote_path.rsplit('\\\\', 1)[0] + vm.exec_command(f'mkdir -p "{remote_dir}"')""" +content = content.replace(old3, new3) + +open("src/virtualbox_client/gui.py", "w", encoding="utf-8").write(content) +print("✅ 所有修复完成") diff --git a/scripts/fix_upload_folder.py b/scripts/fix_upload_folder.py new file mode 100644 index 0000000..f5fecf7 --- /dev/null +++ b/scripts/fix_upload_folder.py @@ -0,0 +1,226 @@ +import os + +# 1. 给 SSHManager 添加 upload_directory 方法 +ssh_path = "src/virtualbox_client/utils/ssh_manager.py" +with open(ssh_path, "r", encoding="utf-8") as f: + content = f.read() + +old_method = """ def upload_file(self, local_path, remote_path): + \"\"\"上传本地文件到远程服务器,返回是否成功\"\"\" + client = self.get_ssh_client() + if not client: + return False + try: + sftp = client.open_sftp() + sftp.put(local_path, remote_path) + sftp.close() + print(f\"✔ 上传成功: {local_path} -> {remote_path}\") + return True + except Exception as e: + if self._cancelled.is_set(): + return False + print(f\"✘ 上传失败: {e}\") + return False +""" + +new_method = """ def upload_file(self, local_path, remote_path): + \"\"\"上传本地文件到远程服务器,返回是否成功\"\"\" + client = self.get_ssh_client() + if not client: + return False + try: + sftp = client.open_sftp() + sftp.put(local_path, remote_path) + sftp.close() + print(f\"✔ 上传成功: {local_path} -> {remote_path}\") + return True + except Exception as e: + if self._cancelled.is_set(): + return False + print(f\"✘ 上传失败: {e}\") + return False + + def upload_directory(self, local_dir, remote_dir): + \"\"\"递归上传整个文件夹到远程服务器,返回是否成功\"\"\" + import os as _os + client = self.get_ssh_client() + if not client: + return False + try: + sftp = client.open_sftp() + local_dir = _os.path.normpath(local_dir) + remote_dir = remote_dir.rstrip('/') + # 创建远程根目录 + self.exec_command(f'mkdir -p \"{remote_dir}\"') + for root, dirs, files in _os.walk(local_dir): + # 计算相对路径 + rel_path = _os.path.relpath(root, local_dir) + if rel_path == '.': + rel_path = '' + # 创建远程子目录 + target_dir = remote_dir + ('/' + rel_path.replace('\\\\', '/') if rel_path else '') + self.exec_command(f'mkdir -p \"{target_dir}\"') + for fname in files: + if self._cancelled.is_set(): + sftp.close() + return False + local_file = _os.path.join(root, fname) + remote_file = target_dir + '/' + fname + sftp.put(local_file, remote_file) + print(f\" ✔ {local_file} -> {remote_file}\") + sftp.close() + print(f\"✔ 文件夹上传成功: {local_dir} -> {remote_dir}\") + return True + except Exception as e: + if self._cancelled.is_set(): + return False + print(f\"✘ 文件夹上传失败: {e}\") + return False +""" + +content = content.replace(old_method, new_method) +with open(ssh_path, "w", encoding="utf-8") as f: + f.write(content) +print("✅ SSHManager 已添加 upload_directory") + +# 2. 给 VBoxSSHManager 添加 upload_directory 方法 +vbox_path = "src/virtualbox_client/utils/vbox_ssh_manager.py" +with open(vbox_path, "r", encoding="utf-8") as f: + content = f.read() + +content = content.replace(old_method, new_method) +with open(vbox_path, "w", encoding="utf-8") as f: + f.write(content) +print("✅ VBoxSSHManager 已添加 upload_directory") + +# 3. 修改 gui.py - 添加上传文件夹功能 +gui_path = "src/virtualbox_client/gui.py" +with open(gui_path, "r", encoding="utf-8") as f: + content = f.read() + +# 在 WorkspaceWorker 中添加 OPERATION_UPLOAD_DIR +old_op = ' OPERATION_UPLOAD = "upload"\n' +new_op = ' OPERATION_UPLOAD = "upload"\n OPERATION_UPLOAD_DIR = "upload_dir"\n' +content = content.replace(old_op, new_op) + +# 在 run 方法中添加分支 +old_run = """ elif self.operation == WorkspaceWorker.OPERATION_UPLOAD: + result = self._upload_file(vm)""" +new_run = """ elif self.operation == WorkspaceWorker.OPERATION_UPLOAD: + result = self._upload_file(vm) + elif self.operation == WorkspaceWorker.OPERATION_UPLOAD_DIR: + result = self._upload_directory(vm)""" +content = content.replace(old_run, new_run) + +# 在 _upload_file 之后添加 _upload_directory 方法 +old_upload = """ def _upload_file(self, vm) -> bool: + local_path = self.kwargs.get("local_path", "") + remote_path = self.kwargs.get("remote_path", "") + if not local_path or not remote_path: + return False + remote_dir = remote_path.rsplit('/', 1)[0] if '/' in remote_path else remote_path.rsplit('\\\\', 1)[0] + vm.exec_command(f'mkdir -p "{remote_dir}"') + return vm.upload_file(local_path, remote_path)""" + +new_upload = """ def _upload_file(self, vm) -> bool: + local_path = self.kwargs.get("local_path", "") + remote_path = self.kwargs.get("remote_path", "") + if not local_path or not remote_path: + return False + remote_dir = remote_path.rsplit('/', 1)[0] if '/' in remote_path else remote_path.rsplit('\\\\', 1)[0] + vm.exec_command(f'mkdir -p "{remote_dir}"') + return vm.upload_file(local_path, remote_path) + + def _upload_directory(self, vm) -> bool: + local_dir = self.kwargs.get("local_path", "") + remote_dir = self.kwargs.get("remote_path", "") + if not local_dir or not remote_dir: + return False + return vm.upload_directory(local_dir, remote_dir)""" + +content = content.replace(old_upload, new_upload) + +# 修改 _on_worker_finished 添加 UPLOAD_DIR 处理 +old_finish = """ elif ( + self._worker and self._worker.operation == WorkspaceWorker.OPERATION_UPLOAD + ): + if result: + self.log_panel.append_log("✅ 上传成功", "success") + else: + self.log_panel.append_log("❌ 上传失败", "error") + QTimer.singleShot(500, self._soft_refresh)""" + +new_finish = """ elif ( + self._worker + and self._worker.operation + in (WorkspaceWorker.OPERATION_UPLOAD, WorkspaceWorker.OPERATION_UPLOAD_DIR) + ): + if result: + self.log_panel.append_log("✅ 上传成功", "success") + else: + self.log_panel.append_log("❌ 上传失败", "error") + QTimer.singleShot(500, self._soft_refresh)""" + +content = content.replace(old_finish, new_finish) + +# 在 WorkspacePanel.setup_ui 的工具栏中添加"上传文件夹"按钮 +old_upload_btn = """ self.upload_btn = QPushButton("📤 上传文件")""" +new_upload_btn = """ self.upload_btn = QPushButton("📤 上传文件")""" +# 保持原样,在后面添加新按钮 +# 在 upload_btn 后面添加 upload_dir_btn +old_toolbar = """ toolbar.addWidget(self.upload_btn) + + self.mkdir_btn = QPushButton("📂 新建文件夹")""" +new_toolbar = """ toolbar.addWidget(self.upload_btn) + + self.upload_dir_btn = QPushButton("📁 上传文件夹") + self.upload_dir_btn.setStyleSheet(\"\"\" + QPushButton { background-color: #16a085; color: white; padding: 6px 14px; + font-size: 12px; font-weight: bold; border: none; border-radius: 4px; } + QPushButton:hover { background-color: #1abc9c; } + QPushButton:disabled { background-color: #555; color: #999; } + \"\"\") + self.upload_dir_btn.clicked.connect(self._upload_folder_to_workspace) + toolbar.addWidget(self.upload_dir_btn) + + self.mkdir_btn = QPushButton("📂 新建文件夹")""" +content = content.replace(old_toolbar, new_toolbar) + +# 更新 _set_busy 禁用 upload_dir_btn +old_set_busy = """ self.upload_btn.setEnabled(not busy) + self.mkdir_btn.setEnabled(not busy) + self.home_btn.setEnabled(not busy)""" +new_set_busy = """ self.upload_btn.setEnabled(not busy) + self.upload_dir_btn.setEnabled(not busy) + self.mkdir_btn.setEnabled(not busy) + self.home_btn.setEnabled(not busy)""" +content = content.replace(old_set_busy, new_set_busy) + +# 在 _upload_to_workspace 方法之后添加 _upload_folder_to_workspace +old_upload_workspace = """ def _upload_to_workspace(self):""" + +new_upload_workspace = """ def _upload_folder_to_workspace(self): + if not self._config_panel or not self._validate_ssh_config(): + return + + local_dir = QFileDialog.getExistingDirectory(self, "选择要上传到工作区的文件夹") + if not local_dir: + return + + folder_name = os.path.basename(local_dir.rstrip('/\\\\')) + remote_path = f"~/workspace/{folder_name}" + self._start_worker( + WorkspaceWorker.OPERATION_UPLOAD_DIR, + local_path=local_dir, + remote_path=remote_path, + ) + + def _upload_to_workspace(self):""" + +content = content.replace(old_upload_workspace, new_upload_workspace) + +with open(gui_path, "w", encoding="utf-8") as f: + f.write(content) +print("✅ gui.py 已添加上传文件夹功能") + +print("\\n所有修改完成!") diff --git a/src/virtualbox_client/__init__.py b/src/virtualbox_client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/virtualbox_client/gui.py b/src/virtualbox_client/gui.py new file mode 100644 index 0000000..48e7fd3 --- /dev/null +++ b/src/virtualbox_client/gui.py @@ -0,0 +1,2568 @@ +""" +VirtualBox Client - PySide6 图形界面 +""" + +import sys +import os +import json +import threading +import time +from datetime import datetime + +try: + from dotenv import load_dotenv + + load_dotenv("./.env.example") +except ImportError: + pass + +from PySide6.QtWidgets import ( + QApplication, + QMainWindow, + QWidget, + QVBoxLayout, + QHBoxLayout, + QGroupBox, + QFormLayout, + QLabel, + QLineEdit, + QSpinBox, + QPushButton, + QPlainTextEdit, + QSplitter, + QStatusBar, + QMessageBox, + QFrame, + QScrollArea, + QCheckBox, + QFileDialog, + QTabWidget, + QTreeWidget, + QTreeWidgetItem, + QHeaderView, + QMenu, + QInputDialog, + QAbstractItemView, + QProgressBar, +) +from PySide6.QtCore import Qt, QObject, QThread, Signal, QTimer, QSize +from PySide6.QtGui import QFont, QTextCursor, QAction, QIcon, QColor, QBrush + + +class AgentWorker(QObject): + """ + 后台线程执行 Agent 任务,通过 Qt Signal 发送日志到主线程。 + 使用 Signal 机制避免了定时器轮询队列带来的延迟和性能开销。 + 支持 VirtualBox 模式和纯 SSH 模式两种运行方式。 + """ + + # 定义信号:日志消息、任务完成(携带 vm_started_by_us)、任务错误、任务取消 + log_signal = Signal(str, str) # (message, level) + finished_signal = Signal(bool) # vm_started_by_us:是否由本任务启动的虚拟机 + error_signal = Signal(str) + cancelled_signal = Signal() + + def __init__(self, config: dict, task: str): + super().__init__() + self.config = config + self.task = task + # 使用共享的 threading.Event,同时控制 AgentWorker 和底层管理器的取消 + self.cancel_event = threading.Event() + # 保存 vm 引用以便在主线程也能取消底层操作 + self._vm = None + + def cancel(self): + """取消任务——设置取消事件,通知所有层级的操作立即停止""" + self.cancel_event.set() + if self._vm is not None: + try: + self._vm.cancel_operations() + except Exception: + pass + + def get_vm(self): + """获取 VM 管理器引用(供主线程关闭 VM 使用)""" + return self._vm + + def is_cancelled(self): + return self.cancel_event.is_set() + + def run(self): + # Windows COM 初始化(仅 VirtualBox 模式需要) + _com_initialized = False + ssh_only = self.config.get("ssh_only", False) + if not ssh_only: + try: + import ctypes + + ctypes.windll.ole32.CoInitializeEx(None, 0) + _com_initialized = True + except Exception: + pass + + try: + from virtualbox_client.utils.vm_agent import VMAgent + + if ssh_only: + from virtualbox_client.utils.ssh_manager import SSHManager + + vm = SSHManager( + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + self.log_signal.emit("🔌 使用纯 SSH 模式(不依赖 VirtualBox)", "info") + else: + from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager + + vm = VBoxSSHManager( + self.config["vm_name"], + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + + vm.set_cancel_event(self.cancel_event) + self._vm = vm + + if self.is_cancelled(): + self.cancelled_signal.emit() + return + + agent = VMAgent( + vm, + api_key=self.config["api_key"], + base_url=self.config.get("base_url", ""), + model=self.config.get("model", "gpt-4o"), + sudo_password=self.config.get("ssh_pass", ""), + ) + + original_print = print + + def custom_print(*args, **kwargs): + msg = " ".join(str(a) for a in args) + self.log_signal.emit(msg, "info") + original_print(*args, **kwargs) + + import builtins + + builtins.print = custom_print + + try: + if self.is_cancelled(): + self.cancelled_signal.emit() + return + + self.log_signal.emit(f"📋 {self.task}", "task") + _, vm_started_by_us = agent.run(self.task) + + if self.is_cancelled(): + self.cancelled_signal.emit() + return + + self.log_signal.emit("✅ 任务完成", "success") + self.finished_signal.emit(vm_started_by_us) + finally: + builtins.print = original_print + + except ImportError as e: + self.log_signal.emit(f"❌ 缺少依赖: {e}", "error") + self.error_signal.emit(str(e)) + except Exception as e: + self.log_signal.emit(f"❌ 错误: {str(e)}", "error") + self.error_signal.emit(str(e)) + finally: + if _com_initialized: + try: + import ctypes + + ctypes.windll.ole32.CoUninitialize() + except Exception: + pass + + +class ShutdownWorker(QObject): + """后台关闭虚拟机的工作线程——避免阻塞 UI 和跨线程 COM 冲突""" + + finished = Signal() + error = Signal(str) + log = Signal(str, str) + + def __init__(self, config: dict): + super().__init__() + self.config = config + + def run(self): + ssh_only = self.config.get("ssh_only", False) + if not ssh_only: + try: + import ctypes + + ctypes.windll.ole32.CoInitializeEx(None, 0) + except Exception: + pass + try: + if ssh_only: + from virtualbox_client.utils.ssh_manager import SSHManager + + self.log.emit("🔌 纯 SSH 模式:正在关闭 SSH 连接...", "info") + vm = SSHManager( + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + vm.close_ssh() + self.log.emit("✅ SSH 连接已关闭", "success") + else: + from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager + + self.log.emit("🛑 正在关闭虚拟机...", "warning") + vm = VBoxSSHManager( + self.config["vm_name"], + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + vm.stop_vm() + self.log.emit("✅ 虚拟机已关闭", "success") + self.finished.emit() + except Exception as e: + self.log.emit(f"❌ 关闭失败: {e}", "error") + self.error.emit(str(e)) + finally: + if not ssh_only: + try: + import ctypes + + ctypes.windll.ole32.CoUninitialize() + except Exception: + pass + + +class ConfigPanel(QWidget): + """配置面板""" + + def __init__(self): + super().__init__() + self.setup_ui() + self.load_from_env() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + mode_group = QGroupBox("运行模式") + mode_layout = QVBoxLayout(mode_group) + self.ssh_only_check = QCheckBox("🔌 纯 SSH 模式(不依赖 VirtualBox)") + self.ssh_only_check.setToolTip( + "勾选后只需填写 SSH 连接信息,不需要 VirtualBox 环境和虚拟机名称" + ) + self.ssh_only_check.toggled.connect(self._on_mode_changed) + mode_layout.addWidget(self.ssh_only_check) + layout.addWidget(mode_group) + + vm_group = QGroupBox("虚拟机配置") + vm_form = QFormLayout(vm_group) + self.vm_name_edit = QLineEdit(placeholderText="例如: ubuntu20.04") + vm_form.addRow("虚拟机名称:", self.vm_name_edit) + self.ssh_host_edit = QLineEdit(placeholderText="例如: 127.0.0.1") + vm_form.addRow("SSH 主机:", self.ssh_host_edit) + self.ssh_port_spin = QSpinBox() + self.ssh_port_spin.setRange(1, 65535) + self.ssh_port_spin.setValue(2222) + vm_form.addRow("SSH 端口:", self.ssh_port_spin) + self.ssh_user_edit = QLineEdit(placeholderText="SSH 用户名") + vm_form.addRow("SSH 用户:", self.ssh_user_edit) + self.ssh_pass_edit = QLineEdit( + echoMode=QLineEdit.EchoMode.Password, placeholderText="SSH 密码" + ) + vm_form.addRow("SSH 密码:", self.ssh_pass_edit) + layout.addWidget(vm_group) + + llm_group = QGroupBox("大模型配置") + llm_form = QFormLayout(llm_group) + self.api_key_edit = QLineEdit( + echoMode=QLineEdit.EchoMode.Password, placeholderText="API Key" + ) + llm_form.addRow("API Key:", self.api_key_edit) + self.base_url_edit = QLineEdit(placeholderText="例如: https://api.deepseek.com") + llm_form.addRow("Base URL:", self.base_url_edit) + self.model_edit = QLineEdit(placeholderText="例如: deepseek-v4-flash") + llm_form.addRow("模型:", self.model_edit) + layout.addWidget(llm_group) + + btn_layout = QHBoxLayout() + self.load_env_btn = QPushButton("📂 从 .env 加载") + self.load_env_btn.clicked.connect(self.load_from_env) + btn_layout.addWidget(self.load_env_btn) + self.save_config_btn = QPushButton("💾 保存配置") + self.save_config_btn.clicked.connect(self.save_config) + btn_layout.addWidget(self.save_config_btn) + layout.addLayout(btn_layout) + layout.addStretch() + + def load_from_env(self): + self.vm_name_edit.setText(os.getenv("VM_NAME", "")) + self.ssh_host_edit.setText(os.getenv("VM_SSH_HOST", "127.0.0.1")) + try: + self.ssh_port_spin.setValue(int(os.getenv("VM_SSH_PORT", "2222"))) + except ValueError: + self.ssh_port_spin.setValue(2222) + self.ssh_user_edit.setText(os.getenv("VM_SSH_USER", "")) + self.ssh_pass_edit.setText(os.getenv("VM_SSH_PASS", "")) + self.api_key_edit.setText(os.getenv("LLM_API_KEY", "")) + self.base_url_edit.setText(os.getenv("LLM_BASE_URL", "")) + self.model_edit.setText(os.getenv("LLM_MODEL", "")) + + def save_config(self): + config = self.get_config() + try: + config_path = os.path.join( + os.path.dirname(__file__), "..", "..", "gui_config.json" + ) + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + QMessageBox.information(self, "保存成功", f"配置已保存到 {config_path}") + except Exception as e: + QMessageBox.warning(self, "保存失败", f"保存配置失败: {e}") + + def _on_mode_changed(self): + ssh_only = self.ssh_only_check.isChecked() + self.vm_name_edit.setEnabled(not ssh_only) + if ssh_only: + self.vm_name_edit.setPlaceholderText("纯 SSH 模式无需虚拟机名称") + else: + self.vm_name_edit.setPlaceholderText("例如: ubuntu20.04") + + def get_config(self) -> dict: + return { + "vm_name": self.vm_name_edit.text().strip(), + "ssh_host": self.ssh_host_edit.text().strip(), + "ssh_port": self.ssh_port_spin.value(), + "ssh_user": self.ssh_user_edit.text().strip(), + "ssh_pass": self.ssh_pass_edit.text().strip(), + "api_key": self.api_key_edit.text().strip(), + "base_url": self.base_url_edit.text().strip(), + "model": self.model_edit.text().strip(), + "ssh_only": self.ssh_only_check.isChecked(), + } + + def validate(self) -> bool: + cfg = self.get_config() + missing = [] + if not cfg["ssh_only"] and not cfg["vm_name"]: + missing.append("虚拟机名称") + if not cfg["ssh_host"]: + missing.append("SSH 主机") + if not cfg["ssh_user"]: + missing.append("SSH 用户") + if not cfg["ssh_pass"]: + missing.append("SSH 密码") + if not cfg["api_key"]: + missing.append("API Key") + if missing: + QMessageBox.warning( + self, + "配置不完整", + "请填写以下字段:\n" + "\n".join(f" - {m}" for m in missing), + ) + return False + return True + + def set_enabled(self, enabled: bool): + self.ssh_only_check.setEnabled(enabled) + self.vm_name_edit.setEnabled(enabled and not self.ssh_only_check.isChecked()) + self.ssh_host_edit.setEnabled(enabled) + self.ssh_port_spin.setEnabled(enabled) + self.ssh_user_edit.setEnabled(enabled) + self.ssh_pass_edit.setEnabled(enabled) + self.api_key_edit.setEnabled(enabled) + self.base_url_edit.setEnabled(enabled) + self.model_edit.setEnabled(enabled) + self.load_env_btn.setEnabled(enabled) + self.save_config_btn.setEnabled(enabled) + + +class UploadWorker(QObject): + """后台线程执行文件上传(支持 VirtualBox 和纯 SSH 模式)""" + + log_signal = Signal(str, str) + finished_signal = Signal(bool) + error_signal = Signal(str) + + def __init__(self, config: dict, local_path: str, remote_path: str): + super().__init__() + self.config = config + self.local_path = local_path + self.remote_path = remote_path + self.cancel_event = threading.Event() + + def cancel(self): + self.cancel_event.set() + + def run(self): + ssh_only = self.config.get("ssh_only", False) + _com_initialized = False + if not ssh_only: + try: + import ctypes + + ctypes.windll.ole32.CoInitializeEx(None, 0) + _com_initialized = True + except Exception: + pass + + try: + if ssh_only: + from virtualbox_client.utils.ssh_manager import SSHManager + + vm = SSHManager( + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + else: + from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager + + vm = VBoxSSHManager( + self.config["vm_name"], + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + vm.set_cancel_event(self.cancel_event) + + if self.cancel_event.is_set(): + return + + self.log_signal.emit( + f"📤 正在上传: {self.local_path} -> {self.remote_path}", "info" + ) + original_print = print + import builtins + + def custom_print(*args, **kwargs): + msg = " ".join(str(a) for a in args) + self.log_signal.emit(msg, "info") + original_print(*args, **kwargs) + + builtins.print = custom_print + + try: + success = vm.upload_file(self.local_path, self.remote_path) + if success: + self.log_signal.emit("✅ 文件上传成功", "success") + self.finished_signal.emit(True) + else: + self.log_signal.emit("❌ 文件上传失败", "error") + self.finished_signal.emit(False) + finally: + builtins.print = original_print + + except ImportError as e: + self.log_signal.emit(f"❌ 缺少依赖: {e}", "error") + self.error_signal.emit(str(e)) + except Exception as e: + self.log_signal.emit(f"❌ 上传错误: {str(e)}", "error") + self.error_signal.emit(str(e)) + finally: + if _com_initialized: + try: + import ctypes + + ctypes.windll.ole32.CoUninitialize() + except Exception: + pass + + +class DownloadWorker(QObject): + """后台线程执行文件下载(支持 VirtualBox 和纯 SSH 模式)""" + + log_signal = Signal(str, str) + finished_signal = Signal(bool) + error_signal = Signal(str) + + def __init__(self, config: dict, remote_path: str, local_path: str): + super().__init__() + self.config = config + self.remote_path = remote_path + self.local_path = local_path + self.cancel_event = threading.Event() + + def cancel(self): + self.cancel_event.set() + + def run(self): + ssh_only = self.config.get("ssh_only", False) + _com_initialized = False + if not ssh_only: + try: + import ctypes + + ctypes.windll.ole32.CoInitializeEx(None, 0) + _com_initialized = True + except Exception: + pass + + try: + if ssh_only: + from virtualbox_client.utils.ssh_manager import SSHManager + + vm = SSHManager( + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + else: + from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager + + vm = VBoxSSHManager( + self.config["vm_name"], + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + vm.set_cancel_event(self.cancel_event) + + if self.cancel_event.is_set(): + return + + self.log_signal.emit( + f"📥 正在下载: {self.remote_path} -> {self.local_path}", "info" + ) + original_print = print + import builtins + + def custom_print(*args, **kwargs): + msg = " ".join(str(a) for a in args) + self.log_signal.emit(msg, "info") + original_print(*args, **kwargs) + + builtins.print = custom_print + + try: + success = vm.download_file(self.remote_path, self.local_path) + if success: + self.log_signal.emit("✅ 文件下载成功", "success") + self.finished_signal.emit(True) + else: + self.log_signal.emit("❌ 文件下载失败", "error") + self.finished_signal.emit(False) + finally: + builtins.print = original_print + + except ImportError as e: + self.log_signal.emit(f"❌ 缺少依赖: {e}", "error") + self.error_signal.emit(str(e)) + except Exception as e: + self.log_signal.emit(f"❌ 下载错误: {str(e)}", "error") + self.error_signal.emit(str(e)) + finally: + if _com_initialized: + try: + import ctypes + + ctypes.windll.ole32.CoUninitialize() + except Exception: + pass + + +class ScriptWorker(QObject): + log_signal = Signal(str, str) + finished_signal = Signal(bool) + error_signal = Signal(str) + + def __init__(self, config: dict, local_path: str): + super().__init__() + self.config = config + self.local_path = local_path + self.cancel_event = threading.Event() + + def cancel(self): + self.cancel_event.set() + + def run(self): + ssh_only = self.config.get("ssh_only", False) + _com_initialized = False + if not ssh_only: + try: + import ctypes + + ctypes.windll.ole32.CoInitializeEx(None, 0) + _com_initialized = True + except Exception: + pass + + try: + if ssh_only: + from virtualbox_client.utils.ssh_manager import SSHManager + + vm = SSHManager( + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + else: + from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager + + vm = VBoxSSHManager( + self.config["vm_name"], + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + vm.set_cancel_event(self.cancel_event) + + if self.cancel_event.is_set(): + return + + # 非纯 SSH 模式下,先确保虚拟机已启动 + if not ssh_only: + self.log_signal.emit("🚀 检查虚拟机状态...", "info") + vm_started = vm.start_vm(wait_for_ssh=True) + if not vm_started: + self.log_signal.emit("❌ 虚拟机启动失败", "error") + self.error_signal.emit("虚拟机启动失败") + return + self.log_signal.emit("✅ 虚拟机已就绪", "success") + if self.cancel_event.is_set(): + return + + import builtins + + original_print = print + + def custom_print(*args, **kwargs): + msg = " ".join(str(a) for a in args) + self.log_signal.emit(msg, "info") + original_print(*args, **kwargs) + + builtins.print = custom_print + + try: + import os as _os + + script_name = _os.path.basename(self.local_path) + remote_path = "/tmp/" + script_name + log_path = "/tmp/" + script_name + ".log" + local_log_path = script_name + ".log" + + self.log_signal.emit( + "[1/3] Upload: " + self.local_path + " -> " + remote_path, "info" + ) + if not vm.upload_file(self.local_path, remote_path): + self.log_signal.emit("Upload failed", "error") + return + + self.log_signal.emit("[2/3] Execute: " + remote_path, "info") + # deploy_* 脚本需要 sudo 权限(系统级安装),其余脚本以普通用户执行 + if script_name.startswith("deploy_"): + cmd = ( + "chmod +x " + remote_path + " && " + "echo '" + + self.config["ssh_pass"] + + "' | sudo -S " + + remote_path + + " 2>&1 | " + "tee " + log_path + ) + self.log_signal.emit( + " 🔑 检测到部署脚本,以 sudo 权限执行", "info" + ) + else: + cmd = ( + "chmod +x " + remote_path + " && " + remote_path + " 2>&1 | " + "tee " + log_path + ) + self.log_signal.emit( + " 👤 检测到开发脚本,以普通用户权限执行", "info" + ) + out, err, code = vm.exec_command(cmd, get_pty=False) + self.log_signal.emit("Exit code: " + str(code), "info") + + self.log_signal.emit( + "[3/3] Download log: " + log_path + " -> " + local_log_path, "info" + ) + if vm.download_file(log_path, local_log_path): + self.log_signal.emit("Log saved: " + local_log_path, "success") + else: + self.log_signal.emit( + "Tee log download failed, saving terminal output", "warning" + ) + if out: + with open(local_log_path, "w") as f: + f.write(out) + self.log_signal.emit( + "Output saved: " + local_log_path, "success" + ) + + vm.exec_command('rm -f "' + remote_path + '" "' + log_path + '"') + self.log_signal.emit("Remote temp files cleaned", "info") + self.log_signal.emit("Script execution complete!", "success") + self.finished_signal.emit(True) + finally: + builtins.print = original_print + + except ImportError as e: + self.log_signal.emit("Missing dependency: " + str(e), "error") + self.error_signal.emit(str(e)) + except Exception as e: + self.log_signal.emit("Script error: " + str(e), "error") + self.error_signal.emit(str(e)) + finally: + if _com_initialized: + try: + import ctypes + + ctypes.windll.ole32.CoUninitialize() + except Exception: + pass + + +class WorkspaceWorker(QObject): + """后台线程执行工作区文件操作(列表、删除、复制、重命名、创建目录)""" + + log_signal = Signal(str, str) + finished_signal = Signal(object) + error_signal = Signal(str) + + OPERATION_LIST = "list" + OPERATION_DELETE = "delete" + OPERATION_COPY = "copy" + OPERATION_RENAME = "rename" + OPERATION_MKDIR = "mkdir" + OPERATION_UPLOAD = "upload" + OPERATION_UPLOAD_DIR = "upload_dir" + + def __init__(self, config: dict, operation: str, **kwargs): + super().__init__() + self.config = config + self.operation = operation + self.kwargs = kwargs + self.cancel_event = threading.Event() + + def cancel(self): + self.cancel_event.set() + + def run(self): + ssh_only = self.config.get("ssh_only", False) + _com_initialized = False + if not ssh_only: + try: + import ctypes + + ctypes.windll.ole32.CoInitializeEx(None, 0) + _com_initialized = True + except Exception: + pass + + try: + if ssh_only: + from virtualbox_client.utils.ssh_manager import SSHManager + + vm = SSHManager( + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + else: + from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager + + vm = VBoxSSHManager( + self.config["vm_name"], + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + vm.set_cancel_event(self.cancel_event) + + if self.cancel_event.is_set(): + return + + if self.operation == WorkspaceWorker.OPERATION_LIST: + dir_path = self.kwargs.get("dir_path", "~/workspace") + result = self._list_files(vm, dir_path) + elif self.operation == WorkspaceWorker.OPERATION_DELETE: + result = self._delete_file(vm) + elif self.operation == WorkspaceWorker.OPERATION_COPY: + result = self._copy_file(vm) + elif self.operation == WorkspaceWorker.OPERATION_RENAME: + result = self._rename_file(vm) + elif self.operation == WorkspaceWorker.OPERATION_MKDIR: + result = self._mkdir(vm) + elif self.operation == WorkspaceWorker.OPERATION_UPLOAD: + result = self._upload_file(vm) + elif self.operation == WorkspaceWorker.OPERATION_UPLOAD_DIR: + result = self._upload_directory(vm) + else: + raise ValueError(f"未知操作: {self.operation}") + + if self.cancel_event.is_set(): + return + + self.finished_signal.emit(result) + + except ImportError as e: + self.log_signal.emit(f"❌ 缺少依赖: {e}", "error") + self.error_signal.emit(str(e)) + except Exception as e: + self.log_signal.emit(f"❌ 工作区操作失败: {str(e)}", "error") + self.error_signal.emit(str(e)) + finally: + if _com_initialized: + try: + import ctypes + + ctypes.windll.ole32.CoUninitialize() + except Exception: + pass + + def _ensure_workspace(self, vm) -> bool: + out, err, code = vm.exec_command("mkdir -p ~/workspace") + return code == 0 + + def _list_files(self, vm, dir_path: str = "~/workspace") -> list: + """列出指定目录中的文件,返回文件信息列表。""" + import base64 + import json as json_mod + + # 对包含 ~ 的路径使用 shell 展开创建目录 + vm.exec_command(f"mkdir -p {dir_path}") + self.log_signal.emit(f"📂 正在扫描: {dir_path}", "info") + + py_script = ( + "import os,sys,json,time\n" + 'ws=os.path.expanduser("' + dir_path.replace('"', '\\"') + '")\n' + "files=[]\n" + "try:\n" + " for f in sorted(os.listdir(ws),key=lambda x:(not os.path.isdir(os.path.join(ws,x)),x.lower())):\n" + " if f.startswith('.'):\n" + " continue\n" + " fp=os.path.join(ws,f)\n" + " try:\n" + " s=os.stat(fp)\n" + " files.append({'name':f,'size':s.st_size,'is_dir':os.path.isdir(fp),'modified':int(s.st_mtime),'path':fp})\n" + " except:\n" + " pass\n" + "except Exception as e:\n" + " sys.stderr.write(str(e))\n" + "print(json.dumps(files))\n" + ) + encoded = base64.b64encode(py_script.encode()).decode() + + out, err, code = vm.exec_command( + f'echo "{encoded}" | base64 -d 2>/dev/null | python3 2>/dev/null' + ) + + if code != 0 or not out or not out.strip(): + out, err, code = vm.exec_command( + f'echo "{encoded}" | base64 -d 2>/dev/null | python 2>/dev/null' + ) + if code != 0 or not out or not out.strip(): + self.log_signal.emit( + "⚠️ Python 不可用,使用 find 命令列出文件", "warning" + ) + # 展开 ~ 为真实路径(find 命令本身不会展开 ~) + resolved_dir = dir_path + if resolved_dir.startswith("~/"): + home_out, _, _ = vm.exec_command("echo ~") + if home_out and home_out.strip(): + resolved_dir = home_out.strip() + resolved_dir[1:] + out2, err2, code2 = vm.exec_command( + f'find {resolved_dir} -maxdepth 1 ! -name "." ! -name ".." -printf "%f|%y|%s\\n" 2>/dev/null | sort' + ) + if code2 != 0 or not out2 or not out2.strip(): + self.log_signal.emit(f"ℹ️ 目录为空或不可访问: {dir_path}", "info") + return [] + + files = [] + for line in out2.strip().split("\n"): + line = line.strip() + if not line: + continue + parts = line.split("|") + if len(parts) < 3: + continue + name, ftype, size_str = parts[0], parts[1], parts[2] + try: + size = int(size_str) + except ValueError: + size = 0 + files.append( + { + "name": name, + "size": size, + "is_dir": ftype == "d", + "modified": "", + "path": f"{resolved_dir}/{name}", + } + ) + return files + + try: + files = json_mod.loads(out.strip()) + for f in files: + f.setdefault("name", "未知") + f.setdefault("size", 0) + f.setdefault("is_dir", False) + f.setdefault("modified", "") + f.setdefault("path", f"~/workspace/{f.get('name', '')}") + if isinstance(f.get("modified"), (int, float)) and f["modified"]: + try: + f["modified"] = datetime.fromtimestamp(f["modified"]).strftime( + "%Y-%m-%d %H:%M:%S" + ) + except Exception: + f["modified"] = str(f["modified"]) + self.log_signal.emit( + f"📂 扫描完成: 找到 {len(files)} 个文件/文件夹", "info" + ) + return files + except (json_mod.JSONDecodeError, Exception) as e: + self.log_signal.emit(f"❌ 解析文件列表失败: {e}", "error") + return [] + + def _delete_file(self, vm) -> bool: + paths = self.kwargs.get("path", []) + if not paths: + return False + if isinstance(paths, str): + paths = [paths] + # 使用空格分隔拼接多个路径,统一用双引号包裹以支持含空格的路径 + joined = " ".join(f'"{p}"' for p in paths) + out, err, code = vm.exec_command(f"rm -rf {joined}") + return code == 0 + + def _copy_file(self, vm) -> bool: + src = self.kwargs.get("src", "") + dst = self.kwargs.get("dst", "") + if not src or not dst: + return False + out, err, code = vm.exec_command(f'cp -r "{src}" "{dst}"') + return code == 0 + + def _rename_file(self, vm) -> bool: + src = self.kwargs.get("src", "") + dst = self.kwargs.get("dst", "") + if not src or not dst: + return False + out, err, code = vm.exec_command(f'mv "{src}" "{dst}"') + return code == 0 + + def _mkdir(self, vm) -> bool: + name = self.kwargs.get("name", "") + if not name: + return False + out, err, code = vm.exec_command(f"mkdir -p ~/workspace/{name}") + return code == 0 + + def _upload_file(self, vm) -> bool: + local_path = self.kwargs.get("local_path", "") + remote_path = self.kwargs.get("remote_path", "") + if not local_path or not remote_path: + return False + remote_dir = ( + remote_path.rsplit("/", 1)[0] + if "/" in remote_path + else remote_path.rsplit("\\", 1)[0] + ) + vm.exec_command(f'mkdir -p "{remote_dir}"') + return vm.upload_file(local_path, remote_path) + + def _upload_directory(self, vm) -> bool: + local_dir = self.kwargs.get("local_path", "") + remote_dir = self.kwargs.get("remote_path", "") + if not local_dir or not remote_dir: + return False + return vm.upload_directory(local_dir, remote_dir) + + +class FileSizeFormatter: + """文件大小格式化工具""" + + @staticmethod + def format(size_bytes: int) -> str: + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + elif size_bytes < 1024 * 1024 * 1024: + return f"{size_bytes / (1024 * 1024):.1f} MB" + else: + return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" + + +class WorkspacePanel(QWidget): + """工作区面板 - 树形文件浏览器,双击文件夹展开子目录,单击显示文件信息""" + + def __init__(self, config_panel: ConfigPanel, log_panel: "LogPanel"): + super().__init__() + self._config_panel = config_panel + self.log_panel = log_panel + self._busy = False + self._loaded_dirs = set() # 已加载的目录路径 + self._worker_thread = None + self._worker = None + self._pending_expand_item = None # 待展开的树节点 + self._current_info_item = None # 当前显示详细信息的项 + self._download_worker_thread = None + self._download_worker = None + + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + + # ---- 工具栏 ---- + toolbar = QHBoxLayout() + + self.refresh_btn = QPushButton("🔄 刷新") + self.refresh_btn.setStyleSheet(""" + QPushButton { background-color: #0e639c; color: white; padding: 6px 14px; + font-size: 12px; font-weight: bold; border: none; border-radius: 4px; } + QPushButton:hover { background-color: #1177bb; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.refresh_btn.clicked.connect(self.refresh_root) + toolbar.addWidget(self.refresh_btn) + + self.upload_btn = QPushButton("📤 上传文件") + self.upload_btn.setStyleSheet(""" + QPushButton { background-color: #2d7d46; color: white; padding: 6px 14px; + font-size: 12px; font-weight: bold; border: none; border-radius: 4px; } + QPushButton:hover { background-color: #389e54; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.upload_btn.clicked.connect(self._upload_to_workspace) + toolbar.addWidget(self.upload_btn) + + self.upload_dir_btn = QPushButton("📁 上传文件夹") + self.upload_dir_btn.setStyleSheet(""" + QPushButton { background-color: #16a085; color: white; padding: 6px 14px; + font-size: 12px; font-weight: bold; border: none; border-radius: 4px; } + QPushButton:hover { background-color: #1abc9c; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.upload_dir_btn.clicked.connect(self._upload_folder_to_workspace) + toolbar.addWidget(self.upload_dir_btn) + + self.mkdir_btn = QPushButton("📂 新建文件夹") + self.mkdir_btn.setStyleSheet(""" + QPushButton { background-color: #e67e22; color: white; padding: 6px 14px; + font-size: 12px; font-weight: bold; border: none; border-radius: 4px; } + QPushButton:hover { background-color: #f39c12; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.mkdir_btn.clicked.connect(self._create_folder) + toolbar.addWidget(self.mkdir_btn) + + self.home_btn = QPushButton("🏠 回到根目录") + self.home_btn.setStyleSheet(""" + QPushButton { background-color: #6c5ce7; color: white; padding: 6px 14px; + font-size: 12px; font-weight: bold; border: none; border-radius: 4px; } + QPushButton:hover { background-color: #7c6cf7; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.home_btn.clicked.connect(self._go_home) + toolbar.addWidget(self.home_btn) + + toolbar.addStretch() + + self.status_label = QLabel("就绪") + self.status_label.setStyleSheet("color: #888; font-size: 12px;") + toolbar.addWidget(self.status_label) + + layout.addLayout(toolbar) + + # ---- 文件树(真正的树状结构) ---- + self.file_tree = QTreeWidget() + self.file_tree.setHeaderLabels(["名称", "大小", "类型", "修改时间", "权限"]) + self.file_tree.setColumnWidth(0, 280) + self.file_tree.setColumnWidth(1, 100) + self.file_tree.setColumnWidth(2, 80) + self.file_tree.setColumnWidth(3, 160) + self.file_tree.setColumnWidth(4, 80) + self.file_tree.setAlternatingRowColors(True) + self.file_tree.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) + self.file_tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.file_tree.customContextMenuRequested.connect(self._show_context_menu) + self.file_tree.setRootIsDecorated(True) + self.file_tree.setAnimated(True) + + # 双击展开/收起文件夹(懒加载) + self.file_tree.itemDoubleClicked.connect(self._on_item_double_clicked) + # 单击显示文件信息 + self.file_tree.itemClicked.connect(self._on_item_clicked) + + self.file_tree.setStyleSheet(""" + QTreeWidget { + background-color: #1e1e1e; color: #d4d4d4; + border: 1px solid #3c3c3c; border-radius: 4px; + alternate-background-color: #2a2a2a; + font-size: 13px; + } + QTreeWidget::item:hover { background-color: #2d2d2d; } + QTreeWidget::item:selected { background-color: #094771; } + QTreeWidget::branch:has-children:closed:has-siblings { border-image: none; } + QTreeWidget::branch:open:has-children:has-siblings { border-image: none; } + QHeaderView::section { + background-color: #2d2d2d; color: #cccccc; + padding: 6px; border: none; border-bottom: 1px solid #3c3c3c; + font-weight: bold; + } + """) + + layout.addWidget(self.file_tree) + + # ---- 文件详细信息面板(单击文件时显示) ---- + self.info_panel = QFrame() + self.info_panel.setFrameShape(QFrame.Shape.StyledPanel) + self.info_panel.setStyleSheet(""" + QFrame { + background-color: #1e1e1e; + border: 1px solid #3c3c3c; + border-radius: 4px; + padding: 8px; + } + QLabel { color: #d4d4d4; font-size: 12px; padding: 2px; } + """) + info_layout = QVBoxLayout(self.info_panel) + info_layout.setContentsMargins(8, 4, 8, 4) + self.info_label = QLabel("选择文件以查看详情") + self.info_label.setStyleSheet( + "color: #888; font-style: italic; font-size: 12px;" + ) + info_layout.addWidget(self.info_label) + self.info_panel.setMaximumHeight(60) + self.info_panel.hide() + layout.addWidget(self.info_panel) + + # ---- 进度条 ---- + self.progress_bar = QProgressBar() + self.progress_bar.setRange(0, 0) + self.progress_bar.setMaximumHeight(4) + self.progress_bar.setTextVisible(False) + self.progress_bar.hide() + layout.addWidget(self.progress_bar) + + # ---- 线程管理 ---- + + def _cleanup_worker(self): + if self._worker_thread: + try: + self._worker_thread.finished.disconnect() + except TypeError, RuntimeError: + pass + if self._worker_thread.isRunning(): + self._worker_thread.quit() + if not self._worker_thread.wait(5000): + self._worker_thread.terminate() + self._worker_thread.wait(3000) + self._worker_thread = None + if self._worker: + try: + self._worker.log_signal.disconnect() + self._worker.finished_signal.disconnect() + self._worker.error_signal.disconnect() + except TypeError, RuntimeError: + pass + self._worker = None + + def _set_busy(self, busy: bool, status_text: str = ""): + self._busy = busy + self.refresh_btn.setEnabled(not busy) + self.upload_btn.setEnabled(not busy) + self.upload_dir_btn.setEnabled(not busy) + self.mkdir_btn.setEnabled(not busy) + self.home_btn.setEnabled(not busy) + self.file_tree.setEnabled(not busy) + if busy: + self.progress_bar.show() + self.status_label.setText(status_text or "操作中...") + else: + self.progress_bar.hide() + self.status_label.setText(status_text or "就绪") + + def _validate_ssh_config(self) -> bool: + if not self._config_panel: + return False + cfg = self._config_panel.get_config() + missing = [] + if not cfg["ssh_host"]: + missing.append("SSH 主机") + if not cfg["ssh_user"]: + missing.append("SSH 用户") + if not cfg["ssh_pass"]: + missing.append("SSH 密码") + if missing: + self.log_panel.append_log( + "❌ SSH 配置不完整: " + ", ".join(missing), "error" + ) + return False + return True + + def _start_worker(self, operation: str, **kwargs): + if not self._config_panel or not self._validate_ssh_config(): + self._set_busy(False, "SSH 配置不完整") + return + + self._cleanup_worker() + self._set_busy(True, "正在连接...") + + config = self._config_panel.get_config() + self._worker = WorkspaceWorker(config, operation, **kwargs) + self._worker_thread = QThread(self) + self._worker.moveToThread(self._worker_thread) + + self._worker.log_signal.connect(self.log_panel.append_log) + self._worker.finished_signal.connect(self._on_worker_finished) + self._worker.error_signal.connect(self._on_worker_error) + self._worker_thread.started.connect(self._worker.run) + self._worker_thread.finished.connect(self._cleanup_worker) + + self._worker_thread.start() + + def _on_worker_finished(self, result): + self._set_busy(False, "就绪") + + if self._worker and self._worker.operation == WorkspaceWorker.OPERATION_LIST: + if self._pending_expand_item: + # 展开子目录的结果 + self._apply_directory_children(self._pending_expand_item, result) + self._pending_expand_item = None + else: + # 根目录刷新结果 + self._refresh_root_tree(result) + count = len(result) if isinstance(result, list) else 0 + self.log_panel.append_log(f"📂 共 {count} 个文件/文件夹", "info") + elif self._worker and self._worker.operation in ( + WorkspaceWorker.OPERATION_UPLOAD, + WorkspaceWorker.OPERATION_UPLOAD_DIR, + ): + if result: + self.log_panel.append_log("✅ 上传成功", "success") + else: + self.log_panel.append_log("❌ 上传失败", "error") + QTimer.singleShot(500, self._soft_refresh) + else: + if result: + self.log_panel.append_log("✅ 操作成功", "success") + else: + self.log_panel.append_log("❌ 操作失败", "error") + QTimer.singleShot(500, self._soft_refresh) + + def _on_worker_error(self, error_msg): + self._set_busy(False, "错误") + self.log_panel.append_log(f"❌ 工作区错误: {error_msg}", "error") + self._pending_expand_item = None + + # ---- 树状结构核心逻辑 ---- + + def refresh_root(self): + """刷新根目录""" + self._loaded_dirs.clear() + self._pending_expand_item = None + self.file_tree.clear() + self.info_panel.hide() + self._start_worker(WorkspaceWorker.OPERATION_LIST, dir_path="~/workspace") + + def _soft_refresh(self): + """刷新后保持展开状态""" + self._loaded_dirs.clear() + self._pending_expand_item = None + self.file_tree.clear() + self.info_panel.hide() + self._start_worker(WorkspaceWorker.OPERATION_LIST, dir_path="~/workspace") + + def _go_home(self): + """回到根目录:收起所有节点,仅显示根目录""" + self.file_tree.collapseAll() + self.info_panel.hide() + + def _refresh_root_tree(self, files: list): + """用文件列表填充根树""" + if not isinstance(files, list): + return + self.file_tree.clear() + + for f in files: + item = self._create_tree_item(f) + self.file_tree.addTopLevelItem(item) + + self.file_tree.sortItems(0, Qt.SortOrder.AscendingOrder) + + def _apply_directory_children(self, parent_item: QTreeWidgetItem, files: list): + """给一个已展开的文件夹项填充子节点""" + if not isinstance(files, list): + return + + # 移除旧的占位符 + while parent_item.childCount() > 0: + child = parent_item.child(0) + if child and child.data(0, Qt.ItemDataRole.UserRole + 10) == "placeholder": + parent_item.removeChild(child) + else: + break + + # 再清空所有子节点(因为已经懒加载过了) + parent_item.takeChildren() + + # 添加新的子节点 + for f in files: + child_item = self._create_tree_item(f) + # 如果是文件夹,添加一个占位子节点使其可展开 + if f.get("is_dir", False): + placeholder = QTreeWidgetItem() + placeholder.setText(0, "") + placeholder.setData(0, Qt.ItemDataRole.UserRole + 10, "placeholder") + child_item.addChild(placeholder) + parent_item.addChild(child_item) + + parent_item.setExpanded(True) + self.file_tree.sortItems(0, Qt.SortOrder.AscendingOrder) + + def _create_tree_item(self, f: dict) -> QTreeWidgetItem: + """根据文件信息创建树节点项""" + name = f.get("name", "未知") + is_dir = f.get("is_dir", False) + size = f.get("size", 0) + modified = f.get("modified", "") + perm = f.get("permissions", "") + + if isinstance(modified, (int, float)): + try: + modified = datetime.fromtimestamp(modified).strftime( + "%Y-%m-%d %H:%M:%S" + ) + except Exception: + modified = str(modified) + + item = QTreeWidgetItem() + item.setText(0, name) + item.setData(0, Qt.ItemDataRole.UserRole, f.get("path", "")) + item.setData(0, Qt.ItemDataRole.UserRole + 1, is_dir) + + if is_dir: + item.setText(1, "—") + item.setText(2, "📁 文件夹") + item.setForeground(0, QBrush(QColor("#569cd6"))) + # 添加一个占位子节点使其可展开 + placeholder = QTreeWidgetItem() + placeholder.setText(0, "") + placeholder.setData(0, Qt.ItemDataRole.UserRole + 10, "placeholder") + item.addChild(placeholder) + else: + item.setText(1, FileSizeFormatter.format(size)) + ext = os.path.splitext(name)[1].lower() + type_map = { + ".py": "🐍 Python", + ".js": "🟨 JavaScript", + ".ts": "🔵 TypeScript", + ".html": "🌐 HTML", + ".css": "🎨 CSS", + ".json": "📋 JSON", + ".txt": "📄 文本", + ".md": "📝 Markdown", + ".yaml": "⚙️ YAML", + ".yml": "⚙️ YAML", + ".sh": "⚡ Shell", + ".zip": "📦 ZIP", + ".tar": "📦 TAR", + ".gz": "📦 GZip", + ".png": "🖼️ 图片", + ".jpg": "🖼️ 图片", + ".jpeg": "🖼️ 图片", + ".gif": "🖼️ GIF", + ".svg": "🖼️ SVG", + ".pdf": "📕 PDF", + ".log": "📋 日志", + ".conf": "⚙️ 配置", + ".env": "🔒 环境", + ".toml": "⚙️ TOML", + ".xml": "📋 XML", + ".lock": "🔒 锁定", + } + item.setText(2, type_map.get(ext, "📄 文件")) + item.setForeground(0, QBrush(QColor("#d4d4d4"))) + + item.setText(3, str(modified)) + item.setText(4, perm) + + return item + + def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int): + """双击节点:文件夹展开/收起懒加载,文件显示信息""" + is_dir = item.data(0, Qt.ItemDataRole.UserRole + 1) + path = item.data(0, Qt.ItemDataRole.UserRole) + + if not is_dir: + # 双击文件显示详细信息 + self._show_file_info(item) + return + + if not path: + return + + # 如果已展开,直接折叠 + if item.isExpanded(): + item.setExpanded(False) + return + + # 检查是否已加载过子目录(通过是否有真实子节点判断) + has_real_children = False + for i in range(item.childCount()): + child = item.child(i) + if child and child.data(0, Qt.ItemDataRole.UserRole + 10) != "placeholder": + has_real_children = True + break + + if has_real_children: + item.setExpanded(True) + return + + # 懒加载:从远程获取子目录内容 + self.log_panel.append_log(f"📂 正在展开: {path}", "info") + self._pending_expand_item = item + self._start_worker(WorkspaceWorker.OPERATION_LIST, dir_path=path) + + def _on_item_clicked(self, item: QTreeWidgetItem, column: int): + """单击节点显示文件信息""" + self._show_file_info(item) + + def _show_file_info(self, item: QTreeWidgetItem): + """显示文件/文件夹的详细信息""" + name = item.text(0) + is_dir = item.data(0, Qt.ItemDataRole.UserRole + 1) + path = item.data(0, Qt.ItemDataRole.UserRole) or "" + + size = item.text(1) + ftype = item.text(2) + modified = item.text(3) + perm = item.text(4) + + if is_dir: + child_count = 0 + for i in range(item.childCount()): + child = item.child(i) + if ( + child + and child.data(0, Qt.ItemDataRole.UserRole + 10) != "placeholder" + ): + child_count += 1 + info_parts = [ + f"📁 **{name}**", + f"路径: {path}", + f"类型: 文件夹", + f"大小: {size}", + f"修改时间: {modified}" if modified else "", + ] + info_text = " | ".join(p for p in info_parts if p) + else: + info_parts = [ + f"📄 **{name}**", + f"路径: {path}", + f"类型: {ftype}", + f"大小: {size}", + f"修改时间: {modified}" if modified else "", + ] + info_text = " | ".join(p for p in info_parts if p) + + self.info_label.setText(f"📌 {info_text}") + self.info_panel.show() + self._current_info_item = item + + # ---- 右键菜单 ---- + + def _get_selected_paths(self) -> list: + paths = [] + for item in self.file_tree.selectedItems(): + path = item.data(0, Qt.ItemDataRole.UserRole) + if path: + paths.append(path) + return paths + + def _get_selected_names(self) -> list: + names = [] + for item in self.file_tree.selectedItems(): + names.append(item.text(0)) + return names + + def _show_context_menu(self, pos): + items = self.file_tree.selectedItems() + if not items: + return + + menu = QMenu(self) + menu.setStyleSheet(""" + QMenu { background-color: #2d2d2d; color: #ccc; border: 1px solid #3c3c3c; padding: 4px; } + QMenu::item { padding: 6px 24px; border-radius: 3px; } + QMenu::item:selected { background-color: #094771; } + QMenu::separator { height: 1px; background: #3c3c3c; margin: 4px 8px; } + """) + + if len(items) == 1: + item = items[0] + name = item.text(0) + is_dir = item.data(0, Qt.ItemDataRole.UserRole + 1) + + rename_action = QAction(f'✏️ 重命名 "{name}"', self) + rename_action.triggered.connect(lambda: self._rename_item(item)) + menu.addAction(rename_action) + + copy_action = QAction(f'📋 复制 "{name}"', self) + copy_action.triggered.connect(lambda: self._copy_item(item)) + menu.addAction(copy_action) + + if is_dir: + download_action = QAction("📥 下载文件夹(打包为 zip)", self) + else: + download_action = QAction("📥 下载文件", self) + download_action.triggered.connect(lambda: self._download_item(item)) + menu.addAction(download_action) + + menu.addSeparator() + + delete_action = QAction(f"🗑️ 删除选中的 {len(items)} 个项目", self) + delete_action.triggered.connect(self._delete_selected) + menu.addAction(delete_action) + + menu.exec(self.file_tree.viewport().mapToGlobal(pos)) + + # ---- 文件操作 ---- + + def _rename_item(self, item): + old_name = item.text(0) + new_name, ok = QInputDialog.getText( + self, "重命名", "请输入新名称:", text=old_name + ) + if not ok or not new_name.strip() or new_name.strip() == old_name: + return + + src_path = item.data(0, Qt.ItemDataRole.UserRole) + if not src_path: + return + + dst_dir = ( + src_path.rsplit("/", 1)[0] + if "/" in src_path + else src_path.rsplit("\\", 1)[0] + ) + dst_path = dst_dir.rstrip("/") + "/" + new_name.strip() + + self._start_worker( + WorkspaceWorker.OPERATION_RENAME, + src=src_path, + dst=dst_path, + ) + + def _copy_item(self, item): + src_path = item.data(0, Qt.ItemDataRole.UserRole) + name = item.text(0) + base, ext = os.path.splitext(name) + new_name = f"{base}_copy{ext}" + dst_dir = ( + src_path.rsplit("/", 1)[0] + if "/" in src_path + else src_path.rsplit("\\", 1)[0] + ) + dst_path = dst_dir.rstrip("/") + "/" + new_name + + self._start_worker( + WorkspaceWorker.OPERATION_COPY, + src=src_path, + dst=dst_path, + ) + + def _download_item(self, item): + path = item.data(0, Qt.ItemDataRole.UserRole) + name = item.text(0) + is_dir = item.data(0, Qt.ItemDataRole.UserRole + 1) + + if is_dir: + local_path, _ = QFileDialog.getSaveFileName( + self, "保存文件夹", f"{name}.zip", "ZIP 文件 (*.zip)" + ) + if not local_path: + return + self._download_folder_as_zip(path, local_path) + else: + local_path, _ = QFileDialog.getSaveFileName( + self, "保存文件", name, "所有文件 (*)" + ) + if not local_path: + return + self._download_single_file(path, local_path) + + def _cleanup_download_worker(self): + """清理下载线程和 worker""" + if self._download_worker_thread: + try: + self._download_worker_thread.finished.disconnect() + except TypeError, RuntimeError: + pass + if self._download_worker_thread.isRunning(): + self._download_worker_thread.quit() + if not self._download_worker_thread.wait(5000): + self._download_worker_thread.terminate() + self._download_worker_thread.wait(3000) + self._download_worker_thread = None + if self._download_worker: + try: + self._download_worker.log_signal.disconnect() + self._download_worker.finished_signal.disconnect() + self._download_worker.error_signal.disconnect() + except TypeError, RuntimeError: + pass + self._download_worker = None + + def _download_single_file(self, remote_path, local_path): + if not self._config_panel or not self._validate_ssh_config(): + return + + # 确保本地目标目录存在 + local_dir = os.path.dirname(local_path) + if local_dir: + os.makedirs(local_dir, exist_ok=True) + + self._cleanup_download_worker() + + config = self._config_panel.get_config() + self._download_worker = DownloadWorker(config, remote_path, local_path) + self._download_worker_thread = QThread(self) + self._download_worker.moveToThread(self._download_worker_thread) + + self._download_worker.log_signal.connect(self.log_panel.append_log) + self._download_worker.finished_signal.connect(self._on_download_worker_finished) + self._download_worker.error_signal.connect(self._on_download_worker_error) + self._download_worker_thread.started.connect(self._download_worker.run) + self._download_worker_thread.finished.connect(self._cleanup_download_worker) + + self.log_panel.append_log(f"📥 正在下载: {remote_path} -> {local_path}", "info") + self._download_worker_thread.start() + + def _on_download_worker_finished(self, success: bool): + self.log_panel.append_log( + "✅ 下载完成" if success else "❌ 下载失败", + "success" if success else "error", + ) + # 通知线程退出事件循环,触发 finished 信号从而调用 _cleanup_download_worker + if self._download_worker_thread and self._download_worker_thread.isRunning(): + self._download_worker_thread.quit() + + def _on_download_worker_error(self, error_msg: str): + self.log_panel.append_log(f"❌ 下载失败: {error_msg}", "error") + if self._download_worker_thread and self._download_worker_thread.isRunning(): + self._download_worker_thread.quit() + + def _download_folder_as_zip(self, folder_path: str, local_zip_path: str): + if not self._config_panel or not self._validate_ssh_config(): + return + + config = self._config_panel.get_config() + ssh_only = config.get("ssh_only", False) + + try: + if ssh_only: + from virtualbox_client.utils.ssh_manager import SSHManager + + vm = SSHManager( + config["ssh_host"], + config["ssh_port"], + config["ssh_user"], + config["ssh_pass"], + ) + else: + from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager + + vm = VBoxSSHManager( + config["vm_name"], + config["ssh_host"], + config["ssh_port"], + config["ssh_user"], + config["ssh_pass"], + ) + + self.log_panel.append_log( + "📦 正在打包远程文件夹(排除 .venv 和 node_modules)...", "info" + ) + zip_name = f"/tmp/workspace_download_{int(time.time())}.zip" + vm.exec_command( + f'cd "{folder_path}" && zip -r "{zip_name}" . -x ".venv/*" "./src/chongming-web/node_modules/*"' + ) + + self.log_panel.append_log("📥 正在下载打包文件...", "info") + success = vm.download_file(zip_name, local_zip_path) + vm.exec_command(f'rm -f "{zip_name}"') + + if success: + self.log_panel.append_log( + f"✅ 文件夹已下载到: {local_zip_path}", "success" + ) + else: + self.log_panel.append_log("❌ 文件夹下载失败", "error") + + except Exception as e: + self.log_panel.append_log(f"❌ 下载失败: {e}", "error") + + def _delete_selected(self): + paths = self._get_selected_paths() + names = self._get_selected_names() + if not paths: + return + + confirm_msg = "确定要删除以下项目吗?\n" + "\n".join(f" - {n}" for n in names) + reply = QMessageBox.question( + self, + "确认删除", + confirm_msg, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + + self._delete_paths_serial(paths) + + def _delete_paths_serial(self, paths: list): + """批量删除所有选中路径(单条命令一次性删除所有,避免递归线程)""" + if not paths: + self._soft_refresh() + return + + self._cleanup_worker() + self._set_busy(True, "正在删除...") + + config = self._config_panel.get_config() + self._worker = WorkspaceWorker( + config, WorkspaceWorker.OPERATION_DELETE, path=paths + ) + self._worker_thread = QThread(self) + self._worker.moveToThread(self._worker_thread) + + self._worker.log_signal.connect(self.log_panel.append_log) + self._worker.finished_signal.connect(self._on_batch_delete_finished) + self._worker.error_signal.connect(self._on_worker_error) + self._worker_thread.started.connect(self._worker.run) + self._worker_thread.finished.connect(self._cleanup_worker) + + self._worker_thread.start() + + def _on_batch_delete_finished(self, result): + self._set_busy(False, "就绪") + if result: + self.log_panel.append_log("✅ 删除完成", "success") + else: + self.log_panel.append_log("❌ 删除失败", "error") + self._soft_refresh() + + def _upload_folder_to_workspace(self): + if not self._config_panel or not self._validate_ssh_config(): + return + + local_dir = QFileDialog.getExistingDirectory(self, "选择要上传到工作区的文件夹") + if not local_dir: + return + + folder_name = os.path.basename(local_dir.rstrip("/\\")) + remote_path = f"~/workspace/{folder_name}" + self._start_worker( + WorkspaceWorker.OPERATION_UPLOAD_DIR, + local_path=local_dir, + remote_path=remote_path, + ) + + def _upload_to_workspace(self): + if not self._config_panel or not self._validate_ssh_config(): + return + + local_paths, _ = QFileDialog.getOpenFileNames(self, "选择要上传到工作区的文件") + if not local_paths: + return + + # 只上传第一个文件(后续通过多次点击上传更多文件) + local_path = local_paths[0] + name = os.path.basename(local_path) + remote_path = f"~/workspace/{name}" + self._start_worker( + WorkspaceWorker.OPERATION_UPLOAD, + local_path=local_path, + remote_path=remote_path, + ) + + def _create_folder(self): + name, ok = QInputDialog.getText(self, "新建文件夹", "请输入文件夹名称:") + if not ok or not name.strip(): + return + self._start_worker(WorkspaceWorker.OPERATION_MKDIR, name=name.strip()) + + +class LogPanel(QWidget): + """高性能日志面板(使用 QPlainTextEdit 替代 QTextEdit)""" + + def __init__(self): + super().__init__() + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + tool_layout = QHBoxLayout() + self.clear_btn = QPushButton("🗑️ 清空") + self.clear_btn.clicked.connect(self.clear_log) + tool_layout.addWidget(self.clear_btn) + tool_layout.addStretch() + self.auto_scroll_check = QCheckBox("📌 自动滚动") + self.auto_scroll_check.setChecked(True) + tool_layout.addWidget(self.auto_scroll_check) + layout.addLayout(tool_layout) + + self.log_output = QPlainTextEdit() + self.log_output.setReadOnly(True) + self.log_output.setMaximumBlockCount(10000) + self.log_output.setFont(QFont("Consolas", 10)) + self.log_output.setStyleSheet(""" + QPlainTextEdit { + background-color: #1e1e1e; + color: #d4d4d4; + border: 1px solid #333; + border-radius: 4px; + padding: 8px; + } + """) + layout.addWidget(self.log_output) + + self._prefix_map = { + "error": "❌ ", + "warning": "⚠️ ", + "success": "✅ ", + "task": "📋 ", + "info": "", + } + + def append_log(self, message: str, level: str = "info"): + timestamp = datetime.now().strftime("%H:%M:%S") + level = self._detect_level(message, level) + prefix = self._prefix_map.get(level, "") + line = f"[{timestamp}] {prefix}{message}" + self.log_output.appendPlainText(line) + if self.auto_scroll_check.isChecked(): + self.log_output.moveCursor(QTextCursor.MoveOperation.End) + + @staticmethod + def _detect_level(message: str, given_level: str) -> str: + if given_level != "info": + return given_level + msg_upper = message.upper() + if any( + x in msg_upper + for x in ["❌", "ERROR", "EXCEPTION", "FAILED", "失败", "错误"] + ): + return "error" + if any(x in msg_upper for x in ["⚠️", "WARN", "跳过", "重试"]): + return "warning" + if any(x in msg_upper for x in ["✅", "SUCCESS", "完成", "成功"]): + return "success" + return "info" + + def clear_log(self): + self.log_output.clear() + + def clear_and_set_header(self, task: str): + self.clear_log() + self.append_log("=" * 60, "info") + self.append_log(f"📋 开始执行任务: {task}", "info") + self.append_log("=" * 60, "info") + + +class TaskPanel(QWidget): + """任务输入和执行面板""" + + def __init__(self, log_panel: LogPanel): + super().__init__() + self.log_panel = log_panel + self._config_panel = None + self._worker_thread = None + self._worker = None + self._shutdown_thread = None + self._shutdown_worker = None + self.setup_ui() + + def set_config_panel(self, panel: ConfigPanel): + self._config_panel = panel + if hasattr(self, "workspace_panel") and self.workspace_panel: + self.workspace_panel._config_panel = panel + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self.tab_widget = QTabWidget() + self.tab_widget.setStyleSheet(""" + QTabWidget::pane { border: 1px solid #3c3c3c; border-radius: 4px; background: transparent; } + QTabBar::tab { background: #2d2d2d; color: #ccc; padding: 8px 16px; border: 1px solid #3c3c3c; border-bottom: none; border-top-left-radius: 4px; border-top-right-radius: 4px; } + QTabBar::tab:selected { background: #3c3c3c; color: #569cd6; font-weight: bold; } + QTabBar::tab:hover { background: #4c4c4c; } + """) + + # ====== 任务描述 Tab ====== + task_tab = QWidget() + task_layout = QVBoxLayout(task_tab) + task_layout.setContentsMargins(8, 8, 8, 8) + + input_group = QGroupBox("任务描述") + input_layout = QVBoxLayout(input_group) + + self.task_input = QPlainTextEdit() + self.task_input.setPlaceholderText( + "请输入自然语言任务描述,例如:\n" + " - 在虚拟机中安装 nginx 并确认服务状态\n" + " - 创建一个 /home/test.txt 文件,写入 'Hello World'\n" + " - 查看系统当前的内存使用情况" + ) + self.task_input.setMaximumHeight(100) + self.task_input.setFont(QFont("Microsoft YaHei UI", 10)) + input_layout.addWidget(self.task_input) + + input_layout.addWidget(QLabel("项目路径:")) + self.project_path_edit = QLineEdit() + self.project_path_edit.setPlaceholderText("例如: /home/user/myproject") + input_layout.addWidget(self.project_path_edit) + + task_layout.addWidget(input_group) + + task_btn_layout = QHBoxLayout() + self.run_btn = QPushButton("▶️ 执行任务") + self.run_btn.setStyleSheet(""" + QPushButton { + background-color: #0e639c; color: white; padding: 10px 30px; + font-size: 14px; font-weight: bold; border: none; border-radius: 6px; + } + QPushButton:hover { background-color: #1177bb; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.run_btn.clicked.connect(self.run_task) + task_btn_layout.addWidget(self.run_btn) + + self.cancel_btn = QPushButton("⏹️ 取消") + self.cancel_btn.setStyleSheet(""" + QPushButton { + background-color: #d32f2f; color: white; padding: 10px 20px; + font-size: 14px; font-weight: bold; border: none; border-radius: 6px; + } + QPushButton:hover { background-color: #e53935; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.cancel_btn.clicked.connect(self.cancel_task) + self.cancel_btn.setEnabled(False) + task_btn_layout.addWidget(self.cancel_btn) + task_layout.addLayout(task_btn_layout) + self.tab_widget.addTab(task_tab, "📋 任务执行") + + # ====== 文件传输 Tab ====== + file_tab = QWidget() + file_layout = QVBoxLayout(file_tab) + file_layout.setContentsMargins(8, 8, 8, 8) + + upload_group = QGroupBox("📤 上传文件到虚拟机") + upload_layout = QFormLayout(upload_group) + + upload_local_layout = QHBoxLayout() + self.upload_local_edit = QLineEdit(placeholderText="选择本地文件路径...") + upload_local_layout.addWidget(self.upload_local_edit) + self.upload_browse_btn = QPushButton("📂 浏览") + self.upload_browse_btn.setFixedWidth(80) + self.upload_browse_btn.clicked.connect(self._browse_upload_file) + upload_local_layout.addWidget(self.upload_browse_btn) + upload_layout.addRow("本地文件:", upload_local_layout) + + self.upload_remote_edit = QLineEdit( + placeholderText="例如: ~/workspace/target_file.txt" + ) + self.upload_remote_edit.setText("~/workspace/") + upload_layout.addRow("远程路径:", self.upload_remote_edit) + + self.upload_btn = QPushButton("📤 上传") + self.upload_btn.setStyleSheet(""" + QPushButton { background-color: #2d7d46; color: white; padding: 8px 20px; + font-size: 13px; font-weight: bold; border: none; border-radius: 4px; } + QPushButton:hover { background-color: #389e54; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.upload_btn.clicked.connect(self.upload_file) + upload_layout.addRow("", self.upload_btn) + file_layout.addWidget(upload_group) + + download_group = QGroupBox("📥 从虚拟机下载文件") + download_layout = QFormLayout(download_group) + + self.download_remote_edit = QLineEdit( + placeholderText="例如: ~/workspace/source_file.txt" + ) + download_layout.addRow("远程路径:", self.download_remote_edit) + + download_local_layout = QHBoxLayout() + self.download_local_edit = QLineEdit(placeholderText="选择本地保存路径...") + download_local_layout.addWidget(self.download_local_edit) + self.download_browse_btn = QPushButton("📂 浏览") + self.download_browse_btn.setFixedWidth(80) + self.download_browse_btn.clicked.connect(self._browse_download_file) + download_local_layout.addWidget(self.download_browse_btn) + download_layout.addRow("保存到:", download_local_layout) + + self.download_btn = QPushButton("📥 下载") + self.download_btn.setStyleSheet(""" + QPushButton { background-color: #8b5cf6; color: white; padding: 8px 20px; + font-size: 13px; font-weight: bold; border: none; border-radius: 4px; } + QPushButton:hover { background-color: #a78bfa; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.download_btn.clicked.connect(self.download_file) + download_layout.addRow("", self.download_btn) + file_layout.addWidget(download_group) + + file_layout.addStretch() + self.tab_widget.addTab(file_tab, "📁 文件传输") + + # ====== Script Execution Tab ====== + script_tab = QWidget() + script_layout = QVBoxLayout(script_tab) + script_layout.setContentsMargins(8, 8, 8, 8) + + script_group = QGroupBox("Upload and Execute Script") + script_group_layout = QVBoxLayout(script_group) + + script_path_layout = QHBoxLayout() + self.script_path_edit = QLineEdit( + placeholderText="Select local .sh script file..." + ) + script_path_layout.addWidget(self.script_path_edit) + self.script_browse_btn = QPushButton("Browse") + self.script_browse_btn.setFixedWidth(80) + self.script_browse_btn.clicked.connect(self._browse_script) + script_path_layout.addWidget(self.script_browse_btn) + script_group_layout.addWidget(QLabel("Local Script:")) + script_group_layout.addLayout(script_path_layout) + + self.run_script_btn = QPushButton("Upload and Execute Script") + self.run_script_btn.setStyleSheet(""" + QPushButton { background-color: #8e44ad; color: white; padding: 10px 30px; + font-size: 14px; font-weight: bold; border: none; border-radius: 6px; } + QPushButton:hover { background-color: #9b59b6; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.run_script_btn.clicked.connect(self._run_script) + script_group_layout.addWidget(self.run_script_btn) + + script_layout.addWidget(script_group) + + desc_group = QGroupBox("How it works") + desc_layout = QVBoxLayout(desc_group) + desc_label = QLabel( + "1. Select a local .sh script file\n" + "2. Click button to auto:\n" + " Upload script to /tmp/\n" + " Execute (deploy_* scripts use sudo, others run as normal user)\n" + " Download execution log\n" + " Clean remote temp files" + ) + desc_label.setStyleSheet("color: #aaa; font-size: 12px; line-height: 1.6;") + desc_layout.addWidget(desc_label) + script_layout.addWidget(desc_group) + + script_layout.addStretch() + self.tab_widget.addTab(script_tab, "Script") + + # ====== 工作区 Tab ====== + self.workspace_panel = WorkspacePanel( + self._config_panel or ConfigPanel(), self.log_panel + ) + self.tab_widget.addTab(self.workspace_panel, "📂 工作区") + + self.tab_widget.currentChanged.connect(self._on_tab_changed) + + layout.addWidget(self.tab_widget) + + def _on_tab_changed(self, index: int): + if self.tab_widget.tabText(index) == "📂 工作区": + if self._config_panel and self.workspace_panel._validate_ssh_config(): + QTimer.singleShot(300, self.workspace_panel.refresh_root) + + def _set_ui_running(self, running: bool): + self.run_btn.setEnabled(not running) + self.cancel_btn.setEnabled(running) + self.task_input.setEnabled(not running) + self.run_script_btn.setEnabled(not running) + self.script_browse_btn.setEnabled(not running) + self.script_path_edit.setEnabled(not running) + if self._config_panel: + self._config_panel.set_enabled(not running) + + def _cleanup_worker(self): + if self._worker_thread: + try: + self._worker_thread.finished.disconnect() + except TypeError, RuntimeError: + pass + if self._worker_thread.isRunning(): + self._worker_thread.quit() + if not self._worker_thread.wait(5000): + self._worker_thread.terminate() + self._worker_thread.wait(3000) + self._worker_thread = None + if self._worker: + try: + self._worker.log_signal.disconnect() + self._worker.finished_signal.disconnect() + self._worker.error_signal.disconnect() + self._worker.cancelled_signal.disconnect() + except TypeError, RuntimeError: + pass + self._worker = None + + def _ensure_old_threads_cleaned(self): + self._cleanup_worker() + self._cleanup_shutdown() + + def run_task(self): + if not self._config_panel or not self._config_panel.validate(): + return + task = self.task_input.toPlainText().strip() + if not task: + QMessageBox.warning(self, "提示", "请输入任务描述") + return + + project_path = self.project_path_edit.text().strip() + if not project_path: + QMessageBox.warning(self, "提示", "请输入项目路径") + return + + project_name = os.path.basename(project_path.rstrip("/\\")) + enhanced_task = ( + f'在 {project_path} 下有一个 "{project_name}" 项目,' + f"请仔细阅读 README.md 文档再执行操作," + f"最后将更新记录写入 README.md 中," + f"不允许使用启动或打包项目\n\n{task}" + ) + + config = self._config_panel.get_config() + self._ensure_old_threads_cleaned() + self._set_ui_running(True) + self.log_panel.clear_and_set_header(enhanced_task) + + self._worker = AgentWorker(config, enhanced_task) + self._worker_thread = QThread(self) + self._worker.moveToThread(self._worker_thread) + + self._worker.log_signal.connect(self.log_panel.append_log) + self._worker.finished_signal.connect(self._on_task_finished) + self._worker.error_signal.connect(self._on_task_error) + self._worker.cancelled_signal.connect(self._on_task_cancelled) + self._worker_thread.started.connect(self._worker.run) + self._worker_thread.finished.connect(self._on_worker_thread_finished) + + self._worker_thread.start() + + def _on_worker_thread_finished(self): + self._cleanup_worker() + + def _on_task_finished(self, vm_started_by_us: bool): + self._set_ui_running(False) + self.log_panel.append_log("=" * 60, "info") + self.log_panel.append_log("✅ 任务已成功完成", "success") + + if vm_started_by_us: + reply = QMessageBox.question( + self, + "虚拟机管理", + "虚拟机是由本任务启动的。\n是否要关闭虚拟机?\n\n" + "选「是」→ 关闭虚拟机\n" + "选「否」→ 保持虚拟机运行", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.Yes, + ) + if reply == QMessageBox.StandardButton.Yes: + config = self._config_panel.get_config() if self._config_panel else {} + self._start_shutdown_thread(config) + + def _on_task_error(self, error_msg: str): + self._set_ui_running(False) + self.log_panel.append_log(f"❌ 任务失败: {error_msg}", "error") + + def _on_task_cancelled(self): + self._set_ui_running(False) + self.log_panel.append_log("⏹️ 任务已取消", "warning") + + def _start_shutdown_thread(self, config: dict): + self._cleanup_shutdown() + self._shutdown_worker = ShutdownWorker(config) + self._shutdown_thread = QThread(self) + self._shutdown_worker.moveToThread(self._shutdown_thread) + + self._shutdown_worker.log.connect(self.log_panel.append_log) + self._shutdown_worker.finished.connect(self._on_shutdown_finished) + self._shutdown_worker.error.connect(self._on_shutdown_error) + self._shutdown_thread.started.connect(self._shutdown_worker.run) + self._shutdown_thread.finished.connect(self._cleanup_shutdown) + + self._shutdown_thread.start() + + def _on_shutdown_finished(self): + self.log_panel.append_log("✅ 虚拟机已关闭", "success") + + def _on_shutdown_error(self, error_msg: str): + self.log_panel.append_log(f"❌ 关机失败: {error_msg}", "error") + + def _cleanup_shutdown(self): + if self._shutdown_thread: + try: + self._shutdown_thread.finished.disconnect() + except TypeError, RuntimeError: + pass + if self._shutdown_thread.isRunning(): + self._shutdown_thread.quit() + if not self._shutdown_thread.wait(5000): + self._shutdown_thread.terminate() + self._shutdown_thread.wait(3000) + self._shutdown_thread = None + if self._shutdown_worker: + try: + self._shutdown_worker.log.disconnect() + self._shutdown_worker.finished.disconnect() + self._shutdown_worker.error.disconnect() + except TypeError, RuntimeError: + pass + self._shutdown_worker = None + + def cancel_task(self): + if self._worker: + self._worker.cancel() + self.log_panel.append_log("⏹️ 正在取消任务...", "warning") + QTimer.singleShot(5000, self._force_cleanup_if_stuck) + + def _force_cleanup_if_stuck(self): + if self._worker_thread and self._worker_thread.isRunning(): + self.log_panel.append_log("⚠️ 任务线程未响应,正在强制终止...", "warning") + self._worker_thread.quit() + if not self._worker_thread.wait(5000): + self._worker_thread.terminate() + self._worker_thread.wait(3000) + self._cleanup_worker() + self._set_ui_running(False) + self.log_panel.append_log("⏹️ 任务已强制终止", "warning") + + _upload_thread = None + _upload_worker = None + + def _cleanup_upload(self): + if self._upload_thread: + try: + self._upload_thread.finished.disconnect() + except TypeError, RuntimeError: + pass + if self._upload_thread.isRunning(): + self._upload_thread.quit() + if not self._upload_thread.wait(5000): + self._upload_thread.terminate() + self._upload_thread.wait(3000) + self._upload_thread = None + if self._upload_worker: + try: + self._upload_worker.log_signal.disconnect() + self._upload_worker.finished_signal.disconnect() + self._upload_worker.error_signal.disconnect() + except TypeError, RuntimeError: + pass + self._upload_worker = None + + def _browse_upload_file(self): + file_path, _ = QFileDialog.getOpenFileName(self, "选择要上传的文件") + if file_path: + self.upload_local_edit.setText(file_path) + name = os.path.basename(file_path) + remote = self.upload_remote_edit.text().strip() + if remote.endswith("/"): + self.upload_remote_edit.setText(remote + name) + else: + self.upload_remote_edit.setText(f"~/workspace/{name}") + + def upload_file(self): + if not self._config_panel or not self._config_panel.validate(): + return + local_path = self.upload_local_edit.text().strip() + remote_path = self.upload_remote_edit.text().strip() + if not local_path: + QMessageBox.warning(self, "提示", "请选择要上传的本地文件") + return + if not remote_path: + QMessageBox.warning(self, "提示", "请输入虚拟机中的目标路径") + return + if not os.path.isfile(local_path): + QMessageBox.warning(self, "提示", f"本地文件不存在: {local_path}") + return + + self._cleanup_upload() + self._set_ui_running(True) + + config = self._config_panel.get_config() + self._upload_worker = UploadWorker(config, local_path, remote_path) + self._upload_thread = QThread(self) + self._upload_worker.moveToThread(self._upload_thread) + + self._upload_worker.log_signal.connect(self.log_panel.append_log) + self._upload_worker.finished_signal.connect(self._on_upload_finished) + self._upload_worker.error_signal.connect(self._on_upload_error) + self._upload_thread.started.connect(self._upload_worker.run) + self._upload_thread.finished.connect(self._cleanup_upload) + + self._upload_thread.start() + + def _on_upload_finished(self, success: bool): + self._set_ui_running(False) + if success: + self.log_panel.append_log("✅ 上传操作已完成", "success") + + def _on_upload_error(self, error_msg: str): + self._set_ui_running(False) + self.log_panel.append_log(f"❌ 上传失败: {error_msg}", "error") + + _download_thread = None + _download_worker = None + + def _cleanup_download(self): + if self._download_thread: + try: + self._download_thread.finished.disconnect() + except TypeError, RuntimeError: + pass + if self._download_thread.isRunning(): + self._download_thread.quit() + if not self._download_thread.wait(5000): + self._download_thread.terminate() + self._download_thread.wait(3000) + self._download_thread = None + if self._download_worker: + try: + self._download_worker.log_signal.disconnect() + self._download_worker.finished_signal.disconnect() + self._download_worker.error_signal.disconnect() + except TypeError, RuntimeError: + pass + self._download_worker = None + + def _browse_download_file(self): + file_path, _ = QFileDialog.getSaveFileName(self, "选择保存位置") + if file_path: + self.download_local_edit.setText(file_path) + + def download_file(self): + if not self._config_panel or not self._config_panel.validate(): + return + remote_path = self.download_remote_edit.text().strip() + local_path = self.download_local_edit.text().strip() + if not remote_path: + QMessageBox.warning(self, "提示", "请输入虚拟机中的文件路径") + return + if not local_path: + QMessageBox.warning(self, "提示", "请选择本地保存路径") + return + + self._cleanup_download() + self._set_ui_running(True) + + config = self._config_panel.get_config() + self._download_worker = DownloadWorker(config, remote_path, local_path) + self._download_thread = QThread(self) + self._download_worker.moveToThread(self._download_thread) + + self._download_worker.log_signal.connect(self.log_panel.append_log) + self._download_worker.finished_signal.connect(self._on_download_finished) + self._download_worker.error_signal.connect(self._on_download_error) + self._download_thread.started.connect(self._download_worker.run) + self._download_thread.finished.connect(self._cleanup_download) + + self._download_thread.start() + + def _on_download_finished(self, success: bool): + self._set_ui_running(False) + if success: + self.log_panel.append_log("✅ 下载操作已完成", "success") + + def _on_download_error(self, error_msg: str): + self._set_ui_running(False) + self.log_panel.append_log(f"❌ 下载失败: {error_msg}", "error") + + _script_thread = None + _script_worker = None + + def _cleanup_script(self): + if self._script_thread: + try: + self._script_thread.finished.disconnect() + except TypeError, RuntimeError: + pass + if self._script_thread.isRunning(): + self._script_thread.quit() + if not self._script_thread.wait(5000): + self._script_thread.terminate() + self._script_thread.wait(3000) + self._script_thread = None + if self._script_worker: + try: + self._script_worker.log_signal.disconnect() + self._script_worker.finished_signal.disconnect() + self._script_worker.error_signal.disconnect() + except TypeError, RuntimeError: + pass + self._script_worker = None + + def _browse_script(self): + file_path, _ = QFileDialog.getOpenFileName( + self, "Select script", "", "Shell scripts (*.sh);;All files (*)" + ) + if file_path: + self.script_path_edit.setText(file_path) + + def _run_script(self): + if not self._config_panel or not self._config_panel.validate(): + return + local_path = self.script_path_edit.text().strip() + if not local_path: + QMessageBox.warning(self, "Hint", "Please select a script file") + return + if not os.path.isfile(local_path): + QMessageBox.warning(self, "Hint", "File not found: " + local_path) + return + + self._cleanup_script() + self._set_ui_running(True) + self.tab_widget.setCurrentIndex(0) + self.log_panel.clear_log() + + config = self._config_panel.get_config() + self._script_worker = ScriptWorker(config, local_path) + self._script_thread = QThread(self) + self._script_worker.moveToThread(self._script_thread) + + self._script_worker.log_signal.connect(self.log_panel.append_log) + self._script_worker.finished_signal.connect(self._on_script_finished) + self._script_worker.error_signal.connect(self._on_script_error) + self._script_thread.started.connect(self._script_worker.run) + self._script_thread.finished.connect(self._cleanup_script) + + self._script_thread.start() + + def _on_script_finished(self, success: bool): + self._set_ui_running(False) + if success: + self.log_panel.append_log("Script execution completed", "success") + + def _on_script_error(self, error_msg: str): + self._set_ui_running(False) + self.log_panel.append_log("Script error: " + error_msg, "error") + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("VirtualBox Client - 虚拟机管理助手") + self.setMinimumSize(1200, 800) + self.setup_ui() + self._center_window() + + def _center_window(self): + screen = QApplication.primaryScreen().geometry() + self.move( + (screen.width() - self.width()) // 2, (screen.height() - self.height()) // 2 + ) + + def setup_ui(self): + central = QWidget() + self.setCentralWidget(central) + main_layout = QHBoxLayout(central) + main_layout.setContentsMargins(10, 10, 10, 10) + + splitter = QSplitter(Qt.Orientation.Horizontal) + + left = QWidget() + left_layout = QVBoxLayout(left) + left_layout.setContentsMargins(0, 0, 5, 0) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + self.config_panel = ConfigPanel() + scroll.setWidget(self.config_panel) + left_layout.addWidget(scroll) + left.setMinimumWidth(320) + left.setMaximumWidth(400) + splitter.addWidget(left) + + right = QWidget() + right_layout = QVBoxLayout(right) + right_layout.setContentsMargins(5, 0, 0, 0) + self.log_panel = LogPanel() + right_layout.addWidget(self.log_panel, stretch=2) + self.task_panel = TaskPanel(self.log_panel) + self.task_panel.set_config_panel(self.config_panel) + right_layout.addWidget(self.task_panel, stretch=1) + splitter.addWidget(right) + + splitter.setSizes([350, 850]) + main_layout.addWidget(splitter) + + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + self.status_label = QLabel("就绪 - 请配置虚拟机连接信息并输入任务") + self.status_bar.addWidget(self.status_label, 1) + + self.setStyleSheet(""" + QMainWindow { background-color: #252526; } + QWidget { color: #cccccc; font-size: 13px; } + QGroupBox { + font-weight: bold; border: 1px solid #3c3c3c; + border-radius: 6px; margin-top: 12px; padding-top: 16px; + } + QGroupBox::title { + subcontrol-origin: margin; left: 10px; padding: 0 5px; color: #569cd6; + } + QLineEdit, QSpinBox { + background-color: #3c3c3c; color: #d4d4d4; + border: 1px solid #555; border-radius: 3px; padding: 5px; + } + QLineEdit:focus, QSpinBox:focus { border-color: #0e639c; } + QPushButton { + background-color: #3c3c3c; color: #cccccc; + border: 1px solid #555; border-radius: 4px; padding: 6px 12px; + } + QPushButton:hover { background-color: #4c4c4c; border-color: #0e639c; } + QScrollArea { border: none; background-color: transparent; } + QPlainTextEdit { background-color: #1e1e1e; border: 1px solid #3c3c3c; border-radius: 4px; } + QSplitter::handle { background-color: #3c3c3c; width: 2px; } + """) + + +def run(): + app = QApplication(sys.argv) + app.setApplicationName("VirtualBox Client") + window = MainWindow() + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + run() diff --git a/src/virtualbox_client/scripts/__init__.py b/src/virtualbox_client/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/virtualbox_client/scripts/agent_task.py b/src/virtualbox_client/scripts/agent_task.py new file mode 100644 index 0000000..06acd47 --- /dev/null +++ b/src/virtualbox_client/scripts/agent_task.py @@ -0,0 +1,59 @@ +import os +import sys + +# 尝试加载 .env 文件(如果存在) +try: + from dotenv import load_dotenv + + load_dotenv("./.env.example") +except ImportError: + pass + +from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager +from virtualbox_client.utils.vm_agent import VMAgent + + +def run(): + # 从环境变量读取配置 + vm_name = os.getenv("VM_NAME", "ubuntu20.04") + ssh_host = os.getenv("VM_SSH_HOST", "127.0.0.1") + ssh_port = int(os.getenv("VM_SSH_PORT", "2222")) + ssh_user = os.getenv("VM_SSH_USER") + ssh_password = os.getenv("VM_SSH_PASS") + api_key = os.getenv("LLM_API_KEY") + base_url = os.getenv("LLM_BASE_URL") # 可选,用于本地模型 + model = os.getenv("LLM_MODEL", "gpt-4o") + + # 参数校验 + if not ssh_user or not ssh_password: + print("❌ 请设置环境变量 VM_SSH_USER 和 VM_SSH_PASS") + sys.exit(1) + if not api_key: + print("❌ 请设置环境变量 LLM_API_KEY") + sys.exit(1) + + if len(sys.argv) < 2: + print("用法: uv run agent-task <任务描述>") + print("示例: uv run agent-task '在虚拟机中安装 nginx 并确认服务状态'") + sys.exit(1) + + task = " ".join(sys.argv[1:]) + + # 创建管理器和 Agent + vm = VBoxSSHManager( + vm_name, + ssh_host, + ssh_port, + ssh_user, + ssh_password, + ) + agent = VMAgent( + vm, + api_key=api_key, + base_url=base_url, + model=model, + sudo_password=os.getenv("VM_SSH_PASS"), + ) + + final_msg = agent.run(task) + print(final_msg) diff --git a/src/virtualbox_client/scripts/example.py b/src/virtualbox_client/scripts/example.py new file mode 100644 index 0000000..7634acc --- /dev/null +++ b/src/virtualbox_client/scripts/example.py @@ -0,0 +1,49 @@ +import sys +import os +from ..utils.vbox_ssh_manager import VBoxSSHManager + + +# ---------- 使用示例 ---------- +def run(): + # 配置参数 + VM_NAME = "ubuntu20.04" + SSH_HOST = "127.0.0.1" # 根据网络配置修改 + SSH_PORT = 2222 # 默认 SSH 端口或转发端口 + SSH_USER = "chakcy" + SSH_PASS = "020709djh" + + # 创建管理工具实例 + vm = VBoxSSHManager(VM_NAME, SSH_HOST, SSH_PORT, SSH_USER, SSH_PASS) + + try: + # 启动虚拟机并等待 SSH 就绪 + if not vm.start_vm(): + sys.exit(1) + + # 1. 上传测试文件 + print("\n--- 上传文件测试 ---") + local_test_file = "test_upload.txt" + with open(local_test_file, "w") as f: + f.write("Hello from host!") + vm.upload_file(local_test_file, "/home/chakcy/test_upload.txt") + os.remove(local_test_file) + + # 2. 执行命令 + print("\n--- 执行命令测试 ---") + out, err, code = vm.exec_command("ls -l /home/chakcy && echo '--- done ---'") + if out: + print(out) + if err: + print("[STDERR]", err) + print(f"退出码: {code}") + + # 3. 下载文件 + print("\n--- 下载文件测试 ---") + vm.download_file("/home/chakcy/test_upload.txt", "downloaded.txt") + if os.path.exists("downloaded.txt"): + with open("downloaded.txt") as f: + print("下载内容:", f.read()) + + finally: + # 关闭虚拟机并清理资源 + vm.stop_vm() diff --git a/src/virtualbox_client/scripts/run_script.py b/src/virtualbox_client/scripts/run_script.py new file mode 100644 index 0000000..f16a5a6 --- /dev/null +++ b/src/virtualbox_client/scripts/run_script.py @@ -0,0 +1,137 @@ +""" +上传本地 sh 脚本到虚拟机 → 执行脚本 → 下载执行日志 + +用法: + uv run run-script <本地脚本路径> + uv run run-script resources/deploy_docker.sh + uv run run-script resources/chongming_dev.sh + +脚本会将本地 sh 文件上传到 /tmp/ 目录,执行,并将 stdout + stderr +保存到 /tmp/<脚本名>.log,最后下载回本地。 + +权限说明: + - deploy_*.sh → 自动以 sudo 权限执行(系统级安装脚本) + - 其他脚本 → 以普通用户权限执行(如开发环境脚本) +""" + +import sys +import os + +# 尝试加载 .env 文件(如果存在) +try: + from dotenv import load_dotenv + + load_dotenv("./.env.example") +except ImportError: + pass + +from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager + + +def run(): + # 从环境变量读取配置 + vm_name = os.getenv("VM_NAME", "ubuntu20.04") + ssh_host = os.getenv("VM_SSH_HOST", "127.0.0.1") + ssh_port = int(os.getenv("VM_SSH_PORT", "2222")) + ssh_user = os.getenv("VM_SSH_USER") + ssh_password = os.getenv("VM_SSH_PASS") + + # 参数校验 + if not ssh_user or not ssh_password: + print("❌ 请设置环境变量 VM_SSH_USER 和 VM_SSH_PASS") + sys.exit(1) + + if len(sys.argv) < 2: + print("用法: uv run run-script <本地脚本路径>") + print("示例: uv run run-script resources/deploy_docker.sh") + sys.exit(1) + + local_path = sys.argv[1] + + # 检查本地脚本是否存在 + if not os.path.isfile(local_path): + print(f"❌ 本地脚本不存在: {local_path}") + sys.exit(1) + + # 获取脚本文件名 + script_name = os.path.basename(local_path) + remote_path = f"/tmp/{script_name}" + log_path = f"/tmp/{script_name}.log" + local_log_path = f"{script_name}.log" + + # 创建管理器和连接 + vm = VBoxSSHManager( + vm_name, + ssh_host, + ssh_port, + ssh_user, + ssh_password, + ) + + try: + # 启动虚拟机 + if not vm.start_vm(): + print("❌ 虚拟机启动失败") + sys.exit(1) + + # 1. 上传脚本 + print(f"\n{'='*50}") + print(f"📤 [1/3] 上传脚本: {local_path} -> {remote_path}") + print(f"{'='*50}") + if not vm.upload_file(local_path, remote_path): + print("❌ 上传失败") + sys.exit(1) + + # 2. 执行脚本 + print(f"\n{'='*50}") + print(f"⚡ [2/3] 执行脚本: {remote_path}") + print(f"{'='*50}") + print(f" 日志将保存到: {log_path}") + print() + + # 赋予执行权限并执行,输出同时写入终端(exec_command 实时打印)和日志文件 + # deploy_* 脚本需要 sudo 权限(系统级安装),其余脚本以普通用户执行 + if script_name.startswith("deploy_"): + cmd = ( + f"chmod +x {remote_path} && " + f"echo '{ssh_password}' | sudo -S {remote_path} 2>&1 | " + f"tee {log_path}" + ) + print(" 🔑 检测到部署脚本,以 sudo 权限执行") + else: + cmd = f"chmod +x {remote_path} && " f"{remote_path} 2>&1 | tee {log_path}" + print(" 👤 检测到开发脚本,以普通用户权限执行") + out, err, code = vm.exec_command(cmd, get_pty=False) + + print(f"\n✔ 脚本执行完成,退出码: {code}") + + # 3. 下载日志 + print(f"\n{'='*50}") + print(f"📥 [3/3] 下载执行日志: {log_path} -> {local_log_path}") + print(f"{'='*50}") + if vm.download_file(log_path, local_log_path): + print(f"✔ 日志已保存到本地: {local_log_path}") + else: + # 如果 tee 方式没拿到日志,尝试直接读取 + print("⚠ 尝试直接下载日志...") + if vm.download_file(log_path, local_log_path): + print(f"✔ 日志已保存到本地: {local_log_path}") + else: + print("⚠ 日志文件未生成,将终端输出保存到本地") + if out: + with open(local_log_path, "w") as f: + f.write(out) + print(f"✔ 终端输出已保存到: {local_log_path}") + + # 清理远程临时文件 + print(f"\n{'='*50}") + print("🧹 清理远程临时文件...") + vm.exec_command(f"rm -f {remote_path} {log_path}") + print("✔ 清理完成") + + print(f"\n{'='*50}") + print("🎉 全部完成!") + print(f"{'='*50}") + + finally: + vm.stop_vm() diff --git a/src/virtualbox_client/utils/__init__.py b/src/virtualbox_client/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/virtualbox_client/utils/ssh_manager.py b/src/virtualbox_client/utils/ssh_manager.py new file mode 100644 index 0000000..039bbe5 --- /dev/null +++ b/src/virtualbox_client/utils/ssh_manager.py @@ -0,0 +1,264 @@ +""" +纯 SSH 连接管理工具(不依赖 VirtualBox/vboxapi) + +提供 SSH 连接、命令执行、文件上传/下载功能。 +与 VBoxSSHManager 保持兼容的接口,但剥离了所有 VirtualBox 依赖。 +""" + +import paramiko +import time +import socket +import threading + + +class SSHManager: + """纯 SSH 管理工具,不依赖 VirtualBox""" + + def __init__( + self, + ssh_host, + ssh_port, + ssh_user, + ssh_password, + ssh_timeout=180, + cmd_timeout=120, + ): + self.ssh_host = ssh_host + self.ssh_port = ssh_port + self.ssh_user = ssh_user + self.ssh_pass = ssh_password + self.ssh_timeout = ssh_timeout + self.cmd_timeout = cmd_timeout + + self._ssh_client = None + self._cancelled = threading.Event() + + def set_cancel_event(self, event: threading.Event): + """设置外部取消事件,与 GUI 层的取消信号同步""" + self._cancelled = event + + def cancel_operations(self): + """标记取消所有正在进行的操作""" + self._cancelled.set() + if self._ssh_client: + try: + self._ssh_client.close() + except Exception: + pass + self._ssh_client = None + + def reset_cancel(self): + """重置取消标记""" + self._cancelled.clear() + + def is_cancelled(self): + """检查是否已取消""" + return self._cancelled.is_set() + + # ---------- SSH 连接管理 ---------- + def get_ssh_client(self): + """获取或创建 SSH 连接(带重试)""" + if self._ssh_client is not None: + return self._ssh_client + + for i in range(5): + if self._cancelled.is_set(): + return None + if not self._is_port_open(self.ssh_host, self.ssh_port): + print(f"SSH 端口 {self.ssh_host}:{self.ssh_port} 不可达,重试 {i+1}/5") + time.sleep(10) + continue + try: + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect( + self.ssh_host, + port=self.ssh_port, + username=self.ssh_user, + password=self.ssh_pass, + timeout=10, + ) + self._ssh_client = client + return client + except Exception as e: + print(f"SSH 连接失败 ({i+1}/5): {e}") + if self._cancelled.is_set(): + return None + time.sleep(10) + return None + + def close_ssh(self): + """关闭 SSH 连接""" + if self._ssh_client: + self._ssh_client.close() + self._ssh_client = None + + # ---------- 虚拟机生命周期(空实现,兼容接口) ---------- + def is_running(self) -> bool: + """SSH-only 模式下始终返回 True(不管理虚拟机生命周期)""" + return True + + def start_vm(self, wait_for_ssh=True): + """SSH-only 模式下直接等待 SSH 就绪""" + if wait_for_ssh: + return self._wait_for_ssh_ready(self.ssh_timeout) + return True + + def stop_vm(self): + """SSH-only 模式下不执行任何操作""" + self.close_ssh() + + def ensure_vm_stopped(self): + """SSH-only 模式下不执行任何操作""" + return False + + # ---------- 核心功能 ---------- + def exec_command(self, command, get_pty=False): + """ + 执行命令并返回 (stdout_str, stderr_str, exit_code) + 边执行边打印输出,方便实时查看进度 + 如果连接失败返回 (None, None, -1) + """ + client = self.get_ssh_client() + if not client: + return None, None, -1 + try: + stdin, stdout, stderr = client.exec_command( + command, timeout=self.cmd_timeout, get_pty=get_pty + ) + + out_lines = [] + for line in stdout: + if self._cancelled.is_set(): + stdout.channel.close() + break + if isinstance(line, bytes): + decoded = line.decode("utf-8", errors="replace").rstrip("\n") + else: + decoded = str(line).rstrip("\n") + print(f" {decoded}") + out_lines.append(decoded) + + err_lines = [] + for line in stderr: + if self._cancelled.is_set(): + stderr.channel.close() + break + if isinstance(line, bytes): + decoded = line.decode("utf-8", errors="replace").rstrip("\n") + else: + decoded = str(line).rstrip("\n") + print(f" [stderr] {decoded}") + err_lines.append(decoded) + + out = "\n".join(out_lines) + err = "\n".join(err_lines) + exit_code = stdout.channel.recv_exit_status() + return out, err, exit_code + except Exception as e: + if self._cancelled.is_set(): + return None, None, -1 + print(f"执行命令失败: {e}") + return None, None, -1 + + def upload_file(self, local_path, remote_path): + """上传本地文件到远程服务器,返回是否成功""" + client = self.get_ssh_client() + if not client: + return False + try: + sftp = client.open_sftp() + sftp.put(local_path, remote_path) + sftp.close() + print(f"✔ 上传成功: {local_path} -> {remote_path}") + return True + except Exception as e: + if self._cancelled.is_set(): + return False + print(f"✘ 上传失败: {e}") + return False + + def upload_directory(self, local_dir, remote_dir): + """递归上传整个文件夹到远程服务器,返回是否成功""" + import os as _os + + client = self.get_ssh_client() + if not client: + return False + try: + sftp = client.open_sftp() + local_dir = _os.path.normpath(local_dir) + remote_dir = remote_dir.rstrip("/") + # 展开 ~ 为真实路径(SFTP 不会自动展开 ~) + if remote_dir.startswith("~/"): + out, _, _ = self.exec_command("echo ~") + if out and out.strip(): + remote_dir = out.strip() + remote_dir[1:] + # 创建远程根目录 + self.exec_command(f'mkdir -p "{remote_dir}"') + for root, dirs, files in _os.walk(local_dir): + # 计算相对路径 + rel_path = _os.path.relpath(root, local_dir) + if rel_path == ".": + rel_path = "" + # 创建远程子目录 + target_dir = remote_dir + ( + "/" + rel_path.replace("\\", "/") if rel_path else "" + ) + self.exec_command(f'mkdir -p "{target_dir}"') + for fname in files: + if self._cancelled.is_set(): + sftp.close() + return False + local_file = _os.path.join(root, fname) + remote_file = target_dir + "/" + fname + sftp.put(local_file, remote_file) + print(f" ✔ {local_file} -> {remote_file}") + sftp.close() + print(f"✔ 文件夹上传成功: {local_dir} -> {remote_dir}") + return True + except Exception as e: + if self._cancelled.is_set(): + return False + print(f"✘ 文件夹上传失败: {e}") + return False + + def download_file(self, remote_path, local_path): + """从远程服务器下载文件到本地,返回是否成功""" + client = self.get_ssh_client() + if not client: + return False + try: + sftp = client.open_sftp() + sftp.get(remote_path, local_path) + sftp.close() + print(f"✔ 下载成功: {remote_path} -> {local_path}") + return True + except Exception as e: + if self._cancelled.is_set(): + return False + print(f"✘ 下载失败: {e}") + return False + + # ---------- 内部辅助方法 ---------- + def _wait_for_ssh_ready(self, timeout=180): + """等待 SSH 端口可连接""" + start = time.time() + while time.time() - start < timeout: + if self._cancelled.is_set(): + return False + if self._is_port_open(self.ssh_host, self.ssh_port): + return True + time.sleep(2) + return False + + @staticmethod + def _is_port_open(host, port, timeout=5): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + try: + s.connect((host, port)) + s.close() + return True + except Exception: + return False diff --git a/src/virtualbox_client/utils/vbox_ssh_manager.py b/src/virtualbox_client/utils/vbox_ssh_manager.py new file mode 100644 index 0000000..2d0dafe --- /dev/null +++ b/src/virtualbox_client/utils/vbox_ssh_manager.py @@ -0,0 +1,385 @@ +import paramiko +import time +import socket +import threading +from vboxapi import VirtualBoxManager + + +class VBoxSSHManager: + """VirtualBox 虚拟机 SSH 管理工具,支持上传、执行、下载文件""" + + def __init__( + self, + vm_name, + ssh_host, + ssh_port, + ssh_user, + ssh_password, + ssh_timeout=180, + cmd_timeout=120, + ): + self.vm_name = vm_name + self.ssh_host = ssh_host + self.ssh_port = ssh_port + self.ssh_user = ssh_user + self.ssh_pass = ssh_password + self.ssh_timeout = ssh_timeout + self.cmd_timeout = cmd_timeout + + self.mgr = VirtualBoxManager(None, None) + self.vbox = self.mgr.getVirtualBox() + self.machine = self.vbox.findMachine(self.vm_name) + self.session = None + self.console = None + self.guest = None + self._ssh_client = None + self._cancelled = threading.Event() + + def set_cancel_event(self, event: threading.Event): + """设置外部取消事件,与 GUI 层的取消信号同步""" + self._cancelled = event + + def cancel_operations(self): + """标记取消所有正在进行的操作""" + self._cancelled.set() + # 强制关闭 SSH 连接,让阻塞的操作立即返回 + if self._ssh_client: + try: + self._ssh_client.close() + except: + pass + self._ssh_client = None + + def reset_cancel(self): + """重置取消标记""" + self._cancelled.clear() + + def is_cancelled(self): + """检查是否已取消""" + return self._cancelled.is_set() + + # ---------- 虚拟机生命周期 ---------- + def is_running(self) -> bool: + """检查虚拟机是否正在运行""" + try: + state = self.machine.state + return state == self.mgr.constants.MachineState_Running + except Exception as e: + print(f"检查虚拟机状态失败: {e}") + return False + + def start_vm(self, wait_for_ssh=True): + """启动虚拟机,并返回是否成功。如果虚拟机已在运行则跳过。""" + if self.is_running(): + print("✔ 虚拟机已在运行,跳过启动") + # 等待 SSH 就绪 + if wait_for_ssh and not self._wait_for_ssh_ready(self.ssh_timeout): + print("SSH 服务在超时时间内未就绪") + return False + return True + + if self._cancelled.is_set(): + return False + + self.session = self.mgr.getSessionObject(self.vbox) + progress = self.machine.launchVMProcess(self.session, "headless", []) + progress.waitForCompletion(-1) + if progress.resultCode != 0: + error = progress.errorInfo.text if progress.errorInfo else "Unknown" + print(f"启动失败: {error}") + self.session.unlockMachine() + self.session = None + return False + + self.console = self.session.console + self.guest = self.console.guest + + # 等待 Running 状态 + if not self._wait_for_vm_state(self.mgr.constants.MachineState_Running, 120): + print("虚拟机未能进入 Running 状态") + self.stop_vm() + return False + print("✔ 虚拟机已运行") + + if self._cancelled.is_set(): + return False + + # 等待 Guest Additions 基本就绪 + self._wait_for_guest_ready(60) + print("等待 SSH 服务启动...") + time.sleep(15) + + if wait_for_ssh and not self._wait_for_ssh_ready(self.ssh_timeout): + print("SSH 服务在超时时间内未就绪") + self.stop_vm() + return False + + return True + + def stop_vm(self): + """关闭虚拟机并清理会话""" + # 先关闭 SSH 连接 + if self._ssh_client: + try: + self._ssh_client.close() + except: + pass + self._ssh_client = None + + # 如果虚拟机没在运行,只需要解锁会话 + if not self.is_running(): + print("虚拟机未运行,跳过关机") + if self.session: + try: + self.session.unlockMachine() + except: + pass + self.session = None + return + + if self.console: + print("正在关闭虚拟机...") + try: + self.console.powerButton() + except: + pass + + # 等待虚拟机进入 Stopped 状态 + self._wait_for_vm_state(self.mgr.constants.MachineState_Stopped, 30) + + if self.session: + try: + self.session.unlockMachine() + except: + pass + self.session = None + + print("虚拟机已关闭") + + def ensure_vm_stopped(self): + """确保虚拟机已关闭(供启动前调用)""" + if self.is_running(): + print("检测到虚拟机正在运行,正在关闭...") + self.stop_vm() + # 等待完全停止 + time.sleep(2) + return True + return False + + # ---------- SSH 连接管理 ---------- + def get_ssh_client(self): + """获取或创建 SSH 连接(带重试)""" + if self._ssh_client is not None: + return self._ssh_client + + for i in range(5): + if self._cancelled.is_set(): + return None + if not self._is_port_open(self.ssh_host, self.ssh_port): + print(f"SSH 端口 {self.ssh_host}:{self.ssh_port} 不可达,重试 {i+1}/5") + time.sleep(10) + continue + try: + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect( + self.ssh_host, + port=self.ssh_port, + username=self.ssh_user, + password=self.ssh_pass, + timeout=10, + ) + self._ssh_client = client + return client + except Exception as e: + print(f"SSH 连接失败 ({i+1}/5): {e}") + if self._cancelled.is_set(): + return None + time.sleep(10) + return None + + def close_ssh(self): + """关闭 SSH 连接""" + if self._ssh_client: + self._ssh_client.close() + self._ssh_client = None + + # ---------- 核心功能 ---------- + def exec_command(self, command, get_pty=False): + """ + 执行命令并返回 (stdout_str, stderr_str, exit_code) + 边执行边打印输出,方便实时查看进度 + 如果连接失败返回 (None, None, -1) + """ + client = self.get_ssh_client() + if not client: + return None, None, -1 + try: + stdin, stdout, stderr = client.exec_command( + command, timeout=self.cmd_timeout, get_pty=get_pty + ) + + # 实时读取并打印 stdout + out_lines = [] + for line in stdout: + if self._cancelled.is_set(): + # 取消时主动关闭通道 + stdout.channel.close() + break + if isinstance(line, bytes): + decoded = line.decode("utf-8", errors="replace").rstrip("\n") + else: + decoded = str(line).rstrip("\n") + print(f" {decoded}") + out_lines.append(decoded) + + # 实时读取并打印 stderr + err_lines = [] + for line in stderr: + if self._cancelled.is_set(): + stderr.channel.close() + break + if isinstance(line, bytes): + decoded = line.decode("utf-8", errors="replace").rstrip("\n") + else: + decoded = str(line).rstrip("\n") + print(f" [stderr] {decoded}") + err_lines.append(decoded) + + out = "\n".join(out_lines) + err = "\n".join(err_lines) + exit_code = stdout.channel.recv_exit_status() + return out, err, exit_code + except Exception as e: + if self._cancelled.is_set(): + return None, None, -1 + print(f"执行命令失败: {e}") + return None, None, -1 + + def upload_file(self, local_path, remote_path): + """上传本地文件到虚拟机,返回是否成功""" + client = self.get_ssh_client() + if not client: + return False + try: + sftp = client.open_sftp() + sftp.put(local_path, remote_path) + sftp.close() + print(f"✔ 上传成功: {local_path} -> {remote_path}") + return True + except Exception as e: + if self._cancelled.is_set(): + return False + print(f"✘ 上传失败: {e}") + return False + + def download_file(self, remote_path, local_path): + """从虚拟机下载文件到本地,返回是否成功""" + client = self.get_ssh_client() + if not client: + return False + try: + sftp = client.open_sftp() + sftp.get(remote_path, local_path) + sftp.close() + print(f"✔ 下载成功: {remote_path} -> {local_path}") + return True + except Exception as e: + if self._cancelled.is_set(): + return False + print(f"✘ 下载失败: {e}") + return False + + def upload_directory(self, local_dir, remote_dir): + """递归上传整个文件夹到远程服务器,返回是否成功""" + import os as _os + + client = self.get_ssh_client() + if not client: + return False + try: + sftp = client.open_sftp() + local_dir = _os.path.normpath(local_dir) + remote_dir = remote_dir.rstrip("/") + # 展开 ~ 为真实路径(SFTP 不会自动展开 ~) + if remote_dir.startswith("~/"): + out, _, _ = self.exec_command("echo ~") + if out and out.strip(): + remote_dir = out.strip() + remote_dir[1:] + self.exec_command(f'mkdir -p "{remote_dir}"') + for root, dirs, files in _os.walk(local_dir): + rel_path = _os.path.relpath(root, local_dir) + if rel_path == ".": + rel_path = "" + target_dir = remote_dir + ( + "/" + rel_path.replace("\\", "/") if rel_path else "" + ) + self.exec_command(f'mkdir -p "{target_dir}"') + for fname in files: + if self._cancelled.is_set(): + sftp.close() + return False + local_file = _os.path.join(root, fname) + remote_file = target_dir + "/" + fname + sftp.put(local_file, remote_file) + print(f" ✔ {local_file} -> {remote_file}") + sftp.close() + print(f"✔ 文件夹上传成功: {local_dir} -> {remote_dir}") + return True + except Exception as e: + if self._cancelled.is_set(): + return False + print(f"✘ 文件夹上传失败: {e}") + return False + + # ---------- 内部辅助方法 ---------- + def _wait_for_vm_state(self, target_state, timeout=120): + start = time.time() + while time.time() - start < timeout: + if self._cancelled.is_set(): + return False + try: + if self.console.state == target_state: # type: ignore + return True + except: + pass + time.sleep(1) + return False + + def _wait_for_guest_ready(self, timeout=60): + start = time.time() + while time.time() - start < timeout: + if self._cancelled.is_set(): + return False + try: + if ( + self.guest.additionsRunLevel # type: ignore + >= self.mgr.constants.AdditionsRunLevelType_System + ): + return True + except: + pass + time.sleep(2) + return False + + def _wait_for_ssh_ready(self, timeout=180): + """等待 SSH 端口可连接""" + start = time.time() + while time.time() - start < timeout: + if self._cancelled.is_set(): + return False + if self._is_port_open(self.ssh_host, self.ssh_port): + return True + time.sleep(2) + return False + + @staticmethod + def _is_port_open(host, port, timeout=5): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + try: + s.connect((host, port)) + s.close() + return True + except: + return False diff --git a/src/virtualbox_client/utils/vm_agent.py b/src/virtualbox_client/utils/vm_agent.py new file mode 100644 index 0000000..e9f059c --- /dev/null +++ b/src/virtualbox_client/utils/vm_agent.py @@ -0,0 +1,336 @@ +import json +import os +import time +from openai import OpenAI +from .vbox_ssh_manager import VBoxSSHManager +from .ssh_manager import SSHManager +from typing import Union + + +class VMAgent: + """大模型驱动的虚拟机自动化 Agent""" + + TOOLS = [ + { + "type": "function", + "function": { + "name": "run_command", + "description": "在虚拟机中执行一条 shell 命令并等待执行完成(如需 sudo,在命令中使用 $SUDO_PASSWORD 占位符,例如 'echo $SUDO_PASSWORD | sudo -S apt update')。重要:所有命令必须是非交互式的,对于 apt install、add-apt-repository 等一定记得加 -y 参数,否则命令会卡住。不要用此工具启动后台服务(如 Web 服务器),请用 start_daemon。", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "要执行的命令,例如 'ls -la /home';如需提权,用 echo $SUDO_PASSWORD | sudo -S <命令> 的格式。重要:命令必须是非交互式的,apt install/add-apt-repository 等必须加 -y 参数!", + }, + "get_pty": { + "type": "boolean", + "description": "是否需要伪终端(如交互式 sudo),默认 false;用 echo $SUDO_PASSWORD | sudo -S 的方式通常不需要", + }, + }, + "required": ["command"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "start_daemon", + "description": "在虚拟机中以后台守护进程方式启动一个服务(如 Web 服务器),不会阻塞后续命令。stdout/stderr 将重定向到日志文件,可通过 check_daemon 查看状态。", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "要以后台服务方式运行的命令,例如 'cd /home/chakcy/chongming && uv run serve'。不要加 & 或 nohup,系统会自动处理。", + }, + "logfile": { + "type": "string", + "description": "日志文件路径,例如 '/tmp/serve.log'。该文件会包含服务的 stdout 和 stderr 输出。", + }, + "workdir": { + "type": "string", + "description": "工作目录,可选。例如 '/home/chakcy/chongming'。", + }, + }, + "required": ["command", "logfile"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "check_daemon", + "description": "检查后台服务的运行状态和最近日志。如果服务正在运行,会返回最后 N 行日志;如果已退出,会返回退出码和完整日志。", + "parameters": { + "type": "object", + "properties": { + "pid": { + "type": "string", + "description": "后台进程的 PID(来自 start_daemon 的返回结果)", + }, + "logfile": { + "type": "string", + "description": "日志文件路径(来自 start_daemon 时指定的 logfile)", + }, + }, + "required": ["pid", "logfile"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "stop_daemon", + "description": "停止一个后台服务进程", + "parameters": { + "type": "object", + "properties": { + "pid": { + "type": "string", + "description": "要停止的后台进程 PID", + }, + }, + "required": ["pid"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "upload_file", + "description": "上传本地文件到虚拟机", + "parameters": { + "type": "object", + "properties": { + "local_path": {"type": "string"}, + "remote_path": {"type": "string"}, + }, + "required": ["local_path", "remote_path"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "download_file", + "description": "从虚拟机下载文件到本地", + "parameters": { + "type": "object", + "properties": { + "remote_path": {"type": "string"}, + "local_path": {"type": "string"}, + }, + "required": ["remote_path", "local_path"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "finish_task", + "description": "任务完成,返回结果总结", + "parameters": { + "type": "object", + "properties": { + "summary": { + "type": "string", + "description": "任务执行总结", + } + }, + "required": ["summary"], + }, + }, + }, + ] + + def __init__( + self, + vm_manager: Union[VBoxSSHManager, SSHManager], + api_key=None, + base_url=None, + model="gpt-4o", + sudo_password=None, + ): + self.vm = vm_manager + self.client = OpenAI( + api_key=api_key or os.getenv("LLM_API_KEY"), + base_url=base_url or os.getenv("LLM_BASE_URL"), + ) + self.model = model or os.getenv("LLM_MODEL", "gpt-4o") + self.sudo_password = sudo_password or os.getenv("VM_SSH_PASS") + # 记录虚拟机是否是由本任务启动的(True=我们启动的,False=执行前已在运行) + self.vm_started_by_us = False + + def run(self, task: str) -> tuple: + """ + 执行一个自然语言任务,返回 (summary, vm_started_by_us) + vm_started_by_us 表示虚拟机是否是由本次任务启动的(供 GUI 决定是否关机) + """ + system_prompt = ( + "你是一个 VirtualBox 虚拟机管理助手。" + "你可以通过提供的工具在 Ubuntu 20.04 虚拟机中执行操作。" + "请一步步完成任务,每次只调用一个工具,观察结果后决定下一步。" + "不要同时调用多个工具。" + "完成任务后调用 finish_task 总结。" + "\n\n重要: 当需要 sudo 提权时,请在命令中使用 $SUDO_PASSWORD 占位符," + "例如: 'echo $SUDO_PASSWORD | sudo -S apt update'" + "这样系统会自动替换为正确的密码。不要试图用别的方式传密码。" + "\n\n重要: 所有命令必须是非交互式的,因为 SSH 无法处理交互式输入。" + "对于 apt、add-apt-repository 等可能弹出确认提示的命令,务必加上 -y 标志。" + "例如,使用 'add-apt-repository -y ' 而不是 'add-apt-repository '," + "使用 'apt install -y <包名>' 而不是 'apt install <包名>'。" + "使用 'apt-get install -y <包名>' 而不是 'apt-get install <包名>'。" + ) + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": task}, + ] + + # 判断虚拟机是否已在运行:如果已在运行则直接 SSH 连接,不重启 + # start_vm() 内部已处理「如果已在运行则跳过启动」的逻辑 + # 但我们还需要知道「是否是本次启动的」,以便让用户决定是否关机 + was_running_before = self.vm.is_running() + if was_running_before: + print("✔ 检测到虚拟机已在运行,直接进行 SSH 连接") + else: + print("🔌 虚拟机未运行,需要启动") + + if not self.vm.start_vm(): + return "❌ 虚拟机启动失败,无法执行任务", False + + # 记录虚拟机是否是由我们启动的 + self.vm_started_by_us = not was_running_before + + try: + while True: + if self.vm.is_cancelled(): + return "⏹️ 任务已取消", self.vm_started_by_us + + response = self.client.chat.completions.create( + model=self.model, + messages=messages, # type: ignore + tools=self.TOOLS, # type: ignore + tool_choice="auto", + ) + msg = response.choices[0].message + + # 输出模型文本(若有) + if msg.content: + print("🤖", msg.content) + + # 无工具调用即结束对话 + if not msg.tool_calls: + break + + # 先追加 assistant 消息到历史(包含所有 tool_calls) + messages.append(msg) # type: ignore + + # 依次处理每个 tool_call + for tool_call in msg.tool_calls: + if self.vm.is_cancelled(): + return "⏹️ 任务已取消", self.vm_started_by_us + + func_name = tool_call.function.name # type: ignore + func_args = json.loads(tool_call.function.arguments) # type: ignore + + print(f"🔧 执行 {func_name}: {func_args}") + + # finish_task 特殊处理:直接跳出循环,不追加到消息 + if func_name == "finish_task": + summary = func_args.get("summary", "任务完成") + print("✅", summary) + return summary, self.vm_started_by_us + + result = self._execute_tool(func_name, func_args) + + messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": result, + } + ) + finally: + # 注意:不再 here 自动关闭 VM! + # VM 的生命周期由 GUI 层根据用户选择来决定是否关闭 + pass + + return "任务执行完成", self.vm_started_by_us + + def stop_vm_if_we_started(self): + """如果 VM 是本任务启动的则关闭,否则保持运行""" + if self.vm_started_by_us: + print("🛑 关闭由本任务启动的虚拟机") + self.vm.stop_vm() + else: + print("⏭️ 虚拟机并非本任务启动,保持运行状态") + + def _execute_tool(self, func_name: str, args: dict) -> str: + if func_name == "run_command": + cmd = args["command"] + cmd = cmd.replace("$SUDO_PASSWORD", self.sudo_password or "") + get_pty = args.get("get_pty", False) + out, err, code = self.vm.exec_command(cmd, get_pty=get_pty) + return f"退出码: {code}\n标准输出:\n{out}\n标准错误:\n{err}" + elif func_name == "start_daemon": + cmd = args["command"] + logfile = args["logfile"] + workdir = args.get("workdir", "") + cmd = cmd.replace("$SUDO_PASSWORD", self.sudo_password or "") + + # 构造后台执行命令:重定向 stdout/stderr 到日志文件 + cd_prefix = f"cd {workdir} && " if workdir else "" + bg_cmd = ( + f"{cd_prefix}nohup bash -c '{cmd}' " f"> {logfile} 2>&1 & echo PID=$!" + ) + out, err, code = self.vm.exec_command(bg_cmd, get_pty=False) + # 提取 PID + pid = "" + if out: + for line in out.splitlines(): + if line.startswith("PID="): + pid = line.split("=", 1)[1].strip() + break + if pid: + return f"后台服务已启动, PID: {pid}, 日志: {logfile}" + else: + return f"启动失败(或未获取到 PID)\n输出: {out}\n错误: {err}" + elif func_name == "check_daemon": + pid = args["pid"] + logfile = args["logfile"] + check_cmd = ( + f"if ps -p {pid} > /dev/null 2>&1; then " + f' echo "STATUS=running"; ' + f' echo "---最后20行日志---"; ' + f' tail -20 "{logfile}" 2>/dev/null || echo "日志文件为空"; ' + f"else " + f' echo "STATUS=exited"; ' + f' echo "---完整日志---"; ' + f' cat "{logfile}" 2>/dev/null || echo "日志文件不存在"; ' + f"fi" + ) + out, err, code = self.vm.exec_command(check_cmd) + return f"{out}\n{err}" if err else out or "无输出" + elif func_name == "stop_daemon": + pid = args["pid"] + stop_cmd = ( + f'kill {pid} 2>/dev/null && echo "已发送 SIGTERM"; ' + f"sleep 2; " + f"if ps -p {pid} > /dev/null 2>&1; then " + f' kill -9 {pid} 2>/dev/null && echo "已强制杀死"; ' + f"fi; " + f'echo "进程 {pid} 已停止"' + ) + out, err, code = self.vm.exec_command(stop_cmd) + return f"{out}\n{err}" if err else out or "无输出" + elif func_name == "upload_file": + success = self.vm.upload_file(args["local_path"], args["remote_path"]) + return "上传成功" if success else "上传失败" + elif func_name == "download_file": + success = self.vm.download_file(args["remote_path"], args["local_path"]) + return "下载成功" if success else "下载失败" + elif func_name == "finish_task": + return args.get("summary", "任务完成") + else: + return f"未知工具: {func_name}" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9b0b003 --- /dev/null +++ b/uv.lock @@ -0,0 +1,664 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + +[[package]] +name = "invoke" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/227c48c5fe47fa178ccf1fda8f047d16c97ba926567b661e9ce2045c600c/invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c", size = 343419, upload-time = "2026-04-07T15:17:48.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/de/bbc12563bbf979618d17625a4e753ff7a078523e28d870d3626daa97261a/invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053", size = 160958, upload-time = "2026-04-07T15:17:46.875Z" }, +] + +[[package]] +name = "jiter" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" }, + { url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" }, + { url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" }, + { url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" }, + { url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" }, + { url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" }, + { url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" }, + { url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, +] + +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + +[[package]] +name = "openai" +version = "2.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/a1/4d5e84cf51720fc1526cc49e10ac1961abcccb55b0efb3d970db1e9a2728/openai-2.36.0.tar.gz", hash = "sha256:139dea0edd2f1b30c33d46ae1a6929e03906254140318e4608e98fe8c566f2e7", size = 753003, upload-time = "2026-05-07T17:33:17.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/1c/5d43735b2553baae2a5e899dcbcd0670a86930d993184d72ca909bf11c9b/openai-2.36.0-py3-none-any.whl", hash = "sha256:143f6194b548dbc2c921af1f1b03b9f14c85fed8a75b5b516f5bcc11a2a50c63", size = 1302361, upload-time = "2026-05-07T17:33:15.063Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "paramiko" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "invoke" }, + { name = "pynacl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size = 1630743, upload-time = "2025-08-04T01:02:03.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" }, +] + +[[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[package]] +name = "pyinstaller" +version = "6.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/60/d03d52e6690d4e9caf333dcd14550cde634ce6c118b3bc8fa3112c3186fd/pyinstaller-6.20.0.tar.gz", hash = "sha256:95c5c7e03d5d61e9dfb8ef259c699cf492bb1041beb6dbe83696608cec07347a", size = 4048728, upload-time = "2026-04-22T20:59:36.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/e4/e228d6d1bbb7fd62dc660a8fb202a583b023d3a3624ca95d1a9290ee4d6a/pyinstaller-6.20.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:bf3be4e1284ee78ddccba5e29f99443a12a7b4673168288ffc4c9d38c6f7b90e", size = 1047642, upload-time = "2026-04-22T20:58:32.006Z" }, + { url = "https://files.pythonhosted.org/packages/ce/bd/afb631bcb3f9040efebd4f6d067f0828b51710818f69fb41a2d4b7787f52/pyinstaller-6.20.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:72ae9c1fdea134afa791f58bdc9a1934d5c7609753c111e0026bfc272b32b712", size = 742494, upload-time = "2026-04-22T20:58:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/76/08/0729a5bac14754150e5d83b39d87d842eb42b0bffcaa03dbad6252e23a39/pyinstaller-6.20.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1031bcc307f3fbeffd4e162723e64d46dbf591c82dd0997413afb2a07328b941", size = 754191, upload-time = "2026-04-22T20:58:40.603Z" }, + { url = "https://files.pythonhosted.org/packages/e6/82/bc0ee4c7b97db1958eb651e0da9fb1e672e5ae53ca8867fd97701de52906/pyinstaller-6.20.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:8df3b3f347659fa2562d8d193a98ad4600133b8b8d07c268df89e4154376750e", size = 751902, upload-time = "2026-04-22T20:58:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/770002d6aaa54173881cb2c49bb195ba67b97bf39bac1cdf320f28401629/pyinstaller-6.20.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b0d3cc9dd8120d448459bd3880a12e2f9774c51443af49047801446377999a59", size = 748634, upload-time = "2026-04-22T20:58:48.579Z" }, + { url = "https://files.pythonhosted.org/packages/fe/db/68ba1fccb71278b2124fb90b37b7c8c0bc4c1173fba45b94466df3d9cb7f/pyinstaller-6.20.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:03696bb6350177c6bc23bcaf78e71a33c4a89b6754dd90d1be2f318e978c918b", size = 748490, upload-time = "2026-04-22T20:58:52.749Z" }, + { url = "https://files.pythonhosted.org/packages/03/0f/ac77ffa996a56be3d5c8f85734a007f8347240691657f9704e7de2527fa3/pyinstaller-6.20.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:6357f1699f6af84f37e7367f031d4f68abdba65543b83990c9e8f5a4cebed0b7", size = 747650, upload-time = "2026-04-22T20:58:57.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/56/1ee91c3a2bc10ca1f36da10a6fd55ff7efc4dec367171eb25992a827874f/pyinstaller-6.20.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0ab39c690abad26ba148e8f664f0478acc82a733997f4f22e757774832802da9", size = 747413, upload-time = "2026-04-22T20:59:01.174Z" }, + { url = "https://files.pythonhosted.org/packages/d7/55/ae264339996953c4cdf9d89d916a0a8fa26a83cf917a742fff8b9d5f3fe8/pyinstaller-6.20.0-py3-none-win32.whl", hash = "sha256:9a7637e8e44b4387b13667fdcaac86ab6b29c446c16d34d8401539b81838759c", size = 1331584, upload-time = "2026-04-22T20:59:07.201Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/300f57578882cce259bfb5ae56fda3b69caa3fe9df40a176c719920ea6e2/pyinstaller-6.20.0-py3-none-win_amd64.whl", hash = "sha256:d588844e890ee80c4365867f98146636e1849bbca8e4284bbf0c809aff0f161a", size = 1391851, upload-time = "2026-04-22T20:59:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ea/b2f8e1642aecda78c0b75c7321f708e49e10bb3c00dd4f148c40761a1527/pyinstaller-6.20.0-py3-none-win_arm64.whl", hash = "sha256:bd53282c0a73e5c95573e1ddc8e5d564d4932bec91efbaed4dc5fdff9c2ae7f2", size = 1332259, upload-time = "2026-04-22T20:59:20.509Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/67/f4452d68793fb15beba4f19ef39a38a8822f0da7452b503c400d5a21f5c1/pyinstaller_hooks_contrib-2026.5.tar.gz", hash = "sha256:f066dfca8f7c45ff6336c9cf9fe25b4e48bfeb322a1aa24faaedfb8a8d1b0b08", size = 173689, upload-time = "2026-05-04T22:36:55.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/5c/fd465d11da4d12b50d7eb5d2ee2ceb780d8d049dbb489f3828d131e387af/pyinstaller_hooks_contrib-2026.5-py3-none-any.whl", hash = "sha256:ea1535783fbdac4626351709e83f3ea80b681d3a4745763ebb407b5e27342eb9", size = 457314, upload-time = "2026-05-04T22:36:53.598Z" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + +[[package]] +name = "pyside6" +version = "6.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyside6-addons" }, + { name = "pyside6-essentials" }, + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/95/f3f5a2799163b6658126d78a85bc1dec9eda88c75c26780556b26071a1d8/pyside6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1f2735dc4f2bd4ec452ae50502c8a22128bba0aced35358a2bbc58384b820c6f", size = 571544, upload-time = "2026-03-23T12:47:20.263Z" }, + { url = "https://files.pythonhosted.org/packages/da/89/9a1f521051714e6694ebbe2b979ded279845ec8e25cb309ca3960158d74f/pyside6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c642e2d25704ca746fd37f56feacf25c5aecc4cd40bef23d18eec81f87d9dc00", size = 571725, upload-time = "2026-03-23T12:47:21.727Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/f779d8bba00fcde31a7d7fb6b59347a70773c9cc8135592dea9972579877/pyside6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:267b344c73580ac938ca63c611881fb42a3922ebfe043e271005f4f06c372c4e", size = 571722, upload-time = "2026-03-23T12:47:22.761Z" }, + { url = "https://files.pythonhosted.org/packages/ac/98/150e01a026df3e9697310236821fa825319bb4b9d6137539cb25a3032968/pyside6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:9092cb002ca43c64006afb2e0d0f6f51aef17aa737c33a45e502326a081ddcbc", size = 577988, upload-time = "2026-03-23T12:47:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/50/e7/55960f7c6b41d058e95cb4af02652c46c48702c506c8bbf12e99550e1fb3/pyside6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:b15f39acc2b8f46251a630acad0d97f9a0a0461f2baffcd66d7adfada8eb641e", size = 561372, upload-time = "2026-03-23T12:47:25.073Z" }, +] + +[[package]] +name = "pyside6-addons" +version = "6.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyside6-essentials" }, + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/df/241f311c61a46b7b1195927da77b2537692ee3442aa9ccd87981164ff78d/pyside6_addons-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d5eaa4643302e3a0fa94c5766234bee4073d7d5ab9c2b7fd222692a176faf182", size = 331554157, upload-time = "2026-03-23T12:40:40.497Z" }, + { url = "https://files.pythonhosted.org/packages/31/b9/e81172835ccc9d8b9792cc6bf7524a252a0db9a76ddd693de230402697f9/pyside6_addons-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ac6fe3d4ef4497dde3efc5e896b0acd53ff6c93be4bf485f045690f919419f35", size = 174948482, upload-time = "2026-03-23T12:41:05.379Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a4/426d9333782bf65ab2a20257d6b4b3af9b8d5d7a710da719865fab49d492/pyside6_addons-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8ffb40222456078930816ebcac2f2511716d2acbc11716dd5acc5c365179a753", size = 170430798, upload-time = "2026-03-23T12:41:38.134Z" }, + { url = "https://files.pythonhosted.org/packages/35/9a/46d271fedfabad8c6dce2ebb69bb593745487ed33753a56a47c3ba4fdb1c/pyside6_addons-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:413e6121c24f5ffdce376298059eddecff74aa6d638e94e0f6015b33d29b889e", size = 168723088, upload-time = "2026-03-23T12:42:00.668Z" }, + { url = "https://files.pythonhosted.org/packages/16/cd/1b28264f7dc9a642da2e4e7c02f67418d0949eb7ce329ae20869703c2630/pyside6_addons-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:aaaee83385977a0fe134b2f4fbfb92b45a880d5b656e4d90a708eef10b1b6de8", size = 35698324, upload-time = "2026-03-23T12:42:13.748Z" }, +] + +[[package]] +name = "pyside6-essentials" +version = "6.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/00/8a8583d3429c737cc20e61a43eba8ab1ec13ddb101e99802c2ffeedf3b41/pyside6_essentials-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:85d6ca87ef35fa6565d385ede72ae48420dd3f63113929d10fc800f6b0360e01", size = 108085251, upload-time = "2026-03-23T12:42:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a9/07c9e5c014b871c1b19caf8f994bcd50b345559b81f81671217b49559b67/pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:dc20e7afd5fc6fe51297db91cef997ce60844be578f7a49fc61b7ab9657a8849", size = 78316055, upload-time = "2026-03-23T12:43:04.19Z" }, + { url = "https://files.pythonhosted.org/packages/7c/35/f06b1b641d7600ec46374c16cd37c66fa4a22870326b4eb073a95471035f/pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:4854cb0a1b061e7a576d8fb7bb7cf9f49540d558b1acb7df0742a7afefe61e4e", size = 77380821, upload-time = "2026-03-23T12:43:24.649Z" }, + { url = "https://files.pythonhosted.org/packages/ff/37/ba95c6262836d2b286b4e05a9d16a5e870995d5d2503ac6adc6312208049/pyside6_essentials-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:3b3362882ad9389357a80504e600180006a957731fec05786fced7b038461fdf", size = 75793322, upload-time = "2026-03-23T12:43:35.575Z" }, + { url = "https://files.pythonhosted.org/packages/53/27/d17f25e45820e633a70e6109b35991eda09a5e8000c2a306f0ab7538d48c/pyside6_essentials-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:81ca603dbf21bc39f89bb42db215c25ebe0c879a1a4c387625c321d2730ec187", size = 56337457, upload-time = "2026-03-23T12:43:43.573Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "shiboken6" +version = "6.11.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/1d/b56b7b694fbc871496435488d1f41c5068de546334850d722756511cef65/shiboken6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d88e8a1eb705f2b9ad21db08a61ae1dc0c773e5cd86a069de0754c4cf1f9b43b", size = 476085, upload-time = "2026-03-23T12:47:05.724Z" }, + { url = "https://files.pythonhosted.org/packages/65/cb/4bb0c76011166230daa7c0074aeb3fdb3935c83ac1fef3789b85fcd1a8fc/shiboken6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad54e64f8192ddbdff0c54ac82b89edcd62ed623f502ea21c960541d19514053", size = 271055, upload-time = "2026-03-23T12:47:07.349Z" }, + { url = "https://files.pythonhosted.org/packages/f5/96/771a6e2b530f725303d16d78a321fa4876b98b4f3615c9851880df8c1a43/shiboken6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a10dc7718104ea2dc15d5b0b96909b77162ce1c76fcc6968e6df692b947a00e9", size = 267456, upload-time = "2026-03-23T12:47:08.689Z" }, + { url = "https://files.pythonhosted.org/packages/72/f7/44c0c42c3f5f29dec457fd46ea0552174bcb8aa75becf03bbd90308ba07b/shiboken6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:483ff78a73c7b3189ca924abc694318084f078bcfeaffa68e32024ff2d025ee1", size = 1222132, upload-time = "2026-03-23T12:47:10.143Z" }, + { url = "https://files.pythonhosted.org/packages/fb/99/6e5ee21db2d6af84bbbd7d871d441dafeb069c6de5667b1aa49891a77c66/shiboken6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:3bd76cf56105ab2d62ecaff630366f11264f69b88d488f10f048da9a065781f4", size = 1783186, upload-time = "2026-03-23T12:47:11.832Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "virtualbox-client" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "openai" }, + { name = "paramiko" }, + { name = "pyside6" }, + { name = "python-dotenv" }, + { name = "pywin32" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyinstaller" }, + { name = "setuptools" }, +] + +[package.metadata] +requires-dist = [ + { name = "openai", specifier = ">=2.35.1" }, + { name = "paramiko", specifier = ">=4.0.0" }, + { name = "pyside6", specifier = ">=6.8.0" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "pywin32", specifier = ">=311" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyinstaller", specifier = ">=6.20.0" }, + { name = "setuptools", specifier = ">=82.0.1" }, +]