runtime-sdk 0.2.0__tar.gz → 0.3.0__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.3.0
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,16 +46,18 @@ 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
57
+ runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
57
58
  runtime list
58
59
  runtime info <id>
60
+ runtime start <id>
59
61
  runtime run <id> "echo hello"
60
62
  runtime run <id> "apt install -y nodejs" --uid 0
61
63
  runtime publish <id> 3000
@@ -73,10 +75,22 @@ client = RuntimeClient(base_url="https://api.runruntime.dev", api_key="rt_live_.
73
75
  computer = client.create_computer()
74
76
  print(computer["public_url"]) # https://goldbird.runruntime.dev
75
77
 
78
+ # Or create one with a durable startup command.
79
+ # That startup config is replayed after cold restore / auto-wake.
80
+ app = client.create_computer(
81
+ slug="myapp",
82
+ command="python3 app.py",
83
+ cwd="/home/ubuntu",
84
+ port=3000,
85
+ )
86
+
76
87
  # Run a command
77
88
  result = client.run_command(computer["id"], "echo hello")
78
89
  print(result["stdout"])
79
90
 
91
+ # Wake a cold computer explicitly
92
+ client.start_computer(app["id"])
93
+
80
94
  # Pin the public URL to a local app port
81
95
  client.publish_port(computer["id"], 3000)
82
96
 
@@ -104,6 +118,11 @@ make smoke
104
118
  Use `make deploy` instead of `make sync` when migrations, env
105
119
  files, Caddy, or systemd units changed.
106
120
 
121
+ Cold restore and public auto-wake replay the saved startup command only for
122
+ computers created with `--command` + `--port` (or the SDK `command`/`port`
123
+ arguments). If you start an app later via a one-off `runtime run`, the
124
+ filesystem is restored after going cold, but that ad-hoc process is not.
125
+
107
126
  Run the SDK unit tests through the backend project environment:
108
127
 
109
128
  ```bash
@@ -28,16 +28,18 @@ 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
39
+ runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
39
40
  runtime list
40
41
  runtime info <id>
42
+ runtime start <id>
41
43
  runtime run <id> "echo hello"
42
44
  runtime run <id> "apt install -y nodejs" --uid 0
43
45
  runtime publish <id> 3000
@@ -55,10 +57,22 @@ client = RuntimeClient(base_url="https://api.runruntime.dev", api_key="rt_live_.
55
57
  computer = client.create_computer()
56
58
  print(computer["public_url"]) # https://goldbird.runruntime.dev
57
59
 
60
+ # Or create one with a durable startup command.
61
+ # That startup config is replayed after cold restore / auto-wake.
62
+ app = client.create_computer(
63
+ slug="myapp",
64
+ command="python3 app.py",
65
+ cwd="/home/ubuntu",
66
+ port=3000,
67
+ )
68
+
58
69
  # Run a command
59
70
  result = client.run_command(computer["id"], "echo hello")
60
71
  print(result["stdout"])
61
72
 
73
+ # Wake a cold computer explicitly
74
+ client.start_computer(app["id"])
75
+
62
76
  # Pin the public URL to a local app port
63
77
  client.publish_port(computer["id"], 3000)
64
78
 
@@ -86,6 +100,11 @@ make smoke
86
100
  Use `make deploy` instead of `make sync` when migrations, env
87
101
  files, Caddy, or systemd units changed.
88
102
 
103
+ Cold restore and public auto-wake replay the saved startup command only for
104
+ computers created with `--command` + `--port` (or the SDK `command`/`port`
105
+ arguments). If you start an app later via a one-off `runtime run`, the
106
+ filesystem is restored after going cold, but that ad-hoc process is not.
107
+
89
108
  Run the SDK unit tests through the backend project environment:
90
109
 
91
110
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runtime-sdk"
3
- version = "0.2.0"
3
+ version = "0.3.0"
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,11 +152,17 @@ 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")
125
161
  info_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
126
162
 
163
+ start_cmd = subparsers.add_parser("start", help="Wake a cold computer")
164
+ start_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
165
+
127
166
  delete_cmd = subparsers.add_parser("delete", aliases=["rm"], help="Delete a computer")
128
167
  delete_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
129
168
 
@@ -174,17 +213,25 @@ def main(argv: list[str] | None = None) -> int:
174
213
  if args.command == "verify":
175
214
  return handle_verify(config, args.code, args.flow_id)
176
215
  if args.command == "login":
177
- return handle_login(config, args.api_key)
216
+ return handle_login(config, args.identifier, args.name, args.api_key)
178
217
  if args.command == "whoami":
179
218
  return handle_whoami(config)
180
219
  if args.command == "logout":
181
220
  return handle_logout(config)
182
221
  if args.command == "create":
183
- return handle_create(config, args.name)
222
+ return handle_create(
223
+ config,
224
+ args.name,
225
+ getattr(args, "startup_command", None),
226
+ getattr(args, "cwd", None),
227
+ getattr(args, "port", None),
228
+ )
184
229
  if args.command in ("list", "ls"):
185
230
  return handle_list(config)
186
231
  if args.command == "info":
187
232
  return handle_info(config, args.id)
233
+ if args.command == "start":
234
+ return handle_start(config, args.id)
188
235
  if args.command in ("delete", "rm"):
189
236
  return handle_delete(config, args.id)
190
237
  if args.command == "run":
@@ -242,7 +289,7 @@ def report_success(payload: dict[str, Any], renderer: Callable[[dict[str, Any]],
242
289
  def _require_api_key(config: RuntimeConfig) -> int | None:
243
290
  if config.api_key:
244
291
  return None
245
- return report_error("missing api key; run verify or login first")
292
+ return report_error("missing api key; run login or verify first")
246
293
 
247
294
 
248
295
  def _prompt_text(message: str, *, default: str | None = None) -> str | None:
@@ -308,7 +355,7 @@ def _computer_table_layout(computers: list[dict[str, Any]]) -> dict[str, int]:
308
355
 
309
356
 
310
357
  def _format_computer_row(c: dict[str, Any], widths: dict[str, int]) -> str:
311
- return " ".join(
358
+ return "".join(
312
359
  [
313
360
  _fit_cell(c.get("slug") or c.get("id") or "?", widths["slug"]),
314
361
  _fit_cell(c.get("status") or "?", widths["status"]),
@@ -320,7 +367,7 @@ def _format_computer_row(c: dict[str, Any], widths: dict[str, int]) -> str:
320
367
 
321
368
  def _computer_table_prompt(prompt: str, computers: list[dict[str, Any]]) -> tuple[str, dict[str, int]]:
322
369
  widths = _computer_table_layout(computers)
323
- header = " ".join(
370
+ header = "".join(
324
371
  [
325
372
  _fit_cell("slug", widths["slug"]),
326
373
  _fit_cell("status", widths["status"]),
@@ -328,7 +375,54 @@ def _computer_table_prompt(prompt: str, computers: list[dict[str, Any]]) -> tupl
328
375
  _fit_cell("created", widths["created_at"]),
329
376
  ]
330
377
  )
331
- return f"{prompt}\n{header}", widths
378
+ divider = "" * len(header)
379
+ return f"{prompt}\n{header}\n{divider}", widths
380
+
381
+
382
+ def _render_computer_summary(computers: list[dict[str, Any]]) -> None:
383
+ mods = _UI.rich()
384
+ Panel, Text = mods["Panel"], mods["Text"]
385
+ counts: dict[str, int] = {}
386
+ for computer in computers:
387
+ status = str(computer.get("status") or "unknown")
388
+ counts[status] = counts.get(status, 0) + 1
389
+
390
+ body = Text()
391
+ body.append(f"total {len(computers)}", style="bold white")
392
+ if counts:
393
+ body.append(" ")
394
+ first = True
395
+ status_styles = {
396
+ "warm": "bold green",
397
+ "cold": "bold cyan",
398
+ "running": "bold green",
399
+ "archiving": "bold yellow",
400
+ "restoring": "bold yellow",
401
+ "creating": "bold magenta",
402
+ "orphaned": "bold red",
403
+ }
404
+ for status in sorted(counts):
405
+ if not first:
406
+ body.append(" • ", style="dim")
407
+ first = False
408
+ body.append(status, style=status_styles.get(status, "bold white"))
409
+ body.append(f" {counts[status]}", style="white")
410
+
411
+ _UI.console().print(
412
+ Panel(body, title="runtime", border_style="blue", expand=False, padding=(0, 1))
413
+ )
414
+
415
+
416
+ def _select_prompt(message: str, choices: list[Any]) -> Any:
417
+ return _UI.q().select(
418
+ message,
419
+ choices=choices,
420
+ qmark="●",
421
+ pointer="❯",
422
+ instruction="↑/↓ move • Enter select • j/k also work",
423
+ use_indicator=True,
424
+ style=_UI.style(),
425
+ )
332
426
 
333
427
 
334
428
  def _pick_computer(config: RuntimeConfig, prompt: str) -> dict[str, Any] | None:
@@ -350,11 +444,7 @@ def _pick_computer(config: RuntimeConfig, prompt: str) -> dict[str, Any] | None:
350
444
  questionary.Choice(title=_format_computer_row(c, widths), value=c) for c in computers
351
445
  ]
352
446
  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()
447
+ picked = _select_prompt(message, choices).unsafe_ask()
358
448
  return picked if isinstance(picked, dict) else None
359
449
 
360
450
 
@@ -367,7 +457,7 @@ def _render_computer_panel(c: dict[str, Any], *, title: str = "computer") -> Non
367
457
  mods = _UI.rich()
368
458
  Panel, Text = mods["Panel"], mods["Text"]
369
459
  body = Text()
370
- for key in ("id", "slug", "status", "public_url", "created_at"):
460
+ for key in ("id", "slug", "status", "internal_status", "public_url", "created_at"):
371
461
  if key in c and c[key] is not None:
372
462
  body.append(f"{key:<12}", style="dim")
373
463
  body.append(f"{c[key]}\n")
@@ -413,22 +503,21 @@ def _render_whoami_panel(payload: dict[str, Any]) -> None:
413
503
  # --------------------------------------------------------------------------- #
414
504
 
415
505
 
416
- def handle_signup(config: RuntimeConfig, email: str | None, name: str | None) -> int:
506
+ def _looks_like_api_key(value: str | None) -> bool:
507
+ candidate = (value or "").strip()
508
+ return candidate.startswith("rt_live_")
509
+
510
+
511
+ def _start_verification_flow(config: RuntimeConfig, email: str | None, name: str | None) -> int:
417
512
  if email is None:
418
513
  if not _interactive():
419
514
  return report_error("email is required")
420
515
  email = _prompt_text("Email")
421
516
  if not email:
422
517
  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
518
 
430
519
  client = RuntimeClient(base_url=config.base_url)
431
- result = client.signup(email, name)
520
+ result = client.start_verification(email, name)
432
521
  config.pending_signup = PendingSignup(
433
522
  flow_id=int(result["flow_id"]),
434
523
  email=email,
@@ -447,11 +536,40 @@ def handle_signup(config: RuntimeConfig, email: str | None, name: str | None) ->
447
536
  return report_success(result, render)
448
537
 
449
538
 
539
+ def _login_with_api_key(config: RuntimeConfig, api_key: str | None) -> int:
540
+ if api_key is None or api_key == "":
541
+ if not _interactive():
542
+ return report_error("api key is required")
543
+ api_key = _prompt_password("API key")
544
+ if not api_key:
545
+ return report_error("api key is required")
546
+
547
+ client = RuntimeClient(base_url=config.base_url, api_key=api_key)
548
+ whoami = client.whoami()
549
+ config.api_key = api_key
550
+ config.pending_signup = None
551
+ save_config(config)
552
+
553
+ def render(_: dict[str, Any]) -> None:
554
+ owner = whoami.get("owner") or {}
555
+ email = owner.get("email")
556
+ if email:
557
+ _UI.console().print(f"[green]✓[/green] logged in as [bold]{email}[/bold]")
558
+ return
559
+ _UI.console().print("[green]✓[/green] api key saved")
560
+
561
+ return report_success({"message": "logged in", **whoami}, render)
562
+
563
+
564
+ def handle_signup(config: RuntimeConfig, email: str | None, name: str | None) -> int:
565
+ return _start_verification_flow(config, email, name)
566
+
567
+
450
568
  def handle_verify(config: RuntimeConfig, code: str | None, flow_id: int | None) -> int:
451
569
  pending = config.pending_signup
452
570
  resolved_flow_id = flow_id or (pending.flow_id if pending else None)
453
571
  if not resolved_flow_id:
454
- return report_error("missing flow id; run signup first or pass --flow-id")
572
+ return report_error("missing flow id; run login first or pass --flow-id")
455
573
 
456
574
  if code is None:
457
575
  if not _interactive():
@@ -468,27 +586,32 @@ def handle_verify(config: RuntimeConfig, code: str | None, flow_id: int | None)
468
586
  config.pending_signup = None
469
587
  save_config(config)
470
588
 
471
- def render(_: dict[str, Any]) -> None:
589
+ def render(payload: dict[str, Any]) -> None:
590
+ owner = payload.get("owner") or {}
591
+ email = owner.get("email")
592
+ if email:
593
+ _UI.console().print(f"[green]✓[/green] verified, logged in as [bold]{email}[/bold]")
594
+ return
472
595
  _UI.console().print("[green]✓[/green] verified, api key saved")
473
596
 
474
597
  return report_success(result, render)
475
598
 
476
599
 
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)
600
+ def handle_login(
601
+ config: RuntimeConfig,
602
+ identifier: str | None,
603
+ name: str | None,
604
+ api_key: str | None,
605
+ ) -> int:
606
+ if api_key is not None:
607
+ if identifier is not None:
608
+ return report_error("pass either an email/api key argument or --api-key, not both")
609
+ return _login_with_api_key(config, api_key)
487
610
 
488
- def render(_: dict[str, Any]) -> None:
489
- _UI.console().print("[green]✓[/green] api key saved")
611
+ if _looks_like_api_key(identifier):
612
+ return _login_with_api_key(config, identifier)
490
613
 
491
- return report_success({"message": "api key saved"}, render)
614
+ return _start_verification_flow(config, identifier, name)
492
615
 
493
616
 
494
617
  def handle_whoami(config: RuntimeConfig) -> int:
@@ -500,26 +623,54 @@ def handle_whoami(config: RuntimeConfig) -> int:
500
623
 
501
624
 
502
625
  def handle_logout(config: RuntimeConfig) -> int:
626
+ had_api_key = config.api_key is not None
627
+ revoked = False
628
+ if config.api_key:
629
+ try:
630
+ RuntimeClient(base_url=config.base_url, api_key=config.api_key).logout()
631
+ revoked = True
632
+ except RuntimeAPIError:
633
+ revoked = False
634
+
503
635
  config.api_key = None
504
636
  config.pending_signup = None
505
637
  save_config(config)
506
638
 
639
+ message = "logged out"
640
+ if had_api_key and not revoked:
641
+ message = "logged out locally"
642
+
507
643
  def render(_: dict[str, Any]) -> None:
508
- _UI.console().print("[green]✓[/green] logged out")
644
+ _UI.console().print(f"[green]✓[/green] {message}")
509
645
 
510
- return report_success({"message": "logged out"}, render)
646
+ return report_success({"message": message}, render)
511
647
 
512
648
 
513
- def handle_create(config: RuntimeConfig, name: str | None) -> int:
649
+ def handle_create(
650
+ config: RuntimeConfig,
651
+ name: str | None,
652
+ startup_command: str | None,
653
+ cwd: str | None,
654
+ port: int | None,
655
+ ) -> int:
514
656
  if (err := _require_api_key(config)) is not None:
515
657
  return err
516
658
 
517
659
  if name is None and _interactive():
518
660
  name = _prompt_text("Name your computer")
519
661
 
662
+ if startup_command is None and port is not None:
663
+ return report_error("startup command is required when port is provided")
664
+ if startup_command is not None and port is None:
665
+ return report_error("port is required when startup command is provided")
666
+
520
667
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
668
+ label = "creating computer…"
669
+ if startup_command is not None:
670
+ label = f"creating computer and starting app on port {port}…"
521
671
  result = _with_spinner(
522
- "creating computer…", lambda: client.create_computer(slug=name)
672
+ label,
673
+ lambda: client.create_computer(slug=name, command=startup_command, cwd=cwd, port=port),
523
674
  )
524
675
 
525
676
  def render(payload: dict[str, Any]) -> None:
@@ -561,6 +712,33 @@ def handle_info(config: RuntimeConfig, computer_id: str | None) -> int:
561
712
  return report_success(result, lambda p: _render_computer_panel(p, title="computer"))
562
713
 
563
714
 
715
+ def handle_start(config: RuntimeConfig, computer_id: str | None) -> int:
716
+ if (err := _require_api_key(config)) is not None:
717
+ return err
718
+
719
+ if computer_id is None:
720
+ if not _interactive():
721
+ return report_error("computer id is required")
722
+ picked = _pick_computer(config, "Pick a cold computer to wake")
723
+ if picked is None:
724
+ return 0
725
+ computer_id = picked.get("id") or picked.get("slug")
726
+ if not computer_id:
727
+ return report_error("computer is missing an id")
728
+
729
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
730
+ result = _with_spinner(f"warming {computer_id}…", lambda: client.start_computer(computer_id))
731
+
732
+ def render(payload: dict[str, Any]) -> None:
733
+ _render_computer_panel(payload, title="warmed")
734
+ url = payload.get("public_url")
735
+ if url:
736
+ _UI.console().print(f"[bold green]→[/bold green] [cyan]{url}[/cyan]")
737
+
738
+ return report_success(result, render)
739
+
740
+
741
+
564
742
  def handle_delete(config: RuntimeConfig, computer_id: str | None) -> int:
565
743
  if (err := _require_api_key(config)) is not None:
566
744
  return err
@@ -701,6 +879,7 @@ def _interactive_list(config: RuntimeConfig) -> int:
701
879
  _UI.console().print("[dim]no computers yet. create one with `runtime create`.[/dim]")
702
880
  return 0
703
881
 
882
+ _render_computer_summary(computers)
704
883
  message, widths = _computer_table_prompt("Pick a computer", computers)
705
884
  choices = [
706
885
  questionary.Choice(title=_format_computer_row(c, widths), value=c)
@@ -710,11 +889,7 @@ def _interactive_list(config: RuntimeConfig) -> int:
710
889
  choices.append(questionary.Choice(title="↻ refresh", value="__refresh__"))
711
890
  choices.append(questionary.Choice(title="✕ quit", value="__quit__"))
712
891
 
713
- picked = questionary.select(
714
- message,
715
- choices=choices,
716
- instruction="↑/↓ to move, Enter to select",
717
- ).unsafe_ask()
892
+ picked = _select_prompt(message, choices).unsafe_ask()
718
893
 
719
894
  if picked == "__quit__":
720
895
  return 0
@@ -738,10 +913,11 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
738
913
  while True:
739
914
  _render_computer_panel(vm, title=f"computer: {slug}")
740
915
 
741
- action = questionary.select(
742
- f"What would you like to do with {slug}?",
743
- choices=[
744
- questionary.Choice(title="▶ run a command", value="run"),
916
+ choices = [questionary.Choice(title="▶ run a command", value="run")]
917
+ if vm.get("status") == "cold":
918
+ choices.append(questionary.Choice(title="☀ start / wake", value="start"))
919
+ choices.extend(
920
+ [
745
921
  questionary.Choice(title="⤴ publish a port", value="publish"),
746
922
  questionary.Choice(title="ℹ refresh info", value="info"),
747
923
  questionary.Choice(title="⧉ copy public url", value="url"),
@@ -749,7 +925,12 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
749
925
  questionary.Separator(),
750
926
  questionary.Choice(title="← back", value="__back__"),
751
927
  questionary.Choice(title="✕ quit", value="__quit__"),
752
- ],
928
+ ]
929
+ )
930
+
931
+ action = _select_prompt(
932
+ f"What would you like to do with {slug}?",
933
+ choices,
753
934
  ).unsafe_ask()
754
935
 
755
936
  if action == "__back__":
@@ -767,6 +948,10 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
767
948
  _render_run_result(result)
768
949
  continue
769
950
 
951
+ if action == "start":
952
+ vm = _with_spinner(f"warming {slug}…", lambda: client.start_computer(vm_id))
953
+ continue
954
+
770
955
  if action == "publish":
771
956
  port_text = _prompt_text("Port to publish", default="3000")
772
957
  if not port_text:
@@ -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]:
@@ -45,6 +70,9 @@ class RuntimeClient:
45
70
  def get_computer(self, computer_id: str) -> dict[str, Any]:
46
71
  return self._request("GET", f"/api/computers/{computer_id}", auth_required=True)
47
72
 
73
+ def start_computer(self, computer_id: str) -> dict[str, Any]:
74
+ return self._request("POST", f"/api/computers/{computer_id}/start", auth_required=True)
75
+
48
76
  def delete_computer(self, computer_id: str) -> dict[str, Any]:
49
77
  return self._request("DELETE", f"/api/computers/{computer_id}", auth_required=True)
50
78
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.2.0
3
+ Version: 0.3.0
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,16 +46,18 @@ 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
57
+ runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
57
58
  runtime list
58
59
  runtime info <id>
60
+ runtime start <id>
59
61
  runtime run <id> "echo hello"
60
62
  runtime run <id> "apt install -y nodejs" --uid 0
61
63
  runtime publish <id> 3000
@@ -73,10 +75,22 @@ client = RuntimeClient(base_url="https://api.runruntime.dev", api_key="rt_live_.
73
75
  computer = client.create_computer()
74
76
  print(computer["public_url"]) # https://goldbird.runruntime.dev
75
77
 
78
+ # Or create one with a durable startup command.
79
+ # That startup config is replayed after cold restore / auto-wake.
80
+ app = client.create_computer(
81
+ slug="myapp",
82
+ command="python3 app.py",
83
+ cwd="/home/ubuntu",
84
+ port=3000,
85
+ )
86
+
76
87
  # Run a command
77
88
  result = client.run_command(computer["id"], "echo hello")
78
89
  print(result["stdout"])
79
90
 
91
+ # Wake a cold computer explicitly
92
+ client.start_computer(app["id"])
93
+
80
94
  # Pin the public URL to a local app port
81
95
  client.publish_port(computer["id"], 3000)
82
96
 
@@ -104,6 +118,11 @@ make smoke
104
118
  Use `make deploy` instead of `make sync` when migrations, env
105
119
  files, Caddy, or systemd units changed.
106
120
 
121
+ Cold restore and public auto-wake replay the saved startup command only for
122
+ computers created with `--command` + `--port` (or the SDK `command`/`port`
123
+ arguments). If you start an app later via a one-off `runtime run`, the
124
+ filesystem is restored after going cold, but that ad-hoc process is not.
125
+
107
126
  Run the SDK unit tests through the backend project environment:
108
127
 
109
128
  ```bash
File without changes