-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathmain.py
More file actions
714 lines (588 loc) · 28.9 KB
/
Copy pathmain.py
File metadata and controls
714 lines (588 loc) · 28.9 KB
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
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
"""LinuxSetupPro entry point: CLI parsing and full install orchestration."""
import argparse
import os
import shlex
import shutil
import subprocess
import sys
import time
from collections import namedtuple
from rich.console import Console
from core import detector, doctor, installer, report, shellConfig, state, updateCheck
from core.bannerGenerator import BannerGenerator, FONTS, COLOR_SCHEMES, default_settings
from core.errorClassifier import ErrorClassifier
from core.packageManager import (
get_backend,
OperationResult,
INSTALLED,
FAILED,
SKIPPED,
REMOVED,
FAILED_REMOVE,
NOT_FOUND,
)
from core.report import PackageRecord, Report
from ui import banner, menu
from ui import theme
VERSION = "2.0.0"
# Exact upgrade command shown when the interpreter is too old, per platform.
_PYTHON_UPGRADE = {
detector.TERMUX: "pkg upgrade python",
detector.DEBIAN: "sudo apt update && sudo apt install python3.10",
detector.FEDORA: "sudo dnf install python3.11",
detector.ARCH: "sudo pacman -Syu python",
}
def build_parser():
parser = argparse.ArgumentParser(
prog="linux-setup-pro",
description="Cross-platform setup and hardening tool for Termux and Linux.",
)
parser.add_argument("--dry-run", action="store_true",
help="show what would change without executing anything")
parser.add_argument("--only", choices=["primary", "secondary"],
help="run only the primary categories or only the secondary menu")
parser.add_argument("--uninstall", action="store_true",
help="remove packages this tool installed, then exit (skips the menu)")
parser.add_argument("--set-editor", action="store_true",
help="reopen the editor picker, update EDITOR, then exit")
parser.add_argument("--report", action="store_true",
help="print the saved installation reports and exit")
parser.add_argument("--check-update", action="store_true",
help="check GitHub for a newer release, print the result, and exit")
parser.add_argument("--doctor", action="store_true",
help="health-check tracked packages against the system, then exit")
parser.add_argument("--version", action="store_true", help="print the version and exit")
parser.add_argument("--no-banner", action="store_true",
help="suppress the startup and end-of-run banners")
parser.add_argument("--verbose", action="store_true",
help="show raw subprocess output instead of progress widgets")
parser.add_argument("--remove-banner", action="store_true",
help="remove the persistent shell banner block and exit")
parser.add_argument("--public-ip", action="store_true",
help="show public IP in the startup session block (makes an outbound request)")
return parser
def check_python_version(platform_info, console):
if sys.version_info >= (3, 8):
return
command = _PYTHON_UPGRADE.get(platform_info.platform, "install Python 3.8 or newer")
console.print(f"Python 3.8+ is required (found {sys.version.split()[0]}).", style=theme.ERROR)
console.print(f"Upgrade with: {command}", style=theme.WARN)
sys.exit(1)
def resolve_platform(console):
platform_info = detector.detect()
if platform_info is None:
console.print("Unsupported or unrecognized platform. Aborting.", style=theme.ERROR)
sys.exit(1)
return platform_info
# One package to install, carrying the coordinates its progress line is numbered by.
_Step = namedtuple("_Step", "entry category index total")
def _primary_queue(catalog):
"""Ordered list of primary entries, each tagged with its category."""
return [entry for _, entry in installer.flatten_primary(catalog)]
def _steps_from_queue(queue, per_category):
"""Turn an install queue into numbered steps.
per_category numbers each package within its category (Primary's convention); otherwise
packages are numbered across the whole queue (Secondary's convention). Both are rebuilt the
same way on resume, so the numbering a run shows survives an interruption.
"""
steps = []
if per_category:
index = 0
while index < len(queue):
category = queue[index].get("_category", "")
run = index
while run < len(queue) and queue[run].get("_category", "") == category:
run += 1
size = run - index
for offset in range(size):
steps.append(_Step(queue[index + offset], category, offset + 1, size))
index = run
else:
total = len(queue)
for i, entry in enumerate(queue):
steps.append(_Step(entry, entry.get("_category", ""), i + 1, total))
return steps
def _install_steps(inst, run_type, queue, steps, console, start_pos=0, show_headings=True):
"""Install steps[start_pos:], recording progress before each package so a kill can resume.
A category heading prints as the category changes when show_headings is set. The marker is
left untouched in dry-run, which is not a resumable operation.
"""
track = not inst.backend.dry_run
records = []
current_category = None
for position in range(start_pos, len(steps)):
step = steps[position]
if show_headings and step.category != current_category:
current_category = step.category
console.print()
console.print(step.category, style=theme.HEADING)
if track:
# Written before the package runs, so a package killed mid-install is retried on resume.
state.write_progress(run_type, queue, position, step.category)
records.extend(inst.install_sequence([step.entry], start_index=step.index, total=step.total))
if track and steps:
state.write_progress(run_type, queue, len(steps), steps[-1].category)
return records
def _finalize_primary(inst, catalog, plat, console, dry_run, records, started):
"""Run the interactive customization tail, write the report, and clear the run marker."""
custom_records, notes = customization_flow(inst, catalog, plat, console, dry_run)
records.extend(custom_records)
elapsed = time.monotonic() - started
Report("primary", plat.pretty_name, plat.backend_key).write(records, elapsed, notes)
if not dry_run:
state.mark_primary_completed()
state.clear_run()
return records
def run_primary(inst, catalog, plat, console, dry_run, verbose):
queue = _primary_queue(catalog)
steps = _steps_from_queue(queue, per_category=True)
started = time.monotonic()
records = _install_steps(inst, "primary", queue, steps, console, show_headings=True)
return _finalize_primary(inst, catalog, plat, console, dry_run, records, started)
def customization_flow(inst, catalog, plat, console, dry_run):
console.print()
console.print("Customization", style=theme.HEADING)
custom = catalog.get("customization", {})
editors = custom.get("editors", [])
shells = custom.get("shells", {})
notes = []
selected = menu.editor_multiselect(editors)
entries = []
for editor in editors:
if editor["name"] in selected:
e = dict(editor)
e["_category"] = "Customization"
entries.append(e)
theme_choice = menu.select(
console,
"Choose a shell prompt theme:",
[("starship", "starship - lightweight, fast, no shell switch required"),
("oh-my-zsh", "oh-my-zsh - heavier, popular, requires switching to zsh")],
default="starship",
)
if theme_choice == "starship" and "starship" in shells:
entries.append(_shell_entry("starship", shells["starship"]))
elif theme_choice == "oh-my-zsh" and "zsh" in shells:
entries.append(_shell_entry("zsh", shells["zsh"]))
want_fish = menu.confirm(console, "Install the fish shell as an optional extra?", default=False)
if want_fish and "fish" in shells:
entries.append(_shell_entry("fish", shells["fish"]))
records = inst.install_sequence(entries) if entries else []
if theme_choice == "oh-my-zsh":
records.append(_install_oh_my_zsh(console, dry_run))
if not dry_run and menu.confirm(console, "Switch default shell to zsh?", default=False):
_switch_shell_to_zsh(console)
default_editor = _choose_default_editor(selected)
if default_editor and not dry_run:
path = shellConfig.set_editor(default_editor, console=console)
notes.append(f"Default EDITOR set to {default_editor} in {path}. "
f"Change it later with: python3 main.py --set-editor")
elif default_editor:
notes.append(f"Default EDITOR would be set to {default_editor} (dry-run).")
return records, notes
def _shell_entry(name, spec):
entry = dict(spec)
entry["name"] = name
entry["_category"] = "Customization"
return entry
def _choose_default_editor(selected):
if not selected:
return None
if "nano" in selected:
return "nano"
if "micro" in selected:
return "micro"
return selected[0]
def _install_oh_my_zsh(console, dry_run):
url = "https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh"
console.print(f"oh-my-zsh installs by running its official installer from {url}", style=theme.WARN)
if dry_run:
return PackageRecord("Customization", "oh-my-zsh", "Community zsh configuration framework.",
SKIPPED, result=OperationResult("oh-my-zsh", SKIPPED, stdout="dry-run"))
if shutil.which("zsh") is None:
result = OperationResult("oh-my-zsh", FAILED, stderr="zsh is not installed")
return PackageRecord("Customization", "oh-my-zsh", "Community zsh configuration framework.",
FAILED, result=result, cause="zsh missing",
suggestion="Select oh-my-zsh again after zsh installs successfully.")
# oh-my-zsh's installer rewrites ~/.zshrc; preserve the current one first.
shellConfig.backup_dotfile(shellConfig.rc_path_for_shell("zsh"), console)
command = f'sh -c "$(curl -fsSL {url})" "" --unattended'
env = dict(os.environ, RUNZSH="no", CHSH="no")
try:
proc = subprocess.run(["bash", "-c", command], env=env, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, timeout=300, check=False)
status = INSTALLED if proc.returncode == 0 else FAILED
result = OperationResult("oh-my-zsh", status, ["bash", "-c", command],
proc.stdout or "", proc.stderr or "", proc.returncode)
except (OSError, subprocess.SubprocessError) as exc:
result = OperationResult("oh-my-zsh", FAILED, stderr=str(exc))
return PackageRecord("Customization", "oh-my-zsh", "Community zsh configuration framework.",
result.status, result=result)
def _switch_shell_to_zsh(console):
zsh = shutil.which("zsh")
if not zsh:
console.print("zsh not found; cannot switch shell.", style=theme.WARN)
return
try:
subprocess.run(["chsh", "-s", zsh], check=False)
console.print(f"Default shell set to {zsh}. Restart your terminal to apply.", style=theme.DIM)
except OSError as exc:
console.print(f"Could not switch shell: {exc}", style=theme.WARN)
def _finalize_secondary(plat, records, started):
"""Write the secondary report and clear the run marker."""
elapsed = time.monotonic() - started
Report("secondary", plat.pretty_name, plat.backend_key).write(records, elapsed)
state.clear_run()
return records
def run_secondary(inst, catalog, plat, console):
console.print()
menu.notice(console,
"Secondary tools marked [LOCKED] require a proot Linux chroot or root access "
"and may not fully function on stock or unrooted devices.",
title="[ HEADS UP ]", border_style=theme.WARN)
entries = [entry for _, entry in installer.flatten_secondary(catalog)]
selected = menu.secondary_menu(entries)
if not selected:
console.print("No tools selected.", style=theme.DIM)
return []
chosen = [e for e in entries if e["name"] in selected]
console.print()
menu.notice(console, "You are about to install: " + ", ".join(e["name"] for e in chosen),
title="[ CONFIRM ]", border_style=theme.ACCENT)
if not menu.confirm(console, "Proceed with installation?", default=False):
console.print("Cancelled.", style=theme.DIM)
return []
steps = _steps_from_queue(chosen, per_category=False)
started = time.monotonic()
records = _install_steps(inst, "secondary", chosen, steps, console, show_headings=False)
return _finalize_secondary(plat, records, started)
def resume_run(inst, catalog, plat, console, dry_run, marker):
"""Continue an interrupted run from the position its marker recorded, then finalize as usual."""
run_type = marker.get("run")
queue = marker.get("queue") or []
position = max(0, int(marker.get("position", 0)))
started = time.monotonic()
if run_type == "primary":
steps = _steps_from_queue(queue, per_category=True)
records = _install_steps(inst, "primary", queue, steps, console,
start_pos=position, show_headings=True)
_finalize_primary(inst, catalog, plat, console, dry_run, records, started)
elif run_type == "secondary":
steps = _steps_from_queue(queue, per_category=False)
records = _install_steps(inst, "secondary", queue, steps, console,
start_pos=position, show_headings=False)
_finalize_secondary(plat, records, started)
else:
# An unrecognized run type is not something we can safely replay; drop the marker.
state.clear_run()
def _maybe_resume(inst, catalog, plat, console, dry_run):
"""Offer to resume an interrupted run before the main menu; declining discards the marker."""
marker = state.read_run()
if not marker or not menu.is_interactive():
return
category = marker.get("category") or "a previous run"
if menu.confirm(console, f"Previous run was interrupted during {category}. Resume?", default=False):
resume_run(inst, catalog, plat, console, dry_run, marker)
else:
state.clear_run()
def run_uninstall(inst, plat, console):
"""Remove tool-installed packages the user selects, then report the outcome per package."""
candidates = state.list_installed(plat.backend_key)
if not candidates:
console.print("No tool-installed packages to uninstall.", style=theme.WARN)
return []
chosen = menu.uninstall_menu(candidates)
if not chosen:
console.print("No packages selected.", style=theme.DIM)
return []
console.print()
menu.notice(console, "You are about to remove: " + ", ".join(c["name"] for c in chosen),
title="[ CONFIRM ]", border_style=theme.ACCENT)
if not menu.confirm(console, "Proceed with removal?", default=False):
console.print("Cancelled.", style=theme.DIM)
return []
started = time.monotonic()
records = []
total = len(chosen)
for index, candidate in enumerate(chosen, start=1):
record = _remove_one(inst, candidate, index, total)
# A package that is gone (removed, or never present) is no longer tool-installed.
if record.status in (REMOVED, NOT_FOUND):
state.forget_installed(plat.backend_key, candidate["name"])
records.append(record)
elapsed = time.monotonic() - started
Report("uninstall", plat.pretty_name, plat.backend_key).write(records, elapsed)
return records
def _remove_one(inst, candidate, index, total):
"""Remove one package through its backend, mapping the backend result to an uninstall status."""
backend = inst.backend
package = candidate["package"]
def operation():
# Nothing to do if the package is already gone; report it plainly, not as a failure.
if not backend.dry_run and not backend.is_installed(package):
return OperationResult(package, NOT_FOUND)
result = backend.remove(package)
if result.status == INSTALLED: # backends signal a successful removal with INSTALLED
result.status = REMOVED
elif result.status == FAILED:
result.status = FAILED_REMOVE
return result
if inst.verbose:
inst.console.print(f"[{index}/{total}] removing {candidate['name']} ({package})")
result = operation()
if result.stdout:
inst.console.print(result.stdout)
if result.stderr:
inst.console.print(result.stderr)
inst.console.print(f" -> {result.status}")
else:
result = inst.progress.run(index, total, "remove", candidate["name"], operation)
return PackageRecord(candidate.get("category", ""), candidate["name"],
candidate.get("description", ""), result.status,
bool(candidate.get("locked")), result)
def banner_generation_flow(backend, plat, console, dry_run):
if not menu.confirm(console, "Generate a persistent terminal banner?", default=False):
return
generator = BannerGenerator(backend, console)
if not dry_run:
generator.ensure_tools()
name = menu.text(console, "Name to display:", default="hacker")
handle = menu.text(console, "Handle or tagline (optional):", default="")
text_value = f"{name} {handle}".strip() if handle else name
if menu.confirm(console, "Customize font and color scheme?", default=False):
font = menu.select(console, "Font:", [(f, f) for f in FONTS], default=FONTS[0])
scheme = menu.select(console, "Color scheme:", [(c, c) for c in COLOR_SCHEMES], default=COLOR_SCHEMES[0])
else:
font, scheme = default_settings()
if dry_run:
console.print(f"Would render banner '{text_value}' with {font}/{scheme} and persist it.",
style=theme.DIM)
return
command = generator.render_preview(text_value, font, scheme)
if command and menu.confirm(console, "Add this banner to your shell startup?", default=True):
path = generator.persist(command)
console.print(f"Banner added to {path}. It appears on your next shell start.", style=theme.DIM)
def action_report(console):
content = report.read_reports()
if content is None:
console.print("No reports found. Run an install first to generate reports/.", style=theme.WARN)
return
# Reports are plain text with no markup; print without style interpretation.
console.print(content, markup=False, highlight=False)
def action_check_update(console):
"""Query GitHub for a newer release and report the outcome in a panel. Never crashes on network failure."""
result = updateCheck.check_for_update(VERSION)
if result.status == updateCheck.UPDATE_AVAILABLE:
menu.notice(console,
f"A newer version ({result.latest}) is available.\n"
f"You are currently on v{result.current}.\n\n"
"This tool never auto-updates, so your local changes and "
"customizations stay intact. Open the release page below to review "
"the changes and download it.",
title="[ UPDATE AVAILABLE ]", border_style=theme.ACCENT)
console.print(result.url or updateCheck.RELEASES_PAGE, style=theme.ACCENT)
elif result.status == updateCheck.UP_TO_DATE:
menu.notice(console, f"You are on the latest version (v{result.current}).",
title="[ UP TO DATE ]", border_style=theme.ACCENT)
else:
menu.notice(console,
"Could not check for updates — the network may be unreachable. "
"Nothing else is affected; try again later.",
title="[ UPDATE CHECK FAILED ]", border_style=theme.WARN)
def action_doctor(backend, plat, console):
"""Audit tracked tool-installed packages, write the health report, and print a short summary."""
result = doctor.run_health_check(backend, plat.backend_key)
path = report.write_health_report(result.checked, result.discrepancies)
summary = report.health_summary_line(len(result.checked), len(result.discrepancies))
console.print()
menu.notice(console, summary, title="[ HEALTH CHECK ]",
border_style=theme.WARN if result.discrepancies else theme.ACCENT)
console.print(f"Full report: {os.path.relpath(path)}", style=theme.DIM)
def action_remove_banner(console):
removed_any = False
for shell in ("bash", "zsh"):
path, removed = shellConfig.remove_banner_block(shell, console=console)
if removed:
console.print(f"Removed banner block from {path}.", style=theme.DIM)
removed_any = True
if removed_any:
console.print("Restart your terminal for the change to take effect.", style=theme.DIM)
else:
console.print("No banner block found.", style=theme.WARN)
def action_set_editor(catalog, console, dry_run):
editors = catalog.get("customization", {}).get("editors", [])
selected = menu.editor_multiselect(editors)
editor = _choose_default_editor(selected)
if not editor:
console.print("No editor selected.", style=theme.WARN)
return
if dry_run:
console.print(f"Would set EDITOR to {editor} (dry-run).", style=theme.DIM)
return
path = shellConfig.set_editor(editor, console=console)
console.print(f"EDITOR set to {editor} in {path}. Restart your terminal to apply.", style=theme.DIM)
def finish_run(backend, plat, console, dry_run, no_banner):
"""Offer banner generation, then show the end banner. Shared by the flag paths."""
banner_generation_flow(backend, plat, console, dry_run)
if not no_banner:
banner.show_end(VERSION, plat, console)
def run_menu(inst, catalog, plat, console, backend, dry_run):
"""Interactive main menu shown for a bare invocation. Returns an exit code."""
end_shown = False
while True:
choice = menu.main_menu(console)
if choice == "1":
run_primary(inst, catalog, plat, console, dry_run, inst.verbose)
end_shown = _show_end_once(plat, console, end_shown)
elif choice == "2":
end_shown = _menu_secondary(inst, catalog, plat, console, dry_run, end_shown)
elif choice == "3":
banner_generation_flow(backend, plat, console, dry_run)
elif choice == "4":
report_submenu(console)
elif choice == "5":
run_uninstall(inst, plat, console)
elif choice == "6":
action_check_update(console)
elif choice == "7":
action_doctor(backend, plat, console)
else: # "8" or a cancelled prompt
if not end_shown:
banner.show_end(VERSION, plat, console)
return 0
def _show_end_once(plat, console, end_shown):
"""Show the end banner only if it has not fired yet this session."""
if not end_shown:
banner.show_end(VERSION, plat, console)
return True
def _menu_secondary(inst, catalog, plat, console, dry_run, end_shown):
"""Run Secondary from the menu, gated behind a completed Primary run."""
if not state.primary_completed():
console.print("Secondary Setup requires Primary Setup to be run first.", style=theme.WARN)
if not menu.confirm(console, "Run Primary Setup now?", default=False):
return end_shown
run_primary(inst, catalog, plat, console, dry_run, inst.verbose)
run_secondary(inst, catalog, plat, console)
return _show_end_once(plat, console, end_shown)
def report_submenu(console):
"""Nested View Last Report menu; each report opens independently."""
while True:
choice = menu.report_menu(console)
if choice == "4a":
open_report(console, "primary")
elif choice == "4b":
open_report(console, "secondary")
elif choice == "4c":
open_report(console, "primary")
open_report(console, "secondary")
else: # "4d" or a cancelled prompt
return
# Exit-key hints shown before an editor launches so users are never trapped in it.
_EDITOR_HINTS = {
"nano": "To exit nano: press Ctrl+X, then Y to save (or N to discard), then Enter.",
"vim": "To exit vim: press Esc, type :wq, then Enter (or :q! to discard).",
"vi": "To exit vi: press Esc, type :wq, then Enter (or :q! to discard).",
"nvim": "To exit neovim: press Esc, type :wq, then Enter (or :q! to discard).",
"micro": "To exit micro: press Ctrl+Q (press Ctrl+S first to save).",
"emacs": "To exit emacs: press Ctrl+X then Ctrl+C (it will prompt to save).",
}
_EDITOR_HINT_DEFAULT = "Save and close the editor to return to the menu."
def open_report(console, kind):
"""Open a saved report in nano, falling back to the configured editor, then to a printed path."""
path = report.report_path(kind)
if not os.path.isfile(path):
console.print(f"No {kind} report found yet — run {kind.capitalize()} Setup first.",
style=theme.WARN)
return
filename = os.path.basename(path)
for editor in _report_editors():
console.print(f"Opening {filename} in {_editor_name(editor)}...", style=theme.DIM)
console.print(_editor_hint(editor), style=theme.DIM)
if _launch_editor(editor, path):
return
console.print(f"Could not open {_editor_name(editor)}.", style=theme.WARN)
console.print("No usable editor found. Open this file manually:", style=theme.WARN)
console.print(os.path.abspath(path))
def _report_editors():
"""Editors to try, nano first, then the one set in Customization or the $EDITOR env var."""
editors = []
if shutil.which("nano"):
editors.append("nano")
configured = shellConfig.get_editor() or os.environ.get("EDITOR")
if configured and _editor_name(configured) != "nano":
editors.append(configured)
return editors
def _editor_name(editor):
# shlex.split raises on unbalanced quotes; fall back to a plain split so naming never crashes.
try:
parts = shlex.split(editor)
except ValueError:
parts = editor.split()
return os.path.basename(parts[0]) if parts else editor
def _editor_hint(editor):
return _EDITOR_HINTS.get(_editor_name(editor), _EDITOR_HINT_DEFAULT)
def _launch_editor(editor, path):
"""Run an interactive editor on path. Returns True if it launched, False if it could not."""
try:
subprocess.run(shlex.split(editor) + [path], check=False)
return True
except (OSError, ValueError):
return False
def main(argv=None):
args = build_parser().parse_args(argv)
console = Console()
if args.version:
console.print(f"LinuxSetupPro v{VERSION}")
return 0
if args.report:
action_report(console)
return 0
if args.check_update:
action_check_update(console)
return 0
if args.remove_banner:
action_remove_banner(console)
return 0
plat = resolve_platform(console)
check_python_version(plat, console)
if not args.no_banner:
banner.show_startup(VERSION, console, plat, public_ip=args.public_ip)
backend = get_backend(plat.backend_key, dry_run=args.dry_run)
if backend is None:
console.print("No package backend available for this platform.", style=theme.ERROR)
return 1
if args.doctor:
action_doctor(backend, plat, console)
return 0
catalog = installer.load_catalog()
if args.set_editor:
action_set_editor(catalog, console, args.dry_run)
return 0
classifier = ErrorClassifier(plat.backend_key)
inst = installer.Installer(backend, classifier, console, verbose=args.verbose)
if args.uninstall:
run_uninstall(inst, plat, console)
if not args.no_banner:
banner.show_end(VERSION, plat, console)
return 0
if args.only == "secondary":
run_secondary(inst, catalog, plat, console)
finish_run(backend, plat, console, args.dry_run, args.no_banner)
return 0
if args.only == "primary":
run_primary(inst, catalog, plat, console, args.dry_run, args.verbose)
finish_run(backend, plat, console, args.dry_run, args.no_banner)
return 0
# Any CLI flag keeps the direct pre-menu behavior; the menu is reserved for a bare invocation.
raw_args = sys.argv[1:] if argv is None else argv
if raw_args:
run_primary(inst, catalog, plat, console, args.dry_run, args.verbose)
if menu.confirm(console, "Proceed to the secondary security tools menu?", default=False):
run_secondary(inst, catalog, plat, console)
finish_run(backend, plat, console, args.dry_run, args.no_banner)
return 0
# Bare invocation: offer to resume any interrupted run, then show the interactive main menu.
_maybe_resume(inst, catalog, plat, console, args.dry_run)
return run_menu(inst, catalog, plat, console, backend, args.dry_run)
if __name__ == "__main__":
sys.exit(main())