runtime-sdk 0.4.20__tar.gz → 0.4.22__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.4.20
3
+ Version: 0.4.22
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
@@ -78,6 +78,8 @@ runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
78
78
  runtime enter <name-or-id> # accepts slug/name like test, or the computer id
79
79
  runtime list
80
80
  runtime info <id>
81
+ runtime share public <id>
82
+ runtime share private <id>
81
83
  runtime start <id>
82
84
  runtime run <id> "echo hello" # one-shot foreground command only
83
85
  runtime run <id> "apt install -y nodejs" --uid 0
@@ -56,6 +56,8 @@ runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
56
56
  runtime enter <name-or-id> # accepts slug/name like test, or the computer id
57
57
  runtime list
58
58
  runtime info <id>
59
+ runtime share public <id>
60
+ runtime share private <id>
59
61
  runtime start <id>
60
62
  runtime run <id> "echo hello" # one-shot foreground command only
61
63
  runtime run <id> "apt install -y nodejs" --uid 0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runtime-sdk"
3
- version = "0.4.20"
3
+ version = "0.4.22"
4
4
  description = "Runtime Python SDK and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import contextlib
5
+ import difflib
5
6
  import importlib
6
7
  import json
7
8
  import os
@@ -132,7 +133,10 @@ class _UI:
132
133
 
133
134
 
134
135
  def build_parser() -> argparse.ArgumentParser:
135
- parser = argparse.ArgumentParser(prog="runtime")
136
+ parser = argparse.ArgumentParser(
137
+ prog="runtime",
138
+ description="Manage RuntimeVM computers — run code, publish apps, share URLs.",
139
+ )
136
140
  parser.add_argument("--base-url", help="Override the Runtime API base URL")
137
141
  parser.add_argument("--version", action="version", version=f"runtime-sdk {__version__}")
138
142
 
@@ -217,7 +221,17 @@ def build_parser() -> argparse.ArgumentParser:
217
221
  create_cmd.add_argument("--command", dest="startup_command", help="Durable startup command to save on create")
218
222
  create_cmd.add_argument("--cwd", help="Working directory for the durable startup command")
219
223
  create_cmd.add_argument("--port", type=int, help="App port to publish for durable startup")
220
- subparsers.add_parser("list", aliases=["ls"], help="List computers")
224
+ list_cmd = subparsers.add_parser("list", aliases=["ls"], help="List computers")
225
+ list_cmd.add_argument(
226
+ "-w",
227
+ "--watch",
228
+ nargs="?",
229
+ const=2,
230
+ type=int,
231
+ default=None,
232
+ metavar="SECONDS",
233
+ help="Re-render the table every N seconds (default 2)",
234
+ )
221
235
 
222
236
  enter_cmd = subparsers.add_parser("enter", help="Enter a computer's console")
223
237
  enter_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
@@ -228,11 +242,24 @@ def build_parser() -> argparse.ArgumentParser:
228
242
  url_cmd = subparsers.add_parser("url", help="Show a computer's public URL and live published port")
229
243
  url_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
230
244
 
245
+ share_cmd = subparsers.add_parser("share", help="Set whether a computer is public or private")
246
+ share_subparsers = share_cmd.add_subparsers(dest="share_action", required=True)
247
+ share_public = share_subparsers.add_parser("public", help="Make a computer publicly accessible")
248
+ share_public.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
249
+ share_private = share_subparsers.add_parser("private", help="Require owner auth before browser access")
250
+ share_private.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
251
+
231
252
  start_cmd = subparsers.add_parser("start", help="Wake a cold computer")
232
253
  start_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
233
254
 
234
255
  delete_cmd = subparsers.add_parser("delete", aliases=["rm"], help="Delete a computer")
235
256
  delete_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
257
+ delete_cmd.add_argument(
258
+ "--force",
259
+ "-y",
260
+ action="store_true",
261
+ help="Skip the typed-name confirmation prompt",
262
+ )
236
263
 
237
264
  run_cmd = subparsers.add_parser("run", help="Run a one-shot command in a computer")
238
265
  run_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
@@ -290,9 +317,129 @@ def build_parser() -> argparse.ArgumentParser:
290
317
  proxy_agent.add_argument("--local-port", type=int, required=True)
291
318
  proxy_agent.add_argument("--remote-port", type=int, required=True)
292
319
 
320
+ help_cmd = subparsers.add_parser("help", help="Show extended help for a topic")
321
+ help_cmd.add_argument("topic", nargs="?", default=None, help="Topic to show (computer, share, account, all)")
322
+
323
+ completion_cmd = subparsers.add_parser(
324
+ "completion", help="Print shell completion script (bash, zsh, fish)"
325
+ )
326
+ completion_cmd.add_argument("shell", choices=["bash", "zsh", "fish"], help="Target shell")
327
+
328
+ parser.format_help = lambda: _format_top_help() # type: ignore[assignment]
293
329
  return parser
294
330
 
295
331
 
332
+ _HELP_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
333
+ (
334
+ "Computers",
335
+ (
336
+ ("create [name]", "Create a new computer (starter app published by default)"),
337
+ ("list, ls", "List computers (-w watches, refreshes every 2s)"),
338
+ ("info [id]", "Show computer details"),
339
+ ("url [id]", "Show a computer's public URL and live published port"),
340
+ ("start [id]", "Wake a cold computer"),
341
+ ("enter [id]", "Enter a computer's console (TTY)"),
342
+ ("run [id] <cmd>", "Run a one-shot command"),
343
+ ("publish [id] [port]", "Promote a running app on a port to the durable public URL"),
344
+ ("delete, rm [id]", "Delete a computer (typed-name confirm; --force / -y to skip)"),
345
+ ),
346
+ ),
347
+ (
348
+ "Sharing",
349
+ (
350
+ ("share public [id]", "Make a computer publicly accessible"),
351
+ ("share private [id]", "Require owner auth before browser access"),
352
+ ),
353
+ ),
354
+ (
355
+ "Startup & service",
356
+ (
357
+ ("startup show [id]", "Show durable startup config"),
358
+ ("startup set [id] --command … --port …", "Save durable startup command"),
359
+ ("startup clear [id]", "Clear durable startup config"),
360
+ ("service show [id]", "Show the durable published app service"),
361
+ ("service clear [id]", "Clear the durable published app service"),
362
+ ),
363
+ ),
364
+ (
365
+ "Proxy",
366
+ (
367
+ ("proxy start <id> <ports…>", "Forward local → remote ports (e.g. 5432 or 15432:5432)"),
368
+ ("proxy ls", "List running proxies"),
369
+ ("proxy stop [ports…] [--all]", "Stop proxies (by local port or all)"),
370
+ ),
371
+ ),
372
+ (
373
+ "GitHub",
374
+ (
375
+ ("switch, checkout [branch]", "Open a GitHub branch workspace in its own computer"),
376
+ ("integrations list", "List account-level integrations"),
377
+ ("integrations connect github", "Install the RuntimeVM GitHub App"),
378
+ ),
379
+ ),
380
+ (
381
+ "Account",
382
+ (
383
+ ("signup [email]", "Send an email verification code"),
384
+ ("verify [code]", "Verify an emailed code and save a fresh API key"),
385
+ ("login [email|api-key]", "Email login or import an API key"),
386
+ ("whoami", "Show current account"),
387
+ ("logout", "Revoke API key and remove local config"),
388
+ ("api-keys list/create/revoke", "Manage Runtime API keys"),
389
+ ("secrets list/show/set/import/rm", "Manage account-level secret sets"),
390
+ ),
391
+ ),
392
+ (
393
+ "Shell",
394
+ (
395
+ ("help [topic]", "Show extended help — topics: computer, share, account, all"),
396
+ ("completion <bash|zsh|fish>", "Print shell completion script"),
397
+ ),
398
+ ),
399
+ )
400
+
401
+
402
+ _HELP_EXAMPLES: tuple[str, ...] = (
403
+ "runtime # interactive menu (create if piped)",
404
+ "runtime ls -w # live-updating list",
405
+ "runtime create mybox --command 'npm start' --cwd /app --port 3000",
406
+ "runtime share private mybox # require owner auth",
407
+ "runtime proxy start mybox 5432 # forward localhost:5432 → mybox:5432",
408
+ "runtime completion zsh > ~/.zsh/completions/_runtime",
409
+ )
410
+
411
+
412
+ def _format_top_help() -> str:
413
+ lines: list[str] = []
414
+ lines.append("runtime — manage RuntimeVM computers")
415
+ lines.append("")
416
+ lines.append("usage: runtime [--base-url URL] [--version] [-h] <command> [args…]")
417
+ lines.append("")
418
+ lines.append("Global options:")
419
+ lines.append(" --base-url URL Override the Runtime API base URL")
420
+ lines.append(" --version Show the CLI version")
421
+ lines.append(" -h, --help Show this help")
422
+ lines.append("")
423
+ left_width = 0
424
+ for _, rows in _HELP_SECTIONS:
425
+ for name, _ in rows:
426
+ left_width = max(left_width, len(name))
427
+ left_width = min(left_width, 38)
428
+ for title, rows in _HELP_SECTIONS:
429
+ lines.append(f"{title}:")
430
+ for name, desc in rows:
431
+ padded = name if len(name) >= left_width else name + " " * (left_width - len(name))
432
+ lines.append(f" {padded} {desc}")
433
+ lines.append("")
434
+ lines.append("Examples:")
435
+ for example in _HELP_EXAMPLES:
436
+ lines.append(f" {example}")
437
+ lines.append("")
438
+ lines.append("Run `runtime <command> --help` for flags and examples on one command.")
439
+ lines.append("Run `runtime help <topic>` for long-form docs.")
440
+ return "\n".join(lines) + "\n"
441
+
442
+
296
443
  def _windows_unsupported() -> bool:
297
444
  return sys.platform == "win32"
298
445
 
@@ -314,10 +461,15 @@ def main(argv: list[str] | None = None) -> int:
314
461
  else:
315
462
  config.base_url = config.configured_base_url or DEFAULT_BASE_URL
316
463
 
317
- # Bare `runtime` → first-run onboarding for interactive users, create otherwise.
464
+ # Bare `runtime` →
465
+ # TTY + no api key → onboarding
466
+ # TTY + api key → interactive menu (list + actions)
467
+ # non-TTY → create (preserves script-friendly default)
318
468
  if args.command is None:
319
- if _interactive() and not config.api_key:
320
- return handle_onboarding(config)
469
+ if _interactive():
470
+ if not config.api_key:
471
+ return handle_onboarding(config)
472
+ return handle_list(config)
321
473
  args.command = "create"
322
474
  args.name = None
323
475
 
@@ -374,17 +526,19 @@ def main(argv: list[str] | None = None) -> int:
374
526
  getattr(args, "port", None),
375
527
  )
376
528
  if args.command in ("list", "ls"):
377
- return handle_list(config)
529
+ return handle_list(config, watch=getattr(args, "watch", None))
378
530
  if args.command == "info":
379
531
  return handle_info(config, args.id)
380
532
  if args.command == "url":
381
533
  return handle_url(config, args.id)
534
+ if args.command == "share":
535
+ return handle_share(config, args.share_action, args.id)
382
536
  if args.command == "start":
383
537
  return handle_start(config, args.id)
384
538
  if args.command == "enter":
385
539
  return handle_enter(config, args.id)
386
540
  if args.command in ("delete", "rm"):
387
- return handle_delete(config, args.id)
541
+ return handle_delete(config, args.id, force=getattr(args, "force", False))
388
542
  if args.command == "run":
389
543
  return handle_run(
390
544
  config, args.id, args.run_command, args.uid, args.gid, args.cwd, args.shell
@@ -413,6 +567,10 @@ def main(argv: list[str] | None = None) -> int:
413
567
  if args.proxy_command == "stop":
414
568
  return handle_proxy_stop(config, args.local_ports, stop_all=args.all)
415
569
  return report_error("unknown proxy command")
570
+ if args.command == "help":
571
+ return handle_help(args.topic)
572
+ if args.command == "completion":
573
+ return handle_completion(args.shell)
416
574
  if args.command == "_proxy_agent":
417
575
  return run_proxy_agent(
418
576
  args.base_url,
@@ -687,6 +845,45 @@ def _prompt_confirm(message: str, *, default: bool = False) -> bool:
687
845
  return bool(_UI.q().confirm(message, default=default).unsafe_ask())
688
846
 
689
847
 
848
+ def _copy_to_clipboard(text: str) -> bool:
849
+ """Copy text to the OS clipboard. Returns True on success."""
850
+ text = str(text or "")
851
+ if not text:
852
+ return False
853
+ candidates = [
854
+ ["pbcopy"],
855
+ ["wl-copy"],
856
+ ["xclip", "-selection", "clipboard"],
857
+ ["xsel", "--clipboard", "--input"],
858
+ ]
859
+ if sys.platform == "win32":
860
+ candidates.insert(0, ["clip"])
861
+ for cmd in candidates:
862
+ if not shutil.which(cmd[0]):
863
+ continue
864
+ try:
865
+ subprocess.run(cmd, input=text.encode(), check=True)
866
+ return True
867
+ except (OSError, subprocess.SubprocessError):
868
+ continue
869
+ return False
870
+
871
+
872
+ def _close_matches(ref: str, candidates: list[str], limit: int = 3) -> list[str]:
873
+ """Return up to `limit` candidates that look similar to ref."""
874
+ ref = (ref or "").strip()
875
+ if not ref:
876
+ return []
877
+ pool = [c for c in candidates if c]
878
+ return difflib.get_close_matches(ref, pool, n=limit, cutoff=0.4)
879
+
880
+
881
+ def _prompt_typed_confirm(message: str, expected: str) -> bool:
882
+ """Ask the user to retype the expected token (case-insensitive). Empty = cancel."""
883
+ typed = _prompt_text(f"{message} (type {expected!r} to confirm)") or ""
884
+ return typed.strip().lower() == expected.strip().lower()
885
+
886
+
690
887
  def _display_width(value: Any, minimum: int, maximum: int) -> int:
691
888
  text = str(value or "")
692
889
  return max(minimum, min(len(text), maximum))
@@ -782,6 +979,12 @@ def _resolve_computer_ref(client: RuntimeClient, ref: str) -> str:
782
979
  return resolved or ref
783
980
  if len(matches) > 1:
784
981
  raise RuntimeAPIError(f"multiple computers matched {ref!r}; use the exact computer id")
982
+
983
+ slugs = [str(c.get("slug") or "").strip() for c in computers if isinstance(c, dict)]
984
+ suggestions = _close_matches(ref, [s for s in slugs if s])
985
+ if suggestions:
986
+ hint = ", ".join(repr(s) for s in suggestions)
987
+ raise RuntimeAPIError(f"computer {ref!r} not found. Did you mean {hint}?")
785
988
  raise RuntimeAPIError(f"computer {ref!r} not found")
786
989
 
787
990
 
@@ -1052,14 +1255,16 @@ def _render_computer_table(computers: list[dict[str, Any]], *, title: str | None
1052
1255
  _UI.console().print(table)
1053
1256
 
1054
1257
 
1055
- def _select_prompt(message: str, choices: list[Any]) -> Any:
1258
+ def _select_prompt(message: str, choices: list[Any], *, instruction: str | None = None) -> Any:
1056
1259
  return _UI.q().select(
1057
1260
  message,
1058
1261
  choices=choices,
1059
1262
  qmark="●",
1060
1263
  pointer="❯",
1061
- instruction="↑/↓ or j/k Enter",
1264
+ instruction=instruction or "↑↓ navigate · / search · enter select · esc cancel",
1062
1265
  use_indicator=True,
1266
+ use_jk_keys=True,
1267
+ use_search_filter=True,
1063
1268
  style=_UI.style(),
1064
1269
  )
1065
1270
 
@@ -1111,6 +1316,7 @@ def _render_computer_panel(c: dict[str, Any], *, title: str = "computer") -> Non
1111
1316
  rows: list[tuple[str, str, str | None]] = [
1112
1317
  ("name", _computer_name(c), "bold white"),
1113
1318
  ("state", f"{_computer_status_dot(status)} {status}", status_style),
1319
+ ("share", str(c.get("visibility") or "public"), "bold white"),
1114
1320
  ("url", _computer_url_label(c) or "—", "white"),
1115
1321
  ("age", _computer_created_label(c) or "—", "dim"),
1116
1322
  ("id", str(c.get("id") or "—"), "dim"),
@@ -1147,6 +1353,7 @@ def _render_network_panel(payload: dict[str, Any], *, title: str = "url") -> Non
1147
1353
  rows: list[tuple[str, str, str | None]] = [
1148
1354
  ("url", str(payload.get("public_url") or "—"), "cyan"),
1149
1355
  ("state", f"{_computer_status_dot(status)} {status}", _computer_status_style(status)),
1356
+ ("share", str(payload.get("visibility") or "public"), "bold white"),
1150
1357
  ]
1151
1358
  if published_port:
1152
1359
  rows.append(("port", str(published_port), "bold white"))
@@ -1906,10 +2113,16 @@ def handle_create(
1906
2113
  return _enter_computer(config, str(computer_id))
1907
2114
 
1908
2115
 
1909
- def handle_list(config: RuntimeConfig) -> int:
2116
+ def handle_list(config: RuntimeConfig, *, watch: int | None = None) -> int:
1910
2117
  if (err := _require_api_key(config)) is not None:
1911
2118
  return err
1912
2119
 
2120
+ if watch is not None:
2121
+ if not _interactive():
2122
+ return report_error("--watch requires an interactive terminal")
2123
+ interval = max(1, int(watch))
2124
+ return _watch_list(config, interval)
2125
+
1913
2126
  if _interactive():
1914
2127
  return _interactive_list(config)
1915
2128
 
@@ -1917,6 +2130,59 @@ def handle_list(config: RuntimeConfig) -> int:
1917
2130
  return report_success(client.list_computers())
1918
2131
 
1919
2132
 
2133
+ def _watch_list(config: RuntimeConfig, interval: int) -> int:
2134
+ from rich.live import Live
2135
+
2136
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2137
+ console = _UI.console()
2138
+ console.print(f"[dim]watching every {interval}s · press Ctrl+C to exit[/dim]")
2139
+
2140
+ def render_table() -> Any:
2141
+ mods = _UI.rich()
2142
+ Table, box = mods["Table"], mods["box"]
2143
+ table = Table(
2144
+ title=f"computers · refresh {interval}s · {datetime.now().strftime('%H:%M:%S')}",
2145
+ box=box.MINIMAL_DOUBLE_HEAD,
2146
+ expand=True,
2147
+ header_style="bold #7dd3fc",
2148
+ title_style="bold white",
2149
+ row_styles=["", "dim"],
2150
+ )
2151
+ table.add_column("Name", style="bold white", no_wrap=True, ratio=2)
2152
+ table.add_column("State", no_wrap=True, ratio=2)
2153
+ table.add_column("URL", overflow="fold", ratio=4)
2154
+ table.add_column("Age", style="dim", justify="right", ratio=1)
2155
+ try:
2156
+ payload = client.list_computers()
2157
+ except RuntimeAPIError as exc:
2158
+ table.add_row(f"[red]error: {exc}[/red]", "", "", "")
2159
+ return table
2160
+ computers = payload.get("computers") or []
2161
+ if not isinstance(computers, list):
2162
+ computers = []
2163
+ for computer in sorted(computers, key=_computer_sort_key):
2164
+ status = _computer_status_label(computer)
2165
+ style = _computer_status_style(status)
2166
+ table.add_row(
2167
+ _computer_name(computer),
2168
+ f"[{style}]{_computer_status_dot(status)} {status}[/{style}]",
2169
+ _computer_url_label(computer),
2170
+ _computer_created_label(computer),
2171
+ )
2172
+ if not computers:
2173
+ table.add_row("[dim]no computers yet[/dim]", "", "", "")
2174
+ return table
2175
+
2176
+ try:
2177
+ with Live(render_table(), console=console, refresh_per_second=4, screen=False) as live:
2178
+ while True:
2179
+ time.sleep(interval)
2180
+ live.update(render_table())
2181
+ except KeyboardInterrupt:
2182
+ console.print("[dim]watch stopped[/dim]")
2183
+ return 0
2184
+
2185
+
1920
2186
  def handle_info(config: RuntimeConfig, computer_id: str | None) -> int:
1921
2187
  if (err := _require_api_key(config)) is not None:
1922
2188
  return err
@@ -1957,6 +2223,36 @@ def handle_url(config: RuntimeConfig, computer_id: str | None) -> int:
1957
2223
  return report_success(result, lambda p: _render_network_panel(p, title="url"))
1958
2224
 
1959
2225
 
2226
+ def handle_share(config: RuntimeConfig, visibility: str | None, computer_id: str | None) -> int:
2227
+ if (err := _require_api_key(config)) is not None:
2228
+ return err
2229
+ if visibility not in ("public", "private"):
2230
+ return report_error("share target must be public or private")
2231
+
2232
+ if computer_id is None:
2233
+ if not _interactive():
2234
+ return report_error("computer id is required")
2235
+ picked = _pick_computer(config, "Pick a computer")
2236
+ if picked is None:
2237
+ return 0
2238
+ computer_id = picked.get("id") or picked.get("slug")
2239
+ if not computer_id:
2240
+ return report_error("computer is missing an id")
2241
+
2242
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2243
+ computer_id = _resolve_computer_ref(client, computer_id)
2244
+ result = _with_spinner(
2245
+ f"setting {computer_id} to {visibility}…",
2246
+ lambda: client.set_visibility(computer_id, visibility),
2247
+ )
2248
+
2249
+ def render(payload: dict[str, Any]) -> None:
2250
+ _render_computer_panel(payload, title="share")
2251
+ _UI.console().print(f"[green]✓[/green] [bold]{computer_id}[/bold] is now [bold]{visibility}[/bold]")
2252
+
2253
+ return report_success(result, render)
2254
+
2255
+
1960
2256
  def handle_startup_show(config: RuntimeConfig, computer_id: str | None) -> int:
1961
2257
  if (err := _require_api_key(config)) is not None:
1962
2258
  return err
@@ -2116,11 +2412,10 @@ def handle_enter(config: RuntimeConfig, computer_id: str | None) -> int:
2116
2412
  return _enter_computer(config, str(computer_id))
2117
2413
 
2118
2414
 
2119
- def handle_delete(config: RuntimeConfig, computer_id: str | None) -> int:
2415
+ def handle_delete(config: RuntimeConfig, computer_id: str | None, *, force: bool = False) -> int:
2120
2416
  if (err := _require_api_key(config)) is not None:
2121
2417
  return err
2122
2418
 
2123
- interactive_pick = False
2124
2419
  if computer_id is None:
2125
2420
  if not _interactive():
2126
2421
  return report_error("computer id is required")
@@ -2130,11 +2425,13 @@ def handle_delete(config: RuntimeConfig, computer_id: str | None) -> int:
2130
2425
  computer_id = picked.get("id") or picked.get("slug")
2131
2426
  if not computer_id:
2132
2427
  return report_error("computer is missing an id")
2133
- interactive_pick = True
2134
2428
 
2135
- if interactive_pick:
2136
- if not _prompt_confirm(
2137
- f"Delete {computer_id}? This cannot be undone.", default=False
2429
+ # In TTY mode, require the user to retype the slug as confirmation
2430
+ # unless --force was passed. Non-TTY callers stay script-friendly.
2431
+ if _interactive() and not force:
2432
+ if not _prompt_typed_confirm(
2433
+ f"Delete {computer_id}? This permanently destroys filesystem, service, and public URL.",
2434
+ str(computer_id),
2138
2435
  ):
2139
2436
  _UI.console().print("[dim]cancelled[/dim]")
2140
2437
  return 0
@@ -2684,18 +2981,19 @@ def _interactive_list(config: RuntimeConfig) -> int:
2684
2981
  computers = []
2685
2982
 
2686
2983
  if not computers:
2687
- _UI.console().print("[dim]no computers yet. create one with `runtime create`.[/dim]")
2688
- return 0
2984
+ _UI.console().print("[dim]no computers yet. creating your first one…[/dim]")
2985
+ return handle_create(config, None, None, None, None)
2689
2986
 
2690
2987
  computers = sorted(computers, key=_computer_sort_key)
2691
2988
  _render_computer_summary(computers)
2692
2989
  widths = _computer_table_layout(computers)
2693
2990
  _render_computer_table(computers, title="computers")
2694
- choices = [
2991
+ choices: list[Any] = [
2695
2992
  questionary.Choice(title=_computer_choice_title(c, widths), value=c)
2696
2993
  for c in computers
2697
2994
  ]
2698
2995
  choices.append(questionary.Separator())
2996
+ choices.append(questionary.Choice(title="+ new computer", value="__new__"))
2699
2997
  choices.append(questionary.Choice(title="↻ refresh", value="__refresh__"))
2700
2998
  choices.append(questionary.Choice(title="✕ quit", value="__quit__"))
2701
2999
 
@@ -2705,6 +3003,9 @@ def _interactive_list(config: RuntimeConfig) -> int:
2705
3003
  return 0
2706
3004
  if picked == "__refresh__":
2707
3005
  continue
3006
+ if picked == "__new__":
3007
+ handle_create(config, None, None, None, None)
3008
+ continue
2708
3009
  if not isinstance(picked, dict):
2709
3010
  continue
2710
3011
 
@@ -2725,7 +3026,10 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
2725
3026
 
2726
3027
  power_state = _computer_power_state(vm)
2727
3028
  internal_status = _computer_internal_status(vm)
2728
- choices = []
3029
+ visibility = str(vm.get("visibility") or "public")
3030
+ flip_visibility = "private" if visibility == "public" else "public"
3031
+
3032
+ choices: list[Any] = []
2729
3033
  if internal_status in ("running", "cold"):
2730
3034
  choices.append(questionary.Choice(title="⌨ enter console", value="enter"))
2731
3035
  if internal_status == "running":
@@ -2735,10 +3039,13 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
2735
3039
  choices.append(questionary.Choice(title="☀ start / wake", value="start"))
2736
3040
  choices.extend(
2737
3041
  [
3042
+ questionary.Choice(title="⧉ copy public url", value="copy-url"),
3043
+ questionary.Choice(title="↗ open in browser", value="open-url"),
3044
+ questionary.Choice(title=f"⚑ make {flip_visibility}", value=f"share:{flip_visibility}"),
3045
+ questionary.Choice(title="⚙ startup config…", value="startup"),
2738
3046
  questionary.Choice(title="ℹ refresh info", value="info"),
2739
- questionary.Choice(title="⧉ copy public url", value="url"),
2740
- questionary.Choice(title="✕ delete", value="delete"),
2741
3047
  questionary.Separator(),
3048
+ questionary.Choice(title="✕ delete", value="delete"),
2742
3049
  questionary.Choice(title="← back", value="__back__"),
2743
3050
  questionary.Choice(title="✕ quit", value="__quit__"),
2744
3051
  ]
@@ -2798,17 +3105,47 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
2798
3105
  vm = _with_spinner("fetching…", lambda: client.get_computer(vm_id))
2799
3106
  continue
2800
3107
 
2801
- if action == "url":
2802
- url = vm.get("public_url") or ""
3108
+ if action == "copy-url":
3109
+ url = str(vm.get("public_url") or "").strip()
2803
3110
  if not url:
2804
3111
  _UI.console().print("[yellow]no public url on this computer[/yellow]")
2805
3112
  continue
2806
- _UI.console().print(f"[cyan]{url}[/cyan]")
3113
+ if _copy_to_clipboard(url):
3114
+ _UI.console().print(f"[green]✓[/green] copied [cyan]{url}[/cyan]")
3115
+ else:
3116
+ _UI.console().print(f"[yellow]no clipboard tool found[/yellow] [cyan]{url}[/cyan]")
3117
+ continue
3118
+
3119
+ if action == "open-url":
3120
+ url = str(vm.get("public_url") or "").strip()
3121
+ if not url:
3122
+ _UI.console().print("[yellow]no public url on this computer[/yellow]")
3123
+ continue
3124
+ with contextlib.suppress(Exception):
3125
+ webbrowser.open(url)
3126
+ _UI.console().print(f"[green]→[/green] opened [cyan]{url}[/cyan]")
3127
+ continue
3128
+
3129
+ if action.startswith("share:"):
3130
+ target = action.split(":", 1)[1]
3131
+ vm = _with_spinner(
3132
+ f"setting {slug} to {target}…",
3133
+ lambda: client.set_visibility(str(vm_id), target),
3134
+ )
3135
+ _UI.console().print(
3136
+ f"[green]✓[/green] [bold]{slug}[/bold] is now [bold]{target}[/bold]"
3137
+ )
3138
+ continue
3139
+
3140
+ if action == "startup":
3141
+ _vm_startup_submenu(client, vm_id, slug)
3142
+ vm = _with_spinner("fetching…", lambda: client.get_computer(vm_id))
2807
3143
  continue
2808
3144
 
2809
3145
  if action == "delete":
2810
- if not _prompt_confirm(
2811
- f"Delete {slug}? This cannot be undone.", default=False
3146
+ if not _prompt_typed_confirm(
3147
+ f"Delete {slug}? This permanently destroys filesystem, service, and public URL.",
3148
+ str(slug),
2812
3149
  ):
2813
3150
  _UI.console().print("[dim]cancelled[/dim]")
2814
3151
  continue
@@ -2817,5 +3154,236 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
2817
3154
  return "__back__"
2818
3155
 
2819
3156
 
3157
+ def _vm_startup_submenu(client: RuntimeClient, vm_id: str, slug: str) -> None:
3158
+ """Inner submenu for durable startup config."""
3159
+ questionary = _UI.q()
3160
+ while True:
3161
+ current = _with_spinner("fetching startup…", lambda: client.get_startup_config(vm_id))
3162
+ _render_startup_panel(current, title=f"startup: {slug}")
3163
+ configured = bool(current.get("configured"))
3164
+ choices: list[Any] = [
3165
+ questionary.Choice(title="✎ set / replace", value="set"),
3166
+ ]
3167
+ if configured:
3168
+ choices.append(questionary.Choice(title="✕ clear", value="clear"))
3169
+ choices.extend(
3170
+ [
3171
+ questionary.Separator(),
3172
+ questionary.Choice(title="← back", value="__back__"),
3173
+ ]
3174
+ )
3175
+ action = _select_prompt("startup config", choices).unsafe_ask()
3176
+ if action in (None, "__back__"):
3177
+ return
3178
+ if action == "set":
3179
+ command = _prompt_text("Startup command", default=str(current.get("command") or ""))
3180
+ if not command:
3181
+ _UI.console().print("[dim]cancelled[/dim]")
3182
+ continue
3183
+ port_text = _prompt_text("App port", default=str(current.get("port") or "3000"))
3184
+ if not port_text:
3185
+ _UI.console().print("[dim]cancelled[/dim]")
3186
+ continue
3187
+ try:
3188
+ port = int(port_text)
3189
+ except ValueError:
3190
+ _UI.err().print("[red]port must be a number[/red]")
3191
+ continue
3192
+ if port <= 0 or port > 65535:
3193
+ _UI.err().print("[red]port must be between 1 and 65535[/red]")
3194
+ continue
3195
+ cwd = _prompt_text("Working directory (optional)", default=str(current.get("cwd") or ""))
3196
+ _with_spinner(
3197
+ "saving startup config…",
3198
+ lambda: client.set_startup_config(vm_id, command=command, cwd=cwd or None, port=port),
3199
+ )
3200
+ _UI.console().print(f"[green]✓[/green] startup saved for [bold]{slug}[/bold]")
3201
+ continue
3202
+ if action == "clear":
3203
+ if not _prompt_confirm(f"Clear startup config for {slug}?", default=False):
3204
+ _UI.console().print("[dim]cancelled[/dim]")
3205
+ continue
3206
+ _with_spinner(
3207
+ "clearing startup config…",
3208
+ lambda: client.clear_startup_config(vm_id),
3209
+ )
3210
+ _UI.console().print(f"[green]✓[/green] cleared startup config for [bold]{slug}[/bold]")
3211
+ continue
3212
+
3213
+
3214
+ # --------------------------------------------------------------------------- #
3215
+ # Help browser + shell completion #
3216
+ # --------------------------------------------------------------------------- #
3217
+
3218
+
3219
+ _HELP_TOPICS: dict[str, tuple[str, ...]] = {
3220
+ "computer": ("Computers",),
3221
+ "computers": ("Computers",),
3222
+ "share": ("Sharing",),
3223
+ "sharing": ("Sharing",),
3224
+ "startup": ("Startup & service",),
3225
+ "service": ("Startup & service",),
3226
+ "proxy": ("Proxy",),
3227
+ "github": ("GitHub",),
3228
+ "account": ("Account",),
3229
+ "shell": ("Shell",),
3230
+ "all": tuple(title for title, _ in _HELP_SECTIONS),
3231
+ }
3232
+
3233
+
3234
+ def handle_help(topic: str | None) -> int:
3235
+ if topic is None:
3236
+ sys.stdout.write(_format_top_help())
3237
+ return 0
3238
+ key = topic.strip().lower()
3239
+ if key not in _HELP_TOPICS:
3240
+ matches = _close_matches(key, list(_HELP_TOPICS))
3241
+ if matches:
3242
+ return report_error(f"unknown help topic {topic!r}. Did you mean {', '.join(repr(m) for m in matches)}?")
3243
+ return report_error(f"unknown help topic {topic!r}. Try: {', '.join(sorted(_HELP_TOPICS))}")
3244
+
3245
+ wanted = set(_HELP_TOPICS[key])
3246
+ lines: list[str] = []
3247
+ for title, rows in _HELP_SECTIONS:
3248
+ if title not in wanted:
3249
+ continue
3250
+ lines.append(f"{title}:")
3251
+ left = max((len(name) for name, _ in rows), default=0)
3252
+ for name, desc in rows:
3253
+ padded = name + " " * max(0, left - len(name))
3254
+ lines.append(f" {padded} {desc}")
3255
+ lines.append("")
3256
+ sys.stdout.write("\n".join(lines) + "\n")
3257
+ return 0
3258
+
3259
+
3260
+ _COMPLETION_BASH = r"""# bash completion for runtime
3261
+ _runtime_complete() {
3262
+ local cur sub
3263
+ COMPREPLY=()
3264
+ cur="${COMP_WORDS[COMP_CWORD]}"
3265
+ if [[ ${COMP_CWORD} -eq 1 ]]; then
3266
+ COMPREPLY=( $(compgen -W "create list ls info url start enter run publish delete rm share startup service proxy switch checkout integrations secrets api-keys signup verify login whoami logout completion help" -- "$cur") )
3267
+ return
3268
+ fi
3269
+ sub="${COMP_WORDS[1]}"
3270
+ case "$sub" in
3271
+ info|url|start|enter|run|publish|delete|rm|share|startup|service)
3272
+ local ids
3273
+ ids=$(runtime ls 2>/dev/null | python3 -c 'import sys,json;print("\n".join(c["slug"] for c in json.load(sys.stdin).get("computers",[]) if c.get("slug")))' 2>/dev/null)
3274
+ COMPREPLY=( $(compgen -W "$ids" -- "$cur") )
3275
+ ;;
3276
+ completion)
3277
+ COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") )
3278
+ ;;
3279
+ share)
3280
+ if [[ ${COMP_CWORD} -eq 2 ]]; then
3281
+ COMPREPLY=( $(compgen -W "public private" -- "$cur") )
3282
+ fi
3283
+ ;;
3284
+ esac
3285
+ }
3286
+ complete -F _runtime_complete runtime
3287
+ """
3288
+
3289
+
3290
+ _COMPLETION_ZSH = r"""#compdef runtime
3291
+ _runtime() {
3292
+ local -a _runtime_commands
3293
+ _runtime_commands=(
3294
+ 'create:Create a new computer'
3295
+ 'list:List computers'
3296
+ 'ls:List computers'
3297
+ 'info:Show details'
3298
+ 'url:Show public URL'
3299
+ 'start:Wake a cold computer'
3300
+ 'enter:Enter console'
3301
+ 'run:Run a command'
3302
+ 'publish:Publish a port'
3303
+ 'delete:Delete a computer'
3304
+ 'rm:Delete a computer'
3305
+ 'share:Set visibility'
3306
+ 'startup:Startup config'
3307
+ 'service:Service config'
3308
+ 'proxy:Port proxies'
3309
+ 'switch:Open a GitHub branch workspace'
3310
+ 'checkout:Open a GitHub branch workspace'
3311
+ 'integrations:Integrations'
3312
+ 'secrets:Secret sets'
3313
+ 'api-keys:API keys'
3314
+ 'signup:Send verification code'
3315
+ 'verify:Verify code'
3316
+ 'login:Login'
3317
+ 'whoami:Show account'
3318
+ 'logout:Log out'
3319
+ 'completion:Shell completion'
3320
+ 'help:Extended help'
3321
+ )
3322
+ local state
3323
+ _arguments -C '1: :->cmd' '*:: :->args'
3324
+ case $state in
3325
+ cmd)
3326
+ _describe -t commands 'runtime command' _runtime_commands
3327
+ ;;
3328
+ args)
3329
+ case ${words[1]} in
3330
+ info|url|start|enter|run|publish|delete|rm|startup|service)
3331
+ local -a ids
3332
+ ids=(${(f)"$(runtime ls 2>/dev/null | python3 -c 'import sys,json;print(\"\\n\".join(c[\"slug\"] for c in json.load(sys.stdin).get(\"computers\",[]) if c.get(\"slug\")))' 2>/dev/null)"})
3333
+ _describe -t slugs 'computer' ids
3334
+ ;;
3335
+ share)
3336
+ _values 'visibility' public private
3337
+ ;;
3338
+ completion)
3339
+ _values 'shell' bash zsh fish
3340
+ ;;
3341
+ esac
3342
+ ;;
3343
+ esac
3344
+ }
3345
+ _runtime "$@"
3346
+ """
3347
+
3348
+
3349
+ _COMPLETION_FISH = r"""# fish completion for runtime
3350
+ function __runtime_slugs
3351
+ runtime ls 2>/dev/null | python3 -c 'import sys,json;print("\n".join(c["slug"] for c in json.load(sys.stdin).get("computers",[]) if c.get("slug")))' 2>/dev/null
3352
+ end
3353
+
3354
+ complete -c runtime -f
3355
+ complete -c runtime -n '__fish_use_subcommand' -a create -d 'Create'
3356
+ complete -c runtime -n '__fish_use_subcommand' -a list -d 'List'
3357
+ complete -c runtime -n '__fish_use_subcommand' -a ls -d 'List'
3358
+ complete -c runtime -n '__fish_use_subcommand' -a info -d 'Show details'
3359
+ complete -c runtime -n '__fish_use_subcommand' -a url -d 'Show URL'
3360
+ complete -c runtime -n '__fish_use_subcommand' -a start -d 'Wake'
3361
+ complete -c runtime -n '__fish_use_subcommand' -a enter -d 'Console'
3362
+ complete -c runtime -n '__fish_use_subcommand' -a run -d 'Run command'
3363
+ complete -c runtime -n '__fish_use_subcommand' -a publish -d 'Publish port'
3364
+ complete -c runtime -n '__fish_use_subcommand' -a delete -d 'Delete'
3365
+ complete -c runtime -n '__fish_use_subcommand' -a rm -d 'Delete'
3366
+ complete -c runtime -n '__fish_use_subcommand' -a share -d 'Set visibility'
3367
+ complete -c runtime -n '__fish_use_subcommand' -a startup -d 'Startup config'
3368
+ complete -c runtime -n '__fish_use_subcommand' -a service -d 'Service config'
3369
+ complete -c runtime -n '__fish_use_subcommand' -a proxy -d 'Port proxies'
3370
+ complete -c runtime -n '__fish_use_subcommand' -a help -d 'Extended help'
3371
+ complete -c runtime -n '__fish_use_subcommand' -a completion -d 'Completion'
3372
+
3373
+ complete -c runtime -n '__fish_seen_subcommand_from info url start enter run publish delete rm startup service' -a '(__runtime_slugs)'
3374
+ complete -c runtime -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
3375
+ complete -c runtime -n '__fish_seen_subcommand_from share' -a 'public private'
3376
+ """
3377
+
3378
+
3379
+ def handle_completion(shell: str) -> int:
3380
+ scripts = {"bash": _COMPLETION_BASH, "zsh": _COMPLETION_ZSH, "fish": _COMPLETION_FISH}
3381
+ script = scripts.get(shell)
3382
+ if script is None:
3383
+ return report_error(f"unsupported shell: {shell}")
3384
+ sys.stdout.write(script)
3385
+ return 0
3386
+
3387
+
2820
3388
  if __name__ == "__main__":
2821
3389
  raise SystemExit(main())
@@ -155,6 +155,14 @@ class RuntimeClient:
155
155
  def get_computer_network(self, computer_id: str) -> dict[str, Any]:
156
156
  return self._request("GET", f"/api/computers/{computer_id}/network", auth_required=True)
157
157
 
158
+ def set_visibility(self, computer_id: str, visibility: str) -> dict[str, Any]:
159
+ return self._request(
160
+ "PUT",
161
+ f"/api/computers/{computer_id}/visibility",
162
+ json={"visibility": visibility},
163
+ auth_required=True,
164
+ )
165
+
158
166
  def get_startup_config(self, computer_id: str) -> dict[str, Any]:
159
167
  return self._request("GET", f"/api/computers/{computer_id}/startup", auth_required=True)
160
168
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.4.20
3
+ Version: 0.4.22
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
@@ -78,6 +78,8 @@ runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
78
78
  runtime enter <name-or-id> # accepts slug/name like test, or the computer id
79
79
  runtime list
80
80
  runtime info <id>
81
+ runtime share public <id>
82
+ runtime share private <id>
81
83
  runtime start <id>
82
84
  runtime run <id> "echo hello" # one-shot foreground command only
83
85
  runtime run <id> "apt install -y nodejs" --uid 0
File without changes