Initial commit: add AutoDeploy project
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.superpowers/
|
||||
.deploy_tool_config/
|
||||
107
README.md
Normal file
107
README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# AutoDeploy
|
||||
|
||||
AutoDeploy 是一个面向 Windows 的 Spring Boot + Vue 本地自动化部署工具。它通过图形界面扫描前后端项目、检查开发环境、生成部署步骤,并依次完成后端构建、前端依赖安装、SQL 导入和服务启动。
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 自动识别 Maven/Gradle Spring Boot 后端、一个或多个 Vue/Vite 前端以及 `.sql` 文件。
|
||||
- 从 Maven、Gradle 和 Spring Boot 配置中推断 Java 版本,推荐 JDK 8、11、17 或 21。
|
||||
- 检测 JDK、Maven、Gradle、Node.js、npm、Vue CLI、MySQL 和 Git。
|
||||
- 支持手动指定工具路径,并可将工具目录写入当前用户的 `PATH`;JDK、Maven 和 Gradle 会同步设置对应的环境变量。
|
||||
- 通过 `winget` 辅助安装常用开发环境,执行安装前会要求用户确认。
|
||||
- 使用 Maven 或 Gradle 构建后端;已有可运行 JAR 时自动跳过重复构建。
|
||||
- 前端缺少 `node_modules` 时自动执行 `npm install`,随后运行 `serve`、`dev` 或 `start` 脚本。
|
||||
- 可按顺序导入多个 SQL 文件,并启动后端、用户端和管理端。
|
||||
- 服务启动前检查端口占用并终止旧进程,等待新服务端口就绪后再继续。
|
||||
- 后端在独立控制台中显示实时输出,各服务同时保留运行日志。
|
||||
|
||||
## 运行环境
|
||||
|
||||
- Windows 10/11
|
||||
- Python 3.9 或更高版本(需包含 Tkinter,Windows 官方 Python 安装包默认提供)
|
||||
- 待部署项目所需的 JDK、Maven/Gradle、Node.js/npm 和 MySQL
|
||||
- 可选:`winget`,用于在界面中安装缺失工具
|
||||
|
||||
本项目本身只使用 Python 标准库,不需要执行 `pip install`。
|
||||
|
||||
## 快速开始
|
||||
|
||||
克隆项目后进入项目目录:
|
||||
|
||||
```powershell
|
||||
python main.py
|
||||
```
|
||||
|
||||
在工作台中按以下顺序操作:
|
||||
|
||||
1. 选择包含后端、前端和 SQL 文件的项目根目录。
|
||||
2. 点击“扫描项目”,确认识别到的组件和推荐 JDK 版本。
|
||||
3. 点击“检测环境”;缺失的工具可以通过“安装环境”处理,也可以手动选择可执行文件。
|
||||
4. 如果需要导入 SQL,填写 MySQL 用户、密码和数据库名。数据库名默认取第一个 SQL 文件的文件名。
|
||||
5. 点击“执行部署”,在运行日志中查看每个步骤和最终访问地址。
|
||||
|
||||
当前默认端口如下:
|
||||
|
||||
| 服务 | 端口 | 地址 |
|
||||
| --- | ---: | --- |
|
||||
| Spring Boot 后端 | 8080 | `http://localhost:8080` |
|
||||
| 管理端 | 8081 | `http://localhost:8081` |
|
||||
| 用户端 | 8082 | `http://localhost:8082` |
|
||||
|
||||
如果 Vue/Vite 配置文件中声明了端口,工具会优先读取该配置;端口发生冲突时部署会停止并给出提示。
|
||||
|
||||
## 项目识别规则
|
||||
|
||||
- 后端:查找 `pom.xml`、`build.gradle` 或 `build.gradle.kts`,并结合 Spring Boot 依赖和 `src/main/java` 判断候选项目。
|
||||
- 前端:查找 `package.json`,结合 Vue 依赖、构建脚本及 `vue.config.js`/`vite.config.*` 判断项目。
|
||||
- 前端角色:目录名包含 `admin`、`manage`、`后台` 等关键字时识别为管理端;包含 `user`、`client`、`web`、`用户` 等关键字时识别为用户端。
|
||||
- 启动脚本:依次使用 `serve`、`dev`、`start` 中第一个可用的 npm 脚本。
|
||||
- SQL:递归收集项目中的 `.sql` 文件;扫描时跳过 `.git`、`node_modules`、`target`、`build`、`dist` 等目录。
|
||||
|
||||
## 构建与运行流程
|
||||
|
||||
部署计划会根据扫描结果动态生成,典型顺序为:
|
||||
|
||||
1. 使用 `mvn clean package -DskipTests` 或 Gradle `bootJar` 构建后端。
|
||||
2. 为尚未安装依赖的前端执行 `npm install`。
|
||||
3. 使用 MySQL 命令行依次导入 SQL 文件。
|
||||
4. 使用 `java -jar` 启动后端,并等待 8080 端口就绪。
|
||||
5. 使用 npm 启动各前端开发服务器,并等待对应端口就绪。
|
||||
6. 在运行日志中输出所有服务的访问地址。
|
||||
|
||||
运行日志默认写入待部署项目根目录下的 `deploy` 文件夹:
|
||||
|
||||
- `backend.log`
|
||||
- `frontend-user.log`
|
||||
- `frontend-admin.log`
|
||||
|
||||
自定义工具路径保存在本机 `.deploy_tool_config/tool_paths.json` 中,该目录已加入 `.gitignore`。
|
||||
|
||||
## 测试
|
||||
|
||||
运行全部单元测试:
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s tests -p "test_*.py"
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── main.py # 程序入口
|
||||
├── deploy_tool/
|
||||
│ ├── gui.py # Tkinter 工作台
|
||||
│ ├── scanner.py # 项目扫描与组件识别
|
||||
│ ├── environment.py # 本机环境检测与工具发现
|
||||
│ ├── installer.py # winget 安装命令与 JDK 推荐
|
||||
│ ├── path_manager.py # 用户环境变量管理
|
||||
│ ├── deployer.py # 部署计划、命令执行与服务管理
|
||||
│ └── service_console.py # 后端独立控制台与日志转存
|
||||
├── tests/ # 单元测试
|
||||
└── docs/ # 设计说明与实现计划
|
||||
```
|
||||
|
||||
## 当前范围
|
||||
|
||||
当前版本聚焦 Windows 本机的开发/演示环境部署。远程服务器上传、Nginx 配置、Docker 部署、生产环境进程守护和凭据托管暂不在本版本范围内。
|
||||
1
deploy_tool/__init__.py
Normal file
1
deploy_tool/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Spring Boot + Vue deployment workbench."""
|
||||
626
deploy_tool/deployer.py
Normal file
626
deploy_tool/deployer.py
Normal file
@@ -0,0 +1,626 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable, Mapping
|
||||
|
||||
from .environment import discover_executable
|
||||
from .scanner import ScanResult
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeploymentConfig:
|
||||
mysql_user: str = "root"
|
||||
mysql_password: str = ""
|
||||
mysql_database: str = ""
|
||||
backend_port: str = "8080"
|
||||
user_frontend_port: str = "8082"
|
||||
admin_frontend_port: str = "8081"
|
||||
output_dir: Path | None = None
|
||||
tool_paths: Mapping[str, str | Path] | None = None
|
||||
tool_search_roots: Iterable[Path] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeploymentStep:
|
||||
label: str
|
||||
command: list[str]
|
||||
cwd: Path
|
||||
description: str
|
||||
kind: str = "command"
|
||||
input_path: Path | None = None
|
||||
output_path: Path | None = None
|
||||
source_path: Path | None = None
|
||||
background: bool = False
|
||||
service_name: str | None = None
|
||||
port: int | None = None
|
||||
url: str | None = None
|
||||
|
||||
|
||||
OutputHandler = Callable[[str], None]
|
||||
ProcessRunner = Callable[[list[str], Path, OutputHandler, Path | None, bool], int]
|
||||
|
||||
|
||||
def build_deployment_plan(scan: ScanResult, config: DeploymentConfig) -> list[DeploymentStep]:
|
||||
steps: list[DeploymentStep] = []
|
||||
output_dir = config.output_dir or scan.root / "deploy"
|
||||
|
||||
if scan.backend:
|
||||
backend_dir = scan.backend.path
|
||||
existing_jar = _find_existing_backend_jar(backend_dir)
|
||||
if existing_jar:
|
||||
steps.append(
|
||||
DeploymentStep(
|
||||
"跳过后端构建",
|
||||
[],
|
||||
backend_dir,
|
||||
f"检测到已有后端 jar,跳过构建: {existing_jar}",
|
||||
kind="backend_build_skip",
|
||||
)
|
||||
)
|
||||
else:
|
||||
build_file_names = {path.name for path in backend_dir.iterdir() if path.is_file()} if backend_dir.exists() else set()
|
||||
if "pom.xml" in build_file_names:
|
||||
command = [_tool_command(config, "Maven", "mvn"), "clean", "package", "-DskipTests"]
|
||||
elif "gradlew.bat" in build_file_names:
|
||||
command = ["gradlew.bat", "bootJar"]
|
||||
elif "gradlew" in build_file_names:
|
||||
command = ["./gradlew", "bootJar"]
|
||||
elif backend_dir.exists():
|
||||
command = [_tool_command(config, "Gradle", "gradle"), "bootJar"]
|
||||
else:
|
||||
command = [_tool_command(config, "Maven", "mvn"), "clean", "package", "-DskipTests"]
|
||||
steps.append(DeploymentStep("构建后端", command, backend_dir, "打包 Spring Boot 应用", kind="backend_build"))
|
||||
|
||||
frontends = scan.detected_frontends
|
||||
for frontend in frontends:
|
||||
display_name = frontend.metadata.get("display_name", frontend.path.name)
|
||||
npm_command = _tool_command(config, "npm", _default_npm_command())
|
||||
if not _frontend_dependencies_installed(frontend.path):
|
||||
steps.append(
|
||||
DeploymentStep(
|
||||
f"安装{display_name}依赖",
|
||||
[npm_command, "install"],
|
||||
frontend.path,
|
||||
f"安装{display_name} package.json 中的依赖",
|
||||
kind="frontend_install",
|
||||
)
|
||||
)
|
||||
|
||||
if scan.sql_files and config.mysql_database:
|
||||
for sql_file in scan.sql_files:
|
||||
command = [_tool_command(config, "MySQL", "mysql"), "-u", config.mysql_user]
|
||||
if config.mysql_password:
|
||||
command.append(f"-p{config.mysql_password}")
|
||||
if not _sql_file_selects_database(sql_file):
|
||||
command.append(config.mysql_database)
|
||||
steps.append(
|
||||
DeploymentStep(
|
||||
"导入 SQL",
|
||||
command,
|
||||
scan.root,
|
||||
f"导入 {sql_file.name}",
|
||||
kind="sql_import",
|
||||
input_path=sql_file,
|
||||
)
|
||||
)
|
||||
|
||||
if scan.backend:
|
||||
backend_context = scan.backend.metadata.get("context_path", "")
|
||||
backend_url = f"http://localhost:{config.backend_port}{backend_context}"
|
||||
steps.append(
|
||||
DeploymentStep(
|
||||
"启动后端",
|
||||
[_tool_command(config, "JDK", "java"), f"-Dserver.port={config.backend_port}", "-jar", "latest-built-jar"],
|
||||
output_dir,
|
||||
"启动 Spring Boot 服务",
|
||||
kind="backend_start",
|
||||
source_path=scan.backend.path,
|
||||
background=True,
|
||||
service_name="后端",
|
||||
port=_validate_port(config.backend_port, "后端端口"),
|
||||
url=backend_url,
|
||||
)
|
||||
)
|
||||
|
||||
frontend_urls: list[tuple[str, str]] = []
|
||||
allocated_ports: dict[int, str] = {}
|
||||
if scan.backend:
|
||||
allocated_ports[_validate_port(config.backend_port, "后端端口")] = "后端"
|
||||
for index, frontend in enumerate(frontends):
|
||||
display_name = frontend.metadata.get("display_name", frontend.path.name)
|
||||
port = _frontend_port(frontend, config, index)
|
||||
if port in allocated_ports:
|
||||
raise ValueError(f"{display_name}端口 {port} 与{allocated_ports[port]}端口冲突")
|
||||
allocated_ports[port] = display_name
|
||||
start_script = frontend.metadata.get("start_script") or _frontend_start_script(frontend.path)
|
||||
if not start_script:
|
||||
raise ValueError(f"{display_name}缺少 serve、dev 或 start 启动脚本: {frontend.path / 'package.json'}")
|
||||
url = f"http://localhost:{port}/"
|
||||
role = frontend.metadata.get("role", f"frontend-{index + 1}")
|
||||
steps.append(
|
||||
DeploymentStep(
|
||||
f"启动{display_name}",
|
||||
[
|
||||
_tool_command(config, "npm", _default_npm_command()),
|
||||
"run",
|
||||
start_script,
|
||||
"--",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
str(port),
|
||||
],
|
||||
frontend.path,
|
||||
f"启动{display_name}开发服务",
|
||||
kind="frontend_start",
|
||||
output_path=output_dir / f"frontend-{role}.log",
|
||||
background=True,
|
||||
service_name=display_name,
|
||||
port=port,
|
||||
url=url,
|
||||
)
|
||||
)
|
||||
frontend_urls.append((display_name, url))
|
||||
|
||||
addresses: list[tuple[str, str]] = []
|
||||
if scan.backend:
|
||||
addresses.append(("后端", backend_url))
|
||||
addresses.extend(frontend_urls)
|
||||
if addresses:
|
||||
summary = "部署完成,访问地址:\n" + "\n".join(f" {name}: {url}" for name, url in addresses)
|
||||
steps.append(DeploymentStep("访问地址", [], output_dir, summary, kind="deployment_summary"))
|
||||
|
||||
return steps
|
||||
|
||||
|
||||
def _tool_command(config: DeploymentConfig, tool_name: str, fallback: str) -> str:
|
||||
configured = config.tool_paths.get(tool_name) if config.tool_paths else None
|
||||
if configured:
|
||||
path = Path(configured)
|
||||
if path.exists():
|
||||
return str(path)
|
||||
|
||||
discovered = discover_executable(tool_name, config.tool_search_roots)
|
||||
return str(discovered) if discovered else fallback
|
||||
|
||||
|
||||
def _default_npm_command() -> str:
|
||||
return "npm.cmd" if os.name == "nt" else "npm"
|
||||
|
||||
|
||||
def _validate_port(value: str | int, label: str) -> int:
|
||||
try:
|
||||
port = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{label}必须是 1 到 65535 之间的整数: {value}") from exc
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError(f"{label}必须是 1 到 65535 之间的整数: {value}")
|
||||
return port
|
||||
|
||||
|
||||
def _frontend_port(frontend, config: DeploymentConfig, index: int) -> int:
|
||||
role = frontend.metadata.get("role")
|
||||
if role == "user":
|
||||
return _validate_port(config.user_frontend_port, "用户端端口")
|
||||
if role == "admin":
|
||||
return _validate_port(config.admin_frontend_port, "管理端端口")
|
||||
configured = frontend.metadata.get("port")
|
||||
if configured:
|
||||
return _validate_port(configured, f"{frontend.path.name} 端口")
|
||||
return 8081 + index
|
||||
|
||||
|
||||
def _frontend_start_script(frontend_dir: Path) -> str | None:
|
||||
package_file = frontend_dir / "package.json"
|
||||
try:
|
||||
package = json.loads(package_file.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
scripts = package.get("scripts")
|
||||
if not isinstance(scripts, dict):
|
||||
return None
|
||||
for name in ("serve", "dev", "start"):
|
||||
if isinstance(scripts.get(name), str):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _sql_file_selects_database(sql_file: Path) -> bool:
|
||||
try:
|
||||
text = sql_file.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
return False
|
||||
normalized = text.lower()
|
||||
return "create database" in normalized and "use `" in normalized or "create database" in normalized and "use " in normalized
|
||||
|
||||
|
||||
def resolve_process_command(command: list[str], path: str | None = None) -> list[str]:
|
||||
if not command:
|
||||
return command
|
||||
executable = command[0]
|
||||
if Path(executable).is_absolute() or any(separator in executable for separator in ("/", "\\")):
|
||||
return command
|
||||
|
||||
resolved = shutil.which(executable, path=path)
|
||||
if resolved is None and os.name == "nt":
|
||||
for suffix in (".cmd", ".bat", ".exe"):
|
||||
resolved = shutil.which(executable + suffix, path=path)
|
||||
if resolved:
|
||||
break
|
||||
if resolved is None:
|
||||
return command
|
||||
return [resolved, *command[1:]]
|
||||
|
||||
|
||||
def _extract_server_port(command: list[str]) -> int | None:
|
||||
prefix = "-Dserver.port="
|
||||
for part in command:
|
||||
if part.startswith(prefix):
|
||||
raw_port = part[len(prefix) :]
|
||||
try:
|
||||
port = int(raw_port)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"后端端口必须是 1 到 65535 之间的整数: {raw_port}") from exc
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError(f"后端端口必须是 1 到 65535 之间的整数: {raw_port}")
|
||||
return port
|
||||
return None
|
||||
|
||||
|
||||
def _parse_windows_listening_pids(output: str, port: int) -> set[int]:
|
||||
pids: set[int] = set()
|
||||
for line in output.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) < 5 or parts[0].upper() != "TCP" or parts[-2].upper() != "LISTENING":
|
||||
continue
|
||||
try:
|
||||
local_port = int(parts[1].rsplit(":", 1)[1])
|
||||
pid = int(parts[-1])
|
||||
except (IndexError, ValueError):
|
||||
continue
|
||||
if local_port == port:
|
||||
pids.add(pid)
|
||||
return pids
|
||||
|
||||
|
||||
def _find_listening_pids(port: int) -> set[int]:
|
||||
if os.name != "nt":
|
||||
raise RuntimeError("自动终止端口占用进程当前仅支持 Windows")
|
||||
result = subprocess.run(
|
||||
["netstat", "-ano", "-p", "tcp"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
errors="replace",
|
||||
shell=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
message = result.stderr.strip() or f"netstat 退出码 {result.returncode}"
|
||||
raise RuntimeError(f"无法检查端口占用: {message}")
|
||||
return _parse_windows_listening_pids(result.stdout, port)
|
||||
|
||||
|
||||
def _terminate_process_tree(pid: int) -> None:
|
||||
result = subprocess.run(
|
||||
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
errors="replace",
|
||||
shell=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
message = result.stdout.strip() or f"taskkill 退出码 {result.returncode}"
|
||||
raise RuntimeError(f"无法终止进程 PID {pid}: {message}")
|
||||
|
||||
|
||||
def _is_port_open(port: int) -> bool:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _wait_until_port_closed(port: int, timeout: float = 10.0) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if not _find_listening_pids(port):
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
return not _find_listening_pids(port)
|
||||
|
||||
|
||||
def _replace_port_listeners(
|
||||
port: int,
|
||||
on_output: OutputHandler,
|
||||
*,
|
||||
find_listeners=None,
|
||||
terminate=None,
|
||||
wait_until_closed=None,
|
||||
) -> bool:
|
||||
find_listeners = find_listeners or _find_listening_pids
|
||||
terminate = terminate or _terminate_process_tree
|
||||
wait_until_closed = wait_until_closed or _wait_until_port_closed
|
||||
|
||||
for pid in sorted(find_listeners(port)):
|
||||
if pid not in find_listeners(port):
|
||||
on_output(f"端口 {port} 的 PID {pid} 已退出,无需再次终止。")
|
||||
continue
|
||||
on_output(f"端口 {port} 已被 PID {pid} 占用,正在终止旧进程树...")
|
||||
try:
|
||||
terminate(pid)
|
||||
except RuntimeError:
|
||||
if pid in find_listeners(port):
|
||||
raise
|
||||
on_output(f"PID {pid} 在终止前已退出,继续检查端口 {port}。")
|
||||
return wait_until_closed(port)
|
||||
|
||||
|
||||
def _restart_backend(
|
||||
command: list[str],
|
||||
cwd: Path,
|
||||
on_output: OutputHandler,
|
||||
*,
|
||||
replace_listeners=None,
|
||||
popen=None,
|
||||
) -> int:
|
||||
port = _extract_server_port(command)
|
||||
if port is None:
|
||||
raise ValueError("后端启动命令缺少 -Dserver.port 配置")
|
||||
replace_listeners = replace_listeners or _replace_port_listeners
|
||||
if not replace_listeners(port, on_output):
|
||||
on_output(f"端口 {port} 未能在限定时间内释放,后端未启动。")
|
||||
return 1
|
||||
popen = popen or subprocess.Popen
|
||||
log_path = cwd / "backend.log"
|
||||
pid_path = cwd / "backend.pid"
|
||||
try:
|
||||
pid_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
with log_path.open("a", encoding="utf-8") as log_file:
|
||||
log_file.write(f"\n\n===== 后端部署 {started_at},端口 {port} =====\n")
|
||||
log_file.flush()
|
||||
if os.name == "nt":
|
||||
console_runner = Path(__file__).with_name("service_console.py")
|
||||
process = popen(
|
||||
[
|
||||
sys.executable,
|
||||
str(console_runner),
|
||||
"--title",
|
||||
f"后端服务 - 端口 {port}",
|
||||
"--log",
|
||||
str(log_path),
|
||||
"--pid-file",
|
||||
str(pid_path),
|
||||
"--",
|
||||
*command,
|
||||
],
|
||||
cwd=cwd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
shell=False,
|
||||
creationflags=_backend_console_creation_flags(),
|
||||
)
|
||||
else:
|
||||
log_file = log_path.open("a", encoding="utf-8")
|
||||
try:
|
||||
process = popen(
|
||||
command,
|
||||
cwd=cwd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
shell=False,
|
||||
creationflags=0,
|
||||
)
|
||||
finally:
|
||||
log_file.close()
|
||||
deadline = time.monotonic() + 60.0
|
||||
while time.monotonic() < deadline:
|
||||
return_code = process.poll()
|
||||
if return_code is not None:
|
||||
on_output(f"后端进程 PID {process.pid} 在端口就绪前退出,退出码: {return_code},日志: {log_path}")
|
||||
return return_code or 1
|
||||
service_pid = _read_service_pid(pid_path) or process.pid
|
||||
if service_pid in _find_listening_pids(port) and _is_port_open(port):
|
||||
on_output(f"后端服务启动成功,PID: {service_pid},端口: {port},日志: {log_path}")
|
||||
return 0
|
||||
time.sleep(0.2)
|
||||
|
||||
on_output(f"后端进程 PID {process.pid} 启动超时,端口 {port} 在 60 秒内未就绪,正在终止进程树。")
|
||||
_terminate_process_tree(process.pid)
|
||||
return 1
|
||||
|
||||
|
||||
def _restart_frontend(
|
||||
command: list[str],
|
||||
cwd: Path,
|
||||
on_output: OutputHandler,
|
||||
*,
|
||||
port: int,
|
||||
service_name: str,
|
||||
log_path: Path,
|
||||
replace_listeners=None,
|
||||
popen=None,
|
||||
) -> int:
|
||||
port = _validate_port(port, f"{service_name}端口")
|
||||
replace_listeners = replace_listeners or _replace_port_listeners
|
||||
if not replace_listeners(port, on_output):
|
||||
on_output(f"端口 {port} 未能在限定时间内释放,{service_name}未启动。")
|
||||
return 1
|
||||
|
||||
popen = popen or subprocess.Popen
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with log_path.open("a", encoding="utf-8") as log_file:
|
||||
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
log_file.write(f"\n\n===== {service_name}部署 {started_at},端口 {port} =====\n")
|
||||
log_file.flush()
|
||||
process = popen(
|
||||
command,
|
||||
cwd=cwd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
shell=False,
|
||||
creationflags=_background_creation_flags(),
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + 120.0
|
||||
while time.monotonic() < deadline:
|
||||
if _is_port_open(port) and _find_listening_pids(port):
|
||||
on_output(
|
||||
f"{service_name}启动成功,PID: {process.pid},端口: {port},"
|
||||
f"访问地址: http://localhost:{port}/,日志: {log_path}"
|
||||
)
|
||||
return 0
|
||||
return_code = process.poll()
|
||||
if return_code is not None:
|
||||
on_output(f"{service_name}进程 PID {process.pid} 在端口就绪前退出,退出码: {return_code},日志: {log_path}")
|
||||
return return_code or 1
|
||||
time.sleep(0.2)
|
||||
|
||||
on_output(f"{service_name}进程 PID {process.pid} 启动超时,端口 {port} 在 120 秒内未就绪,正在终止进程树。")
|
||||
_terminate_process_tree(process.pid)
|
||||
return 1
|
||||
|
||||
|
||||
def _background_creation_flags() -> int:
|
||||
if os.name != "nt":
|
||||
return 0
|
||||
return subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
|
||||
|
||||
def _backend_console_creation_flags() -> int:
|
||||
if os.name != "nt":
|
||||
return 0
|
||||
return subprocess.CREATE_NEW_CONSOLE | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
|
||||
|
||||
def _read_service_pid(pid_path: Path) -> int | None:
|
||||
try:
|
||||
return int(pid_path.read_text(encoding="ascii").strip())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def run_steps(
|
||||
steps: Iterable[DeploymentStep],
|
||||
on_output: OutputHandler,
|
||||
process_runner: ProcessRunner = None,
|
||||
) -> int:
|
||||
runner = process_runner or _run_process
|
||||
for step in steps:
|
||||
try:
|
||||
command = _resolve_command(step)
|
||||
command_text = " ".join(command)
|
||||
on_output(f"\n[{step.label}]{' ' + command_text if command_text else ''}")
|
||||
if step.input_path:
|
||||
on_output(f"stdin: {step.input_path}")
|
||||
if _is_skip_step(step):
|
||||
on_output(step.description)
|
||||
continue
|
||||
if step.kind == "deployment_summary":
|
||||
on_output(step.description)
|
||||
continue
|
||||
step.cwd.mkdir(parents=True, exist_ok=True)
|
||||
if step.kind == "frontend_start" and process_runner is None:
|
||||
if step.port is None or step.output_path is None:
|
||||
raise ValueError(f"{step.label}缺少端口或日志路径配置")
|
||||
return_code = _restart_frontend(
|
||||
resolve_process_command(command),
|
||||
step.cwd,
|
||||
on_output,
|
||||
port=step.port,
|
||||
service_name=step.service_name or "前端",
|
||||
log_path=step.output_path,
|
||||
)
|
||||
else:
|
||||
return_code = runner(command, step.cwd, on_output, step.input_path, step.background)
|
||||
except Exception as exc:
|
||||
on_output(f"步骤失败: {exc}")
|
||||
return 1
|
||||
if return_code != 0:
|
||||
on_output(f"步骤失败,退出码: {return_code}")
|
||||
return return_code
|
||||
return 0
|
||||
|
||||
|
||||
def _resolve_command(step: DeploymentStep) -> list[str]:
|
||||
if step.kind != "backend_start":
|
||||
return step.command
|
||||
backend_dir = step.source_path or step.cwd
|
||||
jar_file = _find_latest_backend_jar(backend_dir)
|
||||
return [part if part != "latest-built-jar" else str(jar_file) for part in step.command]
|
||||
|
||||
|
||||
def _is_skip_step(step: DeploymentStep) -> bool:
|
||||
return step.kind == "skip" or step.kind.endswith("_skip")
|
||||
|
||||
|
||||
def _frontend_dependencies_installed(frontend_dir: Path) -> bool:
|
||||
return (frontend_dir / "node_modules").is_dir()
|
||||
|
||||
|
||||
def _find_latest_backend_jar(backend_dir: Path) -> Path:
|
||||
candidates: list[Path] = []
|
||||
for folder in (backend_dir / "target", backend_dir / "build" / "libs"):
|
||||
if folder.is_dir():
|
||||
candidates.extend(path for path in folder.glob("*.jar") if _is_runnable_jar(path))
|
||||
if not candidates:
|
||||
raise FileNotFoundError(f"未找到后端 jar 产物: {backend_dir / 'target'} 或 {backend_dir / 'build' / 'libs'}")
|
||||
return max(candidates, key=lambda path: (path.stat().st_mtime, path.name))
|
||||
|
||||
|
||||
def _find_existing_backend_jar(backend_dir: Path) -> Path | None:
|
||||
try:
|
||||
return _find_latest_backend_jar(backend_dir)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_runnable_jar(path: Path) -> bool:
|
||||
name = path.name.lower()
|
||||
excluded_suffixes = ("-sources.jar", "-javadoc.jar", "-plain.jar")
|
||||
return not name.startswith("original-") and not name.endswith(excluded_suffixes)
|
||||
|
||||
|
||||
def _run_process(
|
||||
command: list[str],
|
||||
cwd: Path,
|
||||
on_output: OutputHandler,
|
||||
input_path: Path | None = None,
|
||||
background: bool = False,
|
||||
) -> int:
|
||||
command = resolve_process_command(command)
|
||||
if background:
|
||||
return _restart_backend(command, cwd, on_output)
|
||||
|
||||
input_file = input_path.open("r", encoding="utf-8", errors="ignore") if input_path else None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=cwd,
|
||||
stdin=input_file,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
shell=False,
|
||||
)
|
||||
assert process.stdout is not None
|
||||
for line in process.stdout:
|
||||
on_output(line.rstrip())
|
||||
return process.wait()
|
||||
finally:
|
||||
if input_file:
|
||||
input_file.close()
|
||||
189
deploy_tool/environment.py
Normal file
189
deploy_tool/environment.py
Normal file
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
|
||||
CommandRunner = Callable[[list[str]], subprocess.CompletedProcess[str]]
|
||||
|
||||
|
||||
DEFAULT_CHECKS: dict[str, list[str]] = {
|
||||
"JDK": ["java", "-version"],
|
||||
"Maven": ["mvn", "-version"],
|
||||
"Gradle": ["gradle", "-version"],
|
||||
"Node.js": ["node", "--version"],
|
||||
"npm": ["npm", "--version"],
|
||||
"Vue CLI": ["vue", "--version"],
|
||||
"MySQL": ["mysql", "--version"],
|
||||
"Git": ["git", "--version"],
|
||||
}
|
||||
|
||||
EXECUTABLE_NAMES: dict[str, list[str]] = {
|
||||
"JDK": ["java.exe", "java"],
|
||||
"Maven": ["mvn.cmd", "mvn.bat", "mvn"],
|
||||
"Gradle": ["gradle.bat", "gradle"],
|
||||
"Node.js": ["node.exe", "node"],
|
||||
"npm": ["npm.cmd", "npm"],
|
||||
"Vue CLI": ["vue.cmd", "vue"],
|
||||
"MySQL": ["mysql.exe", "mysql"],
|
||||
"Git": ["git.exe", "git"],
|
||||
}
|
||||
|
||||
CONFIG_DIR = Path(".deploy_tool_config")
|
||||
TOOL_PATHS_FILE = CONFIG_DIR / "tool-paths.json"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnvironmentCheck:
|
||||
name: str
|
||||
command: list[str]
|
||||
installed: bool
|
||||
version: str
|
||||
message: str
|
||||
source: str = "PATH"
|
||||
|
||||
|
||||
def check_environment(
|
||||
runner: CommandRunner | None = None,
|
||||
checks: dict[str, list[str]] | None = None,
|
||||
manual_paths: dict[str, str | Path] | None = None,
|
||||
search_roots: Iterable[Path] | None = None,
|
||||
) -> list[EnvironmentCheck]:
|
||||
command_runner = runner or _run_command
|
||||
selected_checks = checks or DEFAULT_CHECKS
|
||||
configured_paths = {name: Path(value) for name, value in (manual_paths or {}).items() if str(value).strip()}
|
||||
roots = list(search_roots) if search_roots is not None else default_search_roots()
|
||||
results: list[EnvironmentCheck] = []
|
||||
|
||||
for name, command in selected_checks.items():
|
||||
path_result = _try_command(name, command, "PATH", command_runner)
|
||||
if path_result.installed:
|
||||
results.append(path_result)
|
||||
continue
|
||||
|
||||
manual_path = configured_paths.get(name)
|
||||
if manual_path and manual_path.exists():
|
||||
manual_command = [str(manual_path), *command[1:]]
|
||||
manual_result = _try_command(name, manual_command, "手动指定", command_runner)
|
||||
if manual_result.installed:
|
||||
results.append(manual_result)
|
||||
continue
|
||||
|
||||
discovered = discover_executable(name, roots)
|
||||
if discovered:
|
||||
discovered_command = [str(discovered), *command[1:]]
|
||||
discovered_result = _try_command(name, discovered_command, "自动发现", command_runner)
|
||||
if discovered_result.installed:
|
||||
results.append(discovered_result)
|
||||
continue
|
||||
|
||||
results.append(path_result)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def discover_executable(name: str, search_roots: Iterable[Path] | None = None) -> Path | None:
|
||||
executable_names = {item.lower() for item in EXECUTABLE_NAMES.get(name, [])}
|
||||
if not executable_names:
|
||||
return None
|
||||
|
||||
for root in search_roots or default_search_roots():
|
||||
if not root.exists() or not root.is_dir():
|
||||
continue
|
||||
for candidate in _direct_candidates(name, root):
|
||||
if candidate.exists() and candidate.name.lower() in executable_names:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def default_search_roots() -> list[Path]:
|
||||
roots = [
|
||||
Path(os.environ.get("ProgramFiles", r"C:\Program Files")),
|
||||
Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")),
|
||||
Path(os.environ.get("LOCALAPPDATA", "")),
|
||||
Path(r"C:\ProgramData"),
|
||||
Path(r"C:\xampp"),
|
||||
Path(r"C:\phpstudy_pro"),
|
||||
Path(r"D:\Program Files"),
|
||||
Path(r"D:\phpstudy_pro"),
|
||||
]
|
||||
return [root for root in roots if str(root)]
|
||||
|
||||
|
||||
def load_tool_paths(config_file: Path = TOOL_PATHS_FILE) -> dict[str, Path]:
|
||||
if not config_file.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(config_file.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {str(name): Path(value) for name, value in data.items() if isinstance(value, str) and value.strip()}
|
||||
|
||||
|
||||
def save_tool_paths(paths: dict[str, str | Path], config_file: Path = TOOL_PATHS_FILE) -> None:
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {name: str(path) for name, path in paths.items() if str(path).strip()}
|
||||
config_file.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _try_command(name: str, command: list[str], source: str, runner: CommandRunner) -> EnvironmentCheck:
|
||||
try:
|
||||
completed = runner(command)
|
||||
except FileNotFoundError:
|
||||
return EnvironmentCheck(name, command, False, "", f"{name} 未安装或不在 PATH 中", source)
|
||||
except subprocess.TimeoutExpired:
|
||||
return EnvironmentCheck(name, command, False, "", f"{name} 检测超时", source)
|
||||
except OSError as exc:
|
||||
return EnvironmentCheck(name, command, False, "", f"{name} 检测失败: {exc}", source)
|
||||
|
||||
output = _combine_output(completed)
|
||||
installed = completed.returncode == 0
|
||||
message = "已安装" if installed else f"命令返回错误码 {completed.returncode}"
|
||||
if installed and source != "PATH":
|
||||
message = f"已安装,来源: {source}"
|
||||
return EnvironmentCheck(name, command, installed, output, message, source)
|
||||
|
||||
|
||||
def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(command, capture_output=True, text=True, timeout=6, shell=False)
|
||||
|
||||
|
||||
def _combine_output(completed: subprocess.CompletedProcess[str]) -> str:
|
||||
output = "\n".join(part.strip() for part in (completed.stdout, completed.stderr) if part and part.strip())
|
||||
return output.splitlines()[0] if output else ""
|
||||
|
||||
|
||||
def _direct_candidates(name: str, root: Path) -> list[Path]:
|
||||
if name == "MySQL":
|
||||
return [
|
||||
root / "MySQL" / "MySQL Server 8.4" / "bin" / "mysql.exe",
|
||||
root / "MySQL" / "MySQL Server 8.0" / "bin" / "mysql.exe",
|
||||
root / "mysql" / "bin" / "mysql.exe",
|
||||
root / "Extensions" / "MySQL" / "bin" / "mysql.exe",
|
||||
]
|
||||
if name == "JDK":
|
||||
return _glob(root, "Java/*/bin/java.exe") + _glob(root, "Eclipse Adoptium/*/bin/java.exe")
|
||||
if name == "Node.js":
|
||||
return [root / "nodejs" / "node.exe", root / "Programs" / "nodejs" / "node.exe"]
|
||||
if name == "npm":
|
||||
return [root / "nodejs" / "npm.cmd", root / "Programs" / "nodejs" / "npm.cmd"]
|
||||
if name == "Maven":
|
||||
return _glob(root, "apache-maven*/bin/mvn.cmd")
|
||||
if name == "Gradle":
|
||||
return _glob(root, "gradle*/bin/gradle.bat")
|
||||
if name == "Git":
|
||||
return [root / "Git" / "bin" / "git.exe", root / "Git" / "cmd" / "git.exe"]
|
||||
return []
|
||||
|
||||
|
||||
def _glob(root: Path, pattern: str) -> list[Path]:
|
||||
try:
|
||||
return list(root.glob(pattern))
|
||||
except OSError:
|
||||
return []
|
||||
554
deploy_tool/gui.py
Normal file
554
deploy_tool/gui.py
Normal file
@@ -0,0 +1,554 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from .deployer import DeploymentConfig, build_deployment_plan, run_steps
|
||||
from .environment import EnvironmentCheck, check_environment, load_tool_paths, save_tool_paths
|
||||
from .installer import build_install_command, recommend_jdk_version, run_install_command
|
||||
from .path_manager import apply_user_environment, build_environment_update
|
||||
from .scanner import ScanResult, scan_project
|
||||
|
||||
|
||||
TEXT = {
|
||||
"title": "\u0053\u0070\u0072\u0069\u006e\u0067\u0020\u0042\u006f\u006f\u0074\u0020\u002b\u0020\u0056\u0075\u0065\u0020\u81ea\u52a8\u5316\u90e8\u7f72\u5de5\u5177",
|
||||
"workbench": "\u90e8\u7f72\u5de5\u4f5c\u53f0",
|
||||
"select_dir": "\u9009\u62e9\u76ee\u5f55",
|
||||
"scan": "\u626b\u63cf\u9879\u76ee",
|
||||
"check_env": "\u68c0\u6d4b\u73af\u5883",
|
||||
"deploy": "\u6267\u884c\u90e8\u7f72",
|
||||
"results": "\u9879\u76ee\u4e0e\u73af\u5883\u7ed3\u679c",
|
||||
"config": "\u90e8\u7f72\u914d\u7f6e",
|
||||
"tool_paths": "\u5de5\u5177\u8def\u5f84\uff08\u53ef\u9009\uff09",
|
||||
"logs": "\u8fd0\u884c\u65e5\u5fd7",
|
||||
}
|
||||
|
||||
SIDEBAR_ITEMS = ("项目识别", "环境检测", "工具路径", "数据库", "部署任务", "运行日志")
|
||||
|
||||
|
||||
def database_name_from_sql_files(sql_files: list[Path]) -> str:
|
||||
return sql_files[0].stem if sql_files else ""
|
||||
|
||||
|
||||
class DeployWorkbench(tk.Tk):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.title(TEXT["title"])
|
||||
self.geometry("1260x820")
|
||||
self.minsize(1080, 700)
|
||||
|
||||
self.project_dir = tk.StringVar()
|
||||
self.backend_port = tk.StringVar(value="8080")
|
||||
self.user_frontend_port = tk.StringVar(value="8082")
|
||||
self.admin_frontend_port = tk.StringVar(value="8081")
|
||||
self.mysql_user = tk.StringVar(value="root")
|
||||
self.mysql_password = tk.StringVar()
|
||||
self.mysql_database = tk.StringVar()
|
||||
self.output_dir = tk.StringVar()
|
||||
self.jdk_version = tk.StringVar(value="8")
|
||||
self.jdk_reason = tk.StringVar(value="默认推荐 JDK 8,扫描项目后会自动调整。")
|
||||
|
||||
self.tool_paths = load_tool_paths()
|
||||
self.tool_path_vars: dict[str, tk.StringVar] = {
|
||||
name: tk.StringVar(value=str(self.tool_paths.get(name, "")))
|
||||
for name in ("MySQL", "JDK", "Maven", "Gradle", "Node.js", "npm", "Git")
|
||||
}
|
||||
|
||||
self.scan_result: ScanResult | None = None
|
||||
self.environment_results: list[EnvironmentCheck] = []
|
||||
self.ui_queue: queue.Queue[tuple[str, object]] = queue.Queue()
|
||||
self.sidebar_buttons: dict[str, tk.Button] = {}
|
||||
self.tool_path_entries: dict[str, ttk.Entry] = {}
|
||||
self.deployment_running = False
|
||||
self.deploy_buttons: list[ttk.Button] = []
|
||||
|
||||
self._configure_style()
|
||||
self._build_layout()
|
||||
self.after(120, self._drain_queue)
|
||||
|
||||
def _configure_style(self) -> None:
|
||||
self.configure(bg="#f5f7fb")
|
||||
self.style = ttk.Style(self)
|
||||
self.style.theme_use("clam")
|
||||
self.style.configure("TFrame", background="#f5f7fb")
|
||||
self.style.configure("Sidebar.TFrame", background="#172033")
|
||||
self.style.configure("Panel.TFrame", background="#ffffff", relief="flat")
|
||||
self.style.configure("TLabel", background="#f5f7fb", foreground="#243043", font=("Microsoft YaHei UI", 10))
|
||||
self.style.configure("Sidebar.TLabel", background="#172033", foreground="#dce6f7", font=("Microsoft YaHei UI", 10))
|
||||
self.style.configure("Title.TLabel", background="#f5f7fb", foreground="#172033", font=("Microsoft YaHei UI", 18, "bold"))
|
||||
self.style.configure("CardTitle.TLabel", background="#ffffff", foreground="#172033", font=("Microsoft YaHei UI", 11, "bold"))
|
||||
self.style.configure("CardValue.TLabel", background="#ffffff", foreground="#2563eb", font=("Microsoft YaHei UI", 12, "bold"))
|
||||
self.style.configure("Muted.TLabel", background="#ffffff", foreground="#667085", font=("Microsoft YaHei UI", 9))
|
||||
self.style.configure("Primary.TButton", font=("Microsoft YaHei UI", 10, "bold"), padding=(14, 8))
|
||||
self.style.configure("TButton", font=("Microsoft YaHei UI", 10), padding=(10, 6))
|
||||
self.style.configure("TEntry", padding=6)
|
||||
|
||||
def _build_layout(self) -> None:
|
||||
shell = ttk.Frame(self)
|
||||
shell.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
sidebar = ttk.Frame(shell, style="Sidebar.TFrame", width=190)
|
||||
sidebar.pack(side=tk.LEFT, fill=tk.Y)
|
||||
sidebar.pack_propagate(False)
|
||||
ttk.Label(sidebar, text=TEXT["workbench"], style="Sidebar.TLabel", font=("Microsoft YaHei UI", 16, "bold")).pack(
|
||||
anchor=tk.W, padx=22, pady=(26, 22)
|
||||
)
|
||||
for item in SIDEBAR_ITEMS:
|
||||
button = tk.Button(
|
||||
sidebar,
|
||||
text=item,
|
||||
command=lambda name=item: self.activate_sidebar_item(name),
|
||||
anchor=tk.W,
|
||||
relief=tk.FLAT,
|
||||
bd=0,
|
||||
cursor="hand2",
|
||||
bg="#172033",
|
||||
activebackground="#22314d",
|
||||
fg="#dce6f7",
|
||||
activeforeground="#ffffff",
|
||||
font=("Microsoft YaHei UI", 10),
|
||||
padx=20,
|
||||
pady=8,
|
||||
)
|
||||
button.pack(fill=tk.X, padx=6, pady=1)
|
||||
self.sidebar_buttons[item] = button
|
||||
|
||||
content = ttk.Frame(shell)
|
||||
content.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=24, pady=22)
|
||||
self._build_header(content)
|
||||
self._build_dashboard(content)
|
||||
self._build_middle(content)
|
||||
self._build_logs(content)
|
||||
|
||||
def _build_header(self, parent: ttk.Frame) -> None:
|
||||
ttk.Label(parent, text="Spring Boot + Vue 自动化部署", style="Title.TLabel").pack(anchor=tk.W)
|
||||
action_bar = ttk.Frame(parent)
|
||||
action_bar.pack(fill=tk.X, pady=(14, 16))
|
||||
self.project_dir_entry = ttk.Entry(action_bar, textvariable=self.project_dir)
|
||||
self.project_dir_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 10))
|
||||
ttk.Button(action_bar, text=TEXT["select_dir"], command=self.choose_folder).pack(side=tk.LEFT, padx=4)
|
||||
ttk.Button(action_bar, text=TEXT["scan"], style="Primary.TButton", command=self.scan_folder).pack(side=tk.LEFT, padx=4)
|
||||
ttk.Button(action_bar, text=TEXT["check_env"], command=self.check_environment_async).pack(side=tk.LEFT, padx=4)
|
||||
ttk.Button(action_bar, text="\u5b89\u88c5\u73af\u5883", style="Primary.TButton", command=self.show_environment_installer).pack(side=tk.LEFT, padx=4)
|
||||
self.deploy_button = ttk.Button(
|
||||
action_bar, text=TEXT["deploy"], style="Primary.TButton", command=self.run_deploy_plan_async
|
||||
)
|
||||
self.deploy_button.pack(side=tk.LEFT, padx=4)
|
||||
self.deploy_buttons.append(self.deploy_button)
|
||||
|
||||
def _build_dashboard(self, parent: ttk.Frame) -> None:
|
||||
cards = ttk.Frame(parent)
|
||||
cards.pack(fill=tk.X, pady=(0, 16))
|
||||
self.card_labels: dict[str, ttk.Label] = {}
|
||||
for key, title in (("backend", "后端"), ("frontend", "前端"), ("sql", "SQL 文件"), ("env", "环境")):
|
||||
card = ttk.Frame(cards, style="Panel.TFrame", padding=16)
|
||||
card.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 12))
|
||||
ttk.Label(card, text=title, style="CardTitle.TLabel").pack(anchor=tk.W)
|
||||
value = ttk.Label(card, text="未扫描", style="CardValue.TLabel")
|
||||
value.pack(anchor=tk.W, pady=(9, 3))
|
||||
ttk.Label(card, text="等待操作", style="Muted.TLabel").pack(anchor=tk.W)
|
||||
self.card_labels[key] = value
|
||||
|
||||
def _build_middle(self, parent: ttk.Frame) -> None:
|
||||
middle = ttk.Frame(parent)
|
||||
middle.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
left = ttk.Frame(middle, style="Panel.TFrame", padding=16)
|
||||
left.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 14))
|
||||
ttk.Label(left, text=TEXT["results"], style="CardTitle.TLabel").pack(anchor=tk.W)
|
||||
self.result_text = tk.Text(left, height=16, wrap=tk.WORD, relief=tk.FLAT, bg="#ffffff", fg="#243043", font=("Consolas", 10))
|
||||
self.result_text.pack(fill=tk.BOTH, expand=True, pady=(10, 0))
|
||||
|
||||
right = ttk.Frame(middle, style="Panel.TFrame", padding=16, width=420)
|
||||
right.pack(side=tk.LEFT, fill=tk.Y)
|
||||
right.pack_propagate(False)
|
||||
|
||||
ttk.Label(right, text=TEXT["config"], style="CardTitle.TLabel").pack(anchor=tk.W)
|
||||
self.mysql_user_widget = self._add_field(right, "MySQL 用户", self.mysql_user)
|
||||
self.mysql_password_widget = self._add_field(right, "MySQL 密码", self.mysql_password, show="*")
|
||||
self.database_widget = self._add_field(right, "数据库名", self.mysql_database)
|
||||
ttk.Label(
|
||||
right,
|
||||
text="服务端口使用默认值:8080 / 8082 / 8081",
|
||||
style="Muted.TLabel",
|
||||
).pack(anchor=tk.W, pady=(12, 0))
|
||||
|
||||
def _build_jdk_install_section(self, parent: ttk.Frame) -> None:
|
||||
ttk.Label(parent, text="JDK 推荐与安装", style="CardTitle.TLabel").pack(anchor=tk.W)
|
||||
row = ttk.Frame(parent, style="Panel.TFrame")
|
||||
row.pack(fill=tk.X, pady=(8, 4))
|
||||
ttk.Label(row, text="版本", style="Muted.TLabel").pack(side=tk.LEFT, padx=(0, 8))
|
||||
ttk.Combobox(row, textvariable=self.jdk_version, values=("8", "11", "17", "21"), state="readonly", width=8).pack(side=tk.LEFT)
|
||||
ttk.Button(row, text="安装 JDK", command=self.install_jdk_async).pack(side=tk.LEFT, padx=(10, 0))
|
||||
ttk.Button(row, text="查看命令", command=self.preview_jdk_install_command).pack(side=tk.LEFT, padx=(6, 0))
|
||||
ttk.Button(row, text="加入 PATH", command=lambda: self.add_tool_to_path("JDK")).pack(side=tk.LEFT, padx=(6, 0))
|
||||
ttk.Label(parent, textvariable=self.jdk_reason, style="Muted.TLabel", wraplength=360).pack(anchor=tk.W, pady=(2, 0))
|
||||
|
||||
def _build_logs(self, parent: ttk.Frame) -> None:
|
||||
log_panel = ttk.Frame(parent, style="Panel.TFrame", padding=14)
|
||||
log_panel.pack(fill=tk.BOTH, pady=(16, 0))
|
||||
ttk.Label(log_panel, text=TEXT["logs"], style="CardTitle.TLabel").pack(anchor=tk.W)
|
||||
self.log_text = tk.Text(log_panel, height=9, wrap=tk.WORD, relief=tk.FLAT, bg="#111827", fg="#d1fadf", insertbackground="#d1fadf", font=("Consolas", 10))
|
||||
self.log_text.pack(fill=tk.BOTH, expand=True, pady=(10, 0))
|
||||
|
||||
def _add_field(self, parent: ttk.Frame, label: str, variable: tk.StringVar, show: str | None = None) -> ttk.Entry:
|
||||
ttk.Label(parent, text=label, style="Muted.TLabel").pack(anchor=tk.W, pady=(10, 4))
|
||||
entry = ttk.Entry(parent, textvariable=variable, show=show)
|
||||
entry.pack(fill=tk.X)
|
||||
return entry
|
||||
|
||||
def _add_tool_path_row(self, parent: ttk.Frame, name: str) -> None:
|
||||
ttk.Label(parent, text=name, style="Muted.TLabel").pack(anchor=tk.W, pady=(8, 3))
|
||||
row = ttk.Frame(parent, style="Panel.TFrame")
|
||||
row.pack(fill=tk.X)
|
||||
entry = ttk.Entry(row, textvariable=self.tool_path_vars[name])
|
||||
entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 6))
|
||||
self.tool_path_entries[name] = entry
|
||||
ttk.Button(row, text="选择", command=lambda tool=name: self.choose_tool_path(tool)).pack(side=tk.LEFT)
|
||||
if name not in {"JDK", "npm"}:
|
||||
ttk.Button(row, text="安装", command=lambda tool=name: self.install_tool_async(tool)).pack(side=tk.LEFT, padx=(6, 0))
|
||||
ttk.Button(row, text="PATH", command=lambda tool=name: self.add_tool_to_path(tool)).pack(side=tk.LEFT, padx=(6, 0))
|
||||
|
||||
def show_environment_installer(self) -> None:
|
||||
dialog = tk.Toplevel(self)
|
||||
dialog.title("\u5b89\u88c5\u73af\u5883")
|
||||
dialog.geometry("680x420")
|
||||
dialog.minsize(620, 360)
|
||||
dialog.configure(bg="#f5f7fb")
|
||||
dialog.transient(self)
|
||||
|
||||
panel = ttk.Frame(dialog, style="Panel.TFrame", padding=18)
|
||||
panel.pack(fill=tk.BOTH, expand=True, padx=16, pady=16)
|
||||
ttk.Label(panel, text="\u5b89\u88c5\u73af\u5883", style="CardTitle.TLabel").pack(anchor=tk.W)
|
||||
ttk.Label(
|
||||
panel,
|
||||
text="\u5b89\u88c5\u524d\u4f1a\u5f39\u7a97\u786e\u8ba4 winget \u547d\u4ee4\uff0c\u5b89\u88c5\u5b8c\u6210\u540e\u4f1a\u81ea\u52a8\u91cd\u65b0\u68c0\u6d4b\u73af\u5883\u3002",
|
||||
style="Muted.TLabel",
|
||||
).pack(anchor=tk.W, pady=(4, 12))
|
||||
|
||||
jdk_row = ttk.Frame(panel, style="Panel.TFrame")
|
||||
jdk_row.pack(fill=tk.X, pady=5)
|
||||
ttk.Label(jdk_row, text="JDK", width=12, style="CardTitle.TLabel").pack(side=tk.LEFT)
|
||||
ttk.Combobox(jdk_row, textvariable=self.jdk_version, values=("8", "11", "17", "21"), state="readonly", width=8).pack(side=tk.LEFT)
|
||||
ttk.Button(jdk_row, text="\u5b89\u88c5", command=self.install_jdk_async).pack(side=tk.LEFT, padx=(8, 0))
|
||||
ttk.Button(jdk_row, text="\u52a0\u5165 PATH", command=lambda: self.add_tool_to_path("JDK")).pack(side=tk.LEFT, padx=(8, 0))
|
||||
ttk.Label(jdk_row, textvariable=self.jdk_reason, style="Muted.TLabel").pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(10, 0))
|
||||
|
||||
for name in ("Maven", "Gradle", "Node.js", "Git", "MySQL"):
|
||||
row = ttk.Frame(panel, style="Panel.TFrame")
|
||||
row.pack(fill=tk.X, pady=5)
|
||||
ttk.Label(row, text=name, width=12, style="CardTitle.TLabel").pack(side=tk.LEFT)
|
||||
ttk.Button(row, text="\u5b89\u88c5", command=lambda tool=name: self.install_tool_async(tool)).pack(side=tk.LEFT)
|
||||
ttk.Button(row, text="\u9009\u62e9\u8def\u5f84", command=lambda tool=name: self.choose_tool_path(tool)).pack(side=tk.LEFT, padx=(8, 0))
|
||||
ttk.Button(row, text="\u52a0\u5165 PATH", command=lambda tool=name: self.add_tool_to_path(tool)).pack(side=tk.LEFT, padx=(8, 0))
|
||||
|
||||
ttk.Separator(panel).pack(fill=tk.X, pady=14)
|
||||
ttk.Button(panel, text="\u68c0\u6d4b\u73af\u5883", command=self.check_environment_async).pack(side=tk.LEFT)
|
||||
ttk.Button(panel, text="\u5173\u95ed", command=dialog.destroy).pack(side=tk.RIGHT)
|
||||
|
||||
def activate_sidebar_item(self, item: str) -> None:
|
||||
for name, button in self.__dict__.get("sidebar_buttons", {}).items():
|
||||
button.configure(bg="#22314d" if name == item else "#172033", fg="#ffffff" if name == item else "#dce6f7")
|
||||
|
||||
if item == "项目识别":
|
||||
self._focus_widget(self.__dict__.get("project_dir_entry"))
|
||||
elif item == "环境检测":
|
||||
self.check_environment_async()
|
||||
elif item == "工具路径":
|
||||
entries = self.__dict__.get("tool_path_entries", {})
|
||||
first_entry = next(iter(entries.values()), None)
|
||||
self._focus_widget(first_entry)
|
||||
elif item == "数据库":
|
||||
self._focus_widget(self.__dict__.get("database_widget"))
|
||||
elif item == "部署任务":
|
||||
self._focus_widget(self.__dict__.get("deploy_button"))
|
||||
elif item == "运行日志":
|
||||
log_text = self.__dict__.get("log_text")
|
||||
self._focus_widget(log_text)
|
||||
if log_text is not None:
|
||||
log_text.see(tk.END)
|
||||
|
||||
def _focus_widget(self, widget: object | None) -> None:
|
||||
if widget is not None:
|
||||
widget.focus_set()
|
||||
|
||||
def choose_folder(self) -> None:
|
||||
folder = filedialog.askdirectory(title="选择前后端项目根目录")
|
||||
if folder:
|
||||
self.project_dir.set(folder)
|
||||
if not self.output_dir.get():
|
||||
self.output_dir.set(str(Path(folder) / "deploy"))
|
||||
|
||||
def choose_output_dir(self) -> None:
|
||||
folder = filedialog.askdirectory(title="选择部署输出目录")
|
||||
if folder:
|
||||
self.output_dir.set(folder)
|
||||
|
||||
def choose_tool_path(self, name: str) -> None:
|
||||
file_path = filedialog.askopenfilename(title=f"选择 {name} 可执行文件", filetypes=(("可执行文件", "*.exe *.cmd *.bat"), ("所有文件", "*.*")))
|
||||
if file_path:
|
||||
self.tool_path_vars[name].set(file_path)
|
||||
self.save_tool_path_settings(silent=True)
|
||||
|
||||
def save_tool_path_settings(self, silent: bool = False) -> None:
|
||||
paths = {name: value.get().strip() for name, value in self.tool_path_vars.items() if value.get().strip()}
|
||||
save_tool_paths(paths)
|
||||
self.tool_paths = load_tool_paths()
|
||||
if not silent:
|
||||
self._append_log("工具路径已保存。")
|
||||
|
||||
def scan_folder(self) -> None:
|
||||
if not self.project_dir.get().strip():
|
||||
messagebox.showwarning("需要目录", "请先选择或输入项目文件夹。")
|
||||
return
|
||||
try:
|
||||
self.scan_result = scan_project(self.project_dir.get().strip())
|
||||
except Exception as exc:
|
||||
messagebox.showerror("扫描失败", str(exc))
|
||||
return
|
||||
database_name = database_name_from_sql_files(self.scan_result.sql_files)
|
||||
if database_name:
|
||||
self.mysql_database.set(database_name)
|
||||
self._append_log(f"已根据 SQL 文件自动填写数据库名: {database_name}")
|
||||
self._apply_jdk_recommendation()
|
||||
self._render_scan_result()
|
||||
self._append_log("项目扫描完成。")
|
||||
|
||||
def _apply_jdk_recommendation(self) -> None:
|
||||
recommendation = recommend_jdk_version(self.scan_result)
|
||||
self.jdk_version.set(recommendation.version)
|
||||
self.jdk_reason.set(recommendation.reason)
|
||||
self._append_log(f"JDK 推荐: {recommendation.reason}")
|
||||
|
||||
def check_environment_async(self) -> None:
|
||||
self.save_tool_path_settings(silent=True)
|
||||
manual_paths = load_tool_paths()
|
||||
self._append_log("开始检测本机环境,会同时检查 PATH、手动路径和常见安装目录...")
|
||||
threading.Thread(target=lambda: self.ui_queue.put(("env", check_environment(manual_paths=manual_paths))), daemon=True).start()
|
||||
|
||||
def preview_jdk_install_command(self) -> None:
|
||||
command = build_install_command("JDK", version=self.jdk_version.get())
|
||||
self._append_log(f"JDK {self.jdk_version.get()} 安装命令: {' '.join(command)}")
|
||||
|
||||
def install_jdk_async(self) -> None:
|
||||
command = build_install_command("JDK", version=self.jdk_version.get())
|
||||
if not messagebox.askyesno(
|
||||
"确认安装 JDK",
|
||||
"即将通过 winget 安装 JDK。\n\n"
|
||||
f"版本: JDK {self.jdk_version.get()}\n"
|
||||
f"命令: {' '.join(command)}\n\n"
|
||||
"安装可能需要管理员权限,并可能修改 PATH。是否继续?",
|
||||
):
|
||||
return
|
||||
self._append_log(f"开始安装 JDK {self.jdk_version.get()}...")
|
||||
threading.Thread(target=lambda: self.ui_queue.put(("install_done", ("JDK", run_install_command(command, self._thread_log)))), daemon=True).start()
|
||||
|
||||
def install_tool_async(self, tool_name: str) -> None:
|
||||
try:
|
||||
command = build_install_command(tool_name)
|
||||
except ValueError as exc:
|
||||
messagebox.showwarning("暂不支持自动安装", str(exc))
|
||||
return
|
||||
if not messagebox.askyesno(
|
||||
f"确认安装 {tool_name}",
|
||||
f"即将通过 winget 安装 {tool_name}。\n\n"
|
||||
f"命令: {' '.join(command)}\n\n"
|
||||
"安装可能需要管理员权限,并可能修改 PATH。是否继续?",
|
||||
):
|
||||
return
|
||||
self._append_log(f"开始安装 {tool_name}...")
|
||||
threading.Thread(
|
||||
target=lambda: self.ui_queue.put(("install_done", (tool_name, run_install_command(command, self._thread_log)))),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def add_tool_to_path(self, tool_name: str) -> None:
|
||||
result = self._find_environment_result(tool_name)
|
||||
executable = Path(result.command[0]) if result and Path(result.command[0]).exists() else None
|
||||
configured = self.tool_path_vars.get(tool_name)
|
||||
if executable is None and configured and configured.get().strip() and Path(configured.get().strip()).exists():
|
||||
executable = Path(configured.get().strip())
|
||||
if executable is None:
|
||||
messagebox.showwarning("未找到可执行文件", f"请先检测环境,或手动选择 {tool_name} 的可执行文件。")
|
||||
return
|
||||
|
||||
update = build_environment_update(tool_name, executable)
|
||||
env_text = "\n".join(f"{name}={value}" for name, value in update.env_vars.items()) or "无额外环境变量"
|
||||
if not messagebox.askyesno(
|
||||
"确认加入 PATH",
|
||||
f"将写入当前用户环境变量,不修改系统级 PATH。\n\n"
|
||||
f"工具: {tool_name}\n"
|
||||
f"PATH 目录: {update.path_entries[0]}\n"
|
||||
f"{env_text}\n\n"
|
||||
"修改后新打开的终端会生效。是否继续?",
|
||||
):
|
||||
return
|
||||
try:
|
||||
apply_user_environment(update)
|
||||
except Exception as exc:
|
||||
messagebox.showerror("写入失败", str(exc))
|
||||
return
|
||||
self._append_log(f"{tool_name} 已加入当前用户 PATH。")
|
||||
self.check_environment_async()
|
||||
|
||||
def add_all_available_tools_to_path(self) -> None:
|
||||
available = [
|
||||
item.name
|
||||
for item in self.environment_results
|
||||
if item.installed and Path(item.command[0]).exists() and item.source in {"手动指定", "自动发现"}
|
||||
]
|
||||
if not available:
|
||||
messagebox.showinfo("没有可加入项", "请先检测环境,或手动指定工具路径。")
|
||||
return
|
||||
if not messagebox.askyesno("确认加入 PATH", f"将这些工具加入当前用户 PATH:\n{', '.join(available)}\n\n是否继续?"):
|
||||
return
|
||||
for name in available:
|
||||
result = self._find_environment_result(name)
|
||||
if result:
|
||||
apply_user_environment(build_environment_update(name, Path(result.command[0])))
|
||||
self._append_log(f"{name} 已加入当前用户 PATH。")
|
||||
self.check_environment_async()
|
||||
|
||||
def _find_environment_result(self, tool_name: str) -> EnvironmentCheck | None:
|
||||
for item in self.environment_results:
|
||||
if item.name == tool_name:
|
||||
return item
|
||||
return None
|
||||
|
||||
def _set_deployment_running(self, running: bool) -> None:
|
||||
self.deployment_running = running
|
||||
state = tk.DISABLED if running else tk.NORMAL
|
||||
for button in self.deploy_buttons:
|
||||
button.configure(state=state)
|
||||
|
||||
def _run_deployment_worker(self, plan) -> None:
|
||||
exit_code = 1
|
||||
try:
|
||||
exit_code = run_steps(plan, self._thread_log)
|
||||
except Exception as exc:
|
||||
self.ui_queue.put(("log", f"部署线程异常: {exc}"))
|
||||
finally:
|
||||
self.ui_queue.put(("deploy_done", exit_code))
|
||||
|
||||
def run_deploy_plan_async(self) -> None:
|
||||
if self.deployment_running:
|
||||
self._append_log("部署任务正在执行,请等待当前任务结束。")
|
||||
return
|
||||
plan = self._current_plan()
|
||||
if not plan:
|
||||
return
|
||||
if not messagebox.askyesno("确认执行", "将开始自动执行本地部署:构建前后端、复制前端产物、导入 SQL,并启动后端、用户端和管理端服务。请确认继续。"):
|
||||
return
|
||||
self._set_deployment_running(True)
|
||||
worker = threading.Thread(target=self._run_deployment_worker, args=(plan,), daemon=True)
|
||||
try:
|
||||
worker.start()
|
||||
except Exception as exc:
|
||||
self._set_deployment_running(False)
|
||||
self._append_log(f"无法启动部署线程: {exc}")
|
||||
|
||||
def _current_plan(self):
|
||||
if self.scan_result is None:
|
||||
messagebox.showwarning("需要扫描", "请先扫描项目。")
|
||||
return []
|
||||
config = DeploymentConfig(
|
||||
mysql_user=self.mysql_user.get().strip() or "root",
|
||||
mysql_password=self.mysql_password.get(),
|
||||
mysql_database=self.mysql_database.get().strip(),
|
||||
backend_port="8080",
|
||||
user_frontend_port="8082",
|
||||
admin_frontend_port="8081",
|
||||
output_dir=None,
|
||||
tool_paths=load_tool_paths(),
|
||||
)
|
||||
return build_deployment_plan(self.scan_result, config)
|
||||
|
||||
def _render_scan_result(self) -> None:
|
||||
assert self.scan_result is not None
|
||||
scan = self.scan_result
|
||||
self.card_labels["backend"].configure(text="已识别" if scan.backend else "未找到")
|
||||
self.card_labels["frontend"].configure(text=f"{len(scan.detected_frontends)} 个" if scan.detected_frontends else "未找到")
|
||||
self.card_labels["sql"].configure(text=f"{len(scan.sql_files)} 个")
|
||||
|
||||
lines = [f"根目录: {scan.root}", ""]
|
||||
lines.append(f"后端: {scan.backend.path if scan.backend else '未识别'}")
|
||||
if scan.backend:
|
||||
lines.append(f" 依据: {scan.backend.detail}")
|
||||
if scan.backend.metadata:
|
||||
lines.append(" 版本线索:")
|
||||
for key, value in scan.backend.metadata.items():
|
||||
lines.append(f" {key}: {value}")
|
||||
lines.append(f"前端: {len(scan.detected_frontends)} 个")
|
||||
for frontend in scan.detected_frontends:
|
||||
display_name = frontend.metadata.get("display_name", frontend.path.name)
|
||||
port = frontend.metadata.get("port", "未配置")
|
||||
lines.append(f" {display_name}: {frontend.path}")
|
||||
lines.append(f" 端口: {port},依据: {frontend.detail}")
|
||||
lines.append(f"JDK 推荐: JDK {self.jdk_version.get()} - {self.jdk_reason.get()}")
|
||||
lines.append("SQL:")
|
||||
lines.extend(f" - {path}" for path in scan.sql_files[:20])
|
||||
if len(scan.sql_files) > 20:
|
||||
lines.append(f" ... 还有 {len(scan.sql_files) - 20} 个")
|
||||
self._set_result_text("\n".join(lines))
|
||||
|
||||
def _render_environment(self, results: list[EnvironmentCheck]) -> None:
|
||||
self.environment_results = results
|
||||
installed = sum(1 for item in results if item.installed)
|
||||
self.card_labels["env"].configure(text=f"{installed}/{len(results)} 可用")
|
||||
lines = ["环境检测结果:", ""]
|
||||
for item in results:
|
||||
status = "OK" if item.installed else "MISS"
|
||||
lines.append(f"[{status}] {item.name} ({item.source}): {item.message}")
|
||||
lines.append(f" 命令: {' '.join(item.command)}")
|
||||
if item.version:
|
||||
lines.append(f" 版本: {item.version}")
|
||||
self._set_result_text("\n".join(lines))
|
||||
self._append_log("环境检测完成。")
|
||||
|
||||
def _set_result_text(self, value: str) -> None:
|
||||
self.result_text.configure(state=tk.NORMAL)
|
||||
self.result_text.delete("1.0", tk.END)
|
||||
self.result_text.insert(tk.END, value)
|
||||
|
||||
def _append_log(self, value: str) -> None:
|
||||
self.log_text.configure(state=tk.NORMAL)
|
||||
self.log_text.insert(tk.END, value + "\n")
|
||||
self.log_text.see(tk.END)
|
||||
|
||||
def _thread_log(self, value: str) -> None:
|
||||
self.ui_queue.put(("log", value))
|
||||
|
||||
def _drain_queue(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
event, payload = self.ui_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
if event == "env":
|
||||
self._render_environment(payload) # type: ignore[arg-type]
|
||||
elif event == "log":
|
||||
self._append_log(str(payload))
|
||||
elif event == "deploy_done":
|
||||
self._set_deployment_running(False)
|
||||
self._append_log(f"部署任务结束,退出码: {payload}")
|
||||
elif event == "install_done":
|
||||
tool_name, exit_code = payload # type: ignore[misc]
|
||||
self._append_log(f"{tool_name} 安装命令结束,退出码: {exit_code}")
|
||||
self.check_environment_async()
|
||||
self.after(1800, lambda name=tool_name: self._prompt_add_tool_after_install(name))
|
||||
self.after(120, self._drain_queue)
|
||||
|
||||
def _prompt_add_tool_after_install(self, tool_name: str) -> None:
|
||||
result = self._find_environment_result(tool_name)
|
||||
if result and result.installed and Path(result.command[0]).exists():
|
||||
extra = " 并设置 JAVA_HOME" if tool_name == "JDK" else ""
|
||||
if messagebox.askyesno(f"{tool_name} 已安装", f"检测到 {tool_name} 可执行文件,是否加入当前用户 PATH{extra}?"):
|
||||
self.add_tool_to_path(tool_name)
|
||||
|
||||
|
||||
def run_app() -> None:
|
||||
app = DeployWorkbench()
|
||||
app.mainloop()
|
||||
177
deploy_tool/installer.py
Normal file
177
deploy_tool/installer.py
Normal file
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .scanner import ScanResult
|
||||
|
||||
|
||||
JDK_PACKAGES = {
|
||||
"8": "EclipseAdoptium.Temurin.8.JDK",
|
||||
"11": "EclipseAdoptium.Temurin.11.JDK",
|
||||
"17": "EclipseAdoptium.Temurin.17.JDK",
|
||||
"21": "EclipseAdoptium.Temurin.21.JDK",
|
||||
}
|
||||
|
||||
WINGET_PACKAGES = {
|
||||
"Maven": "Apache.Maven",
|
||||
"Gradle": "Gradle.Gradle",
|
||||
"Node.js": "OpenJS.NodeJS.LTS",
|
||||
"Git": "Git.Git",
|
||||
"MySQL": "Oracle.MySQL",
|
||||
}
|
||||
|
||||
ANSI_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||
PERCENT_PATTERN = re.compile(r"(\d{1,3})\s*%")
|
||||
PROGRESS_CHARS = "█▓▒░▏▎▍▌▋▊▉■□▪▫▬─━═|/-\\"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JdkRecommendation:
|
||||
version: str
|
||||
reason: str
|
||||
|
||||
|
||||
def recommend_jdk_version(scan: ScanResult | None) -> JdkRecommendation:
|
||||
if not scan or not scan.backend:
|
||||
return JdkRecommendation("8", "\u672a\u626b\u63cf\u5230\u540e\u7aef\u7248\u672c\u4fe1\u606f\uff0c\u9ed8\u8ba4\u63a8\u8350 JDK 8\u3002")
|
||||
|
||||
metadata = scan.backend.metadata
|
||||
java_version = metadata.get("java_version")
|
||||
if java_version:
|
||||
mapped = _map_java_version(java_version)
|
||||
source = metadata.get("java_version_source", "\u9879\u76ee\u914d\u7f6e")
|
||||
return JdkRecommendation(mapped, f"\u6839\u636e {source}={java_version} \u63a8\u8350 JDK {mapped}\u3002")
|
||||
|
||||
boot_version = metadata.get("spring_boot_version", "")
|
||||
if boot_version.startswith("3."):
|
||||
return JdkRecommendation("17", f"Spring Boot 3.x \u9700\u8981 Java 17+\uff0c\u68c0\u6d4b\u5230 Spring Boot {boot_version}\uff0c\u63a8\u8350 JDK 17\u3002")
|
||||
if boot_version.startswith("2."):
|
||||
return JdkRecommendation("8", f"\u68c0\u6d4b\u5230 Spring Boot {boot_version}\uff0c\u9ed8\u8ba4\u63a8\u8350\u517c\u5bb9\u6027\u66f4\u597d\u7684 JDK 8\u3002")
|
||||
|
||||
return JdkRecommendation("8", "\u672a\u53d1\u73b0\u660e\u786e Java \u7248\u672c\uff0c\u9ed8\u8ba4\u63a8\u8350 JDK 8\u3002")
|
||||
|
||||
|
||||
def build_install_command(tool_name: str, version: str | None = None) -> list[str]:
|
||||
if tool_name == "JDK":
|
||||
package_id = JDK_PACKAGES.get(version or "8")
|
||||
if not package_id:
|
||||
raise ValueError(f"\u4e0d\u652f\u6301\u7684 JDK \u7248\u672c: {version}")
|
||||
else:
|
||||
package_id = WINGET_PACKAGES.get(tool_name)
|
||||
if not package_id:
|
||||
raise ValueError(f"\u4e0d\u652f\u6301\u81ea\u52a8\u5b89\u88c5: {tool_name}")
|
||||
return ["winget", "install", "-e", "--id", package_id]
|
||||
|
||||
|
||||
def run_install_command(command: list[str], on_output) -> int:
|
||||
output_lines: list[str] = []
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
shell=False,
|
||||
bufsize=0,
|
||||
)
|
||||
assert process.stdout is not None
|
||||
last_line = ""
|
||||
for raw_line in process.stdout:
|
||||
for line in format_install_output(raw_line):
|
||||
if line and line != last_line:
|
||||
on_output(line)
|
||||
output_lines.append(line)
|
||||
last_line = line
|
||||
exit_code = process.wait()
|
||||
failure_hint = explain_install_failure(_tool_name_from_command(command), exit_code, output_lines)
|
||||
if failure_hint:
|
||||
on_output(failure_hint)
|
||||
return exit_code
|
||||
|
||||
|
||||
def explain_install_failure(tool_name: str | None, exit_code: int, output_lines: list[str]) -> str:
|
||||
if exit_code == 0:
|
||||
return ""
|
||||
|
||||
output_text = "\n".join(output_lines)
|
||||
if tool_name == "Maven" and ("找不到与输入条件匹配的程序包" in output_text or "No package found" in output_text):
|
||||
return (
|
||||
"winget 当前源找不到 Maven 包。请手动下载 Apache Maven,解压后在工具里选择 "
|
||||
"apache-maven 的 bin\\mvn.cmd,或把该 bin 目录加入 PATH。"
|
||||
)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def format_install_output(raw: bytes) -> list[str]:
|
||||
text = _decode_process_bytes(raw)
|
||||
text = ANSI_PATTERN.sub("", text).replace("\b", "")
|
||||
lines: list[str] = []
|
||||
for part in re.split(r"[\r\n]+", text):
|
||||
cleaned = _clean_install_line(part)
|
||||
if cleaned:
|
||||
lines.append(cleaned)
|
||||
return lines
|
||||
|
||||
|
||||
def _decode_process_bytes(raw: bytes) -> str:
|
||||
for encoding in ("utf-8", "utf-16", "gb18030", "mbcs"):
|
||||
try:
|
||||
return raw.decode(encoding)
|
||||
except (LookupError, UnicodeDecodeError):
|
||||
continue
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _clean_install_line(line: str) -> str:
|
||||
text = line.strip()
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
percent = PERCENT_PATTERN.search(text)
|
||||
progress_only = _looks_like_progress_line(text)
|
||||
if percent and progress_only:
|
||||
value = min(int(percent.group(1)), 100)
|
||||
return f"\u5b89\u88c5\u8fdb\u5ea6: {value}%"
|
||||
|
||||
if progress_only:
|
||||
return ""
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def _looks_like_progress_line(text: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
progress_count = sum(1 for char in text if char in PROGRESS_CHARS)
|
||||
percent = bool(PERCENT_PATTERN.search(text))
|
||||
if percent and progress_count:
|
||||
return True
|
||||
visible = [char for char in text if not char.isspace()]
|
||||
if not visible:
|
||||
return False
|
||||
return progress_count / len(visible) > 0.45
|
||||
|
||||
|
||||
def _tool_name_from_command(command: list[str]) -> str | None:
|
||||
package_ids = {package_id: name for name, package_id in WINGET_PACKAGES.items()}
|
||||
package_ids.update({package_id: "JDK" for package_id in JDK_PACKAGES.values()})
|
||||
for part in command:
|
||||
name = package_ids.get(part)
|
||||
if name:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _map_java_version(version: str) -> str:
|
||||
try:
|
||||
major = int(version.split(".", 1)[0])
|
||||
except ValueError:
|
||||
return "8"
|
||||
if major <= 8:
|
||||
return "8"
|
||||
if major <= 11:
|
||||
return "11"
|
||||
if major <= 17:
|
||||
return "17"
|
||||
return "21"
|
||||
98
deploy_tool/path_manager.py
Normal file
98
deploy_tool/path_manager.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
import sys
|
||||
import winreg
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnvironmentUpdate:
|
||||
path_entries: list[Path] = field(default_factory=list)
|
||||
env_vars: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
class RegistryLike(Protocol):
|
||||
def get_value(self, name: str) -> str: ...
|
||||
|
||||
def set_value(self, name: str, value: str) -> None: ...
|
||||
|
||||
def broadcast_change(self) -> None: ...
|
||||
|
||||
|
||||
class UserEnvironmentRegistry:
|
||||
key_path = r"Environment"
|
||||
|
||||
def get_value(self, name: str) -> str:
|
||||
try:
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, self.key_path, 0, winreg.KEY_READ) as key:
|
||||
value, _ = winreg.QueryValueEx(key, name)
|
||||
return str(value)
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
|
||||
def set_value(self, name: str, value: str) -> None:
|
||||
with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, self.key_path, 0, winreg.KEY_SET_VALUE) as key:
|
||||
winreg.SetValueEx(key, name, 0, winreg.REG_EXPAND_SZ, value)
|
||||
|
||||
def broadcast_change(self) -> None:
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
hwnd_broadcast = 0xFFFF
|
||||
wm_settingchange = 0x001A
|
||||
smto_abortifhung = 0x0002
|
||||
result = ctypes.c_ulong()
|
||||
ctypes.windll.user32.SendMessageTimeoutW(
|
||||
hwnd_broadcast,
|
||||
wm_settingchange,
|
||||
0,
|
||||
"Environment",
|
||||
smto_abortifhung,
|
||||
5000,
|
||||
ctypes.byref(result),
|
||||
)
|
||||
|
||||
|
||||
def merge_path_entries(current_path: str, entries: list[Path]) -> str:
|
||||
parts = [part for part in current_path.split(os.pathsep) if part.strip()]
|
||||
seen = {_normalize_path(part) for part in parts}
|
||||
|
||||
for entry in entries:
|
||||
value = str(entry)
|
||||
normalized = _normalize_path(value)
|
||||
if normalized not in seen:
|
||||
parts.append(value)
|
||||
seen.add(normalized)
|
||||
|
||||
return os.pathsep.join(parts)
|
||||
|
||||
|
||||
def build_environment_update(tool_name: str, executable: Path) -> EnvironmentUpdate:
|
||||
exe = Path(executable)
|
||||
bin_dir = exe.parent
|
||||
env_vars: dict[str, str] = {}
|
||||
|
||||
if tool_name == "JDK":
|
||||
env_vars["JAVA_HOME"] = str(bin_dir.parent)
|
||||
elif tool_name == "Maven":
|
||||
env_vars["MAVEN_HOME"] = str(bin_dir.parent)
|
||||
elif tool_name == "Gradle":
|
||||
env_vars["GRADLE_HOME"] = str(bin_dir.parent)
|
||||
|
||||
return EnvironmentUpdate(path_entries=[bin_dir], env_vars=env_vars)
|
||||
|
||||
|
||||
def apply_user_environment(update: EnvironmentUpdate, registry: RegistryLike | None = None) -> None:
|
||||
env_registry = registry or UserEnvironmentRegistry()
|
||||
current_path = env_registry.get_value("Path")
|
||||
env_registry.set_value("Path", merge_path_entries(current_path, update.path_entries))
|
||||
for name, value in update.env_vars.items():
|
||||
env_registry.set_value(name, value)
|
||||
env_registry.broadcast_change()
|
||||
|
||||
|
||||
def _normalize_path(value: str) -> str:
|
||||
return os.path.normcase(os.path.normpath(os.path.expandvars(value.strip().strip('"'))))
|
||||
260
deploy_tool/scanner.py
Normal file
260
deploy_tool/scanner.py
Normal file
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
IGNORED_DIRECTORIES = {"node_modules", "target", "build", "dist", ".git", ".idea", ".vscode"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectComponent:
|
||||
kind: str
|
||||
path: Path
|
||||
detail: str
|
||||
metadata: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScanResult:
|
||||
root: Path
|
||||
backend: ProjectComponent | None
|
||||
frontend: ProjectComponent | None
|
||||
sql_files: list[Path]
|
||||
frontends: list[ProjectComponent] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def detected_frontends(self) -> list[ProjectComponent]:
|
||||
"""Return every detected frontend while preserving the old single-frontend API."""
|
||||
if self.frontends:
|
||||
return self.frontends
|
||||
return [self.frontend] if self.frontend else []
|
||||
|
||||
|
||||
def scan_project(root: str | Path) -> ScanResult:
|
||||
project_root = Path(root).resolve()
|
||||
if not project_root.exists():
|
||||
raise FileNotFoundError(f"目录不存在: {project_root}")
|
||||
if not project_root.is_dir():
|
||||
raise NotADirectoryError(f"不是文件夹: {project_root}")
|
||||
|
||||
backend = _find_backend(project_root)
|
||||
frontends = _find_frontends(project_root)
|
||||
frontend = frontends[0] if frontends else None
|
||||
sql_files = list(_walk_project_files(project_root, suffix=".sql"))
|
||||
return ScanResult(project_root, backend, frontend, sql_files, frontends)
|
||||
|
||||
|
||||
def _find_backend(root: Path) -> ProjectComponent | None:
|
||||
candidates: list[tuple[int, Path, str]] = []
|
||||
for build_file in _walk_project_files(root, names={"pom.xml", "build.gradle", "build.gradle.kts"}):
|
||||
project_dir = build_file.parent
|
||||
score = 1
|
||||
detail_parts = [build_file.name]
|
||||
text = _read_text(build_file)
|
||||
metadata = _extract_backend_metadata(text)
|
||||
metadata.update(_extract_backend_runtime_metadata(project_dir))
|
||||
if "spring-boot" in text or "org.springframework.boot" in text:
|
||||
score += 3
|
||||
detail_parts.append("Spring Boot dependency")
|
||||
if java_version := metadata.get("java_version"):
|
||||
detail_parts.append(f"Java {java_version}")
|
||||
if boot_version := metadata.get("spring_boot_version"):
|
||||
detail_parts.append(f"Spring Boot {boot_version}")
|
||||
if (project_dir / "src" / "main" / "java").exists():
|
||||
score += 2
|
||||
detail_parts.append("src/main/java")
|
||||
candidates.append((score, project_dir, ", ".join(detail_parts), metadata))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
score, path, detail, metadata = sorted(candidates, key=lambda item: (-item[0], len(item[1].parts)))[0]
|
||||
if score < 2:
|
||||
return None
|
||||
return ProjectComponent("Spring Boot", path, detail, metadata)
|
||||
|
||||
|
||||
def _find_frontends(root: Path) -> list[ProjectComponent]:
|
||||
candidates: list[tuple[int, Path, str, dict[str, str]]] = []
|
||||
for package_file in _walk_project_files(root, names={"package.json"}):
|
||||
project_dir = package_file.parent
|
||||
score = 1
|
||||
detail_parts = ["package.json"]
|
||||
package = _load_json(package_file)
|
||||
deps = {}
|
||||
for key in ("dependencies", "devDependencies"):
|
||||
value = package.get(key)
|
||||
if isinstance(value, dict):
|
||||
deps.update(value)
|
||||
scripts = package.get("scripts") if isinstance(package.get("scripts"), dict) else {}
|
||||
if "vue" in deps:
|
||||
score += 3
|
||||
detail_parts.append("Vue dependency")
|
||||
if "build" in scripts:
|
||||
score += 1
|
||||
detail_parts.append("build script")
|
||||
if any((project_dir / name).exists() for name in ("vue.config.js", "vite.config.js", "vite.config.ts")):
|
||||
score += 1
|
||||
detail_parts.append("Vue/Vite config")
|
||||
metadata = _classify_frontend(project_dir)
|
||||
for script_name in ("serve", "dev", "start"):
|
||||
if isinstance(scripts.get(script_name), str):
|
||||
metadata["start_script"] = script_name
|
||||
break
|
||||
configured_port = _extract_frontend_port(project_dir)
|
||||
if configured_port:
|
||||
metadata["port"] = configured_port
|
||||
detail_parts.append(f"port {configured_port}")
|
||||
candidates.append((score, project_dir, ", ".join(detail_parts), metadata))
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
detected = [
|
||||
ProjectComponent("Vue", path, detail, metadata)
|
||||
for score, path, detail, metadata in candidates
|
||||
if score >= 3
|
||||
]
|
||||
role_order = {"user": 0, "admin": 1, "frontend": 2}
|
||||
return sorted(
|
||||
detected,
|
||||
key=lambda item: (role_order.get(item.metadata.get("role", "frontend"), 2), len(item.path.parts), str(item.path)),
|
||||
)
|
||||
|
||||
|
||||
def _find_frontend(root: Path) -> ProjectComponent | None:
|
||||
"""Compatibility helper for callers that still expect one frontend."""
|
||||
frontends = _find_frontends(root)
|
||||
return frontends[0] if frontends else None
|
||||
|
||||
|
||||
def _classify_frontend(project_dir: Path) -> dict[str, str]:
|
||||
name = project_dir.name.lower()
|
||||
if any(token in name for token in ("manage", "manager", "admin", "backend", "backstage", "后台", "管理")):
|
||||
return {"role": "admin", "display_name": "管理端"}
|
||||
if any(token in name for token in ("client", "user", "portal", "front", "web", "用户", "前台")):
|
||||
return {"role": "user", "display_name": "用户端"}
|
||||
return {"role": "frontend", "display_name": project_dir.name}
|
||||
|
||||
|
||||
def _extract_frontend_port(project_dir: Path) -> str | None:
|
||||
for name in ("vue.config.js", "vite.config.js", "vite.config.ts"):
|
||||
config_file = project_dir / name
|
||||
if not config_file.is_file():
|
||||
continue
|
||||
match = re.search(r"\bport\s*:\s*['\"]?(\d{1,5})", _read_text(config_file), re.IGNORECASE)
|
||||
if match and 1 <= int(match.group(1)) <= 65535:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _is_ignored(path: Path) -> bool:
|
||||
return any(part in IGNORED_DIRECTORIES for part in path.parts)
|
||||
|
||||
|
||||
def _walk_project_files(
|
||||
root: Path,
|
||||
*,
|
||||
names: set[str] | None = None,
|
||||
suffix: str | None = None,
|
||||
) -> Iterator[Path]:
|
||||
"""Yield matching files without descending into generated or dependency directories."""
|
||||
for current_dir, directories, filenames in os.walk(root, topdown=True, onerror=lambda _error: None):
|
||||
directories[:] = sorted(name for name in directories if name not in IGNORED_DIRECTORIES)
|
||||
for filename in sorted(filenames):
|
||||
if names is not None and filename not in names:
|
||||
continue
|
||||
if suffix is not None and not filename.lower().endswith(suffix.lower()):
|
||||
continue
|
||||
yield Path(current_dir) / filename
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8", errors="ignore").lower()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _extract_backend_metadata(text: str) -> dict[str, str]:
|
||||
metadata: dict[str, str] = {}
|
||||
java_patterns = [
|
||||
r"<maven\.compiler\.source>\s*([^<\s]+)\s*</maven\.compiler\.source>",
|
||||
r"<maven\.compiler\.target>\s*([^<\s]+)\s*</maven\.compiler\.target>",
|
||||
r"<java\.version>\s*([^<\s]+)\s*</java\.version>",
|
||||
r"sourceCompatibility\s*=\s*['\"]?([^'\"\s]+)",
|
||||
r"targetCompatibility\s*=\s*['\"]?([^'\"\s]+)",
|
||||
r"java\s*\{\s*sourceCompatibility\s*=\s*JavaVersion\.VERSION_([^'\"\s}]+)",
|
||||
]
|
||||
for pattern in java_patterns:
|
||||
match = re.search(pattern, text, re.IGNORECASE | re.MULTILINE)
|
||||
if match:
|
||||
metadata["java_version"] = _normalize_java_version(match.group(1))
|
||||
metadata["java_version_source"] = _java_source_label(pattern)
|
||||
break
|
||||
|
||||
boot_patterns = [
|
||||
r"<artifactId>\s*spring-boot-starter-parent\s*</artifactId>\s*<version>\s*([^<\s]+)\s*</version>",
|
||||
r"org\.springframework\.boot['\"]?\s*:\s*spring-boot[^:'\"]*['\"]?\s*:\s*([^'\"\s]+)",
|
||||
r"id\s+['\"]org\.springframework\.boot['\"]\s+version\s+['\"]([^'\"]+)['\"]",
|
||||
]
|
||||
for pattern in boot_patterns:
|
||||
match = re.search(pattern, text, re.IGNORECASE | re.MULTILINE)
|
||||
if match:
|
||||
metadata["spring_boot_version"] = match.group(1)
|
||||
break
|
||||
return metadata
|
||||
|
||||
|
||||
def _extract_backend_runtime_metadata(project_dir: Path) -> dict[str, str]:
|
||||
resources = project_dir / "src" / "main" / "resources"
|
||||
if not resources.is_dir():
|
||||
return {}
|
||||
for pattern in ("application*.properties", "application*.yml", "application*.yaml"):
|
||||
for config_file in sorted(resources.glob(pattern)):
|
||||
try:
|
||||
text = config_file.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
match = re.search(
|
||||
r"(?:server\.(?:servlet\.)?)?context-path\s*[:=]\s*['\"]?([^'\"\s#]+)",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if match:
|
||||
context_path = match.group(1).strip()
|
||||
if not context_path.startswith("/"):
|
||||
context_path = "/" + context_path
|
||||
return {"context_path": context_path}
|
||||
return {}
|
||||
|
||||
|
||||
def _normalize_java_version(value: str) -> str:
|
||||
clean = value.strip().replace("version_", "").replace("_", ".").lower()
|
||||
if clean.startswith("1."):
|
||||
return clean.split(".", 1)[1]
|
||||
return clean.split(".", 1)[0]
|
||||
|
||||
|
||||
def _java_source_label(pattern: str) -> str:
|
||||
if "maven\\.compiler\\.source" in pattern:
|
||||
return "maven.compiler.source"
|
||||
if "maven\\.compiler\\.target" in pattern:
|
||||
return "maven.compiler.target"
|
||||
if "java\\.version" in pattern:
|
||||
return "java.version"
|
||||
if "sourceCompatibility" in pattern:
|
||||
return "sourceCompatibility"
|
||||
if "targetCompatibility" in pattern:
|
||||
return "targetCompatibility"
|
||||
return "项目配置"
|
||||
70
deploy_tool/service_console.py
Normal file
70
deploy_tool/service_console.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import locale
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _set_console_title(title: str) -> None:
|
||||
if os.name == "nt":
|
||||
ctypes.windll.kernel32.SetConsoleTitleW(title)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Run a service in a visible console and tee its output to a log file.")
|
||||
parser.add_argument("--title", default="后台服务")
|
||||
parser.add_argument("--log", required=True)
|
||||
parser.add_argument("--pid-file", required=True)
|
||||
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||
args = parser.parse_args()
|
||||
|
||||
command = list(args.command)
|
||||
if command and command[0] == "--":
|
||||
command.pop(0)
|
||||
if not command:
|
||||
parser.error("missing service command after --")
|
||||
|
||||
_set_console_title(args.title)
|
||||
log_path = Path(args.log)
|
||||
pid_path = Path(args.pid_file)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
encoding = locale.getpreferredencoding(False) or "utf-8"
|
||||
with log_path.open("a", encoding="utf-8") as log_file:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding=encoding,
|
||||
errors="replace",
|
||||
bufsize=1,
|
||||
shell=False,
|
||||
)
|
||||
pid_path.write_text(str(process.pid), encoding="ascii")
|
||||
assert process.stdout is not None
|
||||
try:
|
||||
for line in process.stdout:
|
||||
print(line, end="", flush=True)
|
||||
log_file.write(line)
|
||||
log_file.flush()
|
||||
return process.wait()
|
||||
except KeyboardInterrupt:
|
||||
process.terminate()
|
||||
return process.wait()
|
||||
finally:
|
||||
process.stdout.close()
|
||||
try:
|
||||
pid_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
63
docs/superpowers/plans/2026-07-02-deploy-tool-workbench.md
Normal file
63
docs/superpowers/plans/2026-07-02-deploy-tool-workbench.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Deploy Tool Workbench Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a Python tkinter workbench for detecting and deploying Spring Boot + Vue separated projects.
|
||||
|
||||
**Architecture:** Core logic lives in testable modules for scanning, environment checks, and deployment planning. The GUI imports those modules and presents their results in a left-nav workbench with dashboard cards, settings, and logs.
|
||||
|
||||
**Tech Stack:** Python 3 standard library, tkinter/ttk, unittest.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Core Project Scanner
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy_tool/scanner.py`
|
||||
- Test: `tests/test_scanner.py`
|
||||
|
||||
- [ ] Write tests for detecting backend, frontend, and SQL files from temporary folders.
|
||||
- [ ] Run `python -m unittest tests.test_scanner` and verify tests fail because the module does not exist.
|
||||
- [ ] Implement scanner dataclasses and `scan_project`.
|
||||
- [ ] Run `python -m unittest tests.test_scanner` and verify pass.
|
||||
|
||||
### Task 2: Environment Checks
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy_tool/environment.py`
|
||||
- Test: `tests/test_environment.py`
|
||||
|
||||
- [ ] Write tests using an injected command runner for installed and missing commands.
|
||||
- [ ] Run `python -m unittest tests.test_environment` and verify tests fail because the module does not exist.
|
||||
- [ ] Implement environment check result types and command checks.
|
||||
- [ ] Run `python -m unittest tests.test_environment` and verify pass.
|
||||
|
||||
### Task 3: Deployment Plan
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy_tool/deployer.py`
|
||||
- Test: `tests/test_deployer.py`
|
||||
|
||||
- [ ] Write tests that generate backend, frontend, and SQL deployment command steps.
|
||||
- [ ] Run `python -m unittest tests.test_deployer` and verify tests fail because the module does not exist.
|
||||
- [ ] Implement `build_deployment_plan`.
|
||||
- [ ] Run `python -m unittest tests.test_deployer` and verify pass.
|
||||
|
||||
### Task 4: Workbench GUI
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy_tool/gui.py`
|
||||
- Create: `main.py`
|
||||
|
||||
- [ ] Implement tkinter layout with sidebar, top action bar, dashboard cards, config form, and log output.
|
||||
- [ ] Wire folder selection, scan, environment check, and deployment plan preview.
|
||||
- [ ] Keep long operations on background threads and push messages to the UI queue.
|
||||
|
||||
### Task 5: Documentation and Verification
|
||||
|
||||
**Files:**
|
||||
- Create: `README.md`
|
||||
|
||||
- [ ] Document usage, features, and current local-only deployment scope.
|
||||
- [ ] Run `python -m unittest`.
|
||||
- [ ] Run `python -m py_compile main.py deploy_tool/*.py`.
|
||||
44
docs/superpowers/plans/2026-07-03-local-auto-deploy.md
Normal file
44
docs/superpowers/plans/2026-07-03-local-auto-deploy.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Local Auto Deploy Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Turn the local deployment flow from a partly executable command preview into an automatic local deploy runner.
|
||||
|
||||
**Architecture:** Keep plan generation in `deploy_tool/deployer.py`, but make deployment steps typed so commands that need runtime context can run without shell placeholders. The runner will build backend and frontend, copy frontend artifacts to the output directory, import SQL through stdin, locate the built jar, and start the backend process.
|
||||
|
||||
**Tech Stack:** Python standard library, `unittest`, `subprocess`, `pathlib`, `shutil`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Deployment Step Model
|
||||
|
||||
**Files:**
|
||||
- Modify: `deploy_tool/deployer.py`
|
||||
- Test: `tests/test_deployer.py`
|
||||
|
||||
- [x] Add tests showing SQL import steps no longer contain shell redirection and backend start no longer contains jar placeholders.
|
||||
- [x] Add fields to `DeploymentStep` for `kind` and optional artifact metadata.
|
||||
- [x] Update `build_deployment_plan` to emit executable step metadata.
|
||||
- [x] Run `python -m unittest tests.test_deployer`.
|
||||
|
||||
### Task 2: Automatic Runner
|
||||
|
||||
**Files:**
|
||||
- Modify: `deploy_tool/deployer.py`
|
||||
- Test: `tests/test_deployer.py`
|
||||
|
||||
- [x] Add tests for latest jar discovery under `target` or `build/libs`.
|
||||
- [x] Add tests for frontend artifact copy from `dist` or `build`.
|
||||
- [x] Add tests with an injected process runner to verify SQL stdin and backend startup command.
|
||||
- [x] Implement helper functions and dependency injection points for process execution.
|
||||
- [x] Run `python -m unittest tests.test_deployer`.
|
||||
|
||||
### Task 3: GUI Wording and Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `deploy_tool/gui.py`
|
||||
- Test: `python -m unittest discover -s tests`
|
||||
|
||||
- [x] Update the confirmation text from partial execution to automatic local deployment.
|
||||
- [x] Run `python -m unittest discover -s tests`.
|
||||
- [x] Run `python -m py_compile main.py @((Get-ChildItem -LiteralPath .\deploy_tool -Filter *.py).FullName)`.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Backend Restart and Health Check Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Restart the process occupying the configured backend port and report deployment success only after the replacement backend is listening.
|
||||
|
||||
**Architecture:** Add testable process/port helpers to `deploy_tool/deployer.py`, then compose them in the existing background runner. Add a main-thread deployment state guard to `deploy_tool/gui.py` so both deploy buttons share one running state.
|
||||
|
||||
**Tech Stack:** Python standard library, `unittest`, Tkinter, Windows `netstat` and `taskkill`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Listener discovery and process replacement
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_deployer.py`
|
||||
- Modify: `deploy_tool/deployer.py`
|
||||
|
||||
- [ ] Add failing tests for server-port extraction and parsing IPv4/IPv6 `netstat` listener rows.
|
||||
- [ ] Run the targeted tests and confirm they fail because the helpers do not exist.
|
||||
- [ ] Implement `_extract_server_port()` and `_parse_windows_listening_pids()`.
|
||||
- [ ] Run the targeted tests and confirm they pass.
|
||||
- [ ] Add a failing test proving listener PIDs are terminated and the port must become free before launch.
|
||||
- [ ] Implement listener lookup, process-tree termination, and bounded port-release polling.
|
||||
- [ ] Run all deployer tests.
|
||||
|
||||
### Task 2: Backend startup readiness
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_deployer.py`
|
||||
- Modify: `deploy_tool/deployer.py`
|
||||
|
||||
- [ ] Add failing tests for early child exit, startup timeout cleanup, and successful readiness.
|
||||
- [ ] Run the targeted tests and confirm the current immediate-success behavior fails them.
|
||||
- [ ] Refactor the background runner behind injectable `_restart_backend()` dependencies.
|
||||
- [ ] Add Windows detached-process creation flags and a timestamped log separator.
|
||||
- [ ] Poll child state and TCP readiness; return non-zero on early exit or timeout.
|
||||
- [ ] Run all deployer tests.
|
||||
|
||||
### Task 3: Duplicate GUI deployment guard
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_gui_navigation.py`
|
||||
- Modify: `deploy_tool/gui.py`
|
||||
|
||||
- [ ] Add failing tests that a running deployment ignores another invocation and that button state is updated together.
|
||||
- [ ] Run the targeted tests and confirm failure.
|
||||
- [ ] Store both deployment button instances, add `deployment_running`, and centralize button state changes.
|
||||
- [ ] Set the guard before the worker starts and clear it when `deploy_done` is handled.
|
||||
- [ ] Run GUI tests.
|
||||
|
||||
### Task 4: Verification
|
||||
|
||||
**Files:**
|
||||
- Verify: `deploy_tool/deployer.py`
|
||||
- Verify: `deploy_tool/gui.py`
|
||||
- Verify: `tests/test_deployer.py`
|
||||
- Verify: `tests/test_gui_navigation.py`
|
||||
|
||||
- [ ] Run `python -m unittest discover -s tests -p 'test_*.py' -v` and require zero failures.
|
||||
- [ ] Run `python -m compileall -q deploy_tool main.py tests` and require exit code 0.
|
||||
- [ ] Inspect `git diff --check` and the final diff for unrelated changes.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Workbench Deploy Tool Design
|
||||
|
||||
## Goal
|
||||
|
||||
Build a Python desktop tool that helps deploy separated Spring Boot + Vue projects. The user selects one folder, then the tool detects backend, frontend, SQL files, required local environments, configurable deployment settings, and deployment command steps.
|
||||
|
||||
## Interface
|
||||
|
||||
Use a workbench layout:
|
||||
|
||||
- Left navigation for Project, Environment, Database, Deployment, Logs.
|
||||
- Top action bar with folder selection, scan, environment check, and deploy actions.
|
||||
- Main dashboard cards for backend, frontend, SQL, and environment status.
|
||||
- Configuration fields for backend port, frontend build directory, MySQL connection, and deployment output directory.
|
||||
- Log panel for command output and status messages.
|
||||
|
||||
## Architecture
|
||||
|
||||
Use Python standard library only for the first version. Keep business logic outside the GUI:
|
||||
|
||||
- `deploy_tool/scanner.py` detects Spring Boot, Vue, and SQL files.
|
||||
- `deploy_tool/environment.py` checks commands such as Java, Maven, Gradle, Node, npm, Vue CLI, and MySQL.
|
||||
- `deploy_tool/deployer.py` builds deployment steps and can run shell commands with streamed output.
|
||||
- `deploy_tool/gui.py` implements the tkinter/ttk workbench.
|
||||
- `main.py` starts the application.
|
||||
|
||||
## Behavior
|
||||
|
||||
The scanner walks the selected folder and recognizes:
|
||||
|
||||
- Spring Boot backend by `pom.xml`, `build.gradle`, `src/main/java`, and Spring Boot dependency text.
|
||||
- Vue frontend by `package.json` containing Vue dependencies or scripts plus common Vue config files.
|
||||
- SQL files by `.sql` extension.
|
||||
|
||||
Environment checks run non-destructive version commands and report installed/missing states.
|
||||
|
||||
Deployment starts with a dry, visible command plan. The first version focuses on local build/import/start orchestration and clear logs rather than remote server provisioning.
|
||||
|
||||
## Testing
|
||||
|
||||
Use `unittest` from the standard library. Tests cover scanner detection, environment command handling with injected runners, and deployment plan generation.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Backend Restart and Health Check Design
|
||||
|
||||
## Goal
|
||||
|
||||
Make backend deployment deterministic on Windows: replace any process listening on the configured backend port, prevent duplicate GUI deployments, keep the launched Java process alive independently of the deployment window, and report success only after the new backend is actually listening.
|
||||
|
||||
## Confirmed Behavior
|
||||
|
||||
- Before starting the backend, find every process listening on the configured backend port.
|
||||
- Terminate each listener and its child process tree, including listeners not created by this tool.
|
||||
- Log every PID that is terminated and fail the deployment if the port cannot be released.
|
||||
- Do not allow another deployment to start while one is already running.
|
||||
- Report exit code `0` only after the new process remains alive and the configured port accepts TCP connections.
|
||||
- If the process exits early or startup times out, report failure and retain the backend log for diagnosis.
|
||||
|
||||
## Architecture
|
||||
|
||||
Keep process orchestration in `deploy_tool/deployer.py` and GUI state in `deploy_tool/gui.py`. Use only the Python standard library and Windows commands already available on the target machine; do not add a package dependency.
|
||||
|
||||
`deployer.py` will contain small helpers with focused responsibilities:
|
||||
|
||||
- Extract the configured server port from the Java command.
|
||||
- Parse Windows `netstat -ano -p tcp` output to identify listening PIDs.
|
||||
- Terminate a PID tree with `taskkill /PID <pid> /T /F`.
|
||||
- Poll the port until it is released before launch.
|
||||
- Poll the child process and port during startup.
|
||||
- Build Windows creation flags that detach the Java process from the GUI console.
|
||||
|
||||
The existing deployment runner contract remains unchanged: each step returns an integer exit code. A background backend step may wait for readiness, but after readiness it returns while the detached Java process continues running.
|
||||
|
||||
## Deployment Flow
|
||||
|
||||
1. Resolve the backend JAR and configured port as today.
|
||||
2. Query listeners for that port.
|
||||
3. Log and terminate each listener process tree.
|
||||
4. Wait up to 10 seconds for the port to become free. Failure to release the port stops deployment.
|
||||
5. Append a clear deployment separator to `backend.log` and start Java with stdout/stderr redirected to that log.
|
||||
6. On Windows, use `CREATE_NEW_PROCESS_GROUP` and `DETACHED_PROCESS` so closing the GUI does not close the backend console process.
|
||||
7. For up to 60 seconds, poll both `process.poll()` and a TCP connection to `127.0.0.1:<port>`.
|
||||
8. If the process exits before listening, return its non-zero code, or `1` if it exited with code `0` without becoming ready.
|
||||
9. If the port begins accepting connections while the child is alive, log the PID and return `0`.
|
||||
10. If startup times out, terminate the new process tree and return `1`.
|
||||
|
||||
## GUI Behavior
|
||||
|
||||
Both visible deployment buttons will be retained, but both refer to the same deployment-running state.
|
||||
|
||||
- After confirmation and before starting the worker thread, set `deployment_running = True` and disable both buttons.
|
||||
- If deployment is already running, ignore another invocation and add an explanatory log message.
|
||||
- When the `deploy_done` event is handled, clear the flag, restore both buttons, and display the real exit code.
|
||||
|
||||
This state transition occurs on the Tk main thread, so rapid clicks cannot enqueue multiple deployment workers.
|
||||
|
||||
## Error Handling and Logs
|
||||
|
||||
- Failure to enumerate listeners, terminate a listener, release the port, create the Java process, or reach readiness produces a non-zero deployment result.
|
||||
- Log messages identify the affected port and PID without hiding the original backend log.
|
||||
- Historical `backend.log` content is preserved; a timestamped separator distinguishes runs.
|
||||
- The existing MyBatis warning is not treated as a startup failure because readiness is based on process state and TCP availability.
|
||||
|
||||
## Testing
|
||||
|
||||
Use `unittest` and dependency injection or pure helpers so tests do not terminate real processes or occupy real user ports.
|
||||
|
||||
Regression coverage will include:
|
||||
|
||||
- Parsing IPv4 and IPv6 listener rows while ignoring unrelated ports and states.
|
||||
- Terminating all listener PIDs before spawning Java.
|
||||
- Returning failure when the old listener cannot be stopped.
|
||||
- Returning failure when Java exits before opening the port.
|
||||
- Returning failure and cleaning up when startup times out.
|
||||
- Returning success only when Java is alive and the configured port becomes reachable.
|
||||
- Applying Windows detached-process flags.
|
||||
- Ignoring a second GUI deployment while one is active.
|
||||
- Disabling and restoring both GUI deployment buttons around a deployment.
|
||||
|
||||
The full existing test suite must continue to pass.
|
||||
5
main.py
Normal file
5
main.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from deploy_tool.gui import run_app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_app()
|
||||
674
tests/test_deployer.py
Normal file
674
tests/test_deployer.py
Normal file
@@ -0,0 +1,674 @@
|
||||
import unittest
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
from deploy_tool.deployer import DeploymentConfig, DeploymentStep, build_deployment_plan, run_steps, resolve_process_command
|
||||
import deploy_tool.deployer as deployer_module
|
||||
from deploy_tool.scanner import ProjectComponent, ScanResult
|
||||
|
||||
|
||||
class DeployerTests(unittest.TestCase):
|
||||
def test_extracts_backend_port_from_java_system_property(self):
|
||||
extract_port = getattr(deployer_module, "_extract_server_port", lambda command: None)
|
||||
|
||||
port = extract_port(["java", "-Dserver.port=9090", "-jar", "app.jar"])
|
||||
|
||||
self.assertEqual(port, 9090)
|
||||
|
||||
def test_rejects_backend_port_outside_valid_tcp_range(self):
|
||||
for value in ("0", "-1", "65536"):
|
||||
with self.subTest(value=value), self.assertRaisesRegex(ValueError, "1 到 65535"):
|
||||
deployer_module._extract_server_port(["java", f"-Dserver.port={value}", "-jar", "app.jar"])
|
||||
|
||||
def test_parses_ipv4_and_ipv6_listeners_for_configured_port(self):
|
||||
parse_listeners = getattr(deployer_module, "_parse_windows_listening_pids", lambda output, port: set())
|
||||
output = """
|
||||
TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 27544
|
||||
TCP [::]:8080 [::]:0 LISTENING 27544
|
||||
TCP 127.0.0.1:8080 127.0.0.1:52100 ESTABLISHED 30000
|
||||
TCP 0.0.0.0:9090 0.0.0.0:0 LISTENING 31000
|
||||
"""
|
||||
|
||||
pids = parse_listeners(output, 8080)
|
||||
|
||||
self.assertEqual(pids, {27544})
|
||||
|
||||
def test_port_release_waits_until_no_interface_has_a_listener(self):
|
||||
listener_snapshots = iter(({27544}, set()))
|
||||
|
||||
with (
|
||||
patch.object(deployer_module, "_find_listening_pids", side_effect=lambda port: next(listener_snapshots)) as find_listeners,
|
||||
patch.object(deployer_module, "_is_port_open", return_value=False),
|
||||
patch.object(deployer_module.time, "sleep", return_value=None),
|
||||
):
|
||||
released = deployer_module._wait_until_port_closed(8080)
|
||||
|
||||
self.assertTrue(released)
|
||||
self.assertEqual(find_listeners.call_count, 2)
|
||||
|
||||
def test_replaces_all_processes_listening_on_backend_port(self):
|
||||
replace_listeners = getattr(deployer_module, "_replace_port_listeners", lambda *args, **kwargs: False)
|
||||
terminated = []
|
||||
events = []
|
||||
|
||||
released = replace_listeners(
|
||||
8080,
|
||||
events.append,
|
||||
find_listeners=lambda port: {27544, 23780},
|
||||
terminate=lambda pid: terminated.append(pid),
|
||||
wait_until_closed=lambda port: True,
|
||||
)
|
||||
|
||||
self.assertTrue(released)
|
||||
self.assertEqual(set(terminated), {27544, 23780})
|
||||
self.assertTrue(any("8080" in event and "27544" in event for event in events))
|
||||
|
||||
def test_port_replacement_skips_pid_removed_by_an_earlier_tree_kill(self):
|
||||
replace_listeners = getattr(deployer_module, "_replace_port_listeners", lambda *args, **kwargs: False)
|
||||
active_pids = {23780, 27544}
|
||||
terminated = []
|
||||
|
||||
def terminate_tree(pid):
|
||||
terminated.append(pid)
|
||||
active_pids.clear()
|
||||
|
||||
released = replace_listeners(
|
||||
8080,
|
||||
lambda value: None,
|
||||
find_listeners=lambda port: set(active_pids),
|
||||
terminate=terminate_tree,
|
||||
wait_until_closed=lambda port: True,
|
||||
)
|
||||
|
||||
self.assertTrue(released)
|
||||
self.assertEqual(terminated, [23780])
|
||||
|
||||
def test_backend_restart_fails_before_spawn_when_port_is_not_released(self):
|
||||
restart_backend = getattr(deployer_module, "_restart_backend", lambda *args, **kwargs: 0)
|
||||
spawned = []
|
||||
|
||||
with TemporaryDirectory() as temp:
|
||||
exit_code = restart_backend(
|
||||
["java", "-Dserver.port=8080", "-jar", "app.jar"],
|
||||
Path(temp),
|
||||
lambda value: None,
|
||||
replace_listeners=lambda port, on_output: False,
|
||||
popen=lambda *args, **kwargs: spawned.append(args),
|
||||
)
|
||||
|
||||
self.assertEqual(exit_code, 1)
|
||||
self.assertEqual(spawned, [])
|
||||
|
||||
def test_backend_restart_returns_child_exit_code_when_java_exits_early(self):
|
||||
restart_backend = getattr(deployer_module, "_restart_backend", lambda *args, **kwargs: 0)
|
||||
|
||||
class ExitedProcess:
|
||||
pid = 23780
|
||||
|
||||
def poll(self):
|
||||
return 2
|
||||
|
||||
with TemporaryDirectory() as temp:
|
||||
exit_code = restart_backend(
|
||||
["java", "-Dserver.port=65530", "-jar", "app.jar"],
|
||||
Path(temp),
|
||||
lambda value: None,
|
||||
replace_listeners=lambda port, on_output: True,
|
||||
popen=lambda *args, **kwargs: ExitedProcess(),
|
||||
)
|
||||
|
||||
self.assertEqual(exit_code, 2)
|
||||
|
||||
def test_backend_restart_reports_success_only_after_port_is_reachable(self):
|
||||
restart_backend = getattr(deployer_module, "_restart_backend", lambda *args, **kwargs: 0)
|
||||
checked_ports = []
|
||||
logs = []
|
||||
|
||||
class RunningProcess:
|
||||
pid = 27544
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
def port_is_open(port):
|
||||
checked_ports.append(port)
|
||||
return True
|
||||
|
||||
with (
|
||||
TemporaryDirectory() as temp,
|
||||
patch.object(deployer_module, "_is_port_open", side_effect=port_is_open),
|
||||
patch.object(deployer_module, "_find_listening_pids", return_value={27544}),
|
||||
):
|
||||
exit_code = restart_backend(
|
||||
["java", "-Dserver.port=65530", "-jar", "app.jar"],
|
||||
Path(temp),
|
||||
logs.append,
|
||||
replace_listeners=lambda port, on_output: True,
|
||||
popen=lambda *args, **kwargs: RunningProcess(),
|
||||
)
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(checked_ports, [65530])
|
||||
self.assertTrue(any("27544" in line and "启动成功" in line for line in logs))
|
||||
|
||||
def test_backend_restart_rejects_reachable_port_owned_by_another_process(self):
|
||||
restart_backend = getattr(deployer_module, "_restart_backend", lambda *args, **kwargs: 0)
|
||||
terminated = []
|
||||
|
||||
class RunningProcess:
|
||||
pid = 27544
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
with (
|
||||
TemporaryDirectory() as temp,
|
||||
patch.object(deployer_module, "_is_port_open", return_value=True),
|
||||
patch.object(deployer_module, "_find_listening_pids", return_value={31000}),
|
||||
patch.object(deployer_module, "_terminate_process_tree", side_effect=lambda pid: terminated.append(pid)),
|
||||
patch.object(deployer_module.time, "monotonic", side_effect=[0.0, 0.0, 61.0]),
|
||||
):
|
||||
exit_code = restart_backend(
|
||||
["java", "-Dserver.port=65530", "-jar", "app.jar"],
|
||||
Path(temp),
|
||||
lambda value: None,
|
||||
replace_listeners=lambda port, on_output: True,
|
||||
popen=lambda *args, **kwargs: RunningProcess(),
|
||||
)
|
||||
|
||||
self.assertEqual(exit_code, 1)
|
||||
self.assertEqual(terminated, [27544])
|
||||
|
||||
def test_backend_restart_terminates_child_after_startup_timeout(self):
|
||||
restart_backend = getattr(deployer_module, "_restart_backend", lambda *args, **kwargs: 0)
|
||||
terminated = []
|
||||
|
||||
class RunningProcess:
|
||||
pid = 27544
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
with (
|
||||
TemporaryDirectory() as temp,
|
||||
patch.object(deployer_module, "_is_port_open", return_value=False),
|
||||
patch.object(deployer_module, "_terminate_process_tree", side_effect=lambda pid: terminated.append(pid)),
|
||||
patch.object(deployer_module.time, "monotonic", side_effect=[0.0, 61.0]),
|
||||
):
|
||||
exit_code = restart_backend(
|
||||
["java", "-Dserver.port=65530", "-jar", "app.jar"],
|
||||
Path(temp),
|
||||
lambda value: None,
|
||||
replace_listeners=lambda port, on_output: True,
|
||||
popen=lambda *args, **kwargs: RunningProcess(),
|
||||
)
|
||||
|
||||
self.assertEqual(exit_code, 1)
|
||||
self.assertEqual(terminated, [27544])
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "Windows creation flags only apply on Windows")
|
||||
def test_backend_restart_uses_visible_windows_console(self):
|
||||
restart_backend = getattr(deployer_module, "_restart_backend", lambda *args, **kwargs: 0)
|
||||
popen_options = {}
|
||||
|
||||
class RunningProcess:
|
||||
pid = 27544
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
def fake_popen(*args, **kwargs):
|
||||
popen_options.update(kwargs)
|
||||
return RunningProcess()
|
||||
|
||||
with (
|
||||
TemporaryDirectory() as temp,
|
||||
patch.object(deployer_module, "_is_port_open", return_value=True),
|
||||
patch.object(deployer_module, "_find_listening_pids", return_value={27544}),
|
||||
):
|
||||
exit_code = restart_backend(
|
||||
["java", "-Dserver.port=65530", "-jar", "app.jar"],
|
||||
Path(temp),
|
||||
lambda value: None,
|
||||
replace_listeners=lambda port, on_output: True,
|
||||
popen=fake_popen,
|
||||
)
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertIn("creationflags", popen_options)
|
||||
self.assertTrue(popen_options["creationflags"] & subprocess.CREATE_NEW_CONSOLE)
|
||||
self.assertTrue(popen_options["creationflags"] & subprocess.CREATE_NEW_PROCESS_GROUP)
|
||||
|
||||
def test_background_runner_returns_backend_restart_health_result(self):
|
||||
class FakeProcess:
|
||||
pid = 27544
|
||||
|
||||
with (
|
||||
TemporaryDirectory() as temp,
|
||||
patch.object(deployer_module, "_restart_backend", return_value=7) as restart_backend,
|
||||
patch.object(deployer_module.subprocess, "Popen", return_value=FakeProcess()),
|
||||
):
|
||||
exit_code = deployer_module._run_process(
|
||||
["java", "-Dserver.port=8080", "-jar", "app.jar"],
|
||||
Path(temp),
|
||||
lambda value: None,
|
||||
background=True,
|
||||
)
|
||||
|
||||
self.assertEqual(exit_code, 7)
|
||||
restart_backend.assert_called_once()
|
||||
|
||||
def test_backend_restart_separates_each_run_in_backend_log(self):
|
||||
restart_backend = getattr(deployer_module, "_restart_backend", lambda *args, **kwargs: 0)
|
||||
|
||||
class RunningProcess:
|
||||
pid = 27544
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
with (
|
||||
TemporaryDirectory() as temp,
|
||||
patch.object(deployer_module, "_is_port_open", return_value=True),
|
||||
patch.object(deployer_module, "_find_listening_pids", return_value={27544}),
|
||||
):
|
||||
root = Path(temp)
|
||||
exit_code = restart_backend(
|
||||
["java", "-Dserver.port=65530", "-jar", "app.jar"],
|
||||
root,
|
||||
lambda value: None,
|
||||
replace_listeners=lambda port, on_output: True,
|
||||
popen=lambda *args, **kwargs: RunningProcess(),
|
||||
)
|
||||
log_text = (root / "backend.log").read_text(encoding="utf-8")
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertIn("后端部署", log_text)
|
||||
|
||||
def test_builds_plan_for_detected_components(self):
|
||||
root = Path("C:/project")
|
||||
scan = ScanResult(
|
||||
root=root,
|
||||
backend=ProjectComponent("Spring Boot", root / "server", "Maven project"),
|
||||
frontend=ProjectComponent(
|
||||
"Vue",
|
||||
root / "web",
|
||||
"Vue project",
|
||||
{"role": "user", "display_name": "用户端", "start_script": "serve"},
|
||||
),
|
||||
sql_files=[root / "db" / "schema.sql"],
|
||||
)
|
||||
config = DeploymentConfig(
|
||||
mysql_user="root",
|
||||
mysql_password="secret",
|
||||
mysql_database="demo",
|
||||
backend_port="8080",
|
||||
output_dir=root / "deploy",
|
||||
)
|
||||
|
||||
plan = build_deployment_plan(scan, config)
|
||||
|
||||
self.assertEqual(
|
||||
[step.kind for step in plan],
|
||||
[
|
||||
"backend_build",
|
||||
"frontend_install",
|
||||
"sql_import",
|
||||
"backend_start",
|
||||
"frontend_start",
|
||||
"deployment_summary",
|
||||
],
|
||||
)
|
||||
self.assertIn("mvn", plan[0].command[0])
|
||||
self.assertEqual(plan[2].cwd, root)
|
||||
|
||||
def test_builds_and_starts_user_and_admin_frontends_then_logs_all_addresses(self):
|
||||
with TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
backend = root / "server"
|
||||
client = root / "client_code"
|
||||
admin = root / "manage_code"
|
||||
(backend / "target").mkdir(parents=True)
|
||||
(backend / "target" / "app.jar").write_text("jar", encoding="utf-8")
|
||||
|
||||
frontends = []
|
||||
for path, role, display_name, port in (
|
||||
(client, "user", "用户端", "8082"),
|
||||
(admin, "admin", "管理端", "8081"),
|
||||
):
|
||||
(path / "node_modules").mkdir(parents=True)
|
||||
(path / "dist").mkdir()
|
||||
(path / "dist" / "index.html").write_text("<main>app</main>", encoding="utf-8")
|
||||
(path / "package.json").write_text(
|
||||
'{"scripts":{"build":"vue-cli-service build","serve":"vue-cli-service serve"}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
frontends.append(
|
||||
ProjectComponent(
|
||||
"Vue",
|
||||
path,
|
||||
"Vue project",
|
||||
{"role": role, "display_name": display_name, "port": port, "start_script": "serve"},
|
||||
)
|
||||
)
|
||||
|
||||
scan = ScanResult(
|
||||
root=root,
|
||||
backend=ProjectComponent(
|
||||
"Spring Boot", backend, "Maven project", {"context_path": "/movies"}
|
||||
),
|
||||
frontend=frontends[0],
|
||||
sql_files=[],
|
||||
frontends=frontends,
|
||||
)
|
||||
plan = build_deployment_plan(scan, DeploymentConfig(output_dir=root / "deploy"))
|
||||
|
||||
self.assertEqual(
|
||||
[step.kind for step in plan],
|
||||
[
|
||||
"backend_build_skip",
|
||||
"backend_start",
|
||||
"frontend_start",
|
||||
"frontend_start",
|
||||
"deployment_summary",
|
||||
],
|
||||
)
|
||||
start_steps = [step for step in plan if step.kind == "frontend_start"]
|
||||
self.assertEqual([step.port for step in start_steps], [8082, 8081])
|
||||
self.assertEqual([step.service_name for step in start_steps], ["用户端", "管理端"])
|
||||
self.assertEqual(start_steps[0].output_path, root / "deploy" / "frontend-user.log")
|
||||
self.assertEqual(start_steps[1].output_path, root / "deploy" / "frontend-admin.log")
|
||||
|
||||
logs = []
|
||||
exit_code = run_steps(plan, logs.append, process_runner=lambda *args: 0)
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
combined = "\n".join(logs)
|
||||
self.assertIn("后端: http://localhost:8080/movies", combined)
|
||||
self.assertIn("用户端: http://localhost:8082/", combined)
|
||||
self.assertIn("管理端: http://localhost:8081/", combined)
|
||||
|
||||
def test_plan_uses_executable_steps_without_shell_placeholders(self):
|
||||
with TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
backend = root / "server"
|
||||
frontend = root / "web"
|
||||
database = root / "db"
|
||||
backend.mkdir()
|
||||
frontend.mkdir()
|
||||
database.mkdir()
|
||||
(backend / "pom.xml").write_text("<project />", encoding="utf-8")
|
||||
(frontend / "package.json").write_text(
|
||||
'{"scripts":{"build":"vite build","dev":"vite"}}', encoding="utf-8"
|
||||
)
|
||||
sql_file = database / "schema.sql"
|
||||
sql_file.write_text("create table demo(id int);", encoding="utf-8")
|
||||
scan = ScanResult(
|
||||
root=root,
|
||||
backend=ProjectComponent("Spring Boot", backend, "Maven project"),
|
||||
frontend=ProjectComponent("Vue", frontend, "Vue project"),
|
||||
sql_files=[sql_file],
|
||||
)
|
||||
config = DeploymentConfig(mysql_database="demo", output_dir=root / "deploy")
|
||||
|
||||
plan = build_deployment_plan(scan, config)
|
||||
|
||||
self.assertNotIn("frontend_build", [step.kind for step in plan])
|
||||
self.assertNotIn("frontend_copy", [step.kind for step in plan])
|
||||
for step in plan:
|
||||
joined = " ".join(step.command)
|
||||
self.assertNotIn("<", joined)
|
||||
self.assertNotIn(">", joined)
|
||||
sql_step = next(step for step in plan if step.kind == "sql_import")
|
||||
self.assertEqual(sql_step.input_path, sql_file)
|
||||
self.assertNotIn(str(sql_file), sql_step.command)
|
||||
|
||||
def test_run_steps_installs_frontend_imports_sql_and_starts_services(self):
|
||||
with TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
backend = root / "server"
|
||||
frontend = root / "web"
|
||||
target = backend / "target"
|
||||
database = root / "db"
|
||||
target.mkdir(parents=True)
|
||||
frontend.mkdir(parents=True)
|
||||
database.mkdir()
|
||||
(backend / "pom.xml").write_text("<project />", encoding="utf-8")
|
||||
(frontend / "package.json").write_text(
|
||||
'{"scripts":{"build":"vite build","dev":"vite"}}', encoding="utf-8"
|
||||
)
|
||||
old_jar = target / "old.jar"
|
||||
app_jar = target / "app.jar"
|
||||
old_jar.write_text("old", encoding="utf-8")
|
||||
app_jar.write_text("new", encoding="utf-8")
|
||||
os.utime(old_jar, (1, 1))
|
||||
os.utime(app_jar, (2, 2))
|
||||
sql_file = database / "schema.sql"
|
||||
sql_file.write_text("create table demo(id int);", encoding="utf-8")
|
||||
scan = ScanResult(
|
||||
root=root,
|
||||
backend=ProjectComponent("Spring Boot", backend, "Maven project"),
|
||||
frontend=ProjectComponent("Vue", frontend, "Vue project"),
|
||||
sql_files=[sql_file],
|
||||
)
|
||||
config = DeploymentConfig(
|
||||
mysql_user="root",
|
||||
mysql_password="secret",
|
||||
mysql_database="demo",
|
||||
backend_port="9090",
|
||||
output_dir=root / "deploy",
|
||||
tool_search_roots=[root / "missing"],
|
||||
)
|
||||
plan = build_deployment_plan(scan, config)
|
||||
calls = []
|
||||
|
||||
def fake_runner(command, cwd, on_output, input_path=None, background=False):
|
||||
calls.append((command, cwd, input_path, background))
|
||||
return 0
|
||||
|
||||
exit_code = run_steps(plan, lambda value: None, process_runner=fake_runner)
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertIn((["mysql", "-u", "root", "-psecret", "demo"], root, sql_file, False), calls)
|
||||
self.assertIn((["java", "-Dserver.port=9090", "-jar", str(app_jar)], root / "deploy", None, True), calls)
|
||||
|
||||
def test_plan_uses_configured_maven_path_for_backend_build(self):
|
||||
with TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
backend = root / "server"
|
||||
maven = root / "tools" / "apache-maven" / "bin" / "mvn.cmd"
|
||||
backend.mkdir()
|
||||
maven.parent.mkdir(parents=True)
|
||||
(backend / "pom.xml").write_text("<project />", encoding="utf-8")
|
||||
maven.write_text("", encoding="utf-8")
|
||||
scan = ScanResult(
|
||||
root=root,
|
||||
backend=ProjectComponent("Spring Boot", backend, "Maven project"),
|
||||
frontend=None,
|
||||
sql_files=[],
|
||||
)
|
||||
config = DeploymentConfig(tool_paths={"Maven": maven})
|
||||
|
||||
plan = build_deployment_plan(scan, config)
|
||||
|
||||
self.assertEqual(plan[0].command, [str(maven), "clean", "package", "-DskipTests"])
|
||||
|
||||
def test_plan_uses_discovered_mysql_path_for_sql_import(self):
|
||||
with TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
mysql = root / "MySQL" / "MySQL Server 8.0" / "bin" / "mysql.exe"
|
||||
database = root / "db"
|
||||
mysql.parent.mkdir(parents=True)
|
||||
database.mkdir()
|
||||
mysql.write_text("", encoding="utf-8")
|
||||
sql_file = database / "schema.sql"
|
||||
sql_file.write_text("create table demo(id int);", encoding="utf-8")
|
||||
scan = ScanResult(
|
||||
root=root,
|
||||
backend=None,
|
||||
frontend=None,
|
||||
sql_files=[sql_file],
|
||||
)
|
||||
config = DeploymentConfig(mysql_database="demo", tool_search_roots=[root])
|
||||
|
||||
plan = build_deployment_plan(scan, config)
|
||||
|
||||
self.assertEqual(plan[0].command, [str(mysql), "-u", "root", "demo"])
|
||||
|
||||
def test_sql_dump_with_database_selection_does_not_require_existing_database(self):
|
||||
with TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
database = root / "db"
|
||||
database.mkdir()
|
||||
sql_file = database / "movie.sql"
|
||||
sql_file.write_text(
|
||||
"CREATE DATABASE IF NOT EXISTS `movie`;\nUSE `movie`;\nCREATE TABLE demo(id int);",
|
||||
encoding="utf-8",
|
||||
)
|
||||
scan = ScanResult(
|
||||
root=root,
|
||||
backend=None,
|
||||
frontend=None,
|
||||
sql_files=[sql_file],
|
||||
)
|
||||
config = DeploymentConfig(mysql_database="movie", tool_search_roots=[root / "missing"])
|
||||
|
||||
plan = build_deployment_plan(scan, config)
|
||||
|
||||
self.assertEqual(plan[0].command, ["mysql", "-u", "root"])
|
||||
|
||||
def test_plan_skips_backend_build_when_runnable_jar_already_exists(self):
|
||||
with TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
backend = root / "server"
|
||||
target = backend / "target"
|
||||
target.mkdir(parents=True)
|
||||
(backend / "pom.xml").write_text("<project />", encoding="utf-8")
|
||||
(target / "app.jar").write_text("jar", encoding="utf-8")
|
||||
scan = ScanResult(
|
||||
root=root,
|
||||
backend=ProjectComponent("Spring Boot", backend, "Maven project"),
|
||||
frontend=None,
|
||||
sql_files=[],
|
||||
)
|
||||
|
||||
plan = build_deployment_plan(scan, DeploymentConfig(output_dir=root / "deploy", tool_search_roots=[root / "missing"]))
|
||||
|
||||
self.assertEqual([step.kind for step in plan], ["backend_build_skip", "backend_start", "deployment_summary"])
|
||||
self.assertEqual(plan[0].command, [])
|
||||
self.assertIn("已有后端 jar", plan[0].description)
|
||||
|
||||
def test_existing_dist_does_not_add_frontend_build_or_copy_steps(self):
|
||||
with TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
frontend = root / "web"
|
||||
dist = frontend / "dist"
|
||||
dist.mkdir(parents=True)
|
||||
(frontend / "package.json").write_text(
|
||||
'{"scripts":{"build":"vite build","dev":"vite"}}', encoding="utf-8"
|
||||
)
|
||||
(dist / "index.html").write_text("<main>built</main>", encoding="utf-8")
|
||||
scan = ScanResult(
|
||||
root=root,
|
||||
backend=None,
|
||||
frontend=ProjectComponent("Vue", frontend, "Vue project"),
|
||||
sql_files=[],
|
||||
)
|
||||
|
||||
plan = build_deployment_plan(scan, DeploymentConfig(output_dir=root / "deploy", tool_search_roots=[root / "missing"]))
|
||||
|
||||
self.assertEqual(
|
||||
[step.kind for step in plan],
|
||||
["frontend_install", "frontend_start", "deployment_summary"],
|
||||
)
|
||||
self.assertNotIn("frontend_build", [step.kind for step in plan])
|
||||
self.assertNotIn("frontend_copy", [step.kind for step in plan])
|
||||
|
||||
def test_run_steps_logs_skip_steps_without_running_a_process(self):
|
||||
with TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
logs = []
|
||||
calls = []
|
||||
skip_step = DeploymentStep("跳过构建", [], root, "检测到已有产物,跳过构建", kind="skip")
|
||||
|
||||
def fake_runner(command, cwd, on_output, input_path=None, background=False):
|
||||
calls.append(command)
|
||||
return 0
|
||||
|
||||
exit_code = run_steps([skip_step], logs.append, process_runner=fake_runner)
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertTrue(any("检测到已有产物" in line for line in logs))
|
||||
|
||||
def test_plan_uses_configured_npm_path_for_frontend_start(self):
|
||||
with TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
frontend = root / "web"
|
||||
npm = root / "nodejs" / "npm.cmd"
|
||||
frontend.mkdir()
|
||||
(frontend / "node_modules").mkdir()
|
||||
npm.parent.mkdir(parents=True)
|
||||
(frontend / "package.json").write_text(
|
||||
'{"scripts":{"build":"vite build","dev":"vite"}}', encoding="utf-8"
|
||||
)
|
||||
npm.write_text("", encoding="utf-8")
|
||||
scan = ScanResult(
|
||||
root=root,
|
||||
backend=None,
|
||||
frontend=ProjectComponent("Vue", frontend, "Vue project"),
|
||||
sql_files=[],
|
||||
)
|
||||
config = DeploymentConfig(tool_paths={"npm": npm})
|
||||
|
||||
plan = build_deployment_plan(scan, config)
|
||||
|
||||
self.assertEqual(
|
||||
plan[0].command,
|
||||
[str(npm), "run", "dev", "--", "--host", "0.0.0.0", "--port", "8081"],
|
||||
)
|
||||
|
||||
def test_plan_installs_frontend_dependencies_before_start_when_node_modules_missing(self):
|
||||
with TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
frontend = root / "web"
|
||||
frontend.mkdir()
|
||||
(frontend / "package.json").write_text(
|
||||
'{"scripts":{"build":"vue-cli-service build","serve":"vue-cli-service serve"},'
|
||||
'"devDependencies":{"@vue/cli-service":"^5.0.0"}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
scan = ScanResult(
|
||||
root=root,
|
||||
backend=None,
|
||||
frontend=ProjectComponent("Vue", frontend, "Vue project"),
|
||||
sql_files=[],
|
||||
)
|
||||
|
||||
plan = build_deployment_plan(scan, DeploymentConfig(output_dir=root / "deploy", tool_search_roots=[root / "missing"]))
|
||||
|
||||
npm = "npm.cmd" if os.name == "nt" else "npm"
|
||||
self.assertEqual(
|
||||
[step.kind for step in plan],
|
||||
["frontend_install", "frontend_start", "deployment_summary"],
|
||||
)
|
||||
self.assertEqual(plan[0].command, [npm, "install"])
|
||||
self.assertEqual(plan[1].command[1:3], ["run", "serve"])
|
||||
|
||||
def test_resolves_windows_cmd_shims_before_starting_process(self):
|
||||
with TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
npm = root / "npm.cmd"
|
||||
npm.write_text("", encoding="utf-8")
|
||||
|
||||
command = resolve_process_command(["npm", "run", "build"], path=str(root))
|
||||
|
||||
self.assertEqual(os.path.normcase(command[0]), os.path.normcase(str(npm)))
|
||||
self.assertEqual(command[1:], ["run", "build"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
103
tests/test_environment.py
Normal file
103
tests/test_environment.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from deploy_tool.environment import check_environment, load_tool_paths, save_tool_paths
|
||||
|
||||
|
||||
class EnvironmentTests(unittest.TestCase):
|
||||
def test_reports_installed_command(self):
|
||||
def runner(command):
|
||||
return subprocess.CompletedProcess(command, 0, "java 21\n", "")
|
||||
|
||||
results = check_environment(runner=runner, checks={"JDK": ["java", "-version"]})
|
||||
|
||||
self.assertTrue(results[0].installed)
|
||||
self.assertEqual(results[0].name, "JDK")
|
||||
self.assertEqual(results[0].source, "PATH")
|
||||
self.assertIn("java 21", results[0].version)
|
||||
|
||||
def test_reports_missing_command(self):
|
||||
def runner(command):
|
||||
raise FileNotFoundError(command[0])
|
||||
|
||||
results = check_environment(runner=runner, checks={"MySQL": ["mysql", "--version"]})
|
||||
|
||||
self.assertFalse(results[0].installed)
|
||||
self.assertEqual(results[0].name, "MySQL")
|
||||
self.assertIn("未安装", results[0].message)
|
||||
|
||||
def test_uses_manual_path_when_command_is_not_in_path(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
mysql = Path(tmp) / "mysql.exe"
|
||||
mysql.write_text("", encoding="utf-8")
|
||||
|
||||
def runner(command):
|
||||
if command[0] == "mysql":
|
||||
raise FileNotFoundError(command[0])
|
||||
return subprocess.CompletedProcess(command, 0, "mysql 8.4\n", "")
|
||||
|
||||
results = check_environment(
|
||||
runner=runner,
|
||||
checks={"MySQL": ["mysql", "--version"]},
|
||||
manual_paths={"MySQL": mysql},
|
||||
)
|
||||
|
||||
self.assertTrue(results[0].installed)
|
||||
self.assertEqual(results[0].source, "手动指定")
|
||||
self.assertEqual(results[0].command[0], str(mysql))
|
||||
|
||||
def test_discovers_executable_from_known_search_paths(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
mysql = Path(tmp) / "MySQL" / "MySQL Server 8.4" / "bin" / "mysql.exe"
|
||||
mysql.parent.mkdir(parents=True)
|
||||
mysql.write_text("", encoding="utf-8")
|
||||
|
||||
def runner(command):
|
||||
if command[0] == "mysql":
|
||||
raise FileNotFoundError(command[0])
|
||||
return subprocess.CompletedProcess(command, 0, "mysql 8.4\n", "")
|
||||
|
||||
results = check_environment(
|
||||
runner=runner,
|
||||
checks={"MySQL": ["mysql", "--version"]},
|
||||
search_roots=[Path(tmp)],
|
||||
)
|
||||
|
||||
self.assertTrue(results[0].installed)
|
||||
self.assertEqual(results[0].source, "自动发现")
|
||||
self.assertEqual(results[0].command[0], str(mysql))
|
||||
|
||||
def test_does_not_recursively_scan_arbitrary_deep_directories(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
mysql = Path(tmp) / "random" / "deep" / "folder" / "mysql.exe"
|
||||
mysql.parent.mkdir(parents=True)
|
||||
mysql.write_text("", encoding="utf-8")
|
||||
|
||||
def runner(command):
|
||||
if command[0] == "mysql":
|
||||
raise FileNotFoundError(command[0])
|
||||
return subprocess.CompletedProcess(command, 0, "mysql 8.4\n", "")
|
||||
|
||||
results = check_environment(
|
||||
runner=runner,
|
||||
checks={"MySQL": ["mysql", "--version"]},
|
||||
search_roots=[Path(tmp)],
|
||||
)
|
||||
|
||||
self.assertFalse(results[0].installed)
|
||||
|
||||
def test_saves_and_loads_manual_paths(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config = Path(tmp) / "tool-paths.json"
|
||||
mysql = Path(tmp) / "mysql.exe"
|
||||
|
||||
save_tool_paths({"MySQL": mysql}, config)
|
||||
loaded = load_tool_paths(config)
|
||||
|
||||
self.assertEqual(loaded, {"MySQL": mysql})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
163
tests/test_gui_navigation.py
Normal file
163
tests/test_gui_navigation.py
Normal file
@@ -0,0 +1,163 @@
|
||||
import unittest
|
||||
import queue
|
||||
from unittest.mock import patch
|
||||
|
||||
from deploy_tool.gui import DeployWorkbench, database_name_from_sql_files
|
||||
import deploy_tool.gui as gui_module
|
||||
|
||||
|
||||
class FakeWidget:
|
||||
def __init__(self):
|
||||
self.focused = False
|
||||
self.seen = False
|
||||
|
||||
def focus_set(self):
|
||||
self.focused = True
|
||||
|
||||
def see(self, index):
|
||||
self.seen = index
|
||||
|
||||
|
||||
class FakeButton:
|
||||
def __init__(self):
|
||||
self.state = None
|
||||
|
||||
def configure(self, **options):
|
||||
self.state = options.get("state")
|
||||
|
||||
|
||||
class GuiNavigationTests(unittest.TestCase):
|
||||
def test_database_name_comes_from_first_sql_filename(self):
|
||||
from pathlib import Path
|
||||
|
||||
self.assertEqual(database_name_from_sql_files([Path("db/movie.sql")]), "movie")
|
||||
self.assertEqual(database_name_from_sql_files([]), "")
|
||||
|
||||
def test_deployment_running_state_updates_deploy_button(self):
|
||||
app = DeployWorkbench.__new__(DeployWorkbench)
|
||||
app.deployment_running = False
|
||||
app.deploy_buttons = [FakeButton()]
|
||||
set_running = getattr(DeployWorkbench, "_set_deployment_running", lambda self, running: None)
|
||||
|
||||
set_running(app, True)
|
||||
|
||||
self.assertTrue(app.deployment_running)
|
||||
self.assertEqual([button.state for button in app.deploy_buttons], ["disabled"])
|
||||
|
||||
set_running(app, False)
|
||||
|
||||
self.assertFalse(app.deployment_running)
|
||||
self.assertEqual([button.state for button in app.deploy_buttons], ["normal"])
|
||||
|
||||
def test_running_deployment_ignores_another_deploy_request(self):
|
||||
app = DeployWorkbench.__new__(DeployWorkbench)
|
||||
app.deployment_running = True
|
||||
plan_calls = []
|
||||
logs = []
|
||||
app._current_plan = lambda: plan_calls.append("planned") or []
|
||||
app._append_log = logs.append
|
||||
|
||||
DeployWorkbench.run_deploy_plan_async(app)
|
||||
|
||||
self.assertEqual(plan_calls, [])
|
||||
self.assertTrue(any("正在执行" in line for line in logs))
|
||||
|
||||
def test_confirmed_deployment_locks_buttons_before_worker_starts(self):
|
||||
app = DeployWorkbench.__new__(DeployWorkbench)
|
||||
app.deployment_running = False
|
||||
app.deploy_buttons = [FakeButton()]
|
||||
app._current_plan = lambda: [object()]
|
||||
app.ui_queue = object()
|
||||
app._thread_log = lambda value: None
|
||||
|
||||
with patch.object(gui_module.messagebox, "askyesno", return_value=True), patch.object(gui_module.threading, "Thread") as thread_class:
|
||||
DeployWorkbench.run_deploy_plan_async(app)
|
||||
|
||||
self.assertTrue(app.deployment_running)
|
||||
self.assertEqual([button.state for button in app.deploy_buttons], ["disabled"])
|
||||
thread_class.return_value.start.assert_called_once_with()
|
||||
|
||||
def test_thread_start_failure_restores_deploy_buttons(self):
|
||||
app = DeployWorkbench.__new__(DeployWorkbench)
|
||||
app.deployment_running = False
|
||||
app.deploy_buttons = [FakeButton()]
|
||||
app._current_plan = lambda: [object()]
|
||||
logs = []
|
||||
app._append_log = logs.append
|
||||
|
||||
with patch.object(gui_module.messagebox, "askyesno", return_value=True), patch.object(gui_module.threading, "Thread") as thread_class:
|
||||
thread_class.return_value.start.side_effect = RuntimeError("thread unavailable")
|
||||
try:
|
||||
DeployWorkbench.run_deploy_plan_async(app)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
self.assertFalse(app.deployment_running)
|
||||
self.assertEqual([button.state for button in app.deploy_buttons], ["normal"])
|
||||
self.assertTrue(any("thread unavailable" in line for line in logs))
|
||||
|
||||
def test_worker_exception_always_queues_deploy_done(self):
|
||||
app = DeployWorkbench.__new__(DeployWorkbench)
|
||||
app.ui_queue = queue.Queue()
|
||||
run_worker = getattr(DeployWorkbench, "_run_deployment_worker", lambda self, plan: None)
|
||||
|
||||
with patch.object(gui_module, "run_steps", side_effect=RuntimeError("unexpected failure")):
|
||||
run_worker(app, [object()])
|
||||
|
||||
events = []
|
||||
while not app.ui_queue.empty():
|
||||
events.append(app.ui_queue.get_nowait())
|
||||
|
||||
self.assertIn(("deploy_done", 1), events)
|
||||
self.assertTrue(any(event == "log" and "unexpected failure" in str(payload) for event, payload in events))
|
||||
|
||||
def test_deploy_done_event_restores_deploy_buttons(self):
|
||||
app = DeployWorkbench.__new__(DeployWorkbench)
|
||||
app.deployment_running = True
|
||||
app.deploy_buttons = [FakeButton()]
|
||||
app.ui_queue = queue.Queue()
|
||||
app.ui_queue.put(("deploy_done", 1))
|
||||
logs = []
|
||||
app._append_log = logs.append
|
||||
app.after = lambda *args, **kwargs: None
|
||||
|
||||
DeployWorkbench._drain_queue(app)
|
||||
|
||||
self.assertFalse(app.deployment_running)
|
||||
self.assertEqual([button.state for button in app.deploy_buttons], ["normal"])
|
||||
self.assertTrue(any("退出码: 1" in line for line in logs))
|
||||
|
||||
def test_top_action_only_exposes_execution(self):
|
||||
self.assertNotIn("plan", gui_module.TEXT)
|
||||
self.assertEqual(gui_module.TEXT["deploy"], "执行部署")
|
||||
|
||||
def test_sidebar_navigation_focuses_logs(self):
|
||||
app = DeployWorkbench.__new__(DeployWorkbench)
|
||||
app.log_text = FakeWidget()
|
||||
app.result_text = FakeWidget()
|
||||
|
||||
app.activate_sidebar_item("运行日志")
|
||||
|
||||
self.assertTrue(app.log_text.focused)
|
||||
self.assertEqual(app.log_text.seen, "end")
|
||||
|
||||
def test_sidebar_navigation_focuses_database_config(self):
|
||||
app = DeployWorkbench.__new__(DeployWorkbench)
|
||||
app.database_widget = FakeWidget()
|
||||
|
||||
app.activate_sidebar_item("数据库")
|
||||
|
||||
self.assertTrue(app.database_widget.focused)
|
||||
|
||||
def test_sidebar_navigation_can_trigger_environment_check(self):
|
||||
app = DeployWorkbench.__new__(DeployWorkbench)
|
||||
calls = []
|
||||
app.check_environment_async = lambda: calls.append("checked")
|
||||
|
||||
app.activate_sidebar_item("环境检测")
|
||||
|
||||
self.assertEqual(calls, ["checked"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
100
tests/test_installer.py
Normal file
100
tests/test_installer.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from deploy_tool.installer import build_install_command, explain_install_failure, format_install_output, recommend_jdk_version
|
||||
from deploy_tool.scanner import scan_project
|
||||
|
||||
|
||||
class InstallerTests(unittest.TestCase):
|
||||
def test_recommends_jdk_8_from_maven_source_property(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
backend = root / "server"
|
||||
(backend / "src" / "main" / "java").mkdir(parents=True)
|
||||
(backend / "pom.xml").write_text(
|
||||
"""
|
||||
<project>
|
||||
<properties>
|
||||
<maven.compiler.source>1.8</maven.compiler.source>
|
||||
<maven.compiler.target>1.8</maven.compiler.target>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>spring-boot-starter-web</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
scan = scan_project(root)
|
||||
|
||||
recommendation = recommend_jdk_version(scan)
|
||||
self.assertEqual(recommendation.version, "8")
|
||||
self.assertIn("maven.compiler.source", recommendation.reason)
|
||||
|
||||
def test_recommends_jdk_17_for_spring_boot_3(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
backend = root / "server"
|
||||
(backend / "src" / "main" / "java").mkdir(parents=True)
|
||||
(backend / "pom.xml").write_text(
|
||||
"""
|
||||
<project>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.5</version>
|
||||
</parent>
|
||||
</project>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
scan = scan_project(root)
|
||||
|
||||
recommendation = recommend_jdk_version(scan)
|
||||
self.assertEqual(recommendation.version, "17")
|
||||
self.assertIn("Spring Boot 3", recommendation.reason)
|
||||
|
||||
def test_defaults_to_jdk_8_without_project_signal(self):
|
||||
recommendation = recommend_jdk_version(None)
|
||||
|
||||
self.assertEqual(recommendation.version, "8")
|
||||
self.assertIn("默认", recommendation.reason)
|
||||
|
||||
def test_builds_jdk_winget_install_command(self):
|
||||
command = build_install_command("JDK", version="8")
|
||||
|
||||
self.assertEqual(command, ["winget", "install", "-e", "--id", "EclipseAdoptium.Temurin.8.JDK"])
|
||||
|
||||
def test_builds_node_winget_install_command(self):
|
||||
command = build_install_command("Node.js")
|
||||
|
||||
self.assertEqual(command, ["winget", "install", "-e", "--id", "OpenJS.NodeJS.LTS"])
|
||||
|
||||
def test_formats_winget_progress_without_garbled_bar(self):
|
||||
raw = "████████████▒▒▒▒▒▒ 63%\r".encode("utf-8")
|
||||
|
||||
self.assertEqual(format_install_output(raw), ["安装进度: 63%"])
|
||||
|
||||
def test_keeps_readable_winget_text(self):
|
||||
raw = "已找到 Eclipse Temurin JDK [EclipseAdoptium.Temurin.8.JDK]\n".encode("utf-8")
|
||||
|
||||
self.assertEqual(format_install_output(raw), ["已找到 Eclipse Temurin JDK [EclipseAdoptium.Temurin.8.JDK]"])
|
||||
|
||||
|
||||
def test_explains_missing_maven_winget_package(self):
|
||||
message = explain_install_failure(
|
||||
"Maven",
|
||||
2316632084,
|
||||
["找不到与输入条件匹配的程序包。"],
|
||||
)
|
||||
|
||||
self.assertIn("winget 当前源找不到 Maven 包", message)
|
||||
self.assertIn("手动下载 Apache Maven", message)
|
||||
self.assertIn("mvn.cmd", message)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
48
tests/test_path_manager.py
Normal file
48
tests/test_path_manager.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from deploy_tool.path_manager import EnvironmentUpdate, build_environment_update, merge_path_entries, apply_user_environment
|
||||
|
||||
|
||||
class FakeRegistry:
|
||||
def __init__(self):
|
||||
self.values = {}
|
||||
self.broadcasted = False
|
||||
|
||||
def get_value(self, name):
|
||||
return self.values.get(name, "")
|
||||
|
||||
def set_value(self, name, value):
|
||||
self.values[name] = value
|
||||
|
||||
def broadcast_change(self):
|
||||
self.broadcasted = True
|
||||
|
||||
|
||||
class PathManagerTests(unittest.TestCase):
|
||||
def test_merge_path_entries_appends_without_duplicates(self):
|
||||
current = r"C:\Windows;C:\Java\bin"
|
||||
merged = merge_path_entries(current, [Path(r"C:\Java\bin"), Path(r"C:\mysql\bin")])
|
||||
|
||||
self.assertEqual(merged, r"C:\Windows;C:\Java\bin;C:\mysql\bin")
|
||||
|
||||
def test_build_environment_update_sets_java_home_from_bin(self):
|
||||
update = build_environment_update("JDK", Path(r"C:\Program Files\Eclipse Adoptium\jdk-8\bin\java.exe"))
|
||||
|
||||
self.assertEqual(update.path_entries, [Path(r"C:\Program Files\Eclipse Adoptium\jdk-8\bin")])
|
||||
self.assertEqual(update.env_vars["JAVA_HOME"], r"C:\Program Files\Eclipse Adoptium\jdk-8")
|
||||
|
||||
def test_apply_user_environment_writes_path_and_env_vars(self):
|
||||
registry = FakeRegistry()
|
||||
registry.values["Path"] = r"C:\Windows"
|
||||
update = EnvironmentUpdate(path_entries=[Path(r"C:\Tools\bin")], env_vars={"JAVA_HOME": r"C:\Java"})
|
||||
|
||||
apply_user_environment(update, registry)
|
||||
|
||||
self.assertEqual(registry.values["Path"], r"C:\Windows;C:\Tools\bin")
|
||||
self.assertEqual(registry.values["JAVA_HOME"], r"C:\Java")
|
||||
self.assertTrue(registry.broadcasted)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
112
tests/test_scanner.py
Normal file
112
tests/test_scanner.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from deploy_tool.scanner import scan_project
|
||||
|
||||
|
||||
class ScannerTests(unittest.TestCase):
|
||||
def test_prunes_generated_and_dependency_directories(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
frontend = root / "web"
|
||||
frontend.mkdir()
|
||||
(frontend / "package.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"dependencies": {"vue": "^3.4.0"},
|
||||
"scripts": {"dev": "vite", "build": "vite build"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
ignored_package = frontend / "node_modules" / "fake" / "package.json"
|
||||
ignored_package.parent.mkdir(parents=True)
|
||||
ignored_package.write_text(
|
||||
json.dumps({"dependencies": {"vue": "^3.4.0"}, "scripts": {"dev": "vite"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
ignored_sql = frontend / "dist" / "bundled.sql"
|
||||
ignored_sql.parent.mkdir()
|
||||
ignored_sql.write_text("select 1;", encoding="utf-8")
|
||||
|
||||
result = scan_project(root)
|
||||
|
||||
self.assertEqual([item.path for item in result.detected_frontends], [frontend])
|
||||
self.assertEqual(result.sql_files, [])
|
||||
|
||||
def test_detects_user_and_admin_frontends_with_ports_and_backend_context(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
backend = root / "server"
|
||||
client = root / "前端" / "client_code"
|
||||
admin = root / "前端" / "manage_code"
|
||||
|
||||
(backend / "src" / "main" / "java").mkdir(parents=True)
|
||||
resources = backend / "src" / "main" / "resources"
|
||||
resources.mkdir(parents=True)
|
||||
(backend / "pom.xml").write_text("<project>spring-boot</project>", encoding="utf-8")
|
||||
(resources / "application.yml").write_text(
|
||||
"server:\n servlet:\n context-path: /movies\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
for frontend, port in ((client, 8082), (admin, 8081)):
|
||||
frontend.mkdir(parents=True)
|
||||
(frontend / "package.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"dependencies": {"vue": "^3.4.0"},
|
||||
"scripts": {"build": "vue-cli-service build", "serve": "vue-cli-service serve"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(frontend / "vue.config.js").write_text(
|
||||
f"module.exports = {{ devServer: {{ port: {port} }} }}", encoding="utf-8"
|
||||
)
|
||||
|
||||
result = scan_project(root)
|
||||
|
||||
self.assertEqual([item.path for item in result.detected_frontends], [client, admin])
|
||||
self.assertEqual([item.metadata["role"] for item in result.detected_frontends], ["user", "admin"])
|
||||
self.assertEqual([item.metadata["port"] for item in result.detected_frontends], ["8082", "8081"])
|
||||
self.assertEqual(result.backend.metadata["context_path"], "/movies")
|
||||
|
||||
def test_detects_spring_boot_vue_and_sql(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
backend = root / "server"
|
||||
frontend = root / "web"
|
||||
sql_dir = root / "db"
|
||||
|
||||
(backend / "src" / "main" / "java").mkdir(parents=True)
|
||||
(backend / "pom.xml").write_text(
|
||||
"<project><dependencies><dependency>spring-boot-starter-web</dependency></dependencies></project>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
frontend.mkdir()
|
||||
(frontend / "package.json").write_text(
|
||||
json.dumps({"dependencies": {"vue": "^3.4.0"}, "scripts": {"build": "vite build"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
sql_dir.mkdir()
|
||||
(sql_dir / "schema.sql").write_text("create table demo(id int);", encoding="utf-8")
|
||||
|
||||
result = scan_project(root)
|
||||
|
||||
self.assertEqual(result.backend.path, backend)
|
||||
self.assertEqual(result.frontend.path, frontend)
|
||||
self.assertEqual(result.sql_files, [sql_dir / "schema.sql"])
|
||||
|
||||
def test_empty_folder_reports_missing_parts(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = scan_project(Path(tmp))
|
||||
|
||||
self.assertIsNone(result.backend)
|
||||
self.assertIsNone(result.frontend)
|
||||
self.assertEqual(result.sql_files, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
37
tests/test_service_console.py
Normal file
37
tests/test_service_console.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from deploy_tool.service_console import main
|
||||
|
||||
|
||||
class ServiceConsoleTests(unittest.TestCase):
|
||||
def test_tees_service_output_to_log_and_removes_pid_file(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
log_path = root / "service.log"
|
||||
pid_path = root / "service.pid"
|
||||
argv = [
|
||||
"service_console.py",
|
||||
"--log",
|
||||
str(log_path),
|
||||
"--pid-file",
|
||||
str(pid_path),
|
||||
"--",
|
||||
sys.executable,
|
||||
"-c",
|
||||
"print('service-ready')",
|
||||
]
|
||||
|
||||
with patch.object(sys, "argv", argv):
|
||||
exit_code = main()
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertIn("service-ready", log_path.read_text(encoding="utf-8"))
|
||||
self.assertFalse(pid_path.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user