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.
Files changed (50) hide show
  1. hc/__init__.py +6 -0
  2. hc/api.py +59 -0
  3. hc/capabilities.py +41 -0
  4. hc/client.py +631 -0
  5. hc/commands/__init__.py +2 -0
  6. hc/commands/_client_helpers.py +74 -0
  7. hc/commands/_compose_helpers.py +95 -0
  8. hc/commands/auth.py +381 -0
  9. hc/commands/connect.py +55 -0
  10. hc/commands/core.py +277 -0
  11. hc/commands/deploy.py +1466 -0
  12. hc/commands/install.py +74 -0
  13. hc/commands/logs.py +58 -0
  14. hc/commands/marketplace.py +113 -0
  15. hc/commands/module.py +103 -0
  16. hc/commands/ping.py +41 -0
  17. hc/commands/plugin.py +453 -0
  18. hc/commands/recovery/__init__.py +222 -0
  19. hc/commands/recovery/compose.py +385 -0
  20. hc/commands/recovery/config.py +60 -0
  21. hc/commands/recovery/core.py +155 -0
  22. hc/commands/recovery/db.py +222 -0
  23. hc/commands/recovery/mode.py +36 -0
  24. hc/commands/recovery/redis.py +60 -0
  25. hc/commands/recovery/ui.py +63 -0
  26. hc/commands/remove.py +55 -0
  27. hc/commands/reset.py +88 -0
  28. hc/commands/search.py +42 -0
  29. hc/commands/secrets.py +457 -0
  30. hc/commands/setup.py +75 -0
  31. hc/commands/setup_wizard.py +153 -0
  32. hc/commands/status.py +64 -0
  33. hc/commands/update.py +377 -0
  34. hc/config.py +127 -0
  35. hc/constants.py +33 -0
  36. hc/core_ops.py +138 -0
  37. hc/core_source.py +126 -0
  38. hc/env_bootstrap.py +43 -0
  39. hc/errors.py +85 -0
  40. hc/main.py +105 -0
  41. hc/marketplace_operation.py +140 -0
  42. hc/native_core.py +369 -0
  43. hc/repl.py +406 -0
  44. hc/setup_runner.py +72 -0
  45. hc/shell.py +9 -0
  46. homeconsole_cli-0.0.1.dist-info/METADATA +155 -0
  47. homeconsole_cli-0.0.1.dist-info/RECORD +50 -0
  48. homeconsole_cli-0.0.1.dist-info/WHEEL +5 -0
  49. homeconsole_cli-0.0.1.dist-info/entry_points.txt +2 -0
  50. 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
@@ -0,0 +1,2 @@
1
+ from __future__ import annotations
2
+