runtime-sdk 0.4.33__tar.gz → 0.4.35__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.33
3
+ Version: 0.4.35
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
@@ -84,6 +84,15 @@ runtime share private <id>
84
84
  runtime start <id>
85
85
  runtime run <id> "echo hello" # one-shot foreground command only
86
86
  runtime run <id> "apt install -y nodejs" --uid 0
87
+ runtime exec <id> -- bash -lc 'for i in 1 2 3; do echo $i; sleep 1; done'
88
+ printf 'hello' | runtime exec <id> --stdin -- cat
89
+ runtime files ls <id> /home/ubuntu
90
+ runtime files read <id> /home/ubuntu/app.py --output app.py
91
+ runtime files write <id> /home/ubuntu/app.py --input app.py --mode 0644
92
+ runtime files mkdir <id> /home/ubuntu/data --parents
93
+ runtime files upload <id> ./local-app /home/ubuntu/app
94
+ runtime files download <id> /home/ubuntu/app ./downloaded-app
95
+ runtime files rm <id> /home/ubuntu/data --recursive
87
96
  runtime startup show <id>
88
97
  runtime startup set <id> --command "python3 app.py" --cwd /home/ubuntu --port 3000
89
98
  runtime startup clear <id> # low-level durable service config
@@ -123,6 +132,10 @@ app = client.create_computer(
123
132
  result = client.run_command(computer["id"], "echo hello")
124
133
  print(result["stdout"])
125
134
 
135
+ client.write_file(computer["id"], "/home/ubuntu/hello.txt", b"hello\n")
136
+ print(client.read_file(computer["id"], "/home/ubuntu/hello.txt"))
137
+ print(client.list_files(computer["id"], "/home/ubuntu"))
138
+
126
139
  # Wake a cold computer explicitly
127
140
  client.start_computer(app["id"])
128
141
 
@@ -62,6 +62,15 @@ runtime share private <id>
62
62
  runtime start <id>
63
63
  runtime run <id> "echo hello" # one-shot foreground command only
64
64
  runtime run <id> "apt install -y nodejs" --uid 0
65
+ runtime exec <id> -- bash -lc 'for i in 1 2 3; do echo $i; sleep 1; done'
66
+ printf 'hello' | runtime exec <id> --stdin -- cat
67
+ runtime files ls <id> /home/ubuntu
68
+ runtime files read <id> /home/ubuntu/app.py --output app.py
69
+ runtime files write <id> /home/ubuntu/app.py --input app.py --mode 0644
70
+ runtime files mkdir <id> /home/ubuntu/data --parents
71
+ runtime files upload <id> ./local-app /home/ubuntu/app
72
+ runtime files download <id> /home/ubuntu/app ./downloaded-app
73
+ runtime files rm <id> /home/ubuntu/data --recursive
65
74
  runtime startup show <id>
66
75
  runtime startup set <id> --command "python3 app.py" --cwd /home/ubuntu --port 3000
67
76
  runtime startup clear <id> # low-level durable service config
@@ -101,6 +110,10 @@ app = client.create_computer(
101
110
  result = client.run_command(computer["id"], "echo hello")
102
111
  print(result["stdout"])
103
112
 
113
+ client.write_file(computer["id"], "/home/ubuntu/hello.txt", b"hello\n")
114
+ print(client.read_file(computer["id"], "/home/ubuntu/hello.txt"))
115
+ print(client.list_files(computer["id"], "/home/ubuntu"))
116
+
104
117
  # Wake a cold computer explicitly
105
118
  client.start_computer(app["id"])
106
119
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runtime-sdk"
3
- version = "0.4.33"
3
+ version = "0.4.35"
4
4
  description = "Runtime Python SDK and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -15,6 +15,8 @@ import socket
15
15
  import struct
16
16
  import subprocess
17
17
  import sys
18
+ import tarfile
19
+ import tempfile
18
20
  import threading
19
21
  import time
20
22
  import webbrowser
@@ -278,6 +280,53 @@ def build_parser() -> argparse.ArgumentParser:
278
280
  run_cmd.add_argument("--cwd", help="Working directory")
279
281
  run_cmd.add_argument("--shell", help="Shell to use")
280
282
 
283
+ exec_cmd = subparsers.add_parser("exec", help="Stream a command in a computer")
284
+ exec_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
285
+ exec_cmd.add_argument("exec_command", nargs="*", help="Command after --")
286
+ exec_cmd.add_argument("--stdin", action="store_true", help="Pipe local stdin to the command")
287
+ exec_cmd.add_argument("--uid", type=int, help="User ID")
288
+ exec_cmd.add_argument("--gid", type=int, help="Group ID")
289
+ exec_cmd.add_argument("--cwd", help="Working directory")
290
+ exec_cmd.add_argument("--shell", help="Shell to use")
291
+
292
+ files_cmd = subparsers.add_parser("files", aliases=["fs", "file"], help="Read, write, list, and delete files")
293
+ files_subparsers = files_cmd.add_subparsers(dest="files_action", required=True)
294
+ files_read = files_subparsers.add_parser("read", help="Read a file to stdout or --output")
295
+ files_read.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
296
+ files_read.add_argument("path", nargs="?", default=None, help="Path inside the computer")
297
+ files_read.add_argument("--output", "-o", help="Local output file")
298
+ files_write = files_subparsers.add_parser("write", help="Write stdin, --content, or --input into a file")
299
+ files_write.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
300
+ files_write.add_argument("path", nargs="?", default=None, help="Path inside the computer")
301
+ files_write.add_argument("--input", "-i", help="Local input file (defaults to stdin)")
302
+ files_write.add_argument("--content", help="Literal text to write")
303
+ files_write.add_argument("--mode", help="File mode, e.g. 0644")
304
+ files_write.add_argument("--uid", type=int, help="User ID")
305
+ files_write.add_argument("--gid", type=int, help="Group ID")
306
+ files_list = files_subparsers.add_parser("list", aliases=["ls"], help="List a directory")
307
+ files_list.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
308
+ files_list.add_argument("path", nargs="?", default=None, help="Directory path")
309
+ files_stat = files_subparsers.add_parser("stat", help="Show file metadata")
310
+ files_stat.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
311
+ files_stat.add_argument("path", nargs="?", default=None, help="Path inside the computer")
312
+ files_mkdir = files_subparsers.add_parser("mkdir", help="Create a directory")
313
+ files_mkdir.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
314
+ files_mkdir.add_argument("path", nargs="?", default=None, help="Directory path")
315
+ files_mkdir.add_argument("--parents", "-p", action="store_true", help="Create parent directories")
316
+ files_mkdir.add_argument("--mode", help="Directory mode, e.g. 0755")
317
+ files_rm = files_subparsers.add_parser("rm", aliases=["delete"], help="Delete a file or directory")
318
+ files_rm.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
319
+ files_rm.add_argument("path", nargs="?", default=None, help="Path inside the computer")
320
+ files_rm.add_argument("--recursive", "-r", action="store_true", help="Delete directories recursively")
321
+ files_upload = files_subparsers.add_parser("upload", help="Upload a local file or directory")
322
+ files_upload.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
323
+ files_upload.add_argument("local", nargs="?", default=None, help="Local file or directory")
324
+ files_upload.add_argument("remote", nargs="?", default=None, help="Remote destination directory")
325
+ files_download = files_subparsers.add_parser("download", help="Download a remote file or directory")
326
+ files_download.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
327
+ files_download.add_argument("remote", nargs="?", default=None, help="Remote file or directory")
328
+ files_download.add_argument("local", nargs="?", default=None, help="Local destination directory")
329
+
281
330
  publish_cmd = subparsers.add_parser(
282
331
  "publish", help="Promote the running app on a local port into the durable public app"
283
332
  )
@@ -361,10 +410,19 @@ _HELP_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
361
410
  ("start [id]", "Wake a cold computer"),
362
411
  ("enter [id]", "Enter a computer's console (TTY)"),
363
412
  ("run [id] <cmd>", "Run a one-shot command"),
413
+ ("exec [id] -- <cmd>", "Stream stdout/stderr and optionally pipe stdin"),
364
414
  ("publish [id] <port> -- <cmd>", "Run and publish a durable service command"),
365
415
  ("delete, rm [id]", "Delete a computer (typed-name confirm; --force / -y to skip)"),
366
416
  ),
367
417
  ),
418
+ (
419
+ "Files",
420
+ (
421
+ ("files read [id] <path>", "Read a file to stdout or --output"),
422
+ ("files write [id] <path>", "Write stdin, --input, or --content to a file"),
423
+ ("files ls/stat/mkdir/rm", "List, inspect, create, and delete paths"),
424
+ ),
425
+ ),
368
426
  (
369
427
  "Sharing",
370
428
  (
@@ -570,6 +628,10 @@ def main(argv: list[str] | None = None) -> int:
570
628
  return handle_run(
571
629
  config, args.id, args.run_command, args.uid, args.gid, args.cwd, args.shell
572
630
  )
631
+ if args.command == "exec":
632
+ return handle_exec_stream(config, args.id, args.exec_command, args.stdin, args.uid, args.gid, args.cwd, args.shell)
633
+ if args.command in ("files", "fs", "file"):
634
+ return handle_files(config, args)
573
635
  if args.command == "publish":
574
636
  return handle_publish(config, args.id, args.port, args.publish_command)
575
637
  if args.command == "startup":
@@ -1068,6 +1130,36 @@ def _computer_url_label(c: dict[str, Any]) -> str:
1068
1130
 
1069
1131
 
1070
1132
 
1133
+ def _computer_email_address(c: dict[str, Any]) -> str:
1134
+ explicit = str(c.get("email_address") or "").strip()
1135
+ if explicit:
1136
+ return explicit
1137
+ slug = str(c.get("slug") or "").strip()
1138
+ public_url = str(c.get("public_url") or "").strip()
1139
+ if not slug or not public_url:
1140
+ return ""
1141
+ host = urlparse(public_url).netloc
1142
+ if not host:
1143
+ return ""
1144
+ suffix = host.removeprefix(slug + ".")
1145
+ if suffix == host or not suffix:
1146
+ return ""
1147
+ return f"{slug}@mail.{suffix}"
1148
+
1149
+
1150
+
1151
+ def _with_computer_email(payload: dict[str, Any]) -> dict[str, Any]:
1152
+ if payload.get("email_address"):
1153
+ return payload
1154
+ email_address = _computer_email_address(payload)
1155
+ if not email_address:
1156
+ return payload
1157
+ enriched = dict(payload)
1158
+ enriched["email_address"] = email_address
1159
+ return enriched
1160
+
1161
+
1162
+
1071
1163
  def _relative_time_label(value: Any) -> str:
1072
1164
  raw = str(value or "").strip()
1073
1165
  if not raw:
@@ -1282,6 +1374,7 @@ def _render_computer_panel(c: dict[str, Any], *, title: str = "computer") -> Non
1282
1374
  ("state", f"{_computer_status_dot(status)} {status}", status_style),
1283
1375
  ("share", str(c.get("visibility") or "public"), "bold white"),
1284
1376
  ("url", _computer_url_label(c) or "—", "white"),
1377
+ ("email", _computer_email_address(c) or "—", "white"),
1285
1378
  ("age", _computer_created_label(c) or "—", "dim"),
1286
1379
  ("id", str(c.get("id") or "—"), "dim"),
1287
1380
  ]
@@ -2118,10 +2211,14 @@ def handle_create(
2118
2211
 
2119
2212
  def render(payload: dict[str, Any]) -> None:
2120
2213
  _render_computer_panel(payload, title="created")
2214
+ email_address = payload.get("email_address") or _computer_email_address(payload)
2215
+ if email_address:
2216
+ _UI.console().print(f"[bold green]@[/bold green] [cyan]{email_address}[/cyan]")
2121
2217
  url = payload.get("public_url")
2122
2218
  if url:
2123
2219
  _UI.console().print(f"[bold green]→[/bold green] [cyan]{url}[/cyan]")
2124
2220
 
2221
+ result = _with_computer_email(result)
2125
2222
  code = report_success(result, render)
2126
2223
  if code != 0 or not _interactive():
2127
2224
  return code
@@ -2590,6 +2687,241 @@ def handle_run(
2590
2687
  return report_success(result, _render_run_result)
2591
2688
 
2592
2689
 
2690
+ def handle_exec_stream(
2691
+ config: RuntimeConfig,
2692
+ computer_id: str | None,
2693
+ command_parts: list[str] | None,
2694
+ pipe_stdin: bool,
2695
+ uid: int | None,
2696
+ gid: int | None,
2697
+ cwd: str | None,
2698
+ shell: str | None,
2699
+ ) -> int:
2700
+ if (err := _require_api_key(config)) is not None:
2701
+ return err
2702
+ if not computer_id:
2703
+ return report_error("computer id is required")
2704
+ command = _command_from_remainder(command_parts)
2705
+ if not command:
2706
+ return report_error("exec requires -- <command...>")
2707
+ stdin_data = None
2708
+ if pipe_stdin:
2709
+ stdin_buffer = getattr(sys.stdin, "buffer", None)
2710
+ stdin_data = stdin_buffer.read() if stdin_buffer is not None else sys.stdin.read().encode()
2711
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2712
+ computer_id = _resolve_computer_ref(client, computer_id)
2713
+ exit_code = 0
2714
+ for frame in client.stream_command(computer_id, command, stdin=stdin_data, uid=uid, gid=gid, cwd=cwd, shell=shell):
2715
+ stdout = frame.get("stdout") or frame.get("data") or ""
2716
+ stderr = frame.get("stderr") or ""
2717
+ error = frame.get("error") or ""
2718
+ if stdout:
2719
+ print(stdout, end="")
2720
+ if stderr:
2721
+ print(stderr, end="", file=sys.stderr)
2722
+ if error:
2723
+ print(error, file=sys.stderr)
2724
+ exit_code = 1
2725
+ if frame.get("type") == "exit" or frame.get("exit_code") is not None:
2726
+ try:
2727
+ exit_code = int(frame.get("exit_code") or 0)
2728
+ except (TypeError, ValueError):
2729
+ exit_code = 1
2730
+ return exit_code
2731
+
2732
+
2733
+ def handle_files(config: RuntimeConfig, args: Any) -> int:
2734
+ action = str(getattr(args, "files_action", "") or "")
2735
+ if action == "read":
2736
+ return handle_files_read(config, args.id, args.path, args.output)
2737
+ if action == "write":
2738
+ return handle_files_write(config, args)
2739
+ if action in ("list", "ls"):
2740
+ return handle_files_list(config, args.id, args.path)
2741
+ if action == "stat":
2742
+ return handle_files_stat(config, args.id, args.path)
2743
+ if action == "mkdir":
2744
+ return handle_files_mkdir(config, args.id, args.path, args.parents, args.mode)
2745
+ if action in ("rm", "delete"):
2746
+ return handle_files_rm(config, args.id, args.path, args.recursive)
2747
+ if action == "upload":
2748
+ return handle_files_upload(config, args.id, args.local, args.remote)
2749
+ if action == "download":
2750
+ return handle_files_download(config, args.id, args.remote, args.local)
2751
+ return report_error("unknown files command")
2752
+
2753
+
2754
+ def _resolve_file_target(config: RuntimeConfig, computer_id: str | None, path: str | None) -> tuple[RuntimeClient, str, str] | int:
2755
+ if (err := _require_api_key(config)) is not None:
2756
+ return err
2757
+ if not computer_id:
2758
+ return report_error("computer id is required")
2759
+ if not path:
2760
+ return report_error("path is required")
2761
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2762
+ resolved_id = _resolve_computer_ref(client, computer_id)
2763
+ return client, resolved_id, path
2764
+
2765
+
2766
+ def handle_files_read(config: RuntimeConfig, computer_id: str | None, path: str | None, output: str | None) -> int:
2767
+ resolved = _resolve_file_target(config, computer_id, path)
2768
+ if isinstance(resolved, int):
2769
+ return resolved
2770
+ client, resolved_id, resolved_path = resolved
2771
+ data = client.read_file(resolved_id, resolved_path)
2772
+ if output:
2773
+ try:
2774
+ Path(output).write_bytes(data)
2775
+ except OSError as exc:
2776
+ return report_error(f"failed to write output file: {exc}")
2777
+ return report_success({"path": resolved_path, "output": output, "size": len(data)}, lambda p: _UI.console().print(f"[green]✓[/green] wrote [bold]{p['size']}[/bold] bytes to [bold]{p['output']}[/bold]"))
2778
+ stdout_buffer = getattr(sys.stdout, "buffer", None)
2779
+ if stdout_buffer is None:
2780
+ sys.stdout.write(data.decode(errors="replace"))
2781
+ sys.stdout.flush()
2782
+ return 0
2783
+ stdout_buffer.write(data)
2784
+ stdout_buffer.flush()
2785
+ return 0
2786
+
2787
+
2788
+ def handle_files_write(config: RuntimeConfig, args: Any) -> int:
2789
+ input_path = getattr(args, "input", None)
2790
+ content = getattr(args, "content", None)
2791
+ if input_path and content is not None:
2792
+ return report_error("pass either --input or --content, not both")
2793
+ resolved = _resolve_file_target(config, args.id, args.path)
2794
+ if isinstance(resolved, int):
2795
+ return resolved
2796
+ client, resolved_id, resolved_path = resolved
2797
+ if input_path:
2798
+ try:
2799
+ data = Path(input_path).read_bytes()
2800
+ except OSError as exc:
2801
+ return report_error(f"failed to read input file: {exc}")
2802
+ elif content is not None:
2803
+ data = content.encode()
2804
+ else:
2805
+ stdin_buffer = getattr(sys.stdin, "buffer", None)
2806
+ data = stdin_buffer.read() if stdin_buffer is not None else sys.stdin.read().encode()
2807
+ result = client.write_file(resolved_id, resolved_path, data, uid=args.uid, gid=args.gid, mode=args.mode)
2808
+ return report_success(result, lambda p: _UI.console().print(f"[green]✓[/green] wrote [bold]{p.get('size', len(data))}[/bold] bytes to [bold]{p.get('path', resolved_path)}[/bold]"))
2809
+
2810
+
2811
+ def _render_file_list(payload: dict[str, Any]) -> None:
2812
+ mods = _UI.rich()
2813
+ Table = mods["Table"]
2814
+ table = Table(title=str(payload.get("path") or "files"))
2815
+ table.add_column("Name", style="bold white")
2816
+ table.add_column("Type")
2817
+ table.add_column("Size", justify="right")
2818
+ table.add_column("Mode", style="dim")
2819
+ for entry in payload.get("entries") or []:
2820
+ if not isinstance(entry, dict):
2821
+ continue
2822
+ table.add_row(str(entry.get("name") or ""), str(entry.get("type") or ""), str(entry.get("size") or 0), str(entry.get("mode") or ""))
2823
+ _UI.console().print(table)
2824
+
2825
+
2826
+ def handle_files_list(config: RuntimeConfig, computer_id: str | None, path: str | None) -> int:
2827
+ resolved = _resolve_file_target(config, computer_id, path or "/home/ubuntu")
2828
+ if isinstance(resolved, int):
2829
+ return resolved
2830
+ client, resolved_id, resolved_path = resolved
2831
+ return report_success(client.list_files(resolved_id, resolved_path), _render_file_list)
2832
+
2833
+
2834
+ def handle_files_stat(config: RuntimeConfig, computer_id: str | None, path: str | None) -> int:
2835
+ resolved = _resolve_file_target(config, computer_id, path)
2836
+ if isinstance(resolved, int):
2837
+ return resolved
2838
+ client, resolved_id, resolved_path = resolved
2839
+ return report_success(client.stat_file(resolved_id, resolved_path))
2840
+
2841
+
2842
+ def handle_files_mkdir(config: RuntimeConfig, computer_id: str | None, path: str | None, parents: bool, mode: str | None) -> int:
2843
+ resolved = _resolve_file_target(config, computer_id, path)
2844
+ if isinstance(resolved, int):
2845
+ return resolved
2846
+ client, resolved_id, resolved_path = resolved
2847
+ result = client.mkdir(resolved_id, resolved_path, recursive=parents, mode=mode)
2848
+ return report_success(result, lambda p: _UI.console().print(f"[green]✓[/green] created [bold]{p.get('name', resolved_path)}[/bold]"))
2849
+
2850
+
2851
+ def handle_files_rm(config: RuntimeConfig, computer_id: str | None, path: str | None, recursive: bool) -> int:
2852
+ resolved = _resolve_file_target(config, computer_id, path)
2853
+ if isinstance(resolved, int):
2854
+ return resolved
2855
+ client, resolved_id, resolved_path = resolved
2856
+ result = client.delete_file(resolved_id, resolved_path, recursive=recursive)
2857
+ return report_success(result, lambda p: _UI.console().print(f"[green]✓[/green] deleted [bold]{p.get('path', resolved_path)}[/bold]"))
2858
+
2859
+
2860
+ def handle_files_upload(config: RuntimeConfig, computer_id: str | None, local: str | None, remote: str | None) -> int:
2861
+ if not local:
2862
+ return report_error("local path is required")
2863
+ local_path = Path(local)
2864
+ if not local_path.exists():
2865
+ return report_error(f"local path does not exist: {local}")
2866
+ resolved = _resolve_file_target(config, computer_id, remote)
2867
+ if isinstance(resolved, int):
2868
+ return resolved
2869
+ client, resolved_id, remote_path = resolved
2870
+ with tempfile.TemporaryFile() as archive:
2871
+ try:
2872
+ with tarfile.open(fileobj=archive, mode="w") as tar:
2873
+ if local_path.is_dir():
2874
+ for child in sorted(local_path.rglob("*")):
2875
+ tar.add(child, arcname=str(child.relative_to(local_path)), recursive=False)
2876
+ else:
2877
+ tar.add(local_path, arcname=local_path.name, recursive=False)
2878
+ except (OSError, tarfile.TarError) as exc:
2879
+ return report_error(f"failed to read local path: {exc}")
2880
+ archive.seek(0)
2881
+ result = client.upload_files(resolved_id, remote_path, archive)
2882
+ return report_success(result, lambda p: _UI.console().print(f"[green]✓[/green] uploaded [bold]{p.get('files', 0)}[/bold] files to [bold]{p.get('path', remote_path)}[/bold]"))
2883
+
2884
+
2885
+ def handle_files_download(config: RuntimeConfig, computer_id: str | None, remote: str | None, local: str | None) -> int:
2886
+ local_dir = Path(local or ".")
2887
+ resolved = _resolve_file_target(config, computer_id, remote)
2888
+ if isinstance(resolved, int):
2889
+ return resolved
2890
+ client, resolved_id, remote_path = resolved
2891
+ try:
2892
+ local_dir.mkdir(parents=True, exist_ok=True)
2893
+ with tempfile.TemporaryFile() as archive:
2894
+ client.download_files_to(resolved_id, remote_path, archive)
2895
+ archive.seek(0)
2896
+ with tarfile.open(fileobj=archive, mode="r") as tar:
2897
+ members = tar.getmembers()
2898
+ for member in members:
2899
+ target = safe_local_extract_path(local_dir, member.name)
2900
+ if member.isdir():
2901
+ target.mkdir(parents=True, exist_ok=True)
2902
+ continue
2903
+ if not member.isfile():
2904
+ return report_error(f"unsupported archive entry: {member.name}")
2905
+ target.parent.mkdir(parents=True, exist_ok=True)
2906
+ source = tar.extractfile(member)
2907
+ if source is None:
2908
+ return report_error(f"missing archive data: {member.name}")
2909
+ target.write_bytes(source.read())
2910
+ except (OSError, tarfile.TarError) as exc:
2911
+ return report_error(f"failed to write downloaded files: {exc}")
2912
+ return report_success({"path": remote_path, "output": str(local_dir), "entries": len(members)}, lambda p: _UI.console().print(f"[green]✓[/green] downloaded [bold]{p['entries']}[/bold] entries to [bold]{p['output']}[/bold]"))
2913
+
2914
+
2915
+ def safe_local_extract_path(root: Path, name: str) -> Path:
2916
+ if not name or Path(name).is_absolute():
2917
+ raise RuntimeAPIError(f"unsafe archive entry: {name}")
2918
+ target = (root / name).resolve()
2919
+ root_resolved = root.resolve()
2920
+ if target != root_resolved and root_resolved not in target.parents:
2921
+ raise RuntimeAPIError(f"unsafe archive entry: {name}")
2922
+ return target
2923
+
2924
+
2593
2925
  def handle_publish(config: RuntimeConfig, computer_id: str | None, port: int | None, command_parts: list[str] | None = None) -> int:
2594
2926
  if (err := _require_api_key(config)) is not None:
2595
2927
  return err
@@ -1,7 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
4
+ import json
3
5
  from dataclasses import dataclass
4
- from typing import Any
6
+ from typing import Any, BinaryIO
7
+ from urllib.parse import urlencode
5
8
 
6
9
  import httpx
7
10
 
@@ -284,6 +287,138 @@ class RuntimeClient:
284
287
  body["shell"] = shell
285
288
  return self._request("POST", f"/api/computers/{computer_id}/exec", json=body, auth_required=True)
286
289
 
290
+ def stream_command(
291
+ self,
292
+ computer_id: str,
293
+ command: str,
294
+ *,
295
+ stdin: bytes | None = None,
296
+ uid: int | None = None,
297
+ gid: int | None = None,
298
+ cwd: str | None = None,
299
+ shell: str | None = None,
300
+ ):
301
+ body: dict[str, Any] = {"command": command}
302
+ if stdin:
303
+ body["stdin_base64"] = base64.b64encode(stdin).decode()
304
+ if uid is not None:
305
+ body["uid"] = uid
306
+ if gid is not None:
307
+ body["gid"] = gid
308
+ if cwd is not None:
309
+ body["cwd"] = cwd
310
+ if shell is not None:
311
+ body["shell"] = shell
312
+ headers = self._headers(True)
313
+ client = httpx.Client(base_url=self.base_url, timeout=None, transport=self.transport)
314
+ try:
315
+ with client.stream("POST", f"/api/computers/{computer_id}/exec/stream", json=body, headers=headers) as response:
316
+ if not response.is_success:
317
+ body_bytes = response.read()
318
+ payload: dict[str, Any] = {}
319
+ if body_bytes:
320
+ try:
321
+ decoded = json.loads(body_bytes.decode())
322
+ if isinstance(decoded, dict):
323
+ payload = decoded
324
+ except ValueError:
325
+ pass
326
+ message = payload.get("error") or payload.get("message") or f"http {response.status_code}"
327
+ raise RuntimeAPIError(str(message), status_code=response.status_code)
328
+ for line in response.iter_lines():
329
+ if not line:
330
+ continue
331
+ try:
332
+ frame = json.loads(line)
333
+ except ValueError as exc:
334
+ raise RuntimeAPIError("server returned invalid NDJSON", status_code=response.status_code) from exc
335
+ if not isinstance(frame, dict):
336
+ raise RuntimeAPIError("server returned non-object NDJSON", status_code=response.status_code)
337
+ yield frame
338
+ except httpx.HTTPError as exc:
339
+ raise RuntimeAPIError(f"request failed: {exc}") from exc
340
+ finally:
341
+ client.close()
342
+
343
+ def read_file(self, computer_id: str, path: str) -> bytes:
344
+ return self._request_bytes(
345
+ "GET",
346
+ f"/api/computers/{computer_id}/files/read",
347
+ params={"path": path},
348
+ auth_required=True,
349
+ )
350
+
351
+ def write_file(
352
+ self,
353
+ computer_id: str,
354
+ path: str,
355
+ data: bytes,
356
+ *,
357
+ uid: int | None = None,
358
+ gid: int | None = None,
359
+ mode: str | None = None,
360
+ ) -> dict[str, Any]:
361
+ params: dict[str, Any] = {"path": path}
362
+ if uid is not None:
363
+ params["uid"] = uid
364
+ if gid is not None:
365
+ params["gid"] = gid
366
+ if mode is not None:
367
+ params["mode"] = mode
368
+ return self._request(
369
+ "PUT",
370
+ f"/api/computers/{computer_id}/files/write",
371
+ params=params,
372
+ data=data,
373
+ auth_required=True,
374
+ )
375
+
376
+ def list_files(self, computer_id: str, path: str) -> dict[str, Any]:
377
+ return self._request("GET", f"/api/computers/{computer_id}/files/list", params={"path": path}, auth_required=True)
378
+
379
+ def stat_file(self, computer_id: str, path: str) -> dict[str, Any]:
380
+ return self._request("GET", f"/api/computers/{computer_id}/files/stat", params={"path": path}, auth_required=True)
381
+
382
+ def mkdir(self, computer_id: str, path: str, *, recursive: bool = False, mode: str | None = None) -> dict[str, Any]:
383
+ body: dict[str, Any] = {"path": path, "recursive": recursive}
384
+ if mode is not None:
385
+ body["mode"] = mode
386
+ return self._request("POST", f"/api/computers/{computer_id}/files/mkdir", json=body, auth_required=True)
387
+
388
+ def delete_file(self, computer_id: str, path: str, *, recursive: bool = False) -> dict[str, Any]:
389
+ return self._request(
390
+ "DELETE",
391
+ f"/api/computers/{computer_id}/files/delete",
392
+ params={"path": path, "recursive": str(recursive).lower()},
393
+ auth_required=True,
394
+ )
395
+
396
+ def upload_files(self, computer_id: str, path: str, archive: Any) -> dict[str, Any]:
397
+ return self._request(
398
+ "PUT",
399
+ f"/api/computers/{computer_id}/files/upload",
400
+ params={"path": path},
401
+ data=archive,
402
+ auth_required=True,
403
+ )
404
+
405
+ def download_files(self, computer_id: str, path: str) -> bytes:
406
+ return self._request_bytes(
407
+ "GET",
408
+ f"/api/computers/{computer_id}/files/download",
409
+ params={"path": path},
410
+ auth_required=True,
411
+ )
412
+
413
+ def download_files_to(self, computer_id: str, path: str, writer: BinaryIO) -> None:
414
+ self._request_bytes_to(
415
+ "GET",
416
+ f"/api/computers/{computer_id}/files/download",
417
+ writer,
418
+ params={"path": path},
419
+ auth_required=True,
420
+ )
421
+
287
422
  def publish_port(self, computer_id: str, port: int, *, command: str | None = None, cwd: str | None = None) -> dict[str, Any]:
288
423
  if port <= 0 or port > 65535:
289
424
  raise RuntimeAPIError("port must be between 1 and 65535")
@@ -297,14 +432,12 @@ class RuntimeClient:
297
432
  path: str,
298
433
  *,
299
434
  json: dict[str, Any] | None = None,
435
+ data: Any = None,
436
+ params: dict[str, Any] | None = None,
300
437
  auth_required: bool = False,
301
438
  timeout: float | None = None,
302
439
  ) -> dict[str, Any]:
303
- headers: dict[str, str] = {}
304
- if auth_required:
305
- if not self.api_key:
306
- raise RuntimeAPIError("missing api key")
307
- headers["Authorization"] = f"Bearer {self.api_key}"
440
+ headers = self._headers(auth_required)
308
441
 
309
442
  try:
310
443
  with httpx.Client(
@@ -312,7 +445,7 @@ class RuntimeClient:
312
445
  timeout=self.timeout if timeout is None else timeout,
313
446
  transport=self.transport,
314
447
  ) as client:
315
- response = client.request(method, path, json=json, headers=headers)
448
+ response = client.request(method, self._path(path, params), json=json, content=data, headers=headers)
316
449
  except httpx.HTTPError as exc:
317
450
  raise RuntimeAPIError(f"request failed: {exc}") from exc
318
451
 
@@ -323,6 +456,80 @@ class RuntimeClient:
323
456
  message = payload.get("error") or payload.get("message") or f"http {response.status_code}"
324
457
  raise RuntimeAPIError(str(message), status_code=response.status_code)
325
458
 
459
+ def _request_bytes(
460
+ self,
461
+ method: str,
462
+ path: str,
463
+ *,
464
+ params: dict[str, Any] | None = None,
465
+ auth_required: bool = False,
466
+ timeout: float | None = None,
467
+ ) -> bytes:
468
+ headers = self._headers(auth_required)
469
+ try:
470
+ with httpx.Client(
471
+ base_url=self.base_url,
472
+ timeout=self.timeout if timeout is None else timeout,
473
+ transport=self.transport,
474
+ ) as client:
475
+ response = client.request(method, self._path(path, params), headers=headers)
476
+ except httpx.HTTPError as exc:
477
+ raise RuntimeAPIError(f"request failed: {exc}") from exc
478
+
479
+ if response.is_success:
480
+ return response.content
481
+
482
+ payload = self._decode_response(response)
483
+ message = payload.get("error") or payload.get("message") or f"http {response.status_code}"
484
+ raise RuntimeAPIError(str(message), status_code=response.status_code)
485
+
486
+ def _request_bytes_to(
487
+ self,
488
+ method: str,
489
+ path: str,
490
+ writer: BinaryIO,
491
+ *,
492
+ params: dict[str, Any] | None = None,
493
+ auth_required: bool = False,
494
+ timeout: float | None = None,
495
+ ) -> None:
496
+ headers = self._headers(auth_required)
497
+ try:
498
+ with httpx.Client(
499
+ base_url=self.base_url,
500
+ timeout=self.timeout if timeout is None else timeout,
501
+ transport=self.transport,
502
+ ) as client:
503
+ with client.stream(method, self._path(path, params), headers=headers) as response:
504
+ if response.is_success:
505
+ for chunk in response.iter_bytes():
506
+ writer.write(chunk)
507
+ return
508
+ response.read()
509
+ payload = self._decode_response(response)
510
+ except httpx.HTTPError as exc:
511
+ raise RuntimeAPIError(f"request failed: {exc}") from exc
512
+
513
+ message = payload.get("error") or payload.get("message") or f"http {response.status_code}"
514
+ raise RuntimeAPIError(str(message), status_code=response.status_code)
515
+
516
+ def _headers(self, auth_required: bool) -> dict[str, str]:
517
+ if not auth_required:
518
+ return {}
519
+ if not self.api_key:
520
+ raise RuntimeAPIError("missing api key")
521
+ return {"Authorization": f"Bearer {self.api_key}"}
522
+
523
+ @staticmethod
524
+ def _path(path: str, params: dict[str, Any] | None = None) -> str:
525
+ if not params:
526
+ return path
527
+ query = urlencode({key: value for key, value in params.items() if value is not None})
528
+ if not query:
529
+ return path
530
+ separator = "&" if "?" in path else "?"
531
+ return f"{path}{separator}{query}"
532
+
326
533
  @staticmethod
327
534
  def _decode_response(response: httpx.Response) -> dict[str, Any]:
328
535
  if not response.content:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.4.33
3
+ Version: 0.4.35
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
@@ -84,6 +84,15 @@ runtime share private <id>
84
84
  runtime start <id>
85
85
  runtime run <id> "echo hello" # one-shot foreground command only
86
86
  runtime run <id> "apt install -y nodejs" --uid 0
87
+ runtime exec <id> -- bash -lc 'for i in 1 2 3; do echo $i; sleep 1; done'
88
+ printf 'hello' | runtime exec <id> --stdin -- cat
89
+ runtime files ls <id> /home/ubuntu
90
+ runtime files read <id> /home/ubuntu/app.py --output app.py
91
+ runtime files write <id> /home/ubuntu/app.py --input app.py --mode 0644
92
+ runtime files mkdir <id> /home/ubuntu/data --parents
93
+ runtime files upload <id> ./local-app /home/ubuntu/app
94
+ runtime files download <id> /home/ubuntu/app ./downloaded-app
95
+ runtime files rm <id> /home/ubuntu/data --recursive
87
96
  runtime startup show <id>
88
97
  runtime startup set <id> --command "python3 app.py" --cwd /home/ubuntu --port 3000
89
98
  runtime startup clear <id> # low-level durable service config
@@ -123,6 +132,10 @@ app = client.create_computer(
123
132
  result = client.run_command(computer["id"], "echo hello")
124
133
  print(result["stdout"])
125
134
 
135
+ client.write_file(computer["id"], "/home/ubuntu/hello.txt", b"hello\n")
136
+ print(client.read_file(computer["id"], "/home/ubuntu/hello.txt"))
137
+ print(client.list_files(computer["id"], "/home/ubuntu"))
138
+
126
139
  # Wake a cold computer explicitly
127
140
  client.start_computer(app["id"])
128
141
 
File without changes