mirror of
https://github.com/Canner/WrenAI.git
synced 2026-08-29 00:03:22 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""GenBI app lifecycle — build instruction composition, index, verify, deploy."""
|
||||
@@ -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}")
|
||||
@@ -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}
|
||||
"""
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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>` |
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user