| 1 | """First-run setup wizard for last30days. |
| 2 | |
| 3 | Detects first run, performs auto-setup (cookie extraction + yt-dlp check), |
| 4 | and writes configuration. The actual wizard UI is SKILL.md-driven (the LLM |
| 5 | presents it), but this module provides the detection and setup actions. |
| 6 | """ |
| 7 | |
| 8 | import json |
| 9 | import logging |
| 10 | import os |
| 11 | import re |
| 12 | import shutil |
| 13 | import subprocess |
| 14 | import time |
| 15 | from pathlib import Path |
| 16 | from typing import Any, Dict, Optional, Tuple |
| 17 | from urllib.error import HTTPError, URLError |
| 18 | from urllib.request import Request, urlopen |
| 19 | |
| 20 | from . import brightdata |
| 21 | |
| 22 | logger = logging.getLogger(__name__) |
| 23 | |
| 24 | |
| 25 | def is_first_run(config: Dict[str, Any]) -> bool: |
| 26 | """Return True if the setup wizard has not been completed. |
| 27 | |
| 28 | Checks for SETUP_COMPLETE in the config dict. If it's not set |
| 29 | (None or empty string), the user hasn't gone through setup yet. |
| 30 | """ |
| 31 | return not config.get("SETUP_COMPLETE") |
| 32 | |
| 33 | |
| 34 | _WELCOME_TEXT = """Welcome to /last30days! I research any topic across Reddit, X, YouTube, TikTok, Digg, arXiv, Techmeme, HN, Polymarket & more - what people actually said in the last 30 days. Let's get you set up (~30s). |
| 35 | |
| 36 | I synthesize what people are actually saying right now across social, news, and market sources. |
| 37 | |
| 38 | Auto setup gives you the core sources free in about 30 seconds: |
| 39 | - Reddit with comments - free keyless discovery (RSS + shreddit), no API key needed. |
| 40 | - YouTube search + transcripts - installs yt-dlp (open source, 190K+ GitHub stars). |
| 41 | - Digg - trending news, GitHub stars, and pipeline feeds - installs the free, keyless Digg CLI. |
| 42 | - arXiv (papers) + Techmeme (tech-news) - install free, keyless Printing Press CLIs and run on any topic (arXiv is relevance + recency gated to research topics). |
| 43 | - StockTwits - retail trader sentiment - auto-on when your topic is a ticker or crypto (e.g. "$NVDA earnings", "bitcoin"), off for everything else. |
| 44 | - Trustpilot - brand/company review sentiment - opt-in (add trustpilot to INCLUDE_SOURCES), off by default. |
| 45 | - Hacker News + Polymarket + GitHub (auto-on if the gh CLI is installed) - always on, zero config. |
| 46 | - X/Twitter - optional. It stays available when you already configured it, or after you explicitly approve a browser-cookie read; skipping it never blocks research. |
| 47 | |
| 48 | Want TikTok and Instagram too? ScrapeCreators adds those (10,000 free calls, scrapecreators.com). No kickbacks, no affiliation. |
| 49 | |
| 50 | Power users can turn on more sources in the Manual Setup guide (LinkedIn, Bluesky, Perplexity, and others) - each needs its own credential, so they are off by default.""" |
| 51 | |
| 52 | |
| 53 | def render_welcome() -> str: |
| 54 | """Return the first-run welcome text. |
| 55 | |
| 56 | Owned by the engine (single source of truth) so the model relays it rather |
| 57 | than re-authoring it -- authored prose gets skipped, relayed command output |
| 58 | does not. Mirrors the SKILL.md welcome; keep in sync if the source set |
| 59 | changes. |
| 60 | """ |
| 61 | return _WELCOME_TEXT |
| 62 | |
| 63 | |
| 64 | # Neutral note recorded on an official-only host instead of a cookie scan. |
| 65 | # Deliberately names no cookie mechanism beyond the fact that none is used |
| 66 | # (the vocabulary rule for Grok Bot onboarding output). |
| 67 | OFFICIAL_HOST_COOKIE_NOTE = "browser sessions are not read on this host" |
| 68 | |
| 69 | |
| 70 | def run_auto_setup(config: Dict[str, Any], *, allow_browser_cookies: bool = False) -> Dict[str, Any]: |
| 71 | """Perform the auto-setup actions. |
| 72 | |
| 73 | - Optionally runs cookie extraction for all registered domains, trying the |
| 74 | browsers from ``env.cookie_extraction_browsers()``. Browser reads are off |
| 75 | unless ``allow_browser_cookies`` is true. |
| 76 | - Checks if yt-dlp is installed |
| 77 | - Best-effort install of digg-pp-cli (Printing Press library) |
| 78 | |
| 79 | Returns: |
| 80 | Dict with keys: |
| 81 | cookies_found: {source_name: browser_name} for each source where cookies were found |
| 82 | browser_cookie_scan_attempted: bool (True only after explicit consent |
| 83 | AND on a host whose X policy permits cookie discovery) |
| 84 | cookie_note: present only on an official-only host, where the scan |
| 85 | is skipped for every domain (neutral, relayable text) |
| 86 | ytdlp_installed: bool |
| 87 | ytdlp_action: already_installed | installed | install_failed | no_homebrew |
| 88 | digg_installed: bool (True when the engine can resolve digg-pp-cli on PATH) |
| 89 | digg_action: already_installed | installed | installed_off_path | install_failed | no_npx |
| 90 | env_written: bool (always False here — caller writes config separately) |
| 91 | ytdlp_stderr: present when ytdlp_action is install_failed |
| 92 | digg_stderr: present when digg_action is install_failed |
| 93 | digg_path: present when digg_action is installed_off_path (binary on disk, not on PATH) |
| 94 | """ |
| 95 | from .env import COOKIE_DOMAINS, cookie_extraction_browsers, x_policy |
| 96 | |
| 97 | cookies_found: Dict[str, str] = {} |
| 98 | cookie_note: Optional[str] = None |
| 99 | |
| 100 | # Official-only host (LAST30DAYS_HOST=grok-bot): the consented |
| 101 | # cookie scan is skipped for EVERY domain (X and Truth Social alike) and |
| 102 | # recorded as not attempted, with a neutral note the caller can relay. |
| 103 | # The free CLI installs below still run. A LAST30DAYS_X_BACKEND=bird pin |
| 104 | # is the one path that re-enables discovery, via x_policy. |
| 105 | if allow_browser_cookies and not x_policy(config).cookie_discovery: |
| 106 | allow_browser_cookies = False |
| 107 | cookie_note = OFFICIAL_HOST_COOKIE_NOTE |
| 108 | |
| 109 | if allow_browser_cookies: |
| 110 | from . import cookie_extract |
| 111 | |
| 112 | cookie_config = dict(config) |
| 113 | if not (cookie_config.get("FROM_BROWSER") or "").strip(): |
| 114 | # Chromium-first: Chrome/Brave/etc. read cookies via the Keychain |
| 115 | # with no Full Disk Access, so try them before Safari, whose |
| 116 | # binarycookies read requires FDA (the dead-end most users hit). |
| 117 | # firefox/safari stay as the silent fallbacks. Note: an explicit |
| 118 | # comma list preserves this order (cookie_extraction_browsers); |
| 119 | # "auto" would put the silent browsers first, so do not use it here. |
| 120 | cookie_config["FROM_BROWSER"] = "chrome,brave,edge,vivaldi,arc,chromium,firefox,safari" |
| 121 | browsers = cookie_extraction_browsers(cookie_config) |
| 122 | |
| 123 | for source_name, spec in COOKIE_DOMAINS.items(): |
| 124 | domain = spec["domain"] |
| 125 | cookie_names = spec["cookies"] |
| 126 | |
| 127 | for browser in browsers: |
| 128 | try: |
| 129 | result = cookie_extract.extract_cookies_with_source(browser, domain, cookie_names) |
| 130 | except Exception as exc: |
| 131 | logger.debug("Cookie extraction failed for %s via %s: %s", source_name, browser, exc) |
| 132 | continue |
| 133 | if result is not None and result[0]: |
| 134 | cookies_found[source_name] = result[1] |
| 135 | break # Found cookies for this service, stop trying browsers |
| 136 | |
| 137 | # Check yt-dlp availability and install via Homebrew if missing. Windows |
| 138 | # has no Homebrew, and its working install path is `pip install yt-dlp` |
| 139 | # (see #904), so it gets its own no-op-install guidance branch instead of |
| 140 | # falling into the Homebrew-oriented no_homebrew outcome. |
| 141 | ytdlp_action: str |
| 142 | if shutil.which("yt-dlp") is not None: |
| 143 | ytdlp_installed = True |
| 144 | ytdlp_action = "already_installed" |
| 145 | elif os.name == "nt": |
| 146 | ytdlp_installed = False |
| 147 | ytdlp_action = "no_pip_windows" |
| 148 | elif shutil.which("brew") is not None: |
| 149 | brew_stderr = "" |
| 150 | try: |
| 151 | proc = subprocess.run( |
| 152 | ["brew", "install", "yt-dlp"], |
| 153 | capture_output=True, text=True, timeout=120, |
| 154 | ) |
| 155 | if proc.returncode == 0: |
| 156 | ytdlp_installed = True |
| 157 | ytdlp_action = "installed" |
| 158 | else: |
| 159 | ytdlp_installed = False |
| 160 | ytdlp_action = "install_failed" |
| 161 | brew_stderr = proc.stderr |
| 162 | logger.warning("brew install yt-dlp failed: %s", proc.stderr) |
| 163 | except Exception as exc: |
| 164 | ytdlp_installed = False |
| 165 | ytdlp_action = "install_failed" |
| 166 | brew_stderr = str(exc) |
| 167 | logger.warning("brew install yt-dlp exception: %s", exc) |
| 168 | else: |
| 169 | ytdlp_installed = False |
| 170 | ytdlp_action = "no_homebrew" |
| 171 | |
| 172 | digg_installed, digg_action, digg_stderr, digg_path = _install_digg_cli() |
| 173 | pp_sources = install_default_pp_sources() |
| 174 | |
| 175 | results: Dict[str, Any] = { |
| 176 | "cookies_found": cookies_found, |
| 177 | "browser_cookie_scan_attempted": allow_browser_cookies, |
| 178 | "ytdlp_installed": ytdlp_installed, |
| 179 | "ytdlp_action": ytdlp_action, |
| 180 | "digg_installed": digg_installed, |
| 181 | "digg_action": digg_action, |
| 182 | # Per-CLI status for the additional default-on Printing Press sources |
| 183 | # (arxiv, techmeme, trustpilot): {source: {installed, action, ...}}. |
| 184 | "pp_sources": pp_sources, |
| 185 | # Reported, never installed: this CLI spends the user's own metered |
| 186 | # credits, so acquiring it stays their decision. Passing |
| 187 | # config matters: a user whose key lives in a .env file or the |
| 188 | # keychain (rather than a `brightdata login` credentials file) is |
| 189 | # active in the engine, and setup must not tell them otherwise. |
| 190 | "brightdata": brightdata_status(config), |
| 191 | "env_written": False, |
| 192 | } |
| 193 | if cookie_note: |
| 194 | results["cookie_note"] = cookie_note |
| 195 | if ytdlp_action == "install_failed": |
| 196 | results["ytdlp_stderr"] = brew_stderr |
| 197 | if digg_action == "install_failed": |
| 198 | results["digg_stderr"] = digg_stderr |
| 199 | if digg_path: |
| 200 | results["digg_path"] = digg_path |
| 201 | return results |
| 202 | |
| 203 | |
| 204 | # Generous timeout: the install shells out to `npx`, which may download the |
| 205 | # Printing Press package and build the Go binary over the network. |
| 206 | DIGG_INSTALL_TIMEOUT = 300 |
| 207 | DIGG_CLI_BIN = "digg-pp-cli" |
| 208 | # Pin the catalog installer; matches printing-press-library npm 0.1.16 default |
| 209 | # ($HOME/.local/bin on macOS/Linux). |
| 210 | PRINTING_PRESS_NPM = "@mvanhorn/printing-press-library@0.1.16" |
| 211 | DIGG_INSTALL_CMD = f"npx -y {PRINTING_PRESS_NPM} install digg --cli-only" |
| 212 | |
| 213 | |
| 214 | def _digg_bin_candidate_paths() -> list[Path]: |
| 215 | """Known install locations for digg-pp-cli (Printing Press library defaults). |
| 216 | |
| 217 | Order: current installer default (~/.local/bin), legacy Go bins, Windows |
| 218 | managed dir. The directory list is ``health.installer_bin_dirs()`` — the |
| 219 | shared single source — with the Digg filename variants appended (plain |
| 220 | name for Unix-style dirs, ``.exe`` in the Windows managed dir). |
| 221 | ``pipeline.available_sources()`` only activates Digg when |
| 222 | ``shutil.which`` resolves on PATH — probing these dirs is for setup |
| 223 | verification and honest off-PATH messaging, not engine activation. |
| 224 | """ |
| 225 | from . import health |
| 226 | |
| 227 | win_dir = health.windows_printing_press_bin_dir() |
| 228 | candidates: list[Path] = [] |
| 229 | for directory in health.installer_bin_dirs(): |
| 230 | if win_dir is not None and directory == win_dir: |
| 231 | candidates.append(directory / f"{DIGG_CLI_BIN}.exe") |
| 232 | else: |
| 233 | candidates.append(directory / DIGG_CLI_BIN) |
| 234 | return candidates |
| 235 | |
| 236 | |
| 237 | def _digg_on_path() -> Optional[str]: |
| 238 | """Return digg-pp-cli when the engine would activate Digg (PATH-resolvable).""" |
| 239 | return shutil.which(DIGG_CLI_BIN) |
| 240 | |
| 241 | |
| 242 | def _digg_off_path_binary() -> Optional[str]: |
| 243 | """Return digg-pp-cli path from known install dirs when not on PATH.""" |
| 244 | for candidate in _digg_bin_candidate_paths(): |
| 245 | if candidate.is_file() and os.access(candidate, os.X_OK): |
| 246 | return str(candidate) |
| 247 | return None |
| 248 | |
| 249 | |
| 250 | def _digg_bin_dir_hint(digg_path: str) -> str: |
| 251 | """Return a copy-pasteable PATH directory for the given binary path.""" |
| 252 | parent = os.path.dirname(os.path.expanduser(digg_path)) |
| 253 | if os.name == "nt": |
| 254 | # Windows PATH edits use absolute dirs; $HOME is a Unix shell convention. |
| 255 | return parent |
| 256 | home = str(Path.home()) |
| 257 | if parent == home: |
| 258 | return "$HOME" |
| 259 | prefix = home + os.sep |
| 260 | if parent.startswith(prefix): |
| 261 | rel = parent[len(prefix):].replace(os.sep, "/") |
| 262 | return f"$HOME/{rel}" if rel else "$HOME" |
| 263 | return parent |
| 264 | |
| 265 | |
| 266 | def _run_npx_install(slug: str) -> Tuple[str, str]: |
| 267 | """Resolve ``npx`` and run the Printing Press catalog install for ``slug``. |
| 268 | |
| 269 | Shared by ``_install_digg_cli`` and ``_install_pp_cli`` -- this is only the |
| 270 | "resolve npx, run the install, interpret no_npx/exception/nonzero-rc" |
| 271 | slice; each caller keeps its own on-path/off-path re-verification |
| 272 | (``_digg_bin_candidate_paths`` vs ``_pp_bin_candidate_paths`` already use |
| 273 | different candidate-directory sources, so merging them here would change |
| 274 | off-path detection behavior beyond this fix's scope). |
| 275 | |
| 276 | Fixes the Windows PATHEXT mismatch: ``shutil.which("npx")`` resolves |
| 277 | ``npx.CMD`` via PATHEXT, but ``subprocess.run`` given the bare string |
| 278 | ``"npx"`` as argv[0] does not do that resolution and fails with |
| 279 | ``WinError 2``. Passing the resolved path is a no-op on macOS/Linux, where |
| 280 | ``shutil.which`` already returns the exact path ``CreateProcess``/``execve`` |
| 281 | would resolve. |
| 282 | |
| 283 | Returns ``(action, stderr)``: ``action`` is ``"no_npx"``, |
| 284 | ``"install_failed"``, or ``""`` when the subprocess ran and returned |
| 285 | ``rc=0`` (in which case ``stderr`` carries any non-fatal stderr output for |
| 286 | the caller's own off-path logging). |
| 287 | """ |
| 288 | npx = shutil.which("npx") |
| 289 | if npx is None: |
| 290 | return "no_npx", "" |
| 291 | try: |
| 292 | proc = subprocess.run( |
| 293 | [npx, "-y", PRINTING_PRESS_NPM, "install", slug, "--cli-only"], |
| 294 | capture_output=True, text=True, timeout=DIGG_INSTALL_TIMEOUT, |
| 295 | ) |
| 296 | except Exception as exc: |
| 297 | logger.warning("npx install %s exception: %s", slug, exc) |
| 298 | return "install_failed", str(exc) |
| 299 | if proc.returncode != 0: |
| 300 | stderr = proc.stderr or f"npx install {slug} exited {proc.returncode}" |
| 301 | logger.warning("npx install %s failed (rc=%s): %s", slug, proc.returncode, stderr) |
| 302 | return "install_failed", stderr |
| 303 | return "", (proc.stderr or "") |
| 304 | |
| 305 | |
| 306 | def _install_digg_cli() -> Tuple[bool, str, str, str]: |
| 307 | """Best-effort install of the digg-pp-cli binary. |
| 308 | |
| 309 | Mirrors the yt-dlp/brew auto-install: it never raises, and degrades to a |
| 310 | recommend-only outcome when the installer is unavailable. Uses |
| 311 | ``@mvanhorn/printing-press-library`` (``--cli-only``) — the same catalog |
| 312 | installer as pp-digg; Hermes/OpenClaw skill wiring is irrelevant here. |
| 313 | |
| 314 | Returns ``(engine_active, action, stderr, off_path_binary)`` where |
| 315 | ``engine_active`` is True only when ``shutil.which`` resolves the binary |
| 316 | (matching ``pipeline.available_sources()``). ``action`` is one of: |
| 317 | already_installed | installed | installed_off_path | install_failed | no_npx |
| 318 | ``stderr`` is populated on ``install_failed``. ``off_path_binary`` is set |
| 319 | when the binary exists on disk but is not PATH-visible to this process. |
| 320 | """ |
| 321 | on_path = _digg_on_path() |
| 322 | if on_path: |
| 323 | return True, "already_installed", "", "" |
| 324 | off_path = _digg_off_path_binary() |
| 325 | if off_path: |
| 326 | return False, "installed_off_path", "", off_path |
| 327 | action, stderr = _run_npx_install("digg") |
| 328 | if action: |
| 329 | return False, action, stderr, "" |
| 330 | on_path = _digg_on_path() |
| 331 | if on_path: |
| 332 | return True, "installed", "", "" |
| 333 | off_path = _digg_off_path_binary() |
| 334 | if off_path: |
| 335 | combined = stderr.strip() |
| 336 | if combined: |
| 337 | logger.warning("digg-pp-cli installed off PATH: %s", combined) |
| 338 | return False, "installed_off_path", combined, off_path |
| 339 | stderr_msg = stderr or "install completed but digg-pp-cli was not found" |
| 340 | logger.warning("npx install digg failed verification: %s", stderr_msg) |
| 341 | return False, "install_failed", stderr_msg, "" |
| 342 | |
| 343 | |
| 344 | # Additional default-on Printing Press sources installed the same way as Digg: |
| 345 | # (engine source key, slug for `install <slug>`, binary name). These activate in |
| 346 | # ``pipeline.available_sources()`` when ``shutil.which`` resolves the binary. |
| 347 | # Trustpilot is intentionally NOT here: it is opt-in (INCLUDE_SOURCES=trustpilot) |
| 348 | # because of its headless-Chrome cookie harvest, so auto-installing its binary |
| 349 | # for a source that stays off by default would be wasted work. Opting in installs |
| 350 | # it on demand via `npx ... install trustpilot --cli-only` (see CONFIGURATION.md). |
| 351 | PP_DEFAULT_SOURCES: list[tuple[str, str, str]] = [ |
| 352 | ("arxiv", "arxiv", "arxiv-pp-cli"), |
| 353 | ("techmeme", "techmeme", "techmeme-pp-cli"), |
| 354 | ] |
| 355 | |
| 356 | # Bright Data is deliberately absent from PP_DEFAULT_SOURCES: it is not a |
| 357 | # Printing Press CLI, it is opt-in like Trustpilot, and it spends the user's |
| 358 | # own metered credits. Setup reports its state and never installs it. |
| 359 | BRIGHTDATA_BIN = "brightdata" |
| 360 | |
| 361 | |
| 362 | def _brightdata_off_path_binary() -> Optional[str]: |
| 363 | """Locate a brightdata binary that exists on disk but not on PATH. |
| 364 | |
| 365 | Covers the common npm global prefixes. The distinction matters because |
| 366 | Hermes and OpenClaw gateways routinely run the engine with a PATH that |
| 367 | excludes the user's npm bin directory, so "installed" and "the engine |
| 368 | can see it" are different questions. |
| 369 | """ |
| 370 | home = Path.home() |
| 371 | candidates = [ |
| 372 | home / ".local" / "bin" / BRIGHTDATA_BIN, |
| 373 | home / ".npm-global" / "bin" / BRIGHTDATA_BIN, |
| 374 | Path("/opt/homebrew/bin") / BRIGHTDATA_BIN, |
| 375 | Path("/usr/local/bin") / BRIGHTDATA_BIN, |
| 376 | ] |
| 377 | npm_prefix = os.environ.get("NPM_CONFIG_PREFIX") |
| 378 | if npm_prefix: |
| 379 | candidates.insert(0, Path(npm_prefix) / "bin" / BRIGHTDATA_BIN) |
| 380 | for candidate in candidates: |
| 381 | try: |
| 382 | if candidate.is_file() and os.access(candidate, os.X_OK): |
| 383 | return str(candidate) |
| 384 | except OSError: |
| 385 | continue |
| 386 | return None |
| 387 | |
| 388 | |
| 389 | def brightdata_status(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: |
| 390 | """Report the Bright Data install and auth state honestly. |
| 391 | |
| 392 | Deliberately never claims the source is active unless the engine's own |
| 393 | gate would pass -- ``brightdata.is_available`` is the single predicate, |
| 394 | so setup and the engine cannot drift apart. Three states matter: |
| 395 | |
| 396 | * ``already_installed`` -- on PATH; ``authenticated`` says whether the |
| 397 | amazon lane will actually run. |
| 398 | * ``installed_off_path`` -- on disk but invisible to the engine, which |
| 399 | is the Hermes/OpenClaw failure mode. Carries the path so the user can |
| 400 | fix their PATH. |
| 401 | * ``not_installed`` -- nothing found. No auto-install: this CLI |
| 402 | spends the user's metered credits, so acquiring it is their call. |
| 403 | """ |
| 404 | installed = brightdata.is_installed() |
| 405 | authenticated = brightdata.has_credentials(config) |
| 406 | if installed: |
| 407 | action = "already_installed" |
| 408 | off_path = "" |
| 409 | else: |
| 410 | off_path = _brightdata_off_path_binary() or "" |
| 411 | action = "installed_off_path" if off_path else "not_installed" |
| 412 | |
| 413 | status: Dict[str, Any] = { |
| 414 | "installed": installed, |
| 415 | "action": action, |
| 416 | "authenticated": installed and authenticated, |
| 417 | # The engine gate, verbatim. Never report active on anything else. |
| 418 | "engine_active": brightdata.is_available(config), |
| 419 | } |
| 420 | if off_path: |
| 421 | status["path"] = off_path |
| 422 | status["hint"] = ( |
| 423 | f"brightdata found at {off_path} but not on PATH; add its directory " |
| 424 | "to PATH so the engine subprocess can see it" |
| 425 | ) |
| 426 | elif installed and not authenticated: |
| 427 | status["hint"] = "run `brightdata login` to activate the amazon source" |
| 428 | elif not installed: |
| 429 | status["hint"] = ( |
| 430 | "install with `npm i -g @brightdata/cli` then `brightdata login` " |
| 431 | "to enable the amazon source" |
| 432 | ) |
| 433 | return status |
| 434 | |
| 435 | |
| 436 | def _pp_bin_candidate_paths(bin_name: str) -> list[Path]: |
| 437 | """Known install locations for a Printing Press CLI binary (slug-parameterized |
| 438 | mirror of ``_digg_bin_candidate_paths``).""" |
| 439 | home = Path.home() |
| 440 | candidates: list[Path] = [home / ".local" / "bin" / bin_name] |
| 441 | gopath = os.environ.get("GOPATH") |
| 442 | if gopath: |
| 443 | candidates.append(Path(gopath) / "bin" / bin_name) |
| 444 | candidates.append(home / "go" / "bin" / bin_name) |
| 445 | if os.name == "nt": |
| 446 | local_app = os.environ.get("LOCALAPPDATA") or os.environ.get("LocalAppData") |
| 447 | if local_app: |
| 448 | candidates.append( |
| 449 | Path(local_app) / "Programs" / "PrintingPress" / "bin" / f"{bin_name}.exe" |
| 450 | ) |
| 451 | return candidates |
| 452 | |
| 453 | |
| 454 | def _pp_off_path_binary(bin_name: str) -> Optional[str]: |
| 455 | for candidate in _pp_bin_candidate_paths(bin_name): |
| 456 | if candidate.is_file() and os.access(candidate, os.X_OK): |
| 457 | return str(candidate) |
| 458 | return None |
| 459 | |
| 460 | |
| 461 | def _install_pp_cli(slug: str, bin_name: str) -> Tuple[bool, str, str, str]: |
| 462 | """Best-effort install of a Printing Press CLI binary. |
| 463 | |
| 464 | Slug-parameterized mirror of ``_install_digg_cli``: never raises, degrades |
| 465 | to recommend-only when the installer is unavailable. Returns |
| 466 | ``(engine_active, action, stderr, off_path_binary)`` with the same action |
| 467 | taxonomy: already_installed | installed | installed_off_path | |
| 468 | install_failed | no_npx. |
| 469 | """ |
| 470 | on_path = shutil.which(bin_name) |
| 471 | if on_path: |
| 472 | return True, "already_installed", "", "" |
| 473 | off_path = _pp_off_path_binary(bin_name) |
| 474 | if off_path: |
| 475 | return False, "installed_off_path", "", off_path |
| 476 | action, stderr = _run_npx_install(slug) |
| 477 | if action: |
| 478 | return False, action, stderr, "" |
| 479 | on_path = shutil.which(bin_name) |
| 480 | if on_path: |
| 481 | return True, "installed", "", "" |
| 482 | off_path = _pp_off_path_binary(bin_name) |
| 483 | if off_path: |
| 484 | combined = stderr.strip() |
| 485 | if combined: |
| 486 | logger.warning("%s installed off PATH: %s", bin_name, combined) |
| 487 | return False, "installed_off_path", combined, off_path |
| 488 | stderr_msg = stderr or f"install completed but {bin_name} was not found" |
| 489 | logger.warning("npx install %s failed verification: %s", slug, stderr_msg) |
| 490 | return False, "install_failed", stderr_msg, "" |
| 491 | |
| 492 | |
| 493 | def install_default_pp_sources() -> Dict[str, Dict[str, Any]]: |
| 494 | """Best-effort install of every additional default-on Printing Press source. |
| 495 | |
| 496 | Returns ``{source_key: {installed, action, stderr?, path?}}`` so the wizard |
| 497 | can report per-CLI status alongside Digg without raising on any single |
| 498 | failure. |
| 499 | """ |
| 500 | out: Dict[str, Dict[str, Any]] = {} |
| 501 | for source_key, slug, bin_name in PP_DEFAULT_SOURCES: |
| 502 | installed, action, stderr, off_path = _install_pp_cli(slug, bin_name) |
| 503 | entry: Dict[str, Any] = {"installed": installed, "action": action} |
| 504 | if action == "install_failed" and stderr: |
| 505 | entry["stderr"] = stderr |
| 506 | if off_path: |
| 507 | entry["path"] = off_path |
| 508 | out[source_key] = entry |
| 509 | return out |
| 510 | |
| 511 | |
| 512 | def _open_secret_append(path: Path): |
| 513 | """Open ``path`` for appending as a 0o600 secret file with no readable window. |
| 514 | |
| 515 | ``os.open`` with ``O_CREAT|O_WRONLY|O_APPEND`` and mode ``0o600`` sets |
| 516 | restrictive permissions at creation (umask can only further restrict, never |
| 517 | widen, so the file is never world-readable even transiently). An explicit |
| 518 | ``chmod`` afterwards also tightens a pre-existing loose file. This matters |
| 519 | because the .env stores API keys, cookies, and tokens. |
| 520 | """ |
| 521 | fd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o600) |
| 522 | try: |
| 523 | os.chmod(path, 0o600) |
| 524 | except OSError: |
| 525 | pass |
| 526 | return os.fdopen(fd, "a", encoding="utf-8") |
| 527 | |
| 528 | |
| 529 | def _replace_env_line(env_path: Path, content: str, key_name: str, value: str) -> bool: |
| 530 | """Rewrite every ``key_name=`` line of ``content`` with ``value`` as a 0o600 secret. |
| 531 | |
| 532 | The new file is written to a sibling temp path opened at 0o600 and moved |
| 533 | over the original, so the secret never has a readable window and a |
| 534 | crash mid-write leaves the old file intact. |
| 535 | """ |
| 536 | new_line = f"{key_name}={_format_env_value(value)}" |
| 537 | lines = [] |
| 538 | replaced = False |
| 539 | for line in content.splitlines(): |
| 540 | stripped = line.strip() |
| 541 | is_key = ( |
| 542 | stripped and not stripped.startswith("#") and "=" in stripped |
| 543 | and stripped.split("=", 1)[0].strip() == key_name |
| 544 | ) |
| 545 | if is_key: |
| 546 | if not replaced: |
| 547 | lines.append(new_line) |
| 548 | replaced = True |
| 549 | continue |
| 550 | lines.append(line) |
| 551 | if not replaced: |
| 552 | lines.append(new_line) |
| 553 | tmp_path = env_path.with_name(env_path.name + ".tmp") |
| 554 | fd = os.open(tmp_path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600) |
| 555 | with os.fdopen(fd, "w", encoding="utf-8") as f: |
| 556 | f.write("\n".join(lines) + "\n") |
| 557 | os.replace(tmp_path, env_path) |
| 558 | try: |
| 559 | os.chmod(env_path, 0o600) |
| 560 | except OSError: |
| 561 | pass |
| 562 | return True |
| 563 | |
| 564 | |
| 565 | def _format_env_value(value: str) -> str: |
| 566 | """Quote a value so it round-trips through env.load_env_file. |
| 567 | |
| 568 | env.load_env_file strips a single layer of matching surrounding quotes but |
| 569 | does NOT process backslash escapes, so we wrap (never escape): |
| 570 | - plain tokens (no whitespace, no leading quote): returned unchanged; |
| 571 | - values with whitespace/leading quote and no double-quote: double-quoted; |
| 572 | - values containing a double-quote but no single-quote: single-quoted; |
| 573 | - values containing both quote types: returned as-is (best effort; no |
| 574 | wrapper round-trips through the loader, and tokens never hit this). |
| 575 | Newlines are not valid in a single-line env value and are stripped. |
| 576 | """ |
| 577 | value = value.replace("\r", "").replace("\n", " ") |
| 578 | needs_quoting = (not value) or value[0] in ("'", '"') or any(c.isspace() for c in value) |
| 579 | if not needs_quoting: |
| 580 | return value |
| 581 | if '"' not in value: |
| 582 | return f'"{value}"' |
| 583 | if "'" not in value: |
| 584 | return f"'{value}'" |
| 585 | return value |
| 586 | |
| 587 | |
| 588 | def write_setup_config(env_path: Path, from_browser: str | None = None) -> bool: |
| 589 | """Write SETUP_COMPLETE and FROM_BROWSER to the .env file. |
| 590 | |
| 591 | Creates the file and parent directories if needed. |
| 592 | Appends to existing file without overwriting existing keys. |
| 593 | |
| 594 | Args: |
| 595 | env_path: Path to the .env file (e.g. ~/.config/last30days/.env) |
| 596 | from_browser: Browser extraction mode to persist. Pass the browser that |
| 597 | actually yielded cookies (e.g. "firefox") to fast-path future runs. |
| 598 | Pass None (default) to NOT pin FROM_BROWSER — the steady-state |
| 599 | default (Firefox/Safari, no Keychain prompt) then applies. We avoid |
| 600 | persisting "auto" because it makes every later run probe Chrome and |
| 601 | re-trigger the Keychain prompt. |
| 602 | |
| 603 | Returns: |
| 604 | True if config was written successfully, False on error. |
| 605 | """ |
| 606 | try: |
| 607 | env_path = Path(env_path) |
| 608 | env_path.parent.mkdir(parents=True, exist_ok=True) |
| 609 | |
| 610 | # Read existing content to avoid overwriting keys |
| 611 | existing_keys: set = set() |
| 612 | existing_content = "" |
| 613 | if env_path.exists(): |
| 614 | existing_content = env_path.read_text(encoding="utf-8") |
| 615 | for line in existing_content.splitlines(): |
| 616 | stripped = line.strip() |
| 617 | if stripped and not stripped.startswith("#") and "=" in stripped: |
| 618 | key = stripped.split("=", 1)[0].strip() |
| 619 | existing_keys.add(key) |
| 620 | |
| 621 | lines_to_add = [] |
| 622 | if "SETUP_COMPLETE" not in existing_keys: |
| 623 | lines_to_add.append("SETUP_COMPLETE=true") |
| 624 | if from_browser and "FROM_BROWSER" not in existing_keys: |
| 625 | lines_to_add.append(f"FROM_BROWSER={_format_env_value(from_browser)}") |
| 626 | |
| 627 | if not lines_to_add: |
| 628 | return True # Nothing to write, already configured |
| 629 | |
| 630 | # Create/append as a 0o600 secret file: the .env holds tokens and keys, |
| 631 | # so it must never be created world-readable. |
| 632 | with _open_secret_append(env_path) as f: |
| 633 | if existing_content and not existing_content.endswith("\n"): |
| 634 | f.write("\n") |
| 635 | f.write("\n".join(lines_to_add) + "\n") |
| 636 | |
| 637 | return True |
| 638 | |
| 639 | except OSError as exc: |
| 640 | logger.error("Failed to write setup config to %s: %s", env_path, exc) |
| 641 | return False |
| 642 | |
| 643 | |
| 644 | def write_api_key( |
| 645 | env_path: Path, |
| 646 | api_key: str, |
| 647 | key_name: str = "SCRAPECREATORS_API_KEY", |
| 648 | *, |
| 649 | replace: bool = False, |
| 650 | ) -> bool: |
| 651 | """Append an API key to the .env file as a 0o600 secret. |
| 652 | |
| 653 | Reuses the same secret-safe write path as ``write_setup_config`` so the |
| 654 | value lands with restrictive permissions and round-trips through |
| 655 | ``env.load_env_file``. Idempotent by default: if ``key_name`` is already |
| 656 | present in the file, nothing is written and the existing value is |
| 657 | preserved (we never clobber a key the user may have set by hand). With |
| 658 | ``replace=True`` an existing line is rewritten in place instead, so an |
| 659 | explicit ``setup --store-key`` can rotate a rejected credential. |
| 660 | |
| 661 | Args: |
| 662 | env_path: Path to the .env file (e.g. ~/.config/last30days/.env). |
| 663 | api_key: The raw key value to persist. |
| 664 | key_name: The env var name to write (default SCRAPECREATORS_API_KEY). |
| 665 | replace: Rewrite an existing ``key_name`` line instead of keeping it. |
| 666 | |
| 667 | Returns: |
| 668 | True if the key was written or already present, False on error or when |
| 669 | ``api_key`` is empty. |
| 670 | """ |
| 671 | if not api_key: |
| 672 | return False |
| 673 | try: |
| 674 | env_path = Path(env_path) |
| 675 | env_path.parent.mkdir(parents=True, exist_ok=True) |
| 676 | |
| 677 | existing_content = "" |
| 678 | if env_path.exists(): |
| 679 | existing_content = env_path.read_text(encoding="utf-8") |
| 680 | for line in existing_content.splitlines(): |
| 681 | stripped = line.strip() |
| 682 | if stripped and not stripped.startswith("#") and "=" in stripped: |
| 683 | if stripped.split("=", 1)[0].strip() == key_name: |
| 684 | if replace: |
| 685 | return _replace_env_line(env_path, existing_content, key_name, api_key) |
| 686 | return True # Already configured; do not duplicate |
| 687 | |
| 688 | line = f"{key_name}={_format_env_value(api_key)}\n" |
| 689 | with _open_secret_append(env_path) as f: |
| 690 | if existing_content and not existing_content.endswith("\n"): |
| 691 | f.write("\n") |
| 692 | f.write(line) |
| 693 | |
| 694 | return True |
| 695 | |
| 696 | except OSError as exc: |
| 697 | logger.error("Failed to write API key to %s: %s", env_path, exc) |
| 698 | return False |
| 699 | |
| 700 | |
| 701 | def mask_api_key(api_key: str) -> str: |
| 702 | """Return a non-secret display form of an API key (prefix + last 4). |
| 703 | |
| 704 | Used so the key never appears verbatim in stdout the host model captures. |
| 705 | Short or empty keys collapse to a fixed placeholder. |
| 706 | """ |
| 707 | if not api_key or len(api_key) <= 8: |
| 708 | return "sc_…" |
| 709 | return f"{api_key[:3]}…{api_key[-4:]}" |
| 710 | |
| 711 | |
| 712 | def get_setup_status_text(results: Dict[str, Any]) -> str: |
| 713 | """Return a human-readable summary of auto-setup results. |
| 714 | |
| 715 | Args: |
| 716 | results: Dict from run_auto_setup() |
| 717 | |
| 718 | Returns: |
| 719 | Multi-line status text. |
| 720 | """ |
| 721 | lines = [] |
| 722 | lines.append("Setup complete! Here's what I found:") |
| 723 | lines.append("") |
| 724 | |
| 725 | cookies_found = results.get("cookies_found", {}) |
| 726 | if results.get("browser_cookie_scan_attempted") and cookies_found: |
| 727 | for source, browser in cookies_found.items(): |
| 728 | lines.append(f" - {source.upper()} cookies found in {browser}") |
| 729 | |
| 730 | ytdlp_action = results.get("ytdlp_action", "") |
| 731 | if ytdlp_action == "installed": |
| 732 | lines.append(" - Installed yt-dlp via Homebrew") |
| 733 | elif ytdlp_action == "install_failed": |
| 734 | lines.append(" - yt-dlp install failed \u2014 run `brew install yt-dlp` manually") |
| 735 | elif ytdlp_action == "no_homebrew": |
| 736 | lines.append(" - yt-dlp not found. Install Homebrew first, then: brew install yt-dlp") |
| 737 | elif ytdlp_action == "no_pip_windows": |
| 738 | lines.append( |
| 739 | " - yt-dlp not found. Install with: pip install yt-dlp " |
| 740 | "(it may install to a Scripts directory not on PATH -- add it to PATH if YouTube search stays inactive)" |
| 741 | ) |
| 742 | elif ytdlp_action == "already_installed": |
| 743 | lines.append(" - yt-dlp already installed") |
| 744 | elif results.get("ytdlp_installed", False): |
| 745 | lines.append(" - yt-dlp is installed (YouTube search ready)") |
| 746 | else: |
| 747 | lines.append(" - yt-dlp not found (install with: brew install yt-dlp)") |
| 748 | |
| 749 | digg_action = results.get("digg_action", "") |
| 750 | if digg_action == "installed": |
| 751 | lines.append(" - Installed Digg CLI (free AI-news clusters source now active)") |
| 752 | elif digg_action == "already_installed": |
| 753 | lines.append(" - Digg CLI already installed (AI-news clusters active)") |
| 754 | elif digg_action == "installed_off_path": |
| 755 | digg_path = results.get("digg_path", "") |
| 756 | if digg_path: |
| 757 | bin_dir = _digg_bin_dir_hint(digg_path) |
| 758 | lines.append( |
| 759 | f" - Digg CLI found at {digg_path} but not on PATH — add " |
| 760 | f"{bin_dir} to PATH and restart your agent session/gateway " |
| 761 | "for Digg to activate" |
| 762 | ) |
| 763 | else: |
| 764 | lines.append( |
| 765 | " - Digg CLI is installed but not on PATH — add its install " |
| 766 | "directory to PATH and restart your agent session/gateway for " |
| 767 | "Digg to activate" |
| 768 | ) |
| 769 | elif digg_action == "install_failed": |
| 770 | lines.append(f" - Digg CLI install failed — run `{DIGG_INSTALL_CMD}` manually") |
| 771 | elif digg_action == "no_npx": |
| 772 | lines.append( |
| 773 | " - Digg CLI not installed (free, optional). Install Node/npx, then: " |
| 774 | f"{DIGG_INSTALL_CMD}" |
| 775 | ) |
| 776 | |
| 777 | pp_sources = results.get("pp_sources", {}) |
| 778 | pp_name: dict[str, str] = {"arxiv": "arXiv", "techmeme": "Techmeme"} |
| 779 | for source_key, entry in sorted(pp_sources.items()): |
| 780 | name = pp_name.get(source_key, source_key.title()) |
| 781 | action = entry.get("action", "") |
| 782 | if action == "installed": |
| 783 | lines.append(f" - Installed {name} CLI ({name} source now active)") |
| 784 | elif action == "already_installed": |
| 785 | lines.append(f" - {name} CLI already installed ({name} active)") |
| 786 | elif action == "installed_off_path": |
| 787 | path = entry.get("path", "") |
| 788 | if path: |
| 789 | lines.append( |
| 790 | f" - {name} CLI at {path} but not on PATH — add " |
| 791 | f"{os.path.dirname(os.path.expanduser(path))} to PATH and " |
| 792 | f"restart your agent session/gateway for {name} to activate" |
| 793 | ) |
| 794 | else: |
| 795 | lines.append( |
| 796 | f" - {name} CLI installed but not on PATH — add its install " |
| 797 | "directory to PATH and restart your agent session/gateway for " |
| 798 | f"{name} to activate" |
| 799 | ) |
| 800 | elif action == "install_failed": |
| 801 | lines.append( |
| 802 | f" - {name} CLI install failed — run " |
| 803 | f"`npx -y {PRINTING_PRESS_NPM} install {source_key} --cli-only` manually" |
| 804 | ) |
| 805 | elif action == "no_npx": |
| 806 | lines.append( |
| 807 | f" - {name} CLI not installed (free, optional). Install Node/npx, " |
| 808 | f"then: `npx -y {PRINTING_PRESS_NPM} install {source_key} --cli-only`" |
| 809 | ) |
| 810 | |
| 811 | # Bright Data / Amazon. Reported but never installed (it spends the user's |
| 812 | # own metered credits), so the only useful thing setup can do is say |
| 813 | # precisely why the lane is or is not active -- the three states below are |
| 814 | # otherwise invisible, since SKILL.md tells the model not to raise the |
| 815 | # subject mid-run. |
| 816 | brightdata_status_entry = results.get("brightdata") or {} |
| 817 | bd_action = brightdata_status_entry.get("action", "") |
| 818 | if brightdata_status_entry.get("engine_active"): |
| 819 | lines.append(" - Bright Data CLI ready (Amazon buyer signals available)") |
| 820 | elif bd_action == "already_installed": |
| 821 | lines.append( |
| 822 | " - Bright Data CLI installed but not logged in — run " |
| 823 | "`brightdata login` to enable Amazon buyer signals (optional)" |
| 824 | ) |
| 825 | elif bd_action == "installed_off_path": |
| 826 | bd_path = brightdata_status_entry.get("path", "") |
| 827 | lines.append( |
| 828 | f" - Bright Data CLI found at {bd_path} but not on PATH — add " |
| 829 | f"{os.path.dirname(os.path.expanduser(bd_path))} to PATH and restart " |
| 830 | "your agent session/gateway for Amazon buyer signals to activate" |
| 831 | ) |
| 832 | elif bd_action == "not_installed": |
| 833 | lines.append( |
| 834 | " - Amazon buyer signals not installed (optional; 5,000 free " |
| 835 | "requests/month). Install with: npm i -g @brightdata/cli && brightdata login" |
| 836 | ) |
| 837 | |
| 838 | cookie_note = results.get("cookie_note") |
| 839 | if cookie_note: |
| 840 | # Official-only host: relay the neutral note; say nothing about |
| 841 | # browsers. The scan was not attempted, so nothing is "found". |
| 842 | lines.append(f" - {cookie_note}") |
| 843 | |
| 844 | env_written = results.get("env_written", False) |
| 845 | if env_written: |
| 846 | lines.append("") |
| 847 | if cookie_note: |
| 848 | lines.append("Configuration saved.") |
| 849 | else: |
| 850 | lines.append("Configuration saved. Future runs will auto-detect your browsers.") |
| 851 | |
| 852 | return "\n".join(lines) |
| 853 | |
| 854 | |
| 855 | # --------------------------------------------------------------------------- |
| 856 | # OpenClaw server-side setup (no browser, JSON output) |
| 857 | # --------------------------------------------------------------------------- |
| 858 | |
| 859 | _OPENCLAW_KEY_NAMES = [ |
| 860 | "SCRAPECREATORS_API_KEY", |
| 861 | "XAI_API_KEY", |
| 862 | "BRAVE_API_KEY", |
| 863 | "EXA_API_KEY", |
| 864 | "SERPER_API_KEY", |
| 865 | "OPENAI_API_KEY", |
| 866 | "AUTH_TOKEN", |
| 867 | ] |
| 868 | |
| 869 | |
| 870 | def run_openclaw_setup(config: Dict[str, Any]) -> Dict[str, Any]: |
| 871 | """Server-side setup probe: no cookies, tool + key availability, Digg CLI. |
| 872 | |
| 873 | Best-effort installs digg-pp-cli when npx is available (same as desktop |
| 874 | ``run_auto_setup``). Returns a dict suitable for JSON output to stdout so |
| 875 | that SKILL.md can present appropriate options to the user. |
| 876 | """ |
| 877 | yt_dlp = shutil.which("yt-dlp") is not None |
| 878 | node = shutil.which("node") is not None |
| 879 | python3 = shutil.which("python3") is not None |
| 880 | |
| 881 | digg_installed, digg_action, digg_stderr, digg_path = _install_digg_cli() |
| 882 | |
| 883 | keys: Dict[str, bool] = {} |
| 884 | for key_name in _OPENCLAW_KEY_NAMES: |
| 885 | short = key_name.lower().replace("_api_key", "").replace("_key", "").replace("_token", "") |
| 886 | # Normalize: AUTH_TOKEN -> auth, SCRAPECREATORS_API_KEY -> scrapecreators |
| 887 | keys[short] = bool(config.get(key_name)) |
| 888 | |
| 889 | # Determine x_method |
| 890 | if config.get("XAI_API_KEY"): |
| 891 | x_method: Optional[str] = "xai" |
| 892 | elif config.get("AUTH_TOKEN") and config.get("CT0"): |
| 893 | x_method = "cookies" |
| 894 | else: |
| 895 | x_method = None |
| 896 | |
| 897 | payload: Dict[str, Any] = { |
| 898 | "yt_dlp": yt_dlp, |
| 899 | "node": node, |
| 900 | "python3": python3, |
| 901 | "digg_cli": digg_installed, |
| 902 | "digg_action": digg_action, |
| 903 | "keys": keys, |
| 904 | "x_method": x_method, |
| 905 | } |
| 906 | if digg_path: |
| 907 | payload["digg_path"] = digg_path |
| 908 | if digg_action == "install_failed" and digg_stderr: |
| 909 | payload["digg_stderr"] = digg_stderr |
| 910 | return payload |
| 911 | |
| 912 | |
| 913 | # --------------------------------------------------------------------------- |
| 914 | # Device auth flow (GitHub OAuth via ScrapeCreators) |
| 915 | # --------------------------------------------------------------------------- |
| 916 | |
| 917 | _DEVICE_BASE = "https://api.scrapecreators.com/v1/github/device" |
| 918 | |
| 919 | # A GitHub device code is always XXXX-XXXX (uppercase alphanumerics). We validate |
| 920 | # user_code against this before copying, labeling, or emitting it so a malformed |
| 921 | # or key-shaped value (e.g. a returning-account server response) is never |
| 922 | # mislabeled as a device code or leaked to stdout/clipboard. |
| 923 | _DEVICE_CODE_RE = re.compile(r"^[0-9A-Z]{4}-[0-9A-Z]{4}$") |
| 924 | |
| 925 | |
| 926 | def _existing_scrapecreators_key() -> Optional[str]: |
| 927 | """Return the SCRAPECREATORS_API_KEY already saved in the .env, if any.""" |
| 928 | try: |
| 929 | from . import env as _env |
| 930 | |
| 931 | if _env.CONFIG_FILE and _env.CONFIG_FILE.exists(): |
| 932 | return _env.load_env_file(_env.CONFIG_FILE).get("SCRAPECREATORS_API_KEY") or None |
| 933 | except Exception as exc: # never let a config-read failure block auth |
| 934 | logger.debug("Could not read existing ScrapeCreators key: %s", exc) |
| 935 | return None |
| 936 | |
| 937 | |
| 938 | def run_device_auth() -> Optional[Tuple[str, str, str, int]]: |
| 939 | """Start the device authorization flow. |
| 940 | |
| 941 | POSTs to the ScrapeCreators device/code endpoint. |
| 942 | |
| 943 | Returns: |
| 944 | (device_code, user_code, verification_uri, interval) on success, |
| 945 | None on failure. |
| 946 | """ |
| 947 | try: |
| 948 | body = json.dumps({}).encode() |
| 949 | req = Request(f"{_DEVICE_BASE}/code", data=body, method="POST") |
| 950 | req.add_header("Content-Type", "application/json") |
| 951 | with urlopen(req, timeout=15) as resp: |
| 952 | data = json.loads(resp.read()) |
| 953 | except (HTTPError, URLError, OSError) as exc: |
| 954 | logger.warning("Device auth code request failed: %s", exc) |
| 955 | return None |
| 956 | |
| 957 | device_code = data.get("device_code") |
| 958 | user_code = data.get("user_code") |
| 959 | verification_uri = data.get("verification_uri") |
| 960 | interval = data.get("interval", 5) |
| 961 | |
| 962 | if not device_code or not user_code: |
| 963 | # Log only the response's key names, never its values — a returning |
| 964 | # account's response could carry a raw API key we must not write to logs. |
| 965 | logger.warning( |
| 966 | "Device auth returned incomplete response (keys: %s)", sorted(data.keys()) |
| 967 | ) |
| 968 | return None |
| 969 | |
| 970 | return (device_code, user_code, verification_uri or "", interval) |
| 971 | |
| 972 | |
| 973 | def poll_device_auth( |
| 974 | device_code: str, |
| 975 | interval: int, |
| 976 | timeout: int = 300, |
| 977 | user_code: str = "", |
| 978 | clipboard_ok: bool = False, |
| 979 | ) -> Optional[str]: |
| 980 | """Poll for an access token after the user authorizes the device. |
| 981 | |
| 982 | Args: |
| 983 | device_code: The device_code from run_device_auth(). |
| 984 | interval: Polling interval in seconds. |
| 985 | timeout: Maximum time to poll in seconds. |
| 986 | user_code: The user code to remind about during polling. |
| 987 | clipboard_ok: Whether the code was copied to clipboard. |
| 988 | |
| 989 | Returns: |
| 990 | access_token on success, None on timeout or failure. |
| 991 | """ |
| 992 | import sys |
| 993 | |
| 994 | started_at = time.time() |
| 995 | deadline = started_at + timeout |
| 996 | last_reminder = started_at |
| 997 | reminder_count = 0 |
| 998 | max_reminders = 4 |
| 999 | reminder_interval = 30 # seconds between reminders |
| 1000 | |
| 1001 | while time.time() < deadline: |
| 1002 | time.sleep(interval) |
| 1003 | |
| 1004 | # Periodic reminder of the code while waiting |
| 1005 | if ( |
| 1006 | user_code |
| 1007 | and reminder_count < max_reminders |
| 1008 | and time.time() - last_reminder >= reminder_interval |
| 1009 | ): |
| 1010 | clipboard_hint = " (on your clipboard)" if clipboard_ok else "" |
| 1011 | print( |
| 1012 | f" Still waiting... Your code: {user_code}{clipboard_hint}", |
| 1013 | file=sys.stderr, |
| 1014 | flush=True, |
| 1015 | ) |
| 1016 | last_reminder = time.time() |
| 1017 | reminder_count += 1 |
| 1018 | |
| 1019 | try: |
| 1020 | body = json.dumps({"device_code": device_code}).encode() |
| 1021 | req = Request(f"{_DEVICE_BASE}/token", data=body, method="POST") |
| 1022 | req.add_header("Content-Type", "application/json") |
| 1023 | with urlopen(req, timeout=15) as resp: |
| 1024 | data = json.loads(resp.read()) |
| 1025 | except HTTPError as exc: |
| 1026 | if exc.code in (400, 403, 428): |
| 1027 | continue |
| 1028 | logger.warning("Device auth poll error: %s", exc) |
| 1029 | return None |
| 1030 | except (URLError, OSError): |
| 1031 | continue |
| 1032 | |
| 1033 | if data.get("access_token"): |
| 1034 | return data["access_token"] |
| 1035 | |
| 1036 | error = data.get("error") |
| 1037 | if error == "slow_down": |
| 1038 | interval = min(interval + 2, 30) |
| 1039 | continue |
| 1040 | if error == "authorization_pending": |
| 1041 | continue |
| 1042 | if error in ("expired_token", "access_denied"): |
| 1043 | logger.warning("Device auth failed: %s", error) |
| 1044 | return None |
| 1045 | |
| 1046 | return None |
| 1047 | |
| 1048 | |
| 1049 | # Bounded retries for transient ScrapeCreators /profile 5xx (see #882). |
| 1050 | _PROFILE_FETCH_ATTEMPTS = 3 |
| 1051 | _PROFILE_FETCH_RETRY_SLEEP_S = 1.0 |
| 1052 | _PROFILE_ERROR_BODY_LIMIT = 200 |
| 1053 | |
| 1054 | |
| 1055 | def _http_error_detail(exc: HTTPError, *, body_limit: int = _PROFILE_ERROR_BODY_LIMIT) -> str: |
| 1056 | """Build a diagnosable HTTPError string including a truncated body. |
| 1057 | |
| 1058 | The body is capped and never treated as a secret source of truth; callers |
| 1059 | still must not log bearer tokens. Used so a 5xx is not a black box. |
| 1060 | """ |
| 1061 | base = f"HTTP Error {exc.code}: {getattr(exc, 'reason', '') or ''}".rstrip(": ") |
| 1062 | try: |
| 1063 | raw = exc.read() or b"" |
| 1064 | except Exception: |
| 1065 | return base |
| 1066 | if not raw: |
| 1067 | return base |
| 1068 | text = raw.decode("utf-8", errors="replace").strip() |
| 1069 | if not text: |
| 1070 | return base |
| 1071 | if len(text) > body_limit: |
| 1072 | text = text[:body_limit] + "…" |
| 1073 | return f"{base} body={text!r}" |
| 1074 | |
| 1075 | |
| 1076 | def fetch_api_key(access_token: str) -> Dict[str, Any]: |
| 1077 | """Fetch the ScrapeCreators API key using the GitHub access token. |
| 1078 | |
| 1079 | GETs the device/profile endpoint with Bearer auth. Distinguishes outcomes |
| 1080 | so callers (and SKILL.md) do not collapse a server 5xx into the |
| 1081 | already-linked guidance (#882). |
| 1082 | |
| 1083 | Returns a result dict: |
| 1084 | - ``{"ok": True, "api_key": str}`` on success |
| 1085 | - ``{"ok": False, "reason": "no_api_key"}`` when /profile is 2xx but has |
| 1086 | no ``api_key`` field (typical already-linked account) |
| 1087 | - ``{"ok": False, "reason": "upstream_error", "http_status": int, |
| 1088 | "detail": str}`` on 5xx after bounded retries |
| 1089 | - ``{"ok": False, "reason": "http_error", "http_status": int, |
| 1090 | "detail": str}`` on other HTTP errors (e.g. 401) |
| 1091 | - ``{"ok": False, "reason": "request_failed", "detail": str}`` on |
| 1092 | network/OS failures |
| 1093 | """ |
| 1094 | data: Optional[Dict[str, Any]] = None |
| 1095 | last_upstream: Optional[Dict[str, Any]] = None |
| 1096 | |
| 1097 | for attempt in range(_PROFILE_FETCH_ATTEMPTS): |
| 1098 | try: |
| 1099 | req = Request(f"{_DEVICE_BASE}/profile") |
| 1100 | req.add_header("Authorization", f"Bearer {access_token}") |
| 1101 | with urlopen(req, timeout=15) as resp: |
| 1102 | data = json.loads(resp.read()) |
| 1103 | break |
| 1104 | except HTTPError as exc: |
| 1105 | detail = _http_error_detail(exc) |
| 1106 | if 500 <= int(exc.code) <= 599: |
| 1107 | logger.warning( |
| 1108 | "Device auth /profile upstream error (attempt %s/%s): %s", |
| 1109 | attempt + 1, |
| 1110 | _PROFILE_FETCH_ATTEMPTS, |
| 1111 | detail, |
| 1112 | ) |
| 1113 | last_upstream = { |
| 1114 | "ok": False, |
| 1115 | "reason": "upstream_error", |
| 1116 | "http_status": int(exc.code), |
| 1117 | "detail": detail, |
| 1118 | } |
| 1119 | if attempt + 1 < _PROFILE_FETCH_ATTEMPTS: |
| 1120 | time.sleep(_PROFILE_FETCH_RETRY_SLEEP_S) |
| 1121 | continue |
| 1122 | return last_upstream |
| 1123 | logger.warning("Failed to fetch API key: %s", detail) |
| 1124 | return { |
| 1125 | "ok": False, |
| 1126 | "reason": "http_error", |
| 1127 | "http_status": int(exc.code), |
| 1128 | "detail": detail, |
| 1129 | } |
| 1130 | except (URLError, OSError) as exc: |
| 1131 | logger.warning("Failed to fetch API key: %s", exc) |
| 1132 | return {"ok": False, "reason": "request_failed", "detail": str(exc)} |
| 1133 | |
| 1134 | if data is None: |
| 1135 | # Defensive: loop exited without success or an explicit return. |
| 1136 | return last_upstream or { |
| 1137 | "ok": False, |
| 1138 | "reason": "request_failed", |
| 1139 | "detail": "No profile response", |
| 1140 | } |
| 1141 | |
| 1142 | api_key = data.get("api_key") |
| 1143 | if not api_key: |
| 1144 | # The /profile response parsed but carried no api_key — the common case |
| 1145 | # for a GitHub account already linked to a ScrapeCreators account. Log |
| 1146 | # the response's FIELD NAMES only (never values — the body may contain a |
| 1147 | # key under a different field) so the already-registered response shape |
| 1148 | # can be handled in a follow-up (see plan OQ1). |
| 1149 | logger.warning( |
| 1150 | "Device auth /profile returned no api_key (fields: %s)", sorted(data.keys()) |
| 1151 | ) |
| 1152 | return {"ok": False, "reason": "no_api_key"} |
| 1153 | return {"ok": True, "api_key": api_key} |
| 1154 | |
| 1155 | |
| 1156 | def _device_handle_path() -> Path: |
| 1157 | """Where run_github_start persists the device_code/interval for run_github_poll. |
| 1158 | |
| 1159 | Kept next to the .env in the config dir; falls back to the OS temp dir when |
| 1160 | no config dir is resolvable (clean/no-config mode). |
| 1161 | """ |
| 1162 | try: |
| 1163 | from . import env as _env |
| 1164 | |
| 1165 | if _env.CONFIG_FILE: |
| 1166 | return _env.CONFIG_FILE.parent / ".github-device-handle.json" |
| 1167 | except Exception: |
| 1168 | pass |
| 1169 | import tempfile |
| 1170 | |
| 1171 | return Path(tempfile.gettempdir()) / "last30days-github-device-handle.json" |
| 1172 | |
| 1173 | |
| 1174 | def _start_device_flow() -> "Tuple[Dict[str, Any], Optional[Dict[str, Any]]]": |
| 1175 | """Submit the GitHub device flow and surface the code, without polling. |
| 1176 | |
| 1177 | Returns ``(public_result, handle)``. ``handle`` is None for the |
| 1178 | already-registered and error cases (nothing to poll); otherwise it carries |
| 1179 | the private poll state (``device_code``/``interval``/``user_code``/ |
| 1180 | ``clipboard_ok``) that never belongs in the public, stdout-printed result. |
| 1181 | Callers either persist the handle to a file (``run_github_start``, for a |
| 1182 | separate poll process) or hand it straight to ``run_github_poll`` in-memory |
| 1183 | (``run_full_device_auth``, so a failed file write can't strand the one-shot). |
| 1184 | """ |
| 1185 | import sys |
| 1186 | import webbrowser |
| 1187 | |
| 1188 | # Already-registered short-circuit: a saved key means no device dance. The |
| 1189 | # key is returned raw here and masked at the CLI boundary before print. |
| 1190 | existing = _existing_scrapecreators_key() |
| 1191 | if existing: |
| 1192 | return ( |
| 1193 | { |
| 1194 | "status": "already_registered", |
| 1195 | "method": "existing", |
| 1196 | "api_key": existing, |
| 1197 | "persisted": True, |
| 1198 | }, |
| 1199 | None, |
| 1200 | ) |
| 1201 | |
| 1202 | result = run_device_auth() |
| 1203 | if result is None: |
| 1204 | return ({"status": "error", "message": "Failed to start device auth flow"}, None) |
| 1205 | |
| 1206 | device_code, user_code, verification_uri, interval = result |
| 1207 | |
| 1208 | # Validate the code shape BEFORE copying, labeling, or emitting it. A |
| 1209 | # non-conforming user_code (e.g. a key-shaped value) is never surfaced as a |
| 1210 | # GitHub device code; we stop rather than instruct the user to paste garbage. |
| 1211 | if not _DEVICE_CODE_RE.match(user_code): |
| 1212 | logger.warning("Device auth returned a non-device-shaped user_code; aborting.") |
| 1213 | return ( |
| 1214 | { |
| 1215 | "status": "error", |
| 1216 | "message": "ScrapeCreators returned an unexpected device-code format.", |
| 1217 | }, |
| 1218 | None, |
| 1219 | ) |
| 1220 | |
| 1221 | # Structured stdout line for machine consumers. |
| 1222 | print( |
| 1223 | json.dumps( |
| 1224 | { |
| 1225 | "event": "device_code_ready", |
| 1226 | "user_code": user_code, |
| 1227 | "verification_uri": verification_uri, |
| 1228 | } |
| 1229 | ), |
| 1230 | flush=True, |
| 1231 | ) |
| 1232 | |
| 1233 | # Copy the code to the clipboard BEFORE opening the browser. |
| 1234 | clipboard_ok = False |
| 1235 | if sys.platform == "darwin": |
| 1236 | try: |
| 1237 | subprocess.run(["pbcopy"], input=user_code.encode(), check=True, timeout=5) |
| 1238 | clipboard_ok = True |
| 1239 | except Exception: |
| 1240 | pass # pbcopy unavailable or failed, fall through |
| 1241 | |
| 1242 | # Print the code as a plain HUMAN line on stdout too, so a foreground caller |
| 1243 | # sees it in the returned output even without reading the JSON. The clipboard |
| 1244 | # claim is only made when pbcopy actually succeeded (else: type it). |
| 1245 | if clipboard_ok: |
| 1246 | print( |
| 1247 | f"Your GitHub code: {user_code} (already on your clipboard - just paste it, Cmd+V)", |
| 1248 | flush=True, |
| 1249 | ) |
| 1250 | else: |
| 1251 | print(f"Your GitHub code: {user_code} (type it on the GitHub page)", flush=True) |
| 1252 | |
| 1253 | # Human box on stderr for direct-terminal users. |
| 1254 | clipboard_hint = " (copied to clipboard)" if clipboard_ok else "" |
| 1255 | code_line = f" Your code: {user_code}{clipboard_hint}" |
| 1256 | action_line = " Paste it on the GitHub page that just opened" |
| 1257 | width = max(len(code_line), len(action_line)) + 2 |
| 1258 | border = "-" * width |
| 1259 | print(f"\n+{border}+", file=sys.stderr) |
| 1260 | print(f"|{code_line.ljust(width)}|", file=sys.stderr) |
| 1261 | print(f"|{action_line.ljust(width)}|", file=sys.stderr) |
| 1262 | print(f"+{border}+", file=sys.stderr) |
| 1263 | |
| 1264 | if verification_uri: |
| 1265 | try: |
| 1266 | webbrowser.open(verification_uri) |
| 1267 | except Exception: |
| 1268 | print(f"Open: {verification_uri}", file=sys.stderr) |
| 1269 | |
| 1270 | public = { |
| 1271 | "status": "awaiting_authorization", |
| 1272 | "user_code": user_code, |
| 1273 | "verification_uri": verification_uri, |
| 1274 | "clipboard_ok": clipboard_ok, |
| 1275 | } |
| 1276 | handle = { |
| 1277 | "device_code": device_code, |
| 1278 | "interval": interval, |
| 1279 | "user_code": user_code, |
| 1280 | "clipboard_ok": clipboard_ok, |
| 1281 | } |
| 1282 | return (public, handle) |
| 1283 | |
| 1284 | |
| 1285 | def run_github_start() -> Dict[str, Any]: |
| 1286 | """Start the device flow and persist the poll handle for a later |
| 1287 | ``run_github_poll`` process. Returns the public result (never the private |
| 1288 | device_code). See ``_start_device_flow`` for the returned statuses.""" |
| 1289 | public, handle = _start_device_flow() |
| 1290 | if handle is not None: |
| 1291 | # Persist the poll handle (0o600) so a separate --github-poll process can |
| 1292 | # resume it. Best-effort: the in-memory one-shot path does not depend on |
| 1293 | # this write succeeding. |
| 1294 | path = _device_handle_path() |
| 1295 | try: |
| 1296 | path.parent.mkdir(parents=True, exist_ok=True) |
| 1297 | path.write_text(json.dumps(handle), encoding="utf-8") |
| 1298 | os.chmod(path, 0o600) |
| 1299 | except Exception as exc: |
| 1300 | logger.warning("Could not persist device handle: %s", exc) |
| 1301 | return public |
| 1302 | |
| 1303 | |
| 1304 | def run_github_poll(timeout: int = 300, *, _handle: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: |
| 1305 | """Poll for authorization using the handle from start. |
| 1306 | |
| 1307 | ``_handle`` (in-memory, from the one-shot) takes precedence over the |
| 1308 | persisted handle file. Returns success (with the fetched key), timeout, or |
| 1309 | the honest "Authorized but failed to fetch API key" branch. Deletes the |
| 1310 | persisted handle when the flow terminates. |
| 1311 | """ |
| 1312 | import sys |
| 1313 | |
| 1314 | if _handle is not None: |
| 1315 | data = _handle |
| 1316 | else: |
| 1317 | try: |
| 1318 | data = json.loads(_device_handle_path().read_text(encoding="utf-8")) |
| 1319 | except Exception: |
| 1320 | return { |
| 1321 | "status": "error", |
| 1322 | "message": "No pending GitHub device flow; run setup --github-start first.", |
| 1323 | } |
| 1324 | |
| 1325 | device_code = data["device_code"] |
| 1326 | interval = int(data.get("interval", 5)) |
| 1327 | user_code = data.get("user_code", "") |
| 1328 | # Read the real clipboard state so the polling reminder never falsely claims |
| 1329 | # the code is on the clipboard (non-macOS, or a failed pbcopy). Missing key |
| 1330 | # (older handle) defaults to False -- don't overstate. |
| 1331 | clipboard_ok = bool(data.get("clipboard_ok", False)) |
| 1332 | |
| 1333 | print("Waiting for authorization...", file=sys.stderr, flush=True) |
| 1334 | access_token = poll_device_auth( |
| 1335 | device_code, interval, timeout=timeout, user_code=user_code, clipboard_ok=clipboard_ok |
| 1336 | ) |
| 1337 | |
| 1338 | def _cleanup() -> None: |
| 1339 | try: |
| 1340 | _device_handle_path().unlink() |
| 1341 | except Exception: |
| 1342 | pass |
| 1343 | |
| 1344 | if access_token is None: |
| 1345 | _cleanup() |
| 1346 | return {"status": "timeout", "user_code": user_code} |
| 1347 | |
| 1348 | fetched = fetch_api_key(access_token) |
| 1349 | _cleanup() |
| 1350 | if fetched.get("ok") and fetched.get("api_key"): |
| 1351 | return { |
| 1352 | "status": "success", |
| 1353 | "method": "device", |
| 1354 | "api_key": fetched["api_key"], |
| 1355 | "user_code": user_code, |
| 1356 | } |
| 1357 | |
| 1358 | # Discriminate failure modes so SKILL.md does not misdiagnose a 5xx as |
| 1359 | # "GitHub already linked" (#882). Keep the historical message only for the |
| 1360 | # 2xx-without-api_key / already-linked case. |
| 1361 | reason = fetched.get("reason") or "request_failed" |
| 1362 | if reason == "no_api_key": |
| 1363 | return { |
| 1364 | "status": "error", |
| 1365 | "message": "Authorized but failed to fetch API key", |
| 1366 | "reason": "no_api_key", |
| 1367 | } |
| 1368 | if reason == "upstream_error": |
| 1369 | http_status = fetched.get("http_status") |
| 1370 | return { |
| 1371 | "status": "error", |
| 1372 | "message": f"Authorized but ScrapeCreators profile failed (HTTP {http_status})", |
| 1373 | "reason": "upstream_error", |
| 1374 | "http_status": http_status, |
| 1375 | "detail": fetched.get("detail"), |
| 1376 | } |
| 1377 | if reason == "http_error": |
| 1378 | http_status = fetched.get("http_status") |
| 1379 | return { |
| 1380 | "status": "error", |
| 1381 | "message": f"Authorized but failed to fetch API key (HTTP {http_status})", |
| 1382 | "reason": "http_error", |
| 1383 | "http_status": http_status, |
| 1384 | "detail": fetched.get("detail"), |
| 1385 | } |
| 1386 | return { |
| 1387 | "status": "error", |
| 1388 | "message": "Authorized but failed to fetch API key", |
| 1389 | "reason": reason, |
| 1390 | "detail": fetched.get("detail"), |
| 1391 | } |
| 1392 | |
| 1393 | |
| 1394 | def run_full_device_auth(timeout: int = 300) -> Dict[str, Any]: |
| 1395 | """Back-compat one-shot: start the device flow, then poll to completion. |
| 1396 | |
| 1397 | Passes the poll handle to ``run_github_poll`` IN MEMORY, so a failed handle- |
| 1398 | file write can't strand the one-shot. Kept so callers of ``setup --github`` / |
| 1399 | ``--device-auth`` still work; the model-driven wizard uses the two-command |
| 1400 | split (start then poll) instead. |
| 1401 | """ |
| 1402 | public, handle = _start_device_flow() |
| 1403 | if handle is None: |
| 1404 | return public # already_registered or error |
| 1405 | return run_github_poll(timeout=timeout, _handle=handle) |
| 1406 | |
| 1407 | |
| 1408 | # --------------------------------------------------------------------------- |
| 1409 | # Unified GitHub auth |
| 1410 | # --------------------------------------------------------------------------- |
| 1411 | |
| 1412 | |
| 1413 | def run_github_auth(timeout: int = 300) -> Dict[str, Any]: |
| 1414 | """Run the --github setup path via device auth (one-shot, back-compat). |
| 1415 | |
| 1416 | The existing-key short-circuit now lives in run_github_start; this delegates |
| 1417 | to the start+poll chain. This path must not read or forward local GitHub CLI |
| 1418 | tokens. |
| 1419 | """ |
| 1420 | return run_full_device_auth(timeout=timeout) |
| 1421 |