From bcd20a79f983465c708f05316543560c509771f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Sun, 15 Mar 2026 02:59:43 +0100 Subject: [PATCH] feat(onboarding): validate provider env placeholders --- CHANGELOG.md | 1 + README.md | 1 + docs/ONBOARDING.md | 2 ++ foundrygate/onboarding.py | 33 +++++++++++++++++++++++++++++++++ scripts/foundrygate-doctor | 19 +++++++++++++++++++ tests/test_onboarding.py | 36 ++++++++++++++++++++++++++++++++++++ 6 files changed, 92 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a1bc5f..e72593d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is intentionally lightweight and human-readable. Group entries by rel - Added starter example files for OpenClaw, n8n, and CLI clients under `docs/examples/` so onboarding can begin from copy/pasteable templates - Added starter provider snippets for cloud, local-worker, and image-provider setups under `docs/examples/` - Added matching provider `.env` starter files for cloud, local-worker, and image-provider onboarding flows +- Added provider env placeholder checks to `foundrygate-doctor` so missing `.env` values are surfaced before rollout ## v0.7.0 - 2026-03-12 diff --git a/README.md b/README.md index 94c39a9..45a5dfe 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ $EDITOR .env ``` The onboarding report now includes concrete OpenClaw, n8n, and CLI quickstart hints, a staged provider-rollout view, and a client matrix, so you can move from a generic health check to a real client and provider rollout path without leaving the terminal. +`foundrygate-doctor` now also flags provider env placeholders from `config.yaml` that are still missing in `.env`. If you prefer the Linux service path instead of a manual Python run, jump to [Helper Scripts](#helper-scripts) and use `./scripts/foundrygate-install`. diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index a14d8ee..7d46a5e 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -26,6 +26,8 @@ $EDITOR .env ./scripts/foundrygate-onboarding-report ``` +`foundrygate-doctor` now also checks whether provider env placeholders referenced in `config.yaml` are actually present in `.env`. + `foundrygate-onboarding-report` now includes concrete OpenClaw, n8n, and CLI quickstart hints plus a staged provider-rollout view. Use it after every provider or client change to keep the deployment understandable for the next operator as well. It also prints a client matrix: diff --git a/foundrygate/onboarding.py b/foundrygate/onboarding.py index c9aafcc..0d37efa 100644 --- a/foundrygate/onboarding.py +++ b/foundrygate/onboarding.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import Any +import yaml from dotenv import dotenv_values, load_dotenv from .config import load_config @@ -23,6 +24,33 @@ def _is_unresolved_env(value: str) -> bool: return stripped.startswith("${") and stripped.endswith("}") +def collect_provider_env_requirements( + *, + config_path: str | Path | None = None, + env_file: str | Path | None = None, +) -> dict[str, list[str]]: + """Return which provider env variables are configured and which are missing.""" + resolved_config = Path(config_path) if config_path else Path.cwd() / "config.yaml" + resolved_env = _env_path(env_file) + env_values = dotenv_values(resolved_env) if resolved_env.exists() else {} + + with resolved_config.open(encoding="utf-8") as handle: + raw = yaml.safe_load(handle) or {} + + required: set[str] = set() + for provider in (raw.get("providers") or {}).values(): + if not isinstance(provider, dict): + continue + for field in ("api_key", "base_url"): + value = provider.get(field, "") + if isinstance(value, str) and _is_unresolved_env(value): + required.add(value.strip()[2:-1].split(":-", 1)[0]) + + present = sorted(name for name in required if env_values.get(name)) + missing = sorted(name for name in required if not env_values.get(name)) + return {"required": sorted(required), "present": present, "missing": missing} + + def _provider_ready(provider: dict[str, Any]) -> tuple[bool, str]: """Return whether one provider looks ready for onboarding.""" contract = provider.get("contract", "generic") @@ -254,6 +282,10 @@ def build_onboarding_report( suggestions.append("Keep auto_update disabled until the provider and client set is stable.") provider_rollout = _build_provider_rollout(providers, list(config.fallback_chain)) client_matrix = _build_client_matrix(client_profiles) + env_requirements = collect_provider_env_requirements( + config_path=config_path, + env_file=resolved_env, + ) enabled_presets = set(client_profiles.get("presets", [])) profile_names = set(client_profiles.get("profiles", {}).keys()) @@ -308,6 +340,7 @@ def build_onboarding_report( "env": { "exists": resolved_env.exists(), "provider_keys_present": sorted(key for key, value in env_values.items() if value), + "provider_requirements": env_requirements, }, "providers": { "total": len(providers), diff --git a/scripts/foundrygate-doctor b/scripts/foundrygate-doctor index cc66bd9..8237ce1 100755 --- a/scripts/foundrygate-doctor +++ b/scripts/foundrygate-doctor @@ -6,6 +6,9 @@ env_file="${FOUNDRYGATE_ENV_FILE:-$repo_root/.env}" config_file="${FOUNDRYGATE_CONFIG_FILE:-$repo_root/config.yaml}" status=0 +export FOUNDRYGATE_ENV_FILE="$env_file" +export FOUNDRYGATE_CONFIG_FILE="$config_file" + ok() { printf '[ok] %s\n' "$1" } @@ -54,6 +57,22 @@ else warn "no provider API key detected in $env_file" fi +python3 - <<'PY' +import os + +from foundrygate.onboarding import collect_provider_env_requirements + +requirements = collect_provider_env_requirements( + config_path=os.environ.get("FOUNDRYGATE_CONFIG_FILE"), + env_file=os.environ.get("FOUNDRYGATE_ENV_FILE"), +) + +for name in requirements["present"]: + print(f"[ok] provider env present: {name}") +for name in requirements["missing"]: + print(f"[warn] provider env missing: {name}") +PY + if command -v curl >/dev/null 2>&1; then if curl -fsS -m 2 http://127.0.0.1:8090/health >/dev/null 2>&1; then ok "local /health endpoint is reachable" diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index ff28ae5..05d6086 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -5,6 +5,7 @@ from foundrygate.onboarding import ( build_onboarding_report, build_onboarding_validation, + collect_provider_env_requirements, render_onboarding_report, render_onboarding_validation, ) @@ -53,6 +54,7 @@ def test_onboarding_report_marks_missing_api_keys_and_presets(tmp_path: Path): assert report["providers"]["total"] == 1 assert report["providers"]["ready"] == 0 assert report["providers"]["missing_api_keys"] == ["deepseek-chat"] + assert report["env"]["provider_requirements"]["missing"] == ["DEEPSEEK_API_KEY"] assert report["clients"]["presets"] == ["openclaw"] assert report["integrations"]["openclaw"]["recommended"] is True assert report["integrations"]["n8n"]["recommended"] is False @@ -262,6 +264,40 @@ def test_onboarding_report_marks_all_builtin_integrations_ready(tmp_path: Path): assert report["clients"]["matrix"][0]["has_rule"] is True +def test_collect_provider_env_requirements_tracks_present_and_missing(tmp_path: Path): + env_file = tmp_path / ".env" + env_file.write_text("DEEPSEEK_API_KEY=sk-demo\n", encoding="utf-8") + + config_file = tmp_path / "config.yaml" + config_file.write_text( + """ +providers: + deepseek-chat: + backend: openai-compat + base_url: "https://api.deepseek.com/v1" + api_key: "${DEEPSEEK_API_KEY}" + model: "deepseek-chat" + image-worker: + contract: image-provider + backend: openai-compat + base_url: "${IMAGE_PROVIDER_BASE_URL}" + api_key: "${IMAGE_PROVIDER_API_KEY}" + model: "image-model" +""".strip(), + encoding="utf-8", + ) + + requirements = collect_provider_env_requirements(config_path=config_file, env_file=env_file) + + assert requirements["required"] == [ + "DEEPSEEK_API_KEY", + "IMAGE_PROVIDER_API_KEY", + "IMAGE_PROVIDER_BASE_URL", + ] + assert requirements["present"] == ["DEEPSEEK_API_KEY"] + assert requirements["missing"] == ["IMAGE_PROVIDER_API_KEY", "IMAGE_PROVIDER_BASE_URL"] + + def test_onboarding_report_includes_client_matrix_and_unmatched_profile_warning( tmp_path: Path, ):