diff --git a/core/wren/.claude/CLAUDE.md b/core/wren/.claude/CLAUDE.md index dd924e217e..397bd323d4 100644 --- a/core/wren/.claude/CLAUDE.md +++ b/core/wren/.claude/CLAUDE.md @@ -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 diff --git a/core/wren/src/wren/cli.py b/core/wren/src/wren/cli.py index f29c516b8a..e09983567a 100644 --- a/core/wren/src/wren/cli.py +++ b/core/wren/src/wren/cli.py @@ -626,6 +626,10 @@ def version(): app.add_typer(serve_app) +from wren.cloud_cli import cloud_app # noqa: PLC0415, E402 + +app.add_typer(cloud_app) + if __name__ == "__main__": app() diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py new file mode 100644 index 0000000000..00e114f2e6 --- /dev/null +++ b/core/wren/src/wren/cloud.py @@ -0,0 +1,1805 @@ +"""`wren cloud` — connect a local directory to a Wren Cloud project's git remote. + +Two credentials, two lifetimes: + +- The **project API key** is the durable credential. It is prompted for once + by ``auth add``, stored locally (0600, keyed by git host + project id), and + never leaves this machine except in the ``Authorization`` header of the + git-token request below. +- The **git token** is a short-TTL JWT minted from the API key on every git + operation by the credential helper (``get``). It is never written to disk + and never reused across operations — each ``get`` call mints a fresh one, + which is also what makes an expired token a non-event: nothing on this + machine ever holds one long enough to present it after it has expired. + +``auth add`` writes a URL-scoped credential helper entry (plus +``useHttpPath``) into the user's *global* git config, not local config — +when the credential is added there is no clone yet, so there is no local +config to write into. Because the helper resolves which project's token to +mint from the path git hands it (``useHttpPath``), no local-directory-to- +project binding is stored anywhere; the binding is the git remote itself, +which git already tracks. + +Do not reuse ``context.convert_mdl_to_project()`` from this module's callers +— it never reads the manifest's ``cubes``, so anything built on it silently +drops them. ``link`` acquires files via git, not via the manifest, so it +should never need that path at all. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import tempfile +import time +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +_WREN_HOME = Path(os.environ.get("WREN_HOME", Path.home() / ".wren")) +_CLOUD_FILE = _WREN_HOME / "cloud.yml" + +# The one definition of the helper: the executable git will resolve from PATH, +# and the sub-command it must be able to serve. The string written into git +# config and the string the pre-flight check probes are both derived from +# these, so a change to one cannot silently leave the other behind — the check +# would then be verifying a command git never runs. +HELPER_EXECUTABLE = "wren" +HELPER_SUBCOMMAND = ("cloud", "git-credential") +HELPER_COMMAND = "!" + " ".join((HELPER_EXECUTABLE, *HELPER_SUBCOMMAND)) + +# How long to wait for the probe below. Generous: it pays one interpreter +# start-up, and a slow machine must not turn a working install into a refusal. +_HELPER_PROBE_TIMEOUT_S = 30.0 + +# Matches both the API's own `repo` field ("org/{org}/{project}/name.git") +# and the `path=` field git hands the credential helper, which carries the +# git-server's own routing prefix ("git/org/{org}/{project}/name.git"). +_REPO_PATH_RE = re.compile( + r"^(?:git/)?org/(?P[^/]+)/(?P[^/]+)/(?P[^/]+\.git)$" +) + +_GIT_TOKEN_PATH_TMPL = "/api/v2/projects/{project_id}/git-token" +_PROJECTS_PATH = "/api/v1/projects" +_PROJECT_KEYS_PATH_TMPL = "/api/v1/projects/{project_id}/keys" + + +class CloudError(Exception): + """Base class for user-facing `wren cloud` errors.""" + + +class InvalidApiKeyError(CloudError): + """The API key was rejected for this host + project.""" + + +class NestedRepoError(CloudError): + """The target directory sits inside a foreign git repository.""" + + def __init__(self, target: Path, found_root: Path): + self.target = target + self.found_root = found_root + super().__init__( + f"{target} is inside an existing git repository rooted at " + f"{found_root}.\n" + "Connecting it to Wren Cloud would push that repository's own " + "files and history into the project's git repository.\n" + f"Move the project to its own directory (outside {found_root}) " + "and run `wren cloud link` again." + ) + + +class GitCommandError(CloudError): + """A shelled-out `git` command failed unexpectedly.""" + + +class CloudApiError(CloudError): + """A `CloudError` that also carries the failed response's HTTP status + and, when the body had one, the server's machine-readable `code`. + + Lets a caller branch on *why* a call failed without parsing prose out + of the message. `code` is `None` whenever the body was missing, + unparseable, not a JSON object, or had no string `code` field — a + caller checking `exc.code == "..."` degrades safely to "unrecognized" + in every one of those cases rather than raising or matching by luck. + """ + + def __init__(self, message: str, *, status_code: int, code: str | None = None): + super().__init__(message) + self.status_code = status_code + self.code = code + + +@dataclass +class GitToken: + repo: str + token: str + expires_in: int + expires_at: str + + +# ── HTTP: mint a short-TTL git token from the durable API key ────────────── + + +def _parse_retry_after(value: str | None, *, default: float = 1.0) -> float: + if not value: + return default + try: + return max(float(value), 0.0) + except ValueError: + return default + + +def _parse_error_code(resp) -> str | None: + """Best-effort extraction of the server's `code` field from an error body. + + Returns `None` on anything other than the expected shape — an + unparseable body, a body that isn't a JSON object, or a missing or + non-string `code` — rather than raising, so a caller can always fall + back to today's generic error instead of crashing on a response it + doesn't recognize. + """ + try: + data = resp.json() + except ValueError: + return None + if not isinstance(data, dict): + return None + code = data.get("code") + return code if isinstance(code, str) else None + + +def _json_object(resp, *, what: str) -> dict: + """Parse a successful response body as a JSON object, or raise CloudError. + + The error paths already tolerate a body that is not what they expect + (`_parse_error_code` returns None rather than raising); the success paths + did not, so a 200 carrying an HTML error page — which an ingress or proxy + in front of the API can produce — surfaced as a `ValueError` or `KeyError` + traceback instead of a message, bypassing both the CLI's handler and the + credential helper's. + """ + try: + data = resp.json() + except ValueError as exc: + raise CloudError( + f"{what} returned a success status with a body that is not JSON: " + f"{resp.text[:200]!r}" + ) from exc + if not isinstance(data, dict): + raise CloudError(f"{what} returned {type(data).__name__}, expected an object.") + return data + + +def _required(data: dict, key: str, *, what: str): + value = data.get(key) + if value is None: + # Names the keys, never the values: this runs on the git-token + # response, so a body carrying `token` but not `repo` would otherwise + # put a live credential into an error message — and errors reach CI + # logs and pasted bug reports. + present = ", ".join(sorted(data)) or "none" + raise CloudError( + f"{what} response is missing `{key}` (keys present: {present})." + ) + return value + + +def mint_git_token( + api_host: str, + project_id: str, + api_key: str, + *, + timeout: float = 15.0, + max_attempts: int = 4, +) -> GitToken: + """Mint a fresh git token for `project_id` from the durable API key. + + One call both validates the key (200 vs 401/403) and returns the full + repo path, so the caller never needs to know its org id separately. + + On 429, backs off using the response's `Retry-After` header rather than + a hardcoded guess — the endpoint's rate limiter is per-process, so the + ceiling a client actually observes varies with deployment topology. + """ + import requests # noqa: PLC0415 + + url = f"{api_host.rstrip('/')}{_GIT_TOKEN_PATH_TMPL.format(project_id=project_id)}" + headers = {"Authorization": f"Bearer {api_key}"} + + attempt = 0 + while True: + attempt += 1 + try: + resp = requests.post(url, headers=headers, timeout=timeout) + except requests.RequestException as exc: + raise CloudError(f"Could not reach {api_host}: {exc}") from exc + + if resp.status_code == 200: + what = f"Minting a git token for project {project_id}" + data = _json_object(resp, what=what) + return GitToken( + repo=_required(data, "repo", what=what), + token=_required(data, "token", what=what), + expires_in=data.get("expiresIn", 0), + expires_at=data.get("expiresAt", ""), + ) + if resp.status_code in (401, 403): + raise InvalidApiKeyError( + f"This key is not valid for project {project_id} on {api_host}." + ) + if resp.status_code == 429 and attempt < max_attempts: + time.sleep(_parse_retry_after(resp.headers.get("Retry-After"))) + continue + raise CloudApiError( + f"Wren Cloud API returned {resp.status_code} minting a git token " + f"for project {project_id} on {api_host}: {resp.text[:300]}", + status_code=resp.status_code, + code=_parse_error_code(resp), + ) + + +# ── Local credential storage: ~/.wren/cloud.yml, 0600, keyed by host+project + + +def _load_store() -> dict: + if not _CLOUD_FILE.exists(): + return {"credentials": {}} + import yaml # noqa: PLC0415 + + try: + data = yaml.safe_load(_CLOUD_FILE.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise CloudError( + f"{_CLOUD_FILE} is not valid YAML: {exc}\n" + f"Fix or remove {_CLOUD_FILE} and run `wren cloud auth add` again." + ) from exc + if data is None: + return {"credentials": {}} + if not isinstance(data, dict) or not isinstance(data.get("credentials", {}), dict): + raise CloudError(f"{_CLOUD_FILE} must contain a 'credentials' mapping.") + data.setdefault("credentials", {}) + return data + + +def _save_store(data: dict) -> None: + import yaml # noqa: PLC0415 + + _WREN_HOME.mkdir(parents=True, exist_ok=True) + payload = yaml.dump( + data, default_flow_style=False, sort_keys=False, allow_unicode=True + ) + fd, tmp_path = tempfile.mkstemp(dir=_WREN_HOME, suffix=".yml.tmp") + try: + os.chmod(tmp_path, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(payload) + os.replace(tmp_path, _CLOUD_FILE) + except Exception: + os.unlink(tmp_path) + raise + os.chmod(_CLOUD_FILE, 0o600) + + +def store_login( + *, + git_host: str, + api_host: str, + project_id: str, + org_id: str, + repo: str, + api_key: str, +) -> None: + """Persist the API key and its metadata, keyed by git host + project id. + + Keyed by `git_host` (not `api_host`) because that is what git itself + hands the credential helper at fetch time (`protocol` + `host`); the + helper has no other way to look this entry back up. + """ + data = _load_store() + creds = data["credentials"] + creds.setdefault(git_host, {})[str(project_id)] = { + "api_host": api_host, + "org_id": str(org_id), + "repo": repo, + "api_key": api_key, + } + _save_store(data) + + +def get_login(git_host: str, project_id: str) -> dict | None: + data = _load_store() + return data["credentials"].get(git_host, {}).get(str(project_id)) + + +def list_logins() -> list[tuple[str, str, dict]]: + """Return `[(git_host, project_id, entry), ...]` for every stored login.""" + data = _load_store() + out: list[tuple[str, str, dict]] = [] + for git_host, projects in data["credentials"].items(): + for project_id, entry in projects.items(): + out.append((git_host, project_id, entry)) + return out + + +def remove_login(git_host: str, project_id: str) -> bool: + """Drop one stored login. Returns False when there was nothing to drop. + + Coerces `project_id` with `str()` to match how `store_login` writes it — + an int-ish id would otherwise never match its own entry. + + Prunes the `git_host` key once its last project is gone. Without that, + `list_logins` would keep skipping an empty mapping that no longer + corresponds to anything, and "are there any logins left on this host?" + — which is what decides whether the host's credential-helper section may + be removed — would answer wrongly. + """ + data = _load_store() + projects = data["credentials"].get(git_host) + if not projects or str(project_id) not in projects: + return False + del projects[str(project_id)] + if not projects: + del data["credentials"][git_host] + _save_store(data) + return True + + +# ── Parsing the project out of a git path ─────────────────────────────────── + + +def normalize_host(host: str) -> str: + """Give a bare hostname a scheme, and drop a trailing slash. + + A host is a hostname in ordinary speech, so `--host cloud.getwren.ai` is + what people type. Without a scheme it reaches `requests` as a relative + URL and comes back as "Invalid URL ... No scheme supplied", which reads + like a bug in the tool rather than a correctable typo. Assume https: + plain http against a credential-bearing endpoint is not a default worth + offering, and `--host http://localhost:3000` still works because a scheme + that is already there is left alone. + """ + host = host.strip().rstrip("/") + if "://" not in host: + host = f"https://{host}" + return host + + +def parse_repo_path(path: str) -> tuple[str, str, str]: + """Parse a repo path into `(org_id, project_id, repo_name)`. + + Accepts both the API's own `repo` field (`org/{org}/{project}/name.git`) + and the `path=` field git hands the credential helper when + `useHttpPath` is set (`git/org/{org}/{project}/name.git`). + """ + match = _REPO_PATH_RE.match(path.strip("/")) + if not match: + raise CloudError(f"Could not parse a Wren Cloud project from path: {path!r}") + return match.group("org"), match.group("project"), match.group("repo") + + +# ── Writing the git config `login` is responsible for ─────────────────────── + + +def _git_config_set_global(key: str, value: str) -> None: + run_git(["config", "--global", "--replace-all", key, value]) + + +def _git_config_add_global(key: str, value: str) -> None: + run_git(["config", "--global", "--add", key, value]) + + +def check_helper_command_serviceable() -> None: + """Refuse to write a helper git would not be able to run. + + `configure_git_credential_helper` writes `HELPER_COMMAND`, whose `!` + prefix makes git run it through a shell — so `wren` is resolved from + `PATH` at *git*-invocation time, not now, and not pinned to the + interpreter running this command. A `wren` on `PATH` that predates the + `cloud` command group therefore breaks **every** git operation against + that host: clone, fetch and push alike, since the entry lives in global + config. The error the user sees comes from git, talks about credentials, + and names neither `wren` nor a version, so there is no path from the + symptom back to the cause — and `login` would have reported success, + because when the credential is added the CLI *is* the capable one and the breakage only + appears later, in a different tool. + + Probing the resolved executable turns that into an immediate refusal that + names the real cause, before anything is written. + + What this cannot cover: `PATH` changing *after* login. Once a different + `wren` is what git runs, nothing on this side is in the path of the + failure — that executable's output is not ours to shape. That residual is + why the helper's own failures identify themselves (`helper_failure_note`). + """ + resolved = shutil.which(HELPER_EXECUTABLE) + if resolved is None: + raise CloudError( + f"No `{HELPER_EXECUTABLE}` found on PATH, so the git credential " + f"helper this command is about to configure " + f"(`{HELPER_COMMAND.lstrip('!')}`) would not be runnable: git " + f"resolves it from PATH every time it authenticates, not from " + f"the interpreter running this command. Install wren so that " + f"`{HELPER_EXECUTABLE}` is on PATH (e.g. `uv tool install " + f"wrenai` or `pipx install wrenai`), then run this again." + ) + + probe = [resolved, *HELPER_SUBCOMMAND, "--help"] + try: + completed = subprocess.run( # noqa: S603 + probe, + capture_output=True, + text=True, + timeout=_HELPER_PROBE_TIMEOUT_S, + ) + except subprocess.TimeoutExpired as exc: + raise CloudError( + f"`{' '.join(probe)}` did not respond within " + f"{_HELPER_PROBE_TIMEOUT_S:.0f}s, so whether the `" + f"{HELPER_EXECUTABLE}` on PATH ({resolved}) can serve git's " + f"credential requests could not be established. Nothing was " + f"written. Try running that command by hand to see what it does." + ) from exc + except OSError as exc: + raise CloudError( + f"Could not run `{' '.join(probe)}` ({exc}), so whether the " + f"`{HELPER_EXECUTABLE}` on PATH ({resolved}) can serve git's " + f"credential requests could not be established. Nothing was " + f"written." + ) from exc + + if completed.returncode != 0: + raise CloudError( + f"The `{HELPER_EXECUTABLE}` on PATH ({resolved}) cannot serve " + f"`{' '.join((HELPER_EXECUTABLE, *HELPER_SUBCOMMAND))}` — it " + f"exited {completed.returncode} when asked. That is the exact " + f"executable git would run to authenticate, every time, for " + f"every clone / fetch / push against this host, so configuring " + f"it now would break them all with an error that comes from git " + f"and mentions neither wren nor a version.\n" + f"Nothing was written. Most likely this PATH entry is an older " + f"wren without the `cloud` commands: upgrade it (e.g. `uv tool " + f"install --force wrenai`), or put the wren you are running now " + f"first on PATH, then run this again." + ) + + +def configure_git_credential_helper(git_host: str) -> None: + """Write a URL-scoped credential-helper entry into the global git config. + + Three settings are written into the `credential.` section, and + the order matters: + + 1. An **empty** `helper =` value, written first. + 2. Our own helper, `!wren cloud git-credential`. + 3. `useHttpPath = true`. + + The empty entry (1) exists because of a helper the user did not put + there: macOS's git ships `credential.helper = osxkeychain` in the + Command Line Tools' own gitconfig file (visible via `git config + --show-origin --get-all credential.helper`, but invisible to `git config + --system` — it is a *different* file git still reads before global + config). Because system config is consulted before global config, that + helper would otherwise answer `get` before ours does, handing git a + stale cached credential instead of a freshly minted one. Worse, because + this design deliberately never caches a token, git's `store` call fires + on every single operation, and *that* helper caches the ephemeral token + into the keychain anyway — the exact thing not-caching exists to avoid. + An empty `helper` value resets the helper chain accumulated so far, + scoped to this section, so it clears only the chain for this specific + host; a clone of some other host is unaffected and still gets its + keychain helper. + + `useHttpPath` (3) makes git additionally hand the helper the request + path (`git/org/{org}/{project}/name.git`); without it git only passes + `protocol` + `host`, and the helper has no way to know which project's + token to mint. This is why `login` needs no separate per-directory + state — the binding lives in the git remote, which git already manages. + + Our helper value (2) is `HELPER_COMMAND`, and must be `!`-prefixed: git + only runs a helper string through a shell (and appends the + `get`/`store`/`erase` argument correctly) when it starts with `!`. A bare + multi-word value like `wren cloud git-credential` would instead have + `git-credential-` prepended to just its first word, which is not what we + want. That `!` is also what makes `wren` PATH-resolved at git-invocation + time, which is what `check_helper_command_serviceable` exists to guard. + + Re-running this (e.g. a second `wren cloud auth add`) stays idempotent: + `--replace-all` first collapses the `helper` key back down to the single + empty value before `--add` appends our helper again, so the section + never accumulates duplicate entries across repeated logins. + """ + section = f"credential.{git_host}" + _git_config_set_global(f"{section}.helper", "") + _git_config_add_global(f"{section}.helper", HELPER_COMMAND) + _git_config_set_global(f"{section}.useHttpPath", "true") + + +def remove_git_credential_helper(git_host: str) -> None: + """Undo `configure_git_credential_helper` for one host. + + Removes the whole section rather than unsetting our own `helper` line. + That function writes *three* things, and the first is an empty `helper` + whose only job is to reset the inherited chain for this host. Removing + just our entry would leave that reset in place — a section that still + suppresses the user's own helper (on macOS, the `osxkeychain` one git + ships in the Command Line Tools' gitconfig) with nothing put back in its + place, so git would have no credential source for the host at all. + + Best-effort: `--remove-section` exits non-zero when the section is not + there, which is the normal state for a host that was never logged in to. + """ + run_git( + ["config", "--global", "--remove-section", f"credential.{git_host}"], + check=False, + ) + + +def login( + *, + host: str, + project_id: str, + api_key: str, + git_host: str | None = None, +) -> GitToken: + """Validate `api_key` against `project_id`, store it, and configure git. + + `git_host` is the host git will actually talk to for this project's + repo. It defaults to `host`, which is correct whenever the git remote + and the Wren Cloud API share one host (the shipped-product shape, where + a single ingress routes both under one domain). Pass it explicitly only + when they differ — e.g. a local/self-hosted setup with no such unified + ingress in front, where the API and the git server sit on separate + hosts or ports. + + The credential helper is checked for serviceability first, before the key + is validated or anything is stored: a login that cannot produce working + git authentication has not achieved what it claims, and refusing before + any write leaves nothing behind to undo. + """ + api_host = host.rstrip("/") + resolved_git_host = (git_host or host).rstrip("/") + + check_helper_command_serviceable() + + token = mint_git_token(api_host, project_id, api_key) + org_id, parsed_project_id, _repo_name = parse_repo_path(token.repo) + + store_login( + git_host=resolved_git_host, + api_host=api_host, + project_id=parsed_project_id, + org_id=org_id, + repo=token.repo, + api_key=api_key, + ) + configure_git_credential_helper(resolved_git_host) + return token + + +def store_key_pending_repo( + *, git_host: str, api_host: str, project_id: str, org_id: str, api_key: str +) -> None: + """Store a key before the repo path is known. + + `create` mints a project key, then validates it by minting a git token — + and only that second call returns the repo path a complete record needs. + When it fails, the key is still the thing worth keeping: without it the + project is unreachable and the user has no way to get it back. Storing it + with an empty `repo` keeps it, and `resolve_repo` fills the gap on the + next use, so nothing has to guess the server's repo naming. + + The credential helper never reads `repo` — it derives the repo from the + path git hands it — so an entry stored this way authenticates correctly + the moment it exists. + """ + store_login( + git_host=git_host, + api_host=api_host, + project_id=str(project_id), + org_id=str(org_id), + repo="", + api_key=api_key, + ) + + +def resolve_repo(git_host: str, project_id: str, entry: dict) -> str: + """Return the entry's repo path, discovering it if it was never stored. + + Complements `store_key_pending_repo`: the repo is recoverable from the + API at any time, by the same call the credential helper makes on every + git operation, so a record missing it is incomplete rather than broken. + """ + repo = entry.get("repo") or "" + if repo: + return repo + token = mint_git_token(entry["api_host"], project_id, entry["api_key"]) + store_login( + git_host=git_host, + api_host=entry["api_host"], + project_id=project_id, + org_id=entry["org_id"], + repo=token.repo, + api_key=entry["api_key"], + ) + return token.repo + + +# ── The credential helper itself: get / store / erase ─────────────────────── + + +def read_credential_input(stream) -> dict[str, str]: + """Parse the `key=value` lines git feeds a credential helper on stdin.""" + data: dict[str, str] = {} + for line in stream: + line = line.rstrip("\n") + if not line: + break + if "=" not in line: + continue + key, _, value = line.partition("=") + data[key] = value + return data + + +def format_credential_output(username: str, password: str) -> str: + return f"username={username}\npassword={password}\n" + + +def git_credential_get(input_data: dict[str, str]) -> str: + """Handle git's `get` call: mint a fresh token and return it. + + Always mints fresh — never caches — so a stale token is never handed to + git in the first place. Combined with git's own credential retry (erase + then get again on a 401/403 from the remote), this is what keeps an + expired token from ever surfacing as a user-visible failure: there is + no code path where this helper hands out a token old enough to have + expired. + """ + protocol = input_data.get("protocol", "") + host = input_data.get("host", "") + path = input_data.get("path", "") + if not path: + raise CloudError( + "git did not send a project path — is `useHttpPath` set for this " + "host? Run `wren cloud auth add` again to fix the git configuration." + ) + org_id, project_id, _repo_name = parse_repo_path(path) + git_host = f"{protocol}://{host}" if protocol and host else host + + entry = get_login(git_host, project_id) + if entry is None or entry.get("org_id") != org_id: + raise CloudError( + f"No stored Wren Cloud login for project {project_id} on " + f"{git_host}. Run `wren cloud auth add` first." + ) + + token = mint_git_token(entry["api_host"], project_id, entry["api_key"]) + return format_credential_output("x-access-token", token.token) + + +def helper_failure_note(message: str) -> str: + """Label a credential-helper failure with what produced it. + + git prints a helper's stderr in the middle of its own output and then + fails with a credentials message of its own. Unlabelled, our line reads as + if git produced it, and the user is left with no way to tell which of + their tools is at fault — the same illegibility as the version-skew case + `check_helper_command_serviceable` guards, arriving by a different route. + + So name the tool, the build, and the executable that actually ran. That + last one is the load-bearing part: git resolves `wren` from PATH at + invocation time, so the wren serving this request is not necessarily the + one the user thinks they installed, and printing its path is what makes + that visible at the only moment it matters. + """ + import sys # noqa: PLC0415 + + from wren import __version__ # noqa: PLC0415 + + ran_as = sys.argv[0] or sys.executable + return ( + f"wren cloud git-credential (wrenai {__version__}, {ran_as}): " + f"{message}\n" + "This is the git credential helper that `wren cloud auth add` " + "configured for this host. git will report a credential failure of " + "its own next; the cause is the line above." + ) + + +def git_credential_store(input_data: dict[str, str]) -> None: # noqa: ARG001 + """Handle git's `store` call: a deliberate no-op. + + We never cache the git token, so there is nothing to store. `store` + means "that credential worked"; since we hand out a fresh token per + operation, there is no cache entry for this to confirm. + """ + return None + + +def git_credential_erase(input_data: dict[str, str]) -> None: # noqa: ARG001 + """Handle git's `erase` call: drop a cached token, never the API key. + + `erase` means "that credential failed" (e.g. git received a 401/403 and + is about to ask for a fresh one) — it is not "log out". We never cache + a token, so this is also a no-op, but it is kept as an explicit, + separate function so that if token caching is ever added, only this + function needs to change, and the API key stays untouched regardless. + """ + return None + + +# ── git subprocess plumbing ───────────────────────────────────────────────── + + +def run_git( + args: list[str], *, cwd: Path | None = None, check: bool = True +) -> subprocess.CompletedProcess: + """Shell out to the system `git` — never a Python git library. + + Keeps the CLI's git operations on the exact same path a user's own + manual `git` commands take, so there is only one implementation of git + behavior to keep correct. + """ + if cwd is not None and not Path(cwd).is_dir(): + # `subprocess` raises FileNotFoundError for a missing cwd, which the + # CLI does not catch — so `wren cloud link /no/such/dir` printed a + # traceback where every other refusal prints a message. Reported as a + # CloudError so it reads like the rest of them. + raise CloudError(f"{cwd} does not exist.") + result = subprocess.run( + ["git", *args], + cwd=cwd, + capture_output=True, + text=True, + ) + if check and result.returncode != 0: + raise GitCommandError( + f"git {' '.join(args)} failed:\n{(result.stderr or result.stdout).strip()}" + ) + return result + + +# ── Nested-repository detection ───────────────────────────────────────────── + + +def find_git_root(path: Path) -> Path | None: + """Walk up from `path` looking for a `.git` entry. + + Returns the first directory (possibly `path` itself) that has one, or + `None` if none is found. + """ + current = path.resolve() + for candidate in (current, *current.parents): + if (candidate / ".git").exists(): + return candidate + return None + + +def check_not_nested(target: Path) -> None: + """Refuse when `target` sits inside a foreign git repository. + + A `.git` found AT `target` itself is fine — an already-tracked local + project, or one `link` is about to create. A `.git` found in an + ANCESTOR is the trap this exists for: continuing would push that + repository's entire contents and history into the project's git + repository. This is the most important error path in `link` — missed, + it is silent data leakage, not a failure anyone would notice. + """ + target = target.resolve() + found = find_git_root(target) + if found is not None and found != target: + raise NestedRepoError(target, found) + + +# ── Reading the binding back out of a directory ───────────────────────────── +# +# The binding is the git remote and nothing else is stored (see the module +# docstring), so every question of the form "what is this directory bound +# to?" has to be answered by asking git, at the time it is asked. These are +# the inverse of the `remote_url` construction in `link`. + + +def current_remote_url(target: Path) -> str | None: + """Return `target`'s `origin` URL, or `None` when it has no `origin`.""" + result = run_git(["remote", "get-url", "origin"], cwd=target, check=False) + if result.returncode != 0: + return None + url = result.stdout.strip() + return url or None + + +def binding_from_remote_url(url: str) -> tuple[str, str, str, str] | None: + """Split a remote URL into `(git_host, org_id, project_id, repo_name)`. + + Returns `None` when the URL is not a Wren Cloud repo — a directory whose + `origin` points at GitHub is bound to something, just not to us, and + callers need to tell those two cases apart to say anything useful. + + Split at the `/git/` routing prefix rather than by URL structure, so + this is a true inverse of the `f"{git_host}/git/{repo}"` that `link` + writes — for whatever `git_host` was passed, including a scheme-less + one. `git_host` therefore comes back as exactly the string `credentials` + is keyed by, which is what makes a stored login findable from a + directory. + """ + marker = "/git/" + index = url.rfind(marker) + if index == -1: + return None + git_host = url[:index] + if not git_host: + return None + try: + org_id, project_id, repo_name = parse_repo_path(url[index + 1 :]) + except CloudError: + return None + return git_host, org_id, project_id, repo_name + + +def check_git_identity_usable(target: Path) -> None: + """Refuse when git could not record a commit in `target`. + + Binding a directory that already has files makes git commits — one for + the existing files, then the reconciling merge — and git refuses to + commit without `user.name`/`user.email`, with a message about + auto-detecting an email address that says nothing about Wren. + + Checked up front because of *where* it would otherwise fail: for + `create`, the merge happens after the project and its key exist, so the + raw git failure leaves a real project that nothing references. Observed + exactly that way — a machine with no git identity produced an orphaned + project. + + An empty target is exempt: that path is a plain clone, which records no + commit and therefore needs no identity. + """ + if not target.exists() or not any(target.iterdir()): + return + probe = run_git(["var", "GIT_COMMITTER_IDENT"], cwd=target, check=False) + if probe.returncode == 0: + return + raise CloudError( + f"git has no identity configured, so it cannot record the commit " + f"binding {target} needs.\n" + "Set one and try again:\n" + ' git config --global user.name "Your Name"\n' + " git config --global user.email you@example.com" + ) + + +def check_not_already_bound(target: Path) -> None: + """Refuse a directory that is still bound to another project. + + `create` is always making a *new* project, so an existing `origin` is + wrong by construction — the remote is the binding, and `link` will not + silently repoint it. Checked here rather than left to `link`, and + specifically *before* the server is touched: `link` runs after the + project and its key already exist, so a refusal raised there leaves a + real project that nothing references. + + A history that *came from* a project is deliberately allowed. Once + `origin` is gone the directory is unbound, and "unlink, then create" is + how a project is duplicated: the local content becomes the new project's + starting point. The new remote holds nothing but its seed commit, so + there is no other project's content for the merge to mix in — which is + why `link` takes `remote_is_fresh` rather than refusing here. + """ + existing = current_remote_url(target) + if existing is not None: + bound = binding_from_remote_url(existing) + where = f"project {bound[2]}" if bound else existing + raise CloudError( + f"{target} is already bound to {where}, so a new project cannot " + "be created for it.\n" + f" origin: {existing}\n" + "Run `wren cloud unlink` here first — that also leaves the " + "directory ready to be duplicated into a new project. Nothing " + "has been created on the server." + ) + + +def _same_remote(a: str | None, b: str | None) -> bool: + """Whether two remote URLs address the same repo. + + Only normalises a trailing slash. Anything cleverer (case, default + ports, credentials in the URL) would risk calling two genuinely + different projects equal, and the safe direction here is to refuse. + """ + if a is None or b is None: + return False + return a.rstrip("/") == b.rstrip("/") + + +def head_has_seeded_hooks(target: Path) -> bool: + """Whether `target`'s HEAD already carries a project's seeded hook file. + + Project creation seeds `.hooks/deploy-modeling.yaml`, so a local history + containing it was acquired from *some* Wren Cloud project. A local + project that has only ever existed on this machine does not have one. + That is what makes this usable as "this directory already belongs to a + project" without storing any per-directory state. + """ + return ( + run_git( + ["cat-file", "-e", "HEAD:.hooks/deploy-modeling.yaml"], + cwd=target, + check=False, + ).returncode + == 0 + ) + + +# ── link: bind a directory to a cloud project, exactly once ───────────────── + + +class LinkOutcome(Enum): + """What `link` actually did, for the CLI to report accurately.""" + + LINKED = "linked" + """A clone or a reconciling merge was performed.""" + + ALREADY_LINKED = "already_linked" + """`target` was already bound to this project and had nothing new to + bring in. Still a success — the caller should point at `git pull` for + updates instead of implying a merge just happened.""" + + +def _default_branch(remote_url: str) -> str: + """Best-effort discovery of the remote's default branch.""" + result = run_git(["ls-remote", "--symref", remote_url, "HEAD"], check=False) + for line in result.stdout.splitlines(): + if line.startswith("ref:") and "HEAD" in line: + ref = line.split()[1] + if ref.startswith("refs/heads/"): + return ref[len("refs/heads/") :] + return "main" + + +def link( + target: Path, + *, + git_host: str, + api_host: str, # noqa: ARG001 — kept for symmetry with login(); not needed here + project_id: str, # noqa: ARG001 + org_id: str, # noqa: ARG001 + repo: str, + remote_is_fresh: bool = False, +) -> LinkOutcome: + """Bind `target` to `repo`, handling both shapes, and do the reconciling + merge at most once. + + - Fresh directory: a plain `git clone`. + - Existing local project (the main case): initialise in place — no + files are moved — add the remote, fetch, and merge with + `--allow-unrelated-histories`. Never `--force`, no exceptions. + - Already-linked directory with nothing new to bring in: reported as + `ALREADY_LINKED` rather than merged again — see the ancestor check + below. This is a one-time bind, not the update path; `git pull` is. + + Refuses up front when `target` sits inside another git repository. + + Re-running this after a previous attempt died mid-way (e.g. inside + `commit`, for lack of a git identity) still completes correctly — see + the "is HEAD born?" check below — because that failure mode is + indistinguishable from a fresh directory unless we ask git directly. + """ + target = target.resolve() + check_not_nested(target) + + remote_url = f"{git_host.rstrip('/')}/git/{repo}" + + is_repo_here = (target / ".git").exists() + has_files = target.exists() and any(target.iterdir()) + + if not is_repo_here and not has_files: + target.parent.mkdir(parents=True, exist_ok=True) + run_git(["clone", remote_url, str(target)]) + return LinkOutcome.LINKED + + if not is_repo_here: + run_git(["init"], cwd=target) + + # Whether the existing files still need a local commit is decided by + # asking git directly ("does HEAD resolve to a real commit?"), not by + # whether `.git` exists. `.git` existing only tells us *some* previous + # attempt got as far as `init` — if that attempt then failed inside + # `commit` (e.g. missing git identity) and the caller retries, `.git` + # is already there but HEAD is still unborn. Keying off `.git` alone + # would skip straight to the remote merge below against that unborn + # HEAD, silently turning the unrelated-history merge this command + # exists to do safely into a trivial fast-forward instead. + head_is_born = ( + run_git( + ["rev-parse", "--verify", "-q", "HEAD"], cwd=target, check=False + ).returncode + == 0 + ) + if not head_is_born: + # Give the existing files a local commit so the merge below has an + # actual history to reconcile against — otherwise an unborn HEAD + # would just fast-forward onto the remote instead of exercising the + # unrelated-history path this command exists to handle safely. + run_git(["add", "-A"], cwd=target) + run_git(["commit", "-m", "Existing local project", "--allow-empty"], cwd=target) + + # An `origin` that already exists is NOT assumed to be the right one. + # This function used to only ever *add* the remote, never check it, which + # meant a directory bound to project A could be handed project B's repo + # and silently stay on A — while the caller reported success for B. Via + # `create` that also left B orphaned server-side: created, keyed, and + # referenced by nothing. + added_origin = False + remotes = run_git(["remote"], cwd=target).stdout.split() + if "origin" in remotes: + existing = current_remote_url(target) + if not _same_remote(existing, remote_url): + bound = binding_from_remote_url(existing or "") + bound_desc = ( + f"project {bound[2]}" if bound else f"{existing or 'an unknown remote'}" + ) + raise CloudError( + f"{target} already has an `origin` pointing at {bound_desc}, " + f"so it cannot be bound to this one.\n" + f" origin: {existing}\n" + f" requested: {remote_url}\n" + "The git remote *is* the binding, so binding elsewhere means " + "replacing it. Run `wren cloud unlink` here first if you meant " + "to move this directory to another project — and note that " + "switching projects wants a clean directory, because this " + "directory's history belongs to the project it came from." + ) + else: + run_git(["remote", "add", "origin", remote_url], cwd=target) + added_origin = True + + run_git(["fetch", "origin"], cwd=target) + + branch = _default_branch(remote_url) + # Before any of the comparisons below: they all speak in terms of + # `origin/` and the current branch, and a name mismatch makes + # every one of them describe a state the user cannot then act on. + try: + _align_branch_name(target, branch) + except CloudError: + if added_origin: + # Same rule as the foreign-history refusal below: a refusal must + # not leave the directory pointing at a project it was never + # bound to, or the next attempt refuses "already bound" for a + # bind that never happened. + run_git(["remote", "remove", "origin"], cwd=target, check=False) + raise + + # If `origin/` is already an ancestor of HEAD, merging it would + # add nothing: either a previous `link` already did the reconciling + # merge (HEAD contains it from there), or this directory was a plain + # clone to begin with. Either way there is nothing to reconcile, and + # re-merging would misrepresent a no-op as a fresh bind. This is the + # one case `link` treats as "already done" rather than "do it again" — + # the merge below still happens exactly once. + already_linked = ( + run_git( + ["merge-base", "--is-ancestor", f"origin/{branch}", "HEAD"], + cwd=target, + check=False, + ).returncode + == 0 + ) + if already_linked: + # Nothing to merge — but "already linked" must not mean "already + # working". The reachable case is a user who hit a conflict on the + # merge below, resolved it and committed as that error tells them + # to: `origin/` is then an ancestor of HEAD, yet upstream + # was never set, because the merge returned non-zero and this + # function raised before reaching `_set_upstream`. Reporting + # already-linked and pointing at `git pull` while `git pull` itself + # cannot run is the failure this whole design keeps trying to avoid. + # + # Only when absent: a user who deliberately points the branch + # somewhere else keeps their choice. No merge happens here either + # way, so this stays a bind, not the update path. + if not _has_upstream(target): + _set_upstream(target, branch) + return LinkOutcome.ALREADY_LINKED + + # Merging a *different* project's history into this one would combine two + # projects' content, and the next `git push` would publish the result. + # Refuse rather than offering a flag: no directory wants two projects at + # once, and the damage only becomes visible once it is published. + # + # "Not an ancestor" (above) is NOT sufficient to detect that: it is also + # false when the local clone is merely behind, or has diverged from, the + # *same* project's remote — which is the ordinary re-bind-after-unlink + # case and must keep working. Ask git whether the histories share any + # ancestor at all; `merge-base` without `--is-ancestor` fails exactly + # when they do not. + # + # `remote_is_fresh` exempts one case, and only one: the caller just created + # this project, so the remote holds nothing but its own seed commit. There + # is no other project's content for a merge to mix in, and the merge is + # how "duplicate this project into a new one" works — unlink, then create, + # and the local history becomes the new project's starting content. The + # refusal below is about protecting a remote that has content; a remote + # that has none does not need protecting. + unrelated = ( + run_git( + ["merge-base", f"origin/{branch}", "HEAD"], cwd=target, check=False + ).returncode + != 0 + ) + if unrelated and not remote_is_fresh and head_has_seeded_hooks(target): + if added_origin: + # Leave nothing behind: this refusal must not strand the target + # pointing at a project it was never bound to. + run_git(["remote", "remove", "origin"], cwd=target, check=False) + raise CloudError( + f"{target} already contains a Wren Cloud project's history — its " + "commits carry the `.hooks/deploy-modeling.yaml` that project " + "creation seeds — and it shares no history with the project you " + "are binding it to, so this would merge one project's content " + "into the other.\n" + "Bind a clean directory instead: either clone into a new one, or " + "remove this directory's `.git` if you no longer need its history. " + "Re-binding to the *same* project is fine, including when your " + "copy is behind — that does not reach this point." + ) + + merge = run_git( + [ + "merge", + "--allow-unrelated-histories", + f"origin/{branch}", + "-m", + "Merge Wren Cloud project history", + ], + cwd=target, + check=False, + ) + if merge.returncode != 0: + detail = (merge.stderr or merge.stdout).strip() + raise CloudError( + "The remote already has content the server created when the " + "project was made, and merging it with your local files hit a " + "conflict git could not resolve automatically.\n" + "This is a normal git merge conflict, not a Wren Cloud error: " + "resolve the conflict markers in the affected files, then " + "`git add` and `git commit` as usual. Do not pass `--force` on " + "the eventual push — that would delete content the server " + "created for this project.\n\n" + f"{detail}" + ) + + _set_upstream(target, branch) + return LinkOutcome.LINKED + + +def _has_upstream(target: Path) -> bool: + return ( + run_git( + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], + cwd=target, + check=False, + ).returncode + == 0 + ) + + +def _align_branch_name(target: Path, branch: str) -> None: + """Rename the local branch to the remote's default branch name. + + A plain `git clone` names the local branch after the remote's; `git init` + plus a merge does not, and it keeps whatever `init.defaultBranch` produced. + When those differ — a remote on `main`, a client whose git still defaults + to `master` — everything downstream looks like it worked and none of it + did: upstream points at `origin/main` while the branch is `master`, so + `git push origin HEAD` exits 0 having created a *second* remote branch + that the project's deploy hook never watches, and the plain `git push` + this design promises fails with "the upstream branch of your current + branch does not match the name of your current branch". + + Leaves a branch that already has an upstream alone: that is a deliberate + choice by the user, and the same rule the already-linked path follows. + """ + if _has_upstream(target): + return + + # HEAD is always born by here: `link` commits any existing files before + # calling this, precisely so the merge below has a history to reconcile. + current = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=target).stdout.strip() + if current == branch: + return + if current == "HEAD": + raise CloudError( + f"{target} has a detached HEAD, so there is no branch to bind. " + f"Check out a branch first — `git switch -c {branch}` — then run " + "`link` again." + ) + + rename = run_git(["branch", "-m", branch], cwd=target, check=False) + if rename.returncode != 0: + # The usual cause is a local branch of that name already existing. + # Continuing would leave the mismatch this function exists to remove, + # and the damage only shows up after a push that reports success. + raise CloudError( + f"{target} is on branch `{current}`, but this project's default " + f"branch is `{branch}`, and renaming failed:\n" + f"{(rename.stderr or rename.stdout).strip()}\n" + f"Plain `git push` cannot work while the two differ. Rename or " + f"remove the local `{branch}` branch, then run `link` again." + ) + + +def _set_upstream(target: Path, branch: str) -> None: + """Make the current branch track `origin/`. + + A plain `git clone` leaves the local branch tracking its remote + automatically; `git init` + `merge` does not. Without this, the first + `git push` after adopting an existing directory fails with "no upstream + branch" even though the merge itself succeeded. + """ + current_branch = run_git( + ["rev-parse", "--abbrev-ref", "HEAD"], cwd=target + ).stdout.strip() + run_git( + ["branch", f"--set-upstream-to=origin/{branch}", current_branch], + cwd=target, + check=False, + ) + + +# ── unlink / logout: undoing what link and login did ─────────────────────── + + +@dataclass +class UnlinkOutcome: + """What `unlink` actually removed, for the CLI to report accurately.""" + + remote_url: str + """The `origin` that was removed.""" + + git_host: str | None + """`None` when `origin` was not a Wren Cloud URL.""" + + project_id: str | None + """`None` when `origin` was not a Wren Cloud URL.""" + + key_forgotten: bool + """Whether a stored login was dropped (only ever with `forget_key`).""" + + helper_removed: bool + """Whether this host's credential-helper section was removed with it.""" + + +def unlink(target: Path, *, forget_key: bool = False) -> UnlinkOutcome: + """Unbind `target` from its project by removing its `origin`. + + The binding is the git remote, so removing it *is* the unbind — there is + no server call to make (the server never knew about the binding) and + nothing else to undo for the directory itself. + + `forget_key` additionally drops the stored login for that project, and + only then, only if no other stored login still uses the same git host, + removes that host's credential-helper section. That ordering is the + point: the helper entry is host-scoped, so removing it while another + project on the same host is still bound would break authentication for + that project too. + """ + target = target.resolve() + + remote_url = current_remote_url(target) + if remote_url is None: + raise CloudError( + f"{target} is not bound to a Wren Cloud project — it has no " + "`origin` remote. Nothing to unlink." + ) + + bound = binding_from_remote_url(remote_url) + git_host = bound[0] if bound else None + project_id = bound[2] if bound else None + + run_git(["remote", "remove", "origin"], cwd=target) + + key_forgotten = False + helper_removed = False + if forget_key and git_host is not None and project_id is not None: + key_forgotten = remove_login(git_host, project_id) + if not any(host == git_host for host, _pid, _entry in list_logins()): + remove_git_credential_helper(git_host) + helper_removed = True + + return UnlinkOutcome( + remote_url=remote_url, + git_host=git_host, + project_id=project_id, + key_forgotten=key_forgotten, + helper_removed=helper_removed, + ) + + +def logout(git_host: str, project_id: str) -> tuple[bool, bool]: + """Drop a stored login. Returns `(login_removed, helper_removed)`. + + Touches no directory: a bound working tree keeps its remote and simply + stops being able to authenticate, which is the honest outcome of + discarding the key it was authenticating with. + + This is deliberately not built on `git_credential_erase`. That function + drops only a cached token and leaves the API key alone on purpose — + `erase` means "that credential failed", not "log out" — and a test + pins that behaviour. + + Removes the host's credential-helper section only once this was its last + stored login, for the same host-scoping reason as `unlink`. + """ + login_removed = remove_login(git_host, project_id) + helper_removed = False + if not any(host == git_host for host, _pid, _entry in list_logins()): + remove_git_credential_helper(git_host) + helper_removed = True + return login_removed, helper_removed + + +# ── create: make a new project and bind it, in one step ───────────────────── +# +# `create` is the other half of `link`: both bind a directory to a cloud +# project, differing only in whether the project has to be made first. It +# ends in exactly the state `login` + `link` leave behind, by calling them — +# not by reimplementing what they do. +# +# The org API key (`osk-...`) this needs is org-wide authority, valid for +# every project in the org, not just the one being created here. It is used +# for exactly two calls below — creating the project and minting that +# project's own key — and is never written to disk. Only the project key +# (`sk-...`) that comes back from the mint is stored, exactly as `login` +# would store it. + + +@dataclass +class CreatedProject: + id: str + org_id: str + display_name: str + status: str + """`"succeeded"` or `"partial"` — the server's own project-creation + status. `"partial"` means the project (and, for AGENTIC projects, its + git repository) exists, but something about it — most often the data + connection or the initial MDL deploy — did not finish successfully. See + `errors` for what failed.""" + errors: list[dict] + """`[{"resource": ..., "message": ...}, ...]` — populated only when + `status == "partial"`.""" + + +def create_project( + api_host: str, + org_key: str, + *, + org_id: str, + display_name: str, + connection_type: str | None = None, + connection_info: dict | None = None, + test_connection: bool = False, + mdl: dict | None = None, + language: str | None = None, + timezone: str | None = None, + timeout: float = 60.0, +) -> CreatedProject: + """Create a new Wren Cloud project, requesting agent mode. + + Always sends `projectType: "AGENTIC"` — `create` exists specifically to + produce agent-mode projects; a CLASSIC project has no git-backed + repository at all, so there would be nothing for the caller to bind a + directory to. The response never echoes back which project type the + server actually granted, so this alone does not confirm the opt-in was + honored — that is confirmed later, the same way `login` confirms + anything about a project: by successfully minting a git token for it. + + The connection (`connection_type` / `connection_info`) is supplied here, + at creation time, in the same request — not via a separate + connection-update call afterward. + + `org_key` authenticates this call; the server accepts only an org key + here, not a project key, because no project exists yet to scope one to. + """ + import requests # noqa: PLC0415 + + url = f"{api_host.rstrip('/')}{_PROJECTS_PATH}" + headers = {"Authorization": f"Bearer {org_key}"} + try: + numeric_org_id = int(org_id) + except (TypeError, ValueError) as exc: + # Reached with `--org acme`: the CLI catches CloudError only, so a + # ValueError here printed a traceback where every other refusal in + # this command prints a message. + raise CloudError( + f"`--org` must be a numeric organization id; got {org_id!r}." + ) from exc + + body: dict = { + "orgId": numeric_org_id, + "displayName": display_name, + "projectType": "AGENTIC", + } + if connection_type is not None: + body["type"] = connection_type + if connection_info is not None: + body["connectionInfo"] = connection_info + if test_connection: + body["testConnection"] = True + if mdl is not None: + body["mdl"] = mdl + if language is not None: + body["language"] = language + if timezone is not None: + body["timezone"] = timezone + + try: + resp = requests.post(url, json=body, headers=headers, timeout=timeout) + except requests.RequestException as exc: + raise CloudError(f"Could not reach {api_host}: {exc}") from exc + + if resp.status_code not in (201, 207): + if resp.status_code in (401, 403): + # Deliberately does not claim the key was a project key. A 401 + # here has several possible causes — wrong level, revoked, or + # belonging to another org or host — and nothing distinguishes + # them from this side, so naming one sends the user looking for + # a key they may already have. (An earlier version of this + # comment justified itself with a CLI-side `osk-` prefix check; + # no such check exists, and the reasoning does not need it.) + raise InvalidApiKeyError( + f"This key was rejected creating a project in org " + f"{org_id} on {api_host}.\n" + "Creating a project needs an organization key. This one may " + "not be one, or may be revoked, or belong to a different " + "organization or host. Pass `--org-key` to use a different " + "one." + ) + raise CloudError( + f"Wren Cloud API returned {resp.status_code} creating a project " + f"on {api_host}: {resp.text[:300]}" + ) + + what = f"Creating a project in org {org_id}" + data = _json_object(resp, what=what) + project = data.get("project") or {} + if not isinstance(project, dict): + raise CloudError( + f"{what} returned a `project` that is not an object " + f"({type(project).__name__})." + ) + project_id = project.get("id") + if project_id is None: + raise CloudError( + "Wren Cloud API accepted the project-creation request on " + f"{api_host} but did not return a project id: {resp.text[:300]}" + ) + return CreatedProject( + id=str(project_id), + org_id=str(org_id), + display_name=project.get("displayName", display_name), + status=data.get("status", "succeeded"), + errors=list(data.get("errors") or []), + ) + + +def default_key_name() -> str: + """Name a minted key with the date it was minted. + + Every key this command mints lands in a list the user manages by hand, + and the server stamps them all with the same origin, so a bare constant + would render that list a row of identical entries. The date is the one + fact carried here: it is enough to correlate a row with something the + user remembers doing, and it sends nothing about the machine. + + Deliberately *not* the host name: that would distinguish same-day mints + from different machines, but it would also put the machine's name into + the account's key list, and that trade was declined. + """ + return f"wren-cli {time.strftime('%Y-%m-%d')}" + + +def mint_project_key( + api_host: str, + project_id: str, + org_key: str, + *, + name: str | None = None, + timeout: float = 15.0, +) -> str: + """Mint a durable project API key (`sk-...`) for `project_id`. + + Authenticates with the org key, used here for the only other thing it is + needed for: authorizing the mint of the project's own key. The server + reveals the secret only in this response, never again — the caller must + capture it now. + """ + import requests # noqa: PLC0415 + + url = ( + f"{api_host.rstrip('/')}{_PROJECT_KEYS_PATH_TMPL.format(project_id=project_id)}" + ) + headers = {"Authorization": f"Bearer {org_key}"} + try: + resp = requests.post( + url, + json={"name": name or default_key_name()}, + headers=headers, + timeout=timeout, + ) + except requests.RequestException as exc: + raise CloudError(f"Could not reach {api_host}: {exc}") from exc + + if resp.status_code not in (200, 201): + if resp.status_code in (401, 403): + raise InvalidApiKeyError( + "This org key was rejected minting a project key for " + f"project {project_id} on {api_host}." + ) + raise CloudError( + f"Wren Cloud API returned {resp.status_code} minting a project " + f"key for project {project_id} on {api_host}: {resp.text[:300]}" + ) + + secret = _json_object(resp, what=f"Minting a key for project {project_id}").get( + "secret" + ) + if not secret: + raise CloudError( + "Wren Cloud API did not return a key secret for project " + f"{project_id} on {api_host}: {resp.text[:300]}" + ) + return secret + + +_PROJECT_FILE = "wren_project.yml" + + +def check_project_builds(target: Path) -> None: + """Refuse unless this directory is a Wren project that compiles. + + `create` turns an existing local Wren project into a Wren Cloud project, + so a directory with no project, or one whose YAML does not compile, has + nothing to convert. Checked here rather than after creation because every + refusal in `create` must leave no server-side state behind. + + The manifest is built and thrown away: the models reach the cloud through + git, not through this call. Uploading it as well would put the same files + in the repository twice — the server materializes an uploaded MDL back + into `models/*/metadata.yml`, in its own normalized rendering, which then + collides with the local YAML on bind. Building anyway is what makes the + refusal honest: a project that cannot compile locally will not deploy + once pushed either, and finding that out before the project exists is + the whole point. + + Deliberately checks *this* directory rather than calling + `context.discover_project_path()`, which walks up from the cwd: a + directory that merely sits inside a project elsewhere is not itself the + project being converted. + """ + from wren import context # noqa: PLC0415 + + if not (target / _PROJECT_FILE).is_file(): + raise CloudError( + f"{target} is not a Wren project — no `{_PROJECT_FILE}`.\n" + "`create` turns a project you already have into a Wren Cloud " + "project, so there has to be one here first:\n" + " wren context init\n" + "then define your models and run `create` again. To start a " + "brand-new project instead, create it in the Wren Cloud web UI." + ) + + try: + # In memory on purpose: `save_target` would write `target/mdl.json`, + # and the scaffold does not ignore it, so the bind below would push a + # build artifact into the project's repository. + context.build_json(target) + except Exception as exc: # noqa: BLE001 - any build failure is the user's + raise CloudError( + f"Building the MDL in {target} failed:\n{exc}\n" + "Fix the project (`wren context validate` reports more), then run " + "`create` again. Nothing was created." + ) from exc + + +def create( + target: Path, + *, + host: str, + org_id: str, + org_key: str, + display_name: str, + git_host: str | None = None, + connection_type: str | None = None, + connection_info: dict | None = None, + test_connection: bool = False, + language: str | None = None, + timezone: str | None = None, +) -> tuple[CreatedProject, LinkOutcome]: + """Create a new agent-mode project on `host` and bind `target` to it. + + Checks `target` is not nested inside a foreign git repository, that the + git credential helper is serviceable, and that `target` is not already + bound to (or carrying the history of) another project — all *before* + creating anything server-side, so a refusal here (unlike a refusal + partway through) never leaves an orphaned project behind. The helper + check is repeated inside `login` below, which is where it belongs for a + bare `login`; running it up front too is what keeps this command's + failure free of side effects, since by the time `login` runs, a project + and a key already exist. + + `check_not_already_bound` is here for exactly that reason and not left + to `link`. `link` runs last, after the project and its key exist, so the + same refusal raised from there produced a real project that nothing + referenced — which is the defect this guard closes. + + Ends in exactly the state `login` + `link` leave a directory in: a + stored project key, a configured git credential helper, and a bound + working tree with upstream tracking set — because this calls `login` + and `link` to get there, rather than reimplementing either. + + If the server did not actually grant the AGENTIC opt-in requested above + (so the project has no git repository at all), that surfaces right here + as a clear, specific error — not as a bare 404 — while `target` is left + untouched: `check_not_nested` already ran, but git has not been touched + yet. The freshly minted project key is included in the error so the + project is not orphaned key-less; recover with `wren cloud auth add` using + that key once the AGENTIC opt-in issue is resolved (e.g. by deleting the + project and re-running `create`). + + If the project is created and confirmed AGENTIC but the local `link` + step then fails (e.g. a merge conflict), the same recovery applies: + `wren cloud auth add` followed by `wren cloud link` completes the bind by + hand — nothing about the project itself needs to be redone. + """ + target = target.resolve() + check_not_nested(target) + check_helper_command_serviceable() + check_not_already_bound(target) + check_git_identity_usable(target) + # Built before anything is created, so a project that does not compile + # costs nothing: every refusal above this line leaves no server-side state, + # and this one must not either. + check_project_builds(target) + + api_host = host.rstrip("/") + resolved_git_host = (git_host or host).rstrip("/") + + project = create_project( + api_host, + org_key, + org_id=org_id, + display_name=display_name, + connection_type=connection_type, + connection_info=connection_info, + test_connection=test_connection, + language=language, + timezone=timezone, + ) + + project_key = mint_project_key(api_host, project.id, org_key) + + # Store the key before validating it. The validation below is the only + # remaining step that can fail, and when it does the key is the one thing + # that cannot be obtained again — so printing it into the error was the + # earlier way of not losing it. stderr routinely reaches CI logs and + # bug reports pasted verbatim, which is not somewhere a live credential + # should end up; storing it first means the message never has to carry it. + store_key_pending_repo( + git_host=resolved_git_host, + api_host=api_host, + project_id=project.id, + org_id=project.org_id, + api_key=project_key, + ) + # And configure git, for the same reason. `login` writes the helper only + # after the git-token call succeeds — which is the call that can fail here + # — so storing the key alone left the recovery this hint promises unable to + # authenticate: `link` would fetch over HTTPS with no helper for the host + # and git would prompt for a username. Serviceability was checked in the + # pre-flight above and the write is idempotent, so doing it here costs + # nothing and makes the hint true. + + def _recovery_hint() -> str: + return ( + f"The project's key is already stored, so nothing needs to be " + f"re-entered. Finish with:\n" + f" wren cloud link {target}\n" + f"Or, if you no longer want it, delete project {project.id} — " + f"`wren cloud auth remove --host {host} --project {project.id}` " + f"drops the stored key." + ) + + try: + configure_git_credential_helper(resolved_git_host) + except CloudError as exc: + # This is past the point of no return, so it owes the same naming as + # every other post-creation failure. Reachable: `git config --global` + # fails with "could not lock config file" when the global config's + # directory is not writable — and a read-only home still *reads*, so + # the git-identity pre-flight passes and this is the first write to + # fail. A malformed global config fails earlier, at that pre-flight. + raise CloudError( + f"Project {project.id} was created on {api_host}, but configuring " + f"git to authenticate to it failed:\n{exc}\n" + f"{_recovery_hint()}" + ) from exc + + try: + token = login( + host=host, + project_id=project.id, + api_key=project_key, + git_host=git_host, + ) + except CloudError as exc: + if isinstance(exc, CloudApiError) and exc.code == "PROJECT_NOT_AGENTIC": + raise CloudError( + f"Project {project.id} was created on {api_host}, but it is " + "not an agent-mode (AGENTIC) project — `wren cloud create` " + "only produces agent-mode projects, which are the only kind " + "that get a git repository at all. There is nothing to bind " + "to; this project has no git remote.\n" + f"{_recovery_hint()}\n" + "(`wren cloud link` cannot help here either, until the " + "project is actually AGENTIC — delete this project and " + "retry `create`, or ask your org admin about the AGENTIC " + "opt-in.)" + ) from exc + raise CloudError( + f"Project {project.id} was created on {api_host}, but binding " + f"this directory to it failed: {exc}\n{_recovery_hint()}" + ) from exc + + try: + outcome = link( + target, + git_host=resolved_git_host, + api_host=api_host, + project_id=project.id, + org_id=project.org_id, + repo=token.repo, + # This project was created moments ago, so its remote holds only + # the seed commit. Without this, `link`'s foreign-history refusal + # would fire on a directory carrying another project's history — + # blocking "duplicate a project" and, worse, doing it *after* the + # project exists, which is exactly the orphan this command guards + # against. + remote_is_fresh=True, + ) + except CloudError as exc: + # The pre-flight checks cannot catch everything that can go wrong in + # git — a transient fetch failure against a just-created repo, for + # instance. Whatever it was, a project now exists and the raw git + # error says nothing about it, which leaves the user with an + # unexplained project in their org and no idea it is theirs. Name it, + # and say what completes the job: the key is already stored by the + # `login` above, so only the bind is left. + raise CloudError( + f"Project {project.id} was created on {api_host} and its key is " + f"stored, but binding {target} to it failed:\n{exc}\n" + "The project is fine — only the local bind is missing. Retry " + "with:\n" + f" wren cloud link {target}\n" + f"Or, if you no longer want it, delete project {project.id} so it " + "does not linger unused." + ) from exc + + # The models reach the cloud here, and only here. `link` has committed the + # project's files and set the upstream; pushing them is what fires the + # repository's `.hooks/deploy-modeling.yaml` and turns them into the + # project's models. Without this the project would exist, be bound, and + # have nothing in it — which is the state this command exists to avoid. + push = run_git(["push", "origin", "HEAD"], cwd=target, check=False) + if push.returncode != 0: + raise CloudError( + f"Project {project.id} was created on {api_host} and {target} is " + f"bound to it, but pushing your project failed:\n" + f"{(push.stderr or push.stdout).strip()}\n" + "The project and the bind are both fine — only the models are " + "still local. Finish with:\n" + " git push\n" + "which is also what deploys them." + ) + return project, outcome diff --git a/core/wren/src/wren/cloud_cli.py b/core/wren/src/wren/cloud_cli.py new file mode 100644 index 0000000000..508a68d618 --- /dev/null +++ b/core/wren/src/wren/cloud_cli.py @@ -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 `.") + 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)) diff --git a/core/wren/src/wren/context_cli.py b/core/wren/src/wren/context_cli.py index e2bf3670b7..27254d5f1a 100644 --- a/core/wren/src/wren/context_cli.py +++ b/core/wren/src/wren/context_cli.py @@ -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" diff --git a/core/wren/src/wren/memory/cli.py b/core/wren/src/wren/memory/cli.py index 2a76c45ed3..e7877a39fa 100644 --- a/core/wren/src/wren/memory/cli.py +++ b/core/wren/src/wren/memory/cli.py @@ -475,14 +475,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 @@ -496,7 +508,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?" @@ -837,7 +849,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, @@ -849,6 +870,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) diff --git a/core/wren/src/wren/profile_cli.py b/core/wren/src/wren/profile_cli.py index 2263c422af..b1d85b16d6 100644 --- a/core/wren/src/wren/profile_cli.py +++ b/core/wren/src/wren/profile_cli.py @@ -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() diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py new file mode 100644 index 0000000000..9aa563fbd1 --- /dev/null +++ b/core/wren/tests/unit/test_cloud.py @@ -0,0 +1,2444 @@ +"""Unit tests for ``wren.cloud`` — path parsing, config writing, the git +credential helper's stdin/stdout protocol, and the binding lifecycle. + +Two kinds of test live here. Most cover pure-logic paths with HTTP mocked. +The ``link`` / ``unlink`` / guard sections instead drive **real git**, with a +local filesystem path standing in for the project's remote — those failure +modes (an `origin` that points somewhere else, a history acquired from +another project) only exist in git's behaviour, so a mock could not detect +them. + +Still exercised manually against a real Wren Cloud stack, not here, because +a mocked HTTP layer cannot stand in for them: auth add + clone, push, +wrong-key messaging, host-scoping of the credential helper, and the +nested-repo refusal against a real nested layout. +""" + +from __future__ import annotations + +import io +import socket +import subprocess +import time +from pathlib import Path + +import pytest +import requests + +from wren import cloud + +pytestmark = pytest.mark.unit + + +# ── parse_repo_path ────────────────────────────────────────────────────────── + + +def test_parse_repo_path_from_api_repo_field(): + assert cloud.parse_repo_path("org/2/16/shared-data.git") == ( + "2", + "16", + "shared-data.git", + ) + + +def test_parse_repo_path_from_git_path_field(): + # `useHttpPath` makes git hand the helper a `path=` value carrying the + # git-server's own routing prefix; the helper must strip it the same + # way it parses the API's own `repo` field. + assert cloud.parse_repo_path("git/org/2/16/shared-data.git") == ( + "2", + "16", + "shared-data.git", + ) + + +def test_parse_repo_path_rejects_unrecognized_shape(): + with pytest.raises(cloud.CloudError): + cloud.parse_repo_path("not/a/wren/path") + + +# ── credential storage: ~/.wren/cloud.yml ─────────────────────────────────── + + +@pytest.fixture(autouse=True) +def _isolated_wren_home(tmp_path, monkeypatch): + home = tmp_path / "wren_home" + monkeypatch.setattr(cloud, "_WREN_HOME", home) + monkeypatch.setattr(cloud, "_CLOUD_FILE", home / "cloud.yml") + yield + + +@pytest.fixture(autouse=True) +def _isolated_git_global_config(tmp_path, monkeypatch): + """Keep `git config --global` writes out of the real user's config. + + `configure_git_credential_helper` writes to global git config, and the + `create` tests below reach it for real. Without this, every run left a + dead `credential.` section behind in the developer's (or CI + runner's) own `~/.gitconfig`, one per run, forever — the tests were + mutating the machine they ran on. + """ + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(tmp_path / "gitconfig")) + + +@pytest.fixture +def _helper_check_passes(monkeypatch): + """Let `auth add`/`create` past the credential-helper pre-flight check. + + The check shells out to whatever `wren` is on PATH, which is a property + of the machine rather than of the code under test. It has its own tests + below; these flows only need it not to be the thing under test. + """ + monkeypatch.setattr(cloud, "check_helper_command_serviceable", lambda: None) + + +def test_store_and_get_login_roundtrip(): + cloud.store_login( + git_host="https://cloud.getwren.ai", + api_host="https://cloud.getwren.ai", + project_id="16", + org_id="2", + repo="org/2/16/shared-data.git", + api_key="sk-test", + ) + entry = cloud.get_login("https://cloud.getwren.ai", "16") + assert entry == { + "api_host": "https://cloud.getwren.ai", + "org_id": "2", + "repo": "org/2/16/shared-data.git", + "api_key": "sk-test", + } + + +def test_get_login_missing_returns_none(): + assert cloud.get_login("https://cloud.getwren.ai", "999") is None + + +def test_cloud_file_is_written_0600(): + cloud.store_login( + git_host="https://cloud.getwren.ai", + api_host="https://cloud.getwren.ai", + project_id="16", + org_id="2", + repo="org/2/16/shared-data.git", + api_key="sk-test", + ) + mode = cloud._CLOUD_FILE.stat().st_mode & 0o777 + assert mode == 0o600 + + +def test_list_logins_returns_every_stored_entry(): + cloud.store_login( + git_host="https://cloud.getwren.ai", + api_host="https://cloud.getwren.ai", + project_id="16", + org_id="2", + repo="org/2/16/shared-data.git", + api_key="sk-a", + ) + cloud.store_login( + git_host="https://cloud.getwren.ai", + api_host="https://cloud.getwren.ai", + project_id="17", + org_id="2", + repo="org/2/17/other.git", + api_key="sk-b", + ) + entries = cloud.list_logins() + assert {(host, project) for host, project, _ in entries} == { + ("https://cloud.getwren.ai", "16"), + ("https://cloud.getwren.ai", "17"), + } + + +# ── credential helper stdin/stdout protocol ───────────────────────────────── + + +def test_read_credential_input_parses_key_value_lines(): + stream = io.StringIO( + "protocol=http\nhost=localhost:8081\npath=git/org/2/16/x.git\n\n" + ) + assert cloud.read_credential_input(stream) == { + "protocol": "http", + "host": "localhost:8081", + "path": "git/org/2/16/x.git", + } + + +def test_read_credential_input_stops_at_blank_line(): + stream = io.StringIO("protocol=http\n\nhost=ignored-after-blank\n") + assert cloud.read_credential_input(stream) == {"protocol": "http"} + + +def test_read_credential_input_ignores_lines_without_equals(): + stream = io.StringIO("protocol=http\ngarbage\nhost=x\n") + assert cloud.read_credential_input(stream) == {"protocol": "http", "host": "x"} + + +def test_format_credential_output(): + assert cloud.format_credential_output("x-access-token", "abc123") == ( + "username=x-access-token\npassword=abc123\n" + ) + + +def test_git_credential_get_mints_a_fresh_token(monkeypatch): + cloud.store_login( + git_host="http://localhost:8081", + api_host="http://localhost:3000", + project_id="16", + org_id="2", + repo="org/2/16/shared-data.git", + api_key="osk-test", + ) + + captured = {} + + def fake_mint(api_host, project_id, api_key, **kwargs): + captured["args"] = (api_host, project_id, api_key) + return cloud.GitToken( + repo="org/2/16/shared-data.git", + token="minted-token", + expires_in=600, + expires_at="2026-01-01T00:00:00Z", + ) + + monkeypatch.setattr(cloud, "mint_git_token", fake_mint) + + output = cloud.git_credential_get( + { + "protocol": "http", + "host": "localhost:8081", + "path": "git/org/2/16/shared-data.git", + } + ) + assert output == "username=x-access-token\npassword=minted-token\n" + assert captured["args"] == ("http://localhost:3000", "16", "osk-test") + + +def test_git_credential_get_without_path_raises(): + with pytest.raises(cloud.CloudError): + cloud.git_credential_get({"protocol": "http", "host": "localhost:8081"}) + + +def test_git_credential_get_without_stored_login_raises(): + with pytest.raises(cloud.CloudError): + cloud.git_credential_get( + { + "protocol": "http", + "host": "localhost:8081", + "path": "git/org/2/16/shared-data.git", + } + ) + + +def test_git_credential_store_is_a_noop(): + # Never cache the token: `store` must not raise and must not create + # any new state. + assert cloud.git_credential_store({"password": "whatever"}) is None + assert not cloud._CLOUD_FILE.exists() + + +def test_git_credential_erase_never_touches_the_api_key(): + cloud.store_login( + git_host="http://localhost:8081", + api_host="http://localhost:3000", + project_id="16", + org_id="2", + repo="org/2/16/shared-data.git", + api_key="osk-test", + ) + cloud.git_credential_erase({"password": "whatever"}) + assert cloud.get_login("http://localhost:8081", "16")["api_key"] == "osk-test" + + +# ── nested-repository detection ────────────────────────────────────────────── + + +def test_check_not_nested_allows_git_root_itself(tmp_path): + target = tmp_path / "proj" + target.mkdir() + (target / ".git").mkdir() + cloud.check_not_nested(target) # must not raise + + +def test_check_not_nested_allows_directory_with_no_git_anywhere(tmp_path): + target = tmp_path / "proj" + target.mkdir() + cloud.check_not_nested(target) # must not raise + + +def test_check_not_nested_refuses_when_ancestor_is_a_git_repo(tmp_path): + outer = tmp_path / "outer" + (outer / ".git").mkdir(parents=True) + target = outer / "nested" / "proj" + target.mkdir(parents=True) + + with pytest.raises(cloud.NestedRepoError) as excinfo: + cloud.check_not_nested(target) + assert str(outer) in str(excinfo.value) + + +def test_find_git_root_returns_none_when_absent(tmp_path): + """`tmp_path` itself has no `.git`, but a parent might on some CI layouts, + so the assertion is scoped to the part that is knowable: whatever it finds + is never inside `tmp_path`, and it does not raise on a path that is not + there. (The previous form, `... is None or True`, could not fail.)""" + + found = cloud.find_git_root(tmp_path / "nope" / "deeper") + + assert found is None or tmp_path not in found.parents + assert found != tmp_path + + +# ── retry-after parsing ────────────────────────────────────────────────────── + + +def test_parse_retry_after_uses_header_value(): + assert cloud._parse_retry_after("5") == 5.0 + + +def test_parse_retry_after_falls_back_on_missing_header(): + assert cloud._parse_retry_after(None) == 1.0 + + +def test_parse_retry_after_falls_back_on_garbage(): + assert cloud._parse_retry_after("not-a-number") == 1.0 + + +# ── run_git ─────────────────────────────────────────────────────────────── + + +def test_run_git_raises_git_command_error_on_failure(tmp_path): + with pytest.raises(cloud.GitCommandError): + cloud.run_git(["not-a-real-git-subcommand"], cwd=tmp_path) + + +def test_run_git_returns_completed_process_on_success(tmp_path): + result = cloud.run_git(["init"], cwd=tmp_path) + assert isinstance(result, subprocess.CompletedProcess) + assert (tmp_path / ".git").exists() + + +# ── link: acquisition, recovery, and the already-linked short-circuit ────── +# +# Unlike the sections above, these drive real git — a local filesystem path +# stands in for the project's remote, so no HTTP/Wren Cloud stack is +# needed. `link()` builds the remote URL as f"{git_host}/git/{repo}", so +# the fake remote must live at exactly that path for local-filesystem +# fetch/clone to reach it. + + +@pytest.fixture(autouse=True) +def _git_identity(monkeypatch): + # `link()` shells out to real `git commit`. Set the identity via env + # vars (subprocess inherits the test process's environment) rather + # than relying on — or mutating — the machine's global git config. + monkeypatch.setenv("GIT_AUTHOR_NAME", "Test") + monkeypatch.setenv("GIT_AUTHOR_EMAIL", "test@example.com") + monkeypatch.setenv("GIT_COMMITTER_NAME", "Test") + monkeypatch.setenv("GIT_COMMITTER_EMAIL", "test@example.com") + + +def _make_wren_project(path: Path, *, model: str = "t") -> Path: + """Turn `path` into the smallest Wren project that compiles. + + `create` converts an existing local project, so every test that drives it + needs one here. `schema_version: 5` is required: without it the loader + uses the legacy layout and finds no models, which would make these tests + pass for the wrong reason. + """ + path.mkdir(parents=True, exist_ok=True) + (path / "wren_project.yml").write_text( + "schema_version: 5\n" + "name: proj\n" + 'version: "1.0"\n' + "catalog: wren\n" + "schema: public\n" + "data_source: bigquery\n" + ) + model_dir = path / "models" / model + model_dir.mkdir(parents=True, exist_ok=True) + (model_dir / "metadata.yml").write_text( + f"name: {model}\n" + "table_reference:\n" + " schema: public\n" + f" table: {model}\n" + "columns:\n" + " - name: id\n" + " type: INTEGER\n" + ) + return path + + +def _seed_remote(remote_dir): + """A real local git repo standing in for the project's remote, with one + commit — like a freshly created Wren Cloud project seeding its own + `.hooks/deploy-modeling.yaml` before the user ever links to it.""" + remote_dir.mkdir(parents=True) + cloud.run_git(["init", "-b", "main"], cwd=remote_dir) + (remote_dir / "seed.txt").write_text("seeded by project creation") + cloud.run_git(["add", "-A"], cwd=remote_dir) + cloud.run_git(["commit", "-m", "seed"], cwd=remote_dir) + # `create` pushes, and git refuses to push into the checked-out branch of + # a non-bare repo. A real remote is bare; this makes the stand-in behave + # like one without losing the seeded worktree the link tests read. + cloud.run_git( + ["config", "receive.denyCurrentBranch", "updateInstead"], cwd=remote_dir + ) + + +def _link(target, *, git_host, repo="shared-data.git"): + return cloud.link( + target, + git_host=git_host, + api_host="unused", + project_id="16", + org_id="2", + repo=repo, + ) + + +def test_link_recovers_when_a_previous_attempt_left_head_unborn(tmp_path): + git_host = str(tmp_path / "host") + _seed_remote(tmp_path / "host" / "git" / "shared-data.git") + + target = _make_wren_project(tmp_path / "project") + (target / "mine.txt").write_text("my existing file") + # Simulate a previous `link` that got as far as `git init` and then + # died before committing (e.g. no git identity): `.git` exists but + # HEAD is unborn. + cloud.run_git(["init"], cwd=target) + + outcome = _link(target, git_host=git_host) + + assert outcome is cloud.LinkOutcome.LINKED + # The unrelated-history merge actually ran (not a fast-forward onto an + # unborn HEAD): both the pre-existing local file and the remote's + # seeded file are present afterward. + assert (target / "mine.txt").exists() + assert (target / "seed.txt").exists() + log = cloud.run_git(["log", "--oneline"], cwd=target).stdout.strip().splitlines() + assert len(log) >= 2 + + +class _FakeResponse: + def __init__(self, status_code, json_data=None, text="", raise_on_json=False): + self.status_code = status_code + self._json_data = json_data or {} + self._raise_on_json = raise_on_json + self.text = text or str(json_data or "") + + def json(self): + if self._raise_on_json: + raise ValueError("not json") + return self._json_data + + +# ── mint_git_token: machine-readable failure surfacing ────────────────────── +# +# The 200/401/403/429 branches are unchanged and already covered by the +# credential-helper and `login` tests above (which monkeypatch +# `mint_git_token` itself). These cover only the new behavior: the +# generic-error branch now attaches the response's HTTP status and, when +# the body has one, its machine-readable `code` — so `create` can branch +# on the failure kind instead of matching prose. + + +def test_mint_git_token_attaches_status_and_code_from_a_realistic_error_body( + monkeypatch, +): + def fake_post(url, headers=None, timeout=None): + return _FakeResponse( + 404, + { + "code": "PROJECT_NOT_AGENTIC", + "error": "This endpoint is only available for agent-mode projects.", + }, + ) + + monkeypatch.setattr(requests, "post", fake_post) + + with pytest.raises(cloud.CloudApiError) as excinfo: + cloud.mint_git_token("https://cloud.getwren.ai", "16", "sk-test") + + assert excinfo.value.status_code == 404 + assert excinfo.value.code == "PROJECT_NOT_AGENTIC" + + +def test_mint_git_token_code_is_none_on_a_body_with_no_code_field(monkeypatch): + def fake_post(url, headers=None, timeout=None): + return _FakeResponse(500, {"error": "server exploded"}, text="server exploded") + + monkeypatch.setattr(requests, "post", fake_post) + + with pytest.raises(cloud.CloudApiError) as excinfo: + cloud.mint_git_token("https://cloud.getwren.ai", "16", "sk-test") + + # No `code` in the body degrades to `None`, not a crash — and the + # message is exactly what it was before this change. + assert excinfo.value.code is None + assert excinfo.value.status_code == 500 + assert "server exploded" in str(excinfo.value) + + +def test_mint_git_token_code_is_none_on_an_unparseable_body(monkeypatch): + def fake_post(url, headers=None, timeout=None): + return _FakeResponse(502, text="bad gateway", raise_on_json=True) + + monkeypatch.setattr(requests, "post", fake_post) + + with pytest.raises(cloud.CloudApiError) as excinfo: + cloud.mint_git_token("https://cloud.getwren.ai", "16", "sk-test") + + assert excinfo.value.code is None + assert excinfo.value.status_code == 502 + + +# ── create_project: POST /api/v1/projects ─────────────────────────────────── + + +def test_create_project_sends_agentic_opt_in_and_org_key_only(monkeypatch): + captured = {} + + def fake_post(url, json=None, headers=None, timeout=None): + captured.update(url=url, json=json, headers=headers) + return _FakeResponse( + 201, + { + "project": {"id": 16, "displayName": "proj"}, + "status": "succeeded", + }, + ) + + monkeypatch.setattr(requests, "post", fake_post) + + project = cloud.create_project( + "https://cloud.getwren.ai", + "osk-org-key", + org_id="2", + display_name="proj", + ) + + assert captured["url"] == "https://cloud.getwren.ai/api/v1/projects" + assert captured["headers"] == {"Authorization": "Bearer osk-org-key"} + assert captured["json"]["orgId"] == 2 + assert captured["json"]["displayName"] == "proj" + assert captured["json"]["projectType"] == "AGENTIC" + # Optional fields are omitted entirely when not given, not sent as null. + assert "type" not in captured["json"] + assert "connectionInfo" not in captured["json"] + assert project == cloud.CreatedProject( + id="16", org_id="2", display_name="proj", status="succeeded", errors=[] + ) + + +def test_create_project_includes_connection_when_given(monkeypatch): + captured = {} + + def fake_post(url, json=None, headers=None, timeout=None): + captured.update(json=json) + return _FakeResponse(201, {"project": {"id": 16}, "status": "succeeded"}) + + monkeypatch.setattr(requests, "post", fake_post) + + cloud.create_project( + "https://cloud.getwren.ai", + "osk-org-key", + org_id="2", + display_name="proj", + connection_type="POSTGRES", + connection_info={"host": "db"}, + test_connection=True, + mdl={"models": []}, + language="en", + timezone="UTC", + ) + + assert captured["json"]["type"] == "POSTGRES" + assert captured["json"]["connectionInfo"] == {"host": "db"} + assert captured["json"]["testConnection"] is True + assert captured["json"]["mdl"] == {"models": []} + assert captured["json"]["language"] == "en" + assert captured["json"]["timezone"] == "UTC" + + +def test_create_project_partial_status_captures_errors(monkeypatch): + def fake_post(url, json=None, headers=None, timeout=None): + return _FakeResponse( + 207, + { + "project": {"id": 16, "displayName": "proj"}, + "status": "partial", + "errors": [{"resource": "mdl", "message": "bad mdl"}], + }, + ) + + monkeypatch.setattr(requests, "post", fake_post) + + project = cloud.create_project( + "https://cloud.getwren.ai", "osk-org-key", org_id="2", display_name="proj" + ) + assert project.status == "partial" + assert project.errors == [{"resource": "mdl", "message": "bad mdl"}] + + +def test_create_project_raises_on_missing_project_id(monkeypatch): + def fake_post(url, json=None, headers=None, timeout=None): + return _FakeResponse(201, {"status": "succeeded"}) + + monkeypatch.setattr(requests, "post", fake_post) + + with pytest.raises(cloud.CloudError): + cloud.create_project( + "https://cloud.getwren.ai", "osk-org-key", org_id="2", display_name="p" + ) + + +def test_create_project_raises_generic_cloud_error_on_other_status(monkeypatch): + def fake_post(url, json=None, headers=None, timeout=None): + return _FakeResponse(500, {}, text="server exploded") + + monkeypatch.setattr(requests, "post", fake_post) + + with pytest.raises(cloud.CloudError, match="server exploded"): + cloud.create_project( + "https://cloud.getwren.ai", "osk-org-key", org_id="2", display_name="p" + ) + + +# ── mint_project_key: POST /api/v1/projects/{id}/keys ─────────────────────── + + +def test_mint_project_key_returns_the_secret(monkeypatch): + captured = {} + + def fake_post(url, json=None, headers=None, timeout=None): + captured.update(url=url, json=json, headers=headers) + return _FakeResponse(201, {"secret": "sk-fresh"}) + + monkeypatch.setattr(requests, "post", fake_post) + + secret = cloud.mint_project_key( + "https://cloud.getwren.ai", "16", "osk-org-key", name="wren-cli" + ) + assert secret == "sk-fresh" + assert captured["url"] == "https://cloud.getwren.ai/api/v1/projects/16/keys" + assert captured["json"] == {"name": "wren-cli"} + assert captured["headers"] == {"Authorization": "Bearer osk-org-key"} + + +def test_mint_project_key_dates_the_key_by_default(monkeypatch): + """The name carries the mint date and nothing about the machine. + + The server stamps every API-minted key with the same origin, so the name + is the only field carrying anything distinguishing, and a constant would + make the user's key list a row of identical entries. The host name was + considered for this and deliberately left out — see `default_key_name`. + """ + captured = {} + + def fake_post(url, json=None, headers=None, timeout=None): + captured.update(json=json) + return _FakeResponse(201, {"secret": "sk-fresh"}) + + monkeypatch.setattr(requests, "post", fake_post) + monkeypatch.setattr(time, "strftime", lambda fmt: "2026-08-14") + + cloud.mint_project_key("https://cloud.getwren.ai", "16", "osk-org-key") + + name = captured["json"]["name"] + assert name == "wren-cli 2026-08-14", name + assert len(name) <= 100, f"server rejects names over 100 chars: {name!r}" + + +def test_default_key_name_carries_nothing_about_the_machine(): + """Guards the privacy decision, not the format. + + Written so that reintroducing the host name has to be a deliberate act + that breaks a test, rather than something that creeps back in. + """ + name = cloud.default_key_name() + assert socket.gethostname().split(".")[0] not in name + assert name.startswith("wren-cli ") + + +def test_mint_project_key_raises_on_missing_secret(monkeypatch): + def fake_post(url, json=None, headers=None, timeout=None): + return _FakeResponse(201, {}) + + monkeypatch.setattr(requests, "post", fake_post) + + with pytest.raises(cloud.CloudError): + cloud.mint_project_key("https://cloud.getwren.ai", "16", "osk-org-key") + + +def test_mint_project_key_raises_invalid_api_key_on_403(monkeypatch): + def fake_post(url, json=None, headers=None, timeout=None): + return _FakeResponse(403, {}, text="forbidden") + + monkeypatch.setattr(requests, "post", fake_post) + + with pytest.raises(cloud.InvalidApiKeyError): + cloud.mint_project_key("https://cloud.getwren.ai", "16", "osk-not-allowed") + + +# ── create: the full create-and-bind flow ─────────────────────────────────── +# +# `create_project` / `mint_project_key` / `mint_git_token` are monkeypatched +# (their own HTTP behavior is covered above); `link`'s real git plumbing +# runs against a local-filesystem stand-in remote, exactly like the `link` +# tests above. + + +def _patch_create_http(monkeypatch, *, project_status="succeeded", project_errors=None): + created = cloud.CreatedProject( + id="16", + org_id="2", + display_name="proj", + status=project_status, + errors=project_errors or [], + ) + calls = {"create_project": 0, "mint_project_key": 0} + + def fake_create_project(api_host, org_key, **kwargs): + calls["create_project"] += 1 + calls["create_project_org_key"] = org_key + return created + + def fake_mint_project_key(api_host, project_id, org_key, **kwargs): + calls["mint_project_key"] += 1 + calls["mint_project_key_org_key"] = org_key + return "sk-fresh-project-key" + + monkeypatch.setattr(cloud, "create_project", fake_create_project) + monkeypatch.setattr(cloud, "mint_project_key", fake_mint_project_key) + return created, calls + + +def test_create_end_to_end_binds_and_stores_only_the_project_key( + tmp_path, monkeypatch, _helper_check_passes +): + git_host = str(tmp_path / "host") + _seed_remote(tmp_path / "host" / "git" / "org" / "2" / "16" / "shared-data.git") + created, calls = _patch_create_http(monkeypatch) + + def fake_mint_git_token(api_host, project_id, api_key, **kwargs): + calls["mint_git_token_api_key"] = api_key + return cloud.GitToken( + repo="org/2/16/shared-data.git", + token="minted-token", + expires_in=600, + expires_at="2026-01-01T00:00:00Z", + ) + + monkeypatch.setattr(cloud, "mint_git_token", fake_mint_git_token) + + target = _make_wren_project(tmp_path / "project") + (target / "mine.txt").write_text("my existing file") + + project, outcome = cloud.create( + target, + host=git_host, + org_id="2", + org_key="osk-should-never-be-stored", + display_name="proj", + git_host=git_host, + ) + + assert project is created + assert outcome is cloud.LinkOutcome.LINKED + # Both the org key and (crucially) the newly-minted project key were + # actually used to talk to the server... + assert calls["create_project_org_key"] == "osk-should-never-be-stored" + assert calls["mint_project_key_org_key"] == "osk-should-never-be-stored" + assert calls["mint_git_token_api_key"] == "sk-fresh-project-key" + # ...but only the project key ever reaches local storage. + stored = cloud.get_login(git_host, "16") + assert stored["api_key"] == "sk-fresh-project-key" + raw = cloud._CLOUD_FILE.read_text() + assert "osk-should-never-be-stored" not in raw + # Directory ends up bound exactly like `link` would leave it. + assert (target / "mine.txt").exists() + assert (target / "seed.txt").exists() + + +def test_create_refuses_nested_directory_before_any_server_call( + tmp_path, monkeypatch, _helper_check_passes +): + outer = tmp_path / "outer" + (outer / ".git").mkdir(parents=True) + target = outer / "nested" / "proj" + target.mkdir(parents=True) + + def fail_if_called(*args, **kwargs): + raise AssertionError("create_project must not be called for a nested target") + + monkeypatch.setattr(cloud, "create_project", fail_if_called) + + with pytest.raises(cloud.NestedRepoError): + cloud.create( + target, + host="https://cloud.getwren.ai", + org_id="2", + org_key="osk-x", + display_name="proj", + ) + + +def test_create_failed_project_creation_leaves_nothing_to_clean_up( + tmp_path, monkeypatch, _helper_check_passes +): + def fake_create_project(api_host, org_key, **kwargs): + raise cloud.CloudError("boom") + + monkeypatch.setattr(cloud, "create_project", fake_create_project) + + target = _make_wren_project(tmp_path / "project") + + with pytest.raises(cloud.CloudError, match="boom"): + cloud.create( + target, + host="https://cloud.getwren.ai", + org_id="2", + org_key="osk-x", + display_name="proj", + ) + + assert not (target / ".git").exists() + assert cloud.list_logins() == [] + + +def test_create_reports_not_agentic_actionably_and_includes_the_project_key( + tmp_path, monkeypatch, _helper_check_passes +): + # Deliberately does NOT monkeypatch `login` or `mint_git_token` — this + # drives the real `login()` -> real `mint_git_token()` path, stubbing + # only the network boundary, so the detection is exercised against an + # actually-realistic response: HTTP 404 with the server's documented + # `{"code": "PROJECT_NOT_AGENTIC", ...}` body. + _patch_create_http(monkeypatch) + + def fake_post(url, headers=None, timeout=None, **kwargs): + return _FakeResponse( + 404, + { + "code": "PROJECT_NOT_AGENTIC", + "error": "This endpoint is only available for agent-mode projects.", + }, + ) + + monkeypatch.setattr(requests, "post", fake_post) + + target = _make_wren_project(tmp_path / "project") + + with pytest.raises(cloud.CloudError) as excinfo: + cloud.create( + target, + host="https://cloud.getwren.ai", + org_id="2", + org_key="osk-x", + display_name="proj", + ) + + message = str(excinfo.value) + assert "not an agent-mode" in message + assert "sk-fresh-project-key" not in message, ( + "a live credential must not reach stderr — it lands in CI logs and in " + "bug reports pasted verbatim" + ) + assert "already stored" in message + # The key was the one thing that could not be fetched again, which is why + # it used to be printed. Asserting only its absence would pass just as + # well if it had been dropped. + stored = [entry for _host, pid, entry in cloud.list_logins() if pid == "16"] + assert stored and stored[0]["api_key"] == "sk-fresh-project-key" + # Nothing was touched locally — this is a pure server-side outcome. + assert not (target / ".git").exists() + + +@pytest.mark.parametrize( + "resp_kwargs", + [ + pytest.param( + {"json_data": {"code": "PROJECT_NOT_FOUND"}, "text": "not found"}, + id="different_code", + ), + pytest.param( + {"json_data": {"error": "nope"}, "text": "nope"}, + id="no_code_field", + ), + pytest.param( + {"raise_on_json": True, "text": "not json"}, + id="unparseable_body", + ), + ], +) +def test_create_degrades_to_generic_bind_failure_on_an_unrecognized_git_token_error( + tmp_path, monkeypatch, resp_kwargs, _helper_check_passes +): + # Same 404 status as the real PROJECT_NOT_AGENTIC case, but a body that + # doesn't say so — a different code, no code at all, or no parseable + # body. This must never be silently misdiagnosed as "not agentic": that + # would send the user chasing a nonexistent org-admin opt-in instead of + # whatever the real 404 means. + _patch_create_http(monkeypatch) + + def fake_post(url, headers=None, timeout=None, **kwargs): + return _FakeResponse(404, **resp_kwargs) + + monkeypatch.setattr(requests, "post", fake_post) + + target = _make_wren_project(tmp_path / "project") + + with pytest.raises(cloud.CloudError) as excinfo: + cloud.create( + target, + host="https://cloud.getwren.ai", + org_id="2", + org_key="osk-x", + display_name="proj", + ) + + message = str(excinfo.value) + assert "not an agent-mode" not in message + assert "binding this directory to it failed" in message + assert "sk-fresh-project-key" not in message, ( + "a live credential must not reach stderr — it lands in CI logs and in " + "bug reports pasted verbatim" + ) + assert "already stored" in message + # The key was the one thing that could not be fetched again, which is why + # it used to be printed. Asserting only its absence would pass just as + # well if it had been dropped. + stored = [entry for _host, pid, entry in cloud.list_logins() if pid == "16"] + assert stored and stored[0]["api_key"] == "sk-fresh-project-key" + assert not (target / ".git").exists() + + +def test_create_reports_bind_failure_with_recovery_hint( + tmp_path, monkeypatch, _helper_check_passes +): + _patch_create_http(monkeypatch) + + def fake_login(*, host, project_id, api_key, git_host): + raise cloud.CloudError("network blip") + + monkeypatch.setattr(cloud, "login", fake_login) + + target = _make_wren_project(tmp_path / "project") + + with pytest.raises(cloud.CloudError) as excinfo: + cloud.create( + target, + host="https://cloud.getwren.ai", + org_id="2", + org_key="osk-x", + display_name="proj", + ) + + message = str(excinfo.value) + assert "network blip" in message + assert "sk-fresh-project-key" not in message, ( + "a live credential must not reach stderr — it lands in CI logs and in " + "bug reports pasted verbatim" + ) + assert "already stored" in message + # The key was the one thing that could not be fetched again, which is why + # it used to be printed. Asserting only its absence would pass just as + # well if it had been dropped. + stored = [entry for _host, pid, entry in cloud.list_logins() if pid == "16"] + assert stored and stored[0]["api_key"] == "sk-fresh-project-key" + assert "wren cloud link" in message + + +def test_link_reports_already_linked_on_rerun_with_nothing_new(tmp_path): + git_host = str(tmp_path / "host") + _seed_remote(tmp_path / "host" / "git" / "shared-data.git") + + target = _make_wren_project(tmp_path / "project") + (target / "mine.txt").write_text("my existing file") + + first = _link(target, git_host=git_host) + assert first is cloud.LinkOutcome.LINKED + head_after_first = cloud.run_git(["rev-parse", "HEAD"], cwd=target).stdout + + second = _link(target, git_host=git_host) + + assert second is cloud.LinkOutcome.ALREADY_LINKED + # Confirms this was a genuine short-circuit, not a second no-op merge: + # HEAD did not move. + head_after_second = cloud.run_git(["rev-parse", "HEAD"], cwd=target).stdout + assert head_after_second == head_after_first + assert (target / "mine.txt").exists() + assert (target / "seed.txt").exists() + + +# ── the credential helper's PATH pre-flight check ──────────────────────────── +# +# git resolves the `wren` in `!wren cloud git-credential` from PATH at +# *git*-invocation time, so `login` writing that entry is a promise about an +# executable it does not control. These cover the check that refuses to make +# the promise when it cannot be kept. What they cannot cover is PATH changing +# after login — nothing on this side is in that failure's path, which is why +# `helper_failure_note` exists as well. + + +def _unserviceable_helper(monkeypatch): + """Make the pre-flight check refuse, whatever this machine's PATH holds.""" + + def refuse(): + raise cloud.CloudError("no usable wren on PATH") + + monkeypatch.setattr(cloud, "check_helper_command_serviceable", refuse) + + +def _fake_probe(monkeypatch, *, which="/usr/local/bin/wren", returncode=0): + """Stand in for the PATH lookup and the probe subprocess, recording both.""" + seen = {} + + def fake_which(name): + seen["which"] = name + return which + + monkeypatch.setattr(cloud.shutil, "which", fake_which) + + def fake_run(args, **kwargs): + seen["argv"] = list(args) + seen["kwargs"] = kwargs + return subprocess.CompletedProcess(args, returncode, stdout="", stderr="") + + monkeypatch.setattr(cloud.subprocess, "run", fake_run) + return seen + + +def test_check_passes_when_the_path_wren_can_serve_the_helper(monkeypatch): + seen = _fake_probe(monkeypatch, returncode=0) + + cloud.check_helper_command_serviceable() + + assert seen["which"] == cloud.HELPER_EXECUTABLE + assert seen["argv"][0] == "/usr/local/bin/wren" + + +def test_check_refuses_when_no_wren_is_on_path(monkeypatch): + monkeypatch.setattr(cloud.shutil, "which", lambda name: None) + + with pytest.raises(cloud.CloudError) as excinfo: + cloud.check_helper_command_serviceable() + + assert "PATH" in str(excinfo.value) + + +def test_check_refuses_when_the_path_wren_cannot_serve_the_helper(monkeypatch): + # The reported failure: an older wren on PATH with no `cloud` group at + # all, which exits non-zero when asked for it. + _fake_probe(monkeypatch, which="/opt/old/bin/wren", returncode=2) + + with pytest.raises(cloud.CloudError) as excinfo: + cloud.check_helper_command_serviceable() + + message = str(excinfo.value) + # Names the executable git would actually run, so the user can tell which + # of possibly several installs is the problem. + assert "/opt/old/bin/wren" in message + assert "cloud git-credential" in message + + +def test_check_refuses_when_the_probe_times_out(monkeypatch): + monkeypatch.setattr(cloud.shutil, "which", lambda name: "/usr/local/bin/wren") + + def fake_run(args, **kwargs): + raise subprocess.TimeoutExpired(args, kwargs.get("timeout", 0)) + + monkeypatch.setattr(cloud.subprocess, "run", fake_run) + + with pytest.raises(cloud.CloudError): + cloud.check_helper_command_serviceable() + + +def test_probe_and_written_helper_come_from_the_same_definition(monkeypatch): + """The check must probe the command that actually gets written. + + A drift between the two would leave the check verifying a command git + never runs — which looks exactly like a working guard. + """ + seen = _fake_probe(monkeypatch, which="/usr/local/bin/wren", returncode=0) + cloud.check_helper_command_serviceable() + probed = seen["argv"] + + written = [] + + def record_git(args, **kwargs): + written.append(list(args)) + + monkeypatch.setattr(cloud, "run_git", record_git) + cloud.configure_git_credential_helper("https://cloud.getwren.ai") + + helper_values = [ + args[-1] + for args in written + if args[:2] == ["config", "--global"] and args[-2].endswith(".helper") + ] + # The value git will run, minus the `!` that makes git shell it out... + assert cloud.HELPER_COMMAND in helper_values + shelled_out = cloud.HELPER_COMMAND.lstrip("!").split() + # ...is the same command the probe asked about, minus `--help`. + assert probed[1:-1] == shelled_out[1:] + assert probed[-1] == "--help" + + +def test_login_refuses_before_any_network_or_config_write(monkeypatch): + _unserviceable_helper(monkeypatch) + + def fail_if_called(*args, **kwargs): + raise AssertionError("must not be reached when the helper is unserviceable") + + monkeypatch.setattr(cloud, "mint_git_token", fail_if_called) + monkeypatch.setattr(cloud, "run_git", fail_if_called) + + with pytest.raises(cloud.CloudError): + cloud.login( + host="https://cloud.getwren.ai", + project_id="16", + api_key="sk-test", + ) + + # Nothing stored either: a refused login leaves no trace to undo. + assert not cloud._CLOUD_FILE.exists() + + +def test_create_refuses_before_any_server_call_when_the_helper_is_unserviceable( + tmp_path, monkeypatch +): + _unserviceable_helper(monkeypatch) + + def fail_if_called(*args, **kwargs): + raise AssertionError("no project may be created when the helper cannot work") + + monkeypatch.setattr(cloud, "create_project", fail_if_called) + monkeypatch.setattr(cloud, "mint_project_key", fail_if_called) + + target = _make_wren_project(tmp_path / "project") + + with pytest.raises(cloud.CloudError): + cloud.create( + target, + host="https://cloud.getwren.ai", + org_id="2", + org_key="osk-x", + display_name="proj", + ) + + +# ── the helper's own failure output ────────────────────────────────────────── + + +def test_helper_failure_note_identifies_the_tool_and_the_executable_that_ran(): + from wren import __version__ + + note = cloud.helper_failure_note("No stored Wren Cloud login for project 16.") + + assert "wren cloud git-credential" in note + assert __version__ in note + # The original cause survives verbatim... + assert "No stored Wren Cloud login for project 16." in note + # ...and the note says why the user is about to see a git error too. + assert "git" in note.lower() + + +def test_link_sets_upstream_when_already_linked_but_tracking_was_never_set(tmp_path): + """The state the conflict message itself sends users into. + + A conflicted bind raises before upstream is set. The user resolves and + commits, as instructed — now `origin/` is an ancestor of HEAD, + so `link` short-circuits to ALREADY_LINKED and points at `git pull`. + Without setting upstream here, that `git pull` (and any `git push`) + cannot run, which makes the success message untrue. + """ + git_host = str(tmp_path / "host") + remote = tmp_path / "host" / "git" / "shared-data.git" + _seed_remote(remote) + + target = _make_wren_project(tmp_path / "project") + (target / "mine.txt").write_text("my existing file") + + # Reproduce the aftermath by hand rather than through link(), so the + # test does not depend on how the merge came to be resolved: HEAD + # contains origin/main, and upstream was never set. + cloud.run_git(["init"], cwd=target) + cloud.run_git(["add", "-A"], cwd=target) + cloud.run_git(["commit", "-m", "Existing local project"], cwd=target) + cloud.run_git( + ["remote", "add", "origin", f"{git_host}/git/shared-data.git"], cwd=target + ) + cloud.run_git(["fetch", "origin"], cwd=target) + cloud.run_git( + ["merge", "--allow-unrelated-histories", "origin/main", "-m", "merged by hand"], + cwd=target, + ) + assert not cloud._has_upstream(target), "precondition: no tracking branch yet" + + outcome = _link(target, git_host=git_host) + + # Still reported as already-linked — this is not a fresh bind... + assert outcome is cloud.LinkOutcome.ALREADY_LINKED + # ...but the directory is now actually usable with plain git. + assert cloud._has_upstream(target) + upstream = cloud.run_git( + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], cwd=target + ).stdout.strip() + assert upstream == "origin/main" + + +def test_link_leaves_an_existing_upstream_alone(tmp_path): + git_host = str(tmp_path / "host") + remote = tmp_path / "host" / "git" / "shared-data.git" + _seed_remote(remote) + # A second branch on the remote, so "some other upstream" is a real ref + # rather than a value git would reject. + cloud.run_git(["branch", "other"], cwd=remote) + + target = tmp_path / "project" + cloud.run_git(["clone", f"{git_host}/git/shared-data.git", str(target)]) + cloud.run_git(["fetch", "origin"], cwd=target) + cloud.run_git(["branch", "--set-upstream-to=origin/other"], cwd=target) + + outcome = _link(target, git_host=git_host) + + assert outcome is cloud.LinkOutcome.ALREADY_LINKED + upstream = cloud.run_git( + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], cwd=target + ).stdout.strip() + assert upstream == "origin/other", "a deliberate upstream must not be re-pointed" + + +# ── Reading the binding back out of a directory ──────────────────────────── +# +# The binding is the git remote and nothing else, so these are the only way +# to answer "what is this directory bound to?". They drive real git. + + +def _seed_hooked_remote(remote_dir, *, marker="a"): + """A seeded remote that also carries `.hooks/deploy-modeling.yaml`. + + The real server seeds that file when a project is created, and it is the + marker `head_has_seeded_hooks` keys off. `_seed_remote` above deliberately + does not write it, so tests that need "this history came from a project" + ask for it explicitly rather than every existing link test acquiring one. + + `marker` must differ between two remotes standing in for two different + projects. Without it both seed commits get identical content, message and + author within the same second, so git assigns them the *same SHA* — and + each then looks like an ancestor of the other, which silently turns an + unrelated-history test into a no-op fast-forward. + + It varies only the commit *message*, deliberately. The real server seeds + a hook file with no project-specific content, so two projects' hook files + are byte-identical — and a duplicate merge therefore has nothing to + conflict over. Putting the marker in the file instead would manufacture a + conflict that real projects never produce. + """ + remote_dir.mkdir(parents=True) + cloud.run_git(["init", "-b", "main"], cwd=remote_dir) + # Same reason as `_seed_remote`: `create` pushes, and a non-bare stand-in + # would refuse the push into its checked-out branch. + cloud.run_git( + ["config", "receive.denyCurrentBranch", "updateInstead"], cwd=remote_dir + ) + (remote_dir / ".hooks").mkdir() + (remote_dir / ".hooks" / "deploy-modeling.yaml").write_text( + "version: '1'\nactions:\n - name: deploy-modeling\n" + ) + cloud.run_git(["add", "-A"], cwd=remote_dir) + cloud.run_git(["commit", "-m", f"Initialize hooks ({marker})"], cwd=remote_dir) + + +def test_current_remote_url_is_none_without_an_origin(tmp_path): + target = tmp_path / "proj" + target.mkdir() + cloud.run_git(["init"], cwd=target) + assert cloud.current_remote_url(target) is None + + +def test_current_remote_url_returns_the_origin_it_was_given(tmp_path): + target = tmp_path / "proj" + target.mkdir() + cloud.run_git(["init"], cwd=target) + cloud.run_git( + [ + "remote", + "add", + "origin", + "https://wren.example/git/org/2/16/shared-data.git", + ], + cwd=target, + ) + assert ( + cloud.current_remote_url(target) + == "https://wren.example/git/org/2/16/shared-data.git" + ) + + +def test_binding_from_remote_url_parses_a_wren_remote(): + assert cloud.binding_from_remote_url( + "https://wren.example/git/org/2/16/shared-data.git" + ) == ("https://wren.example", "2", "16", "shared-data.git") + + +def test_binding_from_remote_url_keeps_a_port_in_the_host(): + # git_host must round-trip to the same string `credentials` is keyed by, + # which for a local setup includes the port. + assert cloud.binding_from_remote_url( + "http://localhost:8081/git/org/2/16/shared-data.git" + ) == ("http://localhost:8081", "2", "16", "shared-data.git") + + +@pytest.mark.parametrize( + "url", + [ + "https://github.com/acme/analytics.git", + "git@github.com:acme/analytics.git", + "https://wren.example/not/a/project/path", + "", + ], +) +def test_binding_from_remote_url_is_none_for_a_non_wren_remote(url): + # A directory pointing at GitHub is bound to something, just not to us; + # callers must be able to tell that apart from "bound to project X". + assert cloud.binding_from_remote_url(url) is None + + +def test_head_has_seeded_hooks_is_false_for_a_local_only_history(tmp_path): + target = tmp_path / "proj" + target.mkdir() + cloud.run_git(["init"], cwd=target) + (target / "model.yml").write_text("name: orders\n") + cloud.run_git(["add", "-A"], cwd=target) + cloud.run_git(["commit", "-m", "local"], cwd=target) + assert cloud.head_has_seeded_hooks(target) is False + + +def test_head_has_seeded_hooks_is_true_after_acquiring_a_project(tmp_path): + git_host = str(tmp_path / "host") + _seed_hooked_remote(tmp_path / "host" / "git" / "shared-data.git") + target = tmp_path / "proj" + _link(target, git_host=git_host) + assert cloud.head_has_seeded_hooks(target) is True + + +# ── remove_login ─────────────────────────────────────────────────────────── + + +def _store(git_host, project_id, *, api_host="https://api.example"): + cloud.store_login( + git_host=git_host, + api_host=api_host, + project_id=project_id, + org_id="2", + repo=f"org/2/{project_id}/shared-data.git", + api_key=f"sk-{project_id}", + ) + + +def test_remove_login_drops_the_entry_and_prunes_the_emptied_host(): + _store("https://wren.example", "16") + assert cloud.remove_login("https://wren.example", "16") is True + assert cloud.list_logins() == [] + # The host key must go too, or "does any login still use this host?" — + # which gates removing the shared credential-helper entry — answers wrongly. + assert "https://wren.example" not in cloud._load_store()["credentials"] + + +def test_remove_login_keeps_other_projects_on_the_same_host(): + _store("https://wren.example", "16") + _store("https://wren.example", "17") + cloud.remove_login("https://wren.example", "16") + remaining = [(host, pid) for host, pid, _entry in cloud.list_logins()] + assert remaining == [("https://wren.example", "17")] + + +def test_remove_login_accepts_an_int_project_id(): + # store_login coerces with str(); a delete that did not would silently + # fail to match its own entry. + _store("https://wren.example", "16") + assert cloud.remove_login("https://wren.example", 16) is True + + +def test_remove_login_returns_false_when_nothing_matches(): + _store("https://wren.example", "16") + assert cloud.remove_login("https://wren.example", "999") is False + assert cloud.remove_login("https://other.example", "16") is False + assert len(cloud.list_logins()) == 1, "a miss must not remove anything" + + +# ── link and create: the binding-lifecycle guards ────────────────────────── +# +# `link` used to only ever *add* `origin`, never check an existing one, so a +# directory bound to project A could be handed project B and silently stay on +# A while the caller reported success for B. Via `create` that also left B +# orphaned server-side. + + +def test_link_refuses_when_origin_points_at_a_different_project(tmp_path): + git_host = str(tmp_path / "host") + _seed_remote(tmp_path / "host" / "git" / "shared-data.git") + target = tmp_path / "proj" + target.mkdir() + cloud.run_git(["init"], cwd=target) + cloud.run_git( + ["remote", "add", "origin", f"{git_host}/git/org/2/99/other.git"], cwd=target + ) + + with pytest.raises(cloud.CloudError) as exc: + _link(target, git_host=git_host) + + message = str(exc.value) + assert "99" in message, "must name the project the directory is actually on" + assert "unlink" in message, "must say how to proceed" + # And the remote must be left exactly as it was, not half-repointed. + assert cloud.current_remote_url(target) == f"{git_host}/git/org/2/99/other.git" + + +def test_link_refuses_to_merge_a_history_acquired_from_another_project(tmp_path): + """The F2 path: even with `origin` correctly removed first, the local + history still belongs to the old project, and merging would combine two + projects' content and publish it on the next push.""" + git_host = str(tmp_path / "host") + _seed_hooked_remote(tmp_path / "host" / "git" / "shared-data.git", marker="16") + _seed_hooked_remote( + tmp_path / "host" / "git" / "org" / "2" / "99" / "other.git", marker="99" + ) + + target = tmp_path / "proj" + _link(target, git_host=git_host) + # Unbind by hand, exactly as a user would before re-binding elsewhere. + cloud.run_git(["remote", "remove", "origin"], cwd=target) + + with pytest.raises(cloud.CloudError) as exc: + _link(target, git_host=git_host, repo="org/2/99/other.git") + + message = str(exc.value) + assert "clean directory" in message + # No merge may have happened. + assert cloud.run_git(["log", "--oneline"], cwd=target).stdout.count("\n") == 1 + + +def test_link_still_adopts_a_pristine_local_project(tmp_path): + """The negative case for the guard above — the whole reason `link` + exists is adopting a local project, and that must be unaffected.""" + git_host = str(tmp_path / "host") + _seed_remote(tmp_path / "host" / "git" / "shared-data.git") + target = tmp_path / "proj" + target.mkdir() + (target / "model.yml").write_text("name: orders\n") + + assert _link(target, git_host=git_host) is cloud.LinkOutcome.LINKED + assert (target / "model.yml").exists(), "the user's own files must survive" + assert (target / "seed.txt").exists(), "the remote's content must arrive" + + +def test_create_refuses_a_bound_directory_without_creating_anything( + tmp_path, monkeypatch, _helper_check_passes +): + """AC: the refusal must happen before the server is touched. This is the + defect that produced a real orphaned project on staging.""" + + def fail_if_called(*args, **kwargs): + raise AssertionError( + "create_project must not be reached: the guard exists precisely so " + "that a refusal leaves no project behind" + ) + + monkeypatch.setattr(cloud, "create_project", fail_if_called) + monkeypatch.setattr(cloud, "mint_project_key", fail_if_called) + + target = tmp_path / "proj" + target.mkdir() + cloud.run_git(["init"], cwd=target) + cloud.run_git( + [ + "remote", + "add", + "origin", + "https://wren.example/git/org/2/16/shared-data.git", + ], + cwd=target, + ) + + with pytest.raises(cloud.CloudError) as exc: + cloud.create( + target, + host="https://wren.example", + org_id="2", + org_key="osk-key", + display_name="proj", + ) + + message = str(exc.value) + assert "16" in message, "must name the project the directory is bound to" + assert "Nothing has been created on the server." in message + + +def test_create_duplicates_a_project_from_a_directory_that_came_from_one( + tmp_path, monkeypatch, _helper_check_passes +): + """Unlink then create is how a project gets duplicated, so a history that + came from another project must NOT be refused here. + + The refusal that does exist protects a remote which already has content + from being merged with an unrelated history. A project created moments + ago holds only its seed commit, so there is nothing to protect — and + refusing would also do it *after* the project exists, orphaning it. + """ + git_host = str(tmp_path / "host") + # The directory's history comes from project 16... + _seed_hooked_remote(tmp_path / "host" / "git" / "shared-data.git", marker="16") + target = tmp_path / "project" + _link(target, git_host=git_host) + (target / "mine.txt").write_text("content worth duplicating") + # The duplicate is still a Wren project being converted, so it carries one. + _make_wren_project(target) + cloud.run_git(["add", "-A"], cwd=target) + cloud.run_git(["commit", "-m", "my work"], cwd=target) + assert cloud.head_has_seeded_hooks(target), "precondition: history from a project" + + # ...the user unlinks, then creates project 99 to duplicate it into. + cloud.unlink(target) + _seed_hooked_remote( + tmp_path / "host" / "git" / "org" / "2" / "99" / "shared-data.git", marker="99" + ) + created, calls = _patch_create_http(monkeypatch) + monkeypatch.setattr( + cloud, + "mint_git_token", + lambda *a, **k: cloud.GitToken( + repo="org/2/99/shared-data.git", + token="t", + expires_in=600, + expires_at="", + ), + ) + + project, outcome = cloud.create( + target, + host=git_host, + org_id="2", + org_key="osk-key", + display_name="dup", + git_host=git_host, + ) + + assert outcome is cloud.LinkOutcome.LINKED + assert calls["create_project"] == 1, "the project must actually be created" + assert (target / "mine.txt").exists(), "the content being duplicated survives" + assert cloud.current_remote_url(target).endswith("org/2/99/shared-data.git") + + +# ── unlink / logout ──────────────────────────────────────────────────────── + + +def _helper_section_exists(git_host): + return ( + cloud.run_git( + ["config", "--global", "--get-all", f"credential.{git_host}.helper"], + check=False, + ).returncode + == 0 + ) + + +def test_unlink_removes_the_remote_and_names_the_project(tmp_path): + git_host = str(tmp_path / "host") + _seed_remote(tmp_path / "host" / "git" / "org" / "2" / "16" / "shared-data.git") + target = tmp_path / "proj" + _link(target, git_host=git_host, repo="org/2/16/shared-data.git") + + outcome = cloud.unlink(target) + + assert outcome.project_id == "16" + assert cloud.current_remote_url(target) is None, "the binding must be gone" + + +def test_unlink_keeps_the_stored_key_by_default(tmp_path): + """Another directory may still be bound to the same project, so + unbinding one must not revoke the credential.""" + git_host = str(tmp_path / "host") + _seed_remote(tmp_path / "host" / "git" / "org" / "2" / "16" / "shared-data.git") + _store(git_host, "16") + target = tmp_path / "proj" + _link(target, git_host=git_host, repo="org/2/16/shared-data.git") + + outcome = cloud.unlink(target) + + assert outcome.key_forgotten is False + assert len(cloud.list_logins()) == 1 + + +def test_unlink_forget_key_drops_the_key_and_the_last_helper(tmp_path): + git_host = str(tmp_path / "host") + _seed_remote(tmp_path / "host" / "git" / "org" / "2" / "16" / "shared-data.git") + _store(git_host, "16") + cloud.configure_git_credential_helper(git_host) + assert _helper_section_exists(git_host), "precondition: helper configured" + target = tmp_path / "proj" + _link(target, git_host=git_host, repo="org/2/16/shared-data.git") + + outcome = cloud.unlink(target, forget_key=True) + + assert outcome.key_forgotten is True + assert outcome.helper_removed is True + assert cloud.list_logins() == [] + assert not _helper_section_exists(git_host) + + +def test_unlink_forget_key_keeps_the_helper_while_the_host_is_still_used(tmp_path): + """The helper entry is host-scoped, so removing it while another project + on the same host is still bound would break that project's auth too.""" + git_host = str(tmp_path / "host") + _seed_remote(tmp_path / "host" / "git" / "org" / "2" / "16" / "shared-data.git") + _store(git_host, "16") + _store(git_host, "17") + cloud.configure_git_credential_helper(git_host) + target = tmp_path / "proj" + _link(target, git_host=git_host, repo="org/2/16/shared-data.git") + + outcome = cloud.unlink(target, forget_key=True) + + assert outcome.key_forgotten is True + assert outcome.helper_removed is False + assert _helper_section_exists(git_host), "project 17 still needs this helper" + + +def test_unlink_refuses_a_directory_that_is_not_bound(tmp_path): + target = tmp_path / "proj" + target.mkdir() + cloud.run_git(["init"], cwd=target) + with pytest.raises(cloud.CloudError, match="not bound"): + cloud.unlink(target) + + +def test_unlink_then_link_rebinds_the_same_project_cleanly(tmp_path): + """Unbind/re-bind must round-trip, or the guards would leave users stuck.""" + git_host = str(tmp_path / "host") + _seed_remote(tmp_path / "host" / "git" / "shared-data.git") + target = tmp_path / "proj" + _link(target, git_host=git_host) + + cloud.unlink(target) + assert _link(target, git_host=git_host) is cloud.LinkOutcome.ALREADY_LINKED + assert cloud._has_upstream(target), "upstream must be restored" + + +def test_logout_drops_the_login_and_leaves_the_directory_bound(tmp_path): + """`logout` is not `unlink`: it discards the credential, and a bound + directory keeps its remote and simply stops being able to authenticate.""" + git_host = str(tmp_path / "host") + _seed_remote(tmp_path / "host" / "git" / "org" / "2" / "16" / "shared-data.git") + _store(git_host, "16") + cloud.configure_git_credential_helper(git_host) + target = tmp_path / "proj" + _link(target, git_host=git_host, repo="org/2/16/shared-data.git") + + login_removed, helper_removed = cloud.logout(git_host, "16") + + assert (login_removed, helper_removed) == (True, True) + assert cloud.list_logins() == [] + assert cloud.current_remote_url(target) is not None, "no directory is touched" + + +def test_logout_keeps_the_helper_while_another_login_uses_the_host(): + git_host = "https://wren.example" + _store(git_host, "16") + _store(git_host, "17") + cloud.configure_git_credential_helper(git_host) + + login_removed, helper_removed = cloud.logout(git_host, "16") + + assert login_removed is True + assert helper_removed is False + assert _helper_section_exists(git_host) + + +def test_link_rebinds_the_same_project_when_the_local_copy_is_behind(tmp_path): + """Regression: the foreign-history guard must not fire on a re-bind to + the same project. + + Found by live testing, not by the test above it: "origin/ is not + an ancestor of HEAD" is false both for unrelated histories *and* for a + clone that is simply behind. Keying the refusal off that alone refused + the ordinary unlink/re-bind recovery path, which is the one thing the + guard had to leave working. + """ + git_host = str(tmp_path / "host") + remote = tmp_path / "host" / "git" / "shared-data.git" + _seed_hooked_remote(remote, marker="16") + + target = tmp_path / "proj" + _link(target, git_host=git_host) + + # The remote moves on, so the local copy is now strictly behind it. + (remote / "models.yml").write_text("name: orders\n") + cloud.run_git(["add", "-A"], cwd=remote) + cloud.run_git(["commit", "-m", "server-side change"], cwd=remote) + + cloud.unlink(target) + assert _link(target, git_host=git_host) is cloud.LinkOutcome.LINKED + assert (target / "models.yml").exists(), "the newer remote content arrived" + + +def test_link_leaves_no_origin_behind_when_it_refuses_a_foreign_history(tmp_path): + """A refusal must not strand the directory pointing at a project it was + never bound to — the module's contract is that refusals leave nothing to + undo.""" + git_host = str(tmp_path / "host") + _seed_hooked_remote(tmp_path / "host" / "git" / "shared-data.git", marker="16") + _seed_hooked_remote( + tmp_path / "host" / "git" / "org" / "2" / "99" / "other.git", marker="99" + ) + target = tmp_path / "proj" + _link(target, git_host=git_host) + cloud.run_git(["remote", "remove", "origin"], cwd=target) + + with pytest.raises(cloud.CloudError): + _link(target, git_host=git_host, repo="org/2/99/other.git") + + assert cloud.current_remote_url(target) is None, ( + "the origin added during the attempt must be rolled back" + ) + + +def test_create_project_rejection_names_what_is_actually_known(monkeypatch): + """A 401 here has several possible causes — a key of the wrong level, a + revoked one, or one belonging to another org or host. The message must + not assert one of them, and in particular must not tell the user they + passed a project key when they may not have.""" + + class _Resp: + status_code = 401 + text = "unauthorized" + + def json(self): + return {} + + monkeypatch.setattr(requests, "post", lambda *a, **k: _Resp()) + + with pytest.raises(cloud.InvalidApiKeyError) as exc: + cloud.create_project( + "https://wren.example", + "osk-revoked", + org_id="190", + display_name="proj", + ) + + message = str(exc.value) + assert "not a project key" not in message + assert "190" in message, "must name the org it was rejected for" + assert "--org-key" in message, "must say how to supply a different key" + + +def test_create_refuses_without_a_git_identity_before_creating_anything( + tmp_path, monkeypatch, _helper_check_passes +): + """Found by live testing, not by this suite: on a machine with no git + identity, `create` built the project and then failed inside the merge, + leaving it orphaned. The identity is needed because binding a directory + with files records commits, so it belongs in the pre-flight.""" + + def fail_if_called(*args, **kwargs): + raise AssertionError( + "create_project must not be reached: a missing git identity has to " + "be caught before anything exists server-side" + ) + + monkeypatch.setattr(cloud, "create_project", fail_if_called) + monkeypatch.setattr(cloud, "mint_project_key", fail_if_called) + # The autouse fixture sets an identity via env vars; strip it so git has + # none from any source. + for var in ( + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_COMMITTER_NAME", + "GIT_COMMITTER_EMAIL", + "EMAIL", + ): + monkeypatch.delenv(var, raising=False) + + target = tmp_path / "proj" + target.mkdir() + (target / "model.yml").write_text("name: orders\n") + + with pytest.raises(cloud.CloudError) as exc: + cloud.create( + target, + host="https://wren.example", + org_id="2", + org_key="osk-key", + display_name="proj", + ) + assert "identity" in str(exc.value) + assert "user.email" in str(exc.value), "must say how to fix it" + + +def test_git_identity_check_exempts_an_empty_directory(tmp_path, monkeypatch): + """An empty target is cloned, and a clone records no commit — requiring an + identity there would refuse a case that works fine.""" + for var in ( + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_COMMITTER_NAME", + "GIT_COMMITTER_EMAIL", + "EMAIL", + ): + monkeypatch.delenv(var, raising=False) + empty = tmp_path / "empty" + empty.mkdir() + cloud.check_git_identity_usable(empty) # must not raise + cloud.check_git_identity_usable(tmp_path / "does-not-exist") # nor here + + +# ── normalize_host ───────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("given", "expected"), + [ + ("cloud.getwren.ai", "https://cloud.getwren.ai"), + ("cloud.getwren.ai/", "https://cloud.getwren.ai"), + (" cloud.getwren.ai ", "https://cloud.getwren.ai"), + ("https://cloud.getwren.ai", "https://cloud.getwren.ai"), + # An explicit scheme is never overridden — a local stack is plain http + # and must stay reachable. + ("http://localhost:3000", "http://localhost:3000"), + ("http://localhost:3000/", "http://localhost:3000"), + ], +) +def test_normalize_host(given, expected): + assert cloud.normalize_host(given) == expected + + +def test_create_names_the_project_when_the_bind_fails( + tmp_path, monkeypatch, _helper_check_passes +): + """A bind failure after creation must not surface as a bare git error. + + Observed live: a transient fetch failure against a just-created repo + produced `git fetch origin failed: ...` and nothing else, leaving a real + project in the org with no indication it had been created, what its id + was, or how to finish. The pre-flight checks cannot cover every way git + can fail, so this path has to report rather than be prevented. + """ + _patch_create_http(monkeypatch) + monkeypatch.setattr( + cloud, + "mint_git_token", + lambda *a, **k: cloud.GitToken( + repo="org/2/16/shared-data.git", token="t", expires_in=600, expires_at="" + ), + ) + monkeypatch.setattr(cloud, "configure_git_credential_helper", lambda git_host: None) + + def fake_link(*args, **kwargs): + raise cloud.GitCommandError( + "git fetch origin failed:\nfatal: protocol error: bad line " + "length character: PACK" + ) + + monkeypatch.setattr(cloud, "link", fake_link) + + target = _make_wren_project(tmp_path / "proj") + (target / "model.yml").write_text("name: orders\n") + + with pytest.raises(cloud.CloudError) as exc: + cloud.create( + target, + host="https://wren.example", + org_id="2", + org_key="osk-key", + display_name="proj", + ) + + message = str(exc.value) + assert "16" in message, "must name the project that now exists" + assert "wren cloud link" in message, "must say what completes the bind" + assert "bad line length character: PACK" in message, ( + "the underlying git error must still be visible" + ) + + +# ── `create` converts a local project, so there must be one ──────────────── +# +# These pin the half of `create` that changed when it stopped being "make an +# empty cloud project" and became "convert the project in this directory". + + +def test_create_refuses_a_directory_that_is_not_a_wren_project( + tmp_path, monkeypatch, _helper_check_passes +): + target = tmp_path / "just-a-folder" + target.mkdir() + + def fail_if_called(*args, **kwargs): + raise AssertionError("nothing may be created for a non-project directory") + + monkeypatch.setattr(cloud, "create_project", fail_if_called) + + with pytest.raises(cloud.CloudError) as exc: + cloud.create( + target, + host="https://cloud.getwren.ai", + org_id="2", + org_key="osk-x", + display_name="proj", + ) + + message = str(exc.value) + assert "wren_project.yml" in message + assert "wren context init" in message, "must say how to get one" + + +def test_create_refuses_a_project_that_does_not_compile( + tmp_path, monkeypatch, _helper_check_passes +): + target = _make_wren_project(tmp_path / "proj") + # Malformed YAML. Chosen after checking what actually fails: the loader + # deliberately skips non-mapping entries and tolerates a missing `name`, + # so those inputs would leave the build green and this test passing for + # no reason. + (target / "models" / "t" / "metadata.yml").write_text("name: [unclosed\n") + + def fail_if_called(*args, **kwargs): + raise AssertionError("a project that will not deploy must not be created") + + monkeypatch.setattr(cloud, "create_project", fail_if_called) + + with pytest.raises(cloud.CloudError) as exc: + cloud.create( + target, + host="https://cloud.getwren.ai", + org_id="2", + org_key="osk-x", + display_name="proj", + ) + assert "Nothing was created" in str(exc.value) + + +def test_check_project_builds_leaves_no_build_artifact(tmp_path): + """`target/mdl.json` is not ignored by the scaffold, so writing it here + would push a build artifact into the project's repository.""" + target = _make_wren_project(tmp_path / "proj") + + cloud.check_project_builds(target) + + assert not (target / "target").exists() + + +def test_check_project_builds_does_not_adopt_an_ancestors_project(tmp_path): + """`context.discover_project_path()` walks up from the cwd. Using it here + would let a bare subdirectory convert its parent's models into a new cloud + project — someone else's manifest, silently.""" + _make_wren_project(tmp_path / "outer") + target = tmp_path / "outer" / "inner" + target.mkdir() + + with pytest.raises(cloud.CloudError) as exc: + cloud.check_project_builds(target) + assert "wren_project.yml" in str(exc.value) + + +def test_create_pushes_so_the_models_deploy( + tmp_path, monkeypatch, _helper_check_passes +): + """The models reach the cloud through git. Without the push the project + exists, is bound, and is empty — the state this command exists to avoid.""" + git_host = str(tmp_path / "host") + remote = tmp_path / "host" / "git" / "org" / "2" / "16" / "shared-data.git" + _seed_remote(remote) + _patch_create_http(monkeypatch) + monkeypatch.setattr( + cloud, + "mint_git_token", + lambda *a, **k: cloud.GitToken( + repo="org/2/16/shared-data.git", + token="t", + expires_in=600, + expires_at="", + ), + ) + + target = _make_wren_project(tmp_path / "project") + + cloud.create( + target, + host=git_host, + org_id="2", + org_key="osk-x", + display_name="proj", + git_host=git_host, + ) + + # Read the model back out of the *remote*: asserting on the local branch + # would pass even if nothing had been pushed. + listed = cloud.run_git(["ls-tree", "-r", "--name-only", "HEAD"], cwd=remote).stdout + assert "models/t/metadata.yml" in listed + assert "wren_project.yml" in listed + + +def test_create_reports_a_push_failure_without_claiming_the_bind_failed( + tmp_path, monkeypatch, _helper_check_passes +): + git_host = str(tmp_path / "host") + _seed_remote(tmp_path / "host" / "git" / "org" / "2" / "16" / "shared-data.git") + _patch_create_http(monkeypatch) + monkeypatch.setattr( + cloud, + "mint_git_token", + lambda *a, **k: cloud.GitToken( + repo="org/2/16/shared-data.git", + token="t", + expires_in=600, + expires_at="", + ), + ) + + real_run_git = cloud.run_git + + def fail_only_push(args, **kwargs): + if args[:1] == ["push"]: + return subprocess.CompletedProcess(args, 1, "", "remote rejected") + return real_run_git(args, **kwargs) + + monkeypatch.setattr(cloud, "run_git", fail_only_push) + + target = _make_wren_project(tmp_path / "project") + + with pytest.raises(cloud.CloudError) as exc: + cloud.create( + target, + host=git_host, + org_id="2", + org_key="osk-x", + display_name="proj", + git_host=git_host, + ) + + message = str(exc.value) + assert "16" in message, "must name the project that now exists" + assert "bound to it" in message, "the bind succeeded — do not imply otherwise" + assert "git push" in message, "must say what finishes the job" + + +# ── The local branch must match the remote's default ─────────────────────── +# +# Wren Cloud seeds `main`. A client whose git still defaults to `master` +# (upstream git's builtin; this machine's is patched to `main`, which is why +# neither the suite nor live testing saw this) ended up with the branch and +# its upstream disagreeing — and every symptom of that looked like success. + + +def test_link_renames_the_local_branch_to_the_remotes_default(tmp_path): + """Forces the mismatch with `git init -b master`, since the ambient + default cannot be relied on to produce one.""" + + git_host = str(tmp_path / "host") + remote = tmp_path / "host" / "git" / "shared-data.git" + _seed_remote(remote) + + target = tmp_path / "proj" + target.mkdir() + cloud.run_git(["init", "-b", "master"], cwd=target) + (target / "mine.txt").write_text("mine") + + outcome = _link(target, git_host=git_host) + assert outcome is cloud.LinkOutcome.LINKED + + branch = cloud.run_git( + ["rev-parse", "--abbrev-ref", "HEAD"], cwd=target + ).stdout.strip() + upstream = cloud.run_git( + ["rev-parse", "--abbrev-ref", "@{upstream}"], cwd=target + ).stdout.strip() + assert branch == "main", "the local branch must take the remote's name" + assert upstream == "origin/main" + + # The point of the rename: `git push` with no arguments has to work, and + # has to land on the branch the remote already has rather than opening a + # second one. Asserting on the branch name alone would miss both. + push = cloud.run_git(["push"], cwd=target, check=False) + assert push.returncode == 0, push.stderr or push.stdout + remote_branches = cloud.run_git( + ["branch", "--format=%(refname:short)"], cwd=remote + ).stdout.split() + assert remote_branches == ["main"], ( + f"a second branch was created: {remote_branches}" + ) + + +def test_link_leaves_a_deliberate_upstream_and_its_branch_name_alone(tmp_path): + """The rename must not override a user who pointed the branch elsewhere — + the same rule the already-linked path follows for upstreams.""" + + git_host = str(tmp_path / "host") + remote = tmp_path / "host" / "git" / "shared-data.git" + _seed_remote(remote) + cloud.run_git(["branch", "other"], cwd=remote) + + target = tmp_path / "proj" + cloud.run_git(["clone", f"{git_host}/git/shared-data.git", str(target)]) + cloud.run_git(["branch", "-m", "mine"], cwd=target) + cloud.run_git(["branch", "--set-upstream-to=origin/other"], cwd=target) + + _link(target, git_host=git_host) + + branch = cloud.run_git( + ["rev-parse", "--abbrev-ref", "HEAD"], cwd=target + ).stdout.strip() + assert branch == "mine", "a deliberate upstream keeps its branch name too" + + +def test_run_git_reports_a_missing_directory_as_a_cloud_error(tmp_path): + """`subprocess` raises FileNotFoundError for a missing cwd, which the CLI + does not catch — so this surfaced as a traceback.""" + + with pytest.raises(cloud.CloudError) as exc: + cloud.run_git(["status"], cwd=tmp_path / "nope") + assert "does not exist" in str(exc.value) + + +def test_link_fills_in_a_repo_that_was_stored_before_it_was_known( + tmp_path, monkeypatch, _helper_check_passes +): + """`create` stores the key before it can know the repo path — only the + git-token call returns that, and it is the call that can fail. The next + use has to complete the record rather than build a broken remote URL from + an empty repo.""" + + cloud.store_key_pending_repo( + git_host="https://cloud.getwren.ai", + api_host="https://cloud.getwren.ai", + project_id="16", + org_id="2", + api_key="sk-x", + ) + entry = cloud.get_login("https://cloud.getwren.ai", "16") + assert entry["repo"] == "", "precondition: the repo is not known yet" + + monkeypatch.setattr( + cloud, + "mint_git_token", + lambda *a, **k: cloud.GitToken( + repo="org/2/16/shared-data.git", token="t", expires_in=600, expires_at="" + ), + ) + + resolved = cloud.resolve_repo("https://cloud.getwren.ai", "16", entry) + + assert resolved == "org/2/16/shared-data.git" + # Written back, so the discovery happens once rather than on every use. + assert ( + cloud.get_login("https://cloud.getwren.ai", "16")["repo"] + == "org/2/16/shared-data.git" + ) + + +def test_resolve_repo_does_not_call_the_api_when_the_repo_is_known(monkeypatch): + def fail_if_called(*args, **kwargs): + raise AssertionError("a complete record must not trigger an API call") + + monkeypatch.setattr(cloud, "mint_git_token", fail_if_called) + + entry = { + "api_host": "https://cloud.getwren.ai", + "org_id": "2", + "repo": "org/2/16/shared-data.git", + "api_key": "sk-x", + } + assert ( + cloud.resolve_repo("https://cloud.getwren.ai", "16", entry) + == "org/2/16/shared-data.git" + ) + + +def test_create_configures_git_even_when_the_key_cannot_be_validated( + tmp_path, monkeypatch, _helper_check_passes +): + """The recovery hint promises `wren cloud link` finishes the job. `login` + writes the credential helper only after the git-token call succeeds — the + call that failed — so storing the key alone left that promise undeliverable: + link would fetch over HTTPS with no helper and git would prompt.""" + + _patch_create_http(monkeypatch) + + def failing_mint(*args, **kwargs): + raise cloud.CloudError("network blip") + + monkeypatch.setattr(cloud, "mint_git_token", failing_mint) + + target = _make_wren_project(tmp_path / "project") + + with pytest.raises(cloud.CloudError): + cloud.create( + target, + host="https://cloud.getwren.ai", + org_id="2", + org_key="osk-x", + display_name="proj", + ) + + # Assert on git's own view, not on our config file: what matters is that + # git resolves a helper for the host the hinted command will talk to. + configured = cloud.run_git( + [ + "config", + "--global", + "--get-urlmatch", + "credential.helper", + "https://cloud.getwren.ai", + ], + check=False, + ) + assert configured.returncode == 0, ( + "git must resolve a credential helper for the host, or the recovery " + "the error message promises cannot authenticate" + ) + assert cloud.HELPER_COMMAND in configured.stdout + + +def test_link_removes_an_origin_it_added_when_the_rename_refuses(tmp_path): + """A refusal must not leave the directory pointing at a project it was + never bound to — the next attempt would refuse "already bound" for a bind + that never happened. Same rule the foreign-history refusal follows.""" + + git_host = str(tmp_path / "host") + _seed_remote(tmp_path / "host" / "git" / "shared-data.git") + + target = tmp_path / "proj" + target.mkdir() + cloud.run_git(["init", "-b", "master"], cwd=target) + (target / "mine.txt").write_text("mine") + cloud.run_git(["add", "-A"], cwd=target) + cloud.run_git(["commit", "-m", "mine"], cwd=target) + # A stray local `main` makes the rename collide. + cloud.run_git(["branch", "main"], cwd=target) + + with pytest.raises(cloud.CloudError) as exc: + _link(target, git_host=git_host) + assert "main" in str(exc.value) + + assert cloud.current_remote_url(target) is None, ( + "the origin this call added must be rolled back" + ) + + +def test_create_names_the_project_when_configuring_git_fails( + tmp_path, monkeypatch, _helper_check_passes +): + """Past the point of no return, so it owes the same naming as every other + post-creation failure. Reachable: `git config --global` fails with "could + not lock config file" when the global config's directory is not writable, + and a read-only home still reads — so the git-identity pre-flight passes + and this is the first write to fail.""" + + _patch_create_http(monkeypatch) + + def failing_configure(git_host): + raise cloud.GitCommandError("git config --global ... failed: could not lock") + + monkeypatch.setattr(cloud, "configure_git_credential_helper", failing_configure) + + target = _make_wren_project(tmp_path / "project") + + with pytest.raises(cloud.CloudError) as excinfo: + cloud.create( + target, + host="https://cloud.getwren.ai", + org_id="2", + org_key="osk-x", + display_name="proj", + ) + + message = str(excinfo.value) + assert "16" in message, "must name the project that now exists" + assert "could not lock" in message, "must keep the underlying git error" + assert "already stored" in message, "must say the key is not lost" + assert "sk-fresh-project-key" not in message + stored = [entry for _host, pid, entry in cloud.list_logins() if pid == "16"] + assert stored and stored[0]["api_key"] == "sk-fresh-project-key" + + +# ── Malformed success responses ──────────────────────────────────────────── +# +# The error paths already tolerate an unexpected body (`_parse_error_code` +# returns None rather than raising). These pin the same for the success paths: +# a 200 carrying an HTML error page — what an ingress in front of the API can +# produce — must not surface as a ValueError or KeyError traceback, because +# that bypasses both the CLI's handler and the credential helper's. + + +def _not_json_response(status: int): + """A success status whose body is not JSON. The status has to match what + each call treats as success — `create_project` accepts 201/207 and rejects + a 200 before it ever parses, so reusing one status would test the wrong + branch for it.""" + + class _Resp: + status_code = status + text = "502 Bad Gateway" + headers: dict = {} + + def json(self): + raise ValueError("Expecting value: line 1 column 1 (char 0)") + + return _Resp() + + +@pytest.mark.parametrize( + "status,call", + [ + pytest.param( + 200, + lambda: cloud.mint_git_token("https://wren.example", "16", "sk-x"), + id="mint_git_token", + ), + pytest.param( + 201, + lambda: cloud.create_project( + "https://wren.example", "osk-x", org_id="2", display_name="p" + ), + id="create_project", + ), + pytest.param( + 201, + lambda: cloud.mint_project_key("https://wren.example", "16", "osk-x"), + id="mint_project_key", + ), + ], +) +def test_a_success_status_with_a_non_json_body_is_a_cloud_error( + status, call, monkeypatch +): + monkeypatch.setattr(requests, "post", lambda *a, **k: _not_json_response(status)) + + with pytest.raises(cloud.CloudError) as exc: + call() + assert "not JSON" in str(exc.value) + + +def test_mint_git_token_reports_a_response_missing_its_fields(monkeypatch): + """Direct indexing raised KeyError here, which is not a CloudError.""" + + class _Resp: + status_code = 200 + headers: dict = {} + text = "{}" + + def json(self): + return {"token": "t"} # no `repo` + + monkeypatch.setattr(requests, "post", lambda *a, **k: _Resp()) + + with pytest.raises(cloud.CloudError) as exc: + cloud.mint_git_token("https://wren.example", "16", "sk-x") + assert "missing `repo`" in str(exc.value) + + +def test_create_project_reports_a_non_numeric_org_id(monkeypatch): + """`--org acme` reached `int()` and printed a ValueError traceback; the CLI + catches CloudError only.""" + + def fail_if_called(*args, **kwargs): + raise AssertionError("must refuse before any request") + + monkeypatch.setattr(requests, "post", fail_if_called) + + with pytest.raises(cloud.CloudError) as exc: + cloud.create_project( + "https://wren.example", "osk-x", org_id="acme", display_name="p" + ) + assert "numeric organization id" in str(exc.value) + + +def test_a_missing_field_error_never_carries_the_response_values(monkeypatch): + """This runs on the git-token response, so a body with `token` but no + `repo` would put a live credential into an error message — and errors + reach CI logs and bug reports pasted verbatim. Introduced by the fix for + unguarded success responses, caught in review.""" + + class _Resp: + status_code = 200 + headers: dict = {} + text = "{}" + + def json(self): + return {"token": "sk-live-token-value", "expiresIn": 600} + + monkeypatch.setattr(requests, "post", lambda *a, **k: _Resp()) + + with pytest.raises(cloud.CloudError) as exc: + cloud.mint_git_token("https://wren.example", "16", "sk-x") + + message = str(exc.value) + assert "sk-live-token-value" not in message + # Still has to be diagnosable: names the missing key and what was present. + assert "missing `repo`" in message + assert "token" in message, "the key names are the useful part" + + +def test_create_project_rejects_a_non_object_project_value(monkeypatch): + """`data.get("project") or {}` let a truthy non-dict through to `.get()`, + raising AttributeError — which is not a CloudError, so it bypassed the + CLI's handler.""" + + class _Resp: + status_code = 201 + headers: dict = {} + text = "{}" + + def json(self): + return {"project": "invalid"} + + monkeypatch.setattr(requests, "post", lambda *a, **k: _Resp()) + + with pytest.raises(cloud.CloudError) as exc: + cloud.create_project( + "https://wren.example", "osk-x", org_id="2", display_name="p" + ) + assert "not an object" in str(exc.value) + + +def test_scaffold_ignores_the_build_artifact(tmp_path, monkeypatch): + """`wren context build` writes target/mdl.json, and `wren cloud create` + pushes the directory — so without this the project's own repository ends + up carrying its compiled output. Observed on a real create.""" + from typer.testing import CliRunner + + from wren import context_cli + + monkeypatch.chdir(tmp_path) + result = CliRunner().invoke(context_cli.context_app, ["init", "--empty"]) + assert result.exit_code == 0, result.output + + ignored = (tmp_path / ".gitignore").read_text() + assert "target/" in ignored + + # Assert through git, not on the file's text: what matters is that git + # actually ignores the artifact. + (tmp_path / "target").mkdir() + (tmp_path / "target" / "mdl.json").write_text("{}") + cloud.run_git(["init"], cwd=tmp_path) + cloud.run_git(["add", "-A"], cwd=tmp_path) + tracked = cloud.run_git(["ls-files"], cwd=tmp_path).stdout + assert "target/mdl.json" not in tracked + + +def test_scaffold_keeps_an_existing_gitignore(tmp_path, monkeypatch): + """A project that already has one keeps whatever it says.""" + from typer.testing import CliRunner + + from wren import context_cli + + monkeypatch.chdir(tmp_path) + (tmp_path / ".gitignore").write_text("mine\n") + + CliRunner().invoke(context_cli.context_app, ["init", "--empty"]) + + assert (tmp_path / ".gitignore").read_text() == "mine\n" diff --git a/core/wren/tests/unit/test_cloud_cli.py b/core/wren/tests/unit/test_cloud_cli.py new file mode 100644 index 0000000000..fc77d622e4 --- /dev/null +++ b/core/wren/tests/unit/test_cloud_cli.py @@ -0,0 +1,1153 @@ +"""Tests for the `wren cloud` Typer sub-app: option wiring and the +git-credential helper's CLI-level stdin/stdout wrapping. + +The required live checks (real login against a running Wren Cloud stack, +real git clone/push, real nested-directory refusal, ...) are exercised +manually — mocking the `wren.cloud` functions here only +proves the CLI passes options through correctly and prompts when it should, +not that the underlying git/HTTP behavior is correct. The guard and +unbind behaviour itself is covered against real git in ``test_cloud.py``. +""" + +from __future__ import annotations + +import pytest +from typer.testing import CliRunner + +from wren import cloud +from wren.cli import app + +pytestmark = pytest.mark.unit + +runner = CliRunner() + + +# ── login ──────────────────────────────────────────────────────────────── + + +def test_login_prompts_for_key_and_never_takes_it_as_an_argument(monkeypatch): + captured = {} + + def fake_login(*, host, project_id, api_key, git_host): + captured.update( + host=host, project_id=project_id, api_key=api_key, git_host=git_host + ) + return cloud.GitToken( + repo="org/2/16/shared-data.git", + token="t", + expires_in=600, + expires_at="", + ) + + monkeypatch.setattr(cloud, "login", fake_login) + + result = runner.invoke( + app, + [ + "cloud", + "auth", + "add", + "--host", + "https://cloud.getwren.ai", + "--project", + "16", + ], + input="sk-secret\n", + ) + assert result.exit_code == 0, result.output + assert captured == { + "host": "https://cloud.getwren.ai", + "project_id": "16", + "api_key": "sk-secret", + "git_host": None, + } + + +def test_login_passes_through_git_host_override(monkeypatch): + captured = {} + + def fake_login(*, host, project_id, api_key, git_host): + captured["git_host"] = git_host + return cloud.GitToken(repo="r", token="t", expires_in=1, expires_at="") + + monkeypatch.setattr(cloud, "login", fake_login) + result = runner.invoke( + app, + [ + "cloud", + "auth", + "add", + "--host", + "https://api.example.com", + "--project", + "16", + "--git-host", + "http://localhost:8081", + ], + input="sk-secret\n", + ) + assert result.exit_code == 0, result.output + assert captured["git_host"] == "http://localhost:8081" + + +def test_login_rejects_empty_key(monkeypatch): + result = runner.invoke( + app, + [ + "cloud", + "auth", + "add", + "--host", + "https://cloud.getwren.ai", + "--project", + "16", + ], + input="\n", + ) + assert result.exit_code != 0 + + +def test_login_reports_cloud_error_without_traceback(monkeypatch): + def fake_login(**kwargs): + raise cloud.InvalidApiKeyError("This key is not valid for project 16.") + + monkeypatch.setattr(cloud, "login", fake_login) + result = runner.invoke( + app, + [ + "cloud", + "auth", + "add", + "--host", + "https://cloud.getwren.ai", + "--project", + "16", + ], + input="sk-wrong\n", + ) + assert result.exit_code != 0 + assert "not valid for project 16" in result.output + + +# ── link ───────────────────────────────────────────────────────────────── + + +def test_link_errors_when_no_login_is_stored(monkeypatch, tmp_path): + monkeypatch.setattr(cloud, "list_logins", lambda: []) + result = runner.invoke(app, ["cloud", "link", str(tmp_path)]) + assert result.exit_code != 0 + assert "wren cloud auth add" in result.output + + +def test_link_disambiguates_multiple_stored_logins(monkeypatch, tmp_path): + monkeypatch.setattr( + cloud, + "list_logins", + lambda: [ + ("https://a.example.com", "16", {"api_host": "https://a.example.com"}), + ("https://b.example.com", "17", {"api_host": "https://b.example.com"}), + ], + ) + result = runner.invoke(app, ["cloud", "link", str(tmp_path)]) + assert result.exit_code != 0 + # Asserts the behaviour, not the wording: it must refuse rather than pick, + # and it must print the flags that would narrow the choice. + assert "--host" in result.output and "--project" in result.output + + +def test_link_host_filters_on_api_host_not_git_host(monkeypatch, tmp_path): + """`--host` must match what `login --host` was given and what the + disambiguation candidates print (`api_host`), not the internal storage + key (`git_host`) — a login stored under a differing `--git-host` must + still be reachable by the host the user actually typed at login.""" + entry = { + "api_host": "https://cloud.getwren.ai", + "org_id": "2", + "repo": "org/2/16/shared-data.git", + "api_key": "sk-x", + } + monkeypatch.setattr( + cloud, + "list_logins", + lambda: [("https://internal-git.example.com", "16", entry)], + ) + + captured = {} + + def fake_link(directory, *, git_host, api_host, project_id, org_id, repo): + captured.update(git_host=git_host, api_host=api_host) + return cloud.LinkOutcome.LINKED + + monkeypatch.setattr(cloud, "link", fake_link) + + result = runner.invoke( + app, + ["cloud", "link", str(tmp_path), "--host", "https://cloud.getwren.ai"], + ) + assert result.exit_code == 0, result.output + assert captured["git_host"] == "https://internal-git.example.com" + assert captured["api_host"] == "https://cloud.getwren.ai" + + +def test_link_host_still_disambiguates_when_api_hosts_collide(monkeypatch, tmp_path): + """Two logins that share an `api_host` but differ only by `--git-host` + (e.g. a corrected re-login after a wrong `--git-host`) must not be + silently missed — `--host ` still leaves both candidates and + the command must ask the user to disambiguate, not report either a + false "not found" or pick one arbitrarily.""" + monkeypatch.setattr( + cloud, + "list_logins", + lambda: [ + ( + "https://wrong-git.example.com", + "16", + {"api_host": "https://cloud.getwren.ai"}, + ), + ( + "https://right-git.example.com", + "16", + {"api_host": "https://cloud.getwren.ai"}, + ), + ], + ) + result = runner.invoke( + app, + ["cloud", "link", str(tmp_path), "--host", "https://cloud.getwren.ai"], + ) + assert result.exit_code != 0 + # Asserts the behaviour, not the wording: it must refuse rather than pick, + # and it must print the flags that would narrow the choice. + assert "--host" in result.output and "--project" in result.output + + +def test_link_invokes_cloud_link_with_the_single_stored_login(monkeypatch, tmp_path): + entry = { + "api_host": "https://cloud.getwren.ai", + "org_id": "2", + "repo": "org/2/16/shared-data.git", + "api_key": "sk-x", + } + monkeypatch.setattr( + cloud, "list_logins", lambda: [("https://cloud.getwren.ai", "16", entry)] + ) + + captured = {} + + def fake_link(directory, *, git_host, api_host, project_id, org_id, repo): + captured.update( + directory=directory, + git_host=git_host, + api_host=api_host, + project_id=project_id, + org_id=org_id, + repo=repo, + ) + return cloud.LinkOutcome.LINKED + + monkeypatch.setattr(cloud, "link", fake_link) + result = runner.invoke(app, ["cloud", "link", str(tmp_path)]) + assert result.exit_code == 0, result.output + assert captured["git_host"] == "https://cloud.getwren.ai" + assert captured["project_id"] == "16" + assert captured["repo"] == "org/2/16/shared-data.git" + assert "Linked project 16" in result.output + + +def test_link_names_a_real_path_when_the_directory_argument_is_defaulted( + monkeypatch, tmp_path +): + """The directory argument defaults to `.` and every message naming it ends + in a full stop, so an unresolved default renders as `into ..` — which reads + as the parent directory. Observed live against staging.""" + + entry = { + "api_host": "https://cloud.getwren.ai", + "org_id": "2", + "repo": "org/2/16/shared-data.git", + "api_key": "sk-x", + } + monkeypatch.setattr( + cloud, "list_logins", lambda: [("https://cloud.getwren.ai", "16", entry)] + ) + monkeypatch.setattr( + cloud, "link", lambda *a, **k: cloud.LinkOutcome.LINKED + ) + monkeypatch.chdir(tmp_path) + + # No directory argument: exercises the `Path(".")` default. + result = runner.invoke(app, ["cloud", "link"]) + + assert result.exit_code == 0, result.output + assert "into .." not in result.output, ( + "the defaulted directory must not render as a bare dot before the " + f"sentence's full stop: {result.output!r}" + ) + assert str(tmp_path.resolve()) in result.output + + +def test_link_reports_already_linked_without_implying_a_fresh_merge( + monkeypatch, tmp_path +): + entry = { + "api_host": "https://cloud.getwren.ai", + "org_id": "2", + "repo": "org/2/16/shared-data.git", + "api_key": "sk-x", + } + monkeypatch.setattr( + cloud, "list_logins", lambda: [("https://cloud.getwren.ai", "16", entry)] + ) + monkeypatch.setattr( + cloud, "link", lambda *args, **kwargs: cloud.LinkOutcome.ALREADY_LINKED + ) + + result = runner.invoke(app, ["cloud", "link", str(tmp_path)]) + + assert result.exit_code == 0, result.output + assert "already linked" in result.output.lower() + assert "git pull" in result.output + + +def test_link_reports_cloud_error_without_traceback(monkeypatch, tmp_path): + entry = { + "api_host": "https://cloud.getwren.ai", + "org_id": "2", + "repo": "org/2/16/shared-data.git", + "api_key": "sk-x", + } + monkeypatch.setattr( + cloud, "list_logins", lambda: [("https://cloud.getwren.ai", "16", entry)] + ) + + def fake_link(*args, **kwargs): + raise cloud.NestedRepoError(tmp_path, tmp_path.parent) + + monkeypatch.setattr(cloud, "link", fake_link) + result = runner.invoke(app, ["cloud", "link", str(tmp_path)]) + assert result.exit_code != 0 + assert "inside an existing git repository" in result.output + + +# ── create ─────────────────────────────────────────────────────────────── + + +# `create` requires a type and a connection info, so every invocation carries +# them. Kept in one place: they are a precondition of the command, not the +# subject of most of these tests. +CONN_ARGS = ["--type", "BIG_QUERY", "--connection-info", '{"projectId": "p"}'] + + +def test_create_reads_org_key_from_env_and_passes_options_through( + monkeypatch, tmp_path +): + monkeypatch.setenv("WREN_CLOUD_ORG_KEY", "osk-from-env") + captured = {} + + def fake_create(directory, **kwargs): + captured.update(directory=directory, **kwargs) + return ( + cloud.CreatedProject( + id="16", org_id="2", display_name="proj", status="succeeded", errors=[] + ), + cloud.LinkOutcome.LINKED, + ) + + monkeypatch.setattr(cloud, "create", fake_create) + + result = runner.invoke( + app, + [ + "cloud", + "create", + str(tmp_path), + "--host", + "https://cloud.getwren.ai", + "--org", + "2", + *CONN_ARGS, + ], + ) + assert result.exit_code == 0, result.output + assert captured["org_key"] == "osk-from-env" + assert captured["org_id"] == "2" + assert captured["display_name"] == tmp_path.resolve().name + assert "Created project 16" in result.output + assert "Linked project 16" in result.output + + +def test_create_prompts_for_org_key_when_not_given(monkeypatch, tmp_path): + captured = {} + + def fake_create(directory, **kwargs): + captured.update(kwargs) + return ( + cloud.CreatedProject( + id="16", org_id="2", display_name="proj", status="succeeded", errors=[] + ), + cloud.LinkOutcome.LINKED, + ) + + monkeypatch.setattr(cloud, "create", fake_create) + + result = runner.invoke( + app, + ["cloud", "create", str(tmp_path), "--host", "h", "--org", "2", *CONN_ARGS], + input="osk-typed-in\n", + ) + assert result.exit_code == 0, result.output + assert captured["org_key"] == "osk-typed-in" + + +def test_create_rejects_a_project_key_instead_of_an_org_key(tmp_path): + result = runner.invoke( + app, + ["cloud", "create", str(tmp_path), "--host", "h", "--org", "2", *CONN_ARGS], + input="sk-project-key\n", + ) + assert result.exit_code != 0 + assert "organization API key" in result.output + + +def test_create_rejects_both_connection_info_flags_together(tmp_path): + result = runner.invoke( + app, + [ + "cloud", + "create", + str(tmp_path), + "--host", + "h", + "--org", + "2", + "--connection-info", + "{}", + "--connection-info-file", + str(tmp_path / "x.json"), + ], + ) + assert result.exit_code != 0 + assert "at most one of" in result.output + + +@pytest.mark.parametrize( + "args,expected", + [ + pytest.param([], ["--type", "--connection-info"], id="neither"), + pytest.param( + ["--connection-info", '{"host": "db"}'], ["--type"], id="no_type" + ), + pytest.param(["--type", "POSTGRES"], ["--connection-info"], id="no_conn"), + ], +) +def test_create_requires_both_a_type_and_a_connection_info( + args, expected, monkeypatch, tmp_path +): + """A project created without a data source is reported by Wren Cloud as + still needing setup, and this CLI cannot attach one afterwards — so the + command refuses rather than producing one that cannot be used.""" + + def fail_if_called(*a, **k): + raise AssertionError("nothing may be created without a data source") + + monkeypatch.setattr(cloud, "create", fail_if_called) + + result = runner.invoke( + app, + ["cloud", "create", str(tmp_path), "--host", "h", "--org", "2", *args], + input="osk-x\n", + ) + + assert result.exit_code != 0 + for flag in expected: + assert flag in result.output, f"{flag} must be named as missing" + + +def test_create_passes_parsed_connection_info_and_type_through(monkeypatch, tmp_path): + captured = {} + + def fake_create(directory, **kwargs): + captured.update(kwargs) + return ( + cloud.CreatedProject( + id="16", org_id="2", display_name="proj", status="succeeded", errors=[] + ), + cloud.LinkOutcome.LINKED, + ) + + monkeypatch.setattr(cloud, "create", fake_create) + + result = runner.invoke( + app, + [ + "cloud", + "create", + str(tmp_path), + "--host", + "h", + "--org", + "2", + "--type", + "POSTGRES", + "--connection-info", + '{"host": "db"}', + "--test-connection", + ], + input="osk-x\n", + ) + assert result.exit_code == 0, result.output + assert captured["connection_type"] == "POSTGRES" + assert captured["connection_info"] == {"host": "db"} + assert captured["test_connection"] is True + + +def test_create_reports_partial_status_errors_but_still_succeeds(monkeypatch, tmp_path): + def fake_create(directory, **kwargs): + return ( + cloud.CreatedProject( + id="16", + org_id="2", + display_name="proj", + status="partial", + errors=[{"resource": "mdl", "message": "bad mdl"}], + ), + cloud.LinkOutcome.LINKED, + ) + + monkeypatch.setattr(cloud, "create", fake_create) + + result = runner.invoke( + app, + ["cloud", "create", str(tmp_path), "--host", "h", "--org", "2", *CONN_ARGS], + input="osk-x\n", + ) + assert result.exit_code == 0, result.output + assert "mdl" in result.output + assert "bad mdl" in result.output + + +def test_create_reports_cloud_error_without_traceback(monkeypatch, tmp_path): + def fake_create(directory, **kwargs): + raise cloud.CloudError("boom") + + monkeypatch.setattr(cloud, "create", fake_create) + + result = runner.invoke( + app, + ["cloud", "create", str(tmp_path), "--host", "h", "--org", "2", *CONN_ARGS], + input="osk-x\n", + ) + assert result.exit_code != 0 + assert "boom" in result.output + + +# ── git-credential get/store/erase (CLI-level stdin/stdout wrapping) ─────── + + +def test_git_credential_get_writes_credentials_to_stdout(monkeypatch): + monkeypatch.setattr( + cloud, + "git_credential_get", + lambda input_data: "username=x-access-token\npassword=minted\n", + ) + result = runner.invoke( + app, + ["cloud", "git-credential", "get"], + input="protocol=http\nhost=localhost:8081\npath=git/org/2/16/x.git\n\n", + ) + assert result.exit_code == 0, result.output + assert result.output == "username=x-access-token\npassword=minted\n" + + +def test_git_credential_get_error_goes_to_stderr_not_stdout(monkeypatch): + """`get`'s stdout is parsed by git as credential fields, so an error + written there is fed to git as credential data. The assertion has to + separate the streams: `result.output` combines them, so checking it + proves the message exists somewhere, not that it stayed off stdout. + """ + + def raise_it(input_data): + raise cloud.CloudError("No stored Wren Cloud login for project 16.") + + monkeypatch.setattr(cloud, "git_credential_get", raise_it) + result = runner.invoke( + app, + ["cloud", "git-credential", "get"], + input="protocol=http\nhost=localhost:8081\npath=git/org/2/16/x.git\n\n", + ) + assert result.exit_code != 0 + assert "No stored Wren Cloud login" in result.stderr + assert "No stored Wren Cloud login" not in result.stdout + # Nothing at all on stdout: git reads it as `key=value` lines, so even + # unrelated output there is a malformed credential reply. + assert result.stdout == "" + + +def test_git_credential_get_error_says_which_tool_produced_it(monkeypatch): + """git prints this line in among its own output, then fails with a + credentials message of its own. Unlabelled, the user cannot tell that + wren — and which wren — is what needs fixing.""" + from wren import __version__ + + def raise_it(input_data): + raise cloud.CloudError("No stored Wren Cloud login for project 16.") + + monkeypatch.setattr(cloud, "git_credential_get", raise_it) + result = runner.invoke( + app, + ["cloud", "git-credential", "get"], + input="protocol=http\nhost=localhost:8081\npath=git/org/2/16/x.git\n\n", + ) + + assert result.exit_code != 0 + assert "wren cloud git-credential" in result.output + assert __version__ in result.output + + +def test_git_credential_store_produces_no_output(monkeypatch): + monkeypatch.setattr(cloud, "git_credential_store", lambda input_data: None) + result = runner.invoke( + app, + ["cloud", "git-credential", "store"], + input="protocol=http\nhost=localhost:8081\npassword=abc\n\n", + ) + assert result.exit_code == 0, result.output + assert result.output == "" + + +def test_git_credential_erase_produces_no_output(monkeypatch): + monkeypatch.setattr(cloud, "git_credential_erase", lambda input_data: None) + result = runner.invoke( + app, + ["cloud", "git-credential", "erase"], + input="protocol=http\nhost=localhost:8081\npassword=abc\n\n", + ) + assert result.exit_code == 0, result.output + assert result.output == "" + + +# ── unlink ─────────────────────────────────────────────────────────────── + + +def _unlink_outcome(**overrides): + defaults = { + "remote_url": "https://cloud.getwren.ai/git/org/2/16/shared-data.git", + "git_host": "https://cloud.getwren.ai", + "project_id": "16", + "key_forgotten": False, + "helper_removed": False, + } + defaults.update(overrides) + return cloud.UnlinkOutcome(**defaults) + + +def test_unlink_defaults_to_keeping_the_key(monkeypatch, tmp_path): + captured = {} + + def fake_unlink(directory, *, forget_key): + captured.update(directory=directory, forget_key=forget_key) + return _unlink_outcome() + + monkeypatch.setattr(cloud, "unlink", fake_unlink) + result = runner.invoke(app, ["cloud", "unlink", str(tmp_path)]) + + assert result.exit_code == 0, result.output + assert captured["forget_key"] is False, ( + "unbinding one directory must not revoke a key another may still use" + ) + assert "project 16" in result.output + + +def test_unlink_forget_key_confirms_before_dropping_the_key(monkeypatch, tmp_path): + calls = {"n": 0} + + def fake_unlink(directory, *, forget_key): + calls["n"] += 1 + return _unlink_outcome(key_forgotten=True) + + monkeypatch.setattr(cloud, "unlink", fake_unlink) + declined = runner.invoke( + app, ["cloud", "unlink", str(tmp_path), "--forget-key"], input="n\n" + ) + assert declined.exit_code != 0 + assert calls["n"] == 0, "declining the prompt must not drop anything" + + accepted = runner.invoke( + app, ["cloud", "unlink", str(tmp_path), "--forget-key"], input="y\n" + ) + assert accepted.exit_code == 0, accepted.output + assert calls["n"] == 1 + + +def test_unlink_yes_skips_the_confirmation(monkeypatch, tmp_path): + monkeypatch.setattr( + cloud, "unlink", lambda d, *, forget_key: _unlink_outcome(key_forgotten=True) + ) + result = runner.invoke( + app, ["cloud", "unlink", str(tmp_path), "--forget-key", "--yes"] + ) + assert result.exit_code == 0, result.output + assert "Dropped the stored API key" in result.output + + +def test_unlink_reports_a_removed_helper(monkeypatch, tmp_path): + monkeypatch.setattr( + cloud, + "unlink", + lambda d, *, forget_key: _unlink_outcome( + key_forgotten=True, helper_removed=True + ), + ) + result = runner.invoke( + app, ["cloud", "unlink", str(tmp_path), "--forget-key", "--yes"] + ) + assert "credential helper" in result.output + + +def test_unlink_reports_a_non_wren_remote_without_naming_a_project( + monkeypatch, tmp_path +): + """`origin` pointing at GitHub is still removed — the directory is + unbound either way — but naming a project would be a fiction.""" + monkeypatch.setattr( + cloud, + "unlink", + lambda d, *, forget_key: _unlink_outcome( + remote_url="https://github.com/acme/analytics.git", + git_host=None, + project_id=None, + ), + ) + result = runner.invoke(app, ["cloud", "unlink", str(tmp_path)]) + assert result.exit_code == 0, result.output + assert "github.com/acme/analytics.git" in result.output + # `origin` was not a Wren Cloud URL, so there is no project to name and + # naming one would be a fiction. Asserts on the concrete strings a + # regression would produce, rather than slicing the message on a word it + # happens to contain. + assert "project 16" not in result.output + assert "project None" not in result.output + + +def test_unlink_surfaces_a_cloud_error(monkeypatch, tmp_path): + def fake_unlink(directory, *, forget_key): + raise cloud.CloudError("not bound to a Wren Cloud project") + + monkeypatch.setattr(cloud, "unlink", fake_unlink) + result = runner.invoke(app, ["cloud", "unlink", str(tmp_path)]) + assert result.exit_code != 0 + assert "not bound" in result.output + + +# ── logout ─────────────────────────────────────────────────────────────── + + +def test_logout_drops_the_only_stored_login(monkeypatch): + entry = { + "api_host": "https://cloud.getwren.ai", + "org_id": "2", + "repo": "org/2/16/shared-data.git", + "api_key": "sk-x", + } + monkeypatch.setattr( + cloud, "list_logins", lambda: [("https://cloud.getwren.ai", "16", entry)] + ) + captured = {} + + def fake_logout(git_host, project_id): + captured.update(git_host=git_host, project_id=project_id) + return True, False + + monkeypatch.setattr(cloud, "logout", fake_logout) + result = runner.invoke(app, ["cloud", "auth", "remove", "--yes"]) + + assert result.exit_code == 0, result.output + assert captured == {"git_host": "https://cloud.getwren.ai", "project_id": "16"} + assert "Logged out of project 16" in result.output + + +def test_logout_errors_when_no_login_is_stored(monkeypatch): + monkeypatch.setattr(cloud, "list_logins", lambda: []) + result = runner.invoke(app, ["cloud", "auth", "remove", "--yes"]) + assert result.exit_code != 0 + assert "no stored Wren Cloud login" in result.output + + +def test_logout_disambiguates_multiple_stored_logins(monkeypatch): + monkeypatch.setattr( + cloud, + "list_logins", + lambda: [ + ("https://a.example.com", "16", {"api_host": "https://a.example.com"}), + ("https://b.example.com", "17", {"api_host": "https://b.example.com"}), + ], + ) + result = runner.invoke(app, ["cloud", "auth", "remove", "--yes"]) + assert result.exit_code != 0 + # Asserts the behaviour, not the wording: it must refuse rather than pick, + # and it must print the flags that would narrow the choice. + assert "--host" in result.output and "--project" in result.output + + +def test_logout_host_filters_on_api_host_not_git_host(monkeypatch): + """Same contract as `link --host`: the value the user typed at `login` + (`api_host`) must reach the right login even when it was stored under a + differing `git_host`.""" + entry = { + "api_host": "https://cloud.getwren.ai", + "org_id": "2", + "repo": "org/2/16/shared-data.git", + "api_key": "sk-x", + } + monkeypatch.setattr( + cloud, + "list_logins", + lambda: [("https://internal-git.example.com", "16", entry)], + ) + captured = {} + monkeypatch.setattr( + cloud, + "logout", + lambda git_host, project_id: ( + captured.update(git_host=git_host) or (True, False) + ), + ) + result = runner.invoke( + app, ["cloud", "auth", "remove", "--host", "https://cloud.getwren.ai", "--yes"] + ) + assert result.exit_code == 0, result.output + assert captured["git_host"] == "https://internal-git.example.com" + + +def test_logout_confirms_by_default(monkeypatch): + entry = { + "api_host": "https://cloud.getwren.ai", + "org_id": "2", + "repo": "org/2/16/shared-data.git", + "api_key": "sk-x", + } + monkeypatch.setattr( + cloud, "list_logins", lambda: [("https://cloud.getwren.ai", "16", entry)] + ) + calls = {"n": 0} + + def fake_logout(git_host, project_id): + calls["n"] += 1 + return True, False + + monkeypatch.setattr(cloud, "logout", fake_logout) + result = runner.invoke(app, ["cloud", "auth", "remove"], input="n\n") + assert result.exit_code != 0 + assert calls["n"] == 0, "declining the prompt must not drop the key" + + +# ── --host default and normalization ────────────────────────────────────── + + +def _fake_created(): + return cloud.CreatedProject( + id="16", org_id="190", display_name="proj", status="succeeded", errors=[] + ) + + +def test_auth_add_defaults_to_the_managed_host(monkeypatch): + captured = {} + + def fake_login(*, host, project_id, api_key, git_host): + captured.update(host=host) + return cloud.GitToken( + repo="org/2/16/x.git", token="t", expires_in=1, expires_at="" + ) + + monkeypatch.setattr(cloud, "login", fake_login) + result = runner.invoke( + app, ["cloud", "auth", "add", "--project", "16"], input="sk-x\n" + ) + assert result.exit_code == 0, result.output + assert captured["host"] == "https://cloud.getwren.ai" + + +def test_auth_add_gives_a_bare_hostname_a_scheme(monkeypatch): + """`--host` reads as a hostname, so people type one. Without a scheme it + reaches requests as a relative URL and fails in a way that looks like a + bug in the tool.""" + captured = {} + + def fake_login(*, host, project_id, api_key, git_host): + captured.update(host=host) + return cloud.GitToken( + repo="org/2/16/x.git", token="t", expires_in=1, expires_at="" + ) + + monkeypatch.setattr(cloud, "login", fake_login) + result = runner.invoke( + app, + ["cloud", "auth", "add", "--project", "16", "--host", "self.example.com"], + input="sk-x\n", + ) + assert result.exit_code == 0, result.output + assert captured["host"] == "https://self.example.com" + + +def test_auth_add_names_the_host_in_the_key_prompt(monkeypatch): + """The only place a defaulted --host is visible before anything happens, + which matters now that omitting it targets the managed service.""" + monkeypatch.setattr( + cloud, + "login", + lambda **kwargs: cloud.GitToken( + repo="org/2/16/x.git", token="t", expires_in=1, expires_at="" + ), + ) + result = runner.invoke( + app, ["cloud", "auth", "add", "--project", "16"], input="sk-x\n" + ) + assert "https://cloud.getwren.ai" in result.output + + +def test_create_defaults_to_the_managed_host(monkeypatch, tmp_path): + captured = {} + + def fake_create(directory, **kwargs): + captured.update(host=kwargs["host"]) + return _fake_created(), cloud.LinkOutcome.LINKED + + monkeypatch.setattr(cloud, "create", fake_create) + result = runner.invoke( + app, + ["cloud", "create", str(tmp_path), "--org", "190", *CONN_ARGS], + input="osk-x\n", + ) + assert result.exit_code == 0, result.output + assert captured["host"] == "https://cloud.getwren.ai" + + +def test_create_upper_cases_the_data_source_type(monkeypatch, tmp_path): + """`--type big_query` reached the server verbatim and came back as a 207 + with a project that had no data source: the server looks the type up in + its enum by exact key and does not validate it on this endpoint, so a + lowercase value silently produced a half-created project.""" + + captured = {} + + def fake_create(directory, **kwargs): + captured.update(kwargs) + return ( + cloud.CreatedProject( + id="16", org_id="2", display_name="p", status="succeeded", errors=[] + ), + cloud.LinkOutcome.LINKED, + ) + + monkeypatch.setattr(cloud, "create", fake_create) + conn = tmp_path / "conn.json" + conn.write_text('{"projectId": "p", "datasetId": "d"}') + + result = runner.invoke( + app, + [ + "cloud", + "create", + str(tmp_path), + "--host", + "https://cloud.getwren.ai", + "--org", + "2", + "--org-key", + "osk-x", + "--type", + "big_query", + "--connection-info-file", + str(conn), + ], + ) + + assert result.exit_code == 0, result.output + assert captured["connection_type"] == "BIG_QUERY" + + +def test_create_help_names_a_type_the_server_accepts(monkeypatch): + """The help used to say `BIGQUERY`, which is not in the server's enum — + following it produced the same half-created project as the lowercase case.""" + + result = runner.invoke(app, ["cloud", "create", "--help"]) + assert result.exit_code == 0 + rendered = " ".join(result.output.split()) + assert "BIG_QUERY" in rendered + assert "BIGQUERY," not in rendered and "BIGQUERY." not in rendered + + +@pytest.mark.parametrize( + "command,extra", + [ + pytest.param("auth_add", [], id="auth_add"), + pytest.param("create", ["--org", "2", *["--type", "BIG_QUERY"], "--connection-info", '{"projectId": "p"}'], id="create"), + ], +) +def test_git_host_is_normalized_like_host(command, extra, monkeypatch, tmp_path): + """`--git-host` becomes both the git-config section name and the helper's + lookup key. Left scheme-less, git never matches the section it wrote and + the helper looks under a different key — while the command still reports + success. `--host` was already normalized; this is the same treatment.""" + + captured = {} + + def fake_login(*, host, project_id, api_key, git_host=None): + captured["git_host"] = git_host + return cloud.GitToken( + repo="org/2/16/shared-data.git", token="t", expires_in=600, expires_at="" + ) + + def fake_create(directory, **kwargs): + captured["git_host"] = kwargs.get("git_host") + return ( + cloud.CreatedProject( + id="16", org_id="2", display_name="p", status="succeeded", errors=[] + ), + cloud.LinkOutcome.LINKED, + ) + + monkeypatch.setattr(cloud, "login", fake_login) + monkeypatch.setattr(cloud, "create", fake_create) + + args = ["cloud"] + args += ["auth", "add"] if command == "auth_add" else ["create", str(tmp_path)] + args += ["--host", "https://cloud.getwren.ai", "--git-host", "git.example.com"] + if command == "auth_add": + args += ["--project", "16"] + args += extra + + result = runner.invoke(app, args, input="osk-x\n") + + assert result.exit_code == 0, result.output + assert captured["git_host"] == "https://git.example.com", ( + "a scheme-less --git-host must not reach the credential store raw" + ) + + +@pytest.mark.parametrize("command", ["link", "auth_remove"]) +def test_host_filter_matches_a_scheme_less_value(command, monkeypatch, tmp_path): + """`auth add` stores the normalized host, so comparing the raw `--host` + against it made a scheme-less value match nothing — reporting "no stored + login" for a login that exists. The mirror of the writing-side defect.""" + + entry = { + "api_host": "https://cloud.getwren.ai", + "org_id": "2", + "repo": "org/2/16/shared-data.git", + "api_key": "sk-x", + } + monkeypatch.setattr( + cloud, "list_logins", lambda: [("https://cloud.getwren.ai", "16", entry)] + ) + monkeypatch.setattr(cloud, "resolve_repo", lambda *a, **k: entry["repo"]) + monkeypatch.setattr(cloud, "link", lambda *a, **k: cloud.LinkOutcome.LINKED) + monkeypatch.setattr(cloud, "logout", lambda *a, **k: (True, False)) + + args = ( + ["cloud", "link", str(tmp_path)] + if command == "link" + else ["cloud", "auth", "remove"] + ) + # No scheme — what a person types when asked for a host. + args += ["--host", "cloud.getwren.ai", "--project", "16"] + if command == "auth_remove": + args += ["--yes"] + + result = runner.invoke(app, args) + + assert result.exit_code == 0, result.output + assert "no stored login" not in result.output + + +# ── Selecting a stored login ─────────────────────────────────────────────── +# +# The same "no stored login" message has now been reported twice for logins +# that exist: once for a scheme-less `--host`, once for `--host` given the +# *git* host. Both times the filter was right and the message was not, so +# these pin the message. + + +def _two_logins(): + return [ + ( + "https://git.example.com", + "16", + { + "api_host": "https://api.example.com", + "org_id": "2", + "repo": "org/2/16/shared-data.git", + "api_key": "sk-x", + }, + ), + ] + + +@pytest.mark.parametrize("command", ["link", "auth_remove"]) +def test_wrong_host_role_says_which_host_is_wanted(command, monkeypatch, tmp_path): + """The store is keyed by git host; `--host` means the API host. Passing the + git host must not report the project as absent — it exists.""" + + monkeypatch.setattr(cloud, "list_logins", _two_logins) + + args = ( + ["cloud", "link", str(tmp_path)] + if command == "link" + else ["cloud", "auth", "remove", "--yes"] + ) + # The git host, which is what `~/.wren/cloud.yml` is keyed by. + args += ["--host", "https://git.example.com", "--project", "16"] + + result = runner.invoke(app, args) + + assert result.exit_code != 0 + out = result.output + assert "not the git host" in out, "must name the field that failed" + assert "https://api.example.com" in out, "must print what is stored" + assert "auth list" in out, "must point at the command that shows it" + + +@pytest.mark.parametrize("command", ["link", "auth_remove"]) +def test_absent_project_is_reported_as_absent(command, monkeypatch, tmp_path): + """The other branch has to stay distinguishable from the one above.""" + + monkeypatch.setattr(cloud, "list_logins", _two_logins) + + args = ( + ["cloud", "link", str(tmp_path)] + if command == "link" + else ["cloud", "auth", "remove", "--yes"] + ) + args += ["--project", "99999"] + + result = runner.invoke(app, args) + + assert result.exit_code != 0 + assert "no stored Wren Cloud login for project 99999" in result.output + assert "not the git host" not in result.output, "wrong diagnosis for this case" + + +def test_auth_list_shows_both_hosts_and_never_a_key(monkeypatch): + """The reason anyone opened `cloud.yml` was to find a value for `--host`, + and that file is keyed by the git host — so the listing has to show both + columns, labelled.""" + + monkeypatch.setattr(cloud, "list_logins", _two_logins) + + result = runner.invoke(app, ["cloud", "auth", "list"]) + + assert result.exit_code == 0, result.output + assert "https://api.example.com" in result.output + assert "https://git.example.com" in result.output + assert "--host" in result.output, "must label which column --host means" + assert "sk-x" not in result.output, "a key must never be printed" + + +def test_auth_list_says_so_when_nothing_is_stored(monkeypatch): + monkeypatch.setattr(cloud, "list_logins", lambda: []) + + result = runner.invoke(app, ["cloud", "auth", "list"]) + + assert result.exit_code == 0 + assert "No stored" in result.output + assert "auth add" in result.output, "must say how to add one" diff --git a/core/wren/tests/unit/test_profile_cli_flags.py b/core/wren/tests/unit/test_profile_cli_flags.py new file mode 100644 index 0000000000..3e7d8bff2f --- /dev/null +++ b/core/wren/tests/unit/test_profile_cli_flags.py @@ -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" diff --git a/docs/core/reference/cli.md b/docs/core/reference/cli.md index a0c90832c3..6814425ff8 100644 --- a/docs/core/reference/cli.md +++ b/docs/core/reference/cli.md @@ -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.