diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 482b5eb2..bd3c7d5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,11 +23,20 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + # Build the React console so the wheel can bundle it as vouch/web/console + # (hatch_build.py force-includes webapp/dist when present). + - uses: actions/setup-node@v4 + with: + node-version: "20" + - run: npm --prefix webapp ci && npm --prefix webapp run build - uses: actions/setup-python@v5 with: python-version: "3.12" - run: python -m pip install --upgrade build - - run: python -m build + # sdist is source-only; the wheel is built from the working tree (not the + # sdist) so the freshly-built, gitignored webapp/dist rides inside it. + - run: python -m build --sdist + - run: python -m build --wheel - uses: actions/upload-artifact@v7 with: name: dist diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f01b10..bb2ec563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] ### Added +- `vouch console`: serve the vendored React web console straight from the + installed package — a same-origin `/proxy` bridge (loopback-guarded) to + `vouch serve --transport http` backends, reimplementing the vite dev-proxy + in python. the built SPA is bundled into the wheel as `vouch/web/console` + (conditionally, via a hatch build hook), so `pip install 'vouch-kb[web]'` + then `vouch console` needs no node and no repo clone. - `kb.activity` read method (+ `vouch activity` CLI mirror): audit-log activity buckets for dashboards — per-day counts with proposal/decision breakdowns, an hour-of-week matrix, and actor/event histograms. windowed diff --git a/README.md b/README.md index 8624883d..a8ce7a5c 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,16 @@ docker run --rm -p 127.0.0.1:5173:5173 -v vouch-demo-data:/data ghcr.io/plind-ju Pre-seeded KB + full webapp console, zero setup. Pass `-e ANTHROPIC_API_KEY=sk-ant-...` to enable LLM features. +**For the full UI without Docker** — Python only, no clone, no node: + +```bash +pipx install 'vouch-kb[web]' # the browser console ships inside the wheel +vouch serve --transport http --port 8731 & # a backend for the current .vouch/ +vouch console # console at http://localhost:5173 — connect it to :8731 +``` + +`vouch console` serves the same React console as the Docker demo, straight from the installed package. + **For CLI + Claude Code integration** (most common ongoing workflow): ```bash @@ -93,9 +103,10 @@ compile: vouch review # walk pending proposals one at a time ``` -**Want a browser UI for reviewing and proposing?** The video shows the **vouch webapp** — chat, review queue, claims, and stats. You have three options: +**Want a browser UI for reviewing and proposing?** The video shows the **vouch webapp** — chat, review queue, claims, and stats. You have four options: - **No setup**: Use the Docker demo (recommended) +- **pip, no clone**: `pipx install 'vouch-kb[web]'` then `vouch console` — serves the same React console from the installed package (Python only, no Docker, no node), open http://localhost:5173 - **Local development**: Clone the repo, run `make console`, open http://localhost:5173 - **CLI-only**: Use `vouch review`, `vouch show `, `vouch approve ` commands instead @@ -113,7 +124,13 @@ docker run --rm -p 127.0.0.1:5173:5173 \ # then open http://localhost:5173 ``` -Or to skip Docker entirely and use the CLI tools: +Or serve that same console with no Docker — `vouch console` in place of Terminal 2 (needs the `[web]` extra), then add the `:8731` backend in the connect dialog: + +```bash +vouch console # http://localhost:5173, proxying to the server above +``` + +Or to skip the browser entirely and use the CLI tools: ```bash vouch review # walk pending proposals @@ -122,7 +139,7 @@ vouch approve # approve a proposal vouch reject --reason "…" # reject with feedback ``` -Lighter alternatives ship with vouch itself: `vouch review-ui` (a built-in browser queue; `pipx install 'vouch-kb[web]'` for the extra), or piecemeal `vouch pending`, `vouch show `, `vouch approve `, `vouch reject --reason "…"`. +Both browser UIs ship with vouch under the `[web]` extra (`pipx install 'vouch-kb[web]'`): `vouch console` is the full React console shown in the video; `vouch review-ui` is a lighter built-in review queue. Or go piecemeal: `vouch pending`, `vouch show `, `vouch approve `, `vouch reject --reason "…"`. **5. Compile the wiki.** diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 00000000..c47df8d6 --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,27 @@ +"""Bundle the built React console (webapp/dist) into the wheel. + +When webapp/dist has been built, ship it as ``vouch/web/console`` so +``pip install 'vouch-kb[web]'`` + ``vouch console`` serves it with no node. +When it has NOT been built (a fresh checkout, or the sdist -> wheel path where +the gitignored dist isn't present), skip it silently: the wheel still builds, +and ``vouch console`` reports the missing console cleanly. This is why the +include is a hook rather than a static ``force-include``, which hard-errors on +a missing source path. +""" + +from __future__ import annotations + +import os +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class ConsoleBundleHook(BuildHookInterface): + PLUGIN_NAME = "custom" + + def initialize(self, version: str, build_data: dict[str, Any]) -> None: + dist = os.path.join(self.root, "webapp", "dist") + if not os.path.isfile(os.path.join(dist, "index.html")): + return # not built — degrade gracefully rather than fail the build + build_data.setdefault("force_include", {})[dist] = "vouch/web/console" diff --git a/openclaw.plugin.json b/openclaw.plugin.json index ffcd470d..a21192b1 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -1,7 +1,7 @@ { "id": "vouch", "name": "Vouch", - "version": "1.2.2", + "version": "1.3.0", "description": "Git-native, review-gated knowledge base. Registers vouch's context engine (cited retrieval + salience reflex + hot memory) and the vouch skills. The kb.* MCP server is deployment config: `openclaw mcp add vouch -- vouch serve`.", "kind": "context-engine", "skills": [ diff --git a/package.json b/package.json index 9ccee39b..f62c5cb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vouch", - "version": "1.2.2", + "version": "1.3.0", "private": true, "description": "OpenClaw plugin packaging for vouch. The Python package lives in pyproject.toml; this file only tells OpenClaw's plugin loader which entry module to import and which plugin API range the plugin supports.", "openclaw": { diff --git a/pyproject.toml b/pyproject.toml index 624cf336..c15a6260 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vouch-kb" -version = "1.2.2" +version = "1.3.0" description = "Git-native, review-gated knowledge base for LLM agents. MCP server + CLI." readme = "README.md" requires-python = ">=3.11" @@ -82,6 +82,14 @@ packages = ["src/vouch"] [tool.hatch.build.targets.wheel.force-include] "adapters" = "vouch/adapters" +# The built React console (webapp/dist) rides inside the wheel as +# vouch/web/console so `pip install 'vouch-kb[web]'` + `vouch console` serves it +# with no node. It is force-included *conditionally* by hatch_build.py — a plain +# force-include hard-errors when webapp/dist isn't built (fresh checkout, sdist +# → wheel), whereas the hook skips it and `vouch console` reports it cleanly. +[tool.hatch.build.targets.wheel.hooks.custom] +path = "hatch_build.py" + [tool.ruff] line-length = 100 target-version = "py311" diff --git a/src/vouch/__init__.py b/src/vouch/__init__.py index 236c24ff..c0b9743c 100644 --- a/src/vouch/__init__.py +++ b/src/vouch/__init__.py @@ -3,4 +3,4 @@ # One of FOUR version sites kept in lockstep (with pyproject.toml, # openclaw.plugin.json, package.json) — enforced by # tests/test_openclaw_plugin_manifest.py::test_manifest_versions_in_step. -__version__ = "1.2.2" +__version__ = "1.3.0" diff --git a/src/vouch/cli.py b/src/vouch/cli.py index d8bd823b..bb9f1f48 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -3804,6 +3804,71 @@ def _resolve_auth_token(auth: str | None) -> str | None: return auth +@cli.command(name="console") +@click.option( + "--bind", + "bind", + default="127.0.0.1:5173", + show_default=True, + help="host:port to bind. A non-loopback host (e.g. 0.0.0.0) also " + "requires --allow-remote so the proxy bridge isn't exposed openly.", +) +@click.option( + "--allow-remote", + is_flag=True, + help="Drop the loopback guard on the /proxy bridge. Only for a deployment " + "behind its own auth — a same-origin page could otherwise drive a " + "local reviewer's backends.", +) +@click.option( + "--open-browser/--no-open-browser", + default=True, + show_default=True, + help="Open the browser to the console on startup.", +) +def console(bind: str, allow_remote: bool, open_browser: bool) -> None: + """Serve the vouch web console (the React review UI) locally. + + Ships the built SPA and a same-origin /proxy bridge to your + `vouch serve --transport http` backends — one `pip install 'vouch-kb[web]'`, + no node. Add a backend from the connect dialog in the UI. + """ + from .web import _require_console_deps + + try: + _require_console_deps() + except ImportError as exc: + raise click.ClickException(str(exc)) from exc + + from .web.console import ConsoleError, resolve_console_dir, serve_console + + host, sep, port_raw = bind.partition(":") + if not sep: + raise click.ClickException(f"invalid --bind {bind!r}; expected host:port") + try: + port = int(port_raw) + except ValueError as exc: + raise click.ClickException(f"invalid port in --bind {bind!r}") from exc + host = host or "127.0.0.1" + if host not in ("127.0.0.1", "::1", "localhost") and not allow_remote: + raise click.ClickException( + f"--bind {bind} is non-loopback; pass --allow-remote to expose the " + "proxy bridge (only behind your own auth)." + ) + + url = f"http://{host}:{port}/" + if open_browser and resolve_console_dir() is not None: + import threading + import webbrowser + + threading.Timer(0.6, lambda: webbrowser.open(url)).start() + click.echo(f"vouch console → {url}") + try: + serve_console(host=host, port=port, allow_remote=allow_remote) + except ConsoleError as exc: + raise click.ClickException(str(exc)) from exc + + @cli.command(name="review-ui") @click.option( "--bind", diff --git a/src/vouch/web/__init__.py b/src/vouch/web/__init__.py index e6c89ab3..ece1cfa5 100644 --- a/src/vouch/web/__init__.py +++ b/src/vouch/web/__init__.py @@ -32,6 +32,26 @@ def _require_web_extra() -> None: ) +def _require_console_deps() -> None: + """Fail with a clean message if the console's serve deps aren't installed. + + The React console needs only starlette (the app) + uvicorn (the server) — + a subset of the [web] extra; jinja2 is not required. + """ + missing: list[str] = [] + for name in ("starlette", "uvicorn"): + try: + __import__(name) + except ImportError: + missing.append(name) + if missing: + raise ImportError( + "vouch console needs the [web] extra. " + "Install with: pip install 'vouch-kb[web]' " + f"(missing: {', '.join(missing)})" + ) + + def create_app( # type: ignore[no-untyped-def] kb_root: str | None = None, *, diff --git a/src/vouch/web/console.py b/src/vouch/web/console.py new file mode 100644 index 00000000..7b5fa8a9 --- /dev/null +++ b/src/vouch/web/console.py @@ -0,0 +1,174 @@ +"""Serve the vendored React review console (the `webapp/` SPA) from vouch. + +The console is a static single-page app. It cannot call vouch cross-origin — +vouch deliberately sends no CORS headers — so it reaches every backend through +a same-origin ``/proxy/*`` bridge, passing the real endpoint in an +``X-Vouch-Target`` header. In dev that bridge is a vite plugin +(`webapp/plugins/vouch-proxy.ts`); this module reimplements it in Python so a +single ``pip install 'vouch-kb[web]'`` can serve the built SPA with no node. + +This is a *viewport*: the bridge only forwards bytes to a `vouch serve +--transport http` backend, which remains the sole path to the review gate. +""" + +from __future__ import annotations + +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +from starlette.applications import Starlette +from starlette.concurrency import run_in_threadpool +from starlette.requests import Request +from starlette.responses import FileResponse, JSONResponse, Response +from starlette.routing import Route + +_MODULE_DIR = Path(__file__).resolve().parent + +# Loopback peers a same-origin browser client can present. The bridge is +# refused to anything else unless the operator explicitly opts in — a +# third-party page must not be able to drive a local reviewer's backends. +_LOOPBACK = frozenset({"127.0.0.1", "::1", "::ffff:127.0.0.1"}) + +# Methods the SPA actually uses are GET (health/capabilities) and POST (rpc); +# accept the common verbs so the bridge stays transparent. +_PROXY_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] + +# A generous ceiling: an rpc that runs the compile/summary LLM is synchronous. +_PROXY_TIMEOUT = 300.0 + + +class ConsoleError(RuntimeError): + """The console cannot be served (no built SPA, or a missing dependency).""" + + +def _default_repo_dist() -> Path: + """`webapp/dist` relative to a source checkout (…/src/vouch/web → repo).""" + return _MODULE_DIR.parents[2] / "webapp" / "dist" + + +def resolve_console_dir( + *, packaged: Path | None = None, repo_dist: Path | None = None +) -> Path | None: + """Locate the built console assets, or ``None`` if none are built. + + Prefers the copy bundled inside the wheel (``vouch/web/console``); falls + back to ``webapp/dist`` in a source checkout. Mirrors how + ``install_adapter`` prefers the repo tree over the packaged copy. + """ + packaged = packaged if packaged is not None else _MODULE_DIR / "console" + if (packaged / "index.html").is_file(): + return packaged + repo_dist = repo_dist if repo_dist is not None else _default_repo_dist() + if (repo_dist / "index.html").is_file(): + return repo_dist + return None + + +def _err(status: int, code: str, message: str) -> JSONResponse: + """The vouch-native error envelope the SPA already understands.""" + return JSONResponse( + {"ok": False, "error": {"code": code, "message": message}}, status_code=status + ) + + +def build_console_app(console_dir: Path, *, allow_remote: bool = False) -> Starlette: + """Build the ASGI app: the ``/proxy/*`` bridge + the static SPA. + + ``allow_remote`` drops the loopback guard on the bridge — only for + deliberately-exposed deployments behind their own auth. + """ + root = console_dir.resolve() + index = root / "index.html" + + async def _proxy(request: Request) -> Response: + client_host = request.client.host if request.client else None + if not allow_remote and client_host not in _LOOPBACK: + return _err(403, "forbidden", "proxy is only available to loopback clients") + + target_raw = request.headers.get("x-vouch-target") + if not target_raw: + return _err(400, "bad_target", "missing X-Vouch-Target header") + parsed = urllib.parse.urlparse(target_raw) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return _err(400, "bad_target", f"not a valid http(s) target: {target_raw}") + + # The path after /proxy is appended to the target's host:port; any path + # on the target itself is dropped, matching vouch-proxy.ts exactly. + sub = request.url.path[len("/proxy") :] or "/" + fwd_url = f"{parsed.scheme}://{parsed.netloc}{sub}" + if request.url.query: + fwd_url += f"?{request.url.query}" + + fwd_headers: dict[str, str] = {} + if request.headers.get("content-type"): + fwd_headers["content-type"] = request.headers["content-type"] + if request.headers.get("authorization"): + fwd_headers["authorization"] = request.headers["authorization"] + body = await request.body() + method = request.method + + def _do() -> tuple[int, str, bytes]: + req = urllib.request.Request( + fwd_url, data=body or None, method=method, headers=fwd_headers + ) + try: + with urllib.request.urlopen(req, timeout=_PROXY_TIMEOUT) as resp: + ctype = resp.headers.get("content-type", "application/json") + return resp.status, ctype, resp.read() + except urllib.error.HTTPError as exc: + # A backend 4xx/5xx is a real answer — pass it through unchanged + # rather than masking it as a proxy 502. + ctype = exc.headers.get("content-type", "application/json") if exc.headers else ( + "application/json" + ) + return exc.code, ctype, exc.read() + + try: + status, ctype, payload = await run_in_threadpool(_do) + except urllib.error.URLError as exc: + return _err(502, "proxy_error", str(exc.reason)) + return Response(content=payload, status_code=status, media_type=ctype) + + async def _spa(request: Request) -> Response: + """Serve a real asset, else index.html so client-side routing works.""" + rel = request.path_params.get("full_path", "") + if rel: + candidate = (root / rel).resolve() + try: + candidate.relative_to(root) # reject path-traversal escapes + except ValueError: + candidate = index + if candidate.is_file(): + return FileResponse(candidate) + return FileResponse(index) + + routes = [ + Route("/proxy", _proxy, methods=_PROXY_METHODS), + Route("/proxy/{path:path}", _proxy, methods=_PROXY_METHODS), + Route("/{full_path:path}", _spa, methods=["GET", "HEAD"]), + ] + return Starlette(routes=routes) + + +def serve_console( + *, + host: str = "127.0.0.1", + port: int = 5173, + allow_remote: bool = False, + console_dir: Path | None = None, +) -> None: + """Serve the console with uvicorn (blocks). Raises ``ConsoleError`` early + if no built SPA can be found, before uvicorn is ever started.""" + resolved = console_dir if console_dir is not None else resolve_console_dir() + if resolved is None: + raise ConsoleError( + "no built vouch console found. from a source checkout run " + "`npm run build` in webapp/; otherwise install a release wheel of " + "vouch-kb[web] (the console ships inside it)." + ) + import uvicorn + + app = build_console_app(resolved, allow_remote=allow_remote) + uvicorn.run(app, host=host, port=port, log_level="info") diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 00000000..c59964d0 --- /dev/null +++ b/tests/test_console.py @@ -0,0 +1,238 @@ +"""Tests for `vouch console` — serving the vendored React SPA + /proxy bridge. + +The console is a static single-page app that reaches vouch HTTP backends +through a same-origin ``/proxy/*`` bridge (the browser can't call vouch +cross-origin — vouch deliberately sends no CORS headers). In dev that bridge +is a vite plugin (`webapp/plugins/vouch-proxy.ts`); these cover the Python +reimplementation of it plus the console-directory resolution that lets one +`pip install` serve the built SPA with no node. +""" + +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from typing import Any, ClassVar + +import pytest + +pytest.importorskip("starlette", reason="vouch console needs the [web] extra") + +from fastapi.testclient import TestClient + +from vouch.web import console as console_mod +from vouch.web.console import build_console_app, resolve_console_dir + +# --- a stub "vouch serve --transport http" upstream ------------------------- + + +class _StubBackend(BaseHTTPRequestHandler): + """Records what the proxy forwarded; echoes enough to assert on.""" + + seen: ClassVar[list[dict[str, Any]]] = [] + + def _reply(self, status: int, body: dict[str, Any]) -> None: + payload = json.dumps(body).encode() + self.send_response(status) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def do_GET(self) -> None: + _StubBackend.seen.append( + {"method": "GET", "path": self.path, "auth": self.headers.get("authorization")} + ) + if self.path == "/boom": + self._reply(401, {"ok": False, "error": {"code": "unauthorized"}}) + return + self._reply(200, {"ok": True, "path": self.path}) + + def do_POST(self) -> None: + n = int(self.headers.get("content-length", 0)) + raw = self.rfile.read(n).decode() + _StubBackend.seen.append({"method": "POST", "path": self.path, "body": raw}) + self._reply(200, {"ok": True, "echo": raw}) + + def log_message(self, *_a: Any) -> None: # silence the test log + pass + + +@pytest.fixture +def upstream(): + _StubBackend.seen = [] + srv = HTTPServer(("127.0.0.1", 0), _StubBackend) + t = threading.Thread(target=srv.serve_forever, daemon=True) + t.start() + host, port = srv.server_address + try: + yield f"http://{host}:{port}" + finally: + srv.shutdown() + + +@pytest.fixture +def console_dir(tmp_path: Path) -> Path: + d = tmp_path / "console" + (d / "assets").mkdir(parents=True) + (d / "index.html").write_text( + "vouch console", encoding="utf-8" + ) + (d / "assets" / "app.js").write_text("console.log('hi')", encoding="utf-8") + return d + + +def _loopback_client(app) -> TestClient: # type: ignore[no-untyped-def] + """A same-origin loopback browser client.""" + return TestClient(app, client=("127.0.0.1", 54321)) + + +# --- static SPA serving ----------------------------------------------------- + + +def test_serves_the_spa_index_at_root(console_dir: Path) -> None: + res = _loopback_client(build_console_app(console_dir)).get("/") + assert res.status_code == 200 + assert "vouch console" in res.text + + +def test_unknown_route_falls_back_to_index_for_client_routing(console_dir: Path) -> None: + # a SPA deep link the server has no file for must return index.html (200), + # not 404 — client-side routing renders the view. + res = _loopback_client(build_console_app(console_dir)).get("/review") + assert res.status_code == 200 + assert "vouch console" in res.text + + +def test_real_static_asset_is_served(console_dir: Path) -> None: + res = _loopback_client(build_console_app(console_dir)).get("/assets/app.js") + assert res.status_code == 200 + assert "console.log" in res.text + + +# --- the /proxy bridge ------------------------------------------------------ + + +def test_proxy_forwards_get_to_the_target_backend(console_dir: Path, upstream: str) -> None: + client = _loopback_client(build_console_app(console_dir)) + res = client.get("/proxy/health", headers={"X-Vouch-Target": upstream}) + assert res.status_code == 200 + assert res.json()["path"] == "/health" + assert _StubBackend.seen[-1] == {"method": "GET", "path": "/health", "auth": None} + + +def test_proxy_forwards_post_body_and_auth_header(console_dir: Path, upstream: str) -> None: + client = _loopback_client(build_console_app(console_dir)) + res = client.post( + "/proxy/rpc", + headers={ + "X-Vouch-Target": upstream, + "authorization": "Bearer sekret", + "content-type": "application/json", + }, + content=b'{"method":"kb.status"}', + ) + assert res.status_code == 200 + assert _StubBackend.seen[-1]["path"] == "/rpc" + assert _StubBackend.seen[-1]["body"] == '{"method":"kb.status"}' + + +def test_proxy_passes_through_a_backend_error_status(console_dir: Path, upstream: str) -> None: + # a 401 from the backend must reach the browser as 401, not be masked as 502. + client = _loopback_client(build_console_app(console_dir)) + res = client.get("/proxy/boom", headers={"X-Vouch-Target": upstream}) + assert res.status_code == 401 + assert res.json()["error"]["code"] == "unauthorized" + + +def test_proxy_requires_the_target_header(console_dir: Path) -> None: + res = _loopback_client(build_console_app(console_dir)).get("/proxy/health") + assert res.status_code == 400 + assert res.json()["error"]["code"] == "bad_target" + + +def test_proxy_rejects_a_non_http_target(console_dir: Path) -> None: + res = _loopback_client(build_console_app(console_dir)).get( + "/proxy/health", headers={"X-Vouch-Target": "ftp://evil.example/x"} + ) + assert res.status_code == 400 + assert res.json()["error"]["code"] == "bad_target" + + +def test_proxy_rejects_non_loopback_client_by_default(console_dir: Path, upstream: str) -> None: + app = build_console_app(console_dir) # allow_remote defaults False + remote = TestClient(app, client=("10.0.0.9", 1234)) + res = remote.get("/proxy/health", headers={"X-Vouch-Target": upstream}) + assert res.status_code == 403 + assert res.json()["error"]["code"] == "forbidden" + + +def test_proxy_allows_remote_when_opted_in(console_dir: Path, upstream: str) -> None: + app = build_console_app(console_dir, allow_remote=True) + remote = TestClient(app, client=("10.0.0.9", 1234)) + res = remote.get("/proxy/health", headers={"X-Vouch-Target": upstream}) + assert res.status_code == 200 + + +# --- console-directory resolution ------------------------------------------ + + +def test_resolve_prefers_the_packaged_console(tmp_path: Path) -> None: + pkg = tmp_path / "pkg" + pkg.mkdir() + (pkg / "index.html").write_text("packaged", encoding="utf-8") + repo = tmp_path / "repo" + repo.mkdir() + (repo / "index.html").write_text("repo", encoding="utf-8") + assert resolve_console_dir(packaged=pkg, repo_dist=repo) == pkg + + +def test_resolve_falls_back_to_repo_dist(tmp_path: Path) -> None: + pkg = tmp_path / "pkg" # never created + repo = tmp_path / "repo" + repo.mkdir() + (repo / "index.html").write_text("repo", encoding="utf-8") + assert resolve_console_dir(packaged=pkg, repo_dist=repo) == repo + + +def test_resolve_is_none_when_no_console_is_built(tmp_path: Path) -> None: + assert resolve_console_dir(packaged=tmp_path / "a", repo_dist=tmp_path / "b") is None + + +def test_serve_console_errors_clearly_when_no_console_is_built( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # no built SPA anywhere → a clean, actionable error, never a traceback deep + # inside uvicorn (which must not even be reached). + monkeypatch.setattr(console_mod, "resolve_console_dir", lambda **_k: None) + with pytest.raises(console_mod.ConsoleError, match="npm run build"): + console_mod.serve_console() + + +# --- the `vouch console` CLI command --------------------------------------- + + +def test_cli_console_rejects_non_loopback_bind_without_allow_remote() -> None: + from click.testing import CliRunner + + from vouch.cli import cli + + res = CliRunner().invoke(cli, ["console", "--bind", "0.0.0.0:5173", "--no-open-browser"]) + assert res.exit_code != 0 + assert "allow-remote" in res.output.lower() + + +def test_cli_console_errors_cleanly_when_no_console_built( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from click.testing import CliRunner + + from vouch.cli import cli + + # loopback bind, but nothing is built → a clean click error, never a server. + monkeypatch.setattr(console_mod, "resolve_console_dir", lambda **_k: None) + res = CliRunner().invoke(cli, ["console", "--no-open-browser"]) + assert res.exit_code != 0 + assert "npm run build" in res.output