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:
Eric Wagoner
2026-08-02 16:56:28 -04:00
co-authored by Claude Fable 5
parent 10f65d8aba
commit bf9795235a
5 changed files with 336 additions and 4 deletions
+9
View File
@@ -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[
+203
View File
@@ -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