runtime-sdk 0.4.34__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.
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.35}/PKG-INFO +14 -1
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.35}/README.md +13 -0
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.35}/pyproject.toml +1 -1
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.35}/scripts/runtime_sdk/cli.py +297 -0
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.35}/scripts/runtime_sdk/client.py +214 -7
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.35}/scripts/runtime_sdk.egg-info/PKG-INFO +14 -1
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.35}/scripts/runtime_sdk/__init__.py +0 -0
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.35}/scripts/runtime_sdk/config.py +0 -0
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.35}/scripts/runtime_sdk.egg-info/SOURCES.txt +0 -0
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.35}/scripts/runtime_sdk.egg-info/dependency_links.txt +0 -0
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.35}/scripts/runtime_sdk.egg-info/entry_points.txt +0 -0
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.35}/scripts/runtime_sdk.egg-info/requires.txt +0 -0
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.35}/scripts/runtime_sdk.egg-info/top_level.txt +0 -0
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.35}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runtime-sdk
|
|
3
|
-
Version: 0.4.
|
|
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
|
|
|
@@ -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":
|
|
@@ -2625,6 +2687,241 @@ def handle_run(
|
|
|
2625
2687
|
return report_success(result, _render_run_result)
|
|
2626
2688
|
|
|
2627
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
|
+
|
|
2628
2925
|
def handle_publish(config: RuntimeConfig, computer_id: str | None, port: int | None, command_parts: list[str] | None = None) -> int:
|
|
2629
2926
|
if (err := _require_api_key(config)) is not None:
|
|
2630
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
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|