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/manpage.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/manpage.py')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/manpage.py | 344 |
1 files changed, 344 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/manpage.py b/packages/meshbay-cli/src/meshbay_cli/manpage.py new file mode 100644 index 0000000..0633904 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/manpage.py @@ -0,0 +1,344 @@ +""" +The man page, generated from the parser: `python -m meshbay_cli.manpage`. + +Every action and option comes from `build_parser()`, so the page cannot list an +option the program lacks or miss one it has; the prose the parser cannot carry +(remote paths, the store, exit codes, environment) is written here. The +packages build it at build time and install it in /usr/share/man/man1. +""" + +import argparse +import sys + +from . import __version__, errors +from .__main__ import ACTIONS, build_parser + +EXIT_CODES = { + 0: "Success.", + errors.USAGE: "Usage error: an unknown option, a missing argument.", + errors.AUTH: "Sign-in refused, no stored session and no passphrase to sign in " + "with, or the account is locked out.", + errors.NOT_FOUND: "A group, a path, or a pattern that matches nothing.", + errors.PARTIAL: "Some items failed; the others were done. Each failure is " + "named on stderr.", + errors.NODE: "No node serving the group could be reached, or the node refused " + "the connection. Also: this machine is waiting for device " + "approval (the code is on stderr).", + errors.NODE_KEY: "A node presented another key than the one pinned at first " + "contact. Nothing was sent. See \\fBtrust\\fR.", + errors.VERSION: "This client, or the protocol it speaks, is too old or too new " + "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.", + errors.INTERRUPTED: "Interrupted (Ctrl+C). A download resumes where it stopped " + "on the next run.", +} + +DESCRIPTION = r""" +.B meshbay-cli +is a member's view of their MeshBay groups from a terminal: sign in, list the +groups and their folders, download and upload files. +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. +.PP +Run with no argument at all, it opens the interactive mode (below). +Global options are accepted before the action and after it. +.PP +Administering a node is not this program's business: see +.BR meshbay-node (1). +""" + +PATHS = r""" +A remote path names a group, then a root the node shares, then folders and a +file: +.PP +.RS +.EX +group[@owner]/root/folder/file +.EE +.RE +.PP +.B @owner +is needed only when two of your groups share a name; group names compare +without regard to case. +A group name may itself contain +.B / +or +.BR @ : +type them as +.B %2F +and +.B %40 +(and a literal +.B % +as +.BR %25 ). +The GROUP column of +.B meshbay-cli groups +prints each group in this form. +A link copied from the application's address bar (containing +.BR #/ ) +is accepted as it is. +.PP +.B get +matches the wildcards +.BR * , +.B ? +and +.B [...] +against the group's index, not against local files: quote them. +""" + +INTERACTIVE = r""" +.B meshbay-cli +with no argument opens two panes: this machine's folders on the left, your +groups on the right, and the transfers below. +A sign-in screen comes first when no session is stored. +.TP +.B Tab +switch pane +.TP +.B Enter +open a folder or a group +.TP +.B Backspace +go up +.TP +.B Space +select an entry (several can be) +.TP +.B F5 +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 / +filter by name; +.B Esc +clears the filter +.TP +.B s +cycle the sort: name, size, date +.TP +.B r +refresh +.TP +.B q +quit +.PP +The right pane follows the node: a file added by someone else appears there. +""" + +SESSION = r""" +.B login +signs in with the account's passphrase and, unless +.B \-\-no\-remember +is given, keeps the session so that later runs need no passphrase. +The passphrase is read from, in order: +.BR \-\-password\-file , +.BR \-\-password , +.BR MESHBAY_PASSWORD , +then a prompt when there is a terminal. +.B \-\-password +puts it in the process list and the shell history; prefer +.B \-\-password\-file \- +(stdin). +.PP +The session and this machine's keys are kept encrypted in the data directory +(see FILES), under a key held by the system's key storage (Secret Service, +macOS Keychain, Windows Credential Manager). +Where there is none, such as a server with no desktop session, the key is kept +in a file only your user can read, like an SSH key without a passphrase; +.B login +says so once. +.B logout +ends the session on the hub, retires this machine's device key and erases +everything stored. +.PP +The first time this machine opens a group whose node holds no copy of your +keys (the application's "Browser access" turned off), it asks to be linked as +a new device and prints a code. +Approve that code from the application on a device already linked (the +group's Settings); with a terminal, +.B meshbay-cli +waits and goes on by itself, otherwise it exits with status 5 and the next run +goes on once the code is approved. +.PP +Each node's key is pinned at first contact. +A node presenting another key is refused, with status 6, until +.B meshbay-cli trust reset +.I NODE +forgets the pin; do that only for a key change the node's operator announced. +""" + +FILES = r""" +.TP +.I $XDG_DATA_HOME/meshbay\-cli/ \fR(default \fI~/.local/share/meshbay\-cli/\fR) +On Windows, +.IR %LOCALAPPDATA%\emeshbay\-cli\e . +Holds +.B state.json +(the hub, the account name, pinned node keys), +.B secrets.enc +(the session and keys, encrypted), +.B store.key +only where the system has no key storage, and +.B interactive.log +(the interactive mode's log). +.TP +\fIname\fB.meshbay\-part\fR, \fIname\fB.meshbay\-part.json\fR +A download in progress, next to where the file lands. +A later +.B get +of the same file resumes from it; the finished file replaces it. +""" + +ENVIRONMENT = r""" +.TP +.B MESHBAY_HUB +The hub, when +.B \-\-hub +is not given (default: the one signed in to, else https://meshbay.org). +.TP +.B MESHBAY_USER +The account name, when +.B \-u +is not given. +.TP +.B MESHBAY_PASSWORD +The passphrase, when neither +.B \-\-password\-file +nor +.B \-\-password +is given. +.TP +.B MESHBAY_CLI_HOME +The data directory, instead of the default (see FILES). +.TP +.B NO_COLOR +The interactive mode draws without colour. +""" + +EXAMPLES = r""" +.EX +meshbay\-cli login \-u alice +meshbay\-cli groups +meshbay\-cli ls \-l Family/photos/2024 +meshbay\-cli get \-R Family/photos/2024 \-o ~/Pictures/ +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' +.EE +""" + + +def _roff(text: str) -> str: + """ + Plain text into roff: no control line, no escape, no hyphen taken for a + dash, and anything past ASCII named, which troff reads whatever the locale. + """ + text = text.replace("\\", "\\e").replace("-", "\\-") + text = "".join(c if ord(c) < 128 else f"\\[u{ord(c):04X}]" for c in text) + return "\n".join("\\&" + line if line[:1] in (".", "'") else line + for line in text.splitlines()) + + +def _option_lines(action: argparse.Action) -> str: + if action.option_strings: + names = ", ".join(f"\\fB{_roff(s)}\\fR" for s in action.option_strings) + if action.choices: + names += " " + "|".join(f"\\fB{_roff(c)}\\fR" for c in action.choices) + elif action.nargs != 0: + names += f" \\fI{_roff(action.metavar or action.dest.upper())}\\fR" + else: + names = f"\\fI{_roff(action.metavar or action.dest.upper())}\\fR" + if action.choices: + names = " | ".join(f"\\fB{_roff(c)}\\fR" for c in action.choices) + help_text = action.help or "" + return f".TP\n{names}\n{_roff(help_text)}\n" + + +def _own_options(parser: argparse.ArgumentParser) -> list[argparse.Action]: + """The action's own arguments; the global ones are listed once, above.""" + late = {s for a in _globals(build_parser()) for s in a.option_strings} + return [a for a in parser._actions + if not isinstance(a, argparse._HelpAction) + and not (set(a.option_strings) & late)] + + +def _globals(parser: argparse.ArgumentParser) -> list[argparse.Action]: + return [a for a in parser._actions + if a.option_strings and not isinstance(a, argparse._SubParsersAction)] + + +def _subparsers(parser: argparse.ArgumentParser) -> dict[str, argparse.ArgumentParser]: + sub = next(a for a in parser._actions if isinstance(a, argparse._SubParsersAction)) + return dict(sub.choices) + + +def _synopsis(name: str, parser: argparse.ArgumentParser) -> str: + """`meshbay-cli ls [-l] ... [path]`, without the global options.""" + parts = [] + own = _own_options(parser) + for a in sorted(own, key=lambda a: not a.option_strings): # options first + if a.option_strings: + s = a.option_strings[0] + if a.choices: + s += " " + "|".join(a.choices) + elif a.nargs != 0: + s += f" {a.metavar or a.dest.upper()}" + parts.append(f"[{s}]") + elif a.choices: + parts.append(" | ".join(a.choices)) + else: + meta = a.metavar or a.dest.upper() + parts.append({"?": f"[{meta}]", "+": f"{meta}...", "*": f"[{meta}...]"} + .get(a.nargs, meta)) + return _roff(" ".join([name] + parts)) + + +def render() -> str: + parser = build_parser() + out = [ + f'.TH MESHBAY\\-CLI 1 "" "meshbay\\-cli {__version__}" "User Commands"', + ".nh", ".ad l", + ".SH NAME", + "meshbay\\-cli \\- a member's MeshBay groups and files, from a terminal", + ".SH SYNOPSIS", + ".B meshbay\\-cli", + ".br", + ".B meshbay\\-cli", + "[\\fIglobal options\\fR] \\fIaction\\fR [\\fIaction options\\fR] " + "[\\fIarguments\\fR]", + ".SH DESCRIPTION", + DESCRIPTION.strip(), + ".SH GLOBAL OPTIONS", + ] + out += [_option_lines(a).rstrip() for a in _globals(parser)] + out.append(".SH ACTIONS") + subs = _subparsers(parser) + for name in ACTIONS: + sp = subs[name] + out += [f".SS {_roff(name)}", f".B {_synopsis(name, sp)}", ".PP", + _roff(sp.description or "")] + out += [_option_lines(a).rstrip() for a in _own_options(sp)] + out += [".SH REMOTE PATHS", PATHS.strip(), + ".SH INTERACTIVE MODE", INTERACTIVE.strip(), + ".SH SESSION, DEVICES AND NODE KEYS", SESSION.strip(), + ".SH EXIT STATUS"] + for code, text in sorted(EXIT_CODES.items()): + out += [".TP", f".B {code}", text] + out += [".SH ENVIRONMENT", ENVIRONMENT.strip(), + ".SH FILES", FILES.strip(), + ".SH EXAMPLES", EXAMPLES.strip(), + ".SH SEE ALSO", ".BR meshbay\\-node (1)", ""] + return "\n".join(out) + + +def main() -> None: + sys.stdout.write(render()) + + +if __name__ == "__main__": + main() |