runtime-sdk 0.4.28__tar.gz → 0.4.30__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.28
3
+ Version: 0.4.30
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runtime-sdk"
3
- version = "0.4.28"
3
+ version = "0.4.30"
4
4
  description = "Runtime Python SDK and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -12,6 +12,7 @@ import shlex
12
12
  import shutil
13
13
  import signal
14
14
  import socket
15
+ import struct
15
16
  import subprocess
16
17
  import sys
17
18
  import threading
@@ -220,9 +221,9 @@ def build_parser() -> argparse.ArgumentParser:
220
221
  default=None,
221
222
  help="Subdomain name (e.g. redsox → redsox.runruntime.dev). Random if skipped.",
222
223
  )
223
- create_cmd.add_argument("--command", dest="startup_command", help="Durable startup command to save on create")
224
- create_cmd.add_argument("--cwd", help="Working directory for the durable startup command")
225
- create_cmd.add_argument("--port", type=int, help="App port to publish for durable startup")
224
+ create_cmd.add_argument("--command", dest="startup_command", help="Initial service command to save on create")
225
+ create_cmd.add_argument("--cwd", help="Working directory for the initial service command")
226
+ create_cmd.add_argument("--port", type=int, help="App port to publish for the initial service")
226
227
  list_cmd = subparsers.add_parser("list", aliases=["ls"], help="List computers")
227
228
  list_cmd.add_argument(
228
229
  "-w",
@@ -282,8 +283,9 @@ def build_parser() -> argparse.ArgumentParser:
282
283
  )
283
284
  publish_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
284
285
  publish_cmd.add_argument("port", nargs="?", type=int, default=None, help="Local app port")
286
+ publish_cmd.add_argument("publish_command", nargs="*", help="Command after --")
285
287
 
286
- startup_cmd = subparsers.add_parser("startup", help="Manage durable startup config")
288
+ startup_cmd = subparsers.add_parser("startup", help=argparse.SUPPRESS)
287
289
  startup_subparsers = startup_cmd.add_subparsers(dest="startup_action", required=True)
288
290
  startup_show = startup_subparsers.add_parser("show", help="Show durable startup config")
289
291
  startup_show.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
@@ -297,8 +299,22 @@ def build_parser() -> argparse.ArgumentParser:
297
299
 
298
300
  service_cmd = subparsers.add_parser("service", help="Manage the durable published app service")
299
301
  service_subparsers = service_cmd.add_subparsers(dest="service_action", required=True)
302
+ service_run = service_subparsers.add_parser("run", help="Start and publish a durable service command")
303
+ service_run.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
304
+ service_run.add_argument("--port", required=True, type=int, help="Service port")
305
+ service_run.add_argument("--cwd", help="Working directory for the service command")
306
+ service_run.add_argument("service_command", nargs="*", help="Command after --")
300
307
  service_show = service_subparsers.add_parser("show", help="Show the durable published app service")
301
308
  service_show.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
309
+ service_status = service_subparsers.add_parser("status", help="Show durable service status")
310
+ service_status.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
311
+ service_logs = service_subparsers.add_parser("logs", help="Stream durable service logs")
312
+ service_logs.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
313
+ service_logs.add_argument("--follow", action="store_true", help="Follow live logs")
314
+ service_restart = service_subparsers.add_parser("restart", help="Restart the durable service")
315
+ service_restart.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
316
+ service_stop = service_subparsers.add_parser("stop", help="Stop the durable service but keep its config")
317
+ service_stop.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
302
318
  service_clear = service_subparsers.add_parser("clear", help="Clear the durable published app service")
303
319
  service_clear.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
304
320
 
@@ -345,7 +361,7 @@ _HELP_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
345
361
  ("start [id]", "Wake a cold computer"),
346
362
  ("enter [id]", "Enter a computer's console (TTY)"),
347
363
  ("run [id] <cmd>", "Run a one-shot command"),
348
- ("publish [id] [port]", "Promote a running app on a port to the durable public URL"),
364
+ ("publish [id] <port> -- <cmd>", "Run and publish a durable service command"),
349
365
  ("delete, rm [id]", "Delete a computer (typed-name confirm; --force / -y to skip)"),
350
366
  ),
351
367
  ),
@@ -359,10 +375,11 @@ _HELP_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
359
375
  (
360
376
  "Startup & service",
361
377
  (
362
- ("startup show [id]", "Show durable startup config"),
363
- ("startup set [id] --command … --port …", "Save durable startup command"),
364
- ("startup clear [id]", "Clear durable startup config"),
365
- ("service show [id]", "Show the durable published app service"),
378
+ ("service run [id] --port N -- <cmd>", "Run and publish a durable service command"),
379
+ ("service status [id]", "Show durable service status"),
380
+ ("service logs [id] --follow", "Stream service logs"),
381
+ ("service restart [id]", "Restart the durable service"),
382
+ ("service stop [id]", "Stop service but keep config"),
366
383
  ("service clear [id]", "Clear the durable published app service"),
367
384
  ),
368
385
  ),
@@ -554,7 +571,7 @@ def main(argv: list[str] | None = None) -> int:
554
571
  config, args.id, args.run_command, args.uid, args.gid, args.cwd, args.shell
555
572
  )
556
573
  if args.command == "publish":
557
- return handle_publish(config, args.id, args.port)
574
+ return handle_publish(config, args.id, args.port, args.publish_command)
558
575
  if args.command == "startup":
559
576
  if args.startup_action == "show":
560
577
  return handle_startup_show(config, args.id)
@@ -564,8 +581,16 @@ def main(argv: list[str] | None = None) -> int:
564
581
  return handle_startup_clear(config, args.id)
565
582
  return report_error("unknown startup command")
566
583
  if args.command == "service":
567
- if args.service_action == "show":
584
+ if args.service_action == "run":
585
+ return handle_service_run(config, args.id, args.port, args.cwd, args.service_command)
586
+ if args.service_action in ("show", "status"):
568
587
  return handle_service_show(config, args.id)
588
+ if args.service_action == "logs":
589
+ return handle_service_logs(config, args.id, args.follow)
590
+ if args.service_action == "restart":
591
+ return handle_service_restart(config, args.id)
592
+ if args.service_action == "stop":
593
+ return handle_service_stop(config, args.id)
569
594
  if args.service_action == "clear":
570
595
  return handle_service_clear(config, args.id)
571
596
  return report_error("unknown service command")
@@ -1314,6 +1339,10 @@ def _render_startup_panel(payload: dict[str, Any], *, title: str = "startup") ->
1314
1339
  body = Text()
1315
1340
  configured = bool(payload.get("configured"))
1316
1341
  rows: list[tuple[str, str, str | None]] = [("configured", "yes" if configured else "no", "bold white" if configured else "dim")]
1342
+ if payload.get("status"):
1343
+ rows.append(("status", str(payload.get("status")), "bold white"))
1344
+ if payload.get("public_url"):
1345
+ rows.append(("url", str(payload.get("public_url")), "cyan"))
1317
1346
  if configured:
1318
1347
  rows.extend(
1319
1348
  [
@@ -1322,6 +1351,12 @@ def _render_startup_panel(payload: dict[str, Any], *, title: str = "startup") ->
1322
1351
  ("port", str(payload.get("port") or "—"), "bold white"),
1323
1352
  ]
1324
1353
  )
1354
+ if payload.get("exec_id"):
1355
+ rows.append(("exec", str(payload.get("exec_id")), "dim"))
1356
+ if payload.get("started_at"):
1357
+ rows.append(("started", str(payload.get("started_at")), "dim"))
1358
+ if payload.get("exit_code") is not None:
1359
+ rows.append(("exit", str(payload.get("exit_code")), "red"))
1325
1360
  for label, value, style in rows:
1326
1361
  body.append(f"{label:<10}", style="dim")
1327
1362
  body.append(value, style=style or "white")
@@ -2066,10 +2101,18 @@ def handle_create(
2066
2101
  label = "creating computer…"
2067
2102
  if startup_command is not None:
2068
2103
  label = f"creating computer and starting app on port {port}…"
2069
- result = _with_spinner(
2070
- label,
2071
- lambda: client.create_computer(slug=name, command=startup_command, cwd=cwd, port=port),
2072
- )
2104
+ try:
2105
+ result = _with_spinner(
2106
+ label,
2107
+ lambda: client.create_computer(slug=name, command=startup_command, cwd=cwd, port=port),
2108
+ )
2109
+ except RuntimeAPIError as exc:
2110
+ if exc.status_code == 409 and "slug already in use" in str(exc) and name:
2111
+ return report_error(
2112
+ f"computer name {name!r} is already in use. Names become public URL slugs and must be unique across active and cold computers. Pick it from `runtime list`, delete it, or choose a different name.",
2113
+ status_code=exc.status_code,
2114
+ )
2115
+ raise
2073
2116
 
2074
2117
  def render(payload: dict[str, Any]) -> None:
2075
2118
  _render_computer_panel(payload, title="created")
@@ -2293,6 +2336,36 @@ def handle_startup_clear(config: RuntimeConfig, computer_id: str | None) -> int:
2293
2336
  return report_success(result or {"configured": False}, render)
2294
2337
 
2295
2338
 
2339
+ def _command_from_remainder(parts: list[str] | None) -> str | None:
2340
+ if not parts:
2341
+ return None
2342
+ if parts and parts[0] == "--":
2343
+ parts = parts[1:]
2344
+ if not parts:
2345
+ return None
2346
+ return shlex.join(parts)
2347
+
2348
+
2349
+ def handle_service_run(config: RuntimeConfig, computer_id: str | None, port: int | None, cwd: str | None, command_parts: list[str] | None) -> int:
2350
+ if (err := _require_api_key(config)) is not None:
2351
+ return err
2352
+ if not computer_id:
2353
+ return report_error("computer id is required")
2354
+ if port is None or port <= 0 or port > 65535:
2355
+ return report_error("port must be between 1 and 65535")
2356
+ command = _command_from_remainder(command_parts)
2357
+ if not command:
2358
+ return report_error("service run requires -- <command...>")
2359
+
2360
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2361
+ computer_id = _resolve_computer_ref(client, computer_id)
2362
+ result = _with_spinner(
2363
+ f"starting service on {computer_id}…",
2364
+ lambda: client.run_service(computer_id, command=command, cwd=cwd, port=port),
2365
+ )
2366
+ return report_success(result, lambda p: _render_startup_panel(p, title="service"))
2367
+
2368
+
2296
2369
  def handle_service_show(config: RuntimeConfig, computer_id: str | None) -> int:
2297
2370
  if (err := _require_api_key(config)) is not None:
2298
2371
  return err
@@ -2313,6 +2386,56 @@ def handle_service_show(config: RuntimeConfig, computer_id: str | None) -> int:
2313
2386
  return report_success(result, lambda p: _render_startup_panel(p, title="service"))
2314
2387
 
2315
2388
 
2389
+ def handle_service_logs(config: RuntimeConfig, computer_id: str | None, follow: bool) -> int:
2390
+ if (err := _require_api_key(config)) is not None:
2391
+ return err
2392
+ if not computer_id:
2393
+ return report_error("computer id is required")
2394
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2395
+ computer_id = _resolve_computer_ref(client, computer_id)
2396
+ for line in client.stream_service_logs(computer_id, follow=follow):
2397
+ try:
2398
+ frame = json.loads(line)
2399
+ except ValueError:
2400
+ print(line)
2401
+ continue
2402
+ stdout = frame.get("stdout") or ""
2403
+ stderr = frame.get("stderr") or ""
2404
+ data = frame.get("data") or ""
2405
+ error = frame.get("error") or ""
2406
+ if stdout:
2407
+ print(stdout, end="")
2408
+ if stderr:
2409
+ print(stderr, end="", file=sys.stderr)
2410
+ if data:
2411
+ print(data, end="")
2412
+ if error:
2413
+ print(error, file=sys.stderr)
2414
+ return 0
2415
+
2416
+
2417
+ def handle_service_restart(config: RuntimeConfig, computer_id: str | None) -> int:
2418
+ if (err := _require_api_key(config)) is not None:
2419
+ return err
2420
+ if not computer_id:
2421
+ return report_error("computer id is required")
2422
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2423
+ computer_id = _resolve_computer_ref(client, computer_id)
2424
+ result = _with_spinner(f"restarting service for {computer_id}…", lambda: client.restart_service(computer_id))
2425
+ return report_success(result, lambda p: _render_startup_panel(p, title="service restarted"))
2426
+
2427
+
2428
+ def handle_service_stop(config: RuntimeConfig, computer_id: str | None) -> int:
2429
+ if (err := _require_api_key(config)) is not None:
2430
+ return err
2431
+ if not computer_id:
2432
+ return report_error("computer id is required")
2433
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2434
+ computer_id = _resolve_computer_ref(client, computer_id)
2435
+ result = _with_spinner(f"stopping service for {computer_id}…", lambda: client.stop_service(computer_id))
2436
+ return report_success(result, lambda p: _render_startup_panel(p, title="service stopped"))
2437
+
2438
+
2316
2439
  def handle_service_clear(config: RuntimeConfig, computer_id: str | None) -> int:
2317
2440
  if (err := _require_api_key(config)) is not None:
2318
2441
  return err
@@ -2465,7 +2588,7 @@ def handle_run(
2465
2588
  return report_success(result, _render_run_result)
2466
2589
 
2467
2590
 
2468
- def handle_publish(config: RuntimeConfig, computer_id: str | None, port: int | None) -> int:
2591
+ def handle_publish(config: RuntimeConfig, computer_id: str | None, port: int | None, command_parts: list[str] | None = None) -> int:
2469
2592
  if (err := _require_api_key(config)) is not None:
2470
2593
  return err
2471
2594
 
@@ -2497,22 +2620,17 @@ def handle_publish(config: RuntimeConfig, computer_id: str | None, port: int | N
2497
2620
  if port <= 0 or port > 65535:
2498
2621
  return report_error("port must be between 1 and 65535")
2499
2622
 
2623
+ command = _command_from_remainder(command_parts)
2624
+ if not command:
2625
+ return report_error(f"publish requires -- <command...>, for example: runtime publish {computer_id} {port} -- npm run dev -- --host 0.0.0.0")
2626
+
2500
2627
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2501
2628
  computer_id = _resolve_computer_ref(client, computer_id)
2502
2629
  result = _with_spinner(
2503
- f"publishing port {port} on {computer_id}…",
2504
- lambda: client.publish_port(computer_id, port),
2630
+ f"publishing service on {computer_id}…",
2631
+ lambda: client.publish_port(computer_id, port, command=command),
2505
2632
  )
2506
-
2507
- def render(payload: dict[str, Any]) -> None:
2508
- _UI.console().print(
2509
- f"[green]✓[/green] public url for [bold]{computer_id}[/bold] now points at durable app port [bold]{payload.get('published_port') or port}[/bold]"
2510
- )
2511
- url = payload.get("public_url")
2512
- if url:
2513
- _UI.console().print(f"[bold green]→[/bold green] [cyan]{url}[/cyan]")
2514
-
2515
- return report_success(result, render)
2633
+ return report_success(result, lambda p: _render_startup_panel(p, title="service"))
2516
2634
 
2517
2635
 
2518
2636
  def handle_proxy_start(config: RuntimeConfig, computer_id: str | None, specs: list[str] | None) -> int:
@@ -2769,6 +2887,26 @@ def _load_posix_tty_modules() -> tuple[Any, Any]:
2769
2887
  return termios_mod, tty_mod
2770
2888
 
2771
2889
 
2890
+ TERMINAL_FRAME_DATA = 0x01
2891
+ TERMINAL_FRAME_WINDOW_SIZE = 0x02
2892
+ TERMINAL_FRAME_SHUTDOWN = 0x03
2893
+ TERMINAL_FRAME_HEARTBEAT = 0x04
2894
+ TERMINAL_FRAME_SESSION_CLOSE = 0x05
2895
+ TERMINAL_FRAME_HEADER_SIZE = 5
2896
+
2897
+
2898
+ def _encode_terminal_frame(frame_type: int, payload: bytes = b"") -> bytes:
2899
+ return bytes([frame_type]) + struct.pack(">I", len(payload)) + payload
2900
+
2901
+
2902
+ def _parse_terminal_frame(raw: bytes) -> tuple[int, bytes]:
2903
+ if len(raw) < TERMINAL_FRAME_HEADER_SIZE:
2904
+ raise RuntimeAPIError("terminal received a truncated frame")
2905
+ payload_len = struct.unpack(">I", raw[1:TERMINAL_FRAME_HEADER_SIZE])[0]
2906
+ if len(raw) != TERMINAL_FRAME_HEADER_SIZE + payload_len:
2907
+ raise RuntimeAPIError("terminal received a malformed frame")
2908
+ return raw[0], raw[TERMINAL_FRAME_HEADER_SIZE:]
2909
+
2772
2910
 
2773
2911
  def _run_terminal_session(ws_url: str) -> int:
2774
2912
  if not sys.stdin.isatty() or not sys.stdout.isatty():
@@ -2792,26 +2930,18 @@ def _run_terminal_session(ws_url: str) -> int:
2792
2930
  result["code"] = 0
2793
2931
  stop.set()
2794
2932
  return
2795
- event = json.loads(raw)
2796
- event_type = str(event.get("type") or "")
2797
- if event_type == "ready":
2933
+ if not isinstance(raw, bytes):
2934
+ raise RuntimeAPIError("terminal received a non-binary frame")
2935
+ frame_type, payload = _parse_terminal_frame(raw)
2936
+ if frame_type == TERMINAL_FRAME_HEARTBEAT:
2798
2937
  continue
2799
- if event_type == "output":
2800
- data = _filter_terminal_output(str(event.get("data") or ""), output_state)
2938
+ if frame_type == TERMINAL_FRAME_DATA:
2939
+ data = _filter_terminal_output(payload.decode("utf-8", errors="ignore"), output_state)
2801
2940
  if data:
2802
2941
  sys.stdout.write(data)
2803
2942
  sys.stdout.flush()
2804
2943
  continue
2805
- if event_type == "exit":
2806
- result["code"] = int(event.get("code") or 0)
2807
- message = str(event.get("message") or "").strip()
2808
- if message:
2809
- result["error"] = message
2810
- stop.set()
2811
- return
2812
- if event_type == "error":
2813
- result["code"] = 1
2814
- result["error"] = str(event.get("message") or "terminal error")
2944
+ if frame_type in (TERMINAL_FRAME_SHUTDOWN, TERMINAL_FRAME_SESSION_CLOSE):
2815
2945
  stop.set()
2816
2946
  return
2817
2947
  except ConnectionClosed:
@@ -2843,9 +2973,11 @@ def _run_terminal_session(ws_url: str) -> int:
2843
2973
  if not data:
2844
2974
  stop.set()
2845
2975
  break
2846
- ws.send(json.dumps({"type": "input", "data": data.decode("utf-8", errors="ignore")}))
2976
+ ws.send(_encode_terminal_frame(TERMINAL_FRAME_DATA, data))
2847
2977
  finally:
2848
2978
  stop.set()
2979
+ with contextlib.suppress(Exception):
2980
+ ws.send(_encode_terminal_frame(TERMINAL_FRAME_SHUTDOWN))
2849
2981
  with contextlib.suppress(Exception):
2850
2982
  ws.close()
2851
2983
  termios_mod.tcsetattr(fd, termios_mod.TCSADRAIN, old_settings)
@@ -2935,7 +3067,7 @@ def _strip_terminal_noise(text: str) -> str:
2935
3067
 
2936
3068
  def _send_terminal_resize(ws: Any) -> None:
2937
3069
  size = shutil.get_terminal_size((100, 24))
2938
- ws.send(json.dumps({"type": "resize", "cols": size.columns, "rows": size.lines}))
3070
+ ws.send(_encode_terminal_frame(TERMINAL_FRAME_WINDOW_SIZE, struct.pack(">II", size.columns, size.lines)))
2939
3071
 
2940
3072
 
2941
3073
  # --------------------------------------------------------------------------- #
@@ -3022,7 +3154,7 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
3022
3154
  choices.append(questionary.Choice(title="⌨ enter console", value="enter"))
3023
3155
  if internal_status == "running":
3024
3156
  choices.append(questionary.Choice(title="▶ run a command", value="run"))
3025
- choices.append(questionary.Choice(title="⤴ publish a port", value="publish"))
3157
+ choices.append(questionary.Choice(title="⤴ publish service", value="publish"))
3026
3158
  if power_state == "cold":
3027
3159
  choices.append(questionary.Choice(title="☀ start / wake", value="start"))
3028
3160
  choices.extend(
@@ -3030,7 +3162,7 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
3030
3162
  questionary.Choice(title="⧉ copy public url", value="copy-url"),
3031
3163
  questionary.Choice(title="↗ open in browser", value="open-url"),
3032
3164
  questionary.Choice(title=f"⚑ make {flip_visibility}", value=f"share:{flip_visibility}"),
3033
- questionary.Choice(title="⚙ startup config…", value="startup"),
3165
+ questionary.Choice(title="⚙ service…", value="startup"),
3034
3166
  questionary.Choice(title="ℹ refresh info", value="info"),
3035
3167
  questionary.Separator(),
3036
3168
  questionary.Choice(title="✕ delete", value="delete"),
@@ -3080,13 +3212,14 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
3080
3212
  if port <= 0 or port > 65535:
3081
3213
  _UI.err().print("[red]port must be between 1 and 65535[/red]")
3082
3214
  continue
3083
- _with_spinner(
3084
- f"publishing port {port} on {slug}…",
3085
- lambda: client.publish_port(vm_id, port),
3086
- )
3087
- _UI.console().print(
3088
- f"[green]✓[/green] public url for [bold]{slug}[/bold] pinned to [bold]{port}[/bold]"
3215
+ command = _prompt_text("Command to run as the service")
3216
+ if not command:
3217
+ continue
3218
+ result = _with_spinner(
3219
+ f"publishing service on {slug}…",
3220
+ lambda: client.publish_port(vm_id, port, command=command),
3089
3221
  )
3222
+ _render_startup_panel(result, title=f"service: {slug}")
3090
3223
  continue
3091
3224
 
3092
3225
  if action == "info":
@@ -3143,11 +3276,11 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
3143
3276
 
3144
3277
 
3145
3278
  def _vm_startup_submenu(client: RuntimeClient, vm_id: str, slug: str) -> None:
3146
- """Inner submenu for durable startup config."""
3279
+ """Inner submenu for the durable service."""
3147
3280
  questionary = _UI.q()
3148
3281
  while True:
3149
- current = _with_spinner("fetching startup…", lambda: client.get_startup_config(vm_id))
3150
- _render_startup_panel(current, title=f"startup: {slug}")
3282
+ current = _with_spinner("fetching service…", lambda: client.get_service(vm_id))
3283
+ _render_startup_panel(current, title=f"service: {slug}")
3151
3284
  configured = bool(current.get("configured"))
3152
3285
  choices: list[Any] = [
3153
3286
  questionary.Choice(title="✎ set / replace", value="set"),
@@ -3160,11 +3293,11 @@ def _vm_startup_submenu(client: RuntimeClient, vm_id: str, slug: str) -> None:
3160
3293
  questionary.Choice(title="← back", value="__back__"),
3161
3294
  ]
3162
3295
  )
3163
- action = _select_prompt("startup config", choices).unsafe_ask()
3296
+ action = _select_prompt("service", choices).unsafe_ask()
3164
3297
  if action in (None, "__back__"):
3165
3298
  return
3166
3299
  if action == "set":
3167
- command = _prompt_text("Startup command", default=str(current.get("command") or ""))
3300
+ command = _prompt_text("Service command", default=str(current.get("command") or ""))
3168
3301
  if not command:
3169
3302
  _UI.console().print("[dim]cancelled[/dim]")
3170
3303
  continue
@@ -3182,20 +3315,20 @@ def _vm_startup_submenu(client: RuntimeClient, vm_id: str, slug: str) -> None:
3182
3315
  continue
3183
3316
  cwd = _prompt_text("Working directory (optional)", default=str(current.get("cwd") or ""))
3184
3317
  _with_spinner(
3185
- "saving startup config…",
3186
- lambda: client.set_startup_config(vm_id, command=command, cwd=cwd or None, port=port),
3318
+ "starting service…",
3319
+ lambda: client.run_service(vm_id, command=command, cwd=cwd or None, port=port),
3187
3320
  )
3188
- _UI.console().print(f"[green]✓[/green] startup saved for [bold]{slug}[/bold]")
3321
+ _UI.console().print(f"[green]✓[/green] service started for [bold]{slug}[/bold]")
3189
3322
  continue
3190
3323
  if action == "clear":
3191
- if not _prompt_confirm(f"Clear startup config for {slug}?", default=False):
3324
+ if not _prompt_confirm(f"Clear service for {slug}?", default=False):
3192
3325
  _UI.console().print("[dim]cancelled[/dim]")
3193
3326
  continue
3194
3327
  _with_spinner(
3195
- "clearing startup config…",
3196
- lambda: client.clear_startup_config(vm_id),
3328
+ "clearing service…",
3329
+ lambda: client.clear_service(vm_id),
3197
3330
  )
3198
- _UI.console().print(f"[green]✓[/green] cleared startup config for [bold]{slug}[/bold]")
3331
+ _UI.console().print(f"[green]✓[/green] cleared service for [bold]{slug}[/bold]")
3199
3332
  continue
3200
3333
 
3201
3334
 
@@ -3209,7 +3342,6 @@ _HELP_TOPICS: dict[str, tuple[str, ...]] = {
3209
3342
  "computers": ("Computers",),
3210
3343
  "share": ("Sharing",),
3211
3344
  "sharing": ("Sharing",),
3212
- "startup": ("Startup & service",),
3213
3345
  "service": ("Startup & service",),
3214
3346
  "proxy": ("Proxy",),
3215
3347
  "github": ("GitHub",),
@@ -3251,7 +3383,7 @@ _runtime_complete() {
3251
3383
  COMPREPLY=()
3252
3384
  cur="${COMP_WORDS[COMP_CWORD]}"
3253
3385
  if [[ ${COMP_CWORD} -eq 1 ]]; then
3254
- COMPREPLY=( $(compgen -W "create list ls info url start enter run publish delete rm share startup service proxy switch checkout github integrate secrets api-keys signup verify login whoami logout completion help" -- "$cur") )
3386
+ COMPREPLY=( $(compgen -W "create list ls info url start enter run publish delete rm share service proxy switch checkout github integrate secrets api-keys signup verify login whoami logout completion help" -- "$cur") )
3255
3387
  return
3256
3388
  fi
3257
3389
  sub="${COMP_WORDS[1]}"
@@ -3295,7 +3427,6 @@ _runtime() {
3295
3427
  'delete:Delete a computer'
3296
3428
  'rm:Delete a computer'
3297
3429
  'share:Set visibility'
3298
- 'startup:Startup config'
3299
3430
  'service:Service config'
3300
3431
  'proxy:Port proxies'
3301
3432
  'switch:Open a GitHub branch workspace'
@@ -3363,7 +3494,6 @@ complete -c runtime -n '__fish_use_subcommand' -a publish -d 'Publish port'
3363
3494
  complete -c runtime -n '__fish_use_subcommand' -a delete -d 'Delete'
3364
3495
  complete -c runtime -n '__fish_use_subcommand' -a rm -d 'Delete'
3365
3496
  complete -c runtime -n '__fish_use_subcommand' -a share -d 'Set visibility'
3366
- complete -c runtime -n '__fish_use_subcommand' -a startup -d 'Startup config'
3367
3497
  complete -c runtime -n '__fish_use_subcommand' -a service -d 'Service config'
3368
3498
  complete -c runtime -n '__fish_use_subcommand' -a proxy -d 'Port proxies'
3369
3499
  complete -c runtime -n '__fish_use_subcommand' -a github -d 'GitHub connections'
@@ -3371,7 +3501,7 @@ complete -c runtime -n '__fish_use_subcommand' -a integrate -d 'Connect GitHub'
3371
3501
  complete -c runtime -n '__fish_use_subcommand' -a help -d 'Extended help'
3372
3502
  complete -c runtime -n '__fish_use_subcommand' -a completion -d 'Completion'
3373
3503
 
3374
- complete -c runtime -n '__fish_seen_subcommand_from info url start enter run publish delete rm startup service' -a '(__runtime_slugs)'
3504
+ complete -c runtime -n '__fish_seen_subcommand_from info url start enter run publish delete rm service' -a '(__runtime_slugs)'
3375
3505
  complete -c runtime -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
3376
3506
  complete -c runtime -n '__fish_seen_subcommand_from share' -a 'public private'
3377
3507
  """
@@ -148,7 +148,71 @@ class RuntimeClient:
148
148
  return self._request("GET", f"/api/computers/{computer_id}/startup", auth_required=True)
149
149
 
150
150
  def get_service_config(self, computer_id: str) -> dict[str, Any]:
151
- return self.get_startup_config(computer_id)
151
+ return self.get_service(computer_id)
152
+
153
+ def get_service(self, computer_id: str) -> dict[str, Any]:
154
+ return self._request("GET", f"/api/computers/{computer_id}/service", auth_required=True)
155
+
156
+ def run_service(
157
+ self,
158
+ computer_id: str,
159
+ *,
160
+ command: str,
161
+ cwd: str | None = None,
162
+ port: int,
163
+ ) -> dict[str, Any]:
164
+ body: dict[str, Any] = {"command": command, "port": port}
165
+ if cwd is not None:
166
+ body["cwd"] = cwd
167
+ return self._request(
168
+ "POST",
169
+ f"/api/computers/{computer_id}/service/run",
170
+ json=body,
171
+ auth_required=True,
172
+ timeout=max(self.timeout, DEFAULT_WARMUP_TIMEOUT),
173
+ )
174
+
175
+ def restart_service(self, computer_id: str) -> dict[str, Any]:
176
+ return self._request(
177
+ "POST",
178
+ f"/api/computers/{computer_id}/service/restart",
179
+ auth_required=True,
180
+ timeout=max(self.timeout, DEFAULT_WARMUP_TIMEOUT),
181
+ )
182
+
183
+ def stop_service(self, computer_id: str) -> dict[str, Any]:
184
+ return self._request("POST", f"/api/computers/{computer_id}/service/stop", auth_required=True)
185
+
186
+ def clear_service(self, computer_id: str) -> dict[str, Any]:
187
+ return self._request("DELETE", f"/api/computers/{computer_id}/service", auth_required=True)
188
+
189
+ def stream_service_logs(self, computer_id: str, *, follow: bool = False):
190
+ if not self.api_key:
191
+ raise RuntimeAPIError("missing api key")
192
+ path = f"/api/computers/{computer_id}/service/logs"
193
+ if follow:
194
+ path += "?follow=true"
195
+ client = httpx.Client(base_url=self.base_url, timeout=None, transport=self.transport)
196
+ try:
197
+ with client.stream("GET", path, headers={"Authorization": f"Bearer {self.api_key}"}) as response:
198
+ if not response.is_success:
199
+ body = response.read()
200
+ payload: dict[str, Any] = {}
201
+ if body:
202
+ try:
203
+ decoded = response.json()
204
+ if isinstance(decoded, dict):
205
+ payload = decoded
206
+ except ValueError:
207
+ pass
208
+ message = payload.get("error") or payload.get("message") or f"http {response.status_code}"
209
+ raise RuntimeAPIError(str(message), status_code=response.status_code)
210
+ for line in response.iter_lines():
211
+ if not line:
212
+ continue
213
+ yield line
214
+ finally:
215
+ client.close()
152
216
 
153
217
  def set_startup_config(
154
218
  self,
@@ -167,7 +231,7 @@ class RuntimeClient:
167
231
  return self._request("DELETE", f"/api/computers/{computer_id}/startup", auth_required=True)
168
232
 
169
233
  def clear_service_config(self, computer_id: str) -> dict[str, Any]:
170
- return self.clear_startup_config(computer_id)
234
+ return self.clear_service(computer_id)
171
235
 
172
236
  def start_computer(self, computer_id: str) -> dict[str, Any]:
173
237
  return self._request(
@@ -220,15 +284,12 @@ class RuntimeClient:
220
284
  body["shell"] = shell
221
285
  return self._request("POST", f"/api/computers/{computer_id}/exec", json=body, auth_required=True)
222
286
 
223
- def publish_port(self, computer_id: str, port: int) -> dict[str, Any]:
287
+ def publish_port(self, computer_id: str, port: int, *, command: str | None = None, cwd: str | None = None) -> dict[str, Any]:
224
288
  if port <= 0 or port > 65535:
225
289
  raise RuntimeAPIError("port must be between 1 and 65535")
226
- return self._request(
227
- "POST",
228
- f"/api/computers/{computer_id}/publish",
229
- json={"port": port},
230
- auth_required=True,
231
- )
290
+ if not command:
291
+ raise RuntimeAPIError("publish requires -- <command...>")
292
+ return self.run_service(computer_id, command=command, cwd=cwd, port=port)
232
293
 
233
294
  def _request(
234
295
  self,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.4.28
3
+ Version: 0.4.30
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
File without changes
File without changes