obsideo-drive 0.2.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.
- obsideo/__init__.py +1 -0
- obsideo/__main__.py +4 -0
- obsideo/cli.py +568 -0
- obsideo/manifest.py +59 -0
- obsideo/sync.py +122 -0
- obsideo_core/__init__.py +4 -0
- obsideo_core/config.py +120 -0
- obsideo_core/crypto.py +50 -0
- obsideo_core/identity.py +36 -0
- obsideo_core/login.py +58 -0
- obsideo_core/names.py +65 -0
- obsideo_core/storage.py +265 -0
- obsideo_drive-0.2.0.dist-info/METADATA +95 -0
- obsideo_drive-0.2.0.dist-info/RECORD +17 -0
- obsideo_drive-0.2.0.dist-info/WHEEL +5 -0
- obsideo_drive-0.2.0.dist-info/entry_points.txt +3 -0
- obsideo_drive-0.2.0.dist-info/top_level.txt +2 -0
obsideo/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The general Obsideo CLI front-end (REPL + browse + sync) on obsideo_core."""
|
obsideo/__main__.py
ADDED
obsideo/cli.py
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""obsideo - the general Obsideo CLI.
|
|
2
|
+
|
|
3
|
+
Save, browse, and sync whatever you want - encrypted on your machine before it
|
|
4
|
+
leaves, so Obsideo can't read it. An interactive shell plus one-shot commands.
|
|
5
|
+
|
|
6
|
+
obsideo login sign up / log in (email -> 3 GB free)
|
|
7
|
+
obsideo start the interactive shell
|
|
8
|
+
obsideo ls / put / get ... run a single command
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import cmd
|
|
12
|
+
import os
|
|
13
|
+
import shlex
|
|
14
|
+
import sys
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from obsideo_core import config, crypto, identity, login, storage
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _unquote(s: str) -> str:
|
|
24
|
+
"""Strip one layer of matching surrounding quotes."""
|
|
25
|
+
s = s.strip()
|
|
26
|
+
if len(s) >= 2 and s[0] in "\"'" and s[-1] == s[0]:
|
|
27
|
+
return s[1:-1]
|
|
28
|
+
return s
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _tokens(arg: str) -> list[str]:
|
|
32
|
+
"""Tokenize a command line respecting quotes, Windows-path-safe (backslashes
|
|
33
|
+
are not escape characters). 'put "C:\\a b\\f.png" name' -> ['C:\\a b\\f.png','name']."""
|
|
34
|
+
try:
|
|
35
|
+
toks = shlex.split(arg, posix=False)
|
|
36
|
+
except ValueError:
|
|
37
|
+
toks = arg.split()
|
|
38
|
+
return [_unquote(t) for t in toks]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _human(n: int | None) -> str:
|
|
42
|
+
if n is None:
|
|
43
|
+
return "?"
|
|
44
|
+
f = float(n)
|
|
45
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
46
|
+
if f < 1024 or unit == "TB":
|
|
47
|
+
return f"{f:.0f} {unit}" if unit == "B" else f"{f:.1f} {unit}"
|
|
48
|
+
f /= 1024
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Operator notices (server-driven broadcasts) ──────────────────────────────
|
|
52
|
+
|
|
53
|
+
_SEEN_FILE = config.CONFIG_DIR / "seen_notices"
|
|
54
|
+
_SEV = { # severity -> (marker, ansi); ansi only emitted on a TTY
|
|
55
|
+
"info": ("·", "\033[36m"), # cyan
|
|
56
|
+
"action": ("!", "\033[33m"), # yellow
|
|
57
|
+
"urgent": ("!!", "\033[1;31m"), # bold red
|
|
58
|
+
}
|
|
59
|
+
_RESET = "\033[0m"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _load_seen() -> set:
|
|
63
|
+
try:
|
|
64
|
+
return set(_SEEN_FILE.read_text().split())
|
|
65
|
+
except OSError:
|
|
66
|
+
return set()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _mark_seen(ids: list) -> None:
|
|
70
|
+
if not ids:
|
|
71
|
+
return
|
|
72
|
+
try:
|
|
73
|
+
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
with open(_SEEN_FILE, "a") as f:
|
|
75
|
+
f.write("\n".join(ids) + "\n")
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def show_notices() -> None:
|
|
81
|
+
"""Print any unseen operator broadcasts to stderr, once each. Strictly
|
|
82
|
+
best-effort: only on an interactive TTY, never touches stdout, and swallows
|
|
83
|
+
every error so it can never break or slow a real command in a script."""
|
|
84
|
+
if not sys.stdout.isatty() or os.environ.get("OBSIDEO_NO_NOTICES"):
|
|
85
|
+
return
|
|
86
|
+
try:
|
|
87
|
+
req = urllib.request.Request(
|
|
88
|
+
f"{config.signup_url()}/v1/notices",
|
|
89
|
+
headers={"User-Agent": config.USER_AGENT},
|
|
90
|
+
)
|
|
91
|
+
with urllib.request.urlopen(req, timeout=4) as resp:
|
|
92
|
+
notices = json.loads(resp.read().decode()).get("notices", [])
|
|
93
|
+
except Exception:
|
|
94
|
+
return
|
|
95
|
+
if not notices:
|
|
96
|
+
return
|
|
97
|
+
seen = _load_seen()
|
|
98
|
+
shown = []
|
|
99
|
+
for n in notices:
|
|
100
|
+
nid = str(n.get("id"))
|
|
101
|
+
if nid in seen:
|
|
102
|
+
continue
|
|
103
|
+
marker, color = _SEV.get((n.get("severity") or "info").lower(), _SEV["info"])
|
|
104
|
+
print(f"{color}{marker} Obsideo:{_RESET} {n.get('body', '').strip()}", file=sys.stderr)
|
|
105
|
+
shown.append(nid)
|
|
106
|
+
_mark_seen(shown)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ── Operator tooling: broadcast a message to all users ────────────────────────
|
|
110
|
+
|
|
111
|
+
def run_admin(argv: list) -> int:
|
|
112
|
+
"""`obsideo admin broadcast [--severity info|action|urgent] [--ttl SECONDS] "message"`
|
|
113
|
+
Authors a notice all users will see in their CLI. Requires the coord admin
|
|
114
|
+
secret in OBSIDEO_ADMIN_SECRET (operator-only; never shipped or stored)."""
|
|
115
|
+
if not argv or argv[0] != "broadcast":
|
|
116
|
+
print('Usage: obsideo admin broadcast [--severity info|action|urgent] '
|
|
117
|
+
'[--ttl SECONDS] "message"', file=sys.stderr)
|
|
118
|
+
return 2
|
|
119
|
+
severity, ttl, words, rest = "info", None, [], argv[1:]
|
|
120
|
+
i = 0
|
|
121
|
+
while i < len(rest):
|
|
122
|
+
if rest[i] == "--severity" and i + 1 < len(rest):
|
|
123
|
+
severity, i = rest[i + 1], i + 2
|
|
124
|
+
elif rest[i] == "--ttl" and i + 1 < len(rest):
|
|
125
|
+
ttl, i = rest[i + 1], i + 2
|
|
126
|
+
else:
|
|
127
|
+
words.append(rest[i]); i += 1
|
|
128
|
+
body = " ".join(words).strip()
|
|
129
|
+
if not body:
|
|
130
|
+
print("A message body is required.", file=sys.stderr)
|
|
131
|
+
return 2
|
|
132
|
+
secret = os.environ.get("OBSIDEO_ADMIN_SECRET", "").strip()
|
|
133
|
+
if not secret:
|
|
134
|
+
print("Set OBSIDEO_ADMIN_SECRET (the coord admin secret) to broadcast.", file=sys.stderr)
|
|
135
|
+
return 2
|
|
136
|
+
payload = {"body": body, "severity": severity}
|
|
137
|
+
if ttl is not None:
|
|
138
|
+
try:
|
|
139
|
+
payload["ttl_seconds"] = int(ttl)
|
|
140
|
+
except ValueError:
|
|
141
|
+
print("--ttl must be an integer number of seconds.", file=sys.stderr)
|
|
142
|
+
return 2
|
|
143
|
+
req = urllib.request.Request(
|
|
144
|
+
f"{config.signup_url()}/internal/messages",
|
|
145
|
+
data=json.dumps(payload).encode(),
|
|
146
|
+
headers={"Content-Type": "application/json", "User-Agent": config.USER_AGENT,
|
|
147
|
+
"X-Admin-Secret": secret},
|
|
148
|
+
method="POST",
|
|
149
|
+
)
|
|
150
|
+
try:
|
|
151
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
152
|
+
out = json.loads(resp.read().decode())
|
|
153
|
+
except urllib.error.HTTPError as e:
|
|
154
|
+
detail = e.read().decode()[:200]
|
|
155
|
+
print(f"Broadcast failed: HTTP {e.code} {detail}", file=sys.stderr)
|
|
156
|
+
return 1
|
|
157
|
+
except urllib.error.URLError as e:
|
|
158
|
+
print(f"Broadcast failed: {e.reason}", file=sys.stderr)
|
|
159
|
+
return 1
|
|
160
|
+
print(f"Broadcast sent (id {out.get('id')}, severity {severity}).")
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def run_login(url: str | None = None) -> bool:
|
|
165
|
+
"""Interactive email-OTP login. Returns True on success."""
|
|
166
|
+
url = url or config.signup_url()
|
|
167
|
+
email = input("Enter your email: ").strip()
|
|
168
|
+
if not email:
|
|
169
|
+
print("Email is required.")
|
|
170
|
+
return False
|
|
171
|
+
print("Sending a verification code...", end="", flush=True)
|
|
172
|
+
try:
|
|
173
|
+
login.start(email, url)
|
|
174
|
+
except login.LoginError as e:
|
|
175
|
+
print(f"\nCouldn't start signup: {e}")
|
|
176
|
+
return False
|
|
177
|
+
print(" sent.")
|
|
178
|
+
print(f"Check {email} for a verification code (it may be in spam).")
|
|
179
|
+
code = input("Enter verification code: ").strip()
|
|
180
|
+
print("Verifying + provisioning storage...", end="", flush=True)
|
|
181
|
+
try:
|
|
182
|
+
creds = login.verify(email, code, url)
|
|
183
|
+
except login.LoginError as e:
|
|
184
|
+
print(f"\nVerification failed: {e}")
|
|
185
|
+
return False
|
|
186
|
+
print(" done.")
|
|
187
|
+
storage.reset_client()
|
|
188
|
+
# Make sure the data key exists + nudge the user to back it up.
|
|
189
|
+
crypto.data_key()
|
|
190
|
+
print(f"\nYou're all set. {creds.get('quota_gb', 3)} GB free.")
|
|
191
|
+
if not creds.get("gateway_registered", True):
|
|
192
|
+
print("Note: storage activation is finishing rollout; if an upload fails, retry shortly.")
|
|
193
|
+
print("Your files are encrypted with a local key. Back it up:")
|
|
194
|
+
print(f" {crypto.DATA_KEY_FILE}")
|
|
195
|
+
print("Type 'obsideo' to open the shell, or 'obsideo put <file>' to store something.")
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class ObsideoShell(cmd.Cmd):
|
|
200
|
+
intro = ("\n Obsideo - encrypted storage we can't read.\n"
|
|
201
|
+
" Type 'help' for commands, 'exit' to quit.\n")
|
|
202
|
+
prompt = "obsideo:/ "
|
|
203
|
+
|
|
204
|
+
def __init__(self):
|
|
205
|
+
super().__init__()
|
|
206
|
+
self._cwd = "" # S3 key prefix; "" = root
|
|
207
|
+
self._refresh_prompt()
|
|
208
|
+
|
|
209
|
+
# ── path helpers ────────────────────────────────────────────────────────
|
|
210
|
+
def _refresh_prompt(self):
|
|
211
|
+
self.prompt = f"obsideo:/{self._cwd} "
|
|
212
|
+
|
|
213
|
+
def _resolve(self, name: str) -> str:
|
|
214
|
+
if name.startswith("/"):
|
|
215
|
+
return name.lstrip("/")
|
|
216
|
+
return f"{self._cwd}{name}"
|
|
217
|
+
|
|
218
|
+
def _require_login(self) -> bool:
|
|
219
|
+
if not config.is_logged_in():
|
|
220
|
+
print("You're not logged in. Run 'login' (or 'obsideo login').")
|
|
221
|
+
return False
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
# ── login ───────────────────────────────────────────────────────────────
|
|
225
|
+
def do_login(self, arg):
|
|
226
|
+
"""Sign up / log in with your email (email -> 3 GB free)."""
|
|
227
|
+
run_login()
|
|
228
|
+
self._cwd = ""
|
|
229
|
+
self._refresh_prompt()
|
|
230
|
+
|
|
231
|
+
# ── ls ──────────────────────────────────────────────────────────────────
|
|
232
|
+
def do_ls(self, arg):
|
|
233
|
+
"""List files and folders. Usage: ls [path]"""
|
|
234
|
+
if not self._require_login():
|
|
235
|
+
return
|
|
236
|
+
target = _unquote(arg.strip())
|
|
237
|
+
prefix = self._resolve(target) if target else self._cwd
|
|
238
|
+
try:
|
|
239
|
+
resp = storage.list_prefix(prefix)
|
|
240
|
+
except Exception as e:
|
|
241
|
+
print(f"Error: {e}")
|
|
242
|
+
return
|
|
243
|
+
for d in resp["folders"]:
|
|
244
|
+
print(f" [dir] {d}/")
|
|
245
|
+
for f in resp["files"]:
|
|
246
|
+
print(f" [file] {f['name']} {_human(f['size'])}")
|
|
247
|
+
if not resp["folders"] and not resp["files"]:
|
|
248
|
+
print(" (empty)")
|
|
249
|
+
|
|
250
|
+
# ── cd / pwd ──────────────────────────────────────────────────────────────
|
|
251
|
+
def do_cd(self, arg):
|
|
252
|
+
"""Change directory. Usage: cd <path> | cd .. | cd /"""
|
|
253
|
+
path = _unquote(arg.strip())
|
|
254
|
+
if not path or path == "/":
|
|
255
|
+
self._cwd = ""
|
|
256
|
+
elif path == "..":
|
|
257
|
+
trimmed = self._cwd.rstrip("/")
|
|
258
|
+
self._cwd = trimmed[:trimmed.rfind("/") + 1] if "/" in trimmed else ""
|
|
259
|
+
elif path.startswith("/"):
|
|
260
|
+
self._cwd = path.lstrip("/")
|
|
261
|
+
if self._cwd and not self._cwd.endswith("/"):
|
|
262
|
+
self._cwd += "/"
|
|
263
|
+
else:
|
|
264
|
+
self._cwd = f"{self._cwd}{path}"
|
|
265
|
+
if not self._cwd.endswith("/"):
|
|
266
|
+
self._cwd += "/"
|
|
267
|
+
self._refresh_prompt()
|
|
268
|
+
print(f" /{self._cwd}")
|
|
269
|
+
|
|
270
|
+
def do_pwd(self, arg):
|
|
271
|
+
"""Print current directory."""
|
|
272
|
+
print(f" /{self._cwd}")
|
|
273
|
+
|
|
274
|
+
# ── put / upload ──────────────────────────────────────────────────────────
|
|
275
|
+
def do_put(self, arg):
|
|
276
|
+
"""Upload a file, or a whole folder recursively.
|
|
277
|
+
|
|
278
|
+
Each file is encrypted on your machine (AES-256-GCM) before upload, so
|
|
279
|
+
Obsideo only ever stores ciphertext. A folder uploads all of its files,
|
|
280
|
+
preserving structure under <name>/.
|
|
281
|
+
|
|
282
|
+
Usage:
|
|
283
|
+
put <local_path> [remote_name] [--no-encrypt]
|
|
284
|
+
|
|
285
|
+
Examples:
|
|
286
|
+
put report.pdf store as report.pdf
|
|
287
|
+
put report.pdf q3.pdf store under a different name
|
|
288
|
+
put ./photos upload the whole folder -> photos/...
|
|
289
|
+
put notes.txt --no-encrypt upload as-is (NOT encrypted)
|
|
290
|
+
"""
|
|
291
|
+
if not self._require_login():
|
|
292
|
+
return
|
|
293
|
+
parts = _tokens(arg)
|
|
294
|
+
if not parts:
|
|
295
|
+
print("Usage: put <local_path> [remote_name] [--no-encrypt]")
|
|
296
|
+
return
|
|
297
|
+
no_encrypt = "--no-encrypt" in parts
|
|
298
|
+
parts = [p for p in parts if p != "--no-encrypt"]
|
|
299
|
+
local = Path(parts[0]).expanduser()
|
|
300
|
+
if not local.exists():
|
|
301
|
+
print(f"Not found: {local}")
|
|
302
|
+
return
|
|
303
|
+
base = parts[1] if len(parts) > 1 else local.name
|
|
304
|
+
do_encrypt = config.load_config().get("encrypt", True) and not no_encrypt
|
|
305
|
+
|
|
306
|
+
if local.is_dir():
|
|
307
|
+
self._put_folder(local, base, do_encrypt)
|
|
308
|
+
else:
|
|
309
|
+
self._put_file(local, self._resolve(base), do_encrypt)
|
|
310
|
+
|
|
311
|
+
do_upload = do_put
|
|
312
|
+
|
|
313
|
+
def _put_file(self, local: Path, key: str, do_encrypt: bool):
|
|
314
|
+
try:
|
|
315
|
+
raw = local.read_bytes()
|
|
316
|
+
except OSError as e:
|
|
317
|
+
print(f" Error reading {local}: {e}")
|
|
318
|
+
return
|
|
319
|
+
body = crypto.encrypt(raw) if do_encrypt else raw
|
|
320
|
+
verb = "Encrypting + uploading" if do_encrypt else "Uploading"
|
|
321
|
+
print(f" {verb} {key.rsplit('/', 1)[-1]} ({_human(len(raw))})...")
|
|
322
|
+
try:
|
|
323
|
+
storage.put(key, body)
|
|
324
|
+
print(f" Stored: /{key}")
|
|
325
|
+
except Exception as e:
|
|
326
|
+
print(f" Error: {e}")
|
|
327
|
+
|
|
328
|
+
def _put_folder(self, folder: Path, base: str, do_encrypt: bool):
|
|
329
|
+
files = [f for f in sorted(folder.rglob("*")) if f.is_file()]
|
|
330
|
+
if not files:
|
|
331
|
+
print(f" (empty folder: {folder})")
|
|
332
|
+
return
|
|
333
|
+
verb = "Encrypting + uploading" if do_encrypt else "Uploading"
|
|
334
|
+
print(f" {verb} folder {base}/ ({len(files)} file(s))...")
|
|
335
|
+
ok = 0
|
|
336
|
+
for f in files:
|
|
337
|
+
rel = f.relative_to(folder).as_posix()
|
|
338
|
+
key = self._resolve(f"{base}/{rel}")
|
|
339
|
+
try:
|
|
340
|
+
raw = f.read_bytes()
|
|
341
|
+
body = crypto.encrypt(raw) if do_encrypt else raw
|
|
342
|
+
storage.put(key, body)
|
|
343
|
+
ok += 1
|
|
344
|
+
print(f" {rel} ({_human(len(raw))})")
|
|
345
|
+
except Exception as e:
|
|
346
|
+
print(f" {rel} - FAILED: {e}")
|
|
347
|
+
print(f" Stored {ok}/{len(files)} file(s) under /{self._resolve(base)}/")
|
|
348
|
+
|
|
349
|
+
# ── get / download ────────────────────────────────────────────────────────
|
|
350
|
+
def do_get(self, arg):
|
|
351
|
+
"""Download a file. Usage: get <remote_file> [local_path]"""
|
|
352
|
+
if not self._require_login():
|
|
353
|
+
return
|
|
354
|
+
parts = _tokens(arg)
|
|
355
|
+
if not parts:
|
|
356
|
+
print("Usage: get <remote_file> [local_path]")
|
|
357
|
+
return
|
|
358
|
+
key = self._resolve(parts[0])
|
|
359
|
+
local = Path(parts[1]).expanduser() if len(parts) > 1 else Path(Path(parts[0]).name)
|
|
360
|
+
print(f" Downloading /{key}...")
|
|
361
|
+
try:
|
|
362
|
+
blob = storage.get(key)
|
|
363
|
+
except Exception as e:
|
|
364
|
+
print(f" Error: {e}")
|
|
365
|
+
return
|
|
366
|
+
try:
|
|
367
|
+
raw = crypto.decrypt(blob)
|
|
368
|
+
except Exception:
|
|
369
|
+
raw = blob # stored unencrypted, or wrong key
|
|
370
|
+
local.parent.mkdir(parents=True, exist_ok=True)
|
|
371
|
+
local.write_bytes(raw)
|
|
372
|
+
print(f" Saved to: {local} ({_human(len(raw))})")
|
|
373
|
+
|
|
374
|
+
do_download = do_get
|
|
375
|
+
|
|
376
|
+
# ── rm ──────────────────────────────────────────────────────────────────
|
|
377
|
+
def do_rm(self, arg):
|
|
378
|
+
"""Delete a file. Usage: rm <remote_file>"""
|
|
379
|
+
if not self._require_login():
|
|
380
|
+
return
|
|
381
|
+
name = _unquote(arg.strip())
|
|
382
|
+
if not name:
|
|
383
|
+
print("Usage: rm <remote_file>")
|
|
384
|
+
return
|
|
385
|
+
key = self._resolve(name)
|
|
386
|
+
try:
|
|
387
|
+
storage.delete(key)
|
|
388
|
+
print(f" Deleted: /{key}")
|
|
389
|
+
except Exception as e:
|
|
390
|
+
print(f" Error: {e}")
|
|
391
|
+
|
|
392
|
+
# ── mkdir ─────────────────────────────────────────────────────────────────
|
|
393
|
+
def do_mkdir(self, arg):
|
|
394
|
+
"""Create a folder. Usage: mkdir <name>"""
|
|
395
|
+
if not self._require_login():
|
|
396
|
+
return
|
|
397
|
+
name = _unquote(arg.strip())
|
|
398
|
+
if not name:
|
|
399
|
+
print("Usage: mkdir <name>")
|
|
400
|
+
return
|
|
401
|
+
try:
|
|
402
|
+
created = storage.mkdir(self._resolve(name))
|
|
403
|
+
print(f" Created: /{created}")
|
|
404
|
+
except Exception as e:
|
|
405
|
+
print(f" Error: {e}")
|
|
406
|
+
|
|
407
|
+
# ── info ──────────────────────────────────────────────────────────────────
|
|
408
|
+
def do_info(self, arg):
|
|
409
|
+
"""Show object metadata. Usage: info <remote_file>"""
|
|
410
|
+
if not self._require_login():
|
|
411
|
+
return
|
|
412
|
+
name = _unquote(arg.strip())
|
|
413
|
+
if not name:
|
|
414
|
+
print("Usage: info <remote_file>")
|
|
415
|
+
return
|
|
416
|
+
meta = storage.head(self._resolve(name))
|
|
417
|
+
if not meta:
|
|
418
|
+
print(" Not found.")
|
|
419
|
+
return
|
|
420
|
+
print(f" size: {_human(meta['size'])}")
|
|
421
|
+
if meta.get("last_modified"):
|
|
422
|
+
print(f" modified: {meta['last_modified']}")
|
|
423
|
+
|
|
424
|
+
# ── account ───────────────────────────────────────────────────────────────
|
|
425
|
+
def do_account(self, arg):
|
|
426
|
+
"""Show your plan: storage used vs. your free quota."""
|
|
427
|
+
if not self._require_login():
|
|
428
|
+
return
|
|
429
|
+
usage = _fetch_usage()
|
|
430
|
+
print()
|
|
431
|
+
print(" -- Obsideo account --------------------------")
|
|
432
|
+
print(" Plan: Free")
|
|
433
|
+
if usage:
|
|
434
|
+
used, quota = usage["used_bytes"], usage["quota_bytes"]
|
|
435
|
+
pct = usage.get("percent_used", (used / quota if quota else 0))
|
|
436
|
+
print(f" Used: {_human(used)} / {_human(quota)} ({pct*100:.1f}%)")
|
|
437
|
+
bar_len = 30
|
|
438
|
+
filled = int(bar_len * min(pct, 1.0))
|
|
439
|
+
print(f" [{'#'*filled}{'-'*(bar_len-filled)}]")
|
|
440
|
+
if pct >= 0.8:
|
|
441
|
+
print(" You're near your limit - reply to any Obsideo email to upgrade.")
|
|
442
|
+
else:
|
|
443
|
+
print(" (usage unavailable - is the account service reachable?)")
|
|
444
|
+
print(" ---------------------------------------------")
|
|
445
|
+
print()
|
|
446
|
+
|
|
447
|
+
# ── sync ──────────────────────────────────────────────────────────────────
|
|
448
|
+
def do_sync(self, arg):
|
|
449
|
+
"""Sync your local folder with Obsideo. Usage: sync push|pull|status"""
|
|
450
|
+
if not self._require_login():
|
|
451
|
+
return
|
|
452
|
+
from obsideo import sync as sync_mod
|
|
453
|
+
sub = arg.strip().lower()
|
|
454
|
+
if sub == "push":
|
|
455
|
+
n = sync_mod.push()
|
|
456
|
+
print(f" Done. {n} file(s) pushed.")
|
|
457
|
+
elif sub == "pull":
|
|
458
|
+
n = sync_mod.pull()
|
|
459
|
+
print(f" Done. {n} file(s) pulled.")
|
|
460
|
+
elif sub == "status":
|
|
461
|
+
s = sync_mod.sync_status()
|
|
462
|
+
for f in s["to_push"]:
|
|
463
|
+
print(f" + {f} (push)")
|
|
464
|
+
for f in s["to_pull"]:
|
|
465
|
+
print(f" - {f} (pull)")
|
|
466
|
+
for f in s["synced"]:
|
|
467
|
+
print(f" = {f}")
|
|
468
|
+
if not any(s.values()):
|
|
469
|
+
print(" Nothing to sync.")
|
|
470
|
+
else:
|
|
471
|
+
print("Usage: sync push|pull|status")
|
|
472
|
+
|
|
473
|
+
# ── config ────────────────────────────────────────────────────────────────
|
|
474
|
+
def do_config(self, arg):
|
|
475
|
+
"""Show or set config. Usage: config | config set <key> <value>"""
|
|
476
|
+
parts = arg.strip().split(None, 2)
|
|
477
|
+
if not parts:
|
|
478
|
+
for k, v in config.load_config().items():
|
|
479
|
+
print(f" {k}: {v}")
|
|
480
|
+
print(f" config_dir: {config.CONFIG_DIR}")
|
|
481
|
+
return
|
|
482
|
+
if parts[0] == "set" and len(parts) == 3:
|
|
483
|
+
key, value = parts[1], parts[2]
|
|
484
|
+
cfg = config.load_config()
|
|
485
|
+
if key == "encrypt":
|
|
486
|
+
value = value.lower() in ("true", "1", "yes", "on")
|
|
487
|
+
cfg[key] = value
|
|
488
|
+
config.save_config(cfg)
|
|
489
|
+
print(f" {key} = {value}")
|
|
490
|
+
else:
|
|
491
|
+
print("Usage: config | config set <key> <value>")
|
|
492
|
+
|
|
493
|
+
# ── exit ──────────────────────────────────────────────────────────────────
|
|
494
|
+
def do_exit(self, arg):
|
|
495
|
+
"""Exit."""
|
|
496
|
+
print("Bye.")
|
|
497
|
+
return True
|
|
498
|
+
|
|
499
|
+
do_quit = do_exit
|
|
500
|
+
|
|
501
|
+
def do_EOF(self, arg):
|
|
502
|
+
print()
|
|
503
|
+
return True
|
|
504
|
+
|
|
505
|
+
def emptyline(self):
|
|
506
|
+
pass
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _fetch_usage() -> dict | None:
|
|
510
|
+
token = config.account_token()
|
|
511
|
+
if not token:
|
|
512
|
+
return None
|
|
513
|
+
try:
|
|
514
|
+
req = urllib.request.Request(
|
|
515
|
+
f"{config.signup_url()}/v1/account/usage",
|
|
516
|
+
headers={"Authorization": f"Bearer {token}", "User-Agent": config.USER_AGENT},
|
|
517
|
+
)
|
|
518
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
519
|
+
return json.loads(resp.read().decode())
|
|
520
|
+
except Exception:
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def main():
|
|
525
|
+
argv = sys.argv[1:]
|
|
526
|
+
|
|
527
|
+
# Standard --help / -h (cmd.Cmd would otherwise read "--help" as a command).
|
|
528
|
+
if argv and argv[0] in ("-h", "--help", "help"):
|
|
529
|
+
ObsideoShell().onecmd("help")
|
|
530
|
+
return
|
|
531
|
+
|
|
532
|
+
# `obsideo login` is interactive and handled specially.
|
|
533
|
+
if argv and argv[0] == "login":
|
|
534
|
+
ok = run_login()
|
|
535
|
+
sys.exit(0 if ok else 1)
|
|
536
|
+
|
|
537
|
+
# `obsideo admin ...` is operator tooling, not a shell command.
|
|
538
|
+
if argv and argv[0] == "admin":
|
|
539
|
+
sys.exit(run_admin(argv[1:]))
|
|
540
|
+
|
|
541
|
+
# Surface any pending operator broadcasts (no-op unless interactive).
|
|
542
|
+
show_notices()
|
|
543
|
+
|
|
544
|
+
shell = ObsideoShell()
|
|
545
|
+
|
|
546
|
+
# One-shot: `obsideo ls`, `obsideo put file.txt`, etc.
|
|
547
|
+
if argv:
|
|
548
|
+
shell.onecmd(" ".join(argv))
|
|
549
|
+
return
|
|
550
|
+
|
|
551
|
+
# First-run nudge: not logged in -> offer login.
|
|
552
|
+
if not config.is_logged_in():
|
|
553
|
+
print("Welcome to Obsideo - encrypted storage we can't read.")
|
|
554
|
+
if input("Log in / sign up now? (Y/n): ").strip().lower() in ("", "y", "yes"):
|
|
555
|
+
if not run_login():
|
|
556
|
+
return
|
|
557
|
+
else:
|
|
558
|
+
print("Run 'obsideo login' when you're ready.")
|
|
559
|
+
return
|
|
560
|
+
|
|
561
|
+
try:
|
|
562
|
+
shell.cmdloop()
|
|
563
|
+
except KeyboardInterrupt:
|
|
564
|
+
print("\nBye.")
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
if __name__ == "__main__":
|
|
568
|
+
main()
|
obsideo/manifest.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Local manifest for tracking synced files (lifted from Cloud_Terminal,
|
|
2
|
+
re-homed under ~/.obsideo)."""
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from obsideo_core import config
|
|
9
|
+
|
|
10
|
+
_MANIFEST_FILE = config.CONFIG_DIR / "manifest.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load() -> dict:
|
|
14
|
+
if not _MANIFEST_FILE.exists():
|
|
15
|
+
return {"files": {}}
|
|
16
|
+
try:
|
|
17
|
+
data = json.loads(_MANIFEST_FILE.read_text())
|
|
18
|
+
if not isinstance(data, dict):
|
|
19
|
+
return {"files": {}}
|
|
20
|
+
data.setdefault("files", {})
|
|
21
|
+
return data
|
|
22
|
+
except Exception:
|
|
23
|
+
return {"files": {}}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _save(m: dict) -> None:
|
|
27
|
+
_MANIFEST_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
_MANIFEST_FILE.write_text(json.dumps(m, indent=2, sort_keys=True))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def upsert(name: str, remote_key: str, local_hash: str | None = None,
|
|
32
|
+
size: int | None = None, encrypted: bool = True) -> None:
|
|
33
|
+
m = _load()
|
|
34
|
+
m["files"][name] = {
|
|
35
|
+
"remote_key": remote_key,
|
|
36
|
+
"local_hash": local_hash,
|
|
37
|
+
"size": size,
|
|
38
|
+
"encrypted": encrypted,
|
|
39
|
+
"last_synced": datetime.now(timezone.utc).isoformat(),
|
|
40
|
+
}
|
|
41
|
+
_save(m)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def remove(name: str) -> None:
|
|
45
|
+
m = _load()
|
|
46
|
+
m["files"].pop(name, None)
|
|
47
|
+
_save(m)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_all() -> dict:
|
|
51
|
+
return _load()["files"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def file_sha256(filepath) -> str:
|
|
55
|
+
h = hashlib.sha256()
|
|
56
|
+
with open(filepath, "rb") as f:
|
|
57
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
58
|
+
h.update(chunk)
|
|
59
|
+
return f"sha256:{h.hexdigest()}"
|