holocubic-cli-python 0.1.0a1__py3-none-any.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.
- holocubic_cli_python/__init__.py +3 -0
- holocubic_cli_python/__main__.py +3 -0
- holocubic_cli_python/app.py +85 -0
- holocubic_cli_python/cli.py +543 -0
- holocubic_cli_python/client.py +375 -0
- holocubic_cli_python/config.py +133 -0
- holocubic_cli_python/errors.py +27 -0
- holocubic_cli_python/models.py +179 -0
- holocubic_cli_python/remote_path.py +62 -0
- holocubic_cli_python/transfer.py +661 -0
- holocubic_cli_python/transport.py +99 -0
- holocubic_cli_python/url.py +52 -0
- holocubic_cli_python-0.1.0a1.dist-info/METADATA +67 -0
- holocubic_cli_python-0.1.0a1.dist-info/RECORD +17 -0
- holocubic_cli_python-0.1.0a1.dist-info/WHEEL +4 -0
- holocubic_cli_python-0.1.0a1.dist-info/entry_points.txt +2 -0
- holocubic_cli_python-0.1.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Local HoloCubic app validation."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
|
|
8
|
+
from .errors import CubicError, UsageError
|
|
9
|
+
from .remote_path import remote_join
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ValidatedApp:
|
|
14
|
+
source: Path
|
|
15
|
+
id: str
|
|
16
|
+
destination: str
|
|
17
|
+
entry: str
|
|
18
|
+
|
|
19
|
+
def public(self) -> dict[str, str]:
|
|
20
|
+
return {
|
|
21
|
+
"source": str(self.source),
|
|
22
|
+
"id": self.id,
|
|
23
|
+
"destination": self.destination,
|
|
24
|
+
"entry": self.entry,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_app_id(value: str) -> str:
|
|
29
|
+
app_id = value.strip()
|
|
30
|
+
if (
|
|
31
|
+
not app_id
|
|
32
|
+
or app_id in {".", ".."}
|
|
33
|
+
or app_id.startswith(".cubic-")
|
|
34
|
+
or "/" in app_id
|
|
35
|
+
or "\\" in app_id
|
|
36
|
+
or any(ord(character) < 32 for character in app_id)
|
|
37
|
+
):
|
|
38
|
+
raise UsageError(f"Invalid app id: {value}")
|
|
39
|
+
remote_join("/sd/apps", app_id)
|
|
40
|
+
return app_id
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _require_file(file_path: Path, label: str) -> None:
|
|
44
|
+
try:
|
|
45
|
+
if file_path.is_symlink() or not file_path.is_file():
|
|
46
|
+
raise OSError("not a regular file")
|
|
47
|
+
except OSError as error:
|
|
48
|
+
raise CubicError(
|
|
49
|
+
f"App {label} is missing or is not a regular file: {file_path}",
|
|
50
|
+
code="INVALID_APP",
|
|
51
|
+
) from error
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_app_directory(
|
|
55
|
+
directory: str | Path, requested_id: str | None = None
|
|
56
|
+
) -> ValidatedApp:
|
|
57
|
+
source = Path(os.path.abspath(os.fspath(directory)))
|
|
58
|
+
if source.is_symlink() or not source.is_dir():
|
|
59
|
+
raise CubicError(
|
|
60
|
+
f"App source is not a regular directory: {source}", code="INVALID_APP"
|
|
61
|
+
)
|
|
62
|
+
info_path = source / "app.info"
|
|
63
|
+
_require_file(info_path, "metadata")
|
|
64
|
+
try:
|
|
65
|
+
info = info_path.read_text(encoding="utf-8")
|
|
66
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
67
|
+
raise CubicError(
|
|
68
|
+
f"Unable to read app metadata: {info_path}", code="INVALID_APP"
|
|
69
|
+
) from error
|
|
70
|
+
match = re.search(r"^\s*entry\s*=\s*(.+?)\s*$", info, flags=re.MULTILINE)
|
|
71
|
+
entry = match.group(1).strip() if match else "main.lua"
|
|
72
|
+
pure_entry = PurePosixPath(entry)
|
|
73
|
+
if (
|
|
74
|
+
not entry
|
|
75
|
+
or pure_entry.is_absolute()
|
|
76
|
+
or ".." in pure_entry.parts
|
|
77
|
+
or "\\" in entry
|
|
78
|
+
):
|
|
79
|
+
raise CubicError(
|
|
80
|
+
f"app.info declares an unsafe entry: {entry}", code="INVALID_APP"
|
|
81
|
+
)
|
|
82
|
+
_require_file(source.joinpath(*pure_entry.parts), "entry")
|
|
83
|
+
_require_file(source / "main.lua", "main.lua")
|
|
84
|
+
app_id = validate_app_id(requested_id if requested_id is not None else source.name)
|
|
85
|
+
return ValidatedApp(source, app_id, remote_join("/sd/apps", app_id), entry)
|
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
"""Command-line interface matching the Node reference implementation."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .app import validate_app_directory, validate_app_id
|
|
13
|
+
from .client import CubicClient, public_info
|
|
14
|
+
from .config import (
|
|
15
|
+
ConfigStore,
|
|
16
|
+
default_config_path,
|
|
17
|
+
resolve_device,
|
|
18
|
+
validate_device_name,
|
|
19
|
+
)
|
|
20
|
+
from .errors import CubicError, UsageError
|
|
21
|
+
from .models import TransferLimits
|
|
22
|
+
from .remote_path import assert_can_delete_remote, normalize_remote_path, remote_join
|
|
23
|
+
from .transfer import (
|
|
24
|
+
TransferProgress,
|
|
25
|
+
download_path,
|
|
26
|
+
ensure_remote_directory,
|
|
27
|
+
upload_path,
|
|
28
|
+
)
|
|
29
|
+
from .url import normalize_device_url
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _positive_integer(value: str) -> int:
|
|
33
|
+
try:
|
|
34
|
+
parsed = int(value)
|
|
35
|
+
except ValueError as error:
|
|
36
|
+
raise argparse.ArgumentTypeError("must be a positive integer") from error
|
|
37
|
+
if parsed <= 0:
|
|
38
|
+
raise argparse.ArgumentTypeError("must be a positive integer")
|
|
39
|
+
return parsed
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _add_transfer_options(
|
|
43
|
+
parser: argparse.ArgumentParser, *, download: bool = False
|
|
44
|
+
) -> None:
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"-f", "--force", action="store_true", help="replace an existing target"
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--retries",
|
|
50
|
+
type=_positive_integer,
|
|
51
|
+
default=2,
|
|
52
|
+
help="retry transient chunk failures",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--max-depth", type=_positive_integer, default=32, help="recursive depth limit"
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--max-entries",
|
|
59
|
+
type=_positive_integer,
|
|
60
|
+
default=4096,
|
|
61
|
+
help="recursive entry limit",
|
|
62
|
+
)
|
|
63
|
+
if download:
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--max-bytes",
|
|
66
|
+
type=_positive_integer,
|
|
67
|
+
default=128 * 1024 * 1024,
|
|
68
|
+
help="aggregate directory download limit",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
73
|
+
parser = argparse.ArgumentParser(
|
|
74
|
+
prog="cubic-py",
|
|
75
|
+
description="Manage HoloCubic DevTools devices and SD-card files",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"-H", "--host", help="use a device without changing saved configuration"
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--timeout", type=_positive_integer, default=60_000, metavar="MILLISECONDS"
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"--json",
|
|
88
|
+
action="store_true",
|
|
89
|
+
dest="as_json",
|
|
90
|
+
help="write stable JSON to stdout",
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--quiet", action="store_true", help="suppress progress and success messages"
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument("--config", help="override the configuration file")
|
|
96
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
97
|
+
|
|
98
|
+
device = commands.add_parser("device", help="manage saved devices")
|
|
99
|
+
device_commands = device.add_subparsers(dest="device_command", required=True)
|
|
100
|
+
device_add = device_commands.add_parser("add", help="verify and save a device")
|
|
101
|
+
device_add.add_argument("name")
|
|
102
|
+
device_add.add_argument("device_host")
|
|
103
|
+
device_add.add_argument("--no-use", action="store_false", dest="use", default=True)
|
|
104
|
+
device_commands.add_parser("list", help="list saved devices")
|
|
105
|
+
device_use = device_commands.add_parser("use", help="select a saved device")
|
|
106
|
+
device_use.add_argument("name")
|
|
107
|
+
device_remove = device_commands.add_parser("remove", help="remove a saved device")
|
|
108
|
+
device_remove.add_argument("name")
|
|
109
|
+
|
|
110
|
+
commands.add_parser("ping", help="test the selected device")
|
|
111
|
+
commands.add_parser("info", help="show device capabilities and transfer limits")
|
|
112
|
+
list_parser = commands.add_parser("ls", help="list a remote directory")
|
|
113
|
+
list_parser.add_argument("remote", nargs="?")
|
|
114
|
+
stat_parser = commands.add_parser(
|
|
115
|
+
"stat", help="show remote file or directory metadata"
|
|
116
|
+
)
|
|
117
|
+
stat_parser.add_argument("remote")
|
|
118
|
+
cat_parser = commands.add_parser("cat", help="write a remote file to stdout")
|
|
119
|
+
cat_parser.add_argument("remote")
|
|
120
|
+
mkdir_parser = commands.add_parser(
|
|
121
|
+
"mkdir", help="create a remote directory and missing parents"
|
|
122
|
+
)
|
|
123
|
+
mkdir_parser.add_argument("remote")
|
|
124
|
+
move_parser = commands.add_parser("mv", help="rename or move a remote path")
|
|
125
|
+
move_parser.add_argument("source")
|
|
126
|
+
move_parser.add_argument("target")
|
|
127
|
+
remove_parser = commands.add_parser("rm", help="remove a remote file or directory")
|
|
128
|
+
remove_parser.add_argument("remote")
|
|
129
|
+
remove_parser.add_argument("-r", "--recursive", action="store_true")
|
|
130
|
+
remove_parser.add_argument("-y", "--yes", action="store_true")
|
|
131
|
+
|
|
132
|
+
push_parser = commands.add_parser(
|
|
133
|
+
"push", aliases=["upload"], help="upload a file or directory recursively"
|
|
134
|
+
)
|
|
135
|
+
push_parser.add_argument("local")
|
|
136
|
+
push_parser.add_argument("remote", nargs="?")
|
|
137
|
+
_add_transfer_options(push_parser)
|
|
138
|
+
pull_parser = commands.add_parser(
|
|
139
|
+
"pull", aliases=["download"], help="download a file or directory recursively"
|
|
140
|
+
)
|
|
141
|
+
pull_parser.add_argument("remote")
|
|
142
|
+
pull_parser.add_argument("local", nargs="?")
|
|
143
|
+
_add_transfer_options(pull_parser, download=True)
|
|
144
|
+
|
|
145
|
+
devrun = commands.add_parser("devrun", help="read, save, or run DevRun source")
|
|
146
|
+
devrun_commands = devrun.add_subparsers(dest="devrun_command", required=True)
|
|
147
|
+
devrun_read = devrun_commands.add_parser("read", help="read DevRun source")
|
|
148
|
+
devrun_read.add_argument("output", nargs="?")
|
|
149
|
+
devrun_read.add_argument("-f", "--force", action="store_true")
|
|
150
|
+
for name in ("save", "run"):
|
|
151
|
+
child = devrun_commands.add_parser(
|
|
152
|
+
name, help=f"{'save and run' if name == 'run' else 'save'} DevRun source"
|
|
153
|
+
)
|
|
154
|
+
child.add_argument("file")
|
|
155
|
+
|
|
156
|
+
app = commands.add_parser("app", help="list and install SD-card apps")
|
|
157
|
+
app_commands = app.add_subparsers(dest="app_command", required=True)
|
|
158
|
+
app_commands.add_parser("list", help="list installed apps")
|
|
159
|
+
app_install = app_commands.add_parser(
|
|
160
|
+
"install", help="validate and upload an app directory"
|
|
161
|
+
)
|
|
162
|
+
app_install.add_argument("directory")
|
|
163
|
+
app_install.add_argument("--id")
|
|
164
|
+
app_install.add_argument("-f", "--force", action="store_true")
|
|
165
|
+
app_remove = app_commands.add_parser(
|
|
166
|
+
"remove", help="remove an installed app directory"
|
|
167
|
+
)
|
|
168
|
+
app_remove.add_argument("id")
|
|
169
|
+
app_remove.add_argument("-y", "--yes", action="store_true", required=True)
|
|
170
|
+
return parser
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _configure_stdio() -> None:
|
|
174
|
+
for stream in (sys.stdout, sys.stderr):
|
|
175
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
176
|
+
if callable(reconfigure):
|
|
177
|
+
reconfigure(encoding="utf-8", errors="backslashreplace")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _format_bytes(value: int) -> str:
|
|
181
|
+
if value < 1024:
|
|
182
|
+
return f"{value} B"
|
|
183
|
+
if value < 1024**2:
|
|
184
|
+
return f"{value / 1024:.1f} KiB"
|
|
185
|
+
return f"{value / 1024**2:.1f} MiB"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class _Runtime:
|
|
189
|
+
def __init__(self, args: argparse.Namespace) -> None:
|
|
190
|
+
self.args = args
|
|
191
|
+
config_path = (
|
|
192
|
+
Path(args.config).resolve() if args.config else default_config_path()
|
|
193
|
+
)
|
|
194
|
+
self.store = ConfigStore(config_path)
|
|
195
|
+
|
|
196
|
+
def target(self) -> tuple[CubicClient, str | None, str]:
|
|
197
|
+
resolved = resolve_device(self.store, self.args.host)
|
|
198
|
+
url = str(resolved["url"])
|
|
199
|
+
return CubicClient(url, self.args.timeout), resolved["name"], url
|
|
200
|
+
|
|
201
|
+
def output(self, value: Any, human: Sequence[str] | str | None = None) -> None:
|
|
202
|
+
if self.args.as_json:
|
|
203
|
+
print(json.dumps(value, ensure_ascii=False, separators=(",", ":")))
|
|
204
|
+
elif not self.args.quiet and human is not None:
|
|
205
|
+
if isinstance(human, str):
|
|
206
|
+
print(human)
|
|
207
|
+
else:
|
|
208
|
+
for line in human:
|
|
209
|
+
print(line)
|
|
210
|
+
|
|
211
|
+
def progress(self, event: TransferProgress) -> None:
|
|
212
|
+
if self.args.as_json or self.args.quiet:
|
|
213
|
+
return
|
|
214
|
+
if event.phase == "scan":
|
|
215
|
+
print(f"Scanning {event.path} ...", file=sys.stderr)
|
|
216
|
+
return
|
|
217
|
+
total = f" / {_format_bytes(event.total_bytes)}" if event.total_bytes else ""
|
|
218
|
+
line = f"{event.phase:<8} {_format_bytes(event.transferred_bytes)}{total} {event.path}"
|
|
219
|
+
if sys.stderr.isatty():
|
|
220
|
+
print(
|
|
221
|
+
f"\r{line:<90}",
|
|
222
|
+
end="\n" if event.phase == "commit" else "",
|
|
223
|
+
file=sys.stderr,
|
|
224
|
+
)
|
|
225
|
+
elif event.phase == "commit" or event.transferred_bytes == event.total_bytes:
|
|
226
|
+
print(line, file=sys.stderr)
|
|
227
|
+
|
|
228
|
+
def execute(self) -> None:
|
|
229
|
+
args = self.args
|
|
230
|
+
if args.command == "device":
|
|
231
|
+
self._device()
|
|
232
|
+
elif args.command == "ping":
|
|
233
|
+
client, name, url = self.target()
|
|
234
|
+
started = time.perf_counter()
|
|
235
|
+
info = client.info(force=True)
|
|
236
|
+
latency = round((time.perf_counter() - started) * 1000)
|
|
237
|
+
self.output(
|
|
238
|
+
{
|
|
239
|
+
"ok": True,
|
|
240
|
+
"name": name,
|
|
241
|
+
"url": url,
|
|
242
|
+
"latency_ms": latency,
|
|
243
|
+
"version": info.version,
|
|
244
|
+
},
|
|
245
|
+
f"Connected to {name or url} in {latency} ms{f' ({info.version})' if info.version else ''}.",
|
|
246
|
+
)
|
|
247
|
+
elif args.command == "info":
|
|
248
|
+
client, name, url = self.target()
|
|
249
|
+
info = client.info(force=True)
|
|
250
|
+
self.output(
|
|
251
|
+
public_info(info, url, name),
|
|
252
|
+
[
|
|
253
|
+
f"Device: {name or '(temporary)'}",
|
|
254
|
+
f"URL: {url}",
|
|
255
|
+
f"Version: {info.version or 'unknown'}",
|
|
256
|
+
f"API: v{info.api_version}",
|
|
257
|
+
f"Root: {info.root_path}",
|
|
258
|
+
f"Chunk size: {_format_bytes(info.chunk_size)}",
|
|
259
|
+
f"Max file: {_format_bytes(info.max_file_size)}",
|
|
260
|
+
f"Capabilities: {', '.join(info.capabilities)}",
|
|
261
|
+
],
|
|
262
|
+
)
|
|
263
|
+
elif args.command == "ls":
|
|
264
|
+
client, _, _ = self.target()
|
|
265
|
+
result = client.list(normalize_remote_path(args.remote))
|
|
266
|
+
self.output(
|
|
267
|
+
result.public(),
|
|
268
|
+
[
|
|
269
|
+
f"{'d' if item.is_dir else '-'} {'' if item.is_dir else str(item.size):>10} {item.name}{'/' if item.is_dir else ''}"
|
|
270
|
+
for item in result.items
|
|
271
|
+
],
|
|
272
|
+
)
|
|
273
|
+
elif args.command == "stat":
|
|
274
|
+
client, _, _ = self.target()
|
|
275
|
+
result = client.stat(args.remote)
|
|
276
|
+
self.output(
|
|
277
|
+
result.public(),
|
|
278
|
+
[
|
|
279
|
+
f"Path: {result.path}",
|
|
280
|
+
f"Type: {'directory' if result.is_dir else 'file'}",
|
|
281
|
+
f"Size: {result.size} bytes",
|
|
282
|
+
f"MIME: {result.mime}",
|
|
283
|
+
],
|
|
284
|
+
)
|
|
285
|
+
elif args.command == "cat":
|
|
286
|
+
if args.as_json:
|
|
287
|
+
raise UsageError("`cat` cannot be combined with --json.")
|
|
288
|
+
client, _, _ = self.target()
|
|
289
|
+
remote = normalize_remote_path(args.remote)
|
|
290
|
+
item = client.stat(remote)
|
|
291
|
+
if item.is_dir:
|
|
292
|
+
raise CubicError(
|
|
293
|
+
f"Remote source is a directory: {remote}", code="NOT_A_FILE"
|
|
294
|
+
)
|
|
295
|
+
info = client.info()
|
|
296
|
+
offset = 0
|
|
297
|
+
writer = getattr(sys.stdout, "buffer", sys.stdout)
|
|
298
|
+
while offset < item.size:
|
|
299
|
+
chunk = client.read(
|
|
300
|
+
remote, offset, min(info.chunk_size, item.size - offset)
|
|
301
|
+
)
|
|
302
|
+
if chunk.next_offset != offset + len(chunk.data) or not chunk.data:
|
|
303
|
+
raise CubicError(
|
|
304
|
+
f"Device returned an invalid read offset for {remote}.",
|
|
305
|
+
code="INVALID_RESPONSE",
|
|
306
|
+
)
|
|
307
|
+
writer.write(chunk.data)
|
|
308
|
+
offset = chunk.next_offset
|
|
309
|
+
elif args.command == "mkdir":
|
|
310
|
+
client, _, _ = self.target()
|
|
311
|
+
target = normalize_remote_path(args.remote)
|
|
312
|
+
ensure_remote_directory(client, target)
|
|
313
|
+
self.output({"path": target}, f"Created {target}")
|
|
314
|
+
elif args.command == "mv":
|
|
315
|
+
client, _, _ = self.target()
|
|
316
|
+
source = normalize_remote_path(args.source)
|
|
317
|
+
target = normalize_remote_path(args.target)
|
|
318
|
+
client.rename(source, target)
|
|
319
|
+
self.output(
|
|
320
|
+
{"source": source, "target": target}, f"Moved {source} -> {target}"
|
|
321
|
+
)
|
|
322
|
+
elif args.command == "rm":
|
|
323
|
+
client, _, _ = self.target()
|
|
324
|
+
target = normalize_remote_path(args.remote)
|
|
325
|
+
assert_can_delete_remote(target)
|
|
326
|
+
item = client.stat(target)
|
|
327
|
+
if item.is_dir:
|
|
328
|
+
if not args.recursive:
|
|
329
|
+
raise UsageError(
|
|
330
|
+
f"Remote path is a directory; use --recursive: {target}"
|
|
331
|
+
)
|
|
332
|
+
if not args.yes:
|
|
333
|
+
raise UsageError("Recursive deletion requires --yes.")
|
|
334
|
+
client.rmdir(target, True)
|
|
335
|
+
else:
|
|
336
|
+
client.remove(target)
|
|
337
|
+
self.output(
|
|
338
|
+
{"removed": target, "recursive": item.is_dir}, f"Removed {target}"
|
|
339
|
+
)
|
|
340
|
+
elif args.command in {"push", "upload"}:
|
|
341
|
+
client, _, _ = self.target()
|
|
342
|
+
result = upload_path(
|
|
343
|
+
client,
|
|
344
|
+
Path(args.local),
|
|
345
|
+
args.remote,
|
|
346
|
+
force=args.force,
|
|
347
|
+
retries=args.retries,
|
|
348
|
+
limits=TransferLimits(args.max_depth, args.max_entries),
|
|
349
|
+
on_progress=self.progress,
|
|
350
|
+
)
|
|
351
|
+
self.output(
|
|
352
|
+
result.public(),
|
|
353
|
+
f"Uploaded {result.files} file(s), {_format_bytes(result.bytes)} -> {result.destination}",
|
|
354
|
+
)
|
|
355
|
+
elif args.command in {"pull", "download"}:
|
|
356
|
+
client, _, _ = self.target()
|
|
357
|
+
result = download_path(
|
|
358
|
+
client,
|
|
359
|
+
args.remote,
|
|
360
|
+
Path(args.local) if args.local else None,
|
|
361
|
+
force=args.force,
|
|
362
|
+
retries=args.retries,
|
|
363
|
+
limits=TransferLimits(args.max_depth, args.max_entries, args.max_bytes),
|
|
364
|
+
on_progress=self.progress,
|
|
365
|
+
)
|
|
366
|
+
self.output(
|
|
367
|
+
result.public(),
|
|
368
|
+
f"Downloaded {result.files} file(s), {_format_bytes(result.bytes)} -> {result.destination}",
|
|
369
|
+
)
|
|
370
|
+
elif args.command == "devrun":
|
|
371
|
+
self._devrun()
|
|
372
|
+
elif args.command == "app":
|
|
373
|
+
self._app()
|
|
374
|
+
else:
|
|
375
|
+
raise UsageError(f"Unknown command: {args.command}")
|
|
376
|
+
|
|
377
|
+
def _device(self) -> None:
|
|
378
|
+
args = self.args
|
|
379
|
+
if args.device_command == "add":
|
|
380
|
+
name = validate_device_name(args.name)
|
|
381
|
+
url = normalize_device_url(args.device_host)
|
|
382
|
+
info = CubicClient(url, args.timeout).info(force=True)
|
|
383
|
+
config = self.store.read()
|
|
384
|
+
profile = {"url": url}
|
|
385
|
+
if info.version:
|
|
386
|
+
profile["version"] = info.version
|
|
387
|
+
config["devices"][name] = profile
|
|
388
|
+
if args.use:
|
|
389
|
+
config["current"] = name
|
|
390
|
+
self.store.write(config)
|
|
391
|
+
self.output(
|
|
392
|
+
{
|
|
393
|
+
"name": name,
|
|
394
|
+
"url": url,
|
|
395
|
+
"selected": config["current"] == name,
|
|
396
|
+
"version": info.version,
|
|
397
|
+
},
|
|
398
|
+
[f"Added {name}: {url}"]
|
|
399
|
+
+ ([f"Selected device: {name}"] if config["current"] == name else []),
|
|
400
|
+
)
|
|
401
|
+
elif args.device_command == "list":
|
|
402
|
+
config = self.store.read()
|
|
403
|
+
rows = [
|
|
404
|
+
{
|
|
405
|
+
"name": name,
|
|
406
|
+
"url": profile["url"],
|
|
407
|
+
"version": profile.get("version"),
|
|
408
|
+
"selected": name == config["current"],
|
|
409
|
+
}
|
|
410
|
+
for name, profile in sorted(config["devices"].items())
|
|
411
|
+
]
|
|
412
|
+
lines: list[str] = []
|
|
413
|
+
for row in rows:
|
|
414
|
+
suffix = f" {row['version']}" if row["version"] else ""
|
|
415
|
+
lines.append(
|
|
416
|
+
f"{'*' if row['selected'] else ' '} {row['name']:<16} {row['url']}{suffix}"
|
|
417
|
+
)
|
|
418
|
+
if not lines:
|
|
419
|
+
lines = ["No saved devices."]
|
|
420
|
+
self.output({"current": config["current"], "devices": rows}, lines)
|
|
421
|
+
elif args.device_command in {"use", "remove"}:
|
|
422
|
+
name = validate_device_name(args.name)
|
|
423
|
+
config = self.store.read()
|
|
424
|
+
if name not in config["devices"]:
|
|
425
|
+
raise CubicError(f"Unknown device: {name}", code="NO_DEVICE")
|
|
426
|
+
if args.device_command == "use":
|
|
427
|
+
config["current"] = name
|
|
428
|
+
self.store.write(config)
|
|
429
|
+
self.output({"current": name}, f"Selected device: {name}")
|
|
430
|
+
else:
|
|
431
|
+
del config["devices"][name]
|
|
432
|
+
if config["current"] == name:
|
|
433
|
+
config["current"] = None
|
|
434
|
+
self.store.write(config)
|
|
435
|
+
self.output(
|
|
436
|
+
{"removed": name, "current": config["current"]},
|
|
437
|
+
f"Removed device: {name}",
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
def _devrun(self) -> None:
|
|
441
|
+
args = self.args
|
|
442
|
+
client, _, _ = self.target()
|
|
443
|
+
if args.devrun_command == "read":
|
|
444
|
+
source = client.read_devrun()
|
|
445
|
+
if not args.output:
|
|
446
|
+
if args.as_json:
|
|
447
|
+
self.output({"source": source})
|
|
448
|
+
else:
|
|
449
|
+
print(source, end="")
|
|
450
|
+
return
|
|
451
|
+
output_path = Path(args.output).resolve()
|
|
452
|
+
mode = "w" if args.force else "x"
|
|
453
|
+
try:
|
|
454
|
+
with output_path.open(mode, encoding="utf-8", newline="") as handle:
|
|
455
|
+
handle.write(source)
|
|
456
|
+
except FileExistsError as error:
|
|
457
|
+
raise CubicError(
|
|
458
|
+
f"Local target already exists: {output_path}. Use --force to replace it.",
|
|
459
|
+
code="TARGET_EXISTS",
|
|
460
|
+
) from error
|
|
461
|
+
self.output(
|
|
462
|
+
{"path": str(output_path), "bytes": len(source.encode("utf-8"))},
|
|
463
|
+
f"Saved DevRun source to {output_path}",
|
|
464
|
+
)
|
|
465
|
+
else:
|
|
466
|
+
source = Path(args.file).resolve().read_text(encoding="utf-8")
|
|
467
|
+
run = args.devrun_command == "run"
|
|
468
|
+
result = client.save_devrun(source, run)
|
|
469
|
+
self.output(
|
|
470
|
+
result.public(),
|
|
471
|
+
f"{'Ran' if run else 'Saved'} {result.entry} ({result.bytes} bytes)",
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def _app(self) -> None:
|
|
475
|
+
args = self.args
|
|
476
|
+
client, _, _ = self.target()
|
|
477
|
+
if args.app_command == "list":
|
|
478
|
+
result = client.apps()
|
|
479
|
+
self.output(
|
|
480
|
+
result.public(),
|
|
481
|
+
[
|
|
482
|
+
f"{item.get('id', '')}{' *' if item.get('id') == result.current_app_id else ''}"
|
|
483
|
+
for item in result.apps
|
|
484
|
+
],
|
|
485
|
+
)
|
|
486
|
+
elif args.app_command == "install":
|
|
487
|
+
validated = validate_app_directory(Path(args.directory), args.id)
|
|
488
|
+
apps = client.apps()
|
|
489
|
+
if validated.id == apps.run_app_id:
|
|
490
|
+
raise UsageError(
|
|
491
|
+
f"Refusing to replace {apps.run_app_id}; use the dedicated devrun commands."
|
|
492
|
+
)
|
|
493
|
+
if validated.id == apps.current_app_id:
|
|
494
|
+
raise UsageError(
|
|
495
|
+
f"Refusing to replace the currently running app {validated.id}; switch apps first."
|
|
496
|
+
)
|
|
497
|
+
transfer = upload_path(
|
|
498
|
+
client,
|
|
499
|
+
validated.source,
|
|
500
|
+
validated.destination,
|
|
501
|
+
force=args.force,
|
|
502
|
+
on_progress=self.progress,
|
|
503
|
+
)
|
|
504
|
+
result = {
|
|
505
|
+
**validated.public(),
|
|
506
|
+
"transfer": transfer.public(),
|
|
507
|
+
"rescanRequired": True,
|
|
508
|
+
}
|
|
509
|
+
self.output(
|
|
510
|
+
result,
|
|
511
|
+
[
|
|
512
|
+
f"Installed {validated.id} -> {validated.destination}",
|
|
513
|
+
"Rescan apps on the device before first launch.",
|
|
514
|
+
],
|
|
515
|
+
)
|
|
516
|
+
elif args.app_command == "remove":
|
|
517
|
+
app_id = validate_app_id(args.id)
|
|
518
|
+
target = remote_join("/sd/apps", app_id)
|
|
519
|
+
apps = client.apps()
|
|
520
|
+
if app_id == apps.run_app_id:
|
|
521
|
+
raise UsageError(
|
|
522
|
+
f"Refusing to remove {apps.run_app_id}; it is managed by the dedicated devrun commands."
|
|
523
|
+
)
|
|
524
|
+
if app_id == apps.current_app_id:
|
|
525
|
+
raise UsageError(
|
|
526
|
+
f"Refusing to remove the currently running app {app_id}; switch apps first."
|
|
527
|
+
)
|
|
528
|
+
client.rmdir(target, True)
|
|
529
|
+
self.output({"removed": app_id, "path": target}, f"Removed app {app_id}")
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
533
|
+
_configure_stdio()
|
|
534
|
+
args = build_parser().parse_args(argv)
|
|
535
|
+
try:
|
|
536
|
+
_Runtime(args).execute()
|
|
537
|
+
return 0
|
|
538
|
+
except CubicError as error:
|
|
539
|
+
print(f"cubic-py: {error}", file=sys.stderr)
|
|
540
|
+
return error.exit_code
|
|
541
|
+
except (OSError, UnicodeError) as error:
|
|
542
|
+
print(f"cubic-py: {error}", file=sys.stderr)
|
|
543
|
+
return 1
|