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/folders.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""commands/folders.py -- mkdir, rm, rename, info commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich import box
|
|
10
|
+
|
|
11
|
+
from cli.api import CloudClient, APIError
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Folder management -- create and manage folders.")
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _client() -> CloudClient:
|
|
18
|
+
return CloudClient()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _fmt(b: int | float | None) -> str:
|
|
22
|
+
import math
|
|
23
|
+
if b is None:
|
|
24
|
+
return "-"
|
|
25
|
+
b = float(b)
|
|
26
|
+
if b == 0:
|
|
27
|
+
return "0 B"
|
|
28
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
29
|
+
idx = min(int(math.log(b, 1024)), len(units) - 1)
|
|
30
|
+
return f"{b / (1024 ** idx):.1f} {units[idx]}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command("mkdir")
|
|
34
|
+
def mkdir(
|
|
35
|
+
name: str = typer.Argument(..., help="Folder name"),
|
|
36
|
+
parent_id: Optional[int] = typer.Option(None, "--parent", "-p", help="Parent folder ID"),
|
|
37
|
+
node: str = typer.Option("central", "--node", "-n", help="Storage node"),
|
|
38
|
+
):
|
|
39
|
+
"""Create a new folder."""
|
|
40
|
+
client = _client()
|
|
41
|
+
body: dict = {"folder_name": name}
|
|
42
|
+
if parent_id is not None:
|
|
43
|
+
body["parent_id"] = parent_id
|
|
44
|
+
if node != "central":
|
|
45
|
+
body["storage_mode"] = node
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
result = client.post("/create_folder", json=body)
|
|
49
|
+
folder_id = result.get("folder_id") or result.get("id")
|
|
50
|
+
if result.get("success") or folder_id:
|
|
51
|
+
console.print(f"[green][OK][/] Folder '[bold]{name}[/]' created (id={folder_id}).")
|
|
52
|
+
else:
|
|
53
|
+
console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
|
|
54
|
+
except APIError as e:
|
|
55
|
+
console.print(f"[red][FAIL][/] {e}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command("rm")
|
|
59
|
+
def rmdir(
|
|
60
|
+
folder_id: int = typer.Argument(..., help="Folder ID to delete"),
|
|
61
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
62
|
+
):
|
|
63
|
+
"""Delete a folder and ALL its contents. Cannot be undone."""
|
|
64
|
+
if not yes:
|
|
65
|
+
typer.confirm(
|
|
66
|
+
f"Delete folder {folder_id} and all its contents? This cannot be undone.",
|
|
67
|
+
abort=True,
|
|
68
|
+
)
|
|
69
|
+
client = _client()
|
|
70
|
+
try:
|
|
71
|
+
result = client.post(f"/delete_folder/{folder_id}")
|
|
72
|
+
if result.get("success"):
|
|
73
|
+
console.print(f"[green][OK][/] Folder {folder_id} deleted.")
|
|
74
|
+
else:
|
|
75
|
+
console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
|
|
76
|
+
except APIError as e:
|
|
77
|
+
console.print(f"[red][FAIL][/] {e}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command("rename")
|
|
81
|
+
def rename_folder(
|
|
82
|
+
folder_id: int = typer.Argument(..., help="Folder ID to rename"),
|
|
83
|
+
new_name: str = typer.Argument(..., help="New folder name"),
|
|
84
|
+
):
|
|
85
|
+
"""Rename a folder."""
|
|
86
|
+
client = _client()
|
|
87
|
+
try:
|
|
88
|
+
result = client.post(f"/rename_folder/{folder_id}", json={"name": new_name})
|
|
89
|
+
if result.get("success"):
|
|
90
|
+
actual = result.get("new_name", new_name)
|
|
91
|
+
console.print(f"[green][OK][/] Folder renamed to '[bold]{actual}[/]'.")
|
|
92
|
+
else:
|
|
93
|
+
console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
|
|
94
|
+
except APIError as e:
|
|
95
|
+
console.print(f"[red][FAIL][/] {e}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command("info")
|
|
99
|
+
def folder_info(
|
|
100
|
+
folder_id: int = typer.Argument(..., help="Folder ID"),
|
|
101
|
+
):
|
|
102
|
+
"""Show detailed info about a folder."""
|
|
103
|
+
client = _client()
|
|
104
|
+
try:
|
|
105
|
+
data = client.get(f"/api/folder_details/{folder_id}")
|
|
106
|
+
except APIError as e:
|
|
107
|
+
console.print(f"[red][FAIL][/] {e}")
|
|
108
|
+
raise typer.Exit(1)
|
|
109
|
+
|
|
110
|
+
folder = data.get("folder") or data
|
|
111
|
+
files = data.get("files", [])
|
|
112
|
+
subfolders = data.get("subfolders") or data.get("folders", [])
|
|
113
|
+
|
|
114
|
+
console.print(Panel(
|
|
115
|
+
f"[bold]{folder.get('name', '?')}[/bold]\n"
|
|
116
|
+
f"[dim]ID:[/dim] {folder.get('id', '?')}\n"
|
|
117
|
+
f"[dim]Parent ID:[/dim] {folder.get('parent_id') or 'root'}\n"
|
|
118
|
+
f"[dim]Created:[/dim] {str(folder.get('created_at', '?'))[:16]}\n"
|
|
119
|
+
f"[dim]Storage:[/dim] {folder.get('storage_mode') or 'central'}\n"
|
|
120
|
+
f"[dim]Files:[/dim] {len(files)}\n"
|
|
121
|
+
f"[dim]Subfolders:[/dim] {len(subfolders)}",
|
|
122
|
+
title=f"[bold]Folder Details[/bold]",
|
|
123
|
+
border_style="magenta",
|
|
124
|
+
))
|
|
125
|
+
|
|
126
|
+
if subfolders:
|
|
127
|
+
t = Table(title="Subfolders", box=box.SIMPLE, header_style="bold magenta")
|
|
128
|
+
t.add_column("ID", style="dim", justify="right")
|
|
129
|
+
t.add_column("Name")
|
|
130
|
+
for sf in subfolders:
|
|
131
|
+
t.add_row(str(sf.get("id", "?")), sf.get("name", "-"))
|
|
132
|
+
console.print(t)
|
|
133
|
+
|
|
134
|
+
if files:
|
|
135
|
+
t = Table(title="Files", box=box.SIMPLE, header_style="bold cyan")
|
|
136
|
+
t.add_column("ID", style="dim", justify="right")
|
|
137
|
+
t.add_column("Name")
|
|
138
|
+
t.add_column("Size", justify="right", style="green")
|
|
139
|
+
for f in files:
|
|
140
|
+
t.add_row(
|
|
141
|
+
str(f.get("id", "?")),
|
|
142
|
+
f.get("original_filename") or f.get("name") or "-",
|
|
143
|
+
_fmt(f.get("file_size") or f.get("size")),
|
|
144
|
+
)
|
|
145
|
+
console.print(t)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@app.command("mv")
|
|
149
|
+
def mv(
|
|
150
|
+
folder_id: int = typer.Argument(..., help="Folder ID to move"),
|
|
151
|
+
dest_id: int = typer.Argument(..., help="Destination parent folder ID (0 for root)"),
|
|
152
|
+
):
|
|
153
|
+
"""Move a folder to another parent folder."""
|
|
154
|
+
client = _client()
|
|
155
|
+
try:
|
|
156
|
+
dest_val = None if dest_id == 0 else dest_id
|
|
157
|
+
result = client.post("/api/move_folder", json={
|
|
158
|
+
"folder_id": folder_id,
|
|
159
|
+
"destination_folder_id": dest_val
|
|
160
|
+
})
|
|
161
|
+
if result.get("success"):
|
|
162
|
+
console.print(f"[green][OK][/] Folder {folder_id} moved to folder {dest_id}.")
|
|
163
|
+
else:
|
|
164
|
+
console.print(f"[red][FAIL][/] {result.get('error') or result.get('message', 'Move failed')}")
|
|
165
|
+
except APIError as e:
|
|
166
|
+
console.print(f"[red][FAIL][/] {e}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command("download")
|
|
170
|
+
def download(
|
|
171
|
+
folder_id: int = typer.Argument(..., help="Folder ID to download"),
|
|
172
|
+
output: str = typer.Option(".", "--output", "-o", help="Output directory"),
|
|
173
|
+
):
|
|
174
|
+
"""Download a folder and its contents as a zip archive."""
|
|
175
|
+
from pathlib import Path
|
|
176
|
+
client = _client()
|
|
177
|
+
out_dir = Path(output)
|
|
178
|
+
|
|
179
|
+
console.print(f"[cyan]Preparing download for folder {folder_id}[/]")
|
|
180
|
+
try:
|
|
181
|
+
path = client.download_folder(folder_id, out_dir)
|
|
182
|
+
console.print(f"[green][OK][/] Saved to [bold]{path}[/bold]")
|
|
183
|
+
except APIError as e:
|
|
184
|
+
console.print(f"[red][FAIL][/] {e}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.command("favorite")
|
|
188
|
+
def favorite(
|
|
189
|
+
folder_id: int = typer.Argument(..., help="Folder ID"),
|
|
190
|
+
):
|
|
191
|
+
"""Toggle favorite status for a folder."""
|
|
192
|
+
client = _client()
|
|
193
|
+
try:
|
|
194
|
+
result = client.post(f"/api/folders/{folder_id}/favorite")
|
|
195
|
+
state = result.get("is_favorite")
|
|
196
|
+
icon = "[red][/]" if state else "[dim][/]"
|
|
197
|
+
label = "Favorited" if state else "Un-favorited"
|
|
198
|
+
console.print(f"{icon} {label} folder {folder_id}.")
|
|
199
|
+
except APIError as e:
|
|
200
|
+
console.print(f"[red][FAIL][/] {e}")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""commands/notifications.py list and mark-read notifications."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from cli.api import CloudClient, APIError
|
|
9
|
+
from cli.ui.table import notifications_table
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Notifications view and manage your notifications.")
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _client() -> CloudClient:
|
|
16
|
+
return CloudClient()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command("ls")
|
|
20
|
+
def notif_ls(
|
|
21
|
+
limit: int = typer.Option(20, "--limit", "-l", help="Max notifications to show"),
|
|
22
|
+
):
|
|
23
|
+
"""List your notifications."""
|
|
24
|
+
client = _client()
|
|
25
|
+
try:
|
|
26
|
+
data = client.get("/api/notifications", limit=limit)
|
|
27
|
+
except APIError as e:
|
|
28
|
+
console.print(f"[red]-[/] {e}")
|
|
29
|
+
raise typer.Exit(1)
|
|
30
|
+
|
|
31
|
+
notifs = data.get("notifications", []) or data.get("data", [])
|
|
32
|
+
if not notifs:
|
|
33
|
+
console.print("[dim]No notifications.[/dim]")
|
|
34
|
+
return
|
|
35
|
+
console.print(notifications_table(notifs))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command("read")
|
|
39
|
+
def notif_read(
|
|
40
|
+
ids: Optional[str] = typer.Argument(None,
|
|
41
|
+
help="Comma-separated notification IDs to mark read, or omit to mark all."),
|
|
42
|
+
):
|
|
43
|
+
"""Mark notifications as read."""
|
|
44
|
+
client = _client()
|
|
45
|
+
body: dict = {}
|
|
46
|
+
if ids:
|
|
47
|
+
id_list = [int(x.strip()) for x in ids.split(",") if x.strip().isdigit()]
|
|
48
|
+
body["notification_ids"] = id_list
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
result = client.post("/api/notifications/mark_read", json=body)
|
|
52
|
+
if result.get("success"):
|
|
53
|
+
console.print("[green][OK][/] Notifications marked as read.")
|
|
54
|
+
else:
|
|
55
|
+
console.print(f"[red]-[/] {result.get('message', 'Error')}")
|
|
56
|
+
except APIError as e:
|
|
57
|
+
console.print(f"[red]-[/] {e}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command("hide")
|
|
61
|
+
def notif_hide(
|
|
62
|
+
ids: str = typer.Argument(..., help="Comma-separated notification IDs to hide"),
|
|
63
|
+
):
|
|
64
|
+
"""Permanently hide notifications by ID."""
|
|
65
|
+
client = _client()
|
|
66
|
+
id_list = [int(x.strip()) for x in ids.split(",") if x.strip().isdigit()]
|
|
67
|
+
if not id_list:
|
|
68
|
+
console.print("[yellow]No valid IDs provided.[/yellow]")
|
|
69
|
+
raise typer.Exit(1)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
result = client.post("/api/notifications/hide", json={"notification_ids": id_list})
|
|
73
|
+
if result.get("success"):
|
|
74
|
+
console.print(f"[green][OK][/] {len(id_list)} notification(s) hidden.")
|
|
75
|
+
else:
|
|
76
|
+
console.print(f"[red]-[/] {result.get('message', 'Error')}")
|
|
77
|
+
except APIError as e:
|
|
78
|
+
console.print(f"[red]-[/] {e}")
|
|
79
|
+
|
cli/commands/prefs.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""commands/prefs.py -- user preferences management."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import 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="Preferences -- view and update your account preferences.")
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _client() -> CloudClient:
|
|
17
|
+
return CloudClient()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command("show")
|
|
21
|
+
def prefs_show():
|
|
22
|
+
"""Display all your current preferences."""
|
|
23
|
+
client = _client()
|
|
24
|
+
try:
|
|
25
|
+
data = client.get("/api/user_preferences")
|
|
26
|
+
except APIError as e:
|
|
27
|
+
console.print(f"[red][FAIL][/] {e}")
|
|
28
|
+
raise typer.Exit(1)
|
|
29
|
+
|
|
30
|
+
prefs = data.get("preferences") or data.get("data") or data
|
|
31
|
+
if not prefs or not isinstance(prefs, dict):
|
|
32
|
+
console.print("[dim]No preferences found.[/dim]")
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
t = Table(title="Your Preferences", box=box.ROUNDED, header_style="bold cyan")
|
|
36
|
+
t.add_column("Key", style="bold")
|
|
37
|
+
t.add_column("Value")
|
|
38
|
+
|
|
39
|
+
for k, v in prefs.items():
|
|
40
|
+
t.add_row(str(k), str(v))
|
|
41
|
+
console.print(t)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command("set")
|
|
45
|
+
def prefs_set(
|
|
46
|
+
key: str = typer.Argument(..., help="Preference key"),
|
|
47
|
+
value: str = typer.Argument(..., help="New value"),
|
|
48
|
+
):
|
|
49
|
+
"""Set a user preference key to a value."""
|
|
50
|
+
client = _client()
|
|
51
|
+
try:
|
|
52
|
+
result = client.post("/api/user_preferences", json={key: value})
|
|
53
|
+
if result.get("success") is not False:
|
|
54
|
+
console.print(f"[green][OK][/] Preference '[bold]{key}[/]' set to '[bold]{value}[/]'.")
|
|
55
|
+
else:
|
|
56
|
+
console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
|
|
57
|
+
except APIError as e:
|
|
58
|
+
console.print(f"[red][FAIL][/] {e}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command("sync")
|
|
62
|
+
def prefs_sync():
|
|
63
|
+
"""Sync preferences across devices."""
|
|
64
|
+
client = _client()
|
|
65
|
+
try:
|
|
66
|
+
result = client.post("/api/user_preferences/sync")
|
|
67
|
+
if result.get("success") is not False:
|
|
68
|
+
console.print("[green][OK][/] Preferences synced.")
|
|
69
|
+
else:
|
|
70
|
+
console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
|
|
71
|
+
except APIError as e:
|
|
72
|
+
console.print(f"[red][FAIL][/] {e}")
|
cli/commands/profile.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""commands/profile.py view/update profile, 2FA toggle, change password."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.prompt import Prompt
|
|
10
|
+
|
|
11
|
+
from cli.api import CloudClient, APIError
|
|
12
|
+
from cli.config import get_stored_user
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Profile view and update your account settings.")
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _client() -> CloudClient:
|
|
19
|
+
return CloudClient()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command("show")
|
|
23
|
+
def profile_show():
|
|
24
|
+
"""Display your profile information."""
|
|
25
|
+
user = get_stored_user()
|
|
26
|
+
if not user:
|
|
27
|
+
console.print("[yellow]Not logged in.[/yellow]")
|
|
28
|
+
raise typer.Exit(1)
|
|
29
|
+
|
|
30
|
+
client = _client()
|
|
31
|
+
try:
|
|
32
|
+
data = client.get("/settings")
|
|
33
|
+
except APIError as e:
|
|
34
|
+
# Fall back to cached data
|
|
35
|
+
data = {}
|
|
36
|
+
|
|
37
|
+
u = data.get("user") or user
|
|
38
|
+
|
|
39
|
+
console.print(Panel(
|
|
40
|
+
f"[bold]{u.get('name', '?')}[/bold]\n"
|
|
41
|
+
f"[dim]Username:[/dim] {u.get('username', '?')}\n"
|
|
42
|
+
f"[dim]Email:[/dim] {u.get('email', '?')}\n"
|
|
43
|
+
f"[dim]2FA:[/dim] {'[green]enabled[/]' if u.get('two_factor_enabled') else '[dim]disabled[/]'}\n"
|
|
44
|
+
f"[dim]Member since:[/dim] {str(u.get('created_at', '?'))[:10]}",
|
|
45
|
+
title="[bold]Your Profile[/bold]",
|
|
46
|
+
border_style="cyan",
|
|
47
|
+
))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command("update")
|
|
51
|
+
def profile_update(
|
|
52
|
+
name: Optional[str] = typer.Option(None, "--name", help="Display name"),
|
|
53
|
+
username: Optional[str] = typer.Option(None, "--username", help="New username"),
|
|
54
|
+
):
|
|
55
|
+
"""Update your display name or username."""
|
|
56
|
+
if not name and not username:
|
|
57
|
+
console.print("[yellow]Provide --name or --username to update.[/yellow]")
|
|
58
|
+
raise typer.Exit(1)
|
|
59
|
+
|
|
60
|
+
client = _client()
|
|
61
|
+
body: dict = {}
|
|
62
|
+
if name:
|
|
63
|
+
body["name"] = name
|
|
64
|
+
if username:
|
|
65
|
+
body["username"] = username
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
result = client.post("/update_profile", json=body)
|
|
69
|
+
if result.get("success"):
|
|
70
|
+
console.print("[green][/] Profile updated.")
|
|
71
|
+
else:
|
|
72
|
+
console.print(f"[red]-[/] {result.get('message', 'Error')}")
|
|
73
|
+
except APIError as e:
|
|
74
|
+
console.print(f"[red]-[/] {e}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command("2fa")
|
|
78
|
+
def profile_2fa(
|
|
79
|
+
enable: Optional[bool] = typer.Option(None, "--enable/--disable",
|
|
80
|
+
help="Enable or disable 2FA"),
|
|
81
|
+
):
|
|
82
|
+
"""Toggle two-factor authentication."""
|
|
83
|
+
if enable is None:
|
|
84
|
+
user = get_stored_user()
|
|
85
|
+
current = bool(user.get("two_factor_enabled"))
|
|
86
|
+
enable = not current
|
|
87
|
+
console.print(f"[dim]Currently: {'enabled' if current else 'disabled'}. Toggling[/dim]")
|
|
88
|
+
|
|
89
|
+
client = _client()
|
|
90
|
+
try:
|
|
91
|
+
result = client.post("/settings/two-factor", json={"enabled": enable})
|
|
92
|
+
if result.get("success"):
|
|
93
|
+
state = "enabled" if enable else "disabled"
|
|
94
|
+
console.print(f"[green][/] 2FA {state}.")
|
|
95
|
+
else:
|
|
96
|
+
console.print(f"[red]-[/] {result.get('message', 'Error')}")
|
|
97
|
+
except APIError as e:
|
|
98
|
+
console.print(f"[red]-[/] {e}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.command("avatar-upload")
|
|
102
|
+
def avatar_upload(
|
|
103
|
+
image: Path = typer.Argument(..., help="Path to the image file"),
|
|
104
|
+
):
|
|
105
|
+
"""Upload a new profile picture."""
|
|
106
|
+
if not image.exists() or not image.is_file():
|
|
107
|
+
console.print(f"[red]File not found:[/] {image}")
|
|
108
|
+
raise typer.Exit(1)
|
|
109
|
+
|
|
110
|
+
client = _client()
|
|
111
|
+
try:
|
|
112
|
+
result = client.upload_avatar(image)
|
|
113
|
+
if result.get("success"):
|
|
114
|
+
console.print("[green][OK][/] Profile picture uploaded successfully.")
|
|
115
|
+
else:
|
|
116
|
+
console.print(f"[red]-[/] {result.get('message', 'Error')}")
|
|
117
|
+
except APIError as e:
|
|
118
|
+
console.print(f"[red]-[/] {e}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command("avatar-rm")
|
|
122
|
+
def avatar_remove():
|
|
123
|
+
"""Remove your current profile picture."""
|
|
124
|
+
client = _client()
|
|
125
|
+
try:
|
|
126
|
+
result = client.post("/remove_pfp")
|
|
127
|
+
if result.get("success"):
|
|
128
|
+
console.print("[green][OK][/] Profile picture removed.")
|
|
129
|
+
else:
|
|
130
|
+
console.print(f"[red]-[/] {result.get('message', 'Error')}")
|
|
131
|
+
except APIError as e:
|
|
132
|
+
console.print(f"[red]-[/] {e}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@app.command("delete-account")
|
|
136
|
+
def profile_delete_account(
|
|
137
|
+
confirm: bool = typer.Option(False, "--yes", "-y", help="Confirm deletion without prompting"),
|
|
138
|
+
):
|
|
139
|
+
"""Permanently delete your account and all associated data."""
|
|
140
|
+
if not confirm:
|
|
141
|
+
sure = Prompt.ask("[bold red]Are you absolutely sure you want to delete your account? This cannot be undone[/bold red] (y/N)")
|
|
142
|
+
if sure.lower() not in ('y', 'yes'):
|
|
143
|
+
console.print("Account deletion aborted.")
|
|
144
|
+
raise typer.Exit()
|
|
145
|
+
|
|
146
|
+
client = _client()
|
|
147
|
+
try:
|
|
148
|
+
result = client.post("/delete_account")
|
|
149
|
+
if result.get("success"):
|
|
150
|
+
console.print(f"[green][OK][/] {result.get('message', 'Account deleted.')}")
|
|
151
|
+
# Clear local config since we are logged out
|
|
152
|
+
from cli.config import clear_credentials
|
|
153
|
+
clear_credentials()
|
|
154
|
+
else:
|
|
155
|
+
console.print(f"[red]-[/] {result.get('error', 'Error deleting account.')}")
|
|
156
|
+
except APIError as e:
|
|
157
|
+
console.print(f"[red]-[/] {e}")
|
cli/commands/search.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""commands/search.py live search (find) command."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from rich import box
|
|
8
|
+
|
|
9
|
+
from cli.api import CloudClient, APIError
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Search files and folders.")
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _client() -> CloudClient:
|
|
16
|
+
return CloudClient()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command("find")
|
|
20
|
+
def find(
|
|
21
|
+
query: str = typer.Argument(..., help="Search query"),
|
|
22
|
+
node: str = typer.Option("central", "--node", "-n", help="Node to search in"),
|
|
23
|
+
):
|
|
24
|
+
"""Search for files and folders by name."""
|
|
25
|
+
client = _client()
|
|
26
|
+
try:
|
|
27
|
+
data = client.get("/live_search", q=query, node=node)
|
|
28
|
+
except APIError as e:
|
|
29
|
+
console.print(f"[red]-[/] {e}")
|
|
30
|
+
raise typer.Exit(1)
|
|
31
|
+
|
|
32
|
+
if isinstance(data, list):
|
|
33
|
+
files = [item for item in data if item.get("item_type") == "file"]
|
|
34
|
+
folders = [item for item in data if item.get("item_type") == "folder"]
|
|
35
|
+
else:
|
|
36
|
+
files = data.get("files", [])
|
|
37
|
+
folders = data.get("folders", [])
|
|
38
|
+
|
|
39
|
+
if not files and not folders:
|
|
40
|
+
console.print(f"[dim]No results for '[bold]{query}[/]'.[/dim]")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
t = Table(
|
|
44
|
+
title=f"Results for '{query}'",
|
|
45
|
+
box=box.ROUNDED,
|
|
46
|
+
header_style="bold cyan",
|
|
47
|
+
)
|
|
48
|
+
t.add_column("ID", justify="right", style="dim")
|
|
49
|
+
t.add_column("Type")
|
|
50
|
+
t.add_column("Name")
|
|
51
|
+
t.add_column("Size / Created", style="dim")
|
|
52
|
+
|
|
53
|
+
for fo in folders:
|
|
54
|
+
t.add_row(
|
|
55
|
+
str(fo.get("id", "?")),
|
|
56
|
+
" folder",
|
|
57
|
+
fo.get("name") or "",
|
|
58
|
+
str(fo.get("created_at", ""))[:16],
|
|
59
|
+
)
|
|
60
|
+
for f in files:
|
|
61
|
+
size = f.get("size") or f.get("file_size") or 0
|
|
62
|
+
t.add_row(
|
|
63
|
+
str(f.get("id", "?")),
|
|
64
|
+
" file",
|
|
65
|
+
f.get("filename") or f.get("name") or "",
|
|
66
|
+
_fmt(size),
|
|
67
|
+
)
|
|
68
|
+
console.print(t)
|
|
69
|
+
console.print(f"[dim]{len(folders)} folder(s) {len(files)} file(s)[/dim]")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _fmt(b: int) -> str:
|
|
73
|
+
import math
|
|
74
|
+
units = ["B", "KB", "MB", "GB"]
|
|
75
|
+
if b == 0:
|
|
76
|
+
return "0 B"
|
|
77
|
+
idx = min(int(math.log(max(b, 1), 1024)), len(units) - 1)
|
|
78
|
+
return f"{b / (1024**idx):.1f} {units[idx]}"
|
cli/commands/servers.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""commands/servers.py private node (server) management."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich import box
|
|
9
|
+
|
|
10
|
+
from cli.api import CloudClient, APIError
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Private servers (nodes) list, add, and remove.")
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _client() -> CloudClient:
|
|
17
|
+
return CloudClient()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command("ls")
|
|
21
|
+
def servers_ls():
|
|
22
|
+
"""List your registered private servers (nodes)."""
|
|
23
|
+
client = _client()
|
|
24
|
+
try:
|
|
25
|
+
data = client.get("/settings/servers")
|
|
26
|
+
except APIError as e:
|
|
27
|
+
console.print(f"[red]-[/] {e}")
|
|
28
|
+
raise typer.Exit(1)
|
|
29
|
+
|
|
30
|
+
nodes = data.get("nodes", []) or data.get("servers", [])
|
|
31
|
+
if not nodes:
|
|
32
|
+
console.print("[dim]No private servers registered.[/dim]")
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
t = Table(title="Private Servers", box=box.ROUNDED, header_style="bold magenta")
|
|
36
|
+
t.add_column("ID", justify="right", style="dim")
|
|
37
|
+
t.add_column("Name")
|
|
38
|
+
t.add_column("Status")
|
|
39
|
+
t.add_column("Last Seen", style="dim")
|
|
40
|
+
|
|
41
|
+
for n in nodes:
|
|
42
|
+
status = n.get("status") or n.get("is_online")
|
|
43
|
+
if status in (True, 1, "online"):
|
|
44
|
+
status_str = "[green]- online[/]"
|
|
45
|
+
else:
|
|
46
|
+
status_str = "[red]- offline[/]"
|
|
47
|
+
t.add_row(
|
|
48
|
+
str(n.get("id", "?")),
|
|
49
|
+
n.get("name") or n.get("label") or "?",
|
|
50
|
+
status_str,
|
|
51
|
+
str(n.get("last_seen") or "")[:16],
|
|
52
|
+
)
|
|
53
|
+
console.print(t)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command("add")
|
|
57
|
+
def servers_add():
|
|
58
|
+
"""Generate a new API key for a private server node."""
|
|
59
|
+
client = _client()
|
|
60
|
+
try:
|
|
61
|
+
result = client.post("/generate_node_key")
|
|
62
|
+
key = result.get("key") or result.get("node_key") or result.get("api_key")
|
|
63
|
+
node_id = result.get("node_id") or result.get("id")
|
|
64
|
+
if key:
|
|
65
|
+
console.print(Panel(
|
|
66
|
+
f"[bold]Node ID:[/bold] {node_id}\n"
|
|
67
|
+
f"[bold]API Key:[/bold] [cyan]{key}[/cyan]\n\n"
|
|
68
|
+
"[dim]Copy this key into your node's config. It will not be shown again.[/dim]",
|
|
69
|
+
title="[bold green]Node Created[/bold green]",
|
|
70
|
+
border_style="green",
|
|
71
|
+
))
|
|
72
|
+
else:
|
|
73
|
+
console.print(f"[red]-[/] {result.get('message', 'Error generating key')}")
|
|
74
|
+
except APIError as e:
|
|
75
|
+
console.print(f"[red]-[/] {e}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command("rm")
|
|
80
|
+
def servers_rm(
|
|
81
|
+
node_id: int = typer.Argument(..., help="Node ID to remove"),
|
|
82
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
83
|
+
):
|
|
84
|
+
"""Remove a private server node."""
|
|
85
|
+
if not yes:
|
|
86
|
+
typer.confirm(f"Remove node {node_id}?", abort=True)
|
|
87
|
+
client = _client()
|
|
88
|
+
try:
|
|
89
|
+
result = client.post(f"/delete_node/{node_id}")
|
|
90
|
+
if result.get("success"):
|
|
91
|
+
console.print(f"[green][/] Node {node_id} removed.")
|
|
92
|
+
else:
|
|
93
|
+
console.print(f"[red]-[/] {result.get('message', 'Error')}")
|
|
94
|
+
except APIError as e:
|
|
95
|
+
console.print(f"[red]-[/] {e}")
|