From 422647e7898b8df67cd11f8fb060aec23dec84aa Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 14 Oct 2026 22:20:15 +0200 Subject: feat(cli): meshbay-cli, a command-line client for members Signs in to the hub, lists the account's groups and their folders, and downloads and uploads files directly with each group's node, end-to-end encrypted. With no argument it opens a two-pane interactive mode (Textual). Session and device keys are kept encrypted under the system's key storage, or a 0600 key file where there is none; node keys are pinned at first contact; a new machine links as a device. Packaged on its own on Linux (deb and rpm, on meshbay-common's venv, with a man page generated from the parser) and frozen next to meshbay-node in the full Windows installer, not the light one, with a self-test the build runs. client/keys.py ports keyderive.js and is LGPL like it. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_cli/client/upload_plan.py | 92 ++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 packages/meshbay-cli/src/meshbay_cli/client/upload_plan.py (limited to 'packages/meshbay-cli/src/meshbay_cli/client/upload_plan.py') diff --git a/packages/meshbay-cli/src/meshbay_cli/client/upload_plan.py b/packages/meshbay-cli/src/meshbay_cli/client/upload_plan.py new file mode 100644 index 0000000..070e686 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/client/upload_plan.py @@ -0,0 +1,92 @@ +""" +What an upload will do, decided before anything is sent — shared by `put` and +the interactive mode, so both refuse the same names and the same collisions. + +A name already in the destination folder, compared without case as the node and +the Files tab compare it, or a name the node does not accept (`names.py`), is +found here; what to do about it (refuse the run, skip it, ask) is the caller's. +""" + +import os +import stat +from dataclasses import dataclass +from pathlib import Path + +from meshbay_common.paths import fold + +from ..errors import USAGE, CliError +from ..output import safe +from .names import same_name, upload_name_problem +from .tree import Tree + + +@dataclass +class UploadFile: + local: Path + rel: list[str] # remote parts below the destination folder + size: int + + +def plan_upload(sources: list[str], recursive: bool): + """Folders to create (parents first) and files to send, as remote parts.""" + dirs: list[list[str]] = [] + files: list[UploadFile] = [] + problems: list[str] = [] + seen: dict[str, str] = {} + for src in sources: + path = Path(src).expanduser() + try: + st = path.stat() + except OSError as e: + problems.append(f"{src}: {e.strerror}") + continue + top = path.resolve().name if src.rstrip("/\\") in (".", "..") else path.name + key = fold(top) + if key in seen: + problems.append(f"{src} and {seen[key]} would have the same name there") + continue + seen[key] = src + if stat.S_ISREG(st.st_mode): + files.append(UploadFile(path, [top], st.st_size)) + elif stat.S_ISDIR(st.st_mode): + if not recursive: + raise CliError(USAGE, f"{safe(src)} is a folder; use -R.") + for here, subdirs, names in os.walk(path): + rel = [top] + list(Path(here).relative_to(path).parts) + dirs.append(rel) + for d in list(subdirs): + if (Path(here) / d).is_symlink(): + subdirs.remove(d) + problems.append(f"{Path(here) / d} is a link to a folder; not " + "followed") + for n in names: + p = Path(here) / n + try: + st_n = p.stat() + except OSError as e: + problems.append(f"{p}: {e.strerror}") + continue + if stat.S_ISREG(st_n.st_mode): + files.append(UploadFile(p, rel + [n], st_n.st_size)) + subdirs.sort() + else: + problems.append(f"{src} is neither a file nor a folder") + return dirs, files, problems + + +def taken_names(tree: Tree, folder: str, names: list[str]) -> set[str]: + """The names among `names` already in `folder`, compared without case.""" + there = [i.name for i in tree.children(folder, include_unavailable=True)] + return {n for n in names if any(same_name(n, t) for t in there)} + + +def refused_names(file_names: list[str], dirs: list[list[str]]) -> list[str]: + """Names the node would refuse — the client's copy of its rule.""" + out = [] + for name in file_names: + if (why := upload_name_problem(name)): + out.append(f"{name}: {why}") + for d in dirs: + if (why := upload_name_problem(d[-1], is_dir=True)): + out.append(f"{'/'.join(d)}/: {why}") + return out -- cgit v1.2.3