summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/commands/rm.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 12:32:24 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 12:32:24 +0200
commit988a0eecd522431980174910db28e45a63ff2710 (patch)
tree80b16f8cfe907f084fac931fa35326c7f2892a9d /packages/meshbay-cli/src/meshbay_cli/commands/rm.py
parente0a1036e0fee15b3c6c83f3e935d50f472a2e776 (diff)
downloadmeshbay-988a0eecd522431980174910db28e45a63ff2710.tar.gz
feat(cli): account, rm -r and mkdir, Tab completion
account shows the account and does what the sign-up form and the Profile page do: register (the passphrase floor and the hub's proof of work checked here, the recovery key printed once), verify, change the passphrase, reset a forgotten one, change the e-mail address, sign out everywhere, delete. A new passphrase carries the copy of the account's keys each node keeps for browsers first — opened under the old M, the same keys sealed under the new one — and only then tells the hub; a refusal sets the copies back. A reset restores them from the recovery copy. The connections that do it never make keys nor ask for a device approval. This machine keeps the M it changed away from, so a node out of reach is carried at its next connection; a copy under an M it does not know is no longer rewritten, which locked the browsers out after a passphrase changed elsewhere. rm -r removes a folder and all it holds, from the node's operator key: the files, then the folders deepest first, one confirmation that counts them. mkdir makes folders, --parents the missing ones (not -p, the passphrase). completion bash|powershell prints the script that makes Tab complete actions, verbs, options and the paths of the groups this machine is signed in to, cached two minutes; the deb and rpm packages install the bash one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/commands/rm.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/rm.py100
1 files changed, 78 insertions, 22 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/rm.py b/packages/meshbay-cli/src/meshbay_cli/commands/rm.py
index 6f5e869..7f4a5c6 100644
--- a/packages/meshbay-cli/src/meshbay_cli/commands/rm.py
+++ b/packages/meshbay-cli/src/meshbay_cli/commands/rm.py
@@ -1,12 +1,18 @@
"""
-`rm`: delete files from groups. Asked first; `--yes` answers for a script.
+`rm`: delete files from groups, and with -r folders and all they hold. Asked
+first; `--yes` answers for a script.
Paths and patterns are resolved against each group's index, as `get` resolves
them, and the whole run is checked before anything is asked or sent: a file
this account may not delete — not uploaded by it, and this machine's key not
-the node's operator — refuses the run (exit 8), as a folder does. The node
-checks again on every file; one it refuses is reported and the others go on
-(exit 4).
+the node's operator — refuses the run (exit 8), as a folder does without -r.
+
+With -r, a folder is everything below it: its files first, then its folders,
+deepest first. The node removes only empty folders, and only for its operator's
+key, so -r on a folder is the operator's, and the confirmation counts every
+file before one is deleted. A group's root is never removed. The node checks
+again on every file and folder; one it refuses is reported and the others go
+on (exit 4) — a folder still holding a file it refused stays.
"""
import json
@@ -14,13 +20,14 @@ import json
from ..client.groups import my_groups
from ..client.paths import handle, parse, resolve_group
from ..client.pool import Pool
-from ..client.remove import delete_file, may_delete
+from ..client.remove import delete_dir, delete_file, may_delete
from ..client.tree import Tree
from ..errors import NODE, NOT_FOUND, PARTIAL, REFUSED, USAGE, CliError
-from ..output import confirm, err, safe, sentence
+from ..output import confirm, err, human_size, safe, sentence
from .context import Context
-HELP = "delete files you uploaded (or any, from the node's operator key)"
+HELP = ("delete files you uploaded (or any, from the node's operator key); with -r, "
+ "folders too, from the operator's key")
# How many names the confirmation shows before it counts the rest.
SHOWN = 10
@@ -32,6 +39,8 @@ def add_parser(sub, parent) -> None:
"quote them. Being the group's owner is not enough: the node "
"lets its operator's key, and the uploader's devices, delete.")
p.add_argument("paths", nargs="+", metavar="PATH", help="group[@owner]/root/.../file")
+ p.add_argument("-r", "-R", dest="recursive", action="store_true",
+ help="folders, with everything in them (the node's operator only)")
p.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation")
@@ -51,7 +60,8 @@ async def _run(ctx: Context, account, pool: Pool) -> int:
groups = await my_groups(account)
trees: dict[str, Tree] = {}
targets = [] # (session, entry, shown)
- missing, folders, not_yours = [], [], []
+ dirs = [] # (session, path, shown)
+ missing, folders, not_yours, roots, not_operator = [], [], [], [], []
for text in a.paths:
remote = parse(text)
if remote is None or not remote.parts:
@@ -69,30 +79,65 @@ async def _run(ctx: Context, account, pool: Pool) -> int:
for path in found:
shown = f"{handle(group)}/{path}"
entry = tree.file(path)
- if entry is None:
+ if entry is not None:
+ if not may_delete(session, entry, account.user_id):
+ not_yours.append(shown)
+ elif all(e["id"] != entry["id"] for _, e, _ in targets):
+ targets.append((session, entry, shown))
+ continue
+ if not a.recursive:
folders.append(shown)
- elif not may_delete(session, entry, account.user_id):
- not_yours.append(shown)
- elif all(e["id"] != entry["id"] for _, e, _ in targets):
- targets.append((session, entry, shown))
+ elif "/" not in path:
+ roots.append(shown)
+ elif session.role != "operator":
+ not_operator.append(shown)
+ elif not tree.root_available(path):
+ raise CliError(REFUSED, f"{safe(shown)} is on a drive the node does not see "
+ "now; nothing was deleted.")
+ else:
+ for folder, kids in tree.walk(path):
+ if all(d[1] != folder or d[0] is not session for d in dirs):
+ dirs.append((session, folder, f"{handle(group)}/{folder}"))
+ for kid in kids:
+ if not kid.is_dir and all(e["id"] != kid.entry["id"]
+ for _, e, _ in targets):
+ targets.append((session, kid.entry,
+ f"{handle(group)}/{kid.path}"))
if missing:
raise CliError(NOT_FOUND, "Nothing in your groups matches: "
+ ", ".join(safe(m) for m in missing))
if folders:
- raise CliError(USAGE, "rm deletes files; these are folders: "
- + ", ".join(safe(f) for f in folders))
+ raise CliError(USAGE, "These are folders; -r deletes them with everything in "
+ "them: " + ", ".join(safe(f) for f in folders))
+ if roots:
+ raise CliError(REFUSED, "A group's root is not deleted here — its operator removes "
+ "it from the node: " + ", ".join(safe(r) for r in roots))
+ if not_operator:
+ raise CliError(REFUSED, "Not deleted — removing a folder is the node operator's, "
+ "and this machine's key is not:\n "
+ + "\n ".join(safe(n) for n in not_operator))
if not_yours:
raise CliError(REFUSED, "Not deleted — you did not upload these, and this machine's "
"key is not the node's operator:\n "
+ "\n ".join(safe(n) for n in not_yours))
names = [shown for _, _, shown in targets]
- listed = "\n ".join(safe(n) for n in names[:SHOWN])
- more = f"\n ... and {len(names) - SHOWN} more" if len(names) > SHOWN else ""
- confirm(f"Delete {len(names)} file{'s' if len(names) != 1 else ''} for every member "
- f"of the group?\n {listed}{more}\n", a.yes)
+ # The deepest first: each one is empty by the time it is asked for.
+ dirs.sort(key=lambda d: d[1].count("/"), reverse=True)
+ tops = [shown for _, path, shown in dirs
+ if not any(path.startswith(other + "/") for _, other, _ in dirs)]
+ shown_names = tops + [n for n in names if not any(n.startswith(t + "/") for t in tops)]
+ listed = "\n ".join(safe(n) + ("/" if n in tops else "") for n in shown_names[:SHOWN])
+ more = (f"\n ... and {len(shown_names) - SHOWN} more"
+ if len(shown_names) > SHOWN else "")
+ size = sum(int(e.get("size", 0)) for _, e, _ in targets)
+ what = f"{len(names)} file{'s' if len(names) != 1 else ''}"
+ if dirs:
+ what += (f" ({human_size(size)}) and {len(dirs)} folder"
+ f"{'s' if len(dirs) != 1 else ''}")
+ confirm(f"Delete {what} for every member of the group?\n {listed}{more}\n", a.yes)
- deleted, failed = [], []
+ deleted, removed, failed = [], [], []
for session, entry, shown in targets:
try:
await delete_file(session, entry["id"])
@@ -101,11 +146,22 @@ async def _run(ctx: Context, account, pool: Pool) -> int:
if e.code not in (NODE, REFUSED):
raise
failed.append(f"{shown}: {e.message}")
+ for session, path, shown in dirs:
+ try:
+ await delete_dir(session, path)
+ removed.append(shown)
+ except CliError as e:
+ if e.code not in (NODE, REFUSED):
+ raise
+ failed.append(f"{shown}/: {e.message}")
if a.json:
- print(json.dumps({"deleted": deleted, "failed": failed}, ensure_ascii=False))
+ print(json.dumps({"deleted": deleted, "folders_removed": removed, "failed": failed},
+ ensure_ascii=False))
elif not a.quiet:
n = len(deleted)
- err(f"Deleted {n} file{'s' if n != 1 else ''}.")
+ err(f"Deleted {n} file{'s' if n != 1 else ''}"
+ + (f" and {len(removed)} folder{'s' if len(removed) != 1 else ''}"
+ if dirs else "") + ".")
for f in failed:
err(f"meshbay-cli: {safe(f)}")
return PARTIAL if failed else 0