verulla-cli 0.0.2__py3-none-any.whl

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.
verulla_cli/main.py ADDED
@@ -0,0 +1,1000 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import tempfile
9
+ import uuid
10
+
11
+ import typer
12
+
13
+ from verulla_cli.auth import DeviceLoginError, get_token, run_device_login
14
+ from verulla_manifest import load_manifest, translate
15
+ from verulla_cli.apps_client import (
16
+ AppsError,
17
+ activate_app,
18
+ delete_app as api_delete_app,
19
+ delete_customer as api_delete_customer,
20
+ export_customers as api_export_customers,
21
+ get_app,
22
+ get_app_logs,
23
+
24
+ get_sketch_events,
25
+
26
+ get_pricing,
27
+
28
+ get_sketch_status,
29
+ get_owner_claim_status,
30
+ get_spend_summary,
31
+ get_usage_summary,
32
+ get_term_usage,
33
+ get_wallet_summary,
34
+ create_topup,
35
+ get_auth_policy,
36
+ invite_customer as api_invite_customer,
37
+ issue_owner_claim,
38
+ list_apps,
39
+ list_customers as api_list_customers,
40
+ lock_customer as api_lock_customer,
41
+ rollback_app as api_rollback_app,
42
+ set_auth_policy,
43
+ unlock_customer as api_unlock_customer,
44
+ )
45
+ from verulla_cli.deploy_client import DeployError, create_deployment
46
+ from verulla_cli.emulate import DockerRunner, EmulationError, emulate_function
47
+ from verulla_cli.buildkit import BuildKitRunner, select_builder, check_dockerfile
48
+ from verulla_cli.packaging import PackagingError, build_source_tarball
49
+ from verulla_cli.secrets_client import SecretsError, delete_secret as api_delete_secret, list_secret_keys, set_secret, _auth_headers
50
+
51
+ app = typer.Typer(
52
+ name="verulla",
53
+ help="Verulla: AI-agent-friendly deployment & hosting CLI.",
54
+ no_args_is_help=True,
55
+ )
56
+ sketch_app = typer.Typer(name="sketch", help="Inspect Sketch trial lifecycle status.", no_args_is_help=True)
57
+ app.add_typer(sketch_app, name="sketch")
58
+ owner_app = typer.Typer(name="owner", help="Manage the verified owner hand-off.", no_args_is_help=True)
59
+ app.add_typer(owner_app, name="owner")
60
+
61
+ _SKETCH_RECEIPT_FIELDS = {
62
+ "app", "tier", "slug", "status", "lifecycle_state", "trial_started_at",
63
+ "expires_at", "retention_ends_at", "purge_eligible", "claim_status", "claim_url",
64
+ "claim_expires_at", "limits", "limits_url", "console_url", "url",
65
+ }
66
+
67
+
68
+ def _safe_sketch_receipt(receipt: dict) -> dict:
69
+ return {key: receipt[key] for key in _SKETCH_RECEIPT_FIELDS if key in receipt}
70
+
71
+
72
+ def _not_implemented(command: str) -> None:
73
+ typer.echo(f"verulla {command}: not yet implemented (coming in a later milestone)")
74
+ raise typer.Exit(code=1)
75
+
76
+
77
+ @app.command()
78
+ def login(
79
+ api_url: str = typer.Option(
80
+ ...,
81
+ "--api-url",
82
+ envvar="VERULLA_API_URL",
83
+ help="Verulla control plane base URL.",
84
+ ),
85
+ ) -> None:
86
+ """Authenticate via the OIDC device authorization grant."""
87
+ try:
88
+ run_device_login(api_url.rstrip("/"))
89
+ except DeviceLoginError as exc:
90
+ typer.echo(f"Login failed: {exc}", err=True)
91
+ raise typer.Exit(code=1) from exc
92
+ typer.echo("Logged in.")
93
+
94
+
95
+ @app.command()
96
+ def dev(
97
+ path: str = typer.Argument(".", help="Path to the directory containing verulla.yml"),
98
+ check: bool = typer.Option(False, "--check", help="Build and smoke-check manifest routes."),
99
+ json_output: bool = typer.Option(False, "--json", help="Print route results as JSON (with --check)."),
100
+ builder: str = typer.Option("auto", "--builder", help="Builder backend: auto, docker, or buildkit."),
101
+ ) -> None:
102
+ """Run a local preview with Docker, or build with BuildKit."""
103
+ base_dir = Path(path).resolve()
104
+ manifest_path = base_dir / "verulla.yml"
105
+ if not manifest_path.is_file():
106
+ typer.echo(f"Error: {manifest_path} not found.", err=True)
107
+ raise typer.Exit(code=1)
108
+ try:
109
+ manifest = load_manifest(manifest_path)
110
+ result = translate(manifest, base_dir)
111
+ except ValueError as exc:
112
+ typer.echo(f"Error: {exc}", err=True)
113
+ raise typer.Exit(code=1) from exc
114
+ try:
115
+ docker_runner = None
116
+ try:
117
+ docker_runner = DockerRunner()
118
+ except EmulationError:
119
+ pass
120
+ selected = select_builder(builder, docker_runner is not None)
121
+ if selected == "buildkit":
122
+ kit = BuildKitRunner()
123
+ for function in result.functions:
124
+ plan = function.build_plan
125
+ filename = plan.dockerfile_path or ".verulla.Dockerfile"
126
+ if check:
127
+ with tempfile.TemporaryDirectory(prefix="verulla-check-") as temp:
128
+ dockerfile_dir = Path(temp)
129
+ if plan.dockerfile_path is None:
130
+ from verulla_manifest.dockerfile import generate_dockerfile
131
+ (dockerfile_dir / filename).write_text(generate_dockerfile(plan))
132
+ check_file = check_dockerfile(function, manifest.routes, dockerfile_dir)
133
+ output = kit.build(function.src_root, check_file, check=True, dockerfile_dir=dockerfile_dir)
134
+ statuses = _route_statuses(output)
135
+ for status in statuses:
136
+ typer.echo(f"ROUTE {status['route']} {status['status']}")
137
+ if json_output:
138
+ typer.echo(json.dumps(statuses) if statuses else json.dumps({"error": "BuildKit check failed", "output": output}))
139
+ if not statuses:
140
+ typer.echo(output.rstrip() or "BuildKit check failed without route results", err=True)
141
+ raise typer.Exit(code=1)
142
+ if any(status["status"] < 200 or status["status"] >= 400 for status in statuses):
143
+ raise typer.Exit(code=1)
144
+ else:
145
+ if plan.dockerfile_path is None:
146
+ from verulla_manifest.dockerfile import generate_dockerfile
147
+ (function.src_root / filename).write_text(generate_dockerfile(plan))
148
+ output = kit.build(function.src_root, filename)
149
+ typer.echo(f"{function.name}: built with BuildKit")
150
+ if output.strip():
151
+ typer.echo(output.rstrip())
152
+ if not check:
153
+ typer.echo("Interactive local preview requires Docker; use verulla deploy to a Sketch app for the remote runtime.")
154
+ return
155
+ return
156
+ runner = docker_runner
157
+ except EmulationError as exc:
158
+ typer.echo(f"Error: {exc}", err=True)
159
+ raise typer.Exit(code=1) from exc
160
+ if check:
161
+ statuses = []
162
+ for function in result.functions:
163
+ plan = function.build_plan
164
+ filename = plan.dockerfile_path or ".verulla.Dockerfile"
165
+ generated = []
166
+ try:
167
+ if plan.dockerfile_path is None:
168
+ from verulla_manifest.dockerfile import generate_dockerfile
169
+ (function.src_root / filename).write_text(generate_dockerfile(plan))
170
+ generated.append(function.src_root / filename)
171
+ test_file = check_dockerfile(function, manifest.routes)
172
+ generated.append(function.src_root / test_file)
173
+ output = runner.check_image(function.src_root, test_file, f"verulla-check-{function.name}:latest")
174
+ statuses.extend(_route_statuses(output))
175
+ if not _route_statuses(output):
176
+ if json_output:
177
+ typer.echo(json.dumps({"error": "Docker check failed", "output": output}))
178
+ typer.echo(output.rstrip() or "Docker check failed without route results", err=True)
179
+ raise typer.Exit(code=1)
180
+ except EmulationError as exc:
181
+ if json_output:
182
+ typer.echo(json.dumps({"error": str(exc), "output": str(exc)}))
183
+ typer.echo(str(exc), err=True)
184
+ raise typer.Exit(code=1) from exc
185
+ finally:
186
+ for generated_file in generated:
187
+ generated_file.unlink(missing_ok=True)
188
+ _print_route_results(statuses, json_output)
189
+ if not statuses or any(not 200 <= item["status"] < 400 for item in statuses):
190
+ raise typer.Exit(code=1)
191
+ return
192
+ host_port = 8000
193
+ for build in result.functions:
194
+ try:
195
+ url = emulate_function(build, runner, host_port)
196
+ except EmulationError as exc:
197
+ typer.echo(f"Error building/running {build.name!r}: {exc}", err=True)
198
+ raise typer.Exit(code=1) from exc
199
+ typer.echo(f"{build.name}: {url}")
200
+ host_port += 1
201
+
202
+
203
+ def _route_statuses(output: str) -> list[dict[str, int | str]]:
204
+ statuses = []
205
+ for line in output.splitlines():
206
+ match = re.search(r"(?:^|\s)ROUTE\s+(\S+)\s+(\d{3})(?:\s|$)", line)
207
+ if match:
208
+ statuses.append({"route": match.group(1), "status": int(match.group(2))})
209
+ seen: set[tuple[str, int]] = set()
210
+ unique = []
211
+ for status in statuses:
212
+ key = (str(status["route"]), int(status["status"]))
213
+ if key not in seen:
214
+ seen.add(key)
215
+ unique.append(status)
216
+ return unique
217
+
218
+
219
+ def _print_route_results(statuses: list[dict[str, int | str]], json_output: bool) -> None:
220
+ for status in statuses:
221
+ typer.echo(f"ROUTE {status['route']} {status['status']}")
222
+ if json_output:
223
+ typer.echo(json.dumps(statuses))
224
+
225
+
226
+ @app.command()
227
+ def deploy(
228
+ path: str = typer.Argument(".", help="Path to the directory containing verulla.yml"),
229
+ api_url: str = typer.Option(
230
+ ...,
231
+ "--api-url",
232
+ envvar="VERULLA_API_URL",
233
+ help="Verulla control plane base URL.",
234
+ ),
235
+ ) -> None:
236
+ """Deploy the app described by verulla.yml to your Verulla tenant."""
237
+ base_dir = Path(path).resolve()
238
+ manifest_path = base_dir / "verulla.yml"
239
+ if not manifest_path.is_file():
240
+ typer.echo(f"Error: {manifest_path} not found.", err=True)
241
+ raise typer.Exit(code=1)
242
+ # Validate locally with the same translator the server uses, before the
243
+ # login check, so manifest problems fail fast and offline.
244
+ try:
245
+ translate(load_manifest(manifest_path), base_dir)
246
+ except ValueError as exc:
247
+ typer.echo(f"Error: {exc}", err=True)
248
+ raise typer.Exit(code=1) from exc
249
+ try:
250
+ token = get_token()
251
+ except DeviceLoginError as exc:
252
+ typer.echo(f"Error: {exc}", err=True)
253
+ raise typer.Exit(code=1) from exc
254
+ try:
255
+ tarball = build_source_tarball(base_dir)
256
+ except PackagingError as exc:
257
+ typer.echo(f"Error: {exc}", err=True)
258
+ raise typer.Exit(code=1) from exc
259
+ typer.echo(f"Uploading {len(tarball) / 1024:.1f} KB...")
260
+ try:
261
+ deployment = create_deployment(api_url.rstrip("/"), token, tarball)
262
+ except DeployError as exc:
263
+ typer.echo(f"Error: {exc}", err=True)
264
+ raise typer.Exit(code=1) from exc
265
+ typer.echo(f"App {deployment.get('app_action', 'deployed')}: {deployment['app']} ({deployment['id']})")
266
+ if deployment.get("url"):
267
+ typer.echo(deployment["url"])
268
+ for fn in deployment.get("functions", []):
269
+ typer.echo(f" {fn['name']} ({fn['adapter']})")
270
+
271
+
272
+ secrets_app = typer.Typer(help="Manage per-tenant secrets and environment variables.")
273
+ app.add_typer(secrets_app, name="secrets")
274
+
275
+
276
+ def _secret_scope(app_name: str | None, shared: bool) -> str | None:
277
+ if app_name and shared:
278
+ typer.echo("Use either --app APP or --shared, not both.", err=True)
279
+ raise typer.Exit(code=2)
280
+ if app_name:
281
+ return app_name
282
+ if shared:
283
+ return None
284
+ manifest = Path.cwd() / "verulla.yml"
285
+ if manifest.is_file():
286
+ try:
287
+ return load_manifest(manifest).app
288
+ except ValueError as exc:
289
+ typer.echo(f"Error reading {manifest}: {exc}", err=True)
290
+ raise typer.Exit(code=1) from exc
291
+ return None
292
+
293
+
294
+ @secrets_app.command("list")
295
+ def secrets_list(
296
+ app_name: str | None = typer.Option(None, "--app", help="Scope secrets to APP."),
297
+ shared: bool = typer.Option(False, "--shared", help="Use tenant-shared scope."),
298
+ api_url: str = typer.Option(
299
+ ...,
300
+ "--api-url",
301
+ envvar="VERULLA_API_URL",
302
+ help="Verulla control plane base URL.",
303
+ ),
304
+ ) -> None:
305
+ """List secret keys (values are never printed)."""
306
+ scope = _secret_scope(app_name, shared)
307
+ try:
308
+ token = get_token()
309
+ except DeviceLoginError as exc:
310
+ typer.echo(f"Error: {exc}", err=True)
311
+ raise typer.Exit(code=1) from exc
312
+ try:
313
+ keys = list_secret_keys(api_url.rstrip("/"), token, app=scope)
314
+ except SecretsError as exc:
315
+ typer.echo(f"Error: {exc}", err=True)
316
+ raise typer.Exit(code=1) from exc
317
+ if not keys:
318
+ typer.echo("No secrets set.")
319
+ return
320
+ for key in keys:
321
+ typer.echo(key)
322
+
323
+
324
+ @secrets_app.command("set")
325
+ def secrets_set(
326
+ key: str = typer.Argument(..., help="Secret key name"),
327
+ app_name: str | None = typer.Option(None, "--app", help="Scope secret to APP."),
328
+ shared: bool = typer.Option(False, "--shared", help="Use tenant-shared scope."),
329
+ api_url: str = typer.Option(
330
+ ...,
331
+ "--api-url",
332
+ envvar="VERULLA_API_URL",
333
+ help="Verulla control plane base URL.",
334
+ ),
335
+ ) -> None:
336
+ """Set a secret value (you'll be prompted; never pass secrets on the command line)."""
337
+ scope = _secret_scope(app_name, shared)
338
+ try:
339
+ token = get_token()
340
+ except DeviceLoginError as exc:
341
+ typer.echo(f"Error: {exc}", err=True)
342
+ raise typer.Exit(code=1) from exc
343
+ value = typer.prompt(f"Value for {key}", hide_input=True)
344
+ try:
345
+ set_secret(api_url.rstrip("/"), token, key, value, app=scope)
346
+ except SecretsError as exc:
347
+ typer.echo(f"Error: {exc}", err=True)
348
+ raise typer.Exit(code=1) from exc
349
+ typer.echo(f"Secret {key!r} set.")
350
+
351
+
352
+ @secrets_app.command("delete")
353
+ def secrets_delete(
354
+ key: str = typer.Argument(..., help="Secret key name"),
355
+ app_name: str | None = typer.Option(None, "--app", help="Scope secret to APP."),
356
+ shared: bool = typer.Option(False, "--shared", help="Use tenant-shared scope."),
357
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL", help="Verulla control plane base URL."),
358
+ ) -> None:
359
+ """Delete a secret without displaying its value."""
360
+ scope = _secret_scope(app_name, shared)
361
+ try:
362
+ token = get_token()
363
+ except DeviceLoginError as exc:
364
+ typer.echo(f"Error: {exc}", err=True)
365
+ raise typer.Exit(code=1) from exc
366
+ try:
367
+ api_delete_secret(api_url.rstrip("/"), token, key, app=scope)
368
+ except SecretsError as exc:
369
+ typer.echo(f"Error: {exc}", err=True)
370
+ raise typer.Exit(code=1) from exc
371
+ typer.echo(f"Secret {key!r} deleted.")
372
+
373
+
374
+ @app.command()
375
+ def logs(
376
+ app_name: str = typer.Argument(..., metavar="APP", help="App name"),
377
+ function: str | None = typer.Argument(None, help="Optional function name (reserved for runtime logs)"),
378
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL", help="Verulla control plane base URL."),
379
+ ) -> None:
380
+ """Show latest deployment status and build log tail (runtime logs are not available yet)."""
381
+ del function
382
+ try:
383
+ token = get_token()
384
+ except DeviceLoginError:
385
+ typer.echo("Not logged in. Run `verulla login` first.", err=True)
386
+ raise typer.Exit(code=1)
387
+ try:
388
+ result = get_app_logs(api_url.rstrip("/"), token, app_name)
389
+ except AppsError as exc:
390
+ typer.echo(f"Error: {exc}", err=True)
391
+ raise typer.Exit(code=1) from exc
392
+ for key in ("app", "deployment_id", "status", "error"):
393
+ typer.echo(f"{key}: {result.get(key)}")
394
+ typer.echo("build_log_tail:")
395
+ typer.echo(result.get("build_log_tail") or "(no build log available)")
396
+ typer.echo("Runtime/pod logs are not available yet.")
397
+
398
+
399
+ def _resolve_app(name: str | None) -> str:
400
+ if name:
401
+ return name
402
+ manifest = Path.cwd() / "verulla.yml"
403
+ if manifest.is_file():
404
+ try:
405
+ return load_manifest(manifest).app
406
+ except ValueError as exc:
407
+ typer.echo(f"Error reading {manifest}: {exc}", err=True)
408
+ raise typer.Exit(code=1) from exc
409
+ typer.echo("Provide APP or run inside a directory containing verulla.yml.", err=True)
410
+ raise typer.Exit(code=2)
411
+
412
+
413
+ @app.command()
414
+ def apps(
415
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL", help="Verulla control plane base URL."),
416
+ ) -> None:
417
+ """List apps in your tenant."""
418
+ try:
419
+ token = get_token()
420
+ except DeviceLoginError:
421
+ typer.echo("Not logged in. Run `verulla login` first.", err=True)
422
+ raise typer.Exit(code=1)
423
+ try:
424
+ rows = list_apps(api_url.rstrip("/"), token)
425
+ except AppsError as exc:
426
+ typer.echo(f"Error: {exc}", err=True)
427
+ raise typer.Exit(code=1) from exc
428
+ if not rows:
429
+ typer.echo("No apps.")
430
+ return
431
+ for row in rows:
432
+ typer.echo(f"{row['app']} {row['status']} {row.get('url') or '-'}")
433
+
434
+
435
+ @app.command()
436
+ def wallet(
437
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL", help="Verulla control plane base URL."),
438
+ ) -> None:
439
+ """Show wallet balance, ledger and spending-limit summary as JSON."""
440
+ try:
441
+ typer.echo(json.dumps(get_wallet_summary(api_url.rstrip("/"), get_token()), indent=2))
442
+ except (DeviceLoginError, AppsError) as exc:
443
+ typer.echo(f"Error: {exc}", err=True)
444
+ raise typer.Exit(code=1) from exc
445
+
446
+
447
+ @app.command()
448
+ def usage(
449
+ app_name: str | None = typer.Argument(None, help="Optional Live app name; omit to show all Live apps."),
450
+ month: str | None = typer.Option(None, "--month", help="Calendar usage month in YYYY-MM format (legacy view)."),
451
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL", help="Verulla control plane base URL."),
452
+ ) -> None:
453
+ """Show Live term allowances, usage, projected costs and spending headroom."""
454
+ try:
455
+ if month is not None:
456
+ result = get_usage_summary(api_url.rstrip("/"), get_token(), month=month)
457
+ else:
458
+ result = get_term_usage(api_url.rstrip("/"), get_token(), app=app_name)
459
+ typer.echo(json.dumps(result, indent=2))
460
+ except (DeviceLoginError, AppsError) as exc:
461
+ typer.echo(f"Error: {exc}", err=True)
462
+ raise typer.Exit(code=1) from exc
463
+
464
+
465
+ @app.command()
466
+ def spend(
467
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL", help="Verulla control plane base URL."),
468
+ ) -> None:
469
+ """Show current spend and spending-limit headroom as JSON."""
470
+ try:
471
+ typer.echo(json.dumps(get_spend_summary(api_url.rstrip("/"), get_token()), indent=2))
472
+ except (DeviceLoginError, AppsError) as exc:
473
+ typer.echo(f"Error: {exc}", err=True)
474
+ raise typer.Exit(code=1) from exc
475
+
476
+
477
+ @app.command()
478
+ def pricing(
479
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL", help="Verulla control plane base URL."),
480
+ ) -> None:
481
+ """Show the USD/CAD fixed-price contract as JSON."""
482
+ try:
483
+ typer.echo(json.dumps(get_pricing(api_url.rstrip("/"), get_token()), indent=2))
484
+ except (DeviceLoginError, AppsError) as exc:
485
+ typer.echo(f"Error: {exc}", err=True)
486
+ raise typer.Exit(code=1) from exc
487
+
488
+
489
+ @app.command()
490
+ def topup(
491
+ amount_cents: int = typer.Argument(..., min=1, help="Top-up amount in minor units (cents)."),
492
+ currency: str = typer.Option("USD", "--currency", help="Wallet currency: USD or CAD (chosen on first top-up)."),
493
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL", help="Verulla control plane base URL."),
494
+ ) -> None:
495
+ """Create a wallet top-up intent; currency becomes fixed after funding."""
496
+ if currency not in {"USD", "CAD"}:
497
+ typer.echo("Currency must be USD or CAD.", err=True)
498
+ raise typer.Exit(code=2)
499
+ try:
500
+ result = create_topup(api_url.rstrip("/"), get_token(), amount_cents, currency)
501
+ except (DeviceLoginError, AppsError) as exc:
502
+ typer.echo(f"Error: {exc}", err=True)
503
+ raise typer.Exit(code=1) from exc
504
+ typer.echo(json.dumps(result, indent=2))
505
+
506
+
507
+ @app.command()
508
+ def live(
509
+ app_name: str = typer.Argument(..., metavar="APP", help="App to upgrade to Live."),
510
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
511
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL", help="Verulla control plane base URL."),
512
+ ) -> None:
513
+ """Upgrade an app to Live at the published price in its wallet currency."""
514
+ try:
515
+ token = get_token()
516
+ except DeviceLoginError:
517
+ typer.echo("Not logged in. Run `verulla login` first.", err=True)
518
+ raise typer.Exit(code=1)
519
+ try:
520
+ app_status = get_app(api_url.rstrip("/"), token, app_name)
521
+ if app_status.get("live_activation_available") is not True:
522
+ typer.echo("Live activation is not currently available for this app.", err=True)
523
+ raise typer.Exit(code=1)
524
+ spend = get_spend_summary(api_url.rstrip("/"), token)
525
+ currency = spend.get("currency", "USD")
526
+ contract = get_pricing(api_url.rstrip("/"), token)
527
+ except AppsError as exc:
528
+ typer.echo(f"Error checking Live availability or spending-limit headroom: {exc}", err=True)
529
+ raise typer.Exit(code=1) from exc
530
+ live_tier = next((tier for tier in contract.get("tiers", []) if tier.get("id") == "live"), {})
531
+ prices = live_tier.get("prices_cents", {})
532
+ price_cents = prices.get(currency)
533
+ if price_cents is None:
534
+ price_cents = live_tier.get("price_usd_cents") if currency == "USD" else live_tier.get("price_cad_cents")
535
+ if currency not in {"USD", "CAD"} or not isinstance(price_cents, int):
536
+ typer.echo(f"Unsupported wallet currency {currency!r} for Live pricing.", err=True)
537
+ raise typer.Exit(code=1)
538
+ symbol = "C$" if currency == "CAD" else "US$"
539
+ price_text = f"{symbol}{price_cents // 100:,}.{price_cents % 100:02d}"
540
+ remaining = spend.get("remaining_cents")
541
+ if isinstance(remaining, int) and not isinstance(remaining, bool):
542
+ headroom = f"{symbol}{remaining // 100:,}.{remaining % 100:02d}"
543
+ else:
544
+ headroom = "no spending limit set"
545
+ typer.echo(f"Live costs {price_text} per 30 days ({currency}).")
546
+ typer.echo(f"Spending-limit headroom: {headroom}.")
547
+ if not yes:
548
+ typer.confirm(f"Make {app_name!r} Live for {price_text} per 30 days?", abort=True)
549
+ try:
550
+ result = activate_app(api_url.rstrip("/"), token, app_name, str(uuid.uuid4()))
551
+ except AppsError as exc:
552
+ typer.echo(f"Error: {exc}", err=True)
553
+ raise typer.Exit(code=1) from exc
554
+ typer.echo(f"{app_name} is Live through {result.get('paid_through')} (price {result.get('pricing_version')}).")
555
+
556
+
557
+ @app.command()
558
+ def status(
559
+ app_name: str | None = typer.Argument(None, metavar="APP"),
560
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL", help="Verulla control plane base URL."),
561
+ ) -> None:
562
+ """Show app overview, or detailed status for APP."""
563
+ try:
564
+ token = get_token()
565
+ except DeviceLoginError:
566
+ typer.echo("Not logged in. Run `verulla login` first.", err=True)
567
+ raise typer.Exit(code=1)
568
+ try:
569
+ if app_name is None:
570
+ rows = list_apps(api_url.rstrip("/"), token)
571
+ for row in rows:
572
+ typer.echo(f"{row['app']} {row['status']} {row.get('url') or '-'}")
573
+ else:
574
+ row = get_app(api_url.rstrip("/"), token, app_name)
575
+ for key in ("app", "tier_id", "pricing_version", "paid_through", "renewal_date", "status", "latest_deployment_id", "source_sha", "function_count", "url", "updated_at", "error", "functions"):
576
+ if key in row:
577
+ typer.echo(f"{key}: {row[key]}")
578
+ except AppsError as exc:
579
+ typer.echo(f"Error: {exc}", err=True)
580
+ raise typer.Exit(code=1) from exc
581
+
582
+
583
+ @sketch_app.command("events")
584
+ def sketch_events(
585
+ app_name: str = typer.Argument(..., metavar="APP"),
586
+ cursor: str | None = typer.Option(None, "--cursor", help="Resume after this event cursor."),
587
+ limit: int = typer.Option(100, min=1, max=500),
588
+ json_output: bool = typer.Option(False, "--json", help="Print the shared API event envelope as JSON."),
589
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL"),
590
+ ) -> None:
591
+ """Poll durable Sketch countdown events after the supplied cursor."""
592
+ result = _auth_api_call(
593
+ get_sketch_events, api_url.rstrip("/"), _agent_token(), app_name,
594
+ cursor=cursor, limit=limit,
595
+ )
596
+ if json_output:
597
+ typer.echo(json.dumps(result))
598
+ return
599
+ for event in result["events"]:
600
+ typer.echo(f"{event['occurred_at']} {event['type']} {event['app']}")
601
+ if result["next_cursor"]:
602
+ typer.echo(f"next_cursor: {result['next_cursor']}")
603
+ typer.echo(f"has_more: {str(result['has_more']).lower()}")
604
+ @sketch_app.command("status")
605
+ def sketch_status(
606
+ app_name: str = typer.Argument(..., metavar="APP"),
607
+ json_output: bool = typer.Option(False, "--json", help="Print the receipt as JSON."),
608
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL", help="Verulla control plane base URL."),
609
+ ) -> None:
610
+ """Show the shared Sketch lifecycle receipt for APP."""
611
+ try:
612
+ token = get_token()
613
+ except DeviceLoginError:
614
+ typer.echo("Not logged in. Run `verulla login` first.", err=True)
615
+ raise typer.Exit(code=1)
616
+ try:
617
+ receipt = _safe_sketch_receipt(get_sketch_status(api_url.rstrip("/"), token, app_name))
618
+ except AppsError as exc:
619
+ typer.echo(f"Error: {exc}", err=True)
620
+ raise typer.Exit(code=1) from exc
621
+ if json_output:
622
+ typer.echo(json.dumps(receipt))
623
+ return
624
+ for key in ("app", "status", "url", "lifecycle_state", "expires_at", "retention_ends_at", "claim_status", "limits_url", "console_url"):
625
+ if key in receipt:
626
+ typer.echo(f"{key}: {receipt[key]}")
627
+
628
+
629
+ @owner_app.command("claim")
630
+ def owner_claim(
631
+ app_name: str = typer.Argument(..., metavar="APP"),
632
+ email: str = typer.Option(..., "--email", help="Verified owner's email address."),
633
+ json_output: bool = typer.Option(False, "--json", help="Print the one-time hand-off as JSON."),
634
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL", help="Verulla control plane base URL."),
635
+ ) -> None:
636
+ """Issue a one-time claim link for the owner of APP's tenant."""
637
+ try:
638
+ token = get_token()
639
+ receipt = get_sketch_status(api_url.rstrip("/"), token, app_name)
640
+ if not receipt.get("url"):
641
+ typer.echo("Error: APP has no public app URL yet; deploy it before issuing an owner claim.", err=True)
642
+ raise typer.Exit(code=1)
643
+ claim = issue_owner_claim(api_url.rstrip("/"), token, email, str(uuid.uuid4()))
644
+ except DeviceLoginError:
645
+ typer.echo("Not logged in. Run `verulla login` first.", err=True)
646
+ raise typer.Exit(code=1)
647
+ except AppsError as exc:
648
+ typer.echo(f"Error: {exc}", err=True)
649
+ raise typer.Exit(code=1) from exc
650
+ result = {
651
+ "app": app_name,
652
+ "url": receipt.get("url"),
653
+ "claim_url": claim.get("claim_url"),
654
+ "expires_at": claim.get("expires_at"),
655
+ "status": claim.get("status", "pending"),
656
+ }
657
+ if not result["url"] or not result["claim_url"] or not result["expires_at"]:
658
+ typer.echo("Error: owner claim response was incomplete.", err=True)
659
+ raise typer.Exit(code=1)
660
+ if json_output:
661
+ typer.echo(json.dumps(result))
662
+ return
663
+ for key, value in result.items():
664
+ typer.echo(f"{key}: {value}")
665
+
666
+
667
+ @owner_app.command("status")
668
+ def owner_status(
669
+ app_name: str = typer.Argument(..., metavar="APP"),
670
+ json_output: bool = typer.Option(False, "--json", help="Print safe claim status as JSON."),
671
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL", help="Verulla control plane base URL."),
672
+ ) -> None:
673
+ """Show safe owner-claim status for APP without revealing its link or email."""
674
+ try:
675
+ token = get_token()
676
+ receipt = get_sketch_status(api_url.rstrip("/"), token, app_name)
677
+ claim = get_owner_claim_status(api_url.rstrip("/"), token)
678
+ except DeviceLoginError:
679
+ typer.echo("Not logged in. Run `verulla login` first.", err=True)
680
+ raise typer.Exit(code=1)
681
+ except AppsError as exc:
682
+ typer.echo(f"Error: {exc}", err=True)
683
+ raise typer.Exit(code=1) from exc
684
+ result = {"app": app_name, "url": receipt.get("url"), "status": claim.get("status")}
685
+ if claim.get("expires_at") is not None:
686
+ result["expires_at"] = claim["expires_at"]
687
+ if json_output:
688
+ typer.echo(json.dumps(result))
689
+ return
690
+ for key, value in result.items():
691
+ typer.echo(f"{key}: {value}")
692
+
693
+
694
+ @app.command()
695
+ def delete(
696
+ app_name: str | None = typer.Argument(None, metavar="APP"),
697
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
698
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL", help="Verulla control plane base URL."),
699
+ ) -> None:
700
+ """Delete an app and its deployments."""
701
+ name = _resolve_app(app_name)
702
+ if not yes:
703
+ typer.confirm(f"Delete app {name!r} and its deployments?", abort=True)
704
+ try:
705
+ token = get_token()
706
+ except DeviceLoginError:
707
+ typer.echo("Not logged in. Run `verulla login` first.", err=True)
708
+ raise typer.Exit(code=1)
709
+ try:
710
+ api_delete_app(api_url.rstrip("/"), token, name)
711
+ except AppsError as exc:
712
+ typer.echo(f"Error: {exc}", err=True)
713
+ raise typer.Exit(code=1) from exc
714
+ typer.echo(f"Deletion queued for {name}.")
715
+
716
+
717
+ @app.command()
718
+ def rollback(
719
+ app_name: str | None = typer.Argument(None, metavar="APP"),
720
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL", help="Verulla control plane base URL."),
721
+ ) -> None:
722
+ """Redeploy the previous successful app snapshot."""
723
+ name = _resolve_app(app_name)
724
+ try:
725
+ token = get_token()
726
+ except DeviceLoginError:
727
+ typer.echo("Not logged in. Run `verulla login` first.", err=True)
728
+ raise typer.Exit(code=1)
729
+ try:
730
+ result = api_rollback_app(api_url.rstrip("/"), token, name)
731
+ except AppsError as exc:
732
+ typer.echo(f"Error: {exc}", err=True)
733
+ raise typer.Exit(code=1) from exc
734
+ typer.echo(f"Rollback deployment {result['id']} queued for {name}.")
735
+
736
+
737
+ @app.command()
738
+ def keys_mint(
739
+ name: str = typer.Argument(..., help="Key name"),
740
+ scopes: list[str] = typer.Option(
741
+ [],
742
+ "--scopes",
743
+ help="Space-separated list of scopes (defaults to all except keys:admin)",
744
+ ),
745
+ expires_in_days: int | None = typer.Option(
746
+ None,
747
+ "--expires-in-days",
748
+ min=1,
749
+ max=3650,
750
+ help="Key expiration in days (1-3650)",
751
+ ),
752
+ api_url: str = typer.Option(
753
+ ...,
754
+ "--api-url",
755
+ envvar="VERULLA_API_URL",
756
+ help="Verulla control plane base URL.",
757
+ ),
758
+ ) -> None:
759
+ """Mint a new tenant-scoped API key.
760
+
761
+ Requires `keys:admin` scope on the authenticated principal. The plaintext
762
+ token is returned once and never persisted. Only ``token_hash`` (SHA-256
763
+ hex) is stored at rest.
764
+ """
765
+ try:
766
+ token = get_token()
767
+ except DeviceLoginError as exc:
768
+ typer.echo(f"Error: {exc}", err=True)
769
+ raise typer.Exit(code=1) from exc
770
+ import httpx
771
+
772
+ owns_client = True
773
+ client = httpx.Client(timeout=10)
774
+ try:
775
+ # Verify the token works
776
+ response = client.get(f"{api_url.rstrip('/')}/v1/me", headers={"Authorization": f"Bearer {token}"})
777
+ if response.status_code == 401:
778
+ typer.echo("Invalid API token", err=True)
779
+ raise typer.Exit(code=1)
780
+ if response.status_code != 200:
781
+ typer.echo(f"Unexpected response: {response.status_code}", err=True)
782
+ raise typer.Exit(code=1)
783
+ # Query parameters follow the route's repeated `scopes` contract.
784
+ params = {"name": name, "scopes": scopes}
785
+ if expires_in_days is not None:
786
+ params["expires_in_days"] = str(expires_in_days)
787
+ mint_response = client.post(
788
+ f"{api_url.rstrip('/')}/v1/keys",
789
+ params=params,
790
+ headers={"Authorization": f"Bearer {token}"},
791
+ )
792
+ if mint_response.status_code == 403:
793
+ typer.echo("Missing required scope keys:admin", err=True)
794
+ raise typer.Exit(code=1)
795
+ if mint_response.status_code != 201:
796
+ typer.echo(f"Mint failed: {mint_response.text}", err=True)
797
+ raise typer.Exit(code=1)
798
+ data = mint_response.json()
799
+ typer.echo(f"Key minted: {data.get('prefix', 'unknown')}...{data.get('name', name)}")
800
+ typer.echo(f"Scopes: {data.get('scopes', [])}")
801
+ typer.echo(f"Token: {data.get('token', '')}")
802
+ finally:
803
+ if owns_client:
804
+ client.close()
805
+
806
+
807
+ @app.command()
808
+ def keys_revoke(
809
+ key_id: str = typer.Argument(..., help="ID of the key to revoke"),
810
+ api_url: str = typer.Option(
811
+ ...,
812
+ "--api-url",
813
+ envvar="VERULLA_API_URL",
814
+ help="Verulla control plane base URL.",
815
+ ),
816
+ ) -> None:
817
+ """Soft-revoke an API key in your tenant.
818
+
819
+ Idempotent: revoking an already-revoked key keeps its original
820
+ revoked_at.
821
+ """
822
+ try:
823
+ token = get_token()
824
+ except DeviceLoginError as exc:
825
+ typer.echo(f"Error: {exc}", err=True)
826
+ raise typer.Exit(code=1) from exc
827
+ import httpx
828
+
829
+ owns_client = True
830
+ client = httpx.Client(timeout=10)
831
+ try:
832
+ revoke_response = client.delete(
833
+ f"{api_url.rstrip('/')}/v1/keys/{key_id}",
834
+ headers={"Authorization": f"Bearer {token}"},
835
+ )
836
+ if revoke_response.status_code == 404:
837
+ typer.echo("API key not found in this tenant", err=True)
838
+ raise typer.Exit(code=1)
839
+ if revoke_response.status_code != 204:
840
+ typer.echo(f"Revoke failed: {revoke_response.text}", err=True)
841
+ raise typer.Exit(code=1)
842
+ typer.echo(f"Key {key_id} revoked.")
843
+ finally:
844
+ if owns_client:
845
+ client.close()
846
+
847
+
848
+ auth_app = typer.Typer(help="Manage app customer authentication policy.", no_args_is_help=True)
849
+ customers_app = typer.Typer(help="Manage customers for an app.", no_args_is_help=True)
850
+ app.add_typer(auth_app, name="auth")
851
+ app.add_typer(customers_app, name="customers")
852
+
853
+
854
+ def _agent_token() -> str:
855
+ try:
856
+ return get_token()
857
+ except DeviceLoginError as exc:
858
+ typer.echo(f"Error: {exc}", err=True)
859
+ raise typer.Exit(code=1) from exc
860
+
861
+
862
+ def _auth_api_call(fn, *args, **kwargs):
863
+ try:
864
+ return fn(*args, **kwargs)
865
+ except AppsError as exc:
866
+ typer.echo(f"Error: {exc}", err=True)
867
+ raise typer.Exit(code=1) from exc
868
+
869
+
870
+ @auth_app.command("show")
871
+ def auth_show(app_name: str = typer.Argument(..., metavar="APP"), api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL")) -> None:
872
+ """Show the app's AuthPolicy and version."""
873
+ data = _auth_api_call(get_auth_policy, api_url.rstrip("/"), _agent_token(), app_name)
874
+ typer.echo(json.dumps(data, indent=2))
875
+
876
+
877
+ @auth_app.command("set")
878
+ def auth_set(app_name: str = typer.Argument(..., metavar="APP"), policy_file: Path = typer.Argument(..., exists=True, readable=True),
879
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL"),
880
+ version: int | None = typer.Option(None, "--version", min=0)) -> None:
881
+ """Set policy from a JSON file, optionally pinning the expected version."""
882
+ try:
883
+ policy = json.loads(policy_file.read_text())
884
+ if not isinstance(policy, dict):
885
+ raise ValueError("policy must be a JSON object")
886
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
887
+ typer.echo(f"Invalid policy JSON: {exc}", err=True)
888
+ raise typer.Exit(code=2) from exc
889
+ if version is None:
890
+ typer.echo(
891
+ "Warning: no --version supplied; updating the latest policy. "
892
+ "Pass the version from `verulla auth show APP` with --version to reject stale updates.",
893
+ err=True,
894
+ )
895
+ current = _auth_api_call(get_auth_policy, api_url.rstrip("/"), _agent_token(), app_name)
896
+ version = current["version"]
897
+ result = _auth_api_call(set_auth_policy, api_url.rstrip("/"), _agent_token(), app_name, version, policy)
898
+ typer.echo(json.dumps(result, indent=2))
899
+
900
+
901
+ @customers_app.command("list")
902
+ def customers_list(app_name: str = typer.Argument(..., metavar="APP"), search: str | None = typer.Option(None, "--search"),
903
+ limit: int = typer.Option(50, min=1, max=100), offset: int = typer.Option(0, min=0),
904
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL")) -> None:
905
+ """List customers (requires auth:customers scope)."""
906
+ result = _auth_api_call(api_list_customers, api_url.rstrip("/"), _agent_token(), app_name, search=search, limit=limit, offset=offset)
907
+ typer.echo(json.dumps(result, indent=2))
908
+
909
+
910
+ @customers_app.command("lock")
911
+ def customers_lock(app_name: str, customer_id: str, api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL")) -> None:
912
+ """Lock a customer account."""
913
+ typer.echo(json.dumps(_auth_api_call(api_lock_customer, api_url.rstrip("/"), _agent_token(), app_name, customer_id), indent=2))
914
+
915
+
916
+ @customers_app.command("unlock")
917
+ def customers_unlock(app_name: str, customer_id: str, api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL")) -> None:
918
+ """Unlock a customer account."""
919
+ typer.echo(json.dumps(_auth_api_call(api_unlock_customer, api_url.rstrip("/"), _agent_token(), app_name, customer_id), indent=2))
920
+
921
+
922
+ @customers_app.command("delete")
923
+ def customers_delete(app_name: str, customer_id: str, yes: bool = typer.Option(False, "--yes", "-y"),
924
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL")) -> None:
925
+ """Soft-delete a customer after confirmation."""
926
+ if not yes:
927
+ typer.confirm(f"Delete customer {customer_id!r} from {app_name!r}?", abort=True)
928
+ _auth_api_call(api_delete_customer, api_url.rstrip("/"), _agent_token(), app_name, customer_id)
929
+ typer.echo("Customer deleted.")
930
+
931
+
932
+ @customers_app.command("export")
933
+ def customers_export(app_name: str, output: Path = typer.Option(..., "--output", writable=True),
934
+ api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL")) -> None:
935
+ """Export customer PII as CSV to OUTPUT (requires auth:customers scope)."""
936
+ csv_data = _auth_api_call(api_export_customers, api_url.rstrip("/"), _agent_token(), app_name)
937
+ descriptor = os.open(output, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
938
+ try:
939
+ os.fchmod(descriptor, 0o600)
940
+ with os.fdopen(descriptor, "w") as export_file:
941
+ descriptor = -1
942
+ export_file.write(csv_data)
943
+ finally:
944
+ if descriptor >= 0:
945
+ os.close(descriptor)
946
+ typer.echo(f"Customer export written to {output}.")
947
+
948
+
949
+ @customers_app.command("invite")
950
+ def customers_invite(app_name: str, email: str, api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL")) -> None:
951
+ """Invite a customer; delivery may be logging-only until email is configured."""
952
+ typer.echo(json.dumps(_auth_api_call(api_invite_customer, api_url.rstrip("/"), _agent_token(), app_name, email), indent=2))
953
+
954
+
955
+ admin_app = typer.Typer(help="Operator commands (need the operator role; tailnet only).", no_args_is_help=True)
956
+ app.add_typer(admin_app, name="admin")
957
+
958
+
959
+ @admin_app.command("whoami")
960
+ def admin_whoami(
961
+ admin_url: str = typer.Option(
962
+ ...,
963
+ "--admin-url",
964
+ envvar="VERULLA_ADMIN_URL",
965
+ help="Admin API base URL (tailnet host; /v1/admin is blocked on the public API host).",
966
+ ),
967
+ ) -> None:
968
+ """Show who the admin API thinks you are (sub, roles, scopes, client)."""
969
+ import httpx
970
+
971
+ try:
972
+ token = get_token()
973
+ except DeviceLoginError as exc:
974
+ typer.echo(f"Error: {exc}", err=True)
975
+ raise typer.Exit(code=1) from exc
976
+ try:
977
+ response = httpx.get(
978
+ f"{admin_url.rstrip('/')}/v1/admin/whoami",
979
+ headers={"Authorization": f"Bearer {token}"},
980
+ timeout=10,
981
+ )
982
+ except httpx.HTTPError as exc:
983
+ typer.echo(f"Error: cannot reach {admin_url}: {exc}", err=True)
984
+ raise typer.Exit(code=1) from exc
985
+ if response.status_code != 200:
986
+ typer.echo(f"Error: {response.status_code} {response.text[:300]}", err=True)
987
+ raise typer.Exit(code=1)
988
+ data = response.json()
989
+ typer.echo(f"sub: {data.get('sub')}")
990
+ typer.echo(f"client: {data.get('client')}")
991
+ typer.echo(f"roles: {', '.join(data.get('roles') or []) or '(none)'}")
992
+ typer.echo(f"scopes: {', '.join(data.get('scopes') or []) or '(none)'}")
993
+
994
+
995
+ def main() -> None:
996
+ app()
997
+
998
+
999
+ if __name__ == "__main__":
1000
+ main()