@ao_zorin/zocket 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/bin/zocket-setup.cjs +12 -0
- package/bin/zocket.cjs +174 -0
- package/docs/AI_AUTODEPLOY.md +52 -0
- package/docs/CLIENTS_MCP.md +59 -0
- package/docs/INSTALL.md +288 -0
- package/docs/LOCAL_MODELS.md +95 -0
- package/package.json +52 -0
- package/pyproject.toml +29 -0
- package/scripts/ai-autodeploy.py +127 -0
- package/scripts/install-zocket.ps1 +116 -0
- package/scripts/install-zocket.sh +228 -0
- package/zocket/__init__.py +2 -0
- package/zocket/__main__.py +5 -0
- package/zocket/audit.py +76 -0
- package/zocket/auth.py +34 -0
- package/zocket/autostart.py +281 -0
- package/zocket/backup.py +33 -0
- package/zocket/cli.py +655 -0
- package/zocket/config_store.py +68 -0
- package/zocket/crypto.py +158 -0
- package/zocket/harden.py +136 -0
- package/zocket/i18n.py +216 -0
- package/zocket/mcp_server.py +249 -0
- package/zocket/paths.py +50 -0
- package/zocket/runner.py +108 -0
- package/zocket/templates/index.html +1062 -0
- package/zocket/templates/login.html +244 -0
- package/zocket/vault.py +331 -0
- package/zocket/web.py +490 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="{{ lang }}">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>zocket login</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--primary-color: #0088cc;
|
|
10
|
+
--primary-accent: #00a1ff;
|
|
11
|
+
--dark-bg: #18222d;
|
|
12
|
+
--dark-surface: #212e3c;
|
|
13
|
+
--dark-text: #e9ecef;
|
|
14
|
+
--dark-muted: #adb5bd;
|
|
15
|
+
--light-bg: #ffffff;
|
|
16
|
+
--light-surface: #f8f9fa;
|
|
17
|
+
--light-text: #2c2c2c;
|
|
18
|
+
--light-muted: #6c757d;
|
|
19
|
+
--border-radius: 14px;
|
|
20
|
+
--page-gradient: radial-gradient(circle at 0 0, rgba(242,245,250,1) 0%, rgba(248,249,250,0.8) 55%);
|
|
21
|
+
--transition: all 0.35s cubic-bezier(0.165, 0.84, 0.44, 1);
|
|
22
|
+
}
|
|
23
|
+
body {
|
|
24
|
+
margin: 0;
|
|
25
|
+
min-height: 100vh;
|
|
26
|
+
display: grid;
|
|
27
|
+
place-items: center;
|
|
28
|
+
padding: 24px;
|
|
29
|
+
background: var(--page-gradient);
|
|
30
|
+
color: var(--light-text);
|
|
31
|
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
32
|
+
}
|
|
33
|
+
body[data-theme="zorin"] {
|
|
34
|
+
background: radial-gradient(circle at 15% -10%, rgba(0, 136, 204, 0.45), rgba(8, 16, 30, 0.95) 45%), linear-gradient(180deg, #18222d 0%, #0f172a 65%, #0b1a2b 100%);
|
|
35
|
+
color: var(--dark-text);
|
|
36
|
+
}
|
|
37
|
+
body[data-theme="zorin"][data-theme-variant="light"] {
|
|
38
|
+
background: radial-gradient(circle at 25% -20%, rgba(0, 136, 204, 0.25), transparent 45%), linear-gradient(180deg, #ffffff 0%, #d7efff 100%);
|
|
39
|
+
color: var(--light-text);
|
|
40
|
+
}
|
|
41
|
+
.card {
|
|
42
|
+
width: min(520px, 94vw);
|
|
43
|
+
border-radius: var(--border-radius);
|
|
44
|
+
padding: 24px;
|
|
45
|
+
background: var(--light-surface);
|
|
46
|
+
border: 1px solid rgba(0, 0, 0, 0.08);
|
|
47
|
+
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.12);
|
|
48
|
+
transition: var(--transition);
|
|
49
|
+
}
|
|
50
|
+
body[data-theme="zorin"] .card {
|
|
51
|
+
background: rgba(8, 11, 24, 0.85);
|
|
52
|
+
border-color: rgba(255, 255, 255, 0.15);
|
|
53
|
+
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.6);
|
|
54
|
+
backdrop-filter: blur(28px);
|
|
55
|
+
}
|
|
56
|
+
body[data-theme="zorin"][data-theme-variant="light"] .card {
|
|
57
|
+
background: rgba(255, 255, 255, 0.96);
|
|
58
|
+
border-color: rgba(0, 136, 204, 0.25);
|
|
59
|
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
|
60
|
+
}
|
|
61
|
+
h1 {
|
|
62
|
+
margin: 0 0 8px;
|
|
63
|
+
font-size: 42px;
|
|
64
|
+
letter-spacing: -0.04em;
|
|
65
|
+
line-height: 1.2;
|
|
66
|
+
}
|
|
67
|
+
h2 {
|
|
68
|
+
margin: 14px 0 8px;
|
|
69
|
+
font-size: 20px;
|
|
70
|
+
font-weight: 600;
|
|
71
|
+
}
|
|
72
|
+
p {
|
|
73
|
+
margin: 0 0 12px;
|
|
74
|
+
color: var(--muted, #6c757d);
|
|
75
|
+
}
|
|
76
|
+
input, button {
|
|
77
|
+
width: 100%;
|
|
78
|
+
margin-bottom: 12px;
|
|
79
|
+
padding: 12px;
|
|
80
|
+
border-radius: var(--border-radius);
|
|
81
|
+
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
82
|
+
font: inherit;
|
|
83
|
+
background: var(--light-surface);
|
|
84
|
+
color: inherit;
|
|
85
|
+
transition: var(--transition);
|
|
86
|
+
}
|
|
87
|
+
button {
|
|
88
|
+
border: none;
|
|
89
|
+
background: linear-gradient(135deg, var(--primary-color), var(--primary-accent));
|
|
90
|
+
color: white;
|
|
91
|
+
cursor: pointer;
|
|
92
|
+
box-shadow: 0 10px 25px rgba(0, 136, 204, 0.25);
|
|
93
|
+
}
|
|
94
|
+
button:hover {
|
|
95
|
+
transform: translateY(-4px);
|
|
96
|
+
}
|
|
97
|
+
button.danger {
|
|
98
|
+
background: linear-gradient(135deg, #ff6b6b, #ff8e8e);
|
|
99
|
+
}
|
|
100
|
+
.lang {
|
|
101
|
+
display: flex;
|
|
102
|
+
gap: 8px;
|
|
103
|
+
margin-bottom: 12px;
|
|
104
|
+
}
|
|
105
|
+
.lang a {
|
|
106
|
+
color: var(--primary-color);
|
|
107
|
+
font-weight: 600;
|
|
108
|
+
text-decoration: none;
|
|
109
|
+
}
|
|
110
|
+
.theme-selector {
|
|
111
|
+
display: flex;
|
|
112
|
+
align-items: center;
|
|
113
|
+
gap: 6px;
|
|
114
|
+
margin-bottom: 12px;
|
|
115
|
+
font-size: 14px;
|
|
116
|
+
color: var(--muted);
|
|
117
|
+
}
|
|
118
|
+
.styled-select {
|
|
119
|
+
appearance: none;
|
|
120
|
+
-webkit-appearance: none;
|
|
121
|
+
-moz-appearance: none;
|
|
122
|
+
padding: 10px 28px 10px 16px;
|
|
123
|
+
border-radius: 999px;
|
|
124
|
+
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
125
|
+
background: var(--light-surface);
|
|
126
|
+
color: inherit;
|
|
127
|
+
font-weight: 500;
|
|
128
|
+
cursor: pointer;
|
|
129
|
+
background-image: url('data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"%3E%3Cpolyline points=\"6 8 10 12 14 8\"/%3E%3C/svg%3E'), none;
|
|
130
|
+
background-position: right 10px center, center;
|
|
131
|
+
background-repeat: no-repeat;
|
|
132
|
+
}
|
|
133
|
+
.theme-selector select:focus {
|
|
134
|
+
outline: none;
|
|
135
|
+
box-shadow: 0 0 0 2px rgba(0, 136, 204, 0.3);
|
|
136
|
+
}
|
|
137
|
+
.variant-toggle {
|
|
138
|
+
display: none;
|
|
139
|
+
align-items: center;
|
|
140
|
+
gap: 6px;
|
|
141
|
+
margin-left: 6px;
|
|
142
|
+
}
|
|
143
|
+
body[data-theme="zorin"] .variant-toggle {
|
|
144
|
+
display: inline-flex;
|
|
145
|
+
}
|
|
146
|
+
.variant-button {
|
|
147
|
+
width: 40px;
|
|
148
|
+
height: 40px;
|
|
149
|
+
border-radius: 50%;
|
|
150
|
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
151
|
+
background: rgba(255, 255, 255, 0.08);
|
|
152
|
+
color: var(--primary-color);
|
|
153
|
+
font-size: 1.1rem;
|
|
154
|
+
cursor: pointer;
|
|
155
|
+
transition: var(--transition);
|
|
156
|
+
}
|
|
157
|
+
.variant-button.active {
|
|
158
|
+
background: var(--primary-color);
|
|
159
|
+
color: #fff;
|
|
160
|
+
box-shadow: 0 0 12px rgba(0, 136, 204, 0.6);
|
|
161
|
+
}
|
|
162
|
+
.notice {
|
|
163
|
+
border-radius: var(--border-radius);
|
|
164
|
+
border: 1px solid rgba(0, 136, 204, 0.3);
|
|
165
|
+
background: rgba(0, 136, 204, 0.08);
|
|
166
|
+
}
|
|
167
|
+
.error {
|
|
168
|
+
border-radius: var(--border-radius);
|
|
169
|
+
border: 1px solid #fecdd3;
|
|
170
|
+
background: #fff1f2;
|
|
171
|
+
}
|
|
172
|
+
</style>
|
|
173
|
+
</head>
|
|
174
|
+
<body data-theme="{{ theme }}" data-theme-variant="{{ theme_variant }}">
|
|
175
|
+
<div class="card">
|
|
176
|
+
<div class="lang">
|
|
177
|
+
<a href="/login?lang=en">EN</a>
|
|
178
|
+
<a href="/login?lang=ru">RU</a>
|
|
179
|
+
</div>
|
|
180
|
+
<form method="post" action="/set-theme" class="theme-selector">
|
|
181
|
+
<label for="theme-select">{{ t("ui.theme") }}:</label>
|
|
182
|
+
<select id="theme-select" class="styled-select" name="theme" onchange="this.form.submit();">
|
|
183
|
+
<option value="standard" {% if theme == 'standard' %}selected{% endif %}>{{ t('ui.theme_standard') }}</option>
|
|
184
|
+
<option value="zorin" {% if theme == 'zorin' %}selected{% endif %}>{{ t('ui.theme_zorin') }}</option>
|
|
185
|
+
</select>
|
|
186
|
+
<input type="hidden" name="next" value="{{ next_path }}" />
|
|
187
|
+
</form>
|
|
188
|
+
{% if theme == "zorin" %}
|
|
189
|
+
<form method="post" action="/set-theme-variant" class="variant-toggle">
|
|
190
|
+
<input type="hidden" name="next" value="{{ next_path }}" />
|
|
191
|
+
<button type="submit" name="variant" value="light" class="variant-button {% if theme_variant == 'light' %}active{% endif %}" aria-label="{{ t('ui.variant_light') }}">☀️</button>
|
|
192
|
+
<button type="submit" name="variant" value="dark" class="variant-button {% if theme_variant == 'dark' %}active{% endif %}" aria-label="{{ t('ui.variant_dark') }}">🌙</button>
|
|
193
|
+
</form>
|
|
194
|
+
{% endif %}
|
|
195
|
+
<h1>{{ t("ui.sign_in") }}</h1>
|
|
196
|
+
|
|
197
|
+
{% if error %}
|
|
198
|
+
<div class="error">{{ error }}</div>
|
|
199
|
+
{% endif %}
|
|
200
|
+
|
|
201
|
+
{% if missing_password %}
|
|
202
|
+
<h1>{{ t("ui.first_setup_title") }}</h1>
|
|
203
|
+
<p>{{ t("ui.first_setup_subtitle") }}</p>
|
|
204
|
+
|
|
205
|
+
<div class="section">
|
|
206
|
+
<h2>{{ t("ui.set_password") }}</h2>
|
|
207
|
+
<form method="post" action="/setup/first-run">
|
|
208
|
+
<input type="hidden" name="mode" value="set_password" />
|
|
209
|
+
<input type="password" name="password" placeholder="{{ t('ui.password') }}" required />
|
|
210
|
+
<input type="password" name="password_repeat" placeholder="{{ t('ui.password_repeat') }}" required />
|
|
211
|
+
<button type="submit">{{ t("ui.save_and_enter") }}</button>
|
|
212
|
+
</form>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<div class="section">
|
|
216
|
+
<h2>{{ t("ui.generate_password") }}</h2>
|
|
217
|
+
<p>{{ t("ui.generate_password_hint") }}</p>
|
|
218
|
+
<form method="post" action="/setup/first-run">
|
|
219
|
+
<input type="hidden" name="mode" value="generate_password" />
|
|
220
|
+
<button type="submit">{{ t("ui.generate_and_enter") }}</button>
|
|
221
|
+
</form>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<div class="section warning">
|
|
225
|
+
<h2>{{ t("ui.continue_without_password") }}</h2>
|
|
226
|
+
<p>{{ t("ui.insecure_warning") }}</p>
|
|
227
|
+
<form method="post" action="/setup/first-run" onsubmit="return confirm('{{ t('ui.insecure_confirm_dialog') }}')">
|
|
228
|
+
<input type="hidden" name="mode" value="no_password" />
|
|
229
|
+
<label class="checkline">
|
|
230
|
+
<input type="checkbox" name="confirm_no_password" value="1" required />
|
|
231
|
+
<span>{{ t("ui.i_understand_risk") }}</span>
|
|
232
|
+
</label>
|
|
233
|
+
<button class="danger" type="submit">{{ t("ui.continue_anyway") }}</button>
|
|
234
|
+
</form>
|
|
235
|
+
</div>
|
|
236
|
+
{% else %}
|
|
237
|
+
<form method="post" action="/login">
|
|
238
|
+
<input type="password" name="password" placeholder="{{ t('ui.password') }}" required />
|
|
239
|
+
<button type="submit">{{ t("ui.login") }}</button>
|
|
240
|
+
</form>
|
|
241
|
+
{% endif %}
|
|
242
|
+
</div>
|
|
243
|
+
</body>
|
|
244
|
+
</html>
|
package/zocket/vault.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fcntl
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Iterator
|
|
10
|
+
|
|
11
|
+
from .crypto import decrypt_payload, encrypt_payload, load_key
|
|
12
|
+
|
|
13
|
+
PROJECT_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
|
|
14
|
+
SECRET_KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def utc_now_iso() -> str:
|
|
18
|
+
return datetime.now(timezone.utc).isoformat()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def empty_vault() -> dict[str, Any]:
|
|
22
|
+
return {"version": 1, "projects": {}}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class VaultError(RuntimeError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProjectNotFoundError(VaultError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SecretNotFoundError(VaultError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ValidationError(VaultError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class VaultService:
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
vault_file: Path,
|
|
45
|
+
key_file: Path,
|
|
46
|
+
lock_file: Path,
|
|
47
|
+
key_storage: str = "file",
|
|
48
|
+
keyring_service: str = "zocket",
|
|
49
|
+
keyring_account: str = "master-key",
|
|
50
|
+
):
|
|
51
|
+
self.vault_file = vault_file
|
|
52
|
+
self.key_file = key_file
|
|
53
|
+
self.lock_file = lock_file
|
|
54
|
+
self.key_storage = key_storage
|
|
55
|
+
self.keyring_service = keyring_service
|
|
56
|
+
self.keyring_account = keyring_account
|
|
57
|
+
self._cached_key: bytes | None = None
|
|
58
|
+
|
|
59
|
+
def _key(self) -> bytes:
|
|
60
|
+
if self._cached_key is None:
|
|
61
|
+
self._cached_key = load_key(
|
|
62
|
+
self.key_file,
|
|
63
|
+
storage=self.key_storage,
|
|
64
|
+
keyring_service=self.keyring_service,
|
|
65
|
+
keyring_account=self.keyring_account,
|
|
66
|
+
)
|
|
67
|
+
return self._cached_key
|
|
68
|
+
|
|
69
|
+
@contextmanager
|
|
70
|
+
def _locked(self) -> Iterator[None]:
|
|
71
|
+
self.lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
with self.lock_file.open("a+") as lock_fd:
|
|
73
|
+
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX)
|
|
74
|
+
try:
|
|
75
|
+
yield
|
|
76
|
+
finally:
|
|
77
|
+
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN)
|
|
78
|
+
|
|
79
|
+
def _read_unlocked(self) -> dict[str, Any]:
|
|
80
|
+
if not self.vault_file.exists():
|
|
81
|
+
return empty_vault()
|
|
82
|
+
ciphertext = self.vault_file.read_bytes()
|
|
83
|
+
if not ciphertext:
|
|
84
|
+
return empty_vault()
|
|
85
|
+
payload = decrypt_payload(ciphertext, self._key())
|
|
86
|
+
if "projects" not in payload or not isinstance(payload["projects"], dict):
|
|
87
|
+
raise VaultError("Vault payload is malformed: missing `projects` map.")
|
|
88
|
+
return payload
|
|
89
|
+
|
|
90
|
+
def _write_unlocked(self, payload: dict[str, Any]) -> None:
|
|
91
|
+
self.vault_file.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
ciphertext = encrypt_payload(payload, self._key())
|
|
93
|
+
tmp = self.vault_file.with_suffix(self.vault_file.suffix + ".tmp")
|
|
94
|
+
tmp.write_bytes(ciphertext)
|
|
95
|
+
os.chmod(tmp, 0o600)
|
|
96
|
+
os.replace(tmp, self.vault_file)
|
|
97
|
+
|
|
98
|
+
def ensure_initialized(self) -> None:
|
|
99
|
+
with self._locked():
|
|
100
|
+
if self.vault_file.exists():
|
|
101
|
+
return
|
|
102
|
+
self._write_unlocked(empty_vault())
|
|
103
|
+
|
|
104
|
+
def _validate_project_name(self, project: str) -> None:
|
|
105
|
+
if not project or not PROJECT_RE.match(project):
|
|
106
|
+
raise ValidationError(
|
|
107
|
+
"Invalid project name. Use only [a-zA-Z0-9._-] characters."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def _validate_secret_key(self, key: str) -> None:
|
|
111
|
+
if not key or not SECRET_KEY_RE.match(key):
|
|
112
|
+
raise ValidationError(
|
|
113
|
+
"Invalid secret key. Use UPPERCASE env-like keys, e.g. SSH_PASSWORD."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def _normalize_folder_path(
|
|
117
|
+
self,
|
|
118
|
+
folder_path: str | None,
|
|
119
|
+
require_exists: bool,
|
|
120
|
+
) -> str | None:
|
|
121
|
+
if folder_path is None:
|
|
122
|
+
return None
|
|
123
|
+
raw = folder_path.strip()
|
|
124
|
+
if not raw:
|
|
125
|
+
return None
|
|
126
|
+
path = Path(raw).expanduser()
|
|
127
|
+
try:
|
|
128
|
+
resolved = path.resolve(strict=require_exists)
|
|
129
|
+
except FileNotFoundError as exc:
|
|
130
|
+
raise ValidationError(f"Project folder not found: {path}") from exc
|
|
131
|
+
except OSError as exc:
|
|
132
|
+
raise ValidationError(f"Invalid project folder path: {path}") from exc
|
|
133
|
+
if require_exists and not resolved.is_dir():
|
|
134
|
+
raise ValidationError(f"Project folder is not a directory: {resolved}")
|
|
135
|
+
return str(resolved)
|
|
136
|
+
|
|
137
|
+
def create_project(
|
|
138
|
+
self,
|
|
139
|
+
project: str,
|
|
140
|
+
description: str = "",
|
|
141
|
+
folder_path: str | None = None,
|
|
142
|
+
) -> None:
|
|
143
|
+
self._validate_project_name(project)
|
|
144
|
+
normalized_folder = self._normalize_folder_path(
|
|
145
|
+
folder_path, require_exists=True
|
|
146
|
+
)
|
|
147
|
+
with self._locked():
|
|
148
|
+
payload = self._read_unlocked()
|
|
149
|
+
projects = payload["projects"]
|
|
150
|
+
if project in projects:
|
|
151
|
+
return
|
|
152
|
+
now = utc_now_iso()
|
|
153
|
+
projects[project] = {
|
|
154
|
+
"description": description,
|
|
155
|
+
"created_at": now,
|
|
156
|
+
"updated_at": now,
|
|
157
|
+
"secrets": {},
|
|
158
|
+
}
|
|
159
|
+
if normalized_folder:
|
|
160
|
+
projects[project]["folder_path"] = normalized_folder
|
|
161
|
+
self._write_unlocked(payload)
|
|
162
|
+
|
|
163
|
+
def list_projects(self) -> list[dict[str, Any]]:
|
|
164
|
+
with self._locked():
|
|
165
|
+
payload = self._read_unlocked()
|
|
166
|
+
items: list[dict[str, Any]] = []
|
|
167
|
+
for project, info in payload["projects"].items():
|
|
168
|
+
secrets = info.get("secrets", {})
|
|
169
|
+
items.append(
|
|
170
|
+
{
|
|
171
|
+
"project": project,
|
|
172
|
+
"description": info.get("description", ""),
|
|
173
|
+
"folder_path": info.get("folder_path"),
|
|
174
|
+
"secret_count": len(secrets),
|
|
175
|
+
"updated_at": info.get("updated_at"),
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
items.sort(key=lambda x: x["project"])
|
|
179
|
+
return items
|
|
180
|
+
|
|
181
|
+
def _get_project(self, payload: dict[str, Any], project: str) -> dict[str, Any]:
|
|
182
|
+
self._validate_project_name(project)
|
|
183
|
+
info = payload["projects"].get(project)
|
|
184
|
+
if info is None:
|
|
185
|
+
raise ProjectNotFoundError(f"Project not found: {project}")
|
|
186
|
+
return info
|
|
187
|
+
|
|
188
|
+
def list_project_secrets(
|
|
189
|
+
self, project: str, include_values: bool = False
|
|
190
|
+
) -> list[dict[str, Any]]:
|
|
191
|
+
with self._locked():
|
|
192
|
+
payload = self._read_unlocked()
|
|
193
|
+
info = self._get_project(payload, project)
|
|
194
|
+
result: list[dict[str, Any]] = []
|
|
195
|
+
for key, item in info.get("secrets", {}).items():
|
|
196
|
+
row: dict[str, Any] = {
|
|
197
|
+
"key": key,
|
|
198
|
+
"description": item.get("description", ""),
|
|
199
|
+
"updated_at": item.get("updated_at"),
|
|
200
|
+
"has_value": bool(item.get("value")),
|
|
201
|
+
}
|
|
202
|
+
if include_values:
|
|
203
|
+
row["value"] = item.get("value", "")
|
|
204
|
+
result.append(row)
|
|
205
|
+
result.sort(key=lambda x: x["key"])
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
def upsert_secret(
|
|
209
|
+
self, project: str, key: str, value: str, description: str = ""
|
|
210
|
+
) -> None:
|
|
211
|
+
self._validate_project_name(project)
|
|
212
|
+
self._validate_secret_key(key)
|
|
213
|
+
if value is None:
|
|
214
|
+
raise ValidationError("Secret value cannot be null.")
|
|
215
|
+
|
|
216
|
+
with self._locked():
|
|
217
|
+
payload = self._read_unlocked()
|
|
218
|
+
projects = payload["projects"]
|
|
219
|
+
if project not in projects:
|
|
220
|
+
now = utc_now_iso()
|
|
221
|
+
projects[project] = {
|
|
222
|
+
"description": "",
|
|
223
|
+
"created_at": now,
|
|
224
|
+
"updated_at": now,
|
|
225
|
+
"secrets": {},
|
|
226
|
+
}
|
|
227
|
+
project_info = projects[project]
|
|
228
|
+
now = utc_now_iso()
|
|
229
|
+
project_info["secrets"][key] = {
|
|
230
|
+
"value": value,
|
|
231
|
+
"description": description,
|
|
232
|
+
"updated_at": now,
|
|
233
|
+
}
|
|
234
|
+
project_info["updated_at"] = now
|
|
235
|
+
self._write_unlocked(payload)
|
|
236
|
+
|
|
237
|
+
def delete_secret(self, project: str, key: str) -> None:
|
|
238
|
+
self._validate_project_name(project)
|
|
239
|
+
self._validate_secret_key(key)
|
|
240
|
+
with self._locked():
|
|
241
|
+
payload = self._read_unlocked()
|
|
242
|
+
info = self._get_project(payload, project)
|
|
243
|
+
if key not in info.get("secrets", {}):
|
|
244
|
+
raise SecretNotFoundError(f"Secret {key} not found in project {project}")
|
|
245
|
+
del info["secrets"][key]
|
|
246
|
+
info["updated_at"] = utc_now_iso()
|
|
247
|
+
self._write_unlocked(payload)
|
|
248
|
+
|
|
249
|
+
def get_secret(self, project: str, key: str) -> dict[str, str]:
|
|
250
|
+
self._validate_project_name(project)
|
|
251
|
+
self._validate_secret_key(key)
|
|
252
|
+
with self._locked():
|
|
253
|
+
payload = self._read_unlocked()
|
|
254
|
+
info = self._get_project(payload, project)
|
|
255
|
+
secret = info.get("secrets", {}).get(key)
|
|
256
|
+
if secret is None:
|
|
257
|
+
raise SecretNotFoundError(f"Secret {key} not found in project {project}")
|
|
258
|
+
return dict(secret)
|
|
259
|
+
|
|
260
|
+
def delete_project(self, project: str) -> None:
|
|
261
|
+
self._validate_project_name(project)
|
|
262
|
+
with self._locked():
|
|
263
|
+
payload = self._read_unlocked()
|
|
264
|
+
if project not in payload["projects"]:
|
|
265
|
+
raise ProjectNotFoundError(f"Project not found: {project}")
|
|
266
|
+
del payload["projects"][project]
|
|
267
|
+
self._write_unlocked(payload)
|
|
268
|
+
|
|
269
|
+
def set_project_folder(self, project: str, folder_path: str | None) -> None:
|
|
270
|
+
self._validate_project_name(project)
|
|
271
|
+
normalized_folder = self._normalize_folder_path(
|
|
272
|
+
folder_path, require_exists=True
|
|
273
|
+
)
|
|
274
|
+
with self._locked():
|
|
275
|
+
payload = self._read_unlocked()
|
|
276
|
+
info = self._get_project(payload, project)
|
|
277
|
+
if normalized_folder:
|
|
278
|
+
info["folder_path"] = normalized_folder
|
|
279
|
+
else:
|
|
280
|
+
info.pop("folder_path", None)
|
|
281
|
+
info["updated_at"] = utc_now_iso()
|
|
282
|
+
self._write_unlocked(payload)
|
|
283
|
+
|
|
284
|
+
def find_project_by_path(self, folder_path: str) -> dict[str, Any] | None:
|
|
285
|
+
normalized_folder = self._normalize_folder_path(
|
|
286
|
+
folder_path, require_exists=False
|
|
287
|
+
)
|
|
288
|
+
if not normalized_folder:
|
|
289
|
+
return None
|
|
290
|
+
target = Path(normalized_folder)
|
|
291
|
+
best_name: str | None = None
|
|
292
|
+
best_info: dict[str, Any] | None = None
|
|
293
|
+
best_depth = -1
|
|
294
|
+
|
|
295
|
+
with self._locked():
|
|
296
|
+
payload = self._read_unlocked()
|
|
297
|
+
for name, info in payload["projects"].items():
|
|
298
|
+
raw_folder = info.get("folder_path")
|
|
299
|
+
if not raw_folder:
|
|
300
|
+
continue
|
|
301
|
+
try:
|
|
302
|
+
candidate = Path(str(raw_folder)).expanduser().resolve(strict=False)
|
|
303
|
+
except OSError:
|
|
304
|
+
continue
|
|
305
|
+
if target != candidate and not target.is_relative_to(candidate):
|
|
306
|
+
continue
|
|
307
|
+
depth = len(candidate.parts)
|
|
308
|
+
if depth > best_depth:
|
|
309
|
+
best_name = name
|
|
310
|
+
best_info = info
|
|
311
|
+
best_depth = depth
|
|
312
|
+
|
|
313
|
+
if best_name is None or best_info is None:
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
"project": best_name,
|
|
318
|
+
"description": best_info.get("description", ""),
|
|
319
|
+
"folder_path": best_info.get("folder_path"),
|
|
320
|
+
"secret_count": len(best_info.get("secrets", {})),
|
|
321
|
+
"updated_at": best_info.get("updated_at"),
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
def get_project_env(self, project: str) -> dict[str, str]:
|
|
325
|
+
with self._locked():
|
|
326
|
+
payload = self._read_unlocked()
|
|
327
|
+
info = self._get_project(payload, project)
|
|
328
|
+
env: dict[str, str] = {}
|
|
329
|
+
for key, item in info.get("secrets", {}).items():
|
|
330
|
+
env[key] = str(item.get("value", ""))
|
|
331
|
+
return env
|