mirror of
https://github.com/Canner/WrenAI.git
synced 2026-10-16 06:57:43 +08:00
feat(wren): add wren cloud to bind a local project to a Wren Cloud git remote (#2706)
This commit is contained in:
@@ -33,6 +33,8 @@ Uses `uv` (not Poetry). `pyproject.toml` uses `hatchling` as build backend.
|
||||
- `wren utils parse-type` — SQL type normalization
|
||||
- `wren memory index|fetch|store|recall` — Semantic memory (when `wren[memory]` installed)
|
||||
- `wren serve mcp` — Serve query/schema/knowledge tools as an MCP server (in-process engine, when `wrenai[mcp]` installed)
|
||||
- `wren cloud auth add|remove` — Store/remove the credential git authenticates to a Wren Cloud project with (touches no directory)
|
||||
- `wren cloud create|link|unlink` — Bind a local directory to a Wren Cloud project's git remote. The binding *is* the git remote; after binding, plain `git push`/`git pull` are the commands
|
||||
|
||||
## Key Design Points
|
||||
|
||||
|
||||
@@ -626,6 +626,10 @@ from wren.serve_cli import serve_app # noqa: PLC0415, E402
|
||||
|
||||
app.add_typer(serve_app)
|
||||
|
||||
from wren.cloud_cli import cloud_app # noqa: PLC0415, E402
|
||||
|
||||
app.add_typer(cloud_app)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,788 @@
|
||||
"""Typer sub-app for ``wren cloud`` commands.
|
||||
|
||||
``wren cloud auth add`` stores a project's API key and configures git to
|
||||
authenticate with it — it touches no directory. ``wren cloud link`` then
|
||||
binds (or adopts) that project's files into a local directory via plain git,
|
||||
once. After that, ordinary ``git push`` / ``git pull`` / ``git diff`` are the
|
||||
commands — none of the security depends on going through this CLI again, and
|
||||
``git pull`` (not a repeated ``link``) is how you get updates.
|
||||
``wren cloud git-credential`` is the helper ``auth add`` wires into git; it
|
||||
is not meant to be invoked by hand.
|
||||
|
||||
The two are separate because authentication and binding have different
|
||||
scopes: a credential covers a project on a host, while a binding covers one
|
||||
directory. That is what makes ``auth add`` on its own useful — configure
|
||||
authentication, then drive ``git clone`` yourself, in CI or with whatever
|
||||
flags you want, with no ``wren`` command in the git path.
|
||||
|
||||
``wren cloud unlink`` and ``wren cloud auth remove`` undo those two, and the
|
||||
asymmetry between them follows from where the state lives:
|
||||
|
||||
- **The binding is the git remote.** Nothing on this machine records which
|
||||
project a directory belongs to; the credential helper reads it back out of
|
||||
the path git hands it. So ``unlink`` removes ``origin`` and that is the
|
||||
entire unbind — no server call, and the project is untouched.
|
||||
- **The API key is per host + project**, so ``auth remove`` drops one and touches
|
||||
no directory. ``unlink`` leaves it alone by default, because another
|
||||
directory may still be bound to the same project.
|
||||
- **The git credential-helper entry is per host**, shared by every project
|
||||
on it. Both commands remove it only once no stored login uses that host.
|
||||
|
||||
To move a directory to a *different* project, unbind it and bind a clean
|
||||
directory. A directory's history belongs to the project it was acquired
|
||||
from, and ``link`` refuses to merge one project's history into another
|
||||
rather than silently combining two projects' content.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Optional
|
||||
|
||||
import typer
|
||||
|
||||
# The managed SaaS deployment, used as the default wherever `--host` names the
|
||||
# *target* of a command. Commands where `--host` instead selects among stored
|
||||
# credentials (``link``, ``auth remove``) deliberately have no default:
|
||||
# defaulting a filter would hide a credential the user does have.
|
||||
DEFAULT_HOST = "https://cloud.getwren.ai"
|
||||
|
||||
|
||||
def _shown(directory: Path) -> Path:
|
||||
"""Render a directory for a message, never as a bare dot.
|
||||
|
||||
Every one of these commands defaults its directory argument to ``.``, and
|
||||
every message that names it ends in a full stop — so the default renders
|
||||
as ``into ..``, which reads as the *parent* directory. Resolving gives an
|
||||
unambiguous absolute path. Display only: the commands themselves keep
|
||||
using the path as given.
|
||||
"""
|
||||
try:
|
||||
return directory.resolve()
|
||||
except OSError:
|
||||
# A path that cannot be resolved is still worth naming; the operation
|
||||
# that follows will report the real problem.
|
||||
return directory
|
||||
|
||||
|
||||
cloud_app = typer.Typer(
|
||||
name="cloud",
|
||||
help="Connect a local project to Wren Cloud's git remote.",
|
||||
)
|
||||
|
||||
auth_app = typer.Typer(
|
||||
name="auth",
|
||||
help="Manage the stored credentials git authenticates to Wren Cloud with.",
|
||||
)
|
||||
cloud_app.add_typer(auth_app)
|
||||
|
||||
git_credential_app = typer.Typer(
|
||||
name="git-credential",
|
||||
help="Git credential helper invoked by git itself — not for direct use.",
|
||||
)
|
||||
cloud_app.add_typer(git_credential_app)
|
||||
|
||||
|
||||
@auth_app.command("add")
|
||||
def auth_add(
|
||||
project: Annotated[str, typer.Option("--project", help="Project id")],
|
||||
host: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
"--host",
|
||||
help=(
|
||||
"Wren Cloud host. Defaults to the managed service; pass a "
|
||||
"hostname or a full URL for a self-hosted one. https is "
|
||||
"assumed when no scheme is given."
|
||||
),
|
||||
),
|
||||
] = DEFAULT_HOST,
|
||||
git_host: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
"--git-host",
|
||||
help=(
|
||||
"Host git should talk to for this project's repo, if it "
|
||||
"differs from --host (e.g. a local setup with no unified "
|
||||
"ingress in front of the API and the git server). Defaults "
|
||||
"to --host."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Store a project API key and configure git to use it automatically.
|
||||
|
||||
Prompts for the API key interactively — never pass it as a command-line
|
||||
argument, since that leaves it recoverable from shell history.
|
||||
|
||||
Validates the key against the project, stores it under ``~/.wren/``,
|
||||
and writes a git credential helper entry scoped to the git host so that
|
||||
``git clone`` / ``git push`` / ``git pull`` against this project's
|
||||
remote just work, with no token ever appearing in the remote URL.
|
||||
|
||||
That helper entry names ``wren``, which git resolves from PATH every time
|
||||
it authenticates. So this refuses up front — before validating the key or
|
||||
writing anything — when the ``wren`` on PATH cannot serve
|
||||
``wren cloud git-credential``: configuring it anyway would break every
|
||||
git operation against the host, with an error from git that names neither
|
||||
wren nor a version.
|
||||
"""
|
||||
from wren import cloud # noqa: PLC0415
|
||||
|
||||
host = cloud.normalize_host(host)
|
||||
# Same treatment as --host, and for a sharper reason: this value
|
||||
# becomes the git-config section name AND the helper's lookup key. A
|
||||
# scheme-less value writes a section git never matches while the
|
||||
# helper looks under the scheme-ful form, so both halves silently
|
||||
# miss and the command still reports success.
|
||||
if git_host is not None:
|
||||
git_host = cloud.normalize_host(git_host)
|
||||
# Naming the host in the prompt is the one place a defaulted `--host`
|
||||
# becomes visible before anything happens — worth it now that omitting the
|
||||
# flag targets the managed service rather than erroring.
|
||||
api_key = typer.prompt(f"Wren Cloud API key for {host}", hide_input=True)
|
||||
if not api_key.strip():
|
||||
typer.echo("Error: an API key is required.", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
token = cloud.login(
|
||||
host=host, project_id=project, api_key=api_key.strip(), git_host=git_host
|
||||
)
|
||||
except cloud.CloudError as exc:
|
||||
typer.echo(f"Error: {exc}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
typer.echo(f"Added credentials for project {project} on {host}.")
|
||||
typer.echo(f"Remote repo: {token.repo}")
|
||||
typer.echo(
|
||||
"git is now configured to authenticate to this project automatically. "
|
||||
"Run `wren cloud link` to bind it, or `git clone` the remote directly."
|
||||
)
|
||||
|
||||
|
||||
def _select_login(
|
||||
*, project: Optional[str], host: Optional[str], command: str
|
||||
) -> tuple[str, str, dict]:
|
||||
"""Pick the one stored login the flags name, or exit with a usable error.
|
||||
|
||||
Shared by `link` and `auth remove` because the filter was duplicated and
|
||||
drifted: the same "no stored login" message has now been reported twice
|
||||
for logins that exist — once for a scheme-less `--host`, once for a
|
||||
`--host` given the *git* host. Both are the message's fault rather than
|
||||
the filter's, so the error names the field that eliminated the candidates
|
||||
and prints what is actually stored.
|
||||
"""
|
||||
from wren import cloud # noqa: PLC0415
|
||||
|
||||
logins = cloud.list_logins()
|
||||
by_project = (
|
||||
logins if project is None else [e for e in logins if e[1] == str(project)]
|
||||
)
|
||||
# `api_host` is what the user typed at `auth add` and what the candidate
|
||||
# list below prints. The store is keyed by *git* host, because that is all
|
||||
# git hands the credential helper — so the two differ on a split-host
|
||||
# deployment, and that difference is exactly what needs explaining here.
|
||||
wanted_host = cloud.normalize_host(host) if host is not None else None
|
||||
selected = (
|
||||
by_project
|
||||
if wanted_host is None
|
||||
else [e for e in by_project if e[2]["api_host"] == wanted_host]
|
||||
)
|
||||
|
||||
if not selected:
|
||||
if project is not None and not by_project:
|
||||
typer.echo(
|
||||
f"Error: no stored Wren Cloud login for project {project}. "
|
||||
"`wren cloud auth list` shows what is stored.",
|
||||
err=True,
|
||||
)
|
||||
elif wanted_host is not None and by_project:
|
||||
stored = sorted({e[2]["api_host"] for e in by_project})
|
||||
typer.echo(
|
||||
f"Error: no stored login matches --host {wanted_host}.\n"
|
||||
f"`--host` is the Wren Cloud API host you gave `auth add`, not "
|
||||
f"the git host. Stored for "
|
||||
+ (f"project {project}" if project else "these logins")
|
||||
+ ": "
|
||||
+ ", ".join(stored)
|
||||
+ ".\n`wren cloud auth list` shows all of them.",
|
||||
err=True,
|
||||
)
|
||||
else:
|
||||
typer.echo(
|
||||
"Error: no stored Wren Cloud login. Run `wren cloud auth add` first.",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if len(selected) > 1:
|
||||
typer.echo(
|
||||
"Error: more than one stored login matches. Narrow it with "
|
||||
"--host and/or --project. Candidates:",
|
||||
err=True,
|
||||
)
|
||||
for git_host, project_id, entry in selected:
|
||||
typer.echo(f" --host {entry['api_host']} --project {project_id}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
return selected[0]
|
||||
|
||||
|
||||
@cloud_app.command()
|
||||
def link(
|
||||
directory: Annotated[
|
||||
Path,
|
||||
typer.Argument(help="Local directory to bind the project into."),
|
||||
] = Path("."),
|
||||
host: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
"--host",
|
||||
help=(
|
||||
"Wren Cloud host the credential was added for, if you added "
|
||||
"credentials for more than one host. Defaults to the only "
|
||||
"stored one, or the one matching --project."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
project: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
"--project",
|
||||
help="Project id, if you have logins for more than one project.",
|
||||
),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Bind a local directory to a Wren Cloud project's git remote, once.
|
||||
|
||||
This is a one-time bind, not the update command — once linked, use
|
||||
``git pull`` to fetch further changes.
|
||||
|
||||
Into a fresh, empty directory this is a plain clone. Into a directory
|
||||
that already contains project files, it initializes git in place,
|
||||
fetches the remote, and merges histories — your files stay where they
|
||||
are, and any real overlap surfaces as an ordinary git conflict for you
|
||||
to resolve. Never force-pushes or force-overwrites.
|
||||
|
||||
Refuses if ``directory`` sits inside another git repository, to avoid
|
||||
pushing that repository's own files into the project's remote.
|
||||
|
||||
Refuses, too, when ``directory`` is already bound somewhere else — an
|
||||
existing ``origin`` pointing at a different project, or a history that
|
||||
came from one. The remote is the binding, so it is never silently
|
||||
repointed, and merging a different project's history in would combine
|
||||
two projects' content and publish the result on the next push. Run
|
||||
``wren cloud unlink`` and bind a clean directory instead.
|
||||
|
||||
Safe to re-run if a previous attempt failed partway through — that
|
||||
recovers cleanly. If the directory is already fully linked, re-running
|
||||
reports that and does not merge again; use ``git pull`` for updates
|
||||
instead.
|
||||
|
||||
Requires having run ``wren cloud auth add`` for the target project first.
|
||||
"""
|
||||
from wren import cloud # noqa: PLC0415
|
||||
|
||||
git_host, project_id, entry = _select_login(
|
||||
project=project, host=host, command="link"
|
||||
)
|
||||
try:
|
||||
outcome = cloud.link(
|
||||
directory,
|
||||
git_host=git_host,
|
||||
api_host=entry["api_host"],
|
||||
project_id=project_id,
|
||||
org_id=entry["org_id"],
|
||||
# May have been stored before the repo path was known (see
|
||||
# `store_key_pending_repo`); this fills it in on first use.
|
||||
repo=cloud.resolve_repo(git_host, project_id, entry),
|
||||
)
|
||||
except cloud.CloudError as exc:
|
||||
typer.echo(f"Error: {exc}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if outcome is cloud.LinkOutcome.ALREADY_LINKED:
|
||||
typer.echo(
|
||||
f"{_shown(directory)} is already linked to project {project_id}. "
|
||||
"Run `git pull` to fetch updates."
|
||||
)
|
||||
else:
|
||||
typer.echo(f"Linked project {project_id} into {_shown(directory)}.")
|
||||
|
||||
|
||||
@cloud_app.command()
|
||||
def create( # noqa: PLR0913
|
||||
org: Annotated[
|
||||
str,
|
||||
typer.Option("--org", help="Organization id the new project belongs to."),
|
||||
],
|
||||
host: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
"--host",
|
||||
help=(
|
||||
"Wren Cloud host. Defaults to the managed service; pass a "
|
||||
"hostname or a full URL for a self-hosted one. https is "
|
||||
"assumed when no scheme is given."
|
||||
),
|
||||
),
|
||||
] = DEFAULT_HOST,
|
||||
directory: Annotated[
|
||||
Path,
|
||||
typer.Argument(help="Local directory to create the project into."),
|
||||
] = Path("."),
|
||||
display_name: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
"--display-name",
|
||||
help="Project display name. Defaults to the directory's name.",
|
||||
),
|
||||
] = None,
|
||||
git_host: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
"--git-host",
|
||||
help=(
|
||||
"Host git should talk to for this project's repo, if it "
|
||||
"differs from --host. See `wren cloud auth add --help` for "
|
||||
"when to pass this; defaults to --host."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
type_: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
"--type",
|
||||
help=(
|
||||
"Data source type for the connection, e.g. BIG_QUERY, "
|
||||
"POSTGRES, SNOWFLAKE. Case-insensitive."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
connection_info: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
"--connection-info",
|
||||
help=(
|
||||
"Connection info as a JSON object, in the Wren Cloud API's "
|
||||
"shape — camelCase keys, and BigQuery `credentials` as the "
|
||||
"service-account object rather than a base64 string. This is "
|
||||
"*not* the shape `wren profile` uses; nothing converts between "
|
||||
"them. See the field list per data source at "
|
||||
"https://wrenai.readme.io/reference/post_projects"
|
||||
"#database-connectioninfo-examples"
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
connection_info_file: Annotated[
|
||||
Optional[Path],
|
||||
typer.Option(
|
||||
"--connection-info-file",
|
||||
help=(
|
||||
"Path to a JSON file with the connection info, same shape as "
|
||||
"--connection-info. A `wren profile` export is a different "
|
||||
"shape and will be rejected."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
test_connection: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--test-connection",
|
||||
help="Ask the server to test the connection before creating the project.",
|
||||
),
|
||||
] = False,
|
||||
language: Annotated[Optional[str], typer.Option("--language")] = None,
|
||||
timezone: Annotated[Optional[str], typer.Option("--timezone")] = None,
|
||||
org_key: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
"--org-key",
|
||||
help=(
|
||||
"Organization API key (starts with `osk-`). Also read from "
|
||||
"the WREN_CLOUD_ORG_KEY environment variable if not passed "
|
||||
"here; otherwise prompted for interactively. Prefer the "
|
||||
"environment variable or the prompt over this flag — a "
|
||||
"command-line argument is recoverable from shell history."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Create a new agent-mode Wren Cloud project and bind `directory` to it.
|
||||
|
||||
This is the other half of `wren cloud link`: both bind a directory to a
|
||||
project, differing only in whether the project has to be made first.
|
||||
Ends in exactly the state `login` + `link` leave a directory in — a
|
||||
plain `git push` works afterward, no further `wren` command needed.
|
||||
|
||||
Requires an organization API key (`osk-...`), which is org-wide
|
||||
authority valid for every project in the org — unlike a project key, it
|
||||
is never written to disk. It is used only to create the project and to
|
||||
mint that project's own key; from then on this command (and everything
|
||||
after it) uses the project key, exactly like `wren cloud auth add` would.
|
||||
|
||||
Always requests agent mode for the new project — a project created any
|
||||
other way has no git repository at all, so there would be nothing here
|
||||
to bind a directory to. If the server did not actually grant that, this
|
||||
fails with a specific error rather than a bare HTTP error, and reports
|
||||
how to recover the project's key.
|
||||
|
||||
`--type` and a connection info are both required. The command connects
|
||||
the data source as it creates the project, because a project without one
|
||||
is reported by Wren Cloud as still needing setup and nothing here can
|
||||
attach one afterwards — the only routes are a REST call or the web UI.
|
||||
|
||||
Refuses up front — before creating anything on the server — if either of
|
||||
those is missing, if `directory` is not a Wren project or its YAML does
|
||||
not compile, if `directory` sits inside another git repository (the same
|
||||
check `link` uses), if the `wren` on PATH cannot serve the git credential
|
||||
helper (the same check `login` uses), or if `directory` is already bound
|
||||
to a project or holds a history acquired from one.
|
||||
|
||||
That last check has to happen here, not just inside the bind step: a new
|
||||
project and its key are created before any git work begins, so a refusal
|
||||
discovered later would leave them behind referenced by nothing. Every
|
||||
refusal above happens with the server untouched.
|
||||
"""
|
||||
from wren import cloud # noqa: PLC0415
|
||||
|
||||
host = cloud.normalize_host(host)
|
||||
# Same treatment as --host, and for a sharper reason: this value
|
||||
# becomes the git-config section name AND the helper's lookup key. A
|
||||
# scheme-less value writes a section git never matches while the
|
||||
# helper looks under the scheme-ful form, so both halves silently
|
||||
# miss and the command still reports success.
|
||||
if git_host is not None:
|
||||
git_host = cloud.normalize_host(git_host)
|
||||
|
||||
if connection_info and connection_info_file:
|
||||
typer.echo(
|
||||
"Error: pass at most one of --connection-info / --connection-info-file.",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
def _read_json_option(raw: str, *, source: str) -> dict:
|
||||
import json # noqa: PLC0415
|
||||
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
typer.echo(f"Error: {source} is not valid JSON: {exc}", err=True)
|
||||
raise typer.Exit(1) from exc
|
||||
if not isinstance(parsed, dict):
|
||||
typer.echo(f"Error: {source} must be a JSON object.", err=True)
|
||||
raise typer.Exit(1)
|
||||
return parsed
|
||||
|
||||
parsed_connection_info: Optional[dict] = None
|
||||
if connection_info_file is not None:
|
||||
try:
|
||||
raw = connection_info_file.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
typer.echo(f"Error: could not read {connection_info_file}: {exc}", err=True)
|
||||
raise typer.Exit(1) from exc
|
||||
parsed_connection_info = _read_json_option(raw, source="--connection-info-file")
|
||||
elif connection_info is not None:
|
||||
parsed_connection_info = _read_json_option(
|
||||
connection_info, source="--connection-info"
|
||||
)
|
||||
|
||||
# The server matches the type against its enum by exact key lookup, and
|
||||
# `POST /api/v1/projects` does not validate it up front: an unrecognized
|
||||
# value strips the connection info to `{}` and surfaces as a 207 with a
|
||||
# project that exists but has no data source. Case is the one part of that
|
||||
# a client can fix without mirroring the server's enum here, and `big_query`
|
||||
# for `BIG_QUERY` is the mistake it actually costs people.
|
||||
if type_ is not None:
|
||||
type_ = type_.strip().upper()
|
||||
|
||||
# Both are required, not merely consistent with each other. A project with
|
||||
# no data source is reported by Wren Cloud as still needing setup, and
|
||||
# nothing in this CLI can finish it: the only way to attach a connection
|
||||
# afterwards is a REST call or the web UI. `create` converts a project you
|
||||
# already have into a usable Wren Cloud project, so it refuses rather than
|
||||
# producing one that cannot be used — before anything is created.
|
||||
missing = []
|
||||
if not type_:
|
||||
missing.append("--type")
|
||||
if parsed_connection_info is None:
|
||||
missing.append("--connection-info / --connection-info-file")
|
||||
if missing:
|
||||
typer.echo(
|
||||
f"Error: {' and '.join(missing)} required.\n"
|
||||
"`create` connects the project's data source as it creates it; a "
|
||||
"project without one is created but unusable, and this CLI cannot "
|
||||
"attach one afterwards.\n"
|
||||
"See `wren cloud create --help` for the accepted types, or create "
|
||||
"the project in the Wren Cloud web UI if you want to pick its data "
|
||||
"source there.",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
resolved_org_key = (org_key or os.environ.get("WREN_CLOUD_ORG_KEY") or "").strip()
|
||||
if not resolved_org_key:
|
||||
resolved_org_key = typer.prompt(
|
||||
f"Wren Cloud organization API key for org {org} on {host}",
|
||||
hide_input=True,
|
||||
).strip()
|
||||
if not resolved_org_key:
|
||||
typer.echo("Error: an organization API key is required.", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
resolved_display_name = display_name or directory.resolve().name
|
||||
|
||||
try:
|
||||
project, outcome = cloud.create(
|
||||
directory,
|
||||
host=host,
|
||||
org_id=org,
|
||||
org_key=resolved_org_key,
|
||||
display_name=resolved_display_name,
|
||||
git_host=git_host,
|
||||
connection_type=type_,
|
||||
connection_info=parsed_connection_info,
|
||||
test_connection=test_connection,
|
||||
language=language,
|
||||
timezone=timezone,
|
||||
)
|
||||
except cloud.CloudError as exc:
|
||||
typer.echo(f"Error: {exc}", err=True)
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
typer.echo(f"Created project {project.id} ({project.display_name}) on {host}.")
|
||||
if project.status == "partial":
|
||||
typer.echo(
|
||||
"Warning: the project was created, but not everything finished "
|
||||
"successfully:",
|
||||
err=True,
|
||||
)
|
||||
for error in project.errors:
|
||||
typer.echo(
|
||||
f" - {error.get('resource', '?')}: {error.get('message', '')}",
|
||||
err=True,
|
||||
)
|
||||
|
||||
if outcome is cloud.LinkOutcome.ALREADY_LINKED:
|
||||
typer.echo(f"{_shown(directory)} is already linked to project {project.id}.")
|
||||
else:
|
||||
typer.echo(f"Linked project {project.id} into {_shown(directory)}.")
|
||||
|
||||
|
||||
@cloud_app.command()
|
||||
def unlink(
|
||||
directory: Annotated[
|
||||
Path, typer.Argument(help="Local directory to unbind.")
|
||||
] = Path("."),
|
||||
forget_key: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--forget-key",
|
||||
help=(
|
||||
"Also drop this project's stored API key. Separate from "
|
||||
"unbinding, because another directory may still be bound to "
|
||||
"the same project and need that key."
|
||||
),
|
||||
),
|
||||
] = False,
|
||||
yes: Annotated[
|
||||
bool,
|
||||
typer.Option("--yes", "-y", help="Skip the confirmation prompt."),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Unbind ``directory`` from the Wren Cloud project it is bound to.
|
||||
|
||||
The binding *is* the git remote, so this removes ``origin`` and that is
|
||||
the whole unbind — there is nothing to tell the server, which never knew
|
||||
about the binding, and the project itself is untouched. Re-binding later
|
||||
with ``wren cloud link`` works and reports ``already linked``.
|
||||
|
||||
Your stored API key is kept by default: unbinding one directory should
|
||||
not revoke a credential another directory may still be using. Pass
|
||||
``--forget-key`` to drop it as well, which additionally removes this
|
||||
host's git credential-helper entry — but only once no stored login uses
|
||||
that host any more, since that entry is shared by every project on it.
|
||||
|
||||
To move a directory to a *different* project, unbind it and start from a
|
||||
clean directory. This directory's history belongs to the project it came
|
||||
from, and ``link`` refuses to merge one project's history into another.
|
||||
"""
|
||||
from wren import cloud # noqa: PLC0415
|
||||
|
||||
if forget_key and not yes:
|
||||
confirm = typer.confirm(
|
||||
f"Drop the stored API key for the project {_shown(directory)} is bound to?"
|
||||
)
|
||||
if not confirm:
|
||||
raise typer.Abort()
|
||||
|
||||
try:
|
||||
outcome = cloud.unlink(directory, forget_key=forget_key)
|
||||
except cloud.CloudError as exc:
|
||||
typer.echo(f"Error: {exc}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if outcome.project_id is not None:
|
||||
typer.echo(f"Unlinked {_shown(directory)} from project {outcome.project_id}.")
|
||||
else:
|
||||
# `origin` was not a Wren Cloud URL. Still removed — the directory is
|
||||
# unbound either way — but naming a project would be a fiction.
|
||||
typer.echo(
|
||||
f"Removed the `origin` remote ({outcome.remote_url}) from {_shown(directory)}."
|
||||
)
|
||||
if outcome.key_forgotten:
|
||||
typer.echo("Dropped the stored API key for that project.")
|
||||
if outcome.helper_removed:
|
||||
typer.echo(
|
||||
f"Removed the git credential helper for {outcome.git_host} — "
|
||||
"no stored logins use that host any more."
|
||||
)
|
||||
|
||||
|
||||
@auth_app.command("list")
|
||||
def auth_list() -> None:
|
||||
"""Show the stored credentials, without showing the keys themselves.
|
||||
|
||||
Exists because there was no way to see what is stored except to open
|
||||
``~/.wren/cloud.yml`` — and that file is keyed by *git* host, while
|
||||
``--host`` on the other commands means the *API* host. Reading the file to
|
||||
find a value for ``--host`` therefore hands you the wrong one. Printing
|
||||
both columns is what makes the difference visible.
|
||||
|
||||
Never prints a key. There is deliberately no flag to.
|
||||
"""
|
||||
from wren import cloud # noqa: PLC0415
|
||||
|
||||
logins = cloud.list_logins()
|
||||
if not logins:
|
||||
typer.echo("No stored Wren Cloud credentials.")
|
||||
typer.echo("Add one with `wren cloud auth add --project <id>`.")
|
||||
return
|
||||
|
||||
rows = [
|
||||
(project_id, entry["api_host"], git_host, entry.get("repo") or "—")
|
||||
for git_host, project_id, entry in logins
|
||||
]
|
||||
rows.sort(key=lambda r: (r[1], int(r[0]) if r[0].isdigit() else 0))
|
||||
|
||||
headers = ("PROJECT", "API HOST (--host)", "GIT HOST (git talks to)", "REPO")
|
||||
widths = [
|
||||
max(len(headers[i]), *(len(r[i]) for r in rows)) for i in range(len(headers))
|
||||
]
|
||||
typer.echo(" ".join(h.ljust(w) for h, w in zip(headers, widths)).rstrip())
|
||||
for r in rows:
|
||||
typer.echo(" ".join(c.ljust(w) for c, w in zip(r, widths)).rstrip())
|
||||
|
||||
|
||||
@auth_app.command("remove")
|
||||
def auth_remove(
|
||||
host: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
"--host",
|
||||
help="Wren Cloud host the credential was added for, if more than one.",
|
||||
),
|
||||
] = None,
|
||||
project: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
"--project",
|
||||
help="Project id, if you have logins for more than one project.",
|
||||
),
|
||||
] = None,
|
||||
yes: Annotated[
|
||||
bool,
|
||||
typer.Option("--yes", "-y", help="Skip the confirmation prompt."),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Remove a stored Wren Cloud credential, without touching any directory.
|
||||
|
||||
This is the counterpart to ``auth add``. It removes the stored API key,
|
||||
and
|
||||
— once that was the last login for its host — that host's git
|
||||
credential-helper entry too.
|
||||
|
||||
No working tree is modified. A directory bound to that project keeps its
|
||||
git remote and simply stops being able to authenticate, which is the
|
||||
honest consequence of discarding the key. Use ``wren cloud unlink`` if
|
||||
you want to unbind a directory as well.
|
||||
|
||||
Distinct from ``wren cloud git-credential erase``, which git calls when
|
||||
a credential is rejected: that drops only the short-lived token and
|
||||
deliberately leaves your API key in place.
|
||||
"""
|
||||
from wren import cloud # noqa: PLC0415
|
||||
|
||||
git_host, project_id, entry = _select_login(
|
||||
project=project, host=host, command="auth remove"
|
||||
)
|
||||
|
||||
if not yes:
|
||||
confirm = typer.confirm(
|
||||
f"Drop the stored API key for project {project_id} on {entry['api_host']}?"
|
||||
)
|
||||
if not confirm:
|
||||
raise typer.Abort()
|
||||
|
||||
try:
|
||||
login_removed, helper_removed = cloud.logout(git_host, project_id)
|
||||
except cloud.CloudError as exc:
|
||||
# `logout` removes the host's credential-helper entry via
|
||||
# `git config --global`, which can fail — the same "could not lock
|
||||
# config file" case `create` handles. The sibling commands all wrap
|
||||
# their `cloud` call; this one did not, so that surfaced as a
|
||||
# traceback.
|
||||
typer.echo(f"Error: {exc}", err=True)
|
||||
raise typer.Exit(1) from exc
|
||||
if not login_removed:
|
||||
typer.echo(
|
||||
f"Error: no stored login for project {project_id} on {git_host}.",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
typer.echo(f"Logged out of project {project_id} on {entry['api_host']}.")
|
||||
if helper_removed:
|
||||
typer.echo(
|
||||
f"Removed the git credential helper for {git_host} — no stored "
|
||||
"logins use that host any more."
|
||||
)
|
||||
|
||||
|
||||
@git_credential_app.command("get")
|
||||
def credential_get() -> None:
|
||||
"""Handle git's ``get`` operation. Invoked by git, not by hand."""
|
||||
from wren.cloud import ( # noqa: PLC0415
|
||||
CloudError,
|
||||
git_credential_get,
|
||||
helper_failure_note,
|
||||
read_credential_input,
|
||||
)
|
||||
|
||||
input_data = read_credential_input(sys.stdin)
|
||||
try:
|
||||
output = git_credential_get(input_data)
|
||||
except CloudError as exc:
|
||||
typer.echo(helper_failure_note(str(exc)), err=True)
|
||||
raise typer.Exit(1)
|
||||
sys.stdout.write(output)
|
||||
|
||||
|
||||
@git_credential_app.command("store")
|
||||
def credential_store() -> None:
|
||||
"""Handle git's ``store`` operation. Invoked by git, not by hand."""
|
||||
from wren.cloud import git_credential_store, read_credential_input # noqa: PLC0415
|
||||
|
||||
git_credential_store(read_credential_input(sys.stdin))
|
||||
|
||||
|
||||
@git_credential_app.command("erase")
|
||||
def credential_erase() -> None:
|
||||
"""Handle git's ``erase`` operation. Invoked by git, not by hand."""
|
||||
from wren.cloud import git_credential_erase, read_credential_input # noqa: PLC0415
|
||||
|
||||
git_credential_erase(read_credential_input(sys.stdin))
|
||||
@@ -299,6 +299,18 @@ def init(
|
||||
)
|
||||
(project_path / "relationships.yml").write_text(rels)
|
||||
|
||||
# `wren context build` writes target/mdl.json — compiled output, derived
|
||||
# from the YAML beside it. Without this, a project pushed to a git remote
|
||||
# carries its own build artifact: observed with `wren cloud create`, which
|
||||
# pushes the directory, so `target/mdl.json` ended up committed to the
|
||||
# project's repository. Only written when absent, so a project that already
|
||||
# has one keeps whatever it says.
|
||||
gitignore = project_path / ".gitignore"
|
||||
if not gitignore.exists():
|
||||
gitignore.write_text(
|
||||
"# Compiled MDL — rebuild with `wren context build`\ntarget/\n"
|
||||
)
|
||||
|
||||
if not empty:
|
||||
# Scaffold example model (table_reference mode)
|
||||
example_model_dir = project_path / "models" / "example"
|
||||
|
||||
@@ -520,14 +520,26 @@ def status(
|
||||
@memory_app.command()
|
||||
def reset(
|
||||
path: PathOpt = None,
|
||||
force: Annotated[
|
||||
bool, typer.Option("--force", "-f", help="Skip confirmation")
|
||||
yes: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--yes",
|
||||
"-y",
|
||||
"--force",
|
||||
"-f",
|
||||
help="Skip confirmation. `--force`/`-f` are deprecated aliases.",
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Drop the derived memory index. knowledge/sql/*.md is preserved.
|
||||
|
||||
The LanceDB index is a derived artifact — after reset, run `wren memory
|
||||
index` to rebuild it from the markdown source of truth.
|
||||
|
||||
``--force`` still works but is deprecated: elsewhere in this CLI
|
||||
``--force`` means "overwrite files" (``wren context init --force``) or
|
||||
"run non-interactively" (``wren memory forget --force``), so the flag
|
||||
that only skips a confirmation is spelled ``--yes``.
|
||||
"""
|
||||
from wren.context import discover_project_path # noqa: PLC0415
|
||||
from wren.memory.index_backend import get_index # noqa: PLC0415
|
||||
@@ -541,7 +553,7 @@ def reset(
|
||||
if idx.name == "grep":
|
||||
typer.echo("grep backend has no derived index — knowledge/sql/ is the source.")
|
||||
return
|
||||
if not force:
|
||||
if not yes:
|
||||
confirm = typer.confirm(
|
||||
"This drops the derived memory index. Your knowledge/sql/*.md "
|
||||
"source files are kept. Continue?"
|
||||
@@ -888,7 +900,16 @@ def forget(
|
||||
] = None,
|
||||
force: Annotated[
|
||||
bool,
|
||||
typer.Option("--force", "-f", help="Skip interactive UI / confirmation"),
|
||||
typer.Option(
|
||||
"--force",
|
||||
"-f",
|
||||
"--yes",
|
||||
"-y",
|
||||
help=(
|
||||
"Run non-interactively: skip the checkbox UI and any "
|
||||
"confirmation. Required with --source to delete in bulk."
|
||||
),
|
||||
),
|
||||
] = False,
|
||||
limit: Annotated[
|
||||
int,
|
||||
@@ -900,6 +921,12 @@ def forget(
|
||||
|
||||
Default: interactive checkbox UI.
|
||||
With --id or --force: non-interactive mode for scripts and agents.
|
||||
|
||||
Unlike ``wren memory reset``, this flag keeps the name ``--force``: it
|
||||
does not merely answer a prompt, it selects a different mode — the
|
||||
checkbox UI is skipped, and ``--source`` only deletes in bulk when it is
|
||||
given. ``--yes``/``-y`` are accepted as aliases so the vocabulary is the
|
||||
same across the CLI.
|
||||
"""
|
||||
mem_store = _get_store(path)
|
||||
|
||||
|
||||
@@ -487,14 +487,26 @@ def _interactive_add(default_ds: str | None) -> dict:
|
||||
@profile_app.command()
|
||||
def rm(
|
||||
name: Annotated[str, typer.Argument(help="Profile name to remove")],
|
||||
force: Annotated[
|
||||
bool, typer.Option("--force", "-f", help="Skip confirmation")
|
||||
yes: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--yes",
|
||||
"-y",
|
||||
"--force",
|
||||
"-f",
|
||||
help="Skip confirmation. `--force`/`-f` are deprecated aliases.",
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Remove a profile."""
|
||||
"""Remove a profile.
|
||||
|
||||
``--force`` still works but is deprecated: elsewhere in this CLI
|
||||
``--force`` means "overwrite files" (see ``wren context init --force``),
|
||||
so the confirmation-skipping flag is spelled ``--yes``.
|
||||
"""
|
||||
from wren.profile import remove_profile # noqa: PLC0415
|
||||
|
||||
if not force:
|
||||
if not yes:
|
||||
confirm = typer.confirm(f"Remove profile '{name}'?")
|
||||
if not confirm:
|
||||
raise typer.Abort()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
"""Unit tests for ``wren profile rm``'s confirmation flag.
|
||||
|
||||
Lives in ``tests/unit/`` deliberately: ``tests/test_profile_cli.py`` covers
|
||||
this command far more thoroughly but is not referenced by any CI job (CI runs
|
||||
``tests/unit/``, ``tests/test_profile_web.py`` and ``tests/test_field_registry.py``),
|
||||
so a regression in a *shipped* flag would not be caught there.
|
||||
|
||||
``rm`` skips its confirmation with ``--yes``/``-y``. ``--force``/``-f`` are
|
||||
kept as deprecated aliases because the command is already released and
|
||||
scripts use them; elsewhere in this CLI ``--force`` means "overwrite files"
|
||||
(``wren context init --force``), which is why the confirmation-skipping
|
||||
spelling moved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from wren import profile_cli
|
||||
from wren.memory import cli as memory_cli
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", ["--yes", "-y", "--force", "-f"])
|
||||
def test_rm_skips_confirmation_for_every_accepted_spelling(flag, monkeypatch):
|
||||
removed = []
|
||||
|
||||
def fake_remove(name):
|
||||
removed.append(name)
|
||||
return True # `rm` treats a falsy return as "profile not found"
|
||||
|
||||
monkeypatch.setattr("wren.profile.remove_profile", fake_remove)
|
||||
|
||||
result = runner.invoke(profile_cli.profile_app, ["rm", "pg", flag])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert removed == ["pg"], (
|
||||
f"{flag} must skip the prompt and remove the profile; a prompt with no "
|
||||
"stdin would abort instead"
|
||||
)
|
||||
|
||||
|
||||
def test_rm_without_a_skip_flag_still_prompts(monkeypatch):
|
||||
removed = []
|
||||
|
||||
def fake_remove(name):
|
||||
removed.append(name)
|
||||
return True # `rm` treats a falsy return as "profile not found"
|
||||
|
||||
monkeypatch.setattr("wren.profile.remove_profile", fake_remove)
|
||||
|
||||
declined = runner.invoke(profile_cli.profile_app, ["rm", "pg"], input="n\n")
|
||||
|
||||
assert declined.exit_code != 0
|
||||
assert removed == [], "declining the prompt must not remove the profile"
|
||||
|
||||
|
||||
# ── wren memory reset / forget ─────────────────────────────────────────────
|
||||
#
|
||||
# Same rename, and the same CI reasoning: tests/unit/test_memory.py is run by
|
||||
# its own CI job behind the `memory` extra, so a flag regression there is not
|
||||
# caught by the default unit job. These assert only on option parsing, which
|
||||
# needs no extra.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", ["--yes", "-y", "--force", "-f"])
|
||||
def test_memory_reset_accepts_every_skip_spelling(flag, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr("wren.context.discover_project_path", lambda: Path("."))
|
||||
|
||||
class _Idx:
|
||||
name = "lancedb"
|
||||
|
||||
def reset(self):
|
||||
calls.append("reset")
|
||||
|
||||
monkeypatch.setattr("wren.memory.index_backend.get_index", lambda *a, **k: _Idx())
|
||||
|
||||
result = runner.invoke(memory_cli.memory_app, ["reset", flag])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert calls == ["reset"], (
|
||||
f"{flag} must skip the prompt; with no stdin a prompt would abort"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", ["--force", "-f", "--yes", "-y"])
|
||||
def test_memory_forget_keeps_force_and_also_accepts_yes(flag):
|
||||
"""`forget`'s flag selects non-interactive *mode*, so it keeps `--force`
|
||||
as its documented name — but `--yes` must still reach it, or the CLI
|
||||
would have two vocabularies for the same user intent."""
|
||||
|
||||
# Parsing is what is under test: --id with --source is rejected *after*
|
||||
# the flag is parsed, so a bad flag name would fail differently (exit 2,
|
||||
# "No such option") than this deliberate usage error.
|
||||
result = runner.invoke(
|
||||
memory_cli.memory_app,
|
||||
["forget", "--id", "1", "--source", "user", flag],
|
||||
)
|
||||
assert "No such option" not in result.output, f"{flag} must be an accepted spelling"
|
||||
@@ -613,3 +613,158 @@ wren genbi deploy sales-overview --provider vercel --prod
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## `wren cloud` — Connect a Project to Wren Cloud
|
||||
|
||||
Binds a local Wren project to a Wren Cloud project's git repository. **The
|
||||
binding *is* the git remote** — nothing on your machine records which project a
|
||||
directory belongs to. After binding, plain `git push` / `git pull` / `git diff`
|
||||
are the commands; none of the security depends on going through this CLI again.
|
||||
|
||||
Two credentials, two lifetimes:
|
||||
|
||||
- The **project API key** is durable. It is prompted for once, stored under
|
||||
`~/.wren/cloud.yml` (mode `0600`, keyed by git host + project id), and never
|
||||
leaves the machine except as an `Authorization` header.
|
||||
- A **git token** is a short-TTL JWT minted from that key on *every* git
|
||||
operation, by a credential helper git invokes itself. It is never written to
|
||||
disk, so an expired token is a non-event — nothing ever holds one long enough
|
||||
to present it late.
|
||||
|
||||
`--host` means different things in different commands, and only defaults where
|
||||
it names a target. On `auth add` and `create` it defaults to
|
||||
`https://cloud.getwren.ai` — pass a URL for a self-hosted deployment. On `link`
|
||||
and `auth remove` it *selects* among stored credentials and has **no** default,
|
||||
deliberately: defaulting a filter would hide a credential you do have. Either
|
||||
way, `https` is assumed when no scheme is given.
|
||||
|
||||
`--git-host` is only for deployments where the API and the git server are on
|
||||
different hosts — a self-hosted setup with no single ingress in front of both.
|
||||
On the managed service, leave it out.
|
||||
|
||||
### `wren cloud auth add`
|
||||
|
||||
Store the credential git will authenticate with, and configure git to use it.
|
||||
Touches no directory, so it works before any clone exists.
|
||||
|
||||
```bash
|
||||
wren cloud auth add --project 1234
|
||||
wren cloud auth add --project 1234 --host https://wren.internal.example.com
|
||||
```
|
||||
|
||||
Writes a URL-scoped credential-helper entry into your **global** git config
|
||||
(there is no clone yet to write a local one into). Because the helper resolves
|
||||
which project's token to mint from the path git hands it, one entry per host
|
||||
serves every project on it.
|
||||
|
||||
Refuses up front if the `wren` on `PATH` cannot serve the helper — a stored
|
||||
credential that git cannot use is worse than none.
|
||||
|
||||
### `wren cloud create`
|
||||
|
||||
Turn a Wren project you already have into a Wren Cloud project: create it,
|
||||
connect its data source, bind this directory, and push — the push is what
|
||||
deploys the models.
|
||||
|
||||
```bash
|
||||
wren cloud create --org 42 --type BIG_QUERY --connection-info-file ./conn.json
|
||||
wren cloud create --org 42 --type POSTGRES --connection-info '{"host":"db","port":"5432","user":"u","password":"p","database":"d"}' --test-connection
|
||||
```
|
||||
|
||||
The organization key comes from `--org-key`, the `WREN_CLOUD_ORG_KEY`
|
||||
environment variable, or a prompt. It is used only to create the project and
|
||||
mint that project's own key, and is **never** written to disk.
|
||||
|
||||
`--type` is case-insensitive, and the names are the API's own — note the
|
||||
underscore in `BIG_QUERY`. `--connection-info` is passed through unchanged, so
|
||||
its fields are the API's too, and they differ per data source.
|
||||
|
||||
**It is not the shape `wren profile` uses, and nothing converts between them.**
|
||||
The API wants camelCase keys, and BigQuery `credentials` as the service-account
|
||||
object; a profile has snake_case keys and `credentials` as a base64 string.
|
||||
Passing a profile export is rejected.
|
||||
|
||||
**For the accepted types and a worked `connectionInfo` example for each, see
|
||||
[Create a project → Database connectionInfo examples](https://wrenai.readme.io/reference/post_projects#database-connectioninfo-examples).**
|
||||
That page is the source of truth; this CLI does not keep its own copy of the
|
||||
list, so a data source added there works here without a CLI release.
|
||||
|
||||
Requirements, all checked **before** anything is created, so a refusal leaves no
|
||||
half-made project behind:
|
||||
|
||||
- The directory is a Wren project (`wren_project.yml`) whose YAML compiles. To
|
||||
start a brand-new project instead, create it in the Wren Cloud web UI.
|
||||
- `--type` and a connection info are both given. A project without a data source
|
||||
is reported as still needing setup, and this CLI cannot attach one afterwards.
|
||||
- The directory is not already bound, does not sit inside another git
|
||||
repository, and git has an identity configured.
|
||||
|
||||
Models travel by git, not by the API: the manifest is built to validate the
|
||||
project and then discarded, and the push fires the repository's deploy hook.
|
||||
|
||||
### `wren cloud link`
|
||||
|
||||
Bind a directory to a project you have already run `auth add` for. Use this to
|
||||
clone a project onto a second machine, or to re-bind after `unlink`.
|
||||
|
||||
```bash
|
||||
wren cloud link # current directory
|
||||
wren cloud link ./my-project --project 1234
|
||||
```
|
||||
|
||||
Safe to re-run if a previous attempt failed partway. If the directory is
|
||||
already fully linked it says so and does not merge again — `git pull` is how you
|
||||
get updates.
|
||||
|
||||
Names the local branch after the remote's default branch, so plain `git push`
|
||||
works afterwards. Refuses to merge one project's history into another.
|
||||
|
||||
### `wren cloud unlink`
|
||||
|
||||
Remove the `origin` remote. That is the entire unbind — no server call, and the
|
||||
project is untouched.
|
||||
|
||||
```bash
|
||||
wren cloud unlink
|
||||
wren cloud unlink ./my-project --forget-key --yes
|
||||
```
|
||||
|
||||
Your stored key is kept by default, since another directory may still be bound
|
||||
to the same project. `--forget-key` drops it too, and removes that host's
|
||||
credential-helper entry — but only once no stored login uses that host, because
|
||||
the entry is shared by every project on it.
|
||||
|
||||
### `wren cloud auth list`
|
||||
|
||||
Show what is stored, without showing the keys.
|
||||
|
||||
```bash
|
||||
wren cloud auth list
|
||||
```
|
||||
|
||||
```text
|
||||
PROJECT API HOST (--host) GIT HOST (git talks to) REPO
|
||||
1234 https://cloud.getwren.ai https://cloud.getwren.ai org/42/1234/shared-data.git
|
||||
```
|
||||
|
||||
Both host columns are printed because they mean different things and can
|
||||
differ. **`--host` on the other commands is the API host** — the value you gave
|
||||
`auth add`. The credential file is keyed by the *git* host instead, because
|
||||
that is all git hands the credential helper, so reading the file to find a
|
||||
value for `--host` gives you the wrong one. This command is the answer to that.
|
||||
|
||||
No flag prints a key.
|
||||
|
||||
### `wren cloud auth remove`
|
||||
|
||||
Drop a stored credential. Touches no directory.
|
||||
|
||||
```bash
|
||||
wren cloud auth remove --project 1234 --yes
|
||||
```
|
||||
|
||||
### `wren cloud git-credential`
|
||||
|
||||
The credential helper `auth add` wires into git. Git invokes it; you do not.
|
||||
|
||||
Reference in New Issue
Block a user