klavex 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.
- klavex/__init__.py +1 -0
- klavex/__main__.py +4 -0
- klavex/api.py +166 -0
- klavex/auth.py +211 -0
- klavex/cli.py +98 -0
- klavex/commands/__init__.py +0 -0
- klavex/commands/envs.py +29 -0
- klavex/commands/login.py +35 -0
- klavex/commands/logout.py +35 -0
- klavex/commands/projects.py +29 -0
- klavex/commands/run.py +65 -0
- klavex/commands/status.py +24 -0
- klavex/commands/vars.py +36 -0
- klavex/commands/whoami.py +28 -0
- klavex/config.py +109 -0
- klavex/errors.py +61 -0
- klavex/inject.py +28 -0
- klavex/tokens.py +42 -0
- klavex-0.1.0.dist-info/METADATA +73 -0
- klavex-0.1.0.dist-info/RECORD +22 -0
- klavex-0.1.0.dist-info/WHEEL +4 -0
- klavex-0.1.0.dist-info/entry_points.txt +3 -0
klavex/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
klavex/__main__.py
ADDED
klavex/api.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Backend HTTP client.
|
|
2
|
+
|
|
3
|
+
Endpoints used by v0.1:
|
|
4
|
+
POST /cli/device-code (login --device)
|
|
5
|
+
POST /cli/token (login: exchange code for cli_token)
|
|
6
|
+
DELETE /cli/tokens/me (logout, server-side revoke)
|
|
7
|
+
GET /auth/me (whoami)
|
|
8
|
+
|
|
9
|
+
Endpoints used by later versions:
|
|
10
|
+
GET /projects (v0.2)
|
|
11
|
+
GET /projects/{id}/environments (v0.2)
|
|
12
|
+
GET /environments/{id}/variables (v0.2 — names only client-side)
|
|
13
|
+
POST /environments/{id}/variables:reveal (v0.3 — does not exist yet)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
from .errors import (
|
|
25
|
+
NetworkError,
|
|
26
|
+
NotAuthenticated,
|
|
27
|
+
NotFound,
|
|
28
|
+
PermissionDenied,
|
|
29
|
+
KlavexError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class CliTokenResponse:
|
|
35
|
+
cli_token: str
|
|
36
|
+
expires_at: str | None
|
|
37
|
+
user: dict[str, Any]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class DeviceCodeResponse:
|
|
42
|
+
device_code: str
|
|
43
|
+
user_code: str
|
|
44
|
+
verification_uri: str
|
|
45
|
+
interval: int # seconds between polls
|
|
46
|
+
expires_in: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ApiClient:
|
|
50
|
+
def __init__(self, base_url: str, token: str | None = None, timeout: float = 10.0) -> None:
|
|
51
|
+
headers = {"User-Agent": f"klavex-cli/{__version__}"}
|
|
52
|
+
if token:
|
|
53
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
54
|
+
self._client = httpx.Client(
|
|
55
|
+
base_url=base_url,
|
|
56
|
+
headers=headers,
|
|
57
|
+
timeout=httpx.Timeout(timeout, connect=5.0),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def __enter__(self) -> ApiClient:
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def __exit__(self, *exc: object) -> None:
|
|
64
|
+
self._client.close()
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
self._client.close()
|
|
68
|
+
|
|
69
|
+
# ---- request helpers --------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
72
|
+
try:
|
|
73
|
+
response = self._client.request(method, path, **kwargs)
|
|
74
|
+
except httpx.HTTPError as exc:
|
|
75
|
+
raise NetworkError(f"Could not reach {self._client.base_url}: {exc}") from exc
|
|
76
|
+
|
|
77
|
+
if response.status_code == 401:
|
|
78
|
+
raise NotAuthenticated()
|
|
79
|
+
if response.status_code == 403:
|
|
80
|
+
raise PermissionDenied(_extract_detail(response) or "Permission denied")
|
|
81
|
+
if response.status_code == 404:
|
|
82
|
+
raise NotFound(_extract_detail(response) or "Not found")
|
|
83
|
+
if response.status_code >= 400:
|
|
84
|
+
raise KlavexError(
|
|
85
|
+
_extract_detail(response) or f"HTTP {response.status_code} from {path}"
|
|
86
|
+
)
|
|
87
|
+
return response
|
|
88
|
+
|
|
89
|
+
# ---- v0.1 endpoints ---------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def exchange_cli_code(self, code: str, code_verifier: str) -> CliTokenResponse:
|
|
92
|
+
r = self._request(
|
|
93
|
+
"POST",
|
|
94
|
+
"/cli/token",
|
|
95
|
+
json={"code": code, "code_verifier": code_verifier},
|
|
96
|
+
)
|
|
97
|
+
data = r.json()
|
|
98
|
+
return CliTokenResponse(
|
|
99
|
+
cli_token=data["cli_token"],
|
|
100
|
+
expires_at=data.get("expires_at"),
|
|
101
|
+
user=data.get("user", {}),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def request_device_code(self, device_name: str) -> DeviceCodeResponse:
|
|
105
|
+
r = self._request("POST", "/cli/device-code", json={"device_name": device_name})
|
|
106
|
+
data = r.json()
|
|
107
|
+
return DeviceCodeResponse(
|
|
108
|
+
device_code=data["device_code"],
|
|
109
|
+
user_code=data["user_code"],
|
|
110
|
+
verification_uri=data["verification_uri"],
|
|
111
|
+
interval=int(data.get("interval", 5)),
|
|
112
|
+
expires_in=int(data.get("expires_in", 600)),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def poll_device_code(self, device_code: str) -> CliTokenResponse | None:
|
|
116
|
+
"""Returns the token once approved, or None if still pending."""
|
|
117
|
+
r = self._client.post("/cli/token", json={"device_code": device_code})
|
|
118
|
+
if r.status_code == 202: # still pending
|
|
119
|
+
return None
|
|
120
|
+
if r.status_code >= 400:
|
|
121
|
+
raise KlavexError(_extract_detail(r) or f"Device polling failed: {r.status_code}")
|
|
122
|
+
data = r.json()
|
|
123
|
+
return CliTokenResponse(
|
|
124
|
+
cli_token=data["cli_token"],
|
|
125
|
+
expires_at=data.get("expires_at"),
|
|
126
|
+
user=data.get("user", {}),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def revoke_current_token(self) -> None:
|
|
130
|
+
self._request("DELETE", "/cli/tokens/me")
|
|
131
|
+
|
|
132
|
+
def me(self) -> dict[str, Any]:
|
|
133
|
+
data: dict[str, Any] = self._request("GET", "/auth/me").json()
|
|
134
|
+
return data
|
|
135
|
+
|
|
136
|
+
# ---- v0.2 / v0.3 read endpoints ----
|
|
137
|
+
|
|
138
|
+
def list_projects(self) -> list[dict[str, Any]]:
|
|
139
|
+
data: list[dict[str, Any]] = self._request("GET", "/projects").json()
|
|
140
|
+
return data
|
|
141
|
+
|
|
142
|
+
def list_environments(self, project_id: str) -> list[dict[str, Any]]:
|
|
143
|
+
data: list[dict[str, Any]] = self._request(
|
|
144
|
+
"GET", f"/projects/{project_id}/environments"
|
|
145
|
+
).json()
|
|
146
|
+
return data
|
|
147
|
+
|
|
148
|
+
def list_variables(self, environment_id: str) -> list[dict[str, Any]]:
|
|
149
|
+
"""Returns variables INCLUDING values. Caller must avoid leaking values."""
|
|
150
|
+
data: list[dict[str, Any]] = self._request(
|
|
151
|
+
"GET", f"/environments/{environment_id}/variables"
|
|
152
|
+
).json()
|
|
153
|
+
return data
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _extract_detail(response: httpx.Response) -> str | None:
|
|
157
|
+
try:
|
|
158
|
+
body = response.json()
|
|
159
|
+
except ValueError:
|
|
160
|
+
return response.text or None
|
|
161
|
+
if isinstance(body, dict):
|
|
162
|
+
for key in ("detail", "message", "error"):
|
|
163
|
+
value = body.get(key)
|
|
164
|
+
if isinstance(value, str):
|
|
165
|
+
return value
|
|
166
|
+
return None
|
klavex/auth.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Browser-callback and device-code login flows.
|
|
2
|
+
|
|
3
|
+
Browser flow (primary):
|
|
4
|
+
1. Bind 127.0.0.1:<random_port>. Mint state + PKCE verifier.
|
|
5
|
+
2. Open browser to <dashboard>/cli/authorize?callback=...&state=...&code_challenge=...
|
|
6
|
+
3. User approves in dashboard. Dashboard mints a one-shot code, redirects to callback.
|
|
7
|
+
4. We verify state, exchange code+verifier at POST /cli/token, get back a long-lived cli_token.
|
|
8
|
+
5. Token goes into the OS keychain. Browser tab shows "you can close this tab".
|
|
9
|
+
|
|
10
|
+
Device-code flow (--device, for headless boxes):
|
|
11
|
+
Standard OAuth 2.0 device authorization grant. Poll /cli/token until approved.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import http.server
|
|
19
|
+
import secrets
|
|
20
|
+
import socket
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
import urllib.parse
|
|
24
|
+
import webbrowser
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .api import ApiClient, CliTokenResponse, DeviceCodeResponse
|
|
29
|
+
from .errors import AuthFlowError, AuthTimeout, StateMismatch
|
|
30
|
+
|
|
31
|
+
# ---------- PKCE -----------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _b64url(data: bytes) -> str:
|
|
35
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def make_pkce_pair() -> tuple[str, str]:
|
|
39
|
+
"""(verifier, challenge) — verifier stays in this process; challenge goes to the dashboard."""
|
|
40
|
+
verifier = _b64url(secrets.token_bytes(32))
|
|
41
|
+
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
|
42
|
+
return verifier, challenge
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------- Loopback HTTP server ------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _free_port() -> int:
|
|
49
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
50
|
+
s.bind(("127.0.0.1", 0))
|
|
51
|
+
port: int = s.getsockname()[1]
|
|
52
|
+
return port
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_SUCCESS_HTML = b"""<!doctype html>
|
|
56
|
+
<html><head><meta charset="utf-8"><title>Klavex CLI</title>
|
|
57
|
+
<style>
|
|
58
|
+
body { font-family: -apple-system, system-ui, sans-serif; padding: 4rem; max-width: 32rem; margin: 0 auto; }
|
|
59
|
+
h1 { font-size: 1.25rem; }
|
|
60
|
+
p { color: #555; }
|
|
61
|
+
</style></head>
|
|
62
|
+
<body><h1>You're signed in.</h1><p>You can close this tab and return to your terminal.</p></body></html>
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
_ERROR_HTML = b"""<!doctype html>
|
|
66
|
+
<html><head><meta charset="utf-8"><title>Klavex CLI</title></head>
|
|
67
|
+
<body><h1>Login failed.</h1><p>Return to your terminal for details.</p></body></html>
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class _CallbackResult:
|
|
73
|
+
code: str | None = None
|
|
74
|
+
state: str | None = None
|
|
75
|
+
error: str | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _CallbackServer(http.server.HTTPServer):
|
|
79
|
+
"""One-shot HTTP server. Records the first /callback hit, then stops accepting requests."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, port: int) -> None:
|
|
82
|
+
self.result = _CallbackResult()
|
|
83
|
+
self.received = threading.Event()
|
|
84
|
+
super().__init__(("127.0.0.1", port), _CallbackHandler)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
88
|
+
server: _CallbackServer
|
|
89
|
+
|
|
90
|
+
def do_GET(self) -> None:
|
|
91
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
92
|
+
if parsed.path != "/callback":
|
|
93
|
+
self.send_response(404)
|
|
94
|
+
self.end_headers()
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
if self.server.received.is_set():
|
|
98
|
+
self.send_response(409)
|
|
99
|
+
self.end_headers()
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
103
|
+
result = self.server.result
|
|
104
|
+
result.code = _first(params.get("code"))
|
|
105
|
+
result.state = _first(params.get("state"))
|
|
106
|
+
result.error = _first(params.get("error"))
|
|
107
|
+
|
|
108
|
+
if result.error or not result.code:
|
|
109
|
+
self.send_response(400)
|
|
110
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
111
|
+
self.end_headers()
|
|
112
|
+
self.wfile.write(_ERROR_HTML)
|
|
113
|
+
else:
|
|
114
|
+
self.send_response(200)
|
|
115
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
116
|
+
self.end_headers()
|
|
117
|
+
self.wfile.write(_SUCCESS_HTML)
|
|
118
|
+
|
|
119
|
+
self.server.received.set()
|
|
120
|
+
|
|
121
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
122
|
+
# Silence the default stderr access log.
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _first(values: list[str] | None) -> str | None:
|
|
127
|
+
return values[0] if values else None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------- Browser flow ---------------------------------------------------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def browser_login(
|
|
134
|
+
api: ApiClient,
|
|
135
|
+
dashboard_url: str,
|
|
136
|
+
timeout_seconds: int = 120,
|
|
137
|
+
open_browser: bool = True,
|
|
138
|
+
) -> CliTokenResponse:
|
|
139
|
+
"""Run the browser-callback login flow. Returns a CLI token response on success."""
|
|
140
|
+
state = secrets.token_urlsafe(32)
|
|
141
|
+
verifier, challenge = make_pkce_pair()
|
|
142
|
+
port = _free_port()
|
|
143
|
+
callback = f"http://127.0.0.1:{port}/callback"
|
|
144
|
+
|
|
145
|
+
authorize_url = (
|
|
146
|
+
dashboard_url.rstrip("/")
|
|
147
|
+
+ "/cli/authorize?"
|
|
148
|
+
+ urllib.parse.urlencode(
|
|
149
|
+
{
|
|
150
|
+
"callback": callback,
|
|
151
|
+
"state": state,
|
|
152
|
+
"code_challenge": challenge,
|
|
153
|
+
"code_challenge_method": "S256",
|
|
154
|
+
"device_name": socket.gethostname(),
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
server = _CallbackServer(port)
|
|
160
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
161
|
+
thread.start()
|
|
162
|
+
try:
|
|
163
|
+
browser_opened = webbrowser.open(authorize_url) if open_browser else False
|
|
164
|
+
if not browser_opened:
|
|
165
|
+
print(f"Open this URL to continue:\n {authorize_url}")
|
|
166
|
+
|
|
167
|
+
if not server.received.wait(timeout=timeout_seconds):
|
|
168
|
+
raise AuthTimeout(timeout_seconds)
|
|
169
|
+
finally:
|
|
170
|
+
server.shutdown()
|
|
171
|
+
thread.join(timeout=2)
|
|
172
|
+
server.server_close()
|
|
173
|
+
|
|
174
|
+
result = server.result
|
|
175
|
+
if result.error:
|
|
176
|
+
raise AuthFlowError(f"Login failed: {result.error}")
|
|
177
|
+
if not result.code:
|
|
178
|
+
raise AuthFlowError("Login failed: no code in callback")
|
|
179
|
+
if result.state != state:
|
|
180
|
+
raise StateMismatch()
|
|
181
|
+
|
|
182
|
+
return api.exchange_cli_code(code=result.code, code_verifier=verifier)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------- Device-code flow ----------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def device_login(
|
|
189
|
+
api: ApiClient,
|
|
190
|
+
open_browser: bool = True,
|
|
191
|
+
poll_max_seconds: int | None = None,
|
|
192
|
+
) -> CliTokenResponse:
|
|
193
|
+
"""Run the device-code login flow. Polls until approved or expiry."""
|
|
194
|
+
device: DeviceCodeResponse = api.request_device_code(device_name=socket.gethostname())
|
|
195
|
+
|
|
196
|
+
print(f"Visit: {device.verification_uri}")
|
|
197
|
+
print(f"Code: {device.user_code}")
|
|
198
|
+
print()
|
|
199
|
+
if open_browser:
|
|
200
|
+
webbrowser.open(device.verification_uri)
|
|
201
|
+
|
|
202
|
+
deadline = time.time() + min(device.expires_in, poll_max_seconds or device.expires_in)
|
|
203
|
+
interval = max(1, device.interval)
|
|
204
|
+
|
|
205
|
+
while time.time() < deadline:
|
|
206
|
+
token = api.poll_device_code(device.device_code)
|
|
207
|
+
if token is not None:
|
|
208
|
+
return token
|
|
209
|
+
time.sleep(interval)
|
|
210
|
+
|
|
211
|
+
raise AuthTimeout(device.expires_in)
|
klavex/cli.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Top-level Typer app. Maps custom exceptions to deterministic exit codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import traceback
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .commands.envs import envs
|
|
12
|
+
from .commands.login import login
|
|
13
|
+
from .commands.logout import logout
|
|
14
|
+
from .commands.projects import projects
|
|
15
|
+
from .commands.run import run
|
|
16
|
+
from .commands.status import status
|
|
17
|
+
from .commands.vars import list_vars
|
|
18
|
+
from .commands.whoami import whoami
|
|
19
|
+
from .errors import KlavexError
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="klavex",
|
|
23
|
+
help="Pull environment variables into a process without writing secrets to disk.",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
add_completion=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _version_callback(value: bool) -> None:
|
|
30
|
+
if value:
|
|
31
|
+
typer.echo(f"klavex {__version__}")
|
|
32
|
+
raise typer.Exit()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.callback()
|
|
36
|
+
def _root(
|
|
37
|
+
version: bool | None = typer.Option(
|
|
38
|
+
None, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."
|
|
39
|
+
),
|
|
40
|
+
debug: bool = typer.Option(False, "--debug", help="Print full tracebacks on errors."),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Root options. --debug is read from sys.argv by the error handler."""
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Register commands — keep the binding here, not in the command modules,
|
|
47
|
+
# so `cli.py` is the one place that lists what's exposed.
|
|
48
|
+
app.command("login")(login)
|
|
49
|
+
app.command("logout")(logout)
|
|
50
|
+
app.command("whoami")(whoami)
|
|
51
|
+
app.command("status")(status)
|
|
52
|
+
app.command("projects")(projects)
|
|
53
|
+
app.command("envs")(envs)
|
|
54
|
+
app.command("vars")(list_vars)
|
|
55
|
+
# `run` needs `allow_extra_args` so Typer hands us argv after `--`.
|
|
56
|
+
app.command(
|
|
57
|
+
"run",
|
|
58
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
59
|
+
)(run)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _entrypoint() -> None:
|
|
63
|
+
"""Wraps the Typer app to map KlavexError -> typed exit code + clean message."""
|
|
64
|
+
try:
|
|
65
|
+
app(standalone_mode=False)
|
|
66
|
+
except KlavexError as exc:
|
|
67
|
+
typer.secho(exc.user_message(), err=True, fg=typer.colors.RED)
|
|
68
|
+
if _wants_debug():
|
|
69
|
+
traceback.print_exc()
|
|
70
|
+
sys.exit(exc.exit_code)
|
|
71
|
+
except KeyboardInterrupt:
|
|
72
|
+
typer.echo("", err=True)
|
|
73
|
+
sys.exit(130)
|
|
74
|
+
except typer.Exit as exc:
|
|
75
|
+
sys.exit(exc.exit_code)
|
|
76
|
+
except SystemExit:
|
|
77
|
+
raise
|
|
78
|
+
except Exception:
|
|
79
|
+
typer.secho("Unexpected error.", err=True, fg=typer.colors.RED)
|
|
80
|
+
traceback.print_exc()
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _wants_debug() -> bool:
|
|
85
|
+
# Read --debug flag without going through Typer's context, since the exception
|
|
86
|
+
# may have escaped before the context was populated.
|
|
87
|
+
return "--debug" in sys.argv
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Console-script entry: pyproject.toml points `klavex` and `kx` here.
|
|
91
|
+
def main() -> None:
|
|
92
|
+
_entrypoint()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Backwards-compat: `python -m klavex` (and the pyproject entry-points that
|
|
96
|
+
# reference `klavex.cli:app`) both work.
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
main()
|
|
File without changes
|
klavex/commands/envs.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from .. import tokens
|
|
6
|
+
from ..api import ApiClient
|
|
7
|
+
from ..config import UserConfig
|
|
8
|
+
from ..errors import NotAuthenticated
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def envs(project_id: str = typer.Argument(..., help="Project ID, e.g. proj_abc123")) -> None:
|
|
12
|
+
"""List environments in a project."""
|
|
13
|
+
token = tokens.load()
|
|
14
|
+
if not token:
|
|
15
|
+
raise NotAuthenticated()
|
|
16
|
+
|
|
17
|
+
cfg = UserConfig.load()
|
|
18
|
+
with ApiClient(cfg.api_url, token=token) as api:
|
|
19
|
+
rows = api.list_environments(project_id)
|
|
20
|
+
|
|
21
|
+
if not rows:
|
|
22
|
+
typer.echo("No environments found.")
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
width_id = max((len(r.get("id", "")) for r in rows), default=4)
|
|
26
|
+
width_name = max((len(r.get("name", "")) for r in rows), default=4)
|
|
27
|
+
typer.echo(f"{'ID'.ljust(width_id)} {'NAME'.ljust(width_name)}")
|
|
28
|
+
for r in rows:
|
|
29
|
+
typer.echo(f"{r.get('id', '').ljust(width_id)} {r.get('name', '').ljust(width_name)}")
|
klavex/commands/login.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from .. import tokens
|
|
6
|
+
from ..api import ApiClient
|
|
7
|
+
from ..auth import browser_login, device_login
|
|
8
|
+
from ..config import UserConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def login(
|
|
12
|
+
device: bool = typer.Option(False, "--device", help="Use device-code flow (no browser open)."),
|
|
13
|
+
no_browser: bool = typer.Option(
|
|
14
|
+
False, "--no-browser", help="Print the URL instead of opening a browser."
|
|
15
|
+
),
|
|
16
|
+
timeout: int = typer.Option(120, "--timeout", help="Seconds to wait for the browser callback."),
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Log in to Klavex. Opens your browser for authentication."""
|
|
19
|
+
cfg = UserConfig.load()
|
|
20
|
+
|
|
21
|
+
with ApiClient(cfg.api_url) as api:
|
|
22
|
+
if device:
|
|
23
|
+
response = device_login(api, open_browser=not no_browser)
|
|
24
|
+
else:
|
|
25
|
+
response = browser_login(
|
|
26
|
+
api,
|
|
27
|
+
dashboard_url=cfg.dashboard_url,
|
|
28
|
+
timeout_seconds=timeout,
|
|
29
|
+
open_browser=not no_browser,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
tokens.store(response.cli_token)
|
|
33
|
+
|
|
34
|
+
user_name = response.user.get("name") or response.user.get("email") or "you"
|
|
35
|
+
typer.echo(f"Logged in as {user_name}.")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from .. import tokens
|
|
6
|
+
from ..api import ApiClient
|
|
7
|
+
from ..config import UserConfig
|
|
8
|
+
from ..errors import NetworkError, NotAuthenticated
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def logout(
|
|
12
|
+
keep_remote: bool = typer.Option(
|
|
13
|
+
False,
|
|
14
|
+
"--keep-remote",
|
|
15
|
+
help="Only delete the local token, don't revoke server-side.",
|
|
16
|
+
),
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Log out of Klavex. Deletes the local token and revokes it server-side."""
|
|
19
|
+
token = tokens.load()
|
|
20
|
+
if not token:
|
|
21
|
+
typer.echo("Already logged out.")
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
if not keep_remote:
|
|
25
|
+
cfg = UserConfig.load()
|
|
26
|
+
try:
|
|
27
|
+
with ApiClient(cfg.api_url, token=token) as api:
|
|
28
|
+
api.revoke_current_token()
|
|
29
|
+
except (NetworkError, NotAuthenticated):
|
|
30
|
+
# If the server is unreachable or the token is already invalid, the
|
|
31
|
+
# local delete still matters. Don't block the user on it.
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
tokens.delete()
|
|
35
|
+
typer.echo("Logged out.")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from .. import tokens
|
|
6
|
+
from ..api import ApiClient
|
|
7
|
+
from ..config import UserConfig
|
|
8
|
+
from ..errors import NotAuthenticated
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def projects() -> None:
|
|
12
|
+
"""List projects in your team."""
|
|
13
|
+
token = tokens.load()
|
|
14
|
+
if not token:
|
|
15
|
+
raise NotAuthenticated()
|
|
16
|
+
|
|
17
|
+
cfg = UserConfig.load()
|
|
18
|
+
with ApiClient(cfg.api_url, token=token) as api:
|
|
19
|
+
rows = api.list_projects()
|
|
20
|
+
|
|
21
|
+
if not rows:
|
|
22
|
+
typer.echo("No projects found.")
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
width_id = max((len(r.get("id", "")) for r in rows), default=4)
|
|
26
|
+
width_name = max((len(r.get("name", "")) for r in rows), default=4)
|
|
27
|
+
typer.echo(f"{'ID'.ljust(width_id)} {'NAME'.ljust(width_name)}")
|
|
28
|
+
for r in rows:
|
|
29
|
+
typer.echo(f"{r.get('id', '').ljust(width_id)} {r.get('name', '').ljust(width_name)}")
|
klavex/commands/run.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""klavex run -e <env_id> -- <cmd...>
|
|
2
|
+
|
|
3
|
+
Pulls variables for the env, spawns <cmd> with them in its environment.
|
|
4
|
+
The parent shell never sees the secrets; nothing is written to disk.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from .. import tokens
|
|
12
|
+
from ..api import ApiClient
|
|
13
|
+
from ..config import ProjectPin, UserConfig
|
|
14
|
+
from ..errors import NotAuthenticated, UsageError
|
|
15
|
+
from ..inject import run as inject_run
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(
|
|
19
|
+
ctx: typer.Context,
|
|
20
|
+
env: str | None = typer.Option(
|
|
21
|
+
None,
|
|
22
|
+
"-e",
|
|
23
|
+
"--env",
|
|
24
|
+
help="Environment ID. Falls back to default_env in .klavex.",
|
|
25
|
+
),
|
|
26
|
+
no_inherit: bool = typer.Option(
|
|
27
|
+
False,
|
|
28
|
+
"--no-inherit",
|
|
29
|
+
help="Don't inherit the parent shell's env. Only the fetched vars are passed in.",
|
|
30
|
+
),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Run a command with vars from an environment injected into its process env.
|
|
33
|
+
|
|
34
|
+
Example: klavex run -e env_dev_abc -- npm start
|
|
35
|
+
"""
|
|
36
|
+
token = tokens.load()
|
|
37
|
+
if not token:
|
|
38
|
+
raise NotAuthenticated()
|
|
39
|
+
|
|
40
|
+
argv = list(ctx.args)
|
|
41
|
+
if not argv:
|
|
42
|
+
raise UsageError("Nothing to run. Pass a command after `--`.")
|
|
43
|
+
|
|
44
|
+
env_id = env
|
|
45
|
+
if not env_id:
|
|
46
|
+
pin = ProjectPin.find()
|
|
47
|
+
if pin and pin.default_env:
|
|
48
|
+
env_id = pin.default_env
|
|
49
|
+
if not env_id:
|
|
50
|
+
raise UsageError(
|
|
51
|
+
"No environment specified. Pass -e <env_id> or set default_env in a .klavex file."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
cfg = UserConfig.load()
|
|
55
|
+
with ApiClient(cfg.api_url, token=token) as api:
|
|
56
|
+
rows = api.list_variables(env_id)
|
|
57
|
+
|
|
58
|
+
env_vars: dict[str, str] = {}
|
|
59
|
+
for r in rows:
|
|
60
|
+
key = r.get("key")
|
|
61
|
+
value = r.get("value")
|
|
62
|
+
if isinstance(key, str) and isinstance(value, str):
|
|
63
|
+
env_vars[key] = value
|
|
64
|
+
|
|
65
|
+
raise typer.Exit(code=inject_run(env_vars, argv, inherit_parent=not no_inherit))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from .. import tokens
|
|
6
|
+
from ..config import ProjectPin, UserConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def status() -> None:
|
|
10
|
+
"""Show local CLI state — login, project pin, API URL — without hitting the network."""
|
|
11
|
+
cfg = UserConfig.load()
|
|
12
|
+
has_token = tokens.load() is not None
|
|
13
|
+
|
|
14
|
+
typer.echo(f"API URL: {cfg.api_url}")
|
|
15
|
+
typer.echo(f"Dashboard URL: {cfg.dashboard_url}")
|
|
16
|
+
typer.echo(f"Logged in: {'yes' if has_token else 'no'}")
|
|
17
|
+
|
|
18
|
+
pin = ProjectPin.find()
|
|
19
|
+
if pin:
|
|
20
|
+
typer.echo(f"Project pin: {pin.project} (from {pin.path})")
|
|
21
|
+
if pin.default_env:
|
|
22
|
+
typer.echo(f"Default env: {pin.default_env}")
|
|
23
|
+
else:
|
|
24
|
+
typer.echo("Project pin: none (no .klavex file in cwd or parents)")
|
klavex/commands/vars.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""List variable NAMES in an environment.
|
|
2
|
+
|
|
3
|
+
Values are never printed by this command — that's what `run` is for.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from .. import tokens
|
|
11
|
+
from ..api import ApiClient
|
|
12
|
+
from ..config import UserConfig
|
|
13
|
+
from ..errors import NotAuthenticated
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def list_vars(
|
|
17
|
+
env: str = typer.Option(..., "-e", "--env", help="Environment ID"),
|
|
18
|
+
) -> None:
|
|
19
|
+
"""List variable names in an environment (values are never shown)."""
|
|
20
|
+
token = tokens.load()
|
|
21
|
+
if not token:
|
|
22
|
+
raise NotAuthenticated()
|
|
23
|
+
|
|
24
|
+
cfg = UserConfig.load()
|
|
25
|
+
with ApiClient(cfg.api_url, token=token) as api:
|
|
26
|
+
rows = api.list_variables(env)
|
|
27
|
+
|
|
28
|
+
if not rows:
|
|
29
|
+
typer.echo("No variables in this environment.")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
keys = sorted(r.get("key", "") for r in rows)
|
|
33
|
+
is_secret_by_key = {r.get("key", ""): r.get("is_secret", False) for r in rows}
|
|
34
|
+
for k in keys:
|
|
35
|
+
marker = " (secret)" if is_secret_by_key.get(k) else ""
|
|
36
|
+
typer.echo(f"{k}{marker}")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from .. import tokens
|
|
6
|
+
from ..api import ApiClient
|
|
7
|
+
from ..config import UserConfig
|
|
8
|
+
from ..errors import NotAuthenticated
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def whoami() -> None:
|
|
12
|
+
"""Show the currently logged-in user and team."""
|
|
13
|
+
token = tokens.load()
|
|
14
|
+
if not token:
|
|
15
|
+
raise NotAuthenticated()
|
|
16
|
+
|
|
17
|
+
cfg = UserConfig.load()
|
|
18
|
+
with ApiClient(cfg.api_url, token=token) as api:
|
|
19
|
+
me = api.me()
|
|
20
|
+
|
|
21
|
+
user = me.get("user", me) if isinstance(me, dict) else {}
|
|
22
|
+
name = user.get("name") or user.get("email") or "?"
|
|
23
|
+
email = user.get("email", "?")
|
|
24
|
+
role = me.get("role") if isinstance(me, dict) else None
|
|
25
|
+
|
|
26
|
+
typer.echo(f"User: {name} <{email}>")
|
|
27
|
+
if role:
|
|
28
|
+
typer.echo(f"Role: {role}")
|
klavex/config.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Non-secret configuration.
|
|
2
|
+
|
|
3
|
+
Two scopes:
|
|
4
|
+
- User config: ~/.config/klavex/config.toml — API URL override, telemetry opt-out.
|
|
5
|
+
- Project pin: .klavex — committed to the project; pins project_id + default_env.
|
|
6
|
+
|
|
7
|
+
Tokens never live here. Tokens go in the OS keychain (see tokens.py).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import tomli_w
|
|
18
|
+
from platformdirs import user_config_path
|
|
19
|
+
|
|
20
|
+
if sys.version_info >= (3, 11):
|
|
21
|
+
import tomllib
|
|
22
|
+
else:
|
|
23
|
+
import tomli as tomllib # type: ignore[import-not-found]
|
|
24
|
+
|
|
25
|
+
DEFAULT_API_URL = "https://api.klavex.dev/api"
|
|
26
|
+
DEFAULT_DASHBOARD_URL = "https://app.klavex.dev"
|
|
27
|
+
|
|
28
|
+
PIN_FILENAME = ".klavex"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _user_config_path() -> Path:
|
|
32
|
+
return user_config_path("klavex") / "config.toml"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class UserConfig:
|
|
37
|
+
api_url: str = DEFAULT_API_URL
|
|
38
|
+
dashboard_url: str = DEFAULT_DASHBOARD_URL
|
|
39
|
+
telemetry: bool = True
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def load(cls) -> UserConfig:
|
|
43
|
+
path = _user_config_path()
|
|
44
|
+
data: dict[str, object] = {}
|
|
45
|
+
if path.exists():
|
|
46
|
+
with path.open("rb") as f:
|
|
47
|
+
data = tomllib.load(f)
|
|
48
|
+
|
|
49
|
+
# Env overrides win over file config.
|
|
50
|
+
api_url = os.environ.get("KLAVEX_API_URL") or data.get("api_url") or DEFAULT_API_URL
|
|
51
|
+
dashboard_url = (
|
|
52
|
+
os.environ.get("KLAVEX_DASHBOARD_URL")
|
|
53
|
+
or data.get("dashboard_url")
|
|
54
|
+
or DEFAULT_DASHBOARD_URL
|
|
55
|
+
)
|
|
56
|
+
telemetry = bool(data.get("telemetry", True))
|
|
57
|
+
if os.environ.get("KLAVEX_NO_TELEMETRY"):
|
|
58
|
+
telemetry = False
|
|
59
|
+
return cls(api_url=str(api_url), dashboard_url=str(dashboard_url), telemetry=telemetry)
|
|
60
|
+
|
|
61
|
+
def save(self) -> None:
|
|
62
|
+
path = _user_config_path()
|
|
63
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
with path.open("wb") as f:
|
|
65
|
+
tomli_w.dump(
|
|
66
|
+
{
|
|
67
|
+
"api_url": self.api_url,
|
|
68
|
+
"dashboard_url": self.dashboard_url,
|
|
69
|
+
"telemetry": self.telemetry,
|
|
70
|
+
},
|
|
71
|
+
f,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class ProjectPin:
|
|
77
|
+
project: str
|
|
78
|
+
default_env: str | None = None
|
|
79
|
+
path: Path | None = None # the .klavex file we read it from
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def find(cls, start: Path | None = None) -> ProjectPin | None:
|
|
83
|
+
"""Walk up from `start` (default cwd) looking for a .klavex file."""
|
|
84
|
+
cur = (start or Path.cwd()).resolve()
|
|
85
|
+
for d in (cur, *cur.parents):
|
|
86
|
+
candidate = d / PIN_FILENAME
|
|
87
|
+
if candidate.is_file():
|
|
88
|
+
with candidate.open("rb") as f:
|
|
89
|
+
data = tomllib.load(f)
|
|
90
|
+
project = data.get("project")
|
|
91
|
+
if not isinstance(project, str):
|
|
92
|
+
return None
|
|
93
|
+
default_env = data.get("default_env")
|
|
94
|
+
return cls(
|
|
95
|
+
project=project,
|
|
96
|
+
default_env=str(default_env) if isinstance(default_env, str) else None,
|
|
97
|
+
path=candidate,
|
|
98
|
+
)
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def write(cls, project: str, default_env: str | None, dest: Path | None = None) -> Path:
|
|
103
|
+
path = (dest or Path.cwd()) / PIN_FILENAME
|
|
104
|
+
payload: dict[str, object] = {"project": project}
|
|
105
|
+
if default_env:
|
|
106
|
+
payload["default_env"] = default_env
|
|
107
|
+
with path.open("wb") as f:
|
|
108
|
+
tomli_w.dump(payload, f)
|
|
109
|
+
return path
|
klavex/errors.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Typed exceptions mapped to deterministic CLI exit codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class KlavexError(Exception):
|
|
7
|
+
"""Base error. Each subclass owns one exit code."""
|
|
8
|
+
|
|
9
|
+
exit_code: int = 1
|
|
10
|
+
|
|
11
|
+
def user_message(self) -> str:
|
|
12
|
+
return str(self)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UsageError(KlavexError):
|
|
16
|
+
exit_code = 2
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NotAuthenticated(KlavexError):
|
|
20
|
+
exit_code = 3
|
|
21
|
+
|
|
22
|
+
def user_message(self) -> str:
|
|
23
|
+
return "Not logged in. Run `klavex login` first."
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NetworkError(KlavexError):
|
|
27
|
+
exit_code = 4
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PermissionDenied(KlavexError):
|
|
31
|
+
exit_code = 5
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NotFound(KlavexError):
|
|
35
|
+
exit_code = 6
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuthFlowError(KlavexError):
|
|
39
|
+
"""Anything that goes wrong during the browser/device-code flow."""
|
|
40
|
+
|
|
41
|
+
exit_code = 3
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class StateMismatch(AuthFlowError):
|
|
45
|
+
def user_message(self) -> str:
|
|
46
|
+
return "Login aborted: state parameter did not match. Try again."
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AuthTimeout(AuthFlowError):
|
|
50
|
+
def __init__(self, seconds: int) -> None:
|
|
51
|
+
super().__init__(f"Login timed out after {seconds} seconds")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TokenStoreUnavailable(KlavexError):
|
|
55
|
+
exit_code = 1
|
|
56
|
+
|
|
57
|
+
def user_message(self) -> str:
|
|
58
|
+
return (
|
|
59
|
+
"No OS keychain backend is available, so the CLI token cannot be stored "
|
|
60
|
+
"securely. Install a keyring backend (e.g. `secretstorage` on Linux) and retry."
|
|
61
|
+
)
|
klavex/inject.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The headline mechanism: spawn a child process with secrets in its env,
|
|
2
|
+
and never write them to disk.
|
|
3
|
+
|
|
4
|
+
This is twelve lines and the entire reason the product exists.
|
|
5
|
+
|
|
6
|
+
Hard rules:
|
|
7
|
+
- NEVER use shell=True (turns unescaped values into shell injection).
|
|
8
|
+
- NEVER write env values to a log, telemetry payload, or temp file.
|
|
9
|
+
- NEVER mutate os.environ in the parent — secrets only exist in the child.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
|
|
17
|
+
from .errors import UsageError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run(env_vars: dict[str, str], argv: list[str], inherit_parent: bool = True) -> int:
|
|
21
|
+
"""Run argv with `env_vars` injected into its environment. Returns exit code."""
|
|
22
|
+
if not argv:
|
|
23
|
+
raise UsageError("Nothing to run. Pass a command after `--`.")
|
|
24
|
+
|
|
25
|
+
base = dict(os.environ) if inherit_parent else {}
|
|
26
|
+
child_env = {**base, **env_vars}
|
|
27
|
+
proc = subprocess.run(argv, env=child_env)
|
|
28
|
+
return proc.returncode
|
klavex/tokens.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""CLI token storage in the OS keychain.
|
|
2
|
+
|
|
3
|
+
We refuse to fall back to a plaintext file. The whole product premise is
|
|
4
|
+
"no plaintext secrets on disk", and that has to apply to our own auth too.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import keyring
|
|
10
|
+
import keyring.errors
|
|
11
|
+
from keyring.backends.fail import Keyring as FailKeyring
|
|
12
|
+
|
|
13
|
+
from .errors import TokenStoreUnavailable
|
|
14
|
+
|
|
15
|
+
SERVICE = "klavex"
|
|
16
|
+
DEFAULT_PROFILE = "default"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ensure_backend() -> None:
|
|
20
|
+
if isinstance(keyring.get_keyring(), FailKeyring):
|
|
21
|
+
raise TokenStoreUnavailable()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def store(token: str, profile: str = DEFAULT_PROFILE) -> None:
|
|
25
|
+
_ensure_backend()
|
|
26
|
+
keyring.set_password(SERVICE, profile, token)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load(profile: str = DEFAULT_PROFILE) -> str | None:
|
|
30
|
+
try:
|
|
31
|
+
return keyring.get_password(SERVICE, profile)
|
|
32
|
+
except keyring.errors.KeyringError:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def delete(profile: str = DEFAULT_PROFILE) -> bool:
|
|
37
|
+
"""Returns True if a token was removed, False if there was nothing to remove."""
|
|
38
|
+
try:
|
|
39
|
+
keyring.delete_password(SERVICE, profile)
|
|
40
|
+
return True
|
|
41
|
+
except keyring.errors.PasswordDeleteError:
|
|
42
|
+
return False
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: klavex
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Klavex CLI — pull environment variables into a process without writing secrets to disk.
|
|
5
|
+
Author: Klavex
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: MacOS
|
|
11
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
19
|
+
Requires-Dist: keyring<26,>=24
|
|
20
|
+
Requires-Dist: platformdirs<5,>=4
|
|
21
|
+
Requires-Dist: tomli-w<2.0,>=1.0
|
|
22
|
+
Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
23
|
+
Requires-Dist: typer<1.0,>=0.12
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
28
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# klavex
|
|
33
|
+
|
|
34
|
+
CLI for [Klavex](https://klavex.dev). Pulls environment variables from your team's vault and injects them into a child process — secrets never touch disk.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install klavex
|
|
38
|
+
klavex login
|
|
39
|
+
klavex run -- npm start
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
See [IMPLEMENTATION.md](./IMPLEMENTATION.md) for the design.
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
| Command | Status |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `klavex login` | v0.1 |
|
|
49
|
+
| `klavex logout` | v0.1 |
|
|
50
|
+
| `klavex whoami` | v0.1 |
|
|
51
|
+
| `klavex status` | v0.1 |
|
|
52
|
+
| `klavex projects` | v0.2 |
|
|
53
|
+
| `klavex envs <project>` | v0.2 |
|
|
54
|
+
| `klavex vars -e <env>` | v0.2 |
|
|
55
|
+
| `klavex run -e <env> -- <cmd>` | v0.3 (blocked on backend reveal endpoint) |
|
|
56
|
+
| `klavex export -e <env>` | v0.4 |
|
|
57
|
+
| `klavex use -p <project> -e <env>` | v0.4 |
|
|
58
|
+
|
|
59
|
+
## Development
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install -e ".[dev]"
|
|
63
|
+
klavex --version
|
|
64
|
+
pytest
|
|
65
|
+
ruff check .
|
|
66
|
+
mypy
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Override the API URL for staging or local:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
export KLAVEX_API_URL=http://localhost:8000
|
|
73
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
klavex/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
klavex/__main__.py,sha256=cXCL1fiQxRjqFbb1L5R1cmFa8T95gAuJBMFLEOFGnUw,65
|
|
3
|
+
klavex/api.py,sha256=TKGeGlzREaIuhwIP-Is8rmlDIStRX6PX8O5kbuKAhTA,5516
|
|
4
|
+
klavex/auth.py,sha256=5y77Lc4qimvEFm4Vje_XtT9m8Yq_rjYdayFzqNp0mho,6746
|
|
5
|
+
klavex/cli.py,sha256=DMTygeIWDEKd2hVHH92Zo5u7GG6WIQPP2MXjfIXbPQ8,2849
|
|
6
|
+
klavex/config.py,sha256=1YFHy6PVfo-rOVYafp_YRov-XLp6aejG-WDAUtIc578,3456
|
|
7
|
+
klavex/errors.py,sha256=BDt5v-AYkPBnNOwtixXCDd8Wyoho1P_FNIF8k2iegFI,1362
|
|
8
|
+
klavex/inject.py,sha256=6ApAbsccvsPRz_3vRtP5OBS8-gOydvyyB3GIVqvv5Bk,932
|
|
9
|
+
klavex/tokens.py,sha256=xz2SbZtLQIlsG5V0_vCbpmItINq3p_lT2e9T4pvdTKM,1146
|
|
10
|
+
klavex/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
klavex/commands/envs.py,sha256=UyI4VQ09NxJlRvmtuMrJCsH2NxQgUiGQIM2d7jXQgwA,920
|
|
12
|
+
klavex/commands/login.py,sha256=pjJ8gklfS71UKRh3v3fi9X1msc8zCFgN3_xNY5TnK08,1142
|
|
13
|
+
klavex/commands/logout.py,sha256=1Q0ekSzsgsLUfAsQ0DqnBOrHtlMZ8aQfhfyHMhfYFvo,988
|
|
14
|
+
klavex/commands/projects.py,sha256=km6MNEYqfXvoHjiHBQ3WL7Nn-4nqKFbDTdZdYiPwGsc,828
|
|
15
|
+
klavex/commands/run.py,sha256=Cc26CD2O9NyYi6LUbv7qNOcgn0qF_guDPPwpuxma2LE,1840
|
|
16
|
+
klavex/commands/status.py,sha256=XoaiB1gjg8217USQqvHos6wdI8KxPsG3Ep3h2PBB1iI,767
|
|
17
|
+
klavex/commands/vars.py,sha256=1SOOpjdhpYLGb78j4O9u-JcO3wkiz4OGMgtVR0A4oh4,1002
|
|
18
|
+
klavex/commands/whoami.py,sha256=9dRB24d8xhFDO2adkwl_69qjg-qpUCJzcIfjL7zdwss,737
|
|
19
|
+
klavex-0.1.0.dist-info/METADATA,sha256=So-mgIigU7R8y6mbgNMiEcdMVpy7fNpzY47KbAMYQ3Y,2066
|
|
20
|
+
klavex-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
21
|
+
klavex-0.1.0.dist-info/entry_points.txt,sha256=9kKuhD7EfWiHS8NF_RPMfvUBCbUZIOISXe7oGNblUY4,64
|
|
22
|
+
klavex-0.1.0.dist-info/RECORD,,
|