verulla-cli 0.0.2__tar.gz → 0.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/PKG-INFO +1 -1
  2. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/pyproject.toml +1 -1
  3. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_apps_client.py +36 -1
  4. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_main.py +134 -0
  5. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/verulla_cli/__init__.py +1 -1
  6. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/verulla_cli/apps_client.py +41 -0
  7. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/verulla_cli/main.py +159 -2
  8. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/.gitignore +0 -0
  9. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_admin_cmd.py +0 -0
  10. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_auth.py +0 -0
  11. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_buildkit.py +0 -0
  12. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_buildkit_real.py +0 -0
  13. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_cli_import.py +0 -0
  14. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_deploy_client.py +0 -0
  15. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_dev_check.py +0 -0
  16. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_emulate.py +0 -0
  17. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_owner_commands.py +0 -0
  18. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_packaging.py +0 -0
  19. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_secrets_client.py +0 -0
  20. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_sketch_status.py +0 -0
  21. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/verulla_cli/auth.py +0 -0
  22. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/verulla_cli/buildkit.py +0 -0
  23. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/verulla_cli/deploy_client.py +0 -0
  24. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/verulla_cli/emulate.py +0 -0
  25. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/verulla_cli/packaging.py +0 -0
  26. {verulla_cli-0.0.2 → verulla_cli-0.0.3}/verulla_cli/secrets_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: verulla-cli
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Requires-Python: >=3.12
5
5
  Requires-Dist: docker>=7
6
6
  Requires-Dist: httpx>=0.27
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "verulla-cli"
3
- version = "0.0.2"
3
+ version = "0.0.3"
4
4
  requires-python = ">=3.12"
5
5
  dependencies = [
6
6
  "verulla-manifest==0.0.2",
@@ -1,10 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
3
4
  import httpx
4
5
  import pytest
5
6
 
6
7
  from verulla_cli.apps_client import (
7
- AppsError, activate_app, delete_app, delete_customer, export_customers, get_app_logs, get_auth_policy,
8
+ AppsError, activate_app, create_signup, delete_app, delete_customer, export_customers, get_app_logs, get_auth_policy,
8
9
  get_spend_summary, invite_customer, list_apps, list_customers, lock_customer,
9
10
  get_sketch_events, rollback_app, set_auth_policy, unlock_customer,
10
11
  )
@@ -25,6 +26,30 @@ def response(status, body):
25
26
  return httpx.Response(status, json=body, request=req)
26
27
 
27
28
 
29
+ def test_create_signup_solves_pow_and_sends_idempotency_header():
30
+ class Client:
31
+ calls = []
32
+
33
+ def get(self, url):
34
+ self.calls.append(("GET", url, None, None))
35
+ return response(200, {"prefix": "challenge-prefix", "difficulty": 2, "challenge_id": "c-1"})
36
+
37
+ def post(self, url, *, headers, json):
38
+ self.calls.append(("POST", url, headers, json))
39
+ return response(201, {"tenant": {"slug": "demo"}})
40
+
41
+ client = Client()
42
+ result = create_signup("http://api.test", "demo", "Demo", "owner@example.test", "idempotency-key-123456", client)
43
+ assert result == {"tenant": {"slug": "demo"}}
44
+ assert client.calls[0][:2] == ("GET", "http://api.test/v1/signup/challenge")
45
+ method, url, headers, body = client.calls[1]
46
+ assert (method, url) == ("POST", "http://api.test/v1/signup")
47
+ assert headers == {"Idempotency-Key": "idempotency-key-123456"}
48
+ assert hashlib.sha256(f"challenge-prefix{body['nonce']}".encode()).hexdigest().startswith("00")
49
+ assert body == {"slug": "demo", "display_name": "Demo", "owner_email": "owner@example.test",
50
+ "challenge_id": "c-1", "nonce": body["nonce"]}
51
+
52
+
28
53
  def test_list_apps_uses_authenticated_apps_endpoint():
29
54
  client = FakeClient(response(200, {"apps": [{"app": "demo"}]}))
30
55
  assert list_apps("http://api.test", "token", client) == [{"app": "demo"}]
@@ -32,6 +57,16 @@ def test_list_apps_uses_authenticated_apps_endpoint():
32
57
 
33
58
 
34
59
 
60
+ def test_get_deployment_uses_id_scoped_endpoint():
61
+ from verulla_cli.apps_client import get_deployment
62
+
63
+ client = FakeClient(response(200, {"id": "dep-1", "status": "building"}))
64
+ assert get_deployment("http://api.test", "token", "dep-1", client)["status"] == "building"
65
+ assert client.calls[0][:3] == (
66
+ "GET", "http://api.test/v1/deployments/dep-1", {"Authorization": "Bearer token"}
67
+ )
68
+
69
+
35
70
  def test_sketch_events_passes_cursor_and_limit_without_changing_contract():
36
71
  body = {
37
72
  "events": [{
@@ -14,6 +14,7 @@ def test_auth_and_customer_commands_are_registered() -> None:
14
14
  result = runner.invoke(app, ["--help"])
15
15
  assert result.exit_code == 0
16
16
  assert "auth" in result.output and "customers" in result.output
17
+ assert "signup" in result.output
17
18
  for command in ("wallet", "spend", "usage", "pricing", "topup"):
18
19
  assert command in result.output
19
20
  for command in ("show", "set"):
@@ -22,6 +23,81 @@ def test_auth_and_customer_commands_are_registered() -> None:
22
23
  assert command in runner.invoke(app, ["customers", "--help"]).output
23
24
 
24
25
 
26
+ def test_signup_stores_key_privately_and_redacts_output(tmp_path, monkeypatch) -> None:
27
+ import stat
28
+
29
+ path = tmp_path / ".verulla" / "credentials.json"
30
+ monkeypatch.setattr("verulla_cli.main.CREDENTIALS_PATH", path)
31
+ pending = tmp_path / ".verulla" / "signup-pending.json"
32
+ monkeypatch.setattr("verulla_cli.main._SIGNUP_PENDING_PATH", pending)
33
+ monkeypatch.setattr("verulla_cli.main.create_signup", lambda *args: {
34
+ "tenant": {"slug": "safe-demo", "display_name": "Demo", "schema_name": "tenant_safe_demo"},
35
+ "api_key": {"token": "vrl_secret-key", "scopes": ["tenant:read"]},
36
+ "owner_claim": {"claim_url": "https://account.verulla.com/claim#claim-secret", "expires_at": "expires"},
37
+ })
38
+ result = runner.invoke(app, ["signup", "--api-url", "https://api.example", "--owner-email", "owner@example.test",
39
+ "--display-name", "Demo", "--slug", "safe-demo"])
40
+ assert result.exit_code == 0, result.output
41
+ assert path.exists() and stat.S_IMODE(path.stat().st_mode) == 0o600
42
+ assert "vrl_secret-key" not in result.output
43
+ assert "owner@example.test" not in result.output
44
+ assert "0123456789abcdef" * 4 not in result.output
45
+ assert "claim-secret" in result.output
46
+ assert not pending.exists()
47
+
48
+
49
+ def test_signup_recovers_interrupted_request_with_same_idempotency_key(tmp_path, monkeypatch) -> None:
50
+ import stat
51
+ from verulla_cli.apps_client import AppsError
52
+
53
+ credentials = tmp_path / ".verulla" / "credentials.json"
54
+ pending = tmp_path / ".verulla" / "signup-pending.json"
55
+ monkeypatch.setattr("verulla_cli.main.CREDENTIALS_PATH", credentials)
56
+ monkeypatch.setattr("verulla_cli.main._SIGNUP_PENDING_PATH", pending)
57
+ attempts = []
58
+
59
+ def fake_signup(*args):
60
+ attempts.append(args)
61
+ if len(attempts) == 1:
62
+ raise AppsError("network interruption")
63
+ return {
64
+ "tenant": {"slug": "safe-demo", "display_name": "Demo"},
65
+ "api_key": {"token": "vrl_secret-key"},
66
+ "owner_claim": {"claim_url": "https://account.verulla.com/claim#claim-secret", "expires_at": "expires"},
67
+ }
68
+
69
+ monkeypatch.setattr("verulla_cli.main.create_signup", fake_signup)
70
+ initial = runner.invoke(app, ["signup", "--api-url", "https://api.example", "--owner-email", "owner@example.test",
71
+ "--display-name", "Demo", "--slug", "safe-demo"])
72
+ assert initial.exit_code == 1
73
+ assert pending.exists() and stat.S_IMODE(pending.stat().st_mode) == 0o600
74
+ saved_pending = json.loads(pending.read_text())
75
+ generated_key = attempts[0][-1]
76
+ assert saved_pending["idempotency_key"] == generated_key
77
+ assert len(bytes.fromhex(generated_key)) == 32
78
+ assert len(set(bytes.fromhex(generated_key))) > 16
79
+ wrong_server = runner.invoke(app, ["signup", "--recover", "--api-url", "https://other.example"])
80
+ assert wrong_server.exit_code == 1 and len(attempts) == 1 and pending.exists()
81
+ recovered = runner.invoke(app, ["signup", "--recover", "--api-url", "https://api.example", "--json"])
82
+ assert recovered.exit_code == 0, recovered.output
83
+ assert attempts[0] == attempts[1]
84
+ assert not pending.exists()
85
+ assert "vrl_secret-key" not in recovered.output
86
+ assert "owner@example.test" not in recovered.output
87
+ assert "123456789abcdef0" * 4 not in recovered.output
88
+ assert json.loads(recovered.output)["tenant"]["slug"] == "safe-demo"
89
+
90
+
91
+ def test_signup_rejects_user_supplied_repeating_recovery_key() -> None:
92
+ repeated_key = "0001020304050607" * 4
93
+ result = runner.invoke(app, [
94
+ "signup", "--api-url", "https://api.example", "--owner-email", "owner@example.test",
95
+ "--display-name", "Demo", "--idempotency-key", repeated_key,
96
+ ])
97
+ assert result.exit_code == 2
98
+ assert repeated_key not in result.output
99
+
100
+
25
101
  def test_usage_accepts_optional_app_and_prints_term_contract(monkeypatch) -> None:
26
102
  monkeypatch.setattr("verulla_cli.main.get_token", lambda: "api-token")
27
103
  monkeypatch.setattr("verulla_cli.auth.load_credentials", lambda: None)
@@ -240,6 +316,64 @@ def test_deploy_success_prints_deployment(tmp_path, monkeypatch) -> None:
240
316
  assert recorded["tarball"].startswith(b"\x1f\x8b")
241
317
 
242
318
 
319
+ def test_deploy_wait_success_prints_sketch_handoff(tmp_path, monkeypatch) -> None:
320
+ monkeypatch.setattr("verulla_cli.main.get_token", lambda: "tok")
321
+ monkeypatch.setattr("verulla_cli.main.create_deployment", lambda *args: {
322
+ "id": "d-wait", "app": "demo-app", "url": "https://demo.verulla.app", "status": "queued",
323
+ })
324
+ states = iter([
325
+ {"status": "building", "build_log_tail": ""},
326
+ {"status": "deployed", "build_log_tail": ""},
327
+ ])
328
+ monkeypatch.setattr("verulla_cli.main.get_deployment", lambda *args: next(states))
329
+ monkeypatch.setattr("verulla_cli.main.get_sketch_status", lambda *args: {
330
+ "app": "demo-app", "status": "deployed", "url": "https://demo.verulla.app",
331
+ "expires_at": "2026-11-06T12:00:00Z", "claim_status": "pending",
332
+ "limits_url": "https://verulla.com/#pricing", "console_url": "https://account.verulla.com/",
333
+ })
334
+ monkeypatch.setattr("verulla_cli.main.time.sleep", lambda _: None)
335
+ result = runner.invoke(app, ["deploy", str(_valid_project(tmp_path)), "--api-url", "http://api.test", "--wait"])
336
+ assert result.exit_code == 0, result.output
337
+ assert "https://demo.verulla.app" in result.output
338
+ assert "2026-11-06T12:00:00Z" in result.output
339
+ assert "72 hours" in result.output
340
+ assert "custom domain" in result.output.lower()
341
+ assert "verulla sketch status demo-app" in result.output
342
+ assert "owner claim status: pending" in result.output.lower()
343
+
344
+
345
+ def test_deploy_wait_failure_exits_nonzero_and_shows_build_log_tail(tmp_path, monkeypatch) -> None:
346
+ monkeypatch.setattr("verulla_cli.main.get_token", lambda: "tok")
347
+ monkeypatch.setattr("verulla_cli.main.create_deployment", lambda *args: {
348
+ "id": "d-fail", "app": "demo-app", "status": "queued",
349
+ })
350
+ monkeypatch.setattr("verulla_cli.main.get_deployment", lambda *args: {
351
+ "id": "d-fail", "status": "apply_failed", "error": "apply failed",
352
+ })
353
+ monkeypatch.setattr("verulla_cli.main.get_app_logs", lambda *args: {
354
+ "deployment_id": "d-fail", "build_log_tail": "build output tail",
355
+ })
356
+ result = runner.invoke(app, ["deploy", str(_valid_project(tmp_path)), "--api-url", "http://api.test", "--wait"])
357
+ assert result.exit_code == 1
358
+ assert "verulla logs demo-app" in result.output
359
+ assert "build output tail" in result.output
360
+ assert "Runtime/pod logs are not available yet" in result.output
361
+
362
+
363
+ def test_deploy_wait_timeout_exits_nonzero_with_inspection_action(tmp_path, monkeypatch) -> None:
364
+ import verulla_cli.main as cli_main
365
+
366
+ monkeypatch.setattr(cli_main, "get_token", lambda: "tok")
367
+ monkeypatch.setattr(cli_main, "create_deployment", lambda *args: {"id": "d-slow", "app": "demo-app", "status": "queued"})
368
+ monkeypatch.setattr(cli_main, "get_deployment", lambda *args: {"status": "building"})
369
+ monkeypatch.setattr(cli_main.time, "monotonic", iter([0.0, 0.0, 2.0]).__next__)
370
+ monkeypatch.setattr(cli_main.time, "sleep", lambda _: None)
371
+ result = runner.invoke(app, ["deploy", str(_valid_project(tmp_path)), "--api-url", "http://api.test", "--wait", "--wait-timeout", "1"])
372
+ assert result.exit_code == 1
373
+ assert "Timed out after 1s" in result.output
374
+ assert "verulla logs demo-app" in result.output
375
+
376
+
243
377
  def test_deploy_logged_in_unreachable_api_fails_cleanly(tmp_path, monkeypatch) -> None:
244
378
  monkeypatch.setattr(
245
379
  "verulla_cli.main.get_token", lambda: "tok"
@@ -4,4 +4,4 @@ Human + agent entry point: login (OIDC device flow), dev (local Docker
4
4
  emulation via verulla_manifest), deploy, secrets, logs.
5
5
  """
6
6
 
7
- __version__ = "0.0.2"
7
+ __version__ = "0.0.3"
@@ -53,10 +53,51 @@ def _request(api_url: str, token: str, method: str, path: str, client=None, *, e
53
53
  http.close()
54
54
 
55
55
 
56
+ def create_signup(api_url: str, slug: str, display_name: str, owner_email: str,
57
+ idempotency_key: str, client=None) -> dict:
58
+ """Submit signup with a recovery key from 32 CSPRNG-generated bytes.
59
+
60
+ Encode the key as 64 lowercase hex characters. It authorizes recovery of
61
+ the bootstrap response and must never be derived from user input.
62
+ """
63
+ import hashlib
64
+
65
+ owns = client is None
66
+ http = client or httpx.Client(timeout=20)
67
+ try:
68
+ challenge_response = http.get(f"{api_url}/v1/signup/challenge")
69
+ challenge_response.raise_for_status()
70
+ challenge = challenge_response.json()
71
+ prefix, difficulty = challenge["prefix"], challenge["difficulty"]
72
+ nonce = 0
73
+ while not hashlib.sha256(f"{prefix}{nonce}".encode()).hexdigest().startswith("0" * difficulty):
74
+ nonce += 1
75
+ response = http.post(
76
+ f"{api_url}/v1/signup",
77
+ headers={"Idempotency-Key": idempotency_key},
78
+ json={"slug": slug, "display_name": display_name, "owner_email": owner_email,
79
+ "challenge_id": challenge["challenge_id"], "nonce": str(nonce)},
80
+ )
81
+ response.raise_for_status()
82
+ return response.json()
83
+ except httpx.HTTPStatusError as exc:
84
+ raise AppsError(f"signup failed ({exc.response.status_code}); check the request and retry safely") from None
85
+ except (httpx.HTTPError, KeyError, TypeError, ValueError) as exc:
86
+ raise AppsError(f"signup request could not be completed ({type(exc).__name__}); retry with the same idempotency key") from None
87
+ finally:
88
+ if owns:
89
+ http.close()
90
+
91
+
56
92
  def list_apps(api_url: str, token: str, client=None) -> list[dict]:
57
93
  return _request(api_url, token, "GET", "/v1/apps", client)["apps"]
58
94
 
59
95
 
96
+ def get_deployment(api_url: str, token: str, deployment_id: str, client=None) -> dict:
97
+ from urllib.parse import quote
98
+ return _request(api_url, token, "GET", f"/v1/deployments/{quote(deployment_id, safe='')}", client)
99
+
100
+
60
101
  def get_app(api_url: str, token: str, name: str, client=None) -> dict:
61
102
  return _request(api_url, token, "GET", f"/v1/apps/{name}", client)
62
103
 
@@ -4,20 +4,23 @@ from pathlib import Path
4
4
  import json
5
5
  import os
6
6
  import re
7
- import shutil
7
+ import secrets
8
8
  import tempfile
9
+ import time
9
10
  import uuid
10
11
 
11
12
  import typer
12
13
 
13
- from verulla_cli.auth import DeviceLoginError, get_token, run_device_login
14
+ from verulla_cli.auth import CREDENTIALS_PATH, DeviceLoginError, get_token, run_device_login, save_credentials
14
15
  from verulla_manifest import load_manifest, translate
15
16
  from verulla_cli.apps_client import (
16
17
  AppsError,
18
+ create_signup,
17
19
  activate_app,
18
20
  delete_app as api_delete_app,
19
21
  delete_customer as api_delete_customer,
20
22
  export_customers as api_export_customers,
23
+ get_deployment,
21
24
  get_app,
22
25
  get_app_logs,
23
26
 
@@ -92,6 +95,109 @@ def login(
92
95
  typer.echo("Logged in.")
93
96
 
94
97
 
98
+ _SIGNUP_PENDING_PATH = Path.home() / ".verulla" / "signup-pending.json"
99
+ _SIGNUP_IDEMPOTENCY_KEY = re.compile(r"[0-9a-f]{64}\Z")
100
+
101
+
102
+ def _valid_signup_idempotency_key(value: str) -> bool:
103
+ if not _SIGNUP_IDEMPOTENCY_KEY.fullmatch(value):
104
+ return False
105
+ key = bytes.fromhex(value)
106
+ return not any(all(key[index] == key[index % period] for index in range(period, len(key)))
107
+ for period in range(1, 17))
108
+
109
+
110
+ def _write_signup_pending(path: Path, payload: dict) -> None:
111
+ path.parent.mkdir(parents=True, exist_ok=True)
112
+ descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
113
+ try:
114
+ os.fchmod(descriptor, 0o600)
115
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
116
+ json.dump(payload, stream)
117
+ stream.flush()
118
+ os.fsync(stream.fileno())
119
+ os.replace(temporary, path)
120
+ except BaseException:
121
+ try:
122
+ os.unlink(temporary)
123
+ except FileNotFoundError:
124
+ pass
125
+ raise
126
+
127
+
128
+ @app.command()
129
+ def signup(
130
+ owner_email: str | None = typer.Option(None, "--owner-email", help="Verified owner email to bind the claim to."),
131
+ display_name: str | None = typer.Option(None, "--display-name", help="Tenant display name."),
132
+ slug: str | None = typer.Option(None, "--slug", help="Safe tenant slug; generated when omitted."),
133
+ recover: bool = typer.Option(False, "--recover", help="Retry the saved interrupted signup request."),
134
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL", help="Verulla control plane URL."),
135
+ json_output: bool = typer.Option(False, "--json", help="Print a redacted JSON receipt."),
136
+ ) -> None:
137
+ """Create a tenant and owner hand-off without a browser or interactive prompts."""
138
+ pending_path = _SIGNUP_PENDING_PATH
139
+ if recover:
140
+ if any((owner_email, display_name, slug)):
141
+ typer.echo("Do not pass new signup values with --recover.", err=True)
142
+ raise typer.Exit(code=2)
143
+ try:
144
+ pending = json.loads(pending_path.read_text(encoding="utf-8"))
145
+ if not isinstance(pending, dict) or not all(pending.get(key) for key in
146
+ ("owner_email", "display_name", "slug", "idempotency_key", "api_url")):
147
+ raise ValueError("invalid pending request")
148
+ if not isinstance(pending.get("idempotency_key"), str) or not _valid_signup_idempotency_key(pending["idempotency_key"]):
149
+ raise ValueError("invalid pending idempotency key")
150
+ if pending["api_url"].rstrip("/") != api_url.rstrip("/"):
151
+ raise ValueError("API URL does not match the pending signup")
152
+ except (OSError, ValueError) as exc:
153
+ typer.echo("No valid interrupted signup is available to recover.", err=True)
154
+ raise typer.Exit(code=1) from exc
155
+ else:
156
+ if not owner_email or not display_name:
157
+ typer.echo("--owner-email and --display-name are required unless --recover is used.", err=True)
158
+ raise typer.Exit(code=2)
159
+ if pending_path.exists():
160
+ typer.echo("An interrupted signup is pending; use `verulla signup --recover` first.", err=True)
161
+ raise typer.Exit(code=1)
162
+ pending = {
163
+ "owner_email": owner_email, "display_name": display_name,
164
+ "slug": slug or f"agent-{secrets.token_hex(4)}",
165
+ "idempotency_key": secrets.token_hex(32),
166
+ "api_url": api_url.rstrip("/"),
167
+ }
168
+ try:
169
+ _write_signup_pending(pending_path, pending)
170
+ except OSError as exc:
171
+ typer.echo(f"Could not securely save signup recovery state: {exc}", err=True)
172
+ raise typer.Exit(code=1) from exc
173
+
174
+ api_url = api_url.rstrip("/")
175
+ try:
176
+ result = create_signup(api_url, pending["slug"], pending["display_name"],
177
+ pending["owner_email"], pending["idempotency_key"])
178
+ api_key = result["api_key"]["token"]
179
+ save_credentials({"access_token": api_key, "expires_in": 7 * 24 * 60 * 60},
180
+ api_url, "signup", path=CREDENTIALS_PATH)
181
+ pending_path.unlink(missing_ok=True)
182
+ except (AppsError, KeyError, ValueError, OSError) as exc:
183
+ typer.echo(f"Signup failed; retry with `verulla signup --recover`. ({type(exc).__name__})", err=True)
184
+ raise typer.Exit(code=1) from None
185
+
186
+ receipt = {
187
+ "tenant": result.get("tenant", {}),
188
+ "owner_claim": result.get("owner_claim", {}),
189
+ }
190
+ if json_output:
191
+ typer.echo(json.dumps(receipt))
192
+ else:
193
+ tenant = receipt["tenant"]
194
+ claim = receipt["owner_claim"]
195
+ typer.echo(f"Tenant: {tenant.get('display_name')} ({tenant.get('slug')})")
196
+ typer.echo(f"Owner claim: {claim.get('claim_url')}")
197
+ typer.echo(f"Claim expires: {claim.get('expires_at')}")
198
+ typer.echo("Bootstrap API key saved to your private Verulla credentials file.")
199
+
200
+
95
201
  @app.command()
96
202
  def dev(
97
203
  path: str = typer.Argument(".", help="Path to the directory containing verulla.yml"),
@@ -232,6 +338,9 @@ def deploy(
232
338
  envvar="VERULLA_API_URL",
233
339
  help="Verulla control plane base URL.",
234
340
  ),
341
+ wait: bool = typer.Option(False, "--wait", help="Wait for build/apply to finish, then print the Sketch hand-off."),
342
+ wait_timeout: int = typer.Option(600, "--wait-timeout", min=1, max=3600,
343
+ help="Maximum seconds to wait (1-3600; default 600)."),
235
344
  ) -> None:
236
345
  """Deploy the app described by verulla.yml to your Verulla tenant."""
237
346
  base_dir = Path(path).resolve()
@@ -267,6 +376,54 @@ def deploy(
267
376
  typer.echo(deployment["url"])
268
377
  for fn in deployment.get("functions", []):
269
378
  typer.echo(f" {fn['name']} ({fn['adapter']})")
379
+ if wait:
380
+ terminal = {"deployed", "build_failed", "apply_failed", "failed", "deleted", "delete_failed", "superseded", "suspended"}
381
+ deadline = time.monotonic() + wait_timeout
382
+ while True:
383
+ try:
384
+ latest = get_deployment(api_url.rstrip("/"), token, deployment["id"])
385
+ except AppsError as exc:
386
+ typer.echo(f"Error checking deployment status: {exc}", err=True)
387
+ raise typer.Exit(code=1) from exc
388
+ status = latest.get("status")
389
+ if status in terminal:
390
+ break
391
+ remaining = deadline - time.monotonic()
392
+ if remaining <= 0:
393
+ typer.echo(
394
+ f"Timed out after {wait_timeout}s waiting for deployment {deployment['id']} (last status: {status}). "
395
+ f"Inspect with: verulla logs {deployment['app']}", err=True,
396
+ )
397
+ raise typer.Exit(code=1)
398
+ time.sleep(min(2, remaining))
399
+ if status != "deployed":
400
+ typer.echo(f"Deployment {deployment['id']} ended with status {status}.", err=True)
401
+ if latest.get("error"):
402
+ typer.echo(f"Error: {latest['error']}", err=True)
403
+ try:
404
+ logs = get_app_logs(api_url.rstrip("/"), token, deployment["app"])
405
+ except AppsError:
406
+ logs = {}
407
+ log_url = f"{api_url.rstrip('/')}/v1/apps/{deployment['app']}/logs"
408
+ typer.echo(f"Build logs: {log_url} (authenticated); inspect with: verulla logs {deployment['app']}", err=True)
409
+ typer.echo("build_log_tail:", err=True)
410
+ tail = logs.get("build_log_tail") if logs.get("deployment_id") == deployment["id"] else None
411
+ typer.echo(tail or "(no matching build log available)", err=True)
412
+ typer.echo("Runtime/pod logs are not available yet.", err=True)
413
+ raise typer.Exit(code=1)
414
+ try:
415
+ receipt = _safe_sketch_receipt(get_sketch_status(api_url.rstrip("/"), token, deployment["app"]))
416
+ except AppsError as exc:
417
+ typer.echo(f"Deployment succeeded, but the Sketch receipt could not be loaded: {exc}", err=True)
418
+ raise typer.Exit(code=1) from exc
419
+ typer.echo("\nSketch hand-off")
420
+ typer.echo(f"Public URL: {receipt.get('url') or deployment.get('url') or '(not provided)'}")
421
+ typer.echo(f"Expires at: {receipt.get('expires_at') or '(expiry not provided by API)'}")
422
+ typer.echo(f"Owner claim status: {receipt.get('claim_status') or '(not provided)'}")
423
+ typer.echo("Trial: free for 72 hours from first successful deployment; no custom domain.")
424
+ if receipt.get("limits_url"):
425
+ typer.echo(f"Limits: {receipt['limits_url']}")
426
+ typer.echo(f"Next: share the public URL with the verified owner, then run `verulla sketch status {deployment['app']}` before expiry.")
270
427
 
271
428
 
272
429
  secrets_app = typer.Typer(help="Manage per-tenant secrets and environment variables.")
File without changes