secryn-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.
- secryn_cli/__init__.py +3 -0
- secryn_cli/__main__.py +3 -0
- secryn_cli/cli.py +699 -0
- secryn_cli/client.py +206 -0
- secryn_cli/config.py +161 -0
- secryn_cli/tests/__init__.py +0 -0
- secryn_cli/tests/test_cli.py +838 -0
- secryn_cli-0.1.0.dist-info/METADATA +12 -0
- secryn_cli-0.1.0.dist-info/RECORD +12 -0
- secryn_cli-0.1.0.dist-info/WHEEL +5 -0
- secryn_cli-0.1.0.dist-info/entry_points.txt +2 -0
- secryn_cli-0.1.0.dist-info/top_level.txt +1 -0
secryn_cli/client.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""HTTP client for the Secryn API.
|
|
2
|
+
|
|
3
|
+
Manages authentication cookies, request serialisation, and error handling.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
from urllib.parse import urljoin
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from .config import Config, clear_cookies, load_cookies, save_cookies
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class APIError(Exception):
|
|
16
|
+
"""Raised when the Secryn API returns a non-2xx status code.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
status_code: HTTP status code returned by the server.
|
|
20
|
+
message: Human-readable error description.
|
|
21
|
+
code: Machine-readable error identifier.
|
|
22
|
+
details: Optional structured context (e.g. validation errors).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
status_code: int,
|
|
28
|
+
message: str,
|
|
29
|
+
code: str = "",
|
|
30
|
+
details: Any = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.status_code = status_code
|
|
33
|
+
self.message = message
|
|
34
|
+
self.code = code
|
|
35
|
+
self.details = details
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
|
|
38
|
+
def __str__(self) -> str:
|
|
39
|
+
if self.details:
|
|
40
|
+
return f"{self.message} ({self.code}): {self.details}"
|
|
41
|
+
return f"{self.message} ({self.code})"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Client:
|
|
45
|
+
"""Low-level HTTP client that talks to the Secryn API.
|
|
46
|
+
|
|
47
|
+
Automatically persists and restores authentication cookies between
|
|
48
|
+
invocations so that a login survives across CLI sessions.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
config: The CLI configuration object holding the API base URL
|
|
52
|
+
and user metadata.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, config: Config) -> None:
|
|
56
|
+
self.config = config
|
|
57
|
+
self.session = requests.Session()
|
|
58
|
+
self.session.headers.update(
|
|
59
|
+
{
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
"Accept": "application/json",
|
|
62
|
+
"User-Agent": config.user_agent,
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
cookies = load_cookies()
|
|
67
|
+
if cookies:
|
|
68
|
+
for cookie in cookies:
|
|
69
|
+
self.session.cookies.set(
|
|
70
|
+
cookie["name"],
|
|
71
|
+
cookie["value"],
|
|
72
|
+
domain=cookie.get("domain", ""),
|
|
73
|
+
path=cookie.get("path", "/"),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _url(self, path: str) -> str:
|
|
77
|
+
"""Resolve an API path against the configured base URL."""
|
|
78
|
+
return urljoin(self.config.api_url.rstrip("/") + "/", path.lstrip("/"))
|
|
79
|
+
|
|
80
|
+
def _persist_cookies(self) -> None:
|
|
81
|
+
"""Write the current session cookies to disk for future reuse."""
|
|
82
|
+
cookies = []
|
|
83
|
+
for cookie in self.session.cookies:
|
|
84
|
+
cookies.append(
|
|
85
|
+
{
|
|
86
|
+
"name": cookie.name,
|
|
87
|
+
"value": cookie.value,
|
|
88
|
+
"domain": cookie.domain,
|
|
89
|
+
"path": cookie.path,
|
|
90
|
+
"expires": cookie.expires,
|
|
91
|
+
"secure": cookie.secure,
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
save_cookies(cookies)
|
|
95
|
+
|
|
96
|
+
def _request(
|
|
97
|
+
self,
|
|
98
|
+
method: str,
|
|
99
|
+
path: str,
|
|
100
|
+
body: Optional[dict] = None,
|
|
101
|
+
raw: bool = False,
|
|
102
|
+
) -> Any:
|
|
103
|
+
"""Execute an HTTP request against the API.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
method: HTTP method (``GET``, ``POST``, ``PUT``, ``DELETE``).
|
|
107
|
+
path: API path relative to the base URL.
|
|
108
|
+
body: Optional JSON-serialisable request body.
|
|
109
|
+
raw: When ``True``, returns the raw response text instead of
|
|
110
|
+
parsing JSON.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Parsed JSON response body, raw response text, or ``None`` for
|
|
114
|
+
204 No Content.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
APIError: The server returned a 4xx or 5xx status code.
|
|
118
|
+
"""
|
|
119
|
+
url = self._url(path)
|
|
120
|
+
kwargs: dict = {}
|
|
121
|
+
if body is not None:
|
|
122
|
+
kwargs["json"] = body
|
|
123
|
+
|
|
124
|
+
resp = self.session.request(method, url, **kwargs)
|
|
125
|
+
self._persist_cookies()
|
|
126
|
+
|
|
127
|
+
if raw:
|
|
128
|
+
if resp.status_code >= 400:
|
|
129
|
+
try:
|
|
130
|
+
err = resp.json()
|
|
131
|
+
raise APIError(
|
|
132
|
+
resp.status_code,
|
|
133
|
+
err.get("message", resp.text),
|
|
134
|
+
err.get("code", ""),
|
|
135
|
+
err.get("details"),
|
|
136
|
+
)
|
|
137
|
+
except (json.JSONDecodeError, ValueError):
|
|
138
|
+
raise APIError(resp.status_code, resp.text)
|
|
139
|
+
return resp.text
|
|
140
|
+
|
|
141
|
+
if resp.status_code == 204 or not resp.text.strip():
|
|
142
|
+
if resp.status_code >= 400:
|
|
143
|
+
raise APIError(resp.status_code, "Request failed", str(resp.status_code))
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
data = resp.json()
|
|
148
|
+
except (json.JSONDecodeError, ValueError):
|
|
149
|
+
if resp.status_code >= 400:
|
|
150
|
+
raise APIError(resp.status_code, resp.text)
|
|
151
|
+
return resp.text
|
|
152
|
+
|
|
153
|
+
if resp.status_code >= 400:
|
|
154
|
+
raise APIError(
|
|
155
|
+
resp.status_code,
|
|
156
|
+
data.get("message", "Request failed"),
|
|
157
|
+
data.get("code", ""),
|
|
158
|
+
data.get("details"),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return data
|
|
162
|
+
|
|
163
|
+
def get(self, path: str) -> Any:
|
|
164
|
+
"""Send a GET request to ``path`` and return the parsed JSON body."""
|
|
165
|
+
return self._request("GET", path)
|
|
166
|
+
|
|
167
|
+
def post(self, path: str, body: Optional[dict] = None) -> Any:
|
|
168
|
+
"""Send a POST request to ``path`` with an optional JSON body."""
|
|
169
|
+
return self._request("POST", path, body)
|
|
170
|
+
|
|
171
|
+
def put(self, path: str, body: Optional[dict] = None) -> Any:
|
|
172
|
+
"""Send a PUT request to ``path`` with an optional JSON body."""
|
|
173
|
+
return self._request("PUT", path, body)
|
|
174
|
+
|
|
175
|
+
def delete(self, path: str) -> Any:
|
|
176
|
+
"""Send a DELETE request to ``path``."""
|
|
177
|
+
return self._request("DELETE", path)
|
|
178
|
+
|
|
179
|
+
def get_raw(self, path: str) -> str:
|
|
180
|
+
"""Send a GET request to ``path`` and return the raw response text.
|
|
181
|
+
|
|
182
|
+
Used for endpoints that return non-JSON content (e.g. ``.env`` export).
|
|
183
|
+
"""
|
|
184
|
+
result = self._request("GET", path, raw=True)
|
|
185
|
+
return result if result is not None else ""
|
|
186
|
+
|
|
187
|
+
def save_config(self) -> None:
|
|
188
|
+
"""Persist the CLI configuration to disk."""
|
|
189
|
+
from .config import save_config as _save_config
|
|
190
|
+
|
|
191
|
+
_save_config(self.config)
|
|
192
|
+
|
|
193
|
+
def logout(self) -> None:
|
|
194
|
+
"""Log out and clear all locally stored credentials.
|
|
195
|
+
|
|
196
|
+
Attempts to notify the server, then removes the cookie jar and
|
|
197
|
+
resets user identity fields.
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
self.post("/auth/logout")
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
clear_cookies()
|
|
204
|
+
self.config.user_id = None
|
|
205
|
+
self.config.user_email = None
|
|
206
|
+
self.save_config()
|
secryn_cli/config.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Configuration management for the Secryn CLI.
|
|
2
|
+
|
|
3
|
+
Stores API URL, user identity, and persisted cookies under
|
|
4
|
+
``~/.config/secryn/`` (or the platform-appropriate equivalent).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
APP_NAME = "secryn"
|
|
14
|
+
API_BASE_PATH = "/api/v1"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def config_dir() -> Path:
|
|
18
|
+
"""Return the platform-specific configuration directory.
|
|
19
|
+
|
|
20
|
+
Resolution order:
|
|
21
|
+
1. ``SECRYN_HOME`` environment variable.
|
|
22
|
+
2. Windows: ``%APPDATA%\\secryn``.
|
|
23
|
+
3. Linux/macOS: ``$XDG_CONFIG_HOME/secryn`` or
|
|
24
|
+
``~/.config/secryn``.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Absolute path to the configuration directory.
|
|
28
|
+
"""
|
|
29
|
+
env_home = os.environ.get("SECRYN_HOME")
|
|
30
|
+
if env_home:
|
|
31
|
+
return Path(env_home)
|
|
32
|
+
|
|
33
|
+
if platform.system() == "Windows":
|
|
34
|
+
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
35
|
+
return base / APP_NAME
|
|
36
|
+
|
|
37
|
+
xdg = os.environ.get("XDG_CONFIG_HOME", "")
|
|
38
|
+
if xdg:
|
|
39
|
+
return Path(xdg) / APP_NAME
|
|
40
|
+
|
|
41
|
+
return Path.home() / ".config" / APP_NAME
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def config_path() -> Path:
|
|
45
|
+
"""Return the path to the main configuration file (``config.json``)."""
|
|
46
|
+
return config_dir() / "config.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cookie_jar_path() -> Path:
|
|
50
|
+
"""Return the path to the persisted cookie jar (``cookies.json``)."""
|
|
51
|
+
return config_dir() / "cookies.json"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Config:
|
|
55
|
+
"""In-memory representation of the CLI configuration.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
api_url: Base URL of the Secryn API including the ``/api/v1`` prefix.
|
|
59
|
+
user_id: ID of the currently authenticated user, if any.
|
|
60
|
+
user_email: Email of the currently authenticated user, if any.
|
|
61
|
+
version: CLI version string.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self) -> None:
|
|
65
|
+
self.api_url: str = f"http://localhost:3000{API_BASE_PATH}"
|
|
66
|
+
self.user_id: Optional[str] = None
|
|
67
|
+
self.user_email: Optional[str] = None
|
|
68
|
+
self.version: str = "0.1.0"
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def user_agent(self) -> str:
|
|
72
|
+
"""``User-Agent`` header value sent with every API request."""
|
|
73
|
+
return f"secryn-cli/{self.version}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_config() -> Config:
|
|
77
|
+
"""Load configuration from disk, falling back to defaults.
|
|
78
|
+
|
|
79
|
+
Creates the config directory with restricted permissions if it does
|
|
80
|
+
not exist yet.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A ``Config`` instance populated with stored values or defaults.
|
|
84
|
+
"""
|
|
85
|
+
cfg = Config()
|
|
86
|
+
|
|
87
|
+
config_dir().mkdir(parents=True, exist_ok=True)
|
|
88
|
+
config_dir().chmod(0o700)
|
|
89
|
+
|
|
90
|
+
path = config_path()
|
|
91
|
+
if not path.exists():
|
|
92
|
+
return cfg
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
data = json.loads(path.read_text())
|
|
96
|
+
cfg.api_url = data.get("api_url", cfg.api_url)
|
|
97
|
+
cfg.user_id = data.get("user_id")
|
|
98
|
+
cfg.user_email = data.get("user_email")
|
|
99
|
+
cfg.version = data.get("version", cfg.version)
|
|
100
|
+
except (json.JSONDecodeError, ValueError):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
return cfg
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def save_config(cfg: Config) -> None:
|
|
107
|
+
"""Persist the current configuration to disk.
|
|
108
|
+
|
|
109
|
+
Writes ``config.json`` with mode ``0600`` inside the config directory.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
cfg: The ``Config`` instance to persist.
|
|
113
|
+
"""
|
|
114
|
+
config_dir().mkdir(parents=True, exist_ok=True)
|
|
115
|
+
config_dir().chmod(0o700)
|
|
116
|
+
|
|
117
|
+
data = {
|
|
118
|
+
"api_url": cfg.api_url,
|
|
119
|
+
"user_id": cfg.user_id,
|
|
120
|
+
"user_email": cfg.user_email,
|
|
121
|
+
"version": cfg.version,
|
|
122
|
+
}
|
|
123
|
+
config_path().write_text(json.dumps(data, indent=2))
|
|
124
|
+
config_path().chmod(0o600)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_cookies() -> Optional[dict]:
|
|
128
|
+
"""Load persisted session cookies from disk.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
A dictionary of serialized cookies, or ``None`` if no cookie
|
|
132
|
+
jar exists or it is unreadable.
|
|
133
|
+
"""
|
|
134
|
+
path = cookie_jar_path()
|
|
135
|
+
if not path.exists():
|
|
136
|
+
return None
|
|
137
|
+
try:
|
|
138
|
+
return json.loads(path.read_text())
|
|
139
|
+
except (json.JSONDecodeError, ValueError):
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def save_cookies(data: dict) -> None:
|
|
144
|
+
"""Persist session cookies to disk.
|
|
145
|
+
|
|
146
|
+
Writes ``cookies.json`` with mode ``0600`` inside the config directory.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
data: Serialized cookie dictionary.
|
|
150
|
+
"""
|
|
151
|
+
config_dir().mkdir(parents=True, exist_ok=True)
|
|
152
|
+
config_dir().chmod(0o700)
|
|
153
|
+
cookie_jar_path().write_text(json.dumps(data, indent=2))
|
|
154
|
+
cookie_jar_path().chmod(0o600)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def clear_cookies() -> None:
|
|
158
|
+
"""Remove the persisted cookie jar from disk, if it exists."""
|
|
159
|
+
path = cookie_jar_path()
|
|
160
|
+
if path.exists():
|
|
161
|
+
path.unlink()
|
|
File without changes
|