diff --git a/bot_bottle/cli/__init__.py b/bot_bottle/cli/__init__.py index a76f2df..dfc9cb0 100644 --- a/bot_bottle/cli/__init__.py +++ b/bot_bottle/cli/__init__.py @@ -7,6 +7,7 @@ from __future__ import annotations import sys +from ..errors import MissingEnvVarError from ..log import Die, die, error from ..manifest import ManifestError from ..store_manager import StoreManager @@ -90,9 +91,10 @@ def main(argv: list[str] | None = None) -> int: mgr.migrate() try: return handler(rest) or 0 + except MissingEnvVarError as e: + error(str(e)) + return 1 except ManifestError as e: - # Manifest/config problems surface as a catchable exception; - # print the reason and exit non-zero (same UX die() used to give). error(str(e)) return 1 except Die as e: diff --git a/bot_bottle/egress.py b/bot_bottle/egress.py index 8d29a00..98ea7bf 100644 --- a/bot_bottle/egress.py +++ b/bot_bottle/egress.py @@ -23,6 +23,7 @@ from .egress_addon_core import ( PathMatch as CorePathMatch, Route, ) +from .errors import MissingEnvVarError from .log import die if TYPE_CHECKING: @@ -354,16 +355,18 @@ def egress_resolve_token_values( for token_env, token_ref in token_env_map.items(): value = host_env.get(token_ref) if value is None: - die( + raise MissingEnvVarError( + token_ref, f"egress: host env var '{token_ref}' is unset. Set it " f"before launching, or remove the corresponding auth block " - f"from bottle.egress.routes." + f"from bottle.egress.routes.", ) if not value: - die( + raise MissingEnvVarError( + token_ref, f"egress: host env var '{token_ref}' is empty. The " f"egress will not inject an empty token; set it to " - f"the real value or remove the route's auth block." + f"the real value or remove the route's auth block.", ) out[token_env] = value return out diff --git a/bot_bottle/env.py b/bot_bottle/env.py index 767ad04..63eaebb 100644 --- a/bot_bottle/env.py +++ b/bot_bottle/env.py @@ -35,6 +35,7 @@ import re import sys from dataclasses import dataclass, field +from .errors import MissingEnvVarError from .log import die from .manifest import Manifest @@ -136,9 +137,10 @@ def resolve_env(manifest: Manifest) -> ResolvedEnv: host_var = env_entry_interpolated_from(raw) host_value = os.environ.get(host_var, "") if not host_value: - die( + raise MissingEnvVarError( + host_var, f"env entry {name} is interpolated from ${host_var}, " - f"but ${host_var} is unset or empty in the host environment." + f"but ${host_var} is unset or empty in the host environment.", ) forwarded[name] = host_value else: # literal diff --git a/bot_bottle/errors.py b/bot_bottle/errors.py new file mode 100644 index 0000000..8866878 --- /dev/null +++ b/bot_bottle/errors.py @@ -0,0 +1,18 @@ +"""bot-bottle application-level exception hierarchy. + +Exceptions here are caught by the CLI dispatcher (``cli/__init__.py``) +and rendered as ``bot-bottle: error: …`` lines — same UX as ``die()``, +but without coupling library code to stderr output.""" + +from __future__ import annotations + + +class MissingEnvVarError(Exception): + """Raised when a required host environment variable is unset or empty. + + ``var_name`` is the exact name of the missing variable so callers + can display it without re-parsing the message string.""" + + def __init__(self, var_name: str, message: str) -> None: + self.var_name = var_name + super().__init__(message) diff --git a/bot_bottle/git_gate.py b/bot_bottle/git_gate.py index e1deae1..0c63248 100644 --- a/bot_bottle/git_gate.py +++ b/bot_bottle/git_gate.py @@ -82,6 +82,7 @@ class GitGatePlan: + class GitGate(ABC): """The per-agent git-gate. Encapsulates the host-side prepare (upstream lift + entrypoint/hook render); the sidecar's diff --git a/bot_bottle/git_gate_provision.py b/bot_bottle/git_gate_provision.py index cb84563..54e70f0 100644 --- a/bot_bottle/git_gate_provision.py +++ b/bot_bottle/git_gate_provision.py @@ -13,7 +13,8 @@ import dataclasses from pathlib import Path from typing import TYPE_CHECKING -from .log import die, info +from .errors import MissingEnvVarError +from .log import info from .manifest import ManifestBottle, ManifestGitEntry from .git_gate_render import GitGateUpstream @@ -34,9 +35,10 @@ def _provision_dynamic_key( pk = entry.Key token = os.environ.get(pk.forge_token_env) if token is None: - die( + raise MissingEnvVarError( + pk.forge_token_env, f"git-gate.repos[{entry.Name!r}] key.forge_token_env" - f" = {pk.forge_token_env!r}: env var is not set" + f" = {pk.forge_token_env!r}: env var is not set", ) api_url = pk.api_url or f"https://{entry.UpstreamHost}" provisioner = get_provisioner(pk.provider, token, api_url) @@ -78,10 +80,11 @@ def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) -> key_id = id_file.read_text().strip() token = os.environ.get(pk.forge_token_env) if token is None: - raise RuntimeError( + raise MissingEnvVarError( + pk.forge_token_env, f"git-gate.repos[{entry.Name!r}] key.forge_token_env" f" = {pk.forge_token_env!r}: env var is not set;" - f" cannot revoke deploy key {key_id}" + f" cannot revoke deploy key {key_id}", ) api_url = pk.api_url or f"https://{entry.UpstreamHost}" provisioner = get_provisioner(pk.provider, token, api_url)