-
Notifications
You must be signed in to change notification settings - Fork 18
Expand file tree
/
Copy pathcli.py
More file actions
executable file
·1418 lines (1219 loc) · 58.4 KB
/
Copy pathcli.py
File metadata and controls
executable file
·1418 lines (1219 loc) · 58.4 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
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/python
# cli.py
"""Universal Package Helper CLI - Main module with improved consistency."""
import argparse
import sys
import os
import webbrowser
from typing import List, Tuple, Optional
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
import logging
# Import modules
from archpkg.config import JUNK_KEYWORDS, LOW_PRIORITY_KEYWORDS, BOOST_KEYWORDS, DISTRO_MAP
from archpkg.exceptions import PackageManagerNotFound, NetworkError, TimeoutError
from archpkg.search_aur import search_aur
from archpkg.search_pacman import search_pacman
from archpkg.search_flatpak import search_flatpak
from archpkg.search_snap import search_snap
from archpkg.search_apt import search_apt
from archpkg.search_dnf import search_dnf
from archpkg.search_zypper import search_zypper
from archpkg.command_gen import generate_command
from archpkg.logging_config import get_logger, PackageHelperLogger
from archpkg.suggest import suggest_apps, list_purposes
from archpkg.cache import get_cache_manager, CacheConfig
from archpkg.github_install import install_from_github, validate_github_url
from archpkg.config_manager import get_user_config, set_config_option
from archpkg.update_manager import check_for_updates, trigger_update_check
from archpkg.download_manager import install_updates, start_background_update_service, stop_background_update_service
from archpkg.installed_apps import track_package, get_all_installed_packages, get_packages_with_updates, InstalledPackage
console = Console()
logger = get_logger(__name__)
# Dependency check for `distro`
try:
import distro
logger.info("Successfully imported distro module")
except ModuleNotFoundError as e:
logger.error(f"Required dependency 'distro' is not installed: {e}")
console.print(Panel(
"[red]Required dependency 'distro' is not installed.[/red]\n\n"
"[bold yellow]To fix this issue:[/bold yellow]\n"
"- Run: [cyan]pip install distro[/cyan]\n"
"- Or reinstall the package: [cyan]pip install --upgrade archpkg-helper[/cyan]\n"
"- If using pipx: [cyan]pipx reinstall archpkg-helper[/cyan]",
title="Missing Dependency",
border_style="red"
))
sys.exit(1)
def detect_distro() -> str:
"""Detect the current Linux distribution with detailed error handling.
Returns:
str: Detected distribution family ('arch', 'debian', 'fedora', or 'unknown')
"""
logger.info("Starting distribution detection")
try:
dist = distro.id().lower().strip()
logger.debug(f"Raw distribution ID: '{dist}'")
if not dist:
logger.warning("Empty distribution ID detected")
console.print(Panel(
"[yellow]Unable to detect your Linux distribution.[/yellow]\n\n"
"[bold cyan]Possible solutions:[/bold cyan]\n"
"- Ensure you're running on a supported Linux distribution\n"
"- Check if the /etc/os-release file exists\n"
"- Try running: [cyan]cat /etc/os-release[/cyan]",
title="Distribution Detection Warning",
border_style="yellow"
))
return "unknown"
detected_family = DISTRO_MAP.get(dist, "unknown")
logger.info(f"Detected distribution: '{dist}' -> family: '{detected_family}'")
if detected_family == "unknown":
logger.warning(f"Unsupported distribution detected: '{dist}'")
console.print(Panel(
f"[yellow]Unsupported distribution detected: '{dist}'[/yellow]\n\n"
"[bold cyan]What you can do:[/bold cyan]\n"
"- Only Flatpak and Snap searches will be available\n"
"- Consider requesting support for your distribution\n"
f"- Supported distributions: {', '.join(DISTRO_MAP.keys())}",
title="Unsupported Distribution",
border_style="yellow"
))
return detected_family
except Exception as e:
PackageHelperLogger.log_exception(logger, "Failed to detect distribution", e)
console.print(Panel(
f"[red]Failed to detect your Linux distribution.[/red]\n\n"
f"[bold]Error details:[/bold] {str(e)}\n\n"
"[bold cyan]Troubleshooting steps:[/bold cyan]\n"
"- Ensure you're running on a Linux system\n"
"- Check if the 'distro' package is properly installed\n"
"- Try reinstalling: [cyan]pip install --upgrade distro[/cyan]\n"
"- Report this issue if the problem persists",
title="Distribution Detection Failed",
border_style="red"
))
return "unknown"
def is_valid_package(name: str, desc: Optional[str]) -> bool:
"""Check if a package is valid (not junk/meta package)."""
desc = (desc or "").lower()
is_junk = any(bad in desc for bad in JUNK_KEYWORDS)
if is_junk:
logger.debug(f"Package '{name}' filtered out as junk package")
return not is_junk
def deduplicate_packages(packages: List[Tuple[str, str, str]], prefer_aur: bool = False) -> List[Tuple[str, str, str]]:
"""Remove duplicate packages, preferring Pacman over AUR by default.
Args:
packages: List of (name, description, source) tuples
prefer_aur: If True, prefer AUR packages over Pacman when duplicates exist
Returns:
List[Tuple[str, str, str]]: Deduplicated packages with preferred sources
"""
logger.debug(f"Deduplicating {len(packages)} packages, prefer_aur={prefer_aur}")
package_groups = {}
for name, desc, source in packages:
if name not in package_groups:
package_groups[name] = []
package_groups[name].append((name, desc, source))
deduplicated = []
for name, group in package_groups.items():
if len(group) == 1:
deduplicated.append(group[0])
else:
sources = [source for _, _, source in group]
if prefer_aur and 'aur' in sources:
preferred = next((pkg for pkg in group if pkg[2] == 'aur'), group[0])
logger.debug(f"Package '{name}' available in multiple sources, preferring AUR")
elif 'pacman' in sources:
preferred = next((pkg for pkg in group if pkg[2] == 'pacman'), group[0])
logger.debug(f"Package '{name}' available in multiple sources, preferring Pacman")
else:
preferred = group[0]
logger.debug(f"Package '{name}' available in multiple sources, using first: {preferred[2]}")
deduplicated.append(preferred)
logger.info(f"Deduplicated {len(packages)} packages to {len(deduplicated)} unique packages")
return deduplicated
def get_top_matches(query: str, all_packages: List[Tuple[str, str, str]], limit: int = 5) -> List[Tuple[str, str, str]]:
"""Get top matching packages with improved scoring algorithm."""
logger.debug(f"Scoring {len(all_packages)} packages for query: '{query}'")
if not all_packages:
logger.debug("No packages to score")
return []
query = query.lower()
query_tokens = set(query.split())
scored_results = []
for name, desc, source in all_packages:
if not is_valid_package(name, desc):
continue
name_l = name.lower()
desc_l = (desc or "").lower()
name_tokens = set(name_l.replace("-", " ").split())
desc_tokens = set(desc_l.split())
score = 0
if query == name_l:
score += 150
logger.debug(f"Exact match bonus for '{name}': +150")
elif query in name_l:
score += 80
logger.debug(f"Substring match bonus for '{name}': +80")
for q in query_tokens:
for token in name_tokens:
if token.startswith(q):
score += 4
for token in desc_tokens:
if token.startswith(q):
score += 1
# Boost keywords
for word in BOOST_KEYWORDS:
if word in name_l or word in desc_l:
score += 3
# Penalize low priority
for bad in LOW_PRIORITY_KEYWORDS:
if bad in name_l or bad in desc_l:
score -= 10
if name_l.endswith("-bin"):
score += 5
# Source priority (IMPROVED: consistent scoring)
source_priority = {
"pacman": 40, "apt": 40, "dnf": 40, "zypper": 40,
"aur": 20,
"flatpak": 10,
"snap": 5
}
score += source_priority.get(source.lower(), 0)
scored_results.append(((name, desc, source), score))
scored_results.sort(key=lambda x: x[1], reverse=True)
top = [pkg for pkg, score in scored_results if score > 0][:limit]
logger.info(f"Found {len(top)} top matches from {len(all_packages)} total packages")
for i, (pkg_info, score) in enumerate(scored_results[:limit]):
logger.debug(f"Top match #{i+1}: {pkg_info[0]} (score: {score})")
return top
def show_opensuse_brave_guidance() -> None:
"""Show guidance for installing Brave browser on openSUSE."""
logger.info("Showing Brave browser installation guidance for openSUSE")
console.print(Panel(
"[bold cyan]Brave Browser on openSUSE[/bold cyan]\n\n"
"[yellow]Brave requires adding an external repository.[/yellow]\n\n"
"[bold]Option 1: Add Brave Repository (Recommended)[/bold]\n"
"1. Import the repository key:\n"
" [cyan]sudo rpm --import https://brave-browser-rpm-release.s3.brave.com/brave-core.asc[/cyan]\n\n"
"2. Add the Brave repository:\n"
" [cyan]sudo zypper addrepo https://brave-browser-rpm-release.s3.brave.com/brave-browser.repo[/cyan]\n\n"
"3. Install Brave:\n"
" [cyan]sudo zypper install brave-browser[/cyan]\n\n"
"[bold]Option 2: Install via Flatpak[/bold]\n"
" [cyan]flatpak install flathub com.brave.Browser[/cyan]\n\n"
"[dim]Note: After adding the repository, you can search for Brave again with archpkg.[/dim]",
title="🦁 Brave Browser Installation",
border_style="blue"
))
def github_fallback(query: str) -> None:
"""Provide GitHub search fallback with clear messaging."""
logger.info(f"No packages found for query '{query}', providing GitHub fallback")
console.print(Panel(
f"[yellow]No packages found for '{query}' in available repositories.[/yellow]\n\n"
"[bold cyan]Alternative options:[/bold cyan]\n"
"- Search GitHub for source code or releases\n"
"- Check if the package name is spelled correctly\n"
"- Try searching with different keywords\n"
"- Look for similar packages with: [cyan]archpkg <similar-name>[/cyan]",
title="No Packages Found",
border_style="yellow"
))
try:
url = f"https://github.com/search?q={query.replace(' ', '+')}&type=repositories"
logger.info(f"Opening GitHub search URL: {url}")
console.print(f"[blue]Opening GitHub search:[/blue] {url}")
webbrowser.open(url)
except Exception as e:
PackageHelperLogger.log_exception(logger, "Failed to open web browser for GitHub search", e)
console.print(Panel(
f"[red]Failed to open web browser.[/red]\n\n"
f"[bold]Error:[/bold] {str(e)}\n\n"
"[bold cyan]Manual search:[/bold cyan]\n"
f"- Visit: https://github.com/search?q={query.replace(' ', '+')}&type=repositories\n"
"- Or search manually on GitHub",
title="Browser Error",
border_style="red"
))
def handle_upgrade_command() -> None:
"""Handle the upgrade command to update archpkg from GitHub."""
logger.info("Starting archpkg upgrade process")
console.print(Panel(
"[bold cyan]🔄 Upgrading archpkg from GitHub...[/bold cyan]\n\n"
"[dim]This will pull the latest code and reinstall archpkg.[/dim]",
title="Archpkg Upgrade",
border_style="cyan"
))
# Check if pipx is available
import shutil
if not shutil.which("pipx"):
logger.error("pipx command not found")
console.print(Panel(
"[red]❌ pipx is not installed or not in PATH.[/red]\n\n"
"[bold cyan]To install pipx:[/bold cyan]\n"
"- Arch Linux: [cyan]sudo pacman -S pipx && pipx ensurepath[/cyan]\n"
"- Debian/Ubuntu: [cyan]sudo apt install pipx && pipx ensurepath[/cyan]\n"
"- Fedora: [cyan]sudo dnf install pipx && pipx ensurepath[/cyan]\n\n"
"[bold yellow]Note:[/bold yellow] After installing pipx, restart your terminal.",
title="pipx Not Found",
border_style="red"
))
return
console.print("[blue]📥 Pulling latest changes from repository...[/blue]")
# Run the upgrade command
upgrade_cmd = "pipx install --force git+https://github.com/AdmGenSameer/archpkg-helper.git"
logger.info(f"Executing upgrade command: {upgrade_cmd}")
try:
console.print("[blue]📦 Reinstalling with latest code...[/blue]\n")
exit_code = os.system(upgrade_cmd)
if exit_code != 0:
logger.error(f"Upgrade failed with exit code: {exit_code}")
console.print(Panel(
f"[red]❌ Upgrade failed with exit code {exit_code}.[/red]\n\n"
"[bold cyan]Troubleshooting:[/bold cyan]\n"
"- Check your internet connection\n"
"- Ensure you can access GitHub (https://github.com)\n"
"- Try again in a few moments\n"
"- Check if pipx is working: [cyan]pipx list[/cyan]\n\n"
"[bold yellow]Manual upgrade:[/bold yellow]\n"
f"Run: [cyan]{upgrade_cmd}[/cyan]",
title="Upgrade Failed",
border_style="red"
))
else:
logger.info("Successfully upgraded archpkg")
console.print(Panel(
"[bold green]✅ Successfully upgraded archpkg! You now have the latest features![/bold green]\n\n"
"[bold cyan]💡 Tip:[/bold cyan] Run [cyan]archpkg --help[/cyan] to see what's new!",
title="Upgrade Complete",
border_style="green"
))
except Exception as e:
PackageHelperLogger.log_exception(logger, "Unexpected error during upgrade", e)
console.print(Panel(
f"[red]❌ An unexpected error occurred during upgrade.[/red]\n\n"
f"[bold]Error details:[/bold] {str(e)}\n\n"
"[bold cyan]What to do:[/bold cyan]\n"
"- Check your internet connection\n"
"- Ensure pipx is properly installed\n"
"- Try running manually: [cyan]pipx install --force git+https://github.com/AdmGenSameer/archpkg-helper.git[/cyan]\n"
"- Report this issue if it persists",
title="Upgrade Error",
border_style="red"
))
def handle_install_command(args) -> None:
"""Handle the install command for one or more packages."""
packages = args.packages
logger.info(f"Install command for packages: {packages}")
# Check if it's a GitHub installation
if len(packages) == 1 and (packages[0].startswith('github:') or packages[0].startswith('https://github.com/')):
github_url = validate_github_url(packages[0])
if github_url:
success = install_from_github(packages[0])
if success:
console.print("[bold green]✓ GitHub installation completed![/bold green]")
else:
console.print("[bold red]✗ GitHub installation failed.[/bold red]")
sys.exit(1)
else:
console.print("[red]Invalid GitHub URL format[/red]")
sys.exit(1)
return
# Determine tracking preference (track is default, no_track overrides it)
should_track = not args.no_track
# Normal package installation
detected = detect_distro()
if len(packages) == 1:
# Single package installation
package_name = packages[0]
logger.info(f"Installing single package: {package_name}")
# Search for the package
results = []
if detected == "arch":
try:
results.extend(search_aur(package_name))
except Exception:
pass
try:
results.extend(search_pacman(package_name))
except Exception:
pass
elif detected == "debian":
try:
results.extend(search_apt(package_name))
except Exception:
pass
elif detected == "fedora":
try:
results.extend(search_dnf(package_name))
except Exception:
pass
# Universal package managers
try:
results.extend(search_flatpak(package_name))
except Exception:
pass
try:
results.extend(search_snap(package_name))
except Exception:
pass
if not results:
console.print(f"[red]No packages found for '{package_name}'[/red]")
sys.exit(1)
top_matches = get_top_matches(package_name, results, limit=5)
if not top_matches:
console.print(f"[red]No suitable matches found for '{package_name}'[/red]")
sys.exit(1)
# Display options
table = Table(title="Found Packages")
table.add_column("Index", style="cyan", no_wrap=True)
table.add_column("Package Name", style="green")
table.add_column("Source", style="blue")
table.add_column("Description", style="yellow")
for idx, (pkg, desc, source) in enumerate(top_matches, 1):
table.add_row(str(idx), pkg, source, desc or "No description")
console.print(table)
try:
choice = input("\nSelect a package to install [1-5 or press Enter to cancel]: ")
if not choice.strip():
console.print("[yellow]Installation cancelled.[/yellow]")
return
choice = int(choice)
if not (1 <= choice <= len(top_matches)):
console.print("[red]Invalid choice.[/red]")
return
pkg, desc, source = top_matches[choice - 1]
command = generate_command(pkg, source)
if not command:
console.print(f"[red]Cannot generate install command for {source}[/red]")
return
console.print(f"\n[bold green]Install Command:[/bold green] {command}")
console.print("[bold yellow]Press Enter to install, or Ctrl+C to cancel...[/bold yellow]")
input()
console.print("[blue]Running install command...[/blue]")
exit_code = os.system(command)
if exit_code == 0:
console.print(f"[bold green]✓ Successfully installed {pkg}![/bold green]")
if should_track:
track_package(pkg, source, "latest")
console.print("[dim]Package tracked for updates[/dim]")
else:
console.print(f"[red]✗ Installation failed with exit code {exit_code}[/red]")
sys.exit(1)
except KeyboardInterrupt:
console.print("\n[yellow]Installation cancelled.[/yellow]")
return
except ValueError:
console.print("[red]Invalid input. Please enter a number.[/red]")
return
else:
# Batch installation
logger.info(f"Batch installation for packages: {packages}")
batch_install_packages(packages, should_track)
def batch_install_packages(package_names: List[str], should_track: bool = True) -> None:
"""Install multiple packages in batch mode with progress tracking."""
logger.info(f"Starting batch installation for packages: {package_names}")
if not package_names:
console.print("[red]No packages specified for batch installation.[/red]")
return
detected = detect_distro()
console.print(f"\n[bold cyan]Batch Installation Mode[/bold cyan]")
console.print(f"Target platform: [cyan]{detected}[/cyan]")
console.print(f"Packages to install: [yellow]{', '.join(package_names)}[/yellow]\n")
# Validate all packages first
console.print("[blue]Validating packages...[/blue]")
validated_packages = []
validation_errors = []
for i, pkg_name in enumerate(package_names, 1):
console.print(f" [{i}/{len(package_names)}] Checking '{pkg_name}'...")
results = []
if detected == "arch":
try:
results.extend(search_aur(pkg_name))
except Exception:
pass
try:
results.extend(search_pacman(pkg_name))
except Exception:
pass
elif detected == "debian":
try:
results.extend(search_apt(pkg_name))
except Exception:
pass
elif detected == "fedora":
try:
results.extend(search_dnf(pkg_name))
except Exception:
pass
try:
results.extend(search_flatpak(pkg_name))
except Exception:
pass
try:
results.extend(search_snap(pkg_name))
except Exception:
pass
if not results:
validation_errors.append(f"'{pkg_name}': No packages found")
console.print(f" [red]✗[/red] No packages found")
continue
top_matches = get_top_matches(pkg_name, results, limit=1)
if not top_matches:
validation_errors.append(f"'{pkg_name}': No suitable matches found")
console.print(f" [red]✗[/red] No suitable matches found")
continue
pkg, desc, source = top_matches[0]
command = generate_command(pkg, source)
if not command:
validation_errors.append(f"'{pkg_name}': Cannot generate install command")
console.print(f" [red]✗[/red] Cannot generate install command")
continue
validated_packages.append((pkg_name, pkg, desc, source, command))
console.print(f" [green]✓[/green] Found: {pkg} ({source})")
if validation_errors:
console.print(f"\n[red]Validation failed for {len(validation_errors)} package(s):[/red]")
for error in validation_errors:
console.print(f" - {error}")
console.print("\n[yellow]Batch installation cancelled.[/yellow]")
return
console.print(f"\n[green]✓ All {len(validated_packages)} packages validated![/green]\n")
# Proceed with installation
console.print("[blue]Starting batch installation...[/blue]\n")
successful_installs = []
failed_installs = []
for i, (original_name, pkg, desc, source, command) in enumerate(validated_packages, 1):
console.print(f"[{i}/{len(validated_packages)}] Installing [bold cyan]{pkg}[/bold cyan] ({source})...")
console.print(f" Command: [dim]{command}[/dim]")
exit_code = os.system(command)
if exit_code == 0:
console.print(f" [green]✓[/green] Successfully installed {pkg}")
successful_installs.append(pkg)
if should_track:
try:
track_package(pkg, source, "latest")
except Exception as e:
logger.warning(f"Failed to track package {pkg}: {e}")
else:
console.print(f" [red]✗[/red] Failed to install {pkg}")
failed_installs.append((pkg, exit_code))
# Summary
console.print(f"\n[bold]Batch Installation Summary:[/bold]")
console.print(f"Total packages: {len(validated_packages)}")
console.print(f"[green]Successful: {len(successful_installs)}[/green]")
if successful_installs:
console.print(f" - {', '.join(successful_installs)}")
if failed_installs:
console.print(f"[red]Failed: {len(failed_installs)}[/red]")
for pkg, code in failed_installs:
console.print(f" - {pkg} (exit code: {code})")
def handle_update_command(args) -> None:
"""Handle the update command."""
packages = args.packages if args.packages else None
check_only = args.check_only
if check_only:
console.print("[blue]Checking for updates...[/blue]")
result = trigger_update_check()
if result["status"] == "success":
updates_found = result.get("updates_found", 0)
if updates_found > 0:
console.print(f"[green]Found {updates_found} update(s) available![/green]")
console.print("Run 'archpkg update' to install them.")
else:
console.print("[green]All packages are up to date![/green]")
else:
console.print(f"[red]Update check failed: {result.get('message', 'Unknown error')}[/red]")
else:
console.print("[blue]Installing updates...[/blue]")
result = install_updates(packages)
if result["status"] == "success":
installed = result.get("installed", 0)
failed = result.get("failed", 0)
if installed > 0:
console.print(f"[green]Successfully installed {installed} update(s)[/green]")
if failed > 0:
console.print(f"[red]Failed to install {failed} update(s)[/red]")
for item in result.get("results", []):
if item.get("status") == "failed":
console.print(f" - {item['package']}: {item.get('error', 'Unknown error')}")
if installed == 0 and failed == 0:
console.print("[green]No updates available[/green]")
else:
console.print("[red]Update installation failed[/red]")
def handle_config_command(args) -> None:
"""Handle the config command."""
if args.list:
config = get_user_config()
console.print("[bold]Current Configuration:[/bold]")
for key, value in config.__dict__.items():
if not key.startswith('_'):
console.print(f" {key}: {value}")
return
if not args.key:
console.print("[red]Error: Specify a configuration key or use --list[/red]")
return
if args.value is None:
# Get value
config = get_user_config()
if hasattr(config, args.key):
console.print(f"{args.key}: {getattr(config, args.key)}")
else:
console.print(f"[red]Unknown configuration key: {args.key}[/red]")
else:
# Set value
try:
value = args.value
# Convert to appropriate type
if value.lower() in ('true', 'false'):
value = value.lower() == 'true'
elif value.isdigit():
value = int(value)
elif value.replace('.', '').isdigit():
value = float(value)
set_config_option(args.key, value)
console.print(f"[green]Updated {args.key} = {value}[/green]")
except Exception as e:
console.print(f"[red]Failed to update configuration: {e}[/red]")
def handle_list_installed_command(args) -> None:
"""Handle the list-installed command."""
packages = get_all_installed_packages()
if not packages:
console.print("[yellow]No packages are currently being tracked for updates.[/yellow]")
console.print("Use 'archpkg install --track <package>' to track packages.")
return
table = Table(title="Tracked Installed Packages")
table.add_column("Package Name", style="green")
table.add_column("Source", style="blue")
table.add_column("Installed Version", style="cyan")
table.add_column("Update Available", style="yellow")
table.add_column("Last Updated", style="magenta")
for pkg in packages:
update_status = "[green]No[/green]" if not pkg.update_available else "[red]Yes[/red]"
last_updated = pkg.install_date or "Never"
table.add_row(
pkg.name,
pkg.source,
pkg.version or "Unknown",
update_status,
last_updated
)
console.print(table)
def handle_service_command(args) -> None:
"""Handle the service command."""
action = args.action
if action == "start":
start_background_update_service()
console.print("[green]Background update service started[/green]")
elif action == "stop":
stop_background_update_service()
console.print("[green]Background update service stopped[/green]")
elif action == "status":
config = get_user_config()
if config.auto_update_enabled:
console.print("[green]Background update service is enabled[/green]")
console.print(f"Check interval: {config.update_check_interval_hours} hours")
console.print(f"Auto-install: {'Enabled' if config.auto_install_updates else 'Disabled'}")
else:
console.print("[yellow]Background update service is disabled[/yellow]")
def handle_web_command(args) -> None:
"""Handle the web command."""
try:
from archpkg.web import app as web_app
console.print(Panel(
f"[bold cyan]🌐 Starting Web Interface[/bold cyan]\n\n"
f"Host: [yellow]{args.host}[/yellow]\n"
f"Port: [yellow]{args.port}[/yellow]\n\n"
f"Access the interface at:\n"
f"[cyan]http://{args.host}:{args.port}/[/cyan]\n\n"
f"Press [bold]Ctrl+C[/bold] to stop the server.",
title="Web Interface",
border_style="cyan"
))
web_app.run(host=args.host, port=args.port, debug=False)
except KeyboardInterrupt:
console.print("\n[yellow]Web server stopped.[/yellow]")
except Exception as e:
console.print(f"[red]Failed to start web server: {e}[/red]")
def handle_search_errors(source_name: str, error: Exception) -> None:
"""Centralized error handling for search operations."""
PackageHelperLogger.log_exception(logger, f"{source_name} search failed", error)
error_messages = {
"aur": {
"network": "Cannot connect to AUR servers. Check your internet connection.",
"timeout": "AUR search timed out. Try again later.",
"generic": "AUR search failed. The service might be temporarily unavailable."
},
"pacman": {
"not_found": "pacman command not found. Install pacman or run on Arch-based system.",
"permission": "Permission denied running pacman. Check your user permissions.",
"generic": "pacman search failed. Ensure pacman is properly installed."
},
"flatpak": {
"not_found": "flatpak command not found. Install flatpak first.",
"no_remotes": "No Flatpak remotes configured. Run: flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo",
"generic": "Flatpak search failed. Ensure Flatpak is properly configured."
},
"snap": {
"not_found": "snap command not found. Install snapd first.",
"not_running": "snapd service is not running. Run: sudo systemctl start snapd",
"generic": "Snap search failed. Ensure snapd is installed and running."
},
"apt": {
"not_found": "apt-cache command not found. Run on Debian/Ubuntu-based system.",
"update_needed": "Package cache is outdated. Run: sudo apt update",
"generic": "APT search failed. Update package cache or check APT configuration."
},
"dnf": {
"not_found": "dnf command not found. Run on Fedora/RHEL-based system.",
"cache_error": "DNF cache error. Try: sudo dnf clean all && sudo dnf makecache",
"generic": "DNF search failed. Check DNF configuration or try clearing cache."
},
"zypper": {
"not_found": "zypper command not found. Run on openSUSE-based system.",
"cache_error": "Zypper cache error. Try: sudo zypper refresh",
"generic": "Zypper search failed. Check Zypper configuration or try refreshing cache."
}
}
# Determine specific error type
if isinstance(error, (NetworkError, ConnectionError)):
error_type = "network"
elif isinstance(error, TimeoutError):
error_type = "timeout"
elif isinstance(error, PackageManagerNotFound):
error_type = "not_found"
elif isinstance(error, PermissionError):
error_type = "permission"
else:
error_type = "generic"
message = error_messages.get(source_name, {}).get(error_type, f"{source_name} search encountered an error.")
console.print(f"[yellow]{source_name.upper()}: {message}[/yellow]")
def main() -> None:
"""
Main entrypoint for CLI search + install flow.
IMPROVED: Better type annotations and error handling.
"""
logger.info("Starting archpkg-helper CLI")
# Custom help message with emojis and comprehensive information
description = """🎯 ArchPkg Helper - Universal Package Manager for All Linux Distros
📦 What does it do?
Searches and installs packages across multiple sources:
✓ Official repos (pacman, apt, dnf, zypper)
✓ AUR (Arch User Repository)
✓ Flatpak & Snap (works on any distro)"""
epilog = """
🔍 SEARCH FOR PACKAGES
archpkg search <package-name>
archpkg <package-name> (search is default)
Examples:
🔸 archpkg firefox
🔸 archpkg visual studio code
🔸 archpkg search telegram
Flags:
--aur Prefer AUR packages over official repos (Arch only)
--no-cache Skip cache, search fresh results
💡 GET APP SUGGESTIONS BY PURPOSE
archpkg suggest <purpose>
Examples:
🔸 archpkg suggest video editing
🔸 archpkg suggest office
🔸 archpkg suggest programming
--list Show all available purposes
⚙️ CACHE MANAGEMENT
--cache-stats Show cache statistics
--clear-cache all Clear all cached results
--clear-cache aur Clear only AUR cache
🔧 ADVANCED OPTIONS
--debug Show detailed debug information
--log-info Show logging configuration
🔄 UPGRADE ARCHPKG
archpkg upgrade Upgrade archpkg tool from GitHub to get latest features
🌍 Supports: Arch, Manjaro, EndeavourOS, Ubuntu, Debian, Fedora, openSUSE, and more!
"""
# Handle backward compatibility: if first non-flag argument is not a known command, treat as search
# This allows "archpkg firefox" to work the same as "archpkg search firefox"
if len(sys.argv) > 1:
first_arg_idx = 1
# Skip over flags to find first positional argument
while first_arg_idx < len(sys.argv):
arg = sys.argv[first_arg_idx]
if arg.startswith('-'):
first_arg_idx += 1
# Skip flag value if it's an option that takes a value
if first_arg_idx < len(sys.argv) and arg in ['--clear-cache'] and not sys.argv[first_arg_idx].startswith('-'):
first_arg_idx += 1
else:
# Found a positional argument
break
# If we found a positional arg and it's not a known command, inject 'search'
known_commands = ['search', 'suggest', 'upgrade', 'install', 'update', 'config', 'list-installed', 'service', 'web', '-h', '--help']
if first_arg_idx < len(sys.argv) and sys.argv[first_arg_idx] not in known_commands:
sys.argv.insert(first_arg_idx, 'search')
parser = argparse.ArgumentParser(
description=description,
epilog=epilog,
formatter_class=argparse.RawDescriptionHelpFormatter,
add_help=False # We'll add custom help
)
# Add custom help argument
parser.add_argument('-h', '--help', action='store_true', help='Show this help message')
# Global arguments (must come before subparsers)
parser.add_argument('--debug', action='store_true', help='Enable debug logging to console')
parser.add_argument('--log-info', action='store_true', help='Show logging configuration and exit')
parser.add_argument('--no-cache', action='store_true', help='Bypass cache and perform fresh search')
parser.add_argument('--cache-stats', action='store_true', help='Show cache statistics and exit')
parser.add_argument('--clear-cache', choices=['all', 'aur', 'pacman', 'apt', 'dnf', 'zypper', 'flatpak', 'snap'],
help='Clear cache for specified source or all sources')
parser.add_argument('--aur', action='store_true', help='Prefer AUR packages over Pacman when both are available')
subparsers = parser.add_subparsers(dest='command', help='Available commands')
# Search command (default behavior)
search_parser = subparsers.add_parser(
'search',
help='Search for packages by name',
formatter_class=argparse.RawDescriptionHelpFormatter
)
search_parser.add_argument('query', type=str, nargs='*', help='Name of the software to search for')
search_parser.add_argument('--aur', action='store_true', help='Prefer AUR packages over Pacman when both are available')
search_parser.add_argument('--no-cache', action='store_true', help='Bypass cache and perform fresh search')
# Suggest command
suggest_parser = subparsers.add_parser(
'suggest',
help='Get app suggestions based on purpose',
formatter_class=argparse.RawDescriptionHelpFormatter
)
suggest_parser.add_argument('purpose', type=str, nargs='*', help='Purpose or use case (e.g., "video editing", "office")')
suggest_parser.add_argument('--list', action='store_true', help='List all available purposes')
# Upgrade command
upgrade_parser = subparsers.add_parser(
'upgrade',
help='Upgrade archpkg tool itself from GitHub',
formatter_class=argparse.RawDescriptionHelpFormatter
)
# Install command
install_parser = subparsers.add_parser(
'install',
help='Install one or more packages',
formatter_class=argparse.RawDescriptionHelpFormatter
)
install_parser.add_argument('packages', type=str, nargs='+', help='Package name(s) to install')
install_parser.add_argument('--source', '-s', type=str, help='Package source (auto-detected if not specified)')
install_parser.add_argument('--track', action='store_true', default=True, help='Track installation for updates (default: enabled)')
install_parser.add_argument('--no-track', action='store_true', help='Do not track installation for updates')
# Update command
update_parser = subparsers.add_parser(
'update',
help='Check for and install package updates',
formatter_class=argparse.RawDescriptionHelpFormatter
)
update_parser.add_argument('packages', type=str, nargs='*', help='Specific packages to update (all if not specified)')
update_parser.add_argument('--check-only', '-c', action='store_true', help='Only check for updates, do not install')
# Config command
config_parser = subparsers.add_parser(
'config',
help='Manage archpkg configuration settings',
formatter_class=argparse.RawDescriptionHelpFormatter
)
config_parser.add_argument('key', type=str, nargs='?', help='Configuration key to get/set')
config_parser.add_argument('value', type=str, nargs='?', help='Value to set (if setting)')
config_parser.add_argument('--list', '-l', action='store_true', help='List all configuration settings')
# List-installed command
list_installed_parser = subparsers.add_parser(
'list-installed',
help='List all installed packages being tracked for updates',
formatter_class=argparse.RawDescriptionHelpFormatter
)
# Service command
service_parser = subparsers.add_parser(
'service',
help='Manage the background update service',
formatter_class=argparse.RawDescriptionHelpFormatter
)
service_parser.add_argument('action', type=str, choices=['start', 'stop', 'status'], help='Action to perform')
# Web command
web_parser = subparsers.add_parser(
'web',
help='Start the web interface',
formatter_class=argparse.RawDescriptionHelpFormatter
)
web_parser.add_argument('--port', '-p', type=int, default=5000, help='Port to run the web server on (default: 5000)')
web_parser.add_argument('--host', type=str, default='127.0.0.1', help='Host to bind to (default: 127.0.0.1)')
# GitHub install command (as a special format of install)
# This will be handled in the install command by checking for github: prefix
# Handle help manually to show custom format
if '--help' in sys.argv or '-h' in sys.argv:
parser.print_help()
sys.exit(0)
args = parser.parse_args()
# Initialize cache manager
cache_config = CacheConfig(enabled=not args.no_cache)
cache_manager = get_cache_manager(cache_config)