Initial commit: add AutoDeploy project
This commit is contained in:
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())
|
||||
Reference in New Issue
Block a user