refactor(errors): introduce MissingEnvVarError for missing host env vars

Replace die()/RuntimeError at the point of detection with a typed
MissingEnvVarError, and catch it in the CLI dispatcher alongside
ManifestError. Library code stays free of stderr output; the CLI
renders all user-facing error lines in one place.

Sites updated: egress.egress_resolve_token_values (unset/empty token),
git_gate._provision_dynamic_key and revoke_git_gate_provisioned_keys
(missing forge_token_env), env.resolve_env (unset interpolated var).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 12:55:30 -04:00
parent 5bfe45bc1d
commit 8f31741c9f
6 changed files with 42 additions and 13 deletions
+4 -2
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import sys import sys
from ..errors import MissingEnvVarError
from ..log import Die, die, error from ..log import Die, die, error
from ..manifest import ManifestError from ..manifest import ManifestError
from ..store_manager import StoreManager from ..store_manager import StoreManager
@@ -90,9 +91,10 @@ def main(argv: list[str] | None = None) -> int:
mgr.migrate() mgr.migrate()
try: try:
return handler(rest) or 0 return handler(rest) or 0
except MissingEnvVarError as e:
error(str(e))
return 1
except ManifestError as e: 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)) error(str(e))
return 1 return 1
except Die as e: except Die as e:
+7 -4
View File
@@ -23,6 +23,7 @@ from .egress_addon_core import (
PathMatch as CorePathMatch, PathMatch as CorePathMatch,
Route, Route,
) )
from .errors import MissingEnvVarError
from .log import die from .log import die
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -354,16 +355,18 @@ def egress_resolve_token_values(
for token_env, token_ref in token_env_map.items(): for token_env, token_ref in token_env_map.items():
value = host_env.get(token_ref) value = host_env.get(token_ref)
if value is None: if value is None:
die( raise MissingEnvVarError(
token_ref,
f"egress: host env var '{token_ref}' is unset. Set it " f"egress: host env var '{token_ref}' is unset. Set it "
f"before launching, or remove the corresponding auth block " f"before launching, or remove the corresponding auth block "
f"from bottle.egress.routes." f"from bottle.egress.routes.",
) )
if not value: if not value:
die( raise MissingEnvVarError(
token_ref,
f"egress: host env var '{token_ref}' is empty. The " f"egress: host env var '{token_ref}' is empty. The "
f"egress will not inject an empty token; set it to " 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 out[token_env] = value
return out return out
+4 -2
View File
@@ -35,6 +35,7 @@ import re
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from .errors import MissingEnvVarError
from .log import die from .log import die
from .manifest import Manifest from .manifest import Manifest
@@ -136,9 +137,10 @@ def resolve_env(manifest: Manifest) -> ResolvedEnv:
host_var = env_entry_interpolated_from(raw) host_var = env_entry_interpolated_from(raw)
host_value = os.environ.get(host_var, "") host_value = os.environ.get(host_var, "")
if not host_value: if not host_value:
die( raise MissingEnvVarError(
host_var,
f"env entry {name} is interpolated from ${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 forwarded[name] = host_value
else: # literal else: # literal
+18
View File
@@ -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)
+1
View File
@@ -82,6 +82,7 @@ class GitGatePlan:
class GitGate(ABC): class GitGate(ABC):
"""The per-agent git-gate. Encapsulates the host-side prepare """The per-agent git-gate. Encapsulates the host-side prepare
(upstream lift + entrypoint/hook render); the sidecar's (upstream lift + entrypoint/hook render); the sidecar's
+8 -5
View File
@@ -13,7 +13,8 @@ import dataclasses
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .log import die, info from .errors import MissingEnvVarError
from .log import info
from .manifest import ManifestBottle, ManifestGitEntry from .manifest import ManifestBottle, ManifestGitEntry
from .git_gate_render import GitGateUpstream from .git_gate_render import GitGateUpstream
@@ -34,9 +35,10 @@ def _provision_dynamic_key(
pk = entry.Key pk = entry.Key
token = os.environ.get(pk.forge_token_env) token = os.environ.get(pk.forge_token_env)
if token is None: if token is None:
die( raise MissingEnvVarError(
pk.forge_token_env,
f"git-gate.repos[{entry.Name!r}] key.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}" api_url = pk.api_url or f"https://{entry.UpstreamHost}"
provisioner = get_provisioner(pk.provider, token, api_url) 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() key_id = id_file.read_text().strip()
token = os.environ.get(pk.forge_token_env) token = os.environ.get(pk.forge_token_env)
if token is None: if token is None:
raise RuntimeError( raise MissingEnvVarError(
pk.forge_token_env,
f"git-gate.repos[{entry.Name!r}] key.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;"
f" cannot revoke deploy key {key_id}" f" cannot revoke deploy key {key_id}",
) )
api_url = pk.api_url or f"https://{entry.UpstreamHost}" api_url = pk.api_url or f"https://{entry.UpstreamHost}"
provisioner = get_provisioner(pk.provider, token, api_url) provisioner = get_provisioner(pk.provider, token, api_url)