First finding of Eric's clean-room run, and the exact kind the rehearsal exists for: init writes credentials to .env, but nothing ever loaded it — the dev repo's committed .envrc + direnv did it invisibly, and a fresh directory has neither. The web banner then advised "run bggpipe init or load .env", circular counsel for someone who just ran init. A typer callback now loads ./.env before every command, using the same parsing rules as the wizard that writes it (export prefixes, quoted values, quoted-empty = unset). Real environment variables always outrank the file, so direnv setups and explicit overrides keep working unchanged. Verified in a scrubbed-environment clean room: the credentials banner is gone with nothing but .env present. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
288 lines
10 KiB
Python
288 lines
10 KiB
Python
"""`bggpipe init` — guided first-run setup, idempotent like every stage.
|
|
|
|
Inspects the working directory and only fills gaps: creates photos/ and
|
|
data/, writes a default config.toml when none exists, prompts for the
|
|
credentials missing from the environment and .env (hidden input, appended
|
|
to .env with 0600 permissions — values never echo and never reach logs),
|
|
and offers the one-time Playwright browser download. Re-running reports
|
|
status and prompts only for what is still missing; without a TTY it
|
|
prints the status report and exits instead of hanging on a prompt.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
import typer
|
|
|
|
from bggpipe.config import Config
|
|
|
|
CONFIG_TEMPLATE = """\
|
|
# Non-secret knobs for bggpipe. Everything account-related — including
|
|
# your BGG username — lives in .env (see .env.example), not here.
|
|
|
|
photos_dir = "photos"
|
|
data_dir = "data"
|
|
rate_limit_seconds = 2.0
|
|
|
|
# Which vision backend reads your shelf photos. Both recipes below stay
|
|
# on file; this line picks one.
|
|
vision_provider = "anthropic"
|
|
|
|
[vision.anthropic]
|
|
# reads ANTHROPIC_API_KEY from the environment
|
|
model = "claude-sonnet-5"
|
|
|
|
[vision."openai-compatible"]
|
|
# OpenAI, OpenRouter, or a local runtime (Ollama, LM Studio, vLLM).
|
|
# key_env names the env var holding the key; "" = endpoint needs none.
|
|
base_url = "http://localhost:11434/v1"
|
|
model = "qwen2.5vl:7b"
|
|
key_env = ""
|
|
"""
|
|
|
|
ENV_HEADER = """\
|
|
# bggpipe credentials — this file must stay out of version control.
|
|
"""
|
|
|
|
# (key, secret?, why it's needed, where to get it)
|
|
ENV_KEYS = (
|
|
(
|
|
"ANTHROPIC_API_KEY",
|
|
True,
|
|
"vision extraction (stage 1)",
|
|
"https://console.anthropic.com/",
|
|
),
|
|
(
|
|
"BGG_USERNAME",
|
|
False,
|
|
"diff/upload/enrich",
|
|
"your boardgamegeek.com account name",
|
|
),
|
|
(
|
|
"BGG_PASSWORD",
|
|
True,
|
|
"upload's website login (stage 5)",
|
|
"your boardgamegeek.com password",
|
|
),
|
|
(
|
|
"BGG_API_TOKEN",
|
|
True,
|
|
"resolve/diff/enrich (stages 2, 4, 6)",
|
|
"https://boardgamegeek.com/applications — approval takes a week+, "
|
|
"apply early; everything except upload works while you wait",
|
|
),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class InitReport:
|
|
created_dirs: list[str] = field(default_factory=list)
|
|
wrote_config: bool = False
|
|
keys_ready: list[str] = field(default_factory=list)
|
|
keys_written: list[str] = field(default_factory=list)
|
|
keys_missing: list[str] = field(default_factory=list)
|
|
browser_installed: bool = False
|
|
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
|
|
never outlive this parse and are never printed."""
|
|
if not env_path.exists():
|
|
return set()
|
|
present = set()
|
|
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 value:
|
|
present.add(key)
|
|
return present
|
|
|
|
|
|
def _quote_env_value(value: str) -> str:
|
|
"""Single-quote for `source`/direnv safety: spaces, $, backslashes and
|
|
quotes must survive the shell verbatim."""
|
|
return "'" + value.replace("'", "'\\''") + "'"
|
|
|
|
|
|
def _default_secret_prompt(label: str) -> str:
|
|
return typer.prompt(label, default="", show_default=False, hide_input=True)
|
|
|
|
|
|
def _default_plain_prompt(label: str) -> str:
|
|
return typer.prompt(label, default="", show_default=False)
|
|
|
|
|
|
def _default_install_browser() -> bool:
|
|
result = subprocess.run( # noqa: S603 — fixed argv, no shell
|
|
[sys.executable, "-m", "playwright", "install", "chromium"],
|
|
check=False,
|
|
)
|
|
return result.returncode == 0
|
|
|
|
|
|
def run_init(
|
|
cfg: Config,
|
|
*,
|
|
project_dir: Path | None = None,
|
|
interactive: bool | None = None,
|
|
plain_prompt: Callable[[str], str] = _default_plain_prompt,
|
|
secret_prompt: Callable[[str], str] = _default_secret_prompt,
|
|
confirm: Callable[[str], bool] | None = None,
|
|
install_browser: Callable[[], bool] = _default_install_browser,
|
|
) -> InitReport:
|
|
project_dir = project_dir or Path.cwd()
|
|
if interactive is None:
|
|
interactive = sys.stdin.isatty()
|
|
if confirm is None:
|
|
confirm = lambda label: typer.confirm(label, default=True) # noqa: E731
|
|
|
|
report = InitReport()
|
|
typer.echo("bggpipe setup — checks what exists, fills only the gaps.\n")
|
|
if trap := cfg.tracked_data_warning():
|
|
typer.echo(f"WARNING: {trap}\n", err=True)
|
|
report.warnings.append(trap)
|
|
|
|
# -- directories ----------------------------------------------------
|
|
for path in (project_dir / cfg.photos_dir, project_dir / cfg.data_dir):
|
|
if not path.is_dir():
|
|
path.mkdir(parents=True)
|
|
report.created_dirs.append(str(path))
|
|
typer.echo(f" created {path}/")
|
|
else:
|
|
typer.echo(f" found {path}/")
|
|
|
|
# -- config.toml ----------------------------------------------------
|
|
config_path = project_dir / "config.toml"
|
|
if not config_path.exists():
|
|
config_path.write_text(CONFIG_TEMPLATE)
|
|
report.wrote_config = True
|
|
typer.echo(f" wrote {config_path} (defaults — edit if you like)")
|
|
else:
|
|
typer.echo(f" found {config_path}")
|
|
|
|
# -- credentials ----------------------------------------------------
|
|
# the vision key follows the configured provider: an openai-compatible
|
|
# setup prompts for ITS key env (or none, for a local endpoint)
|
|
from bggpipe.config import load_config
|
|
|
|
active = load_config(config_path if config_path.exists() else None)
|
|
env_keys = list(ENV_KEYS)
|
|
if active.vision_provider != "anthropic":
|
|
env_keys = [k for k in env_keys if k[0] != "ANTHROPIC_API_KEY"]
|
|
if active.vision_key_env:
|
|
env_keys.insert(
|
|
0,
|
|
(
|
|
active.vision_key_env,
|
|
True,
|
|
"vision extraction via "
|
|
+ (active.vision_base_url or "the configured endpoint"),
|
|
"your vision provider's console",
|
|
),
|
|
)
|
|
else:
|
|
typer.echo(
|
|
" vision: openai-compatible endpoint with no key configured "
|
|
"(local runtime) — nothing to prompt for"
|
|
)
|
|
env_path = project_dir / ".env"
|
|
in_file = _env_file_keys(env_path)
|
|
for key, secret, why, where in env_keys:
|
|
if os.environ.get(key) or key in in_file:
|
|
report.keys_ready.append(key)
|
|
typer.echo(f" {key}: set")
|
|
continue
|
|
if not interactive:
|
|
report.keys_missing.append(key)
|
|
continue
|
|
typer.echo(f"\n {key} — needed for {why}\n ({where})")
|
|
prompt = secret_prompt if secret else plain_prompt
|
|
value = prompt(f" {key} (enter to skip)").strip()
|
|
if not value:
|
|
report.keys_missing.append(key)
|
|
continue
|
|
is_new_file = not env_path.exists()
|
|
# owner-only from the FIRST byte — chmod-after-write leaves a
|
|
# world-readable window holding a credential
|
|
fd = os.open(env_path, os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o600)
|
|
with os.fdopen(fd, "a") as f:
|
|
if is_new_file:
|
|
f.write(ENV_HEADER)
|
|
f.write(f"{key}={_quote_env_value(value)}\n")
|
|
env_path.chmod(0o600) # older files created by hand tighten up too
|
|
report.keys_written.append(key)
|
|
typer.echo(f" {key}: saved to {env_path}")
|
|
|
|
# -- browser --------------------------------------------------------
|
|
if interactive and confirm(
|
|
"\nDownload/verify the Chromium browser for the upload stage? "
|
|
"(one-time, ~100MB; skippable)"
|
|
):
|
|
report.browser_installed = install_browser()
|
|
if not report.browser_installed:
|
|
typer.echo(" browser install failed — re-run init to retry")
|
|
|
|
# -- summary --------------------------------------------------------
|
|
typer.echo("\nStatus:")
|
|
for key in report.keys_ready + report.keys_written:
|
|
typer.echo(f" ready {key}")
|
|
for key in report.keys_missing:
|
|
typer.echo(f" missing {key}")
|
|
if report.keys_missing:
|
|
typer.echo(
|
|
"\nMissing keys are fine to start: extract only needs "
|
|
"ANTHROPIC_API_KEY, and re-running `bggpipe init` prompts for "
|
|
"the rest whenever you have them."
|
|
)
|
|
typer.echo(
|
|
"\nNext: drop shelf photos into "
|
|
f"{project_dir / cfg.photos_dir}/ and run `bggpipe extract`."
|
|
)
|
|
if env_path.exists() and not os.environ.get("BGG_USERNAME"):
|
|
typer.echo(
|
|
"Load .env into your shell first: `direnv allow` (if you use "
|
|
"direnv) or `set -a; source .env; set +a`."
|
|
)
|
|
return report
|