waidrop-cli 1.0.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.
- waidrop_cli/__init__.py +3 -0
- waidrop_cli/__main__.py +4 -0
- waidrop_cli/archive.py +103 -0
- waidrop_cli/auth.py +321 -0
- waidrop_cli/auth_flow.py +182 -0
- waidrop_cli/chats.py +169 -0
- waidrop_cli/cli.py +733 -0
- waidrop_cli/config.py +359 -0
- waidrop_cli/context.py +352 -0
- waidrop_cli/crypto.py +126 -0
- waidrop_cli/dropbox.py +217 -0
- waidrop_cli/env_file.py +132 -0
- waidrop_cli/errors.py +21 -0
- waidrop_cli/fetch.py +214 -0
- waidrop_cli/mcp_cli.py +293 -0
- waidrop_cli/mcp_config.py +191 -0
- waidrop_cli/mcp_server.py +367 -0
- waidrop_cli/media.py +179 -0
- waidrop_cli/output.py +160 -0
- waidrop_cli-1.0.0.dist-info/METADATA +477 -0
- waidrop_cli-1.0.0.dist-info/RECORD +25 -0
- waidrop_cli-1.0.0.dist-info/WHEEL +4 -0
- waidrop_cli-1.0.0.dist-info/entry_points.txt +3 -0
- waidrop_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
- waidrop_cli-1.0.0.dist-info/licenses/NOTICE +6 -0
waidrop_cli/crypto.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Client-side archive crypto (must match the extension's background/crypto.js).
|
|
2
|
+
|
|
3
|
+
Contract:
|
|
4
|
+
K = 32-byte master key (base64)
|
|
5
|
+
hashKey = HKDF-SHA256(ikm=K, salt=b"waidrop-v1", info=b"chat-hash", 32)
|
|
6
|
+
encKey = HKDF-SHA256(ikm=K, salt=b"waidrop-v1", info=b"object-encryption", 32)
|
|
7
|
+
mediaKey = HKDF-SHA256(ikm=K, salt=b"waidrop-v1", info=b"media-encryption", 32)
|
|
8
|
+
chatHash = lowerhex(HMAC-SHA256(hashKey, chatId))[:16]
|
|
9
|
+
mediaHash = lowerhex(HMAC-SHA256(hashKey, b"media:" + msgId))[:32]
|
|
10
|
+
object = b"WAAR" | 0x01 | nonce(12) | AES-256-GCM ciphertext+tag (AAD = first 5 bytes)
|
|
11
|
+
media = b"WAMR" | 0x01 | nonce(12) | AES-256-GCM ciphertext+tag (AAD = header + path)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
from cryptography.hazmat.primitives import hashes
|
|
22
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
23
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
24
|
+
|
|
25
|
+
MAGIC = b"WAAR"
|
|
26
|
+
VERSION = 1
|
|
27
|
+
MEDIA_MAGIC = b"WAMR"
|
|
28
|
+
MEDIA_VERSION = 1
|
|
29
|
+
SALT = b"waidrop-v1"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def decode_key(key_base64: str) -> bytes:
|
|
33
|
+
try:
|
|
34
|
+
raw = base64.b64decode(str(key_base64 or ""), validate=True)
|
|
35
|
+
except Exception as exc:
|
|
36
|
+
raise ValueError("encryption key is not valid base64") from exc
|
|
37
|
+
if len(raw) != 32:
|
|
38
|
+
raise ValueError("encryption key must be 32 bytes (base64)")
|
|
39
|
+
return raw
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_valid_key(key_base64: str) -> bool:
|
|
43
|
+
try:
|
|
44
|
+
decode_key(key_base64)
|
|
45
|
+
return True
|
|
46
|
+
except ValueError:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def derive_keys(key_base64: str) -> tuple[bytes, bytes]:
|
|
51
|
+
master = decode_key(key_base64)
|
|
52
|
+
hash_key = HKDF(algorithm=hashes.SHA256(), length=32, salt=SALT, info=b"chat-hash").derive(
|
|
53
|
+
master
|
|
54
|
+
)
|
|
55
|
+
enc_key = HKDF(
|
|
56
|
+
algorithm=hashes.SHA256(), length=32, salt=SALT, info=b"object-encryption"
|
|
57
|
+
).derive(master)
|
|
58
|
+
return hash_key, enc_key
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def derive_media_key(key_base64: str) -> bytes:
|
|
62
|
+
master = decode_key(key_base64)
|
|
63
|
+
return HKDF(algorithm=hashes.SHA256(), length=32, salt=SALT, info=b"media-encryption").derive(
|
|
64
|
+
master
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def chat_hash(hash_key: bytes, chat_id: str) -> str:
|
|
69
|
+
return hmac.new(hash_key, str(chat_id).encode("utf-8"), hashlib.sha256).hexdigest()[:16]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def media_hash(hash_key: bytes, msg_id: str) -> str:
|
|
73
|
+
return hmac.new(hash_key, f"media:{msg_id}".encode(), hashlib.sha256).hexdigest()[:32]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def decrypt_text(enc_key: bytes, envelope: bytes) -> str:
|
|
77
|
+
data = bytes(envelope)
|
|
78
|
+
header = len(MAGIC) + 1
|
|
79
|
+
if len(data) <= header + 12 + 16:
|
|
80
|
+
raise ValueError("encrypted object is too short")
|
|
81
|
+
if data[: len(MAGIC)] != MAGIC:
|
|
82
|
+
raise ValueError("bad envelope magic")
|
|
83
|
+
if data[len(MAGIC)] != VERSION:
|
|
84
|
+
raise ValueError("unsupported envelope version")
|
|
85
|
+
nonce = data[header : header + 12]
|
|
86
|
+
ciphertext = data[header + 12 :]
|
|
87
|
+
plaintext = AESGCM(enc_key).decrypt(nonce, ciphertext, data[:header])
|
|
88
|
+
return plaintext.decode("utf-8")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def encrypt_text(enc_key: bytes, text: str) -> bytes:
|
|
92
|
+
"""Encrypt helper (tests/backup tooling); production only decrypts."""
|
|
93
|
+
nonce = os.urandom(12)
|
|
94
|
+
header = MAGIC + bytes([VERSION])
|
|
95
|
+
ciphertext = AESGCM(enc_key).encrypt(nonce, text.encode("utf-8"), header)
|
|
96
|
+
return header + nonce + ciphertext
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _media_aad(relative_path: str) -> bytes:
|
|
100
|
+
return MEDIA_MAGIC + bytes([MEDIA_VERSION]) + str(relative_path).encode("utf-8")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def decrypt_media(media_key: bytes, relative_path: str, envelope: bytes) -> bytes:
|
|
104
|
+
data = bytes(envelope)
|
|
105
|
+
header = len(MEDIA_MAGIC) + 1
|
|
106
|
+
if len(data) <= header + 12 + 16:
|
|
107
|
+
raise ValueError("media envelope is too short")
|
|
108
|
+
if data[: len(MEDIA_MAGIC)] != MEDIA_MAGIC:
|
|
109
|
+
raise ValueError("bad media envelope magic")
|
|
110
|
+
if data[len(MEDIA_MAGIC)] != MEDIA_VERSION:
|
|
111
|
+
raise ValueError("unsupported media envelope version")
|
|
112
|
+
nonce = data[header : header + 12]
|
|
113
|
+
ciphertext = data[header + 12 :]
|
|
114
|
+
return AESGCM(media_key).decrypt(nonce, ciphertext, _media_aad(relative_path))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def encrypt_media(
|
|
118
|
+
media_key: bytes, relative_path: str, payload: bytes, nonce: bytes | None = None
|
|
119
|
+
) -> bytes:
|
|
120
|
+
"""Encrypt helper (tests/backup tooling); production only decrypts."""
|
|
121
|
+
nonce = nonce or os.urandom(12)
|
|
122
|
+
if len(nonce) != 12:
|
|
123
|
+
raise ValueError("nonce must be 12 bytes")
|
|
124
|
+
header = MEDIA_MAGIC + bytes([MEDIA_VERSION])
|
|
125
|
+
ciphertext = AESGCM(media_key).encrypt(nonce, bytes(payload), _media_aad(relative_path))
|
|
126
|
+
return header + nonce + ciphertext
|
waidrop_cli/dropbox.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Minimal Dropbox API client (stdlib urllib)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
|
|
11
|
+
from .auth import (
|
|
12
|
+
EXPIRY_SKEW_SECONDS,
|
|
13
|
+
load_credentials,
|
|
14
|
+
parse_token_document,
|
|
15
|
+
refresh_access_token,
|
|
16
|
+
save_credentials,
|
|
17
|
+
)
|
|
18
|
+
from .config import DropboxConfig
|
|
19
|
+
from .errors import RemoteError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DropboxClient:
|
|
23
|
+
def __init__(self, config: DropboxConfig):
|
|
24
|
+
self.config = config
|
|
25
|
+
self._access_token = config.token
|
|
26
|
+
self._expires_at: float | None = config.token_expires_at
|
|
27
|
+
|
|
28
|
+
# --- auth ---------------------------------------------------------------
|
|
29
|
+
def _refresh(self) -> None:
|
|
30
|
+
if not (self.config.refresh_token and self.config.app_key):
|
|
31
|
+
raise RemoteError(
|
|
32
|
+
"Dropbox access token expired and no refresh credentials are configured; "
|
|
33
|
+
"run `waidrop auth` to connect"
|
|
34
|
+
)
|
|
35
|
+
document = refresh_access_token(
|
|
36
|
+
app_key=self.config.app_key,
|
|
37
|
+
refresh_token=self.config.refresh_token,
|
|
38
|
+
token_url=self.config.token_url,
|
|
39
|
+
client_secret=self.config.app_secret or None,
|
|
40
|
+
timeout=self.config.timeout,
|
|
41
|
+
)
|
|
42
|
+
parsed = parse_token_document(document)
|
|
43
|
+
self._access_token = parsed["access_token"]
|
|
44
|
+
self._expires_at = parsed["expires_at"]
|
|
45
|
+
self._persist(parsed)
|
|
46
|
+
|
|
47
|
+
def _persist(self, parsed: dict) -> None:
|
|
48
|
+
"""Write a refreshed access token back to the credentials file (if any)."""
|
|
49
|
+
if not (self.config.from_file and self.config.credentials_path):
|
|
50
|
+
return
|
|
51
|
+
creds = load_credentials(self.config.credentials_path)
|
|
52
|
+
if creds is None:
|
|
53
|
+
return
|
|
54
|
+
creds.access_token = parsed["access_token"]
|
|
55
|
+
creds.expires_at = parsed["expires_at"]
|
|
56
|
+
if parsed.get("refresh_token"):
|
|
57
|
+
creds.refresh_token = parsed["refresh_token"]
|
|
58
|
+
save_credentials(self.config.credentials_path, creds)
|
|
59
|
+
|
|
60
|
+
def _token(self) -> str:
|
|
61
|
+
expired = (
|
|
62
|
+
self._expires_at is not None and time.time() >= self._expires_at - EXPIRY_SKEW_SECONDS
|
|
63
|
+
)
|
|
64
|
+
if self._access_token and not expired:
|
|
65
|
+
return self._access_token
|
|
66
|
+
if self.config.refresh_token and self.config.app_key:
|
|
67
|
+
self._refresh()
|
|
68
|
+
return self._access_token or ""
|
|
69
|
+
if self._access_token:
|
|
70
|
+
return self._access_token
|
|
71
|
+
raise RemoteError("no Dropbox access token available")
|
|
72
|
+
|
|
73
|
+
# --- transport ----------------------------------------------------------
|
|
74
|
+
def _send(self, url: str, data: bytes, extra_headers: dict, retry: bool = True):
|
|
75
|
+
request = urllib.request.Request(
|
|
76
|
+
url,
|
|
77
|
+
data=data,
|
|
78
|
+
method="POST",
|
|
79
|
+
headers={"authorization": f"Bearer {self._token()}", **extra_headers},
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
with urllib.request.urlopen(request, timeout=self.config.timeout) as response:
|
|
83
|
+
return response.read(), None
|
|
84
|
+
except urllib.error.HTTPError as exc:
|
|
85
|
+
if exc.code == 401 and retry and self.config.refresh_token and self.config.app_key:
|
|
86
|
+
detail = _body(exc)
|
|
87
|
+
if "missing_scope" not in detail:
|
|
88
|
+
self._refresh()
|
|
89
|
+
return self._send(url, data, extra_headers, retry=False)
|
|
90
|
+
return None, exc
|
|
91
|
+
except urllib.error.URLError as exc:
|
|
92
|
+
raise RemoteError(f"cannot reach Dropbox: {exc.reason}") from exc
|
|
93
|
+
|
|
94
|
+
def _rpc(self, path: str, payload: dict, allow_not_found: bool = False):
|
|
95
|
+
data, error = self._send(
|
|
96
|
+
self.config.api_base + path,
|
|
97
|
+
json.dumps(payload).encode("utf-8"),
|
|
98
|
+
{"content-type": "application/json"},
|
|
99
|
+
)
|
|
100
|
+
if error is None:
|
|
101
|
+
try:
|
|
102
|
+
return json.loads(data.decode("utf-8"))
|
|
103
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
104
|
+
raise RemoteError("Dropbox returned an invalid JSON response") from exc
|
|
105
|
+
detail = _body(error)
|
|
106
|
+
if error.code == 401:
|
|
107
|
+
raise RemoteError(
|
|
108
|
+
_scope_guidance(detail) or f"Dropbox authorization failed: HTTP 401 {detail}"
|
|
109
|
+
)
|
|
110
|
+
if error.code == 409 and allow_not_found and "not_found" in detail:
|
|
111
|
+
return None
|
|
112
|
+
if error.code == 429:
|
|
113
|
+
retry_after = error.headers.get("retry-after") if error.headers else None
|
|
114
|
+
raise RemoteError(f"Dropbox rate limit (retry after {retry_after or '?'}s)")
|
|
115
|
+
raise RemoteError(f"Dropbox API error HTTP {error.code}: {detail}")
|
|
116
|
+
|
|
117
|
+
# --- account / lifecycle ------------------------------------------------
|
|
118
|
+
def ensure_access_token(self) -> str:
|
|
119
|
+
return self._token()
|
|
120
|
+
|
|
121
|
+
def get_current_account(self) -> dict:
|
|
122
|
+
data, error = self._send(
|
|
123
|
+
self.config.api_base + "/users/get_current_account",
|
|
124
|
+
b"null",
|
|
125
|
+
{"content-type": "application/json"},
|
|
126
|
+
)
|
|
127
|
+
if error is None:
|
|
128
|
+
try:
|
|
129
|
+
return json.loads(data.decode("utf-8"))
|
|
130
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
131
|
+
raise RemoteError("Dropbox returned an invalid JSON response") from exc
|
|
132
|
+
detail = _body(error)
|
|
133
|
+
if error.code == 401:
|
|
134
|
+
raise RemoteError(
|
|
135
|
+
_scope_guidance(detail) or f"Dropbox authorization failed: HTTP 401 {detail}"
|
|
136
|
+
)
|
|
137
|
+
raise RemoteError(f"Dropbox account lookup failed: HTTP {error.code} {detail}")
|
|
138
|
+
|
|
139
|
+
def revoke(self) -> bool:
|
|
140
|
+
"""Revoke the current access token (and its refresh token). Returns True on success."""
|
|
141
|
+
token = self._token()
|
|
142
|
+
request = urllib.request.Request(
|
|
143
|
+
self.config.revoke_url,
|
|
144
|
+
method="POST",
|
|
145
|
+
headers={"authorization": f"Bearer {token}"},
|
|
146
|
+
)
|
|
147
|
+
try:
|
|
148
|
+
with urllib.request.urlopen(request, timeout=self.config.timeout):
|
|
149
|
+
return True
|
|
150
|
+
except urllib.error.HTTPError as exc:
|
|
151
|
+
if exc.code in (400, 401):
|
|
152
|
+
return False
|
|
153
|
+
raise RemoteError(f"Dropbox token revoke failed: HTTP {exc.code} {_body(exc)}") from exc
|
|
154
|
+
except urllib.error.URLError as exc:
|
|
155
|
+
raise RemoteError(f"cannot reach Dropbox: {exc.reason}") from exc
|
|
156
|
+
|
|
157
|
+
# --- endpoints ----------------------------------------------------------
|
|
158
|
+
def list_folder(self, path: str, missing_ok: bool = True) -> list[dict]:
|
|
159
|
+
document = self._rpc(
|
|
160
|
+
"/files/list_folder", {"path": path, "recursive": False}, allow_not_found=missing_ok
|
|
161
|
+
)
|
|
162
|
+
if document is None:
|
|
163
|
+
return []
|
|
164
|
+
entries = list(document.get("entries") or [])
|
|
165
|
+
while document.get("has_more"):
|
|
166
|
+
document = self._rpc("/files/list_folder/continue", {"cursor": document.get("cursor")})
|
|
167
|
+
entries.extend(document.get("entries") or [])
|
|
168
|
+
return entries
|
|
169
|
+
|
|
170
|
+
def download(self, path: str) -> bytes | None:
|
|
171
|
+
data, error = self._send(
|
|
172
|
+
self.config.content_base + "/files/download",
|
|
173
|
+
b"",
|
|
174
|
+
{"dropbox-api-arg": json.dumps({"path": path})},
|
|
175
|
+
)
|
|
176
|
+
if error is None:
|
|
177
|
+
return data
|
|
178
|
+
detail = _body(error)
|
|
179
|
+
if error.code == 409 and "not_found" in detail:
|
|
180
|
+
return None
|
|
181
|
+
if error.code == 401:
|
|
182
|
+
raise RemoteError(
|
|
183
|
+
_scope_guidance(detail) or f"Dropbox authorization failed: HTTP 401 {detail}"
|
|
184
|
+
)
|
|
185
|
+
raise RemoteError(f"Dropbox download failed: HTTP {error.code} {detail}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _body(exc: urllib.error.HTTPError) -> str:
|
|
189
|
+
cached = getattr(exc, "_cached_body", None)
|
|
190
|
+
if cached is not None:
|
|
191
|
+
return cached
|
|
192
|
+
try:
|
|
193
|
+
detail = exc.read().decode("utf-8", errors="replace")[:300]
|
|
194
|
+
except Exception:
|
|
195
|
+
detail = ""
|
|
196
|
+
exc._cached_body = detail
|
|
197
|
+
return detail
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _scope_guidance(detail: str) -> str | None:
|
|
201
|
+
try:
|
|
202
|
+
document = json.loads(detail)
|
|
203
|
+
except json.JSONDecodeError:
|
|
204
|
+
return None
|
|
205
|
+
error = document.get("error")
|
|
206
|
+
summary = str(document.get("error_summary") or "")
|
|
207
|
+
if isinstance(error, dict) and (
|
|
208
|
+
error.get(".tag") == "missing_scope" or "missing_scope" in summary
|
|
209
|
+
):
|
|
210
|
+
scope = error.get("required_scope")
|
|
211
|
+
if isinstance(scope, list):
|
|
212
|
+
scope = ", ".join(str(item) for item in scope)
|
|
213
|
+
return (
|
|
214
|
+
f"Dropbox token is missing required scope {scope or 'unknown'}; "
|
|
215
|
+
"enable it in the Dropbox app console and re-authorize"
|
|
216
|
+
)
|
|
217
|
+
return None
|
waidrop_cli/env_file.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Private environment files for archive credentials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import SimpleNamespace
|
|
10
|
+
|
|
11
|
+
from .auth import resolve_credentials_path
|
|
12
|
+
from .errors import UsageError
|
|
13
|
+
|
|
14
|
+
ENV_KEY_PREFIX = "WAIDROP_"
|
|
15
|
+
MANAGED_KEYS = ("WAIDROP_KEY", "WAIDROP_ACCOUNT")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def default_env_path(args=None) -> Path:
|
|
19
|
+
credentials = resolve_credentials_path(args or SimpleNamespace(credentials_file=None))
|
|
20
|
+
return credentials.parent / "env"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_env_text(text: str) -> dict[str, str]:
|
|
24
|
+
values: dict[str, str] = {}
|
|
25
|
+
for raw_line in text.splitlines():
|
|
26
|
+
line = raw_line.strip()
|
|
27
|
+
if not line or line.startswith("#"):
|
|
28
|
+
continue
|
|
29
|
+
if line.startswith("export "):
|
|
30
|
+
line = line[7:].lstrip()
|
|
31
|
+
key, separator, value = line.partition("=")
|
|
32
|
+
key = key.strip()
|
|
33
|
+
if not separator or not key or not key.startswith(ENV_KEY_PREFIX):
|
|
34
|
+
continue
|
|
35
|
+
value = value.strip()
|
|
36
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
37
|
+
value = value[1:-1]
|
|
38
|
+
values[key] = value
|
|
39
|
+
return values
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_env_file(path: Path) -> dict[str, str]:
|
|
43
|
+
path = Path(path).expanduser()
|
|
44
|
+
try:
|
|
45
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
46
|
+
except FileNotFoundError:
|
|
47
|
+
return {}
|
|
48
|
+
except OSError:
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
if os.name != "nt":
|
|
52
|
+
try:
|
|
53
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
54
|
+
except OSError:
|
|
55
|
+
return {}
|
|
56
|
+
if mode & 0o077:
|
|
57
|
+
raise UsageError(
|
|
58
|
+
f"env file {path} must not be readable or writable by group/others; "
|
|
59
|
+
f"run `chmod 600 {path}`"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return parse_env_text(text)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def read_env_file_unchecked(path: Path) -> dict[str, str]:
|
|
66
|
+
"""Read an env file without enforcing permissions (used before repairing it)."""
|
|
67
|
+
path = Path(path).expanduser()
|
|
68
|
+
try:
|
|
69
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
return {}
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
raise UsageError(f"could not read {path}: {exc}") from exc
|
|
74
|
+
return parse_env_text(text)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def write_env_file(path: Path, *, key: str, account: str) -> None:
|
|
78
|
+
path = Path(path).expanduser()
|
|
79
|
+
existing = _read_text_unchecked(path)
|
|
80
|
+
lines = _strip_managed_keys(existing)
|
|
81
|
+
if not lines:
|
|
82
|
+
lines.append("# WAiDrop CLI environment; keep this file private (chmod 600).")
|
|
83
|
+
elif lines[-1].strip():
|
|
84
|
+
lines.append("")
|
|
85
|
+
lines.append(f"WAIDROP_KEY={key}")
|
|
86
|
+
lines.append(f"WAIDROP_ACCOUNT={account}")
|
|
87
|
+
_atomic_write_private(path, "\n".join(lines).rstrip("\n") + "\n")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _read_text_unchecked(path: Path) -> str:
|
|
91
|
+
try:
|
|
92
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
93
|
+
except FileNotFoundError:
|
|
94
|
+
return ""
|
|
95
|
+
except OSError as exc:
|
|
96
|
+
raise UsageError(f"could not read {path}: {exc}") from exc
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _strip_managed_keys(text: str) -> list[str]:
|
|
100
|
+
lines: list[str] = []
|
|
101
|
+
for raw_line in text.splitlines():
|
|
102
|
+
parsed = parse_env_text(raw_line)
|
|
103
|
+
if any(name in parsed for name in MANAGED_KEYS):
|
|
104
|
+
continue
|
|
105
|
+
lines.append(raw_line)
|
|
106
|
+
while lines and not lines[-1].strip():
|
|
107
|
+
lines.pop()
|
|
108
|
+
return lines
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _atomic_write_private(path: Path, text: str) -> None:
|
|
112
|
+
parent = path.parent
|
|
113
|
+
existed = parent.exists()
|
|
114
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
if os.name != "nt" and (not existed or parent == default_env_path().parent):
|
|
116
|
+
try:
|
|
117
|
+
os.chmod(parent, 0o700)
|
|
118
|
+
except OSError:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
fd, tmp = tempfile.mkstemp(dir=str(parent), prefix=".env-", suffix=".tmp")
|
|
122
|
+
try:
|
|
123
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
124
|
+
handle.write(text)
|
|
125
|
+
os.chmod(tmp, 0o600)
|
|
126
|
+
os.replace(tmp, path)
|
|
127
|
+
except BaseException:
|
|
128
|
+
try:
|
|
129
|
+
os.unlink(tmp)
|
|
130
|
+
except OSError:
|
|
131
|
+
pass
|
|
132
|
+
raise
|
waidrop_cli/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Error types with stable CLI exit codes."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CliError(Exception):
|
|
5
|
+
exit_code = 1
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UsageError(CliError):
|
|
9
|
+
exit_code = 2
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NoMatchesError(CliError):
|
|
13
|
+
exit_code = 3
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GuardError(CliError):
|
|
17
|
+
exit_code = 4
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RemoteError(CliError):
|
|
21
|
+
exit_code = 5
|