summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/manpage.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/manpage.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/manpage.py344
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()