diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-15 01:12:16 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-15 01:12:16 +0200 |
| commit | 382fcc5f675f9942350013780335315757c245c5 (patch) | |
| tree | a24ed8732c6d3971b071ed6af3c399d6142b79f4 /packages/meshbay-cli/src/meshbay_cli/commands | |
| parent | 6f68bb6c1970e26de1f66c073ea5d7fecd21840d (diff) | |
| download | meshbay-382fcc5f675f9942350013780335315757c245c5.tar.gz | |
feat(cli): rm, and Delete in the interactive mode
rm deletes files, resolved as get resolves paths and patterns, with the
node's rule (design §5.4): the uploading account's devices, or the node's
operator account. The whole run is checked against the index first (the
entry's uploader_id, the role the join result gives this key): a file
outside the rule, or a folder, refuses it before anything is asked or sent.
Then one confirmation, or --yes. The node's challenge is checked against
what was asked before it is signed. In the interactive mode, Delete on the
right pane does the same, asked once per batch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/commands')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/commands/rm.py | 111 |
1 files changed, 111 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/rm.py b/packages/meshbay-cli/src/meshbay_cli/commands/rm.py new file mode 100644 index 0000000..6f5e869 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/rm.py @@ -0,0 +1,111 @@ +""" +`rm`: delete files from groups. 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). +""" + +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.tree import Tree +from ..errors import NODE, NOT_FOUND, PARTIAL, REFUSED, USAGE, CliError +from ..output import confirm, err, safe, sentence +from .context import Context + +HELP = "delete files you uploaded (or any, from the node's operator key)" + +# How many names the confirmation shows before it counts the rest. +SHOWN = 10 + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("rm", parents=[parent], help=HELP, description=sentence(HELP) + + ". Wildcards (* ? [...]) are matched against the group, so " + "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("-y", "--yes", action="store_true", help="do not ask for confirmation") + + +async def run(ctx: Context) -> int: + await ctx.hub.check_version() + account = ctx.account() + pool = Pool(account) + try: + return await _run(ctx, account, pool) + finally: + await pool.close() + await account.close() + + +async def _run(ctx: Context, account, pool: Pool) -> int: + a = ctx.args + groups = await my_groups(account) + trees: dict[str, Tree] = {} + targets = [] # (session, entry, shown) + missing, folders, not_yours = [], [], [] + for text in a.paths: + remote = parse(text) + if remote is None or not remote.parts: + raise CliError(USAGE, "Name the files to delete, inside a group.") + group = resolve_group(groups, remote.group) + session = await pool.session(group) + if group["id"] not in trees: + trees[group["id"]] = Tree.from_index(await session.index()) + tree = trees[group["id"]] + found = tree.expand(remote.parts) + if not found and tree.file(remote.inner): + found = [remote.inner] # a literal name with brackets in it + if not found: + missing.append(text) + for path in found: + shown = f"{handle(group)}/{path}" + entry = tree.file(path) + if entry is None: + 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)) + 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)) + 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) + + deleted, failed = [], [] + for session, entry, shown in targets: + try: + await delete_file(session, entry["id"]) + deleted.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)) + elif not a.quiet: + n = len(deleted) + err(f"Deleted {n} file{'s' if n != 1 else ''}.") + for f in failed: + err(f"meshbay-cli: {safe(f)}") + return PARTIAL if failed else 0 |