aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 01:12:16 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 01:12:16 +0200
commit382fcc5f675f9942350013780335315757c245c5 (patch)
treea24ed8732c6d3971b071ed6af3c399d6142b79f4 /packages/meshbay-cli/src
parent6f68bb6c1970e26de1f66c073ea5d7fecd21840d (diff)
downloadmeshbay-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')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/__main__.py3
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/remove.py45
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/session.py9
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/rm.py111
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/manpage.py25
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/tui/screens.py59
6 files changed, 245 insertions, 7 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/__main__.py b/packages/meshbay-cli/src/meshbay_cli/__main__.py
index 91e523a..a29b528 100644
--- a/packages/meshbay-cli/src/meshbay_cli/__main__.py
+++ b/packages/meshbay-cli/src/meshbay_cli/__main__.py
@@ -24,6 +24,7 @@ from .commands import (
logout,
ls,
put,
+ rm,
trust,
)
from .commands.context import Context
@@ -31,7 +32,7 @@ from .errors import INTERRUPTED, USAGE, CliError
ACTIONS = {"login": login, "logout": logout, "groups": groups, "invitations": invitations,
"join": join, "decline": decline, "leave": leave, "ls": ls, "get": get,
- "put": put, "devices": devices, "trust": trust,
+ "put": put, "rm": rm, "devices": devices, "trust": trust,
"browser-access": browser_access}
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/remove.py b/packages/meshbay-cli/src/meshbay_cli/client/remove.py
new file mode 100644
index 0000000..370cf95
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/client/remove.py
@@ -0,0 +1,45 @@
+"""
+Deleting a file from a group (docs/MESHBAY_DESIGN.md §5.4).
+
+The node decides, and on two grounds only: a key paired with it as operator, or
+a live device of the account that uploaded the file — any of them, so a file
+sent from a phone is deleted from the laptop. Being the group's owner on the hub
+is neither. The request is answered with a challenge the client signs with its
+key on the node; the node rebuilds what was signed itself.
+"""
+
+from meshbay_common import MNP_VERSION
+from meshbay_common.adminop import OP_FILE_DELETE, admin_transcript
+
+from ..errors import NODE, CliError
+from .keys import b64, unb64
+from .session import GroupSession
+
+
+def may_delete(session: GroupSession, entry: dict, user_id: str) -> bool:
+ """What the node will accept, known before asking: the operator, or the
+ file's uploader. The node checks again; this only avoids asking in vain."""
+ return session.role == "operator" or entry.get("uploader_id") == user_id
+
+
+async def delete_file(session: GroupSession, file_id: str) -> None:
+ """Delete one file. Raises `NodeRefusal` with the node's reason."""
+ reply = await session.ch.request({"type": "file_delete", "v": MNP_VERSION,
+ "file_id": file_id},
+ "admin_challenge", "file_delete_ack")
+ if reply["type"] == "file_delete_ack":
+ return
+ # What is signed is checked against what was asked, never taken from the
+ # node: a challenge for another operation, file, group or node is refused.
+ if (reply.get("op") != OP_FILE_DELETE or reply.get("subject") != file_id
+ or reply.get("group_id") != session.group_id
+ or reply.get("node_pk") != session.node_pk):
+ raise CliError(NODE, "The node asked to sign something other than this deletion; "
+ "nothing was signed.")
+ transcript = admin_transcript(op=OP_FILE_DELETE, node_pk_b64=session.node_pk,
+ group_id=session.group_id, subject=file_id,
+ nonce=unb64(reply["nonce"]), ts=int(reply["ts"]))
+ await session.ch.request({"type": "admin_response", "v": MNP_VERSION,
+ "op_id": reply["op_id"], "op": OP_FILE_DELETE,
+ "signature": b64(session.identity.sk_ed.sign(transcript))},
+ "file_delete_ack")
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/session.py b/packages/meshbay-cli/src/meshbay_cli/client/session.py
index d5264a2..41bf0d6 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/session.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/session.py
@@ -197,7 +197,8 @@ class GroupSession:
def __init__(self, pc: RTCPeerConnection, ch: Channel, group_id: str,
node_id: str, node_pk: str, gek: bytes, ack: dict,
- identity: Identity | None = None, nonce_node: bytes = b""):
+ identity: Identity | None = None, nonce_node: bytes = b"",
+ role: str = ""):
self.pc = pc
self.ch = ch
self.group_id = group_id
@@ -209,6 +210,10 @@ class GroupSession:
# and the nonce that ties a signature to this connection.
self.identity = identity
self.nonce_node = nonce_node
+ # What the node's roster says this key is here: "operator" when it was
+ # paired with the node — the authority to delete anyone's file. Not the
+ # hub's word on who owns the group (docs/MESHBAY_DESIGN.md §3.3).
+ self.role = role
async def index(self) -> dict:
"""`{version, entries, dirs, roots}`, opened under the group key."""
@@ -351,7 +356,7 @@ async def _handshake(account: Account, pc, ch: Channel, group_id: str, node_id:
await _device_hello(account, ch, identity, node_pk, group_id, nonce_s)
await _settle_bundle(account, ch, identity, node_pk, bundle)
return GroupSession(pc, ch, group_id, node_id, node_pk, gek, config,
- identity, nonce_s)
+ identity, nonce_s, str(joined.get("role") or ""))
async def _fetch_bundle(ch: Channel) -> str:
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
diff --git a/packages/meshbay-cli/src/meshbay_cli/manpage.py b/packages/meshbay-cli/src/meshbay_cli/manpage.py
index 7ee3d33..8316fc5 100644
--- a/packages/meshbay-cli/src/meshbay_cli/manpage.py
+++ b/packages/meshbay-cli/src/meshbay_cli/manpage.py
@@ -35,7 +35,8 @@ EXIT_CODES = {
"for the hub or the node.",
errors.REFUSED: "Refused before anything was sent: a name already taken or not "
"accepted by the node, a folder read-only or unavailable, leaving a "
- "group you own, revoking this machine.",
+ "group you own, revoking this machine, deleting a file you may not "
+ "delete.",
errors.INTERRUPTED: "Interrupted (Ctrl+C). A download resumes where it stopped "
"on the next run.",
}
@@ -43,8 +44,8 @@ EXIT_CODES = {
DESCRIPTION = r"""
.B meshbay-cli
is a member's view of their MeshBay groups from a terminal: sign in, join and
-leave groups, list them and their folders, download and upload files, and
-approve this account's other devices.
+leave groups, list them and their folders, download, upload and delete files,
+and approve this account's other devices.
It talks to the hub only to sign in and to find a group's nodes; files go
directly between this machine and the node, end-to-end encrypted, never
through the hub.
@@ -119,6 +120,12 @@ select an entry (several can be)
copy the selection, or the entry under the cursor, to the other pane's folder;
a name already there is asked about once for the whole batch
.TP
+.B Delete
+delete the selection, or the file under the cursor, from the group; files only,
+each of them yours to delete (see
+.BR rm ),
+asked once for the batch
+.TP
.B /
filter by name;
.B Esc
@@ -211,6 +218,15 @@ the code it shows, and revokes one.
Exactly one waiting device must match a code: a code that matches several, which
somebody who saw it on your screen could cause, approves none.
.PP
+.B rm
+deletes files for every member of the group.
+The node allows it to the account that uploaded a file, from any of its
+devices, and to the node's operator; being the group's owner on the hub counts
+only when the owner's account is the operator of the node serving the group,
+as it is when the owner hosts it with the desktop application.
+A run that names a file this account may not delete, or a folder, is refused
+before anything is asked or sent.
+.PP
.B browser\-access on
makes this machine leave, on each node it opens a group on, a copy of its keys
sealed under your passphrase, so that a browser can open the group;
@@ -282,6 +298,7 @@ meshbay\-cli get 'Family/music/*.flac'
meshbay\-cli put \-R ~/scans Family/documents
tar c notes | meshbay\-cli put \- Family/documents/notes.tar
meshbay\-cli ls \-\-json \-R / | jq '.[] | .path'
+meshbay\-cli rm 'Family/documents/draft*.odt'
meshbay\-cli devices approve Family@bob ZX4P\-7QRT
meshbay\-cli leave \-\-yes Club@carol
.EE
@@ -381,7 +398,7 @@ def render() -> str:
out += [".SH REMOTE PATHS", PATHS.strip(),
".SH INTERACTIVE MODE", INTERACTIVE.strip(),
".SH SESSION, DEVICES AND NODE KEYS", SESSION.strip(),
- ".SH GROUPS: JOINING, LEAVING, DEVICES", MEMBERSHIP.strip(),
+ ".SH GROUPS: JOINING, LEAVING, DELETING, DEVICES", MEMBERSHIP.strip(),
".SH EXIT STATUS"]
for code, text in sorted(EXIT_CODES.items()):
out += [".TP", f".B {code}", text]
diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/screens.py b/packages/meshbay-cli/src/meshbay_cli/tui/screens.py
index e1ab620..0306325 100644
--- a/packages/meshbay-cli/src/meshbay_cli/tui/screens.py
+++ b/packages/meshbay-cli/src/meshbay_cli/tui/screens.py
@@ -6,6 +6,10 @@ with the rules `get` and `put` follow: an upload is decided whole before it
starts (names the node refuses stop it, names already there are asked about
once for the batch), nothing on either side is replaced without being asked,
and a name from a group is made writable here before it is written.
+
+Delete removes the selection from a group with the rules `rm` follows: files
+only, every one of them this account's to delete (uploaded by it, or this key
+the node's operator) or none is, then asked once for the batch.
"""
import asyncio
@@ -24,6 +28,7 @@ from ..client.account import Account
from ..client.names import UnsafeName, local_path
from ..client.paths import handle
from ..client.pool import Pool
+from ..client.remove import delete_file, may_delete
from ..client.store import FILE_KEY_NOTICE, KEY_FILE
from ..client.transfers import PART_SUFFIX, TransferError, create_dir, download, upload
from ..client.tree import Tree
@@ -95,6 +100,7 @@ class SignInScreen(Screen):
class MainScreen(Screen):
BINDINGS = [
Binding("f5", "copy", "Copy"),
+ Binding("delete", "delete", "Delete"),
Binding("q", "app.quit", "Quit"),
Binding("tab", "app.focus_next", "Switch", show=False),
]
@@ -331,6 +337,59 @@ class MainScreen(Screen):
except TransferError as e:
self.strip.update_job(job, state="failed", note=e.message)
+ # ── Delete ───────────────────────────────────────────────────────────────
+
+ def action_delete(self) -> None:
+ focused = self.app.focused
+ if focused is None or self.remote not in focused.ancestors_with_self:
+ self.app.notify("Delete removes files from a group: select them on the "
+ "right.", timeout=4)
+ return
+ self.run_worker(self._delete(), group="delete")
+
+ async def _delete(self) -> None:
+ remote = self.remote
+ rows = remote.selection()
+ if remote.group is None or not rows:
+ self.app.notify("Open a group and choose the files to delete.", timeout=4)
+ return
+ group = remote.group
+ try:
+ session = await self.pool.session(group)
+ except CliError as e:
+ self.app.notify(e.message, severity="error", markup=False)
+ return
+ folders = [r.name for r in rows if r.is_dir]
+ not_yours = [r.name for r in rows if not r.is_dir
+ and not may_delete(session, r.data["entry"], self.account.user_id)]
+ if folders or not_yours:
+ why = (["Folders are not deleted here: " + ", ".join(map(safe, folders))]
+ if folders else [])
+ if not_yours:
+ why.append("You did not upload these, and this machine's key is not the "
+ "node's operator: " + ", ".join(map(safe, not_yours)))
+ await self._ask("Nothing was deleted.\n" + "\n".join(why), [("ok", "OK")])
+ return
+ names = ", ".join(safe(r.name) for r in rows[:5]) + (
+ f" and {len(rows) - 5} more" if len(rows) > 5 else "")
+ answer = await self._ask(
+ f"Delete {len(rows)} file(s) from {safe(handle(group))} for every member? "
+ f"{names}", [("delete", "Delete"), ("cancel", "Cancel")])
+ if answer != "delete":
+ return
+ remote.marks.clear()
+ deleted = 0
+ for row in rows:
+ try:
+ await delete_file(session, row.data["entry"]["id"])
+ deleted += 1
+ except CliError as e:
+ self.app.notify(f"{safe(row.name)}: {e.message}", severity="error",
+ markup=False, timeout=8)
+ if deleted:
+ self.app.notify(f"Deleted {deleted} file(s).", timeout=4)
+ self._changed(group)
+
async def on_unmount(self) -> None:
with contextlib.suppress(Exception):
await self.pool.close()