aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/manpage.py
blob: b2e492670d2c7b2fcf47c8af900060e7c14a13cf (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
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
"""
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. Also: the node refused a file being streamed.",
    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), which is also how chat \\-\\-follow "
                        "and stream end. 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, search them all at once, download,
upload and delete files, play a film or a song in a media player, read and
post in a group's chat, 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
.B account register
creates an account: a new passphrase, typed twice, must have 12 characters
and be hard enough to guess, as in the application.
It prints the account's recovery key once \(em MeshBay keeps it nowhere \(em
and asks for the code e-mailed to the address, or
.B account verify
takes it later.
.B account passphrase
changes a known passphrase: the copy of your keys each node keeps for browsers
is sealed again, the same keys, under the new one, and only then is the hub
told; if the hub refuses, the copies are set back.
A node out of reach keeps its copy as it was, and this machine seals it again
the next time it connects there.
.B account reset
is for a forgotten passphrase: a code e-mailed to the account's address, a new
passphrase, and, if you have it, the recovery key, which gives back your keys
on each node; without it, a group whose keys this machine does not keep needs
its operator.
.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 chat
shows the last messages of a group, oldest first, each with its time and the name
its sender sealed in it, and posts one with
.BR \-m .
With
.BR \-\-follow ,
it then prints each new message as it arrives, until Ctrl+C (status 130); a
connection that drops or stops answering is opened again, and what was said
meanwhile is printed, each message once.
With
.BR \-\-json ,
one object per line.
Messages are encrypted and signed end to end, as the application's are; one this
machine cannot verify or open is shown as unreadable, never left out.
The device that signed a message is checked against the group's roster and the
keys this machine accepted for that account before; a key it has not seen and
nothing vouches for is pointed out under the message.
.B meshbay\-cli chat
with no group opens the interactive chat: the groups (Enter joins one whose chat
is on), then the conversation, with new messages as they arrive and a line to
write in; Esc or
.B /part
goes back to the groups,
.B /quit
or Ctrl+Q quits.
A right-click copies the text selected with the mouse, else the line under the
pointer, and pastes into the line written in: the system clipboard's text, read
with the platform's tool (wl\-paste, xclip or xsel, pbpaste, PowerShell), else
what was copied here last.
.B /put
\fIFILE\fR sends one local file, relative to the current directory or absolute,
into the chat's folder and names it in the conversation, as the application's
paperclip does;
.B /get
\fINAME\fR saves a file of that folder, named from it, into the current
directory.
The chat's folder is the one the group's operator chose for it while its root
accepts uploads and is present, else the group's first such root.
.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.
"""

STREAMING = r"""
.B stream
plays one video or sound file of a group in a media player that reads HTTP,
such as VLC or mpv.
The file is served on this machine at an address printed on stdout, as it is
on the node: the player decodes it, so every format the player knows plays,
subtitles held in the file included.
Seeking is the player's: it asks for another part of the file, fetched from the
node then.
Nothing is written to disk.
.PP
The address works only on this machine (127.0.0.1) and only while
.B stream
runs; it carries a random part, so another program or another account of this
machine cannot guess it, and a request naming another host, as a web page
would send, is refused.
.B \-\-player
\fIPROGRAM\fR starts the player on the address (on Windows, VLC is looked for
where it installs itself too); closing it stops the stream.
Ctrl+C stops it too (status 130), and closes the player
.B stream
started.
A connection to the node that drops or stops answering is opened again; the
player sees a slower read.
If the node refuses the file, because it was removed or its disk went away,
the stream stops (status 5).
"""

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 device keys accepted for
the members whose messages were read, 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) and
.B completion\-cache.json
(the groups and what they hold, as Tab completion last read them; two minutes).
.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_NEW_PASSWORD
The new passphrase for
.BR "account passphrase" ,
when
.B \-\-new\-password\-file
is not 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 stream \-\-player vlc 'Family/films/Night Drive.mkv'
meshbay\-cli stream \-\-player mpv "$(meshbay\-cli search \-t audio nocturne | head \-1)"
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 search \-t audio holiday
meshbay\-cli search beach 2024 | head \-1 | xargs \-d '\\n' meshbay\-cli get
meshbay\-cli rm 'Family/documents/draft*.odt'
meshbay\-cli chat \-100 Family
meshbay\-cli \-\-json chat Family \-\-follow | jq \-r .text
echo 'On my way' | meshbay\-cli chat Family \-m \-
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 STREAMING", STREAMING.strip(),
            ".SH GROUPS: JOINING, LEAVING, DELETING, CHAT, 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()