mycloudctl 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.
- cli/__init__.py +2 -0
- cli/__main__.py +138 -0
- cli/api.py +323 -0
- cli/commands/__init__.py +1 -0
- cli/commands/auth.py +234 -0
- cli/commands/batch.py +175 -0
- cli/commands/favorites.py +35 -0
- cli/commands/files.py +356 -0
- cli/commands/folders.py +200 -0
- cli/commands/notifications.py +79 -0
- cli/commands/prefs.py +72 -0
- cli/commands/profile.py +157 -0
- cli/commands/search.py +78 -0
- cli/commands/servers.py +95 -0
- cli/commands/share.py +392 -0
- cli/commands/stash.py +92 -0
- cli/commands/storage.py +88 -0
- cli/config.py +87 -0
- cli/ui/__init__.py +1 -0
- cli/ui/table.py +112 -0
- cli/ui/tree.py +56 -0
- mycloudctl-1.0.0.dist-info/METADATA +377 -0
- mycloudctl-1.0.0.dist-info/RECORD +26 -0
- mycloudctl-1.0.0.dist-info/WHEEL +5 -0
- mycloudctl-1.0.0.dist-info/entry_points.txt +2 -0
- mycloudctl-1.0.0.dist-info/top_level.txt +1 -0
cli/__init__.py
ADDED
cli/__main__.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
__main__.py myCloud CLI entry point.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
mycloud <command> [subcommand] [options]
|
|
6
|
+
|
|
7
|
+
Install:
|
|
8
|
+
pip install -e ./cli
|
|
9
|
+
# then just run: mycloud
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from cli.commands import auth, files, folders, stash, share, batch
|
|
17
|
+
from cli.commands import favorites, search, servers, notifications, profile
|
|
18
|
+
from cli.commands import storage, prefs
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="mycloudctl",
|
|
24
|
+
help="""
|
|
25
|
+
[bold cyan]myCloud CLI[/bold cyan] Your cloud storage from the terminal.
|
|
26
|
+
|
|
27
|
+
[dim]Get started:[/dim]
|
|
28
|
+
mycloudctl login Sign in to myCloud
|
|
29
|
+
mycloudctl ls Browse your files
|
|
30
|
+
mycloudctl upload <file> Upload a file
|
|
31
|
+
mycloudctl upload <file> --stash Upload directly to stash
|
|
32
|
+
mycloudctl download <id> Download a file
|
|
33
|
+
mycloudctl info <id> Show file details
|
|
34
|
+
mycloudctl stats Show storage usage
|
|
35
|
+
mycloudctl find <query> Search files and folders
|
|
36
|
+
mycloudctl --help Show this help
|
|
37
|
+
""",
|
|
38
|
+
rich_markup_mode="rich",
|
|
39
|
+
add_completion=True,
|
|
40
|
+
no_args_is_help=True,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# ------------------------------------------------------------------ #
|
|
44
|
+
# Top-level auth commands (login, logout, whoami)
|
|
45
|
+
# ------------------------------------------------------------------ #
|
|
46
|
+
app.add_typer(auth.app, name="auth", hidden=True) # namespace for power users
|
|
47
|
+
app.command("login")(auth.login)
|
|
48
|
+
app.command("logout")(auth.logout)
|
|
49
|
+
app.command("whoami")(auth.whoami)
|
|
50
|
+
app.command("register")(auth.register)
|
|
51
|
+
|
|
52
|
+
# ------------------------------------------------------------------ #
|
|
53
|
+
# File operations
|
|
54
|
+
# ------------------------------------------------------------------ #
|
|
55
|
+
app.command("ls")(files.ls)
|
|
56
|
+
app.command("upload")(files.upload)
|
|
57
|
+
app.command("download")(files.download)
|
|
58
|
+
app.command("rm")(files.rm)
|
|
59
|
+
app.command("rename")(files.rename)
|
|
60
|
+
app.command("mv")(files.mv)
|
|
61
|
+
app.command("info")(files.info) # <-- file info shortcut
|
|
62
|
+
app.command("favorite")(files.favorite)
|
|
63
|
+
app.command("cat")(files.cat)
|
|
64
|
+
app.command("download-all")(files.download_all)
|
|
65
|
+
app.command("rm-all")(files.remove_all)
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------------------ #
|
|
68
|
+
# Folder operations (sub-group)
|
|
69
|
+
# ------------------------------------------------------------------ #
|
|
70
|
+
app.add_typer(folders.app, name="folders")
|
|
71
|
+
# Also expose mkdir directly at the top level for convenience
|
|
72
|
+
app.command("mkdir")(folders.mkdir)
|
|
73
|
+
app.command("rmdir")(folders.rmdir)
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------------ #
|
|
76
|
+
# Stash
|
|
77
|
+
# ------------------------------------------------------------------ #
|
|
78
|
+
app.add_typer(stash.app, name="stash")
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------ #
|
|
81
|
+
# Favorites shortcut
|
|
82
|
+
# ------------------------------------------------------------------ #
|
|
83
|
+
app.command("fav")(favorites.fav_toggle)
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------ #
|
|
86
|
+
# Search
|
|
87
|
+
# ------------------------------------------------------------------ #
|
|
88
|
+
app.command("find")(search.find)
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------ #
|
|
91
|
+
# Sharing
|
|
92
|
+
# ------------------------------------------------------------------ #
|
|
93
|
+
app.add_typer(share.app, name="share")
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------ #
|
|
96
|
+
# Batch operations
|
|
97
|
+
# ------------------------------------------------------------------ #
|
|
98
|
+
app.add_typer(batch.app, name="batch")
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------ #
|
|
101
|
+
# Private servers / nodes
|
|
102
|
+
# ------------------------------------------------------------------ #
|
|
103
|
+
app.add_typer(servers.app, name="servers")
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------ #
|
|
106
|
+
# Notifications
|
|
107
|
+
# ------------------------------------------------------------------ #
|
|
108
|
+
app.add_typer(notifications.app, name="notify")
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------ #
|
|
111
|
+
# Profile & settings
|
|
112
|
+
# ------------------------------------------------------------------ #
|
|
113
|
+
app.add_typer(profile.app, name="profile")
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------ #
|
|
116
|
+
# Storage stats
|
|
117
|
+
# ------------------------------------------------------------------ #
|
|
118
|
+
app.add_typer(storage.app, name="storage")
|
|
119
|
+
app.command("stats")(storage.stats) # <-- top-level shortcut
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------ #
|
|
122
|
+
# User preferences
|
|
123
|
+
# ------------------------------------------------------------------ #
|
|
124
|
+
app.add_typer(prefs.app, name="prefs")
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------ #
|
|
127
|
+
# Version
|
|
128
|
+
# ------------------------------------------------------------------ #
|
|
129
|
+
|
|
130
|
+
@app.command("version")
|
|
131
|
+
def version_cmd():
|
|
132
|
+
"""Show the myCloud CLI version."""
|
|
133
|
+
from cli import __version__
|
|
134
|
+
console.print(f"[bold]myCloud CLI[/bold] v{__version__}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
app()
|
cli/api.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""
|
|
2
|
+
api.py myCloud HTTP client.
|
|
3
|
+
|
|
4
|
+
All CLI commands use CloudClient to communicate with the server.
|
|
5
|
+
Handles:
|
|
6
|
+
- Bearer token injection
|
|
7
|
+
- 401 friendly re-login message
|
|
8
|
+
- Streaming uploads and downloads with Rich progress bars
|
|
9
|
+
- JSON error unwrapping
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Generator, IO
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.progress import (
|
|
20
|
+
Progress,
|
|
21
|
+
SpinnerColumn,
|
|
22
|
+
BarColumn,
|
|
23
|
+
DownloadColumn,
|
|
24
|
+
TransferSpeedColumn,
|
|
25
|
+
TimeRemainingColumn,
|
|
26
|
+
TextColumn,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from cli.config import get_base_url, get_token
|
|
30
|
+
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
_CHUNK = 1024 * 64 # 64 KB read/write chunks
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class APIError(Exception):
|
|
37
|
+
"""Raised when the server returns a non-2xx status."""
|
|
38
|
+
def __init__(self, message: str, status_code: int = 0):
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.status_code = status_code
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CloudClient:
|
|
44
|
+
"""Thin httpx wrapper with auth + progress for the myCloud CLI."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, base_url: str | None = None, token: str | None = None):
|
|
47
|
+
self.base_url = (base_url or get_base_url()).rstrip("/")
|
|
48
|
+
self.token = token or get_token()
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------ #
|
|
51
|
+
# Internal helpers
|
|
52
|
+
# ------------------------------------------------------------------ #
|
|
53
|
+
|
|
54
|
+
def _headers(self, extra: dict | None = None) -> dict:
|
|
55
|
+
h = {
|
|
56
|
+
"Accept": "application/json",
|
|
57
|
+
"X-Requested-With": "XMLHttpRequest"
|
|
58
|
+
}
|
|
59
|
+
if self.token:
|
|
60
|
+
h["Authorization"] = f"Bearer {self.token}"
|
|
61
|
+
if extra:
|
|
62
|
+
h.update(extra)
|
|
63
|
+
return h
|
|
64
|
+
|
|
65
|
+
def _url(self, path: str) -> str:
|
|
66
|
+
return f"{self.base_url}{path}"
|
|
67
|
+
|
|
68
|
+
def _raise(self, resp: httpx.Response) -> None:
|
|
69
|
+
if resp.status_code == 401:
|
|
70
|
+
try:
|
|
71
|
+
msg = resp.json().get("error")
|
|
72
|
+
if msg:
|
|
73
|
+
raise APIError(msg, 401)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
# If we've raised APIError inside the try, re-raise it
|
|
76
|
+
if isinstance(e, APIError):
|
|
77
|
+
raise e
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
raise APIError(
|
|
81
|
+
"Not authenticated. Run [bold]mycloud login[/bold] first.",
|
|
82
|
+
401,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if not resp.is_success:
|
|
86
|
+
try:
|
|
87
|
+
body = resp.json()
|
|
88
|
+
msg = body.get("error") or body.get("message") or resp.text
|
|
89
|
+
except Exception:
|
|
90
|
+
msg = resp.text or f"HTTP {resp.status_code}"
|
|
91
|
+
raise APIError(msg, resp.status_code)
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------ #
|
|
94
|
+
# JSON endpoints
|
|
95
|
+
# ------------------------------------------------------------------ #
|
|
96
|
+
|
|
97
|
+
def get(self, path: str, **params) -> Any:
|
|
98
|
+
resp = httpx.get(
|
|
99
|
+
self._url(path),
|
|
100
|
+
params=params or None,
|
|
101
|
+
headers=self._headers(),
|
|
102
|
+
follow_redirects=True,
|
|
103
|
+
timeout=30,
|
|
104
|
+
)
|
|
105
|
+
self._raise(resp)
|
|
106
|
+
return resp.json()
|
|
107
|
+
|
|
108
|
+
def get_raw(self, path: str) -> str:
|
|
109
|
+
resp = httpx.get(
|
|
110
|
+
self._url(path),
|
|
111
|
+
headers=self._headers(),
|
|
112
|
+
follow_redirects=True,
|
|
113
|
+
timeout=30,
|
|
114
|
+
)
|
|
115
|
+
self._raise(resp)
|
|
116
|
+
return resp.text
|
|
117
|
+
def post(self, path: str, json: Any = None, data: dict | None = None,
|
|
118
|
+
files: dict | None = None, extra_headers: dict | None = None) -> Any:
|
|
119
|
+
h = self._headers(extra_headers)
|
|
120
|
+
if files:
|
|
121
|
+
# multipart don't set Content-Type, httpx handles it
|
|
122
|
+
resp = httpx.post(
|
|
123
|
+
self._url(path), headers=h, data=data or {},
|
|
124
|
+
files=files, timeout=None, follow_redirects=True,
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
resp = httpx.post(
|
|
128
|
+
self._url(path), headers=h, json=json,
|
|
129
|
+
timeout=30, follow_redirects=True,
|
|
130
|
+
)
|
|
131
|
+
self._raise(resp)
|
|
132
|
+
return resp.json()
|
|
133
|
+
|
|
134
|
+
def delete(self, path: str, json: Any = None) -> Any:
|
|
135
|
+
if json is not None:
|
|
136
|
+
resp = httpx.request(
|
|
137
|
+
"DELETE",
|
|
138
|
+
self._url(path),
|
|
139
|
+
headers=self._headers(),
|
|
140
|
+
json=json,
|
|
141
|
+
follow_redirects=True,
|
|
142
|
+
timeout=30,
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
resp = httpx.delete(
|
|
146
|
+
self._url(path),
|
|
147
|
+
headers=self._headers(),
|
|
148
|
+
follow_redirects=True,
|
|
149
|
+
timeout=30,
|
|
150
|
+
)
|
|
151
|
+
self._raise(resp)
|
|
152
|
+
return resp.json()
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------ #
|
|
155
|
+
# Streaming upload
|
|
156
|
+
# ------------------------------------------------------------------ #
|
|
157
|
+
|
|
158
|
+
def upload_avatar(self, local_path: Path) -> Any:
|
|
159
|
+
with open(local_path, "rb") as f:
|
|
160
|
+
resp = httpx.post(
|
|
161
|
+
self._url("/upload_pfp"),
|
|
162
|
+
headers=self._headers(),
|
|
163
|
+
files={"file": (local_path.name, f, "image/jpeg")},
|
|
164
|
+
timeout=30, follow_redirects=True,
|
|
165
|
+
)
|
|
166
|
+
self._raise(resp)
|
|
167
|
+
return resp.json()
|
|
168
|
+
|
|
169
|
+
def upload_file(
|
|
170
|
+
self,
|
|
171
|
+
local_path: Path,
|
|
172
|
+
folder_id: int | None = None,
|
|
173
|
+
node: str = "central",
|
|
174
|
+
stash: bool = False,
|
|
175
|
+
) -> Any:
|
|
176
|
+
"""Upload a single file with a Rich progress bar."""
|
|
177
|
+
file_size = local_path.stat().st_size
|
|
178
|
+
|
|
179
|
+
with Progress(
|
|
180
|
+
SpinnerColumn(),
|
|
181
|
+
TextColumn("[bold blue]{task.fields[filename]}"),
|
|
182
|
+
BarColumn(),
|
|
183
|
+
DownloadColumn(),
|
|
184
|
+
TransferSpeedColumn(),
|
|
185
|
+
TimeRemainingColumn(),
|
|
186
|
+
console=console,
|
|
187
|
+
transient=True,
|
|
188
|
+
) as progress:
|
|
189
|
+
task = progress.add_task(
|
|
190
|
+
"upload", filename=local_path.name, total=file_size
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
class TrackedFile:
|
|
194
|
+
def __init__(self, f: IO[bytes]):
|
|
195
|
+
self._f = f
|
|
196
|
+
|
|
197
|
+
def read(self, n: int = -1) -> bytes:
|
|
198
|
+
chunk = self._f.read(n)
|
|
199
|
+
progress.update(task, advance=len(chunk))
|
|
200
|
+
return chunk
|
|
201
|
+
|
|
202
|
+
def fileno(self):
|
|
203
|
+
return self._f.fileno()
|
|
204
|
+
|
|
205
|
+
with open(local_path, "rb") as f:
|
|
206
|
+
tracked = TrackedFile(f)
|
|
207
|
+
form_data: dict[str, Any] = {}
|
|
208
|
+
if stash:
|
|
209
|
+
form_data["days"] = "7"
|
|
210
|
+
elif folder_id is not None:
|
|
211
|
+
form_data["folder_id"] = str(folder_id)
|
|
212
|
+
if node and node != "central":
|
|
213
|
+
form_data["node"] = str(node)
|
|
214
|
+
|
|
215
|
+
endpoint = "/stash_add" if stash else "/upload"
|
|
216
|
+
|
|
217
|
+
resp = httpx.post(
|
|
218
|
+
self._url(endpoint),
|
|
219
|
+
headers=self._headers(),
|
|
220
|
+
data=form_data,
|
|
221
|
+
files={"file": (local_path.name, tracked, "application/octet-stream")},
|
|
222
|
+
timeout=None,
|
|
223
|
+
follow_redirects=True,
|
|
224
|
+
)
|
|
225
|
+
self._raise(resp)
|
|
226
|
+
return resp.json()
|
|
227
|
+
|
|
228
|
+
# ------------------------------------------------------------------ #
|
|
229
|
+
# Streaming download
|
|
230
|
+
# ------------------------------------------------------------------ #
|
|
231
|
+
|
|
232
|
+
def download_file(
|
|
233
|
+
self,
|
|
234
|
+
file_id: int,
|
|
235
|
+
dest_dir: Path,
|
|
236
|
+
filename: str | None = None,
|
|
237
|
+
) -> Path:
|
|
238
|
+
"""Download a file with a Rich progress bar. Returns the saved path."""
|
|
239
|
+
url = self._url(f"/download/{file_id}")
|
|
240
|
+
|
|
241
|
+
with httpx.stream(
|
|
242
|
+
"GET", url, headers=self._headers(),
|
|
243
|
+
follow_redirects=True, timeout=None,
|
|
244
|
+
) as resp:
|
|
245
|
+
self._raise(resp)
|
|
246
|
+
|
|
247
|
+
# Determine filename from Content-Disposition or provided name
|
|
248
|
+
if not filename:
|
|
249
|
+
cd = resp.headers.get("content-disposition", "")
|
|
250
|
+
for part in cd.split(";"):
|
|
251
|
+
part = part.strip()
|
|
252
|
+
if part.startswith("filename="):
|
|
253
|
+
filename = part.split("=", 1)[1].strip().strip('"')
|
|
254
|
+
break
|
|
255
|
+
if not filename:
|
|
256
|
+
filename = f"file_{file_id}"
|
|
257
|
+
|
|
258
|
+
dest = dest_dir / filename
|
|
259
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
|
|
261
|
+
total = int(resp.headers.get("content-length", 0)) or None
|
|
262
|
+
|
|
263
|
+
with Progress(
|
|
264
|
+
SpinnerColumn(),
|
|
265
|
+
TextColumn("[bold green]{task.fields[filename]}"),
|
|
266
|
+
BarColumn(),
|
|
267
|
+
DownloadColumn(),
|
|
268
|
+
TransferSpeedColumn(),
|
|
269
|
+
TimeRemainingColumn(),
|
|
270
|
+
console=console,
|
|
271
|
+
transient=True,
|
|
272
|
+
) as progress:
|
|
273
|
+
task = progress.add_task("download", filename=filename, total=total)
|
|
274
|
+
with open(dest, "wb") as f:
|
|
275
|
+
for chunk in resp.iter_bytes(_CHUNK):
|
|
276
|
+
f.write(chunk)
|
|
277
|
+
progress.update(task, advance=len(chunk))
|
|
278
|
+
|
|
279
|
+
return dest
|
|
280
|
+
|
|
281
|
+
def download_folder(self, folder_id: int, dest_dir: Path) -> Path:
|
|
282
|
+
"""Download a folder as a zip archive."""
|
|
283
|
+
url = self._url(f"/download_folder/{folder_id}")
|
|
284
|
+
return self._stream_zip(url, dest_dir, f"folder_{folder_id}.zip")
|
|
285
|
+
|
|
286
|
+
def download_all(self, dest_dir: Path, node: str = "central",
|
|
287
|
+
folder_id: int | None = None) -> Path:
|
|
288
|
+
"""Download all files in the current view as a zip."""
|
|
289
|
+
params: dict[str, Any] = {}
|
|
290
|
+
if folder_id is not None:
|
|
291
|
+
params["folder_id"] = folder_id
|
|
292
|
+
if node and node != "central":
|
|
293
|
+
params["node"] = node
|
|
294
|
+
url = self._url("/download_all")
|
|
295
|
+
return self._stream_zip(url, dest_dir, "mycloud_files.zip", params=params)
|
|
296
|
+
|
|
297
|
+
def _stream_zip(self, url: str, dest_dir: Path, default_name: str,
|
|
298
|
+
params: dict | None = None) -> Path:
|
|
299
|
+
with httpx.stream(
|
|
300
|
+
"GET", url, headers=self._headers(), params=params,
|
|
301
|
+
follow_redirects=True, timeout=None,
|
|
302
|
+
) as resp:
|
|
303
|
+
self._raise(resp)
|
|
304
|
+
cd = resp.headers.get("content-disposition", "")
|
|
305
|
+
name = default_name
|
|
306
|
+
for part in cd.split(";"):
|
|
307
|
+
part = part.strip()
|
|
308
|
+
if part.startswith("filename="):
|
|
309
|
+
name = part.split("=", 1)[1].strip().strip('"')
|
|
310
|
+
break
|
|
311
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
dest = dest_dir / name
|
|
313
|
+
total = int(resp.headers.get("content-length", 0)) or None
|
|
314
|
+
with Progress(
|
|
315
|
+
SpinnerColumn(), BarColumn(), DownloadColumn(),
|
|
316
|
+
console=console, transient=True,
|
|
317
|
+
) as progress:
|
|
318
|
+
task = progress.add_task("zip", total=total)
|
|
319
|
+
with open(dest, "wb") as f:
|
|
320
|
+
for chunk in resp.iter_bytes(_CHUNK):
|
|
321
|
+
f.write(chunk)
|
|
322
|
+
progress.update(task, advance=len(chunk))
|
|
323
|
+
return dest
|
cli/commands/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# cli/commands/__init__.py
|
cli/commands/auth.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""commands/auth.py login, logout, whoami commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
import webbrowser
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.prompt import Prompt
|
|
12
|
+
from rich import print as rprint
|
|
13
|
+
|
|
14
|
+
from cli.api import CloudClient, APIError
|
|
15
|
+
from cli.config import (
|
|
16
|
+
save_credentials, clear_credentials, get_base_url,
|
|
17
|
+
get_token, get_stored_user,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="Authentication login, logout, and account info.")
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command("login")
|
|
25
|
+
def login(
|
|
26
|
+
username: str = typer.Option(None, "--username", "-u", help="Username or email"),
|
|
27
|
+
password: str = typer.Option(None, "--password", "-p", help="Password (prefer interactive prompt)"),
|
|
28
|
+
server: str = typer.Option(None, "--server", "-s", help="Override server URL"),
|
|
29
|
+
):
|
|
30
|
+
"""Log in to myCloud and save credentials locally."""
|
|
31
|
+
base_url = server or get_base_url()
|
|
32
|
+
client = CloudClient(base_url=base_url, token="")
|
|
33
|
+
|
|
34
|
+
console.print(Panel.fit(
|
|
35
|
+
"[bold cyan]myCloud CLI[/bold cyan] - Sign in",
|
|
36
|
+
border_style="cyan",
|
|
37
|
+
))
|
|
38
|
+
console.print(f"[dim]Server:[/dim] {base_url}\n")
|
|
39
|
+
|
|
40
|
+
identifier = username or Prompt.ask("[bold]Username or email[/]")
|
|
41
|
+
pwd = password or Prompt.ask("[bold]Password[/]", password=True)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
data = client.post("/api/auth/token", json={
|
|
45
|
+
"username": identifier,
|
|
46
|
+
"password": pwd,
|
|
47
|
+
"label": "CLI",
|
|
48
|
+
})
|
|
49
|
+
except APIError as e:
|
|
50
|
+
console.print(f"[red]- Login failed:[/red] {e}")
|
|
51
|
+
raise typer.Exit(1)
|
|
52
|
+
|
|
53
|
+
if data.get("two_factor_required"):
|
|
54
|
+
_handle_2fa(client, data["login_id"], base_url)
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
_store_and_greet(data, base_url)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _handle_2fa(client: CloudClient, login_id: str, base_url: str) -> None:
|
|
61
|
+
"""Open the 2FA email link in the browser and poll until verified."""
|
|
62
|
+
verify_url = f"{base_url}/2fa/verify"
|
|
63
|
+
console.print(
|
|
64
|
+
"\n[bold yellow] Two-factor authentication required.[/]\n"
|
|
65
|
+
"A verification email has been sent to your inbox.\n"
|
|
66
|
+
"Opening your browser so you can click the link\n"
|
|
67
|
+
)
|
|
68
|
+
try:
|
|
69
|
+
webbrowser.open(f"{base_url}/login")
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
console.print("[dim]Waiting for you to click the email link[/dim]")
|
|
74
|
+
|
|
75
|
+
with console.status("[cyan]Polling for 2FA confirmation[/cyan]", spinner="dots"):
|
|
76
|
+
while True:
|
|
77
|
+
time.sleep(2)
|
|
78
|
+
try:
|
|
79
|
+
poll = client.get("/api/auth/2fa-poll", login_id=login_id, label="CLI")
|
|
80
|
+
except APIError as e:
|
|
81
|
+
console.print(f"[red]Poll error:[/] {e}")
|
|
82
|
+
raise typer.Exit(1)
|
|
83
|
+
|
|
84
|
+
status = poll.get("status")
|
|
85
|
+
if status == "verified":
|
|
86
|
+
_store_and_greet(poll, client.base_url)
|
|
87
|
+
return
|
|
88
|
+
elif status == "expired":
|
|
89
|
+
console.print("[red]- 2FA link expired. Please run login again.[/]")
|
|
90
|
+
raise typer.Exit(1)
|
|
91
|
+
elif status in ("invalid",):
|
|
92
|
+
console.print("[red]- 2FA session invalid.[/]")
|
|
93
|
+
raise typer.Exit(1)
|
|
94
|
+
# status == "pending" keep waiting
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _store_and_greet(data: dict, base_url: str) -> None:
|
|
98
|
+
user = data.get("user", {})
|
|
99
|
+
save_credentials({
|
|
100
|
+
"token": data["token"],
|
|
101
|
+
"expires_at": data.get("expires_at", ""),
|
|
102
|
+
"base_url": base_url,
|
|
103
|
+
"user": user,
|
|
104
|
+
})
|
|
105
|
+
console.print(
|
|
106
|
+
f"\n[bold green] Logged in as [cyan]{user.get('username', '?')}[/cyan]"
|
|
107
|
+
f" ({user.get('name', '')})[/bold green]"
|
|
108
|
+
)
|
|
109
|
+
console.print(f"[dim]Token expires: {data.get('expires_at', 'unknown')}[/dim]\n")
|
|
110
|
+
console.print("Run [bold]mycloud ls[/bold] to browse your files.")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command("logout")
|
|
114
|
+
def logout():
|
|
115
|
+
"""Revoke the current token and clear local credentials."""
|
|
116
|
+
token = get_token()
|
|
117
|
+
if not token:
|
|
118
|
+
console.print("[yellow]Not logged in.[/yellow]")
|
|
119
|
+
raise typer.Exit()
|
|
120
|
+
|
|
121
|
+
client = CloudClient()
|
|
122
|
+
try:
|
|
123
|
+
client.delete("/api/auth/token")
|
|
124
|
+
console.print("[green] Logged out (token revoked on server).[/green]")
|
|
125
|
+
except APIError:
|
|
126
|
+
console.print("[yellow]Could not reach server clearing local credentials anyway.[/yellow]")
|
|
127
|
+
finally:
|
|
128
|
+
clear_credentials()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.command("whoami")
|
|
132
|
+
def whoami():
|
|
133
|
+
"""Show the currently authenticated user and storage usage."""
|
|
134
|
+
user = get_stored_user()
|
|
135
|
+
if not user:
|
|
136
|
+
console.print("[yellow]Not logged in. Run [bold]mycloud login[/bold].[/yellow]")
|
|
137
|
+
raise typer.Exit(1)
|
|
138
|
+
|
|
139
|
+
console.print(Panel(
|
|
140
|
+
f"[bold cyan]{user.get('name', user.get('username'))}[/bold cyan]\n"
|
|
141
|
+
f"[dim]Username:[/dim] {user.get('username', '?')}\n"
|
|
142
|
+
f"[dim]Email:[/dim] {user.get('email', '?')}\n"
|
|
143
|
+
f"[dim]Server:[/dim] {get_base_url()}",
|
|
144
|
+
title="[bold]Signed In[/bold]",
|
|
145
|
+
border_style="green",
|
|
146
|
+
))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.command("register")
|
|
150
|
+
def register(
|
|
151
|
+
name: str = typer.Option(None, "--name", "-n", help="Full name"),
|
|
152
|
+
username: str = typer.Option(None, "--username", "-u", help="Username"),
|
|
153
|
+
email: str = typer.Option(None, "--email", "-e", help="Email address"),
|
|
154
|
+
password: str = typer.Option(None, "--password", "-p", help="Password"),
|
|
155
|
+
):
|
|
156
|
+
"""Register a new myCloud account."""
|
|
157
|
+
client = CloudClient(base_url=get_base_url(), token="")
|
|
158
|
+
|
|
159
|
+
console.print(Panel.fit(
|
|
160
|
+
"[bold cyan]myCloud CLI[/bold cyan] - Register",
|
|
161
|
+
border_style="cyan",
|
|
162
|
+
))
|
|
163
|
+
|
|
164
|
+
_name = name or Prompt.ask("[bold]Full name[/]")
|
|
165
|
+
_username = username or Prompt.ask("[bold]Username[/]")
|
|
166
|
+
_email = email or Prompt.ask("[bold]Email[/]")
|
|
167
|
+
_pwd = password or Prompt.ask("[bold]Password[/]", password=True)
|
|
168
|
+
_pwd_confirm = Prompt.ask("[bold]Confirm Password[/]", password=True) if not password else password
|
|
169
|
+
|
|
170
|
+
if _pwd != _pwd_confirm:
|
|
171
|
+
console.print("[red]- Passwords do not match.[/red]")
|
|
172
|
+
raise typer.Exit(1)
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
data = client.post("/register", json={
|
|
176
|
+
"name": _name,
|
|
177
|
+
"username": _username,
|
|
178
|
+
"email": _email,
|
|
179
|
+
"password": _pwd,
|
|
180
|
+
"confirm_password": _pwd_confirm
|
|
181
|
+
})
|
|
182
|
+
if data.get("success"):
|
|
183
|
+
console.print(f"[green][OK][/green] {data.get('message')}")
|
|
184
|
+
else:
|
|
185
|
+
console.print(f"[red][FAIL][/red] {data.get('error') or data.get('message', 'Registration failed')}")
|
|
186
|
+
except APIError as e:
|
|
187
|
+
console.print(f"[red]- Registration failed:[/red] {e}")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@app.command("forgot-password")
|
|
191
|
+
def forgot_password(
|
|
192
|
+
email: str = typer.Option(None, "--email", "-e", help="Account email address"),
|
|
193
|
+
):
|
|
194
|
+
"""Request a password reset link."""
|
|
195
|
+
client = CloudClient(base_url=get_base_url(), token="")
|
|
196
|
+
|
|
197
|
+
_email = email or Prompt.ask("[bold]Email[/]")
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
data = client.post("/forgot_password", json={"email": _email})
|
|
201
|
+
if data.get("success"):
|
|
202
|
+
console.print(f"[green][OK][/green] {data.get('message')}")
|
|
203
|
+
else:
|
|
204
|
+
console.print(f"[red][FAIL][/red] {data.get('error', 'Request failed')}")
|
|
205
|
+
except APIError as e:
|
|
206
|
+
console.print(f"[red]- Request failed:[/red] {e}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@app.command("reset-password")
|
|
210
|
+
def reset_password(
|
|
211
|
+
token: str = typer.Argument(..., help="Reset token from your email"),
|
|
212
|
+
password: str = typer.Option(None, "--password", "-p", help="New Password"),
|
|
213
|
+
):
|
|
214
|
+
"""Reset your password using a token."""
|
|
215
|
+
client = CloudClient(base_url=get_base_url(), token="")
|
|
216
|
+
|
|
217
|
+
_pwd = password or Prompt.ask("[bold]New Password[/]", password=True)
|
|
218
|
+
_pwd_confirm = Prompt.ask("[bold]Confirm Password[/]", password=True) if not password else password
|
|
219
|
+
|
|
220
|
+
if _pwd != _pwd_confirm:
|
|
221
|
+
console.print("[red]- Passwords do not match.[/red]")
|
|
222
|
+
raise typer.Exit(1)
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
data = client.post(f"/reset_password/{token}", json={
|
|
226
|
+
"password": _pwd,
|
|
227
|
+
"confirm_password": _pwd_confirm
|
|
228
|
+
})
|
|
229
|
+
if data.get("success"):
|
|
230
|
+
console.print(f"[green][OK][/green] {data.get('message')}")
|
|
231
|
+
else:
|
|
232
|
+
console.print(f"[red][FAIL][/red] {data.get('error', 'Reset failed')}")
|
|
233
|
+
except APIError as e:
|
|
234
|
+
console.print(f"[red]- Reset failed:[/red] {e}")
|