runtime-sdk 0.4.34__tar.gz → 0.4.36__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.36}/PKG-INFO +18 -2
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.36}/README.md +17 -1
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.36}/pyproject.toml +1 -1
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.36}/scripts/runtime_sdk/cli.py +381 -21
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.36}/scripts/runtime_sdk/client.py +219 -9
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.36}/scripts/runtime_sdk.egg-info/PKG-INFO +18 -2
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.36}/scripts/runtime_sdk/__init__.py +0 -0
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.36}/scripts/runtime_sdk/config.py +0 -0
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.36}/scripts/runtime_sdk.egg-info/SOURCES.txt +0 -0
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.36}/scripts/runtime_sdk.egg-info/dependency_links.txt +0 -0
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.36}/scripts/runtime_sdk.egg-info/entry_points.txt +0 -0
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.36}/scripts/runtime_sdk.egg-info/requires.txt +0 -0
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.36}/scripts/runtime_sdk.egg-info/top_level.txt +0 -0
- {runtime_sdk-0.4.34 → runtime_sdk-0.4.36}/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.36
|
|
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
|
|
@@ -76,7 +76,9 @@ runtime switch -c feature/login --repo owner/repo # create a new branch worksp
|
|
|
76
76
|
runtime checkout --repo owner/repo feature/login # alias for switch
|
|
77
77
|
runtime create # creates a computer with the starter app already published
|
|
78
78
|
runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
|
|
79
|
-
runtime enter <name-or-id> # accepts slug/name
|
|
79
|
+
runtime enter <name-or-id> # open an interactive shell; accepts slug/name or computer id
|
|
80
|
+
runtime enter <name-or-id> -- claude # open a real TTY and run an interactive command
|
|
81
|
+
runtime enter <name-or-id> -- python # use enter for REPLs, agents, prompts, and full-screen apps
|
|
80
82
|
runtime list
|
|
81
83
|
runtime info <id>
|
|
82
84
|
runtime share public <id>
|
|
@@ -84,6 +86,16 @@ runtime share private <id>
|
|
|
84
86
|
runtime start <id>
|
|
85
87
|
runtime run <id> "echo hello" # one-shot foreground command only
|
|
86
88
|
runtime run <id> "apt install -y nodejs" --uid 0
|
|
89
|
+
runtime exec <id> -- bash -lc 'for i in 1 2 3; do echo $i; sleep 1; done'
|
|
90
|
+
printf 'hello' | runtime exec <id> --stdin -- cat
|
|
91
|
+
# Use runtime exec for automation and exact exit codes; use runtime enter -- <cmd> for interactive TTY commands.
|
|
92
|
+
runtime files ls <id> /home/ubuntu
|
|
93
|
+
runtime files read <id> /home/ubuntu/app.py --output app.py
|
|
94
|
+
runtime files write <id> /home/ubuntu/app.py --input app.py --mode 0644
|
|
95
|
+
runtime files mkdir <id> /home/ubuntu/data --parents
|
|
96
|
+
runtime files upload <id> ./local-app /home/ubuntu/app
|
|
97
|
+
runtime files download <id> /home/ubuntu/app ./downloaded-app
|
|
98
|
+
runtime files rm <id> /home/ubuntu/data --recursive
|
|
87
99
|
runtime startup show <id>
|
|
88
100
|
runtime startup set <id> --command "python3 app.py" --cwd /home/ubuntu --port 3000
|
|
89
101
|
runtime startup clear <id> # low-level durable service config
|
|
@@ -123,6 +135,10 @@ app = client.create_computer(
|
|
|
123
135
|
result = client.run_command(computer["id"], "echo hello")
|
|
124
136
|
print(result["stdout"])
|
|
125
137
|
|
|
138
|
+
client.write_file(computer["id"], "/home/ubuntu/hello.txt", b"hello\n")
|
|
139
|
+
print(client.read_file(computer["id"], "/home/ubuntu/hello.txt"))
|
|
140
|
+
print(client.list_files(computer["id"], "/home/ubuntu"))
|
|
141
|
+
|
|
126
142
|
# Wake a cold computer explicitly
|
|
127
143
|
client.start_computer(app["id"])
|
|
128
144
|
|
|
@@ -54,7 +54,9 @@ runtime switch -c feature/login --repo owner/repo # create a new branch worksp
|
|
|
54
54
|
runtime checkout --repo owner/repo feature/login # alias for switch
|
|
55
55
|
runtime create # creates a computer with the starter app already published
|
|
56
56
|
runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
|
|
57
|
-
runtime enter <name-or-id> # accepts slug/name
|
|
57
|
+
runtime enter <name-or-id> # open an interactive shell; accepts slug/name or computer id
|
|
58
|
+
runtime enter <name-or-id> -- claude # open a real TTY and run an interactive command
|
|
59
|
+
runtime enter <name-or-id> -- python # use enter for REPLs, agents, prompts, and full-screen apps
|
|
58
60
|
runtime list
|
|
59
61
|
runtime info <id>
|
|
60
62
|
runtime share public <id>
|
|
@@ -62,6 +64,16 @@ runtime share private <id>
|
|
|
62
64
|
runtime start <id>
|
|
63
65
|
runtime run <id> "echo hello" # one-shot foreground command only
|
|
64
66
|
runtime run <id> "apt install -y nodejs" --uid 0
|
|
67
|
+
runtime exec <id> -- bash -lc 'for i in 1 2 3; do echo $i; sleep 1; done'
|
|
68
|
+
printf 'hello' | runtime exec <id> --stdin -- cat
|
|
69
|
+
# Use runtime exec for automation and exact exit codes; use runtime enter -- <cmd> for interactive TTY commands.
|
|
70
|
+
runtime files ls <id> /home/ubuntu
|
|
71
|
+
runtime files read <id> /home/ubuntu/app.py --output app.py
|
|
72
|
+
runtime files write <id> /home/ubuntu/app.py --input app.py --mode 0644
|
|
73
|
+
runtime files mkdir <id> /home/ubuntu/data --parents
|
|
74
|
+
runtime files upload <id> ./local-app /home/ubuntu/app
|
|
75
|
+
runtime files download <id> /home/ubuntu/app ./downloaded-app
|
|
76
|
+
runtime files rm <id> /home/ubuntu/data --recursive
|
|
65
77
|
runtime startup show <id>
|
|
66
78
|
runtime startup set <id> --command "python3 app.py" --cwd /home/ubuntu --port 3000
|
|
67
79
|
runtime startup clear <id> # low-level durable service config
|
|
@@ -101,6 +113,10 @@ app = client.create_computer(
|
|
|
101
113
|
result = client.run_command(computer["id"], "echo hello")
|
|
102
114
|
print(result["stdout"])
|
|
103
115
|
|
|
116
|
+
client.write_file(computer["id"], "/home/ubuntu/hello.txt", b"hello\n")
|
|
117
|
+
print(client.read_file(computer["id"], "/home/ubuntu/hello.txt"))
|
|
118
|
+
print(client.list_files(computer["id"], "/home/ubuntu"))
|
|
119
|
+
|
|
104
120
|
# Wake a cold computer explicitly
|
|
105
121
|
client.start_computer(app["id"])
|
|
106
122
|
|
|
@@ -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
|
|
@@ -187,9 +189,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
187
189
|
secrets_set = secrets_subparsers.add_parser("set", help="Create or replace a secret set from KEY=VALUE pairs")
|
|
188
190
|
secrets_set.add_argument("slug", nargs="?", default=None, help="Secret set slug")
|
|
189
191
|
secrets_set.add_argument("pairs", nargs="*", help="KEY=VALUE pairs")
|
|
192
|
+
secrets_set.add_argument("--proxy", action="append", default=[], metavar="KEY=HOST", help="Deliver KEY through the egress proxy and inject it only for HOST")
|
|
190
193
|
secrets_import = secrets_subparsers.add_parser("import", help="Create or replace a secret set from a .env file")
|
|
191
194
|
secrets_import.add_argument("slug", nargs="?", default=None, help="Secret set slug")
|
|
192
195
|
secrets_import.add_argument("--env-file", required=True, help="Path to .env file")
|
|
196
|
+
secrets_import.add_argument("--proxy", action="append", default=[], metavar="KEY=HOST", help="Deliver KEY through the egress proxy and inject it only for HOST")
|
|
193
197
|
secrets_delete = secrets_subparsers.add_parser("delete", aliases=["rm"], help="Delete a secret set")
|
|
194
198
|
secrets_delete.add_argument("slug", nargs="?", default=None, help="Secret set slug")
|
|
195
199
|
|
|
@@ -236,8 +240,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
236
240
|
help="Re-render the table every N seconds (default 2)",
|
|
237
241
|
)
|
|
238
242
|
|
|
239
|
-
enter_cmd = subparsers.add_parser(
|
|
243
|
+
enter_cmd = subparsers.add_parser(
|
|
244
|
+
"enter",
|
|
245
|
+
help="Enter a computer's console",
|
|
246
|
+
epilog="Use `runtime exec` for exact exit codes; use `runtime enter <id> -- <cmd>` for interactive TTY commands.",
|
|
247
|
+
)
|
|
240
248
|
enter_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
249
|
+
enter_cmd.add_argument("enter_command", nargs="*", help="Command to run in the interactive terminal")
|
|
241
250
|
|
|
242
251
|
info_cmd = subparsers.add_parser("info", help="Get computer details")
|
|
243
252
|
info_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
@@ -278,6 +287,53 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
278
287
|
run_cmd.add_argument("--cwd", help="Working directory")
|
|
279
288
|
run_cmd.add_argument("--shell", help="Shell to use")
|
|
280
289
|
|
|
290
|
+
exec_cmd = subparsers.add_parser("exec", help="Stream a command in a computer")
|
|
291
|
+
exec_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
292
|
+
exec_cmd.add_argument("exec_command", nargs="*", help="Command after --")
|
|
293
|
+
exec_cmd.add_argument("--stdin", action="store_true", help="Pipe local stdin to the command")
|
|
294
|
+
exec_cmd.add_argument("--uid", type=int, help="User ID")
|
|
295
|
+
exec_cmd.add_argument("--gid", type=int, help="Group ID")
|
|
296
|
+
exec_cmd.add_argument("--cwd", help="Working directory")
|
|
297
|
+
exec_cmd.add_argument("--shell", help="Shell to use")
|
|
298
|
+
|
|
299
|
+
files_cmd = subparsers.add_parser("files", aliases=["fs", "file"], help="Read, write, list, and delete files")
|
|
300
|
+
files_subparsers = files_cmd.add_subparsers(dest="files_action", required=True)
|
|
301
|
+
files_read = files_subparsers.add_parser("read", help="Read a file to stdout or --output")
|
|
302
|
+
files_read.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
303
|
+
files_read.add_argument("path", nargs="?", default=None, help="Path inside the computer")
|
|
304
|
+
files_read.add_argument("--output", "-o", help="Local output file")
|
|
305
|
+
files_write = files_subparsers.add_parser("write", help="Write stdin, --content, or --input into a file")
|
|
306
|
+
files_write.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
307
|
+
files_write.add_argument("path", nargs="?", default=None, help="Path inside the computer")
|
|
308
|
+
files_write.add_argument("--input", "-i", help="Local input file (defaults to stdin)")
|
|
309
|
+
files_write.add_argument("--content", help="Literal text to write")
|
|
310
|
+
files_write.add_argument("--mode", help="File mode, e.g. 0644")
|
|
311
|
+
files_write.add_argument("--uid", type=int, help="User ID")
|
|
312
|
+
files_write.add_argument("--gid", type=int, help="Group ID")
|
|
313
|
+
files_list = files_subparsers.add_parser("list", aliases=["ls"], help="List a directory")
|
|
314
|
+
files_list.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
315
|
+
files_list.add_argument("path", nargs="?", default=None, help="Directory path")
|
|
316
|
+
files_stat = files_subparsers.add_parser("stat", help="Show file metadata")
|
|
317
|
+
files_stat.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
318
|
+
files_stat.add_argument("path", nargs="?", default=None, help="Path inside the computer")
|
|
319
|
+
files_mkdir = files_subparsers.add_parser("mkdir", help="Create a directory")
|
|
320
|
+
files_mkdir.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
321
|
+
files_mkdir.add_argument("path", nargs="?", default=None, help="Directory path")
|
|
322
|
+
files_mkdir.add_argument("--parents", "-p", action="store_true", help="Create parent directories")
|
|
323
|
+
files_mkdir.add_argument("--mode", help="Directory mode, e.g. 0755")
|
|
324
|
+
files_rm = files_subparsers.add_parser("rm", aliases=["delete"], help="Delete a file or directory")
|
|
325
|
+
files_rm.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
326
|
+
files_rm.add_argument("path", nargs="?", default=None, help="Path inside the computer")
|
|
327
|
+
files_rm.add_argument("--recursive", "-r", action="store_true", help="Delete directories recursively")
|
|
328
|
+
files_upload = files_subparsers.add_parser("upload", help="Upload a local file or directory")
|
|
329
|
+
files_upload.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
330
|
+
files_upload.add_argument("local", nargs="?", default=None, help="Local file or directory")
|
|
331
|
+
files_upload.add_argument("remote", nargs="?", default=None, help="Remote destination directory")
|
|
332
|
+
files_download = files_subparsers.add_parser("download", help="Download a remote file or directory")
|
|
333
|
+
files_download.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
334
|
+
files_download.add_argument("remote", nargs="?", default=None, help="Remote file or directory")
|
|
335
|
+
files_download.add_argument("local", nargs="?", default=None, help="Local destination directory")
|
|
336
|
+
|
|
281
337
|
publish_cmd = subparsers.add_parser(
|
|
282
338
|
"publish", help="Promote the running app on a local port into the durable public app"
|
|
283
339
|
)
|
|
@@ -359,12 +415,21 @@ _HELP_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
|
|
|
359
415
|
("info [id]", "Show computer details"),
|
|
360
416
|
("url [id]", "Show a computer's public URL and live published port"),
|
|
361
417
|
("start [id]", "Wake a cold computer"),
|
|
362
|
-
("enter [id]", "Enter a computer's console
|
|
418
|
+
("enter [id] [-- <cmd>]", "Enter a computer's console or interactive TTY command"),
|
|
363
419
|
("run [id] <cmd>", "Run a one-shot command"),
|
|
420
|
+
("exec [id] -- <cmd>", "Stream stdout/stderr and optionally pipe stdin"),
|
|
364
421
|
("publish [id] <port> -- <cmd>", "Run and publish a durable service command"),
|
|
365
422
|
("delete, rm [id]", "Delete a computer (typed-name confirm; --force / -y to skip)"),
|
|
366
423
|
),
|
|
367
424
|
),
|
|
425
|
+
(
|
|
426
|
+
"Files",
|
|
427
|
+
(
|
|
428
|
+
("files read [id] <path>", "Read a file to stdout or --output"),
|
|
429
|
+
("files write [id] <path>", "Write stdin, --input, or --content to a file"),
|
|
430
|
+
("files ls/stat/mkdir/rm", "List, inspect, create, and delete paths"),
|
|
431
|
+
),
|
|
432
|
+
),
|
|
368
433
|
(
|
|
369
434
|
"Sharing",
|
|
370
435
|
(
|
|
@@ -522,9 +587,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
522
587
|
if args.secrets_action == "show":
|
|
523
588
|
return handle_secrets_show(config, args.slug)
|
|
524
589
|
if args.secrets_action == "set":
|
|
525
|
-
return handle_secrets_set(config, args.slug, args.pairs)
|
|
590
|
+
return handle_secrets_set(config, args.slug, args.pairs, args.proxy)
|
|
526
591
|
if args.secrets_action == "import":
|
|
527
|
-
return handle_secrets_import(config, args.slug, args.env_file)
|
|
592
|
+
return handle_secrets_import(config, args.slug, args.env_file, args.proxy)
|
|
528
593
|
if args.secrets_action in ("delete", "rm"):
|
|
529
594
|
return handle_secrets_delete(config, args.slug)
|
|
530
595
|
return report_error("unknown secrets command")
|
|
@@ -563,13 +628,17 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
563
628
|
if args.command == "start":
|
|
564
629
|
return handle_start(config, args.id)
|
|
565
630
|
if args.command == "enter":
|
|
566
|
-
return handle_enter(config, args.id)
|
|
631
|
+
return handle_enter(config, args.id, args.enter_command)
|
|
567
632
|
if args.command in ("delete", "rm"):
|
|
568
633
|
return handle_delete(config, args.id, force=getattr(args, "force", False))
|
|
569
634
|
if args.command == "run":
|
|
570
635
|
return handle_run(
|
|
571
636
|
config, args.id, args.run_command, args.uid, args.gid, args.cwd, args.shell
|
|
572
637
|
)
|
|
638
|
+
if args.command == "exec":
|
|
639
|
+
return handle_exec_stream(config, args.id, args.exec_command, args.stdin, args.uid, args.gid, args.cwd, args.shell)
|
|
640
|
+
if args.command in ("files", "fs", "file"):
|
|
641
|
+
return handle_files(config, args)
|
|
573
642
|
if args.command == "publish":
|
|
574
643
|
return handle_publish(config, args.id, args.port, args.publish_command)
|
|
575
644
|
if args.command == "startup":
|
|
@@ -1529,7 +1598,15 @@ def _render_secret_set_panel(payload: dict[str, Any]) -> None:
|
|
|
1529
1598
|
body.append(f"{'slug':<12}", style="dim")
|
|
1530
1599
|
body.append(f"{payload.get('slug') or '—'}\n")
|
|
1531
1600
|
body.append(f"{'keys':<12}", style="dim")
|
|
1532
|
-
|
|
1601
|
+
deliveries = payload.get("deliveries") or {}
|
|
1602
|
+
lines = []
|
|
1603
|
+
for key in payload.get("keys") or []:
|
|
1604
|
+
meta = deliveries.get(str(key)) if isinstance(deliveries, dict) else None
|
|
1605
|
+
if isinstance(meta, dict) and meta.get("delivery") == "proxy":
|
|
1606
|
+
lines.append(f"{key} proxy {meta.get('target_host') or '—'}")
|
|
1607
|
+
else:
|
|
1608
|
+
lines.append(f"{key} env")
|
|
1609
|
+
body.append("\n".join(lines) or "—")
|
|
1533
1610
|
_UI.console().print(Panel(body, title="secret set", border_style="cyan", expand=False))
|
|
1534
1611
|
|
|
1535
1612
|
|
|
@@ -1818,6 +1895,26 @@ def _parse_secret_pairs(pairs: list[str] | None) -> dict[str, str]:
|
|
|
1818
1895
|
|
|
1819
1896
|
|
|
1820
1897
|
|
|
1898
|
+
def _parse_secret_delivery(proxy_specs: list[str] | None, values: dict[str, str]) -> dict[str, dict[str, str]]:
|
|
1899
|
+
delivery: dict[str, dict[str, str]] = {}
|
|
1900
|
+
for spec in proxy_specs or []:
|
|
1901
|
+
if "=" not in spec:
|
|
1902
|
+
raise RuntimeConfigError(f"invalid proxy delivery: {spec}")
|
|
1903
|
+
key, host = spec.split("=", 1)
|
|
1904
|
+
key = key.strip()
|
|
1905
|
+
host = host.strip()
|
|
1906
|
+
if not key or not host or key not in values:
|
|
1907
|
+
raise RuntimeConfigError(f"invalid proxy delivery: {spec}")
|
|
1908
|
+
delivery[key] = {
|
|
1909
|
+
"delivery": "proxy",
|
|
1910
|
+
"target_host": host,
|
|
1911
|
+
"header": "Authorization",
|
|
1912
|
+
"scheme": "Bearer",
|
|
1913
|
+
}
|
|
1914
|
+
return delivery
|
|
1915
|
+
|
|
1916
|
+
|
|
1917
|
+
|
|
1821
1918
|
def _parse_env_file(path: str) -> dict[str, str]:
|
|
1822
1919
|
try:
|
|
1823
1920
|
lines = Path(path).read_text(encoding="utf-8").splitlines()
|
|
@@ -1867,7 +1964,7 @@ def handle_secrets_show(config: RuntimeConfig, slug: str | None) -> int:
|
|
|
1867
1964
|
|
|
1868
1965
|
|
|
1869
1966
|
|
|
1870
|
-
def handle_secrets_set(config: RuntimeConfig, slug: str | None, pairs: list[str] | None) -> int:
|
|
1967
|
+
def handle_secrets_set(config: RuntimeConfig, slug: str | None, pairs: list[str] | None, proxy_specs: list[str] | None) -> int:
|
|
1871
1968
|
if (err := _require_api_key(config)) is not None:
|
|
1872
1969
|
return err
|
|
1873
1970
|
resolved_slug = _coerce_secret_set_slug(slug)
|
|
@@ -1880,8 +1977,9 @@ def handle_secrets_set(config: RuntimeConfig, slug: str | None, pairs: list[str]
|
|
|
1880
1977
|
values = _parse_secret_pairs(pairs)
|
|
1881
1978
|
if not values:
|
|
1882
1979
|
return report_error("at least one KEY=VALUE pair is required")
|
|
1980
|
+
delivery = _parse_secret_delivery(proxy_specs, values)
|
|
1883
1981
|
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
1884
|
-
secret_set = client.put_secret_set(resolved_slug, values)
|
|
1982
|
+
secret_set = client.put_secret_set(resolved_slug, values, delivery)
|
|
1885
1983
|
return report_success(
|
|
1886
1984
|
{"secret_set": secret_set},
|
|
1887
1985
|
lambda payload: _UI.console().print(f"[green]✓[/green] saved [bold]{payload['secret_set'].get('slug', resolved_slug)}[/bold]"),
|
|
@@ -1889,7 +1987,7 @@ def handle_secrets_set(config: RuntimeConfig, slug: str | None, pairs: list[str]
|
|
|
1889
1987
|
|
|
1890
1988
|
|
|
1891
1989
|
|
|
1892
|
-
def handle_secrets_import(config: RuntimeConfig, slug: str | None, env_file: str) -> int:
|
|
1990
|
+
def handle_secrets_import(config: RuntimeConfig, slug: str | None, env_file: str, proxy_specs: list[str] | None) -> int:
|
|
1893
1991
|
if (err := _require_api_key(config)) is not None:
|
|
1894
1992
|
return err
|
|
1895
1993
|
resolved_slug = _coerce_secret_set_slug(slug)
|
|
@@ -1902,8 +2000,9 @@ def handle_secrets_import(config: RuntimeConfig, slug: str | None, env_file: str
|
|
|
1902
2000
|
values = _parse_env_file(env_file)
|
|
1903
2001
|
if not values:
|
|
1904
2002
|
return report_error("env file did not contain any secrets")
|
|
2003
|
+
delivery = _parse_secret_delivery(proxy_specs, values)
|
|
1905
2004
|
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
1906
|
-
secret_set = client.put_secret_set(resolved_slug, values)
|
|
2005
|
+
secret_set = client.put_secret_set(resolved_slug, values, delivery)
|
|
1907
2006
|
return report_success(
|
|
1908
2007
|
{"secret_set": secret_set},
|
|
1909
2008
|
lambda payload: _UI.console().print(f"[green]✓[/green] saved [bold]{payload['secret_set'].get('slug', resolved_slug)}[/bold] from [bold]{env_file}[/bold]"),
|
|
@@ -2523,12 +2622,14 @@ def handle_start(config: RuntimeConfig, computer_id: str | None) -> int:
|
|
|
2523
2622
|
return report_success(result, render)
|
|
2524
2623
|
|
|
2525
2624
|
|
|
2526
|
-
def handle_enter(config: RuntimeConfig, computer_id: str | None) -> int:
|
|
2625
|
+
def handle_enter(config: RuntimeConfig, computer_id: str | None, command_parts: list[str] | None = None) -> int:
|
|
2527
2626
|
if (err := _require_api_key(config)) is not None:
|
|
2528
2627
|
return err
|
|
2529
2628
|
if not _interactive():
|
|
2530
2629
|
return report_error("enter requires an interactive terminal")
|
|
2531
2630
|
|
|
2631
|
+
command = _command_from_remainder(command_parts)
|
|
2632
|
+
|
|
2532
2633
|
if computer_id is None:
|
|
2533
2634
|
picked = _pick_computer(
|
|
2534
2635
|
config,
|
|
@@ -2543,6 +2644,8 @@ def handle_enter(config: RuntimeConfig, computer_id: str | None) -> int:
|
|
|
2543
2644
|
|
|
2544
2645
|
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
2545
2646
|
computer_id = _resolve_computer_ref(client, str(computer_id))
|
|
2647
|
+
if command:
|
|
2648
|
+
return _enter_computer(config, str(computer_id), command=command)
|
|
2546
2649
|
return _enter_computer(config, str(computer_id))
|
|
2547
2650
|
|
|
2548
2651
|
|
|
@@ -2625,6 +2728,241 @@ def handle_run(
|
|
|
2625
2728
|
return report_success(result, _render_run_result)
|
|
2626
2729
|
|
|
2627
2730
|
|
|
2731
|
+
def handle_exec_stream(
|
|
2732
|
+
config: RuntimeConfig,
|
|
2733
|
+
computer_id: str | None,
|
|
2734
|
+
command_parts: list[str] | None,
|
|
2735
|
+
pipe_stdin: bool,
|
|
2736
|
+
uid: int | None,
|
|
2737
|
+
gid: int | None,
|
|
2738
|
+
cwd: str | None,
|
|
2739
|
+
shell: str | None,
|
|
2740
|
+
) -> int:
|
|
2741
|
+
if (err := _require_api_key(config)) is not None:
|
|
2742
|
+
return err
|
|
2743
|
+
if not computer_id:
|
|
2744
|
+
return report_error("computer id is required")
|
|
2745
|
+
command = _command_from_remainder(command_parts)
|
|
2746
|
+
if not command:
|
|
2747
|
+
return report_error("exec requires -- <command...>")
|
|
2748
|
+
stdin_data = None
|
|
2749
|
+
if pipe_stdin:
|
|
2750
|
+
stdin_buffer = getattr(sys.stdin, "buffer", None)
|
|
2751
|
+
stdin_data = stdin_buffer.read() if stdin_buffer is not None else sys.stdin.read().encode()
|
|
2752
|
+
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
2753
|
+
computer_id = _resolve_computer_ref(client, computer_id)
|
|
2754
|
+
exit_code = 0
|
|
2755
|
+
for frame in client.stream_command(computer_id, command, stdin=stdin_data, uid=uid, gid=gid, cwd=cwd, shell=shell):
|
|
2756
|
+
stdout = frame.get("stdout") or frame.get("data") or ""
|
|
2757
|
+
stderr = frame.get("stderr") or ""
|
|
2758
|
+
error = frame.get("error") or ""
|
|
2759
|
+
if stdout:
|
|
2760
|
+
print(stdout, end="")
|
|
2761
|
+
if stderr:
|
|
2762
|
+
print(stderr, end="", file=sys.stderr)
|
|
2763
|
+
if error:
|
|
2764
|
+
print(error, file=sys.stderr)
|
|
2765
|
+
exit_code = 1
|
|
2766
|
+
if frame.get("type") == "exit" or frame.get("exit_code") is not None:
|
|
2767
|
+
try:
|
|
2768
|
+
exit_code = int(frame.get("exit_code") or 0)
|
|
2769
|
+
except (TypeError, ValueError):
|
|
2770
|
+
exit_code = 1
|
|
2771
|
+
return exit_code
|
|
2772
|
+
|
|
2773
|
+
|
|
2774
|
+
def handle_files(config: RuntimeConfig, args: Any) -> int:
|
|
2775
|
+
action = str(getattr(args, "files_action", "") or "")
|
|
2776
|
+
if action == "read":
|
|
2777
|
+
return handle_files_read(config, args.id, args.path, args.output)
|
|
2778
|
+
if action == "write":
|
|
2779
|
+
return handle_files_write(config, args)
|
|
2780
|
+
if action in ("list", "ls"):
|
|
2781
|
+
return handle_files_list(config, args.id, args.path)
|
|
2782
|
+
if action == "stat":
|
|
2783
|
+
return handle_files_stat(config, args.id, args.path)
|
|
2784
|
+
if action == "mkdir":
|
|
2785
|
+
return handle_files_mkdir(config, args.id, args.path, args.parents, args.mode)
|
|
2786
|
+
if action in ("rm", "delete"):
|
|
2787
|
+
return handle_files_rm(config, args.id, args.path, args.recursive)
|
|
2788
|
+
if action == "upload":
|
|
2789
|
+
return handle_files_upload(config, args.id, args.local, args.remote)
|
|
2790
|
+
if action == "download":
|
|
2791
|
+
return handle_files_download(config, args.id, args.remote, args.local)
|
|
2792
|
+
return report_error("unknown files command")
|
|
2793
|
+
|
|
2794
|
+
|
|
2795
|
+
def _resolve_file_target(config: RuntimeConfig, computer_id: str | None, path: str | None) -> tuple[RuntimeClient, str, str] | int:
|
|
2796
|
+
if (err := _require_api_key(config)) is not None:
|
|
2797
|
+
return err
|
|
2798
|
+
if not computer_id:
|
|
2799
|
+
return report_error("computer id is required")
|
|
2800
|
+
if not path:
|
|
2801
|
+
return report_error("path is required")
|
|
2802
|
+
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
2803
|
+
resolved_id = _resolve_computer_ref(client, computer_id)
|
|
2804
|
+
return client, resolved_id, path
|
|
2805
|
+
|
|
2806
|
+
|
|
2807
|
+
def handle_files_read(config: RuntimeConfig, computer_id: str | None, path: str | None, output: str | None) -> int:
|
|
2808
|
+
resolved = _resolve_file_target(config, computer_id, path)
|
|
2809
|
+
if isinstance(resolved, int):
|
|
2810
|
+
return resolved
|
|
2811
|
+
client, resolved_id, resolved_path = resolved
|
|
2812
|
+
data = client.read_file(resolved_id, resolved_path)
|
|
2813
|
+
if output:
|
|
2814
|
+
try:
|
|
2815
|
+
Path(output).write_bytes(data)
|
|
2816
|
+
except OSError as exc:
|
|
2817
|
+
return report_error(f"failed to write output file: {exc}")
|
|
2818
|
+
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]"))
|
|
2819
|
+
stdout_buffer = getattr(sys.stdout, "buffer", None)
|
|
2820
|
+
if stdout_buffer is None:
|
|
2821
|
+
sys.stdout.write(data.decode(errors="replace"))
|
|
2822
|
+
sys.stdout.flush()
|
|
2823
|
+
return 0
|
|
2824
|
+
stdout_buffer.write(data)
|
|
2825
|
+
stdout_buffer.flush()
|
|
2826
|
+
return 0
|
|
2827
|
+
|
|
2828
|
+
|
|
2829
|
+
def handle_files_write(config: RuntimeConfig, args: Any) -> int:
|
|
2830
|
+
input_path = getattr(args, "input", None)
|
|
2831
|
+
content = getattr(args, "content", None)
|
|
2832
|
+
if input_path and content is not None:
|
|
2833
|
+
return report_error("pass either --input or --content, not both")
|
|
2834
|
+
resolved = _resolve_file_target(config, args.id, args.path)
|
|
2835
|
+
if isinstance(resolved, int):
|
|
2836
|
+
return resolved
|
|
2837
|
+
client, resolved_id, resolved_path = resolved
|
|
2838
|
+
if input_path:
|
|
2839
|
+
try:
|
|
2840
|
+
data = Path(input_path).read_bytes()
|
|
2841
|
+
except OSError as exc:
|
|
2842
|
+
return report_error(f"failed to read input file: {exc}")
|
|
2843
|
+
elif content is not None:
|
|
2844
|
+
data = content.encode()
|
|
2845
|
+
else:
|
|
2846
|
+
stdin_buffer = getattr(sys.stdin, "buffer", None)
|
|
2847
|
+
data = stdin_buffer.read() if stdin_buffer is not None else sys.stdin.read().encode()
|
|
2848
|
+
result = client.write_file(resolved_id, resolved_path, data, uid=args.uid, gid=args.gid, mode=args.mode)
|
|
2849
|
+
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]"))
|
|
2850
|
+
|
|
2851
|
+
|
|
2852
|
+
def _render_file_list(payload: dict[str, Any]) -> None:
|
|
2853
|
+
mods = _UI.rich()
|
|
2854
|
+
Table = mods["Table"]
|
|
2855
|
+
table = Table(title=str(payload.get("path") or "files"))
|
|
2856
|
+
table.add_column("Name", style="bold white")
|
|
2857
|
+
table.add_column("Type")
|
|
2858
|
+
table.add_column("Size", justify="right")
|
|
2859
|
+
table.add_column("Mode", style="dim")
|
|
2860
|
+
for entry in payload.get("entries") or []:
|
|
2861
|
+
if not isinstance(entry, dict):
|
|
2862
|
+
continue
|
|
2863
|
+
table.add_row(str(entry.get("name") or ""), str(entry.get("type") or ""), str(entry.get("size") or 0), str(entry.get("mode") or ""))
|
|
2864
|
+
_UI.console().print(table)
|
|
2865
|
+
|
|
2866
|
+
|
|
2867
|
+
def handle_files_list(config: RuntimeConfig, computer_id: str | None, path: str | None) -> int:
|
|
2868
|
+
resolved = _resolve_file_target(config, computer_id, path or "/home/ubuntu")
|
|
2869
|
+
if isinstance(resolved, int):
|
|
2870
|
+
return resolved
|
|
2871
|
+
client, resolved_id, resolved_path = resolved
|
|
2872
|
+
return report_success(client.list_files(resolved_id, resolved_path), _render_file_list)
|
|
2873
|
+
|
|
2874
|
+
|
|
2875
|
+
def handle_files_stat(config: RuntimeConfig, computer_id: str | None, path: str | None) -> int:
|
|
2876
|
+
resolved = _resolve_file_target(config, computer_id, path)
|
|
2877
|
+
if isinstance(resolved, int):
|
|
2878
|
+
return resolved
|
|
2879
|
+
client, resolved_id, resolved_path = resolved
|
|
2880
|
+
return report_success(client.stat_file(resolved_id, resolved_path))
|
|
2881
|
+
|
|
2882
|
+
|
|
2883
|
+
def handle_files_mkdir(config: RuntimeConfig, computer_id: str | None, path: str | None, parents: bool, mode: str | None) -> int:
|
|
2884
|
+
resolved = _resolve_file_target(config, computer_id, path)
|
|
2885
|
+
if isinstance(resolved, int):
|
|
2886
|
+
return resolved
|
|
2887
|
+
client, resolved_id, resolved_path = resolved
|
|
2888
|
+
result = client.mkdir(resolved_id, resolved_path, recursive=parents, mode=mode)
|
|
2889
|
+
return report_success(result, lambda p: _UI.console().print(f"[green]✓[/green] created [bold]{p.get('name', resolved_path)}[/bold]"))
|
|
2890
|
+
|
|
2891
|
+
|
|
2892
|
+
def handle_files_rm(config: RuntimeConfig, computer_id: str | None, path: str | None, recursive: bool) -> int:
|
|
2893
|
+
resolved = _resolve_file_target(config, computer_id, path)
|
|
2894
|
+
if isinstance(resolved, int):
|
|
2895
|
+
return resolved
|
|
2896
|
+
client, resolved_id, resolved_path = resolved
|
|
2897
|
+
result = client.delete_file(resolved_id, resolved_path, recursive=recursive)
|
|
2898
|
+
return report_success(result, lambda p: _UI.console().print(f"[green]✓[/green] deleted [bold]{p.get('path', resolved_path)}[/bold]"))
|
|
2899
|
+
|
|
2900
|
+
|
|
2901
|
+
def handle_files_upload(config: RuntimeConfig, computer_id: str | None, local: str | None, remote: str | None) -> int:
|
|
2902
|
+
if not local:
|
|
2903
|
+
return report_error("local path is required")
|
|
2904
|
+
local_path = Path(local)
|
|
2905
|
+
if not local_path.exists():
|
|
2906
|
+
return report_error(f"local path does not exist: {local}")
|
|
2907
|
+
resolved = _resolve_file_target(config, computer_id, remote)
|
|
2908
|
+
if isinstance(resolved, int):
|
|
2909
|
+
return resolved
|
|
2910
|
+
client, resolved_id, remote_path = resolved
|
|
2911
|
+
with tempfile.TemporaryFile() as archive:
|
|
2912
|
+
try:
|
|
2913
|
+
with tarfile.open(fileobj=archive, mode="w") as tar:
|
|
2914
|
+
if local_path.is_dir():
|
|
2915
|
+
for child in sorted(local_path.rglob("*")):
|
|
2916
|
+
tar.add(child, arcname=str(child.relative_to(local_path)), recursive=False)
|
|
2917
|
+
else:
|
|
2918
|
+
tar.add(local_path, arcname=local_path.name, recursive=False)
|
|
2919
|
+
except (OSError, tarfile.TarError) as exc:
|
|
2920
|
+
return report_error(f"failed to read local path: {exc}")
|
|
2921
|
+
archive.seek(0)
|
|
2922
|
+
result = client.upload_files(resolved_id, remote_path, archive)
|
|
2923
|
+
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]"))
|
|
2924
|
+
|
|
2925
|
+
|
|
2926
|
+
def handle_files_download(config: RuntimeConfig, computer_id: str | None, remote: str | None, local: str | None) -> int:
|
|
2927
|
+
local_dir = Path(local or ".")
|
|
2928
|
+
resolved = _resolve_file_target(config, computer_id, remote)
|
|
2929
|
+
if isinstance(resolved, int):
|
|
2930
|
+
return resolved
|
|
2931
|
+
client, resolved_id, remote_path = resolved
|
|
2932
|
+
try:
|
|
2933
|
+
local_dir.mkdir(parents=True, exist_ok=True)
|
|
2934
|
+
with tempfile.TemporaryFile() as archive:
|
|
2935
|
+
client.download_files_to(resolved_id, remote_path, archive)
|
|
2936
|
+
archive.seek(0)
|
|
2937
|
+
with tarfile.open(fileobj=archive, mode="r") as tar:
|
|
2938
|
+
members = tar.getmembers()
|
|
2939
|
+
for member in members:
|
|
2940
|
+
target = safe_local_extract_path(local_dir, member.name)
|
|
2941
|
+
if member.isdir():
|
|
2942
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
2943
|
+
continue
|
|
2944
|
+
if not member.isfile():
|
|
2945
|
+
return report_error(f"unsupported archive entry: {member.name}")
|
|
2946
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
2947
|
+
source = tar.extractfile(member)
|
|
2948
|
+
if source is None:
|
|
2949
|
+
return report_error(f"missing archive data: {member.name}")
|
|
2950
|
+
target.write_bytes(source.read())
|
|
2951
|
+
except (OSError, tarfile.TarError) as exc:
|
|
2952
|
+
return report_error(f"failed to write downloaded files: {exc}")
|
|
2953
|
+
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]"))
|
|
2954
|
+
|
|
2955
|
+
|
|
2956
|
+
def safe_local_extract_path(root: Path, name: str) -> Path:
|
|
2957
|
+
if not name or Path(name).is_absolute():
|
|
2958
|
+
raise RuntimeAPIError(f"unsafe archive entry: {name}")
|
|
2959
|
+
target = (root / name).resolve()
|
|
2960
|
+
root_resolved = root.resolve()
|
|
2961
|
+
if target != root_resolved and root_resolved not in target.parents:
|
|
2962
|
+
raise RuntimeAPIError(f"unsafe archive entry: {name}")
|
|
2963
|
+
return target
|
|
2964
|
+
|
|
2965
|
+
|
|
2628
2966
|
def handle_publish(config: RuntimeConfig, computer_id: str | None, port: int | None, command_parts: list[str] | None = None) -> int:
|
|
2629
2967
|
if (err := _require_api_key(config)) is not None:
|
|
2630
2968
|
return err
|
|
@@ -2893,7 +3231,7 @@ def _handle_proxy_client(base_url: str, api_key: str, computer_id: str, remote_p
|
|
|
2893
3231
|
conn.close()
|
|
2894
3232
|
|
|
2895
3233
|
|
|
2896
|
-
def _enter_computer(config: RuntimeConfig, computer_id: str) -> int:
|
|
3234
|
+
def _enter_computer(config: RuntimeConfig, computer_id: str, *, command: str | None = None) -> int:
|
|
2897
3235
|
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
2898
3236
|
ticket_payload = _with_spinner(
|
|
2899
3237
|
f"preparing console for {computer_id}…",
|
|
@@ -2906,7 +3244,7 @@ def _enter_computer(config: RuntimeConfig, computer_id: str) -> int:
|
|
|
2906
3244
|
_UI.console().print("[cyan]●[/cyan] Connecting to console...")
|
|
2907
3245
|
|
|
2908
3246
|
try:
|
|
2909
|
-
return _run_terminal_session(ws_url)
|
|
3247
|
+
return _run_terminal_session(ws_url, bootstrap_command=command)
|
|
2910
3248
|
except RuntimeAPIError as exc:
|
|
2911
3249
|
return report_error(str(exc), status_code=exc.status_code)
|
|
2912
3250
|
except (InvalidHandshake, TimeoutError, OSError) as exc:
|
|
@@ -2945,7 +3283,7 @@ def _parse_terminal_frame(raw: bytes) -> tuple[int, bytes]:
|
|
|
2945
3283
|
return raw[0], raw[TERMINAL_FRAME_HEADER_SIZE:]
|
|
2946
3284
|
|
|
2947
3285
|
|
|
2948
|
-
def _run_terminal_session(ws_url: str) -> int:
|
|
3286
|
+
def _run_terminal_session(ws_url: str, *, bootstrap_command: str | None = None) -> int:
|
|
2949
3287
|
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
2950
3288
|
return report_error("enter requires an interactive terminal")
|
|
2951
3289
|
|
|
@@ -2954,10 +3292,12 @@ def _run_terminal_session(ws_url: str) -> int:
|
|
|
2954
3292
|
old_settings = termios_mod.tcgetattr(fd)
|
|
2955
3293
|
stop = threading.Event()
|
|
2956
3294
|
result: dict[str, Any] = {"code": 0, "error": None}
|
|
2957
|
-
|
|
3295
|
+
bootstrap_echo = f"exec {bootstrap_command.strip()}" if bootstrap_command and bootstrap_command.strip() else ""
|
|
3296
|
+
output_state: dict[str, Any] = {"buffer": "", "suppressing_preamble": True, "bootstrap_echo": bootstrap_echo}
|
|
2958
3297
|
|
|
2959
3298
|
with websocket_connect(ws_url, open_timeout=DEFAULT_WARMUP_TIMEOUT, close_timeout=1) as ws:
|
|
2960
3299
|
_send_terminal_resize(ws)
|
|
3300
|
+
_send_terminal_bootstrap(ws, bootstrap_command)
|
|
2961
3301
|
|
|
2962
3302
|
def recv_loop() -> None:
|
|
2963
3303
|
try:
|
|
@@ -3035,7 +3375,7 @@ def _filter_terminal_output(chunk: str, state: dict[str, Any]) -> str:
|
|
|
3035
3375
|
if not chunk:
|
|
3036
3376
|
return ""
|
|
3037
3377
|
if not state.get("suppressing_preamble", False):
|
|
3038
|
-
return _strip_terminal_noise(chunk)
|
|
3378
|
+
return _strip_terminal_noise(chunk, str(state.get("bootstrap_echo") or ""))
|
|
3039
3379
|
|
|
3040
3380
|
buffer = str(state.get("buffer") or "") + chunk
|
|
3041
3381
|
marker = "\x1b[1;34mruntime@"
|
|
@@ -3043,9 +3383,9 @@ def _filter_terminal_output(chunk: str, state: dict[str, Any]) -> str:
|
|
|
3043
3383
|
if idx != -1:
|
|
3044
3384
|
state["suppressing_preamble"] = False
|
|
3045
3385
|
state["buffer"] = ""
|
|
3046
|
-
return _strip_terminal_noise(buffer[idx:])
|
|
3386
|
+
return _strip_terminal_noise(buffer[idx:], str(state.get("bootstrap_echo") or ""))
|
|
3047
3387
|
|
|
3048
|
-
cleaned = _strip_terminal_noise(buffer)
|
|
3388
|
+
cleaned = _strip_terminal_noise(buffer, str(state.get("bootstrap_echo") or ""))
|
|
3049
3389
|
if cleaned and _should_release_terminal_output(cleaned):
|
|
3050
3390
|
state["suppressing_preamble"] = False
|
|
3051
3391
|
state["buffer"] = ""
|
|
@@ -3054,7 +3394,7 @@ def _filter_terminal_output(chunk: str, state: dict[str, Any]) -> str:
|
|
|
3054
3394
|
if len(buffer) > 8192:
|
|
3055
3395
|
state["suppressing_preamble"] = False
|
|
3056
3396
|
state["buffer"] = ""
|
|
3057
|
-
return _strip_terminal_noise(buffer)
|
|
3397
|
+
return _strip_terminal_noise(buffer, str(state.get("bootstrap_echo") or ""))
|
|
3058
3398
|
|
|
3059
3399
|
state["buffer"] = buffer
|
|
3060
3400
|
return ""
|
|
@@ -3077,10 +3417,13 @@ def _should_release_terminal_output(text: str) -> bool:
|
|
|
3077
3417
|
def _flush_terminal_output(state: dict[str, Any]) -> str:
|
|
3078
3418
|
buffer = str(state.get("buffer") or "")
|
|
3079
3419
|
state["buffer"] = ""
|
|
3080
|
-
return _strip_terminal_noise(buffer)
|
|
3420
|
+
return _strip_terminal_noise(buffer, str(state.get("bootstrap_echo") or ""))
|
|
3421
|
+
|
|
3081
3422
|
|
|
3423
|
+
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
|
3082
3424
|
|
|
3083
|
-
|
|
3425
|
+
|
|
3426
|
+
def _strip_terminal_noise(text: str, bootstrap_echo: str = "") -> str:
|
|
3084
3427
|
if not text:
|
|
3085
3428
|
return ""
|
|
3086
3429
|
|
|
@@ -3098,10 +3441,27 @@ def _strip_terminal_noise(text: str) -> str:
|
|
|
3098
3441
|
continue
|
|
3099
3442
|
if stripped == "* Docs: https://docs.slicervm.com":
|
|
3100
3443
|
continue
|
|
3444
|
+
if _is_terminal_bootstrap_echo(stripped, bootstrap_echo):
|
|
3445
|
+
continue
|
|
3101
3446
|
filtered.append(line)
|
|
3102
3447
|
return "".join(filtered)
|
|
3103
3448
|
|
|
3104
3449
|
|
|
3450
|
+
def _is_terminal_bootstrap_echo(line: str, bootstrap_echo: str) -> bool:
|
|
3451
|
+
bootstrap_echo = bootstrap_echo.strip()
|
|
3452
|
+
if not bootstrap_echo:
|
|
3453
|
+
return False
|
|
3454
|
+
clean = ANSI_ESCAPE_RE.sub("", line).strip()
|
|
3455
|
+
return clean == bootstrap_echo or clean.endswith(bootstrap_echo)
|
|
3456
|
+
|
|
3457
|
+
|
|
3458
|
+
def _send_terminal_bootstrap(ws: Any, command: str | None) -> None:
|
|
3459
|
+
command = (command or "").strip()
|
|
3460
|
+
if not command:
|
|
3461
|
+
return
|
|
3462
|
+
ws.send(_encode_terminal_frame(TERMINAL_FRAME_DATA, f"exec {command}\n".encode()))
|
|
3463
|
+
|
|
3464
|
+
|
|
3105
3465
|
def _send_terminal_resize(ws: Any) -> None:
|
|
3106
3466
|
size = shutil.get_terminal_size((100, 24))
|
|
3107
3467
|
ws.send(_encode_terminal_frame(TERMINAL_FRAME_WINDOW_SIZE, struct.pack(">II", size.columns, size.lines)))
|
|
@@ -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
|
|
|
@@ -72,8 +75,11 @@ class RuntimeClient:
|
|
|
72
75
|
def get_secret_set(self, slug: str) -> dict[str, Any]:
|
|
73
76
|
return self._request("GET", f"/api/secrets/{slug}", auth_required=True)
|
|
74
77
|
|
|
75
|
-
def put_secret_set(self, slug: str, values: dict[str, str]) -> dict[str, Any]:
|
|
76
|
-
|
|
78
|
+
def put_secret_set(self, slug: str, values: dict[str, str], delivery: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
79
|
+
payload: dict[str, Any] = {"values": values}
|
|
80
|
+
if delivery:
|
|
81
|
+
payload["deliveries"] = delivery
|
|
82
|
+
return self._request("PUT", f"/api/secrets/{slug}", json=payload, auth_required=True)
|
|
77
83
|
|
|
78
84
|
def delete_secret_set(self, slug: str) -> dict[str, Any]:
|
|
79
85
|
return self._request("DELETE", f"/api/secrets/{slug}", auth_required=True)
|
|
@@ -284,6 +290,138 @@ class RuntimeClient:
|
|
|
284
290
|
body["shell"] = shell
|
|
285
291
|
return self._request("POST", f"/api/computers/{computer_id}/exec", json=body, auth_required=True)
|
|
286
292
|
|
|
293
|
+
def stream_command(
|
|
294
|
+
self,
|
|
295
|
+
computer_id: str,
|
|
296
|
+
command: str,
|
|
297
|
+
*,
|
|
298
|
+
stdin: bytes | None = None,
|
|
299
|
+
uid: int | None = None,
|
|
300
|
+
gid: int | None = None,
|
|
301
|
+
cwd: str | None = None,
|
|
302
|
+
shell: str | None = None,
|
|
303
|
+
):
|
|
304
|
+
body: dict[str, Any] = {"command": command}
|
|
305
|
+
if stdin:
|
|
306
|
+
body["stdin_base64"] = base64.b64encode(stdin).decode()
|
|
307
|
+
if uid is not None:
|
|
308
|
+
body["uid"] = uid
|
|
309
|
+
if gid is not None:
|
|
310
|
+
body["gid"] = gid
|
|
311
|
+
if cwd is not None:
|
|
312
|
+
body["cwd"] = cwd
|
|
313
|
+
if shell is not None:
|
|
314
|
+
body["shell"] = shell
|
|
315
|
+
headers = self._headers(True)
|
|
316
|
+
client = httpx.Client(base_url=self.base_url, timeout=None, transport=self.transport)
|
|
317
|
+
try:
|
|
318
|
+
with client.stream("POST", f"/api/computers/{computer_id}/exec/stream", json=body, headers=headers) as response:
|
|
319
|
+
if not response.is_success:
|
|
320
|
+
body_bytes = response.read()
|
|
321
|
+
payload: dict[str, Any] = {}
|
|
322
|
+
if body_bytes:
|
|
323
|
+
try:
|
|
324
|
+
decoded = json.loads(body_bytes.decode())
|
|
325
|
+
if isinstance(decoded, dict):
|
|
326
|
+
payload = decoded
|
|
327
|
+
except ValueError:
|
|
328
|
+
pass
|
|
329
|
+
message = payload.get("error") or payload.get("message") or f"http {response.status_code}"
|
|
330
|
+
raise RuntimeAPIError(str(message), status_code=response.status_code)
|
|
331
|
+
for line in response.iter_lines():
|
|
332
|
+
if not line:
|
|
333
|
+
continue
|
|
334
|
+
try:
|
|
335
|
+
frame = json.loads(line)
|
|
336
|
+
except ValueError as exc:
|
|
337
|
+
raise RuntimeAPIError("server returned invalid NDJSON", status_code=response.status_code) from exc
|
|
338
|
+
if not isinstance(frame, dict):
|
|
339
|
+
raise RuntimeAPIError("server returned non-object NDJSON", status_code=response.status_code)
|
|
340
|
+
yield frame
|
|
341
|
+
except httpx.HTTPError as exc:
|
|
342
|
+
raise RuntimeAPIError(f"request failed: {exc}") from exc
|
|
343
|
+
finally:
|
|
344
|
+
client.close()
|
|
345
|
+
|
|
346
|
+
def read_file(self, computer_id: str, path: str) -> bytes:
|
|
347
|
+
return self._request_bytes(
|
|
348
|
+
"GET",
|
|
349
|
+
f"/api/computers/{computer_id}/files/read",
|
|
350
|
+
params={"path": path},
|
|
351
|
+
auth_required=True,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def write_file(
|
|
355
|
+
self,
|
|
356
|
+
computer_id: str,
|
|
357
|
+
path: str,
|
|
358
|
+
data: bytes,
|
|
359
|
+
*,
|
|
360
|
+
uid: int | None = None,
|
|
361
|
+
gid: int | None = None,
|
|
362
|
+
mode: str | None = None,
|
|
363
|
+
) -> dict[str, Any]:
|
|
364
|
+
params: dict[str, Any] = {"path": path}
|
|
365
|
+
if uid is not None:
|
|
366
|
+
params["uid"] = uid
|
|
367
|
+
if gid is not None:
|
|
368
|
+
params["gid"] = gid
|
|
369
|
+
if mode is not None:
|
|
370
|
+
params["mode"] = mode
|
|
371
|
+
return self._request(
|
|
372
|
+
"PUT",
|
|
373
|
+
f"/api/computers/{computer_id}/files/write",
|
|
374
|
+
params=params,
|
|
375
|
+
data=data,
|
|
376
|
+
auth_required=True,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def list_files(self, computer_id: str, path: str) -> dict[str, Any]:
|
|
380
|
+
return self._request("GET", f"/api/computers/{computer_id}/files/list", params={"path": path}, auth_required=True)
|
|
381
|
+
|
|
382
|
+
def stat_file(self, computer_id: str, path: str) -> dict[str, Any]:
|
|
383
|
+
return self._request("GET", f"/api/computers/{computer_id}/files/stat", params={"path": path}, auth_required=True)
|
|
384
|
+
|
|
385
|
+
def mkdir(self, computer_id: str, path: str, *, recursive: bool = False, mode: str | None = None) -> dict[str, Any]:
|
|
386
|
+
body: dict[str, Any] = {"path": path, "recursive": recursive}
|
|
387
|
+
if mode is not None:
|
|
388
|
+
body["mode"] = mode
|
|
389
|
+
return self._request("POST", f"/api/computers/{computer_id}/files/mkdir", json=body, auth_required=True)
|
|
390
|
+
|
|
391
|
+
def delete_file(self, computer_id: str, path: str, *, recursive: bool = False) -> dict[str, Any]:
|
|
392
|
+
return self._request(
|
|
393
|
+
"DELETE",
|
|
394
|
+
f"/api/computers/{computer_id}/files/delete",
|
|
395
|
+
params={"path": path, "recursive": str(recursive).lower()},
|
|
396
|
+
auth_required=True,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def upload_files(self, computer_id: str, path: str, archive: Any) -> dict[str, Any]:
|
|
400
|
+
return self._request(
|
|
401
|
+
"PUT",
|
|
402
|
+
f"/api/computers/{computer_id}/files/upload",
|
|
403
|
+
params={"path": path},
|
|
404
|
+
data=archive,
|
|
405
|
+
auth_required=True,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
def download_files(self, computer_id: str, path: str) -> bytes:
|
|
409
|
+
return self._request_bytes(
|
|
410
|
+
"GET",
|
|
411
|
+
f"/api/computers/{computer_id}/files/download",
|
|
412
|
+
params={"path": path},
|
|
413
|
+
auth_required=True,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
def download_files_to(self, computer_id: str, path: str, writer: BinaryIO) -> None:
|
|
417
|
+
self._request_bytes_to(
|
|
418
|
+
"GET",
|
|
419
|
+
f"/api/computers/{computer_id}/files/download",
|
|
420
|
+
writer,
|
|
421
|
+
params={"path": path},
|
|
422
|
+
auth_required=True,
|
|
423
|
+
)
|
|
424
|
+
|
|
287
425
|
def publish_port(self, computer_id: str, port: int, *, command: str | None = None, cwd: str | None = None) -> dict[str, Any]:
|
|
288
426
|
if port <= 0 or port > 65535:
|
|
289
427
|
raise RuntimeAPIError("port must be between 1 and 65535")
|
|
@@ -297,14 +435,12 @@ class RuntimeClient:
|
|
|
297
435
|
path: str,
|
|
298
436
|
*,
|
|
299
437
|
json: dict[str, Any] | None = None,
|
|
438
|
+
data: Any = None,
|
|
439
|
+
params: dict[str, Any] | None = None,
|
|
300
440
|
auth_required: bool = False,
|
|
301
441
|
timeout: float | None = None,
|
|
302
442
|
) -> 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}"
|
|
443
|
+
headers = self._headers(auth_required)
|
|
308
444
|
|
|
309
445
|
try:
|
|
310
446
|
with httpx.Client(
|
|
@@ -312,7 +448,7 @@ class RuntimeClient:
|
|
|
312
448
|
timeout=self.timeout if timeout is None else timeout,
|
|
313
449
|
transport=self.transport,
|
|
314
450
|
) as client:
|
|
315
|
-
response = client.request(method, path, json=json, headers=headers)
|
|
451
|
+
response = client.request(method, self._path(path, params), json=json, content=data, headers=headers)
|
|
316
452
|
except httpx.HTTPError as exc:
|
|
317
453
|
raise RuntimeAPIError(f"request failed: {exc}") from exc
|
|
318
454
|
|
|
@@ -323,6 +459,80 @@ class RuntimeClient:
|
|
|
323
459
|
message = payload.get("error") or payload.get("message") or f"http {response.status_code}"
|
|
324
460
|
raise RuntimeAPIError(str(message), status_code=response.status_code)
|
|
325
461
|
|
|
462
|
+
def _request_bytes(
|
|
463
|
+
self,
|
|
464
|
+
method: str,
|
|
465
|
+
path: str,
|
|
466
|
+
*,
|
|
467
|
+
params: dict[str, Any] | None = None,
|
|
468
|
+
auth_required: bool = False,
|
|
469
|
+
timeout: float | None = None,
|
|
470
|
+
) -> bytes:
|
|
471
|
+
headers = self._headers(auth_required)
|
|
472
|
+
try:
|
|
473
|
+
with httpx.Client(
|
|
474
|
+
base_url=self.base_url,
|
|
475
|
+
timeout=self.timeout if timeout is None else timeout,
|
|
476
|
+
transport=self.transport,
|
|
477
|
+
) as client:
|
|
478
|
+
response = client.request(method, self._path(path, params), headers=headers)
|
|
479
|
+
except httpx.HTTPError as exc:
|
|
480
|
+
raise RuntimeAPIError(f"request failed: {exc}") from exc
|
|
481
|
+
|
|
482
|
+
if response.is_success:
|
|
483
|
+
return response.content
|
|
484
|
+
|
|
485
|
+
payload = self._decode_response(response)
|
|
486
|
+
message = payload.get("error") or payload.get("message") or f"http {response.status_code}"
|
|
487
|
+
raise RuntimeAPIError(str(message), status_code=response.status_code)
|
|
488
|
+
|
|
489
|
+
def _request_bytes_to(
|
|
490
|
+
self,
|
|
491
|
+
method: str,
|
|
492
|
+
path: str,
|
|
493
|
+
writer: BinaryIO,
|
|
494
|
+
*,
|
|
495
|
+
params: dict[str, Any] | None = None,
|
|
496
|
+
auth_required: bool = False,
|
|
497
|
+
timeout: float | None = None,
|
|
498
|
+
) -> None:
|
|
499
|
+
headers = self._headers(auth_required)
|
|
500
|
+
try:
|
|
501
|
+
with httpx.Client(
|
|
502
|
+
base_url=self.base_url,
|
|
503
|
+
timeout=self.timeout if timeout is None else timeout,
|
|
504
|
+
transport=self.transport,
|
|
505
|
+
) as client:
|
|
506
|
+
with client.stream(method, self._path(path, params), headers=headers) as response:
|
|
507
|
+
if response.is_success:
|
|
508
|
+
for chunk in response.iter_bytes():
|
|
509
|
+
writer.write(chunk)
|
|
510
|
+
return
|
|
511
|
+
response.read()
|
|
512
|
+
payload = self._decode_response(response)
|
|
513
|
+
except httpx.HTTPError as exc:
|
|
514
|
+
raise RuntimeAPIError(f"request failed: {exc}") from exc
|
|
515
|
+
|
|
516
|
+
message = payload.get("error") or payload.get("message") or f"http {response.status_code}"
|
|
517
|
+
raise RuntimeAPIError(str(message), status_code=response.status_code)
|
|
518
|
+
|
|
519
|
+
def _headers(self, auth_required: bool) -> dict[str, str]:
|
|
520
|
+
if not auth_required:
|
|
521
|
+
return {}
|
|
522
|
+
if not self.api_key:
|
|
523
|
+
raise RuntimeAPIError("missing api key")
|
|
524
|
+
return {"Authorization": f"Bearer {self.api_key}"}
|
|
525
|
+
|
|
526
|
+
@staticmethod
|
|
527
|
+
def _path(path: str, params: dict[str, Any] | None = None) -> str:
|
|
528
|
+
if not params:
|
|
529
|
+
return path
|
|
530
|
+
query = urlencode({key: value for key, value in params.items() if value is not None})
|
|
531
|
+
if not query:
|
|
532
|
+
return path
|
|
533
|
+
separator = "&" if "?" in path else "?"
|
|
534
|
+
return f"{path}{separator}{query}"
|
|
535
|
+
|
|
326
536
|
@staticmethod
|
|
327
537
|
def _decode_response(response: httpx.Response) -> dict[str, Any]:
|
|
328
538
|
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.36
|
|
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
|
|
@@ -76,7 +76,9 @@ runtime switch -c feature/login --repo owner/repo # create a new branch worksp
|
|
|
76
76
|
runtime checkout --repo owner/repo feature/login # alias for switch
|
|
77
77
|
runtime create # creates a computer with the starter app already published
|
|
78
78
|
runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
|
|
79
|
-
runtime enter <name-or-id> # accepts slug/name
|
|
79
|
+
runtime enter <name-or-id> # open an interactive shell; accepts slug/name or computer id
|
|
80
|
+
runtime enter <name-or-id> -- claude # open a real TTY and run an interactive command
|
|
81
|
+
runtime enter <name-or-id> -- python # use enter for REPLs, agents, prompts, and full-screen apps
|
|
80
82
|
runtime list
|
|
81
83
|
runtime info <id>
|
|
82
84
|
runtime share public <id>
|
|
@@ -84,6 +86,16 @@ runtime share private <id>
|
|
|
84
86
|
runtime start <id>
|
|
85
87
|
runtime run <id> "echo hello" # one-shot foreground command only
|
|
86
88
|
runtime run <id> "apt install -y nodejs" --uid 0
|
|
89
|
+
runtime exec <id> -- bash -lc 'for i in 1 2 3; do echo $i; sleep 1; done'
|
|
90
|
+
printf 'hello' | runtime exec <id> --stdin -- cat
|
|
91
|
+
# Use runtime exec for automation and exact exit codes; use runtime enter -- <cmd> for interactive TTY commands.
|
|
92
|
+
runtime files ls <id> /home/ubuntu
|
|
93
|
+
runtime files read <id> /home/ubuntu/app.py --output app.py
|
|
94
|
+
runtime files write <id> /home/ubuntu/app.py --input app.py --mode 0644
|
|
95
|
+
runtime files mkdir <id> /home/ubuntu/data --parents
|
|
96
|
+
runtime files upload <id> ./local-app /home/ubuntu/app
|
|
97
|
+
runtime files download <id> /home/ubuntu/app ./downloaded-app
|
|
98
|
+
runtime files rm <id> /home/ubuntu/data --recursive
|
|
87
99
|
runtime startup show <id>
|
|
88
100
|
runtime startup set <id> --command "python3 app.py" --cwd /home/ubuntu --port 3000
|
|
89
101
|
runtime startup clear <id> # low-level durable service config
|
|
@@ -123,6 +135,10 @@ app = client.create_computer(
|
|
|
123
135
|
result = client.run_command(computer["id"], "echo hello")
|
|
124
136
|
print(result["stdout"])
|
|
125
137
|
|
|
138
|
+
client.write_file(computer["id"], "/home/ubuntu/hello.txt", b"hello\n")
|
|
139
|
+
print(client.read_file(computer["id"], "/home/ubuntu/hello.txt"))
|
|
140
|
+
print(client.list_files(computer["id"], "/home/ubuntu"))
|
|
141
|
+
|
|
126
142
|
# Wake a cold computer explicitly
|
|
127
143
|
client.start_computer(app["id"])
|
|
128
144
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|