summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/manpage.py
blob: 8316fc54dc7395321ac9e7822947a180e9926dbb (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
"""
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. Also: an action "
                  "that asks for confirmation, run with no terminal and without "
                  "\\fB\\-\\-yes\\fR, or not confirmed.",
    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, an invitation, a path, a device or a pattern that "
                      "matches nothing; an invitation link no longer valid; a linking "
                      "code no device is waiting with, or several are.",
    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), or the node asks for an "
                 "invitation's code.",
    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, leaving a "
                    "group you own, revoking this machine, deleting a file you may not "
                    "delete.",
    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, join and
leave groups, list them and their folders, download, upload and delete files,
and approve this account's other devices.
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 Delete
delete the selection, or the file under the cursor, from the group; files only,
each of them yours to delete (see
.BR rm ),
asked once for the 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), or with
.B meshbay-cli devices approve
.I GROUP CODE
on a machine already linked; 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.
"""

MEMBERSHIP = r"""
Joining has two halves.
The hub makes the account a member:
.B join
accepts an invitation listed by
.BR invitations ,
or redeems an invitation link pasted as it was sent.
Then the group's node admits it: a key pair is made on this machine, the node
wraps the group key for it, and it is kept (see FILES), so
.B join
needs a saved session.
A link carries the code for the node it names, and is sent to that node only.
An invitation's code is given with
.BR \-\-code ,
or typed when the node asks for it; a node that cannot be reached gets it at the
next connection.
.PP
.B leave
ends a membership on the hub.
The node keeps what it holds (the key it pinned, its copy of your keys, the
files you uploaded) until its operator removes them, and the group key already
served still opens what it opened until the operator replaces it.
The owner of a group cannot leave it.
.PP
.B devices
lists this account's devices on the node serving a group, approves a new one by
the code it shows, and revokes one.
Exactly one waiting device must match a code: a code that matches several, which
somebody who saw it on your screen could cause, approves none.
.PP
.B rm
deletes files for every member of the group.
The node allows it to the account that uploaded a file, from any of its
devices, and to the node's operator; being the group's owner on the hub counts
only when the owner's account is the operator of the node serving the group,
as it is when the owner hosts it with the desktop application.
A run that names a file this account may not delete, or a folder, is refused
before anything is asked or sent.
.PP
.B browser\-access on
makes this machine leave, on each node it opens a group on, a copy of its keys
sealed under your passphrase, so that a browser can open the group;
.B off
withdraws that copy.
Never set, nothing is written or withdrawn.
It is this machine's setting, as Browser access is the desktop application's:
the hub keeps a copy for browsers to read, and that copy never turns it on here.
"""

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, the browser access setting),
.B secrets.enc
(the session, the keys, invitation codes waiting for their node; 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 invitations
meshbay\-cli join Family@bob \-\-code K7P2\-9WQX
meshbay\-cli join 'https://meshbay.org/#/invite?v=1&g=...'
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'
meshbay\-cli rm 'Family/documents/draft*.odt'
meshbay\-cli devices approve Family@bob ZX4P\-7QRT
meshbay\-cli leave \-\-yes Club@carol
.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 GROUPS: JOINING, LEAVING, DELETING, DEVICES", MEMBERSHIP.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()