homeconsole-cli 0.0.1__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.
- hc/__init__.py +6 -0
- hc/api.py +59 -0
- hc/capabilities.py +41 -0
- hc/client.py +631 -0
- hc/commands/__init__.py +2 -0
- hc/commands/_client_helpers.py +74 -0
- hc/commands/_compose_helpers.py +95 -0
- hc/commands/auth.py +381 -0
- hc/commands/connect.py +55 -0
- hc/commands/core.py +277 -0
- hc/commands/deploy.py +1466 -0
- hc/commands/install.py +74 -0
- hc/commands/logs.py +58 -0
- hc/commands/marketplace.py +113 -0
- hc/commands/module.py +103 -0
- hc/commands/ping.py +41 -0
- hc/commands/plugin.py +453 -0
- hc/commands/recovery/__init__.py +222 -0
- hc/commands/recovery/compose.py +385 -0
- hc/commands/recovery/config.py +60 -0
- hc/commands/recovery/core.py +155 -0
- hc/commands/recovery/db.py +222 -0
- hc/commands/recovery/mode.py +36 -0
- hc/commands/recovery/redis.py +60 -0
- hc/commands/recovery/ui.py +63 -0
- hc/commands/remove.py +55 -0
- hc/commands/reset.py +88 -0
- hc/commands/search.py +42 -0
- hc/commands/secrets.py +457 -0
- hc/commands/setup.py +75 -0
- hc/commands/setup_wizard.py +153 -0
- hc/commands/status.py +64 -0
- hc/commands/update.py +377 -0
- hc/config.py +127 -0
- hc/constants.py +33 -0
- hc/core_ops.py +138 -0
- hc/core_source.py +126 -0
- hc/env_bootstrap.py +43 -0
- hc/errors.py +85 -0
- hc/main.py +105 -0
- hc/marketplace_operation.py +140 -0
- hc/native_core.py +369 -0
- hc/repl.py +406 -0
- hc/setup_runner.py +72 -0
- hc/shell.py +9 -0
- homeconsole_cli-0.0.1.dist-info/METADATA +155 -0
- homeconsole_cli-0.0.1.dist-info/RECORD +50 -0
- homeconsole_cli-0.0.1.dist-info/WHEEL +5 -0
- homeconsole_cli-0.0.1.dist-info/entry_points.txt +2 -0
- homeconsole_cli-0.0.1.dist-info/top_level.txt +1 -0
hc/client.py
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator, Callable
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from hc.api import API_PREFIX_CANDIDATES
|
|
11
|
+
from hc import api as endpoints
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class HCClient:
|
|
16
|
+
base_url: str
|
|
17
|
+
token: str
|
|
18
|
+
verify_ssl: bool = True
|
|
19
|
+
api_prefix: str | None = None
|
|
20
|
+
auth: str = "auto" # auto|bearer|api-key
|
|
21
|
+
refresh_token: str = ""
|
|
22
|
+
on_token_refreshed: Callable[[str], None] | None = field(default=None)
|
|
23
|
+
|
|
24
|
+
def _auth_hint(self, status_code: int) -> None:
|
|
25
|
+
console = Console()
|
|
26
|
+
if status_code == 403:
|
|
27
|
+
console.print("[yellow]Не хватает прав для этой операции.[/yellow]")
|
|
28
|
+
console.print("Проверь роль: `hc auth whoami`")
|
|
29
|
+
|
|
30
|
+
def _headers(self) -> dict[str, str]:
|
|
31
|
+
if not self.token:
|
|
32
|
+
return {}
|
|
33
|
+
mode = (self.auth or "auto").lower()
|
|
34
|
+
if mode == "bearer":
|
|
35
|
+
return {"Authorization": f"Bearer {self.token}"}
|
|
36
|
+
if mode in {"api-key", "apikey", "x-api-key"}:
|
|
37
|
+
return {"X-API-Key": self.token}
|
|
38
|
+
# auto: JWT имеет ровно 2 точки
|
|
39
|
+
if self.token.count(".") >= 2:
|
|
40
|
+
return {"Authorization": f"Bearer {self.token}"}
|
|
41
|
+
return {"X-API-Key": self.token}
|
|
42
|
+
|
|
43
|
+
def _candidate_prefixes(self) -> tuple[str, ...]:
|
|
44
|
+
if self.api_prefix:
|
|
45
|
+
return (self.api_prefix,)
|
|
46
|
+
return API_PREFIX_CANDIDATES
|
|
47
|
+
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
# Low-level helpers
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
async def _do_request(
|
|
53
|
+
self, method: str, path: str, *, timeout: float = 30.0, **kwargs: Any
|
|
54
|
+
) -> httpx.Response | None:
|
|
55
|
+
console = Console()
|
|
56
|
+
try:
|
|
57
|
+
async with httpx.AsyncClient(
|
|
58
|
+
base_url=self.base_url, timeout=timeout, verify=self.verify_ssl
|
|
59
|
+
) as client:
|
|
60
|
+
return await client.request(method, path, headers=self._headers(), **kwargs)
|
|
61
|
+
except httpx.ConnectError:
|
|
62
|
+
hostport = self.base_url.replace("http://", "").replace("https://", "")
|
|
63
|
+
console.print(f"[red]Ошибка: Core недоступен на {hostport}[/red]")
|
|
64
|
+
return None
|
|
65
|
+
except httpx.RequestError as e:
|
|
66
|
+
console.print(f"[red]Ошибка: {e}[/red]")
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
async def _try_refresh(self) -> bool:
|
|
70
|
+
"""Refresh access token via POST /auth/v1/refresh using stored session cookie.
|
|
71
|
+
Updates self.token and calls on_token_refreshed on success."""
|
|
72
|
+
if not self.refresh_token:
|
|
73
|
+
return False
|
|
74
|
+
try:
|
|
75
|
+
async with httpx.AsyncClient(
|
|
76
|
+
base_url=self.base_url, timeout=10.0, verify=self.verify_ssl
|
|
77
|
+
) as client:
|
|
78
|
+
resp = await client.post(
|
|
79
|
+
endpoints.AUTH_REFRESH,
|
|
80
|
+
cookies={"session_id": self.refresh_token},
|
|
81
|
+
)
|
|
82
|
+
if resp.status_code != 200:
|
|
83
|
+
return False
|
|
84
|
+
data = resp.json()
|
|
85
|
+
payload = data.get("result") if isinstance(data, dict) else None
|
|
86
|
+
new_token = (
|
|
87
|
+
(payload.get("access_token") if isinstance(payload, dict) else None)
|
|
88
|
+
or (data.get("access_token") if isinstance(data, dict) else None)
|
|
89
|
+
)
|
|
90
|
+
if not new_token:
|
|
91
|
+
return False
|
|
92
|
+
self.token = str(new_token)
|
|
93
|
+
if self.on_token_refreshed:
|
|
94
|
+
self.on_token_refreshed(str(new_token))
|
|
95
|
+
return True
|
|
96
|
+
except Exception: # noqa: BLE001
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
def _expired_session_hint(self) -> None:
|
|
100
|
+
console = Console()
|
|
101
|
+
console.print("[red]Сессия истекла.[/red] Войдите заново: `hc auth login -u admin`")
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
# Request methods
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
async def _request_json(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
108
|
+
console = Console()
|
|
109
|
+
last_resp: httpx.Response | None = None
|
|
110
|
+
|
|
111
|
+
for prefix in self._candidate_prefixes():
|
|
112
|
+
resp = await self._do_request(method, f"{prefix}{path}", **kwargs)
|
|
113
|
+
if resp is None:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
last_resp = resp
|
|
117
|
+
|
|
118
|
+
if resp.status_code == 404 and self.api_prefix is None:
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
if self.api_prefix is None:
|
|
122
|
+
self.api_prefix = prefix
|
|
123
|
+
|
|
124
|
+
if resp.status_code == 401:
|
|
125
|
+
if await self._try_refresh():
|
|
126
|
+
resp = await self._do_request(method, f"{prefix}{path}", **kwargs)
|
|
127
|
+
if resp is None:
|
|
128
|
+
return None
|
|
129
|
+
if not resp.is_error:
|
|
130
|
+
try:
|
|
131
|
+
return resp.json()
|
|
132
|
+
except ValueError:
|
|
133
|
+
return {"raw": resp.text}
|
|
134
|
+
self._expired_session_hint()
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
if resp.status_code == 403:
|
|
138
|
+
self._auth_hint(403)
|
|
139
|
+
return None
|
|
140
|
+
if resp.is_error:
|
|
141
|
+
text = (resp.text or "").strip()
|
|
142
|
+
console.print(f"[red]Ошибка: HTTP {resp.status_code} {text}[/red]")
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
return resp.json()
|
|
147
|
+
except ValueError:
|
|
148
|
+
return {"raw": resp.text}
|
|
149
|
+
|
|
150
|
+
if last_resp is not None and last_resp.status_code == 404:
|
|
151
|
+
console.print("[red]Ошибка: API endpoint не найден (проверь версию Core)[/red]")
|
|
152
|
+
console.print(
|
|
153
|
+
"Подсказка: в актуальных сборках health на `/api/v1/monitor/health`, "
|
|
154
|
+
"в старых — `/monitor/health`."
|
|
155
|
+
)
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
async def _request_json_optional(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
159
|
+
"""Like _request_json but 404 is not an error and does not print."""
|
|
160
|
+
last_resp: httpx.Response | None = None
|
|
161
|
+
|
|
162
|
+
for prefix in self._candidate_prefixes():
|
|
163
|
+
resp = await self._do_request(method, f"{prefix}{path}", **kwargs)
|
|
164
|
+
if resp is None:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
last_resp = resp
|
|
168
|
+
|
|
169
|
+
if resp.status_code == 404 and self.api_prefix is None:
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
if self.api_prefix is None:
|
|
173
|
+
self.api_prefix = prefix
|
|
174
|
+
|
|
175
|
+
if resp.status_code == 404:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
if resp.status_code == 401:
|
|
179
|
+
if await self._try_refresh():
|
|
180
|
+
resp = await self._do_request(method, f"{prefix}{path}", **kwargs)
|
|
181
|
+
if resp is None:
|
|
182
|
+
return None
|
|
183
|
+
if not resp.is_error:
|
|
184
|
+
try:
|
|
185
|
+
return resp.json()
|
|
186
|
+
except ValueError:
|
|
187
|
+
return {"raw": resp.text}
|
|
188
|
+
self._expired_session_hint()
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
if resp.status_code == 403:
|
|
192
|
+
self._auth_hint(403)
|
|
193
|
+
return None
|
|
194
|
+
if resp.is_error:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
return resp.json()
|
|
199
|
+
except ValueError:
|
|
200
|
+
return {"raw": resp.text}
|
|
201
|
+
|
|
202
|
+
if last_resp is not None and last_resp.status_code == 404:
|
|
203
|
+
return None
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
async def _request_json_absolute(
|
|
207
|
+
self,
|
|
208
|
+
method: str,
|
|
209
|
+
path: str,
|
|
210
|
+
*,
|
|
211
|
+
http_timeout: float = 10.0,
|
|
212
|
+
return_error_json: bool = False,
|
|
213
|
+
**kwargs: Any,
|
|
214
|
+
) -> Any:
|
|
215
|
+
"""Request by absolute path (no /api prefix).
|
|
216
|
+
|
|
217
|
+
``http_timeout`` — таймаут HTTP в секундах.
|
|
218
|
+
Если ``return_error_json`` и тело ошибки JSON-объект — вернуть его (иначе ``None``).
|
|
219
|
+
"""
|
|
220
|
+
resp = await self._do_request(method, path, timeout=http_timeout, **kwargs)
|
|
221
|
+
if resp is None:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
if resp.status_code == 401:
|
|
225
|
+
if await self._try_refresh():
|
|
226
|
+
resp = await self._do_request(method, path, timeout=http_timeout, **kwargs)
|
|
227
|
+
if resp is None:
|
|
228
|
+
return None
|
|
229
|
+
if not resp.is_error:
|
|
230
|
+
try:
|
|
231
|
+
return resp.json()
|
|
232
|
+
except ValueError:
|
|
233
|
+
return {"raw": resp.text}
|
|
234
|
+
self._expired_session_hint()
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
if resp.status_code == 403:
|
|
238
|
+
self._auth_hint(403)
|
|
239
|
+
return None
|
|
240
|
+
if resp.is_error:
|
|
241
|
+
if return_error_json:
|
|
242
|
+
try:
|
|
243
|
+
err_obj = resp.json()
|
|
244
|
+
return err_obj if isinstance(err_obj, dict) else None
|
|
245
|
+
except ValueError:
|
|
246
|
+
return {
|
|
247
|
+
"ok": False,
|
|
248
|
+
"error": (resp.text or "").strip() or f"HTTP {resp.status_code}",
|
|
249
|
+
}
|
|
250
|
+
return None
|
|
251
|
+
try:
|
|
252
|
+
return resp.json()
|
|
253
|
+
except ValueError:
|
|
254
|
+
return {"raw": resp.text}
|
|
255
|
+
|
|
256
|
+
async def _post_multipart_absolute(
|
|
257
|
+
self,
|
|
258
|
+
path: str,
|
|
259
|
+
*,
|
|
260
|
+
files: dict[str, Any],
|
|
261
|
+
data: dict[str, str] | None,
|
|
262
|
+
http_timeout: float = 300.0,
|
|
263
|
+
return_error_json: bool = True,
|
|
264
|
+
) -> Any:
|
|
265
|
+
"""POST multipart without forcing JSON Content-Type (для install-upload)."""
|
|
266
|
+
console = Console()
|
|
267
|
+
for attempt in range(2):
|
|
268
|
+
try:
|
|
269
|
+
async with httpx.AsyncClient(
|
|
270
|
+
base_url=self.base_url, timeout=http_timeout, verify=self.verify_ssl
|
|
271
|
+
) as client:
|
|
272
|
+
resp = await client.post(
|
|
273
|
+
path,
|
|
274
|
+
headers=dict(self._headers()),
|
|
275
|
+
files=files,
|
|
276
|
+
data=data,
|
|
277
|
+
)
|
|
278
|
+
except httpx.ConnectError:
|
|
279
|
+
hostport = self.base_url.replace("http://", "").replace("https://", "")
|
|
280
|
+
console.print(f"[red]Ошибка: Core недоступен на {hostport}[/red]")
|
|
281
|
+
return None
|
|
282
|
+
except httpx.RequestError as e:
|
|
283
|
+
console.print(f"[red]Ошибка: {e}[/red]")
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
if resp.status_code == 401 and attempt == 0 and await self._try_refresh():
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
if resp.status_code == 401:
|
|
290
|
+
self._expired_session_hint()
|
|
291
|
+
return None
|
|
292
|
+
if resp.status_code == 403:
|
|
293
|
+
self._auth_hint(403)
|
|
294
|
+
return None
|
|
295
|
+
if resp.is_error:
|
|
296
|
+
if return_error_json:
|
|
297
|
+
try:
|
|
298
|
+
err_obj = resp.json()
|
|
299
|
+
return err_obj if isinstance(err_obj, dict) else None
|
|
300
|
+
except ValueError:
|
|
301
|
+
return {
|
|
302
|
+
"ok": False,
|
|
303
|
+
"error": (resp.text or "").strip() or f"HTTP {resp.status_code}",
|
|
304
|
+
}
|
|
305
|
+
return None
|
|
306
|
+
try:
|
|
307
|
+
return resp.json()
|
|
308
|
+
except ValueError:
|
|
309
|
+
return {"raw": resp.text}
|
|
310
|
+
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
async def _stream_sse(self, path: str, **kwargs: Any) -> AsyncGenerator[str, None]:
|
|
314
|
+
console = Console()
|
|
315
|
+
url = f"{(self.api_prefix or API_PREFIX_CANDIDATES[0])}{path}"
|
|
316
|
+
|
|
317
|
+
for attempt in range(2):
|
|
318
|
+
try:
|
|
319
|
+
async with httpx.AsyncClient(
|
|
320
|
+
base_url=self.base_url, timeout=None, verify=self.verify_ssl
|
|
321
|
+
) as client:
|
|
322
|
+
async with client.stream(
|
|
323
|
+
"GET",
|
|
324
|
+
url,
|
|
325
|
+
headers={**self._headers(), "Accept": "text/event-stream"},
|
|
326
|
+
**kwargs,
|
|
327
|
+
) as resp:
|
|
328
|
+
if resp.status_code == 401:
|
|
329
|
+
if attempt == 0 and await self._try_refresh():
|
|
330
|
+
continue # retry with refreshed token
|
|
331
|
+
self._expired_session_hint()
|
|
332
|
+
return
|
|
333
|
+
if resp.status_code == 403:
|
|
334
|
+
self._auth_hint(403)
|
|
335
|
+
return
|
|
336
|
+
if resp.is_error:
|
|
337
|
+
console.print(f"[red]Ошибка: HTTP {resp.status_code}[/red]")
|
|
338
|
+
return
|
|
339
|
+
async for line in resp.aiter_lines():
|
|
340
|
+
if not line:
|
|
341
|
+
continue
|
|
342
|
+
if line.startswith("data:"):
|
|
343
|
+
yield line.removeprefix("data:").lstrip()
|
|
344
|
+
return
|
|
345
|
+
except httpx.ConnectError:
|
|
346
|
+
hostport = self.base_url.replace("http://", "").replace("https://", "")
|
|
347
|
+
console.print(f"[red]Ошибка: Core недоступен на {hostport}[/red]")
|
|
348
|
+
return
|
|
349
|
+
except httpx.RequestError as e:
|
|
350
|
+
console.print(f"[red]Ошибка: {e}[/red]")
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
# ------------------------------------------------------------------
|
|
354
|
+
# Auth
|
|
355
|
+
# ------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
async def admin_status(self) -> dict[str, Any] | None:
|
|
358
|
+
data = await self._request_json_absolute("GET", endpoints.ADMIN_STATUS)
|
|
359
|
+
return data if isinstance(data, dict) else None
|
|
360
|
+
|
|
361
|
+
async def auth_bootstrap(self) -> dict[str, Any] | None:
|
|
362
|
+
data = await self._request_json_absolute("GET", endpoints.AUTH_BOOTSTRAP)
|
|
363
|
+
return data if isinstance(data, dict) else None
|
|
364
|
+
|
|
365
|
+
async def auth_initialize(self, user_id: str, username: str, password: str) -> dict[str, Any] | None:
|
|
366
|
+
data = await self._request_json_absolute(
|
|
367
|
+
"POST",
|
|
368
|
+
endpoints.AUTH_INITIALIZE,
|
|
369
|
+
json={"user_id": user_id, "username": username, "password": password},
|
|
370
|
+
)
|
|
371
|
+
return data if isinstance(data, dict) else None
|
|
372
|
+
|
|
373
|
+
async def auth_login(self, user_id: str, password: str) -> dict[str, Any] | None:
|
|
374
|
+
data = await self._request_json_absolute(
|
|
375
|
+
"POST",
|
|
376
|
+
endpoints.AUTH_LOGIN,
|
|
377
|
+
json={"user_id": user_id, "password": password},
|
|
378
|
+
)
|
|
379
|
+
return data if isinstance(data, dict) else None
|
|
380
|
+
|
|
381
|
+
async def auth_login_full(
|
|
382
|
+
self, user_id: str, password: str
|
|
383
|
+
) -> tuple[dict[str, Any] | None, str]:
|
|
384
|
+
"""Login and return (response_data, session_id_cookie) for refresh token storage."""
|
|
385
|
+
try:
|
|
386
|
+
async with httpx.AsyncClient(
|
|
387
|
+
base_url=self.base_url, timeout=30.0, verify=self.verify_ssl
|
|
388
|
+
) as client:
|
|
389
|
+
resp = await client.post(
|
|
390
|
+
endpoints.AUTH_LOGIN,
|
|
391
|
+
json={"user_id": user_id, "password": password},
|
|
392
|
+
)
|
|
393
|
+
if resp.is_error:
|
|
394
|
+
return None, ""
|
|
395
|
+
session_id = resp.cookies.get("session_id") or ""
|
|
396
|
+
try:
|
|
397
|
+
return resp.json(), session_id
|
|
398
|
+
except ValueError:
|
|
399
|
+
return None, ""
|
|
400
|
+
except (httpx.ConnectError, httpx.RequestError):
|
|
401
|
+
return None, ""
|
|
402
|
+
|
|
403
|
+
async def auth_logout(self) -> dict[str, Any] | None:
|
|
404
|
+
data = await self._request_json_absolute("POST", endpoints.AUTH_LOGOUT, json={})
|
|
405
|
+
return data if isinstance(data, dict) else None
|
|
406
|
+
|
|
407
|
+
async def auth_me(self) -> dict[str, Any] | None:
|
|
408
|
+
data = await self._request_json_absolute("GET", endpoints.AUTH_ME)
|
|
409
|
+
return data if isinstance(data, dict) else None
|
|
410
|
+
|
|
411
|
+
async def api_keys_list(self) -> dict[str, Any] | None:
|
|
412
|
+
data = await self._request_json_absolute("GET", endpoints.ADMIN_AUTH_API_KEYS)
|
|
413
|
+
return data if isinstance(data, dict) else None
|
|
414
|
+
|
|
415
|
+
async def api_keys_create(self, name: str | None = None) -> dict[str, Any] | None:
|
|
416
|
+
payload: dict[str, Any] = {}
|
|
417
|
+
if name:
|
|
418
|
+
payload["name"] = name
|
|
419
|
+
data = await self._request_json_absolute("POST", endpoints.ADMIN_AUTH_API_KEYS, json=payload)
|
|
420
|
+
return data if isinstance(data, dict) else None
|
|
421
|
+
|
|
422
|
+
async def api_keys_revoke(self, key_id: str) -> dict[str, Any] | None:
|
|
423
|
+
data = await self._request_json_absolute(
|
|
424
|
+
"POST", endpoints.ADMIN_AUTH_API_KEYS_REVOKE, json={"key_id": key_id}
|
|
425
|
+
)
|
|
426
|
+
return data if isinstance(data, dict) else None
|
|
427
|
+
|
|
428
|
+
async def api_keys_rotate(self, key_id: str) -> dict[str, Any] | None:
|
|
429
|
+
data = await self._request_json_absolute(
|
|
430
|
+
"POST", endpoints.ADMIN_AUTH_API_KEYS_ROTATE, json={"key_id": key_id}
|
|
431
|
+
)
|
|
432
|
+
return data if isinstance(data, dict) else None
|
|
433
|
+
|
|
434
|
+
async def list_users(self) -> dict[str, Any] | None:
|
|
435
|
+
data = await self._request_json_absolute("GET", endpoints.ADMIN_AUTH_USERS)
|
|
436
|
+
return data if isinstance(data, dict) else None
|
|
437
|
+
|
|
438
|
+
async def create_user(
|
|
439
|
+
self, user_id: str, username: str, password: str, is_admin: bool = False
|
|
440
|
+
) -> dict[str, Any] | None:
|
|
441
|
+
data = await self._request_json_absolute(
|
|
442
|
+
"POST",
|
|
443
|
+
endpoints.ADMIN_AUTH_USERS,
|
|
444
|
+
json={
|
|
445
|
+
"user_id": user_id,
|
|
446
|
+
"username": username,
|
|
447
|
+
"password": password,
|
|
448
|
+
"is_admin": is_admin,
|
|
449
|
+
},
|
|
450
|
+
)
|
|
451
|
+
return data if isinstance(data, dict) else None
|
|
452
|
+
|
|
453
|
+
async def list_sessions(self) -> dict[str, Any] | None:
|
|
454
|
+
data = await self._request_json_absolute("GET", endpoints.ADMIN_AUTH_SESSIONS)
|
|
455
|
+
return data if isinstance(data, dict) else None
|
|
456
|
+
|
|
457
|
+
async def revoke_session(self, session_id: str) -> dict[str, Any] | None:
|
|
458
|
+
data = await self._request_json_absolute(
|
|
459
|
+
"POST", endpoints.ADMIN_AUTH_SESSIONS_REVOKE, json={"session_id": session_id}
|
|
460
|
+
)
|
|
461
|
+
return data if isinstance(data, dict) else None
|
|
462
|
+
|
|
463
|
+
async def revoke_all_sessions(self) -> dict[str, Any] | None:
|
|
464
|
+
data = await self._request_json_absolute(
|
|
465
|
+
"POST", endpoints.ADMIN_AUTH_SESSIONS_REVOKE_ALL, json={}
|
|
466
|
+
)
|
|
467
|
+
return data if isinstance(data, dict) else None
|
|
468
|
+
|
|
469
|
+
async def inspector_plugins(self) -> dict[str, Any] | None:
|
|
470
|
+
data = await self._request_json_absolute("GET", endpoints.ADMIN_INSPECTOR_PLUGINS)
|
|
471
|
+
return data if isinstance(data, dict) else None
|
|
472
|
+
|
|
473
|
+
# ------------------------------------------------------------------
|
|
474
|
+
# Plugins
|
|
475
|
+
# ------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
async def health(self) -> dict[str, Any] | None:
|
|
478
|
+
data = await self._request_json_absolute("GET", endpoints.MONITOR_HEALTH)
|
|
479
|
+
if isinstance(data, dict):
|
|
480
|
+
return data
|
|
481
|
+
data = await self._request_json("GET", endpoints.HEALTH)
|
|
482
|
+
return data if isinstance(data, dict) else None
|
|
483
|
+
|
|
484
|
+
async def get_plugins(self) -> list[dict[str, Any]] | None:
|
|
485
|
+
data = await self._request_json_optional("GET", endpoints.PLUGINS)
|
|
486
|
+
return data if isinstance(data, list) else None
|
|
487
|
+
|
|
488
|
+
async def install_plugin(self, name: str) -> AsyncGenerator[str, None]:
|
|
489
|
+
async for msg in self._stream_sse(endpoints.PLUGIN_INSTALL.format(name=name)):
|
|
490
|
+
yield msg
|
|
491
|
+
|
|
492
|
+
async def remove_plugin(self, name: str) -> dict[str, Any] | None:
|
|
493
|
+
data = await self._request_json("DELETE", endpoints.PLUGIN.format(name=name))
|
|
494
|
+
return data if isinstance(data, dict) else None
|
|
495
|
+
|
|
496
|
+
async def start_plugin(self, name: str) -> dict[str, Any] | None:
|
|
497
|
+
data = await self._request_json("POST", endpoints.PLUGIN_START.format(name=name))
|
|
498
|
+
return data if isinstance(data, dict) else None
|
|
499
|
+
|
|
500
|
+
async def stop_plugin(self, name: str) -> dict[str, Any] | None:
|
|
501
|
+
data = await self._request_json("POST", endpoints.PLUGIN_STOP.format(name=name))
|
|
502
|
+
return data if isinstance(data, dict) else None
|
|
503
|
+
|
|
504
|
+
async def reload_plugin(self, name: str) -> dict[str, Any] | None:
|
|
505
|
+
data = await self._request_json_absolute("POST", endpoints.PLUGIN_RELOAD.format(name=name))
|
|
506
|
+
return data if isinstance(data, dict) else None
|
|
507
|
+
|
|
508
|
+
async def admin_marketplace_install_archive(
|
|
509
|
+
self,
|
|
510
|
+
archive_path: str,
|
|
511
|
+
*,
|
|
512
|
+
sha256: str | None = None,
|
|
513
|
+
http_timeout: float = 300.0,
|
|
514
|
+
) -> dict[str, Any] | None:
|
|
515
|
+
"""
|
|
516
|
+
Установка плагина из архива через операцию ядра ``marketplace.install``.
|
|
517
|
+
|
|
518
|
+
Важно: ``archive_path`` — путь **на машине (или в контейнере), где работает Core**,
|
|
519
|
+
а не обязательно на хосте, с которого вы вызываете ``hc``.
|
|
520
|
+
"""
|
|
521
|
+
body: dict[str, Any] = {"archive_path": archive_path}
|
|
522
|
+
if sha256:
|
|
523
|
+
body["sha256"] = sha256
|
|
524
|
+
data = await self._request_json_absolute(
|
|
525
|
+
"POST",
|
|
526
|
+
endpoints.ADMIN_MARKETPLACE_INSTALL,
|
|
527
|
+
json=body,
|
|
528
|
+
http_timeout=http_timeout,
|
|
529
|
+
return_error_json=True,
|
|
530
|
+
)
|
|
531
|
+
return data if isinstance(data, dict) else None
|
|
532
|
+
|
|
533
|
+
async def admin_marketplace_install_upload_archive(
|
|
534
|
+
self,
|
|
535
|
+
local_path: str | Any,
|
|
536
|
+
*,
|
|
537
|
+
sha256: str | None = None,
|
|
538
|
+
http_timeout: float = 300.0,
|
|
539
|
+
) -> dict[str, Any] | None:
|
|
540
|
+
"""Загрузить архив с локального диска и установить через ``install-upload`` (multipart)."""
|
|
541
|
+
from pathlib import Path as PathClass
|
|
542
|
+
|
|
543
|
+
p = PathClass(local_path)
|
|
544
|
+
if not p.is_file():
|
|
545
|
+
Console().print(f"[red]Файл не найден: {p}[/red]")
|
|
546
|
+
return None
|
|
547
|
+
|
|
548
|
+
data_form: dict[str, str] | None = {"sha256": sha256} if sha256 else None
|
|
549
|
+
|
|
550
|
+
with p.open("rb") as fh:
|
|
551
|
+
files = {"file": (p.name, fh, "application/octet-stream")}
|
|
552
|
+
raw = await self._post_multipart_absolute(
|
|
553
|
+
endpoints.ADMIN_MARKETPLACE_INSTALL_UPLOAD,
|
|
554
|
+
files=files,
|
|
555
|
+
data=data_form,
|
|
556
|
+
http_timeout=http_timeout,
|
|
557
|
+
return_error_json=True,
|
|
558
|
+
)
|
|
559
|
+
return raw if isinstance(raw, dict) else None
|
|
560
|
+
|
|
561
|
+
async def restart_plugin_container(self, name: str) -> dict[str, Any] | None:
|
|
562
|
+
data = await self._request_json_absolute(
|
|
563
|
+
"POST", endpoints.PLUGIN_RESTART_CONTAINER.format(name=name)
|
|
564
|
+
)
|
|
565
|
+
return data if isinstance(data, dict) else None
|
|
566
|
+
|
|
567
|
+
async def get_plugin_info(self, name: str) -> dict[str, Any] | None:
|
|
568
|
+
data = await self._request_json("GET", endpoints.PLUGIN.format(name=name))
|
|
569
|
+
return data if isinstance(data, dict) else None
|
|
570
|
+
|
|
571
|
+
# ------------------------------------------------------------------
|
|
572
|
+
# Modules
|
|
573
|
+
# ------------------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
async def get_modules(self) -> list[dict[str, Any]] | None:
|
|
576
|
+
data = await self._request_json_optional("GET", endpoints.MODULES)
|
|
577
|
+
return data if isinstance(data, list) else None
|
|
578
|
+
|
|
579
|
+
async def start_module(self, name: str) -> dict[str, Any] | None:
|
|
580
|
+
data = await self._request_json_optional("POST", endpoints.MODULE_START.format(name=name))
|
|
581
|
+
return data if isinstance(data, dict) else None
|
|
582
|
+
|
|
583
|
+
async def stop_module(self, name: str) -> dict[str, Any] | None:
|
|
584
|
+
data = await self._request_json_optional("POST", endpoints.MODULE_STOP.format(name=name))
|
|
585
|
+
return data if isinstance(data, dict) else None
|
|
586
|
+
|
|
587
|
+
async def restart_module(self, name: str) -> dict[str, Any] | None:
|
|
588
|
+
data = await self._request_json_optional("POST", endpoints.MODULE_RESTART.format(name=name))
|
|
589
|
+
return data if isinstance(data, dict) else None
|
|
590
|
+
|
|
591
|
+
# ------------------------------------------------------------------
|
|
592
|
+
# Logs & Marketplace
|
|
593
|
+
# ------------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
async def stream_logs(self, module: str | None, follow: bool) -> AsyncGenerator[str, None]:
|
|
596
|
+
params: dict[str, Any] = {"follow": str(follow).lower()}
|
|
597
|
+
if module:
|
|
598
|
+
params["module"] = module
|
|
599
|
+
async for msg in self._stream_sse(endpoints.LOGS, params=params):
|
|
600
|
+
yield msg
|
|
601
|
+
|
|
602
|
+
async def get_marketplace_index(self) -> list[dict[str, Any]] | None:
|
|
603
|
+
data = await self._request_json_optional("GET", endpoints.MARKETPLACE_INDEX)
|
|
604
|
+
return data if isinstance(data, list) else None
|
|
605
|
+
|
|
606
|
+
async def search_marketplace(self, query: str) -> list[dict[str, Any]] | None:
|
|
607
|
+
data = await self._request_json_optional(
|
|
608
|
+
"GET", endpoints.MARKETPLACE_SEARCH, params={"q": query}
|
|
609
|
+
)
|
|
610
|
+
return data if isinstance(data, list) else None
|
|
611
|
+
|
|
612
|
+
async def get_marketplace_updates(self) -> list[dict[str, Any]]:
|
|
613
|
+
"""Сравнить версии установленных плагинов с каталогом marketplace."""
|
|
614
|
+
installed = await self.get_plugins() or []
|
|
615
|
+
catalog = await self.get_marketplace_index() or []
|
|
616
|
+
|
|
617
|
+
catalog_map: dict[str, str] = {}
|
|
618
|
+
for entry in catalog:
|
|
619
|
+
if isinstance(entry, dict) and entry.get("name"):
|
|
620
|
+
catalog_map[str(entry["name"])] = str(entry.get("version", ""))
|
|
621
|
+
|
|
622
|
+
updates: list[dict[str, Any]] = []
|
|
623
|
+
for plugin in installed:
|
|
624
|
+
if not isinstance(plugin, dict):
|
|
625
|
+
continue
|
|
626
|
+
name = str(plugin.get("name", ""))
|
|
627
|
+
current = str(plugin.get("version", ""))
|
|
628
|
+
latest = catalog_map.get(name, "")
|
|
629
|
+
if latest and latest != current:
|
|
630
|
+
updates.append({"name": name, "current": current, "latest": latest})
|
|
631
|
+
return updates
|
hc/commands/__init__.py
ADDED