Initial commit: add AutoDeploy project
This commit is contained in:
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 "项目配置"
|
||||
Reference in New Issue
Block a user