runtime-sdk 0.2.0__tar.gz → 0.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Runtime Python SDK and CLI
5
5
  Project-URL: Repository, https://github.com/The-Money-Company-Limited/runtimevm
6
6
  Project-URL: Issues, https://github.com/The-Money-Company-Limited/runtimevm/issues
@@ -46,11 +46,11 @@ Or pass `--base-url` per command.
46
46
 
47
47
  ```bash
48
48
  # Auth
49
- runtime signup you@example.com --name "Your Name"
49
+ runtime login you@example.com
50
50
  runtime verify 123456
51
51
  runtime whoami
52
52
  runtime logout
53
- runtime login <api_key>
53
+ runtime login --api-key rt_live_...
54
54
 
55
55
  # Computers
56
56
  runtime create
@@ -28,11 +28,11 @@ Or pass `--base-url` per command.
28
28
 
29
29
  ```bash
30
30
  # Auth
31
- runtime signup you@example.com --name "Your Name"
31
+ runtime login you@example.com
32
32
  runtime verify 123456
33
33
  runtime whoami
34
34
  runtime logout
35
- runtime login <api_key>
35
+ runtime login --api-key rt_live_...
36
36
 
37
37
  # Computers
38
38
  runtime create
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runtime-sdk"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Runtime Python SDK and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -50,6 +50,7 @@ class _UI:
50
50
  _console: Any = None
51
51
  _err_console: Any = None
52
52
  _questionary: Any = None
53
+ _questionary_style: Any = None
53
54
  _rich_mods: dict[str, Any] | None = None
54
55
 
55
56
  @classmethod
@@ -76,6 +77,25 @@ class _UI:
76
77
  cls._questionary = questionary
77
78
  return cls._questionary
78
79
 
80
+ @classmethod
81
+ def style(cls) -> Any:
82
+ if cls._questionary_style is None:
83
+ cls._questionary_style = cls.q().Style(
84
+ [
85
+ ("qmark", "fg:#22c55e bold"),
86
+ ("question", "bold fg:#e5e7eb"),
87
+ ("answer", "fg:#22c55e bold"),
88
+ ("pointer", "fg:#60a5fa bold"),
89
+ ("highlighted", "fg:#ffffff bg:#1f2937 bold"),
90
+ ("selected", "fg:#22c55e bold"),
91
+ ("separator", "fg:#4b5563"),
92
+ ("instruction", "fg:#94a3b8 italic"),
93
+ ("text", "fg:#d1d5db"),
94
+ ("disabled", "fg:#6b7280 italic"),
95
+ ]
96
+ )
97
+ return cls._questionary_style
98
+
79
99
  @classmethod
80
100
  def rich(cls) -> dict[str, Any]:
81
101
  if cls._rich_mods is None:
@@ -98,16 +118,29 @@ def build_parser() -> argparse.ArgumentParser:
98
118
  # Subcommand is optional: bare `runtime` defaults to `create`.
99
119
  subparsers = parser.add_subparsers(dest="command", required=False)
100
120
 
101
- signup = subparsers.add_parser("signup")
121
+ signup = subparsers.add_parser("signup", help="Send an email verification code")
102
122
  signup.add_argument("email", nargs="?", default=None)
103
123
  signup.add_argument("--name", default=None)
104
124
 
105
- verify = subparsers.add_parser("verify")
125
+ verify = subparsers.add_parser("verify", help="Verify an emailed code and save a fresh API key")
106
126
  verify.add_argument("code", nargs="?", default=None)
107
127
  verify.add_argument("--flow-id", type=int)
108
128
 
109
- login = subparsers.add_parser("login")
110
- login.add_argument("api_key", nargs="?", default=None)
129
+ login = subparsers.add_parser("login", help="Start email login or import an API key")
130
+ login.add_argument(
131
+ "identifier",
132
+ nargs="?",
133
+ default=None,
134
+ help="Email address to send a code to, or an API key for backwards compatibility",
135
+ )
136
+ login.add_argument("--name", default=None, help="Optional display name for first-time email signup")
137
+ login.add_argument(
138
+ "--api-key",
139
+ nargs="?",
140
+ const="",
141
+ default=None,
142
+ help="Import an API key explicitly (prompts if omitted in interactive mode)",
143
+ )
111
144
 
112
145
  subparsers.add_parser("whoami")
113
146
  subparsers.add_parser("logout")
@@ -119,6 +152,9 @@ def build_parser() -> argparse.ArgumentParser:
119
152
  default=None,
120
153
  help="Subdomain name (e.g. redsox → redsox.runruntime.dev). Random if skipped.",
121
154
  )
155
+ create_cmd.add_argument("--command", dest="startup_command", help="App startup command to run after create")
156
+ create_cmd.add_argument("--cwd", help="Working directory for the startup command")
157
+ create_cmd.add_argument("--port", type=int, help="App port to publish after startup")
122
158
  subparsers.add_parser("list", aliases=["ls"], help="List computers")
123
159
 
124
160
  info_cmd = subparsers.add_parser("info", help="Get computer details")
@@ -174,13 +210,19 @@ def main(argv: list[str] | None = None) -> int:
174
210
  if args.command == "verify":
175
211
  return handle_verify(config, args.code, args.flow_id)
176
212
  if args.command == "login":
177
- return handle_login(config, args.api_key)
213
+ return handle_login(config, args.identifier, args.name, args.api_key)
178
214
  if args.command == "whoami":
179
215
  return handle_whoami(config)
180
216
  if args.command == "logout":
181
217
  return handle_logout(config)
182
218
  if args.command == "create":
183
- return handle_create(config, args.name)
219
+ return handle_create(
220
+ config,
221
+ args.name,
222
+ getattr(args, "startup_command", None),
223
+ getattr(args, "cwd", None),
224
+ getattr(args, "port", None),
225
+ )
184
226
  if args.command in ("list", "ls"):
185
227
  return handle_list(config)
186
228
  if args.command == "info":
@@ -242,7 +284,7 @@ def report_success(payload: dict[str, Any], renderer: Callable[[dict[str, Any]],
242
284
  def _require_api_key(config: RuntimeConfig) -> int | None:
243
285
  if config.api_key:
244
286
  return None
245
- return report_error("missing api key; run verify or login first")
287
+ return report_error("missing api key; run login or verify first")
246
288
 
247
289
 
248
290
  def _prompt_text(message: str, *, default: str | None = None) -> str | None:
@@ -308,7 +350,7 @@ def _computer_table_layout(computers: list[dict[str, Any]]) -> dict[str, int]:
308
350
 
309
351
 
310
352
  def _format_computer_row(c: dict[str, Any], widths: dict[str, int]) -> str:
311
- return " ".join(
353
+ return "".join(
312
354
  [
313
355
  _fit_cell(c.get("slug") or c.get("id") or "?", widths["slug"]),
314
356
  _fit_cell(c.get("status") or "?", widths["status"]),
@@ -320,7 +362,7 @@ def _format_computer_row(c: dict[str, Any], widths: dict[str, int]) -> str:
320
362
 
321
363
  def _computer_table_prompt(prompt: str, computers: list[dict[str, Any]]) -> tuple[str, dict[str, int]]:
322
364
  widths = _computer_table_layout(computers)
323
- header = " ".join(
365
+ header = "".join(
324
366
  [
325
367
  _fit_cell("slug", widths["slug"]),
326
368
  _fit_cell("status", widths["status"]),
@@ -328,7 +370,53 @@ def _computer_table_prompt(prompt: str, computers: list[dict[str, Any]]) -> tupl
328
370
  _fit_cell("created", widths["created_at"]),
329
371
  ]
330
372
  )
331
- return f"{prompt}\n{header}", widths
373
+ divider = "" * len(header)
374
+ return f"{prompt}\n{header}\n{divider}", widths
375
+
376
+
377
+ def _render_computer_summary(computers: list[dict[str, Any]]) -> None:
378
+ mods = _UI.rich()
379
+ Panel, Text = mods["Panel"], mods["Text"]
380
+ counts: dict[str, int] = {}
381
+ for computer in computers:
382
+ status = str(computer.get("status") or "unknown")
383
+ counts[status] = counts.get(status, 0) + 1
384
+
385
+ body = Text()
386
+ body.append(f"total {len(computers)}", style="bold white")
387
+ if counts:
388
+ body.append(" ")
389
+ first = True
390
+ status_styles = {
391
+ "running": "bold green",
392
+ "cold": "bold cyan",
393
+ "archiving": "bold yellow",
394
+ "restoring": "bold yellow",
395
+ "creating": "bold magenta",
396
+ "orphaned": "bold red",
397
+ }
398
+ for status in sorted(counts):
399
+ if not first:
400
+ body.append(" • ", style="dim")
401
+ first = False
402
+ body.append(status, style=status_styles.get(status, "bold white"))
403
+ body.append(f" {counts[status]}", style="white")
404
+
405
+ _UI.console().print(
406
+ Panel(body, title="runtime", border_style="blue", expand=False, padding=(0, 1))
407
+ )
408
+
409
+
410
+ def _select_prompt(message: str, choices: list[Any]) -> Any:
411
+ return _UI.q().select(
412
+ message,
413
+ choices=choices,
414
+ qmark="●",
415
+ pointer="❯",
416
+ instruction="↑/↓ move • Enter select • j/k also work",
417
+ use_indicator=True,
418
+ style=_UI.style(),
419
+ )
332
420
 
333
421
 
334
422
  def _pick_computer(config: RuntimeConfig, prompt: str) -> dict[str, Any] | None:
@@ -350,11 +438,7 @@ def _pick_computer(config: RuntimeConfig, prompt: str) -> dict[str, Any] | None:
350
438
  questionary.Choice(title=_format_computer_row(c, widths), value=c) for c in computers
351
439
  ]
352
440
  choices.append(questionary.Choice(title="← cancel", value=None))
353
- picked = questionary.select(
354
- message,
355
- choices=choices,
356
- instruction="↑/↓ to move, Enter to select",
357
- ).unsafe_ask()
441
+ picked = _select_prompt(message, choices).unsafe_ask()
358
442
  return picked if isinstance(picked, dict) else None
359
443
 
360
444
 
@@ -413,22 +497,21 @@ def _render_whoami_panel(payload: dict[str, Any]) -> None:
413
497
  # --------------------------------------------------------------------------- #
414
498
 
415
499
 
416
- def handle_signup(config: RuntimeConfig, email: str | None, name: str | None) -> int:
500
+ def _looks_like_api_key(value: str | None) -> bool:
501
+ candidate = (value or "").strip()
502
+ return candidate.startswith("rt_live_")
503
+
504
+
505
+ def _start_verification_flow(config: RuntimeConfig, email: str | None, name: str | None) -> int:
417
506
  if email is None:
418
507
  if not _interactive():
419
508
  return report_error("email is required")
420
509
  email = _prompt_text("Email")
421
510
  if not email:
422
511
  return report_error("email is required")
423
- if name is None:
424
- if not _interactive():
425
- return report_error("--name is required")
426
- name = _prompt_text("Your name")
427
- if not name:
428
- return report_error("name is required")
429
512
 
430
513
  client = RuntimeClient(base_url=config.base_url)
431
- result = client.signup(email, name)
514
+ result = client.start_verification(email, name)
432
515
  config.pending_signup = PendingSignup(
433
516
  flow_id=int(result["flow_id"]),
434
517
  email=email,
@@ -447,11 +530,40 @@ def handle_signup(config: RuntimeConfig, email: str | None, name: str | None) ->
447
530
  return report_success(result, render)
448
531
 
449
532
 
533
+ def _login_with_api_key(config: RuntimeConfig, api_key: str | None) -> int:
534
+ if api_key is None or api_key == "":
535
+ if not _interactive():
536
+ return report_error("api key is required")
537
+ api_key = _prompt_password("API key")
538
+ if not api_key:
539
+ return report_error("api key is required")
540
+
541
+ client = RuntimeClient(base_url=config.base_url, api_key=api_key)
542
+ whoami = client.whoami()
543
+ config.api_key = api_key
544
+ config.pending_signup = None
545
+ save_config(config)
546
+
547
+ def render(_: dict[str, Any]) -> None:
548
+ owner = whoami.get("owner") or {}
549
+ email = owner.get("email")
550
+ if email:
551
+ _UI.console().print(f"[green]✓[/green] logged in as [bold]{email}[/bold]")
552
+ return
553
+ _UI.console().print("[green]✓[/green] api key saved")
554
+
555
+ return report_success({"message": "logged in", **whoami}, render)
556
+
557
+
558
+ def handle_signup(config: RuntimeConfig, email: str | None, name: str | None) -> int:
559
+ return _start_verification_flow(config, email, name)
560
+
561
+
450
562
  def handle_verify(config: RuntimeConfig, code: str | None, flow_id: int | None) -> int:
451
563
  pending = config.pending_signup
452
564
  resolved_flow_id = flow_id or (pending.flow_id if pending else None)
453
565
  if not resolved_flow_id:
454
- return report_error("missing flow id; run signup first or pass --flow-id")
566
+ return report_error("missing flow id; run login first or pass --flow-id")
455
567
 
456
568
  if code is None:
457
569
  if not _interactive():
@@ -468,27 +580,32 @@ def handle_verify(config: RuntimeConfig, code: str | None, flow_id: int | None)
468
580
  config.pending_signup = None
469
581
  save_config(config)
470
582
 
471
- def render(_: dict[str, Any]) -> None:
583
+ def render(payload: dict[str, Any]) -> None:
584
+ owner = payload.get("owner") or {}
585
+ email = owner.get("email")
586
+ if email:
587
+ _UI.console().print(f"[green]✓[/green] verified, logged in as [bold]{email}[/bold]")
588
+ return
472
589
  _UI.console().print("[green]✓[/green] verified, api key saved")
473
590
 
474
591
  return report_success(result, render)
475
592
 
476
593
 
477
- def handle_login(config: RuntimeConfig, api_key: str | None) -> int:
478
- if api_key is None:
479
- if not _interactive():
480
- return report_error("api_key is required")
481
- api_key = _prompt_password("API key")
482
- if not api_key:
483
- return report_error("api key is required")
484
-
485
- config.api_key = api_key
486
- save_config(config)
594
+ def handle_login(
595
+ config: RuntimeConfig,
596
+ identifier: str | None,
597
+ name: str | None,
598
+ api_key: str | None,
599
+ ) -> int:
600
+ if api_key is not None:
601
+ if identifier is not None:
602
+ return report_error("pass either an email/api key argument or --api-key, not both")
603
+ return _login_with_api_key(config, api_key)
487
604
 
488
- def render(_: dict[str, Any]) -> None:
489
- _UI.console().print("[green]✓[/green] api key saved")
605
+ if _looks_like_api_key(identifier):
606
+ return _login_with_api_key(config, identifier)
490
607
 
491
- return report_success({"message": "api key saved"}, render)
608
+ return _start_verification_flow(config, identifier, name)
492
609
 
493
610
 
494
611
  def handle_whoami(config: RuntimeConfig) -> int:
@@ -500,26 +617,54 @@ def handle_whoami(config: RuntimeConfig) -> int:
500
617
 
501
618
 
502
619
  def handle_logout(config: RuntimeConfig) -> int:
620
+ had_api_key = config.api_key is not None
621
+ revoked = False
622
+ if config.api_key:
623
+ try:
624
+ RuntimeClient(base_url=config.base_url, api_key=config.api_key).logout()
625
+ revoked = True
626
+ except RuntimeAPIError:
627
+ revoked = False
628
+
503
629
  config.api_key = None
504
630
  config.pending_signup = None
505
631
  save_config(config)
506
632
 
633
+ message = "logged out"
634
+ if had_api_key and not revoked:
635
+ message = "logged out locally"
636
+
507
637
  def render(_: dict[str, Any]) -> None:
508
- _UI.console().print("[green]✓[/green] logged out")
638
+ _UI.console().print(f"[green]✓[/green] {message}")
509
639
 
510
- return report_success({"message": "logged out"}, render)
640
+ return report_success({"message": message}, render)
511
641
 
512
642
 
513
- def handle_create(config: RuntimeConfig, name: str | None) -> int:
643
+ def handle_create(
644
+ config: RuntimeConfig,
645
+ name: str | None,
646
+ startup_command: str | None,
647
+ cwd: str | None,
648
+ port: int | None,
649
+ ) -> int:
514
650
  if (err := _require_api_key(config)) is not None:
515
651
  return err
516
652
 
517
653
  if name is None and _interactive():
518
654
  name = _prompt_text("Name your computer")
519
655
 
656
+ if startup_command is None and port is not None:
657
+ return report_error("startup command is required when port is provided")
658
+ if startup_command is not None and port is None:
659
+ return report_error("port is required when startup command is provided")
660
+
520
661
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
662
+ label = "creating computer…"
663
+ if startup_command is not None:
664
+ label = f"creating computer and starting app on port {port}…"
521
665
  result = _with_spinner(
522
- "creating computer…", lambda: client.create_computer(slug=name)
666
+ label,
667
+ lambda: client.create_computer(slug=name, command=startup_command, cwd=cwd, port=port),
523
668
  )
524
669
 
525
670
  def render(payload: dict[str, Any]) -> None:
@@ -701,6 +846,7 @@ def _interactive_list(config: RuntimeConfig) -> int:
701
846
  _UI.console().print("[dim]no computers yet. create one with `runtime create`.[/dim]")
702
847
  return 0
703
848
 
849
+ _render_computer_summary(computers)
704
850
  message, widths = _computer_table_prompt("Pick a computer", computers)
705
851
  choices = [
706
852
  questionary.Choice(title=_format_computer_row(c, widths), value=c)
@@ -710,11 +856,7 @@ def _interactive_list(config: RuntimeConfig) -> int:
710
856
  choices.append(questionary.Choice(title="↻ refresh", value="__refresh__"))
711
857
  choices.append(questionary.Choice(title="✕ quit", value="__quit__"))
712
858
 
713
- picked = questionary.select(
714
- message,
715
- choices=choices,
716
- instruction="↑/↓ to move, Enter to select",
717
- ).unsafe_ask()
859
+ picked = _select_prompt(message, choices).unsafe_ask()
718
860
 
719
861
  if picked == "__quit__":
720
862
  return 0
@@ -738,9 +880,9 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
738
880
  while True:
739
881
  _render_computer_panel(vm, title=f"computer: {slug}")
740
882
 
741
- action = questionary.select(
883
+ action = _select_prompt(
742
884
  f"What would you like to do with {slug}?",
743
- choices=[
885
+ [
744
886
  questionary.Choice(title="▶ run a command", value="run"),
745
887
  questionary.Choice(title="⤴ publish a port", value="publish"),
746
888
  questionary.Choice(title="ℹ refresh info", value="info"),
@@ -22,8 +22,14 @@ class RuntimeClient:
22
22
  def __post_init__(self) -> None:
23
23
  self.base_url = self.base_url.rstrip("/")
24
24
 
25
- def signup(self, email: str, name: str) -> dict[str, Any]:
26
- return self._request("POST", "/api/auth/start", json={"email": email, "name": name})
25
+ def start_verification(self, email: str, name: str | None = None) -> dict[str, Any]:
26
+ body: dict[str, Any] = {"email": email}
27
+ if name is not None:
28
+ body["name"] = name
29
+ return self._request("POST", "/api/auth/start", json=body)
30
+
31
+ def signup(self, email: str, name: str | None = None) -> dict[str, Any]:
32
+ return self.start_verification(email, name)
27
33
 
28
34
  def verify(self, flow_id: int, code: str) -> dict[str, Any]:
29
35
  return self._request("POST", "/api/auth/verify", json={"flow_id": flow_id, "code": code})
@@ -31,12 +37,31 @@ class RuntimeClient:
31
37
  def whoami(self) -> dict[str, Any]:
32
38
  return self._request("GET", "/api/auth/whoami", auth_required=True)
33
39
 
40
+ def logout(self) -> dict[str, Any]:
41
+ return self._request("POST", "/api/auth/logout", auth_required=True)
42
+
34
43
  # --- Computers ---
35
44
 
36
- def create_computer(self, *, slug: str | None = None) -> dict[str, Any]:
45
+ def create_computer(
46
+ self,
47
+ *,
48
+ slug: str | None = None,
49
+ command: str | None = None,
50
+ cwd: str | None = None,
51
+ port: int | None = None,
52
+ ) -> dict[str, Any]:
37
53
  body: dict[str, Any] = {}
38
54
  if slug:
39
55
  body["slug"] = slug
56
+ if command is not None or cwd is not None or port is not None:
57
+ start: dict[str, Any] = {}
58
+ if command is not None:
59
+ start["command"] = command
60
+ if cwd is not None:
61
+ start["cwd"] = cwd
62
+ if port is not None:
63
+ start["port"] = port
64
+ body["start"] = start
40
65
  return self._request("POST", "/api/computers", json=body or None, auth_required=True)
41
66
 
42
67
  def list_computers(self) -> dict[str, Any]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Runtime Python SDK and CLI
5
5
  Project-URL: Repository, https://github.com/The-Money-Company-Limited/runtimevm
6
6
  Project-URL: Issues, https://github.com/The-Money-Company-Limited/runtimevm/issues
@@ -46,11 +46,11 @@ Or pass `--base-url` per command.
46
46
 
47
47
  ```bash
48
48
  # Auth
49
- runtime signup you@example.com --name "Your Name"
49
+ runtime login you@example.com
50
50
  runtime verify 123456
51
51
  runtime whoami
52
52
  runtime logout
53
- runtime login <api_key>
53
+ runtime login --api-key rt_live_...
54
54
 
55
55
  # Computers
56
56
  runtime create
File without changes