feat(wren): GenBI app build & deploy — semantic layer → shareable web app (#2348)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Pin Hsu Chen
2026-06-11 13:01:34 +08:00
committed by GitHub
parent d5e78790b9
commit 67257bf2a8
26 changed files with 2661 additions and 6 deletions
+19
View File
@@ -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
+2
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""GenBI app lifecycle — build instruction composition, index, verify, deploy."""
+374
View File
@@ -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 <project>/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/<name>`` directory.
The slug rule rejects path separators, ``..`` and a leading dot, so the
returned path is always contained in ``<project>/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/<name>/.")],
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/<name>/.")],
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}")
+127
View File
@@ -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
<script type="module">
import {{ WrenEngine }} from
"https://unpkg.com/@wrenai/wren-core-wasm@{WREN_CORE_WASM_VERSION}/dist/index.js";
const engine = await WrenEngine.init();
await engine.loadMDL(mdl, profile); // profile: {{ source: "<url-prefix-or-empty>" }}
const result = await engine.query(sql);
</script>
```
## 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}
"""
+99
View File
@@ -0,0 +1,99 @@
"""App index — the single source of truth for GenBI apps in a project.
Owns ``<project>/.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
@@ -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]()
@@ -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."""
...
@@ -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,
)
@@ -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"),
)
+41
View File
@@ -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
+160
View File
@@ -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)
@@ -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:
@@ -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/<name>/, 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 <name> --prompt "<the user's request, verbatim>" --data-mode <mode>
```
For long or multi-line prompts use `--prompt-file <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/<name>/` — never outside it.
- Copy the compiled MDL into the app as `apps/<name>/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/<name>/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; <db> is the project's DuckDB file (see wren_project.yml)
python - <<'PY'
import duckdb
con = duckdb.connect("<db>.duckdb", read_only=True)
con.execute(
"COPY (SELECT * FROM <table>) "
"TO 'apps/<name>/data/<table>.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 <name> --data-mode <mode>
wren genbi verify <name>
```
If verify fails: fix the reported problems and re-run verify. Do NOT proceed
to deploy on a failed verify. Offer `wren genbi open <name>` for a local
preview before shipping.
### 5. Deploy (only if the user asked for it)
```bash
wren genbi deploy <name> --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 <name> --prompt "…" [--data-mode snapshot\|live]` |
| Record the app | `wren genbi register <name> --data-mode <mode>` |
| Preflight | `wren genbi verify <name>` |
| Local preview | `wren genbi open <name>` |
| Ship | `wren genbi deploy <name> --provider vercel\|cloudflare [--prod]` |
| Inventory | `wren genbi list` / `wren genbi remove <name>` |
+258
View File
@@ -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
+404
View File
@@ -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("<html><body>GenBI</body></html>")
(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
+147
View File
@@ -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("<html></html>")
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("<html></html>")
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
+230
View File
@@ -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("<html><body>GenBI</body></html>")
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(
'<script>const k = "AKIAIOSFODNN7EXAMPLE";</script>'
)
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("<html></html>")
(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
+10 -3
View File
@@ -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
+1
View File
@@ -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
+154
View File
@@ -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 <preview-url>
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) <production-url>
```
What the agent runs behind each turn:
### 1. Build — get the instruction
```bash
wren genbi build forecast-dashboard --prompt "<the user's request, verbatim>" --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/<name>/`, following the instruction:
- copy the compiled MDL in as `apps/<name>/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/<name>/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("<db>.duckdb", read_only=True)
con.execute(
"COPY (SELECT * FROM <table>) "
"TO 'apps/<name>/data/<table>.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
+23
View File
@@ -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
│ └── <name>/ # 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/<name>/` 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.
+74
View File
@@ -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 <name>`
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 <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 <name>` / `list` / `remove <name>`
Machine-written app index (`<project>/.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 `<project>/apps/`.
### `wren genbi verify <name>`
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 <name>`
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 <name>`
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.
+58
View File
@@ -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 <name> --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/<name>/` 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 <name> --data-mode <mode>`, then `wren genbi verify <name>` (files, parseable MDL, snapshot asset, default-deny secret scan) |
| **4. Preview** | Local check | `wren genbi open <name>` serves the app for local review |
| **5. Deploy** | Ship a URL | `wren genbi deploy <name> --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
+3 -2
View File
@@ -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 <name>`, and `wren ask <prompt> --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 <name>`, and `wren ask <prompt> --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"
}
+2 -1
View File
@@ -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 <name> to fetch a bundled script (e.g. dlt-connector / introspect_dlt)
```