From 67257bf2a867df8271c52e0aa352e9b4c3ae155e Mon Sep 17 00:00:00 2001 From: Pin Hsu Chen <96507253+PaulChen79@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:01:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(wren):=20GenBI=20app=20build=20&=20deploy?= =?UTF-8?q?=20=E2=80=94=20semantic=20layer=20=E2=86=92=20shareable=20web?= =?UTF-8?q?=20app=20(#2348)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.8 --- core/wren/README.md | 19 + core/wren/src/wren/cli.py | 2 + core/wren/src/wren/genbi/__init__.py | 1 + core/wren/src/wren/genbi/cli.py | 374 ++++++++++++++++ core/wren/src/wren/genbi/composer.py | 127 ++++++ core/wren/src/wren/genbi/index.py | 99 +++++ .../wren/src/wren/genbi/providers/__init__.py | 21 + core/wren/src/wren/genbi/providers/base.py | 40 ++ .../src/wren/genbi/providers/cloudflare.py | 135 ++++++ core/wren/src/wren/genbi/providers/vercel.py | 92 ++++ core/wren/src/wren/genbi/tokens.py | 41 ++ core/wren/src/wren/genbi/verify.py | 160 +++++++ .../skills_content/dlt-connector/SKILL.md | 8 + .../src/wren/skills_content/genbi/SKILL.md | 178 ++++++++ core/wren/tests/unit/test_genbi_build.py | 258 +++++++++++ core/wren/tests/unit/test_genbi_deploy.py | 404 ++++++++++++++++++ core/wren/tests/unit/test_genbi_index.py | 147 +++++++ core/wren/tests/unit/test_genbi_verify.py | 230 ++++++++++ core/wren/tests/unit/test_skills_cli.py | 13 +- docs/core/README.md | 1 + docs/core/guides/genbi.md | 154 +++++++ docs/core/guides/manage_project.md | 23 + docs/core/reference/cli.md | 74 ++++ docs/core/reference/skills.md | 58 +++ skills/index.json | 5 +- skills/wren/SKILL.md | 3 +- 26 files changed, 2661 insertions(+), 6 deletions(-) create mode 100644 core/wren/src/wren/genbi/__init__.py create mode 100644 core/wren/src/wren/genbi/cli.py create mode 100644 core/wren/src/wren/genbi/composer.py create mode 100644 core/wren/src/wren/genbi/index.py create mode 100644 core/wren/src/wren/genbi/providers/__init__.py create mode 100644 core/wren/src/wren/genbi/providers/base.py create mode 100644 core/wren/src/wren/genbi/providers/cloudflare.py create mode 100644 core/wren/src/wren/genbi/providers/vercel.py create mode 100644 core/wren/src/wren/genbi/tokens.py create mode 100644 core/wren/src/wren/genbi/verify.py create mode 100644 core/wren/src/wren/skills_content/genbi/SKILL.md create mode 100644 core/wren/tests/unit/test_genbi_build.py create mode 100644 core/wren/tests/unit/test_genbi_deploy.py create mode 100644 core/wren/tests/unit/test_genbi_index.py create mode 100644 core/wren/tests/unit/test_genbi_verify.py create mode 100644 docs/core/guides/genbi.md diff --git a/core/wren/README.md b/core/wren/README.md index c3650faa4..272f7039f 100644 --- a/core/wren/README.md +++ b/core/wren/README.md @@ -142,6 +142,25 @@ wren memory store --nl "top customers" --sql "SELECT ..." # store NL→SQL pair wren memory recall -q "best customers" # retrieve similar past queries ``` +**7. (Optional) Build a shareable GenBI app** — turn the context layer into a +browser-side dashboard (powered by `wren-core-wasm`) and deploy it to Vercel or +Cloudflare Pages. The CLI owns the build instruction + deterministic state; an +agent authors the app from it: + +```bash +wren genbi build sales --prompt "orders dashboard" --data-mode snapshot # print build instruction +# agent authors apps/sales/ from the instruction (mdl.json + data/*.parquet) +wren genbi register sales --data-mode snapshot # record the app +wren genbi verify sales # preflight (files, MDL, data, secret scan) +wren genbi open sales # local preview +wren genbi deploy sales --provider vercel # ship a shareable URL (preview; --prod for production) +``` + +Tokens come from the env / `.env` (`VERCEL_TOKEN` / `CLOUDFLARE_API_TOKEN`), +never CLI flags; Cloudflare needs `wrangler` installed. See the +[GenBI guide](../../docs/core/guides/genbi.md) and the +[CLI reference](../../docs/core/reference/cli.md#wren-genbi--build--deploy-genbi-apps). + --- ## Connection profiles diff --git a/core/wren/src/wren/cli.py b/core/wren/src/wren/cli.py index 6ad130a3e..25f38d68c 100644 --- a/core/wren/src/wren/cli.py +++ b/core/wren/src/wren/cli.py @@ -616,8 +616,10 @@ if find_spec("lancedb") and find_spec("sentence_transformers"): app.add_typer(memory_app) +from wren.genbi.cli import genbi_app # noqa: PLC0415, E402 from wren.profile_cli import profile_app # noqa: PLC0415, E402 +app.add_typer(genbi_app) app.add_typer(profile_app) diff --git a/core/wren/src/wren/genbi/__init__.py b/core/wren/src/wren/genbi/__init__.py new file mode 100644 index 000000000..aace0482f --- /dev/null +++ b/core/wren/src/wren/genbi/__init__.py @@ -0,0 +1 @@ +"""GenBI app lifecycle — build instruction composition, index, verify, deploy.""" diff --git a/core/wren/src/wren/genbi/cli.py b/core/wren/src/wren/genbi/cli.py new file mode 100644 index 000000000..49ff47c02 --- /dev/null +++ b/core/wren/src/wren/genbi/cli.py @@ -0,0 +1,374 @@ +"""Typer sub-app for ``wren genbi`` commands.""" + +from __future__ import annotations + +import re +from typing import Annotated, Optional + +import typer + +genbi_app = typer.Typer( + name="genbi", + help="Build and deploy GenBI apps from this project's context layer.", +) + +# App names become a path segment under /apps/. Constrain them to a +# simple slug so a crafted name (e.g. "../../etc") can't escape apps/ when +# joined in build/register/verify/open/deploy. +_APP_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$") + + +def _resolve_app_dir(project_path, name: str): + """Validate ``name`` as a slug and return the ``apps/`` directory. + + The slug rule rejects path separators, ``..`` and a leading dot, so the + returned path is always contained in ``/apps/``. + """ + if not _APP_NAME_RE.fullmatch(name): + typer.echo( + "Error: invalid app name. Use letters, numbers, '_' or '-' " + "(no path separators, and not starting with a dot).", + err=True, + ) + raise typer.Exit(1) + return project_path / "apps" / name + + +def _resolve_prompt(prompt: str | None, prompt_file: str | None) -> str | None: + """Resolve the user prompt from flag, file, or stdin ('-').""" + import sys # noqa: PLC0415 + from pathlib import Path # noqa: PLC0415 + + if prompt_file is not None: + p = Path(prompt_file).expanduser() + if not p.exists(): + typer.echo(f"Error: prompt file not found: {p}", err=True) + raise typer.Exit(1) + return p.read_text().strip() or None + if prompt == "-": + return sys.stdin.read().strip() or None + return prompt + + +ProjectPathOpt = Annotated[ + Optional[str], + typer.Option( + "--path", + "-p", + help="Project directory. Auto-detected via WREN_PROJECT_HOME, cwd walk, or ~/.wren/config.yml.", + ), +] + + +@genbi_app.command() +def build( + name: Annotated[str, typer.Argument(help="App name — written to apps//.")], + prompt: Annotated[ + Optional[str], + typer.Option( + "--prompt", help="The user's request for the app. Use '-' to read stdin." + ), + ] = None, + prompt_file: Annotated[ + Optional[str], + typer.Option( + "--prompt-file", + help="Read the user's request from a file (for long/multi-line prompts).", + ), + ] = None, + data_mode: Annotated[ + str, + typer.Option("--data-mode", help="snapshot (bundled data) or live."), + ] = "snapshot", + path: ProjectPathOpt = None, +) -> None: + """Print a project-hydrated build instruction for an agent. + + Writes no app files; only compiles target/mdl.json if it's missing. + """ + from wren.context import ( # noqa: PLC0415 + discover_project_path, + load_models, + load_project_config, + ) + from wren.genbi.composer import compose_build_instruction # noqa: PLC0415 + + try: + project_path = discover_project_path(path) + except SystemExit as e: + typer.echo(str(e), err=True) + raise typer.Exit(1) + + user_prompt = _resolve_prompt(prompt, prompt_file) + if user_prompt is None: + typer.echo( + "Error: a prompt is required — pass --prompt, --prompt-file, " + "or --prompt - to read stdin.", + err=True, + ) + raise typer.Exit(1) + + from wren.genbi.composer import DATA_MODES # noqa: PLC0415 + + if data_mode not in DATA_MODES: + typer.echo( + f"Error: invalid --data-mode {data_mode!r}. Expected one of: " + f"{', '.join(DATA_MODES)}.", + err=True, + ) + raise typer.Exit(1) + + app_dir = _resolve_app_dir(project_path, name) + + mdl_path = project_path / "target" / "mdl.json" + if not mdl_path.exists(): + # Hydrating the instruction needs a current MDL — compile implicitly + # (PRD risk #6) so the agent always sees an up-to-date context layer. + from wren.context import build_json, save_target # noqa: PLC0415 + + try: + save_target(build_json(project_path), project_path) + typer.echo(f"(compiled {mdl_path} first)", err=True) + except Exception as e: + typer.echo(f"Error: could not compile MDL: {e}", err=True) + raise typer.Exit(1) + + config = load_project_config(project_path) + instruction = compose_build_instruction( + app_name=name, + data_mode=data_mode, + user_prompt=user_prompt, + mdl_path=mdl_path, + app_dir=app_dir, + models=load_models(project_path), + data_source=config.get("data_source", "unknown"), + ) + typer.echo(instruction) + + +def _discover(path: str | None): + from wren.context import discover_project_path # noqa: PLC0415 + + try: + return discover_project_path(path) + except SystemExit as e: + typer.echo(str(e), err=True) + raise typer.Exit(1) + + +@genbi_app.command() +def register( + name: Annotated[str, typer.Argument(help="App name under apps//.")], + data_mode: Annotated[ + str, + typer.Option("--data-mode", help="snapshot (bundled data) or live."), + ] = "snapshot", + path: ProjectPathOpt = None, +) -> None: + """Record an agent-authored app in the project index (.wren/apps.yml).""" + from wren.genbi.composer import DATA_MODES # noqa: PLC0415 + from wren.genbi.index import register_app # noqa: PLC0415 + + project_path = _discover(path) + + if data_mode not in DATA_MODES: + typer.echo( + f"Error: invalid --data-mode {data_mode!r}. Expected one of: " + f"{', '.join(DATA_MODES)}.", + err=True, + ) + raise typer.Exit(1) + + app_dir = _resolve_app_dir(project_path, name) + if not app_dir.is_dir(): + typer.echo( + f"Error: no app found at {app_dir}.\n" + " Write the app there first (see `wren genbi build`).", + err=True, + ) + raise typer.Exit(1) + + entry = register_app(project_path, name, data_mode=data_mode) + typer.echo(f"Registered {name} ({entry['data_mode']}, {entry['status']}).") + + +@genbi_app.command(name="list") +def list_apps(path: ProjectPathOpt = None) -> None: + """List registered apps with data mode, status, and deploy state.""" + from wren.genbi.index import load_index # noqa: PLC0415 + + project_path = _discover(path) + apps = load_index(project_path)["apps"] + if not apps: + typer.echo("No apps registered. See `wren genbi build` to create one.") + return + + for name, entry in apps.items(): + deploy = entry.get("deploy") or {} + suffix = f" → {deploy['last_url']}" if deploy.get("last_url") else "" + typer.echo( + f"{name} [{entry.get('data_mode', '?')}, {entry.get('status', '?')}]" + f"{suffix}" + ) + + +@genbi_app.command() +def remove( + name: Annotated[str, typer.Argument(help="Registered app name.")], + path: ProjectPathOpt = None, +) -> None: + """Remove an app's entry from the project index.""" + from wren.genbi.index import remove_app # noqa: PLC0415 + + project_path = _discover(path) + if not remove_app(project_path, name): + typer.echo(f"Error: app {name!r} is not registered.", err=True) + raise typer.Exit(1) + typer.echo(f"Removed {name} from the index (files under apps/{name}/ kept).") + + +def _require_registered(project_path, name: str) -> dict: + from wren.genbi.index import get_app # noqa: PLC0415 + + entry = get_app(project_path, name) + if entry is None: + typer.echo( + f"Error: app {name!r} is not registered.\n" + f" Run `wren genbi register {name}` first.", + err=True, + ) + raise typer.Exit(1) + return entry + + +@genbi_app.command() +def verify( + name: Annotated[str, typer.Argument(help="Registered app name.")], + path: ProjectPathOpt = None, +) -> None: + """Preflight an app: required files, parseable MDL, bundled data.""" + from wren.genbi.index import update_app # noqa: PLC0415 + from wren.genbi.verify import verify_app # noqa: PLC0415 + + project_path = _discover(path) + entry = _require_registered(project_path, name) + + result = verify_app( + _resolve_app_dir(project_path, name), + data_mode=entry.get("data_mode", "snapshot"), + ) + if not result.passed: + typer.echo(f"Verify failed for {name}:", err=True) + for failure in result.failures: + typer.echo(f" - {failure}", err=True) + raise typer.Exit(1) + + if entry.get("status") == "scaffolded": + update_app(project_path, name, status="built") + typer.echo(f"Verify passed for {name}.") + + +@genbi_app.command(name="open") +def open_app( + name: Annotated[str, typer.Argument(help="Registered app name.")], + port: Annotated[ + int, typer.Option("--port", help="Local port (0 = auto-pick).") + ] = 0, + path: ProjectPathOpt = None, +) -> None: + """Serve a built app locally for preview.""" + import http.server # noqa: PLC0415 + import socketserver # noqa: PLC0415 + from functools import partial # noqa: PLC0415 + + project_path = _discover(path) + _require_registered(project_path, name) + app_dir = _resolve_app_dir(project_path, name) + + handler = partial(http.server.SimpleHTTPRequestHandler, directory=str(app_dir)) + with socketserver.TCPServer(("127.0.0.1", port), handler) as httpd: + actual_port = httpd.server_address[1] + typer.echo(f"Serving {name} at http://127.0.0.1:{actual_port}/ (Ctrl-C stops)") + try: + httpd.serve_forever() + except KeyboardInterrupt: + typer.echo("\nStopped.") + + +@genbi_app.command() +def deploy( + name: Annotated[str, typer.Argument(help="Registered app name.")], + provider: Annotated[ + str, + typer.Option("--provider", help="Deploy target: vercel or cloudflare."), + ] = "vercel", + prod: Annotated[ + bool, + typer.Option("--prod", help="Deploy to production (default: preview)."), + ] = False, + path: ProjectPathOpt = None, +) -> None: + """Verify, then ship a registered app to the user's provider account.""" + from datetime import date # noqa: PLC0415 + + from wren.genbi.index import update_app # noqa: PLC0415 + from wren.genbi.providers import get_provider # noqa: PLC0415 + from wren.genbi.providers.base import DeployError # noqa: PLC0415 + from wren.genbi.tokens import resolve_token # noqa: PLC0415 + from wren.genbi.verify import verify_app # noqa: PLC0415 + + project_path = _discover(path) + entry = _require_registered(project_path, name) + app_dir = _resolve_app_dir(project_path, name) + + try: + adapter = get_provider(provider) + except KeyError as e: + typer.echo(f"Error: {e.args[0]}", err=True) + raise typer.Exit(1) + + # Preflight — a broken app never reaches a public URL. + result = verify_app( + app_dir, + data_mode=entry.get("data_mode", "snapshot"), + ) + if not result.passed: + typer.echo(f"Deploy aborted — verify failed for {name}:", err=True) + for failure in result.failures: + typer.echo(f" - {failure}", err=True) + raise typer.Exit(1) + + token = resolve_token(adapter.env_token_var, project_path) + if not token: + typer.echo( + f"Error: no {adapter.env_token_var} found.\n" + f" Export it (`export {adapter.env_token_var}=...`) or add it to " + "your project's .env file.\n" + " Never pass tokens as CLI flags — they leak into shell history.", + err=True, + ) + raise typer.Exit(1) + + try: + deployment = adapter.deploy( + app_dir, + app_name=name, + token=token, + prod=prod, + link=entry.get("deploy"), + ) + except DeployError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) + + deploy_state = { + "provider": adapter.name, + "project_id": deployment.project_id, + "org_id": deployment.org_id, + "account_id": deployment.account_id, + "last_url": deployment.url, + "last_deployed_at": date.today().isoformat(), + "environment": deployment.environment, + } + update_app(project_path, name, status="deployed", deploy=deploy_state) + typer.echo(f"Deployed {name} ({deployment.environment}): {deployment.url}") diff --git a/core/wren/src/wren/genbi/composer.py b/core/wren/src/wren/genbi/composer.py new file mode 100644 index 000000000..726b8d586 --- /dev/null +++ b/core/wren/src/wren/genbi/composer.py @@ -0,0 +1,127 @@ +"""Compose the `wren genbi build` instruction. + +The CLI never builds the app — it hands the agent an authoritative, +project-hydrated build instruction (static template + live project facts + +the user's prompt, verbatim). Mirrors ``_build_base_instructions()`` / +``wren context instructions``: plain markdown on stdout. +""" + +from __future__ import annotations + +from pathlib import Path + +# Version of the wren-core-wasm npm package this instruction targets. +# Keep in sync with core/wren-core-wasm/package.json. +WREN_CORE_WASM_VERSION = "0.4.1" + +DATA_MODES = ("snapshot", "live") + +_LIVE_GUIDANCE = """\ +**Live mode** — the deployed app connects back to the user's own +warehouse/API at query time: + +1. Write a connection config (endpoint URL only) into the app so it knows + where to reach the data. +2. HARD RULE: warehouse credentials MUST NEVER be inlined into the app. + The app is a public static site — anyone who opens the URL can read + every file. Use a proxy/API with its own auth, or browser-side auth. + `wren genbi verify` scans for inlined credentials as best-effort + defense-in-depth — it catches common patterns but is NOT a guarantee; + the HARD RULE above is what actually keeps secrets out. +3. The endpoint the app queries must allow the deployed origin via CORS — + surface this requirement to the user; it is configured on their side. +""" + +_SNAPSHOT_GUIDANCE = """\ +**Snapshot mode** — fully serverless; data ships with the app: + +1. Export the project's data (e.g. the dlt pipeline's DuckDB output) to + parquet (or a .duckdb file) and place it inside the target folder as a + static asset (e.g. `data/*.parquet`). +2. Point the engine profile's `source` at those static assets so the browser + queries them client-side via wren-core-wasm. No backend is involved. +""" + + +def _data_mode_guidance(data_mode: str) -> str: + if data_mode == "snapshot": + return _SNAPSHOT_GUIDANCE + if data_mode == "live": + return _LIVE_GUIDANCE + raise ValueError(f"unknown data-mode {data_mode!r}; expected one of {DATA_MODES}") + + +def _format_model_inventory(models: list[dict]) -> str: + """One markdown bullet per model with its column names.""" + if not models: + return "- (no models found — run `wren context build` first)" + lines = [] + for model in models: + cols = ", ".join(c.get("name", "?") for c in model.get("columns", [])) + lines.append(f"- **{model.get('name', '?')}**: {cols}") + return "\n".join(lines) + + +def compose_build_instruction( + *, + app_name: str, + data_mode: str, + user_prompt: str, + mdl_path: Path, + app_dir: Path, + models: list[dict], + data_source: str, +) -> str: + """Return the full build instruction for the agent. Pure — no IO.""" + return f"""\ +# GenBI App Build Instruction + +You are building a self-contained, static GenBI web app powered by +`wren-core-wasm`. The app runs the Wren engine in the browser and answers the +user's request below using this project's context layer. + +## Wiring wren-core-wasm (pinned: {WREN_CORE_WASM_VERSION}) + +Load the engine from a shared CDN — do NOT bundle the ~68MB wasm binary into +the app folder: + +```html + +``` + +## Project context + +- Compiled MDL: {mdl_path} +- Target folder: {app_dir} (write the app here; do not write outside it) +- Data source: {data_source} +- Data mode: {data_mode} + +### Available models + +{_format_model_inventory(models)} + +## Data handling ({data_mode}) + +{_data_mode_guidance(data_mode)} + +## Acceptance criteria + +- The app loads its MDL and runs at least one query successfully. +- The app contains an `index.html` entry point under the target folder. +- The app answers the user request below. + +## Final steps (run these after the app is written) + +1. `wren genbi register {app_name} --data-mode {data_mode}` +2. `wren genbi verify {app_name}` + +## User request + +{user_prompt} +""" diff --git a/core/wren/src/wren/genbi/index.py b/core/wren/src/wren/genbi/index.py new file mode 100644 index 000000000..3032b00ee --- /dev/null +++ b/core/wren/src/wren/genbi/index.py @@ -0,0 +1,99 @@ +"""App index — the single source of truth for GenBI apps in a project. + +Owns ``/.wren/apps.yml``. Machine-written (via ``wren genbi +register``), never hand-rolled. Mirrors the ``~/.wren/profiles.yml`` +registry pattern. Secrets are never stored here — only non-secret link +state. +""" + +from __future__ import annotations + +from datetime import date +from pathlib import Path + +import yaml + +INDEX_SCHEMA_VERSION = 1 + +_INDEX_RELPATH = Path(".wren") / "apps.yml" + +# App status state machine: scaffolded → built → deployed +STATUSES = ("scaffolded", "built", "deployed") + + +def index_path(project_path: Path) -> Path: + return project_path / _INDEX_RELPATH + + +class MalformedIndexError(Exception): + """Raised when ``.wren/apps.yml`` exists but can't be parsed as an index.""" + + +def load_index(project_path: Path) -> dict: + """Return the parsed index, or an empty skeleton if absent. + + Raises :class:`MalformedIndexError` (with the offending path) when the file + exists but is malformed YAML or not a mapping — better than crashing with + an opaque ``YAMLError``/``AttributeError`` deep in a command. + """ + path = index_path(project_path) + if not path.exists(): + return {"schema_version": INDEX_SCHEMA_VERSION, "apps": {}} + try: + data = yaml.safe_load(path.read_text()) + except yaml.YAMLError as e: + raise MalformedIndexError(f"{path} is not valid YAML: {e}") from e + if data is None: + data = {} + if not isinstance(data, dict): + raise MalformedIndexError( + f"{path} is malformed — expected a mapping at the top level, " + f"got {type(data).__name__}." + ) + data.setdefault("schema_version", INDEX_SCHEMA_VERSION) + data.setdefault("apps", {}) + return data + + +def save_index(project_path: Path, index: dict) -> None: + path = index_path(project_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(index, default_flow_style=False, sort_keys=False)) + + +def register_app(project_path: Path, name: str, *, data_mode: str) -> dict: + """Create or update the entry for ``name``. Returns the entry.""" + index = load_index(project_path) + entry = index["apps"].get(name) or { + "source": f"apps/{name}", + "status": "scaffolded", + "created_at": date.today().isoformat(), + } + entry["data_mode"] = data_mode + index["apps"][name] = entry + save_index(project_path, index) + return entry + + +def remove_app(project_path: Path, name: str) -> bool: + """Remove the entry for ``name``. Returns False if it wasn't registered.""" + index = load_index(project_path) + if name not in index["apps"]: + return False + del index["apps"][name] + save_index(project_path, index) + return True + + +def get_app(project_path: Path, name: str) -> dict | None: + """Return the entry for ``name`` or None if not registered.""" + return load_index(project_path)["apps"].get(name) + + +def update_app(project_path: Path, name: str, **fields) -> dict: + """Merge ``fields`` into the entry for ``name`` and persist.""" + index = load_index(project_path) + entry = index["apps"][name] + entry.update(fields) + save_index(project_path, index) + return entry diff --git a/core/wren/src/wren/genbi/providers/__init__.py b/core/wren/src/wren/genbi/providers/__init__.py new file mode 100644 index 000000000..bfb7af0fe --- /dev/null +++ b/core/wren/src/wren/genbi/providers/__init__.py @@ -0,0 +1,21 @@ +"""Deploy providers — interchangeable adapters behind the DeployProvider protocol.""" + +from __future__ import annotations + +from wren.genbi.providers.base import DeployProvider + + +def get_provider(name: str) -> DeployProvider: + """Return the adapter for ``name`` or raise KeyError with the known set.""" + from wren.genbi.providers.cloudflare import CloudflareProvider # noqa: PLC0415 + from wren.genbi.providers.vercel import VercelProvider # noqa: PLC0415 + + registry: dict[str, type] = { + "vercel": VercelProvider, + "cloudflare": CloudflareProvider, + } + if name not in registry: + raise KeyError( + f"unknown provider {name!r}; expected one of: {', '.join(registry)}" + ) + return registry[name]() diff --git a/core/wren/src/wren/genbi/providers/base.py b/core/wren/src/wren/genbi/providers/base.py new file mode 100644 index 000000000..317504d7b --- /dev/null +++ b/core/wren/src/wren/genbi/providers/base.py @@ -0,0 +1,40 @@ +"""The DeployProvider protocol — vercel/cloudflare today, wren SaaS later.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + + +@dataclass +class Deployment: + """Result of a provider upload — non-secret link state only.""" + + url: str + environment: str # "preview" | "production" + project_id: str | None = None + org_id: str | None = None + account_id: str | None = None + + +class DeployError(RuntimeError): + """Raised by adapters with a user-actionable message.""" + + +class DeployProvider(Protocol): + name: str + env_token_var: str + + def deploy( + self, + build_dir: Path, + *, + app_name: str, + token: str, + prod: bool, + link: dict | None, + ) -> Deployment: + """Upload ``build_dir`` and return the deployment. ``link`` is the + previously persisted provider state (project/account ids), if any.""" + ... diff --git a/core/wren/src/wren/genbi/providers/cloudflare.py b/core/wren/src/wren/genbi/providers/cloudflare.py new file mode 100644 index 000000000..90eea35ef --- /dev/null +++ b/core/wren/src/wren/genbi/providers/cloudflare.py @@ -0,0 +1,135 @@ +"""Cloudflare Pages adapter — deploys via the official ``wrangler`` CLI. + +Cloudflare Pages has no single inline-upload REST endpoint (unlike Vercel); +Direct Upload is a multi-step protocol (manifest → upload token → multipart +asset upload → completion token → create deployment) that is only partially +documented and drifts over time. Cloudflare's own guidance is to use +``wrangler pages deploy``, so this adapter shells out to it. + +Requires the ``wrangler`` CLI (or ``npx wrangler``), CLOUDFLARE_API_TOKEN +(scope must include Pages:Edit) and CLOUDFLARE_ACCOUNT_ID. The token travels +ONLY via the subprocess environment — never argv. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from wren.genbi.providers.base import DeployError, Deployment + +# Matches the per-deployment / project URL wrangler prints on success. +_PAGES_URL_RE = re.compile(r"https://[A-Za-z0-9.-]+\.pages\.dev\S*") + + +def _wrangler_cmd() -> list[str] | None: + """Return the base argv for invoking wrangler, or None if unavailable.""" + import shutil # noqa: PLC0415 + + if shutil.which("wrangler"): + return ["wrangler"] + if shutil.which("npx"): + return ["npx", "wrangler"] + return None + + +def _run(cmd: list[str], *, env: dict, cwd: str | None = None): + """Run a subprocess and return the CompletedProcess. Patched in tests.""" + import subprocess # noqa: PLC0415 + + return subprocess.run( + cmd, env=env, cwd=cwd, capture_output=True, text=True, timeout=600 + ) + + +class CloudflareProvider: + name = "cloudflare" + env_token_var = "CLOUDFLARE_API_TOKEN" + + def deploy( + self, + build_dir: Path, + *, + app_name: str, + token: str, + prod: bool, + link: dict | None, + ) -> Deployment: + import os # noqa: PLC0415 + + # account_id: previously persisted link state, else environment + # (the token resolver already merged .env files into os.environ). + account_id = (link or {}).get("account_id") or os.environ.get( + "CLOUDFLARE_ACCOUNT_ID" + ) + if not account_id: + raise DeployError( + "no CLOUDFLARE_ACCOUNT_ID found — export it or add it to your " + "project's .env (Cloudflare Pages deploys are account-scoped)." + ) + + base = _wrangler_cmd() + if base is None: + raise DeployError( + "Cloudflare Pages deploys require the `wrangler` CLI, which " + "isn't on PATH. Install it (`npm install -g wrangler`) or make " + "`npx` available, then retry." + ) + + # Token + account id travel via the environment, never argv. + env = { + **os.environ, + "CLOUDFLARE_API_TOKEN": token, + "CLOUDFLARE_ACCOUNT_ID": account_id, + } + + # Ensure the Pages project exists; tolerate "already exists". + created = _run( + base + + ["pages", "project", "create", app_name, "--production-branch", "main"], + env=env, + ) + if created.returncode != 0: + combined = f"{created.stdout}\n{created.stderr}".lower() + if "already exists" not in combined: + raise DeployError( + "could not create Cloudflare Pages project: " + f"{(created.stderr or created.stdout).strip()[:500]}" + ) + + # Deploy the prebuilt folder. Run with cwd=build_dir (deploying ".") so + # wrangler can't pick up an unrelated wrangler.toml from the project + # root. Production = the project's production branch ("main"); any other + # branch is a preview deployment. + branch = "main" if prod else "preview" + result = _run( + base + + [ + "pages", + "deploy", + ".", + "--project-name", + app_name, + "--branch", + branch, + ], + env=env, + cwd=str(build_dir), + ) + if result.returncode != 0: + raise DeployError( + "wrangler pages deploy failed: " + f"{(result.stderr or result.stdout).strip()[:500]}" + ) + + match = _PAGES_URL_RE.search(f"{result.stdout}\n{result.stderr}") + if not match: + raise DeployError( + "could not determine the deployment URL from wrangler output; " + "cannot confirm where the app was deployed." + ) + return Deployment( + url=match.group(0), + environment="production" if prod else "preview", + account_id=account_id, + ) diff --git a/core/wren/src/wren/genbi/providers/vercel.py b/core/wren/src/wren/genbi/providers/vercel.py new file mode 100644 index 000000000..08d922cb9 --- /dev/null +++ b/core/wren/src/wren/genbi/providers/vercel.py @@ -0,0 +1,92 @@ +"""Vercel adapter — deploys a static app folder via the REST API. + +Uses the v13 deployments endpoint with inline base64 files: a single call +creates (or reuses) the project and uploads the app. No vercel CLI needed. +The token travels ONLY in the Authorization header — never argv. +""" + +from __future__ import annotations + +import base64 +from pathlib import Path + +from wren.genbi.providers.base import DeployError, Deployment + +_API_URL = "https://api.vercel.com/v13/deployments" + + +def _request(*, method: str, url: str, headers: dict, payload: dict) -> dict: + """Thin transport wrapper — monkeypatched in tests.""" + import requests # noqa: PLC0415 + + resp = requests.request(method, url, headers=headers, json=payload, timeout=120) + if resp.status_code >= 400: + raise DeployError(f"Vercel API error {resp.status_code}: {resp.text[:500]}") + return resp.json() + + +def _collect_files(build_dir: Path) -> list[dict]: + # Skip symlinks and anything resolving outside build_dir — the app folder + # ships to a public host, so a stray symlink must never exfiltrate files + # from elsewhere on disk. + build_root = build_dir.resolve() + files = [] + for path in sorted(build_dir.rglob("*")): + if not path.is_file() or path.is_symlink(): + continue + if not path.resolve().is_relative_to(build_root): + continue + # Never ship a .env* file to a public host, even if verify's scan + # found no recognizable secret pattern in it. + if path.name.startswith(".env"): + continue + files.append( + { + "file": str(path.relative_to(build_dir)), + "data": base64.b64encode(path.read_bytes()).decode(), + "encoding": "base64", + } + ) + return files + + +class VercelProvider: + name = "vercel" + env_token_var = "VERCEL_TOKEN" + + def deploy( + self, + build_dir: Path, + *, + app_name: str, + token: str, + prod: bool, + link: dict | None, + ) -> Deployment: + payload: dict = { + "name": app_name, + "files": _collect_files(build_dir), + "projectSettings": {"framework": None}, + } + if prod: + payload["target"] = "production" + + headers = {"Authorization": f"Bearer {token}"} + url = _API_URL + if link and link.get("org_id"): + url = f"{_API_URL}?teamId={link['org_id']}" + + data = _request(method="POST", url=url, headers=headers, payload=payload) + + raw_url = data.get("url") + if not raw_url: + raise DeployError( + "Vercel API response did not include a deployment URL; " + "cannot confirm where the app was deployed." + ) + return Deployment( + url=raw_url if raw_url.startswith("http") else f"https://{raw_url}", + environment="production" if prod else "preview", + project_id=data.get("projectId"), + org_id=data.get("ownerId") or (link or {}).get("org_id"), + ) diff --git a/core/wren/src/wren/genbi/tokens.py b/core/wren/src/wren/genbi/tokens.py new file mode 100644 index 000000000..b2adcfec1 --- /dev/null +++ b/core/wren/src/wren/genbi/tokens.py @@ -0,0 +1,41 @@ +"""Provider token discovery — env var → .env files → caller prompts. + +Copies the Vercel agent-skill pattern. The token is NEVER accepted as a +``--token`` CLI flag (it would leak into shell history / process lists); +callers export it into the request context instead. +""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def resolve_token(env_var: str, project_path: Path) -> str | None: + """3-tier lookup: process env → merged .env files → project .env. + + Returns None when absent — the caller decides whether to prompt. + """ + # 1. Shell-exported environment always wins. + if value := os.environ.get(env_var): + return value + + # 2. Standard .env discovery (cwd → cwd-walk project root → ~/.wren/.env). + from wren.profile import _ensure_env_loaded # noqa: PLC0415 + + _ensure_env_loaded() + if value := os.environ.get(env_var): + return value + + # 3. The explicit project dir's .env — covers --path projects outside cwd. + project_env = project_path / ".env" + if project_env.exists(): + try: + from dotenv import dotenv_values # noqa: PLC0415 + + if value := dotenv_values(project_env).get(env_var): + return value + except ImportError: + pass + + return None diff --git a/core/wren/src/wren/genbi/verify.py b/core/wren/src/wren/genbi/verify.py new file mode 100644 index 000000000..ec929bdfa --- /dev/null +++ b/core/wren/src/wren/genbi/verify.py @@ -0,0 +1,160 @@ +"""Deterministic preflight for GenBI apps. + +Structural checks only (no browser): required files exist, the app's MDL +parses, and snapshot apps ship a data asset. A real headless wasm smoke +query is a possible future hardening; deploy gates on this verifier. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path + +_DATA_ASSET_SUFFIXES = {".parquet", ".duckdb"} + +# Secret scanning is DEFAULT-DENY: scan every file except known binary/data +# formats. An allowlist of text suffixes loses the race against whatever +# extension an agent picks next (.env, .ts, .vue, …) — those are exactly the +# files most likely to carry an inlined credential, and they all ship to the +# public host. Only skip formats that are binary or can't meaningfully hold a +# secret as readable text. +_UNSCANNABLE_SUFFIXES = { + ".parquet", + ".duckdb", + ".wasm", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".ico", + ".bmp", + ".woff", + ".woff2", + ".ttf", + ".otf", + ".eot", + ".pdf", + ".zip", + ".gz", + ".mp4", + ".webm", +} + +# A public static app must never ship credentials — anyone who opens the +# URL can read every file. Patterns kept narrow to avoid false positives. +_SECRET_PATTERNS: tuple[tuple[str, re.Pattern], ...] = ( + ( + "connection string with password", + re.compile(r"\b\w+://[^/\s:@]+:[^/\s@]+@[^/\s]+"), + ), + ( + "AWS access key id", + re.compile(r"\bAKIA[0-9A-Z]{16}\b"), + ), + ( + "password/secret/token assignment", + re.compile( + r"""["']?(password|passwd|secret|api[_-]?key|access[_-]?token)["']?""" + r"""\s*[:=]\s*["'][^"']{8,}["']""", + re.IGNORECASE, + ), + ), +) + + +def _scan_for_secrets(app_dir: Path) -> list[str]: + """Return failure messages for files that appear to inline credentials. + + Default-deny: every regular file is scanned except known binary/data + formats (see ``_UNSCANNABLE_SUFFIXES``). Symlinks are skipped — the + providers don't ship them. + """ + failures = [] + for path in sorted(app_dir.rglob("*")): + if not path.is_file() or path.is_symlink(): + continue + # A `.env*` file in a public static app is virtually always a mistake. + # Fail on its mere presence — independent of whether the narrow content + # patterns recognize the secret inside (and `wrangler pages deploy .` + # would ship it whole, so the gate is the only thing that can stop it). + if path.name.startswith(".env"): + failures.append( + f"{path.relative_to(app_dir)} must not ship to a public host — " + "remove the .env file from the app folder" + ) + continue + if path.suffix.lower() in _UNSCANNABLE_SUFFIXES: + continue + try: + text = path.read_text(errors="ignore") + except OSError: + continue + for label, pattern in _SECRET_PATTERNS: + if pattern.search(text): + failures.append( + f"possible inlined secret in {path.relative_to(app_dir)} " + f"({label}) — a public static app must never ship credentials" + ) + break + return failures + + +@dataclass +class VerifyResult: + passed: bool + failures: list[str] = field(default_factory=list) + + +def verify_app(app_dir: Path, *, data_mode: str) -> VerifyResult: + """Run all structural checks for the app at ``app_dir``.""" + from wren.genbi.composer import DATA_MODES # noqa: PLC0415 + + failures: list[str] = [] + + # Fail closed on an unknown mode — otherwise a typo (e.g. "snapsho") would + # silently skip the snapshot data-asset check and pass. + if data_mode not in DATA_MODES: + return VerifyResult( + False, + [ + f"unknown data_mode {data_mode!r} " + f"(expected one of: {', '.join(DATA_MODES)})" + ], + ) + + if not app_dir.is_dir(): + return VerifyResult(False, [f"app folder missing: {app_dir}"]) + + if not (app_dir / "index.html").is_file(): + failures.append("missing index.html entry point") + + mdl = app_dir / "mdl.json" + if not mdl.is_file(): + failures.append("missing mdl.json (copy the compiled MDL into the app)") + else: + try: + parsed = json.loads(mdl.read_text()) + if not parsed: + failures.append("mdl.json is empty") + except json.JSONDecodeError as e: + failures.append(f"mdl.json is not valid JSON: {e}") + + if data_mode == "snapshot": + assets = [ + p + for p in app_dir.rglob("*") + if p.is_file() and p.suffix.lower() in _DATA_ASSET_SUFFIXES + ] + if not assets: + failures.append( + "snapshot app has no data asset (*.parquet / *.duckdb) — " + "bundle the data with the app" + ) + + # Security gate for every mode: the app ships to a public static host. + failures.extend(_scan_for_secrets(app_dir)) + + return VerifyResult(not failures, failures) diff --git a/core/wren/src/wren/skills_content/dlt-connector/SKILL.md b/core/wren/src/wren/skills_content/dlt-connector/SKILL.md index f2f826904..140c49780 100644 --- a/core/wren/src/wren/skills_content/dlt-connector/SKILL.md +++ b/core/wren/src/wren/skills_content/dlt-connector/SKILL.md @@ -243,6 +243,14 @@ Only after queries return real data, tell the user the setup is complete. Summar - Which profile is active - Example queries they can try next +### Next step: share it as an app + +The project is now DuckDB-backed, which is exactly what GenBI snapshot mode +wants. If the user wants to turn this data into a shareable dashboard / web app +and deploy it (Vercel / Cloudflare), hand off to the GenBI workflow: +`wren skills get genbi`. Its snapshot source is the very `.duckdb` file this +pipeline produced. + ## Troubleshooting If `wren context build` fails: diff --git a/core/wren/src/wren/skills_content/genbi/SKILL.md b/core/wren/src/wren/skills_content/genbi/SKILL.md new file mode 100644 index 000000000..1d5daba6a --- /dev/null +++ b/core/wren/src/wren/skills_content/genbi/SKILL.md @@ -0,0 +1,178 @@ +--- +name: genbi +description: "Turn a Wren project's context layer into a shareable, browser-side GenBI web app and deploy it to the user's Vercel or Cloudflare account. Orchestrates the full flow: `wren genbi build` returns a project-hydrated build instruction, the agent authors the app from scratch into apps//, then register → verify → deploy produce a shareable URL. Use this skill whenever the user wants to: build a dashboard from their Wren project, make a shareable analytics app, deploy their context layer as a web app, host a GenBI app on Vercel or Cloudflare Pages, or asks for a 'genbi app'." +license: Apache-2.0 +metadata: + author: wrenai +--- + +# Wren GenBI App — Agent Workflow Guide + +> This guide is served by the `wren` CLI (`wren skills get genbi`), so it +> always matches your installed wren-engine version. + +Turn a Wren context layer into a shareable GenBI app — from a natural-language +request to a public URL in one conversation. + +**Division of labor:** the CLI owns the authoritative build instruction (it +knows the live project facts and the pinned `wren-core-wasm` version) and all +deterministic state (index, verify, deploy). You — the agent — author the app +code by following the instruction. Never hand-write `.wren/apps.yml`. + +## Preconditions + +1. A Wren project is discoverable (`wren_project.yml` in cwd/ancestors, or ask + for the path and pass `-p`). +2. The context layer exists. If `target/mdl.json` is missing, `wren genbi + build` compiles it implicitly — no separate step needed. +3. `wren` CLI ≥ the version that ships the `genbi` command group + (`wren genbi --help` works). + +## Workflow + +### 1. Resolve the app name and data mode + +- App name: short kebab-case derived from the request (e.g. `sales-overview`). +- Data mode: + - `snapshot` (default) — data is bundled with the app as parquet/duckdb and + queried client-side. Fully serverless. Right for demos, reports, small + data, and dlt-pipeline output. + - `live` — the app calls back to the user's warehouse/API at view time. + Right for production-scale or always-fresh data. Requires a CORS-enabled + endpoint and carries strict no-credentials rules. +- Ask the user ONLY if the choice is genuinely ambiguous. + +### 2. Get the build instruction + +```bash +wren genbi build --prompt "" --data-mode +``` + +For long or multi-line prompts use `--prompt-file ` or pipe to +`--prompt -`. The command prints the authoritative build instruction — +wasm wiring (pinned version, CDN load), the project's model/column inventory, +data-mode guidance, acceptance criteria, and the target folder. It writes no +app files — the only thing it may touch is `target/mdl.json`, which it compiles +first if missing (see Precondition 2). + +### 3. Author the app + +Follow the instruction exactly. Key conventions: + +- Write everything under `apps//` — never outside it. +- Copy the compiled MDL into the app as `apps//mdl.json`. +- Load `wren-core-wasm` from the CDN given in the instruction; never bundle + the ~68MB binary. +- snapshot: export the data the dashboard needs into `apps//data/` as + parquet (`verify` requires at least one `.parquet`/`.duckdb` asset). See + **Snapshot data export** below for the recipe and where the data comes from. +- live: write an endpoint-only connection config. NEVER inline credentials — + `verify` scans for them (best-effort) and `deploy` gates on `verify`, but + the rule is on you: a public static host exposes every shipped file. +- Design the dashboard to actually answer the user's request: pick the right + charts/tables for the question, not a generic template. + +#### Snapshot data export + +The CLI hands you the build instruction, but the snapshot bytes still have to +be exported into the app folder — that step is yours. + +**Where the data comes from:** + +- **DuckDB-backed project** (incl. anything loaded by the `dlt-connector` + skill — its pipelines always land in a `.duckdb` file): the project's DuckDB + file *is* your snapshot source. If the user is connecting SaaS data (HubSpot, + Stripe, Salesforce, …) and has no project yet, run the SaaS→project flow + first: `wren skills get dlt-connector`. Then come back here to ship it. +- **Warehouse-backed project** (Postgres, BigQuery, Snowflake, …): run the + query/queries the dashboard needs through the MDL layer and write the result + to parquet. Keep snapshots small — snapshot mode is for demos/reports, not + full warehouse extracts; use `live` mode for large or always-fresh data. + +**Recipe (DuckDB → parquet):** + +```bash +# from the project root; is the project's DuckDB file (see wren_project.yml) +python - <<'PY' +import duckdb +con = duckdb.connect(".duckdb", read_only=True) +con.execute( + "COPY (SELECT * FROM ) " + "TO 'apps//data/
.parquet' (FORMAT parquet)" +) +PY +``` + +Only export the columns/rows the dashboard uses. The compiled `mdl.json` you +copied in keeps the context layer intact regardless of how you bundle data. + +### 4. Register and verify + +```bash +wren genbi register --data-mode +wren genbi verify +``` + +If verify fails: fix the reported problems and re-run verify. Do NOT proceed +to deploy on a failed verify. Offer `wren genbi open ` for a local +preview before shipping. + +### 5. Deploy (only if the user asked for it) + +```bash +wren genbi deploy --provider vercel # or cloudflare +``` + +- Preview deployment by default. **Confirm with the user before `--prod`.** +- Tokens: the CLI discovers `VERCEL_TOKEN` / `CLOUDFLARE_API_TOKEN` from the + environment or `.env` files. If missing, ask the user to export it or add + it to the project `.env`. NEVER put a token on the command line. +- Cloudflare also needs `CLOUDFLARE_ACCOUNT_ID` (env or `.env`), a token + scoped with Pages:Edit, and the `wrangler` CLI on PATH (or `npx` available) + — the adapter shells out to `wrangler pages deploy`. If it's missing, ask + the user to `npm install -g wrangler`. +- Report the returned URL to the user. Re-deploying the same app updates the + same provider target. +- **Verify the URL actually loads.** After deploying, fetch the URL — a + successful deploy can still return **HTTP 401/403** to outsiders because of + the provider's access protection (see below). Don't report a link as + "shareable" until you've confirmed it serves. + +### Vercel Deployment Protection (the 401 trap) + +New Vercel projects ship with **Vercel Authentication** turned ON by default, +so every deployment — preview *and* production — returns **401** to anyone not +logged into the owning Vercel account/team. The deploy itself succeeded; the +URL is just gated. + +- To make the app publicly shareable, the user disables it in the Vercel + dashboard: **Project → Settings → Deployment Protection → Vercel + Authentication → Disabled** (or scope it to production only). This setting + is not controllable from `wren genbi deploy`; it's a one-time toggle per + project in Vercel. +- If the user only needs a private link (viewable while logged into their + Vercel account), leaving protection on is fine — just tell them the link + won't work for logged-out visitors. + +## Safety boundaries + +- Never inline secrets/credentials into app files — the deploy target is a + public static host; anyone with the URL can read every file. `verify` + scans for inlined credentials, but treat it as best-effort + defense-in-depth, not a guarantee — never rely on it to catch a secret + you shouldn't have written in the first place. +- Never pass tokens as CLI flags; they leak into shell history. +- Confirm before production deploys. +- All index state goes through `wren genbi register/remove` — never edit + `.wren/apps.yml` by hand. + +## Quick reference + +| Step | Command | +| --- | --- | +| Get build instruction | `wren genbi build --prompt "…" [--data-mode snapshot\|live]` | +| Record the app | `wren genbi register --data-mode ` | +| Preflight | `wren genbi verify ` | +| Local preview | `wren genbi open ` | +| Ship | `wren genbi deploy --provider vercel\|cloudflare [--prod]` | +| Inventory | `wren genbi list` / `wren genbi remove ` | diff --git a/core/wren/tests/unit/test_genbi_build.py b/core/wren/tests/unit/test_genbi_build.py new file mode 100644 index 000000000..6713cca31 --- /dev/null +++ b/core/wren/tests/unit/test_genbi_build.py @@ -0,0 +1,258 @@ +"""Behavior tests for `wren genbi build` — the instruction composer.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from wren.cli import app + +runner = CliRunner() + +pytestmark = pytest.mark.unit + + +# ── Helpers ──────────────────────────────────────────────────────────────── + + +def _make_project(tmp_path: Path) -> Path: + """Minimal valid v2 project with one model and a compiled mdl.json.""" + (tmp_path / "wren_project.yml").write_text( + 'schema_version: 2\nname: test_proj\nversion: "1.0"\n' + "catalog: wren\nschema: public\ndata_source: duckdb\n" + ) + model_dir = tmp_path / "models" / "orders" + model_dir.mkdir(parents=True) + (model_dir / "metadata.yml").write_text( + "name: orders\n" + 'table_reference:\n catalog: ""\n schema: public\n table: orders\n' + "columns:\n" + " - name: id\n type: INTEGER\n is_calculated: false\n" + " not_null: true\n properties: {}\n" + " - name: total\n type: DECIMAL\n is_calculated: false\n" + " not_null: false\n properties: {}\n" + "primary_key: id\ncached: false\nproperties:\n description: Orders table\n" + ) + (tmp_path / "relationships.yml").write_text("relationships: []\n") + target = tmp_path / "target" + target.mkdir() + (target / "mdl.json").write_text( + '{"catalog": "wren", "schema": "public", "models": []}' + ) + return tmp_path + + +def _snapshot_tree(root: Path) -> set[str]: + return {str(p.relative_to(root)) for p in root.rglob("*")} + + +# ── Tracer bullet ────────────────────────────────────────────────────────── + + +def test_build_prints_instruction_and_writes_nothing(tmp_path: Path) -> None: + project = _make_project(tmp_path) + before = _snapshot_tree(project) + + result = runner.invoke( + app, + [ + "genbi", + "build", + "myapp", + "--prompt", + "show revenue by month", + "-p", + str(project), + ], + ) + + assert result.exit_code == 0, result.output + # Live project context: MDL path + target folder + assert str(project / "target" / "mdl.json") in result.output + assert "apps/myapp" in result.output + # User prompt verbatim + assert "show revenue by month" in result.output + # Pure composer: nothing on disk changed + assert _snapshot_tree(project) == before + + +def test_build_includes_model_inventory(tmp_path: Path) -> None: + project = _make_project(tmp_path) + + result = runner.invoke( + app, ["genbi", "build", "myapp", "--prompt", "x", "-p", str(project)] + ) + + assert result.exit_code == 0, result.output + # Model and its columns from the YAML project + assert "orders" in result.output + assert "id" in result.output + assert "total" in result.output + # Data source type from wren_project.yml + assert "duckdb" in result.output + + +def test_build_includes_wasm_wiring_and_final_steps(tmp_path: Path) -> None: + project = _make_project(tmp_path) + + result = runner.invoke( + app, ["genbi", "build", "myapp", "--prompt", "x", "-p", str(project)] + ) + + assert result.exit_code == 0, result.output + # wasm wiring: pinned version + CDN directive (don't bundle ~68MB) + assert "wren-core-wasm" in result.output + assert "0.4.1" in result.output + assert "CDN" in result.output + # load sequence + assert "loadMDL" in result.output + # final steps: register then verify, with the app name filled in + assert "wren genbi register myapp --data-mode snapshot" in result.output + assert "wren genbi verify myapp" in result.output + + +def test_build_snapshot_mode_gives_data_bundling_guidance(tmp_path: Path) -> None: + project = _make_project(tmp_path) + + result = runner.invoke( + app, + [ + "genbi", + "build", + "myapp", + "--prompt", + "x", + "--data-mode", + "snapshot", + "-p", + str(project), + ], + ) + + assert result.exit_code == 0, result.output + # snapshot: convert data to parquet/duckdb, ship as static asset, query client-side + assert "parquet" in result.output.lower() + assert "static" in result.output.lower() + + +def test_build_rejects_unknown_data_mode(tmp_path: Path) -> None: + project = _make_project(tmp_path) + + result = runner.invoke( + app, + [ + "genbi", + "build", + "myapp", + "--prompt", + "x", + "--data-mode", + "bogus", + "-p", + str(project), + ], + ) + + assert result.exit_code != 0 + assert "data-mode" in result.output or "data_mode" in result.output + + +def test_build_accepts_prompt_file(tmp_path: Path) -> None: + project = _make_project(tmp_path) + prompt_file = tmp_path / "req.txt" + prompt_file.write_text("multi line\nrequest with 'quotes' and $vars\n") + + result = runner.invoke( + app, + [ + "genbi", + "build", + "myapp", + "--prompt-file", + str(prompt_file), + "-p", + str(project), + ], + ) + + assert result.exit_code == 0, result.output + assert "multi line" in result.output + assert "request with 'quotes' and $vars" in result.output + + +def test_build_reads_prompt_from_stdin(tmp_path: Path) -> None: + project = _make_project(tmp_path) + + result = runner.invoke( + app, + ["genbi", "build", "myapp", "--prompt", "-", "-p", str(project)], + input="stdin request body\n", + ) + + assert result.exit_code == 0, result.output + assert "stdin request body" in result.output + + +def test_build_requires_a_prompt(tmp_path: Path) -> None: + project = _make_project(tmp_path) + + result = runner.invoke(app, ["genbi", "build", "myapp", "-p", str(project)]) + + assert result.exit_code != 0 + assert "prompt" in result.output.lower() + + +def test_build_compiles_mdl_when_missing(tmp_path: Path) -> None: + project = _make_project(tmp_path) + (project / "target" / "mdl.json").unlink() + (project / "target").rmdir() + + result = runner.invoke( + app, ["genbi", "build", "myapp", "--prompt", "x", "-p", str(project)] + ) + + assert result.exit_code == 0, result.output + # mdl was compiled implicitly and the instruction still points at it + assert (project / "target" / "mdl.json").exists() + assert str(project / "target" / "mdl.json") in result.output + + +def test_build_live_mode_gives_connection_guidance_and_hard_rule( + tmp_path: Path, +) -> None: + project = _make_project(tmp_path) + + result = runner.invoke( + app, + [ + "genbi", + "build", + "myapp", + "--prompt", + "x", + "--data-mode", + "live", + "-p", + str(project), + ], + ) + + assert result.exit_code == 0, result.output + out = result.output + # live: connect back to the user's warehouse/API + CORS requirement + assert "CORS" in out + assert "connection" in out.lower() + # hard rule: never inline warehouse credentials into the public app + assert "credentials" in out.lower() + assert "never" in out.lower() or "must not" in out.lower() + + +def test_build_rejects_path_traversal_name(tmp_path: Path) -> None: + project = _make_project(tmp_path) + result = runner.invoke( + app, ["genbi", "build", "../evil", "--prompt", "x", "-p", str(project)] + ) + assert result.exit_code != 0 + assert "invalid app name" in result.output diff --git a/core/wren/tests/unit/test_genbi_deploy.py b/core/wren/tests/unit/test_genbi_deploy.py new file mode 100644 index 000000000..a3a9c2250 --- /dev/null +++ b/core/wren/tests/unit/test_genbi_deploy.py @@ -0,0 +1,404 @@ +"""Behavior tests for `wren genbi deploy` — token discovery + providers.""" + +from __future__ import annotations + +import json +import types +from pathlib import Path + +import pytest +import yaml +from typer.testing import CliRunner + +import wren.genbi.providers.cloudflare as cloudflare +import wren.genbi.providers.vercel as vercel +from wren.cli import app +from wren.genbi.tokens import resolve_token + +runner = CliRunner() + +pytestmark = pytest.mark.unit + + +@pytest.fixture(autouse=True) +def _isolate_env_loading(monkeypatch): + """Make token discovery hermetic. + + ``resolve_token``'s tier-2 calls ``wren.profile._ensure_env_loaded()``, + which merges ``~/.wren/.env`` (and any cwd ``.env``) into ``os.environ``. + Without neutralizing it, these tests depend on the developer's machine — + a real ``VERCEL_TOKEN`` in ``~/.wren/.env`` makes the missing-token cases + pass spuriously and can even trigger a real provider API call. Tests must + only see tokens they set via ``setenv`` (tier 1) or a project ``.env`` + (tier 3). + """ + import wren.profile # noqa: PLC0415 + + monkeypatch.setattr(wren.profile, "_ensure_env_loaded", lambda: None) + + +def _make_deployable_project(tmp_path: Path) -> Path: + (tmp_path / "wren_project.yml").write_text( + 'schema_version: 2\nname: test_proj\nversion: "1.0"\n' + "catalog: wren\nschema: public\ndata_source: duckdb\n" + ) + app_dir = tmp_path / "apps" / "myapp" + (app_dir / "data").mkdir(parents=True) + (app_dir / "index.html").write_text("GenBI") + (app_dir / "mdl.json").write_text(json.dumps({"models": [{}]})) + (app_dir / "data" / "orders.parquet").write_bytes(b"PAR1fake") + result = runner.invoke(app, ["genbi", "register", "myapp", "-p", str(tmp_path)]) + assert result.exit_code == 0, result.output + return tmp_path + + +# ── TokenResolver ────────────────────────────────────────────────────────── + + +def test_token_from_environment_wins(tmp_path: Path, monkeypatch) -> None: + (tmp_path / ".env").write_text("VERCEL_TOKEN=from-dotenv\n") + monkeypatch.setenv("VERCEL_TOKEN", "from-env") + + assert resolve_token("VERCEL_TOKEN", tmp_path) == "from-env" + + +def test_token_falls_back_to_project_dotenv(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("VERCEL_TOKEN", raising=False) + (tmp_path / ".env").write_text("VERCEL_TOKEN=from-dotenv\n") + + assert resolve_token("VERCEL_TOKEN", tmp_path) == "from-dotenv" + + +def test_token_absent_returns_none(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("VERCEL_TOKEN", raising=False) + + assert resolve_token("VERCEL_TOKEN", tmp_path) is None + + +# ── Vercel deploy ────────────────────────────────────────────────────────── + + +class _FakeTransport: + """Captures provider HTTP requests and returns canned responses.""" + + def __init__(self, response: dict) -> None: + self.calls: list[dict] = [] + self.response = response + + def __call__(self, *, method: str, url: str, headers: dict, payload: dict) -> dict: + self.calls.append( + {"method": method, "url": url, "headers": headers, "payload": payload} + ) + return self.response + + +def test_deploy_vercel_uploads_and_persists_state(tmp_path: Path, monkeypatch) -> None: + project = _make_deployable_project(tmp_path) + monkeypatch.setenv("VERCEL_TOKEN", "tok-123") + fake = _FakeTransport( + {"id": "dpl_1", "url": "myapp-abc.vercel.app", "projectId": "prj_9"} + ) + monkeypatch.setattr(vercel, "_request", fake) + + result = runner.invoke( + app, ["genbi", "deploy", "myapp", "--provider", "vercel", "-p", str(project)] + ) + + assert result.exit_code == 0, result.output + assert "https://myapp-abc.vercel.app" in result.output + # request construction + call = fake.calls[0] + assert "api.vercel.com" in call["url"] + assert call["headers"]["Authorization"] == "Bearer tok-123" + filenames = {f["file"] for f in call["payload"]["files"]} + assert "index.html" in filenames and "mdl.json" in filenames + assert call["payload"].get("target") != "production" # preview by default + # deploy state persisted + index = yaml.safe_load((project / ".wren" / "apps.yml").read_text()) + entry = index["apps"]["myapp"] + assert entry["status"] == "deployed" + assert entry["deploy"]["provider"] == "vercel" + assert entry["deploy"]["project_id"] == "prj_9" + assert entry["deploy"]["last_url"] == "https://myapp-abc.vercel.app" + assert entry["deploy"]["environment"] == "preview" + # no secrets in the index + assert "tok-123" not in (project / ".wren" / "apps.yml").read_text() + + +def test_deploy_prod_flag_targets_production(tmp_path: Path, monkeypatch) -> None: + project = _make_deployable_project(tmp_path) + monkeypatch.setenv("VERCEL_TOKEN", "tok-123") + fake = _FakeTransport({"id": "dpl_1", "url": "myapp.vercel.app"}) + monkeypatch.setattr(vercel, "_request", fake) + + result = runner.invoke( + app, + [ + "genbi", + "deploy", + "myapp", + "--provider", + "vercel", + "--prod", + "-p", + str(project), + ], + ) + + assert result.exit_code == 0, result.output + assert fake.calls[0]["payload"]["target"] == "production" + index = yaml.safe_load((project / ".wren" / "apps.yml").read_text()) + assert index["apps"]["myapp"]["deploy"]["environment"] == "production" + + +def test_deploy_without_token_gives_actionable_error(tmp_path, monkeypatch) -> None: + project = _make_deployable_project(tmp_path) + monkeypatch.delenv("VERCEL_TOKEN", raising=False) + + result = runner.invoke( + app, ["genbi", "deploy", "myapp", "--provider", "vercel", "-p", str(project)] + ) + + assert result.exit_code != 0 + assert "VERCEL_TOKEN" in result.output + + +def test_deploy_runs_verify_first_and_aborts_on_failure(tmp_path, monkeypatch) -> None: + project = _make_deployable_project(tmp_path) + (project / "apps" / "myapp" / "mdl.json").unlink() # break the app + monkeypatch.setenv("VERCEL_TOKEN", "tok-123") + fake = _FakeTransport({"id": "dpl_1", "url": "x.vercel.app"}) + monkeypatch.setattr(vercel, "_request", fake) + + result = runner.invoke( + app, ["genbi", "deploy", "myapp", "--provider", "vercel", "-p", str(project)] + ) + + assert result.exit_code != 0 + assert "mdl.json" in result.output + assert fake.calls == [] # nothing was uploaded + + +def test_deploy_unregistered_app_errors(tmp_path, monkeypatch) -> None: + project = _make_deployable_project(tmp_path) + runner.invoke(app, ["genbi", "remove", "myapp", "-p", str(project)]) + monkeypatch.setenv("VERCEL_TOKEN", "tok-123") + + result = runner.invoke( + app, ["genbi", "deploy", "myapp", "--provider", "vercel", "-p", str(project)] + ) + + assert result.exit_code != 0 + assert "not registered" in result.output + + +def test_deploy_unknown_provider_errors(tmp_path) -> None: + project = _make_deployable_project(tmp_path) + + result = runner.invoke( + app, ["genbi", "deploy", "myapp", "--provider", "bogus", "-p", str(project)] + ) + + assert result.exit_code != 0 + assert "provider" in result.output.lower() + + +# ── Cloudflare deploy (shells out to wrangler) ────────────────────────────── + + +class _FakeWrangler: + """Stub for cloudflare._run — returns queued CompletedProcess-likes.""" + + def __init__(self, results: list) -> None: + self.results = list(results) + self.calls: list[dict] = [] + + def __call__(self, cmd, *, env, cwd=None): + self.calls.append({"cmd": cmd, "env": env, "cwd": cwd}) + return self.results.pop(0) + + +def _proc(stdout: str = "", stderr: str = "", returncode: int = 0): + return types.SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr) + + +def _patch_wrangler(monkeypatch, results: list) -> _FakeWrangler: + monkeypatch.setattr(cloudflare, "_wrangler_cmd", lambda: ["wrangler"]) + fake = _FakeWrangler(results) + monkeypatch.setattr(cloudflare, "_run", fake) + return fake + + +def test_deploy_cloudflare_invokes_wrangler_and_persists_state( + tmp_path, monkeypatch +) -> None: + project = _make_deployable_project(tmp_path) + monkeypatch.setenv("CLOUDFLARE_API_TOKEN", "cf-tok") + monkeypatch.setenv("CLOUDFLARE_ACCOUNT_ID", "acct-42") + fake = _patch_wrangler( + monkeypatch, + [ + _proc(), # project create + _proc(stdout="✨ Deployment complete! https://abc123.myapp.pages.dev"), + ], + ) + + result = runner.invoke( + app, + ["genbi", "deploy", "myapp", "--provider", "cloudflare", "-p", str(project)], + ) + + assert result.exit_code == 0, result.output + assert "https://abc123.myapp.pages.dev" in result.output + # deploy command construction + deploy_call = fake.calls[-1] + assert deploy_call["cmd"][:3] == ["wrangler", "pages", "deploy"] + assert "--project-name" in deploy_call["cmd"] and "myapp" in deploy_call["cmd"] + assert "--branch" in deploy_call["cmd"] and "preview" in deploy_call["cmd"] + assert deploy_call["cwd"] == str(project / "apps" / "myapp") + # token travels via env, NEVER argv + for call in fake.calls: + assert "cf-tok" not in " ".join(call["cmd"]) + assert call["env"]["CLOUDFLARE_API_TOKEN"] == "cf-tok" + assert call["env"]["CLOUDFLARE_ACCOUNT_ID"] == "acct-42" + # deploy state persisted; no secret in the index + index = yaml.safe_load((project / ".wren" / "apps.yml").read_text()) + entry = index["apps"]["myapp"] + assert entry["status"] == "deployed" + assert entry["deploy"]["provider"] == "cloudflare" + assert entry["deploy"]["account_id"] == "acct-42" + assert entry["deploy"]["last_url"] == "https://abc123.myapp.pages.dev" + assert "cf-tok" not in (project / ".wren" / "apps.yml").read_text() + + +def test_deploy_cloudflare_prod_uses_production_branch(tmp_path, monkeypatch) -> None: + project = _make_deployable_project(tmp_path) + monkeypatch.setenv("CLOUDFLARE_API_TOKEN", "cf-tok") + monkeypatch.setenv("CLOUDFLARE_ACCOUNT_ID", "acct-42") + fake = _patch_wrangler( + monkeypatch, + [_proc(), _proc(stdout="https://myapp.pages.dev")], + ) + + result = runner.invoke( + app, + [ + "genbi", + "deploy", + "myapp", + "--provider", + "cloudflare", + "--prod", + "-p", + str(project), + ], + ) + + assert result.exit_code == 0, result.output + deploy_cmd = fake.calls[-1]["cmd"] + assert deploy_cmd[deploy_cmd.index("--branch") + 1] == "main" + + +def test_deploy_cloudflare_tolerates_existing_project(tmp_path, monkeypatch) -> None: + project = _make_deployable_project(tmp_path) + monkeypatch.setenv("CLOUDFLARE_API_TOKEN", "cf-tok") + monkeypatch.setenv("CLOUDFLARE_ACCOUNT_ID", "acct-42") + _patch_wrangler( + monkeypatch, + [ + _proc(stderr="A project with this name already exists", returncode=1), + _proc(stdout="https://abc.myapp.pages.dev"), + ], + ) + + result = runner.invoke( + app, + ["genbi", "deploy", "myapp", "--provider", "cloudflare", "-p", str(project)], + ) + + assert result.exit_code == 0, result.output + assert "https://abc.myapp.pages.dev" in result.output + + +def test_deploy_cloudflare_requires_account_id(tmp_path, monkeypatch) -> None: + project = _make_deployable_project(tmp_path) + monkeypatch.setenv("CLOUDFLARE_API_TOKEN", "cf-tok") + monkeypatch.delenv("CLOUDFLARE_ACCOUNT_ID", raising=False) + + result = runner.invoke( + app, + ["genbi", "deploy", "myapp", "--provider", "cloudflare", "-p", str(project)], + ) + + assert result.exit_code != 0 + assert "CLOUDFLARE_ACCOUNT_ID" in result.output + + +def test_deploy_cloudflare_errors_when_wrangler_missing(tmp_path, monkeypatch) -> None: + project = _make_deployable_project(tmp_path) + monkeypatch.setenv("CLOUDFLARE_API_TOKEN", "cf-tok") + monkeypatch.setenv("CLOUDFLARE_ACCOUNT_ID", "acct-42") + monkeypatch.setattr(cloudflare, "_wrangler_cmd", lambda: None) + + result = runner.invoke( + app, + ["genbi", "deploy", "myapp", "--provider", "cloudflare", "-p", str(project)], + ) + + assert result.exit_code != 0 + assert "wrangler" in result.output.lower() + + +def test_deploy_cloudflare_surfaces_wrangler_failure(tmp_path, monkeypatch) -> None: + project = _make_deployable_project(tmp_path) + monkeypatch.setenv("CLOUDFLARE_API_TOKEN", "cf-tok") + monkeypatch.setenv("CLOUDFLARE_ACCOUNT_ID", "acct-42") + _patch_wrangler( + monkeypatch, + [_proc(), _proc(stderr="Authentication error [code: 10000]", returncode=1)], + ) + + result = runner.invoke( + app, + ["genbi", "deploy", "myapp", "--provider", "cloudflare", "-p", str(project)], + ) + + assert result.exit_code != 0 + assert "wrangler pages deploy failed" in result.output + + +# ── Never fabricate a deployment URL ──────────────────────────────────────── + + +def test_deploy_vercel_fails_when_response_omits_url(tmp_path, monkeypatch) -> None: + project = _make_deployable_project(tmp_path) + monkeypatch.setenv("VERCEL_TOKEN", "tok-123") + monkeypatch.setattr(vercel, "_request", _FakeTransport({"id": "dpl_1"})) # no url + + result = runner.invoke( + app, ["genbi", "deploy", "myapp", "--provider", "vercel", "-p", str(project)] + ) + + assert result.exit_code != 0 + assert "did not include a deployment URL" in result.output + # a missing URL must not be persisted as a successful deploy + index = yaml.safe_load((project / ".wren" / "apps.yml").read_text()) + assert index["apps"]["myapp"]["status"] != "deployed" + + +def test_deploy_cloudflare_fails_when_no_url_in_output(tmp_path, monkeypatch) -> None: + project = _make_deployable_project(tmp_path) + monkeypatch.setenv("CLOUDFLARE_API_TOKEN", "cf-tok") + monkeypatch.setenv("CLOUDFLARE_ACCOUNT_ID", "acct-42") + _patch_wrangler( + monkeypatch, + [_proc(), _proc(stdout="deployed, but no url printed", returncode=0)], + ) + + result = runner.invoke( + app, + ["genbi", "deploy", "myapp", "--provider", "cloudflare", "-p", str(project)], + ) + + assert result.exit_code != 0 + assert "could not determine the deployment URL" in result.output diff --git a/core/wren/tests/unit/test_genbi_index.py b/core/wren/tests/unit/test_genbi_index.py new file mode 100644 index 000000000..2d1d8dad2 --- /dev/null +++ b/core/wren/tests/unit/test_genbi_index.py @@ -0,0 +1,147 @@ +"""Behavior tests for the app index — `wren genbi register/list/remove`.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from typer.testing import CliRunner + +from wren.cli import app +from wren.genbi.index import MalformedIndexError, index_path, load_index + +runner = CliRunner() + +pytestmark = pytest.mark.unit + + +def _make_project(tmp_path: Path, *, with_app: str | None = None) -> Path: + (tmp_path / "wren_project.yml").write_text( + 'schema_version: 2\nname: test_proj\nversion: "1.0"\n' + "catalog: wren\nschema: public\ndata_source: duckdb\n" + ) + if with_app: + app_dir = tmp_path / "apps" / with_app + app_dir.mkdir(parents=True) + (app_dir / "index.html").write_text("") + return tmp_path + + +# ── Tracer bullet ────────────────────────────────────────────────────────── + + +def test_register_writes_index_entry(tmp_path: Path) -> None: + project = _make_project(tmp_path, with_app="myapp") + + result = runner.invoke( + app, + ["genbi", "register", "myapp", "--data-mode", "snapshot", "-p", str(project)], + ) + + assert result.exit_code == 0, result.output + index = yaml.safe_load((project / ".wren" / "apps.yml").read_text()) + entry = index["apps"]["myapp"] + assert entry["source"] == "apps/myapp" + assert entry["data_mode"] == "snapshot" + assert entry["status"] == "scaffolded" + + +def test_register_requires_app_dir_on_disk(tmp_path: Path) -> None: + project = _make_project(tmp_path) # no app written + + result = runner.invoke(app, ["genbi", "register", "ghost", "-p", str(project)]) + + assert result.exit_code != 0 + assert "no app found" in result.output.lower() + assert not (project / ".wren" / "apps.yml").exists() + + +def test_register_is_idempotent_update(tmp_path: Path) -> None: + project = _make_project(tmp_path, with_app="myapp") + + r1 = runner.invoke(app, ["genbi", "register", "myapp", "-p", str(project)]) + r2 = runner.invoke( + app, + ["genbi", "register", "myapp", "--data-mode", "live", "-p", str(project)], + ) + + assert r1.exit_code == 0 and r2.exit_code == 0 + index = yaml.safe_load((project / ".wren" / "apps.yml").read_text()) + assert list(index["apps"].keys()) == ["myapp"] # no duplicate + assert index["apps"]["myapp"]["data_mode"] == "live" # updated + + +def test_list_shows_registered_apps(tmp_path: Path) -> None: + project = _make_project(tmp_path, with_app="myapp") + app2 = project / "apps" / "other" + app2.mkdir() + (app2 / "index.html").write_text("") + runner.invoke(app, ["genbi", "register", "myapp", "-p", str(project)]) + runner.invoke( + app, ["genbi", "register", "other", "--data-mode", "live", "-p", str(project)] + ) + + result = runner.invoke(app, ["genbi", "list", "-p", str(project)]) + + assert result.exit_code == 0, result.output + assert "myapp" in result.output and "other" in result.output + assert "snapshot" in result.output and "live" in result.output + assert "scaffolded" in result.output + + +def test_list_with_no_index_reports_no_apps(tmp_path: Path) -> None: + project = _make_project(tmp_path) + + result = runner.invoke(app, ["genbi", "list", "-p", str(project)]) + + assert result.exit_code == 0, result.output + assert "no apps" in result.output.lower() + + +def test_remove_deletes_index_entry(tmp_path: Path) -> None: + project = _make_project(tmp_path, with_app="myapp") + runner.invoke(app, ["genbi", "register", "myapp", "-p", str(project)]) + + result = runner.invoke(app, ["genbi", "remove", "myapp", "-p", str(project)]) + + assert result.exit_code == 0, result.output + index = yaml.safe_load((project / ".wren" / "apps.yml").read_text()) + assert "myapp" not in index["apps"] + # listing afterwards shows nothing + listed = runner.invoke(app, ["genbi", "list", "-p", str(project)]) + assert "no apps" in listed.output.lower() + + +def test_remove_unknown_app_errors(tmp_path: Path) -> None: + project = _make_project(tmp_path) + + result = runner.invoke(app, ["genbi", "remove", "ghost", "-p", str(project)]) + + assert result.exit_code != 0 + assert "not registered" in result.output.lower() + + +def test_load_index_raises_on_malformed_yaml(tmp_path: Path) -> None: + path = index_path(tmp_path) + path.parent.mkdir(parents=True) + path.write_text("apps: [unclosed\n") # invalid YAML + + with pytest.raises(MalformedIndexError): + load_index(tmp_path) + + +def test_load_index_raises_on_non_mapping(tmp_path: Path) -> None: + path = index_path(tmp_path) + path.parent.mkdir(parents=True) + path.write_text("- just\n- a list\n") # valid YAML, wrong shape + + with pytest.raises(MalformedIndexError): + load_index(tmp_path) + + +def test_register_rejects_path_traversal_name(tmp_path: Path) -> None: + project = _make_project(tmp_path) + result = runner.invoke(app, ["genbi", "register", "../evil", "-p", str(project)]) + assert result.exit_code != 0 + assert "invalid app name" in result.output diff --git a/core/wren/tests/unit/test_genbi_verify.py b/core/wren/tests/unit/test_genbi_verify.py new file mode 100644 index 000000000..190d34b8e --- /dev/null +++ b/core/wren/tests/unit/test_genbi_verify.py @@ -0,0 +1,230 @@ +"""Behavior tests for `wren genbi verify` and `wren genbi open`.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml +from typer.testing import CliRunner + +from wren.cli import app +from wren.genbi.verify import verify_app + +runner = CliRunner() + +pytestmark = pytest.mark.unit + + +def _make_project_with_app( + tmp_path: Path, + *, + register: bool = True, + data_mode: str = "snapshot", + index_html: bool = True, + mdl_json: bool = True, + data_asset: bool = True, +) -> Path: + (tmp_path / "wren_project.yml").write_text( + 'schema_version: 2\nname: test_proj\nversion: "1.0"\n' + "catalog: wren\nschema: public\ndata_source: duckdb\n" + ) + app_dir = tmp_path / "apps" / "myapp" + app_dir.mkdir(parents=True) + if index_html: + (app_dir / "index.html").write_text("GenBI") + if mdl_json: + (app_dir / "mdl.json").write_text( + json.dumps({"catalog": "wren", "schema": "public", "models": [{}]}) + ) + if data_asset: + (app_dir / "data").mkdir() + (app_dir / "data" / "orders.parquet").write_bytes(b"PAR1fake") + if register: + result = runner.invoke( + app, + [ + "genbi", + "register", + "myapp", + "--data-mode", + data_mode, + "-p", + str(tmp_path), + ], + ) + assert result.exit_code == 0, result.output + return tmp_path + + +def _status(project: Path) -> str: + index = yaml.safe_load((project / ".wren" / "apps.yml").read_text()) + return index["apps"]["myapp"]["status"] + + +# ── Tracer bullet ────────────────────────────────────────────────────────── + + +def test_verify_passes_and_flips_status_to_built(tmp_path: Path) -> None: + project = _make_project_with_app(tmp_path) + + result = runner.invoke(app, ["genbi", "verify", "myapp", "-p", str(project)]) + + assert result.exit_code == 0, result.output + assert _status(project) == "built" + + +def test_verify_fails_on_missing_index_html(tmp_path: Path) -> None: + project = _make_project_with_app(tmp_path, index_html=False) + + result = runner.invoke(app, ["genbi", "verify", "myapp", "-p", str(project)]) + + assert result.exit_code != 0 + assert "index.html" in result.output + assert _status(project) == "scaffolded" # not flipped + + +def test_verify_fails_on_invalid_mdl_json(tmp_path: Path) -> None: + project = _make_project_with_app(tmp_path, mdl_json=False) + (project / "apps" / "myapp" / "mdl.json").write_text("{not json") + + result = runner.invoke(app, ["genbi", "verify", "myapp", "-p", str(project)]) + + assert result.exit_code != 0 + assert "mdl.json" in result.output + assert _status(project) == "scaffolded" + + +def test_verify_snapshot_requires_data_asset(tmp_path: Path) -> None: + project = _make_project_with_app(tmp_path, data_asset=False) + + result = runner.invoke(app, ["genbi", "verify", "myapp", "-p", str(project)]) + + assert result.exit_code != 0 + assert "data asset" in result.output + assert _status(project) == "scaffolded" + + +def test_verify_unregistered_app_errors(tmp_path: Path) -> None: + project = _make_project_with_app(tmp_path, register=False) + + result = runner.invoke(app, ["genbi", "verify", "myapp", "-p", str(project)]) + + assert result.exit_code != 0 + assert "not registered" in result.output + + +def test_open_unregistered_app_errors(tmp_path: Path) -> None: + project = _make_project_with_app(tmp_path, register=False) + + result = runner.invoke(app, ["genbi", "open", "myapp", "-p", str(project)]) + + assert result.exit_code != 0 + assert "not registered" in result.output + + +def test_verify_live_app_does_not_require_data_asset(tmp_path: Path) -> None: + project = _make_project_with_app(tmp_path, data_mode="live", data_asset=False) + + result = runner.invoke(app, ["genbi", "verify", "myapp", "-p", str(project)]) + + assert result.exit_code == 0, result.output + assert _status(project) == "built" + + +def test_verify_fails_on_inlined_connection_credentials(tmp_path: Path) -> None: + project = _make_project_with_app(tmp_path, data_mode="live", data_asset=False) + (project / "apps" / "myapp" / "config.js").write_text( + 'const DB = "postgres://admin:s3cretpw@db.internal:5432/prod";' + ) + + result = runner.invoke(app, ["genbi", "verify", "myapp", "-p", str(project)]) + + assert result.exit_code != 0 + assert "secret" in result.output.lower() or "credential" in result.output.lower() + assert "config.js" in result.output + assert _status(project) == "scaffolded" + + +def test_verify_fails_on_inlined_password_assignment(tmp_path: Path) -> None: + project = _make_project_with_app(tmp_path, data_mode="live", data_asset=False) + (project / "apps" / "myapp" / "settings.json").write_text( + '{"host": "db.internal", "password": "hunter2hunter2"}' + ) + + result = runner.invoke(app, ["genbi", "verify", "myapp", "-p", str(project)]) + + assert result.exit_code != 0 + assert "settings.json" in result.output + assert _status(project) == "scaffolded" + + +def test_verify_snapshot_app_with_secret_also_fails(tmp_path: Path) -> None: + project = _make_project_with_app(tmp_path) # snapshot, complete + (project / "apps" / "myapp" / "index.html").write_text( + '' + ) + + result = runner.invoke(app, ["genbi", "verify", "myapp", "-p", str(project)]) + + assert result.exit_code != 0 + assert "index.html" in result.output + + +def test_verify_fails_closed_on_unknown_data_mode(tmp_path: Path) -> None: + # a typo'd mode must NOT silently skip the snapshot data-asset check + result = verify_app(tmp_path, data_mode="snapsho") + assert not result.passed + assert any("unknown data_mode" in f for f in result.failures) + + +def _otherwise_valid_snapshot_app(tmp_path: Path) -> Path: + (tmp_path / "index.html").write_text("") + (tmp_path / "mdl.json").write_text('{"models": []}') + (tmp_path / "data.parquet").write_bytes(b"PAR1") # snapshot asset + return tmp_path + + +@pytest.mark.parametrize( + "filename, contents", + [ + # .ts / .yaml are NOT in any web allowlist but are scanned (default-deny) + ("config.ts", 'export const DB = "postgres://user:p4ssword@host/db";\n'), + ("settings.yaml", 'password: "s3cretValue123"\n'), + ], +) +def test_verify_scans_non_weblike_files_for_secrets( + tmp_path: Path, filename: str, contents: str +) -> None: + app = _otherwise_valid_snapshot_app(tmp_path) + (app / filename).write_text(contents) + + result = verify_app(app, data_mode="snapshot") + + assert not result.passed + assert any(filename in f for f in result.failures), result.failures + + +def test_verify_rejects_dotenv_by_presence(tmp_path: Path) -> None: + # a .env file must fail the gate on presence alone — even content the + # narrow patterns wouldn't recognize, and even though wrangler would + # otherwise ship the whole folder. + app = _otherwise_valid_snapshot_app(tmp_path) + (app / ".env").write_text("API_KEY=plain_unquoted_value\n") + + result = verify_app(app, data_mode="snapshot") + + assert not result.passed + assert any(".env" in f for f in result.failures), result.failures + + +def test_verify_does_not_scan_binary_data_assets(tmp_path: Path) -> None: + # a parquet whose bytes happen to contain a secret-like run must not be + # read/flagged — binary/data formats are skipped to avoid false positives. + app = _otherwise_valid_snapshot_app(tmp_path) + (app / "data.parquet").write_bytes(b'password="s3cretValue123"\x00PAR1') + + result = verify_app(app, data_mode="snapshot") + + assert result.passed, result.failures diff --git a/core/wren/tests/unit/test_skills_cli.py b/core/wren/tests/unit/test_skills_cli.py index 930f1e8c5..f731152aa 100644 --- a/core/wren/tests/unit/test_skills_cli.py +++ b/core/wren/tests/unit/test_skills_cli.py @@ -55,12 +55,19 @@ def test_skills_get_unknown_raises(): skills_delivery.get_skill("nope") -# ── Ticket 1b: all five skills + --full + --script ────────────────────────── +# ── Ticket 1b: all bundled skills + --full + --script ─────────────────────── -ALL_SKILLS = {"onboarding", "usage", "generate-mdl", "dlt-connector", "enrich-context"} +ALL_SKILLS = { + "onboarding", + "usage", + "generate-mdl", + "dlt-connector", + "enrich-context", + "genbi", +} -def test_all_five_skills_bundled(): +def test_all_skills_bundled(): names = {s.name for s in skills_delivery.list_skills()} assert names == ALL_SKILLS diff --git a/docs/core/README.md b/docs/core/README.md index 20ececfe5..6c9104306 100644 --- a/docs/core/README.md +++ b/docs/core/README.md @@ -31,6 +31,7 @@ Changes merged to `main` are automatically synced to the doc website via GitHub - [Memory](guides/memory.md) - [Profiles](guides/profiles.md) - [dbt Integration](guides/dbt-integration.md) +- [Build & deploy a GenBI app](guides/genbi.md) ## Reference diff --git a/docs/core/guides/genbi.md b/docs/core/guides/genbi.md new file mode 100644 index 000000000..c9de27157 --- /dev/null +++ b/docs/core/guides/genbi.md @@ -0,0 +1,154 @@ +--- +sidebar_label: Build & deploy a GenBI app +--- + +# Build & deploy a GenBI app + +`wren genbi` turns a project's context layer into a shareable, browser-side +GenBI web app — powered by [`wren-core-wasm`](../sdk/wasm.md) — and deploys it +to the user's Vercel or Cloudflare Pages account. The whole flow runs through +an AI agent: the user describes the dashboard they want in natural language, +and the agent drives the CLI to produce a public URL. + +## The CLI ↔ agent split + +GenBI deliberately divides the work: + +- **The CLI owns the deterministic parts** — the authoritative build + instruction (it knows the live project facts and the pinned wasm version), + the app index (`.wren/apps.yml`), `verify`, and `deploy`. +- **The agent owns authoring** — it writes the app code by following the build + instruction, choosing the charts and layout that actually answer the user's + question. + +`.wren/apps.yml` is always machine-written via `wren genbi register/remove` — +never edited by hand. + +The matching agent workflow guide is served by the CLI: `wren skills get +genbi`. For the full command/flag reference see the +[CLI reference](../reference/cli.md#wren-genbi--build--deploy-genbi-apps). + +## Data modes + +Pick one at build time: + +| Mode | Where the data lives | Use it for | +|------|----------------------|------------| +| **snapshot** (default) | Bundled with the app as `data/*.parquet`, queried client-side via wasm | Demos, reports, small data, dlt-pipeline output — fully serverless | +| **live** | The app calls back to your warehouse/API at view time | Production-scale or always-fresh data; needs a CORS-enabled endpoint and **never** inlined credentials | + +## The conversational flow + +The user never has to know the `wren genbi` commands — they describe intent and +the agent translates it. A typical end-to-end conversation: + +```text +U: My project is in ~/forecast — last week's forecast by product? +U: And the 8-week trend? +U: Turn this into an interactive dashboard I can filter by product/OSAT and share. +A: (build → author → register → verify) Done — preview locally or deploy? +U: Preview first. → A: http://127.0.0.1:8848/ +U: Make the OSAT chart a share-of-total; lighten the palette. +U: Deploy it to Vercel. +A: I need a VERCEL_TOKEN — add it to ~/.wren/.env, then tell me. +U: Done. → A: Deployed +A: Heads-up: the URL returns 401 — Vercel Deployment Protection is on. + Disable it at Project → Settings → Deployment Protection to make it public. +U: Disabled. → A: Confirmed public ✅ +U: Ship it to production. → A: (--prod) +``` + +What the agent runs behind each turn: + +### 1. Build — get the instruction + +```bash +wren genbi build forecast-dashboard --prompt "" --data-mode snapshot +``` + +Prints the authoritative build instruction (wasm wiring with the pinned +`wren-core-wasm` version, the project's model/column inventory, data-mode +guidance, acceptance criteria, and the target folder). It writes no app files; +it only compiles `target/mdl.json` first if it's missing. Use `--prompt-file` +or `--prompt -` for long prompts. + +### 2. Author the app + +The agent writes everything under `apps//`, following the instruction: + +- copy the compiled MDL in as `apps//mdl.json`; +- load `wren-core-wasm` from the CDN in the instruction (never bundle the + ~68 MB binary); +- **snapshot:** export the data the dashboard needs to `apps//data/` as + parquet. A DuckDB-backed project (including anything loaded by the + [`dlt-connector`](../reference/skills.md#dlt-connector) skill) exports + trivially: + + ```bash + python - <<'PY' + import duckdb + con = duckdb.connect(".duckdb", read_only=True) + con.execute( + "COPY (SELECT * FROM
) " + "TO 'apps//data/
.parquet' (FORMAT parquet)" + ) + PY + ``` + +- **live:** write an endpoint-only connection config — never inline + credentials. + +### 3. Register & verify + +```bash +wren genbi register forecast-dashboard --data-mode snapshot +wren genbi verify forecast-dashboard +``` + +`verify` is a deterministic, no-browser preflight: required files exist, +`mdl.json` parses, snapshot apps ship a `.parquet`/`.duckdb` asset, and a +**default-deny secret scan** flags inlined credentials (and refuses any +`.env*` file outright). `deploy` gates on `verify`. + +> The secret scan is best-effort defense-in-depth, **not** a guarantee — the +> real rule is *never inline secrets into app files*. The deploy target is a +> public static host: anyone with the URL can read every shipped file. + +### 4. Preview locally + +```bash +wren genbi open forecast-dashboard --port 8848 +``` + +### 5. Deploy + +```bash +wren genbi deploy forecast-dashboard --provider vercel # or cloudflare +wren genbi deploy forecast-dashboard --provider vercel --prod # confirm with the user first +``` + +- **Preview by default;** `--prod` ships to production. +- **Tokens** are discovered from the environment or `.env` files + (`VERCEL_TOKEN` / `CLOUDFLARE_API_TOKEN`, plus `CLOUDFLARE_ACCOUNT_ID`) — + never passed as CLI flags (they'd leak into shell history). +- **Cloudflare** shells out to the [`wrangler`](https://developers.cloudflare.com/workers/wrangler/) + CLI (`npm install -g wrangler`, or have `npx` available) — Pages has no + single inline-upload REST endpoint. + +## Vercel Deployment Protection (the 401 trap) + +New Vercel projects ship with **Vercel Authentication** on by default, so every +deployment — preview *and* production — returns **HTTP 401** to anyone not +logged into the owning account. The deploy itself succeeded; the URL is just +gated. To make the app publicly shareable, disable it in the Vercel dashboard: +**Project → Settings → Deployment Protection → Vercel Authentication → +Disabled**. This is a one-time per-project toggle, not controllable from +`wren genbi deploy`. If you only need a private link (viewable while logged +into your account), leaving it on is fine. + +## See also + +- [`wren genbi` CLI reference](../reference/cli.md#wren-genbi--build--deploy-genbi-apps) +- [`genbi` skill](../reference/skills.md#genbi) +- [`dlt-connector` skill](../reference/skills.md#dlt-connector) — load SaaS data first +- [wren-core-wasm](../sdk/wasm.md) — the in-browser engine that powers the app diff --git a/docs/core/guides/manage_project.md b/docs/core/guides/manage_project.md index d5e370d64..a0e734ba2 100644 --- a/docs/core/guides/manage_project.md +++ b/docs/core/guides/manage_project.md @@ -68,6 +68,29 @@ profile: pg-dev > The same field names `catalog` and `schema` appear inside each model's `table_reference` to point at the database. Do not confuse the two — see the [MDL schema reference](/oss/reference/mdl) for the full distinction. +## Project layout + +Everything below lives **in the project** (version-controlled); connection credentials and global CLI state live separately under `~/.wren/` (see [Where profiles live](#where-profiles-live)). + +```text +my_project/ +├── wren_project.yml # project manifest (fields above) +├── models/ # one YAML file per model +├── relationships.yml # relationships between models +├── cubes/ # (optional) pre-aggregation cubes +├── views/ # (optional) saved views +├── instructions.md # business context for the agent +├── apps/ # (optional) generated GenBI apps, one dir per app +│ └── / # built by an agent via `wren genbi` — see the GenBI guide +├── target/ +│ └── mdl.json # compiled MDL — `wren context build` +└── .wren/ # project-local CLI state (gitignore-able) + ├── memory/ # indexed schema + NL→SQL pairs — `wren memory index` + └── apps.yml # GenBI app index — written by `wren genbi register` +``` + +`apps//` and `.wren/apps.yml` are added by the [GenBI workflow](genbi.md); `.wren/apps.yml` is machine-written via `wren genbi register/remove` — never edit it by hand. + ## Profile management Profiles separate connection credentials from project definitions. The same MDL project can connect to multiple databases by switching profiles. diff --git a/docs/core/reference/cli.md b/docs/core/reference/cli.md index a3d60d52a..eff9fdf22 100644 --- a/docs/core/reference/cli.md +++ b/docs/core/reference/cli.md @@ -370,3 +370,77 @@ to run. ```bash wren ask "monthly orders trend" --direct ``` + +## `wren genbi` — Build & Deploy GenBI Apps + +Turn a project's context layer into a shareable, browser-side GenBI web app +(powered by `wren-core-wasm`) and deploy it to Vercel or Cloudflare Pages. + +**CLI ↔ agent split:** the CLI owns the authoritative build instruction and all +deterministic state (the app index, verify, deploy). The agent authors the app +code by following the instruction. `.wren/apps.yml` is only ever written by the +CLI — never by hand. The matching agent workflow guide is `wren skills get +genbi`. + +### `wren genbi build ` + +Print a project-hydrated build instruction (wasm wiring with the pinned +`wren-core-wasm` version, the project's model/column inventory, data-mode +guidance, acceptance criteria, and the target folder). Writes no app files; it +only compiles `target/mdl.json` first if it's missing. + +```bash +wren genbi build sales-overview --prompt "orders dashboard" --data-mode snapshot +# --prompt-file / --prompt - read a long prompt from a file or stdin +# --data-mode snapshot|live snapshot (default): bundle data with the app +# live: app calls a CORS endpoint at view time +``` + +### `wren genbi register ` / `list` / `remove ` + +Machine-written app index (`/.wren/apps.yml`). + +```bash +wren genbi register sales-overview --data-mode snapshot # record an authored app +wren genbi list # apps + status + deploy state +wren genbi remove sales-overview # drop index entry (files kept) +``` + +App names must be simple slugs (letters, numbers, `_`, `-`); names containing +path separators are rejected so they can't escape `/apps/`. + +### `wren genbi verify ` + +Deterministic deploy preflight (no browser): required files exist, `mdl.json` +parses, snapshot apps ship a `.parquet`/`.duckdb` asset, and a default-deny +secret scan flags inlined credentials. `deploy` gates on this. The secret scan +is best-effort defense-in-depth, not a guarantee — never inline secrets. + +### `wren genbi open ` + +Serve a built app locally for preview (blocking; Ctrl-C stops). + +```bash +wren genbi open sales-overview --port 8848 # 0 = auto-pick +``` + +### `wren genbi deploy ` + +Verify, then ship to the user's provider account and return a shareable URL. +Preview by default; `--prod` deploys to production (confirm with the user +first). + +```bash +wren genbi deploy sales-overview --provider vercel # or cloudflare +wren genbi deploy sales-overview --provider vercel --prod +``` + +- **Tokens** are discovered from the environment or `.env` files + (`VERCEL_TOKEN` / `CLOUDFLARE_API_TOKEN`) — never passed as CLI flags. + Cloudflare also needs `CLOUDFLARE_ACCOUNT_ID`. +- **Cloudflare** shells out to the `wrangler` CLI (`npm install -g wrangler`, + or have `npx` available) — Pages has no single inline-upload REST endpoint. +- **Vercel Deployment Protection:** new Vercel projects return HTTP 401 to + logged-out visitors by default. To make the URL public, disable it at + Project → Settings → Deployment Protection. The deploy itself succeeded; + the URL is just gated. diff --git a/docs/core/reference/skills.md b/docs/core/reference/skills.md index 77fee1d7d..0d5e90012 100644 --- a/docs/core/reference/skills.md +++ b/docs/core/reference/skills.md @@ -37,6 +37,7 @@ always matches the installed CLI. | **generate-mdl** | `wren skills get generate-mdl` | One-time setup: explore database schema, normalize types, scaffold MDL YAML project | | **enrich-context** | `wren skills get enrich-context` | Deepen business context the schema can't carry: enum/unit/null semantics, default filters, synonyms, currency rules, and named aggregation metrics as cubes — via grill or auto-pilot mode | | **dlt-connector** | `wren skills get dlt-connector` | Connect SaaS APIs (HubSpot, Stripe, Salesforce, GitHub, Slack, …) into DuckDB via dlt, then auto-generate a Wren project | +| **genbi** | `wren skills get genbi` | Turn a project's context layer into a shareable, browser-side GenBI web app and deploy it to Vercel or Cloudflare Pages | List them with `wren skills list`. @@ -366,6 +367,63 @@ before improvising. --- +## genbi + +Turns a project's context layer into a shareable, browser-side GenBI web +app (powered by `wren-core-wasm`) and deploys it to Vercel or Cloudflare +Pages — from a natural-language request to a public URL in one conversation. +Full command reference: [`wren genbi`](cli.md#wren-genbi--build--deploy-genbi-apps). + +### CLI ↔ agent split + +The CLI owns the authoritative build instruction and all deterministic state +(the app index, verify, deploy); the agent authors the app code by following +the instruction. `.wren/apps.yml` is only ever written by the CLI. + +### Workflow + +| Step | Goal | Key actions | +|------|------|-------------| +| **1. Build** | Get the authoritative build instruction | `wren genbi build --prompt "…" --data-mode snapshot\|live` — prints wasm wiring (pinned version), the model/column inventory, acceptance criteria, target folder | +| **2. Author** | Write the app | Agent writes `apps//` per the instruction; copies in `mdl.json`; exports the snapshot data to `data/*.parquet` (DuckDB-backed projects — incl. dlt output — export trivially) | +| **3. Register & verify** | Record + preflight | `wren genbi register --data-mode `, then `wren genbi verify ` (files, parseable MDL, snapshot asset, default-deny secret scan) | +| **4. Preview** | Local check | `wren genbi open ` serves the app for local review | +| **5. Deploy** | Ship a URL | `wren genbi deploy --provider vercel\|cloudflare [--prod]` — preview by default; confirm before `--prod` | + +### Data modes + +- **snapshot** (default) — data ships with the app (parquet/duckdb), queried + client-side. Fully serverless; right for demos, reports, small data, and + dlt-pipeline output. +- **live** — the app calls back to a CORS-enabled endpoint at view time. Right + for production-scale or always-fresh data; never inline credentials. + +### Deploy notes + +- **Tokens** come from the environment or `.env` (`VERCEL_TOKEN` / + `CLOUDFLARE_API_TOKEN`, plus `CLOUDFLARE_ACCOUNT_ID`) — never CLI flags. +- **Cloudflare** shells out to the `wrangler` CLI (`npm install -g wrangler`). +- **Vercel Deployment Protection** is on by default — a deployed URL returns + 401 to logged-out visitors until disabled in Project → Settings → Deployment + Protection. The agent verifies the URL actually loads before calling it + shareable. + +### When to trigger + +The discovery stub routes the agent here on phrases like: + +- "build a dashboard from my Wren project" +- "make a shareable analytics app" +- "deploy my context layer as a web app" +- "host a GenBI app on Vercel / Cloudflare Pages" + +### Data origin handoff + +If the data is coming from a SaaS source, run [`dlt-connector`](#dlt-connector) +first — its DuckDB output is exactly the snapshot source `genbi` bundles. + +--- + ## Skill bundle layout Inside the wheel, each skill is a directory under diff --git a/skills/index.json b/skills/index.json index 3078be381..4e50589ca 100644 --- a/skills/index.json +++ b/skills/index.json @@ -7,14 +7,15 @@ "skills": [ { "name": "wren", - "description": "Wren CLI discovery stub. The actual workflow guides live inside the wren CLI; this stub teaches an AI agent to call `wren skills list`, `wren skills get `, and `wren ask --guided|--direct`. Use whenever the user asks a data question, wants to install / set up Wren Engine, connect a new database, load SaaS data via dlt, generate or regenerate an MDL project, or enrich a project with business context.", + "description": "Wren CLI discovery stub. The actual workflow guides live inside the wren CLI; this stub teaches an AI agent to call `wren skills list`, `wren skills get `, and `wren ask --guided|--direct`. Use whenever the user asks a data question, wants to install / set up Wren Engine, connect a new database, load SaaS data via dlt, generate or regenerate an MDL project, enrich a project with business context, or build & deploy a shareable GenBI web app / dashboard from a project's semantic layer (Vercel / Cloudflare).", "tags": [ "wren", "discovery-stub", "cli", "sql", "mdl", - "semantic-layer" + "semantic-layer", + "genbi" ], "repository": "https://github.com/Canner/WrenAI/tree/main/skills/wren" } diff --git a/skills/wren/SKILL.md b/skills/wren/SKILL.md index d9ca8f9f9..021117c7f 100644 --- a/skills/wren/SKILL.md +++ b/skills/wren/SKILL.md @@ -1,6 +1,6 @@ --- name: wren -description: "Wren CLI for AI agents — a semantic SQL layer over 22+ databases (Postgres, MySQL, BigQuery, Snowflake, Spark, …). The actual workflow guides live inside the `wren` CLI itself; this is just a discovery stub. Use whenever the user asks a data question (how many, show me, top N, compare, trend, breakdown, metric, revenue, customers, orders), wants to install / set up Wren Engine, connect a new database, connect SaaS data via dlt (HubSpot, Stripe, Salesforce, GitHub, Slack), generate or regenerate an MDL project from a database schema, or enrich a project with business context (enum meanings, units, cubes like ARR / DAU / churn). Triggers: 'install wren', 'set up wren engine', 'connect database to wren', 'connect SaaS to wren', 'load hubspot / stripe / salesforce data', 'generate mdl', 'scaffold wren project', 'enrich wren context', 'augment my project', 'add cubes', 'wren onboarding', 'wren usage', 'wren generate mdl', 'wren dlt connector', 'wren enrich context'." +description: "Wren CLI for AI agents — a semantic SQL layer over 22+ databases (Postgres, MySQL, BigQuery, Snowflake, Spark, …). The actual workflow guides live inside the `wren` CLI itself; this is just a discovery stub. Use whenever the user asks a data question (how many, show me, top N, compare, trend, breakdown, metric, revenue, customers, orders), wants to install / set up Wren Engine, connect a new database, connect SaaS data via dlt (HubSpot, Stripe, Salesforce, GitHub, Slack), generate or regenerate an MDL project from a database schema, enrich a project with business context (enum meanings, units, cubes like ARR / DAU / churn), or turn a project's context layer into a shareable GenBI web app / dashboard and deploy it to Vercel or Cloudflare. Triggers: 'install wren', 'set up wren engine', 'connect database to wren', 'connect SaaS to wren', 'load hubspot / stripe / salesforce data', 'generate mdl', 'scaffold wren project', 'enrich wren context', 'augment my project', 'add cubes', 'build a dashboard', 'make a shareable analytics app', 'deploy my context layer as a web app', 'genbi app', 'wren onboarding', 'wren usage', 'wren generate mdl', 'wren dlt connector', 'wren enrich context', 'wren genbi'." license: Apache-2.0 allowed-tools: Bash(wren:*) --- @@ -22,6 +22,7 @@ wren skills get usage # day-to-day querying wren skills get generate-mdl # generate MDL from a database schema wren skills get dlt-connector # connect SaaS sources via dlt wren skills get enrich-context # add business context (units, enums, cubes) +wren skills get genbi # build & deploy a shareable GenBI web app # add --full to include the skill's reference docs # add --script to fetch a bundled script (e.g. dlt-connector / introspect_dlt) ```