From 09bd5110936bb90ca464391aaba7e4268850265d Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Wed, 12 Aug 2026 18:37:15 +0800 Subject: [PATCH 01/30] feat(wren): add cloud login/pull and git-credential helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connects a local Wren project to a Wren Cloud project's git remote so that plain `git push`/`git pull`/`git diff` work afterward with no client-side security dependency: - `wren cloud login --host --project ` validates an interactively-prompted API key (never taken as a CLI argument), stores it under ~/.wren/ (0600), and writes a host-scoped `credential..helper` / `useHttpPath=true` entry to the user's global git config. No project id is written into project files — the directory<->project binding is the git remote itself. - `wren cloud pull` acquires the project via plain git: a fresh directory gets a plain clone; a directory with existing files is adopted in place (init + remote add + fetch + merge --allow-unrelated-histories, with upstream tracking set explicitly so a follow-up plain `git push` has somewhere to go). Refuses when the target sits inside another git repository. Never force-pushes or force-overwrites; real conflicts surface as ordinary git conflicts with an explanatory message instead of raw git output. - `wren cloud git-credential get|store|erase` is the helper git invokes: `get` mints a short-TTL token on demand (retrying once on 401/403, backing off on 429 via Retry-After) and never caches it; `store` is a no-op; `erase` only ever drops a cached token, never the underlying API key. All git operations shell out to the system `git` binary. (cherry picked from commit a4ab95d443b293964fef8d78452002a51be0a23b) (cherry picked from commit 5b37b346e6fbe4130a42f3751b4a8e2439f14924) (cherry picked from commit 4d2e1d84a51d4832966c6e79a984adb7e28c669e) --- core/wren/src/wren/cli.py | 4 + core/wren/src/wren/cloud.py | 545 +++++++++++++++++++++++++ core/wren/src/wren/cloud_cli.py | 196 +++++++++ core/wren/tests/unit/test_cloud.py | 278 +++++++++++++ core/wren/tests/unit/test_cloud_cli.py | 232 +++++++++++ 5 files changed, 1255 insertions(+) create mode 100644 core/wren/src/wren/cloud.py create mode 100644 core/wren/src/wren/cloud_cli.py create mode 100644 core/wren/tests/unit/test_cloud.py create mode 100644 core/wren/tests/unit/test_cloud_cli.py 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..674568ad13 --- /dev/null +++ b/core/wren/src/wren/cloud.py @@ -0,0 +1,545 @@ +"""`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 ``login``, 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. + +``login`` writes a URL-scoped credential helper entry (plus ``useHttpPath``) +into the user's *global* git config, not local config — at login time 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. ``pull`` 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 subprocess +import tempfile +import time +from dataclasses import dataclass +from pathlib import Path + +_WREN_HOME = Path(os.environ.get("WREN_HOME", Path.home() / ".wren")) +_CLOUD_FILE = _WREN_HOME / "cloud.yml" + +# 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" + + +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 pull` again." + ) + + +class GitCommandError(CloudError): + """A shelled-out `git` command failed unexpectedly.""" + + +@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 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: + data = resp.json() + return GitToken( + repo=data["repo"], + token=data["token"], + 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 CloudError( + f"Wren Cloud API returned {resp.status_code} minting a git token " + f"for project {project_id} on {api_host}: {resp.text[:300]}" + ) + + +# ── 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 login` 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 + + +# ── Parsing the project out of a git path ─────────────────────────────────── + + +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 configure_git_credential_helper(git_host: str) -> None: + """Write a URL-scoped credential-helper entry into the global git config. + + Both settings are required, for different reasons: + + - The section is scoped to exactly `git_host` — this helper must never + apply to a git host the user did not explicitly log in to. + - `useHttpPath` 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. + + The helper value 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. + """ + section = f"credential.{git_host}" + _git_config_set_global(f"{section}.helper", "!wren cloud git-credential") + _git_config_set_global(f"{section}.useHttpPath", "true") + + +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. + """ + api_host = host.rstrip("/") + resolved_git_host = (git_host or host).rstrip("/") + + 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 + + +# ── 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 login` 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 login` first." + ) + + token = mint_git_token(entry["api_host"], project_id, entry["api_key"]) + return format_credential_output("x-access-token", token.token) + + +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. + """ + 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 `pull` 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 `pull` — 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) + + +# ── pull: acquire the repo correctly, exactly once ────────────────────────── + + +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 pull( + 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, +) -> None: + """Acquire `repo` into `target`, handling both shapes. + + - 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. + + Refuses up front when `target` sits inside another git repository. + """ + 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 + + if not is_repo_here: + run_git(["init"], cwd=target) + # 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) + + remotes = run_git(["remote"], cwd=target).stdout.split() + if "origin" not in remotes: + run_git(["remote", "add", "origin", remote_url], cwd=target) + + run_git(["fetch", "origin"], cwd=target) + + branch = _default_branch(remote_url) + 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}" + ) + + # 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, + ) diff --git a/core/wren/src/wren/cloud_cli.py b/core/wren/src/wren/cloud_cli.py new file mode 100644 index 0000000000..8b028802c5 --- /dev/null +++ b/core/wren/src/wren/cloud_cli.py @@ -0,0 +1,196 @@ +"""Typer sub-app for ``wren cloud`` commands. + +``wren cloud login`` connects a local directory to a Wren Cloud project's +git remote; ``wren cloud pull`` then acquires (or adopts) that project's +files via plain git. After that, ordinary ``git push`` / ``git pull`` / +``git diff`` are the commands — none of the security depends on going +through this CLI again. ``wren cloud git-credential`` is the helper `login` +wires into git; it is not meant to be invoked by hand. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Annotated, Optional + +import typer + +cloud_app = typer.Typer( + name="cloud", + help="Connect a local project to Wren Cloud's git remote.", +) + +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) + + +@cloud_app.command() +def login( + host: Annotated[ + str, + typer.Option("--host", help="Wren Cloud host, e.g. https://cloud.getwren.ai"), + ], + project: Annotated[str, typer.Option("--project", help="Project id")], + 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. + """ + from wren import cloud # noqa: PLC0415 + + api_key = typer.prompt("Wren Cloud API key", 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"Logged in to 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 pull` to fetch it, or `git clone` the remote directly." + ) + + +@cloud_app.command() +def pull( + directory: Annotated[ + Path, + typer.Argument(help="Local directory to pull the project into."), + ] = Path("."), + host: Annotated[ + Optional[str], + typer.Option( + "--host", + help=( + "Wren Cloud host used at login, if you logged in to more " + "than one host. Defaults to the only stored login, 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: + """Acquire a Wren Cloud project's files into a local directory via git. + + 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. + + Requires having run ``wren cloud login`` for the target project first. + """ + from wren import cloud # noqa: PLC0415 + + logins = cloud.list_logins() + if project is not None: + logins = [entry for entry in logins if entry[1] == str(project)] + if host is not None: + logins = [entry for entry in logins if entry[0] == host] + + if not logins: + typer.echo( + "Error: no stored Wren Cloud login found" + + (f" for project {project}" if project else "") + + ". Run `wren cloud login` first.", + err=True, + ) + raise typer.Exit(1) + if len(logins) > 1: + typer.echo( + "Error: more than one stored login matches; disambiguate with " + "--host and/or --project. Candidates:", + err=True, + ) + for git_host, project_id, entry in logins: + typer.echo(f" --host {entry['api_host']} --project {project_id}", err=True) + raise typer.Exit(1) + + git_host, project_id, entry = logins[0] + try: + cloud.pull( + directory, + git_host=git_host, + api_host=entry["api_host"], + project_id=project_id, + org_id=entry["org_id"], + repo=entry["repo"], + ) + except cloud.CloudError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) + + typer.echo(f"Pulled project {project_id} into {directory}.") + + +@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, + read_credential_input, + ) + + input_data = read_credential_input(sys.stdin) + try: + output = git_credential_get(input_data) + except CloudError as exc: + typer.echo(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/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py new file mode 100644 index 0000000000..fb34336e53 --- /dev/null +++ b/core/wren/tests/unit/test_cloud.py @@ -0,0 +1,278 @@ +"""Unit tests for ``wren.cloud`` — path parsing, config writing, and the +git credential helper's stdin/stdout protocol. + +These cover the pure-logic paths only. The seven live checks against a real +Wren Cloud stack (login+clone, push, wrong-key messaging, host-scoping, +fresh-dir pull, existing-dir pull, nested-repo refusal) are exercised +manually, not here — a mocked HTTP/git layer cannot stand in for them. +""" + +from __future__ import annotations + +import io +import subprocess + +import pytest + +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 + + +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): + assert cloud.find_git_root(tmp_path / "nope" / "deeper") is None or True + # tmp_path itself has no .git, but parents might in exotic CI setups; + # what matters is it never raises. + + +# ── 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() 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..5c8dafe434 --- /dev/null +++ b/core/wren/tests/unit/test_cloud_cli.py @@ -0,0 +1,232 @@ +"""Tests for the `wren cloud` Typer sub-app: option wiring and the +git-credential helper's CLI-level stdin/stdout wrapping. + +The seven required live checks (real login against a running Wren Cloud +stack, real git clone/push, real nested-directory refusal, ...) are +exercised manually — mocking `wren.cloud.login`/`pull` here only proves the +CLI passes options through correctly, not that the underlying git/HTTP +behavior is correct. +""" + +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", "login", "--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", + "login", + "--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", "login", "--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", "login", "--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 + + +# ── pull ───────────────────────────────────────────────────────────────── + + +def test_pull_errors_when_no_login_is_stored(monkeypatch, tmp_path): + monkeypatch.setattr(cloud, "list_logins", lambda: []) + result = runner.invoke(app, ["cloud", "pull", str(tmp_path)]) + assert result.exit_code != 0 + assert "wren cloud login" in result.output + + +def test_pull_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", "pull", str(tmp_path)]) + assert result.exit_code != 0 + assert "disambiguate" in result.output.lower() + + +def test_pull_invokes_cloud_pull_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_pull(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, + ) + + monkeypatch.setattr(cloud, "pull", fake_pull) + result = runner.invoke(app, ["cloud", "pull", 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" + + +def test_pull_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_pull(*args, **kwargs): + raise cloud.NestedRepoError(tmp_path, tmp_path.parent) + + monkeypatch.setattr(cloud, "pull", fake_pull) + result = runner.invoke(app, ["cloud", "pull", str(tmp_path)]) + assert result.exit_code != 0 + assert "inside an existing git repository" 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): + 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.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 == "" From c088b580e904ac3b64480ec56a0211fa7081e736 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Wed, 12 Aug 2026 19:02:33 +0800 Subject: [PATCH 02/30] fix(wren): reset inherited credential-helper chain and fix pull() idempotency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS git ships an osxkeychain helper in system config, which is consulted before global config. Without resetting it, login's helper never actually answers `get` first, so a stale keychain-cached token can be served instead of a fresh one — and because this design never caches, `store` still fires on every operation, persisting the ephemeral token into the keychain anyway. Write an empty `helper =` first in the URL-scoped section to reset the inherited chain for that host only; other hosts keep their own helper. --replace-all followed by --add keeps repeated `login` runs idempotent (verified by actually re-running it, not just reading the sequence). Also fix pull()'s local-commit step: it decided whether a commit was still needed by checking whether `.git` existed, which conflates "some earlier attempt got as far as `init`" with "a commit actually landed". A retry after a failed `commit` (e.g. missing git identity) would silently skip straight to the remote merge against an unborn HEAD, turning the unrelated-history merge into a trivial fast-forward. Ask git directly via `rev-parse --verify -q HEAD` instead. (cherry picked from commit e24bf8a43743b1818321a8084da8920d10851094) (cherry picked from commit c8c23c1b31988cca143b6f736316f389d1e465b9) (cherry picked from commit ed9c08083526a0a53080bde5668916eb08601031) --- core/wren/src/wren/cloud.py | 71 ++++++++++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py index 674568ad13..9524e843ba 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -253,28 +253,56 @@ def _git_config_set_global(key: str, value: str) -> None: run_git(["config", "--global", "--replace-all", key, value]) -def configure_git_credential_helper(git_host: str) -> None: - """Write a URL-scoped credential-helper entry into the global git config. +def _git_config_add_global(key: str, value: str) -> None: + run_git(["config", "--global", "--add", key, value]) - Both settings are required, for different reasons: - - The section is scoped to exactly `git_host` — this helper must never - apply to a git host the user did not explicitly log in to. - - `useHttpPath` 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. +def configure_git_credential_helper(git_host: str) -> None: + """Write a URL-scoped credential-helper entry into the global git config. - The helper value must be `!`-prefixed: git only runs a helper string + 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) 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. + + Re-running this (e.g. a second `wren cloud login`) 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", "!wren cloud git-credential") + _git_config_set_global(f"{section}.helper", "") + _git_config_add_global(f"{section}.helper", "!wren cloud git-credential") _git_config_set_global(f"{section}.useHttpPath", "true") @@ -492,6 +520,23 @@ def pull( 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 From 278c10371a9662a22c0fd8c9343807ca29df9141 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Wed, 12 Aug 2026 19:08:19 +0800 Subject: [PATCH 03/30] fix(wren): make `pull --host` filter on the field it documents and displays `--host`'s help text calls it "the host used at login" and the disambiguation candidates print `api_host`, but the filter matched `git_host` instead. A user who passes the exact value they gave `login` got a false "no stored login found", even though a matching login existed under a different `--git-host`. Filter on `api_host`, the same field the docs and the candidate list already use, so the two can't disagree. Two logins that share an `api_host` but differ only by `--git-host` still correctly fall through to the disambiguation error rather than being silently missed or arbitrarily resolved. (cherry picked from commit eb5516c518842ac46b2d4357cba6d918e2868e88) (cherry picked from commit cf2b02e762f890cec37a3992e2eb2d834410373d) (cherry picked from commit 1fc4798c68dd29361d067b27c630a0ea9c6ac8a3) --- core/wren/src/wren/cloud_cli.py | 8 +++- core/wren/tests/unit/test_cloud_cli.py | 63 ++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/core/wren/src/wren/cloud_cli.py b/core/wren/src/wren/cloud_cli.py index 8b028802c5..599b561586 100644 --- a/core/wren/src/wren/cloud_cli.py +++ b/core/wren/src/wren/cloud_cli.py @@ -125,7 +125,13 @@ def pull( if project is not None: logins = [entry for entry in logins if entry[1] == str(project)] if host is not None: - logins = [entry for entry in logins if entry[0] == host] + # Filtered on the same field the help text promises and the + # disambiguation candidates below print: `api_host` — the host the + # user passed to `login --host`, not the (possibly different) + # `--git-host`. Filtering on `git_host` here while displaying + # `api_host` would silently reject the exact value a user would + # naturally reach for: the host they logged in with. + logins = [entry for entry in logins if entry[2]["api_host"] == host] if not logins: typer.echo( diff --git a/core/wren/tests/unit/test_cloud_cli.py b/core/wren/tests/unit/test_cloud_cli.py index 5c8dafe434..ac2703ece3 100644 --- a/core/wren/tests/unit/test_cloud_cli.py +++ b/core/wren/tests/unit/test_cloud_cli.py @@ -127,6 +127,69 @@ def test_pull_disambiguates_multiple_stored_logins(monkeypatch, tmp_path): assert "disambiguate" in result.output.lower() +def test_pull_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_pull(directory, *, git_host, api_host, project_id, org_id, repo): + captured.update(git_host=git_host, api_host=api_host) + + monkeypatch.setattr(cloud, "pull", fake_pull) + + result = runner.invoke( + app, + ["cloud", "pull", 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_pull_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", "pull", str(tmp_path), "--host", "https://cloud.getwren.ai"], + ) + assert result.exit_code != 0 + assert "disambiguate" in result.output.lower() + + def test_pull_invokes_cloud_pull_with_the_single_stored_login(monkeypatch, tmp_path): entry = { "api_host": "https://cloud.getwren.ai", From 04a0d74cf8159034a71a02802f7f43f3582bea2c Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Thu, 13 Aug 2026 17:59:37 +0800 Subject: [PATCH 04/30] refactor(wren): rename cloud pull to link and make re-runs explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pull` was never an update path — it acquires a project's files once via a plain clone or a one-time unrelated-history merge, and every further sync is ordinary `git pull`/`git push`. Keeping the name `pull` invited users to expect repeated invocations to behave like git's own pull. Rename the command and its underlying function to `link`, and give re-runs an explicit "already linked" outcome: when the target directory is already caught up with the remote, `link` now reports that and points at `git pull` for updates instead of quietly re-merging. Recovery from a partially-completed previous attempt (unborn HEAD) still works exactly as before — only the fully-caught-up case short-circuits. No behavior change to `login`, the git-credential helper, or the acquisition logic itself (clone path, git-init+merge path, nested-repo refusal, upstream wiring). (cherry picked from commit 11e190c02ffb099c7f323a345dedbcc966403647) (cherry picked from commit 9f3fcdcbf2eb1e769d788f9c4a207b1bcc559f5f) (cherry picked from commit 0abd2011f300ede891c35ced63f77c2e55d08d4a) --- core/wren/src/wren/cloud.py | 60 ++++++++++++++--- core/wren/src/wren/cloud_cli.py | 37 +++++++---- core/wren/tests/unit/test_cloud.py | 91 +++++++++++++++++++++++++- core/wren/tests/unit/test_cloud_cli.py | 66 +++++++++++++------ 4 files changed, 213 insertions(+), 41 deletions(-) diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py index 9524e843ba..ed8d13e736 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -21,7 +21,7 @@ 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. ``pull`` acquires files via git, not via the manifest, so it +drops them. ``link`` acquires files via git, not via the manifest, so it should never need that path at all. """ @@ -33,6 +33,7 @@ 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")) @@ -68,7 +69,7 @@ def __init__(self, target: Path, found_root: Path): "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 pull` again." + "and run `wren cloud link` again." ) @@ -461,10 +462,10 @@ 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 `pull` is about to create. A `.git` found in an + 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 `pull` — missed, + 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() @@ -473,7 +474,19 @@ def check_not_nested(target: Path) -> None: raise NestedRepoError(target, found) -# ── pull: acquire the repo correctly, exactly once ────────────────────────── +# ── 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: @@ -487,7 +500,7 @@ def _default_branch(remote_url: str) -> str: return "main" -def pull( +def link( target: Path, *, git_host: str, @@ -495,15 +508,24 @@ def pull( project_id: str, # noqa: ARG001 org_id: str, # noqa: ARG001 repo: str, -) -> None: - """Acquire `repo` into `target`, handling both shapes. +) -> 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) @@ -516,7 +538,7 @@ def pull( 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 + return LinkOutcome.LINKED if not is_repo_here: run_git(["init"], cwd=target) @@ -551,6 +573,25 @@ def pull( run_git(["fetch", "origin"], cwd=target) branch = _default_branch(remote_url) + + # 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: + return LinkOutcome.ALREADY_LINKED + merge = run_git( [ "merge", @@ -588,3 +629,4 @@ def pull( cwd=target, check=False, ) + return LinkOutcome.LINKED diff --git a/core/wren/src/wren/cloud_cli.py b/core/wren/src/wren/cloud_cli.py index 599b561586..574618ccb7 100644 --- a/core/wren/src/wren/cloud_cli.py +++ b/core/wren/src/wren/cloud_cli.py @@ -1,11 +1,12 @@ """Typer sub-app for ``wren cloud`` commands. ``wren cloud login`` connects a local directory to a Wren Cloud project's -git remote; ``wren cloud pull`` then acquires (or adopts) that project's -files via plain git. After that, ordinary ``git push`` / ``git pull`` / -``git diff`` are the commands — none of the security depends on going -through this CLI again. ``wren cloud git-credential`` is the helper `login` -wires into git; it is not meant to be invoked by hand. +git remote; ``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 `login` wires into git; it is not meant to be invoked by hand. """ from __future__ import annotations @@ -77,15 +78,15 @@ def login( typer.echo(f"Remote repo: {token.repo}") typer.echo( "git is now configured to authenticate to this project automatically. " - "Run `wren cloud pull` to fetch it, or `git clone` the remote directly." + "Run `wren cloud link` to bind it, or `git clone` the remote directly." ) @cloud_app.command() -def pull( +def link( directory: Annotated[ Path, - typer.Argument(help="Local directory to pull the project into."), + typer.Argument(help="Local directory to bind the project into."), ] = Path("."), host: Annotated[ Optional[str], @@ -106,7 +107,10 @@ def pull( ), ] = None, ) -> None: - """Acquire a Wren Cloud project's files into a local directory via git. + """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, @@ -117,6 +121,11 @@ def pull( Refuses if ``directory`` sits inside another git repository, to avoid pushing that repository's own files into the project's remote. + 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 login`` for the target project first. """ from wren import cloud # noqa: PLC0415 @@ -153,7 +162,7 @@ def pull( git_host, project_id, entry = logins[0] try: - cloud.pull( + outcome = cloud.link( directory, git_host=git_host, api_host=entry["api_host"], @@ -165,7 +174,13 @@ def pull( typer.echo(f"Error: {exc}", err=True) raise typer.Exit(1) - typer.echo(f"Pulled project {project_id} into {directory}.") + if outcome is cloud.LinkOutcome.ALREADY_LINKED: + typer.echo( + f"{directory} is already linked to project {project_id}. " + "Run `git pull` to fetch updates." + ) + else: + typer.echo(f"Linked project {project_id} into {directory}.") @git_credential_app.command("get") diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index fb34336e53..7de7966127 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -3,7 +3,7 @@ These cover the pure-logic paths only. The seven live checks against a real Wren Cloud stack (login+clone, push, wrong-key messaging, host-scoping, -fresh-dir pull, existing-dir pull, nested-repo refusal) are exercised +fresh-dir link, existing-dir link, nested-repo refusal) are exercised manually, not here — a mocked HTTP/git layer cannot stand in for them. """ @@ -276,3 +276,92 @@ 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 _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"], 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) + + +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 = tmp_path / "project" + target.mkdir() + (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 + + +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 = tmp_path / "project" + target.mkdir() + (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() diff --git a/core/wren/tests/unit/test_cloud_cli.py b/core/wren/tests/unit/test_cloud_cli.py index ac2703ece3..707eee725d 100644 --- a/core/wren/tests/unit/test_cloud_cli.py +++ b/core/wren/tests/unit/test_cloud_cli.py @@ -3,7 +3,7 @@ The seven required live checks (real login against a running Wren Cloud stack, real git clone/push, real nested-directory refusal, ...) are -exercised manually — mocking `wren.cloud.login`/`pull` here only proves the +exercised manually — mocking `wren.cloud.login`/`link` here only proves the CLI passes options through correctly, not that the underlying git/HTTP behavior is correct. """ @@ -103,17 +103,17 @@ def fake_login(**kwargs): assert "not valid for project 16" in result.output -# ── pull ───────────────────────────────────────────────────────────────── +# ── link ───────────────────────────────────────────────────────────────── -def test_pull_errors_when_no_login_is_stored(monkeypatch, tmp_path): +def test_link_errors_when_no_login_is_stored(monkeypatch, tmp_path): monkeypatch.setattr(cloud, "list_logins", lambda: []) - result = runner.invoke(app, ["cloud", "pull", str(tmp_path)]) + result = runner.invoke(app, ["cloud", "link", str(tmp_path)]) assert result.exit_code != 0 assert "wren cloud login" in result.output -def test_pull_disambiguates_multiple_stored_logins(monkeypatch, tmp_path): +def test_link_disambiguates_multiple_stored_logins(monkeypatch, tmp_path): monkeypatch.setattr( cloud, "list_logins", @@ -122,12 +122,12 @@ def test_pull_disambiguates_multiple_stored_logins(monkeypatch, tmp_path): ("https://b.example.com", "17", {"api_host": "https://b.example.com"}), ], ) - result = runner.invoke(app, ["cloud", "pull", str(tmp_path)]) + result = runner.invoke(app, ["cloud", "link", str(tmp_path)]) assert result.exit_code != 0 assert "disambiguate" in result.output.lower() -def test_pull_host_filters_on_api_host_not_git_host(monkeypatch, tmp_path): +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 @@ -146,21 +146,22 @@ def test_pull_host_filters_on_api_host_not_git_host(monkeypatch, tmp_path): captured = {} - def fake_pull(directory, *, git_host, api_host, project_id, org_id, repo): + 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, "pull", fake_pull) + monkeypatch.setattr(cloud, "link", fake_link) result = runner.invoke( app, - ["cloud", "pull", str(tmp_path), "--host", "https://cloud.getwren.ai"], + ["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_pull_host_still_disambiguates_when_api_hosts_collide(monkeypatch, tmp_path): +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 @@ -184,13 +185,13 @@ def test_pull_host_still_disambiguates_when_api_hosts_collide(monkeypatch, tmp_p ) result = runner.invoke( app, - ["cloud", "pull", str(tmp_path), "--host", "https://cloud.getwren.ai"], + ["cloud", "link", str(tmp_path), "--host", "https://cloud.getwren.ai"], ) assert result.exit_code != 0 assert "disambiguate" in result.output.lower() -def test_pull_invokes_cloud_pull_with_the_single_stored_login(monkeypatch, tmp_path): +def test_link_invokes_cloud_link_with_the_single_stored_login(monkeypatch, tmp_path): entry = { "api_host": "https://cloud.getwren.ai", "org_id": "2", @@ -203,7 +204,7 @@ def test_pull_invokes_cloud_pull_with_the_single_stored_login(monkeypatch, tmp_p captured = {} - def fake_pull(directory, *, git_host, api_host, project_id, org_id, repo): + def fake_link(directory, *, git_host, api_host, project_id, org_id, repo): captured.update( directory=directory, git_host=git_host, @@ -212,16 +213,41 @@ def fake_pull(directory, *, git_host, api_host, project_id, org_id, repo): org_id=org_id, repo=repo, ) + return cloud.LinkOutcome.LINKED - monkeypatch.setattr(cloud, "pull", fake_pull) - result = runner.invoke(app, ["cloud", "pull", str(tmp_path)]) + 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_pull_reports_cloud_error_without_traceback(monkeypatch, tmp_path): +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", @@ -232,11 +258,11 @@ def test_pull_reports_cloud_error_without_traceback(monkeypatch, tmp_path): cloud, "list_logins", lambda: [("https://cloud.getwren.ai", "16", entry)] ) - def fake_pull(*args, **kwargs): + def fake_link(*args, **kwargs): raise cloud.NestedRepoError(tmp_path, tmp_path.parent) - monkeypatch.setattr(cloud, "pull", fake_pull) - result = runner.invoke(app, ["cloud", "pull", str(tmp_path)]) + 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 From 32ac85727ab5fbead7035c0131d735f80ac0093b Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Thu, 13 Aug 2026 18:37:12 +0800 Subject: [PATCH 05/30] feat(wren): add `wren cloud create` to make and bind a project in one step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create` is the other half of `link`: both bind a directory to a Wren Cloud project, differing only in whether the project has to be made first. It always requests the AGENTIC opt-in (a project created any other way has no git repository to bind to), takes the org key only transiently to create the project and mint its own project key, and then reuses `login`/`link`/`check_not_nested` unmodified to reach exactly the state `link` leaves a directory in — a plain `git push` works afterward with no further `wren` command. If the server did not actually grant the AGENTIC opt-in, that surfaces as a specific, actionable error instead of a bare 404, with the project's freshly minted key included so it is recoverable via `wren cloud login` rather than orphaned. (cherry picked from commit 75ce6da8a7b9a4335866fd21db14b648780dc067) (cherry picked from commit a05cd2632c3e9a9ca67d087440269497f8ffafdb) (cherry picked from commit b256407c512b947392d6687568620bfaf5ba3309) --- core/wren/src/wren/cloud.py | 276 +++++++++++++++++++ core/wren/src/wren/cloud_cli.py | 209 ++++++++++++++ core/wren/tests/unit/test_cloud.py | 365 +++++++++++++++++++++++++ core/wren/tests/unit/test_cloud_cli.py | 192 +++++++++++++ 4 files changed, 1042 insertions(+) diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py index ed8d13e736..08478382ca 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -47,6 +47,8 @@ ) _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): @@ -630,3 +632,277 @@ def link( check=False, ) return LinkOutcome.LINKED + + +# ── 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}"} + body: dict = { + "orgId": int(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): + raise InvalidApiKeyError( + f"This org key was rejected creating a project on {api_host}. " + "`wren cloud create` needs an org API key (starts with " + "`osk-`), not a project key." + ) + raise CloudError( + f"Wren Cloud API returned {resp.status_code} creating a project " + f"on {api_host}: {resp.text[:300]}" + ) + + data = resp.json() + project = data.get("project") or {} + 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 mint_project_key( + api_host: str, + project_id: str, + org_key: str, + *, + name: str = "wren-cli", + 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}, 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 = resp.json().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 + + +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, + mdl: dict | None = None, + 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 *before* + creating anything server-side, so a refusal here (unlike a refusal + partway through) never leaves an orphaned project behind. + + 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 login` 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 login` 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) + + 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, + mdl=mdl, + language=language, + timezone=timezone, + ) + + project_key = mint_project_key(api_host, project.id, org_key) + + def _recovery_hint() -> str: + hint = f" wren cloud login --host {host} --project {project.id}" + if git_host: + hint += f" --git-host {git_host}" + return ( + f"The project's key is: {project_key}\n" + "Recover with:\n" + f"{hint}\n" + "then `wren cloud link` in this directory." + ) + + try: + token = login( + host=host, + project_id=project.id, + api_key=project_key, + git_host=git_host, + ) + except CloudError as exc: + detail = str(exc) + if "PROJECT_NOT_AGENTIC" in detail or "agent-mode projects" in detail: + 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 + + outcome = link( + target, + git_host=resolved_git_host, + api_host=api_host, + project_id=project.id, + org_id=project.org_id, + repo=token.repo, + ) + return project, outcome diff --git a/core/wren/src/wren/cloud_cli.py b/core/wren/src/wren/cloud_cli.py index 574618ccb7..c3665ac623 100644 --- a/core/wren/src/wren/cloud_cli.py +++ b/core/wren/src/wren/cloud_cli.py @@ -11,6 +11,7 @@ from __future__ import annotations +import os import sys from pathlib import Path from typing import Annotated, Optional @@ -183,6 +184,214 @@ def link( typer.echo(f"Linked project {project_id} into {directory}.") +@cloud_app.command() +def create( # noqa: PLR0913 + host: Annotated[ + str, + typer.Option("--host", help="Wren Cloud host, e.g. https://cloud.getwren.ai"), + ], + org: Annotated[ + str, + typer.Option("--org", help="Organization id the new project belongs to."), + ], + 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 login --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. POSTGRES, BIGQUERY.", + ), + ] = None, + connection_info: Annotated[ + Optional[str], + typer.Option( + "--connection-info", + help='Connection info as a JSON object, e.g. \'{"host": "..."}\'.', + ), + ] = None, + connection_info_file: Annotated[ + Optional[Path], + typer.Option( + "--connection-info-file", + help="Path to a JSON file with the connection info.", + ), + ] = None, + test_connection: Annotated[ + bool, + typer.Option( + "--test-connection", + help="Ask the server to test the connection before creating the project.", + ), + ] = False, + mdl_file: Annotated[ + Optional[Path], + typer.Option("--mdl-file", help="Path to a JSON file with an initial MDL."), + ] = None, + 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 login` 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. + + Refuses up front if `directory` sits inside another git repository, + before creating anything on the server — the same check `link` uses. + """ + from wren import cloud # noqa: PLC0415 + + 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" + ) + + if parsed_connection_info is not None and not type_: + typer.echo( + "Error: --type is required when a connection info is given.", err=True + ) + raise typer.Exit(1) + + parsed_mdl: Optional[dict] = None + if mdl_file is not None: + try: + raw = mdl_file.read_text(encoding="utf-8") + except OSError as exc: + typer.echo(f"Error: could not read {mdl_file}: {exc}", err=True) + raise typer.Exit(1) from exc + parsed_mdl = _read_json_option(raw, source="--mdl-file") + + 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( + "Wren Cloud organization API key", hide_input=True + ).strip() + if not resolved_org_key: + typer.echo("Error: an organization API key is required.", err=True) + raise typer.Exit(1) + if not resolved_org_key.startswith("osk-"): + typer.echo( + "Error: `wren cloud create` needs an organization API key " + "(starts with `osk-`), not a project key.", + 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, + mdl=parsed_mdl, + 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"{directory} is already linked to project {project.id}.") + else: + typer.echo(f"Linked project {project.id} into {directory}.") + + @git_credential_app.command("get") def credential_get() -> None: """Handle git's ``get`` operation. Invoked by git, not by hand.""" diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index 7de7966127..e267e159ff 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -13,6 +13,7 @@ import subprocess import pytest +import requests from wren import cloud @@ -344,6 +345,370 @@ def test_link_recovers_when_a_previous_attempt_left_head_unborn(tmp_path): assert len(log) >= 2 +class _FakeResponse: + def __init__(self, status_code, json_data=None, text=""): + self.status_code = status_code + self._json_data = json_data or {} + self.text = text or str(json_data or "") + + def json(self): + return self._json_data + + +# ── 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_rejects_org_or_project_key_on_401(monkeypatch): + def fake_post(url, json=None, headers=None, timeout=None): + return _FakeResponse(401, {}, text="unauthorized") + + monkeypatch.setattr(requests, "post", fake_post) + + with pytest.raises(cloud.InvalidApiKeyError): + cloud.create_project( + "https://cloud.getwren.ai", + "sk-not-an-org-key", + org_id="2", + display_name="p", + ) + + +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_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): + 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 = tmp_path / "project" + target.mkdir() + (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): + 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 +): + def fake_create_project(api_host, org_key, **kwargs): + raise cloud.CloudError("boom") + + monkeypatch.setattr(cloud, "create_project", fake_create_project) + + target = tmp_path / "project" + target.mkdir() + + 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 +): + _patch_create_http(monkeypatch) + + def fake_login(*, host, project_id, api_key, git_host): + raise cloud.CloudError( + "Wren Cloud API returned 404 minting a git token for project " + f'{project_id} on {host}: {{"code":"PROJECT_NOT_AGENTIC",' + '"error":"This endpoint is only available for agent-mode ' + 'projects."}' + ) + + monkeypatch.setattr(cloud, "login", fake_login) + + target = tmp_path / "project" + target.mkdir() + + 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" in message + assert "wren cloud login" in message + # Nothing was touched locally — this is a pure server-side outcome. + assert not (target / ".git").exists() + + +def test_create_reports_bind_failure_with_recovery_hint(tmp_path, monkeypatch): + _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 = tmp_path / "project" + target.mkdir() + + 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" in message + assert "wren cloud login" in message + 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") diff --git a/core/wren/tests/unit/test_cloud_cli.py b/core/wren/tests/unit/test_cloud_cli.py index 707eee725d..d643c9a094 100644 --- a/core/wren/tests/unit/test_cloud_cli.py +++ b/core/wren/tests/unit/test_cloud_cli.py @@ -267,6 +267,198 @@ def fake_link(*args, **kwargs): assert "inside an existing git repository" in result.output +# ── create ─────────────────────────────────────────────────────────────── + + +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", + ], + ) + 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"], + 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"], + 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 + + +def test_create_requires_type_when_connection_info_is_given(tmp_path): + result = runner.invoke( + app, + [ + "cloud", + "create", + str(tmp_path), + "--host", + "h", + "--org", + "2", + "--connection-info", + '{"host": "db"}', + ], + input="osk-x\n", + ) + assert result.exit_code != 0 + assert "--type is required" in result.output + + +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"], + 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"], + input="osk-x\n", + ) + assert result.exit_code != 0 + assert "boom" in result.output + + # ── git-credential get/store/erase (CLI-level stdin/stdout wrapping) ─────── From 7f894bccf81a1443f9cdf147d67199db5f080c04 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Thu, 13 Aug 2026 18:48:24 +0800 Subject: [PATCH 06/30] fix(wren): detect the AGENTIC refusal by the server's error code, not prose mint_git_token's generic-error branch now parses the response body and attaches the HTTP status and machine-readable `code` to a new CloudApiError (a CloudError subclass), degrading to `code=None` on any body that isn't the expected shape rather than raising. `create` matches on `code == "PROJECT_NOT_AGENTIC"` instead of substring-matching the error message, so an unrecognized 404 (different code, no code, or an unparseable body) reliably falls through to the generic bind-failure message instead of risking a silent misdiagnosis. Existing callers (the credential helper's `get` and `login`) are unaffected: the raised message text is unchanged, and CloudApiError is still a CloudError. (cherry picked from commit dee334c5eaf058aec2694b72bd5464dac9ca505d) (cherry picked from commit 264060121e8ce35ea44c2726aa06ce27b00aa506) (cherry picked from commit 9fbc1f3df8417b3e93da53468d682014f11f55eb) --- core/wren/src/wren/cloud.py | 45 +++++++++- core/wren/tests/unit/test_cloud.py | 137 +++++++++++++++++++++++++++-- 2 files changed, 170 insertions(+), 12 deletions(-) diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py index 08478382ca..717159f76c 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -79,6 +79,23 @@ 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 @@ -99,6 +116,25 @@ def _parse_retry_after(value: str | None, *, default: float = 1.0) -> float: 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 mint_git_token( api_host: str, project_id: str, @@ -144,9 +180,11 @@ def mint_git_token( if resp.status_code == 429 and attempt < max_attempts: time.sleep(_parse_retry_after(resp.headers.get("Retry-After"))) continue - raise CloudError( + 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]}" + f"for project {project_id} on {api_host}: {resp.text[:300]}", + status_code=resp.status_code, + code=_parse_error_code(resp), ) @@ -878,8 +916,7 @@ def _recovery_hint() -> str: git_host=git_host, ) except CloudError as exc: - detail = str(exc) - if "PROJECT_NOT_AGENTIC" in detail or "agent-mode projects" in detail: + 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` " diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index e267e159ff..993fcebb22 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -346,15 +346,78 @@ def test_link_recovers_when_a_previous_attempt_left_head_unborn(tmp_path): class _FakeResponse: - def __init__(self, status_code, json_data=None, text=""): + 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 ─────────────────────────────────── @@ -650,17 +713,23 @@ def fake_create_project(api_host, org_key, **kwargs): def test_create_reports_not_agentic_actionably_and_includes_the_project_key( tmp_path, monkeypatch ): + # 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_login(*, host, project_id, api_key, git_host): - raise cloud.CloudError( - "Wren Cloud API returned 404 minting a git token for project " - f'{project_id} on {host}: {{"code":"PROJECT_NOT_AGENTIC",' - '"error":"This endpoint is only available for agent-mode ' - 'projects."}' + 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(cloud, "login", fake_login) + monkeypatch.setattr(requests, "post", fake_post) target = tmp_path / "project" target.mkdir() @@ -682,6 +751,58 @@ def fake_login(*, host, project_id, api_key, git_host): 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 +): + # 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 = tmp_path / "project" + target.mkdir() + + 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" in message + assert "wren cloud login" in message + assert not (target / ".git").exists() + + def test_create_reports_bind_failure_with_recovery_hint(tmp_path, monkeypatch): _patch_create_http(monkeypatch) From 41386269ee75e7cdae991f45c5bea7f74b18f8ec Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Fri, 14 Aug 2026 14:08:45 +0800 Subject: [PATCH 07/30] feat(wren): name a minted project key by the date it was minted Every key the CLI mints landed in the user's key list under one constant name, and the server stamps all API-minted keys with the same origin, so the list gave no way to tell one from another. The host name would also distinguish same-day mints from different machines, but it would put the machine's name into the account's key list; that trade was declined, and a test guards against it creeping back in. (cherry picked from commit f71e6953ea15b398727e3b66f234f79ae1b60f5c) (cherry picked from commit 47dd75864ceb5262b554537abfdd106d81ff1fd4) (cherry picked from commit 7d939ec5dbd069fb30bad14188d9be255481565e) --- core/wren/src/wren/cloud.py | 25 ++++++++++++++++++-- core/wren/tests/unit/test_cloud.py | 37 ++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py index 717159f76c..aebfdab683 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -790,12 +790,28 @@ def create_project( ) +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 = "wren-cli", + name: str | None = None, timeout: float = 15.0, ) -> str: """Mint a durable project API key (`sk-...`) for `project_id`. @@ -812,7 +828,12 @@ def mint_project_key( ) headers = {"Authorization": f"Bearer {org_key}"} try: - resp = requests.post(url, json={"name": name}, headers=headers, timeout=timeout) + 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 diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index 993fcebb22..e6c02be279 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -10,7 +10,9 @@ from __future__ import annotations import io +import socket import subprocess +import time import pytest import requests @@ -566,6 +568,41 @@ def fake_post(url, json=None, headers=None, timeout=None): 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, {}) From 38236e3fcbd430e0e2c37965433df788171aa776 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Mon, 17 Aug 2026 18:40:52 +0800 Subject: [PATCH 08/30] fix(wren): refuse to configure a credential helper git cannot run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wren cloud login` writes `!wren cloud git-credential` into global git config, and the leading `!` makes git resolve `wren` from PATH at git-invocation time — not at login time, and not pinned to the interpreter that ran login. A `wren` on PATH without the `cloud` commands therefore breaks every clone / fetch / push against that host, with an error that comes from git, talks about credentials, and names neither wren nor a version. login reported success regardless, because at login time the CLI is the capable one; the breakage appeared later, in a different tool. login and create now probe the PATH-resolved `wren` before writing anything and refuse with a message naming that executable and how to fix it. create runs the check before creating the project server-side, so a refusal cannot leave an orphaned project behind. The helper string written into git config and the command the probe asks about are now derived from one definition, so a change to one cannot leave the other verifying a command git never runs. The check 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 — so the helper's own failures now identify themselves: the tool, its version and the executable that actually served the request, printed immediately above git's own credential error. Also stop the unit tests from writing into the real global git config. The create tests reach `git config --global` for real, and every run left a dead `credential.` section behind in the developer's or CI runner's own config file. (cherry picked from commit 47c9cc98c2be3abe9be1ab62a39e2cff59da2fa9) (cherry picked from commit 3360f110d531602d9fd08c6f4842d268fc8a625a) (cherry picked from commit 44545a5f1aeffa646c3b0ccc2b3d0f07f52958ca) --- core/wren/src/wren/cloud.py | 150 +++++++++++++++-- core/wren/src/wren/cloud_cli.py | 16 +- core/wren/tests/unit/test_cloud.py | 216 ++++++++++++++++++++++++- core/wren/tests/unit/test_cloud_cli.py | 21 +++ 4 files changed, 385 insertions(+), 18 deletions(-) diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py index aebfdab683..da13a0413f 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -29,6 +29,7 @@ import os import re +import shutil import subprocess import tempfile import time @@ -39,6 +40,19 @@ _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"). @@ -298,6 +312,81 @@ 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 at login time 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. @@ -330,11 +419,13 @@ def configure_git_credential_helper(git_host: str) -> None: 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) 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. + 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 login`) stays idempotent: `--replace-all` first collapses the `helper` key back down to the single @@ -343,7 +434,7 @@ def configure_git_credential_helper(git_host: str) -> None: """ section = f"credential.{git_host}" _git_config_set_global(f"{section}.helper", "") - _git_config_add_global(f"{section}.helper", "!wren cloud git-credential") + _git_config_add_global(f"{section}.helper", HELPER_COMMAND) _git_config_set_global(f"{section}.useHttpPath", "true") @@ -363,10 +454,17 @@ def login( 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) @@ -435,6 +533,35 @@ def git_credential_get(input_data: dict[str, str]) -> str: 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 login` " + "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. @@ -874,9 +1001,13 @@ def create( ) -> 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 *before* - creating anything server-side, so a refusal here (unlike a refusal - partway through) never leaves an orphaned project behind. + Checks `target` is not nested inside a foreign git repository, and that + the git credential helper is serviceable, *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. Ends in exactly the state `login` + `link` leave a directory in: a stored project key, a configured git credential helper, and a bound @@ -899,6 +1030,7 @@ def create( """ target = target.resolve() check_not_nested(target) + check_helper_command_serviceable() api_host = host.rstrip("/") resolved_git_host = (git_host or host).rstrip("/") diff --git a/core/wren/src/wren/cloud_cli.py b/core/wren/src/wren/cloud_cli.py index c3665ac623..8ab9f2240a 100644 --- a/core/wren/src/wren/cloud_cli.py +++ b/core/wren/src/wren/cloud_cli.py @@ -59,6 +59,13 @@ def login( 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 @@ -283,8 +290,10 @@ def create( # noqa: PLR0913 fails with a specific error rather than a bare HTTP error, and reports how to recover the project's key. - Refuses up front if `directory` sits inside another git repository, - before creating anything on the server — the same check `link` uses. + Refuses up front — before creating anything on the server — if + `directory` sits inside another git repository (the same check `link` + uses), or if the `wren` on PATH cannot serve the git credential helper + (the same check `login` uses). """ from wren import cloud # noqa: PLC0415 @@ -398,6 +407,7 @@ def credential_get() -> None: from wren.cloud import ( # noqa: PLC0415 CloudError, git_credential_get, + helper_failure_note, read_credential_input, ) @@ -405,7 +415,7 @@ def credential_get() -> None: try: output = git_credential_get(input_data) except CloudError as exc: - typer.echo(str(exc), err=True) + typer.echo(helper_failure_note(str(exc)), err=True) raise typer.Exit(1) sys.stdout.write(output) diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index e6c02be279..897801644c 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -60,6 +60,30 @@ def _isolated_wren_home(tmp_path, monkeypatch): 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 `login`/`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", @@ -656,7 +680,9 @@ def fake_mint_project_key(api_host, project_id, org_key, **kwargs): return created, calls -def test_create_end_to_end_binds_and_stores_only_the_project_key(tmp_path, monkeypatch): +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) @@ -702,7 +728,9 @@ def fake_mint_git_token(api_host, project_id, api_key, **kwargs): assert (target / "seed.txt").exists() -def test_create_refuses_nested_directory_before_any_server_call(tmp_path, monkeypatch): +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" @@ -724,7 +752,7 @@ def fail_if_called(*args, **kwargs): def test_create_failed_project_creation_leaves_nothing_to_clean_up( - tmp_path, monkeypatch + tmp_path, monkeypatch, _helper_check_passes ): def fake_create_project(api_host, org_key, **kwargs): raise cloud.CloudError("boom") @@ -748,7 +776,7 @@ def fake_create_project(api_host, org_key, **kwargs): def test_create_reports_not_agentic_actionably_and_includes_the_project_key( - tmp_path, monkeypatch + 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 @@ -806,7 +834,7 @@ def fake_post(url, headers=None, timeout=None, **kwargs): ], ) def test_create_degrades_to_generic_bind_failure_on_an_unrecognized_git_token_error( - tmp_path, monkeypatch, resp_kwargs + 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 @@ -840,7 +868,9 @@ def fake_post(url, headers=None, timeout=None, **kwargs): assert not (target / ".git").exists() -def test_create_reports_bind_failure_with_recovery_hint(tmp_path, monkeypatch): +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): @@ -888,3 +918,177 @@ def test_link_reports_already_linked_on_rerun_with_nothing_new(tmp_path): 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 = tmp_path / "project" + target.mkdir() + + 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() diff --git a/core/wren/tests/unit/test_cloud_cli.py b/core/wren/tests/unit/test_cloud_cli.py index d643c9a094..3b12f43925 100644 --- a/core/wren/tests/unit/test_cloud_cli.py +++ b/core/wren/tests/unit/test_cloud_cli.py @@ -491,6 +491,27 @@ def raise_it(input_data): assert "No stored Wren Cloud login" in result.output +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( From 50f2370264a6297f307e84ee2d5d5ecfe30ba824 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Mon, 17 Aug 2026 19:23:24 +0800 Subject: [PATCH 09/30] fix(wren): set upstream on the already-linked path too, so plain git works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `link` set upstream tracking only on the branch that performs the merge. The already-linked short-circuit returned before it, which is harmless for a second run after a successful bind or for a plain clone — both already track their remote. It is not harmless in the state the conflict message itself creates. A conflicted bind raises before upstream is set; the user resolves and commits, as that message instructs; `origin/` is then an ancestor of HEAD, so `link` reports "already linked, run `git pull`" and exits 0 — with no tracking branch, so `git pull` and `git push` both refuse. The command reported success and recommended a command that could not run. Upstream is now set on that path too, and only when the branch has none, so a deliberately re-pointed branch keeps its own. No merge is added, so already-linked still means already-linked rather than a fresh bind. Verified against a real server and git-server: in the directory left by an actual conflicted bind, `git pull` went from "no tracking information" to "Already up to date." and a plain `git push` landed a commit. (cherry picked from commit 6a62505cc57ccfa3715426632dfa078ce07caa1d) (cherry picked from commit bd932aea6e191d7f113202592472bf09c510a160) (cherry picked from commit 09680c61eb050dd3888652a7deb8272ae1f4fe3d) --- core/wren/src/wren/cloud.py | 42 ++++++++++++++++--- core/wren/tests/unit/test_cloud.py | 67 ++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py index da13a0413f..e25d7d831e 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -757,6 +757,20 @@ def link( == 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 merge = run_git( @@ -784,10 +798,29 @@ def link( f"{detail}" ) - # 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. + _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 _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() @@ -796,7 +829,6 @@ def link( cwd=target, check=False, ) - return LinkOutcome.LINKED # ── create: make a new project and bind it, in one step ───────────────────── diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index 897801644c..fc9ee5bbe1 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -1092,3 +1092,70 @@ def test_helper_failure_note_identifies_the_tool_and_the_executable_that_ran(): 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 = tmp_path / "project" + target.mkdir() + (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" From 800902259d86f9d743c84373b72b006dd466b1f8 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Thu, 27 Aug 2026 16:59:26 +0800 Subject: [PATCH 10/30] feat(wren): guard the cloud binding lifecycle and add unlink/logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directory-to-project binding is the git remote and nothing else, but nothing guarded that remote's lifecycle. `link` only ever *added* `origin` and never checked 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. Through `create` that also left B orphaned server-side: created, keyed, and referenced by nothing. Reproduced against a live server, where it created a real project. `create` now refuses a directory that is already bound, or whose history came from a project, *before* touching the server — beside the existing nested-repo and helper checks, so the refusal leaves nothing behind. `link` verifies an existing `origin` matches the repo it was asked to bind, and refuses to merge a history that shares no ancestor with the target project rather than combining two projects' content for the next push to publish. That refusal rolls back the `origin` it added. The guards block operations users legitimately need, so `unlink` and `logout` land with them. `unlink` removes the remote and keeps the stored key by default, since another directory may still be bound to the same project; `--forget-key` drops it too. Both remove the host's credential helper only once no stored login uses that host, because that entry is shared by every project on it. Detecting the foreign history needed two conditions, not one: "origin is not an ancestor of HEAD" is also false when a clone is merely behind the same project's remote. Keying the refusal off that alone rejected the ordinary unlink/re-bind recovery — found by driving it against a live server, and now pinned by a regression test. (cherry picked from commit a7d3992ccfc8a49a1bb887525d569b83e7ecedd4) (cherry picked from commit 229bf05e3ca18d331a45343cb2f8157ba64e9cf0) (cherry picked from commit 67a9299f6dca87ba9836bf43175e6a26ab3a1892) --- core/wren/.claude/CLAUDE.md | 1 + core/wren/src/wren/cloud.py | 335 ++++++++++++++++- core/wren/src/wren/cloud_cli.py | 194 +++++++++- core/wren/tests/unit/test_cloud.py | 489 ++++++++++++++++++++++++- core/wren/tests/unit/test_cloud_cli.py | 219 ++++++++++- 5 files changed, 1216 insertions(+), 22 deletions(-) diff --git a/core/wren/.claude/CLAUDE.md b/core/wren/.claude/CLAUDE.md index dd924e217e..0b264cfa66 100644 --- a/core/wren/.claude/CLAUDE.md +++ b/core/wren/.claude/CLAUDE.md @@ -33,6 +33,7 @@ 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 login|link|create|unlink|logout` — Bind a local project 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/cloud.py b/core/wren/src/wren/cloud.py index e25d7d831e..0df543fec0 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -285,6 +285,29 @@ def list_logins() -> list[tuple[str, str, dict]]: 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 ─────────────────────────────────── @@ -438,6 +461,26 @@ def configure_git_credential_helper(git_host: str) -> None: _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, @@ -641,6 +684,122 @@ def check_not_nested(target: Path) -> None: 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_not_already_bound(target: Path) -> None: + """Refuse a directory that is already bound to, or came from, a project. + + `create` is always making a *new* project, so any existing binding is + wrong by construction. It has to be 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 there orphans + them — which is exactly what happened before this guard existed. + + Two distinct states both mean "not a clean directory": + + - an `origin` remote — the binding itself; + - a HEAD carrying a seeded hook commit — no `origin` (perhaps removed by + hand), but the history still belongs to the project it came from, so + `link` would later refuse to merge it anyway. + """ + 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, or run `create` in a " + "directory that is not bound to anything. Nothing has been " + "created on the server." + ) + if head_has_seeded_hooks(target): + raise CloudError( + f"{target} has no `origin`, but its history came from a Wren " + "Cloud project — its commits carry the " + "`.hooks/deploy-modeling.yaml` that project creation seeds.\n" + "Creating a new project for it would produce a directory holding " + "two projects' content. Use a clean directory, or remove this " + "one's `.git` if you no longer need its history. 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 ───────────────── @@ -733,9 +892,35 @@ def link( 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" not in remotes: + 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) @@ -773,6 +958,40 @@ def link( _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. + unrelated = ( + run_git( + ["merge-base", f"origin/{branch}", "HEAD"], cwd=target, check=False + ).returncode + != 0 + ) + if unrelated 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", @@ -831,6 +1050,98 @@ def _set_upstream(target: Path, branch: str) -> None: ) +# ── 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 @@ -1033,13 +1344,20 @@ def create( ) -> 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, and that - the git credential helper is serviceable, *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. + 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 @@ -1063,6 +1381,7 @@ def create( target = target.resolve() check_not_nested(target) check_helper_command_serviceable() + check_not_already_bound(target) api_host = host.rstrip("/") resolved_git_host = (git_host or host).rstrip("/") diff --git a/core/wren/src/wren/cloud_cli.py b/core/wren/src/wren/cloud_cli.py index 8ab9f2240a..9e15b61e16 100644 --- a/core/wren/src/wren/cloud_cli.py +++ b/core/wren/src/wren/cloud_cli.py @@ -7,6 +7,24 @@ 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 `login` wires into git; it is not meant to be invoked by hand. + +``wren cloud unlink`` and ``wren cloud logout`` 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 ``logout`` 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 @@ -129,6 +147,13 @@ def link( 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 @@ -292,8 +317,14 @@ def create( # noqa: PLR0913 Refuses up front — before creating anything on the server — if `directory` sits inside another git repository (the same check `link` - uses), or if the `wren` on PATH cannot serve the git credential helper - (the same check `login` uses). + 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 @@ -401,6 +432,165 @@ def _read_json_option(raw: str, *, source: str) -> dict: typer.echo(f"Linked project {project.id} into {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, + force: Annotated[ + bool, + typer.Option("--force", "-f", 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 force: + confirm = typer.confirm( + f"Drop the stored API key for the project {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 {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 {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." + ) + + +@cloud_app.command() +def logout( + host: Annotated[ + Optional[str], + typer.Option( + "--host", + help="Wren Cloud host used at login, if you have more than one.", + ), + ] = None, + project: Annotated[ + Optional[str], + typer.Option( + "--project", + help="Project id, if you have logins for more than one project.", + ), + ] = None, + force: Annotated[ + bool, + typer.Option("--force", "-f", help="Skip the confirmation prompt."), + ] = False, +) -> None: + """Drop a stored Wren Cloud login, without touching any directory. + + This is the counterpart to ``login``. 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 + + logins = cloud.list_logins() + if project is not None: + logins = [entry for entry in logins if entry[1] == str(project)] + if host is not None: + # Same field as `link` filters on, for the same reason: `api_host` is + # what the user typed at `login` and what the candidates below print. + logins = [entry for entry in logins if entry[2]["api_host"] == host] + + if not logins: + typer.echo( + "Error: no stored Wren Cloud login found" + + (f" for project {project}" if project else "") + + ".", + err=True, + ) + raise typer.Exit(1) + if len(logins) > 1: + typer.echo( + "Error: more than one stored login matches; disambiguate with " + "--host and/or --project. Candidates:", + err=True, + ) + for _git_host, project_id, entry in logins: + typer.echo(f" --host {entry['api_host']} --project {project_id}", err=True) + raise typer.Exit(1) + + git_host, project_id, entry = logins[0] + + if not force: + confirm = typer.confirm( + f"Drop the stored API key for project {project_id} on {entry['api_host']}?" + ) + if not confirm: + raise typer.Abort() + + login_removed, helper_removed = cloud.logout(git_host, project_id) + 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.""" diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index fc9ee5bbe1..9381437ef8 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -1,10 +1,17 @@ -"""Unit tests for ``wren.cloud`` — path parsing, config writing, and the -git credential helper's stdin/stdout protocol. - -These cover the pure-logic paths only. The seven live checks against a real -Wren Cloud stack (login+clone, push, wrong-key messaging, host-scoping, -fresh-dir link, existing-dir link, nested-repo refusal) are exercised -manually, not here — a mocked HTTP/git layer cannot stand in for them. +"""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: login+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 @@ -1159,3 +1166,471 @@ def test_link_leaves_an_existing_upstream_alone(tmp_path): ["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. + """ + remote_dir.mkdir(parents=True) + cloud.run_git(["init"], cwd=remote_dir) + (remote_dir / ".hooks").mkdir() + (remote_dir / ".hooks" / "deploy-modeling.yaml").write_text( + f"version: '1'\n# project {marker}\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_accepts_an_existing_origin_that_already_matches(tmp_path): + # The re-bind-to-the-same-project path must keep working: this is how a + # user recovers a directory whose upstream was never set. + git_host = str(tmp_path / "host") + _seed_remote(tmp_path / "host" / "git" / "shared-data.git") + target = tmp_path / "proj" + assert _link(target, git_host=git_host) is cloud.LinkOutcome.LINKED + assert _link(target, git_host=git_host) is cloud.LinkOutcome.ALREADY_LINKED + + +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_refuses_a_directory_whose_history_came_from_a_project( + tmp_path, monkeypatch, _helper_check_passes +): + """The `origin`-removed variant. Without this check, `create` would build + the project and only fail later inside `link` — orphaning it again.""" + + def fail_if_called(*args, **kwargs): + raise AssertionError("create_project must not be reached") + + monkeypatch.setattr(cloud, "create_project", fail_if_called) + monkeypatch.setattr(cloud, "mint_project_key", fail_if_called) + + 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) + cloud.run_git(["remote", "remove", "origin"], 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", + ) + assert "Nothing has been created on the server." in str(exc.value) + + +# ── 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" + ) diff --git a/core/wren/tests/unit/test_cloud_cli.py b/core/wren/tests/unit/test_cloud_cli.py index 3b12f43925..d2c3fe6167 100644 --- a/core/wren/tests/unit/test_cloud_cli.py +++ b/core/wren/tests/unit/test_cloud_cli.py @@ -1,11 +1,12 @@ """Tests for the `wren cloud` Typer sub-app: option wiring and the git-credential helper's CLI-level stdin/stdout wrapping. -The seven required live checks (real login against a running Wren Cloud -stack, real git clone/push, real nested-directory refusal, ...) are -exercised manually — mocking `wren.cloud.login`/`link` here only proves the -CLI passes options through correctly, not that the underlying git/HTTP -behavior is correct. +The required live checks (real login against a running Wren Cloud stack, +real git clone/push, real nested-directory refusal, ...) are exercised +manually — mocking `wren.cloud.login`/`link`/`unlink`/`logout` 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 @@ -532,3 +533,211 @@ def test_git_credential_erase_produces_no_output(monkeypatch): ) 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_force_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", "--force"] + ) + 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", "--force"] + ) + 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 + assert "project" not in result.output.split("origin")[-1].lower() + + +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", "logout", "--force"]) + + 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", "logout", "--force"]) + 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", "logout", "--force"]) + assert result.exit_code != 0 + assert "disambiguate" in result.output.lower() + + +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", "logout", "--host", "https://cloud.getwren.ai", "--force"] + ) + 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", "logout"], input="n\n") + assert result.exit_code != 0 + assert calls["n"] == 0, "declining the prompt must not drop the key" From a961bf34ee8554f5ed4c28cdd23ac5d0fbcdf410 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Thu, 27 Aug 2026 17:07:05 +0800 Subject: [PATCH 11/30] refactor(wren): spell the confirmation-skipping flag --yes, not --force MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--force` already meant two different things in this CLI: "overwrite files" (`wren context init --force`) and "skip the confirmation prompt" (`profile rm`). The second is the odd one out — and actively misleading for the cloud commands, where the product's own guidance is never to `git push --force` and where the binding guards deliberately have no bypass at all. A user reading `wren cloud unlink --force` could reasonably expect it to override a refusal. It does not, and never will. So the confirmation-skipping flag is now `--yes`/`-y` everywhere, leaving `--force` to mean overwriting, consistently. `wren profile rm` is already released, so `--force`/`-f` keep working there as deprecated aliases rather than breaking scripts; the new cloud commands have never shipped and take `--yes`/`-y` only. Adds tests/unit/test_profile_cli_flags.py for this, because tests/test_profile_cli.py — which covers `rm` far better — is referenced by no CI job, so a regression in a shipped flag would not have been caught. (cherry picked from commit d0ac90a9bd0d2f10f1a2d826e5002beac6be1bc2) (cherry picked from commit 51b35b6fd17879dddd74980a2b48827591eadf0c) (cherry picked from commit 0e473c1fcec3df40ed9e93ce86a81dc30ad25515) --- core/wren/src/wren/cloud_cli.py | 12 ++-- core/wren/src/wren/profile_cli.py | 20 +++++-- core/wren/tests/unit/test_cloud_cli.py | 14 ++--- .../wren/tests/unit/test_profile_cli_flags.py | 58 +++++++++++++++++++ 4 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 core/wren/tests/unit/test_profile_cli_flags.py diff --git a/core/wren/src/wren/cloud_cli.py b/core/wren/src/wren/cloud_cli.py index 9e15b61e16..1d121c6739 100644 --- a/core/wren/src/wren/cloud_cli.py +++ b/core/wren/src/wren/cloud_cli.py @@ -448,9 +448,9 @@ def unlink( ), ), ] = False, - force: Annotated[ + yes: Annotated[ bool, - typer.Option("--force", "-f", help="Skip the confirmation prompt."), + typer.Option("--yes", "-y", help="Skip the confirmation prompt."), ] = False, ) -> None: """Unbind ``directory`` from the Wren Cloud project it is bound to. @@ -472,7 +472,7 @@ def unlink( """ from wren import cloud # noqa: PLC0415 - if forget_key and not force: + if forget_key and not yes: confirm = typer.confirm( f"Drop the stored API key for the project {directory} is bound to?" ) @@ -518,9 +518,9 @@ def logout( help="Project id, if you have logins for more than one project.", ), ] = None, - force: Annotated[ + yes: Annotated[ bool, - typer.Option("--force", "-f", help="Skip the confirmation prompt."), + typer.Option("--yes", "-y", help="Skip the confirmation prompt."), ] = False, ) -> None: """Drop a stored Wren Cloud login, without touching any directory. @@ -568,7 +568,7 @@ def logout( git_host, project_id, entry = logins[0] - if not force: + if not yes: confirm = typer.confirm( f"Drop the stored API key for project {project_id} on {entry['api_host']}?" ) 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_cli.py b/core/wren/tests/unit/test_cloud_cli.py index d2c3fe6167..681eb8e216 100644 --- a/core/wren/tests/unit/test_cloud_cli.py +++ b/core/wren/tests/unit/test_cloud_cli.py @@ -588,12 +588,12 @@ def fake_unlink(directory, *, forget_key): assert calls["n"] == 1 -def test_unlink_force_skips_the_confirmation(monkeypatch, tmp_path): +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", "--force"] + 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 @@ -608,7 +608,7 @@ def test_unlink_reports_a_removed_helper(monkeypatch, tmp_path): ), ) result = runner.invoke( - app, ["cloud", "unlink", str(tmp_path), "--forget-key", "--force"] + app, ["cloud", "unlink", str(tmp_path), "--forget-key", "--yes"] ) assert "credential helper" in result.output @@ -663,7 +663,7 @@ def fake_logout(git_host, project_id): return True, False monkeypatch.setattr(cloud, "logout", fake_logout) - result = runner.invoke(app, ["cloud", "logout", "--force"]) + result = runner.invoke(app, ["cloud", "logout", "--yes"]) assert result.exit_code == 0, result.output assert captured == {"git_host": "https://cloud.getwren.ai", "project_id": "16"} @@ -672,7 +672,7 @@ def fake_logout(git_host, project_id): def test_logout_errors_when_no_login_is_stored(monkeypatch): monkeypatch.setattr(cloud, "list_logins", lambda: []) - result = runner.invoke(app, ["cloud", "logout", "--force"]) + result = runner.invoke(app, ["cloud", "logout", "--yes"]) assert result.exit_code != 0 assert "no stored Wren Cloud login" in result.output @@ -686,7 +686,7 @@ def test_logout_disambiguates_multiple_stored_logins(monkeypatch): ("https://b.example.com", "17", {"api_host": "https://b.example.com"}), ], ) - result = runner.invoke(app, ["cloud", "logout", "--force"]) + result = runner.invoke(app, ["cloud", "logout", "--yes"]) assert result.exit_code != 0 assert "disambiguate" in result.output.lower() @@ -715,7 +715,7 @@ def test_logout_host_filters_on_api_host_not_git_host(monkeypatch): ), ) result = runner.invoke( - app, ["cloud", "logout", "--host", "https://cloud.getwren.ai", "--force"] + app, ["cloud", "logout", "--host", "https://cloud.getwren.ai", "--yes"] ) assert result.exit_code == 0, result.output assert captured["git_host"] == "https://internal-git.example.com" 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..2a56a46b4e --- /dev/null +++ b/core/wren/tests/unit/test_profile_cli_flags.py @@ -0,0 +1,58 @@ +"""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 + +import pytest +from typer.testing import CliRunner + +from wren import profile_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" From fd9c5043413b529fa3e52295d30ba4d978cc297d Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Thu, 27 Aug 2026 17:11:39 +0800 Subject: [PATCH 12/30] refactor(wren): extend --yes to wren memory, keeping --force where it means more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes the confirmation-flag rename. `wren memory reset` only ever asked "are you sure", so it takes `--yes`/`-y` now, with `--force`/`-f` kept as deprecated aliases since the command is released. `wren memory forget` deliberately keeps `--force` as its documented name. Its flag is not a confirmation skip: it selects non-interactive mode — the checkbox UI is skipped, and `--source` only deletes in bulk when the flag is present. Calling that `--yes` would describe it wrongly. `--yes`/`-y` are accepted as aliases anyway, so a user who learned the vocabulary elsewhere is not turned away. So across the CLI: `--yes` skips a prompt, `--force` does something stronger — overwrite files, or change mode. The new flag tests cover both commands by option parsing only, so they need no `memory` extra and run in the default unit job. tests/unit/test_memory.py runs in its own CI job behind that extra, where a flag regression would not have been caught by the default one. (cherry picked from commit 677ec4cbdf6a36c3d0eb62e18c884df48c041edf) (cherry picked from commit d29cff61d7c8805c1488c14322daf60274f8088a) (cherry picked from commit 06b605bc4a172c7788ac828fc1f2ca4fdf5754be) --- core/wren/src/wren/memory/cli.py | 35 ++++++++++++-- .../wren/tests/unit/test_profile_cli_flags.py | 47 +++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) 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/tests/unit/test_profile_cli_flags.py b/core/wren/tests/unit/test_profile_cli_flags.py index 2a56a46b4e..3e7d8bff2f 100644 --- a/core/wren/tests/unit/test_profile_cli_flags.py +++ b/core/wren/tests/unit/test_profile_cli_flags.py @@ -14,10 +14,13 @@ 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 @@ -56,3 +59,47 @@ def fake_remove(name): 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" From 890784b29be9df52b0d83c5020093fc19fa7679c Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Fri, 28 Aug 2026 15:58:18 +0800 Subject: [PATCH 13/30] fix(wren): stop blaming a project key when an org key is rejected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create_project`'s 401 said the key was probably a project key rather than an org key. The CLI already refuses a non-`osk-` key before this call, so that was never a reachable cause — and sending someone to look for a key they already have is worse than saying nothing. It now names the org and host and says what is actually likely: revoked, or belonging elsewhere. (cherry picked from commit 4ed301e5e444fb9c6be20765671458d675e61109) (cherry picked from commit cc402e6fa2f37c2cbc813cf3841ea262ca008204) --- core/wren/src/wren/cloud.py | 14 +++++++++++--- core/wren/src/wren/cloud_cli.py | 7 ------- core/wren/tests/unit/test_cloud.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py index 0df543fec0..80b3ec2874 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -1233,10 +1233,18 @@ def create_project( 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. The CLI + # already refuses a non-`osk-` key before this call, so that is + # not a reachable cause here — and since the key may have come + # from storage rather than from something just typed, guessing + # wrongly sends the user looking for a key they already have. raise InvalidApiKeyError( - f"This org key was rejected creating a project on {api_host}. " - "`wren cloud create` needs an org API key (starts with " - "`osk-`), not a project key." + 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 " diff --git a/core/wren/src/wren/cloud_cli.py b/core/wren/src/wren/cloud_cli.py index 1d121c6739..fc80b7743b 100644 --- a/core/wren/src/wren/cloud_cli.py +++ b/core/wren/src/wren/cloud_cli.py @@ -384,13 +384,6 @@ def _read_json_option(raw: str, *, source: str) -> dict: if not resolved_org_key: typer.echo("Error: an organization API key is required.", err=True) raise typer.Exit(1) - if not resolved_org_key.startswith("osk-"): - typer.echo( - "Error: `wren cloud create` needs an organization API key " - "(starts with `osk-`), not a project key.", - err=True, - ) - raise typer.Exit(1) resolved_display_name = display_name or directory.resolve().name diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index 9381437ef8..83a7b9e5d3 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -1634,3 +1634,32 @@ def test_link_leaves_no_origin_behind_when_it_refuses_a_foreign_history(tmp_path 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" From 88c6a90340a2ba359ad6df1e8a4e6da808422ae9 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Thu, 27 Aug 2026 18:12:58 +0800 Subject: [PATCH 14/30] refactor(wren): rename cloud login/logout to auth add/remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `login --project ` borrowed a verb whose every precedent is host-scoped — `docker login `, `gh auth login --hostname` — for an operation that is necessarily project-scoped: the credential itself may be a project key, and the endpoint that validates it and yields the repo path is per-project. Logging in to a single resource is not what the verb means. `auth add` / `auth remove` say what the commands do: they add and remove a stored credential. Nothing here is a session, and after the recent removal of the stored-key lookup nothing reads ambient state at all — so the name no longer suggests one. That suggestion is what made "we are logged in, so create should not ask" feel natural, and it cost a round of work. Grouping them under `auth` also puts them next to `git-credential`, the other credential-machinery command. Free to do now: `wren cloud` has never been merged to main and this branch has never been pushed, so there is no compatibility surface. It would not be free later. Internals keep "login" as a *noun* for a stored entry — `store_login`, `list_logins`, `remove_login` — which reads correctly and is what the file already called it. Only the commands and the prose that referred to them as commands changed. (cherry picked from commit 92d5595667a6e6225980de09cd2eab72a129bc22) (cherry picked from commit 1a81bb02eaabc5b294575930f2a3608bcca53fd4) (cherry picked from commit 52347b1b9de6c00b4d8dab7b82379c6e54723328) --- core/wren/.claude/CLAUDE.md | 3 +- core/wren/src/wren/cloud.py | 27 ++++++----- core/wren/src/wren/cloud_cli.py | 66 ++++++++++++++++---------- core/wren/tests/unit/test_cloud.py | 11 +++-- core/wren/tests/unit/test_cloud_cli.py | 47 +++++++++++++----- 5 files changed, 98 insertions(+), 56 deletions(-) diff --git a/core/wren/.claude/CLAUDE.md b/core/wren/.claude/CLAUDE.md index 0b264cfa66..397bd323d4 100644 --- a/core/wren/.claude/CLAUDE.md +++ b/core/wren/.claude/CLAUDE.md @@ -33,7 +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 login|link|create|unlink|logout` — Bind a local project to a Wren Cloud project's git remote. The binding *is* the git remote; after binding, plain `git push`/`git pull` are the commands +- `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/cloud.py b/core/wren/src/wren/cloud.py index 80b3ec2874..3a0ce07491 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -3,7 +3,7 @@ Two credentials, two lifetimes: - The **project API key** is the durable credential. It is prompted for once - by ``login``, stored locally (0600, keyed by git host + project id), and + 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 @@ -12,9 +12,10 @@ 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. -``login`` writes a URL-scoped credential helper entry (plus ``useHttpPath``) -into the user's *global* git config, not local config — at login time there -is no clone yet, so there is no local config to write into. Because the +``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. @@ -215,7 +216,7 @@ def _load_store() -> dict: 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 login` again." + f"Fix or remove {_CLOUD_FILE} and run `wren cloud auth add` again." ) from exc if data is None: return {"credentials": {}} @@ -347,7 +348,7 @@ def check_helper_command_serviceable() -> None: 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 at login time the CLI *is* the capable one and the breakage only + 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 @@ -450,7 +451,7 @@ def configure_git_credential_helper(git_host: str) -> None: 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 login`) stays idempotent: + 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. @@ -560,7 +561,7 @@ def git_credential_get(input_data: dict[str, str]) -> str: if not path: raise CloudError( "git did not send a project path — is `useHttpPath` set for this " - "host? Run `wren cloud login` again to fix the git configuration." + "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 @@ -569,7 +570,7 @@ def git_credential_get(input_data: dict[str, str]) -> str: 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 login` first." + f"{git_host}. Run `wren cloud auth add` first." ) token = mint_git_token(entry["api_host"], project_id, entry["api_key"]) @@ -599,7 +600,7 @@ def helper_failure_note(message: str) -> str: return ( f"wren cloud git-credential (wrenai {__version__}, {ran_as}): " f"{message}\n" - "This is the git credential helper that `wren cloud login` " + "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." ) @@ -1377,13 +1378,13 @@ def create( 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 login` using + 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 login` followed by `wren cloud link` completes the bind by + `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() @@ -1410,7 +1411,7 @@ def create( project_key = mint_project_key(api_host, project.id, org_key) def _recovery_hint() -> str: - hint = f" wren cloud login --host {host} --project {project.id}" + hint = f" wren cloud auth add --host {host} --project {project.id}" if git_host: hint += f" --git-host {git_host}" return ( diff --git a/core/wren/src/wren/cloud_cli.py b/core/wren/src/wren/cloud_cli.py index fc80b7743b..feede7e737 100644 --- a/core/wren/src/wren/cloud_cli.py +++ b/core/wren/src/wren/cloud_cli.py @@ -1,21 +1,28 @@ """Typer sub-app for ``wren cloud`` commands. -``wren cloud login`` connects a local directory to a Wren Cloud project's -git remote; ``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 `login` wires into git; it is not meant to be invoked by hand. - -``wren cloud unlink`` and ``wren cloud logout`` undo those two, and the +``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 ``logout`` drops one and touches +- **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 @@ -41,6 +48,12 @@ 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.", @@ -48,8 +61,8 @@ cloud_app.add_typer(git_credential_app) -@cloud_app.command() -def login( +@auth_app.command("add") +def auth_add( host: Annotated[ str, typer.Option("--host", help="Wren Cloud host, e.g. https://cloud.getwren.ai"), @@ -100,7 +113,7 @@ def login( typer.echo(f"Error: {exc}", err=True) raise typer.Exit(1) - typer.echo(f"Logged in to project {project} on {host}.") + 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. " @@ -119,9 +132,9 @@ def link( typer.Option( "--host", help=( - "Wren Cloud host used at login, if you logged in to more " - "than one host. Defaults to the only stored login, or the " - "one matching --project." + "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, @@ -159,7 +172,7 @@ def link( reports that and does not merge again; use ``git pull`` for updates instead. - Requires having run ``wren cloud login`` for the target project first. + Requires having run ``wren cloud auth add`` for the target project first. """ from wren import cloud # noqa: PLC0415 @@ -169,7 +182,7 @@ def link( if host is not None: # Filtered on the same field the help text promises and the # disambiguation candidates below print: `api_host` — the host the - # user passed to `login --host`, not the (possibly different) + # user passed to `auth add --host`, not the (possibly different) # `--git-host`. Filtering on `git_host` here while displaying # `api_host` would silently reject the exact value a user would # naturally reach for: the host they logged in with. @@ -179,7 +192,7 @@ def link( typer.echo( "Error: no stored Wren Cloud login found" + (f" for project {project}" if project else "") - + ". Run `wren cloud login` first.", + + ". Run `wren cloud auth add` first.", err=True, ) raise typer.Exit(1) @@ -243,7 +256,7 @@ def create( # noqa: PLR0913 "--git-host", help=( "Host git should talk to for this project's repo, if it " - "differs from --host. See `wren cloud login --help` for " + "differs from --host. See `wren cloud auth add --help` for " "when to pass this; defaults to --host." ), ), @@ -307,7 +320,7 @@ def create( # noqa: PLR0913 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 login` would. + 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 @@ -495,13 +508,13 @@ def unlink( ) -@cloud_app.command() -def logout( +@auth_app.command("remove") +def auth_remove( host: Annotated[ Optional[str], typer.Option( "--host", - help="Wren Cloud host used at login, if you have more than one.", + help="Wren Cloud host the credential was added for, if more than one.", ), ] = None, project: Annotated[ @@ -516,9 +529,10 @@ def logout( typer.Option("--yes", "-y", help="Skip the confirmation prompt."), ] = False, ) -> None: - """Drop a stored Wren Cloud login, without touching any directory. + """Remove a stored Wren Cloud credential, without touching any directory. - This is the counterpart to ``login``. It removes the stored API key, and + 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. diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index 83a7b9e5d3..f42ef112ef 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -9,7 +9,8 @@ them. Still exercised manually against a real Wren Cloud stack, not here, because -a mocked HTTP layer cannot stand in for them: login+clone, push, wrong-key +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. """ @@ -82,7 +83,7 @@ def _isolated_git_global_config(tmp_path, monkeypatch): @pytest.fixture def _helper_check_passes(monkeypatch): - """Let `login`/`create` past the credential-helper pre-flight check. + """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 @@ -818,7 +819,7 @@ def fake_post(url, headers=None, timeout=None, **kwargs): message = str(excinfo.value) assert "not an agent-mode" in message assert "sk-fresh-project-key" in message - assert "wren cloud login" in message + assert "wren cloud auth add" in message # Nothing was touched locally — this is a pure server-side outcome. assert not (target / ".git").exists() @@ -871,7 +872,7 @@ def fake_post(url, headers=None, timeout=None, **kwargs): assert "not an agent-mode" not in message assert "binding this directory to it failed" in message assert "sk-fresh-project-key" in message - assert "wren cloud login" in message + assert "wren cloud auth add" in message assert not (target / ".git").exists() @@ -900,7 +901,7 @@ def fake_login(*, host, project_id, api_key, git_host): message = str(excinfo.value) assert "network blip" in message assert "sk-fresh-project-key" in message - assert "wren cloud login" in message + assert "wren cloud auth add" in message assert "wren cloud link" in message diff --git a/core/wren/tests/unit/test_cloud_cli.py b/core/wren/tests/unit/test_cloud_cli.py index 681eb8e216..5d5a6acfee 100644 --- a/core/wren/tests/unit/test_cloud_cli.py +++ b/core/wren/tests/unit/test_cloud_cli.py @@ -3,7 +3,7 @@ The required live checks (real login against a running Wren Cloud stack, real git clone/push, real nested-directory refusal, ...) are exercised -manually — mocking `wren.cloud.login`/`link`/`unlink`/`logout` here only +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``. @@ -43,7 +43,15 @@ def fake_login(*, host, project_id, api_key, git_host): result = runner.invoke( app, - ["cloud", "login", "--host", "https://cloud.getwren.ai", "--project", "16"], + [ + "cloud", + "auth", + "add", + "--host", + "https://cloud.getwren.ai", + "--project", + "16", + ], input="sk-secret\n", ) assert result.exit_code == 0, result.output @@ -67,7 +75,8 @@ def fake_login(*, host, project_id, api_key, git_host): app, [ "cloud", - "login", + "auth", + "add", "--host", "https://api.example.com", "--project", @@ -84,7 +93,15 @@ def fake_login(*, host, project_id, api_key, git_host): def test_login_rejects_empty_key(monkeypatch): result = runner.invoke( app, - ["cloud", "login", "--host", "https://cloud.getwren.ai", "--project", "16"], + [ + "cloud", + "auth", + "add", + "--host", + "https://cloud.getwren.ai", + "--project", + "16", + ], input="\n", ) assert result.exit_code != 0 @@ -97,7 +114,15 @@ def fake_login(**kwargs): monkeypatch.setattr(cloud, "login", fake_login) result = runner.invoke( app, - ["cloud", "login", "--host", "https://cloud.getwren.ai", "--project", "16"], + [ + "cloud", + "auth", + "add", + "--host", + "https://cloud.getwren.ai", + "--project", + "16", + ], input="sk-wrong\n", ) assert result.exit_code != 0 @@ -111,7 +136,7 @@ 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 login" in result.output + assert "wren cloud auth add" in result.output def test_link_disambiguates_multiple_stored_logins(monkeypatch, tmp_path): @@ -663,7 +688,7 @@ def fake_logout(git_host, project_id): return True, False monkeypatch.setattr(cloud, "logout", fake_logout) - result = runner.invoke(app, ["cloud", "logout", "--yes"]) + 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"} @@ -672,7 +697,7 @@ def fake_logout(git_host, project_id): def test_logout_errors_when_no_login_is_stored(monkeypatch): monkeypatch.setattr(cloud, "list_logins", lambda: []) - result = runner.invoke(app, ["cloud", "logout", "--yes"]) + result = runner.invoke(app, ["cloud", "auth", "remove", "--yes"]) assert result.exit_code != 0 assert "no stored Wren Cloud login" in result.output @@ -686,7 +711,7 @@ def test_logout_disambiguates_multiple_stored_logins(monkeypatch): ("https://b.example.com", "17", {"api_host": "https://b.example.com"}), ], ) - result = runner.invoke(app, ["cloud", "logout", "--yes"]) + result = runner.invoke(app, ["cloud", "auth", "remove", "--yes"]) assert result.exit_code != 0 assert "disambiguate" in result.output.lower() @@ -715,7 +740,7 @@ def test_logout_host_filters_on_api_host_not_git_host(monkeypatch): ), ) result = runner.invoke( - app, ["cloud", "logout", "--host", "https://cloud.getwren.ai", "--yes"] + 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" @@ -738,6 +763,6 @@ def fake_logout(git_host, project_id): return True, False monkeypatch.setattr(cloud, "logout", fake_logout) - result = runner.invoke(app, ["cloud", "logout"], input="n\n") + 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" From fe87b0cd3b005ac395a076438041b9c51a1e7c24 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Thu, 27 Aug 2026 18:26:09 +0800 Subject: [PATCH 15/30] fix(wren): let create duplicate a project, and guard the git identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The foreign-history refusal was applied to `create` as well as `link`, which blocked a legitimate flow: unlink a directory, then create, to duplicate a project's content into a new one. The two cases are not the same. `link` to an *existing* project must refuse, because that remote may hold other people's content and merging an unrelated history into it publishes a mixture. A project created moments ago holds nothing but its own seed commit, so there is no content to protect — and the merge is precisely how the duplicate gets its starting point. `link` now takes `remote_is_fresh`, which `create` passes, exempting only that case. Removing `create`'s pre-flight alone would have recreated the orphan bug: the refusal inside `link` fires after the project and key exist. Hence the parameter rather than just deleting the check. Driving the duplicate flow live then exposed a second, unrelated orphan path this had missed: with no git identity configured, `create` built the project and failed inside the merge, leaving it referenced by nothing. Binding a directory that has files records commits, so the identity belongs in the pre-flight. An empty target is exempt — that path is a clone, which records no commit. Also corrects the test seeder: it distinguished two projects' seed commits by varying the hook file's *contents*, which manufactured a merge conflict real projects never produce. The server seeds a hook file with no project-specific content, so two projects' hook files are identical; only the commit message now varies, which is enough for distinct SHAs. (cherry picked from commit d666a87ff630e2ce9aea6b0dd124534827851721) (cherry picked from commit e1cfbc7086440c6c7ef648909a0ddaf8995a4225) (cherry picked from commit 38ffccad068e83c44d4f0590da34eeee7c9cbb41) --- core/wren/src/wren/cloud.py | 98 +++++++++++++------- core/wren/tests/unit/test_cloud.py | 139 +++++++++++++++++++++++------ 2 files changed, 182 insertions(+), 55 deletions(-) diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py index 3a0ce07491..ce7b834aae 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -15,10 +15,10 @@ ``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. +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 @@ -730,21 +730,53 @@ def binding_from_remote_url(url: str) -> tuple[str, str, str, str] | None: return git_host, org_id, project_id, repo_name -def check_not_already_bound(target: Path) -> None: - """Refuse a directory that is already bound to, or came from, a project. +def check_git_identity_usable(target: Path) -> None: + """Refuse when git could not record a commit in `target`. - `create` is always making a *new* project, so any existing binding is - wrong by construction. It has to be 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 there orphans - them — which is exactly what happened before this guard existed. + 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. - Two distinct states both mean "not a clean directory": + 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 `origin` remote — the binding itself; - - a HEAD carrying a seeded hook commit — no `origin` (perhaps removed by - hand), but the history still belongs to the project it came from, so - `link` would later refuse to merge it anyway. + 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: @@ -754,19 +786,9 @@ def check_not_already_bound(target: Path) -> None: 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, or run `create` in a " - "directory that is not bound to anything. Nothing has been " - "created on the server." - ) - if head_has_seeded_hooks(target): - raise CloudError( - f"{target} has no `origin`, but its history came from a Wren " - "Cloud project — its commits carry the " - "`.hooks/deploy-modeling.yaml` that project creation seeds.\n" - "Creating a new project for it would produce a directory holding " - "two projects' content. Use a clean directory, or remove this " - "one's `.git` if you no longer need its history. Nothing has " - "been created on the server." + "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." ) @@ -835,6 +857,7 @@ def link( 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. @@ -970,13 +993,21 @@ def link( # 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 head_has_seeded_hooks(target): + 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. @@ -1391,6 +1422,7 @@ def create( check_not_nested(target) check_helper_command_serviceable() check_not_already_bound(target) + check_git_identity_usable(target) api_host = host.rstrip("/") resolved_git_host = (git_host or host).rstrip("/") @@ -1454,5 +1486,11 @@ def _recovery_hint() -> str: 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, ) return project, outcome diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index f42ef112ef..8533272d7f 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -10,9 +10,8 @@ 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. +wrong-key messaging, host-scoping of the credential helper, and the +nested-repo refusal against a real nested layout. """ from __future__ import annotations @@ -1188,12 +1187,18 @@ def _seed_hooked_remote(remote_dir, *, marker="a"): 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"], cwd=remote_dir) (remote_dir / ".hooks").mkdir() (remote_dir / ".hooks" / "deploy-modeling.yaml").write_text( - f"version: '1'\n# project {marker}\n" + "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) @@ -1435,33 +1440,57 @@ def fail_if_called(*args, **kwargs): assert "Nothing has been created on the server." in message -def test_create_refuses_a_directory_whose_history_came_from_a_project( +def test_create_duplicates_a_project_from_a_directory_that_came_from_one( tmp_path, monkeypatch, _helper_check_passes ): - """The `origin`-removed variant. Without this check, `create` would build - the project and only fail later inside `link` — orphaning it again.""" - - def fail_if_called(*args, **kwargs): - raise AssertionError("create_project must not be reached") - - monkeypatch.setattr(cloud, "create_project", fail_if_called) - monkeypatch.setattr(cloud, "mint_project_key", fail_if_called) + """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") - _seed_hooked_remote(tmp_path / "host" / "git" / "shared-data.git") - target = tmp_path / "proj" + # 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) - cloud.run_git(["remote", "remove", "origin"], cwd=target) + (target / "mine.txt").write_text("content worth duplicating") + 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" - 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 "Nothing has been created on the server." in str(exc.value) + # ...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 ──────────────────────────────────────────────────────── @@ -1664,3 +1693,63 @@ def json(self): 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 From d91a7d5767ff8c02dd90637181ca2c5eb9144188 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Thu, 27 Aug 2026 18:34:59 +0800 Subject: [PATCH 16/30] feat(wren): default cloud --host to the managed service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everyone targeting the managed deployment had to type the same --host on every `auth add` and `create`. It now defaults to https://cloud.getwren.ai. Only where --host names the *target* of a command. On `link` and `auth remove` it selects among stored credentials instead, and defaulting a filter would hide a credential the user does have, so those keep no default. A bare hostname now gets https:// prepended, because --host reads as a hostname and that is what people type — without a scheme it reached requests as a relative URL and came back as "Invalid URL ... No scheme supplied", which looks like a fault in the tool rather than a fixable typo. An explicit scheme is never overridden, so a local stack on plain http still works. Since omitting --host now targets production instead of erroring, both commands name the host in the key prompt. That is the one moment before anything happens where a wrong host is still cheap to notice. (cherry picked from commit 50467d95ca8447ce416239e40d9734264cb925b2) (cherry picked from commit d510562bc03116b18e228a19692fd226f3f9061f) (cherry picked from commit a0331984f4aee45f84a7c69ea6e4e20ee8a4e9bb) --- core/wren/src/wren/cloud.py | 17 ++++++ core/wren/src/wren/cloud_cli.py | 45 +++++++++++--- core/wren/tests/unit/test_cloud.py | 20 +++++++ core/wren/tests/unit/test_cloud_cli.py | 81 ++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 9 deletions(-) diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py index ce7b834aae..500559c4d8 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -312,6 +312,23 @@ def remove_login(git_host: str, project_id: str) -> bool: # ── 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)`. diff --git a/core/wren/src/wren/cloud_cli.py b/core/wren/src/wren/cloud_cli.py index feede7e737..005d8f7053 100644 --- a/core/wren/src/wren/cloud_cli.py +++ b/core/wren/src/wren/cloud_cli.py @@ -43,6 +43,12 @@ 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" + cloud_app = typer.Typer( name="cloud", help="Connect a local project to Wren Cloud's git remote.", @@ -63,11 +69,18 @@ @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, e.g. https://cloud.getwren.ai"), - ], - project: Annotated[str, typer.Option("--project", help="Project id")], + 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( @@ -100,7 +113,11 @@ def auth_add( """ from wren import cloud # noqa: PLC0415 - api_key = typer.prompt("Wren Cloud API key", hide_input=True) + host = cloud.normalize_host(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) @@ -231,14 +248,21 @@ def link( @cloud_app.command() def create( # noqa: PLR0913 - host: Annotated[ - str, - typer.Option("--host", help="Wren Cloud host, e.g. https://cloud.getwren.ai"), - ], 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."), @@ -341,6 +365,8 @@ def create( # noqa: PLR0913 """ from wren import cloud # noqa: PLC0415 + host = cloud.normalize_host(host) + if connection_info and connection_info_file: typer.echo( "Error: pass at most one of --connection-info / --connection-info-file.", @@ -392,7 +418,8 @@ def _read_json_option(raw: str, *, source: str) -> dict: 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( - "Wren Cloud organization API key", hide_input=True + 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) diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index 8533272d7f..3ac0b15e43 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -1753,3 +1753,23 @@ def test_git_identity_check_exempts_an_empty_directory(tmp_path, monkeypatch): 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 diff --git a/core/wren/tests/unit/test_cloud_cli.py b/core/wren/tests/unit/test_cloud_cli.py index 5d5a6acfee..e640cc680c 100644 --- a/core/wren/tests/unit/test_cloud_cli.py +++ b/core/wren/tests/unit/test_cloud_cli.py @@ -766,3 +766,84 @@ def fake_logout(git_host, project_id): 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"], + input="osk-x\n", + ) + assert result.exit_code == 0, result.output + assert captured["host"] == "https://cloud.getwren.ai" From e6eb0dc5ad478c1d31afcfc794abb01a658df17a Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Thu, 27 Aug 2026 19:09:02 +0800 Subject: [PATCH 17/30] fix(wren): report the created project when cloud create fails to bind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bind failure after creation escaped as a bare git error. Observed live: a transient fetch failure against a just-created repo produced only Error: git fetch origin failed: fatal: protocol error: bad line length character: PACK with no hint that a project now existed. It happened twice, leaving two unexplained projects in the org that the user had no way to connect to what they had just run. The docstring already promised this recovery; the code never implemented it, because the `link` call sat outside the try that handles the rest. It now names the project, keeps the underlying git error visible, and says that only the bind is missing — the key is already stored by then, so `wren cloud link` finishes the job, or the project can be deleted if unwanted. Deliberately not a retry. The trigger looks like a race between project creation and the immediate first fetch, and a malformed protocol response is never a legitimate "not ready" signal — papering over it in the client would hide a server-side defect. Filed separately; a plain `git fetch` of the same repo succeeds minutes later, so the failure is transient rather than a property of fresh repos. (cherry picked from commit d5c90f24f1a27166bea2681f21b0c77548719d30) (cherry picked from commit 5bd9a4b7912ac6dd913f69e47ba34b3ea030e138) (cherry picked from commit 85ba67d41072fab719083c76b54c03a9b3571eb8) --- core/wren/src/wren/cloud.py | 47 +++++++++++++++++++--------- core/wren/tests/unit/test_cloud.py | 50 ++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 14 deletions(-) diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py index 500559c4d8..463387b8e6 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -1496,18 +1496,37 @@ def _recovery_hint() -> str: f"this directory to it failed: {exc}\n{_recovery_hint()}" ) from exc - 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, - ) + 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 return project, outcome diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index 3ac0b15e43..be38b2abef 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -1773,3 +1773,53 @@ def test_git_identity_check_exempts_an_empty_directory(tmp_path, monkeypatch): ) 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 = 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", + ) + + 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" + ) From c9e5b71017766ac5cabf8f1e860708ce2c0e0bd0 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Thu, 27 Aug 2026 19:36:01 +0800 Subject: [PATCH 18/30] fix(wren): stop printing the default directory as a bare dot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wren cloud link` in the current directory reported Linked project 1234 into .. The directory argument defaults to `Path(".")` and every message that names it ends in a full stop, so the two run together and read as the *parent* directory. Seven messages across link, create, unlink and unlink's confirmation prompt shared the defect. They now resolve the path for display; the commands still use it exactly as given. Found while running the CLI against staging, not by the suite — no test asserted on the defaulted-directory form, only on `str(tmp_path)`. (cherry picked from commit 3b3deecf623eea3a6e32b77b781afcf1bc44a896) (cherry picked from commit 82ebfef7c0e44494f3056498594865f6cb55bd06) (cherry picked from commit 3d40ae56bc40c582703b0679995ae3b21ba4dbc7) --- core/wren/src/wren/cloud_cli.py | 32 ++++++++++++++++++++------ core/wren/tests/unit/test_cloud_cli.py | 32 ++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/core/wren/src/wren/cloud_cli.py b/core/wren/src/wren/cloud_cli.py index 005d8f7053..9229bf0d43 100644 --- a/core/wren/src/wren/cloud_cli.py +++ b/core/wren/src/wren/cloud_cli.py @@ -49,6 +49,24 @@ # 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.", @@ -239,11 +257,11 @@ def link( if outcome is cloud.LinkOutcome.ALREADY_LINKED: typer.echo( - f"{directory} is already linked to project {project_id}. " + 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 {directory}.") + typer.echo(f"Linked project {project_id} into {_shown(directory)}.") @cloud_app.command() @@ -460,9 +478,9 @@ def _read_json_option(raw: str, *, source: str) -> dict: ) if outcome is cloud.LinkOutcome.ALREADY_LINKED: - typer.echo(f"{directory} is already linked to project {project.id}.") + typer.echo(f"{_shown(directory)} is already linked to project {project.id}.") else: - typer.echo(f"Linked project {project.id} into {directory}.") + typer.echo(f"Linked project {project.id} into {_shown(directory)}.") @cloud_app.command() @@ -507,7 +525,7 @@ def unlink( if forget_key and not yes: confirm = typer.confirm( - f"Drop the stored API key for the project {directory} is bound to?" + f"Drop the stored API key for the project {_shown(directory)} is bound to?" ) if not confirm: raise typer.Abort() @@ -519,12 +537,12 @@ def unlink( raise typer.Exit(1) if outcome.project_id is not None: - typer.echo(f"Unlinked {directory} from project {outcome.project_id}.") + 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 {directory}." + 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.") diff --git a/core/wren/tests/unit/test_cloud_cli.py b/core/wren/tests/unit/test_cloud_cli.py index e640cc680c..cdccd8263c 100644 --- a/core/wren/tests/unit/test_cloud_cli.py +++ b/core/wren/tests/unit/test_cloud_cli.py @@ -250,6 +250,38 @@ def fake_link(directory, *, git_host, api_host, project_id, org_id, repo): 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 ): From df4762ea2b88b19f59b4b69ceca79b73610c18ff Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Fri, 28 Aug 2026 13:13:10 +0800 Subject: [PATCH 19/30] feat(wren): make cloud create convert the project in the directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create` produced a project the web UI reported as unfinished. It set up the connection and the git remote, but a project with no models is `DATASOURCE_SAVED`, not `ONBOARDING_FINISHED`, and nothing in this CLI could take it further — the mutation that picks tables is GraphQL behind a session, and the REST surface exposes models read-only. `create` exists to turn a Wren project you already have into a Wren Cloud project, so the models it should end up with are the ones already defined in the directory. It now builds them, and refuses when there is nothing to convert: no `wren_project.yml`, or YAML that does not compile. Both checks run before the project is created, so a refusal still leaves nothing behind. The models travel by git, not by the API. Uploading the built manifest as well looked obvious and is wrong: 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 — every file conflicted, add/add. So the manifest is built to validate and thrown away, and `create` pushes instead. The push is what fires `.hooks/deploy-modeling.yaml`, and that is what creates the models. `--mdl-file` goes with it. The directory already is the manifest's source, at a known path; a second way to supply one only reintroduces the two-authors problem. `--type` is upper-cased before it is sent, and its help now names values the server accepts. It said `BIGQUERY`, which is not in the enum, and `POST /api/v1/projects` does not validate the type: an unrecognized value strips the connection info to `{}` and returns 207 with a project that has no data source. Following the CLI's own help produced that. Reported separately; the case fix is the part a client can own without mirroring the enum. Verified end to end against a running deployment: `create` in a project directory yields a project with its models deployed and the data source connected — `type` set, no sample dataset, models > 0, which is the exact condition the web UI reads as set up. (cherry picked from commit aaf2f1b0860af70e4ff0b2a43c3df58299a636e3) (cherry picked from commit 69a9998945022a44340106d82de59bd18f451361) (cherry picked from commit 3503fd3e770293a43d9bb837007ac2fadc09c38a) --- core/wren/src/wren/cloud.py | 73 +++++++- core/wren/src/wren/cloud_cli.py | 28 ++- core/wren/tests/unit/test_cloud.py | 237 ++++++++++++++++++++++--- core/wren/tests/unit/test_cloud_cli.py | 55 ++++++ 4 files changed, 356 insertions(+), 37 deletions(-) diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py index 463387b8e6..8d0cca4182 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -1384,6 +1384,56 @@ def mint_project_key( 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, *, @@ -1395,7 +1445,6 @@ def create( 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, ) -> tuple[CreatedProject, LinkOutcome]: @@ -1440,6 +1489,10 @@ def create( 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("/") @@ -1452,7 +1505,6 @@ def create( connection_type=connection_type, connection_info=connection_info, test_connection=test_connection, - mdl=mdl, language=language, timezone=timezone, ) @@ -1529,4 +1581,21 @@ def _recovery_hint() -> str: 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 index 9229bf0d43..612e74a344 100644 --- a/core/wren/src/wren/cloud_cli.py +++ b/core/wren/src/wren/cloud_cli.py @@ -307,7 +307,10 @@ def create( # noqa: PLR0913 Optional[str], typer.Option( "--type", - help="Data source type for the connection, e.g. POSTGRES, BIGQUERY.", + help=( + "Data source type for the connection, e.g. BIG_QUERY, " + "POSTGRES, SNOWFLAKE. Case-insensitive." + ), ), ] = None, connection_info: Annotated[ @@ -331,10 +334,6 @@ def create( # noqa: PLR0913 help="Ask the server to test the connection before creating the project.", ), ] = False, - mdl_file: Annotated[ - Optional[Path], - typer.Option("--mdl-file", help="Path to a JSON file with an initial MDL."), - ] = None, language: Annotated[Optional[str], typer.Option("--language")] = None, timezone: Annotated[Optional[str], typer.Option("--timezone")] = None, org_key: Annotated[ @@ -418,21 +417,21 @@ def _read_json_option(raw: str, *, source: str) -> dict: 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() + if parsed_connection_info is not None and not type_: typer.echo( "Error: --type is required when a connection info is given.", err=True ) raise typer.Exit(1) - parsed_mdl: Optional[dict] = None - if mdl_file is not None: - try: - raw = mdl_file.read_text(encoding="utf-8") - except OSError as exc: - typer.echo(f"Error: could not read {mdl_file}: {exc}", err=True) - raise typer.Exit(1) from exc - parsed_mdl = _read_json_option(raw, source="--mdl-file") - 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( @@ -456,7 +455,6 @@ def _read_json_option(raw: str, *, source: str) -> dict: connection_type=type_, connection_info=parsed_connection_info, test_connection=test_connection, - mdl=parsed_mdl, language=language, timezone=timezone, ) diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index be38b2abef..fd954fa2fe 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -20,6 +20,7 @@ import socket import subprocess import time +from pathlib import Path import pytest import requests @@ -332,6 +333,37 @@ def _git_identity(monkeypatch): 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 @@ -341,6 +373,10 @@ def _seed_remote(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"): @@ -358,8 +394,7 @@ 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 = tmp_path / "project" - target.mkdir() + 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 @@ -705,8 +740,7 @@ def fake_mint_git_token(api_host, project_id, api_key, **kwargs): monkeypatch.setattr(cloud, "mint_git_token", fake_mint_git_token) - target = tmp_path / "project" - target.mkdir() + target = _make_wren_project(tmp_path / "project") (target / "mine.txt").write_text("my existing file") project, outcome = cloud.create( @@ -766,8 +800,7 @@ def fake_create_project(api_host, org_key, **kwargs): monkeypatch.setattr(cloud, "create_project", fake_create_project) - target = tmp_path / "project" - target.mkdir() + target = _make_wren_project(tmp_path / "project") with pytest.raises(cloud.CloudError, match="boom"): cloud.create( @@ -803,8 +836,7 @@ def fake_post(url, headers=None, timeout=None, **kwargs): monkeypatch.setattr(requests, "post", fake_post) - target = tmp_path / "project" - target.mkdir() + target = _make_wren_project(tmp_path / "project") with pytest.raises(cloud.CloudError) as excinfo: cloud.create( @@ -855,8 +887,7 @@ def fake_post(url, headers=None, timeout=None, **kwargs): monkeypatch.setattr(requests, "post", fake_post) - target = tmp_path / "project" - target.mkdir() + target = _make_wren_project(tmp_path / "project") with pytest.raises(cloud.CloudError) as excinfo: cloud.create( @@ -885,8 +916,7 @@ def fake_login(*, host, project_id, api_key, git_host): monkeypatch.setattr(cloud, "login", fake_login) - target = tmp_path / "project" - target.mkdir() + target = _make_wren_project(tmp_path / "project") with pytest.raises(cloud.CloudError) as excinfo: cloud.create( @@ -908,8 +938,7 @@ 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 = tmp_path / "project" - target.mkdir() + target = _make_wren_project(tmp_path / "project") (target / "mine.txt").write_text("my existing file") first = _link(target, git_host=git_host) @@ -1072,8 +1101,7 @@ def fail_if_called(*args, **kwargs): monkeypatch.setattr(cloud, "create_project", fail_if_called) monkeypatch.setattr(cloud, "mint_project_key", fail_if_called) - target = tmp_path / "project" - target.mkdir() + target = _make_wren_project(tmp_path / "project") with pytest.raises(cloud.CloudError): cloud.create( @@ -1114,8 +1142,7 @@ def test_link_sets_upstream_when_already_linked_but_tracking_was_never_set(tmp_p remote = tmp_path / "host" / "git" / "shared-data.git" _seed_remote(remote) - target = tmp_path / "project" - target.mkdir() + 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 @@ -1196,6 +1223,11 @@ def _seed_hooked_remote(remote_dir, *, marker="a"): """ remote_dir.mkdir(parents=True) cloud.run_git(["init"], 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" @@ -1457,6 +1489,8 @@ def test_create_duplicates_a_project_from_a_directory_that_came_from_one( 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" @@ -1804,8 +1838,7 @@ def fake_link(*args, **kwargs): monkeypatch.setattr(cloud, "link", fake_link) - target = tmp_path / "proj" - target.mkdir() + target = _make_wren_project(tmp_path / "proj") (target / "model.yml").write_text("name: orders\n") with pytest.raises(cloud.CloudError) as exc: @@ -1823,3 +1856,167 @@ def fake_link(*args, **kwargs): 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" diff --git a/core/wren/tests/unit/test_cloud_cli.py b/core/wren/tests/unit/test_cloud_cli.py index cdccd8263c..e8d4716e86 100644 --- a/core/wren/tests/unit/test_cloud_cli.py +++ b/core/wren/tests/unit/test_cloud_cli.py @@ -879,3 +879,58 @@ def fake_create(directory, **kwargs): ) 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 From ed43dc145434208ab31b2de8c07084c9de21f3cc Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Fri, 28 Aug 2026 14:46:52 +0800 Subject: [PATCH 20/30] feat(wren): require a data source when creating a cloud project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create` accepted no `--type` and no connection info, and produced a project with no data source. Wren Cloud reports that as still needing setup, and this CLI cannot finish it: the only ways to attach a connection afterwards are a REST call or the web UI. So the command was able to hand back a project that nothing it owns could make usable. Both are now required, and named individually when missing so the message says which one to add. The refusal joins the others that run before the project is created, so it costs nothing: verified live that the server-side project count is unchanged across all three ways of getting it wrong. The previous rule — `--type` required *when* connection info was given — is subsumed. Its test is replaced by a parametrized one covering all three cases, which also asserts `cloud.create` is never reached. (cherry picked from commit b5835c552f933df484ec0d10017167e2519f2577) (cherry picked from commit 946b6f5b0995b3cecd8ca1b336ee75cef06209de) (cherry picked from commit 02cafb88c98048608c1f2648e1f8b4e59da28484) --- core/wren/src/wren/cloud_cli.py | 38 +++++++++++++---- core/wren/tests/unit/test_cloud_cli.py | 56 +++++++++++++++++--------- 2 files changed, 69 insertions(+), 25 deletions(-) diff --git a/core/wren/src/wren/cloud_cli.py b/core/wren/src/wren/cloud_cli.py index 612e74a344..c6346dd08e 100644 --- a/core/wren/src/wren/cloud_cli.py +++ b/core/wren/src/wren/cloud_cli.py @@ -369,11 +369,17 @@ def create( # noqa: PLR0913 fails with a specific error rather than a bare HTTP error, and reports how to recover the project's key. - Refuses up front — before creating anything on the server — 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. + `--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 @@ -426,9 +432,27 @@ def _read_json_option(raw: str, *, source: str) -> dict: if type_ is not None: type_ = type_.strip().upper() - if parsed_connection_info is not None and not type_: + # 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( - "Error: --type is required when a connection info is given.", err=True + 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) diff --git a/core/wren/tests/unit/test_cloud_cli.py b/core/wren/tests/unit/test_cloud_cli.py index e8d4716e86..48481bdc6b 100644 --- a/core/wren/tests/unit/test_cloud_cli.py +++ b/core/wren/tests/unit/test_cloud_cli.py @@ -328,6 +328,12 @@ def fake_link(*args, **kwargs): # ── 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 ): @@ -355,6 +361,7 @@ def fake_create(directory, **kwargs): "https://cloud.getwren.ai", "--org", "2", + *CONN_ARGS, ], ) assert result.exit_code == 0, result.output @@ -381,7 +388,7 @@ def fake_create(directory, **kwargs): result = runner.invoke( app, - ["cloud", "create", str(tmp_path), "--host", "h", "--org", "2"], + ["cloud", "create", str(tmp_path), "--host", "h", "--org", "2", *CONN_ARGS], input="osk-typed-in\n", ) assert result.exit_code == 0, result.output @@ -391,7 +398,7 @@ def fake_create(directory, **kwargs): 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"], + ["cloud", "create", str(tmp_path), "--host", "h", "--org", "2", *CONN_ARGS], input="sk-project-key\n", ) assert result.exit_code != 0 @@ -419,24 +426,37 @@ def test_create_rejects_both_connection_info_flags_together(tmp_path): assert "at most one of" in result.output -def test_create_requires_type_when_connection_info_is_given(tmp_path): +@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", - "--connection-info", - '{"host": "db"}', - ], + ["cloud", "create", str(tmp_path), "--host", "h", "--org", "2", *args], input="osk-x\n", ) + assert result.exit_code != 0 - assert "--type is required" in result.output + 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): @@ -494,7 +514,7 @@ def fake_create(directory, **kwargs): result = runner.invoke( app, - ["cloud", "create", str(tmp_path), "--host", "h", "--org", "2"], + ["cloud", "create", str(tmp_path), "--host", "h", "--org", "2", *CONN_ARGS], input="osk-x\n", ) assert result.exit_code == 0, result.output @@ -510,7 +530,7 @@ def fake_create(directory, **kwargs): result = runner.invoke( app, - ["cloud", "create", str(tmp_path), "--host", "h", "--org", "2"], + ["cloud", "create", str(tmp_path), "--host", "h", "--org", "2", *CONN_ARGS], input="osk-x\n", ) assert result.exit_code != 0 @@ -874,7 +894,7 @@ def fake_create(directory, **kwargs): monkeypatch.setattr(cloud, "create", fake_create) result = runner.invoke( app, - ["cloud", "create", str(tmp_path), "--org", "190"], + ["cloud", "create", str(tmp_path), "--org", "190", *CONN_ARGS], input="osk-x\n", ) assert result.exit_code == 0, result.output From 70833b27eb69cc30135345f040c4df04e1c6628b Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Fri, 28 Aug 2026 15:16:19 +0800 Subject: [PATCH 21/30] fix(wren): align the local branch with the remote's default on link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found that nothing ever renamed the local branch. Wren Cloud seeds `main`; a client whose git still defaults to `master` — upstream git's builtin — ended up with the branch and its upstream disagreeing, and every symptom of that read as success: local branch after link: master upstream after link: origin/main push origin HEAD -> rc=0 remote branches after push: [main, master] <- a second branch remote main tip: b1606c5 local HEAD: 97983eb plain git push -> rc=128: "The upstream branch of your current branch does not match the name of your current branch" So `create`'s push reported success while pushing to a branch the deploy hook does not watch, producing exactly the "project exists, is bound, and has nothing in it" state that push was added to prevent — and the plain `git push` this design promises, and the docstring claims, failed outright. `_default_branch` already discovered the remote's name correctly and `_set_upstream` already used it; only the branch itself was left behind. `_align_branch_name` now renames it (or points unborn HEAD at it), before the ancestor comparisons, which all speak in terms of `origin/`. A branch that already has an upstream is left alone — the same rule the already-linked path follows for a deliberate upstream. Neither the suite nor live testing could see this: this machine's Apple Git has a patched builtin default of `main`, and the fixtures hide global config, so the stand-in remotes and the targets always agreed. The stand-in remotes now pass `git init -b main` explicitly rather than inheriting whichever builtin the runner has, and the new test forces the mismatch with `git init -b master`. It asserts on the push landing on one branch, not just on the branch name — the name alone would miss the second-branch symptom. Also from the same review: - `--git-host` was passed through raw while `--host` was normalized. A scheme-less value writes a git-config section git never matches, while the helper looks under the scheme-ful form, so both halves miss and `auth add` still prints that git is configured. Normalized at both call sites. - `run_git` with a nonexistent cwd raised FileNotFoundError, which the CLI does not catch, so `link /no/such/dir` printed a traceback where every other refusal prints a message. - `test_find_git_root_returns_none_when_absent` asserted `... is None or True`, which cannot fail. Replaced with the part that is actually knowable. Verified live through the real CLI on a `master`-branch directory: branch and upstream both end on `main`, plain `git push` works, and the deploy hook fires (the models appear, and the project reports as set up). (cherry picked from commit 3e7bfcafcf6aea29c19c3c4b2d4de68197d731e6) (cherry picked from commit e914486fa82cffbb1c9130240eb75dfe94344b0b) (cherry picked from commit baa6a7c6188f5b462412a410469db8cc140cb46d) --- core/wren/src/wren/cloud.py | 56 ++++++++++++++++ core/wren/src/wren/cloud_cli.py | 14 ++++ core/wren/tests/unit/test_cloud.py | 91 ++++++++++++++++++++++++-- core/wren/tests/unit/test_cloud_cli.py | 48 ++++++++++++++ 4 files changed, 204 insertions(+), 5 deletions(-) diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py index 8d0cca4182..43f80315a3 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -657,6 +657,12 @@ def run_git( 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, @@ -966,6 +972,10 @@ def link( 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. + _align_branch_name(target, branch) # If `origin/` is already an ancestor of HEAD, merging it would # add nothing: either a previous `link` already did the reconciling @@ -1081,6 +1091,52 @@ def _has_upstream(target: Path) -> bool: ) +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 + + unborn = ( + run_git(["rev-parse", "--verify", "HEAD"], cwd=target, check=False).returncode + != 0 + ) + if unborn: + # `git branch -m` has nothing to rename before the first commit. + run_git(["symbolic-ref", "HEAD", f"refs/heads/{branch}"], cwd=target) + return + + current = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=target).stdout.strip() + if current == branch: + return + + 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/`. diff --git a/core/wren/src/wren/cloud_cli.py b/core/wren/src/wren/cloud_cli.py index c6346dd08e..7956df9340 100644 --- a/core/wren/src/wren/cloud_cli.py +++ b/core/wren/src/wren/cloud_cli.py @@ -132,6 +132,13 @@ def auth_add( 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. @@ -389,6 +396,13 @@ def create( # noqa: PLR0913 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( diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index fd954fa2fe..d2363bfe8c 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -279,9 +279,15 @@ def test_check_not_nested_refuses_when_ancestor_is_a_git_repo(tmp_path): def test_find_git_root_returns_none_when_absent(tmp_path): - assert cloud.find_git_root(tmp_path / "nope" / "deeper") is None or True - # tmp_path itself has no .git, but parents might in exotic CI setups; - # what matters is it never raises. + """`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 ────────────────────────────────────────────────────── @@ -369,7 +375,7 @@ def _seed_remote(remote_dir): 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"], cwd=remote_dir) + 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) @@ -1222,7 +1228,7 @@ def _seed_hooked_remote(remote_dir, *, marker="a"): conflict that real projects never produce. """ remote_dir.mkdir(parents=True) - cloud.run_git(["init"], cwd=remote_dir) + 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( @@ -2020,3 +2026,78 @@ def fail_only_push(args, **kwargs): 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) diff --git a/core/wren/tests/unit/test_cloud_cli.py b/core/wren/tests/unit/test_cloud_cli.py index 48481bdc6b..38b575c1d8 100644 --- a/core/wren/tests/unit/test_cloud_cli.py +++ b/core/wren/tests/unit/test_cloud_cli.py @@ -954,3 +954,51 @@ def test_create_help_names_a_type_the_server_accepts(monkeypatch): 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" + ) From a93406032270e2b35d5f04e553ff625f0b9b35c7 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Fri, 28 Aug 2026 15:36:44 +0800 Subject: [PATCH 22/30] fix(wren): store the new project's key instead of printing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review flagged the recovery hint: when `create` could not validate the key it had just minted, it printed the key into the error so it would not be lost. stderr routinely reaches CI logs and bug reports pasted verbatim, which is not somewhere a live credential belongs. The key is now stored before the validating call, so the message never has to carry it — it says the key is already stored and names the one command that finishes the job. Storing first needed one adjustment to the reviewer's sketch: `store_login` wants the repo path, and only `mint_git_token` returns it — the very call that can fail. Rather than hardcode the server's repo naming, the key is stored with an empty repo (`store_key_pending_repo`) and `resolve_repo` fills it in on first use, via the same call the credential helper already makes on every git operation. The helper itself never reads `repo` — it derives the repo from the path git hands it — so an entry stored this way authenticates the moment it exists; only `link` needs the field, and it now completes the record. Three tests asserted the key *was* in the message. They now assert it is not — and, because absence alone would also pass if the key had simply been dropped, that it is retrievable from the store. (cherry picked from commit 9d9ab3db5ac92dd5ae8ac454a724ca0065a82499) (cherry picked from commit 56e5f344e5ba560a4324fdd034b98548456ee020) (cherry picked from commit 3ea4e575215be3aece436f4eea967537f49511a7) --- core/wren/src/wren/cloud.py | 75 ++++++++++++++++++++++--- core/wren/src/wren/cloud_cli.py | 4 +- core/wren/tests/unit/test_cloud.py | 90 ++++++++++++++++++++++++++++-- 3 files changed, 155 insertions(+), 14 deletions(-) diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py index 43f80315a3..7b939b6f4e 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -541,6 +541,54 @@ def login( 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 ─────────────────────── @@ -1567,15 +1615,28 @@ def create( 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, + ) + def _recovery_hint() -> str: - hint = f" wren cloud auth add --host {host} --project {project.id}" - if git_host: - hint += f" --git-host {git_host}" return ( - f"The project's key is: {project_key}\n" - "Recover with:\n" - f"{hint}\n" - "then `wren cloud link` in this directory." + 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: diff --git a/core/wren/src/wren/cloud_cli.py b/core/wren/src/wren/cloud_cli.py index 7956df9340..375ae2dc12 100644 --- a/core/wren/src/wren/cloud_cli.py +++ b/core/wren/src/wren/cloud_cli.py @@ -256,7 +256,9 @@ def link( api_host=entry["api_host"], project_id=project_id, org_id=entry["org_id"], - repo=entry["repo"], + # 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) diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index d2363bfe8c..f30b2b9a0e 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -855,8 +855,16 @@ def fake_post(url, headers=None, timeout=None, **kwargs): message = str(excinfo.value) assert "not an agent-mode" in message - assert "sk-fresh-project-key" in message - assert "wren cloud auth add" 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() @@ -907,8 +915,16 @@ def fake_post(url, headers=None, timeout=None, **kwargs): 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" in message - assert "wren cloud auth add" 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() @@ -935,8 +951,16 @@ def fake_login(*, host, project_id, api_key, git_host): message = str(excinfo.value) assert "network blip" in message - assert "sk-fresh-project-key" in message - assert "wren cloud auth add" 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 @@ -2101,3 +2125,57 @@ def test_run_git_reports_a_missing_directory_as_a_cloud_error(tmp_path): 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" + ) From f1b79cf9f9fa5f513ce4d89db51a8e8c09c6cea3 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Fri, 28 Aug 2026 15:49:25 +0800 Subject: [PATCH 23/30] fix(wren): configure git when create stores a key it could not validate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 review caught a gap the previous commit introduced. Storing the key instead of printing it removed the credential from stderr, but `login` writes the credential helper only *after* the git-token call succeeds — and that is the call that fails on this path. So the key was stored and the hint said "finish with `wren cloud link`", while link would fetch over HTTPS with no helper for the host and git would prompt for a username. Reproduced: key stored, no `credential..helper` section. It was also worse than what it replaced: the old fallback was `auth add`, and with the key no longer displayed — and no command that prints a stored one — the user could not answer its prompt. Fixed by configuring the helper alongside storing the key. Serviceability is already checked in create's pre-flight and the write is idempotent. The test asserts through `git config --get-urlmatch`, i.e. that *git* resolves a helper for the host the hinted command will use, rather than that our file has a line in it. Three more from the same round: - The rename refusal did not roll back an `origin` this call added, unlike the foreign-history refusal 60 lines below it. A refusal left the directory pointing at a project it was never bound to, so the next attempt refused "already bound" for a bind that never happened. - `_align_branch_name`'s unborn-HEAD branch was unreachable: `link` commits any unborn HEAD before calling it. Removed rather than kept as untested defensive code. Detached HEAD now gets a message that says so, instead of "is on branch `HEAD`" plus git's rename error. - `link --host` and `auth remove --host` compared the raw flag against the normalized stored `api_host`, so `--host cloud.getwren.ai` reported "no stored login" for a login that exists — the reading-side mirror of the `--git-host` defect fixed in the previous commit. Pre-existing, adjacent, and the same one-line shape. (cherry picked from commit b2ca2c6702587d607c539cca61e7e12a93917a53) (cherry picked from commit 8eb00583714fa97b011a017d309aabc1d8eb83f5) (cherry picked from commit 352b08688422ddeb1c08d209eb33697327ebc6c2) --- core/wren/src/wren/cloud.py | 36 +++++++++---- core/wren/src/wren/cloud_cli.py | 20 +++++++- core/wren/tests/unit/test_cloud.py | 71 ++++++++++++++++++++++++++ core/wren/tests/unit/test_cloud_cli.py | 35 +++++++++++++ 4 files changed, 150 insertions(+), 12 deletions(-) diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py index 7b939b6f4e..ae1fafe864 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -1023,7 +1023,16 @@ def link( # 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. - _align_branch_name(target, branch) + 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 @@ -1158,18 +1167,17 @@ def _align_branch_name(target: Path, branch: str) -> None: if _has_upstream(target): return - unborn = ( - run_git(["rev-parse", "--verify", "HEAD"], cwd=target, check=False).returncode - != 0 - ) - if unborn: - # `git branch -m` has nothing to rename before the first commit. - run_git(["symbolic-ref", "HEAD", f"refs/heads/{branch}"], cwd=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: @@ -1628,6 +1636,14 @@ def create( 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. + configure_git_credential_helper(resolved_git_host) def _recovery_hint() -> str: return ( diff --git a/core/wren/src/wren/cloud_cli.py b/core/wren/src/wren/cloud_cli.py index 375ae2dc12..a579e79347 100644 --- a/core/wren/src/wren/cloud_cli.py +++ b/core/wren/src/wren/cloud_cli.py @@ -228,7 +228,15 @@ def link( # `--git-host`. Filtering on `git_host` here while displaying # `api_host` would silently reject the exact value a user would # naturally reach for: the host they logged in with. - logins = [entry for entry in logins if entry[2]["api_host"] == host] + # Normalized before comparing: `auth add` stores the normalized form, + # so a scheme-less `--host cloud.getwren.ai` here matched nothing and + # reported "no stored login" for a login that exists. Same defect as + # the one just fixed on the writing side, on the reading side. + logins = [ + entry + for entry in logins + if entry[2]["api_host"] == cloud.normalize_host(host) + ] if not logins: typer.echo( @@ -636,7 +644,15 @@ def auth_remove( if host is not None: # Same field as `link` filters on, for the same reason: `api_host` is # what the user typed at `login` and what the candidates below print. - logins = [entry for entry in logins if entry[2]["api_host"] == host] + # Normalized before comparing: `auth add` stores the normalized form, + # so a scheme-less `--host cloud.getwren.ai` here matched nothing and + # reported "no stored login" for a login that exists. Same defect as + # the one just fixed on the writing side, on the reading side. + logins = [ + entry + for entry in logins + if entry[2]["api_host"] == cloud.normalize_host(host) + ] if not logins: typer.echo( diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index f30b2b9a0e..7439630e53 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -2179,3 +2179,74 @@ def fail_if_called(*args, **kwargs): 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" + ) diff --git a/core/wren/tests/unit/test_cloud_cli.py b/core/wren/tests/unit/test_cloud_cli.py index 38b575c1d8..65218d9e2d 100644 --- a/core/wren/tests/unit/test_cloud_cli.py +++ b/core/wren/tests/unit/test_cloud_cli.py @@ -1002,3 +1002,38 @@ def fake_create(directory, **kwargs): 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 From 0c842f84afc33341816e1252db7992910466ad15 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Fri, 28 Aug 2026 15:55:47 +0800 Subject: [PATCH 24/30] fix(wren): name the project when configuring git fails after creating it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last item from round 3. Moving the credential-helper write out of `login` took it out of the except-block that names the project, so a failure there died with a bare "git config --global ... failed:" — no project id, no mention that the key is stored — on a path where the project and its key already exist. The trigger is real, not hypothetical: `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, so it does not reach here.) Confirmed the reviewer's other candidate does not reproduce: a read-only config *file* in a writable directory succeeds, because git writes via a temp file and renames over it. (cherry picked from commit 9b9c9a200e2fc898530cb3fae860e88488a7055d) (cherry picked from commit 592494bcbb8acec2d7aa085ac077f4909245d627) (cherry picked from commit 9144402e8fd85dab21f553e0336f70bcc4750a3f) --- core/wren/src/wren/cloud.py | 16 ++++++++++++- core/wren/tests/unit/test_cloud.py | 36 ++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py index ae1fafe864..2e12d40979 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -1643,7 +1643,6 @@ def create( # 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. - configure_git_credential_helper(resolved_git_host) def _recovery_hint() -> str: return ( @@ -1655,6 +1654,21 @@ def _recovery_hint() -> str: 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, diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index 7439630e53..af3d8bae08 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -2250,3 +2250,39 @@ def test_link_removes_an_origin_it_added_when_the_rename_refuses(tmp_path): 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" From 9b39251fd56e1940259911be11638aca4ab26ec0 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Fri, 28 Aug 2026 16:04:55 +0800 Subject: [PATCH 25/30] docs(wren): document the wren cloud commands in the CLI reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commands shipped with no reference-doc entry — the docstrings were the only documentation, which is not where anyone looks for a flag. Leads with the two things that are not guessable from the flags: the binding is the git remote (so plain git is the interface afterwards, and nothing local records the directory-to-project mapping), and the credential split between a durable on-disk project key and a per-operation token that never touches disk. Every documented flag was checked against its subcommand's own `--help` rather than written from memory, and the factual claims — the default host, the credential path, its mode — against the source. (cherry picked from commit f1b1d2c38cec1219c8e978e75ab0cdffbfda6da1) (cherry picked from commit 33a89fabfb37bee636acc12e09175eb63e5afeb1) --- docs/core/reference/cli.md | 119 +++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/docs/core/reference/cli.md b/docs/core/reference/cli.md index a0c90832c3..839021caf6 100644 --- a/docs/core/reference/cli.md +++ b/docs/core/reference/cli.md @@ -613,3 +613,122 @@ 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` defaults to `https://cloud.getwren.ai`; pass a URL for a self-hosted +deployment. `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. Data-source names are the API's, e.g. `BIG_QUERY`, +`POSTGRES`, `MYSQL`, `SNOWFLAKE`, `TRINO`. + +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 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. From c36ff2de4427b26bc4d576e4fdee7815847d2dbc Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Fri, 28 Aug 2026 16:25:41 +0800 Subject: [PATCH 26/30] docs(wren): link the API reference from --type, and trim three tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--type` and `--connection-info` are passed straight through to the API, so the accepted values and the per-datasource field names live there, not here. The doc now points at the published reference for both rather than half-listing them — and says so, since a data source added upstream works here with no CLI release. Keeps the one thing worth stating inline: the underscore in `BIG_QUERY`, which is the mistake this costs people. Test cleanup from a read-through: - `test_git_credential_get_error_goes_to_stderr_not_stdout` asserted on `result.output`, which combines the streams — so it proved the message existed somewhere, not that it stayed off stdout. That distinction is the whole point of the test: `get`'s stdout is parsed by git as credential fields, so anything written there is fed to git as credential data. Now asserts against `result.stderr` and requires stdout to be empty. - Deleted two tests that were strict subsets of others driving the same path: a 401 that only checked the exception type (the survivor also pins the message) and a double-link that only checked the outcomes (the survivor also proves HEAD did not move). The first also still carried the removed "project key" premise in its name. - One assertion sliced the message on the word "origin" to check a project was not named, so a rewording that dropped that word would silently change what was being checked. Asserts on the concrete strings instead. --- core/wren/tests/unit/test_cloud.py | 25 ------------------------- core/wren/tests/unit/test_cloud_cli.py | 19 +++++++++++++++++-- docs/core/reference/cli.md | 10 ++++++++-- 3 files changed, 25 insertions(+), 29 deletions(-) diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index af3d8bae08..c90d825c4a 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -580,21 +580,6 @@ def fake_post(url, json=None, headers=None, timeout=None): assert project.errors == [{"resource": "mdl", "message": "bad mdl"}] -def test_create_project_rejects_org_or_project_key_on_401(monkeypatch): - def fake_post(url, json=None, headers=None, timeout=None): - return _FakeResponse(401, {}, text="unauthorized") - - monkeypatch.setattr(requests, "post", fake_post) - - with pytest.raises(cloud.InvalidApiKeyError): - cloud.create_project( - "https://cloud.getwren.ai", - "sk-not-an-org-key", - org_id="2", - display_name="p", - ) - - 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"}) @@ -1412,16 +1397,6 @@ def test_link_refuses_when_origin_points_at_a_different_project(tmp_path): assert cloud.current_remote_url(target) == f"{git_host}/git/org/2/99/other.git" -def test_link_accepts_an_existing_origin_that_already_matches(tmp_path): - # The re-bind-to-the-same-project path must keep working: this is how a - # user recovers a directory whose upstream was never set. - git_host = str(tmp_path / "host") - _seed_remote(tmp_path / "host" / "git" / "shared-data.git") - target = tmp_path / "proj" - assert _link(target, git_host=git_host) is cloud.LinkOutcome.LINKED - assert _link(target, git_host=git_host) is cloud.LinkOutcome.ALREADY_LINKED - - 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 diff --git a/core/wren/tests/unit/test_cloud_cli.py b/core/wren/tests/unit/test_cloud_cli.py index 65218d9e2d..8a6c8de141 100644 --- a/core/wren/tests/unit/test_cloud_cli.py +++ b/core/wren/tests/unit/test_cloud_cli.py @@ -556,6 +556,12 @@ def test_git_credential_get_writes_credentials_to_stdout(monkeypatch): 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.") @@ -566,7 +572,11 @@ def raise_it(input_data): 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.output + 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): @@ -707,7 +717,12 @@ def test_unlink_reports_a_non_wren_remote_without_naming_a_project( 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 - assert "project" not in result.output.split("origin")[-1].lower() + # `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): diff --git a/docs/core/reference/cli.md b/docs/core/reference/cli.md index 839021caf6..4a5336454b 100644 --- a/docs/core/reference/cli.md +++ b/docs/core/reference/cli.md @@ -673,8 +673,14 @@ 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. Data-source names are the API's, e.g. `BIG_QUERY`, -`POSTGRES`, `MYSQL`, `SNOWFLAKE`, `TRINO`. +`--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. + +**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: From 03934d3dd5d364d21a85d185ea9b1bd9a1baf285 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Fri, 28 Aug 2026 16:35:50 +0800 Subject: [PATCH 27/30] fix(wren): address CodeRabbit review on the cloud commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings, all verified locally before acting. - **`--org acme` printed a traceback.** `int(org_id)` raised `ValueError`, and the CLI catches `CloudError` only. Reproduced through the real CLI. Now refuses with a message, before any request. - **A comment claimed a guard that does not exist.** `create_project`'s 401 branch justified not blaming a project key with "the CLI already refuses a non-`osk-` key before this call". It does not — the CLI only checks the key is non-empty. The behaviour is right and the reasoning stands on its own (a 401 has several possible causes and nothing here distinguishes them), so the comment now says that instead of resting on a check that was never written. - **Successful responses were parsed unguarded.** A 2xx whose body is not JSON — what an ingress in front of the API can return — raised `ValueError`, and a response missing `repo`/`token` raised `KeyError`. Neither is a `CloudError`, so both bypassed the CLI's handler *and* the credential helper's. The error paths already tolerated this (`_parse_error_code` returns None rather than raising); the success paths now do too, through one shared reader so all three sites fail the same way. - **`auth remove` did not wrap its `cloud` call.** Its siblings all catch `CloudError`; this one surfaced `logout`'s `git config --global` failure — the same "could not lock config file" case `create` already handles — as a traceback. - **The docs over-scoped the `--host` default.** Only `auth add` and `create` default it; on `link` and `auth remove` it selects among stored credentials and deliberately has none, which the code comment beside `DEFAULT_HOST` already said. The doc now matches. The parametrized non-JSON test carries the status each call treats as success: `create_project` accepts 201/207 and rejects a 200 before parsing, so a shared status would have exercised the wrong branch for it — the first version of the test did exactly that and failed. --- core/wren/src/wren/cloud.py | 66 +++++++++++++++--- core/wren/src/wren/cloud_cli.py | 11 ++- core/wren/tests/unit/test_cloud.py | 106 +++++++++++++++++++++++++++-- docs/core/reference/cli.md | 8 ++- 4 files changed, 171 insertions(+), 20 deletions(-) diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py index 2e12d40979..e75d93da05 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -150,6 +150,35 @@ def _parse_error_code(resp) -> str | None: 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: + raise CloudError(f"{what} response is missing `{key}`: {data!r}") + return value + + def mint_git_token( api_host: str, project_id: str, @@ -181,10 +210,11 @@ def mint_git_token( raise CloudError(f"Could not reach {api_host}: {exc}") from exc if resp.status_code == 200: - data = resp.json() + what = f"Minting a git token for project {project_id}" + data = _json_object(resp, what=what) return GitToken( - repo=data["repo"], - token=data["token"], + repo=_required(data, "repo", what=what), + token=_required(data, "token", what=what), expires_in=data.get("expiresIn", 0), expires_at=data.get("expiresAt", ""), ) @@ -1369,8 +1399,18 @@ def create_project( 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": int(org_id), + "orgId": numeric_org_id, "displayName": display_name, "projectType": "AGENTIC", } @@ -1394,11 +1434,13 @@ def create_project( 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. The CLI - # already refuses a non-`osk-` key before this call, so that is - # not a reachable cause here — and since the key may have come - # from storage rather than from something just typed, guessing - # wrongly sends the user looking for a key they already have. + # 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" @@ -1412,7 +1454,7 @@ def create_project( f"on {api_host}: {resp.text[:300]}" ) - data = resp.json() + data = _json_object(resp, what=f"Creating a project in org {org_id}") project = data.get("project") or {} project_id = project.get("id") if project_id is None: @@ -1487,7 +1529,9 @@ def mint_project_key( f"key for project {project_id} on {api_host}: {resp.text[:300]}" ) - secret = resp.json().get("secret") + 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 " diff --git a/core/wren/src/wren/cloud_cli.py b/core/wren/src/wren/cloud_cli.py index a579e79347..662dd7e5d5 100644 --- a/core/wren/src/wren/cloud_cli.py +++ b/core/wren/src/wren/cloud_cli.py @@ -681,7 +681,16 @@ def auth_remove( if not confirm: raise typer.Abort() - login_removed, helper_removed = cloud.logout(git_host, project_id) + 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}.", diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index c90d825c4a..6e24e2c12e 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -382,7 +382,9 @@ def _seed_remote(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) + cloud.run_git( + ["config", "receive.denyCurrentBranch", "updateInstead"], cwd=remote_dir + ) def _link(target, *, git_host, repo="shared-data.git"): @@ -1976,9 +1978,7 @@ def test_create_pushes_so_the_models_deploy( # 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 + 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 @@ -2064,11 +2064,13 @@ def test_link_renames_the_local_branch_to_the_remotes_default(tmp_path): # 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) + 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}" + 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): @@ -2261,3 +2263,95 @@ def failing_configure(git_host): 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) diff --git a/docs/core/reference/cli.md b/docs/core/reference/cli.md index 4a5336454b..b0b98235b6 100644 --- a/docs/core/reference/cli.md +++ b/docs/core/reference/cli.md @@ -633,8 +633,12 @@ Two credentials, two lifetimes: disk, so an expired token is a non-event — nothing ever holds one long enough to present it late. -`--host` defaults to `https://cloud.getwren.ai`; pass a URL for a self-hosted -deployment. `https` is assumed when no scheme is given. +`--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. From 27cc9a3752dabc3c58404a7aea399cc1ca2f2c57 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Fri, 28 Aug 2026 16:57:14 +0800 Subject: [PATCH 28/30] fix(wren): keep response values out of the missing-field error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both from the second review round, and both are in the code the first round's fix introduced. `_required` interpolated the whole response body. It runs on the git-token response, so a body carrying `token` but not `repo` put a live credential into an error message — the same defect as the recovery hint that used to print the project key, reintroduced two commits after fixing it. Reproduced: the token appeared verbatim. It now names the missing key and which keys were present, never the values, so the error stays diagnosable without carrying secrets. `_json_object` validates only the top level, so `{"project": "invalid"}` passed it and then reached `.get()` on a string — `AttributeError`, which is not a `CloudError` and therefore bypassed the CLI's handler. Reproduced, and now rejected with a message. Both tests mutation-checked: restoring either old form fails its test. --- core/wren/src/wren/cloud.py | 17 +++++++++-- core/wren/tests/unit/test_cloud.py | 48 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/core/wren/src/wren/cloud.py b/core/wren/src/wren/cloud.py index e75d93da05..00e114f2e6 100644 --- a/core/wren/src/wren/cloud.py +++ b/core/wren/src/wren/cloud.py @@ -175,7 +175,14 @@ def _json_object(resp, *, what: str) -> dict: def _required(data: dict, key: str, *, what: str): value = data.get(key) if value is None: - raise CloudError(f"{what} response is missing `{key}`: {data!r}") + # 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 @@ -1454,8 +1461,14 @@ def create_project( f"on {api_host}: {resp.text[:300]}" ) - data = _json_object(resp, what=f"Creating a project in org {org_id}") + 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( diff --git a/core/wren/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index 6e24e2c12e..6edff7a68f 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -2355,3 +2355,51 @@ def fail_if_called(*args, **kwargs): "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) From c6bae589285fcc020274aba0419fdf53e3210aa6 Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Mon, 31 Aug 2026 10:09:06 +0800 Subject: [PATCH 29/30] fix(wren): keep the compiled MDL out of the project's repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wren context build` writes `target/mdl.json`, the scaffold shipped no `.gitignore`, and `wren cloud create` pushes the directory — so a project's own repository ended up carrying its build artifact. Observed on a real create, not inferred: `git ls-files` listed `target/mdl.json` after the push. `init` now writes a `.gitignore` with `target/`, and only when there is not one already, so a project that has its own keeps it. The first test asserts through git (`git add -A` then `ls-files`) rather than on the file's text, because what matters is that git actually ignores the artifact. The second was initially not falsifiable — with no implementation at all it passes just as well, since nothing writes the file. Re-checked by mutating only the existence guard, which is the failure it actually guards against. --- core/wren/src/wren/context_cli.py | 12 +++++++++ core/wren/tests/unit/test_cloud.py | 39 ++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) 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/tests/unit/test_cloud.py b/core/wren/tests/unit/test_cloud.py index 6edff7a68f..9aa563fbd1 100644 --- a/core/wren/tests/unit/test_cloud.py +++ b/core/wren/tests/unit/test_cloud.py @@ -2403,3 +2403,42 @@ def json(self): "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" From eb87e22db44fb84b7c8080bb9744865ebeeaee2a Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Mon, 31 Aug 2026 15:04:56 +0800 Subject: [PATCH 30/30] feat(wren): add `wren cloud auth list`, and say which host `--host` means MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From an end-to-end review against a self-hosted stack with the API and the git server on separate ports — the case where the two hosts actually differ. **`auth remove --host ` reported the project as absent.** `--host` is matched against `api_host`, which is right: it is what the user typed at `auth add` and what the disambiguation list prints. The credential file is keyed by *git* host, which is also right: that is all git hands the credential helper. The collision is at the human layer — there was no way to see what is stored except to open the file, and the file shows the value that does not work. So the message was the defect, not either host choice. The same message had already been reported once before for a scheme-less `--host`; different cause, identical symptom. It now names the field that eliminated the candidates and prints what is stored: Error: no stored login matches --host http://localhost:8081. `--host` is the Wren Cloud API host you gave `auth add`, not the git host. Stored for project 34: http://localhost:3000. `wren cloud auth list` shows all of them. `auth list` is new, and removes the reason anyone opens the file: it prints both host columns, labelled, so the difference is visible. It never prints a key and has no flag to. The filter itself was duplicated inline in `link` and `auth remove` — which is how one of them drifted — so both now share `_select_login`. **`--connection-info` did not say which shape it wants.** It needs the API's — camelCase, BigQuery `credentials` as the service-account object — while a user with a working project has a `wren profile` export: snake_case, `credentials` base64. Both are correct on their own side of a conversion, nothing converts, and the first thing anyone tries is the file they already have. The help text and the reference now name the shape and link the field list. Deliberately not converting: that would put a per-datasource mapping of the server's schema in the client, the same drift the `--type` enum is kept out for. Also relaxed three tests that asserted the word "disambiguate" appeared in the output. They pinned prose, not behaviour; they now assert the command refuses and prints the flags that would narrow the choice. --- core/wren/src/wren/cloud_cli.py | 199 +++++++++++++++---------- core/wren/tests/unit/test_cloud_cli.py | 105 ++++++++++++- docs/core/reference/cli.md | 26 ++++ 3 files changed, 251 insertions(+), 79 deletions(-) diff --git a/core/wren/src/wren/cloud_cli.py b/core/wren/src/wren/cloud_cli.py index 662dd7e5d5..508a68d618 100644 --- a/core/wren/src/wren/cloud_cli.py +++ b/core/wren/src/wren/cloud_cli.py @@ -163,6 +163,74 @@ def auth_add( ) +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[ @@ -218,45 +286,9 @@ def link( """ from wren import cloud # noqa: PLC0415 - logins = cloud.list_logins() - if project is not None: - logins = [entry for entry in logins if entry[1] == str(project)] - if host is not None: - # Filtered on the same field the help text promises and the - # disambiguation candidates below print: `api_host` — the host the - # user passed to `auth add --host`, not the (possibly different) - # `--git-host`. Filtering on `git_host` here while displaying - # `api_host` would silently reject the exact value a user would - # naturally reach for: the host they logged in with. - # Normalized before comparing: `auth add` stores the normalized form, - # so a scheme-less `--host cloud.getwren.ai` here matched nothing and - # reported "no stored login" for a login that exists. Same defect as - # the one just fixed on the writing side, on the reading side. - logins = [ - entry - for entry in logins - if entry[2]["api_host"] == cloud.normalize_host(host) - ] - - if not logins: - typer.echo( - "Error: no stored Wren Cloud login found" - + (f" for project {project}" if project else "") - + ". Run `wren cloud auth add` first.", - err=True, - ) - raise typer.Exit(1) - if len(logins) > 1: - typer.echo( - "Error: more than one stored login matches; disambiguate with " - "--host and/or --project. Candidates:", - err=True, - ) - for git_host, project_id, entry in logins: - typer.echo(f" --host {entry['api_host']} --project {project_id}", err=True) - raise typer.Exit(1) - - git_host, project_id, entry = logins[0] + git_host, project_id, entry = _select_login( + project=project, host=host, command="link" + ) try: outcome = cloud.link( directory, @@ -334,14 +366,26 @@ def create( # noqa: PLR0913 Optional[str], typer.Option( "--connection-info", - help='Connection info as a JSON object, e.g. \'{"host": "..."}\'.', + 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.", + 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[ @@ -599,6 +643,41 @@ def unlink( ) +@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[ @@ -638,41 +717,9 @@ def auth_remove( """ from wren import cloud # noqa: PLC0415 - logins = cloud.list_logins() - if project is not None: - logins = [entry for entry in logins if entry[1] == str(project)] - if host is not None: - # Same field as `link` filters on, for the same reason: `api_host` is - # what the user typed at `login` and what the candidates below print. - # Normalized before comparing: `auth add` stores the normalized form, - # so a scheme-less `--host cloud.getwren.ai` here matched nothing and - # reported "no stored login" for a login that exists. Same defect as - # the one just fixed on the writing side, on the reading side. - logins = [ - entry - for entry in logins - if entry[2]["api_host"] == cloud.normalize_host(host) - ] - - if not logins: - typer.echo( - "Error: no stored Wren Cloud login found" - + (f" for project {project}" if project else "") - + ".", - err=True, - ) - raise typer.Exit(1) - if len(logins) > 1: - typer.echo( - "Error: more than one stored login matches; disambiguate with " - "--host and/or --project. Candidates:", - err=True, - ) - for _git_host, project_id, entry in logins: - typer.echo(f" --host {entry['api_host']} --project {project_id}", err=True) - raise typer.Exit(1) - - git_host, project_id, entry = logins[0] + git_host, project_id, entry = _select_login( + project=project, host=host, command="auth remove" + ) if not yes: confirm = typer.confirm( diff --git a/core/wren/tests/unit/test_cloud_cli.py b/core/wren/tests/unit/test_cloud_cli.py index 8a6c8de141..fc77d622e4 100644 --- a/core/wren/tests/unit/test_cloud_cli.py +++ b/core/wren/tests/unit/test_cloud_cli.py @@ -150,7 +150,9 @@ def test_link_disambiguates_multiple_stored_logins(monkeypatch, tmp_path): ) result = runner.invoke(app, ["cloud", "link", str(tmp_path)]) assert result.exit_code != 0 - assert "disambiguate" in result.output.lower() + # 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): @@ -214,7 +216,9 @@ def test_link_host_still_disambiguates_when_api_hosts_collide(monkeypatch, tmp_p ["cloud", "link", str(tmp_path), "--host", "https://cloud.getwren.ai"], ) assert result.exit_code != 0 - assert "disambiguate" in result.output.lower() + # 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): @@ -780,7 +784,9 @@ def test_logout_disambiguates_multiple_stored_logins(monkeypatch): ) result = runner.invoke(app, ["cloud", "auth", "remove", "--yes"]) assert result.exit_code != 0 - assert "disambiguate" in result.output.lower() + # 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): @@ -1052,3 +1058,96 @@ def test_host_filter_matches_a_scheme_less_value(command, monkeypatch, tmp_path) 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/docs/core/reference/cli.md b/docs/core/reference/cli.md index b0b98235b6..6814425ff8 100644 --- a/docs/core/reference/cli.md +++ b/docs/core/reference/cli.md @@ -681,6 +681,11 @@ mint that project's own key, and is **never** written to disk. 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 @@ -731,6 +736,27 @@ 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.