diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-14 22:20:15 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-14 22:20:15 +0200 |
| commit | 422647e7898b8df67cd11f8fb060aec23dec84aa (patch) | |
| tree | 7ecca0e8df1bc8a7d26cf4b471bd44906dd9cfd1 /packages/meshbay-cli/src/meshbay_cli/client/paths.py | |
| parent | 28c88908b88c58cba4de08710f6bb268ede02d57 (diff) | |
| download | meshbay-422647e7898b8df67cd11f8fb060aec23dec84aa.tar.gz | |
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client/paths.py')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/paths.py | 95 |
1 files changed, 95 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/paths.py b/packages/meshbay-cli/src/meshbay_cli/client/paths.py new file mode 100644 index 0000000..1086085 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/client/paths.py @@ -0,0 +1,95 @@ +""" +Remote paths: `<group>[@<owner>]/<root>/<folder>/.../<file>`. + +The same handle the application shows under every group and puts in its links +(docs/MESHBAY_DESIGN.md §8.4, `static/group-link.js`), without the `#/`. + +A group name may contain `/` and `@` (the hub refuses only control characters +and bidirectional overrides); a username contains neither. So the owner is +whatever follows the **last** `@` of the first segment, and a `/` inside a name +is written `%2F`. Only `%2F` and `%25` are decoded in a typed path — a file +called `100%.txt` is typed as it is. A link copied from the application +(anything containing `#/`) is decoded as the application encodes it, every +segment in full. +""" + +import unicodedata +from dataclasses import dataclass +from urllib.parse import unquote + +from ..errors import NOT_FOUND, USAGE, CliError + + +@dataclass +class RemotePath: + group: str # the first segment, decoded: a name, maybe with @owner + parts: list[str] # root, folders, file — decoded + from_link: bool = False + + @property + def inner(self) -> str: + return "/".join(self.parts) + + +def _typed(segment: str) -> str: + return segment.replace("%2F", "/").replace("%2f", "/").replace("%25", "%") + + +def parse(text: str) -> RemotePath | None: + """None for "all my groups" (empty, or `/`).""" + from_link = "#/" in text + if from_link: + text = text.split("#", 1)[1] + segments = [s for s in text.split("/") if s] + if not segments: + return None + decode = unquote if from_link else _typed + decoded = [unicodedata.normalize("NFC", decode(s)) for s in segments] + if any(s in (".", "..") for s in decoded[1:]): + raise CliError(USAGE, "A remote path cannot contain . or .. segments.") + return RemotePath(decoded[0], decoded[1:], from_link) + + +def encode_name(name: str) -> str: + """A group or file name as it is typed in a path.""" + return name.replace("%", "%25").replace("/", "%2F") + + +def handle(group: dict) -> str: + """`name@owner`, ready to be typed back.""" + return f"{encode_name(group['name'])}@{group.get('owner_username') or ''}" + + +def _by_owner(named: list[dict], owner: str) -> dict | None: + exact = [g for g in named if g.get("owner_username") == owner] + if exact: + return exact[0] + loose = [g for g in named if (g.get("owner_username") or "").lower() == owner.lower()] + return loose[0] if len(loose) == 1 else None + + +def resolve_group(groups: list[dict], spec: str) -> dict: + """ + The group `spec` names among `/v1/groups/mine` rows. + + Names compare without case, as the hub keeps them unique per owner. With an + `@`, `name@owner` is tried first; when it names nothing, the whole segment is + tried as a name, since a group name may contain an `@` of its own. + """ + def named(name: str) -> list[dict]: + n = name.lower() + return [g for g in groups if (g.get("name") or "").lower() == n] + + at = spec.rfind("@") + if 0 < at < len(spec) - 1: + found = _by_owner(named(spec[:at]), spec[at + 1:]) + if found is not None: + return found + candidates = named(spec) + if len(candidates) == 1: + return candidates[0] + if len(candidates) > 1: + options = ", ".join(sorted(handle(g) for g in candidates)) + raise CliError(USAGE, f"Several of your groups are called {spec!r}; " + f"name the owner: {options}") + raise CliError(NOT_FOUND, f"You are not a member of a group called {spec!r}.") |