Add SSM deployment and reliable Maven setup
This commit is contained in:
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -52,17 +53,21 @@ ProcessRunner = Callable[[list[str], Path, OutputHandler, Path | None, bool], in
|
||||
def build_deployment_plan(scan: ScanResult, config: DeploymentConfig) -> list[DeploymentStep]:
|
||||
steps: list[DeploymentStep] = []
|
||||
output_dir = config.output_dir or scan.root / "deploy"
|
||||
backend_is_ssm = bool(
|
||||
scan.backend and (scan.backend.kind == "SSM" or scan.backend.metadata.get("backend_type") == "ssm")
|
||||
)
|
||||
|
||||
if scan.backend:
|
||||
backend_dir = scan.backend.path
|
||||
existing_jar = _find_existing_backend_jar(backend_dir)
|
||||
if existing_jar:
|
||||
existing_artifact = _find_existing_backend_war(backend_dir) if backend_is_ssm else _find_existing_backend_jar(backend_dir)
|
||||
if existing_artifact:
|
||||
artifact_label = "WAR" if backend_is_ssm else "jar"
|
||||
steps.append(
|
||||
DeploymentStep(
|
||||
"跳过后端构建",
|
||||
[],
|
||||
backend_dir,
|
||||
f"检测到已有后端 jar,跳过构建: {existing_jar}",
|
||||
f"检测到已有后端 {artifact_label},跳过构建: {existing_artifact}",
|
||||
kind="backend_build_skip",
|
||||
)
|
||||
)
|
||||
@@ -71,14 +76,15 @@ def build_deployment_plan(scan: ScanResult, config: DeploymentConfig) -> list[De
|
||||
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"]
|
||||
command = ["gradlew.bat", "war" if backend_is_ssm else "bootJar"]
|
||||
elif "gradlew" in build_file_names:
|
||||
command = ["./gradlew", "bootJar"]
|
||||
command = ["./gradlew", "war" if backend_is_ssm else "bootJar"]
|
||||
elif backend_dir.exists():
|
||||
command = [_tool_command(config, "Gradle", "gradle"), "bootJar"]
|
||||
command = [_tool_command(config, "Gradle", "gradle"), "war" if backend_is_ssm else "bootJar"]
|
||||
else:
|
||||
command = [_tool_command(config, "Maven", "mvn"), "clean", "package", "-DskipTests"]
|
||||
steps.append(DeploymentStep("构建后端", command, backend_dir, "打包 Spring Boot 应用", kind="backend_build"))
|
||||
description = "构建 SSM WAR 应用" if backend_is_ssm else "打包 Spring Boot 应用"
|
||||
steps.append(DeploymentStep("构建后端", command, backend_dir, description, kind="backend_build"))
|
||||
|
||||
frontends = scan.detected_frontends
|
||||
for frontend in frontends:
|
||||
@@ -116,20 +122,45 @@ def build_deployment_plan(scan: ScanResult, config: DeploymentConfig) -> list[De
|
||||
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,
|
||||
if backend_is_ssm:
|
||||
if _validate_port(config.backend_port, "后端端口") != 8080:
|
||||
raise ValueError("SSM 外置 Tomcat 当前使用默认端口 8080,请将后端端口设置为 8080")
|
||||
tomcat_command = _required_tool_command(config, "Tomcat", "catalina.bat")
|
||||
tomcat_home = Path(tomcat_command).parent.parent
|
||||
context_name = backend_context.strip("/") or "ROOT"
|
||||
if not re.fullmatch(r"[A-Za-z0-9._-]+", context_name):
|
||||
raise ValueError(f"SSM context path 不能用于 WAR 文件名: {backend_context}")
|
||||
deployed_war = tomcat_home / "webapps" / f"{context_name}.war"
|
||||
steps.append(
|
||||
DeploymentStep(
|
||||
"发布并启动 SSM 后端",
|
||||
[tomcat_command, "run"],
|
||||
output_dir,
|
||||
f"发布 WAR 到 Tomcat: {deployed_war}",
|
||||
kind="ssm_start",
|
||||
source_path=scan.backend.path,
|
||||
output_path=deployed_war,
|
||||
background=True,
|
||||
service_name="SSM 后端",
|
||||
port=8080,
|
||||
url=backend_url,
|
||||
)
|
||||
)
|
||||
else:
|
||||
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] = {}
|
||||
@@ -193,6 +224,17 @@ def _tool_command(config: DeploymentConfig, tool_name: str, fallback: str) -> st
|
||||
return str(discovered) if discovered else fallback
|
||||
|
||||
|
||||
def _required_tool_command(config: DeploymentConfig, tool_name: str, fallback: str) -> str:
|
||||
command = _tool_command(config, tool_name, fallback)
|
||||
path = Path(command)
|
||||
if path.exists():
|
||||
return str(path.resolve())
|
||||
resolved = shutil.which(command)
|
||||
if resolved:
|
||||
return resolved
|
||||
raise FileNotFoundError(f"部署 SSM 项目需要 {tool_name},请先安装或选择其可执行文件")
|
||||
|
||||
|
||||
def _default_npm_command() -> str:
|
||||
return "npm.cmd" if os.name == "nt" else "npm"
|
||||
|
||||
@@ -371,10 +413,12 @@ def _restart_backend(
|
||||
cwd: Path,
|
||||
on_output: OutputHandler,
|
||||
*,
|
||||
port: int | None = None,
|
||||
require_service_pid: bool = True,
|
||||
replace_listeners=None,
|
||||
popen=None,
|
||||
) -> int:
|
||||
port = _extract_server_port(command)
|
||||
port = port if port is not None else _extract_server_port(command)
|
||||
if port is None:
|
||||
raise ValueError("后端启动命令缺少 -Dserver.port 配置")
|
||||
replace_listeners = replace_listeners or _replace_port_listeners
|
||||
@@ -433,7 +477,8 @@ def _restart_backend(
|
||||
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):
|
||||
listening_pids = _find_listening_pids(port)
|
||||
if _is_port_open(port) and (not require_service_pid or service_pid in listening_pids):
|
||||
on_output(f"后端服务启动成功,PID: {service_pid},端口: {port},日志: {log_path}")
|
||||
return 0
|
||||
time.sleep(0.2)
|
||||
@@ -443,6 +488,40 @@ def _restart_backend(
|
||||
return 1
|
||||
|
||||
|
||||
def _restart_ssm_backend(
|
||||
command: list[str],
|
||||
cwd: Path,
|
||||
on_output: OutputHandler,
|
||||
*,
|
||||
port: int,
|
||||
source_war: Path,
|
||||
deployed_war: Path,
|
||||
replace_listeners=None,
|
||||
) -> int:
|
||||
replace_listeners = replace_listeners or _replace_port_listeners
|
||||
if not replace_listeners(port, on_output):
|
||||
on_output(f"端口 {port} 未能在限定时间内释放,SSM 后端未启动。")
|
||||
return 1
|
||||
_deploy_ssm_war(source_war, deployed_war, on_output)
|
||||
return _restart_backend(
|
||||
command,
|
||||
cwd,
|
||||
on_output,
|
||||
port=port,
|
||||
require_service_pid=False,
|
||||
replace_listeners=lambda _port, _output: True,
|
||||
)
|
||||
|
||||
|
||||
def _deploy_ssm_war(source_war: Path, deployed_war: Path, on_output: OutputHandler) -> None:
|
||||
deployed_war.parent.mkdir(parents=True, exist_ok=True)
|
||||
exploded_dir = deployed_war.with_suffix("")
|
||||
if exploded_dir.is_dir():
|
||||
shutil.rmtree(exploded_dir)
|
||||
shutil.copy2(source_war, deployed_war)
|
||||
on_output(f"SSM WAR 已发布: {source_war} -> {deployed_war}")
|
||||
|
||||
|
||||
def _restart_frontend(
|
||||
command: list[str],
|
||||
cwd: Path,
|
||||
@@ -545,6 +624,22 @@ def run_steps(
|
||||
service_name=step.service_name or "前端",
|
||||
log_path=step.output_path,
|
||||
)
|
||||
elif step.kind == "ssm_start":
|
||||
if step.port is None or step.source_path is None or step.output_path is None:
|
||||
raise ValueError(f"{step.label}缺少端口、项目目录或 Tomcat WAR 路径配置")
|
||||
source_war = _find_latest_backend_war(step.source_path)
|
||||
if process_runner is None:
|
||||
return_code = _restart_ssm_backend(
|
||||
resolve_process_command(command),
|
||||
step.cwd,
|
||||
on_output,
|
||||
port=step.port,
|
||||
source_war=source_war,
|
||||
deployed_war=step.output_path,
|
||||
)
|
||||
else:
|
||||
_deploy_ssm_war(source_war, step.output_path, on_output)
|
||||
return_code = runner(command, step.cwd, on_output, step.input_path, step.background)
|
||||
else:
|
||||
return_code = runner(command, step.cwd, on_output, step.input_path, step.background)
|
||||
except Exception as exc:
|
||||
@@ -589,6 +684,23 @@ def _find_existing_backend_jar(backend_dir: Path) -> Path | None:
|
||||
return None
|
||||
|
||||
|
||||
def _find_latest_backend_war(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("*.war") if not path.name.lower().endswith("-sources.war"))
|
||||
if not candidates:
|
||||
raise FileNotFoundError(f"未找到 SSM WAR 产物: {backend_dir / 'target'} 或 {backend_dir / 'build' / 'libs'}")
|
||||
return max(candidates, key=lambda path: (path.stat().st_mtime, path.name))
|
||||
|
||||
|
||||
def _find_existing_backend_war(backend_dir: Path) -> Path | None:
|
||||
try:
|
||||
return _find_latest_backend_war(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")
|
||||
|
||||
@@ -14,6 +14,7 @@ CommandRunner = Callable[[list[str]], subprocess.CompletedProcess[str]]
|
||||
DEFAULT_CHECKS: dict[str, list[str]] = {
|
||||
"JDK": ["java", "-version"],
|
||||
"Maven": ["mvn", "-version"],
|
||||
"Tomcat": ["catalina.bat", "version"],
|
||||
"Gradle": ["gradle", "-version"],
|
||||
"Node.js": ["node", "--version"],
|
||||
"npm": ["npm", "--version"],
|
||||
@@ -25,6 +26,7 @@ DEFAULT_CHECKS: dict[str, list[str]] = {
|
||||
EXECUTABLE_NAMES: dict[str, list[str]] = {
|
||||
"JDK": ["java.exe", "java"],
|
||||
"Maven": ["mvn.cmd", "mvn.bat", "mvn"],
|
||||
"Tomcat": ["catalina.bat", "catalina.sh"],
|
||||
"Gradle": ["gradle.bat", "gradle"],
|
||||
"Node.js": ["node.exe", "node"],
|
||||
"npm": ["npm.cmd", "npm"],
|
||||
@@ -151,7 +153,11 @@ def _try_command(name: str, command: list[str], source: str, runner: CommandRunn
|
||||
|
||||
|
||||
def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(command, capture_output=True, text=True, timeout=6, shell=False)
|
||||
environment = os.environ.copy()
|
||||
executable = Path(command[0])
|
||||
if executable.name.lower() in {"catalina.bat", "catalina.sh"} and executable.exists():
|
||||
environment["CATALINA_HOME"] = str(executable.parent.parent)
|
||||
return subprocess.run(command, capture_output=True, text=True, timeout=6, shell=False, env=environment)
|
||||
|
||||
|
||||
def _combine_output(completed: subprocess.CompletedProcess[str]) -> str:
|
||||
@@ -175,6 +181,8 @@ def _direct_candidates(name: str, root: Path) -> list[Path]:
|
||||
return [root / "nodejs" / "npm.cmd", root / "Programs" / "nodejs" / "npm.cmd"]
|
||||
if name == "Maven":
|
||||
return _glob(root, "apache-maven*/bin/mvn.cmd")
|
||||
if name == "Tomcat":
|
||||
return _glob(root, "apache-tomcat*/bin/catalina.bat")
|
||||
if name == "Gradle":
|
||||
return _glob(root, "gradle*/bin/gradle.bat")
|
||||
if name == "Git":
|
||||
|
||||
@@ -8,13 +8,21 @@ 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 .installer import (
|
||||
MAVEN_VERSION,
|
||||
TOMCAT_VERSION,
|
||||
build_install_command,
|
||||
install_maven,
|
||||
install_tomcat,
|
||||
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",
|
||||
"title": "Spring Boot / SSM + Vue 自动化部署工具",
|
||||
"workbench": "\u90e8\u7f72\u5de5\u4f5c\u53f0",
|
||||
"select_dir": "\u9009\u62e9\u76ee\u5f55",
|
||||
"scan": "\u626b\u63cf\u9879\u76ee",
|
||||
@@ -54,7 +62,7 @@ class DeployWorkbench(tk.Tk):
|
||||
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")
|
||||
for name in ("MySQL", "JDK", "Maven", "Tomcat", "Gradle", "Node.js", "npm", "Git")
|
||||
}
|
||||
|
||||
self.scan_result: ScanResult | None = None
|
||||
@@ -124,7 +132,7 @@ class DeployWorkbench(tk.Tk):
|
||||
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)
|
||||
ttk.Label(parent, text="Spring Boot / SSM + 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)
|
||||
@@ -237,7 +245,7 @@ class DeployWorkbench(tk.Tk):
|
||||
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"):
|
||||
for name in ("Maven", "Tomcat", "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)
|
||||
@@ -347,6 +355,12 @@ class DeployWorkbench(tk.Tk):
|
||||
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:
|
||||
if tool_name == "Maven":
|
||||
self.install_maven_async()
|
||||
return
|
||||
if tool_name == "Tomcat":
|
||||
self.install_tomcat_async()
|
||||
return
|
||||
try:
|
||||
command = build_install_command(tool_name)
|
||||
except ValueError as exc:
|
||||
@@ -365,6 +379,42 @@ class DeployWorkbench(tk.Tk):
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def install_maven_async(self) -> None:
|
||||
if not messagebox.askyesno(
|
||||
"确认安装 Maven",
|
||||
f"将从 Apache 官方地址下载 Maven {MAVEN_VERSION},校验安装包后解压到当前用户目录。\n\n"
|
||||
"安装完成后会自动保存 mvn.cmd 路径。是否继续?",
|
||||
):
|
||||
return
|
||||
self._append_log(f"开始安装 Maven {MAVEN_VERSION}...")
|
||||
threading.Thread(target=self._install_maven_worker, daemon=True).start()
|
||||
|
||||
def _install_maven_worker(self) -> None:
|
||||
try:
|
||||
executable = install_maven(self._thread_log)
|
||||
except Exception as exc:
|
||||
self.ui_queue.put(("maven_install_done", (None, str(exc))))
|
||||
return
|
||||
self.ui_queue.put(("maven_install_done", (executable, "")))
|
||||
|
||||
def install_tomcat_async(self) -> None:
|
||||
if not messagebox.askyesno(
|
||||
"确认安装 Tomcat",
|
||||
f"将从 Apache 官方地址下载 Tomcat {TOMCAT_VERSION},校验安装包后解压到当前用户目录。\n\n"
|
||||
"Tomcat 9 适用于常见的 Java 8 / javax.servlet SSM 项目。是否继续?",
|
||||
):
|
||||
return
|
||||
self._append_log(f"开始安装 Tomcat {TOMCAT_VERSION}...")
|
||||
threading.Thread(target=self._install_tomcat_worker, daemon=True).start()
|
||||
|
||||
def _install_tomcat_worker(self) -> None:
|
||||
try:
|
||||
executable = install_tomcat(self._thread_log)
|
||||
except Exception as exc:
|
||||
self.ui_queue.put(("tomcat_install_done", (None, str(exc))))
|
||||
return
|
||||
self.ui_queue.put(("tomcat_install_done", (executable, "")))
|
||||
|
||||
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
|
||||
@@ -539,6 +589,28 @@ class DeployWorkbench(tk.Tk):
|
||||
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))
|
||||
elif event == "maven_install_done":
|
||||
executable, error = payload # type: ignore[misc]
|
||||
if error:
|
||||
self._append_log(f"Maven 安装失败: {error}")
|
||||
messagebox.showerror("Maven 安装失败", str(error))
|
||||
continue
|
||||
self.tool_path_vars["Maven"].set(str(executable))
|
||||
self.save_tool_path_settings(silent=True)
|
||||
self._append_log(f"Maven 路径已保存: {executable}")
|
||||
self.check_environment_async()
|
||||
self.after(1800, lambda: self._prompt_add_tool_after_install("Maven"))
|
||||
elif event == "tomcat_install_done":
|
||||
executable, error = payload # type: ignore[misc]
|
||||
if error:
|
||||
self._append_log(f"Tomcat 安装失败: {error}")
|
||||
messagebox.showerror("Tomcat 安装失败", str(error))
|
||||
continue
|
||||
self.tool_path_vars["Tomcat"].set(str(executable))
|
||||
self.save_tool_path_settings(silent=True)
|
||||
self._append_log(f"Tomcat 路径已保存: {executable}")
|
||||
self.check_environment_async()
|
||||
self.after(1800, lambda: self._prompt_add_tool_after_install("Tomcat"))
|
||||
self.after(120, self._drain_queue)
|
||||
|
||||
def _prompt_add_tool_after_install(self, tool_name: str) -> None:
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import BinaryIO, Callable
|
||||
|
||||
from .scanner import ScanResult
|
||||
|
||||
@@ -15,13 +23,19 @@ JDK_PACKAGES = {
|
||||
}
|
||||
|
||||
WINGET_PACKAGES = {
|
||||
"Maven": "Apache.Maven",
|
||||
"Gradle": "Gradle.Gradle",
|
||||
"Node.js": "OpenJS.NodeJS.LTS",
|
||||
"Git": "Git.Git",
|
||||
"MySQL": "Oracle.MySQL",
|
||||
}
|
||||
|
||||
MAVEN_VERSION = "3.10.0"
|
||||
MAVEN_ARCHIVE_NAME = f"apache-maven-{MAVEN_VERSION}-bin.zip"
|
||||
MAVEN_DOWNLOAD_BASE = f"https://dlcdn.apache.org/maven/maven-3/{MAVEN_VERSION}/binaries"
|
||||
TOMCAT_VERSION = "9.0.122"
|
||||
TOMCAT_ARCHIVE_NAME = f"apache-tomcat-{TOMCAT_VERSION}-windows-x64.zip"
|
||||
TOMCAT_DOWNLOAD_BASE = f"https://dlcdn.apache.org/tomcat/tomcat-9/v{TOMCAT_VERSION}/bin"
|
||||
|
||||
ANSI_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||
PERCENT_PATTERN = re.compile(r"(\d{1,3})\s*%")
|
||||
PROGRESS_CHARS = "█▓▒░▏▎▍▌▋▊▉■□▪▫▬─━═|/-\\"
|
||||
@@ -65,6 +79,153 @@ def build_install_command(tool_name: str, version: str | None = None) -> list[st
|
||||
return ["winget", "install", "-e", "--id", package_id]
|
||||
|
||||
|
||||
def default_maven_install_root() -> Path:
|
||||
local_app_data = os.environ.get("LOCALAPPDATA", "").strip()
|
||||
base = Path(local_app_data) if local_app_data else Path.home() / ".autodeploy"
|
||||
return base / "AutoDeploy" / "tools"
|
||||
|
||||
|
||||
def install_maven(
|
||||
on_output: Callable[[str], None],
|
||||
install_root: Path | None = None,
|
||||
opener: Callable[..., BinaryIO] | None = None,
|
||||
) -> Path:
|
||||
root = Path(install_root) if install_root is not None else default_maven_install_root()
|
||||
archive_url = f"{MAVEN_DOWNLOAD_BASE}/{MAVEN_ARCHIVE_NAME}"
|
||||
return _install_verified_apache_archive(
|
||||
product_name="Maven",
|
||||
version=MAVEN_VERSION,
|
||||
archive_name=MAVEN_ARCHIVE_NAME,
|
||||
archive_url=archive_url,
|
||||
extracted_dir_name=f"apache-maven-{MAVEN_VERSION}",
|
||||
executable_relative=Path("bin") / "mvn.cmd",
|
||||
on_output=on_output,
|
||||
install_root=root,
|
||||
opener=opener,
|
||||
)
|
||||
|
||||
|
||||
def install_tomcat(
|
||||
on_output: Callable[[str], None],
|
||||
install_root: Path | None = None,
|
||||
opener: Callable[..., BinaryIO] | None = None,
|
||||
) -> Path:
|
||||
root = Path(install_root) if install_root is not None else default_maven_install_root()
|
||||
archive_url = f"{TOMCAT_DOWNLOAD_BASE}/{TOMCAT_ARCHIVE_NAME}"
|
||||
return _install_verified_apache_archive(
|
||||
product_name="Tomcat",
|
||||
version=TOMCAT_VERSION,
|
||||
archive_name=TOMCAT_ARCHIVE_NAME,
|
||||
archive_url=archive_url,
|
||||
extracted_dir_name=f"apache-tomcat-{TOMCAT_VERSION}",
|
||||
executable_relative=Path("bin") / "catalina.bat",
|
||||
on_output=on_output,
|
||||
install_root=root,
|
||||
opener=opener,
|
||||
)
|
||||
|
||||
|
||||
def _install_verified_apache_archive(
|
||||
*,
|
||||
product_name: str,
|
||||
version: str,
|
||||
archive_name: str,
|
||||
archive_url: str,
|
||||
extracted_dir_name: str,
|
||||
executable_relative: Path,
|
||||
on_output: Callable[[str], None],
|
||||
install_root: Path,
|
||||
opener: Callable[..., BinaryIO] | None,
|
||||
) -> Path:
|
||||
product_home = install_root / extracted_dir_name
|
||||
executable = product_home / executable_relative
|
||||
if executable.exists():
|
||||
on_output(f"{product_name} {version} 已安装: {executable}")
|
||||
return executable
|
||||
|
||||
open_url = opener or urllib.request.urlopen
|
||||
checksum_url = f"{archive_url}.sha512"
|
||||
install_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=f"autodeploy-{product_name.lower()}-") as temporary:
|
||||
temp_dir = Path(temporary)
|
||||
archive_path = temp_dir / archive_name
|
||||
|
||||
on_output(f"正在从 Apache 官方地址下载 {product_name} {version}...")
|
||||
_download_to_file(archive_url, archive_path, open_url, on_output)
|
||||
expected_checksum = _download_checksum(checksum_url, open_url)
|
||||
actual_checksum = _sha512(archive_path)
|
||||
if actual_checksum.lower() != expected_checksum.lower():
|
||||
raise RuntimeError(f"{product_name} 安装包 SHA-512 校验失败,已停止安装。")
|
||||
on_output(f"{product_name} 安装包校验通过,正在解压...")
|
||||
|
||||
extract_root = temp_dir / "extracted"
|
||||
_safe_extract_zip(archive_path, extract_root)
|
||||
extracted_home = extract_root / extracted_dir_name
|
||||
extracted_executable = extracted_home / executable_relative
|
||||
if not extracted_executable.exists():
|
||||
raise RuntimeError(f"{product_name} 安装包结构异常,未找到 {executable_relative}。")
|
||||
shutil.copytree(extracted_home, product_home, dirs_exist_ok=True)
|
||||
|
||||
if not executable.exists():
|
||||
raise RuntimeError(f"{product_name} 解压完成,但未找到 {executable_relative}。")
|
||||
on_output(f"{product_name} {version} 安装完成: {executable}")
|
||||
return executable
|
||||
|
||||
|
||||
def _download_to_file(
|
||||
url: str,
|
||||
destination: Path,
|
||||
opener: Callable[..., BinaryIO],
|
||||
on_output: Callable[[str], None],
|
||||
) -> None:
|
||||
with opener(url, timeout=60) as response, destination.open("wb") as output:
|
||||
headers = getattr(response, "headers", {})
|
||||
total_text = headers.get("Content-Length", "") if hasattr(headers, "get") else ""
|
||||
total = int(total_text) if str(total_text).isdigit() else 0
|
||||
downloaded = 0
|
||||
last_percent = -10
|
||||
while True:
|
||||
chunk = response.read(1024 * 256)
|
||||
if not chunk:
|
||||
break
|
||||
output.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if total:
|
||||
percent = min(downloaded * 100 // total, 100)
|
||||
if percent >= last_percent + 10 or percent == 100:
|
||||
on_output(f"下载进度: {percent}%")
|
||||
last_percent = percent
|
||||
|
||||
|
||||
def _download_checksum(url: str, opener: Callable[..., BinaryIO]) -> str:
|
||||
with opener(url, timeout=30) as response:
|
||||
text = response.read().decode("ascii", errors="strict").strip()
|
||||
checksum = text.split()[0] if text else ""
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{128}", checksum):
|
||||
raise RuntimeError("Apache Maven 校验文件格式无效。")
|
||||
return checksum
|
||||
|
||||
|
||||
def _sha512(path: Path) -> str:
|
||||
digest = hashlib.sha512()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _safe_extract_zip(archive_path: Path, destination: Path) -> None:
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
destination_resolved = destination.resolve()
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
for member in archive.infolist():
|
||||
target = (destination / member.filename).resolve()
|
||||
if target != destination_resolved and destination_resolved not in target.parents:
|
||||
raise RuntimeError("Maven 安装包包含不安全的文件路径。")
|
||||
archive.extractall(destination)
|
||||
|
||||
|
||||
def run_install_command(command: list[str], on_output) -> int:
|
||||
output_lines: list[str] = []
|
||||
process = subprocess.Popen(
|
||||
|
||||
@@ -79,6 +79,8 @@ def build_environment_update(tool_name: str, executable: Path) -> EnvironmentUpd
|
||||
env_vars["JAVA_HOME"] = str(bin_dir.parent)
|
||||
elif tool_name == "Maven":
|
||||
env_vars["MAVEN_HOME"] = str(bin_dir.parent)
|
||||
elif tool_name == "Tomcat":
|
||||
env_vars["CATALINA_HOME"] = str(bin_dir.parent)
|
||||
elif tool_name == "Gradle":
|
||||
env_vars["GRADLE_HOME"] = str(bin_dir.parent)
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ def scan_project(root: str | Path) -> ScanResult:
|
||||
|
||||
|
||||
def _find_backend(root: Path) -> ProjectComponent | None:
|
||||
candidates: list[tuple[int, Path, str]] = []
|
||||
candidates: list[tuple[int, Path, str, str, dict[str, str]]] = []
|
||||
for build_file in _walk_project_files(root, names={"pom.xml", "build.gradle", "build.gradle.kts"}):
|
||||
project_dir = build_file.parent
|
||||
score = 1
|
||||
@@ -58,9 +58,29 @@ def _find_backend(root: Path) -> ProjectComponent | None:
|
||||
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:
|
||||
is_spring_boot = "spring-boot" in text or "org.springframework.boot" in text
|
||||
web_xml = project_dir / "src" / "main" / "webapp" / "WEB-INF" / "web.xml"
|
||||
packaging = metadata.get("packaging", "")
|
||||
is_ssm = not is_spring_boot and (
|
||||
packaging == "war"
|
||||
or web_xml.exists()
|
||||
or ("spring" in text and "mybatis" in text)
|
||||
)
|
||||
if not is_spring_boot and not is_ssm:
|
||||
continue
|
||||
if is_spring_boot:
|
||||
score += 3
|
||||
detail_parts.append("Spring Boot dependency")
|
||||
kind = "Spring Boot"
|
||||
metadata["backend_type"] = "spring_boot"
|
||||
else:
|
||||
score += 3
|
||||
kind = "SSM"
|
||||
metadata["backend_type"] = "ssm"
|
||||
metadata["packaging"] = "war"
|
||||
context_name = metadata.get("final_name") or metadata.get("artifact_id") or project_dir.name
|
||||
metadata.setdefault("context_path", f"/{context_name}")
|
||||
detail_parts.append("SSM WAR application")
|
||||
if java_version := metadata.get("java_version"):
|
||||
detail_parts.append(f"Java {java_version}")
|
||||
if boot_version := metadata.get("spring_boot_version"):
|
||||
@@ -68,14 +88,14 @@ def _find_backend(root: Path) -> ProjectComponent | None:
|
||||
if (project_dir / "src" / "main" / "java").exists():
|
||||
score += 2
|
||||
detail_parts.append("src/main/java")
|
||||
candidates.append((score, project_dir, ", ".join(detail_parts), metadata))
|
||||
candidates.append((score, project_dir, kind, ", ".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]
|
||||
score, path, kind, 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)
|
||||
return ProjectComponent(kind, path, detail, metadata)
|
||||
|
||||
|
||||
def _find_frontends(root: Path) -> list[ProjectComponent]:
|
||||
@@ -188,6 +208,11 @@ def _load_json(path: Path) -> dict:
|
||||
|
||||
def _extract_backend_metadata(text: str) -> dict[str, str]:
|
||||
metadata: dict[str, str] = {}
|
||||
project_text = re.sub(r"<parent\b[^>]*>.*?</parent>", "", text, flags=re.IGNORECASE | re.DOTALL)
|
||||
for key, tag in (("packaging", "packaging"), ("artifact_id", "artifactId"), ("final_name", "finalName")):
|
||||
match = re.search(rf"<{tag}>\s*([^<\s]+)\s*</{tag}>", project_text, re.IGNORECASE)
|
||||
if match:
|
||||
metadata[key] = match.group(1).strip()
|
||||
java_patterns = [
|
||||
r"<maven\.compiler\.source>\s*([^<\s]+)\s*</maven\.compiler\.source>",
|
||||
r"<maven\.compiler\.target>\s*([^<\s]+)\s*</maven\.compiler\.target>",
|
||||
|
||||
@@ -35,6 +35,10 @@ def main() -> int:
|
||||
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
encoding = locale.getpreferredencoding(False) or "utf-8"
|
||||
environment = os.environ.copy()
|
||||
executable = Path(command[0])
|
||||
if executable.name.lower() in {"catalina.bat", "catalina.sh"} and executable.exists():
|
||||
environment["CATALINA_HOME"] = str(executable.parent.parent)
|
||||
with log_path.open("a", encoding="utf-8") as log_file:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
@@ -46,6 +50,7 @@ def main() -> int:
|
||||
errors="replace",
|
||||
bufsize=1,
|
||||
shell=False,
|
||||
env=environment,
|
||||
)
|
||||
pid_path.write_text(str(process.pid), encoding="ascii")
|
||||
assert process.stdout is not None
|
||||
|
||||
Reference in New Issue
Block a user