2026-07-14 16:30:36 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
2026-10-08 17:58:09 +08:00
|
|
|
|
import hashlib
|
|
|
|
|
|
import os
|
|
|
|
|
|
import shutil
|
2026-07-14 16:30:36 +08:00
|
|
|
|
import subprocess
|
2026-10-08 17:58:09 +08:00
|
|
|
|
import tempfile
|
|
|
|
|
|
import urllib.request
|
|
|
|
|
|
import zipfile
|
2026-07-14 16:30:36 +08:00
|
|
|
|
from dataclasses import dataclass
|
2026-10-08 17:58:09 +08:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import BinaryIO, Callable
|
2026-07-14 16:30:36 +08:00
|
|
|
|
|
|
|
|
|
|
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 = {
|
|
|
|
|
|
"Gradle": "Gradle.Gradle",
|
|
|
|
|
|
"Node.js": "OpenJS.NodeJS.LTS",
|
|
|
|
|
|
"Git": "Git.Git",
|
|
|
|
|
|
"MySQL": "Oracle.MySQL",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 17:58:09 +08:00
|
|
|
|
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"
|
|
|
|
|
|
|
2026-07-14 16:30:36 +08:00
|
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-08 17:58:09 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-14 16:30:36 +08:00
|
|
|
|
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"
|