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/commands/share.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""commands/share.py -- share, unshare, shared-with-me, accept, decline, link, copy, mute."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich import box
|
|
9
|
+
|
|
10
|
+
from cli.api import CloudClient, APIError
|
|
11
|
+
from cli.ui.table import shares_table
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Sharing -- share files/folders, manage invites and public links.")
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _client() -> CloudClient:
|
|
18
|
+
return CloudClient()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ------------------------------------------------------------------ #
|
|
22
|
+
# send (invite)
|
|
23
|
+
# ------------------------------------------------------------------ #
|
|
24
|
+
|
|
25
|
+
@app.command("send")
|
|
26
|
+
def share_send(
|
|
27
|
+
item_id: int = typer.Argument(..., help="File or folder ID to share"),
|
|
28
|
+
user: str = typer.Option(..., "--user", "-u", help="Username or email of recipient"),
|
|
29
|
+
folder: bool = typer.Option(False, "--folder", "-f", help="ID is a folder"),
|
|
30
|
+
permission: str = typer.Option("read", "--permission", "-p", help="read | write"),
|
|
31
|
+
):
|
|
32
|
+
"""Share a file or folder with another user."""
|
|
33
|
+
client = _client()
|
|
34
|
+
try:
|
|
35
|
+
if folder:
|
|
36
|
+
result = client.post(f"/share_folder/{item_id}", json={"shared_with": user, "permission": permission})
|
|
37
|
+
else:
|
|
38
|
+
result = client.post(f"/share_file/{item_id}", json={"shared_with": user, "permission": permission})
|
|
39
|
+
if result.get("success"):
|
|
40
|
+
console.print(f"[green][OK][/] Shared with [bold]{user}[/bold] ({permission}).")
|
|
41
|
+
else:
|
|
42
|
+
console.print(f"[red][FAIL][/] {result.get('message') or 'Error'}")
|
|
43
|
+
except APIError as e:
|
|
44
|
+
console.print(f"[red]-[/] {e}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------ #
|
|
48
|
+
# link (public link)
|
|
49
|
+
# ------------------------------------------------------------------ #
|
|
50
|
+
|
|
51
|
+
@app.command("link")
|
|
52
|
+
def share_link(
|
|
53
|
+
item_id: int = typer.Argument(..., help="File or folder ID"),
|
|
54
|
+
folder: bool = typer.Option(False, "--folder", "-f", help="ID is a folder"),
|
|
55
|
+
):
|
|
56
|
+
"""Generate a public share link for a file or folder."""
|
|
57
|
+
client = _client()
|
|
58
|
+
try:
|
|
59
|
+
if folder:
|
|
60
|
+
result = client.post(f"/generate_folder_share_link/{item_id}")
|
|
61
|
+
else:
|
|
62
|
+
result = client.post(f"/generate_share_link/{item_id}")
|
|
63
|
+
link = result.get("link") or result.get("share_link") or result.get("url") or result.get("share_url")
|
|
64
|
+
if link:
|
|
65
|
+
console.print(f"[green][OK][/] Share link:\n[bold cyan]{link}[/bold cyan]")
|
|
66
|
+
else:
|
|
67
|
+
console.print(f"[red][FAIL][/] {result.get('message', 'Could not generate link')}")
|
|
68
|
+
except APIError as e:
|
|
69
|
+
console.print(f"[red]-[/] {e}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------ #
|
|
73
|
+
# disable-link
|
|
74
|
+
# ------------------------------------------------------------------ #
|
|
75
|
+
|
|
76
|
+
@app.command("disable-link")
|
|
77
|
+
def share_disable_link(
|
|
78
|
+
item_id: int = typer.Argument(..., help="File or folder ID"),
|
|
79
|
+
item_type: str = typer.Option("file", "--type", "-t", help="'file' or 'folder'"),
|
|
80
|
+
):
|
|
81
|
+
"""Disable/revoke the public share link for a file or folder."""
|
|
82
|
+
client = _client()
|
|
83
|
+
try:
|
|
84
|
+
result = client.post("/api/share/disable_link", json={"item_id": item_id, "item_type": item_type})
|
|
85
|
+
if result.get("success"):
|
|
86
|
+
console.print("[green][OK][/] Share link disabled.")
|
|
87
|
+
else:
|
|
88
|
+
console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
|
|
89
|
+
except APIError as e:
|
|
90
|
+
console.print(f"[red]-[/] {e}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------ #
|
|
94
|
+
# info
|
|
95
|
+
# ------------------------------------------------------------------ #
|
|
96
|
+
|
|
97
|
+
@app.command("info")
|
|
98
|
+
def share_info(
|
|
99
|
+
item_id: int = typer.Argument(..., help="File or folder ID"),
|
|
100
|
+
item_type: str = typer.Option("file", "--type", "-t", help="'file' or 'folder'"),
|
|
101
|
+
):
|
|
102
|
+
"""Show share details for a file or folder."""
|
|
103
|
+
client = _client()
|
|
104
|
+
try:
|
|
105
|
+
result = client.get(f"/api/share_details/{item_type}/{item_id}")
|
|
106
|
+
rows = result.get("shares", []) or result.get("data", [])
|
|
107
|
+
if not rows:
|
|
108
|
+
console.print("[dim]Not shared with anyone.[/dim]")
|
|
109
|
+
return
|
|
110
|
+
console.print(shares_table(rows))
|
|
111
|
+
except APIError as e:
|
|
112
|
+
console.print(f"[red]-[/] {e}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------ #
|
|
116
|
+
# update-perm
|
|
117
|
+
# ------------------------------------------------------------------ #
|
|
118
|
+
|
|
119
|
+
@app.command("update-perm")
|
|
120
|
+
def share_update_perm(
|
|
121
|
+
item_id: int = typer.Argument(..., help="File or folder ID"),
|
|
122
|
+
user_id: int = typer.Option(..., "--user-id", help="User ID whose permission to change"),
|
|
123
|
+
permission: str = typer.Option(..., "--permission", "-p", help="read | write"),
|
|
124
|
+
item_type: str = typer.Option("file", "--type", "-t", help="'file' or 'folder'"),
|
|
125
|
+
):
|
|
126
|
+
"""Update a share's permission level."""
|
|
127
|
+
client = _client()
|
|
128
|
+
try:
|
|
129
|
+
result = client.post("/api/share/update_permissions", json={
|
|
130
|
+
"item_id": item_id,
|
|
131
|
+
"item_type": item_type,
|
|
132
|
+
"shared_with_user_id": user_id,
|
|
133
|
+
"permission": permission,
|
|
134
|
+
})
|
|
135
|
+
if result.get("success"):
|
|
136
|
+
console.print(f"[green][OK][/] Permission updated to '{permission}'.")
|
|
137
|
+
else:
|
|
138
|
+
console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
|
|
139
|
+
except APIError as e:
|
|
140
|
+
console.print(f"[red]-[/] {e}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ------------------------------------------------------------------ #
|
|
144
|
+
# rm (remove share)
|
|
145
|
+
# ------------------------------------------------------------------ #
|
|
146
|
+
|
|
147
|
+
@app.command("rm")
|
|
148
|
+
def share_rm(
|
|
149
|
+
item_id: int = typer.Argument(..., help="File or folder ID"),
|
|
150
|
+
item_type: str = typer.Option("file", "--type", "-t", help="'file' or 'folder'"),
|
|
151
|
+
shared_with_id: Optional[int] = typer.Option(None, "--with-id", help="Specific user ID to un-share"),
|
|
152
|
+
):
|
|
153
|
+
"""Remove a share from a file or folder."""
|
|
154
|
+
client = _client()
|
|
155
|
+
try:
|
|
156
|
+
result = client.post("/api/share/remove", json={
|
|
157
|
+
"item_type": item_type,
|
|
158
|
+
"item_id": item_id,
|
|
159
|
+
"shared_with_user_id": shared_with_id,
|
|
160
|
+
})
|
|
161
|
+
if result.get("success"):
|
|
162
|
+
console.print("[green][OK][/] Share removed.")
|
|
163
|
+
else:
|
|
164
|
+
console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
|
|
165
|
+
except APIError as e:
|
|
166
|
+
console.print(f"[red]-[/] {e}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------------ #
|
|
170
|
+
# copy (add shared file to my cloud)
|
|
171
|
+
# ------------------------------------------------------------------ #
|
|
172
|
+
|
|
173
|
+
@app.command("copy")
|
|
174
|
+
def share_copy(
|
|
175
|
+
file_id: int = typer.Argument(..., help="Shared file ID to copy to your cloud"),
|
|
176
|
+
):
|
|
177
|
+
"""Copy a shared file into your own myCloud storage."""
|
|
178
|
+
client = _client()
|
|
179
|
+
try:
|
|
180
|
+
result = client.post(f"/add_shared_to_mycloud/{file_id}")
|
|
181
|
+
if result.get("success"):
|
|
182
|
+
console.print(f"[green][OK][/] {result.get('message', 'File copied to your cloud.')}")
|
|
183
|
+
else:
|
|
184
|
+
console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
|
|
185
|
+
except APIError as e:
|
|
186
|
+
console.print(f"[red]-[/] {e}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ------------------------------------------------------------------ #
|
|
190
|
+
# copy-folder (add shared folder to my cloud)
|
|
191
|
+
# ------------------------------------------------------------------ #
|
|
192
|
+
|
|
193
|
+
@app.command("copy-folder")
|
|
194
|
+
def share_copy_folder(
|
|
195
|
+
folder_id: int = typer.Argument(..., help="Shared folder ID to copy to your cloud"),
|
|
196
|
+
):
|
|
197
|
+
"""Copy a shared folder (and contents) into your own myCloud storage."""
|
|
198
|
+
client = _client()
|
|
199
|
+
try:
|
|
200
|
+
result = client.post(f"/copy_shared_folder/{folder_id}")
|
|
201
|
+
if result.get("success"):
|
|
202
|
+
console.print(f"[green][OK][/] {result.get('message', 'Folder copied to your cloud.')}")
|
|
203
|
+
else:
|
|
204
|
+
console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
|
|
205
|
+
except APIError as e:
|
|
206
|
+
console.print(f"[red]-[/] {e}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------ #
|
|
210
|
+
# with-me
|
|
211
|
+
# ------------------------------------------------------------------ #
|
|
212
|
+
|
|
213
|
+
@app.command("with-me")
|
|
214
|
+
def shared_with_me():
|
|
215
|
+
"""List all files and folders shared with you."""
|
|
216
|
+
client = _client()
|
|
217
|
+
try:
|
|
218
|
+
data = client.get("/shared_files")
|
|
219
|
+
except APIError as e:
|
|
220
|
+
console.print(f"[red]-[/] {e}")
|
|
221
|
+
raise typer.Exit(1)
|
|
222
|
+
|
|
223
|
+
files = data.get("shared_files", []) or data.get("files", [])
|
|
224
|
+
folders = data.get("shared_folders", []) or data.get("folders", [])
|
|
225
|
+
|
|
226
|
+
from cli.ui.table import files_table, folders_table
|
|
227
|
+
if folders:
|
|
228
|
+
console.print(folders_table(folders, title="Folders Shared With Me"))
|
|
229
|
+
if files:
|
|
230
|
+
console.print(files_table(files, title="Files Shared With Me"))
|
|
231
|
+
if not files and not folders:
|
|
232
|
+
console.print("[dim]Nothing shared with you yet.[/dim]")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ------------------------------------------------------------------ #
|
|
236
|
+
# Invites -- list / accept / decline
|
|
237
|
+
# ------------------------------------------------------------------ #
|
|
238
|
+
|
|
239
|
+
@app.command("invites")
|
|
240
|
+
def share_invites():
|
|
241
|
+
"""List pending share invites."""
|
|
242
|
+
client = _client()
|
|
243
|
+
try:
|
|
244
|
+
result = client.get("/api/share_invites/details")
|
|
245
|
+
except APIError as e:
|
|
246
|
+
console.print(f"[red]-[/] {e}")
|
|
247
|
+
raise typer.Exit(1)
|
|
248
|
+
|
|
249
|
+
invites = result.get("invites", []) or result.get("data", [])
|
|
250
|
+
if not invites:
|
|
251
|
+
console.print("[dim]No pending share invites.[/dim]")
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
t = Table(title="Pending Share Invites", box=box.ROUNDED, header_style="bold cyan")
|
|
255
|
+
t.add_column("Token", style="dim", no_wrap=True)
|
|
256
|
+
t.add_column("From")
|
|
257
|
+
t.add_column("Item")
|
|
258
|
+
t.add_column("Type")
|
|
259
|
+
|
|
260
|
+
for inv in invites:
|
|
261
|
+
t.add_row(
|
|
262
|
+
inv.get("request_token") or inv.get("token") or "-",
|
|
263
|
+
inv.get("sender_username") or inv.get("sender_email") or "?",
|
|
264
|
+
str(inv.get("item_id", "?")),
|
|
265
|
+
inv.get("item_type", "?"),
|
|
266
|
+
)
|
|
267
|
+
console.print(t)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@app.command("accept")
|
|
271
|
+
def share_accept(
|
|
272
|
+
token: str = typer.Argument(..., help="Share invite token"),
|
|
273
|
+
):
|
|
274
|
+
"""Accept a share invite."""
|
|
275
|
+
client = _client()
|
|
276
|
+
try:
|
|
277
|
+
result = client.post("/api/share_invites/respond", json={"token": token, "action": "accept"})
|
|
278
|
+
if result.get("success"):
|
|
279
|
+
console.print("[green][OK][/] Invite accepted.")
|
|
280
|
+
else:
|
|
281
|
+
console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
|
|
282
|
+
except APIError as e:
|
|
283
|
+
console.print(f"[red]-[/] {e}")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@app.command("decline")
|
|
287
|
+
def share_decline(
|
|
288
|
+
token: str = typer.Argument(..., help="Share invite token"),
|
|
289
|
+
):
|
|
290
|
+
"""Decline a share invite."""
|
|
291
|
+
client = _client()
|
|
292
|
+
try:
|
|
293
|
+
result = client.post("/api/share_invites/respond", json={"token": token, "action": "decline"})
|
|
294
|
+
if result.get("success"):
|
|
295
|
+
console.print("[green][OK][/] Invite declined.")
|
|
296
|
+
else:
|
|
297
|
+
console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
|
|
298
|
+
except APIError as e:
|
|
299
|
+
console.print(f"[red]-[/] {e}")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ------------------------------------------------------------------ #
|
|
303
|
+
# Mute / unmute senders
|
|
304
|
+
# ------------------------------------------------------------------ #
|
|
305
|
+
|
|
306
|
+
@app.command("mute")
|
|
307
|
+
def share_mute(
|
|
308
|
+
identifier: str = typer.Argument(..., help="Username or email to mute share invites from"),
|
|
309
|
+
):
|
|
310
|
+
"""Mute share invites from a user."""
|
|
311
|
+
client = _client()
|
|
312
|
+
try:
|
|
313
|
+
result = client.post("/api/share_invites/mute", json={"identifier": identifier})
|
|
314
|
+
if result.get("success"):
|
|
315
|
+
console.print(f"[green][OK][/] Muted share invites from {identifier}.")
|
|
316
|
+
else:
|
|
317
|
+
console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
|
|
318
|
+
except APIError as e:
|
|
319
|
+
console.print(f"[red]-[/] {e}")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@app.command("unmute")
|
|
323
|
+
def share_unmute(
|
|
324
|
+
muted_user_id: int = typer.Argument(..., help="User ID to unmute"),
|
|
325
|
+
):
|
|
326
|
+
"""Unmute share invites from a user."""
|
|
327
|
+
client = _client()
|
|
328
|
+
try:
|
|
329
|
+
result = client.post("/api/share_invites/unmute", json={"muted_user_id": muted_user_id})
|
|
330
|
+
if result.get("success"):
|
|
331
|
+
console.print(f"[green][OK][/] Unmuted user {muted_user_id}.")
|
|
332
|
+
else:
|
|
333
|
+
console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
|
|
334
|
+
except APIError as e:
|
|
335
|
+
console.print(f"[red]-[/] {e}")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@app.command("muted")
|
|
339
|
+
def share_muted():
|
|
340
|
+
"""List users whose share invites you have muted."""
|
|
341
|
+
client = _client()
|
|
342
|
+
try:
|
|
343
|
+
result = client.get("/api/share_invites/muted_users")
|
|
344
|
+
except APIError as e:
|
|
345
|
+
console.print(f"[red]-[/] {e}")
|
|
346
|
+
raise typer.Exit(1)
|
|
347
|
+
|
|
348
|
+
users = result.get("muted_users") or result.get("data", [])
|
|
349
|
+
if not users:
|
|
350
|
+
console.print("[dim]No muted users.[/dim]")
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
t = Table(title="Muted Users", box=box.ROUNDED, header_style="bold yellow")
|
|
354
|
+
t.add_column("User ID", style="dim", justify="right")
|
|
355
|
+
t.add_column("Username")
|
|
356
|
+
for u in users:
|
|
357
|
+
t.add_row(str(u.get("id", "?")), u.get("username") or u.get("email") or "?")
|
|
358
|
+
console.print(t)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@app.command("claim-link")
|
|
362
|
+
def share_claim_link(
|
|
363
|
+
token: str = typer.Argument(..., help="Public share token to claim"),
|
|
364
|
+
):
|
|
365
|
+
"""Claim a public share link using its token."""
|
|
366
|
+
client = _client()
|
|
367
|
+
endpoints = [
|
|
368
|
+
"/add_share_from_token",
|
|
369
|
+
"/add_folder_share_from_token",
|
|
370
|
+
"/add_batch_share_from_token"
|
|
371
|
+
]
|
|
372
|
+
|
|
373
|
+
success = False
|
|
374
|
+
errors = []
|
|
375
|
+
|
|
376
|
+
for endpoint in endpoints:
|
|
377
|
+
try:
|
|
378
|
+
result = client.post(endpoint, json={"token": token})
|
|
379
|
+
if result.get("success"):
|
|
380
|
+
console.print(f"[green][OK][/] {result.get('message', 'Share link claimed successfully.')}")
|
|
381
|
+
success = True
|
|
382
|
+
break
|
|
383
|
+
except APIError as e:
|
|
384
|
+
msg = str(e)
|
|
385
|
+
if "Invalid or expired" not in msg:
|
|
386
|
+
errors.append(msg)
|
|
387
|
+
|
|
388
|
+
if not success:
|
|
389
|
+
if errors:
|
|
390
|
+
console.print(f"[red][FAIL][/] {errors[0]}")
|
|
391
|
+
else:
|
|
392
|
+
console.print("[red][FAIL][/] Token is invalid or expired.")
|
cli/commands/stash.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""commands/stash.py stash add, ls, pop commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich import box
|
|
9
|
+
|
|
10
|
+
from cli.api import CloudClient, APIError
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Stash temporary file storage with auto-expiry.")
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _client() -> CloudClient:
|
|
17
|
+
return CloudClient()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command("ls")
|
|
21
|
+
def stash_ls():
|
|
22
|
+
"""List your stashed files."""
|
|
23
|
+
client = _client()
|
|
24
|
+
try:
|
|
25
|
+
data = client.get("/stash_page")
|
|
26
|
+
except APIError as e:
|
|
27
|
+
console.print(f"[red]-[/] {e}")
|
|
28
|
+
raise typer.Exit(1)
|
|
29
|
+
|
|
30
|
+
files = data.get("stashed_files", []) or data.get("files", [])
|
|
31
|
+
if not files:
|
|
32
|
+
console.print("[dim]No stashed files.[/dim]")
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
t = Table(title="Stashed Files", box=box.ROUNDED, header_style="bold yellow")
|
|
36
|
+
t.add_column("ID", justify="right", style="dim")
|
|
37
|
+
t.add_column("Name")
|
|
38
|
+
t.add_column("Size", justify="right", style="green")
|
|
39
|
+
t.add_column("Expires", style="dim")
|
|
40
|
+
|
|
41
|
+
for f in files:
|
|
42
|
+
t.add_row(
|
|
43
|
+
str(f.get("id", "?")),
|
|
44
|
+
f.get("filename") or f.get("name") or f.get("original_filename") or "-",
|
|
45
|
+
_fmt(f.get("size") or f.get("file_size") or 0),
|
|
46
|
+
str(f.get("stash_expiry_at") or "")[:16],
|
|
47
|
+
)
|
|
48
|
+
console.print(t)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command("add")
|
|
52
|
+
def stash_add(
|
|
53
|
+
file_ids: List[int] = typer.Argument(..., help="File ID(s) to stash"),
|
|
54
|
+
days: int = typer.Option(7, "--days", "-d", help="Expiry in days"),
|
|
55
|
+
):
|
|
56
|
+
"""Move file(s) to the stash (temporary storage with expiry)."""
|
|
57
|
+
client = _client()
|
|
58
|
+
for fid in file_ids:
|
|
59
|
+
try:
|
|
60
|
+
result = client.post("/api/batch_stash", json={"file_ids": [fid], "days": days})
|
|
61
|
+
if result.get("success"):
|
|
62
|
+
console.print(f"[green][OK][/] File {fid} stashed for {days} day(s).")
|
|
63
|
+
else:
|
|
64
|
+
console.print(f"[red][FAIL][/] File {fid}: {result.get('message', 'Error')}")
|
|
65
|
+
except APIError as e:
|
|
66
|
+
console.print(f"[red]-[/] File {fid}: {e}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command("pop")
|
|
70
|
+
def stash_pop(
|
|
71
|
+
file_ids: List[int] = typer.Argument(..., help="File ID(s) to unstash"),
|
|
72
|
+
):
|
|
73
|
+
"""Restore file(s) from the stash back to your storage."""
|
|
74
|
+
client = _client()
|
|
75
|
+
for fid in file_ids:
|
|
76
|
+
try:
|
|
77
|
+
result = client.post("/api/batch_unstash", json={"file_ids": [fid]})
|
|
78
|
+
if result.get("success"):
|
|
79
|
+
console.print(f"[green][OK][/] File {fid} restored from stash.")
|
|
80
|
+
else:
|
|
81
|
+
console.print(f"[red][FAIL][/] File {fid}: {result.get('message', 'Error')}")
|
|
82
|
+
except APIError as e:
|
|
83
|
+
console.print(f"[red]-[/] File {fid}: {e}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _fmt(b: int) -> str:
|
|
87
|
+
import math
|
|
88
|
+
units = ["B", "KB", "MB", "GB"]
|
|
89
|
+
if b == 0:
|
|
90
|
+
return "0 B"
|
|
91
|
+
idx = min(int(math.log(max(b, 1), 1024)), len(units) - 1)
|
|
92
|
+
return f"{b / (1024**idx):.1f} {units[idx]}"
|
cli/commands/storage.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""commands/storage.py -- storage stats and nuke commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich import box
|
|
9
|
+
|
|
10
|
+
from cli.api import CloudClient, APIError
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Storage -- view stats and manage your storage quota.")
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _client() -> CloudClient:
|
|
17
|
+
return CloudClient()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _fmt(b: int | float | None) -> str:
|
|
21
|
+
import math
|
|
22
|
+
if b is None:
|
|
23
|
+
return "-"
|
|
24
|
+
b = float(b)
|
|
25
|
+
if b == 0:
|
|
26
|
+
return "0 B"
|
|
27
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
28
|
+
idx = min(int(math.log(b, 1024)), len(units) - 1)
|
|
29
|
+
return f"{b / (1024 ** idx):.1f} {units[idx]}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command("stats")
|
|
33
|
+
def stats():
|
|
34
|
+
"""Show your storage usage and account statistics."""
|
|
35
|
+
client = _client()
|
|
36
|
+
try:
|
|
37
|
+
data = client.get("/api/user/stats")
|
|
38
|
+
except APIError as e:
|
|
39
|
+
console.print(f"[red][FAIL][/] {e}")
|
|
40
|
+
raise typer.Exit(1)
|
|
41
|
+
|
|
42
|
+
used = int(data.get("storage_used") or 0)
|
|
43
|
+
total = int(data.get("storage_limit") or 0)
|
|
44
|
+
limit_label = data.get("storage_limit_label") or (_fmt(total) if total else "Unlimited")
|
|
45
|
+
files = int(data.get("file_count") or 0)
|
|
46
|
+
folders = int(data.get("folder_count") or 0)
|
|
47
|
+
stashed = int(data.get("stash_count") or 0)
|
|
48
|
+
shared = int(data.get("shared_count") or 0)
|
|
49
|
+
|
|
50
|
+
pct = f"{(used / total * 100):.1f}%" if total else "N/A"
|
|
51
|
+
|
|
52
|
+
bar_width = 30
|
|
53
|
+
filled = int((used / total) * bar_width) if total else 0
|
|
54
|
+
bar = "[green]" + "#" * filled + "[/][dim]" + "-" * (bar_width - filled) + "[/]"
|
|
55
|
+
|
|
56
|
+
console.print(Panel(
|
|
57
|
+
f"[bold]Storage Used:[/bold] {_fmt(used)} / {limit_label} ({pct})\n"
|
|
58
|
+
f"{bar}\n\n"
|
|
59
|
+
f"[dim]Files:[/dim] {files}\n"
|
|
60
|
+
f"[dim]Folders:[/dim] {folders}\n"
|
|
61
|
+
f"[dim]Stashed:[/dim] {stashed}\n"
|
|
62
|
+
f"[dim]Shared:[/dim] {shared}",
|
|
63
|
+
title="[bold cyan]myCloud Storage[/bold cyan]",
|
|
64
|
+
border_style="cyan",
|
|
65
|
+
))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command("nuke")
|
|
69
|
+
def nuke(
|
|
70
|
+
target: str = typer.Option("all", "--target", "-t",
|
|
71
|
+
help="What to delete: 'all', 'central', or a node ID"),
|
|
72
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
73
|
+
):
|
|
74
|
+
"""[DANGER] Delete ALL files and folders. Cannot be undone."""
|
|
75
|
+
if not yes:
|
|
76
|
+
console.print("[bold red][WARN] This will permanently delete ALL your files and folders![/]")
|
|
77
|
+
typer.confirm("Are you absolutely sure?", abort=True)
|
|
78
|
+
typer.confirm("This CANNOT be undone. Proceed?", abort=True)
|
|
79
|
+
|
|
80
|
+
client = _client()
|
|
81
|
+
try:
|
|
82
|
+
result = client.post("/delete_all_files", data={"target_node": target})
|
|
83
|
+
if result.get("success"):
|
|
84
|
+
console.print("[green][OK][/] All files and folders deleted.")
|
|
85
|
+
else:
|
|
86
|
+
console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
|
|
87
|
+
except APIError as e:
|
|
88
|
+
console.print(f"[red][FAIL][/] {e}")
|
cli/config.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
config.py myCloud CLI configuration and credential management.
|
|
3
|
+
|
|
4
|
+
Credentials are stored in ~/.mycloud/credentials.json.
|
|
5
|
+
The server base URL is read from:
|
|
6
|
+
1. $MYCLOUD_URL environment variable
|
|
7
|
+
2. ~/.mycloud/config.toml [url = "https://..."]
|
|
8
|
+
3. Falls back to the default production URL.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Paths
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
CONFIG_DIR = Path.home() / ".mycloud"
|
|
20
|
+
CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
|
|
21
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
22
|
+
|
|
23
|
+
DEFAULT_URL = "https://cloud.mysphere.co.in"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_base_url() -> str:
|
|
27
|
+
"""Return the server base URL (trailing slash stripped)."""
|
|
28
|
+
# 1. Environment variable
|
|
29
|
+
env = os.environ.get("MYCLOUD_URL", "").strip()
|
|
30
|
+
if env:
|
|
31
|
+
return env.rstrip("/")
|
|
32
|
+
|
|
33
|
+
# 2. Try credentials.json first since `login` saves it there
|
|
34
|
+
creds = load_credentials()
|
|
35
|
+
if creds.get("base_url"):
|
|
36
|
+
return creds["base_url"].rstrip("/")
|
|
37
|
+
|
|
38
|
+
# 3. Config file (minimal TOML just look for `url = "..."`)
|
|
39
|
+
if CONFIG_FILE.exists():
|
|
40
|
+
for line in CONFIG_FILE.read_text(encoding="utf-8").splitlines():
|
|
41
|
+
line = line.strip()
|
|
42
|
+
if line.startswith("url") and "=" in line:
|
|
43
|
+
value = line.split("=", 1)[1].strip().strip('"').strip("'")
|
|
44
|
+
if value:
|
|
45
|
+
return value.rstrip("/")
|
|
46
|
+
|
|
47
|
+
return DEFAULT_URL
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Credentials
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def load_credentials() -> dict:
|
|
55
|
+
"""Return stored credentials dict or empty dict."""
|
|
56
|
+
if CREDENTIALS_FILE.exists():
|
|
57
|
+
try:
|
|
58
|
+
return json.loads(CREDENTIALS_FILE.read_text(encoding="utf-8"))
|
|
59
|
+
except Exception:
|
|
60
|
+
return {}
|
|
61
|
+
return {}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def save_credentials(data: dict) -> None:
|
|
65
|
+
"""Persist credentials to disk (mode 600)."""
|
|
66
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
CREDENTIALS_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
68
|
+
try:
|
|
69
|
+
CREDENTIALS_FILE.chmod(0o600)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def clear_credentials() -> None:
|
|
75
|
+
"""Delete stored credentials."""
|
|
76
|
+
if CREDENTIALS_FILE.exists():
|
|
77
|
+
CREDENTIALS_FILE.unlink()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_token() -> str:
|
|
81
|
+
"""Return the stored bearer token, or empty string."""
|
|
82
|
+
return load_credentials().get("token", "")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_stored_user() -> dict:
|
|
86
|
+
"""Return the stored user info dict."""
|
|
87
|
+
return load_credentials().get("user", {})
|
cli/ui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# cli/ui/__init__.py
|