iowap-node 2.3.20__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.
- iowap_node-2.3.20.dist-info/METADATA +126 -0
- iowap_node-2.3.20.dist-info/RECORD +28 -0
- iowap_node-2.3.20.dist-info/WHEEL +4 -0
- iowap_node-2.3.20.dist-info/entry_points.txt +3 -0
- iowap_node-2.3.20.dist-info/licenses/LICENSE +21 -0
- nodes/__init__.py +1 -0
- nodes/common/__init__.py +0 -0
- nodes/common/capability.py +520 -0
- nodes/common/cli/__init__.py +0 -0
- nodes/common/cli/cli_artifact.py +42 -0
- nodes/common/cli/cli_bridge.py +264 -0
- nodes/common/cli/cli_capabilities.py +229 -0
- nodes/common/cli/cli_docs.py +122 -0
- nodes/common/cli/cli_file.py +434 -0
- nodes/common/cli/cli_hp.py +546 -0
- nodes/common/cli/cli_node.py +249 -0
- nodes/common/cli/cli_route.py +92 -0
- nodes/common/cli/cli_server.py +248 -0
- nodes/common/cli/cli_task.py +191 -0
- nodes/common/cli/cli_update.py +48 -0
- nodes/common/envelope.py +168 -0
- nodes/common/file_serve.py +648 -0
- nodes/common/handler_runner.py +497 -0
- nodes/common/node_cli.py +1311 -0
- nodes/common/node_config.py +856 -0
- nodes/common/node_daemon.py +946 -0
- nodes/common/node_utils.py +448 -0
- nodes/common/relay_client.py +1246 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""CLI bridge subcommands — upload/download large files via temp bridge routes (T-153).
|
|
2
|
+
|
|
3
|
+
The bridge way (T-129) streams big files directly storage<->caller instead
|
|
4
|
+
of buffering them through the relay or the artifact store. A caller opens
|
|
5
|
+
a channel by submitting a bridge task (``storage.upload_channel`` /
|
|
6
|
+
``storage.download_channel``, or the backup variants ``backup.create``
|
|
7
|
+
``mode=bridge`` / ``backup.restore``), waits for the stage to complete,
|
|
8
|
+
extracts the public ``upload_url`` / ``download_url``, then streams the
|
|
9
|
+
file against that URL chunkwise.
|
|
10
|
+
|
|
11
|
+
Handler signatures are plain ``(client, args) -> int``; the
|
|
12
|
+
``node_cli.with_client`` decorator is applied at parser-registration time
|
|
13
|
+
in the facade.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from nodes.common.relay_client import RelayClient
|
|
28
|
+
|
|
29
|
+
# Chunk size for streaming upload/download (64 KB, same as the storage
|
|
30
|
+
# node writes with).
|
|
31
|
+
_CHUNK = 64 * 1024
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Shared helpers
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _find_result(data: dict[str, Any]) -> dict[str, Any] | None:
|
|
40
|
+
"""Return the first completed stage's result from a task fetch.
|
|
41
|
+
|
|
42
|
+
T-005b Response Envelope (iowap-node >= 2.3.12): handler_runner
|
|
43
|
+
normalizes conforming handler stdout to the Response Envelope and
|
|
44
|
+
passes it through VERBATIM, so ``stages[].result`` can BE the whole
|
|
45
|
+
envelope ``{"status": ..., "result": {...}, "error": ...}`` (plus the
|
|
46
|
+
``_handler`` debug dict) instead of the flat inner result the
|
|
47
|
+
pre-T-005b CLI consumers expect. Unwrap exactly one envelope layer;
|
|
48
|
+
legacy flat results pass through unchanged. An envelope with
|
|
49
|
+
``status: "error"`` or a set ``error`` yields None (failed work).
|
|
50
|
+
"""
|
|
51
|
+
for s in data.get("stages", []):
|
|
52
|
+
if s.get("status") == "completed" and s.get("result"):
|
|
53
|
+
res = s["result"]
|
|
54
|
+
is_envelope = isinstance(res, dict) and (
|
|
55
|
+
"_handler" in res or {"status", "result", "error"} <= set(res)
|
|
56
|
+
)
|
|
57
|
+
if is_envelope:
|
|
58
|
+
if res.get("status") == "error" or res.get("error"):
|
|
59
|
+
return None
|
|
60
|
+
inner = res.get("result")
|
|
61
|
+
return inner if isinstance(inner, dict) else {}
|
|
62
|
+
return res
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _wait_for_result(client: RelayClient, task_id: str, interval: int) -> dict[str, Any] | None:
|
|
67
|
+
"""Poll a task until it reaches a terminal state; return the completed
|
|
68
|
+
stage result (or None on failure/not-found)."""
|
|
69
|
+
while True:
|
|
70
|
+
data = client.get_task(task_id)
|
|
71
|
+
if "error" in data:
|
|
72
|
+
print(f"Task {task_id}: {data['error']}", file=sys.stderr)
|
|
73
|
+
return None
|
|
74
|
+
task = data.get("task", {})
|
|
75
|
+
status = task.get("status", "unknown")
|
|
76
|
+
if status in ("completed", "failed", "timed_out", "cancelled"):
|
|
77
|
+
if status != "completed":
|
|
78
|
+
print(f"Task {task_id} — {status}", file=sys.stderr)
|
|
79
|
+
return None
|
|
80
|
+
return _find_result(data)
|
|
81
|
+
print(f"\r⏳ {status}...", end="", flush=True)
|
|
82
|
+
time.sleep(max(1, interval))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _bearer(client: RelayClient) -> dict[str, str]:
|
|
86
|
+
"""Authorization header for reaching the caller-authenticated bridge route."""
|
|
87
|
+
if not client.token:
|
|
88
|
+
print("no runtime token available", file=sys.stderr)
|
|
89
|
+
raise SystemExit(1)
|
|
90
|
+
return {"Authorization": f"Bearer {client.token}"}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# bridge upload
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _cmd_bridge_upload(client: RelayClient, args) -> int:
|
|
99
|
+
file_path = Path(args.file)
|
|
100
|
+
if not file_path.is_file():
|
|
101
|
+
print(f"file not found: {file_path}", file=sys.stderr)
|
|
102
|
+
return 2
|
|
103
|
+
|
|
104
|
+
# Choose the bridge task to open the channel: storage or backup.
|
|
105
|
+
if args.backup:
|
|
106
|
+
payload: dict[str, Any] = {
|
|
107
|
+
"source": args.source or file_path.name,
|
|
108
|
+
"type": args.type or "full",
|
|
109
|
+
"mode": "bridge",
|
|
110
|
+
# T-162: preserve the original filename so a restore writes it
|
|
111
|
+
# back with the right name/suffix.
|
|
112
|
+
"filename": file_path.name,
|
|
113
|
+
}
|
|
114
|
+
capability = "backup.create"
|
|
115
|
+
else:
|
|
116
|
+
payload = {"channel_id": args.channel} if args.channel else {}
|
|
117
|
+
capability = "storage.upload_channel"
|
|
118
|
+
|
|
119
|
+
resp = client.submit_simple_task(capability, payload, name=args.name or "",
|
|
120
|
+
priority=args.priority)
|
|
121
|
+
task_id = resp.get("task_id") or resp.get("id")
|
|
122
|
+
if not task_id:
|
|
123
|
+
print(f"no task_id in submit response: {resp}", file=sys.stderr)
|
|
124
|
+
return 1
|
|
125
|
+
|
|
126
|
+
result = _wait_for_result(client, str(task_id), args.interval)
|
|
127
|
+
if result is None:
|
|
128
|
+
return 1
|
|
129
|
+
|
|
130
|
+
url = result.get("upload_url")
|
|
131
|
+
if not url:
|
|
132
|
+
print(f"no upload_url in result: {result}", file=sys.stderr)
|
|
133
|
+
return 1
|
|
134
|
+
|
|
135
|
+
# Stream the file chunkwise to the bridge URL (relay proxies it).
|
|
136
|
+
# T-162: send the original filename so the storage node preserves it
|
|
137
|
+
# (instead of storing under the opaque channel id / data.bin).
|
|
138
|
+
headers = _bearer(client)
|
|
139
|
+
headers["X-Filename"] = file_path.name
|
|
140
|
+
try:
|
|
141
|
+
with file_path.open("rb") as f, httpx.stream(
|
|
142
|
+
"POST", url, headers=headers, content=f,
|
|
143
|
+
timeout=httpx.Timeout(30.0, read=None),
|
|
144
|
+
) as r:
|
|
145
|
+
r.raise_for_status()
|
|
146
|
+
except httpx.HTTPStatusError as exc:
|
|
147
|
+
print(f"bridge upload failed ({exc.response.status_code}): "
|
|
148
|
+
f"{exc.response.text}", file=sys.stderr)
|
|
149
|
+
return 1
|
|
150
|
+
except httpx.HTTPError as exc:
|
|
151
|
+
print(f"bridge upload failed: {exc}", file=sys.stderr)
|
|
152
|
+
return 1
|
|
153
|
+
|
|
154
|
+
if args.json:
|
|
155
|
+
print(json.dumps({"status": "uploaded", "path": str(file_path),
|
|
156
|
+
"size_bytes": file_path.stat().st_size,
|
|
157
|
+
"channel_id": result.get("channel_id"),
|
|
158
|
+
"backup_id": result.get("backup_id")}, default=str))
|
|
159
|
+
else:
|
|
160
|
+
tag = result.get("backup_id") or result.get("channel_id") or ""
|
|
161
|
+
suffix = f" ({tag})" if tag else ""
|
|
162
|
+
print(f"✅ Uploaded {file_path.stat().st_size} bytes via bridge{suffix}")
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# bridge download
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _cmd_bridge_download(client: RelayClient, args) -> int:
|
|
172
|
+
if args.backup:
|
|
173
|
+
capability = "backup.restore"
|
|
174
|
+
payload: dict[str, Any] = {"backup_id": args.backup}
|
|
175
|
+
elif args.channel:
|
|
176
|
+
capability = "storage.download_channel"
|
|
177
|
+
payload = {"channel_id": args.channel}
|
|
178
|
+
else:
|
|
179
|
+
print("bridge download requires --channel <id> or --backup <id>", file=sys.stderr)
|
|
180
|
+
return 2
|
|
181
|
+
|
|
182
|
+
resp = client.submit_simple_task(capability, payload, name=args.name or "",
|
|
183
|
+
priority=args.priority)
|
|
184
|
+
task_id = resp.get("task_id") or resp.get("id")
|
|
185
|
+
if not task_id:
|
|
186
|
+
print(f"no task_id in submit response: {resp}", file=sys.stderr)
|
|
187
|
+
return 1
|
|
188
|
+
|
|
189
|
+
result = _wait_for_result(client, str(task_id), args.interval)
|
|
190
|
+
if result is None:
|
|
191
|
+
return 1
|
|
192
|
+
|
|
193
|
+
out_path = args.output
|
|
194
|
+
# NOTE: when out_path is None we do NOT derive a default here — the
|
|
195
|
+
# storage node echoes the original filename in the X-Filename response
|
|
196
|
+
# header (T-162), which is only known after the stream starts. We set
|
|
197
|
+
# the default inside the stream block below, falling back to the
|
|
198
|
+
# channel/backup id.
|
|
199
|
+
|
|
200
|
+
# Small backups may come back inline as data_base64 — write that directly.
|
|
201
|
+
if "data_base64" in result and not result.get("download_url"):
|
|
202
|
+
if out_path is None:
|
|
203
|
+
# Inline path: no X-Filename header, but the backup manifest
|
|
204
|
+
# carries the original filename (T-162).
|
|
205
|
+
out_path = Path(
|
|
206
|
+
result.get("filename")
|
|
207
|
+
or result.get("channel_id")
|
|
208
|
+
or result.get("backup_id")
|
|
209
|
+
or "download"
|
|
210
|
+
)
|
|
211
|
+
try:
|
|
212
|
+
out_path.write_bytes(base64.b64decode(result["data_base64"]))
|
|
213
|
+
except Exception as exc: # noqa: BLE001
|
|
214
|
+
print(f"failed to write inline data: {exc}", file=sys.stderr)
|
|
215
|
+
return 1
|
|
216
|
+
if args.json:
|
|
217
|
+
print(json.dumps({"status": "downloaded", "path": str(out_path),
|
|
218
|
+
"size_bytes": out_path.stat().st_size}, default=str))
|
|
219
|
+
else:
|
|
220
|
+
print(f"✅ Downloaded {out_path.stat().st_size} bytes to {out_path}")
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
url = result.get("download_url")
|
|
224
|
+
if not url:
|
|
225
|
+
print(f"no download_url in result: {result}", file=sys.stderr)
|
|
226
|
+
return 1
|
|
227
|
+
|
|
228
|
+
# Stream the response chunkwise to the output file. When no --output
|
|
229
|
+
# was given, restore the original filename (T-162): prefer the backup
|
|
230
|
+
# manifest's filename (result.filename), then the channel's X-Filename
|
|
231
|
+
# response header, then fall back to the channel/backup id.
|
|
232
|
+
try:
|
|
233
|
+
with httpx.stream(
|
|
234
|
+
"GET", url, headers=_bearer(client), timeout=httpx.Timeout(30.0, read=None)
|
|
235
|
+
) as r:
|
|
236
|
+
r.raise_for_status()
|
|
237
|
+
if out_path is None:
|
|
238
|
+
header_name = r.headers.get("x-filename")
|
|
239
|
+
out_path = Path(
|
|
240
|
+
result.get("filename")
|
|
241
|
+
or header_name
|
|
242
|
+
or result.get("channel_id")
|
|
243
|
+
or result.get("backup_id")
|
|
244
|
+
or "download"
|
|
245
|
+
)
|
|
246
|
+
total = 0
|
|
247
|
+
with out_path.open("wb") as f:
|
|
248
|
+
for chunk in r.iter_bytes(chunk_size=_CHUNK):
|
|
249
|
+
f.write(chunk)
|
|
250
|
+
total += len(chunk)
|
|
251
|
+
except httpx.HTTPStatusError as exc:
|
|
252
|
+
print(f"bridge download failed ({exc.response.status_code}): "
|
|
253
|
+
f"{exc.response.text}", file=sys.stderr)
|
|
254
|
+
return 1
|
|
255
|
+
except httpx.HTTPError as exc:
|
|
256
|
+
print(f"bridge download failed: {exc}", file=sys.stderr)
|
|
257
|
+
return 1
|
|
258
|
+
|
|
259
|
+
if args.json:
|
|
260
|
+
print(json.dumps({"status": "downloaded", "path": str(out_path),
|
|
261
|
+
"size_bytes": out_path.stat().st_size}, default=str))
|
|
262
|
+
else:
|
|
263
|
+
print(f"✅ Downloaded {out_path.stat().st_size} bytes to {out_path}")
|
|
264
|
+
return 0
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""CLI capabilities subcommands — list/validate/publish/diff/current/server/info (T-117 split).
|
|
2
|
+
|
|
3
|
+
Constants ``ACTIVE_PATH`` and ``PROFILES_DIR`` are referenced lazily via
|
|
4
|
+
``node_config`` (``_nc.ACTIVE_PATH`` / ``_nc.PROFILES_DIR``) so the
|
|
5
|
+
``cl.ACTIVE_PATH`` / ``cl.PROFILES_DIR`` monkeypatches applied by the test
|
|
6
|
+
fixture take effect. ``PID_PATH`` (CLI-specific, lives in the facade) is
|
|
7
|
+
referenced lazily via ``node_cli.PID_PATH`` at call time — this is the
|
|
8
|
+
one tolerated circular access because it only resolves inside a handler
|
|
9
|
+
body, after ``node_cli`` is fully initialised. ``node_daemon.PID_PATH``
|
|
10
|
+
(SSE daemon) is resolved the same lazy way inside the publish handler
|
|
11
|
+
(t_c214ca25: the SSE daemon's pid file must be probed for hot-reload).
|
|
12
|
+
|
|
13
|
+
The ``with_client``-decorated handlers (server/info) keep the plain
|
|
14
|
+
``(client, args) -> int`` signature; the decorator is applied at
|
|
15
|
+
parser-registration time in the facade.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import nodes.common.node_cli as _cli
|
|
25
|
+
from nodes.common import node_config as _nc
|
|
26
|
+
from nodes.common import node_utils as _nu
|
|
27
|
+
from nodes.common.node_config import (
|
|
28
|
+
CapabilityValidationError,
|
|
29
|
+
current_profile_name,
|
|
30
|
+
diff_profiles,
|
|
31
|
+
list_profiles,
|
|
32
|
+
load_profile,
|
|
33
|
+
profile_path,
|
|
34
|
+
publish_profile,
|
|
35
|
+
validate_profile,
|
|
36
|
+
)
|
|
37
|
+
from nodes.common.relay_client import RelayClient, _setup_logging
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _cmd_capabilities_list(args) -> int: # noqa: ARG001
|
|
41
|
+
profiles = list_profiles()
|
|
42
|
+
if not profiles:
|
|
43
|
+
print("(no profiles in %s)" % _nc.PROFILES_DIR)
|
|
44
|
+
return 0
|
|
45
|
+
active = current_profile_name()
|
|
46
|
+
for p in profiles:
|
|
47
|
+
marker = "*" if active and (p.stem == active or p.name == active) else " "
|
|
48
|
+
print(f"{marker} {p.stem}")
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _cmd_capabilities_validate(args) -> int:
|
|
53
|
+
_setup_logging(args.log_level)
|
|
54
|
+
target = args.profile
|
|
55
|
+
if target is None:
|
|
56
|
+
if not _nc.ACTIVE_PATH.exists():
|
|
57
|
+
print("no active profile and no profile name given", file=sys.stderr)
|
|
58
|
+
return 1
|
|
59
|
+
path = _nc.ACTIVE_PATH
|
|
60
|
+
target = "active"
|
|
61
|
+
else:
|
|
62
|
+
path = profile_path(target)
|
|
63
|
+
try:
|
|
64
|
+
caps = validate_profile(path)
|
|
65
|
+
except CapabilityValidationError as exc:
|
|
66
|
+
print(f"INVALID {target}: {exc}", file=sys.stderr)
|
|
67
|
+
return 1
|
|
68
|
+
print(f"OK {target} ({len(caps)} capability{'ies' if len(caps) != 1 else ''})")
|
|
69
|
+
for c in caps:
|
|
70
|
+
print(f" - {c['name']} v{c['version']} "
|
|
71
|
+
f"auto_publish={c['auto_publish']} claimable={c['claimable']} "
|
|
72
|
+
f"max_parallel={c['max_parallel']} timeout={c['timeout']}")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _cmd_capabilities_publish(args) -> int:
|
|
77
|
+
_setup_logging(args.log_level)
|
|
78
|
+
try:
|
|
79
|
+
active = publish_profile(args.profile)
|
|
80
|
+
except CapabilityValidationError as exc:
|
|
81
|
+
print(f"publish FAILED: {exc}", file=sys.stderr)
|
|
82
|
+
return 1
|
|
83
|
+
# Best-effort SIGHUP to any running daemon. The SSE daemon writes
|
|
84
|
+
# node-daemon.pid; the legacy polling daemon writes node-cli.pid —
|
|
85
|
+
# both install SIGHUP handlers, so notify every live pid file
|
|
86
|
+
# (t_c214ca25: the old code only probed node-cli.pid and always took
|
|
87
|
+
# the "daemon not running" branch on SSE-only nodes).
|
|
88
|
+
from nodes.common.node_daemon import PID_PATH as _DAEMON_PID_PATH
|
|
89
|
+
|
|
90
|
+
pids = _nu.sighup_pid_files([_DAEMON_PID_PATH, _cli.PID_PATH])
|
|
91
|
+
if pids:
|
|
92
|
+
print(f"published '{args.profile}' -> {active} (sent SIGHUP to pid {', '.join(str(p) for p in pids)})")
|
|
93
|
+
else:
|
|
94
|
+
print(f"published '{args.profile}' -> {active} (daemon not running)")
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _cmd_capabilities_diff(args) -> int:
|
|
99
|
+
_setup_logging(args.log_level)
|
|
100
|
+
if args.profile is None:
|
|
101
|
+
if not _nc.ACTIVE_PATH.exists():
|
|
102
|
+
print("no active profile and no profile name given", file=sys.stderr)
|
|
103
|
+
return 1
|
|
104
|
+
working_path = _nc.ACTIVE_PATH
|
|
105
|
+
working_label = "active"
|
|
106
|
+
else:
|
|
107
|
+
working_path = profile_path(args.profile)
|
|
108
|
+
working_label = args.profile
|
|
109
|
+
try:
|
|
110
|
+
working = load_profile(working_path)
|
|
111
|
+
except CapabilityValidationError as exc:
|
|
112
|
+
print(f"working profile invalid: {exc}", file=sys.stderr)
|
|
113
|
+
return 1
|
|
114
|
+
if _nc.ACTIVE_PATH.exists():
|
|
115
|
+
try:
|
|
116
|
+
active = load_profile(_nc.ACTIVE_PATH)
|
|
117
|
+
except CapabilityValidationError as exc:
|
|
118
|
+
print(f"active profile invalid: {exc}", file=sys.stderr)
|
|
119
|
+
return 1
|
|
120
|
+
else:
|
|
121
|
+
active = []
|
|
122
|
+
diff = diff_profiles(active, working)
|
|
123
|
+
if not diff["added"] and not diff["removed"] and not diff["changed"]:
|
|
124
|
+
print(f"no differences between active and {working_label}")
|
|
125
|
+
return 0
|
|
126
|
+
print(f"diff active -> {working_label}:")
|
|
127
|
+
for c in diff["added"]:
|
|
128
|
+
print(f"+ {c['name']} v{c['version']}")
|
|
129
|
+
for name in diff["removed"]:
|
|
130
|
+
print(f"- {name}")
|
|
131
|
+
for ch in diff["changed"]:
|
|
132
|
+
print(f"~ {ch['name']}:")
|
|
133
|
+
_print_cap_diff(ch["old"], ch["new"])
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _print_cap_diff(old: dict[str, Any], new: dict[str, Any]) -> None:
|
|
138
|
+
keys = ("version", "auto_publish", "claimable", "handler", "max_parallel", "timeout")
|
|
139
|
+
for k in keys:
|
|
140
|
+
ov, nv = old.get(k), new.get(k)
|
|
141
|
+
if ov != nv:
|
|
142
|
+
print(f" {k}: {ov!r} -> {nv!r}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _cmd_capabilities_current(args) -> int: # noqa: ARG001
|
|
146
|
+
name = current_profile_name()
|
|
147
|
+
if name is None:
|
|
148
|
+
print("(no active profile set)")
|
|
149
|
+
return 1
|
|
150
|
+
print(name)
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _cmd_capabilities_server(client: RelayClient, args) -> int:
|
|
155
|
+
"""Query capabilities from the relay server (all registered nodes)."""
|
|
156
|
+
try:
|
|
157
|
+
resp = client._get_with_retry("/relay/v2/discovery/capabilities")
|
|
158
|
+
resp.raise_for_status()
|
|
159
|
+
data = resp.json()
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
print(f"failed to query server capabilities: {exc}", file=sys.stderr)
|
|
162
|
+
return 1
|
|
163
|
+
|
|
164
|
+
caps = data.get("capabilities", data) if isinstance(data, dict) else data
|
|
165
|
+
if args.json:
|
|
166
|
+
print(json.dumps(caps, default=str))
|
|
167
|
+
return 0
|
|
168
|
+
if not caps:
|
|
169
|
+
print("(no capabilities registered on the server)")
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
print(f"Server capabilities ({len(caps)} total):\n")
|
|
173
|
+
for c in caps:
|
|
174
|
+
name = c.get("name", "?")
|
|
175
|
+
ver = c.get("version", "?")
|
|
176
|
+
avail = c.get("available", False)
|
|
177
|
+
nodes = c.get("nodes", [])
|
|
178
|
+
status = "✅" if avail else "❌"
|
|
179
|
+
node_names = ", ".join(
|
|
180
|
+
f"{n.get('node_name', '?')} ({n.get('node_id', '?')})" for n in nodes
|
|
181
|
+
) if nodes else "(no nodes)"
|
|
182
|
+
print(f" {status} {name:20} v{ver:8} [{node_names}]")
|
|
183
|
+
desc = c.get("description")
|
|
184
|
+
if desc:
|
|
185
|
+
print(f" {desc}")
|
|
186
|
+
schema = c.get("input_schema")
|
|
187
|
+
if schema:
|
|
188
|
+
print(f" Input: {json.dumps(schema, indent=6)}")
|
|
189
|
+
print()
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _cmd_capabilities_info(client: RelayClient, args) -> int:
|
|
194
|
+
"""Show detailed info for a single capability registered on the relay."""
|
|
195
|
+
try:
|
|
196
|
+
resp = client._get_with_retry(f"/relay/v2/discovery/capabilities/{args.name}")
|
|
197
|
+
if resp.status_code == 404:
|
|
198
|
+
print(f"Capability '{args.name}' not found.")
|
|
199
|
+
return 1
|
|
200
|
+
resp.raise_for_status()
|
|
201
|
+
cap = resp.json()
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
print(f"failed to query capability: {exc}", file=sys.stderr)
|
|
204
|
+
return 1
|
|
205
|
+
|
|
206
|
+
if args.json:
|
|
207
|
+
print(json.dumps(cap, default=str))
|
|
208
|
+
return 0
|
|
209
|
+
print(f"Name: {cap.get('name', '?')}")
|
|
210
|
+
print(f"Type: {cap.get('type', '-')}")
|
|
211
|
+
print(f"Version: {cap.get('version', '?')}")
|
|
212
|
+
print(f"Available: {'yes' if cap.get('available', False) else 'no'}")
|
|
213
|
+
desc = cap.get("description")
|
|
214
|
+
if desc:
|
|
215
|
+
print(f"Description: {desc}")
|
|
216
|
+
schema = cap.get("input_schema")
|
|
217
|
+
if schema:
|
|
218
|
+
print("Input Schema:")
|
|
219
|
+
print(json.dumps(schema, indent=2))
|
|
220
|
+
nodes = cap.get("nodes", [])
|
|
221
|
+
if nodes:
|
|
222
|
+
print(f"\nNodes ({len(nodes)}):")
|
|
223
|
+
for n in nodes:
|
|
224
|
+
print(
|
|
225
|
+
f" - {n.get('node_name', '?')} ({n.get('node_id', '?')}) "
|
|
226
|
+
f"(load={n.get('load', 0):.1f}, "
|
|
227
|
+
f"queue={n.get('queue_depth', 0)})"
|
|
228
|
+
)
|
|
229
|
+
return 0
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""CLI docs subcommand — read relay documentation (T-117 split).
|
|
2
|
+
|
|
3
|
+
Handler signatures are plain ``(client, args) -> int``; the
|
|
4
|
+
``node_cli.with_client`` decorator is applied at parser-registration time
|
|
5
|
+
in the facade.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import html as html_mod
|
|
11
|
+
import json
|
|
12
|
+
import re as _re
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from nodes.common.relay_client import RelayClient
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _html_to_text(html: str) -> str:
|
|
21
|
+
"""Best-effort conversion of an HTML document to terminal-friendly text.
|
|
22
|
+
|
|
23
|
+
The docs endpoint serves rendered Markdown as HTML. On a headless node
|
|
24
|
+
we want something readable in the terminal, so we strip tags, expand
|
|
25
|
+
block elements to newlines, decode the common entities, and collapse
|
|
26
|
+
excess blank lines. This is intentionally simple — it does not aim to
|
|
27
|
+
reproduce a full browser.
|
|
28
|
+
"""
|
|
29
|
+
# Drop <head>…</head> (style/title) entirely.
|
|
30
|
+
html = _re.sub(r"<head\b.*?</head>", "", html, flags=_re.S | _re.I)
|
|
31
|
+
# Drop <style>…</style> and <script>…</script>.
|
|
32
|
+
html = _re.sub(r"<(style|script)\b.*?</\1>", "", html, flags=_re.S | _re.I)
|
|
33
|
+
# Block-level elements → surrounding newlines.
|
|
34
|
+
block_tags = (
|
|
35
|
+
"p", "br", "div", "section", "article", "header", "footer",
|
|
36
|
+
"h1", "h2", "h3", "h4", "h5", "h6",
|
|
37
|
+
"ul", "ol", "li", "pre", "blockquote", "table", "tr",
|
|
38
|
+
)
|
|
39
|
+
html = _re.sub(
|
|
40
|
+
rf"</?({ '|'.join(block_tags) })\b[^>]*>",
|
|
41
|
+
"\n",
|
|
42
|
+
html,
|
|
43
|
+
flags=_re.I,
|
|
44
|
+
)
|
|
45
|
+
# <td>/<th> → tab separator, <hr> → rule.
|
|
46
|
+
html = _re.sub(r"</?(td|th)\b[^>]*>", "\t", html, flags=_re.I)
|
|
47
|
+
html = _re.sub(r"<hr\b[^>]*/?>", "\n----\n", html, flags=_re.I)
|
|
48
|
+
# Code spans keep their text only.
|
|
49
|
+
html = _re.sub(r"</?code\b[^>]*>", "", html, flags=_re.I)
|
|
50
|
+
# Strip all remaining tags.
|
|
51
|
+
html = _re.sub(r"<[^>]+>", "", html)
|
|
52
|
+
# Decode HTML entities (& < …).
|
|
53
|
+
html = html_mod.unescape(html)
|
|
54
|
+
# Collapse runs of whitespace inside lines, keep newlines.
|
|
55
|
+
html = _re.sub(r"[ \t]+", " ", html)
|
|
56
|
+
html = _re.sub(r" *\n *", "\n", html)
|
|
57
|
+
# Trim leading whitespace per line.
|
|
58
|
+
html = "\n".join(line.rstrip() for line in html.splitlines())
|
|
59
|
+
# Collapse 3+ blank lines to 2.
|
|
60
|
+
html = _re.sub(r"\n{3,}", "\n\n", html)
|
|
61
|
+
return html.strip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cmd_docs(client: RelayClient, args) -> int:
|
|
65
|
+
"""node-cli docs [<name>] — read relay documentation from the server.
|
|
66
|
+
|
|
67
|
+
Without an argument, lists all public documents (name + URL).
|
|
68
|
+
With a name, fetches the document and prints it as readable text.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
if args.name:
|
|
72
|
+
resp = client._get_with_retry(f"/relay/v2/docs/{args.name}")
|
|
73
|
+
if resp.status_code == 404:
|
|
74
|
+
print(f"Document '{args.name}' not found.", file=sys.stderr)
|
|
75
|
+
return 1
|
|
76
|
+
resp.raise_for_status()
|
|
77
|
+
body = resp.text
|
|
78
|
+
ctype = resp.headers.get("content-type", "")
|
|
79
|
+
if "html" in ctype.lower() or body.lstrip().lower().startswith("<!doctype"):
|
|
80
|
+
print(_html_to_text(body))
|
|
81
|
+
else:
|
|
82
|
+
# Server returned JSON with content/markdown, or raw text.
|
|
83
|
+
try:
|
|
84
|
+
data = resp.json()
|
|
85
|
+
if isinstance(data, dict):
|
|
86
|
+
text = data.get("content") or data.get("markdown")
|
|
87
|
+
if text:
|
|
88
|
+
print(text)
|
|
89
|
+
return 0
|
|
90
|
+
if args.json:
|
|
91
|
+
print(json.dumps(data, default=str))
|
|
92
|
+
return 0
|
|
93
|
+
print(json.dumps(data, indent=2, default=str))
|
|
94
|
+
except (json.JSONDecodeError, ValueError):
|
|
95
|
+
print(body)
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
# List all docs.
|
|
99
|
+
resp = client._get_with_retry("/relay/v2/docs")
|
|
100
|
+
resp.raise_for_status()
|
|
101
|
+
data = resp.json()
|
|
102
|
+
docs = data if isinstance(data, list) else data.get("docs", [])
|
|
103
|
+
if args.json:
|
|
104
|
+
print(json.dumps(docs, default=str))
|
|
105
|
+
return 0
|
|
106
|
+
print(f"Relay documentation ({len(docs)} pages):\n")
|
|
107
|
+
for doc in docs:
|
|
108
|
+
name = doc.get("name") or doc.get("title", "?")
|
|
109
|
+
url = doc.get("url", "")
|
|
110
|
+
available = doc.get("available", True)
|
|
111
|
+
marker = "📄" if available else "🚫"
|
|
112
|
+
print(f" {marker} {name}")
|
|
113
|
+
if url:
|
|
114
|
+
print(f" {url}")
|
|
115
|
+
print()
|
|
116
|
+
return 0
|
|
117
|
+
except httpx.HTTPStatusError as exc:
|
|
118
|
+
print(
|
|
119
|
+
f"docs request failed: {exc.response.status_code} {exc.response.text}",
|
|
120
|
+
file=sys.stderr,
|
|
121
|
+
)
|
|
122
|
+
return 1
|