From ce499995655addb827c88268c0ebdbfe86ebcfe1 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah Date: Sun, 5 Jul 2026 16:33:52 -0700 Subject: [PATCH] refactor: split setup delegate modules --- cli/python/base_setup/brewfile_delegate.py | 142 ++++++ cli/python/base_setup/delegates.py | 479 ++----------------- cli/python/base_setup/mise_delegate.py | 316 ++++++++++++ cli/python/base_setup/tests/test_brewfile.py | 13 + cli/python/base_setup/tests/test_mise.py | 40 +- 5 files changed, 540 insertions(+), 450 deletions(-) create mode 100644 cli/python/base_setup/brewfile_delegate.py create mode 100644 cli/python/base_setup/mise_delegate.py diff --git a/cli/python/base_setup/brewfile_delegate.py b/cli/python/base_setup/brewfile_delegate.py new file mode 100644 index 00000000..cb4d7d45 --- /dev/null +++ b/cli/python/base_setup/brewfile_delegate.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import base_cli + +from . import process +from .checks import ArtifactCheck +from .errors import ArtifactError +from .manifest import BaseManifest +from .platform_policy import brewfile_delegates_supported, platform_label + + +def check_brewfile(manifest: BaseManifest) -> ArtifactCheck: + try: + brewfile_path = resolve_brewfile_path(manifest) + except ArtifactError as exc: + return ArtifactCheck( + name="brewfile", + ok=False, + message=str(exc), + fix=f"Update '{manifest.path}' or run 'basectl setup {manifest.project_name}'.", + finding_id="BASE-P010", + ) + + if not brewfile_delegates_supported(): + return ArtifactCheck( + name="brewfile", + ok=False, + message=( + f"Brewfile delegates are macOS/Homebrew-only; skipping '{brewfile_path}' " + f"on BASE_PLATFORM='{platform_label()}'." + ), + fix="Use a platform-native project setup path; for uv projects, install uv and rerun basectl setup/check.", + finding_id="BASE-P011", + status="warn", + ) + + if not process.command_exists("brew"): + return ArtifactCheck( + name="brewfile", + ok=False, + message=f"Homebrew is required to check Brewfile dependencies from '{brewfile_path}'.", + fix="basectl setup", + finding_id="BASE-P011", + ) + + try: + ok = process.run_check( + ["brew", "bundle", "check", f"--file={brewfile_path}"], + env=homebrew_no_auto_update_env(), + timeout_seconds=process.DIAGNOSTIC_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired: + return ArtifactCheck( + name="brewfile", + ok=False, + message=( + f"Homebrew Brewfile check for '{brewfile_path}' timed out after " + f"{process.DIAGNOSTIC_TIMEOUT_SECONDS} seconds." + ), + fix=f"Retry 'basectl doctor {manifest.project_name}' or inspect Homebrew with 'brew doctor'.", + status="warn", + finding_id="BASE-P012", + ) + if ok: + return ArtifactCheck( + name="brewfile", + ok=True, + message=f"Brewfile dependencies are satisfied for '{brewfile_path}'.", + fix="", + finding_id="BASE-P012", + ) + return ArtifactCheck( + name="brewfile", + ok=False, + message=f"Brewfile dependencies are not satisfied for '{brewfile_path}'.", + fix=f"basectl setup {manifest.project_name}", + finding_id="BASE-P012", + ) + + +def reconcile_brewfile(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> None: + if manifest.brewfile is None: + return + + brewfile_path = resolve_brewfile_path(manifest) + command = ["brew", "bundle", f"--file={brewfile_path}"] + check_command = ["brew", "bundle", "check", f"--file={brewfile_path}"] + + if not brewfile_delegates_supported(): + ctx.log.info( + "Skipping Brewfile '%s' on BASE_PLATFORM='%s'; Brewfile delegates are macOS/Homebrew-only.", + brewfile_path, + platform_label(), + ) + return + + if dry_run: + process.dry_run_command(ctx, command) + return + + if not process.command_exists("brew"): + raise ArtifactError(f"Homebrew is required to install Brewfile dependencies from '{brewfile_path}'.") + + env = homebrew_no_auto_update_env() + if process.run_check( + check_command, + env=env, + timeout_seconds=process.DIAGNOSTIC_TIMEOUT_SECONDS, + ): + ctx.log.info("Brewfile dependencies are already satisfied for '%s'.", brewfile_path) + return + + ctx.log.info("Installing Homebrew dependencies from Brewfile '%s'.", brewfile_path) + process.run_command(ctx, command, env=env) + + +def resolve_brewfile_path(manifest: BaseManifest) -> Path: + if manifest.brewfile is None: + raise ArtifactError(f"{manifest.path}: brewfile is not configured.") + + brewfile = Path(manifest.brewfile) + if brewfile.is_absolute(): + raise ArtifactError(f"{manifest.path}: brewfile must be relative to the project root.") + + project_root = manifest.path.parent.resolve() + brewfile_path = (project_root / brewfile).resolve() + if not brewfile_path.is_relative_to(project_root): + raise ArtifactError(f"{manifest.path}: brewfile must stay inside the project root.") + if not brewfile_path.is_file(): + raise ArtifactError(f"{manifest.path}: brewfile '{manifest.brewfile}' does not exist.") + return brewfile_path + + +def homebrew_no_auto_update_env() -> dict[str, str]: + env = os.environ.copy() + env["HOMEBREW_NO_AUTO_UPDATE"] = "1" + return env + diff --git a/cli/python/base_setup/delegates.py b/cli/python/base_setup/delegates.py index 52ecf091..0954a06f 100644 --- a/cli/python/base_setup/delegates.py +++ b/cli/python/base_setup/delegates.py @@ -1,444 +1,41 @@ from __future__ import annotations -import json -import os -import shutil -import subprocess -from pathlib import Path -from typing import Any - -import base_cli - from . import process -from .checks import ArtifactCheck -from .errors import ArtifactError -from .manifest import BaseManifest -from .platform_policy import brewfile_delegates_supported, current_base_platform, platform_label -from .user_paths import prepend_user_local_bin_to_path -from .user_paths import user_local_bin - - -MISE_INSTALL_COMMAND_TEXT = "curl https://mise.run | sh" - - -def check_brewfile(manifest: BaseManifest) -> ArtifactCheck: - try: - brewfile_path = resolve_brewfile_path(manifest) - except ArtifactError as exc: - return ArtifactCheck( - name="brewfile", - ok=False, - message=str(exc), - fix=f"Update '{manifest.path}' or run 'basectl setup {manifest.project_name}'.", - finding_id="BASE-P010", - ) - - if not brewfile_delegates_supported(): - return ArtifactCheck( - name="brewfile", - ok=False, - message=( - f"Brewfile delegates are macOS/Homebrew-only; skipping '{brewfile_path}' " - f"on BASE_PLATFORM='{platform_label()}'." - ), - fix="Use a platform-native project setup path; for uv projects, install uv and rerun basectl setup/check.", - finding_id="BASE-P011", - status="warn", - ) - - if not process.command_exists("brew"): - return ArtifactCheck( - name="brewfile", - ok=False, - message=f"Homebrew is required to check Brewfile dependencies from '{brewfile_path}'.", - fix="basectl setup", - finding_id="BASE-P011", - ) - - try: - ok = process.run_check( - ["brew", "bundle", "check", f"--file={brewfile_path}"], - env=homebrew_no_auto_update_env(), - timeout_seconds=process.DIAGNOSTIC_TIMEOUT_SECONDS, - ) - except subprocess.TimeoutExpired: - return ArtifactCheck( - name="brewfile", - ok=False, - message=( - f"Homebrew Brewfile check for '{brewfile_path}' timed out after " - f"{process.DIAGNOSTIC_TIMEOUT_SECONDS} seconds." - ), - fix=f"Retry 'basectl doctor {manifest.project_name}' or inspect Homebrew with 'brew doctor'.", - status="warn", - finding_id="BASE-P012", - ) - if ok: - return ArtifactCheck( - name="brewfile", - ok=True, - message=f"Brewfile dependencies are satisfied for '{brewfile_path}'.", - fix="", - finding_id="BASE-P012", - ) - return ArtifactCheck( - name="brewfile", - ok=False, - message=f"Brewfile dependencies are not satisfied for '{brewfile_path}'.", - fix=f"basectl setup {manifest.project_name}", - finding_id="BASE-P012", - ) - - -def check_mise(manifest: BaseManifest) -> ArtifactCheck: - try: - mise_path = resolve_mise_path(manifest) - except ArtifactError as exc: - return ArtifactCheck( - name="mise", - ok=False, - message=str(exc), - fix=f"Update '{manifest.path}' or run 'basectl setup {manifest.project_name}'.", - finding_id="BASE-P020", - ) - - mise_bin = mise_executable() - if mise_bin is None: - return ArtifactCheck( - name="mise", - ok=False, - message=f"mise is not available, but the manifest declares project config '{mise_path}'.", - fix=( - f"Run 'basectl setup {manifest.project_name} --dry-run' to review the mise bootstrap, " - "then rerun with '--yes', or install mise manually." - ), - finding_id="BASE-P021", - status="warn", - ) - - project_root = manifest.path.parent.resolve() - details = mise_details(project_root, mise_path) - trust_problem = check_mise_trust(project_root, mise_path, mise_bin, details) - if trust_problem is not None: - return trust_problem - - verified_details = details | {"trusted": True, "missing_tools_checked": True} - missing_problem = check_mise_missing_tools(manifest, project_root, mise_path, mise_bin, verified_details) - if missing_problem is not None: - return missing_problem - - return ArtifactCheck( - name="mise", - ok=True, - message=f"mise config '{mise_path}' is trusted and mise-managed tools are installed.", - fix="", - finding_id="BASE-P022", - details=verified_details, - ) - - -def check_mise_trust( - project_root: Path, - mise_path: Path, - mise_bin: Path, - details: dict[str, object], -) -> ArtifactCheck | None: - try: - trust_check = process.run_capture( - [str(mise_bin), "trust", "--show"], - cwd=project_root, - timeout_seconds=process.DIAGNOSTIC_TIMEOUT_SECONDS, - ) - except subprocess.TimeoutExpired: - return ArtifactCheck( - name="mise", - ok=False, - message=( - f"mise trust status check for '{mise_path}' timed out after " - f"{process.DIAGNOSTIC_TIMEOUT_SECONDS} seconds." - ), - fix=f"Retry 'mise trust --show' in '{project_root}'.", - status="warn", - finding_id="BASE-P022", - details=details, - ) - trust_text = command_text(trust_check.stdout, trust_check.stderr) - if trust_check.returncode != 0: - return ArtifactCheck( - name="mise", - ok=False, - message=f"mise trust status could not be checked for '{mise_path}'.", - fix=f"Run 'mise trust --show' in '{project_root}' for details.", - status="warn", - finding_id="BASE-P022", - details=details | {"returncode": trust_check.returncode}, - ) - if mise_config_untrusted(trust_text): - return ArtifactCheck( - name="mise", - ok=False, - message=f"mise config '{mise_path}' is not trusted by mise.", - fix=f"mise trust {mise_path}", - finding_id="BASE-P022", - details=details | {"trusted": False}, - ) - if "trusted" not in trust_text.lower(): - return ArtifactCheck( - name="mise", - ok=False, - message=f"mise trust status for '{mise_path}' could not be determined.", - fix=f"Run 'mise trust --show' in '{project_root}' for details.", - status="warn", - finding_id="BASE-P022", - details=details, - ) - return None - - -def check_mise_missing_tools( - manifest: BaseManifest, - project_root: Path, - mise_path: Path, - mise_bin: Path, - details: dict[str, object], -) -> ArtifactCheck | None: - try: - missing_check = process.run_capture( - [str(mise_bin), "ls", "--missing", "--json"], - cwd=project_root, - timeout_seconds=process.DIAGNOSTIC_TIMEOUT_SECONDS, - ) - except subprocess.TimeoutExpired: - return ArtifactCheck( - name="mise", - ok=False, - message=( - f"mise missing-tool status check for '{mise_path}' timed out after " - f"{process.DIAGNOSTIC_TIMEOUT_SECONDS} seconds." - ), - fix=f"Retry 'mise ls --missing --json' in '{project_root}'.", - status="warn", - finding_id="BASE-P022", - details=details, - ) - if missing_check.returncode != 0: - missing_text = command_text(missing_check.stdout, missing_check.stderr) - if mise_config_untrusted(missing_text): - return ArtifactCheck( - name="mise", - ok=False, - message=f"mise config '{mise_path}' is not trusted by mise.", - fix=f"mise trust {mise_path}", - finding_id="BASE-P022", - details=details | {"trusted": False, "returncode": missing_check.returncode}, - ) - return ArtifactCheck( - name="mise", - ok=False, - message=f"mise missing-tool status could not be checked for '{mise_path}'.", - fix=f"Run 'mise ls --missing --json' in '{project_root}' for details.", - status="warn", - finding_id="BASE-P022", - details=details | {"returncode": missing_check.returncode}, - ) - - try: - missing_payload = json.loads(missing_check.stdout or "{}") - except json.JSONDecodeError: - return ArtifactCheck( - name="mise", - ok=False, - message=f"mise missing-tool output for '{mise_path}' could not be parsed as JSON.", - fix=f"Run 'mise ls --missing --json' in '{project_root}' for details.", - status="warn", - finding_id="BASE-P022", - details=details, - ) - - missing_tools = missing_tool_names(missing_payload) - if missing_tools: - tool_list = ", ".join(missing_tools) - return ArtifactCheck( - name="mise", - ok=False, - message=f"mise-managed tools are missing for project config '{mise_path}': {tool_list}.", - fix=f"basectl setup {manifest.project_name}", - finding_id="BASE-P022", - details=details | {"missing_tools": missing_tools}, - ) - return None - - -def mise_details(project_root: Path, mise_path: Path) -> dict[str, object]: - return { - "project_root": str(project_root), - "mise_config": str(mise_path), - "trust_checked": True, - "missing_tools_checked": False, - } - - -def reconcile_brewfile(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> None: - if manifest.brewfile is None: - return - - brewfile_path = resolve_brewfile_path(manifest) - command = ["brew", "bundle", f"--file={brewfile_path}"] - check_command = ["brew", "bundle", "check", f"--file={brewfile_path}"] - - if not brewfile_delegates_supported(): - ctx.log.info( - "Skipping Brewfile '%s' on BASE_PLATFORM='%s'; Brewfile delegates are macOS/Homebrew-only.", - brewfile_path, - platform_label(), - ) - return - - if dry_run: - process.dry_run_command(ctx, command) - return - - if not process.command_exists("brew"): - raise ArtifactError(f"Homebrew is required to install Brewfile dependencies from '{brewfile_path}'.") - - env = homebrew_no_auto_update_env() - if process.run_check( - check_command, - env=env, - timeout_seconds=process.DIAGNOSTIC_TIMEOUT_SECONDS, - ): - ctx.log.info("Brewfile dependencies are already satisfied for '%s'.", brewfile_path) - return - - ctx.log.info("Installing Homebrew dependencies from Brewfile '%s'.", brewfile_path) - process.run_command(ctx, command, env=env) - - -def reconcile_mise(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> None: - if manifest.mise is None: - return - - mise_path = resolve_mise_path(manifest) - project_root = manifest.path.parent.resolve() - mise_bin = ensure_mise_available(ctx, manifest, dry_run=dry_run) - command = ["mise", "install"] if dry_run else [str(mise_bin), "install"] - if dry_run: - process.dry_run_command(ctx, command, cwd=project_root) - return - - require_mise_trusted_for_setup(manifest, project_root, mise_path, mise_bin) - ctx.log.info("Installing mise-managed tools from '%s'.", mise_path) - process.run_command(ctx, command, cwd=project_root) - - -def require_mise_trusted_for_setup( - manifest: BaseManifest, - project_root: Path, - mise_path: Path, - mise_bin: Path, -) -> None: - trust_problem = check_mise_trust(project_root, mise_path, mise_bin, mise_details(project_root, mise_path)) - if trust_problem is None: - return - - raise ArtifactError( - f"{trust_problem.message} " - f"Run '{trust_problem.fix}', then rerun 'basectl setup {manifest.project_name} --yes'." - ) - - -def ensure_mise_available(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> Path: - mise_bin = mise_executable() - if mise_bin is not None: - return mise_bin - - if current_base_platform() != "linux-debian": - raise ArtifactError("mise is required to set up this project. Install mise and rerun basectl setup.") - - if dry_run: - ctx.log.info("[DRY-RUN] Would bootstrap mise: %s", MISE_INSTALL_COMMAND_TEXT) - return Path("mise") - - if os.environ.get("BASE_SETUP_YES") != "true": - raise ArtifactError( - f"mise is required to set up project '{manifest.project_name}'. " - f"Run 'basectl setup {manifest.project_name} --dry-run' to review the mise bootstrap, " - "then rerun with '--yes' to apply it." - ) - - ctx.log.info("Bootstrapping mise for project '%s'.", manifest.project_name) - process.run_command(ctx, ["sh", "-c", MISE_INSTALL_COMMAND_TEXT]) - prepend_user_local_bin_to_path() - mise_bin = mise_executable() - if mise_bin is None: - raise ArtifactError( - "mise bootstrap completed, but mise was not found. " - "Add '$HOME/.local/bin' to PATH or install mise, then rerun basectl setup." - ) - return mise_bin - - -def resolve_brewfile_path(manifest: BaseManifest) -> Path: - if manifest.brewfile is None: - raise ArtifactError(f"{manifest.path}: brewfile is not configured.") - - brewfile = Path(manifest.brewfile) - if brewfile.is_absolute(): - raise ArtifactError(f"{manifest.path}: brewfile must be relative to the project root.") - - project_root = manifest.path.parent.resolve() - brewfile_path = (project_root / brewfile).resolve() - if not brewfile_path.is_relative_to(project_root): - raise ArtifactError(f"{manifest.path}: brewfile must stay inside the project root.") - if not brewfile_path.is_file(): - raise ArtifactError(f"{manifest.path}: brewfile '{manifest.brewfile}' does not exist.") - return brewfile_path - - -def homebrew_no_auto_update_env() -> dict[str, str]: - env = os.environ.copy() - env["HOMEBREW_NO_AUTO_UPDATE"] = "1" - return env - - -def command_text(stdout: str, stderr: str) -> str: - return "\n".join(part for part in (stdout, stderr) if part) - - -def mise_config_untrusted(output: str) -> bool: - normalized = output.lower() - return "untrusted" in normalized or "no trusted config files found" in normalized or "not trusted" in normalized - - -def missing_tool_names(payload: Any) -> list[str]: - if not isinstance(payload, dict): - return ["unknown"] if payload else [] - names = [str(name) for name, value in payload.items() if value] - return sorted(names) - - -def mise_executable() -> Path | None: - resolved = shutil.which("mise") - if resolved: - return Path(resolved) - - candidate = user_local_bin() / "mise" - if candidate.is_file() and os.access(candidate, os.X_OK): - return candidate - return None - - -def resolve_mise_path(manifest: BaseManifest) -> Path: - if manifest.mise is None: - raise ArtifactError(f"{manifest.path}: mise is not configured.") - - mise = Path(manifest.mise) - if mise.is_absolute(): - raise ArtifactError(f"{manifest.path}: mise must be relative to the project root.") - project_root = manifest.path.parent.resolve() - mise_path = (project_root / mise).resolve() - if not mise_path.is_relative_to(project_root): - raise ArtifactError(f"{manifest.path}: mise must stay inside the project root.") - if not mise_path.is_file(): - raise ArtifactError(f"{manifest.path}: mise config '{manifest.mise}' does not exist.") - return mise_path +from .brewfile_delegate import check_brewfile +from .brewfile_delegate import homebrew_no_auto_update_env +from .brewfile_delegate import reconcile_brewfile +from .brewfile_delegate import resolve_brewfile_path +from .mise_delegate import MISE_INSTALL_COMMAND_TEXT +from .mise_delegate import check_mise +from .mise_delegate import check_mise_missing_tools +from .mise_delegate import check_mise_trust +from .mise_delegate import command_text +from .mise_delegate import ensure_mise_available +from .mise_delegate import mise_config_untrusted +from .mise_delegate import mise_details +from .mise_delegate import mise_executable +from .mise_delegate import missing_tool_names +from .mise_delegate import reconcile_mise +from .mise_delegate import require_mise_trusted_for_setup +from .mise_delegate import resolve_mise_path + +__all__ = ( + "MISE_INSTALL_COMMAND_TEXT", + "check_brewfile", + "check_mise", + "check_mise_missing_tools", + "check_mise_trust", + "command_text", + "ensure_mise_available", + "homebrew_no_auto_update_env", + "mise_config_untrusted", + "mise_details", + "mise_executable", + "missing_tool_names", + "process", + "reconcile_brewfile", + "reconcile_mise", + "require_mise_trusted_for_setup", + "resolve_brewfile_path", + "resolve_mise_path", +) diff --git a/cli/python/base_setup/mise_delegate.py b/cli/python/base_setup/mise_delegate.py new file mode 100644 index 00000000..6e13b0eb --- /dev/null +++ b/cli/python/base_setup/mise_delegate.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import base_cli + +from . import process +from .checks import ArtifactCheck +from .errors import ArtifactError +from .manifest import BaseManifest +from .platform_policy import current_base_platform +from .user_paths import prepend_user_local_bin_to_path +from .user_paths import user_local_bin + +MISE_INSTALL_COMMAND_TEXT = "curl https://mise.run | sh" + + +def check_mise(manifest: BaseManifest) -> ArtifactCheck: + try: + mise_path = resolve_mise_path(manifest) + except ArtifactError as exc: + return ArtifactCheck( + name="mise", + ok=False, + message=str(exc), + fix=f"Update '{manifest.path}' or run 'basectl setup {manifest.project_name}'.", + finding_id="BASE-P020", + ) + + mise_bin = mise_executable() + if mise_bin is None: + return ArtifactCheck( + name="mise", + ok=False, + message=f"mise is not available, but the manifest declares project config '{mise_path}'.", + fix=( + f"Run 'basectl setup {manifest.project_name} --dry-run' to review the mise bootstrap, " + "then rerun with '--yes', or install mise manually." + ), + finding_id="BASE-P021", + status="warn", + ) + + project_root = manifest.path.parent.resolve() + details = mise_details(project_root, mise_path) + trust_problem = check_mise_trust(project_root, mise_path, mise_bin, details) + if trust_problem is not None: + return trust_problem + + verified_details = details | {"trusted": True, "missing_tools_checked": True} + missing_problem = check_mise_missing_tools(manifest, project_root, mise_path, mise_bin, verified_details) + if missing_problem is not None: + return missing_problem + + return ArtifactCheck( + name="mise", + ok=True, + message=f"mise config '{mise_path}' is trusted and mise-managed tools are installed.", + fix="", + finding_id="BASE-P022", + details=verified_details, + ) + + +def check_mise_trust( + project_root: Path, + mise_path: Path, + mise_bin: Path, + details: dict[str, object], +) -> ArtifactCheck | None: + try: + trust_check = process.run_capture( + [str(mise_bin), "trust", "--show"], + cwd=project_root, + timeout_seconds=process.DIAGNOSTIC_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired: + return ArtifactCheck( + name="mise", + ok=False, + message=( + f"mise trust status check for '{mise_path}' timed out after " + f"{process.DIAGNOSTIC_TIMEOUT_SECONDS} seconds." + ), + fix=f"Retry 'mise trust --show' in '{project_root}'.", + status="warn", + finding_id="BASE-P022", + details=details, + ) + trust_text = command_text(trust_check.stdout, trust_check.stderr) + if trust_check.returncode != 0: + return ArtifactCheck( + name="mise", + ok=False, + message=f"mise trust status could not be checked for '{mise_path}'.", + fix=f"Run 'mise trust --show' in '{project_root}' for details.", + status="warn", + finding_id="BASE-P022", + details=details | {"returncode": trust_check.returncode}, + ) + if mise_config_untrusted(trust_text): + return ArtifactCheck( + name="mise", + ok=False, + message=f"mise config '{mise_path}' is not trusted by mise.", + fix=f"mise trust {mise_path}", + finding_id="BASE-P022", + details=details | {"trusted": False}, + ) + if "trusted" not in trust_text.lower(): + return ArtifactCheck( + name="mise", + ok=False, + message=f"mise trust status for '{mise_path}' could not be determined.", + fix=f"Run 'mise trust --show' in '{project_root}' for details.", + status="warn", + finding_id="BASE-P022", + details=details, + ) + return None + + +def check_mise_missing_tools( + manifest: BaseManifest, + project_root: Path, + mise_path: Path, + mise_bin: Path, + details: dict[str, object], +) -> ArtifactCheck | None: + try: + missing_check = process.run_capture( + [str(mise_bin), "ls", "--missing", "--json"], + cwd=project_root, + timeout_seconds=process.DIAGNOSTIC_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired: + return ArtifactCheck( + name="mise", + ok=False, + message=( + f"mise missing-tool status check for '{mise_path}' timed out after " + f"{process.DIAGNOSTIC_TIMEOUT_SECONDS} seconds." + ), + fix=f"Retry 'mise ls --missing --json' in '{project_root}'.", + status="warn", + finding_id="BASE-P022", + details=details, + ) + if missing_check.returncode != 0: + missing_text = command_text(missing_check.stdout, missing_check.stderr) + if mise_config_untrusted(missing_text): + return ArtifactCheck( + name="mise", + ok=False, + message=f"mise config '{mise_path}' is not trusted by mise.", + fix=f"mise trust {mise_path}", + finding_id="BASE-P022", + details=details | {"trusted": False, "returncode": missing_check.returncode}, + ) + return ArtifactCheck( + name="mise", + ok=False, + message=f"mise missing-tool status could not be checked for '{mise_path}'.", + fix=f"Run 'mise ls --missing --json' in '{project_root}' for details.", + status="warn", + finding_id="BASE-P022", + details=details | {"returncode": missing_check.returncode}, + ) + + try: + missing_payload = json.loads(missing_check.stdout or "{}") + except json.JSONDecodeError: + return ArtifactCheck( + name="mise", + ok=False, + message=f"mise missing-tool output for '{mise_path}' could not be parsed as JSON.", + fix=f"Run 'mise ls --missing --json' in '{project_root}' for details.", + status="warn", + finding_id="BASE-P022", + details=details, + ) + + missing_tools = missing_tool_names(missing_payload) + if missing_tools: + tool_list = ", ".join(missing_tools) + return ArtifactCheck( + name="mise", + ok=False, + message=f"mise-managed tools are missing for project config '{mise_path}': {tool_list}.", + fix=f"basectl setup {manifest.project_name}", + finding_id="BASE-P022", + details=details | {"missing_tools": missing_tools}, + ) + return None + + +def mise_details(project_root: Path, mise_path: Path) -> dict[str, object]: + return { + "project_root": str(project_root), + "mise_config": str(mise_path), + "trust_checked": True, + "missing_tools_checked": False, + } + + +def reconcile_mise(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> None: + if manifest.mise is None: + return + + mise_path = resolve_mise_path(manifest) + project_root = manifest.path.parent.resolve() + mise_bin = ensure_mise_available(ctx, manifest, dry_run=dry_run) + command = ["mise", "install"] if dry_run else [str(mise_bin), "install"] + if dry_run: + process.dry_run_command(ctx, command, cwd=project_root) + return + + require_mise_trusted_for_setup(manifest, project_root, mise_path, mise_bin) + ctx.log.info("Installing mise-managed tools from '%s'.", mise_path) + process.run_command(ctx, command, cwd=project_root) + + +def require_mise_trusted_for_setup( + manifest: BaseManifest, + project_root: Path, + mise_path: Path, + mise_bin: Path, +) -> None: + trust_problem = check_mise_trust(project_root, mise_path, mise_bin, mise_details(project_root, mise_path)) + if trust_problem is None: + return + + raise ArtifactError( + f"{trust_problem.message} " + f"Run '{trust_problem.fix}', then rerun 'basectl setup {manifest.project_name} --yes'." + ) + + +def ensure_mise_available(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> Path: + mise_bin = mise_executable() + if mise_bin is not None: + return mise_bin + + if current_base_platform() != "linux-debian": + raise ArtifactError("mise is required to set up this project. Install mise and rerun basectl setup.") + + if dry_run: + ctx.log.info("[DRY-RUN] Would bootstrap mise: %s", MISE_INSTALL_COMMAND_TEXT) + return Path("mise") + + if os.environ.get("BASE_SETUP_YES") != "true": + raise ArtifactError( + f"mise is required to set up project '{manifest.project_name}'. " + f"Run 'basectl setup {manifest.project_name} --dry-run' to review the mise bootstrap, " + "then rerun with '--yes' to apply it." + ) + + ctx.log.info("Bootstrapping mise for project '%s'.", manifest.project_name) + process.run_command(ctx, ["sh", "-c", MISE_INSTALL_COMMAND_TEXT]) + prepend_user_local_bin_to_path() + mise_bin = mise_executable() + if mise_bin is None: + raise ArtifactError( + "mise bootstrap completed, but mise was not found. " + "Add '$HOME/.local/bin' to PATH or install mise, then rerun basectl setup." + ) + return mise_bin + + +def command_text(stdout: str, stderr: str) -> str: + return "\n".join(part for part in (stdout, stderr) if part) + + +def mise_config_untrusted(output: str) -> bool: + normalized = output.lower() + return "untrusted" in normalized or "no trusted config files found" in normalized or "not trusted" in normalized + + +def missing_tool_names(payload: Any) -> list[str]: + if not isinstance(payload, dict): + return ["unknown"] if payload else [] + names = [str(name) for name, value in payload.items() if value] + return sorted(names) + + +def mise_executable() -> Path | None: + resolved = shutil.which("mise") + if resolved: + return Path(resolved) + + candidate = user_local_bin() / "mise" + if candidate.is_file() and os.access(candidate, os.X_OK): + return candidate + return None + + +def resolve_mise_path(manifest: BaseManifest) -> Path: + if manifest.mise is None: + raise ArtifactError(f"{manifest.path}: mise is not configured.") + + mise = Path(manifest.mise) + if mise.is_absolute(): + raise ArtifactError(f"{manifest.path}: mise must be relative to the project root.") + project_root = manifest.path.parent.resolve() + mise_path = (project_root / mise).resolve() + if not mise_path.is_relative_to(project_root): + raise ArtifactError(f"{manifest.path}: mise must stay inside the project root.") + if not mise_path.is_file(): + raise ArtifactError(f"{manifest.path}: mise config '{manifest.mise}' does not exist.") + return mise_path + diff --git a/cli/python/base_setup/tests/test_brewfile.py b/cli/python/base_setup/tests/test_brewfile.py index 0db4326a..c4f9b0a9 100644 --- a/cli/python/base_setup/tests/test_brewfile.py +++ b/cli/python/base_setup/tests/test_brewfile.py @@ -14,6 +14,19 @@ class BrewfileTests(unittest.TestCase): + def test_delegates_reexports_brewfile_helpers(self) -> None: + from base_setup import brewfile_delegate + + expected_names = ( + "check_brewfile", + "homebrew_no_auto_update_env", + "reconcile_brewfile", + "resolve_brewfile_path", + ) + + for name in expected_names: + with self.subTest(name=name): + self.assertIs(getattr(delegates, name), getattr(brewfile_delegate, name)) def test_brewfile_dry_run_invokes_brew_bundle(self) -> None: ctx = fake_context() diff --git a/cli/python/base_setup/tests/test_mise.py b/cli/python/base_setup/tests/test_mise.py index 108805ee..f4bd2ae9 100644 --- a/cli/python/base_setup/tests/test_mise.py +++ b/cli/python/base_setup/tests/test_mise.py @@ -62,6 +62,28 @@ def trusted_mise_check(project_root: Path, mise_bin: Path = Path("mise")) -> sub class MiseTests(unittest.TestCase): + def test_delegates_reexports_mise_helpers(self) -> None: + from base_setup import mise_delegate + + expected_names = ( + "MISE_INSTALL_COMMAND_TEXT", + "check_mise", + "check_mise_missing_tools", + "check_mise_trust", + "command_text", + "ensure_mise_available", + "mise_config_untrusted", + "mise_details", + "mise_executable", + "missing_tool_names", + "reconcile_mise", + "require_mise_trusted_for_setup", + "resolve_mise_path", + ) + + for name in expected_names: + with self.subTest(name=name): + self.assertIs(getattr(delegates, name), getattr(mise_delegate, name)) def test_mise_dry_run_invokes_mise_install_in_project_root(self) -> None: ctx = fake_context() @@ -77,7 +99,7 @@ def test_mise_dry_run_invokes_mise_install_in_project_root(self) -> None: artifacts=(), ) - with mock.patch("base_setup.delegates.mise_executable", return_value=Path("mise")): + with mock.patch("base_setup.mise_delegate.mise_executable", return_value=Path("mise")): delegates.reconcile_mise(ctx, manifest, dry_run=True) info_messages = [call.args[0] % call.args[1:] for call in ctx.log.info.call_args_list] @@ -94,7 +116,7 @@ def test_mise_dry_run_plans_linux_debian_bootstrap_when_missing(self) -> None: with ( mock.patch.dict(os.environ, {"BASE_PLATFORM": "linux-debian"}), - mock.patch("base_setup.delegates.mise_executable", return_value=None), + mock.patch("base_setup.mise_delegate.mise_executable", return_value=None), ): delegates.reconcile_mise(ctx, manifest, dry_run=True) @@ -111,7 +133,7 @@ def test_mise_bootstraps_on_linux_debian_with_yes(self) -> None: with ( mock.patch.dict(os.environ, {"BASE_PLATFORM": "linux-debian", "BASE_SETUP_YES": "true"}), - mock.patch("base_setup.delegates.mise_executable", side_effect=[None, mise_path]), + mock.patch("base_setup.mise_delegate.mise_executable", side_effect=[None, mise_path]), mock.patch("base_setup.delegates.process.run_capture", return_value=trusted_mise_check(project_root)), mock.patch("base_setup.delegates.process.run_command") as run_command, ): @@ -130,7 +152,7 @@ def test_mise_requires_yes_before_linux_debian_bootstrap(self) -> None: with ( mock.patch.dict(os.environ, {"BASE_PLATFORM": "linux-debian"}, clear=False), - mock.patch("base_setup.delegates.mise_executable", return_value=None), + mock.patch("base_setup.mise_delegate.mise_executable", return_value=None), ): with self.assertRaisesRegex(RuntimeError, "Run 'basectl setup demo --dry-run'.*'--yes'"): delegates.reconcile_mise(ctx, manifest, dry_run=False) @@ -149,7 +171,7 @@ def test_mise_invokes_install_in_project_root(self) -> None: artifacts=(), ) - with mock.patch("base_setup.delegates.mise_executable", return_value=Path("mise")), mock.patch( + with mock.patch("base_setup.mise_delegate.mise_executable", return_value=Path("mise")), mock.patch( "base_setup.process.run_command" ) as run_command, mock.patch( "base_setup.delegates.process.run_capture", @@ -174,7 +196,7 @@ def test_mise_setup_refuses_untrusted_project_config_before_install(self) -> Non ) with ( - mock.patch("base_setup.delegates.mise_executable", return_value=mise_bin), + mock.patch("base_setup.mise_delegate.mise_executable", return_value=mise_bin), mock.patch("base_setup.delegates.process.run_capture", return_value=trust_check), mock.patch("base_setup.delegates.process.run_command") as run_command, ): @@ -220,7 +242,7 @@ def test_mise_check_warns_when_tool_is_missing(self) -> None: project_root.mkdir() manifest = make_manifest(project_root) - with mock.patch("base_setup.delegates.mise_executable", return_value=None): + with mock.patch("base_setup.mise_delegate.mise_executable", return_value=None): check = delegates.check_mise(manifest) self.assertFalse(check.ok) @@ -259,7 +281,7 @@ def test_mise_check_warns_when_trust_probe_times_out(self) -> None: manifest = make_manifest(project_root) with ( - mock.patch("base_setup.delegates.mise_executable", return_value=Path("mise")), + mock.patch("base_setup.mise_delegate.mise_executable", return_value=Path("mise")), mock.patch( "base_setup.process.run_capture", side_effect=subprocess.TimeoutExpired( @@ -335,7 +357,7 @@ def test_mise_check_warns_when_missing_tools_probe_times_out(self) -> None: ) with ( - mock.patch("base_setup.delegates.mise_executable", return_value=Path("mise")), + mock.patch("base_setup.mise_delegate.mise_executable", return_value=Path("mise")), mock.patch( "base_setup.process.run_capture", side_effect=[