diff --git a/README.md b/README.md index d5d1627..7cbb7c3 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Your photos and every pipeline artifact live in the directory where you run it, The `init` wizard is idempotent — re-run it anytime to check status or add keys you skipped. It prompts for the credentials below (hidden input, saved to a `.env` it creates with owner-only permissions) and offers the one-time Playwright Chromium download. Prefer doing it by hand? Copy [.env.example](.env.example) beside your data, fill it in, and run `playwright install chromium` yourself. -Secrets live in environment variables only, never in config files, code, or logs. `.env` is gitignored. If you use [direnv](https://direnv.net/), the committed `.envrc` loads `.env` automatically after a one-time `direnv allow`; otherwise export the variables yourself (e.g. `set -a; source .env; set +a`). +Secrets live in environment variables only, never in config files, code, or logs, and `.env` is gitignored. Every `bggpipe` command loads `.env` from the working directory by itself — real environment variables always win over the file, so [direnv](https://direnv.net/) users and CI overrides keep working unchanged. | Variable | Used by | What it is | |---|---|---| diff --git a/src/bggpipe/cli.py b/src/bggpipe/cli.py index 7c51ba3..6a8cc6a 100644 --- a/src/bggpipe/cli.py +++ b/src/bggpipe/cli.py @@ -14,6 +14,17 @@ app = typer.Typer( no_args_is_help=True, ) + +@app.callback() +def _load_dotenv() -> None: + # credentials live in ./.env (written by init); load them so a fresh + # directory works without direnv or manual sourcing. Real environment + # variables always take precedence over the file. + from bggpipe.init_wizard import load_env_file + + load_env_file(Path(".env")) + + ConfigOpt = Annotated[ Path | None, typer.Option("--config", help="Path to config.toml (default: ./config.toml)"), diff --git a/src/bggpipe/init_wizard.py b/src/bggpipe/init_wizard.py index f898d72..93b53bc 100644 --- a/src/bggpipe/init_wizard.py +++ b/src/bggpipe/init_wizard.py @@ -91,6 +91,31 @@ class InitReport: warnings: list[str] = field(default_factory=list) +def load_env_file(env_path: Path) -> list[str]: + """Export .env values into this process's environment — only keys the + environment doesn't already set (a real env var always wins). Returns + the keys loaded. Same parsing rules as _env_file_keys; values go + straight into os.environ and are never printed or logged.""" + if not env_path.exists(): + return [] + loaded = [] + for line in env_path.read_text().splitlines(): + line = line.strip() + if "=" not in line or line.startswith("#"): + continue + key, _, value = line.partition("=") + key = key.strip() + if key.startswith("export "): + key = key.removeprefix("export ").strip() + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"": + value = value[1:-1] + if key and value and not os.environ.get(key): + os.environ[key] = value + loaded.append(key) + return loaded + + def _env_file_keys(env_path: Path) -> set[str]: """Key names with non-empty values in .env. Handles `export KEY=v` and quoted values; a quoted-empty value ("" / '') counts as NOT set. Values diff --git a/tests/test_init.py b/tests/test_init.py index 62775a6..4f452c0 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -190,3 +190,31 @@ def test_fresh_config_template_carries_both_vision_blocks(tmp_path, monkeypatch) cfg = load_config(tmp_path / "config.toml") assert cfg.vision_provider == "anthropic" assert cfg.model == "claude-sonnet-5" # active block applied cleanly + + +def test_load_env_file_exports_without_overriding(tmp_path, monkeypatch): + """A fresh directory must work straight after init: the CLI loads .env + itself. A real environment variable always outranks the file.""" + from bggpipe.init_wizard import load_env_file + + env = tmp_path / ".env" + env.write_text( + "# comment\n" + 'export BGG_USERNAME="someone"\n' + "BGG_API_TOKEN=tok-123\n" + "EMPTY=''\n" + "BGG_PASSWORD=from-file\n" + ) + monkeypatch.delenv("BGG_USERNAME", raising=False) + monkeypatch.delenv("BGG_API_TOKEN", raising=False) + monkeypatch.delenv("EMPTY", raising=False) + monkeypatch.setenv("BGG_PASSWORD", "from-environment") + + loaded = load_env_file(env) + assert set(loaded) == {"BGG_USERNAME", "BGG_API_TOKEN"} + assert os.environ["BGG_USERNAME"] == "someone" + assert os.environ["BGG_API_TOKEN"] == "tok-123" + assert "EMPTY" not in os.environ # quoted-empty means NOT set + assert os.environ["BGG_PASSWORD"] == "from-environment" # env wins + + assert load_env_file(tmp_path / "absent.env") == []