bggpipe init: guided, idempotent first-run setup
One command replaces the clone-era checklist: creates photos/ and data/, writes a default config.toml, prompts for the four credentials with hidden input (appended to a 0600 .env, only the missing ones, values never echoed), and offers the one-time Chromium download. Re-runs report status and fill gaps; without a TTY it reports instead of hanging. Groundwork for any future publishing path — PyPI or a bundled app both need exactly this wizard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ Full design lives in `bgg-shelf-pipeline-spec.md` (read it before changing pipel
|
|||||||
|
|
||||||
## Commands
|
## 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 <stage>` — run a pipeline stage. Non-secret settings come from `config.toml` (username, dirs, vision model, rate limit); `--config` overrides the path.
|
- `uv run bggpipe <stage>` — 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 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.
|
- `uv run ruff check` / `uv run ruff format` — lint (rules E, F, I, UP, B, SIM) and format.
|
||||||
|
|||||||
@@ -39,11 +39,12 @@ Every stage is idempotent and resumable: kill it mid-run, restart, lose nothing.
|
|||||||
```sh
|
```sh
|
||||||
git clone https://git.kestrelsnest.social/eric/bggpipe.git
|
git clone https://git.kestrelsnest.social/eric/bggpipe.git
|
||||||
cd bggpipe
|
cd bggpipe
|
||||||
uv sync # installs Python deps
|
uv sync # installs Python deps
|
||||||
uv run playwright install chromium # browser for the upload stage
|
uv run bggpipe init # guided setup: folders, credentials, browser download
|
||||||
cp .env.example .env # then fill in your keys
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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`).
|
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 |
|
| Variable | Used by | What it is |
|
||||||
|
|||||||
@@ -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()
|
@app.command()
|
||||||
def extract(
|
def extract(
|
||||||
only: Annotated[
|
only: Annotated[
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user