@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.
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import secrets
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ DEFAULT_CONFIG: dict[str, Any] = {
10
+ "language": "en",
11
+ "key_storage": "file",
12
+ "keyring_service": "zocket",
13
+ "keyring_account": "master-key",
14
+ "web_auth_enabled": True,
15
+ "web_password_hash": "",
16
+ "web_password_salt": "",
17
+ "web_password_iterations": 390000,
18
+ "theme": "standard",
19
+ "theme_variant": "dark",
20
+ "session_secret": "",
21
+ "audit_enabled": True,
22
+ "exec_allowlist": [],
23
+ "exec_return_output": True,
24
+ "exec_max_output_chars": 4000,
25
+ "exec_allow_full_output": False,
26
+ "exec_substitute_env": True,
27
+ }
28
+
29
+
30
+ def _deep_copy_default() -> dict[str, Any]:
31
+ return json.loads(json.dumps(DEFAULT_CONFIG))
32
+
33
+
34
+ class ConfigStore:
35
+ def __init__(self, path: Path):
36
+ self.path = path
37
+
38
+ def load(self) -> dict[str, Any]:
39
+ if not self.path.exists():
40
+ return _deep_copy_default()
41
+ raw = self.path.read_text(encoding="utf-8")
42
+ if not raw.strip():
43
+ return _deep_copy_default()
44
+ data = json.loads(raw)
45
+ if not isinstance(data, dict):
46
+ return _deep_copy_default()
47
+ merged = _deep_copy_default()
48
+ merged.update(data)
49
+ return merged
50
+
51
+ def save(self, payload: dict[str, Any]) -> None:
52
+ self.path.parent.mkdir(parents=True, exist_ok=True)
53
+ tmp = self.path.with_suffix(self.path.suffix + ".tmp")
54
+ tmp.write_text(
55
+ json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True) + "\n",
56
+ encoding="utf-8",
57
+ )
58
+ os.chmod(tmp, 0o600)
59
+ os.replace(tmp, self.path)
60
+
61
+ def ensure_exists(self) -> dict[str, Any]:
62
+ cfg = self.load()
63
+ if not cfg.get("session_secret"):
64
+ cfg["session_secret"] = secrets.token_urlsafe(32)
65
+ self.save(cfg)
66
+ elif not self.path.exists():
67
+ self.save(cfg)
68
+ return cfg
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from cryptography.fernet import Fernet, InvalidToken
9
+
10
+ KeyStorage = str
11
+
12
+
13
+ class KeyNotFoundError(FileNotFoundError):
14
+ pass
15
+
16
+
17
+ class DecryptionError(RuntimeError):
18
+ pass
19
+
20
+
21
+ def _import_keyring():
22
+ try:
23
+ import keyring # type: ignore
24
+
25
+ return keyring
26
+ except Exception as exc: # pragma: no cover - environment dependent
27
+ raise RuntimeError(
28
+ "Keyring backend is unavailable. Install `keyring` package and OS backend."
29
+ ) from exc
30
+
31
+
32
+ def _store_key_keyring(service: str, account: str, key: bytes, force: bool) -> None:
33
+ keyring = _import_keyring()
34
+ if not force:
35
+ existing = keyring.get_password(service, account)
36
+ if existing:
37
+ raise FileExistsError(
38
+ f"Key already exists in keyring service={service} account={account}"
39
+ )
40
+ keyring.set_password(service, account, key.decode("utf-8"))
41
+
42
+
43
+ def _load_key_keyring(service: str, account: str) -> bytes:
44
+ keyring = _import_keyring()
45
+ value = keyring.get_password(service, account)
46
+ if not value:
47
+ raise KeyNotFoundError(
48
+ f"Master key not found in keyring service={service} account={account}"
49
+ )
50
+ return value.encode("utf-8")
51
+
52
+
53
+ def _delete_key_keyring(service: str, account: str) -> None:
54
+ keyring = _import_keyring()
55
+ try:
56
+ keyring.delete_password(service, account)
57
+ except Exception:
58
+ return
59
+
60
+
61
+ def generate_key_file(path: Path, force: bool = False) -> Path:
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ if path.exists() and not force:
64
+ raise FileExistsError(f"Key file already exists: {path}")
65
+
66
+ key = Fernet.generate_key()
67
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
68
+ tmp_path.write_bytes(key + b"\n")
69
+ os.chmod(tmp_path, 0o600)
70
+ os.replace(tmp_path, path)
71
+ return path
72
+
73
+
74
+ def generate_master_key(
75
+ path: Path,
76
+ storage: KeyStorage = "file",
77
+ keyring_service: str = "zocket",
78
+ keyring_account: str = "master-key",
79
+ force: bool = False,
80
+ ) -> bytes:
81
+ key = Fernet.generate_key()
82
+ if storage == "keyring":
83
+ _store_key_keyring(keyring_service, keyring_account, key, force=force)
84
+ return key
85
+ generate_key_file(path, force=force)
86
+ return path.read_bytes().strip()
87
+
88
+
89
+ def load_key(
90
+ path: Path,
91
+ env_var: str = "ZOCKET_MASTER_KEY",
92
+ storage: KeyStorage = "file",
93
+ keyring_service: str = "zocket",
94
+ keyring_account: str = "master-key",
95
+ ) -> bytes:
96
+ from_env = os.environ.get(env_var)
97
+ if from_env:
98
+ return from_env.strip().encode("utf-8")
99
+
100
+ if storage == "keyring":
101
+ return _load_key_keyring(keyring_service, keyring_account)
102
+
103
+ if not path.exists():
104
+ raise KeyNotFoundError(
105
+ f"Master key file not found: {path}. Run `zocket init` first."
106
+ )
107
+
108
+ key = path.read_bytes().strip()
109
+ if not key:
110
+ raise KeyNotFoundError(f"Master key file is empty: {path}")
111
+ return key
112
+
113
+
114
+ def store_key(
115
+ key: bytes,
116
+ path: Path,
117
+ storage: KeyStorage = "file",
118
+ keyring_service: str = "zocket",
119
+ keyring_account: str = "master-key",
120
+ ) -> None:
121
+ if storage == "keyring":
122
+ _store_key_keyring(keyring_service, keyring_account, key, force=True)
123
+ return
124
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
125
+ tmp_path.write_bytes(key + b"\n")
126
+ os.chmod(tmp_path, 0o600)
127
+ os.replace(tmp_path, path)
128
+
129
+
130
+ def delete_key(
131
+ path: Path,
132
+ storage: KeyStorage = "file",
133
+ keyring_service: str = "zocket",
134
+ keyring_account: str = "master-key",
135
+ ) -> None:
136
+ if storage == "keyring":
137
+ _delete_key_keyring(keyring_service, keyring_account)
138
+ return
139
+ if path.exists():
140
+ path.unlink()
141
+
142
+
143
+ def encrypt_payload(payload: dict[str, Any], key: bytes) -> bytes:
144
+ raw = json.dumps(payload, ensure_ascii=True, separators=(",", ":")).encode("utf-8")
145
+ return Fernet(key).encrypt(raw)
146
+
147
+
148
+ def decrypt_payload(ciphertext: bytes, key: bytes) -> dict[str, Any]:
149
+ try:
150
+ raw = Fernet(key).decrypt(ciphertext)
151
+ except InvalidToken as exc:
152
+ raise DecryptionError(
153
+ "Failed to decrypt vault. Master key is invalid or vault is corrupted."
154
+ ) from exc
155
+ data = json.loads(raw.decode("utf-8"))
156
+ if not isinstance(data, dict):
157
+ raise DecryptionError("Vault payload is malformed.")
158
+ return data
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
11
+ cp = subprocess.run(cmd, capture_output=True, text=True, check=False)
12
+ if check and cp.returncode != 0:
13
+ msg = cp.stderr.strip() or cp.stdout.strip() or f"command failed: {' '.join(cmd)}"
14
+ raise RuntimeError(msg)
15
+ return cp
16
+
17
+
18
+ def _service_text(
19
+ description: str,
20
+ user: str,
21
+ group: str,
22
+ zocket_home: Path,
23
+ exec_start: str,
24
+ ) -> str:
25
+ return (
26
+ "[Unit]\n"
27
+ f"Description={description}\n"
28
+ "After=network-online.target\n"
29
+ "Wants=network-online.target\n\n"
30
+ "[Service]\n"
31
+ "Type=simple\n"
32
+ f"User={user}\n"
33
+ f"Group={group}\n"
34
+ f"Environment=ZOCKET_HOME={zocket_home}\n"
35
+ f"ExecStart={exec_start}\n"
36
+ "Restart=on-failure\n"
37
+ "RestartSec=2\n"
38
+ "NoNewPrivileges=true\n"
39
+ "PrivateTmp=true\n"
40
+ "ProtectSystem=strict\n"
41
+ "ProtectHome=read-only\n"
42
+ "ProtectKernelTunables=true\n"
43
+ "ProtectControlGroups=true\n"
44
+ "LockPersonality=true\n"
45
+ "MemoryDenyWriteExecute=true\n"
46
+ f"ReadWritePaths={zocket_home}\n\n"
47
+ "[Install]\n"
48
+ "WantedBy=multi-user.target\n"
49
+ )
50
+
51
+
52
+ def install_linux_system_services(
53
+ service_user: str = "zocketd",
54
+ zocket_home: Path = Path("/var/lib/zocket"),
55
+ web_port: int = 18001,
56
+ mcp_host: str = "127.0.0.1",
57
+ mcp_port: int = 18002,
58
+ mcp_mode: str = "metadata",
59
+ dry_run: bool = False,
60
+ ) -> dict[str, object]:
61
+ if not sys.platform.startswith("linux"):
62
+ return {"ok": False, "message": "linux-only command"}
63
+ if os.geteuid() != 0 and not dry_run:
64
+ return {"ok": False, "message": "run this command as root"}
65
+
66
+ python_exe = shutil.which("python3") or "/usr/bin/python3"
67
+ web_exec = f"{python_exe} -m zocket web --host 127.0.0.1 --port {web_port}"
68
+ mcp_exec = (
69
+ f"{python_exe} -m zocket mcp --transport streamable-http "
70
+ f"--mode {mcp_mode} --host {mcp_host} --port {mcp_port}"
71
+ )
72
+ web_service = _service_text(
73
+ "Zocket Web Panel (system)",
74
+ service_user,
75
+ service_user,
76
+ zocket_home,
77
+ web_exec,
78
+ )
79
+ mcp_service = _service_text(
80
+ "Zocket MCP HTTP (system)",
81
+ service_user,
82
+ service_user,
83
+ zocket_home,
84
+ mcp_exec,
85
+ )
86
+ result: dict[str, object] = {
87
+ "ok": True,
88
+ "dry_run": dry_run,
89
+ "service_user": service_user,
90
+ "zocket_home": str(zocket_home),
91
+ "preview": {
92
+ "/etc/systemd/system/zocket-web.service": web_service,
93
+ "/etc/systemd/system/zocket-mcp-http.service": mcp_service,
94
+ },
95
+ }
96
+ if dry_run:
97
+ return result
98
+
99
+ _run(
100
+ [
101
+ "id",
102
+ service_user,
103
+ ],
104
+ check=False,
105
+ )
106
+ exists = _run(["id", service_user], check=False).returncode == 0
107
+ if not exists:
108
+ _run(
109
+ [
110
+ "useradd",
111
+ "--system",
112
+ "--create-home",
113
+ "--home-dir",
114
+ str(zocket_home),
115
+ "--shell",
116
+ "/usr/sbin/nologin",
117
+ service_user,
118
+ ]
119
+ )
120
+ zocket_home.mkdir(parents=True, exist_ok=True)
121
+ os.chmod(zocket_home, 0o700)
122
+ _run(["chown", "-R", f"{service_user}:{service_user}", str(zocket_home)])
123
+
124
+ web_path = Path("/etc/systemd/system/zocket-web.service")
125
+ mcp_path = Path("/etc/systemd/system/zocket-mcp-http.service")
126
+ web_path.write_text(web_service, encoding="utf-8")
127
+ mcp_path.write_text(mcp_service, encoding="utf-8")
128
+
129
+ _run(["systemctl", "daemon-reload"])
130
+ _run(["systemctl", "enable", "--now", "zocket-web.service"])
131
+ _run(["systemctl", "enable", "--now", "zocket-mcp-http.service"])
132
+ result["status"] = {
133
+ "web": _run(["systemctl", "is-active", "zocket-web.service"], check=False).stdout.strip(),
134
+ "mcp": _run(["systemctl", "is-active", "zocket-mcp-http.service"], check=False).stdout.strip(),
135
+ }
136
+ return result
package/zocket/i18n.py ADDED
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ SUPPORTED_LANGS = {"en", "ru"}
6
+ DEFAULT_LANG = "en"
7
+
8
+ MESSAGES: dict[str, dict[str, str]] = {
9
+ "en": {
10
+ "app.tagline": "Local encrypted vault for MCP/CLI workflows.",
11
+ "ui.projects": "Projects",
12
+ "ui.name": "Name",
13
+ "ui.keys_count": "Keys",
14
+ "ui.new_project": "New project",
15
+ "ui.optional_desc": "Description (optional)",
16
+ "ui.optional_folder": "Project folder path (optional)",
17
+ "ui.project_folder": "Project folder",
18
+ "ui.not_set": "Not set",
19
+ "ui.choose_folder": "Choose folder",
20
+ "ui.save_folder": "Save folder",
21
+ "ui.clear_folder": "Clear folder",
22
+ "ui.folder_picker": "Folder picker",
23
+ "ui.current_path": "Current path",
24
+ "ui.parent_folder": "Up",
25
+ "ui.roots": "Roots",
26
+ "ui.select_folder": "Select this folder",
27
+ "ui.close": "Close",
28
+ "ui.loading": "Loading...",
29
+ "ui.no_subfolders": "No subfolders",
30
+ "ui.folder_picker_failed": "Failed to load folders",
31
+ "ui.create": "Create",
32
+ "ui.real_values_visible": "Real values are visible.",
33
+ "ui.hide_values": "Hide values",
34
+ "ui.masked_values_visible": "Masked values are visible.",
35
+ "ui.show_values": "Show values",
36
+ "ui.delete_project": "Delete project",
37
+ "ui.secrets": "Secrets",
38
+ "ui.description": "Description",
39
+ "ui.updated_at": "Updated",
40
+ "ui.value": "Value",
41
+ "ui.delete": "Delete",
42
+ "ui.edit_secret": "Edit secret",
43
+ "ui.add_or_update_secret": "Add or update secret",
44
+ "ui.secret_preset": "Secret preset",
45
+ "ui.choose_preset": "Choose preset",
46
+ "ui.friendly_name": "Friendly name (optional)",
47
+ "ui.key": "Key",
48
+ "ui.folder_search": "Search folders",
49
+ "ui.no_matching_folders": "No matching folders",
50
+ "ui.save": "Save",
51
+ "ui.no_projects": "No projects yet",
52
+ "ui.create_left": "Create a project in the left panel.",
53
+ "ui.lang": "Language",
54
+ "ui.lang_en": "English",
55
+ "ui.lang_ru": "Russian",
56
+ "ui.theme": "Theme",
57
+ "ui.theme_standard": "Standard",
58
+ "ui.theme_zorin": "Zorin Pretty",
59
+ "ui.variant_light": "Light view",
60
+ "ui.variant_dark": "Dark glow",
61
+ "ui.sign_in": "Sign in",
62
+ "ui.password": "Password",
63
+ "ui.password_repeat": "Repeat password",
64
+ "ui.login": "Login",
65
+ "ui.logout": "Logout",
66
+ "ui.invalid_login": "Invalid password",
67
+ "ui.auth_required": "Authentication is required",
68
+ "ui.first_time_set_password": "Set admin password first via CLI: zocket auth set-password",
69
+ "ui.first_setup_title": "First launch setup",
70
+ "ui.first_setup_subtitle": "Choose how to protect your local panel.",
71
+ "ui.set_password": "Set your password",
72
+ "ui.save_and_enter": "Save and open panel",
73
+ "ui.generate_password": "Generate strong password",
74
+ "ui.generate_password_hint": "A secure random password will be generated and shown once.",
75
+ "ui.generate_and_enter": "Generate and open panel",
76
+ "ui.continue_without_password": "Continue without password",
77
+ "ui.insecure_warning": "This is less secure. Anyone with local access may open the panel.",
78
+ "ui.i_understand_risk": "I understand the risk",
79
+ "ui.continue_anyway": "Continue without password",
80
+ "ui.insecure_confirm_dialog": "Continue without password? This is less secure.",
81
+ "ui.confirm_insecure_required": "Please confirm that you understand the security risk.",
82
+ "ui.invalid_setup_option": "Invalid setup option.",
83
+ "ui.password_required": "Password is required.",
84
+ "ui.passwords_do_not_match": "Passwords do not match.",
85
+ "ui.generated_password_notice": "Generated admin password (shown once):",
86
+ "ui.generated_password_save_now": "Save it now. You can change it later via CLI.",
87
+ "msg.key_file": "Key file: {path}",
88
+ "msg.vault_file": "Vault file: {path}",
89
+ "msg.init_complete": "Initialization complete.",
90
+ "msg.project_created": "Project created: {name}",
91
+ "msg.project_deleted": "Project deleted: {name}",
92
+ "msg.project_folder_set": "Project folder saved: {name}",
93
+ "msg.project_folder_cleared": "Project folder cleared: {name}",
94
+ "msg.secret_saved": "Secret {key} saved for project {project}",
95
+ "msg.secret_deleted": "Secret {key} deleted from project {project}",
96
+ "msg.password_set": "Web admin password was updated.",
97
+ "msg.language_set": "Language set to {lang}.",
98
+ "err.usage_use": "Usage: zocket use <project> -- <command> [args...]",
99
+ "err.need_login": "Error: password login required.",
100
+ },
101
+ "ru": {
102
+ "app.tagline": "Локальное шифрованное хранилище для MCP/CLI.",
103
+ "ui.projects": "Проекты",
104
+ "ui.name": "Имя",
105
+ "ui.keys_count": "Ключей",
106
+ "ui.new_project": "Новый проект",
107
+ "ui.optional_desc": "Описание (опционально)",
108
+ "ui.optional_folder": "Папка проекта (опционально)",
109
+ "ui.project_folder": "Папка проекта",
110
+ "ui.not_set": "Не задано",
111
+ "ui.choose_folder": "Выбрать папку",
112
+ "ui.save_folder": "Сохранить папку",
113
+ "ui.clear_folder": "Очистить папку",
114
+ "ui.folder_picker": "Выбор папки",
115
+ "ui.current_path": "Текущий путь",
116
+ "ui.parent_folder": "Вверх",
117
+ "ui.roots": "Корни",
118
+ "ui.select_folder": "Выбрать эту папку",
119
+ "ui.close": "Закрыть",
120
+ "ui.loading": "Загрузка...",
121
+ "ui.no_subfolders": "Подпапок нет",
122
+ "ui.folder_picker_failed": "Не удалось загрузить папки",
123
+ "ui.create": "Создать",
124
+ "ui.real_values_visible": "Показаны реальные значения.",
125
+ "ui.hide_values": "Скрыть значения",
126
+ "ui.masked_values_visible": "Показаны замаскированные значения.",
127
+ "ui.show_values": "Показать значения",
128
+ "ui.delete_project": "Удалить проект",
129
+ "ui.secrets": "Секреты",
130
+ "ui.description": "Описание",
131
+ "ui.updated_at": "Обновлён",
132
+ "ui.value": "Значение",
133
+ "ui.delete": "Удалить",
134
+ "ui.edit_secret": "Редактировать",
135
+ "ui.add_or_update_secret": "Добавить или обновить секрет",
136
+ "ui.secret_preset": "Пресет секрета",
137
+ "ui.choose_preset": "Выбрать пресет",
138
+ "ui.friendly_name": "Читабельное имя (опционально)",
139
+ "ui.key": "Ключ",
140
+ "ui.folder_search": "Поиск папок",
141
+ "ui.no_matching_folders": "Совпадений не найдено",
142
+ "ui.save": "Сохранить",
143
+ "ui.no_projects": "Проектов пока нет",
144
+ "ui.create_left": "Создайте проект слева.",
145
+ "ui.lang": "Язык",
146
+ "ui.lang_en": "Английский",
147
+ "ui.lang_ru": "Русский",
148
+ "ui.theme": "Тема",
149
+ "ui.theme_standard": "Стандартная",
150
+ "ui.theme_zorin": "Zorin Pretty",
151
+ "ui.variant_light": "Светлая",
152
+ "ui.variant_dark": "Тёмная",
153
+ "ui.hero_badge": "Zorin Pretty",
154
+ "ui.hero_title": "Локальное MCP-хранилище в неоновой оболочке",
155
+ "ui.hero_subtitle": "Переключайся между CLI-агентами на градиентном холсте, вдохновлённом zorin.pw. Секреты по-прежнему зашифрованы, а интерфейс выглядит как панель управления.",
156
+ "ui.hero_agent": "Готово для агентов",
157
+ "ui.hero_agent_body": "MCP под TLS, автозапуск и контроль сессий доступны в каждом проекте.",
158
+ "ui.hero_theme": "Управление темой",
159
+ "ui.hero_theme_body": "Zorin Pretty активирует градиенты, стеклянные карточки и атмосферные свечения.",
160
+ "ui.sign_in": "Вход",
161
+ "ui.password": "Пароль",
162
+ "ui.password_repeat": "Повторите пароль",
163
+ "ui.login": "Войти",
164
+ "ui.logout": "Выйти",
165
+ "ui.invalid_login": "Неверный пароль",
166
+ "ui.auth_required": "Требуется аутентификация",
167
+ "ui.first_time_set_password": "Сначала задайте пароль через CLI: zocket auth set-password",
168
+ "ui.first_setup_title": "Первичная настройка",
169
+ "ui.first_setup_subtitle": "Выберите способ защиты локальной панели.",
170
+ "ui.set_password": "Задать свой пароль",
171
+ "ui.save_and_enter": "Сохранить и открыть панель",
172
+ "ui.generate_password": "Сгенерировать надёжный пароль",
173
+ "ui.generate_password_hint": "Будет сгенерирован случайный пароль и показан один раз.",
174
+ "ui.generate_and_enter": "Сгенерировать и открыть панель",
175
+ "ui.continue_without_password": "Продолжить без пароля",
176
+ "ui.insecure_warning": "Это менее безопасно. Любой с локальным доступом сможет открыть панель.",
177
+ "ui.i_understand_risk": "Я понимаю риск",
178
+ "ui.continue_anyway": "Продолжить без пароля",
179
+ "ui.insecure_confirm_dialog": "Продолжить без пароля? Это менее безопасно.",
180
+ "ui.confirm_insecure_required": "Подтвердите, что вы понимаете риск безопасности.",
181
+ "ui.invalid_setup_option": "Некорректный вариант настройки.",
182
+ "ui.password_required": "Нужно ввести пароль.",
183
+ "ui.passwords_do_not_match": "Пароли не совпадают.",
184
+ "ui.generated_password_notice": "Сгенерированный пароль администратора (показан один раз):",
185
+ "ui.generated_password_save_now": "Сохраните его сейчас. Позже можно сменить через CLI.",
186
+ "msg.key_file": "Файл ключа: {path}",
187
+ "msg.vault_file": "Файл vault: {path}",
188
+ "msg.init_complete": "Инициализация завершена.",
189
+ "msg.project_created": "Проект создан: {name}",
190
+ "msg.project_deleted": "Проект удалён: {name}",
191
+ "msg.project_folder_set": "Папка проекта сохранена: {name}",
192
+ "msg.project_folder_cleared": "Папка проекта очищена: {name}",
193
+ "msg.secret_saved": "Секрет {key} сохранён для проекта {project}",
194
+ "msg.secret_deleted": "Секрет {key} удалён из проекта {project}",
195
+ "msg.password_set": "Пароль веб-админа обновлён.",
196
+ "msg.language_set": "Язык переключен на {lang}.",
197
+ "err.usage_use": "Использование: zocket use <project> -- <command> [args...]",
198
+ "err.need_login": "Ошибка: нужен парольный вход.",
199
+ },
200
+ }
201
+
202
+
203
+ def normalize_lang(value: str | None) -> str:
204
+ if not value:
205
+ return DEFAULT_LANG
206
+ value = value.lower().strip()
207
+ return value if value in SUPPORTED_LANGS else DEFAULT_LANG
208
+
209
+
210
+ def tr(locale_code: str, message_id: str, **kwargs: Any) -> str:
211
+ locale = normalize_lang(locale_code)
212
+ table = MESSAGES.get(locale, MESSAGES[DEFAULT_LANG])
213
+ text = table.get(message_id, MESSAGES[DEFAULT_LANG].get(message_id, message_id))
214
+ if kwargs:
215
+ return text.format(**kwargs)
216
+ return text