ssh-server-manager 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ssh_server_manager/__init__.py +4 -0
- ssh_server_manager/askpass.py +63 -0
- ssh_server_manager/assets/ui/app.js +452 -0
- ssh_server_manager/assets/ui/index.html +188 -0
- ssh_server_manager/assets/ui/styles.css +104 -0
- ssh_server_manager/auth.py +189 -0
- ssh_server_manager/cli.py +406 -0
- ssh_server_manager/db.py +477 -0
- ssh_server_manager/importer.py +149 -0
- ssh_server_manager/paths.py +80 -0
- ssh_server_manager/service.py +102 -0
- ssh_server_manager/ssh_config.py +162 -0
- ssh_server_manager/ssh_runner.py +262 -0
- ssh_server_manager/validation.py +89 -0
- ssh_server_manager/vault.py +113 -0
- ssh_server_manager/webapp.py +377 -0
- ssh_server_manager-0.2.0.dist-info/METADATA +173 -0
- ssh_server_manager-0.2.0.dist-info/RECORD +21 -0
- ssh_server_manager-0.2.0.dist-info/WHEEL +5 -0
- ssh_server_manager-0.2.0.dist-info/entry_points.txt +3 -0
- ssh_server_manager-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import getpass
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Sequence
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .db import ConflictError, Database, DatabaseError, NotFoundError
|
|
16
|
+
from .importer import apply_import, preview_import
|
|
17
|
+
from .paths import database_path, managed_ssh_config_path, original_ssh_config_path
|
|
18
|
+
from .service import CredentialService
|
|
19
|
+
from .ssh_config import SSHConfigError, render_config
|
|
20
|
+
from .ssh_runner import SSHError, SSHRunner
|
|
21
|
+
from .validation import ValidationError
|
|
22
|
+
from .vault import VaultError, get_vault
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def emit(value: Any, *, as_json: bool = False) -> None:
|
|
26
|
+
if as_json:
|
|
27
|
+
print(json.dumps(value, indent=2, ensure_ascii=False, default=str))
|
|
28
|
+
return
|
|
29
|
+
if isinstance(value, list):
|
|
30
|
+
for item in value:
|
|
31
|
+
print(format_item(item))
|
|
32
|
+
elif isinstance(value, dict):
|
|
33
|
+
for key, item in value.items():
|
|
34
|
+
if isinstance(item, (dict, list)):
|
|
35
|
+
print(f"{key}={json.dumps(item, ensure_ascii=False)}")
|
|
36
|
+
else:
|
|
37
|
+
print(f"{key}={item}")
|
|
38
|
+
else:
|
|
39
|
+
print(value)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def format_item(item: Any) -> str:
|
|
43
|
+
if not isinstance(item, dict):
|
|
44
|
+
return str(item)
|
|
45
|
+
if "hostname" in item:
|
|
46
|
+
credential = item.get("credential_label") or "OpenSSH default"
|
|
47
|
+
jumps = ",".join(item.get("proxy_jumps", [])) or "-"
|
|
48
|
+
return f"{item['alias']:<22} {item['username']}@{item['hostname']}:{item['port']} auth={credential} jump={jumps}"
|
|
49
|
+
if "kind" in item:
|
|
50
|
+
detail = item.get("key_path") or ("stored" if item.get("has_secret") else "not stored")
|
|
51
|
+
return f"{item['label']:<22} {item['kind']:<8} {detail}"
|
|
52
|
+
return json.dumps(item, ensure_ascii=False)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def add_json_option(parser: argparse.ArgumentParser) -> None:
|
|
56
|
+
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
60
|
+
parser = argparse.ArgumentParser(prog="serverctl", description="Manage SSH hosts and secure credentials")
|
|
61
|
+
parser.add_argument("--version", action="version", version=f"serverctl {__version__}")
|
|
62
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
63
|
+
|
|
64
|
+
doctor = commands.add_parser("doctor", help="diagnose local dependencies and storage")
|
|
65
|
+
add_json_option(doctor)
|
|
66
|
+
|
|
67
|
+
ui = commands.add_parser("ui", help="open the local management UI")
|
|
68
|
+
ui.add_argument("--no-open", action="store_true", help="do not open a browser")
|
|
69
|
+
ui.add_argument(
|
|
70
|
+
"--port",
|
|
71
|
+
type=int,
|
|
72
|
+
default=0,
|
|
73
|
+
help="loopback port; 0 (the default) chooses an available port",
|
|
74
|
+
)
|
|
75
|
+
ui.add_argument(
|
|
76
|
+
"--url-file",
|
|
77
|
+
type=Path,
|
|
78
|
+
help="write the one-time URL to a mode-600 local file (required with --no-open)",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
server = commands.add_parser("server", help="manage connection profiles")
|
|
82
|
+
server_commands = server.add_subparsers(dest="server_command", required=True)
|
|
83
|
+
server_list = server_commands.add_parser("list")
|
|
84
|
+
add_json_option(server_list)
|
|
85
|
+
server_show = server_commands.add_parser("show")
|
|
86
|
+
server_show.add_argument("alias")
|
|
87
|
+
add_json_option(server_show)
|
|
88
|
+
|
|
89
|
+
server_add = server_commands.add_parser("add")
|
|
90
|
+
server_add.add_argument("alias")
|
|
91
|
+
server_add.add_argument("--hostname", required=True)
|
|
92
|
+
server_add.add_argument("--username", required=True)
|
|
93
|
+
server_add.add_argument("--port", type=int, default=22)
|
|
94
|
+
server_add.add_argument("--credential")
|
|
95
|
+
server_add.add_argument("--proxy-jump", action="append", default=[])
|
|
96
|
+
server_add.add_argument("--notes", default="")
|
|
97
|
+
add_json_option(server_add)
|
|
98
|
+
|
|
99
|
+
server_edit = server_commands.add_parser("edit")
|
|
100
|
+
server_edit.add_argument("alias")
|
|
101
|
+
server_edit.add_argument("--new-alias")
|
|
102
|
+
server_edit.add_argument("--hostname")
|
|
103
|
+
server_edit.add_argument("--username")
|
|
104
|
+
server_edit.add_argument("--port", type=int)
|
|
105
|
+
server_edit.add_argument("--credential", help="credential label/id, or 'none'")
|
|
106
|
+
server_edit.add_argument("--proxy-jump", action="append")
|
|
107
|
+
server_edit.add_argument("--notes")
|
|
108
|
+
add_json_option(server_edit)
|
|
109
|
+
|
|
110
|
+
server_remove = server_commands.add_parser("remove")
|
|
111
|
+
server_remove.add_argument("alias")
|
|
112
|
+
server_remove.add_argument("--yes", action="store_true")
|
|
113
|
+
add_json_option(server_remove)
|
|
114
|
+
|
|
115
|
+
server_import = server_commands.add_parser("import")
|
|
116
|
+
server_import.add_argument("--config")
|
|
117
|
+
server_import.add_argument("--apply", action="store_true")
|
|
118
|
+
server_import.add_argument("--overwrite", action="store_true")
|
|
119
|
+
add_json_option(server_import)
|
|
120
|
+
|
|
121
|
+
server_test = server_commands.add_parser("test")
|
|
122
|
+
server_test.add_argument("alias")
|
|
123
|
+
server_test.add_argument("--timeout", type=int, default=8)
|
|
124
|
+
add_json_option(server_test)
|
|
125
|
+
|
|
126
|
+
credential = commands.add_parser("credential", help="manage reusable credentials")
|
|
127
|
+
credential_commands = credential.add_subparsers(dest="credential_command", required=True)
|
|
128
|
+
credential_list = credential_commands.add_parser("list")
|
|
129
|
+
add_json_option(credential_list)
|
|
130
|
+
add_password = credential_commands.add_parser("add-password")
|
|
131
|
+
add_password.add_argument("label")
|
|
132
|
+
add_json_option(add_password)
|
|
133
|
+
add_key = credential_commands.add_parser("add-key")
|
|
134
|
+
add_key.add_argument("label")
|
|
135
|
+
add_key.add_argument("--key-path", required=True)
|
|
136
|
+
add_key.add_argument("--store-passphrase", action="store_true")
|
|
137
|
+
add_json_option(add_key)
|
|
138
|
+
add_agent = credential_commands.add_parser("add-agent")
|
|
139
|
+
add_agent.add_argument("label")
|
|
140
|
+
add_json_option(add_agent)
|
|
141
|
+
edit_credential = credential_commands.add_parser("edit")
|
|
142
|
+
edit_credential.add_argument("credential")
|
|
143
|
+
edit_credential.add_argument("--label")
|
|
144
|
+
edit_credential.add_argument("--key-path")
|
|
145
|
+
edit_credential.add_argument("--replace-secret", action="store_true")
|
|
146
|
+
edit_credential.add_argument("--replace-passphrase", action="store_true")
|
|
147
|
+
edit_credential.add_argument("--clear-passphrase", action="store_true")
|
|
148
|
+
add_json_option(edit_credential)
|
|
149
|
+
remove_credential = credential_commands.add_parser("remove")
|
|
150
|
+
remove_credential.add_argument("credential")
|
|
151
|
+
remove_credential.add_argument("--yes", action="store_true")
|
|
152
|
+
add_json_option(remove_credential)
|
|
153
|
+
|
|
154
|
+
connect = commands.add_parser("connect", help="open an SSH session")
|
|
155
|
+
connect.add_argument("alias")
|
|
156
|
+
connect.add_argument("--timeout", type=int, default=12)
|
|
157
|
+
|
|
158
|
+
execute = commands.add_parser("exec", help="execute a remote command")
|
|
159
|
+
execute.add_argument("alias")
|
|
160
|
+
execute.add_argument("--stdin", action="store_true", help="read UTF-8 text from stdin")
|
|
161
|
+
execute.add_argument(
|
|
162
|
+
"--stdin-binary",
|
|
163
|
+
action="store_true",
|
|
164
|
+
help="read raw bytes from stdin (implies --stdin)",
|
|
165
|
+
)
|
|
166
|
+
execute.add_argument("--timeout", type=int, default=30)
|
|
167
|
+
execute.add_argument("--reuse", type=int, default=0)
|
|
168
|
+
execute.add_argument(
|
|
169
|
+
"--shell",
|
|
170
|
+
action="store_true",
|
|
171
|
+
help="run one command string through the remote POSIX sh -lc",
|
|
172
|
+
)
|
|
173
|
+
execute.add_argument("--json", action="store_true")
|
|
174
|
+
|
|
175
|
+
config = commands.add_parser("config", help="render managed OpenSSH config")
|
|
176
|
+
config_commands = config.add_subparsers(dest="config_command", required=True)
|
|
177
|
+
config_render = config_commands.add_parser("render")
|
|
178
|
+
add_json_option(config_render)
|
|
179
|
+
return parser
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def resolve_credential(database: Database, value: str | None) -> str | None:
|
|
183
|
+
if not value or value.casefold() == "none":
|
|
184
|
+
return None
|
|
185
|
+
return database.get_credential(value)["id"]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def confirm(prompt: str, forced: bool) -> bool:
|
|
189
|
+
if forced:
|
|
190
|
+
return True
|
|
191
|
+
if not sys.stdin.isatty():
|
|
192
|
+
return False
|
|
193
|
+
return input(f"{prompt} [y/N] ").strip().casefold() in {"y", "yes"}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def normalize_remote_command(remote: Sequence[str], *, shell: bool = False) -> list[str]:
|
|
197
|
+
"""Normalize the command after ``--`` without guessing at shell syntax."""
|
|
198
|
+
command = list(remote)
|
|
199
|
+
if not command:
|
|
200
|
+
raise ValidationError("a remote command is required after --")
|
|
201
|
+
if not shell:
|
|
202
|
+
return command
|
|
203
|
+
if len(command) != 1:
|
|
204
|
+
raise ValidationError("--shell expects exactly one command string after --")
|
|
205
|
+
return ["sh", "-lc", command[0]]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def ssh_client_version(ssh_path: str | None) -> str | None:
|
|
209
|
+
if not ssh_path:
|
|
210
|
+
return None
|
|
211
|
+
try:
|
|
212
|
+
result = subprocess.run([ssh_path, "-V"], capture_output=True, text=True, timeout=5, check=False)
|
|
213
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
214
|
+
return None
|
|
215
|
+
return (result.stderr or result.stdout).strip().splitlines()[0] if (result.stderr or result.stdout).strip() else None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def run_doctor(database: Database) -> dict[str, Any]:
|
|
219
|
+
ssh_path = shutil.which("ssh")
|
|
220
|
+
checks: dict[str, Any] = {
|
|
221
|
+
"version": {"ok": True, "serverctl": __version__},
|
|
222
|
+
"platform": {
|
|
223
|
+
"ok": True,
|
|
224
|
+
"system": platform.system(),
|
|
225
|
+
"release": platform.release(),
|
|
226
|
+
"python": platform.python_version(),
|
|
227
|
+
"connection_reuse": os.name != "nt",
|
|
228
|
+
},
|
|
229
|
+
"database": {"ok": True, "path": str(database_path())},
|
|
230
|
+
"ssh": {"ok": bool(ssh_path), "path": ssh_path, "client": ssh_client_version(ssh_path)},
|
|
231
|
+
"ssh_keygen": {"ok": bool(shutil.which("ssh-keygen")), "path": shutil.which("ssh-keygen")},
|
|
232
|
+
"original_config": {"ok": True, "path": str(original_ssh_config_path())},
|
|
233
|
+
"managed_config": {"ok": True, "path": str(managed_ssh_config_path())},
|
|
234
|
+
}
|
|
235
|
+
try:
|
|
236
|
+
checks["vault"] = {"ok": True, **get_vault().diagnose()}
|
|
237
|
+
except VaultError as exc:
|
|
238
|
+
checks["vault"] = {"ok": False, "message": str(exc)}
|
|
239
|
+
for module in ("fastapi", "uvicorn", "webauthn", "argon2"):
|
|
240
|
+
try:
|
|
241
|
+
__import__(module)
|
|
242
|
+
checks[module] = {"ok": True}
|
|
243
|
+
except ImportError:
|
|
244
|
+
checks[module] = {"ok": False, "message": "run scripts/bootstrap"}
|
|
245
|
+
checks["ok"] = all(item.get("ok", False) for item in checks.values() if isinstance(item, dict))
|
|
246
|
+
return checks
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def handle(args: argparse.Namespace) -> int:
|
|
250
|
+
database = Database()
|
|
251
|
+
if args.command == "doctor":
|
|
252
|
+
result = run_doctor(database)
|
|
253
|
+
emit(result, as_json=args.json)
|
|
254
|
+
return 0 if result["ok"] else 1
|
|
255
|
+
if args.command == "ui":
|
|
256
|
+
from .webapp import run_ui
|
|
257
|
+
|
|
258
|
+
run_ui(
|
|
259
|
+
database=database,
|
|
260
|
+
port=args.port,
|
|
261
|
+
open_browser=not args.no_open,
|
|
262
|
+
url_file=args.url_file,
|
|
263
|
+
)
|
|
264
|
+
return 0
|
|
265
|
+
if args.command == "server":
|
|
266
|
+
if args.server_command == "list":
|
|
267
|
+
emit(database.list_servers(), as_json=args.json)
|
|
268
|
+
elif args.server_command == "show":
|
|
269
|
+
emit(database.get_server(args.alias), as_json=args.json)
|
|
270
|
+
elif args.server_command == "add":
|
|
271
|
+
result = database.create_server(
|
|
272
|
+
alias=args.alias,
|
|
273
|
+
hostname=args.hostname,
|
|
274
|
+
port=args.port,
|
|
275
|
+
username=args.username,
|
|
276
|
+
credential_id=resolve_credential(database, args.credential),
|
|
277
|
+
proxy_jumps=args.proxy_jump,
|
|
278
|
+
notes=args.notes,
|
|
279
|
+
)
|
|
280
|
+
render_config(database)
|
|
281
|
+
emit(result, as_json=args.json)
|
|
282
|
+
elif args.server_command == "edit":
|
|
283
|
+
changes = {
|
|
284
|
+
key: value
|
|
285
|
+
for key, value in {
|
|
286
|
+
"alias": args.new_alias,
|
|
287
|
+
"hostname": args.hostname,
|
|
288
|
+
"port": args.port,
|
|
289
|
+
"username": args.username,
|
|
290
|
+
"notes": args.notes,
|
|
291
|
+
}.items()
|
|
292
|
+
if value is not None
|
|
293
|
+
}
|
|
294
|
+
if args.credential is not None:
|
|
295
|
+
changes["credential_id"] = resolve_credential(database, args.credential)
|
|
296
|
+
if args.proxy_jump is not None:
|
|
297
|
+
changes["proxy_jumps"] = args.proxy_jump
|
|
298
|
+
result = database.update_server(args.alias, **changes)
|
|
299
|
+
render_config(database)
|
|
300
|
+
emit(result, as_json=args.json)
|
|
301
|
+
elif args.server_command == "remove":
|
|
302
|
+
if not confirm(f"Remove server {args.alias}?", args.yes):
|
|
303
|
+
raise DatabaseError("removal cancelled; use --yes for non-interactive removal")
|
|
304
|
+
result = database.delete_server(args.alias)
|
|
305
|
+
render_config(database)
|
|
306
|
+
emit(result, as_json=args.json)
|
|
307
|
+
elif args.server_command == "import":
|
|
308
|
+
preview = preview_import(database, config=args.config)
|
|
309
|
+
result = apply_import(database, preview, overwrite=args.overwrite) if args.apply else preview
|
|
310
|
+
if args.apply:
|
|
311
|
+
render_config(database)
|
|
312
|
+
emit(result, as_json=args.json)
|
|
313
|
+
elif args.server_command == "test":
|
|
314
|
+
result = SSHRunner(database).test(args.alias, timeout=args.timeout)
|
|
315
|
+
emit(result, as_json=args.json)
|
|
316
|
+
return 0 if result["ok"] else 1
|
|
317
|
+
return 0
|
|
318
|
+
if args.command == "credential":
|
|
319
|
+
if args.credential_command == "list":
|
|
320
|
+
emit(database.list_credentials(), as_json=args.json)
|
|
321
|
+
return 0
|
|
322
|
+
service = CredentialService(database, get_vault())
|
|
323
|
+
if args.credential_command == "add-password":
|
|
324
|
+
first = getpass.getpass("Password: ")
|
|
325
|
+
second = getpass.getpass("Confirm password: ")
|
|
326
|
+
if first != second:
|
|
327
|
+
raise VaultError("password confirmation does not match")
|
|
328
|
+
result = service.create_password(args.label, first)
|
|
329
|
+
elif args.credential_command == "add-key":
|
|
330
|
+
passphrase = getpass.getpass("Private-key passphrase: ") if args.store_passphrase else None
|
|
331
|
+
result = service.create_key(args.label, args.key_path, passphrase or None)
|
|
332
|
+
elif args.credential_command == "add-agent":
|
|
333
|
+
result = service.create_agent(args.label)
|
|
334
|
+
elif args.credential_command == "edit":
|
|
335
|
+
secret = getpass.getpass("New password: ") if args.replace_secret else None
|
|
336
|
+
passphrase = getpass.getpass("New private-key passphrase: ") if args.replace_passphrase else None
|
|
337
|
+
result = service.update(
|
|
338
|
+
args.credential,
|
|
339
|
+
label=args.label,
|
|
340
|
+
key_path=args.key_path,
|
|
341
|
+
secret=secret,
|
|
342
|
+
passphrase=passphrase,
|
|
343
|
+
clear_passphrase=args.clear_passphrase,
|
|
344
|
+
)
|
|
345
|
+
elif args.credential_command == "remove":
|
|
346
|
+
if not confirm(f"Remove credential {args.credential}?", args.yes):
|
|
347
|
+
raise DatabaseError("removal cancelled; use --yes for non-interactive removal")
|
|
348
|
+
result = service.delete(args.credential)
|
|
349
|
+
render_config(database)
|
|
350
|
+
emit(result, as_json=args.json)
|
|
351
|
+
return 0
|
|
352
|
+
if args.command == "connect":
|
|
353
|
+
return SSHRunner(database).connect(args.alias, timeout=args.timeout)
|
|
354
|
+
if args.command == "exec":
|
|
355
|
+
if args.reuse > 0 and os.name == "nt":
|
|
356
|
+
print(
|
|
357
|
+
"warning: --reuse is ignored on Windows; OpenSSH ControlMaster needs Unix domain sockets",
|
|
358
|
+
file=sys.stderr,
|
|
359
|
+
)
|
|
360
|
+
remote = normalize_remote_command(args.remote_command, shell=args.shell)
|
|
361
|
+
if args.stdin_binary:
|
|
362
|
+
stdin_data = sys.stdin.buffer.read()
|
|
363
|
+
else:
|
|
364
|
+
stdin_data = sys.stdin.read() if args.stdin else None
|
|
365
|
+
result = SSHRunner(database).execute(
|
|
366
|
+
args.alias,
|
|
367
|
+
remote,
|
|
368
|
+
stdin_data=stdin_data,
|
|
369
|
+
timeout=args.timeout,
|
|
370
|
+
reuse=args.reuse,
|
|
371
|
+
capture=args.json,
|
|
372
|
+
)
|
|
373
|
+
if args.json:
|
|
374
|
+
emit(result, as_json=True)
|
|
375
|
+
return int(result["returncode"])
|
|
376
|
+
if args.command == "config" and args.config_command == "render":
|
|
377
|
+
path = render_config(database)
|
|
378
|
+
emit({"path": str(path)}, as_json=args.json)
|
|
379
|
+
return 0
|
|
380
|
+
return 2
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
384
|
+
parser = build_parser()
|
|
385
|
+
raw_argv = list(argv) if argv is not None else sys.argv[1:]
|
|
386
|
+
remote_command: list[str] = []
|
|
387
|
+
if raw_argv and raw_argv[0] == "exec" and "--" in raw_argv:
|
|
388
|
+
separator = raw_argv.index("--")
|
|
389
|
+
remote_command = raw_argv[separator + 1 :]
|
|
390
|
+
raw_argv = raw_argv[:separator]
|
|
391
|
+
args = parser.parse_args(raw_argv)
|
|
392
|
+
if args.command == "exec":
|
|
393
|
+
args.remote_command = remote_command
|
|
394
|
+
try:
|
|
395
|
+
return handle(args)
|
|
396
|
+
except (ValidationError, VaultError, DatabaseError, SSHConfigError, SSHError, ValueError) as exc:
|
|
397
|
+
as_json = bool(getattr(args, "json", False))
|
|
398
|
+
if as_json:
|
|
399
|
+
print(json.dumps({"ok": False, "error": exc.__class__.__name__, "message": str(exc)}, ensure_ascii=False))
|
|
400
|
+
else:
|
|
401
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
402
|
+
return 2
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
if __name__ == "__main__":
|
|
406
|
+
raise SystemExit(main())
|