From 12b61a04e34aa8d5ef2196af0bd719eda8025808 Mon Sep 17 00:00:00 2001 From: subzeroid <143403577+subzeroid@users.noreply.github.com> Date: Thu, 28 May 2026 05:30:39 +0300 Subject: [PATCH] fix: clarify backend setup names --- README.md | 19 +++++-- docs/backends.md | 18 +++--- docs/cli-reference.md | 2 +- docs/index.md | 2 +- docs/installation.md | 14 +++-- insto/backends/__init__.py | 30 ++++++---- insto/cli.py | 89 +++++++++++++++++++----------- insto/commands/operational.py | 2 +- insto/config.py | 44 +++++++++++---- tests/test_backend_contract.py | 28 ++++++++++ tests/test_cli.py | 70 +++++++++++++++++++++++ tests/test_commands_operational.py | 12 ++-- tests/test_config.py | 67 +++++++++++++++------- uv.lock | 2 +- 14 files changed, 295 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 05f6100..8e233d6 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,14 @@ uv tool install 'insto[aiograpi]' pipx install 'insto[aiograpi]' ``` -`insto setup` then offers a `hiker | aiograpi` choice and prompts for +If `insto` is already installed through `pipx` and you later switch to +`aiograpi`, add the optional dependency to the existing tool venv: + +```sh +pipx inject insto aiograpi +``` + +`insto setup` then offers a `hikerapi | aiograpi` choice and prompts for the right credentials. See [`docs/backends.md`](docs/backends.md) for the trade-offs and the account-ban risk on aiograpi. @@ -90,17 +97,17 @@ The token is read with `getpass` so it does not echo to the terminal; pass Token precedence is **flag > env (`HIKERAPI_TOKEN`) > config.toml**; the same precedence applies to the proxy (`--proxy`, `HIKERAPI_PROXY`, -`[hiker].proxy`). `socks5h://` (Tor) and `http://` proxies are both +`[hikerapi].proxy`). `socks5h://` (Tor) and `http://` proxies are both supported. ### Environment variables | Variable | Purpose | |-------------------|---------------------------------------------------------------------| -| `HIKERAPI_TOKEN` | API token (overrides `[hiker].token` in config.toml) | -| `HIKERAPI_PROXY` | Proxy URL (overrides `[hiker].proxy`) | +| `HIKERAPI_TOKEN` | API token (overrides `[hikerapi].token` in config.toml) | +| `HIKERAPI_PROXY` | Proxy URL (overrides `[hikerapi].proxy`) | | `INSTO_HOME` | Override the default `~/.insto/` config root | -| `INSTO_BACKEND` | `hiker` (default) / `aiograpi` / `fake` (e2e suite). Same as `--backend` and `[backend]` in `config.toml` | +| `INSTO_BACKEND` | `hikerapi` (default) / `aiograpi` / `fake` (e2e suite). Same as `--backend` and `[backend]` in `config.toml`; legacy `hiker` is still accepted | ## How insto compares to other Instagram OSINT tools @@ -200,7 +207,7 @@ when the target list exceeds the confirmation threshold. | `--maltego [PATH or -]` | Maltego entity-import CSV (alias for `--output-format maltego`) | | `--output-format {json,csv,maltego}` | Explicit format selector | | `--limit N` / `--no-download` | Per-command paging cap and media opt-out | -| `--backend {hiker,aiograpi}` | Backend selector for this invocation (overrides `$INSTO_BACKEND` and `config.toml`) | +| `--backend {hikerapi,aiograpi}` | Backend selector for this invocation (overrides `$INSTO_BACKEND` and `config.toml`) | | `--no-progress` | Suppress tqdm bars + spinner on long commands (`/fans`, `/wliked`, `/wcommented`, `/dossier`) | | `--yes / -y` | Skip confirmation prompts (required for `/batch -`) | | `--verbose` / `--debug` | Logging level for `~/.insto/logs/insto.log` | diff --git a/docs/backends.md b/docs/backends.md index 2890620..b616ff3 100644 --- a/docs/backends.md +++ b/docs/backends.md @@ -4,7 +4,7 @@ The contract lives in `insto/backends/_base.py:OSINTBackend`. Two implementations ship: -| | **hiker** (default install) | **aiograpi** (`insto[aiograpi]`) | +| | **hikerapi** (default install) | **aiograpi** (`insto[aiograpi]`) | |---|---|---| | Authentication | API token | Username + password (+ 2FA) | | Cost | Pay-per-call | Free | @@ -17,14 +17,14 @@ The contract lives in `insto/backends/_base.py:OSINTBackend`. Two implementation ## Pick a backend -Default is **hiker**. Switch to aiograpi when you need data behind Instagram's login wall — private profiles you follow or posts on accounts that 403 from logged-out HTTP. For OSINT on public profiles, hiker is the right choice nine times out of ten and carries no account-ban risk. +Default is **hikerapi**. Switch to aiograpi when you need data behind Instagram's login wall — private profiles you follow or posts on accounts that 403 from logged-out HTTP. For OSINT on public profiles, HikerAPI is the right choice nine times out of ten and carries no account-ban risk. You can flip backends mid-session at any time by editing `~/.insto/config.toml` (or running `insto setup` again): ```toml backend = "aiograpi" -[hiker] +[hikerapi] token = "hk_live_..." # kept around in case you flip back [aiograpi] @@ -39,13 +39,13 @@ Precedence is **flag > env > toml > default** for every key: | Key | Flag | Env | |---|---|---| | `backend` | _(no flag yet)_ | `INSTO_BACKEND` | -| `hiker.token` | `--hiker-token` | `HIKERAPI_TOKEN` | -| `hiker.proxy` | `--proxy` | `HIKERAPI_PROXY` | +| `hikerapi.token` | `--hiker-token` | `HIKERAPI_TOKEN` | +| `hikerapi.proxy` | `--proxy` | `HIKERAPI_PROXY` | | `aiograpi.username` | _(no flag)_ | `AIOGRAPI_USERNAME` | | `aiograpi.password` | _(no flag)_ | `AIOGRAPI_PASSWORD` | | `aiograpi.totp_seed` | _(no flag)_ | `AIOGRAPI_TOTP_SEED` | -## hiker — HikerAPI +## hikerapi — HikerAPI Authenticates with a [HikerAPI](https://hikerapi.com/p/6k1q1388) token. Pay-per-call, no Instagram login, no account-ban risk. @@ -61,7 +61,7 @@ What the backend handles: - **Retries** — `with_retry` decorator. `RateLimited` (with `retry_after`) and `Transient` retry with exponential backoff + jitter; `AuthInvalid`, `QuotaExhausted`, `SchemaDrift`, `Banned` propagate immediately. - **Schema drift** — every mapper raises `SchemaDrift(endpoint, missing_field)` instead of `KeyError` when HikerAPI's documented fields move. Counter shown in `/health`. - **Cursor safety** — every `iter_*` method has a 1000-page hard cap so a server-side cursor loop cannot DOS the operator. -- **Proxy** — `--proxy` / `HIKERAPI_PROXY` / `[hiker].proxy` plumbed through `httpx`. `socks5h://` (Tor) supported. +- **Proxy** — `--proxy` / `HIKERAPI_PROXY` / `[hikerapi].proxy` plumbed through `httpx`. `socks5h://` (Tor) supported. ### HikerAPI 403 @@ -116,7 +116,7 @@ Don't have the seed? Either re-enable 2FA in Instagram's settings to capture it, `Profile.access` is one of: -| State | hiker | aiograpi | +| State | hikerapi | aiograpi | |---|---|---| | `public` | ✓ | ✓ | | `private` | ✓ | ✓ | @@ -124,4 +124,4 @@ Don't have the seed? Either re-enable 2FA in Instagram's settings to capture it, | `blocked` | n/a | ✓ (only via aiograpi error response) | | `deleted` | ✓ | ✓ | -Commands that strictly need a logged-in account carry a `requires=("followed",)` annotation. They run cleanly on aiograpi; on hiker they exit with a typed message. +Commands that strictly need a logged-in account carry a `requires=("followed",)` annotation. They run cleanly on aiograpi; on hikerapi they exit with a typed message. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 6ceb347..08c6452 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -17,7 +17,7 @@ Every command inherits these via the global parser. Flag conflicts (e.g. `--json | `--yes` | Skip interactive confirmations (`/batch` over 25 targets, `/purge`). | | `--proxy URL` | Per-call proxy. Schemes: `http://`, `https://`, `socks5h://` (Tor). | | `--hiker-token TOKEN` | Per-call HikerAPI token override. | -| `--backend {hiker,aiograpi}` | Per-invocation backend selector (overrides `$INSTO_BACKEND` and `config.toml`). | +| `--backend {hikerapi,aiograpi}` | Per-invocation backend selector (overrides `$INSTO_BACKEND` and `config.toml`). | | `--no-progress` | Suppress tqdm bars + `⢿ ...` spinner on long-running commands. | | `--non-interactive` | `insto setup` only — read every value from env vars without prompts. CI/automation. | | `--verbose` / `--debug` | Bump log level (writes to `~/.insto/logs/insto.log`, rotated). | diff --git a/docs/index.md b/docs/index.md index 4c43c09..9204e17 100644 --- a/docs/index.md +++ b/docs/index.md @@ -44,7 +44,7 @@ Two surfaces over the same command grammar: ## Pick a backend -| | **hiker** (default) | **aiograpi** (`insto[aiograpi]`) | +| | **hikerapi** (default) | **aiograpi** (`insto[aiograpi]`) | |---|---|---| | Auth | API token | Instagram login + 2FA | | Cost | Pay-per-call, [100 free requests](https://hikerapi.com/p/6k1q1388) at signup (no card) | Free | diff --git a/docs/installation.md b/docs/installation.md index b0b97d8..edb9706 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -41,7 +41,13 @@ pipx install 'insto[aiograpi]' pip install 'insto[aiograpi]' # in a venv only — see PEP 668 below ``` -After install, `insto setup` will offer `backend (hiker | aiograpi)` and prompt for the credentials of the chosen backend. See [Backends](backends.md) for the trade-offs and the account-ban risk. +If `insto` is already installed through `pipx` and you later switch to `aiograpi`, add the optional dependency to the existing tool venv: + +```sh +pipx inject insto aiograpi +``` + +After install, `insto setup` will offer `backend (hikerapi | aiograpi)` and prompt for the credentials of the chosen backend. See [Backends](backends.md) for the trade-offs and the account-ban risk. ### What about `pip install insto`? @@ -90,8 +96,8 @@ insto setup Interactive wizard. Writes `~/.insto/config.toml` (mode `0600`) with: -- `hiker.token` — your [HikerAPI](https://hikerapi.com/p/6k1q1388) access key. New here? Sign up via for **100 free requests** (no card required) to try the CLI. -- `hiker.proxy` (optional) — `http://`, `https://`, or `socks5h://` proxy URL. +- `hikerapi.token` — your [HikerAPI](https://hikerapi.com/tokens) access key. +- `hikerapi.proxy` (optional) — `http://`, `https://`, or `socks5h://` proxy URL. - `output_dir` — where downloads and exports land (resolved to absolute). - `db_path` — where the sqlite store lives (default `~/.insto/store.db`). @@ -100,6 +106,6 @@ Token can also live in: - `--hiker-token ` — per-call flag (overrides everything else). - `HIKERAPI_TOKEN` env — overrides the toml file. -Precedence: **flag > env > toml**. Same shape for `--proxy` / `HIKERAPI_PROXY` / `[hiker].proxy`. +Precedence: **flag > env > toml**. Same shape for `--proxy` / `HIKERAPI_PROXY` / `[hikerapi].proxy`. `~/.insto/` is created mode `0700`; `store.db` and `config.toml` are `0600`. The setup wizard refuses to write a world-readable file. diff --git a/insto/backends/__init__.py b/insto/backends/__init__.py index d12fc3d..8312bbc 100644 --- a/insto/backends/__init__.py +++ b/insto/backends/__init__.py @@ -6,7 +6,7 @@ runtime dependency footprint) of every backend at once. Practically: `import insto` does not import `hikerapi`. Only the -`make_backend("hiker", ...)` call does — and that import lives inside the +`make_backend("hikerapi", ...)` call does — and that import lives inside the function body. Setting `INSTO_BACKEND=fake` in the environment overrides the requested @@ -21,8 +21,15 @@ from typing import Any from insto.backends._base import OSINTBackend +from insto.config import BACKEND_AIOGRAPI, BACKEND_FAKE, BACKEND_HIKERAPI, LEGACY_BACKEND_HIKER BACKEND_OVERRIDE_ENV = "INSTO_BACKEND" +AIOGRAPI_INSTALL_HINT = ( + "aiograpi backend requested but the `aiograpi` package is not installed. " + "For an existing pipx install, run: `pipx inject insto aiograpi`. " + "Fresh installs can use: `pipx install 'insto[aiograpi]'` or " + "`uv tool install --force 'insto[aiograpi]'`." +) __all__ = ["BACKEND_OVERRIDE_ENV", "OSINTBackend", "make_backend"] @@ -31,7 +38,8 @@ def make_backend(name: str, **opts: Any) -> OSINTBackend: """Construct a backend by short name. Known names: - "hiker" — `HikerBackend` (HikerAPI SDK). Imports `hikerapi` lazily. + "hikerapi" — `HikerBackend` (HikerAPI SDK). Imports `hikerapi` lazily. + "hiker" — legacy alias for "hikerapi". "fake" — `FakeBackendProd`, hardcoded in-process data for E2E tests. Selected when `INSTO_BACKEND=fake` is set even if the caller asked for another backend. @@ -41,25 +49,25 @@ def make_backend(name: str, **opts: Any) -> OSINTBackend: override = os.environ.get(BACKEND_OVERRIDE_ENV) if override: name = override - if name == "hiker": + if name in {BACKEND_HIKERAPI, LEGACY_BACKEND_HIKER}: from insto.backends.hiker import HikerBackend return HikerBackend(**opts) - if name == "aiograpi": + if name == BACKEND_AIOGRAPI: # aiograpi is an optional dependency: gate the import so the # default install (hiker-only) does not have to ship it. If the # user did not install `insto[aiograpi]`, give them the exact # command to run. try: from insto.backends.aiograpi import AiograpiBackend + + return AiograpiBackend(**opts) except ModuleNotFoundError as exc: # pragma: no cover — environment dependent - raise RuntimeError( - "aiograpi backend requested but the `aiograpi` package is not " - "installed. Run: `pip install 'insto[aiograpi]'` " - "(or `uv tool install 'insto[aiograpi]'` / `pipx install 'insto[aiograpi]'`)." - ) from exc - return AiograpiBackend(**opts) - if name == "fake": + missing = getattr(exc, "name", None) + if missing == "aiograpi" or "aiograpi" in str(exc): + raise RuntimeError(AIOGRAPI_INSTALL_HINT) from exc + raise + if name == BACKEND_FAKE: from insto.backends._fake import FakeBackendProd return FakeBackendProd(**opts) diff --git a/insto/cli.py b/insto/cli.py index eb9e0b0..80d0542 100644 --- a/insto/cli.py +++ b/insto/cli.py @@ -46,9 +46,12 @@ parse_command_line, ) from insto.config import ( + BACKEND_AIOGRAPI, + BACKEND_HIKERAPI, Config, config_dir, load_config, + normalize_backend, write_config, ) from insto.exceptions import ( @@ -70,6 +73,7 @@ LOG_FILENAME = "insto.log" LOG_MAX_BYTES = 5 * 1024 * 1024 LOG_BACKUP_COUNT = 3 +HIKERAPI_TOKENS_URL = "https://hikerapi.com/tokens" SETUP_HINT = "no HIKERAPI_TOKEN configured. Run `insto setup` to create one." @@ -212,7 +216,8 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument( "--backend", - choices=("hiker", "aiograpi"), + type=normalize_backend, + choices=(BACKEND_HIKERAPI, BACKEND_AIOGRAPI), default=None, help="backend selector for this invocation (overrides $INSTO_BACKEND and config.toml)", ) @@ -276,15 +281,15 @@ def _build_backend(config: Config) -> Any: """Construct the backend the active config selects. Centralised so both the one-shot CLI and the REPL get the same - selection logic (hiker / aiograpi). aiograpi import-failures bubble + selection logic (hikerapi / aiograpi). aiograpi import-failures bubble up as `RuntimeError` from `make_backend`; the caller's existing `_format_error` handles them. """ from insto.backends import make_backend - if config.backend == "aiograpi": + if config.backend == BACKEND_AIOGRAPI: return make_backend( - "aiograpi", + BACKEND_AIOGRAPI, username=config.aiograpi_username, password=config.aiograpi_password, totp_seed=config.aiograpi_totp_seed, @@ -292,7 +297,7 @@ def _build_backend(config: Config) -> Any: proxy=config.hiker_proxy, ) return make_backend( - "hiker", + BACKEND_HIKERAPI, token=config.hiker_token, proxy=config.hiker_proxy, ) @@ -330,15 +335,17 @@ def _run_setup_non_interactive(*, out: IO[str] | None = None) -> int: """Setup driven entirely by env-vars + existing config — no prompts. Resolution order per field: env-var → existing config.toml → built-in - default. Errors on missing required fields (hiker.token when - backend=hiker, aiograpi credentials when backend=aiograpi) so a CI + default. Errors on missing required fields (hikerapi.token when + backend=hikerapi, aiograpi credentials when backend=aiograpi) so a CI run fails loudly instead of writing a half-broken config. """ stream = out if out is not None else sys.stdout existing = _safe_load_config() - backend = os.environ.get("INSTO_BACKEND") or (existing.backend if existing else None) or "hiker" - if backend not in {"hiker", "aiograpi"}: + backend = normalize_backend( + os.environ.get("INSTO_BACKEND") or (existing.backend if existing else None) + ) + if backend not in {BACKEND_HIKERAPI, BACKEND_AIOGRAPI}: print(f"--non-interactive: unknown backend {backend!r}", file=sys.stderr) return 2 @@ -367,14 +374,14 @@ def _run_setup_non_interactive(*, out: IO[str] | None = None) -> int: ) # Required-field guard: fail loudly so CI catches missing secrets. - if backend == "hiker" and not token: + if backend == BACKEND_HIKERAPI and not token: print( - "--non-interactive: backend=hiker but HIKERAPI_TOKEN is unset and " - "config.toml has no [hiker].token. Set the env var or run interactive setup.", + "--non-interactive: backend=hikerapi but HIKERAPI_TOKEN is unset and " + "config.toml has no [hikerapi].token. Set the env var or run interactive setup.", file=sys.stderr, ) return 2 - if backend == "aiograpi" and not (aio_user and aio_pass): + if backend == BACKEND_AIOGRAPI and not (aio_user and aio_pass): print( "--non-interactive: backend=aiograpi but AIOGRAPI_USERNAME / " "AIOGRAPI_PASSWORD are missing. Set both env vars or run interactive setup.", @@ -389,7 +396,7 @@ def _run_setup_non_interactive(*, out: IO[str] | None = None) -> int: if proxy: hiker["proxy"] = proxy if hiker: - payload["hiker"] = hiker + payload["hikerapi"] = hiker aio_section: dict[str, Any] = {} if aio_user: aio_section["username"] = aio_user @@ -438,30 +445,34 @@ def _run_setup( print("insto setup — writes ~/.insto/config.toml (mode 0600)", file=stream) print("press Enter to keep the shown default; values are masked on display.", file=stream) - backend_default = (existing.backend if existing else None) or "hiker" - backend_input = prompt(f"backend (hiker | aiograpi) [{backend_default}]: ").strip().lower() - backend = backend_input or backend_default - if backend not in {"hiker", "aiograpi"}: - print(f"unknown backend {backend!r}; falling back to hiker", file=stream) - backend = "hiker" + backend_default = normalize_backend(existing.backend if existing else None) + backend_input = prompt(f"backend (hikerapi | aiograpi) [{backend_default}]: ").strip().lower() + backend = normalize_backend(backend_input or backend_default) + if backend not in {BACKEND_HIKERAPI, BACKEND_AIOGRAPI}: + print(f"unknown backend {backend!r}; falling back to hikerapi", file=stream) + backend = BACKEND_HIKERAPI token_default = existing.hiker_token if existing else None - if backend == "hiker": + if backend == BACKEND_HIKERAPI: if token_default: token_disp = f"***{token_default[-4:]}" if len(token_default) >= 4 else "***" - token_input = secret_prompt(f"hiker.token [{token_disp}] (input hidden): ").strip() + token_input = secret_prompt( + f"hikerapi.token [{token_disp}] (get one: {HIKERAPI_TOKENS_URL}) (input hidden): " + ).strip() else: - token_input = secret_prompt("hiker.token (input hidden): ").strip() + token_input = secret_prompt( + f"hikerapi.token (get one: {HIKERAPI_TOKENS_URL}) (input hidden): " + ).strip() token = token_input or token_default else: - # Keep an existing hiker.token alive even when switching to aiograpi — + # Keep an existing hikerapi.token alive even when switching to aiograpi — # an operator may want to flip back without re-entering the secret. token = token_default aio_user = existing.aiograpi_username if existing else None aio_pass = existing.aiograpi_password if existing else None aio_totp = existing.aiograpi_totp_seed if existing else None - if backend == "aiograpi": + if backend == BACKEND_AIOGRAPI: u_in = prompt(f"aiograpi.username [{aio_user or '(none)'}]: ").strip() if u_in: aio_user = u_in @@ -513,7 +524,7 @@ def _run_setup( if proxy: hiker["proxy"] = proxy if hiker: - payload["hiker"] = hiker + payload["hikerapi"] = hiker aio_section: dict[str, Any] = {} if aio_user: aio_section["username"] = aio_user @@ -529,9 +540,9 @@ def _run_setup( path = write_config(payload) print(f"wrote {path}", file=stream) - if backend == "hiker" and not token: + if backend == BACKEND_HIKERAPI and not token: print(SETUP_HINT, file=stream) - if backend == "aiograpi" and not (aio_user and aio_pass): + if backend == BACKEND_AIOGRAPI and not (aio_user and aio_pass): print( "aiograpi backend selected but credentials are incomplete; " "re-run `insto setup` to add username/password.", @@ -590,10 +601,12 @@ async def _run_oneshot( # instead of letting a raw traceback escape from `asyncio.run`. print(redact_secrets(f"config error: {exc}"), file=sys.stderr) return 1 - if config.backend == "hiker" and not config.hiker_token: + if config.backend == BACKEND_HIKERAPI and not config.hiker_token: print(SETUP_HINT, file=sys.stderr) return 1 - if config.backend == "aiograpi" and not (config.aiograpi_username and config.aiograpi_password): + if config.backend == BACKEND_AIOGRAPI and not ( + config.aiograpi_username and config.aiograpi_password + ): print( "no aiograpi credentials configured. Run `insto setup` and pick the aiograpi backend.", file=sys.stderr, @@ -715,10 +728,24 @@ def main(argv: list[str] | None = None) -> int: ) config = _safe_load_config(args.hiker_token, args.proxy, args.backend) - if config is None or not config.hiker_token: + missing_hikerapi_config = config is None or ( + config.backend == BACKEND_HIKERAPI and not config.hiker_token + ) + if missing_hikerapi_config: print(SETUP_HINT, file=sys.stderr) if not args.interactive: return 1 + elif ( + config is not None + and config.backend == BACKEND_AIOGRAPI + and not (config.aiograpi_username and config.aiograpi_password) + ): + print( + "no aiograpi credentials configured. Run `insto setup` and pick the aiograpi backend.", + file=sys.stderr, + ) + if not args.interactive: + return 1 try: from insto.repl import run_repl diff --git a/insto/commands/operational.py b/insto/commands/operational.py index 74f7f10..f58c6d9 100644 --- a/insto/commands/operational.py +++ b/insto/commands/operational.py @@ -220,7 +220,7 @@ async def theme_cmd(ctx: CommandContext) -> dict[str, Any]: hiker["token"] = cfg.hiker_token if cfg.hiker_proxy: hiker["proxy"] = cfg.hiker_proxy - payload["hiker"] = hiker + payload["hikerapi"] = hiker if cfg.sources.get("output_dir") != "default": payload["output_dir"] = str(cfg.output_dir) if cfg.sources.get("db_path") != "default": diff --git a/insto/config.py b/insto/config.py index 60f926e..31291a6 100644 --- a/insto/config.py +++ b/insto/config.py @@ -35,6 +35,12 @@ ENV_AIOGRAPI_USERNAME = "AIOGRAPI_USERNAME" ENV_AIOGRAPI_PASSWORD = "AIOGRAPI_PASSWORD" ENV_AIOGRAPI_TOTP = "AIOGRAPI_TOTP_SEED" +BACKEND_HIKERAPI = "hikerapi" +LEGACY_BACKEND_HIKER = "hiker" +BACKEND_AIOGRAPI = "aiograpi" +BACKEND_FAKE = "fake" +HIKERAPI_SECTION = "hikerapi" +LEGACY_HIKER_SECTION = "hiker" Origin = Literal["flag", "env", "toml", "default"] @@ -90,7 +96,7 @@ class Config: db_path: Path = field(default_factory=db_path) cli_history_path: Path = field(default_factory=cli_history_path) theme: str = DEFAULT_THEME_NAME - backend: str = "hiker" + backend: str = BACKEND_HIKERAPI aiograpi_username: str | None = None aiograpi_password: str | None = None aiograpi_totp_seed: str | None = None @@ -138,6 +144,14 @@ def _pick( return default, "default" +def normalize_backend(value: Any) -> str: + """Return the public backend name, accepting legacy config aliases.""" + backend = str(value) if value else BACKEND_HIKERAPI + if backend == LEGACY_BACKEND_HIKER: + return BACKEND_HIKERAPI + return backend + + def load_config(cli_overrides: dict[str, Any] | None = None) -> Config: """Build a Config with precedence: cli_overrides > env > toml > defaults. @@ -146,17 +160,23 @@ def load_config(cli_overrides: dict[str, Any] | None = None) -> Config: """ cli = cli_overrides or {} toml_data = _read_toml(config_file_path()) - raw_hiker = toml_data.get("hiker") - hiker_toml: dict[str, Any] = raw_hiker if isinstance(raw_hiker, dict) else {} + raw_hikerapi = toml_data.get(HIKERAPI_SECTION) + raw_legacy_hiker = toml_data.get(LEGACY_HIKER_SECTION) + if isinstance(raw_hikerapi, dict): + hiker_toml: dict[str, Any] = raw_hikerapi + elif isinstance(raw_legacy_hiker, dict): + hiker_toml = raw_legacy_hiker + else: + hiker_toml = {} raw_aio = toml_data.get("aiograpi") aio_toml: dict[str, Any] = raw_aio if isinstance(raw_aio, dict) else {} sources: dict[str, Origin] = {} - token, sources["hiker.token"] = _pick( + token, sources["hikerapi.token"] = _pick( cli, "hiker_token", ENV_TOKEN, hiker_toml.get("token"), None ) - proxy, sources["hiker.proxy"] = _pick( + proxy, sources["hikerapi.proxy"] = _pick( cli, "hiker_proxy", ENV_PROXY, hiker_toml.get("proxy"), None ) out_value, sources["output_dir"] = _pick( @@ -169,7 +189,7 @@ def load_config(cli_overrides: dict[str, Any] | None = None) -> Config: cli, "theme", ENV_THEME, toml_data.get("theme"), DEFAULT_THEME_NAME ) backend_value, sources["backend"] = _pick( - cli, "backend", "INSTO_BACKEND", toml_data.get("backend"), "hiker" + cli, "backend", "INSTO_BACKEND", toml_data.get("backend"), BACKEND_HIKERAPI ) aio_user, sources["aiograpi.username"] = _pick( cli, "aiograpi_username", ENV_AIOGRAPI_USERNAME, aio_toml.get("username"), None @@ -210,7 +230,7 @@ def load_config(cli_overrides: dict[str, Any] | None = None) -> Config: db_path=Path(db_value), cli_history_path=cli_history_path(), theme=str(theme_value) if theme_value else DEFAULT_THEME_NAME, - backend=str(backend_value) if backend_value else "hiker", + backend=normalize_backend(backend_value), aiograpi_username=aio_user, aiograpi_password=aio_pass, aiograpi_totp_seed=aio_totp, @@ -223,7 +243,7 @@ def write_config(values: dict[str, Any]) -> Path: """Write `values` to ~/.insto/config.toml as 0600. Refuse world-readable result. `values` mirrors what `load_config` reads, e.g. - `{"hiker": {"token": "...", "proxy": "..."}, "output_dir": "./out"}`. + `{"hikerapi": {"token": "...", "proxy": "..."}, "output_dir": "./out"}`. """ ensure_config_dir() path = config_file_path() @@ -260,10 +280,10 @@ def _redact(value: str) -> str: def effective_config_report(config: Config) -> list[dict[str, Any]]: """Return rows of `{key, value, origin}` for the `/config` command.""" - redacted_keys = {"hiker.token", "aiograpi.password", "aiograpi.totp_seed"} + redacted_keys = {"hikerapi.token", "aiograpi.password", "aiograpi.totp_seed"} snapshot: dict[str, Any] = { - "hiker.token": config.hiker_token, - "hiker.proxy": config.hiker_proxy, + "hikerapi.token": config.hiker_token, + "hikerapi.proxy": config.hiker_proxy, "output_dir": str(config.output_dir), "db_path": str(config.db_path), "cli_history_path": str(config.cli_history_path), @@ -281,7 +301,7 @@ def effective_config_report(config: Config) -> list[dict[str, Any]]: display = None elif key in redacted_keys: display = _redact(str(value)) - elif key == "hiker.proxy": + elif key == "hikerapi.proxy": # Proxy URLs may carry `user:pass@host:port` userinfo. Mask the # credentials but keep the host/port visible so the operator can # still verify which proxy is configured. diff --git a/tests/test_backend_contract.py b/tests/test_backend_contract.py index 996fbf5..67aa60f 100644 --- a/tests/test_backend_contract.py +++ b/tests/test_backend_contract.py @@ -12,6 +12,7 @@ from __future__ import annotations +import builtins import importlib import sys @@ -259,3 +260,30 @@ def test_make_backend_unknown_name_raises_value_error() -> None: with pytest.raises(ValueError, match="unknown backend"): make_backend("does-not-exist") + + +def test_make_backend_accepts_hikerapi_alias() -> None: + from insto.backends import make_backend + from insto.backends.hiker import HikerBackend + + backend = make_backend("hikerapi", token="test") + + assert isinstance(backend, HikerBackend) + + +def test_make_backend_aiograpi_missing_dependency_has_install_hint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + real_import = builtins.__import__ + + def fake_import(name: str, *args: object, **kwargs: object) -> object: + if name == "aiograpi" or name.startswith("aiograpi."): + raise ModuleNotFoundError("No module named 'aiograpi'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + from insto.backends import make_backend + + with pytest.raises(RuntimeError, match="pipx inject insto aiograpi"): + make_backend("aiograpi", username="instag", password="secret") diff --git a/tests/test_cli.py b/tests/test_cli.py index 4a257cc..8e9601c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -116,6 +116,25 @@ def test_parser_hiker_token_flag() -> None: assert args.hiker_token is None +def test_parser_backend_prefers_hikerapi_and_keeps_legacy_hiker_alias() -> None: + args = build_parser().parse_args(["--backend", "hikerapi"]) + assert args.backend == "hikerapi" + + legacy = build_parser().parse_args(["--backend", "hiker"]) + assert legacy.backend == "hikerapi" + + +def test_parser_backend_help_only_shows_public_backend_names( + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(SystemExit): + build_parser().parse_args(["--help"]) + + out = capsys.readouterr().out + assert "--backend {hikerapi,aiograpi}" in out + assert "--backend {hiker,aiograpi}" not in out + + def test_parsed_cmd_resolves_to_command_spec() -> None: """`-c ...` must round-trip through the command parser to a CommandSpec.""" from insto.commands._base import parse_command_line @@ -265,6 +284,32 @@ def test_setup_keeps_existing_token_when_blank(monkeypatch: pytest.MonkeyPatch) assert cfg.hiker_token == "existing-token-9999" +def test_setup_hiker_token_prompt_links_to_hikerapi_tokens() -> None: + prompts: list[str] = [] + answers = iter(["", "tok-1234567890", "", "", ""]) + + def prompt(text: str) -> str: + prompts.append(text) + return next(answers) + + rc = _run_setup(prompt=prompt) + + assert rc == 0 + assert "backend (hikerapi | aiograpi) [hikerapi]" in prompts[0] + token_prompt = next(text for text in prompts if text.startswith("hikerapi.token")) + assert "https://hikerapi.com/tokens" in token_prompt + + +def test_setup_writes_hikerapi_backend_and_section() -> None: + rc = _run_setup(prompt=_scripted_prompt(["", "tok-1234567890", "", "", ""])) + + assert rc == 0 + contents = config_file_path().read_text() + assert 'backend = "hikerapi"' in contents + assert "[hikerapi]" in contents + assert "[hiker]" not in contents + + def test_setup_clear_proxy_with_dash() -> None: cfgmod.write_config( {"hiker": {"token": "tok-abcd1234", "proxy": "http://prev:1"}, "output_dir": "./o"} @@ -431,6 +476,31 @@ def test_main_no_args_no_token_prints_hint_and_exits( assert rc == 1 +def test_main_aiograpi_backend_does_not_require_hikerapi_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import insto.repl as repl_mod + + cfgmod.write_config( + { + "backend": "aiograpi", + "aiograpi": {"username": "instag", "password": "secret"}, + } + ) + captured: dict[str, Any] = {} + + def fake_run_repl(config: Any = None, *, target: str | None = None) -> None: + captured["backend"] = config.backend + captured["target"] = target + + monkeypatch.setattr(repl_mod, "run_repl", fake_run_repl) + + rc = cli_mod.main([]) + + assert rc == 0 + assert captured == {"backend": "aiograpi", "target": None} + + def test_main_setup_invokes_wizard(monkeypatch: pytest.MonkeyPatch) -> None: called: dict[str, Any] = {} diff --git a/tests/test_commands_operational.py b/tests/test_commands_operational.py index a9c16bb..32467fb 100644 --- a/tests/test_commands_operational.py +++ b/tests/test_commands_operational.py @@ -49,8 +49,8 @@ def config(tmp_path: Path) -> Config: cli_history_path=tmp_path / "cli_history", ) cfg.sources = { - "hiker.token": "env", - "hiker.proxy": "default", + "hikerapi.token": "env", + "hikerapi.proxy": "default", "output_dir": "default", "db_path": "default", "cli_history_path": "default", @@ -195,16 +195,16 @@ async def test_config_reports_each_key_with_origin( rows = await dispatch("/config", facade=facade, session=session, console=console) keys = {r["key"] for r in rows} assert { - "hiker.token", - "hiker.proxy", + "hikerapi.token", + "hikerapi.proxy", "output_dir", "db_path", "cli_history_path", } <= keys by_key = {r["key"]: r for r in rows} - assert by_key["hiker.token"]["origin"] == "env" + assert by_key["hikerapi.token"]["origin"] == "env" # token must be redacted, never displayed in full - assert "abcd1234" not in (by_key["hiker.token"]["value"] or "") + assert "abcd1234" not in (by_key["hikerapi.token"]["value"] or "") text = console.export_text() assert "abcd1234" not in text diff --git a/tests/test_config.py b/tests/test_config.py index fa03856..f6064b9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -60,7 +60,8 @@ def test_load_config_defaults_when_no_inputs() -> None: assert cfg.output_dir == Path("./output") assert cfg.db_path == config_dir() / "store.db" assert cfg.cli_history_path == config_dir() / "cli_history" - assert cfg.sources["hiker.token"] == "default" + assert cfg.backend == "hikerapi" + assert cfg.sources["hikerapi.token"] == "default" assert cfg.sources["output_dir"] == "default" @@ -72,15 +73,15 @@ def test_load_config_reads_env(monkeypatch: pytest.MonkeyPatch) -> None: assert cfg.hiker_token == "tok-from-env" assert cfg.hiker_proxy == "socks5h://127.0.0.1:9050" assert cfg.output_dir == Path("/tmp/insto-out") - assert cfg.sources["hiker.token"] == "env" - assert cfg.sources["hiker.proxy"] == "env" + assert cfg.sources["hikerapi.token"] == "env" + assert cfg.sources["hikerapi.proxy"] == "env" assert cfg.sources["output_dir"] == "env" def test_load_config_reads_toml() -> None: write_config( { - "hiker": {"token": "tok-toml", "proxy": "http://proxy:3128"}, + "hikerapi": {"token": "tok-toml", "proxy": "http://proxy:3128"}, "output_dir": "./out-toml", "db_path": "/var/insto.db", } @@ -90,28 +91,52 @@ def test_load_config_reads_toml() -> None: assert cfg.hiker_proxy == "http://proxy:3128" assert cfg.output_dir == Path("./out-toml") assert cfg.db_path == Path("/var/insto.db") - assert cfg.sources["hiker.token"] == "toml" - assert cfg.sources["hiker.proxy"] == "toml" + assert cfg.sources["hikerapi.token"] == "toml" + assert cfg.sources["hikerapi.proxy"] == "toml" assert cfg.sources["output_dir"] == "toml" assert cfg.sources["db_path"] == "toml" +def test_load_config_accepts_legacy_hiker_backend_and_section() -> None: + write_config({"backend": "hiker", "hiker": {"token": "legacy-token"}}) + + cfg = load_config() + + assert cfg.backend == "hikerapi" + assert cfg.hiker_token == "legacy-token" + assert cfg.sources["backend"] == "toml" + assert cfg.sources["hikerapi.token"] == "toml" + + +def test_load_config_prefers_hikerapi_section_over_legacy_hiker_section() -> None: + write_config( + { + "hikerapi": {"token": "new-token"}, + "hiker": {"token": "old-token"}, + } + ) + + cfg = load_config() + + assert cfg.hiker_token == "new-token" + + def test_precedence_flag_beats_env_beats_toml(monkeypatch: pytest.MonkeyPatch) -> None: write_config({"hiker": {"token": "from-toml"}}) monkeypatch.setenv(cfgmod.ENV_TOKEN, "from-env") cfg_env = load_config() assert cfg_env.hiker_token == "from-env" - assert cfg_env.sources["hiker.token"] == "env" + assert cfg_env.sources["hikerapi.token"] == "env" cfg_flag = load_config({"hiker_token": "from-flag"}) assert cfg_flag.hiker_token == "from-flag" - assert cfg_flag.sources["hiker.token"] == "flag" + assert cfg_flag.sources["hikerapi.token"] == "flag" monkeypatch.delenv(cfgmod.ENV_TOKEN) cfg_toml = load_config() assert cfg_toml.hiker_token == "from-toml" - assert cfg_toml.sources["hiker.token"] == "toml" + assert cfg_toml.sources["hikerapi.token"] == "toml" def test_write_config_creates_0600_file() -> None: @@ -149,7 +174,7 @@ def test_write_config_overwrites_existing_securely() -> None: def test_effective_config_report_origins(monkeypatch: pytest.MonkeyPatch) -> None: write_config( { - "hiker": {"token": "tok-toml-1234567890"}, + "hikerapi": {"token": "tok-toml-1234567890"}, "output_dir": "./from-toml", } ) @@ -158,13 +183,13 @@ def test_effective_config_report_origins(monkeypatch: pytest.MonkeyPatch) -> Non rows = {row["key"]: row for row in effective_config_report(cfg)} - assert rows["hiker.token"]["origin"] == "toml" - assert rows["hiker.token"]["value"].startswith("***") - assert rows["hiker.token"]["value"].endswith("7890") - assert "tok-toml" not in rows["hiker.token"]["value"] + assert rows["hikerapi.token"]["origin"] == "toml" + assert rows["hikerapi.token"]["value"].startswith("***") + assert rows["hikerapi.token"]["value"].endswith("7890") + assert "tok-toml" not in rows["hikerapi.token"]["value"] - assert rows["hiker.proxy"]["origin"] == "env" - assert rows["hiker.proxy"]["value"] == "http://proxy:1" + assert rows["hikerapi.proxy"]["origin"] == "env" + assert rows["hikerapi.proxy"]["value"] == "http://proxy:1" assert rows["output_dir"]["origin"] == "toml" assert rows["output_dir"]["value"] == "from-toml" @@ -186,7 +211,7 @@ def test_effective_config_report_masks_proxy_userinfo( rows = {row["key"]: row for row in effective_config_report(cfg)} - value = rows["hiker.proxy"]["value"] + value = rows["hikerapi.proxy"]["value"] assert "alice" not in value assert "hunter2" not in value assert "proxy.example.com:3128" in value @@ -196,9 +221,9 @@ def test_effective_config_report_masks_proxy_userinfo( def test_effective_config_report_defaults_when_unset() -> None: cfg = load_config() rows = {row["key"]: row for row in effective_config_report(cfg)} - assert rows["hiker.token"]["value"] is None - assert rows["hiker.token"]["origin"] == "default" - assert rows["hiker.proxy"]["value"] is None + assert rows["hikerapi.token"]["value"] is None + assert rows["hikerapi.token"]["origin"] == "default" + assert rows["hikerapi.proxy"]["value"] is None assert rows["output_dir"]["value"] == "output" assert rows["output_dir"]["origin"] == "default" @@ -227,7 +252,7 @@ def test_empty_env_var_treated_as_unset(monkeypatch: pytest.MonkeyPatch) -> None monkeypatch.setenv(cfgmod.ENV_TOKEN, "") cfg = load_config() assert cfg.hiker_token == "from-toml" - assert cfg.sources["hiker.token"] == "toml" + assert cfg.sources["hikerapi.token"] == "toml" def test_umask_does_not_leak_perms(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/uv.lock b/uv.lock index 1326e13..fb1e670 100644 --- a/uv.lock +++ b/uv.lock @@ -409,7 +409,7 @@ wheels = [ [[package]] name = "insto" -version = "0.7.7" +version = "0.7.12" source = { editable = "." } dependencies = [ { name = "hikerapi" },