#!/usr/bin/env python3 import argparse import hashlib import json import os import pathlib import shutil import subprocess import sys import time from typing import Any from script_common import resolve_command, SdtResult # type: ignore StepResult = dict[str, Any] def run_step(command: str, args: list[str], cwd: str) -> StepResult: resolved = str(resolve_command(command)) if shutil.which(resolved) is None and not pathlib.Path(resolved).exists(): return { "command": resolved, "args": args, "cwd": cwd, "exit_code": 127, "elapsed_seconds": 0.0, "status": "failed", "failure_reason": f"command_not_found:{resolved}", } started = time.time() proc = subprocess.run([resolved, *args], cwd=cwd, check=False) elapsed = round(time.time() - started, 3) return { "command": resolved, "args": args, "cwd": cwd, "exit_code": proc.returncode, "elapsed_seconds": elapsed, "status": "ok" if proc.returncode == 0 else "failed", "failure_reason": None if proc.returncode == 0 else f"non_zero_exit:{proc.returncode}", } def resolve_python_executable() -> str: candidates = ["py", "python"] if os.name == "nt" else ["python3", "python"] for c in candidates: if shutil.which(c): return c return "python" def parse_common(parser: argparse.ArgumentParser) -> None: _ = parser.add_argument("--project-root", required=True) _ = parser.add_argument("--working-dir", default=".") _ = parser.add_argument("--json", action="store_true") def resolve_cwd(project_root: str, working_dir: str) -> str: return os.path.abspath(os.path.join(project_root, working_dir)) EXCLUDED_SCAN_DIRS = {".git", "node_modules", "bin", "obj", ".venv", "venv", ".sdt", "dist", "build"} def discover_dotnet_target(project_root: str, cwd: str) -> str | None: # Prefer local solution first (.slnx, then .sln), then csproj, then bounded scan from project root. local_slnx = sorted(pathlib.Path(cwd).glob("*.slnx")) if len(local_slnx) == 1: return str(local_slnx[0]) local_sln = sorted(pathlib.Path(cwd).glob("*.sln")) if len(local_sln) == 1: return str(local_sln[0]) local_csproj = sorted(pathlib.Path(cwd).glob("*.csproj")) if len(local_csproj) == 1: return str(local_csproj[0]) slnx_hits = bounded_find_files(project_root, ".slnx", max_depth=4) if len(slnx_hits) == 1: return slnx_hits[0] sln_hits = bounded_find_files(project_root, ".sln", max_depth=4) if len(sln_hits) == 1: return sln_hits[0] csproj_hits = bounded_find_files(project_root, ".csproj", max_depth=4) if len(csproj_hits) == 1: return csproj_hits[0] return None def bounded_find_files(root: str, extension: str, max_depth: int) -> list[str]: root_path = pathlib.Path(root).resolve() results: list[str] = [] for current_root, dirs, files in os.walk(root_path): rel = pathlib.Path(current_root).resolve().relative_to(root_path) depth = len(rel.parts) dirs[:] = [d for d in dirs if d not in EXCLUDED_SCAN_DIRS] if depth > max_depth: dirs[:] = [] continue for name in files: if name.lower().endswith(extension.lower()): results.append(str(pathlib.Path(current_root) / name)) return sorted(results) def run_dotnet_action(project_root: str, working_dir: str, verb: str) -> tuple[int, StepResult]: cwd = resolve_cwd(project_root, working_dir) target = discover_dotnet_target(project_root, cwd) if not target: return 0, { "command": "dotnet", "args": [verb], "cwd": cwd, "exit_code": 0, "elapsed_seconds": 0.0, "status": "skipped", "failure_reason": None, "skip_reason": "not_applicable_no_dotnet_target", "message": "No .slnx/.sln/.csproj found for this step. Skipping dotnet action.", } args = [verb, target] step = run_step("dotnet", args, cwd) step["resolved_target"] = target # Explicitly ensure the first return value is an integer and narrow the step result exit_val = step.get("exit_code", 1) exit_code = int(exit_val) if isinstance(exit_val, (int, float, str)) and str(exit_val).isdigit() else 1 return exit_code, step def _deps_hash(app_root: str) -> str: h = hashlib.sha256() for name in ("package.json", "package-lock.json"): p = pathlib.Path(app_root) / name if p.exists(): h.update(p.read_bytes()) return h.hexdigest() def ensure_npm_dependencies(app_root: str) -> dict[str, Any]: package_json = pathlib.Path(app_root) / "package.json" if not package_json.exists(): return {"installed": False, "reason": "not_applicable"} node_modules = pathlib.Path(app_root) / "node_modules" deps_hash_file = node_modules / ".sdt-deps.sha256" expected = _deps_hash(app_root) should_install = not node_modules.exists() if not should_install: if not deps_hash_file.exists(): should_install = True else: current = deps_hash_file.read_text(encoding="utf-8").strip() should_install = current != expected if not should_install: return {"installed": False, "reason": "deps_unchanged"} lock_exists = (pathlib.Path(app_root) / "package-lock.json").exists() install_args = ["ci", "--no-audit", "--fund=false"] if lock_exists else ["install", "--no-audit", "--fund=false"] install_step = run_step("npm", install_args, app_root) if install_step["exit_code"] != 0: if lock_exists and install_args[0] == "ci": fallback = run_step("npm", ["install", "--no-audit", "--fund=false"], app_root) if fallback["exit_code"] != 0: fallback["failure_reason"] = "deps_install_failed_after_ci_fallback" return {"installed": True, "reason": "install_failed", "step": fallback} install_step = fallback else: return {"installed": True, "reason": "install_failed", "step": install_step} node_modules.mkdir(parents=True, exist_ok=True) deps_hash_file.write_text(expected, encoding="utf-8") return {"installed": True, "reason": "installed", "step": install_step} def read_package_json(cwd: str) -> dict[str, Any] | None: package_json = pathlib.Path(cwd) / "package.json" if not package_json.exists(): return None try: return json.loads(package_json.read_text(encoding="utf-8")) except Exception: return None def has_npm_script(cwd: str, script_name: str) -> bool: data = read_package_json(cwd) if not isinstance(data, dict): return False scripts = data.get("scripts") if not isinstance(scripts, dict): return False return script_name in scripts and isinstance(scripts.get(script_name), str) def action_dotnet_build(args: argparse.Namespace) -> tuple[int, StepResult]: return run_dotnet_action(args.project_root, args.working_dir, "build") def action_dotnet_restore(args: argparse.Namespace) -> tuple[int, StepResult]: return run_dotnet_action(args.project_root, args.working_dir, "restore") def action_dotnet_test(args: argparse.Namespace) -> tuple[int, StepResult]: return run_dotnet_action(args.project_root, args.working_dir, "test") def action_dotnet_publish(args: argparse.Namespace) -> tuple[int, StepResult]: return run_dotnet_action(args.project_root, args.working_dir, "publish") def action_npm_install(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) if not (pathlib.Path(cwd) / "package.json").exists(): return 0, { "command": "npm", "args": ["install"], "cwd": cwd, "exit_code": 0, "elapsed_seconds": 0.0, "status": "skipped", "failure_reason": None, "skip_reason": "not_applicable_no_package_json", } step = run_step("npm", ["install"], cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def action_npm_ci(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) if not (pathlib.Path(cwd) / "package.json").exists(): return 0, { "command": "npm", "args": ["ci"], "cwd": cwd, "exit_code": 0, "elapsed_seconds": 0.0, "status": "skipped", "failure_reason": None, "skip_reason": "not_applicable_no_package_json", } step = run_step("npm", ["ci"], cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def action_npm_build(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) if not (pathlib.Path(cwd) / "package.json").exists(): return 0, { "command": "npm", "args": ["run", "build"], "cwd": cwd, "exit_code": 0, "elapsed_seconds": 0.0, "status": "skipped", "failure_reason": None, "skip_reason": "not_applicable_no_package_json", } if not has_npm_script(cwd, "build"): return 0, { "command": "npm", "args": ["run", "build"], "cwd": cwd, "exit_code": 0, "elapsed_seconds": 0.0, "status": "skipped", "failure_reason": None, "skip_reason": "not_applicable_missing_build_script", } deps = ensure_npm_dependencies(cwd) if deps.get("reason") == "not_applicable": return 0, { "command": "npm", "args": ["run", "build"], "cwd": cwd, "exit_code": 0, "elapsed_seconds": 0.0, "status": "skipped", "failure_reason": None, "skip_reason": "not_applicable_no_package_json", } if deps.get("reason") == "install_failed": step = deps["step"] step["failure_reason"] = "deps_install_failed" return int(step["exit_code"]), step step = run_step("npm", ["run", "build"], cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def action_npm_test(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) if not (pathlib.Path(cwd) / "package.json").exists(): return 0, { "command": "npm", "args": ["test"], "cwd": cwd, "exit_code": 0, "elapsed_seconds": 0.0, "status": "skipped", "failure_reason": None, "skip_reason": "not_applicable_no_package_json", } if not has_npm_script(cwd, "test"): return 0, { "command": "npm", "args": ["test"], "cwd": cwd, "exit_code": 0, "elapsed_seconds": 0.0, "status": "skipped", "failure_reason": None, "skip_reason": "not_applicable_missing_test_script", } deps = ensure_npm_dependencies(cwd) if deps.get("reason") == "not_applicable": return 0, { "command": "npm", "args": ["test"], "cwd": cwd, "exit_code": 0, "elapsed_seconds": 0.0, "status": "skipped", "failure_reason": None, "skip_reason": "not_applicable_no_package_json", } if deps.get("reason") == "install_failed": step = deps["step"] step["failure_reason"] = "deps_install_failed" return int(step["exit_code"]), step step = run_step("npm", ["test"], cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def action_npm_audit(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) if not (pathlib.Path(cwd) / "package.json").exists(): return 0, { "command": "npm", "args": ["audit"], "cwd": cwd, "exit_code": 0, "elapsed_seconds": 0.0, "status": "skipped", "failure_reason": None, "skip_reason": "not_applicable_no_package_json", } step = run_step("npm", ["audit"], cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def action_python_venv_create(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, ".") venv_dir = str(args.venv_dir) if hasattr(args, "venv_dir") else ".venv" step = run_step(resolve_python_executable(), ["-m", "venv", venv_dir], cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def action_python_pip_install(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, ".") req = str(args.requirements) step = run_step(resolve_python_executable(), ["-m", "pip", "install", "-r", req], cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def action_python_pip_sync(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, ".") req = str(args.requirements) step = run_step(resolve_python_executable(), ["-m", "pip", "install", "-r", req], cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def action_python_pytest(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) step = run_step(resolve_python_executable(), ["-m", "pytest"], cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def action_cargo_build(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) if not (pathlib.Path(cwd) / "Cargo.toml").exists(): return 0, { "command": "cargo", "args": ["build"], "cwd": cwd, "exit_code": 0, "elapsed_seconds": 0.0, "status": "skipped", "failure_reason": None, "skip_reason": "not_applicable_no_cargo_toml", } step = run_step("cargo", ["build"], cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def action_cargo_test(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) if not (pathlib.Path(cwd) / "Cargo.toml").exists(): return 0, { "command": "cargo", "args": ["test"], "cwd": cwd, "exit_code": 0, "elapsed_seconds": 0.0, "status": "skipped", "failure_reason": None, "skip_reason": "not_applicable_no_cargo_toml", } step = run_step("cargo", ["test"], cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def action_tauri_build(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) tauri_conf = pathlib.Path(cwd) / "src-tauri" / "tauri.conf.json" if not tauri_conf.exists(): tauri_conf = pathlib.Path(cwd) / "tauri.conf.json" if not tauri_conf.exists() or not (pathlib.Path(cwd) / "package.json").exists(): return 0, { "command": "npm", "args": ["run", "tauri", "build"], "cwd": cwd, "exit_code": 0, "elapsed_seconds": 0.0, "status": "skipped", "failure_reason": None, "skip_reason": "not_applicable_no_tauri_project", } deps = ensure_npm_dependencies(cwd) if deps.get("reason") == "install_failed": step = deps["step"] step["failure_reason"] = "deps_install_failed" return int(step["exit_code"]), step tauri_args = ["run", "tauri", "build"] if hasattr(args, "no_bundle") and args.no_bundle: tauri_args.extend(["--", "--no-bundle"]) step = run_step("npm", tauri_args, cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def action_git_status(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) step = run_step("git", ["status"], cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def action_git_fetch(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) step = run_step("git", ["fetch"], cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def action_git_pull(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) step = run_step("git", ["pull"], cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def action_git_clean(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) step = run_step("git", ["clean", "-fd"], cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def action_docker_build(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) step = run_step("docker", ["build", "."], cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def action_docker_compose_up(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) step = run_step("docker", ["compose", "up", "-d"], cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def action_docker_compose_down(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) step = run_step("docker", ["compose", "down"], cwd) return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step def main() -> int: parser = argparse.ArgumentParser(description="SDT normalized build actions") sub = parser.add_subparsers(dest="action", required=True) p0 = sub.add_parser("dotnet-restore"); parse_common(p0) p1 = sub.add_parser("dotnet-build"); parse_common(p1) p1b = sub.add_parser("dotnet-test"); parse_common(p1b) p1c = sub.add_parser("dotnet-publish"); parse_common(p1c) p2 = sub.add_parser("npm-install"); parse_common(p2) p2b = sub.add_parser("npm-ci"); parse_common(p2b) p3 = sub.add_parser("npm-build"); parse_common(p3) p3b = sub.add_parser("npm-test"); parse_common(p3b) p3c = sub.add_parser("npm-audit"); parse_common(p3c) p4 = sub.add_parser("python-venv-create"); parse_common(p4) _ = p4.add_argument("--venv-dir", default=".venv") p5 = sub.add_parser("python-pip-install"); parse_common(p5) _ = p5.add_argument("--requirements", required=True) p5b = sub.add_parser("python-pip-sync"); parse_common(p5b) _ = p5b.add_argument("--requirements", required=True) p5c = sub.add_parser("python-pytest"); parse_common(p5c) p6 = sub.add_parser("cargo-build"); parse_common(p6) p6b = sub.add_parser("cargo-test"); parse_common(p6b) p7 = sub.add_parser("tauri-build"); parse_common(p7) _ = p7.add_argument("--no-bundle", action="store_true") p8 = sub.add_parser("git-status"); parse_common(p8) p9 = sub.add_parser("git-fetch"); parse_common(p9) p10 = sub.add_parser("git-pull"); parse_common(p10) p11 = sub.add_parser("git-clean"); parse_common(p11) p12 = sub.add_parser("docker-build"); parse_common(p12) p13 = sub.add_parser("docker-compose-up"); parse_common(p13) p14 = sub.add_parser("docker-compose-down"); parse_common(p14) args = parser.parse_args() handlers = { "dotnet-restore": action_dotnet_restore, "dotnet-build": action_dotnet_build, "dotnet-test": action_dotnet_test, "dotnet-publish": action_dotnet_publish, "npm-install": action_npm_install, "npm-ci": action_npm_ci, "npm-build": action_npm_build, "npm-test": action_npm_test, "npm-audit": action_npm_audit, "python-venv-create": action_python_venv_create, "python-pip-install": action_python_pip_install, "python-pip-sync": action_python_pip_sync, "python-pytest": action_python_pytest, "cargo-build": action_cargo_build, "cargo-test": action_cargo_test, "tauri-build": action_tauri_build, "git-status": action_git_status, "git-fetch": action_git_fetch, "git-pull": action_git_pull, "git-clean": action_git_clean, "docker-build": action_docker_build, "docker-compose-up": action_docker_compose_up, "docker-compose-down": action_docker_compose_down, } code, summary = handlers[args.action](args) if args.json: print(json.dumps(summary)) return int(code) if __name__ == "__main__": sys.exit(main())