feat: add Digg to first-run NUX (auto-install digg-pp-cli) (#590)
* feat(setup): auto-install digg-pp-cli during first-run NUX Install the free, keyless digg-pp-cli via the Printing Press installer in run_auto_setup, mirroring the yt-dlp/brew auto-install. The Digg source already activates whenever the binary is on PATH (available_sources), so this closes the only NUX gap. Degrades to a recommend-only outcome when npx is unavailable; verifies on PATH and the Go bin dir; never raises. Reports the outcome in the setup-complete summary. * docs(nux): describe Digg auto-install in Step 0 + CONFIGURATION Step 0 wizard narrative now mentions the best-effort digg-pp-cli install; CONFIGURATION.md documents Digg as a free, keyless, read-only optional source with its install command. Source display itself needs no change — ACTIVE_SOURCES_LIST is built from the engine's --diagnose output, which already maps digg->Digg. * fix(setup): align Digg NUX with printing-press-library PATH contract Use pinned @mvanhorn/printing-press-library@0.1.16, probe ~/.local/bin, distinguish installed_off_path from engine-active installs, and extend OpenClaw setup. Document the Hermes/OpenClaw agent PATH gap in AGENTS.md and docs/solutions/. * fix(setup): off-PATH Digg message names the actual install dir The installed_off_path status hardcoded '$HOME/.local/bin' in its PATH instruction even though the binary is probed across ~/.local/bin, $GOPATH/bin, and ~/go/bin. A user whose digg-pp-cli landed in ~/go/bin was told to add the wrong dir and couldn't activate Digg. Derive the dir to add from where the binary was actually found. Resolves Greptile #590. --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Trevin Chow <trevin@trevinchow.com>
This commit is contained in:
@@ -41,6 +41,7 @@ Python 3.12+ required. Use `uv` for the env; the venv lives at `.venv/`.
|
||||
- One-time setup: `npx skills add . -g -y` copies the skill into `~/.agents/skills/<name>/` (real directory) and, for harnesses that support symlinked skill dirs, drops a per-host symlink pointing at that copy. **Working-tree edits do NOT propagate automatically** — the `~/.agents/skills/<name>/` copy is frozen at install time. To sync after edits, re-run `npx skills add . -g -y`. For live-edit on a dev machine, replace the install copy with a symlink to the working tree: `ln -sfn "$PWD/skills/last30days" ~/.agents/skills/last30days` (run from the repo root).
|
||||
- Git remote: origin = public (`mvanhorn/last30days-skill`)
|
||||
- Every `lib/*.py` call to `log.source_log(...)` must pass `tty_only=False`. The default is `True`, which silently drops every line when stderr isn't a TTY (Claude Code, Codex, CI, captured output) — turning source observability into invisible failure. Enforced by `tests/test_source_log_visibility.py`.
|
||||
- **CLI-gated optional sources** (Digg via `digg-pp-cli`, YouTube via `yt-dlp`) activate only when `shutil.which` resolves the binary on the **agent subprocess PATH** — not merely when the file exists on disk. First-run setup installs Digg through `@mvanhorn/printing-press-library` (default `$HOME/.local/bin`); Hermes/OpenClaw gateways often need that directory on PATH. Setup must distinguish PATH-visible installs from off-PATH binaries and must not claim "now active" unless the engine gate would pass. See `docs/solutions/integration-issues/digg-cli-agent-path-setup-wizard.md`.
|
||||
|
||||
## Security hygiene
|
||||
- Never commit real API keys, browser cookies, auth tokens, app passwords, access tokens, or `.env` contents.
|
||||
|
||||
@@ -69,6 +69,7 @@ The project-scoped file is the cleanest pattern for **per-client setups**: drop
|
||||
| Polymarket | none | always on | yes |
|
||||
| GitHub | `gh` CLI installed (uses your GitHub auth) | always on if `gh` present | yes |
|
||||
| YouTube | `yt-dlp` CLI installed | always on if `yt-dlp` present | yes |
|
||||
| Digg | `digg-pp-cli` on PATH (auto-installed during first-run setup via `npx -y @mvanhorn/printing-press-library@0.1.16 install digg --cli-only`; binary defaults to `$HOME/.local/bin` — Hermes/OpenClaw agent subprocesses must inherit that dir on PATH for Digg to activate; prior pp-digg installs use the same path) | always on if `digg-pp-cli` on PATH | yes (free, keyless, read-only) |
|
||||
| X / Twitter | one of: `AUTH_TOKEN` + `CT0` (browser cookies, Bird CLI), `XAI_API_KEY`, `XQUIK_API_KEY`, `SCRAPECREATORS_API_KEY`, or `FROM_BROWSER` (cookie-jar auth) | X items in results | cookie-jar / Bird = free; Xquik / xAI / ScrapeCreators = key-based |
|
||||
| TikTok | `SCRAPECREATORS_API_KEY` + `INCLUDE_SOURCES` contains `tiktok` | TikTok items | 10K free calls |
|
||||
| Instagram | `SCRAPECREATORS_API_KEY` + `INCLUDE_SOURCES` contains `instagram` | Instagram Reels | 10K free calls; raise `LAST30DAYS_TRANSCRIPT_TIMEOUT` (default 30s) if SC is slow on your network |
|
||||
|
||||
@@ -47,6 +47,7 @@ On first run, the skill will guide you through setup:
|
||||
1. **Auto setup** (~30 seconds)
|
||||
- Scans browser cookies for X/Twitter
|
||||
- Checks/installs yt-dlp for YouTube
|
||||
- Best-effort install of `digg-pp-cli` for Digg AI-news clusters (via `@mvanhorn/printing-press-library`; binary lands in `$HOME/.local/bin` — ensure your Hermes gateway PATH includes it, or Digg stays off even after install)
|
||||
- Configures free sources (Reddit, HN, Polymarket)
|
||||
|
||||
2. **Optional: ScrapeCreators**
|
||||
@@ -65,6 +66,7 @@ On first run, the skill will guide you through setup:
|
||||
- **Hacker News** - Tech discussions via Algolia
|
||||
- **Polymarket** - Prediction markets
|
||||
- **YouTube** - Search and transcripts (requires yt-dlp)
|
||||
- **Digg** - AI-news story clusters (requires `digg-pp-cli` on the agent PATH; auto-installed to `$HOME/.local/bin` during setup when `npx` is available)
|
||||
|
||||
### Requires API Key
|
||||
- **X/Twitter** - xAI API key or browser cookies
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: Digg NUX must match printing-press-library install paths and agent subprocess PATH
|
||||
date: 2026-06-17
|
||||
category: docs/solutions/integration-issues
|
||||
module: lib/setup_wizard
|
||||
problem_type: integration_issue
|
||||
component: development_workflow
|
||||
severity: medium
|
||||
symptoms:
|
||||
- Digg source silently off after first-run setup reports success on Hermes or OpenClaw
|
||||
- Users who already installed pp-digg via printing-press-library still see Digg missing from --diagnose available_sources
|
||||
- Setup wizard probed ~/go/bin while the catalog installer writes to ~/.local/bin (printing-press-library 0.1.16+)
|
||||
- OpenClaw setup --openclaw path skipped Digg install entirely
|
||||
root_cause: config_error
|
||||
resolution_type: code_fix
|
||||
related_components:
|
||||
- lib/pipeline
|
||||
- lib/digg
|
||||
- CONFIGURATION.md
|
||||
tags:
|
||||
- digg
|
||||
- setup-wizard
|
||||
- printing-press-library
|
||||
- agent-path
|
||||
- hermes
|
||||
- openclaw
|
||||
- nux
|
||||
- optional-cli-sources
|
||||
---
|
||||
|
||||
# Digg NUX must match printing-press-library install paths and agent subprocess PATH
|
||||
|
||||
## Problem
|
||||
|
||||
First-run setup auto-install for `digg-pp-cli` could report success while the engine still omitted Digg, especially on Hermes and OpenClaw where the agent subprocess PATH often excludes `$HOME/.local/bin`. The initial PR also used the deprecated `@mvanhorn/printing-press` package and probed legacy `~/go/bin` fallbacks instead of the current Printing Press default install dir.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- `--diagnose` `available_sources` lacks `digg` even though pp-digg or setup "installed" the CLI.
|
||||
- Hermes/OpenClaw users with a prior `npx @mvanhorn/printing-press-library install digg --cli-only` run hit false failures or false "now active" messages depending on probe logic.
|
||||
- OpenClaw `setup --openclaw` never attempted Digg install (desktop NUX only).
|
||||
|
||||
## What Didn't Work
|
||||
|
||||
- **Treating "binary exists somewhere" as installed** — `pipeline.available_sources()` and `digg._is_available()` gate on `shutil.which("digg-pp-cli")` only. Probing `~/go/bin` without PATH visibility produced false positives.
|
||||
- **Assuming Hermes vs OpenClaw use different binary locations** — both harnesses use the same printing-press-library default (`$HOME/.local/bin`); only the focused pp-digg *skill* wiring differs.
|
||||
- **Using `@mvanhorn/printing-press`** — superseded by `@mvanhorn/printing-press-library`; install defaults moved from `$GOPATH/bin` to `$HOME/.local/bin` in npm 0.1.16.
|
||||
|
||||
## Solution
|
||||
|
||||
Align setup wizard with the catalog installer and the engine PATH gate:
|
||||
|
||||
1. **Pin installer:** `npx -y @mvanhorn/printing-press-library@0.1.16 install digg --cli-only` (`--cli-only` only — last30days embeds Digg as an engine source, not pp-digg skill).
|
||||
2. **Split outcomes:** `already_installed` / `installed` only when `shutil.which` resolves; `installed_off_path` when the binary exists under known dirs (`~/.local/bin`, legacy `~/go/bin`, Windows PrintingPress bin) but is not PATH-visible; surface `digg_path` and PATH-restart guidance in status text.
|
||||
3. **OpenClaw parity:** `run_openclaw_setup()` runs the same `_install_digg_cli()` and returns `digg_cli`, `digg_action`, optional `digg_path`.
|
||||
4. **Docs:** CONFIGURATION.md, SKILL.md Step 0, HERMES_SETUP.md, AGENTS.md rule for CLI-gated sources.
|
||||
|
||||
Key helper shape in `setup_wizard.py`:
|
||||
|
||||
```python
|
||||
def _digg_on_path() -> Optional[str]:
|
||||
return shutil.which(DIGG_CLI_BIN) # engine gate
|
||||
|
||||
def _digg_off_path_binary() -> Optional[str]:
|
||||
for candidate in _digg_bin_candidate_paths(): # ~/.local/bin first
|
||||
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||||
return str(candidate)
|
||||
return None
|
||||
```
|
||||
|
||||
## Why This Works
|
||||
|
||||
The engine never reads "is pp-digg skill installed?" — every research run shells out to `digg-pp-cli` by name on PATH. Printing Press already installs to a managed user bin dir and warns when that dir is off PATH; last30days setup must mirror that contract instead of inventing a separate success definition. Detecting off-PATH binaries lets setup reuse prior pp-digg installs without lying about activation.
|
||||
|
||||
## Prevention
|
||||
|
||||
- When adding NUX auto-install for a CLI-gated source, match the upstream installer's default bin dir and pin the npm semver.
|
||||
- Success messaging must use the same probe as `available_sources()` (`shutil.which`), with a separate off-PATH outcome when the binary exists on disk.
|
||||
- Cover Hermes/OpenClaw in tests with redirected `HOME` and mocked PATH; add OpenClaw JSON fields when server setup should mirror desktop NUX.
|
||||
- Search `docs/solutions/` for `digg`, `setup-wizard`, and `agent-path` before changing optional-source onboarding.
|
||||
@@ -339,7 +339,7 @@ Before proceeding to Step 1, handle first-run setup.
|
||||
|
||||
**If this IS a first run:**
|
||||
- Run `python3 skills/last30days/scripts/last30days.py setup` (relative to the skill root) to launch the setup wizard.
|
||||
- Follow the wizard's prompts end-to-end. The wizard handles platform detection (OpenClaw vs Claude Code), auto vs manual setup, browser cookie extraction, ScrapeCreators opt-in, and the initial topic picker.
|
||||
- Follow the wizard's prompts end-to-end. The wizard handles platform detection (OpenClaw vs Claude Code), auto vs manual setup, browser cookie extraction, ScrapeCreators opt-in, a best-effort auto-install of the free, keyless Digg CLI (`digg-pp-cli` via `@mvanhorn/printing-press-library install digg --cli-only` — Digg activates only when the binary is on the **agent subprocess PATH**, typically `$HOME/.local/bin`; setup reports honestly if the CLI is installed off-PATH; recommend-only if `npx` is unavailable), and the initial topic picker.
|
||||
- After the wizard writes `SETUP_COMPLETE=true` to `~/.config/last30days/.env`, proceed to research.
|
||||
|
||||
The setup wizard lives as a Python module so it works across all hosts (Claude Code, Codex, Cursor, etc.) and the common-case (already set up) path through this file stays short.
|
||||
|
||||
@@ -35,12 +35,19 @@ def run_auto_setup(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
from ``env.cookie_extraction_browsers()`` (honors ``FROM_BROWSER``;
|
||||
defaults to Firefox/Safari, so no Chrome Keychain prompt)
|
||||
- Checks if yt-dlp is installed
|
||||
- Best-effort install of digg-pp-cli (Printing Press library)
|
||||
|
||||
Returns:
|
||||
Dict with keys:
|
||||
cookies_found: {source_name: browser_name} for each source where cookies were found
|
||||
ytdlp_installed: bool
|
||||
ytdlp_action: already_installed | installed | install_failed | no_homebrew
|
||||
digg_installed: bool (True when the engine can resolve digg-pp-cli on PATH)
|
||||
digg_action: already_installed | installed | installed_off_path | install_failed | no_npx
|
||||
env_written: bool (always False here — caller writes config separately)
|
||||
ytdlp_stderr: present when ytdlp_action is install_failed
|
||||
digg_stderr: present when digg_action is install_failed
|
||||
digg_path: present when digg_action is installed_off_path (binary on disk, not on PATH)
|
||||
"""
|
||||
from . import cookie_extract
|
||||
from .env import COOKIE_DOMAINS, cookie_extraction_browsers
|
||||
@@ -97,17 +104,120 @@ def run_auto_setup(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
ytdlp_installed = False
|
||||
ytdlp_action = "no_homebrew"
|
||||
|
||||
digg_installed, digg_action, digg_stderr, digg_path = _install_digg_cli()
|
||||
|
||||
results: Dict[str, Any] = {
|
||||
"cookies_found": cookies_found,
|
||||
"ytdlp_installed": ytdlp_installed,
|
||||
"ytdlp_action": ytdlp_action,
|
||||
"digg_installed": digg_installed,
|
||||
"digg_action": digg_action,
|
||||
"env_written": False,
|
||||
}
|
||||
if ytdlp_action == "install_failed":
|
||||
results["ytdlp_stderr"] = brew_stderr
|
||||
if digg_action == "install_failed":
|
||||
results["digg_stderr"] = digg_stderr
|
||||
if digg_path:
|
||||
results["digg_path"] = digg_path
|
||||
return results
|
||||
|
||||
|
||||
# Generous timeout: the install shells out to `npx`, which may download the
|
||||
# Printing Press package and build the Go binary over the network.
|
||||
DIGG_INSTALL_TIMEOUT = 300
|
||||
DIGG_CLI_BIN = "digg-pp-cli"
|
||||
# Pin the catalog installer; matches printing-press-library npm 0.1.16 default
|
||||
# ($HOME/.local/bin on macOS/Linux).
|
||||
PRINTING_PRESS_NPM = "@mvanhorn/printing-press-library@0.1.16"
|
||||
DIGG_INSTALL_CMD = f"npx -y {PRINTING_PRESS_NPM} install digg --cli-only"
|
||||
|
||||
|
||||
def _digg_bin_candidate_paths() -> list[Path]:
|
||||
"""Known install locations for digg-pp-cli (Printing Press library defaults).
|
||||
|
||||
Order: current installer default (~/.local/bin), legacy Go bins, Windows
|
||||
managed dir. ``pipeline.available_sources()`` only activates Digg when
|
||||
``shutil.which`` resolves on PATH — probing these dirs is for setup
|
||||
verification and honest off-PATH messaging, not engine activation.
|
||||
"""
|
||||
home = Path.home()
|
||||
candidates: list[Path] = [home / ".local" / "bin" / DIGG_CLI_BIN]
|
||||
gopath = os.environ.get("GOPATH")
|
||||
if gopath:
|
||||
candidates.append(Path(gopath) / "bin" / DIGG_CLI_BIN)
|
||||
candidates.append(home / "go" / "bin" / DIGG_CLI_BIN)
|
||||
if os.name == "nt":
|
||||
local_app = os.environ.get("LOCALAPPDATA") or os.environ.get("LocalAppData")
|
||||
if local_app:
|
||||
candidates.append(
|
||||
Path(local_app) / "Programs" / "PrintingPress" / "bin" / f"{DIGG_CLI_BIN}.exe"
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def _digg_on_path() -> Optional[str]:
|
||||
"""Return digg-pp-cli when the engine would activate Digg (PATH-resolvable)."""
|
||||
return shutil.which(DIGG_CLI_BIN)
|
||||
|
||||
|
||||
def _digg_off_path_binary() -> Optional[str]:
|
||||
"""Return digg-pp-cli path from known install dirs when not on PATH."""
|
||||
for candidate in _digg_bin_candidate_paths():
|
||||
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||||
return str(candidate)
|
||||
return None
|
||||
|
||||
|
||||
def _install_digg_cli() -> Tuple[bool, str, str, str]:
|
||||
"""Best-effort install of the digg-pp-cli binary.
|
||||
|
||||
Mirrors the yt-dlp/brew auto-install: it never raises, and degrades to a
|
||||
recommend-only outcome when the installer is unavailable. Uses
|
||||
``@mvanhorn/printing-press-library`` (``--cli-only``) — the same catalog
|
||||
installer as pp-digg; Hermes/OpenClaw skill wiring is irrelevant here.
|
||||
|
||||
Returns ``(engine_active, action, stderr, off_path_binary)`` where
|
||||
``engine_active`` is True only when ``shutil.which`` resolves the binary
|
||||
(matching ``pipeline.available_sources()``). ``action`` is one of:
|
||||
already_installed | installed | installed_off_path | install_failed | no_npx
|
||||
``stderr`` is populated on ``install_failed``. ``off_path_binary`` is set
|
||||
when the binary exists on disk but is not PATH-visible to this process.
|
||||
"""
|
||||
on_path = _digg_on_path()
|
||||
if on_path:
|
||||
return True, "already_installed", "", ""
|
||||
off_path = _digg_off_path_binary()
|
||||
if off_path:
|
||||
return False, "installed_off_path", "", off_path
|
||||
if shutil.which("npx") is None:
|
||||
return False, "no_npx", "", ""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["npx", "-y", PRINTING_PRESS_NPM, "install", "digg", "--cli-only"],
|
||||
capture_output=True, text=True, timeout=DIGG_INSTALL_TIMEOUT,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("npx install digg exception: %s", exc)
|
||||
return False, "install_failed", str(exc), ""
|
||||
if proc.returncode != 0:
|
||||
stderr = proc.stderr or f"npx install digg exited {proc.returncode}"
|
||||
logger.warning("npx install digg failed (rc=%s): %s", proc.returncode, stderr)
|
||||
return False, "install_failed", stderr, ""
|
||||
on_path = _digg_on_path()
|
||||
if on_path:
|
||||
return True, "installed", "", ""
|
||||
off_path = _digg_off_path_binary()
|
||||
if off_path:
|
||||
combined = (proc.stderr or "").strip()
|
||||
if combined:
|
||||
logger.warning("digg-pp-cli installed off PATH: %s", combined)
|
||||
return False, "installed_off_path", combined, off_path
|
||||
stderr = proc.stderr or "install completed but digg-pp-cli was not found"
|
||||
logger.warning("npx install digg failed verification: %s", stderr)
|
||||
return False, "install_failed", stderr, ""
|
||||
|
||||
|
||||
def _open_secret_append(path: Path):
|
||||
"""Open ``path`` for appending as a 0o600 secret file with no readable window.
|
||||
|
||||
@@ -238,6 +348,31 @@ def get_setup_status_text(results: Dict[str, Any]) -> str:
|
||||
else:
|
||||
lines.append(" - yt-dlp not found (install with: brew install yt-dlp)")
|
||||
|
||||
digg_action = results.get("digg_action", "")
|
||||
if digg_action == "installed":
|
||||
lines.append(" - Installed Digg CLI (free AI-news clusters source now active)")
|
||||
elif digg_action == "already_installed":
|
||||
lines.append(" - Digg CLI already installed (AI-news clusters active)")
|
||||
elif digg_action == "installed_off_path":
|
||||
digg_path = results.get("digg_path", "")
|
||||
# Tell the user to add the dir where the binary was ACTUALLY found
|
||||
# (probed across ~/.local/bin, $GOPATH/bin, ~/go/bin, Windows). Hardcoding
|
||||
# ~/.local/bin would misdirect a user whose binary lives in ~/go/bin.
|
||||
bin_dir = str(Path(digg_path).parent) if digg_path else "$HOME/.local/bin"
|
||||
shown_path = digg_path or "$HOME/.local/bin/digg-pp-cli"
|
||||
lines.append(
|
||||
f" - Digg CLI found at {shown_path} but not on PATH — add "
|
||||
f"{bin_dir} to PATH and restart your agent session/gateway "
|
||||
"for Digg to activate"
|
||||
)
|
||||
elif digg_action == "install_failed":
|
||||
lines.append(f" - Digg CLI install failed — run `{DIGG_INSTALL_CMD}` manually")
|
||||
elif digg_action == "no_npx":
|
||||
lines.append(
|
||||
" - Digg CLI not installed (free, optional). Install Node/npx, then: "
|
||||
f"{DIGG_INSTALL_CMD}"
|
||||
)
|
||||
|
||||
env_written = results.get("env_written", False)
|
||||
if env_written:
|
||||
lines.append("")
|
||||
@@ -262,15 +397,18 @@ _OPENCLAW_KEY_NAMES = [
|
||||
|
||||
|
||||
def run_openclaw_setup(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Server-side setup probe: no cookies, just tool + key availability.
|
||||
"""Server-side setup probe: no cookies, tool + key availability, Digg CLI.
|
||||
|
||||
Returns a dict suitable for JSON output to stdout so that SKILL.md
|
||||
can present appropriate options to the user.
|
||||
Best-effort installs digg-pp-cli when npx is available (same as desktop
|
||||
``run_auto_setup``). Returns a dict suitable for JSON output to stdout so
|
||||
that SKILL.md can present appropriate options to the user.
|
||||
"""
|
||||
yt_dlp = shutil.which("yt-dlp") is not None
|
||||
node = shutil.which("node") is not None
|
||||
python3 = shutil.which("python3") is not None
|
||||
|
||||
digg_installed, digg_action, digg_stderr, digg_path = _install_digg_cli()
|
||||
|
||||
keys: Dict[str, bool] = {}
|
||||
for key_name in _OPENCLAW_KEY_NAMES:
|
||||
short = key_name.lower().replace("_api_key", "").replace("_key", "").replace("_token", "")
|
||||
@@ -285,13 +423,20 @@ def run_openclaw_setup(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
else:
|
||||
x_method = None
|
||||
|
||||
return {
|
||||
payload: Dict[str, Any] = {
|
||||
"yt_dlp": yt_dlp,
|
||||
"node": node,
|
||||
"python3": python3,
|
||||
"digg_cli": digg_installed,
|
||||
"digg_action": digg_action,
|
||||
"keys": keys,
|
||||
"x_method": x_method,
|
||||
}
|
||||
if digg_path:
|
||||
payload["digg_path"] = digg_path
|
||||
if digg_action == "install_failed" and digg_stderr:
|
||||
payload["digg_stderr"] = digg_stderr
|
||||
return payload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -13,8 +13,17 @@ from lib import setup_wizard
|
||||
class TestRunOpenclawSetup:
|
||||
"""Tests for run_openclaw_setup()."""
|
||||
|
||||
@staticmethod
|
||||
def _patch_digg_noop(func):
|
||||
"""Keep OpenClaw setup tests free of real digg-pp-cli / npx side effects."""
|
||||
return patch(
|
||||
"lib.setup_wizard._install_digg_cli",
|
||||
return_value=(False, "no_npx", "", ""),
|
||||
)(func)
|
||||
|
||||
@_patch_digg_noop
|
||||
@patch("shutil.which")
|
||||
def test_all_tools_present_no_keys(self, mock_which):
|
||||
def test_all_tools_present_no_keys(self, mock_which, _mock_digg):
|
||||
"""All CLI tools found, no API keys configured."""
|
||||
mock_which.side_effect = lambda cmd: f"/usr/bin/{cmd}"
|
||||
config = {}
|
||||
@@ -26,9 +35,12 @@ class TestRunOpenclawSetup:
|
||||
assert result["python3"] is True
|
||||
assert all(v is False for v in result["keys"].values())
|
||||
assert result["x_method"] is None
|
||||
assert result["digg_cli"] is False
|
||||
assert result["digg_action"] == "no_npx"
|
||||
|
||||
@_patch_digg_noop
|
||||
@patch("shutil.which")
|
||||
def test_missing_tools(self, mock_which):
|
||||
def test_missing_tools(self, mock_which, _mock_digg):
|
||||
"""Some CLI tools missing."""
|
||||
def which_side(cmd):
|
||||
if cmd == "node":
|
||||
@@ -43,8 +55,9 @@ class TestRunOpenclawSetup:
|
||||
assert result["node"] is False
|
||||
assert result["python3"] is True
|
||||
|
||||
@_patch_digg_noop
|
||||
@patch("shutil.which")
|
||||
def test_keys_detected(self, mock_which):
|
||||
def test_keys_detected(self, mock_which, _mock_digg):
|
||||
"""API keys in config are reported as present."""
|
||||
mock_which.return_value = None
|
||||
config = {
|
||||
@@ -72,8 +85,9 @@ class TestRunOpenclawSetup:
|
||||
)
|
||||
assert expected in text
|
||||
|
||||
@_patch_digg_noop
|
||||
@patch("shutil.which")
|
||||
def test_x_method_xai(self, mock_which):
|
||||
def test_x_method_xai(self, mock_which, _mock_digg):
|
||||
"""x_method is 'xai' when XAI_API_KEY is set."""
|
||||
mock_which.return_value = None
|
||||
config = {"XAI_API_KEY": "xai-key"}
|
||||
@@ -82,8 +96,9 @@ class TestRunOpenclawSetup:
|
||||
|
||||
assert result["x_method"] == "xai"
|
||||
|
||||
@_patch_digg_noop
|
||||
@patch("shutil.which")
|
||||
def test_x_method_cookies(self, mock_which):
|
||||
def test_x_method_cookies(self, mock_which, _mock_digg):
|
||||
"""x_method is 'cookies' when AUTH_TOKEN + CT0 are set."""
|
||||
mock_which.return_value = None
|
||||
config = {"AUTH_TOKEN": "tok", "CT0": "ct0val"}
|
||||
@@ -92,8 +107,9 @@ class TestRunOpenclawSetup:
|
||||
|
||||
assert result["x_method"] == "cookies"
|
||||
|
||||
@_patch_digg_noop
|
||||
@patch("shutil.which")
|
||||
def test_x_method_xai_over_cookies(self, mock_which):
|
||||
def test_x_method_xai_over_cookies(self, mock_which, _mock_digg):
|
||||
"""XAI takes priority over cookies for x_method."""
|
||||
mock_which.return_value = None
|
||||
config = {"XAI_API_KEY": "xai-key", "AUTH_TOKEN": "tok", "CT0": "ct0val"}
|
||||
@@ -102,8 +118,9 @@ class TestRunOpenclawSetup:
|
||||
|
||||
assert result["x_method"] == "xai"
|
||||
|
||||
@_patch_digg_noop
|
||||
@patch("shutil.which")
|
||||
def test_x_method_null_when_nothing(self, mock_which):
|
||||
def test_x_method_null_when_nothing(self, mock_which, _mock_digg):
|
||||
"""x_method is None when no X access configured."""
|
||||
mock_which.return_value = None
|
||||
config = {}
|
||||
@@ -112,8 +129,9 @@ class TestRunOpenclawSetup:
|
||||
|
||||
assert result["x_method"] is None
|
||||
|
||||
@_patch_digg_noop
|
||||
@patch("shutil.which")
|
||||
def test_output_is_json_serializable(self, mock_which):
|
||||
def test_output_is_json_serializable(self, mock_which, _mock_digg):
|
||||
"""Result can be serialized to JSON without errors."""
|
||||
mock_which.return_value = "/usr/bin/something"
|
||||
config = {"XAI_API_KEY": "k", "OPENAI_API_KEY": "ok"}
|
||||
@@ -125,6 +143,37 @@ class TestRunOpenclawSetup:
|
||||
assert parsed["yt_dlp"] is True
|
||||
assert parsed["keys"]["xai"] is True
|
||||
|
||||
@patch("lib.setup_wizard._install_digg_cli")
|
||||
@patch("shutil.which")
|
||||
def test_digg_cli_on_path(self, mock_which, mock_digg_install):
|
||||
"""OpenClaw JSON reports digg_cli when PATH resolves digg-pp-cli."""
|
||||
mock_which.side_effect = lambda cmd: f"/usr/bin/{cmd}"
|
||||
mock_digg_install.return_value = (True, "already_installed", "", "")
|
||||
|
||||
result = setup_wizard.run_openclaw_setup({})
|
||||
|
||||
assert result["digg_cli"] is True
|
||||
assert result["digg_action"] == "already_installed"
|
||||
assert "digg_path" not in result
|
||||
|
||||
@patch("lib.setup_wizard._install_digg_cli")
|
||||
@patch("shutil.which")
|
||||
def test_digg_cli_off_path(self, mock_which, mock_digg_install):
|
||||
"""OpenClaw JSON surfaces off-PATH installs from prior pp-digg setup."""
|
||||
mock_which.return_value = None
|
||||
mock_digg_install.return_value = (
|
||||
False,
|
||||
"installed_off_path",
|
||||
"",
|
||||
"/Users/me/.local/bin/digg-pp-cli",
|
||||
)
|
||||
|
||||
result = setup_wizard.run_openclaw_setup({})
|
||||
|
||||
assert result["digg_cli"] is False
|
||||
assert result["digg_action"] == "installed_off_path"
|
||||
assert result["digg_path"] == "/Users/me/.local/bin/digg-pp-cli"
|
||||
|
||||
|
||||
class TestRunDeviceAuth:
|
||||
"""Tests for run_device_auth()."""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for the first-run setup wizard module."""
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
@@ -173,6 +174,156 @@ class TestYtdlpAutoInstall:
|
||||
assert "something broke" in results["ytdlp_stderr"]
|
||||
|
||||
|
||||
class TestDiggAutoInstall:
|
||||
"""Tests for digg-pp-cli auto-install via npx in run_auto_setup()."""
|
||||
|
||||
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
||||
@patch("shutil.which")
|
||||
def test_digg_already_installed(self, mock_which, mock_extract):
|
||||
"""digg-pp-cli already on PATH -> already_installed, no subprocess."""
|
||||
# yt-dlp missing + brew missing keeps the yt-dlp path subprocess-free;
|
||||
# digg-pp-cli present short-circuits before any npx call.
|
||||
def which_side_effect(cmd):
|
||||
return "/Users/me/go/bin/digg-pp-cli" if cmd == "digg-pp-cli" else None
|
||||
mock_which.side_effect = which_side_effect
|
||||
|
||||
with patch("subprocess.run") as mock_subproc:
|
||||
results = setup_wizard.run_auto_setup({})
|
||||
mock_subproc.assert_not_called()
|
||||
|
||||
assert results["digg_installed"] is True
|
||||
assert results["digg_action"] == "already_installed"
|
||||
|
||||
# Redirect HOME/GOPATH so real ~/.local/bin or ~/go/bin digg-pp-cli on the
|
||||
# dev box does not make the binary look present during absence tests.
|
||||
@staticmethod
|
||||
def _empty_home(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("GOPATH", raising=False)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
||||
@patch("shutil.which")
|
||||
def test_digg_no_npx(self, mock_which, mock_extract, tmp_path, monkeypatch):
|
||||
"""digg-pp-cli missing + npx missing -> no_npx, no subprocess."""
|
||||
self._empty_home(tmp_path, monkeypatch)
|
||||
mock_which.return_value = None
|
||||
|
||||
with patch("subprocess.run") as mock_subproc:
|
||||
results = setup_wizard.run_auto_setup({})
|
||||
mock_subproc.assert_not_called()
|
||||
|
||||
assert results["digg_installed"] is False
|
||||
assert results["digg_action"] == "no_npx"
|
||||
|
||||
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
||||
@patch("subprocess.run")
|
||||
@patch("shutil.which")
|
||||
def test_digg_install_succeeds(self, mock_which, mock_subproc, mock_extract, tmp_path, monkeypatch):
|
||||
"""npx present + install succeeds + binary verifiable -> installed."""
|
||||
self._empty_home(tmp_path, monkeypatch)
|
||||
# First which("digg-pp-cli") (pre-install) -> None, npx -> present,
|
||||
# then post-install which("digg-pp-cli") -> resolves.
|
||||
calls = {"digg": 0}
|
||||
|
||||
def which_side_effect(cmd):
|
||||
if cmd == "digg-pp-cli":
|
||||
calls["digg"] += 1
|
||||
return None if calls["digg"] == 1 else "/Users/me/go/bin/digg-pp-cli"
|
||||
if cmd == "npx":
|
||||
return "/opt/homebrew/bin/npx"
|
||||
return None
|
||||
mock_which.side_effect = which_side_effect
|
||||
mock_subproc.return_value = MagicMock(returncode=0, stderr="")
|
||||
|
||||
results = setup_wizard.run_auto_setup({})
|
||||
|
||||
mock_subproc.assert_called_once_with(
|
||||
["npx", "-y", setup_wizard.PRINTING_PRESS_NPM, "install", "digg", "--cli-only"],
|
||||
capture_output=True, text=True, timeout=setup_wizard.DIGG_INSTALL_TIMEOUT,
|
||||
)
|
||||
assert results["digg_installed"] is True
|
||||
assert results["digg_action"] == "installed"
|
||||
|
||||
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
||||
@patch("subprocess.run")
|
||||
@patch("shutil.which")
|
||||
def test_digg_install_fails_nonzero(self, mock_which, mock_subproc, mock_extract, tmp_path, monkeypatch):
|
||||
"""npx install returns non-zero -> install_failed with stderr."""
|
||||
self._empty_home(tmp_path, monkeypatch)
|
||||
def which_side_effect(cmd):
|
||||
return "/opt/homebrew/bin/npx" if cmd == "npx" else None
|
||||
mock_which.side_effect = which_side_effect
|
||||
mock_subproc.return_value = MagicMock(returncode=1, stderr="npm ERR! boom")
|
||||
|
||||
results = setup_wizard.run_auto_setup({})
|
||||
|
||||
assert results["digg_installed"] is False
|
||||
assert results["digg_action"] == "install_failed"
|
||||
assert "boom" in results["digg_stderr"]
|
||||
|
||||
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
||||
@patch("shutil.which")
|
||||
def test_digg_prior_install_off_path(self, mock_which, mock_extract, tmp_path, monkeypatch):
|
||||
"""pp-digg CLI at ~/.local/bin but not on PATH -> installed_off_path, no npx."""
|
||||
self._empty_home(tmp_path, monkeypatch)
|
||||
local_bin = tmp_path / ".local" / "bin"
|
||||
local_bin.mkdir(parents=True)
|
||||
binary = local_bin / "digg-pp-cli"
|
||||
binary.write_text("#!/bin/sh\n")
|
||||
binary.chmod(0o755)
|
||||
mock_which.return_value = None
|
||||
|
||||
with patch("subprocess.run") as mock_subproc:
|
||||
results = setup_wizard.run_auto_setup({})
|
||||
mock_subproc.assert_not_called()
|
||||
|
||||
assert results["digg_installed"] is False
|
||||
assert results["digg_action"] == "installed_off_path"
|
||||
assert results["digg_path"] == str(binary)
|
||||
|
||||
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
||||
@patch("subprocess.run")
|
||||
@patch("shutil.which")
|
||||
def test_digg_install_zero_but_not_on_path(self, mock_which, mock_subproc, mock_extract, tmp_path, monkeypatch):
|
||||
"""rc=0, binary at $HOME/.local/bin but not on PATH -> installed_off_path."""
|
||||
self._empty_home(tmp_path, monkeypatch)
|
||||
def which_side_effect(cmd):
|
||||
return "/opt/homebrew/bin/npx" if cmd == "npx" else None
|
||||
mock_which.side_effect = which_side_effect
|
||||
|
||||
local_bin = tmp_path / ".local" / "bin"
|
||||
|
||||
def fake_install(*args, **kwargs):
|
||||
local_bin.mkdir(parents=True, exist_ok=True)
|
||||
binary = local_bin / "digg-pp-cli"
|
||||
binary.write_text("#!/bin/sh\n")
|
||||
binary.chmod(0o755)
|
||||
return MagicMock(returncode=0, stderr="")
|
||||
mock_subproc.side_effect = fake_install
|
||||
|
||||
results = setup_wizard.run_auto_setup({})
|
||||
|
||||
assert results["digg_installed"] is False
|
||||
assert results["digg_action"] == "installed_off_path"
|
||||
assert results["digg_path"] == str(local_bin / "digg-pp-cli")
|
||||
|
||||
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
|
||||
@patch("subprocess.run")
|
||||
@patch("shutil.which")
|
||||
def test_digg_install_timeout_does_not_raise(self, mock_which, mock_subproc, mock_extract, tmp_path, monkeypatch):
|
||||
"""subprocess raising (e.g. timeout) -> install_failed, no exception escapes."""
|
||||
self._empty_home(tmp_path, monkeypatch)
|
||||
def which_side_effect(cmd):
|
||||
return "/opt/homebrew/bin/npx" if cmd == "npx" else None
|
||||
mock_which.side_effect = which_side_effect
|
||||
mock_subproc.side_effect = subprocess.TimeoutExpired(cmd="npx", timeout=300)
|
||||
|
||||
results = setup_wizard.run_auto_setup({})
|
||||
|
||||
assert results["digg_installed"] is False
|
||||
assert results["digg_action"] == "install_failed"
|
||||
|
||||
|
||||
class TestWriteSetupConfig:
|
||||
"""Tests for write_setup_config()."""
|
||||
|
||||
@@ -366,6 +517,60 @@ class TestGetSetupStatusText:
|
||||
assert "yt-dlp install failed" in text
|
||||
assert "manually" in text
|
||||
|
||||
def test_status_text_digg_installed(self):
|
||||
results = {"cookies_found": {}, "ytdlp_action": "already_installed",
|
||||
"digg_action": "installed", "env_written": False}
|
||||
text = setup_wizard.get_setup_status_text(results)
|
||||
assert "Installed Digg CLI" in text
|
||||
|
||||
def test_status_text_digg_already_installed(self):
|
||||
results = {"cookies_found": {}, "ytdlp_action": "already_installed",
|
||||
"digg_action": "already_installed", "env_written": False}
|
||||
text = setup_wizard.get_setup_status_text(results)
|
||||
assert "Digg CLI already installed" in text
|
||||
|
||||
def test_status_text_digg_install_failed(self):
|
||||
results = {"cookies_found": {}, "ytdlp_action": "already_installed",
|
||||
"digg_action": "install_failed", "env_written": False}
|
||||
text = setup_wizard.get_setup_status_text(results)
|
||||
assert "Digg CLI install failed" in text
|
||||
assert "printing-press-library" in text
|
||||
|
||||
def test_status_text_digg_installed_off_path(self):
|
||||
results = {"cookies_found": {}, "ytdlp_action": "already_installed",
|
||||
"digg_action": "installed_off_path",
|
||||
"digg_path": "/Users/me/.local/bin/digg-pp-cli",
|
||||
"env_written": False}
|
||||
text = setup_wizard.get_setup_status_text(results)
|
||||
assert "not on PATH" in text
|
||||
assert ".local/bin" in text
|
||||
assert "now active" not in text.lower()
|
||||
|
||||
def test_status_text_digg_off_path_uses_actual_dir(self):
|
||||
"""The PATH instruction names the dir where the binary was ACTUALLY found,
|
||||
not a hardcoded ~/.local/bin (Greptile #590)."""
|
||||
results = {"cookies_found": {}, "ytdlp_action": "already_installed",
|
||||
"digg_action": "installed_off_path",
|
||||
"digg_path": "/Users/me/go/bin/digg-pp-cli",
|
||||
"env_written": False}
|
||||
text = setup_wizard.get_setup_status_text(results)
|
||||
assert "/Users/me/go/bin/digg-pp-cli" in text
|
||||
assert "add /Users/me/go/bin to PATH" in text
|
||||
assert ".local/bin" not in text
|
||||
|
||||
def test_status_text_digg_no_npx(self):
|
||||
results = {"cookies_found": {}, "ytdlp_action": "already_installed",
|
||||
"digg_action": "no_npx", "env_written": False}
|
||||
text = setup_wizard.get_setup_status_text(results)
|
||||
assert "Digg CLI not installed" in text
|
||||
|
||||
def test_status_text_digg_absent_key_renders(self):
|
||||
"""No digg_action key (defensive) -> no Digg line, no error."""
|
||||
results = {"cookies_found": {}, "ytdlp_action": "already_installed",
|
||||
"env_written": False}
|
||||
text = setup_wizard.get_setup_status_text(results)
|
||||
assert "Digg" not in text
|
||||
|
||||
|
||||
class TestSetupSubcommand:
|
||||
"""Tests for setup subcommand detection in argument parsing."""
|
||||
|
||||
Reference in New Issue
Block a user