monolynx-cli 0.1.0__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.
@@ -0,0 +1,11 @@
1
+ """Pakiet monolynx-cli: instalowalny klient linii komend platformy Monolynx.
2
+
3
+ Niezależny od pakietu backendu `monolynx` -- nie importuje z niego niczego (patrz
4
+ tests/test_main.py::test_package_never_imports_backend_monolynx_package).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from monolynx_cli.version import __version__
10
+
11
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ """Umożliwia `python -m monolynx_cli`, wołając ten sam obiekt Typer co entry pointy CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from monolynx_cli.main import app
6
+
7
+ if __name__ == "__main__":
8
+ app(prog_name="monolynx")
monolynx_cli/client.py ADDED
@@ -0,0 +1,225 @@
1
+ """Klient HTTP monolynx-cli: MonolynxClient, wyjątki i mapowanie błędów `/api/v2` (RFC 7807).
2
+
3
+ Jeden klient dla wszystkich komend: nagłówki (Authorization Bearer, User-Agent, Accept),
4
+ retry z backoffem, jednorazowy hook odświeżenia tokenu po 401 i tłumaczenie odpowiedzi
5
+ błędu na wyjątki z deterministycznym `exit_code`. Globalny handler w `monolynx_cli.main`
6
+ zamienia każdy `MonolynxError` na komunikat na stderr i kod wyjścia procesu:
7
+
8
+ 1 MonolynxAPIError błąd API (4xx poza 401/403, 429/5xx po wyczerpaniu prób)
9
+ 3 MonolynxAuthError 401/403, nieudany refresh, brak tokenu wykryty przed wysłaniem
10
+ 4 MonolynxNetworkError timeout, odmowa połączenia, DNS (bez retry)
11
+
12
+ Retry: maks. 4 próby. GET/DELETE ponawiane na 429 i 5xx, POST/PATCH tylko na 429 --
13
+ ponowienie po 5xx mogłoby zdublować utworzony zasób. Czekanie idzie wyłącznie przez
14
+ wstrzyknięte `sleep`, dzięki czemu testy nie śpią naprawdę.
15
+
16
+ Token nie ma stałego prefiksu (legacy `osk_`, access token OAuth to goły `token_urlsafe`),
17
+ więc do logów `--debug` trafia wyłącznie `config.mask_token()`.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ import time
24
+ from collections.abc import Callable
25
+ from typing import Any
26
+
27
+ import httpx
28
+ from rich.console import Console
29
+
30
+ from monolynx_cli.config import mask_token
31
+ from monolynx_cli.version import __version__
32
+
33
+ BACKOFF_DELAYS: tuple[float, ...] = (0.5, 1.0, 2.0)
34
+ MAX_RETRY_AFTER: float = 60.0
35
+ DEFAULT_TIMEOUT: float = 30.0
36
+
37
+ _ERROR_BODY_LIMIT = 200
38
+ _DEBUG_BODY_LIMIT = 500
39
+ _RETRY_AFTER_SECONDS = re.compile(r"\d+(?:\.\d+)?")
40
+ _NO_TOKEN_HINT = "Zaloguj się: monolynx auth login"
41
+
42
+
43
+ class MonolynxError(Exception):
44
+ exit_code: int = 1
45
+
46
+
47
+ class MonolynxAPIError(MonolynxError):
48
+ exit_code = 1
49
+
50
+ def __init__(self, status: int | None, title: str, detail: str | None = None, type_: str = "about:blank") -> None:
51
+ head = f"{status} {title}" if status is not None else title
52
+ super().__init__(f"{head}: {detail}" if detail else head)
53
+ self.status = status
54
+ self.title = title
55
+ self.detail = detail
56
+ self.type_ = type_
57
+
58
+
59
+ class MonolynxAuthError(MonolynxAPIError):
60
+ exit_code = 3
61
+
62
+
63
+ class MonolynxNetworkError(MonolynxError):
64
+ exit_code = 4
65
+
66
+ def __init__(self, message: str) -> None:
67
+ super().__init__(message)
68
+
69
+
70
+ def _parse_retry_after(value: str | None) -> float | None:
71
+ # Data HTTP, wartość ujemna i nienumeryczna celowo wracają do zwykłego backoffu.
72
+ if value is None:
73
+ return None
74
+ text = value.strip()
75
+ if not _RETRY_AFTER_SECONDS.fullmatch(text):
76
+ return None
77
+ return min(float(text), MAX_RETRY_AFTER)
78
+
79
+
80
+ def _build_error(response: httpx.Response) -> MonolynxAPIError:
81
+ status = response.status_code
82
+ body = response.text.strip()
83
+ title = response.reason_phrase or "Błąd HTTP"
84
+ detail: str | None = body[:_ERROR_BODY_LIMIT] or None
85
+ type_ = "about:blank"
86
+
87
+ content_type = response.headers.get("content-type", "").split(";")[0].strip().lower()
88
+ if content_type == "application/problem+json":
89
+ try:
90
+ payload = response.json()
91
+ except ValueError:
92
+ payload = None
93
+ if isinstance(payload, dict):
94
+ problem_title = payload.get("title")
95
+ problem_detail = payload.get("detail")
96
+ problem_type = payload.get("type")
97
+ title = problem_title if isinstance(problem_title, str) and problem_title else title
98
+ detail = problem_detail if isinstance(problem_detail, str) and problem_detail else None
99
+ type_ = problem_type if isinstance(problem_type, str) and problem_type else type_
100
+
101
+ error_class = MonolynxAuthError if status in (401, 403) else MonolynxAPIError
102
+ return error_class(status, title, detail, type_)
103
+
104
+
105
+ class MonolynxClient:
106
+ def __init__(
107
+ self,
108
+ endpoint: str,
109
+ token: str | None,
110
+ *,
111
+ timeout: float = DEFAULT_TIMEOUT,
112
+ debug: bool = False,
113
+ refresh: Callable[[], str | None] | None = None,
114
+ transport: httpx.BaseTransport | None = None,
115
+ sleep: Callable[[float], None] = time.sleep,
116
+ ) -> None:
117
+ self._token = token
118
+ self._debug = debug
119
+ self._refresh = refresh
120
+ self._sleep = sleep
121
+ try:
122
+ self._client = httpx.Client(
123
+ base_url=endpoint,
124
+ timeout=timeout,
125
+ transport=transport,
126
+ headers={"User-Agent": f"monolynx-cli/{__version__}", "Accept": "application/json"},
127
+ )
128
+ except httpx.InvalidURL as exc:
129
+ raise MonolynxNetworkError(f"Nieprawidłowy adres API: {endpoint} ({exc})") from exc
130
+
131
+ def __enter__(self) -> MonolynxClient:
132
+ return self
133
+
134
+ def __exit__(self, *exc_info: object) -> None:
135
+ self.close()
136
+
137
+ def close(self) -> None:
138
+ self._client.close()
139
+
140
+ def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
141
+ return self._request("GET", path, params=params)
142
+
143
+ def delete(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
144
+ return self._request("DELETE", path, params=params)
145
+
146
+ def post(self, path: str, *, json: Any = None, params: dict[str, Any] | None = None) -> Any:
147
+ return self._request("POST", path, json=json, params=params)
148
+
149
+ def patch(self, path: str, *, json: Any = None, params: dict[str, Any] | None = None) -> Any:
150
+ return self._request("PATCH", path, json=json, params=params)
151
+
152
+ def _request(self, method: str, path: str, *, json: Any = None, params: dict[str, Any] | None = None) -> Any:
153
+ if not self._token:
154
+ raise MonolynxAuthError(None, "Brak tokenu", _NO_TOKEN_HINT)
155
+
156
+ retry_5xx = method in ("GET", "DELETE")
157
+ refreshed = False
158
+ retries = 0
159
+ while True:
160
+ response = self._send(method, path, json, params)
161
+ status = response.status_code
162
+
163
+ if status == 401 and self._refresh is not None and not refreshed:
164
+ refreshed = True
165
+ new_token = self._refresh()
166
+ if new_token is None:
167
+ raise _build_error(response)
168
+ self._token = new_token
169
+ continue
170
+
171
+ if status < 400:
172
+ return self._parse_success(response)
173
+
174
+ if retries < len(BACKOFF_DELAYS) and (status == 429 or (retry_5xx and status >= 500)):
175
+ delay = BACKOFF_DELAYS[retries]
176
+ if status == 429:
177
+ retry_after = _parse_retry_after(response.headers.get("retry-after"))
178
+ if retry_after is not None:
179
+ delay = retry_after
180
+ self._sleep(delay)
181
+ retries += 1
182
+ continue
183
+
184
+ raise _build_error(response)
185
+
186
+ def _send(self, method: str, path: str, json: Any, params: dict[str, Any] | None) -> httpx.Response:
187
+ token = self._token or ""
188
+ try:
189
+ request = self._client.build_request(method, path, json=json, params=params, headers={"Authorization": f"Bearer {token}"})
190
+ self._log_request(request, token)
191
+ response = self._client.send(request)
192
+ except httpx.InvalidURL as exc:
193
+ raise MonolynxNetworkError(f"Nieprawidłowy adres żądania: {path} ({exc})") from exc
194
+ except httpx.TransportError as exc:
195
+ raise MonolynxNetworkError(f"Błąd połączenia z {self._client.base_url} ({type(exc).__name__}): {exc}") from exc
196
+ self._log_response(response)
197
+ return response
198
+
199
+ @staticmethod
200
+ def _parse_success(response: httpx.Response) -> Any:
201
+ if response.status_code == 204 or not response.content:
202
+ return None
203
+ try:
204
+ return response.json()
205
+ except ValueError as exc:
206
+ body = response.text.strip()[:_ERROR_BODY_LIMIT] or None
207
+ raise MonolynxAPIError(response.status_code, "Nieprawidłowa odpowiedź serwera", body) from exc
208
+
209
+ def _log_request(self, request: httpx.Request, token: str) -> None:
210
+ if not self._debug:
211
+ return
212
+ console = Console(stderr=True, markup=False, highlight=False, emoji=False)
213
+ console.print(f"> {request.method} {request.url}", soft_wrap=True)
214
+ console.print(f"> Authorization: Bearer {mask_token(token)}", soft_wrap=True)
215
+ console.print(f"> User-Agent: monolynx-cli/{__version__}", soft_wrap=True)
216
+ console.print("> Accept: application/json", soft_wrap=True)
217
+
218
+ def _log_response(self, response: httpx.Response) -> None:
219
+ if not self._debug:
220
+ return
221
+ console = Console(stderr=True, markup=False, highlight=False, emoji=False)
222
+ console.print(f"< {response.status_code}", soft_wrap=True)
223
+ body = response.text.strip()[:_DEBUG_BODY_LIMIT]
224
+ if body:
225
+ console.print(body, soft_wrap=True)
@@ -0,0 +1 @@
1
+ """Moduly komend monolynx-cli, kazdy z wlasnym typer.Typer() rejestrowanym w main.py."""
@@ -0,0 +1,157 @@
1
+ """Wspólne helpery komend: ustawienia, klient projektu, potwierdzenia, paginacja, teksty z pliku/stdin.
2
+
3
+ Każda grupa komend otwiera klienta przez `new_client(ctx, settings)` (albo `open_project(ctx)`,
4
+ które dodaje sprawdzenie tokenu i projektu i zwraca (MonolynxClient, slug)). Fabryka tworzy
5
+ klienta przez nazwę modułową `MonolynxClient` w TYM module, którą testy podmieniają przez
6
+ monkeypatch (`monolynx_cli.commands._common.MonolynxClient`), więc grupy nie importują klasy
7
+ klienta do tworzenia instancji. Fabryka podpina odświeżanie tokenu OAuth: proaktywne przed
8
+ pierwszym żądaniem (gdy access token wygasa w ciągu doby) i reaktywne po 401.
9
+
10
+ Odpowiedź serwera i plik konfiguracyjny to granice systemu: `fetch_all` sprawdza kształt
11
+ koperty każdej strony przed użyciem, a `load_settings` zamienia uszkodzoną konfigurację na
12
+ komunikat i kod 1. Każda wartość użytkownika wstawiana do ścieżki URL przechodzi przez
13
+ `path_segment`. Błędy lokalne (brak projektu, sprzeczne flagi, brak pliku) kończą się
14
+ typer.Exit z komunikatem na stderr, bez żadnego żądania HTTP. Brak tokenu to
15
+ MonolynxAuthError (kod 3), tak jak w `auth whoami`. Błędy odpowiedzi serwera propagują do
16
+ globalnego handlera w monolynx_cli.main.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Any
24
+ from urllib.parse import quote
25
+
26
+ import typer
27
+
28
+ from monolynx_cli import oauth
29
+ from monolynx_cli.client import MonolynxAPIError, MonolynxAuthError, MonolynxClient
30
+ from monolynx_cli.config import ConfigError, Settings, resolve_settings
31
+ from monolynx_cli.options import get_options
32
+
33
+ PER_PAGE = 100
34
+ STDIN_MARKER = "-"
35
+
36
+
37
+ def _stdin_is_tty() -> bool:
38
+ # Osobna funkcja, żeby testy mogły ją podmienić: CliRunner nie ma prawdziwego TTY.
39
+ return sys.stdin.isatty()
40
+
41
+
42
+ def usage_error(message: str) -> typer.Exit:
43
+ """Wypisz komunikat na stderr i zwróć typer.Exit(2) do rzucenia (błąd użycia)."""
44
+ typer.echo(message, err=True)
45
+ return typer.Exit(2)
46
+
47
+
48
+ def load_settings(ctx: typer.Context, project: str | None = None) -> Settings:
49
+ opts = get_options(ctx)
50
+ try:
51
+ return resolve_settings({"profile": opts.profile, "project": project or opts.project})
52
+ except ConfigError as exc:
53
+ typer.echo(str(exc), err=True)
54
+ raise typer.Exit(1) from exc
55
+
56
+
57
+ def require_project(settings: Settings) -> str:
58
+ if not settings.project:
59
+ typer.echo("Brak projektu: podaj --project, ustaw MONOLYNX_PROJECT albo project w profilu.", err=True)
60
+ raise typer.Exit(2)
61
+ return settings.project
62
+
63
+
64
+ def new_client(ctx: typer.Context, settings: Settings) -> MonolynxClient:
65
+ """Klient API z odświeżaniem tokenu OAuth; dla tokenu statycznego i MONOLYNX_TOKEN bez odświeżania."""
66
+ opts = get_options(ctx)
67
+ bearer = oauth.refresh_if_expiring(settings, timeout=opts.timeout) or settings.bearer
68
+ refresh = oauth.make_refresh_hook(settings, timeout=opts.timeout)
69
+ return MonolynxClient(settings.endpoint, bearer, timeout=opts.timeout, debug=opts.debug, refresh=refresh)
70
+
71
+
72
+ def open_project(ctx: typer.Context) -> tuple[MonolynxClient, str]:
73
+ """Zwróć klienta API i slug projektu z flag globalnych, zmiennych środowiskowych i profilu."""
74
+ settings = load_settings(ctx)
75
+ if settings.bearer is None:
76
+ raise MonolynxAuthError(None, "Brak tokenu", "Brak zapisanego tokenu. Zaloguj się: monolynx auth login")
77
+ slug = require_project(settings)
78
+ path_segment(slug) # odrzuca `..` zanim new_client wykona choćby odświeżenie tokenu
79
+ return new_client(ctx, settings), slug
80
+
81
+
82
+ def project_path(slug: str, suffix: str = "") -> str:
83
+ """Ścieżka endpointu API v2 w obrębie projektu, np. project_path("mon", "/tickets")."""
84
+ return f"/api/v2/projects/{path_segment(slug)}{suffix}"
85
+
86
+
87
+ def confirm_destructive(message: str, *, yes: bool) -> None:
88
+ if yes:
89
+ return
90
+ if not _stdin_is_tty():
91
+ typer.echo("Operacja wymaga potwierdzenia: dodaj --yes.", err=True)
92
+ raise typer.Exit(2)
93
+ if not typer.confirm(message, default=False):
94
+ typer.echo("Anulowano.", err=True)
95
+ raise typer.Exit(1)
96
+
97
+
98
+ def path_segment(value: str) -> str:
99
+ """Zakoduj wartość użytkownika jako jeden segment ścieżki URL.
100
+
101
+ `.` i `..` (oraz pusty tekst) kodowanie zostawia bez zmian, a klient HTTP normalizuje je
102
+ w ścieżce, więc wyszłyby poza zamierzony zasób; to błąd użycia, bez żądania HTTP.
103
+ """
104
+ if value in ("", ".", ".."):
105
+ raise usage_error(f"Niedozwolony identyfikator w ścieżce: {value!r}.")
106
+ return quote(value, safe="")
107
+
108
+
109
+ def read_text_option(value: str | None, file_path: Path | None, *, option: str, file_option: str | None = None) -> str | None:
110
+ """Rozwiąż tekst z flagi (`-` = stdin) albo z pliku; None gdy żadne nie podane.
111
+
112
+ Podanie obu źródeł naraz, brak pliku i pusty stdin to błąd użycia (exit 2).
113
+ """
114
+ if value is not None and file_path is not None:
115
+ raise usage_error(f"Podaj {option} albo {file_option}, nie oba naraz.")
116
+ if file_path is not None:
117
+ try:
118
+ return file_path.read_text(encoding="utf-8")
119
+ except (OSError, UnicodeDecodeError) as exc:
120
+ detail = exc.strerror if isinstance(exc, OSError) else "plik nie jest poprawnym UTF-8"
121
+ raise usage_error(f"Nie można odczytać pliku {file_path}: {detail or exc}") from exc
122
+ if value == STDIN_MARKER:
123
+ text = sys.stdin.read()
124
+ if not text.strip():
125
+ raise usage_error(f"Pusty tekst na standardowym wejściu dla {option} -.")
126
+ return text
127
+ return value
128
+
129
+
130
+ def without_none(data: dict[str, Any]) -> dict[str, Any]:
131
+ """Odrzuć klucze z wartością None; puste stringi i puste listy zostają (semantyka czyszczenia MCP)."""
132
+ return {key: value for key, value in data.items() if value is not None}
133
+
134
+
135
+ def _invalid_page(detail: str) -> MonolynxAPIError:
136
+ return MonolynxAPIError(200, "Nieprawidłowa odpowiedź serwera", detail)
137
+
138
+
139
+ def fetch_all(client: MonolynxClient, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
140
+ """Pobierz elementy ze wszystkich stron; `params` (np. filtr statusu) idą na każdą stronę."""
141
+ items: list[dict[str, Any]] = []
142
+ page = 1
143
+ total_pages = 1
144
+ while page <= total_pages:
145
+ payload = client.get(path, params={**(params or {}), "page": page, "per_page": PER_PAGE})
146
+ if not isinstance(payload, dict):
147
+ raise _invalid_page("Odpowiedź nie jest obiektem JSON.")
148
+ page_items = payload.get("items")
149
+ if not isinstance(page_items, list) or not all(isinstance(item, dict) for item in page_items):
150
+ raise _invalid_page("Pole items nie jest listą obiektów.")
151
+ pages = payload.get("total_pages")
152
+ if isinstance(pages, bool) or not isinstance(pages, int) or pages < 1:
153
+ raise _invalid_page("Pole total_pages nie jest liczbą całkowitą >= 1.")
154
+ items.extend(page_items)
155
+ total_pages = pages
156
+ page += 1
157
+ return items
@@ -0,0 +1,169 @@
1
+ """Komendy monolynx_cli.commands.auth: login/logout/whoami.
2
+
3
+ Login domyślnie robi OAuth 2.1 w przeglądarce (monolynx_cli.oauth), z --token zapisuje
4
+ token API bez przeglądarki. Po zalogowaniu i we whoami komenda bije w GET /api/v2/me
5
+ backendu Monolynx (autoryzacja Bearer) przez monolynx_cli.client.MonolynxClient -- nazwę
6
+ modułową `MonolynxClient` podmieniają testy przez monkeypatch; dla profilu oauth klient
7
+ dostaje hook odświeżania tokenu po 401 (oauth.make_refresh_hook). Błędy HTTP i sieci to wyjątki klienta (MonolynxError), które
8
+ propaguje do globalnego handlera w monolynx_cli.main (kody wyjścia 1/3/4), więc komendy
9
+ nie mają własnych typer.Exit dla odpowiedzi serwera.
10
+ Token nigdy nie trafia na stdout/stderr w pełnej postaci, tylko przez config.mask_token.
11
+ Plik konfiguracyjny może być ręcznie edytowany przez człowieka, a odpowiedź serwera
12
+ może nie być tym, czego oczekujemy (zły --endpoint, HTML zamiast JSON) -- obie granice
13
+ są sprawdzane przed użyciem, nigdy nie kończą się nieobsłużonym wyjątkiem.
14
+
15
+ Lokalna flaga `--profile` każdej komendy wygrywa z globalną `--profile` z roota
16
+ (monolynx_cli.main), globalna wygrywa z MONOLYNX_PROFILE/MONOLYNX_PROJECT -- kolejność
17
+ pierwszeństwa poza tym bez zmian, patrz config.resolve_settings. whoami wypisuje wynik
18
+ przez output.emit() (konwencja modułu monolynx_cli.output), nie przez typer.echo.
19
+ Komunikaty stanu (login, logout, brak profilu) idą na stderr przez output.echo_stderr i
20
+ są wyciszane przez --quiet; stdout zostaje czysty dla potoków.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Callable
26
+
27
+ import typer
28
+
29
+ from monolynx_cli import oauth
30
+ from monolynx_cli.client import MonolynxAPIError, MonolynxAuthError, MonolynxClient
31
+ from monolynx_cli.config import (
32
+ DEFAULT_PROFILE,
33
+ LOGOUT_CLEARED_FIELDS,
34
+ ConfigError,
35
+ get_profile,
36
+ load_config,
37
+ mask_token,
38
+ resolve_settings,
39
+ save_config,
40
+ )
41
+ from monolynx_cli.options import GlobalOptions, get_options
42
+ from monolynx_cli.output import echo_stderr, emit
43
+
44
+ app = typer.Typer(no_args_is_help=True)
45
+
46
+
47
+ def _fetch_email(endpoint: str, token: str, opts: GlobalOptions, refresh: Callable[[], str | None] | None = None) -> str:
48
+ with MonolynxClient(endpoint, token, timeout=opts.timeout, debug=opts.debug, refresh=refresh) as client:
49
+ payload = client.get("/api/v2/me")
50
+ email = payload.get("email") if isinstance(payload, dict) else None
51
+ if not isinstance(email, str):
52
+ raise MonolynxAPIError(200, "Nieprawidłowa odpowiedź serwera", "Odpowiedź nie zawiera pola email.")
53
+ return email
54
+
55
+
56
+ def _save_token_profile(profile: str, endpoint: str, token: str) -> None:
57
+ data = load_config()
58
+ profiles = data.setdefault("profile", {})
59
+ target = dict(profiles.get(profile, {}))
60
+ target["endpoint"] = endpoint
61
+ target["auth_type"] = "token"
62
+ target["token"] = token
63
+ profiles[profile] = target
64
+ data.setdefault("general", {}).setdefault("active_profile", DEFAULT_PROFILE)
65
+ save_config(data)
66
+
67
+
68
+ @app.command("login")
69
+ def login(
70
+ ctx: typer.Context,
71
+ endpoint: str | None = typer.Option(None, "--endpoint", help="Adres API Monolynx."),
72
+ token: str | None = typer.Option(None, "--token", help="Token API zamiast logowania w przeglądarce (bez OAuth)."),
73
+ profile: str | None = typer.Option(None, "--profile", help="Nazwa profilu docelowego."),
74
+ no_browser: bool = typer.Option(False, "--no-browser", help="Nie otwieraj przeglądarki, wypisz adres autoryzacji na stderr."),
75
+ ) -> None:
76
+ """Zaloguj się przez OAuth w przeglądarce (albo tokenem API z --token) i zapisz sesję w profilu konfiguracji."""
77
+ opts = get_options(ctx)
78
+ try:
79
+ settings = resolve_settings({"endpoint": endpoint, "profile": profile or opts.profile, "project": opts.project})
80
+ except ConfigError as exc:
81
+ typer.echo(str(exc), err=True)
82
+ raise typer.Exit(1) from exc
83
+
84
+ tokens: oauth.TokenSet | None = None
85
+ if token is None:
86
+ tokens = oauth.run_login(settings.endpoint, no_browser=no_browser, timeout=opts.timeout, quiet=opts.quiet, no_color=opts.no_color)
87
+ email = _fetch_email(settings.endpoint, tokens.access_token, opts)
88
+ else:
89
+ email = _fetch_email(settings.endpoint, token, opts)
90
+
91
+ try:
92
+ if tokens is not None:
93
+ oauth.save_tokens(settings.profile, settings.endpoint, tokens)
94
+ else:
95
+ _save_token_profile(settings.profile, settings.endpoint, str(token))
96
+ except ConfigError as exc:
97
+ typer.echo(str(exc), err=True)
98
+ raise typer.Exit(1) from exc
99
+
100
+ echo_stderr(f"Zalogowano jako {email} (profil {settings.profile})", quiet=opts.quiet, no_color=opts.no_color)
101
+
102
+
103
+ @app.command("logout")
104
+ def logout(
105
+ ctx: typer.Context,
106
+ profile: str | None = typer.Option(None, "--profile", help="Nazwa profilu do wylogowania."),
107
+ ) -> None:
108
+ """Wyczyść zapisane sekrety (token/access_token/refresh_token/expires_at) profilu."""
109
+ opts = get_options(ctx)
110
+ try:
111
+ settings = resolve_settings({"profile": profile or opts.profile, "project": opts.project})
112
+ except ConfigError as exc:
113
+ typer.echo(str(exc), err=True)
114
+ raise typer.Exit(1) from exc
115
+
116
+ try:
117
+ data = load_config()
118
+ except ConfigError as exc:
119
+ typer.echo(str(exc), err=True)
120
+ raise typer.Exit(1) from exc
121
+
122
+ profiles = data.get("profile", {})
123
+ if settings.profile not in profiles:
124
+ echo_stderr(f"Profil {settings.profile} nie istnieje.", quiet=opts.quiet, no_color=opts.no_color)
125
+ return
126
+
127
+ target = dict(profiles[settings.profile])
128
+ for field in LOGOUT_CLEARED_FIELDS:
129
+ target.pop(field, None)
130
+ profiles[settings.profile] = target
131
+ save_config(data)
132
+
133
+ echo_stderr(f"Wylogowano z profilu {settings.profile}", quiet=opts.quiet, no_color=opts.no_color)
134
+
135
+
136
+ @app.command("whoami")
137
+ def whoami(
138
+ ctx: typer.Context,
139
+ profile: str | None = typer.Option(None, "--profile", help="Nazwa profilu do sprawdzenia."),
140
+ ) -> None:
141
+ """Wyświetl dane zalogowanego użytkownika."""
142
+ opts = get_options(ctx)
143
+ try:
144
+ settings = resolve_settings({"profile": profile or opts.profile, "project": opts.project})
145
+ except ConfigError as exc:
146
+ typer.echo(str(exc), err=True)
147
+ raise typer.Exit(1) from exc
148
+
149
+ if settings.bearer is None:
150
+ raise MonolynxAuthError(None, "Brak tokenu", "Brak zapisanego tokenu. Zaloguj się: monolynx auth login")
151
+
152
+ hook = oauth.make_refresh_hook(settings, timeout=opts.timeout)
153
+ email = _fetch_email(settings.endpoint, str(settings.bearer), opts, refresh=hook)
154
+
155
+ result: dict[str, str | None] = {"email": email, "profile": settings.profile, "auth_type": settings.auth_type}
156
+ if settings.auth_type != "oauth":
157
+ result["token"] = mask_token(str(settings.bearer))
158
+ emit(ctx, result)
159
+ return
160
+
161
+ # Po odświeżeniu w profilu leży nowy access token i nowy expires_at, a settings trzyma stary.
162
+ try:
163
+ stored = get_profile(settings.profile)
164
+ except ConfigError as exc:
165
+ typer.echo(str(exc), err=True)
166
+ raise typer.Exit(1) from exc
167
+ result["token"] = mask_token(str(stored.get("access_token") or settings.bearer))
168
+ result["expires_at"] = stored.get("expires_at")
169
+ emit(ctx, result)
@@ -0,0 +1,44 @@
1
+ """Komenda `monolynx completion <shell>`: wypisuje skrypt uzupełniania poleceń na stdout.
2
+
3
+ Skrypt generuje `typer.completion.get_completion_script` -- ta sama funkcja, której używa
4
+ wbudowane `--show-completion`, więc wynik jest identyczny; różnica to jawna powłoka
5
+ zamiast autodetekcji i wybór nazwy programu. Moduł `typer.completion` jest publiczny i od
6
+ typer 0.12 do 0.27 ma tę samą sygnaturę (keyword-only `prog_name`, `complete_var`, `shell`);
7
+ nowsze typer wendorują Clicka, więc `click.shell_completion` nie jest dostępne i nie ma
8
+ innej publicznej drogi. Komenda nie czyta konfiguracji ani tokenu.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import typer
14
+ from typer.completion import get_completion_script
15
+
16
+ from monolynx_cli.commands._common import usage_error
17
+
18
+ SHELLS = ("bash", "zsh", "fish")
19
+ # Skrypt jest wiązany z nazwą wywołania (zmienna _<PROG>_COMPLETE), a pakiet instaluje
20
+ # tylko te dwie; inna nazwa (np. `python -m monolynx_cli`) dostaje skrypt dla `monolynx`.
21
+ PROG_NAMES = ("monolynx", "mnx")
22
+ DEFAULT_PROG_NAME = "monolynx"
23
+
24
+
25
+ def completion(
26
+ ctx: typer.Context,
27
+ shell: str = typer.Argument(..., metavar="POWŁOKA", help="Powłoka: bash, zsh lub fish."),
28
+ ) -> None:
29
+ """Wypisz skrypt uzupełniania poleceń (bash, zsh, fish) na stdout.
30
+
31
+ Skrypt dotyczy nazwy, pod którą uruchomiono program:
32
+
33
+ monolynx completion zsh - skrypt dla monolynx,
34
+
35
+ mnx completion zsh - skrypt dla mnx.
36
+
37
+ Nie wymaga logowania ani konfiguracji.
38
+ """
39
+ if shell not in SHELLS:
40
+ raise usage_error(f"Nieznana powłoka: {shell}. Dostępne: {', '.join(SHELLS)}.")
41
+ prog_name = ctx.find_root().info_name
42
+ if prog_name not in PROG_NAMES:
43
+ prog_name = DEFAULT_PROG_NAME
44
+ typer.echo(get_completion_script(prog_name=prog_name, complete_var=f"_{prog_name.upper()}_COMPLETE", shell=shell))