diff --git a/README.md b/README.md
index 7f401d1..aed0463 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,16 @@
# AutoDeploy
-AutoDeploy 是一个面向 Windows 的 Spring Boot + Vue 本地自动化部署工具。它通过图形界面扫描前后端项目、检查开发环境、生成部署步骤,并依次完成后端构建、前端依赖安装、SQL 导入和服务启动。
+AutoDeploy 是一个面向 Windows 的 Spring Boot / SSM + Vue 本地自动化部署工具。它通过图形界面扫描前后端项目、检查开发环境、生成部署步骤,并依次完成后端构建、前端依赖安装、SQL 导入和服务启动。
## 主要功能
-- 自动识别 Maven/Gradle Spring Boot 后端、一个或多个 Vue/Vite 前端以及 `.sql` 文件。
+- 自动识别 Maven/Gradle Spring Boot 后端、Maven/Gradle SSM WAR 后端、一个或多个 Vue/Vite 前端以及 `.sql` 文件。
- 从 Maven、Gradle 和 Spring Boot 配置中推断 Java 版本,推荐 JDK 8、11、17 或 21。
-- 检测 JDK、Maven、Gradle、Node.js、npm、Vue CLI、MySQL 和 Git。
-- 支持手动指定工具路径,并可将工具目录写入当前用户的 `PATH`;JDK、Maven 和 Gradle 会同步设置对应的环境变量。
-- 通过 `winget` 辅助安装常用开发环境,执行安装前会要求用户确认。
-- 使用 Maven 或 Gradle 构建后端;已有可运行 JAR 时自动跳过重复构建。
+- 检测 JDK、Maven、Tomcat、Gradle、Node.js、npm、Vue CLI、MySQL 和 Git。
+- 支持手动指定工具路径,并可将工具目录写入当前用户的 `PATH`;JDK、Maven、Tomcat 和 Gradle 会同步设置对应的环境变量。
+- Maven 和 Tomcat 从 Apache 官方地址下载并校验后安装到当前用户目录;其他常用开发环境通过 `winget` 辅助安装,执行安装前会要求用户确认。
+- 使用 Maven 或 Gradle 构建后端;已有可运行 JAR/WAR 时自动跳过重复构建。
+- 将传统 SSM 项目的 WAR 发布到 Tomcat 9,并等待 8080 端口就绪。
- 前端缺少 `node_modules` 时自动执行 `npm install`,随后运行 `serve`、`dev` 或 `start` 脚本。
- 可按顺序导入多个 SQL 文件,并启动后端、用户端和管理端。
- 服务启动前检查端口占用并终止旧进程,等待新服务端口就绪后再继续。
@@ -19,7 +20,7 @@ AutoDeploy 是一个面向 Windows 的 Spring Boot + Vue 本地自动化部署
- Windows 10/11
- Python 3.9 或更高版本(需包含 Tkinter,Windows 官方 Python 安装包默认提供)
-- 待部署项目所需的 JDK、Maven/Gradle、Node.js/npm 和 MySQL
+- 待部署项目所需的 JDK、Maven/Gradle、Node.js/npm 和 MySQL;传统 SSM 项目还需要 Tomcat 9
- 可选:`winget`,用于在界面中安装缺失工具
本项目本身只使用 Python 标准库,不需要执行 `pip install`。
@@ -45,6 +46,7 @@ python main.py
| 服务 | 端口 | 地址 |
| --- | ---: | --- |
| Spring Boot 后端 | 8080 | `http://localhost:8080` |
+| SSM / Tomcat 后端 | 8080 | `http://localhost:8080/<项目上下文>` |
| 管理端 | 8081 | `http://localhost:8081` |
| 用户端 | 8082 | `http://localhost:8082` |
@@ -52,7 +54,7 @@ python main.py
## 项目识别规则
-- 后端:查找 `pom.xml`、`build.gradle` 或 `build.gradle.kts`,并结合 Spring Boot 依赖和 `src/main/java` 判断候选项目。
+- 后端:查找 `pom.xml`、`build.gradle` 或 `build.gradle.kts`;Spring Boot 根据 Boot 依赖识别,SSM 根据 WAR 打包、`web.xml`、Spring/MyBatis 依赖识别。
- 前端:查找 `package.json`,结合 Vue 依赖、构建脚本及 `vue.config.js`/`vite.config.*` 判断项目。
- 前端角色:目录名包含 `admin`、`manage`、`后台` 等关键字时识别为管理端;包含 `user`、`client`、`web`、`用户` 等关键字时识别为用户端。
- 启动脚本:依次使用 `serve`、`dev`、`start` 中第一个可用的 npm 脚本。
@@ -62,10 +64,10 @@ python main.py
部署计划会根据扫描结果动态生成,典型顺序为:
-1. 使用 `mvn clean package -DskipTests` 或 Gradle `bootJar` 构建后端。
+1. 使用 `mvn clean package -DskipTests` 或 Gradle `bootJar`/`war` 构建后端。
2. 为尚未安装依赖的前端执行 `npm install`。
3. 使用 MySQL 命令行依次导入 SQL 文件。
-4. 使用 `java -jar` 启动后端,并等待 8080 端口就绪。
+4. Spring Boot 使用 `java -jar` 启动;SSM 将 WAR 发布到 Tomcat 9;随后等待 8080 端口就绪。
5. 使用 npm 启动各前端开发服务器,并等待对应端口就绪。
6. 在运行日志中输出所有服务的访问地址。
@@ -94,7 +96,7 @@ python -m unittest discover -s tests -p "test_*.py"
│ ├── gui.py # Tkinter 工作台
│ ├── scanner.py # 项目扫描与组件识别
│ ├── environment.py # 本机环境检测与工具发现
-│ ├── installer.py # winget 安装命令与 JDK 推荐
+│ ├── installer.py # Maven/Tomcat 下载、winget 安装命令与 JDK 推荐
│ ├── path_manager.py # 用户环境变量管理
│ ├── deployer.py # 部署计划、命令执行与服务管理
│ └── service_console.py # 后端独立控制台与日志转存
diff --git a/deploy_tool/deployer.py b/deploy_tool/deployer.py
index b80ae47..5bedb20 100644
--- a/deploy_tool/deployer.py
+++ b/deploy_tool/deployer.py
@@ -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")
diff --git a/deploy_tool/environment.py b/deploy_tool/environment.py
index 195dd66..bb35483 100644
--- a/deploy_tool/environment.py
+++ b/deploy_tool/environment.py
@@ -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":
diff --git a/deploy_tool/gui.py b/deploy_tool/gui.py
index aa5594c..843aef9 100644
--- a/deploy_tool/gui.py
+++ b/deploy_tool/gui.py
@@ -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:
diff --git a/deploy_tool/installer.py b/deploy_tool/installer.py
index cdbc021..e56ae4f 100644
--- a/deploy_tool/installer.py
+++ b/deploy_tool/installer.py
@@ -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(
diff --git a/deploy_tool/path_manager.py b/deploy_tool/path_manager.py
index 5325e47..397b712 100644
--- a/deploy_tool/path_manager.py
+++ b/deploy_tool/path_manager.py
@@ -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)
diff --git a/deploy_tool/scanner.py b/deploy_tool/scanner.py
index b8dc5bf..b12fb1f 100644
--- a/deploy_tool/scanner.py
+++ b/deploy_tool/scanner.py
@@ -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"]*>.*?", "", 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"\s*([^<\s]+)\s*",
r"\s*([^<\s]+)\s*",
diff --git a/deploy_tool/service_console.py b/deploy_tool/service_console.py
index 2c54151..fd552c1 100644
--- a/deploy_tool/service_console.py
+++ b/deploy_tool/service_console.py
@@ -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
diff --git a/tests/test_deployer.py b/tests/test_deployer.py
index ba2b814..88e0d71 100644
--- a/tests/test_deployer.py
+++ b/tests/test_deployer.py
@@ -182,6 +182,30 @@ class DeployerTests(unittest.TestCase):
self.assertEqual(exit_code, 1)
self.assertEqual(terminated, [27544])
+ def test_tomcat_restart_accepts_port_owned_by_batch_child_process(self):
+ class RunningProcess:
+ pid = 27544
+
+ def poll(self):
+ return None
+
+ with (
+ TemporaryDirectory() as temp,
+ patch.object(deployer_module, "_is_port_open", return_value=True),
+ patch.object(deployer_module, "_find_listening_pids", return_value={31000}),
+ ):
+ exit_code = deployer_module._restart_backend(
+ ["catalina.bat", "run"],
+ Path(temp),
+ lambda _value: None,
+ port=8080,
+ require_service_pid=False,
+ replace_listeners=lambda _port, _output: True,
+ popen=lambda *args, **kwargs: RunningProcess(),
+ )
+
+ self.assertEqual(exit_code, 0)
+
def test_backend_restart_terminates_child_after_startup_timeout(self):
restart_backend = getattr(deployer_module, "_restart_backend", lambda *args, **kwargs: 0)
terminated = []
@@ -496,6 +520,64 @@ class DeployerTests(unittest.TestCase):
self.assertEqual(plan[0].command, [str(maven), "clean", "package", "-DskipTests"])
+ def test_deploys_ssm_war_to_configured_tomcat(self):
+ with TemporaryDirectory() as temp:
+ root = Path(temp)
+ backend = root / "ssm-server"
+ target = backend / "target"
+ tomcat = root / "apache-tomcat" / "bin" / "catalina.bat"
+ target.mkdir(parents=True)
+ tomcat.parent.mkdir(parents=True)
+ tomcat.write_text("@echo off", encoding="utf-8")
+ source_war = target / "movies.war"
+ source_war.write_text("war-content", encoding="utf-8")
+ scan = ScanResult(
+ root=root,
+ backend=ProjectComponent(
+ "SSM", backend, "Maven SSM WAR", {"backend_type": "ssm", "context_path": "/movies"}
+ ),
+ frontend=None,
+ sql_files=[],
+ )
+ config = DeploymentConfig(
+ output_dir=root / "deploy",
+ tool_paths={"Tomcat": tomcat},
+ tool_search_roots=[root / "missing"],
+ )
+
+ plan = build_deployment_plan(scan, config)
+
+ self.assertEqual([step.kind for step in plan], ["backend_build_skip", "ssm_start", "deployment_summary"])
+ start_step = plan[1]
+ self.assertEqual(start_step.command, [str(tomcat.resolve()), "run"])
+ self.assertEqual(start_step.output_path, tomcat.parent.parent / "webapps" / "movies.war")
+ self.assertEqual(start_step.url, "http://localhost:8080/movies")
+
+ calls = []
+
+ def fake_runner(command, cwd, on_output, input_path=None, background=False):
+ calls.append((command, cwd, background))
+ return 0
+
+ self.assertEqual(run_steps(plan, lambda _line: None, process_runner=fake_runner), 0)
+ self.assertEqual((tomcat.parent.parent / "webapps" / "movies.war").read_text(), "war-content")
+ self.assertIn(([str(tomcat.resolve()), "run"], root / "deploy", True), calls)
+
+ def test_ssm_plan_requires_tomcat(self):
+ with TemporaryDirectory() as temp:
+ root = Path(temp)
+ backend = root / "ssm-server"
+ backend.mkdir()
+ scan = ScanResult(
+ root=root,
+ backend=ProjectComponent("SSM", backend, "SSM", {"backend_type": "ssm", "context_path": "/app"}),
+ frontend=None,
+ sql_files=[],
+ )
+
+ with self.assertRaisesRegex(FileNotFoundError, "需要 Tomcat"):
+ build_deployment_plan(scan, DeploymentConfig(tool_search_roots=[root / "missing"]))
+
def test_plan_uses_discovered_mysql_path_for_sql_import(self):
with TemporaryDirectory() as temp:
root = Path(temp)
diff --git a/tests/test_environment.py b/tests/test_environment.py
index 8a3af25..c6db92a 100644
--- a/tests/test_environment.py
+++ b/tests/test_environment.py
@@ -2,7 +2,9 @@ import subprocess
import tempfile
import unittest
from pathlib import Path
+from unittest.mock import patch
+import deploy_tool.environment as environment_module
from deploy_tool.environment import check_environment, load_tool_paths, save_tool_paths
@@ -98,6 +100,18 @@ class EnvironmentTests(unittest.TestCase):
self.assertEqual(loaded, {"MySQL": mysql})
+ def test_tomcat_command_receives_catalina_home(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ catalina = Path(tmp) / "apache-tomcat" / "bin" / "catalina.bat"
+ catalina.parent.mkdir(parents=True)
+ catalina.write_text("@echo off", encoding="utf-8")
+ completed = subprocess.CompletedProcess([str(catalina), "version"], 0, "Tomcat", "")
+
+ with patch("deploy_tool.environment.subprocess.run", return_value=completed) as run:
+ environment_module._run_command([str(catalina), "version"])
+
+ self.assertEqual(run.call_args.kwargs["env"]["CATALINA_HOME"], str(catalina.parent.parent))
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_installer.py b/tests/test_installer.py
index aa4a10b..8614179 100644
--- a/tests/test_installer.py
+++ b/tests/test_installer.py
@@ -1,8 +1,20 @@
+import hashlib
+import io
import tempfile
import unittest
+import zipfile
from pathlib import Path
-from deploy_tool.installer import build_install_command, explain_install_failure, format_install_output, recommend_jdk_version
+from deploy_tool.installer import (
+ MAVEN_ARCHIVE_NAME,
+ TOMCAT_VERSION,
+ install_maven,
+ install_tomcat,
+ build_install_command,
+ explain_install_failure,
+ format_install_output,
+ recommend_jdk_version,
+)
from deploy_tool.scanner import scan_project
@@ -73,6 +85,54 @@ class InstallerTests(unittest.TestCase):
self.assertEqual(command, ["winget", "install", "-e", "--id", "OpenJS.NodeJS.LTS"])
+ def test_maven_is_not_offered_as_an_invalid_winget_package(self):
+ with self.assertRaises(ValueError):
+ build_install_command("Maven")
+
+ def test_installs_maven_from_verified_apache_archive(self):
+ archive_buffer = io.BytesIO()
+ with zipfile.ZipFile(archive_buffer, "w") as archive:
+ archive.writestr("apache-maven-3.10.0/bin/mvn.cmd", "@echo off")
+ archive_bytes = archive_buffer.getvalue()
+ checksum = hashlib.sha512(archive_bytes).hexdigest().encode("ascii")
+
+ def opener(url, timeout):
+ return io.BytesIO(checksum if url.endswith(".sha512") else archive_bytes)
+
+ with tempfile.TemporaryDirectory() as tmp:
+ messages = []
+ executable = install_maven(messages.append, Path(tmp), opener)
+
+ self.assertTrue(executable.exists())
+ self.assertEqual(executable.read_text(), "@echo off")
+ self.assertTrue(any("校验通过" in message for message in messages))
+
+ def test_rejects_maven_archive_with_wrong_checksum(self):
+ def opener(url, timeout):
+ payload = ("0" * 128).encode("ascii") if url.endswith(".sha512") else b"not-a-zip"
+ return io.BytesIO(payload)
+
+ with tempfile.TemporaryDirectory() as tmp:
+ with self.assertRaisesRegex(RuntimeError, "SHA-512"):
+ install_maven(lambda _message: None, Path(tmp), opener)
+ self.assertFalse((Path(tmp) / MAVEN_ARCHIVE_NAME.removesuffix("-bin.zip")).exists())
+
+ def test_installs_tomcat_from_verified_apache_archive(self):
+ archive_buffer = io.BytesIO()
+ with zipfile.ZipFile(archive_buffer, "w") as archive:
+ archive.writestr(f"apache-tomcat-{TOMCAT_VERSION}/bin/catalina.bat", "@echo off")
+ archive_bytes = archive_buffer.getvalue()
+ checksum = hashlib.sha512(archive_bytes).hexdigest().encode("ascii")
+
+ def opener(url, timeout):
+ return io.BytesIO(checksum if url.endswith(".sha512") else archive_bytes)
+
+ with tempfile.TemporaryDirectory() as tmp:
+ executable = install_tomcat(lambda _message: None, Path(tmp), opener)
+
+ self.assertTrue(executable.exists())
+ self.assertEqual(executable.name, "catalina.bat")
+
def test_formats_winget_progress_without_garbled_bar(self):
raw = "████████████▒▒▒▒▒▒ 63%\r".encode("utf-8")
diff --git a/tests/test_path_manager.py b/tests/test_path_manager.py
index 7915dd1..b7d9fa1 100644
--- a/tests/test_path_manager.py
+++ b/tests/test_path_manager.py
@@ -32,6 +32,12 @@ class PathManagerTests(unittest.TestCase):
self.assertEqual(update.path_entries, [Path(r"C:\Program Files\Eclipse Adoptium\jdk-8\bin")])
self.assertEqual(update.env_vars["JAVA_HOME"], r"C:\Program Files\Eclipse Adoptium\jdk-8")
+ def test_build_environment_update_sets_catalina_home_from_bin(self):
+ update = build_environment_update("Tomcat", Path(r"C:\Tools\apache-tomcat-9\bin\catalina.bat"))
+
+ self.assertEqual(update.path_entries, [Path(r"C:\Tools\apache-tomcat-9\bin")])
+ self.assertEqual(update.env_vars["CATALINA_HOME"], r"C:\Tools\apache-tomcat-9")
+
def test_apply_user_environment_writes_path_and_env_vars(self):
registry = FakeRegistry()
registry.values["Path"] = r"C:\Windows"
diff --git a/tests/test_scanner.py b/tests/test_scanner.py
index 3c25970..8e5adb1 100644
--- a/tests/test_scanner.py
+++ b/tests/test_scanner.py
@@ -99,6 +99,37 @@ class ScannerTests(unittest.TestCase):
self.assertEqual(result.frontend.path, frontend)
self.assertEqual(result.sql_files, [sql_dir / "schema.sql"])
+ def test_detects_maven_ssm_war_project_and_context_path(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ backend = root / "ssm-server"
+ (backend / "src" / "main" / "java").mkdir(parents=True)
+ web_inf = backend / "src" / "main" / "webapp" / "WEB-INF"
+ web_inf.mkdir(parents=True)
+ (web_inf / "web.xml").write_text("", encoding="utf-8")
+ (backend / "pom.xml").write_text(
+ """
+
+ movie-system
+ war
+
+ spring-webmvc
+ mybatis
+
+ movies
+
+ """,
+ encoding="utf-8",
+ )
+
+ result = scan_project(root)
+
+ self.assertIsNotNone(result.backend)
+ self.assertEqual(result.backend.kind, "SSM")
+ self.assertEqual(result.backend.metadata["backend_type"], "ssm")
+ self.assertEqual(result.backend.metadata["packaging"], "war")
+ self.assertEqual(result.backend.metadata["context_path"], "/movies")
+
def test_empty_folder_reports_missing_parts(self):
with tempfile.TemporaryDirectory() as tmp:
result = scan_project(Path(tmp))