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
|
"""
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()
|