runtime-sdk 0.4.28__tar.gz → 0.4.29__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.29
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.29"
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")
@@ -2293,6 +2328,36 @@ def handle_startup_clear(config: RuntimeConfig, computer_id: str | None) -> int:
2293
2328
  return report_success(result or {"configured": False}, render)
2294
2329
 
2295
2330
 
2331
+ def _command_from_remainder(parts: list[str] | None) -> str | None:
2332
+ if not parts:
2333
+ return None
2334
+ if parts and parts[0] == "--":
2335
+ parts = parts[1:]
2336
+ if not parts:
2337
+ return None
2338
+ return shlex.join(parts)
2339
+
2340
+
2341
+ def handle_service_run(config: RuntimeConfig, computer_id: str | None, port: int | None, cwd: str | None, command_parts: list[str] | None) -> int:
2342
+ if (err := _require_api_key(config)) is not None:
2343
+ return err
2344
+ if not computer_id:
2345
+ return report_error("computer id is required")
2346
+ if port is None or port <= 0 or port > 65535:
2347
+ return report_error("port must be between 1 and 65535")
2348
+ command = _command_from_remainder(command_parts)
2349
+ if not command:
2350
+ return report_error("service run requires -- <command...>")
2351
+
2352
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2353
+ computer_id = _resolve_computer_ref(client, computer_id)
2354
+ result = _with_spinner(
2355
+ f"starting service on {computer_id}…",
2356
+ lambda: client.run_service(computer_id, command=command, cwd=cwd, port=port),
2357
+ )
2358
+ return report_success(result, lambda p: _render_startup_panel(p, title="service"))
2359
+
2360
+
2296
2361
  def handle_service_show(config: RuntimeConfig, computer_id: str | None) -> int:
2297
2362
  if (err := _require_api_key(config)) is not None:
2298
2363
  return err
@@ -2313,6 +2378,56 @@ def handle_service_show(config: RuntimeConfig, computer_id: str | None) -> int:
2313
2378
  return report_success(result, lambda p: _render_startup_panel(p, title="service"))
2314
2379
 
2315
2380
 
2381
+ def handle_service_logs(config: RuntimeConfig, computer_id: str | None, follow: bool) -> int:
2382
+ if (err := _require_api_key(config)) is not None:
2383
+ return err
2384
+ if not computer_id:
2385
+ return report_error("computer id is required")
2386
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2387
+ computer_id = _resolve_computer_ref(client, computer_id)
2388
+ for line in client.stream_service_logs(computer_id, follow=follow):
2389
+ try:
2390
+ frame = json.loads(line)
2391
+ except ValueError:
2392
+ print(line)
2393
+ continue
2394
+ stdout = frame.get("stdout") or ""
2395
+ stderr = frame.get("stderr") or ""
2396
+ data = frame.get("data") or ""
2397
+ error = frame.get("error") or ""
2398
+ if stdout:
2399
+ print(stdout, end="")
2400
+ if stderr:
2401
+ print(stderr, end="", file=sys.stderr)
2402
+ if data:
2403
+ print(data, end="")
2404
+ if error:
2405
+ print(error, file=sys.stderr)
2406
+ return 0
2407
+
2408
+
2409
+ def handle_service_restart(config: RuntimeConfig, computer_id: str | None) -> int:
2410
+ if (err := _require_api_key(config)) is not None:
2411
+ return err
2412
+ if not computer_id:
2413
+ return report_error("computer id is required")
2414
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2415
+ computer_id = _resolve_computer_ref(client, computer_id)
2416
+ result = _with_spinner(f"restarting service for {computer_id}…", lambda: client.restart_service(computer_id))
2417
+ return report_success(result, lambda p: _render_startup_panel(p, title="service restarted"))
2418
+
2419
+
2420
+ def handle_service_stop(config: RuntimeConfig, computer_id: str | None) -> int:
2421
+ if (err := _require_api_key(config)) is not None:
2422
+ return err
2423
+ if not computer_id:
2424
+ return report_error("computer id is required")
2425
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2426
+ computer_id = _resolve_computer_ref(client, computer_id)
2427
+ result = _with_spinner(f"stopping service for {computer_id}…", lambda: client.stop_service(computer_id))
2428
+ return report_success(result, lambda p: _render_startup_panel(p, title="service stopped"))
2429
+
2430
+
2316
2431
  def handle_service_clear(config: RuntimeConfig, computer_id: str | None) -> int:
2317
2432
  if (err := _require_api_key(config)) is not None:
2318
2433
  return err
@@ -2465,7 +2580,7 @@ def handle_run(
2465
2580
  return report_success(result, _render_run_result)
2466
2581
 
2467
2582
 
2468
- def handle_publish(config: RuntimeConfig, computer_id: str | None, port: int | None) -> int:
2583
+ def handle_publish(config: RuntimeConfig, computer_id: str | None, port: int | None, command_parts: list[str] | None = None) -> int:
2469
2584
  if (err := _require_api_key(config)) is not None:
2470
2585
  return err
2471
2586
 
@@ -2497,22 +2612,17 @@ def handle_publish(config: RuntimeConfig, computer_id: str | None, port: int | N
2497
2612
  if port <= 0 or port > 65535:
2498
2613
  return report_error("port must be between 1 and 65535")
2499
2614
 
2615
+ command = _command_from_remainder(command_parts)
2616
+ if not command:
2617
+ return report_error(f"publish requires -- <command...>, for example: runtime publish {computer_id} {port} -- npm run dev -- --host 0.0.0.0")
2618
+
2500
2619
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2501
2620
  computer_id = _resolve_computer_ref(client, computer_id)
2502
2621
  result = _with_spinner(
2503
- f"publishing port {port} on {computer_id}…",
2504
- lambda: client.publish_port(computer_id, port),
2622
+ f"publishing service on {computer_id}…",
2623
+ lambda: client.publish_port(computer_id, port, command=command),
2505
2624
  )
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)
2625
+ return report_success(result, lambda p: _render_startup_panel(p, title="service"))
2516
2626
 
2517
2627
 
2518
2628
  def handle_proxy_start(config: RuntimeConfig, computer_id: str | None, specs: list[str] | None) -> int:
@@ -2769,6 +2879,26 @@ def _load_posix_tty_modules() -> tuple[Any, Any]:
2769
2879
  return termios_mod, tty_mod
2770
2880
 
2771
2881
 
2882
+ TERMINAL_FRAME_DATA = 0x01
2883
+ TERMINAL_FRAME_WINDOW_SIZE = 0x02
2884
+ TERMINAL_FRAME_SHUTDOWN = 0x03
2885
+ TERMINAL_FRAME_HEARTBEAT = 0x04
2886
+ TERMINAL_FRAME_SESSION_CLOSE = 0x05
2887
+ TERMINAL_FRAME_HEADER_SIZE = 5
2888
+
2889
+
2890
+ def _encode_terminal_frame(frame_type: int, payload: bytes = b"") -> bytes:
2891
+ return bytes([frame_type]) + struct.pack(">I", len(payload)) + payload
2892
+
2893
+
2894
+ def _parse_terminal_frame(raw: bytes) -> tuple[int, bytes]:
2895
+ if len(raw) < TERMINAL_FRAME_HEADER_SIZE:
2896
+ raise RuntimeAPIError("terminal received a truncated frame")
2897
+ payload_len = struct.unpack(">I", raw[1:TERMINAL_FRAME_HEADER_SIZE])[0]
2898
+ if len(raw) != TERMINAL_FRAME_HEADER_SIZE + payload_len:
2899
+ raise RuntimeAPIError("terminal received a malformed frame")
2900
+ return raw[0], raw[TERMINAL_FRAME_HEADER_SIZE:]
2901
+
2772
2902
 
2773
2903
  def _run_terminal_session(ws_url: str) -> int:
2774
2904
  if not sys.stdin.isatty() or not sys.stdout.isatty():
@@ -2792,26 +2922,18 @@ def _run_terminal_session(ws_url: str) -> int:
2792
2922
  result["code"] = 0
2793
2923
  stop.set()
2794
2924
  return
2795
- event = json.loads(raw)
2796
- event_type = str(event.get("type") or "")
2797
- if event_type == "ready":
2925
+ if not isinstance(raw, bytes):
2926
+ raise RuntimeAPIError("terminal received a non-binary frame")
2927
+ frame_type, payload = _parse_terminal_frame(raw)
2928
+ if frame_type == TERMINAL_FRAME_HEARTBEAT:
2798
2929
  continue
2799
- if event_type == "output":
2800
- data = _filter_terminal_output(str(event.get("data") or ""), output_state)
2930
+ if frame_type == TERMINAL_FRAME_DATA:
2931
+ data = _filter_terminal_output(payload.decode("utf-8", errors="ignore"), output_state)
2801
2932
  if data:
2802
2933
  sys.stdout.write(data)
2803
2934
  sys.stdout.flush()
2804
2935
  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")
2936
+ if frame_type in (TERMINAL_FRAME_SHUTDOWN, TERMINAL_FRAME_SESSION_CLOSE):
2815
2937
  stop.set()
2816
2938
  return
2817
2939
  except ConnectionClosed:
@@ -2843,9 +2965,11 @@ def _run_terminal_session(ws_url: str) -> int:
2843
2965
  if not data:
2844
2966
  stop.set()
2845
2967
  break
2846
- ws.send(json.dumps({"type": "input", "data": data.decode("utf-8", errors="ignore")}))
2968
+ ws.send(_encode_terminal_frame(TERMINAL_FRAME_DATA, data))
2847
2969
  finally:
2848
2970
  stop.set()
2971
+ with contextlib.suppress(Exception):
2972
+ ws.send(_encode_terminal_frame(TERMINAL_FRAME_SHUTDOWN))
2849
2973
  with contextlib.suppress(Exception):
2850
2974
  ws.close()
2851
2975
  termios_mod.tcsetattr(fd, termios_mod.TCSADRAIN, old_settings)
@@ -2935,7 +3059,7 @@ def _strip_terminal_noise(text: str) -> str:
2935
3059
 
2936
3060
  def _send_terminal_resize(ws: Any) -> None:
2937
3061
  size = shutil.get_terminal_size((100, 24))
2938
- ws.send(json.dumps({"type": "resize", "cols": size.columns, "rows": size.lines}))
3062
+ ws.send(_encode_terminal_frame(TERMINAL_FRAME_WINDOW_SIZE, struct.pack(">II", size.columns, size.lines)))
2939
3063
 
2940
3064
 
2941
3065
  # --------------------------------------------------------------------------- #
@@ -3022,7 +3146,7 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
3022
3146
  choices.append(questionary.Choice(title="⌨ enter console", value="enter"))
3023
3147
  if internal_status == "running":
3024
3148
  choices.append(questionary.Choice(title="▶ run a command", value="run"))
3025
- choices.append(questionary.Choice(title="⤴ publish a port", value="publish"))
3149
+ choices.append(questionary.Choice(title="⤴ publish service", value="publish"))
3026
3150
  if power_state == "cold":
3027
3151
  choices.append(questionary.Choice(title="☀ start / wake", value="start"))
3028
3152
  choices.extend(
@@ -3030,7 +3154,7 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
3030
3154
  questionary.Choice(title="⧉ copy public url", value="copy-url"),
3031
3155
  questionary.Choice(title="↗ open in browser", value="open-url"),
3032
3156
  questionary.Choice(title=f"⚑ make {flip_visibility}", value=f"share:{flip_visibility}"),
3033
- questionary.Choice(title="⚙ startup config…", value="startup"),
3157
+ questionary.Choice(title="⚙ service…", value="startup"),
3034
3158
  questionary.Choice(title="ℹ refresh info", value="info"),
3035
3159
  questionary.Separator(),
3036
3160
  questionary.Choice(title="✕ delete", value="delete"),
@@ -3080,13 +3204,14 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
3080
3204
  if port <= 0 or port > 65535:
3081
3205
  _UI.err().print("[red]port must be between 1 and 65535[/red]")
3082
3206
  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]"
3207
+ command = _prompt_text("Command to run as the service")
3208
+ if not command:
3209
+ continue
3210
+ result = _with_spinner(
3211
+ f"publishing service on {slug}…",
3212
+ lambda: client.publish_port(vm_id, port, command=command),
3089
3213
  )
3214
+ _render_startup_panel(result, title=f"service: {slug}")
3090
3215
  continue
3091
3216
 
3092
3217
  if action == "info":
@@ -3143,11 +3268,11 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
3143
3268
 
3144
3269
 
3145
3270
  def _vm_startup_submenu(client: RuntimeClient, vm_id: str, slug: str) -> None:
3146
- """Inner submenu for durable startup config."""
3271
+ """Inner submenu for the durable service."""
3147
3272
  questionary = _UI.q()
3148
3273
  while True:
3149
- current = _with_spinner("fetching startup…", lambda: client.get_startup_config(vm_id))
3150
- _render_startup_panel(current, title=f"startup: {slug}")
3274
+ current = _with_spinner("fetching service…", lambda: client.get_service(vm_id))
3275
+ _render_startup_panel(current, title=f"service: {slug}")
3151
3276
  configured = bool(current.get("configured"))
3152
3277
  choices: list[Any] = [
3153
3278
  questionary.Choice(title="✎ set / replace", value="set"),
@@ -3160,11 +3285,11 @@ def _vm_startup_submenu(client: RuntimeClient, vm_id: str, slug: str) -> None:
3160
3285
  questionary.Choice(title="← back", value="__back__"),
3161
3286
  ]
3162
3287
  )
3163
- action = _select_prompt("startup config", choices).unsafe_ask()
3288
+ action = _select_prompt("service", choices).unsafe_ask()
3164
3289
  if action in (None, "__back__"):
3165
3290
  return
3166
3291
  if action == "set":
3167
- command = _prompt_text("Startup command", default=str(current.get("command") or ""))
3292
+ command = _prompt_text("Service command", default=str(current.get("command") or ""))
3168
3293
  if not command:
3169
3294
  _UI.console().print("[dim]cancelled[/dim]")
3170
3295
  continue
@@ -3182,20 +3307,20 @@ def _vm_startup_submenu(client: RuntimeClient, vm_id: str, slug: str) -> None:
3182
3307
  continue
3183
3308
  cwd = _prompt_text("Working directory (optional)", default=str(current.get("cwd") or ""))
3184
3309
  _with_spinner(
3185
- "saving startup config…",
3186
- lambda: client.set_startup_config(vm_id, command=command, cwd=cwd or None, port=port),
3310
+ "starting service…",
3311
+ lambda: client.run_service(vm_id, command=command, cwd=cwd or None, port=port),
3187
3312
  )
3188
- _UI.console().print(f"[green]✓[/green] startup saved for [bold]{slug}[/bold]")
3313
+ _UI.console().print(f"[green]✓[/green] service started for [bold]{slug}[/bold]")
3189
3314
  continue
3190
3315
  if action == "clear":
3191
- if not _prompt_confirm(f"Clear startup config for {slug}?", default=False):
3316
+ if not _prompt_confirm(f"Clear service for {slug}?", default=False):
3192
3317
  _UI.console().print("[dim]cancelled[/dim]")
3193
3318
  continue
3194
3319
  _with_spinner(
3195
- "clearing startup config…",
3196
- lambda: client.clear_startup_config(vm_id),
3320
+ "clearing service…",
3321
+ lambda: client.clear_service(vm_id),
3197
3322
  )
3198
- _UI.console().print(f"[green]✓[/green] cleared startup config for [bold]{slug}[/bold]")
3323
+ _UI.console().print(f"[green]✓[/green] cleared service for [bold]{slug}[/bold]")
3199
3324
  continue
3200
3325
 
3201
3326
 
@@ -3209,7 +3334,6 @@ _HELP_TOPICS: dict[str, tuple[str, ...]] = {
3209
3334
  "computers": ("Computers",),
3210
3335
  "share": ("Sharing",),
3211
3336
  "sharing": ("Sharing",),
3212
- "startup": ("Startup & service",),
3213
3337
  "service": ("Startup & service",),
3214
3338
  "proxy": ("Proxy",),
3215
3339
  "github": ("GitHub",),
@@ -3251,7 +3375,7 @@ _runtime_complete() {
3251
3375
  COMPREPLY=()
3252
3376
  cur="${COMP_WORDS[COMP_CWORD]}"
3253
3377
  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") )
3378
+ 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
3379
  return
3256
3380
  fi
3257
3381
  sub="${COMP_WORDS[1]}"
@@ -3295,7 +3419,6 @@ _runtime() {
3295
3419
  'delete:Delete a computer'
3296
3420
  'rm:Delete a computer'
3297
3421
  'share:Set visibility'
3298
- 'startup:Startup config'
3299
3422
  'service:Service config'
3300
3423
  'proxy:Port proxies'
3301
3424
  'switch:Open a GitHub branch workspace'
@@ -3363,7 +3486,6 @@ complete -c runtime -n '__fish_use_subcommand' -a publish -d 'Publish port'
3363
3486
  complete -c runtime -n '__fish_use_subcommand' -a delete -d 'Delete'
3364
3487
  complete -c runtime -n '__fish_use_subcommand' -a rm -d 'Delete'
3365
3488
  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
3489
  complete -c runtime -n '__fish_use_subcommand' -a service -d 'Service config'
3368
3490
  complete -c runtime -n '__fish_use_subcommand' -a proxy -d 'Port proxies'
3369
3491
  complete -c runtime -n '__fish_use_subcommand' -a github -d 'GitHub connections'
@@ -3371,7 +3493,7 @@ complete -c runtime -n '__fish_use_subcommand' -a integrate -d 'Connect GitHub'
3371
3493
  complete -c runtime -n '__fish_use_subcommand' -a help -d 'Extended help'
3372
3494
  complete -c runtime -n '__fish_use_subcommand' -a completion -d 'Completion'
3373
3495
 
3374
- complete -c runtime -n '__fish_seen_subcommand_from info url start enter run publish delete rm startup service' -a '(__runtime_slugs)'
3496
+ complete -c runtime -n '__fish_seen_subcommand_from info url start enter run publish delete rm service' -a '(__runtime_slugs)'
3375
3497
  complete -c runtime -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
3376
3498
  complete -c runtime -n '__fish_seen_subcommand_from share' -a 'public private'
3377
3499
  """
@@ -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.29
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