diff --git a/CLAUDE.md b/CLAUDE.md index 0a8b822..33a98b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ Full design lives in `bgg-shelf-pipeline-spec.md` (read it before changing pipel ## Commands -- `uv sync` — install deps (Python 3.12+, managed by **uv**; use `uv add`, never pip). Playwright needs a one-time `uv run playwright install chromium`. +- `uv sync` — install deps (Python 3.12+, managed by **uv**; use `uv add`, never pip). `uv run bggpipe init` handles first-run setup (folders, .env credentials, the one-time `playwright install chromium`). - `uv run bggpipe ` — run a pipeline stage. Non-secret settings come from `config.toml` (username, dirs, vision model, rate limit); `--config` overrides the path. - `uv run pytest` — the suite runs fully offline against fixtures. Tests marked `live` hit the real BGG API (read-only) and are skipped unless you pass `--run-live`. - `uv run ruff check` / `uv run ruff format` — lint (rules E, F, I, UP, B, SIM) and format. diff --git a/README.md b/README.md index e31ff55..3532c61 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,12 @@ Every stage is idempotent and resumable: kill it mid-run, restart, lose nothing. ```sh git clone https://git.kestrelsnest.social/eric/bggpipe.git cd bggpipe -uv sync # installs Python deps -uv run playwright install chromium # browser for the upload stage -cp .env.example .env # then fill in your keys +uv sync # installs Python deps +uv run bggpipe init # guided setup: folders, credentials, browser download ``` +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? `cp .env.example .env`, fill it in, and run `uv 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`). | Variable | Used by | What it is | diff --git a/src/bggpipe/cli.py b/src/bggpipe/cli.py index 4a7add3..5fd3a21 100644 --- a/src/bggpipe/cli.py +++ b/src/bggpipe/cli.py @@ -20,6 +20,15 @@ ConfigOpt = Annotated[ ] +@app.command() +def init(config: ConfigOpt = None) -> None: + """Guided first-run setup: folders, config, credentials, browser.""" + from bggpipe.init_wizard import run_init + + cfg = load_config(config) + run_init(cfg) + + @app.command() def extract( only: Annotated[ diff --git a/src/bggpipe/init_wizard.py b/src/bggpipe/init_wizard.py new file mode 100644 index 0000000..63504d7 --- /dev/null +++ b/src/bggpipe/init_wizard.py @@ -0,0 +1,203 @@ +"""`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" +model = "claude-sonnet-5" +rate_limit_seconds = 2.0 +""" + +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 + + +def _env_file_keys(env_path: Path) -> set[str]: + """Key names with non-empty values in .env. Values are never read into + variables that 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 "=" in line and not line.startswith("#"): + key, _, value = line.partition("=") + if value.strip(): + present.add(key.strip()) + return present + + +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") + + # -- 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 ---------------------------------------------------- + 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() + with env_path.open("a") as f: + if is_new_file: + f.write(ENV_HEADER) + f.write(f"{key}={value}\n") + env_path.chmod(0o600) # credentials: owner-only, every time + 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 diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..0576b12 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,119 @@ +"""Init-wizard tests: idempotent setup, credential hygiene, no prompts +without a TTY. All prompts and the browser installer are injected.""" + +from __future__ import annotations + +import os + +from bggpipe.config import Config +from bggpipe.init_wizard import run_init + + +def _clear_env(monkeypatch): + for key in ("ANTHROPIC_API_KEY", "BGG_USERNAME", "BGG_PASSWORD", "BGG_API_TOKEN"): + monkeypatch.delenv(key, raising=False) + + +def _run(tmp_path, *, interactive=True, answers=None, confirm=False, installer=None): + given = dict(answers or {}) + + def prompt(label: str) -> str: + for key in list(given): + if key in label: + return given.pop(key) + return "" + + return run_init( + Config(), + project_dir=tmp_path, + interactive=interactive, + plain_prompt=prompt, + secret_prompt=prompt, + confirm=lambda label: confirm, + install_browser=installer or (lambda: True), + ) + + +def test_creates_dirs_config_and_env_from_nothing(tmp_path, monkeypatch): + _clear_env(monkeypatch) + report = _run( + tmp_path, + answers={"ANTHROPIC_API_KEY": "sk-test-123", "BGG_USERNAME": "eric"}, + ) + assert (tmp_path / "photos").is_dir() and (tmp_path / "data").is_dir() + assert (tmp_path / "config.toml").exists() + env = (tmp_path / ".env").read_text() + assert "ANTHROPIC_API_KEY=sk-test-123" in env + assert "BGG_USERNAME=eric" in env + assert report.keys_written == ["ANTHROPIC_API_KEY", "BGG_USERNAME"] + assert set(report.keys_missing) == {"BGG_PASSWORD", "BGG_API_TOKEN"} + + +def test_env_file_gets_owner_only_permissions(tmp_path, monkeypatch): + _clear_env(monkeypatch) + _run(tmp_path, answers={"BGG_PASSWORD": "hunter2hunter2"}) + mode = os.stat(tmp_path / ".env").st_mode & 0o777 + assert mode == 0o600 + + +def test_rerun_prompts_only_for_missing_keys(tmp_path, monkeypatch): + _clear_env(monkeypatch) + _run(tmp_path, answers={"BGG_USERNAME": "eric"}) + before = (tmp_path / ".env").read_text() + + report = _run(tmp_path, answers={"BGG_API_TOKEN": "tok-abc"}) + env = (tmp_path / ".env").read_text() + assert env.startswith(before) # existing content untouched, appended only + assert "BGG_USERNAME" in report.keys_ready # not re-prompted + assert report.keys_written == ["BGG_API_TOKEN"] + + +def test_env_vars_count_as_configured(tmp_path, monkeypatch): + _clear_env(monkeypatch) + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-from-direnv") + report = _run(tmp_path) + assert "ANTHROPIC_API_KEY" in report.keys_ready + assert not (tmp_path / ".env").exists() # nothing to write + + +def test_non_interactive_reports_without_prompting(tmp_path, monkeypatch): + _clear_env(monkeypatch) + + def explode(label: str) -> str: + raise AssertionError("prompted without a TTY") + + report = run_init( + Config(), + project_dir=tmp_path, + interactive=False, + plain_prompt=explode, + secret_prompt=explode, + confirm=lambda label: (_ for _ in ()).throw(AssertionError("confirmed")), + install_browser=lambda: True, + ) + assert len(report.keys_missing) == 4 + assert not report.browser_installed + + +def test_existing_config_and_dirs_are_left_alone(tmp_path, monkeypatch): + _clear_env(monkeypatch) + (tmp_path / "photos").mkdir() + (tmp_path / "config.toml").write_text('photos_dir = "shots"\n') + report = _run(tmp_path) + assert (tmp_path / "config.toml").read_text() == 'photos_dir = "shots"\n' + assert not report.wrote_config + + +def test_browser_install_runs_only_on_confirm(tmp_path, monkeypatch): + _clear_env(monkeypatch) + ran = [] + _run(tmp_path, confirm=True, installer=lambda: ran.append(1) or True) + assert ran == [1] + _run(tmp_path, confirm=False, installer=lambda: ran.append(2) or True) + assert ran == [1] + + +def test_secret_values_never_appear_in_output(tmp_path, monkeypatch, capsys): + _clear_env(monkeypatch) + _run(tmp_path, answers={"BGG_PASSWORD": "s3cret-value-xyz"}) + assert "s3cret-value-xyz" not in capsys.readouterr().out