From dcaab5b681433974db65dc1991b56a19c25a38cf Mon Sep 17 00:00:00 2001 From: Luke Lombardi <33990301+luke-lombardi@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:37:32 +0100 Subject: [PATCH 1/3] Open a $20 credit checkout from beam setup and drop semicolons from CLI copy Co-authored-by: Cursor --- sdk/pyproject.toml | 2 +- sdk/src/beta9/auth.py | 6 +-- sdk/src/beta9/cli/setup.py | 74 +++++++++++++++++++------- sdk/src/beta9/deployment/onboarding.py | 16 +++--- sdk/tests/test_vm_onboarding.py | 61 ++++++++++++++++++--- 5 files changed, 122 insertions(+), 37 deletions(-) diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml index 8948e0f3f..2291f9352 100644 --- a/sdk/pyproject.toml +++ b/sdk/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "jsonschema>=4.17.3,<5", ] name = "beta9" -version = "0.1.279" +version = "0.1.280" description = "" [project.scripts] diff --git a/sdk/src/beta9/auth.py b/sdk/src/beta9/auth.py index a964b8177..4230026f4 100644 --- a/sdk/src/beta9/auth.py +++ b/sdk/src/beta9/auth.py @@ -66,7 +66,7 @@ def save_login(context: ConfigContext, name: str = DEFAULT_CONTEXT_NAME) -> None def _post(auth_url: Optional[str], path: str, payload: Dict[str, str]) -> requests.Response: if not auth_url: raise LoginError( - "This install has no browser sign-in; create a token in the dashboard and run `config create`.", + "This install has no browser sign-in. Create a token in the dashboard and run `config create`.", "LOGIN_NOT_CONFIGURED", ) try: @@ -168,7 +168,7 @@ def poll(self) -> Optional[ConfigContext]: self.interval += 2 return None if error == "expired_token": - raise LoginError("The sign-in code expired; run login again.", "LOGIN_EXPIRED") + raise LoginError("The sign-in code expired. Run login again.", "LOGIN_EXPIRED") if error == "access_denied": raise LoginError("Sign-in was denied in the browser.", "LOGIN_DENIED") raise LoginError(_message(response, "Sign-in failed")) @@ -188,5 +188,5 @@ def wait(self, timeout: Optional[float] = None) -> ConfigContext: if context is not None: return context if time.monotonic() + self.interval > deadline: - raise LoginError("Timed out waiting for sign-in; run login again.", "LOGIN_EXPIRED") + raise LoginError("Timed out waiting for sign-in. Run login again.", "LOGIN_EXPIRED") time.sleep(self.interval) diff --git a/sdk/src/beta9/cli/setup.py b/sdk/src/beta9/cli/setup.py index b35248b72..6e1887076 100644 --- a/sdk/src/beta9/cli/setup.py +++ b/sdk/src/beta9/cli/setup.py @@ -25,6 +25,8 @@ ) UNIVERSAL_SKILLS_DIR = "~/.agents/skills" +# The minimum credit purchase, which also covers a first VM. +CHECKOUT_CENTS = 2000 @click.group(cls=ClickCommonGroup) @@ -57,7 +59,7 @@ def setup(): "--yes", is_flag=True, expose_value=False, - help="Accepted for symmetry with installers; nothing prompts.", + help="Accepted for symmetry with installers. Nothing prompts.", ) @extraclick.config_context_option def agent(clients: Sequence[str], skip_skills: bool, skip_mcp: bool, context: Optional[str]): @@ -139,7 +141,7 @@ def configure_agents( name="vm", help="Sign in, connect your agents and start a persistent CPU VM. Run again to resume.", ) -@click.option("--name", help="Name of the first VM; defaults to -agent.") +@click.option("--name", help="Name of the first VM. Defaults to -agent.") @click.option("--desktop", is_flag=True, help="Include a browser desktop.") @click.option("--client", "clients", type=click.Choice(CLIENT_IDS), multiple=True) @click.option("--skip-agents", is_flag=True, help="Sign in and prepare the VM only.") @@ -158,7 +160,7 @@ def setup_vm(name, desktop, clients, skip_agents, yes, context): with redirect_stdout(sys.stderr) if terminal.json_output() else nullcontext(): selected_context, agents = connect_onboarding(selected, clients, skip_agents, yes) if not confirm_workload( - "Start your VM (4 vCPU, 8 GiB RAM, 50 GiB disk; runs until stopped)?", yes + "Start your VM (4 vCPU, 8 GiB RAM, 50 GiB disk, runs until stopped)?", yes ): return with redirect_stdout(sys.stderr) if terminal.json_output() else nullcontext(): @@ -222,42 +224,76 @@ def confirm_workload(prompt: str, yes: bool) -> bool: def ensure_credit(context: ConfigContext, unattended: bool) -> None: - """Use the gateway's credit decision; purchases stay in the product dashboard.""" - import re + """Use the gateway's credit decision; Enter opens checkout for the signed-in account.""" import time import webbrowser from ..mcp.tools import call_remote - summary = call_remote(context, "whoami", {}) - credit = summary.get("credit") + credit = call_remote(context, "whoami", {}).get("credit") if not credit or credit.get("ok"): return message = credit.get("message") or "Add credits before starting compute." - match = re.search(r"https?://[^\s<>]+", message) - link = match.group(0).rstrip(".,)") if match else "" - if unattended or not sys.stdin.isatty() or not link: + if unattended or not sys.stdin.isatty(): raise click.ClickException(message) - terminal.header(f"Add credits to {summary.get('workspace_name') or 'your workspace'}") - terminal.print(message) + terminal.header("Add credits") + terminal.print(credit_reason(message), markup=False) click.prompt( - "Press enter to add credits via Stripe (Ctrl+C to exit)", + f"Press enter to add ${CHECKOUT_CENTS // 100} in credits (Ctrl+C to exit)", default="", show_default=False, prompt_suffix=" ", ) + link = credit_checkout(context) or credits_page(message) + if not link: + raise click.ClickException(message) webbrowser.open(link, new=2) - terminal.print("Choose an amount and complete checkout. Setup continues when credits arrive.") + terminal.detail(f"If it didn't open: {link}") deadline = time.monotonic() + 900 - with terminal.progress("Waiting for credits"): + with terminal.progress("Waiting for checkout"): while time.monotonic() < deadline: if call_remote(context, "whoami", {}).get("credit", {}).get("ok"): - terminal.success("Credits confirmed") + terminal.success("Added credits") return time.sleep(3) raise click.ClickException("Credits are still pending. Run this command again after checkout.") +def credit_reason(message: str) -> str: + """The first sentence of the gateway's credit message, without the dashboard link.""" + import re + + reason = re.split(r"[;.]\s", message, maxsplit=1)[0].rstrip(".") + return reason[:1].upper() + reason[1:] + "." + + +def credits_page(message: str) -> str: + import re + + match = re.search(r"https?://[^\s<>]+", message) + return match.group(0).rstrip(".,)") if match else "" + + +def credit_checkout(context: ConfigContext) -> str: + """A Stripe Checkout URL for this context's account, or "" if its auth server has none.""" + import requests + + if not context.auth_url or not context.token: + return "" + try: + response = requests.post( + context.auth_url.rstrip("/") + "/cli/checkout", + json={"total_cents_amount": CHECKOUT_CENTS}, + headers={"Authorization": f"Bearer {context.token}"}, + timeout=10, + allow_redirects=False, + ) + url = response.json().get("url") if response.status_code == 201 else "" + except (requests.RequestException, ValueError, AttributeError): + return "" + return url if isinstance(url, str) and url.startswith("https://") else "" + + def connect_onboarding( selected: str, clients: Sequence[str], skip_agents: bool, unattended: bool ) -> Tuple[ConfigContext, Optional[Dict[str, Any]]]: @@ -280,7 +316,7 @@ def connect_onboarding( agents = None if skip_agents else configure_agents(clients, False, False, selected) if agents and agents["failed"]: report_failures(agents["failed"]) - raise click.ClickException("Agent configuration failed; fix it and resume setup") + raise click.ClickException("Agent configuration failed. Fix it and resume setup.") if agents: terminal.success("Agent skills and MCP configured") return context, agents @@ -380,9 +416,9 @@ def setup_example(kind, name, clients, skip_agents, yes, context): with redirect_stdout(sys.stderr) if terminal.json_output() else nullcontext(): configured, agents = connect_onboarding(selected, clients, skip_agents, yes) if not confirm_workload( - "Run hello world in a sandbox (1 CPU, 256 MiB; stops when finished)?" + "Run hello world in a sandbox (1 CPU, 256 MiB, stops when finished)?" if kind == "sandboxes" - else "Run hello world (1 CPU, 256 MiB; scales to zero when idle)?", + else "Run hello world (1 CPU, 256 MiB, scales to zero when idle)?", yes, ): return diff --git a/sdk/src/beta9/deployment/onboarding.py b/sdk/src/beta9/deployment/onboarding.py index 0edc0eb50..9f2cd994b 100644 --- a/sdk/src/beta9/deployment/onboarding.py +++ b/sdk/src/beta9/deployment/onboarding.py @@ -47,7 +47,7 @@ def prepare_vm( vm = _create_vm(service, path, state, name, desktop) else: if (vm.info.get("metadata") or {}).get("beam.onboarding") != marker["beam.onboarding"]: - raise ValueError(f"VM {name!r} already exists; choose a different --name") + raise ValueError(f"VM {name!r} already exists. Choose a different --name.") if vm.info.get("desired_state") != "running": vm.start(wait=False) @@ -55,7 +55,7 @@ def prepare_vm( probe = vm.process.exec("sh", "-c", "test -d /workspace && uname -s", cwd="/") if probe.wait(30) != 0 or probe.stdout.read().strip() != "Linux": raise RuntimeError( - "VM guest readiness check failed; inspect vm get/logs and retry setup" + "VM guest readiness check failed. Inspect vm get/logs and retry setup." ) return vm.refresh().info @@ -147,7 +147,7 @@ def prepare_example( path = directory / "app.py" if kind == "sandboxes": if matches and state.get("app_id") != matches[0].get("app_id"): - raise ValueError(f"App {name!r} already exists; choose a different --name") + raise ValueError(f"App {name!r} already exists. Choose a different --name.") path.write_text( "from beta9 import Sandbox\n\n" f"code = '''{SANDBOX_CODE}'''\n\n" @@ -172,7 +172,7 @@ def prepare_example( if matches: existing = call("get_app", {"name": name}) if marker not in existing.get("config", {}).get("env", []): - raise ValueError(f"App {name!r} already exists; choose a different --name") + raise ValueError(f"App {name!r} already exists. Choose a different --name.") decorator = "endpoint" if kind == "inference" else "function" scaling = " keep_warm_seconds=0,\n" if kind == "inference" else "" @@ -217,7 +217,7 @@ def prepare_example( ) if not accepted: raise RuntimeError( - "Sample deployment was not accepted; inspect logs and resume setup" + "Sample deployment was not accepted. Inspect logs and resume setup." ) existing = call("get_app", {"name": name}) @@ -297,7 +297,7 @@ def _run_sandbox_example( apps = call("list_apps", {"name": name}).get("items", []) if len(apps) != 1 or not apps[0].get("app_id"): - raise RuntimeError("Sandbox app could not be found; inspect logs before retrying") + raise RuntimeError("Sandbox app could not be found. Inspect logs before retrying.") state["app_id"] = apps[0]["app_id"] _save_state(directory / "request.json", state) @@ -322,7 +322,7 @@ def _run_sandbox_example( raise TimeoutError("Sandbox sample did not finish within 30 seconds") time.sleep(0.1) if exit_code != 0 or not "".join(output).strip().endswith("Hello from the cloud"): - raise RuntimeError("Sandbox hello-world result was not verified; inspect logs") + raise RuntimeError("Sandbox hello-world result was not verified. Inspect logs.") return { "name": name, "example": "sandboxes", @@ -333,7 +333,7 @@ def _run_sandbox_example( finally: try: if instance is not None and instance.ok and not instance.terminate(): - raise RuntimeError("Sandbox did not stop; inspect container status") + raise RuntimeError("Sandbox did not stop. Inspect container status.") finally: os.chdir(previous_directory) if previous_channel is None: diff --git a/sdk/tests/test_vm_onboarding.py b/sdk/tests/test_vm_onboarding.py index b4087ac30..87e61efea 100644 --- a/sdk/tests/test_vm_onboarding.py +++ b/sdk/tests/test_vm_onboarding.py @@ -251,23 +251,34 @@ def test_credit_checkpoint_never_opens_checkout_for_unattended_setup(monkeypatch opening.assert_not_called() -def test_interactive_credit_checkpoint_waits_for_confirmed_funding(monkeypatch): +def unfunded_setup(monkeypatch, checkout_status=201): + """An interactive credit checkpoint whose account is funded on the second poll.""" import webbrowser + import requests + from beta9.cli import setup opening = MagicMock() prompt = MagicMock(return_value="") + checkout = MagicMock( + return_value=MagicMock( + status_code=checkout_status, + json=lambda: {"url": "https://checkout.stripe.com/c/pay/cs_test"}, + ) + ) monkeypatch.setattr(webbrowser, "open", opening) monkeypatch.setattr(setup.click, "prompt", prompt) + monkeypatch.setattr(requests, "post", checkout) monkeypatch.setattr(setup.sys.stdin, "isatty", lambda: True) statuses = iter( [ { - "workspace_name": "test", + "workspace_name": "014913", "credit": { "ok": False, - "message": "Add credits at https://account.example/credits", + "message": "This workspace has no credits yet. Add credits at " + "https://account.example/settings/credits to run workloads.", }, }, {"credit": {"ok": False}}, @@ -276,14 +287,52 @@ def test_interactive_credit_checkpoint_waits_for_confirmed_funding(monkeypatch): ) monkeypatch.setattr("beta9.mcp.tools.call_remote", lambda *_: next(statuses)) monkeypatch.setattr("time.sleep", lambda _: None) - setup.ensure_credit(object(), unattended=False) - opening.assert_called_once_with("https://account.example/credits", new=2) + context = ConfigContext(token="workspace-token", auth_url="https://api.example/v2/oauth") + return setup, context, opening, prompt, checkout + + +def test_enter_opens_a_twenty_dollar_checkout_for_the_signed_in_account(monkeypatch, capsys): + setup, context, opening, prompt, checkout = unfunded_setup(monkeypatch) + + setup.ensure_credit(context, unattended=False) + prompt.assert_called_once_with( - "Press enter to add credits via Stripe (Ctrl+C to exit)", + "Press enter to add $20 in credits (Ctrl+C to exit)", default="", show_default=False, prompt_suffix=" ", ) + checkout.assert_called_once() + assert checkout.call_args.args == ("https://api.example/v2/oauth/cli/checkout",) + assert checkout.call_args.kwargs["json"] == {"total_cents_amount": 2000} + assert checkout.call_args.kwargs["headers"] == {"Authorization": "Bearer workspace-token"} + opening.assert_called_once_with("https://checkout.stripe.com/c/pay/cs_test", new=2) + output = capsys.readouterr().out + assert "This workspace has no credits yet." in output + assert "Added credits" in output + assert "014913" not in output + assert ";" not in output + + +def test_checkout_falls_back_to_the_credits_page(monkeypatch): + setup, context, opening, _, _ = unfunded_setup(monkeypatch, checkout_status=404) + + setup.ensure_credit(context, unattended=False) + + opening.assert_called_once_with("https://account.example/settings/credits", new=2) + + +@pytest.mark.parametrize( + "message", + [ + "this workspace has no credits yet; add credits at https://x/settings/credits to run workloads", + "This workspace has no credits yet. Add credits at https://x/settings/credits to run workloads.", + ], +) +def test_credit_reason_is_one_sentence_without_the_link(message): + from beta9.cli import setup + + assert setup.credit_reason(message) == "This workspace has no credits yet." def test_product_dashboard_templates_are_optional_and_context_bound(monkeypatch): From ab3d9e5f3e74996edb12cb6810ac8d455926834b Mon Sep 17 00:00:00 2001 From: Luke Lombardi <33990301+luke-lombardi@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:39:37 +0100 Subject: [PATCH 2/3] Lock the 0.1.280 SDK version Co-authored-by: Cursor --- sdk/uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/uv.lock b/sdk/uv.lock index 5a7db484b..649bd4b8d 100644 --- a/sdk/uv.lock +++ b/sdk/uv.lock @@ -118,7 +118,7 @@ wheels = [ [[package]] name = "beta9" -version = "0.1.279" +version = "0.1.280" source = { editable = "." } dependencies = [ { name = "asgiref" }, From 9df60de5fd9968a1cc841aaa1f954155e518cc5d Mon Sep 17 00:00:00 2001 From: Luke Lombardi <33990301+luke-lombardi@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:42:39 +0100 Subject: [PATCH 3/3] bump copy --- sdk/src/beta9/cli/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/src/beta9/cli/setup.py b/sdk/src/beta9/cli/setup.py index 6e1887076..fe198047d 100644 --- a/sdk/src/beta9/cli/setup.py +++ b/sdk/src/beta9/cli/setup.py @@ -165,7 +165,7 @@ def setup_vm(name, desktop, clients, skip_agents, yes, context): return with redirect_stdout(sys.stderr) if terminal.json_output() else nullcontext(): with ServiceClient(get_config_context(selected)) as service: - with terminal.StepTracker().step("Starting VM and checking the guest", "VM ready"): + with terminal.StepTracker().step("Starting VM", "VM ready"): result = prepare_vm( service, get_settings().config_path.parent / "onboarding",