runtime-sdk 0.4.49__py3-none-win_amd64.whl
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/__init__.py +18 -0
- runtime_sdk/account.py +243 -0
- runtime_sdk/arguments.py +10 -0
- runtime_sdk/auth.py +175 -0
- runtime_sdk/cli.py +985 -0
- runtime_sdk/client.py +616 -0
- runtime_sdk/completion.py +348 -0
- runtime_sdk/computers.py +578 -0
- runtime_sdk/config.py +201 -0
- runtime_sdk/console.py +118 -0
- runtime_sdk/files.py +233 -0
- runtime_sdk/github.py +229 -0
- runtime_sdk/goals.py +368 -0
- runtime_sdk/output.py +69 -0
- runtime_sdk/providers.py +422 -0
- runtime_sdk/proxy.py +600 -0
- runtime_sdk/runtime-tui.exe +0 -0
- runtime_sdk/secrets.py +155 -0
- runtime_sdk/services.py +173 -0
- runtime_sdk/ssh.py +197 -0
- runtime_sdk/terminal.py +330 -0
- runtime_sdk-0.4.49.dist-info/METADATA +273 -0
- runtime_sdk-0.4.49.dist-info/RECORD +26 -0
- runtime_sdk-0.4.49.dist-info/WHEEL +5 -0
- runtime_sdk-0.4.49.dist-info/entry_points.txt +2 -0
- runtime_sdk-0.4.49.dist-info/top_level.txt +1 -0
runtime_sdk/config.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterator, TextIO
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.runruntime.dev"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RuntimeConfigError(RuntimeError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class WorkOSSession:
|
|
24
|
+
access_token: str
|
|
25
|
+
refresh_token: str
|
|
26
|
+
organization_id: str
|
|
27
|
+
session_id: str
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
for field_name in (
|
|
31
|
+
"access_token",
|
|
32
|
+
"refresh_token",
|
|
33
|
+
"organization_id",
|
|
34
|
+
"session_id",
|
|
35
|
+
):
|
|
36
|
+
value = getattr(self, field_name, None)
|
|
37
|
+
if not isinstance(value, str) or not value.strip():
|
|
38
|
+
raise ValueError(f"session {field_name} is required")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class RuntimeConfig:
|
|
43
|
+
base_url: str = DEFAULT_BASE_URL
|
|
44
|
+
configured_base_url: str | None = None
|
|
45
|
+
session: WorkOSSession | None = None
|
|
46
|
+
api_key: str | None = None
|
|
47
|
+
_tracks_saved_credentials: bool = field(
|
|
48
|
+
default=False, init=False, repr=False, compare=False
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
if self.session is not None and self.api_key is not None:
|
|
53
|
+
raise ValueError("config cannot contain both a WorkOS session and an API key")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def config_path() -> Path:
|
|
57
|
+
root = os.environ.get("XDG_CONFIG_HOME")
|
|
58
|
+
if root:
|
|
59
|
+
return Path(root) / "runtime" / "config.json"
|
|
60
|
+
if sys.platform == "win32":
|
|
61
|
+
root = os.environ.get("APPDATA")
|
|
62
|
+
if root:
|
|
63
|
+
return Path(root) / "Runtime" / "config.json"
|
|
64
|
+
return Path.home() / ".config" / "runtime" / "config.json"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_config() -> RuntimeConfig:
|
|
68
|
+
path = config_path()
|
|
69
|
+
if not path.exists():
|
|
70
|
+
config = RuntimeConfig()
|
|
71
|
+
config._tracks_saved_credentials = True
|
|
72
|
+
return config
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
76
|
+
except OSError as exc:
|
|
77
|
+
raise RuntimeConfigError(f"failed to read config: {exc}") from exc
|
|
78
|
+
except ValueError as exc:
|
|
79
|
+
raise RuntimeConfigError("config file contains invalid JSON") from exc
|
|
80
|
+
if not isinstance(data, dict):
|
|
81
|
+
raise RuntimeConfigError("config file must contain a JSON object")
|
|
82
|
+
|
|
83
|
+
configured_base_url = data.get("base_url") or None
|
|
84
|
+
session = data.get("session")
|
|
85
|
+
try:
|
|
86
|
+
config = RuntimeConfig(
|
|
87
|
+
base_url=configured_base_url or DEFAULT_BASE_URL,
|
|
88
|
+
configured_base_url=configured_base_url,
|
|
89
|
+
session=WorkOSSession(**session) if session is not None else None,
|
|
90
|
+
api_key=_optional_string(data.get("api_key")),
|
|
91
|
+
)
|
|
92
|
+
config._tracks_saved_credentials = True
|
|
93
|
+
return config
|
|
94
|
+
except (TypeError, ValueError) as exc:
|
|
95
|
+
raise RuntimeConfigError(f"config file has an invalid shape: {exc}") from exc
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def save_config(config: RuntimeConfig) -> None:
|
|
99
|
+
with config_lock():
|
|
100
|
+
_save_config_unlocked(config)
|
|
101
|
+
config._tracks_saved_credentials = True
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@contextmanager
|
|
105
|
+
def config_lock() -> Iterator[None]:
|
|
106
|
+
path = config_path()
|
|
107
|
+
try:
|
|
108
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
109
|
+
_set_private_permissions(path.parent, 0o700)
|
|
110
|
+
with path.with_suffix(".lock").open("a+", encoding="utf-8") as lock:
|
|
111
|
+
_set_private_permissions(Path(lock.name), 0o600)
|
|
112
|
+
with _exclusive_file_lock(lock):
|
|
113
|
+
yield
|
|
114
|
+
except OSError as exc:
|
|
115
|
+
raise RuntimeConfigError(f"failed to lock config: {exc}") from exc
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _save_config_unlocked(config: RuntimeConfig) -> None:
|
|
119
|
+
path = config_path()
|
|
120
|
+
payload: dict[str, object] = {
|
|
121
|
+
"session": asdict(config.session) if config.session else None,
|
|
122
|
+
"api_key": config.api_key,
|
|
123
|
+
}
|
|
124
|
+
if config.configured_base_url is not None:
|
|
125
|
+
payload["base_url"] = config.configured_base_url
|
|
126
|
+
|
|
127
|
+
temporary_path: str | None = None
|
|
128
|
+
try:
|
|
129
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
130
|
+
_set_private_permissions(path.parent, 0o700)
|
|
131
|
+
fd, temporary_path = tempfile.mkstemp(prefix=".config-", dir=path.parent)
|
|
132
|
+
if sys.platform != "win32":
|
|
133
|
+
os.fchmod(fd, 0o600)
|
|
134
|
+
with os.fdopen(fd, "w", encoding="utf-8") as file:
|
|
135
|
+
json.dump(payload, file, indent=2)
|
|
136
|
+
file.write("\n")
|
|
137
|
+
file.flush()
|
|
138
|
+
os.fsync(file.fileno())
|
|
139
|
+
os.replace(temporary_path, path)
|
|
140
|
+
temporary_path = None
|
|
141
|
+
if sys.platform != "win32":
|
|
142
|
+
directory_fd = os.open(path.parent, os.O_RDONLY)
|
|
143
|
+
try:
|
|
144
|
+
os.fsync(directory_fd)
|
|
145
|
+
finally:
|
|
146
|
+
os.close(directory_fd)
|
|
147
|
+
except OSError as exc:
|
|
148
|
+
raise RuntimeConfigError(f"failed to write config: {exc}") from exc
|
|
149
|
+
finally:
|
|
150
|
+
if temporary_path is not None:
|
|
151
|
+
try:
|
|
152
|
+
os.unlink(temporary_path)
|
|
153
|
+
except FileNotFoundError:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@contextmanager
|
|
158
|
+
def _exclusive_file_lock(lock: TextIO) -> Iterator[None]:
|
|
159
|
+
if sys.platform != "win32":
|
|
160
|
+
import fcntl
|
|
161
|
+
|
|
162
|
+
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
163
|
+
try:
|
|
164
|
+
yield
|
|
165
|
+
finally:
|
|
166
|
+
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
import msvcrt
|
|
170
|
+
|
|
171
|
+
lock.seek(0, os.SEEK_END)
|
|
172
|
+
if lock.tell() == 0:
|
|
173
|
+
lock.write("\0")
|
|
174
|
+
lock.flush()
|
|
175
|
+
lock.seek(0)
|
|
176
|
+
while True:
|
|
177
|
+
try:
|
|
178
|
+
msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1)
|
|
179
|
+
break
|
|
180
|
+
except OSError as exc:
|
|
181
|
+
if exc.errno not in (errno.EACCES, errno.EAGAIN, errno.EDEADLK):
|
|
182
|
+
raise
|
|
183
|
+
time.sleep(0.05)
|
|
184
|
+
try:
|
|
185
|
+
yield
|
|
186
|
+
finally:
|
|
187
|
+
lock.seek(0)
|
|
188
|
+
msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _set_private_permissions(path: Path, mode: int) -> None:
|
|
192
|
+
if sys.platform != "win32":
|
|
193
|
+
os.chmod(path, mode)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _optional_string(value: object) -> str | None:
|
|
197
|
+
if value is None:
|
|
198
|
+
return None
|
|
199
|
+
if not isinstance(value, str) or not value.strip():
|
|
200
|
+
raise ValueError("api_key must be a non-empty string")
|
|
201
|
+
return value
|
runtime_sdk/console.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable, Iterator
|
|
8
|
+
|
|
9
|
+
from runtime_sdk.client import RuntimeAPIError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@contextlib.contextmanager
|
|
13
|
+
def raw_terminal_input(fd: int) -> Iterator[Callable[[float], bytes | None]]:
|
|
14
|
+
if sys.platform == "win32":
|
|
15
|
+
with _windows_raw_terminal_input(fd) as read:
|
|
16
|
+
yield read
|
|
17
|
+
return
|
|
18
|
+
with _posix_raw_terminal_input(fd) as read:
|
|
19
|
+
yield read
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@contextlib.contextmanager
|
|
23
|
+
def _posix_raw_terminal_input(
|
|
24
|
+
fd: int,
|
|
25
|
+
) -> Iterator[Callable[[float], bytes | None]]:
|
|
26
|
+
try:
|
|
27
|
+
import select
|
|
28
|
+
import termios
|
|
29
|
+
import tty
|
|
30
|
+
except ModuleNotFoundError as exc:
|
|
31
|
+
raise RuntimeAPIError(
|
|
32
|
+
"interactive console is not supported on this platform"
|
|
33
|
+
) from exc
|
|
34
|
+
|
|
35
|
+
old_settings = termios.tcgetattr(fd)
|
|
36
|
+
tty.setraw(fd)
|
|
37
|
+
try:
|
|
38
|
+
def read(timeout: float) -> bytes | None:
|
|
39
|
+
readable, _, _ = select.select([fd], [], [], timeout)
|
|
40
|
+
return os.read(fd, 1024) if fd in readable else None
|
|
41
|
+
|
|
42
|
+
yield read
|
|
43
|
+
finally:
|
|
44
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@contextlib.contextmanager
|
|
48
|
+
def _windows_raw_terminal_input(
|
|
49
|
+
fd: int,
|
|
50
|
+
) -> Iterator[Callable[[float], bytes | None]]:
|
|
51
|
+
import ctypes
|
|
52
|
+
import msvcrt
|
|
53
|
+
from ctypes import wintypes
|
|
54
|
+
|
|
55
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
56
|
+
kernel32.GetConsoleMode.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
|
|
57
|
+
kernel32.GetConsoleMode.restype = wintypes.BOOL
|
|
58
|
+
kernel32.SetConsoleMode.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
|
59
|
+
kernel32.SetConsoleMode.restype = wintypes.BOOL
|
|
60
|
+
input_handle = msvcrt.get_osfhandle(fd)
|
|
61
|
+
output_handle = msvcrt.get_osfhandle(sys.stdout.fileno())
|
|
62
|
+
input_mode = wintypes.DWORD()
|
|
63
|
+
output_mode = wintypes.DWORD()
|
|
64
|
+
if not kernel32.GetConsoleMode(input_handle, ctypes.byref(input_mode)):
|
|
65
|
+
raise RuntimeAPIError("failed to read Windows console mode")
|
|
66
|
+
if not kernel32.GetConsoleMode(output_handle, ctypes.byref(output_mode)):
|
|
67
|
+
raise RuntimeAPIError("failed to read Windows console output mode")
|
|
68
|
+
|
|
69
|
+
enable_virtual_terminal_input = 0x0200
|
|
70
|
+
enable_virtual_terminal_processing = 0x0004
|
|
71
|
+
cooked_input_flags = 0x0001 | 0x0002 | 0x0004
|
|
72
|
+
raw_input_mode = (input_mode.value & ~cooked_input_flags) | enable_virtual_terminal_input
|
|
73
|
+
raw_output_mode = output_mode.value | enable_virtual_terminal_processing
|
|
74
|
+
if not kernel32.SetConsoleMode(input_handle, raw_input_mode):
|
|
75
|
+
raise RuntimeAPIError("failed to enable Windows console input")
|
|
76
|
+
if not kernel32.SetConsoleMode(output_handle, raw_output_mode):
|
|
77
|
+
kernel32.SetConsoleMode(input_handle, input_mode.value)
|
|
78
|
+
raise RuntimeAPIError("failed to enable Windows terminal output")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
yield lambda timeout: _read_windows_input(msvcrt, timeout)
|
|
82
|
+
finally:
|
|
83
|
+
kernel32.SetConsoleMode(input_handle, input_mode.value)
|
|
84
|
+
kernel32.SetConsoleMode(output_handle, output_mode.value)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_WINDOWS_SPECIAL_KEYS = {
|
|
88
|
+
"H": b"\x1b[A",
|
|
89
|
+
"P": b"\x1b[B",
|
|
90
|
+
"M": b"\x1b[C",
|
|
91
|
+
"K": b"\x1b[D",
|
|
92
|
+
"G": b"\x1b[H",
|
|
93
|
+
"O": b"\x1b[F",
|
|
94
|
+
"R": b"\x1b[2~",
|
|
95
|
+
"S": b"\x1b[3~",
|
|
96
|
+
"I": b"\x1b[5~",
|
|
97
|
+
"Q": b"\x1b[6~",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _read_windows_input(msvcrt: object, timeout: float) -> bytes | None:
|
|
102
|
+
deadline = time.monotonic() + timeout
|
|
103
|
+
while not msvcrt.kbhit():
|
|
104
|
+
if time.monotonic() >= deadline:
|
|
105
|
+
return None
|
|
106
|
+
time.sleep(min(0.01, max(0.0, deadline - time.monotonic())))
|
|
107
|
+
|
|
108
|
+
data = bytearray()
|
|
109
|
+
while msvcrt.kbhit():
|
|
110
|
+
char = msvcrt.getwch()
|
|
111
|
+
if char in ("\0", "\xe0"):
|
|
112
|
+
data.extend(_WINDOWS_SPECIAL_KEYS.get(msvcrt.getwch(), b""))
|
|
113
|
+
continue
|
|
114
|
+
if "\ud800" <= char <= "\udbff" and msvcrt.kbhit():
|
|
115
|
+
char += msvcrt.getwch()
|
|
116
|
+
char = char.encode("utf-16", errors="surrogatepass").decode("utf-16")
|
|
117
|
+
data.extend(char.encode("utf-8", errors="replace"))
|
|
118
|
+
return bytes(data)
|
runtime_sdk/files.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import tarfile
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from runtime_sdk import computers, output as cli_output
|
|
10
|
+
from runtime_sdk.auth import authenticated_client
|
|
11
|
+
from runtime_sdk.client import RuntimeAPIError, RuntimeClient
|
|
12
|
+
from runtime_sdk.config import RuntimeConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def handle_files(config: RuntimeConfig, args: Any) -> int:
|
|
16
|
+
action = str(getattr(args, "files_action", "") or "")
|
|
17
|
+
if action == "read":
|
|
18
|
+
return handle_files_read(config, args.id, args.path, args.output)
|
|
19
|
+
if action == "write":
|
|
20
|
+
return handle_files_write(config, args)
|
|
21
|
+
if action in ("list", "ls"):
|
|
22
|
+
return handle_files_list(config, args.id, args.path)
|
|
23
|
+
if action == "stat":
|
|
24
|
+
return handle_files_stat(config, args.id, args.path)
|
|
25
|
+
if action == "mkdir":
|
|
26
|
+
return handle_files_mkdir(config, args.id, args.path, args.parents, args.mode)
|
|
27
|
+
if action in ("rm", "delete"):
|
|
28
|
+
return handle_files_rm(config, args.id, args.path, args.recursive)
|
|
29
|
+
if action == "upload":
|
|
30
|
+
return handle_files_upload(config, args.id, args.local, args.remote)
|
|
31
|
+
if action == "download":
|
|
32
|
+
return handle_files_download(config, args.id, args.remote, args.local)
|
|
33
|
+
return cli_output.report_error("unknown files command")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_file_target(
|
|
37
|
+
config: RuntimeConfig, computer_id: str | None, path: str | None
|
|
38
|
+
) -> tuple[RuntimeClient, str, str] | int:
|
|
39
|
+
if (err := cli_output.require_auth(config)) is not None:
|
|
40
|
+
return err
|
|
41
|
+
if not computer_id:
|
|
42
|
+
return cli_output.report_error("computer id is required")
|
|
43
|
+
if not path:
|
|
44
|
+
return cli_output.report_error("path is required")
|
|
45
|
+
client = authenticated_client(config)
|
|
46
|
+
resolved_id = computers.resolve_computer_ref(client, computer_id)
|
|
47
|
+
return client, resolved_id, path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def handle_files_read(
|
|
51
|
+
config: RuntimeConfig, computer_id: str | None, path: str | None, output: str | None
|
|
52
|
+
) -> int:
|
|
53
|
+
resolved = _resolve_file_target(config, computer_id, path)
|
|
54
|
+
if isinstance(resolved, int):
|
|
55
|
+
return resolved
|
|
56
|
+
client, resolved_id, resolved_path = resolved
|
|
57
|
+
data = client.read_file(resolved_id, resolved_path)
|
|
58
|
+
if output:
|
|
59
|
+
try:
|
|
60
|
+
Path(output).write_bytes(data)
|
|
61
|
+
except OSError as exc:
|
|
62
|
+
return cli_output.report_error(f"failed to write output file: {exc}")
|
|
63
|
+
return cli_output.report_success(
|
|
64
|
+
{"path": resolved_path, "output": output, "size": len(data)}
|
|
65
|
+
)
|
|
66
|
+
stdout_buffer = getattr(sys.stdout, "buffer", None)
|
|
67
|
+
if stdout_buffer is None:
|
|
68
|
+
sys.stdout.write(data.decode(errors="replace"))
|
|
69
|
+
sys.stdout.flush()
|
|
70
|
+
return 0
|
|
71
|
+
stdout_buffer.write(data)
|
|
72
|
+
stdout_buffer.flush()
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def handle_files_write(config: RuntimeConfig, args: Any) -> int:
|
|
77
|
+
input_path = getattr(args, "input", None)
|
|
78
|
+
content = getattr(args, "content", None)
|
|
79
|
+
if input_path and content is not None:
|
|
80
|
+
return cli_output.report_error("pass either --input or --content, not both")
|
|
81
|
+
resolved = _resolve_file_target(config, args.id, args.path)
|
|
82
|
+
if isinstance(resolved, int):
|
|
83
|
+
return resolved
|
|
84
|
+
client, resolved_id, resolved_path = resolved
|
|
85
|
+
if input_path:
|
|
86
|
+
try:
|
|
87
|
+
data = Path(input_path).read_bytes()
|
|
88
|
+
except OSError as exc:
|
|
89
|
+
return cli_output.report_error(f"failed to read input file: {exc}")
|
|
90
|
+
elif content is not None:
|
|
91
|
+
data = content.encode()
|
|
92
|
+
else:
|
|
93
|
+
stdin_buffer = getattr(sys.stdin, "buffer", None)
|
|
94
|
+
data = (
|
|
95
|
+
stdin_buffer.read()
|
|
96
|
+
if stdin_buffer is not None
|
|
97
|
+
else sys.stdin.read().encode()
|
|
98
|
+
)
|
|
99
|
+
result = client.write_file(
|
|
100
|
+
resolved_id, resolved_path, data, uid=args.uid, gid=args.gid, mode=args.mode
|
|
101
|
+
)
|
|
102
|
+
return cli_output.report_success(result)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def handle_files_list(
|
|
106
|
+
config: RuntimeConfig, computer_id: str | None, path: str | None
|
|
107
|
+
) -> int:
|
|
108
|
+
resolved = _resolve_file_target(config, computer_id, path or "/home/runtime")
|
|
109
|
+
if isinstance(resolved, int):
|
|
110
|
+
return resolved
|
|
111
|
+
client, resolved_id, resolved_path = resolved
|
|
112
|
+
return cli_output.report_success(client.list_files(resolved_id, resolved_path))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def handle_files_stat(
|
|
116
|
+
config: RuntimeConfig, computer_id: str | None, path: str | None
|
|
117
|
+
) -> int:
|
|
118
|
+
resolved = _resolve_file_target(config, computer_id, path)
|
|
119
|
+
if isinstance(resolved, int):
|
|
120
|
+
return resolved
|
|
121
|
+
client, resolved_id, resolved_path = resolved
|
|
122
|
+
return cli_output.report_success(client.stat_file(resolved_id, resolved_path))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def handle_files_mkdir(
|
|
126
|
+
config: RuntimeConfig,
|
|
127
|
+
computer_id: str | None,
|
|
128
|
+
path: str | None,
|
|
129
|
+
parents: bool,
|
|
130
|
+
mode: str | None,
|
|
131
|
+
) -> int:
|
|
132
|
+
resolved = _resolve_file_target(config, computer_id, path)
|
|
133
|
+
if isinstance(resolved, int):
|
|
134
|
+
return resolved
|
|
135
|
+
client, resolved_id, resolved_path = resolved
|
|
136
|
+
result = client.mkdir(resolved_id, resolved_path, recursive=parents, mode=mode)
|
|
137
|
+
return cli_output.report_success(result)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def handle_files_rm(
|
|
141
|
+
config: RuntimeConfig, computer_id: str | None, path: str | None, recursive: bool
|
|
142
|
+
) -> int:
|
|
143
|
+
resolved = _resolve_file_target(config, computer_id, path)
|
|
144
|
+
if isinstance(resolved, int):
|
|
145
|
+
return resolved
|
|
146
|
+
client, resolved_id, resolved_path = resolved
|
|
147
|
+
result = client.delete_file(resolved_id, resolved_path, recursive=recursive)
|
|
148
|
+
return cli_output.report_success(result)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def handle_files_upload(
|
|
152
|
+
config: RuntimeConfig,
|
|
153
|
+
computer_id: str | None,
|
|
154
|
+
local: str | None,
|
|
155
|
+
remote: str | None,
|
|
156
|
+
) -> int:
|
|
157
|
+
if not local:
|
|
158
|
+
return cli_output.report_error("local path is required")
|
|
159
|
+
local_path = Path(local)
|
|
160
|
+
if not local_path.exists():
|
|
161
|
+
return cli_output.report_error(f"local path does not exist: {local}")
|
|
162
|
+
resolved = _resolve_file_target(config, computer_id, remote)
|
|
163
|
+
if isinstance(resolved, int):
|
|
164
|
+
return resolved
|
|
165
|
+
client, resolved_id, remote_path = resolved
|
|
166
|
+
with tempfile.TemporaryFile() as archive:
|
|
167
|
+
try:
|
|
168
|
+
with tarfile.open(fileobj=archive, mode="w") as tar:
|
|
169
|
+
if local_path.is_dir():
|
|
170
|
+
for child in sorted(local_path.rglob("*")):
|
|
171
|
+
tar.add(
|
|
172
|
+
child,
|
|
173
|
+
arcname=str(child.relative_to(local_path)),
|
|
174
|
+
recursive=False,
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
tar.add(local_path, arcname=local_path.name, recursive=False)
|
|
178
|
+
except (OSError, tarfile.TarError) as exc:
|
|
179
|
+
return cli_output.report_error(f"failed to read local path: {exc}")
|
|
180
|
+
archive.seek(0)
|
|
181
|
+
result = client.upload_files(resolved_id, remote_path, archive)
|
|
182
|
+
return cli_output.report_success(result)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def handle_files_download(
|
|
186
|
+
config: RuntimeConfig,
|
|
187
|
+
computer_id: str | None,
|
|
188
|
+
remote: str | None,
|
|
189
|
+
local: str | None,
|
|
190
|
+
) -> int:
|
|
191
|
+
local_dir = Path(local or ".")
|
|
192
|
+
resolved = _resolve_file_target(config, computer_id, remote)
|
|
193
|
+
if isinstance(resolved, int):
|
|
194
|
+
return resolved
|
|
195
|
+
client, resolved_id, remote_path = resolved
|
|
196
|
+
try:
|
|
197
|
+
local_dir.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
with tempfile.TemporaryFile() as archive:
|
|
199
|
+
client.download_files_to(resolved_id, remote_path, archive)
|
|
200
|
+
archive.seek(0)
|
|
201
|
+
with tarfile.open(fileobj=archive, mode="r") as tar:
|
|
202
|
+
members = tar.getmembers()
|
|
203
|
+
for member in members:
|
|
204
|
+
target = safe_local_extract_path(local_dir, member.name)
|
|
205
|
+
if member.isdir():
|
|
206
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
continue
|
|
208
|
+
if not member.isfile():
|
|
209
|
+
return cli_output.report_error(
|
|
210
|
+
f"unsupported archive entry: {member.name}"
|
|
211
|
+
)
|
|
212
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
source = tar.extractfile(member)
|
|
214
|
+
if source is None:
|
|
215
|
+
return cli_output.report_error(
|
|
216
|
+
f"missing archive data: {member.name}"
|
|
217
|
+
)
|
|
218
|
+
target.write_bytes(source.read())
|
|
219
|
+
except (OSError, tarfile.TarError) as exc:
|
|
220
|
+
return cli_output.report_error(f"failed to write downloaded files: {exc}")
|
|
221
|
+
return cli_output.report_success(
|
|
222
|
+
{"path": remote_path, "output": str(local_dir), "entries": len(members)}
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def safe_local_extract_path(root: Path, name: str) -> Path:
|
|
227
|
+
if not name or Path(name).is_absolute():
|
|
228
|
+
raise RuntimeAPIError(f"unsafe archive entry: {name}")
|
|
229
|
+
target = (root / name).resolve()
|
|
230
|
+
root_resolved = root.resolve()
|
|
231
|
+
if target != root_resolved and root_resolved not in target.parents:
|
|
232
|
+
raise RuntimeAPIError(f"unsafe archive entry: {name}")
|
|
233
|
+
return target
|