From 988a0eecd522431980174910db28e45a63ff2710 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 15 Oct 2026 12:32:24 +0200 Subject: feat(cli): account, rm -r and mkdir, Tab completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../meshbay-cli/src/meshbay_cli/commands/rm.py | 100 ++++++++++++++++----- 1 file changed, 78 insertions(+), 22 deletions(-) (limited to 'packages/meshbay-cli/src/meshbay_cli/commands/rm.py') 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 -- cgit v1.2.3