aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client/paths.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-14 22:20:15 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-14 22:20:15 +0200
commit422647e7898b8df67cd11f8fb060aec23dec84aa (patch)
tree7ecca0e8df1bc8a7d26cf4b471bd44906dd9cfd1 /packages/meshbay-cli/src/meshbay_cli/client/paths.py
parent28c88908b88c58cba4de08710f6bb268ede02d57 (diff)
downloadmeshbay-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.py95
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}.")