pluck-cli 0.2.1__tar.gz → 0.3.2__tar.gz
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.
- {pluck_cli-0.2.1/src/pluck_cli.egg-info → pluck_cli-0.3.2}/PKG-INFO +8 -2
- {pluck_cli-0.2.1 → pluck_cli-0.3.2}/README.md +7 -1
- {pluck_cli-0.2.1 → pluck_cli-0.3.2}/pyproject.toml +1 -1
- {pluck_cli-0.2.1 → pluck_cli-0.3.2}/src/gh_install.py +575 -93
- {pluck_cli-0.2.1 → pluck_cli-0.3.2/src/pluck_cli.egg-info}/PKG-INFO +8 -2
- {pluck_cli-0.2.1 → pluck_cli-0.3.2}/tests/test_gh_install.py +12 -12
- {pluck_cli-0.2.1 → pluck_cli-0.3.2}/LICENSE +0 -0
- {pluck_cli-0.2.1 → pluck_cli-0.3.2}/setup.cfg +0 -0
- {pluck_cli-0.2.1 → pluck_cli-0.3.2}/src/pluck_cli.egg-info/SOURCES.txt +0 -0
- {pluck_cli-0.2.1 → pluck_cli-0.3.2}/src/pluck_cli.egg-info/dependency_links.txt +0 -0
- {pluck_cli-0.2.1 → pluck_cli-0.3.2}/src/pluck_cli.egg-info/entry_points.txt +0 -0
- {pluck_cli-0.2.1 → pluck_cli-0.3.2}/src/pluck_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pluck-cli
|
|
3
|
-
Version: 0.2
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Pluck any git repo from any forge — auto-detect, auto-install, done!
|
|
5
5
|
Author: pluck contributors
|
|
6
6
|
License: MIT
|
|
@@ -175,7 +175,11 @@ Any git hosting platform that follows the standard `host/owner/repo` URL pattern
|
|
|
175
175
|
| `stats` | Show statistics | `pluck stats` |
|
|
176
176
|
| `doctor` | Check tool availability | `pluck doctor` |
|
|
177
177
|
| `config [key] [val]` | View/set config | `pluck config install_dir ~/Apps` |
|
|
178
|
-
| `search <query> [--forge <name>]` | Search repos
|
|
178
|
+
| `search <query> [--forge <name>] [--all] [--output <file>]` | Search repos across forges | `pluck search python installer --all --output results.txt` |
|
|
179
|
+
| `pin <name>` | Pin an app to prevent updates | `pluck pin myapp` |
|
|
180
|
+
| `unpin <name>` | Unpin an app | `pluck unpin myapp` |
|
|
181
|
+
| `self-update` | Update pluck itself | `pluck self-update` |
|
|
182
|
+
| `cache prune` | Clear download cache | `pluck cache prune` |
|
|
179
183
|
| `export <file>` | Export registry | `pluck export ~/backup.json` |
|
|
180
184
|
| `import <file>` | Import registry | `pluck import ~/backup.json` |
|
|
181
185
|
| `completion <shell>` | Generate shell completion | `pluck completion bash` |
|
|
@@ -197,6 +201,8 @@ Any git hosting platform that follows the standard `host/owner/repo` URL pattern
|
|
|
197
201
|
| `--no-color` | Disable colored output |
|
|
198
202
|
| `--timeout <secs>` | Timeout for git clone in seconds |
|
|
199
203
|
| `--retries <n>` | Number of retries for failed git clone |
|
|
204
|
+
| `--jobs <n>` | Number of parallel installs (default: 1) |
|
|
205
|
+
| `--release` | Install from pre-built release assets instead of cloning |
|
|
200
206
|
| `--verbose` | Show detailed git clone output |
|
|
201
207
|
|
|
202
208
|
## 📥 Installation
|
|
@@ -150,7 +150,11 @@ Any git hosting platform that follows the standard `host/owner/repo` URL pattern
|
|
|
150
150
|
| `stats` | Show statistics | `pluck stats` |
|
|
151
151
|
| `doctor` | Check tool availability | `pluck doctor` |
|
|
152
152
|
| `config [key] [val]` | View/set config | `pluck config install_dir ~/Apps` |
|
|
153
|
-
| `search <query> [--forge <name>]` | Search repos
|
|
153
|
+
| `search <query> [--forge <name>] [--all] [--output <file>]` | Search repos across forges | `pluck search python installer --all --output results.txt` |
|
|
154
|
+
| `pin <name>` | Pin an app to prevent updates | `pluck pin myapp` |
|
|
155
|
+
| `unpin <name>` | Unpin an app | `pluck unpin myapp` |
|
|
156
|
+
| `self-update` | Update pluck itself | `pluck self-update` |
|
|
157
|
+
| `cache prune` | Clear download cache | `pluck cache prune` |
|
|
154
158
|
| `export <file>` | Export registry | `pluck export ~/backup.json` |
|
|
155
159
|
| `import <file>` | Import registry | `pluck import ~/backup.json` |
|
|
156
160
|
| `completion <shell>` | Generate shell completion | `pluck completion bash` |
|
|
@@ -172,6 +176,8 @@ Any git hosting platform that follows the standard `host/owner/repo` URL pattern
|
|
|
172
176
|
| `--no-color` | Disable colored output |
|
|
173
177
|
| `--timeout <secs>` | Timeout for git clone in seconds |
|
|
174
178
|
| `--retries <n>` | Number of retries for failed git clone |
|
|
179
|
+
| `--jobs <n>` | Number of parallel installs (default: 1) |
|
|
180
|
+
| `--release` | Install from pre-built release assets instead of cloning |
|
|
175
181
|
| `--verbose` | Show detailed git clone output |
|
|
176
182
|
|
|
177
183
|
## 📥 Installation
|
|
@@ -5,6 +5,7 @@ GitHub App Installer - Paste URL, Auto-Install, Done!
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import concurrent.futures
|
|
8
9
|
import json
|
|
9
10
|
import os
|
|
10
11
|
import re
|
|
@@ -18,7 +19,7 @@ import urllib.request
|
|
|
18
19
|
from datetime import datetime
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
|
|
21
|
-
__version__ = "0.2
|
|
22
|
+
__version__ = "0.3.2"
|
|
22
23
|
|
|
23
24
|
# Configuration
|
|
24
25
|
DEFAULT_INSTALL_DIR_MACOS = Path.home() / "Applications"
|
|
@@ -33,13 +34,14 @@ CONFIG_FILE = (
|
|
|
33
34
|
Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "pluck" / "config.json"
|
|
34
35
|
)
|
|
35
36
|
_CONFIG_OLD_DIR = Path.home() / ".config" / "gh-install"
|
|
37
|
+
CACHE_DIR = Path.home() / ".cache" / "pluck"
|
|
36
38
|
SHARED_PATHS = {
|
|
37
39
|
Path.home() / "go" / "bin",
|
|
38
40
|
Path.home() / "Applications",
|
|
39
41
|
Path.home() / ".local" / "opt",
|
|
40
42
|
Path.home() / "bin",
|
|
41
43
|
}
|
|
42
|
-
VALID_METHODS = {"script", "binary", "python", "node", "go", "rust", "make", "download"}
|
|
44
|
+
VALID_METHODS = {"script", "binary", "python", "node", "go", "rust", "make", "download", "release"}
|
|
43
45
|
GIST_PATTERN = r"gist\.github\.com[:/]([^/]+)/([a-f0-9]+)"
|
|
44
46
|
# GitLab personal snippet: gitlab.com/-/snippets/12345
|
|
45
47
|
# GitLab project snippet: gitlab.com/owner/repo/-/snippets/12345
|
|
@@ -88,10 +90,15 @@ def print_usage():
|
|
|
88
90
|
("stats", "Show installation statistics"),
|
|
89
91
|
("doctor", "Check tool availability"),
|
|
90
92
|
("config [key] [value]", "View/set config"),
|
|
91
|
-
("search <query> [--forge <name>]
|
|
93
|
+
("search <query> [--forge <name>] [--all] [--output <file>]",
|
|
94
|
+
"Search repos (github|gitlab|codeberg|bitbucket)"),
|
|
92
95
|
("export <file>", "Export registry"),
|
|
93
96
|
("import <file>", "Import registry"),
|
|
94
97
|
("completion <shell>", "Generate shell completion"),
|
|
98
|
+
("pin <name>", "Pin an app to prevent updates"),
|
|
99
|
+
("unpin <name>", "Unpin an app"),
|
|
100
|
+
("self-update", "Update pluck itself"),
|
|
101
|
+
("cache <prune|path>", "Manage download cache"),
|
|
95
102
|
("version", "Show version"),
|
|
96
103
|
("help", "Show this help"),
|
|
97
104
|
]
|
|
@@ -107,6 +114,7 @@ def print_usage():
|
|
|
107
114
|
("--no-color", "Disable colored output"),
|
|
108
115
|
("--timeout <secs>", "Timeout for git clone in seconds"),
|
|
109
116
|
("--retries <n>", "Number of retries for failed git clone"),
|
|
117
|
+
("--jobs <n>", "Number of parallel installs (default: 1)"),
|
|
110
118
|
("--verbose", "Show detailed git clone output"),
|
|
111
119
|
]
|
|
112
120
|
|
|
@@ -462,12 +470,15 @@ def install_python(repo_path, install_dir):
|
|
|
462
470
|
|
|
463
471
|
try:
|
|
464
472
|
app_dir = install_dir / repo_path.name
|
|
473
|
+
ignore = shutil.ignore_patterns(".git", "__pycache__", ".venv", "venv")
|
|
474
|
+
shutil.copytree(repo_path, app_dir, dirs_exist_ok=True, ignore=ignore)
|
|
475
|
+
|
|
465
476
|
venv_path = app_dir / ".venv"
|
|
466
477
|
venv_path.parent.mkdir(parents=True, exist_ok=True)
|
|
467
478
|
subprocess.run([sys.executable, "-m", "venv", str(venv_path)], check=True)
|
|
468
479
|
|
|
469
480
|
pip_path = venv_path / "bin" / "pip"
|
|
470
|
-
subprocess.run([str(pip_path), "install", "-e", str(
|
|
481
|
+
subprocess.run([str(pip_path), "install", "-e", str(app_dir)], check=True)
|
|
471
482
|
|
|
472
483
|
print_success(f"Installed to {app_dir}")
|
|
473
484
|
|
|
@@ -647,6 +658,14 @@ def download_and_install(
|
|
|
647
658
|
else:
|
|
648
659
|
install_dir = DEFAULT_INSTALL_DIR
|
|
649
660
|
|
|
661
|
+
# Check if this is a local path instead of a URL
|
|
662
|
+
local_candidate = Path(repo_url).expanduser()
|
|
663
|
+
if local_candidate.exists():
|
|
664
|
+
return _install_local_path(
|
|
665
|
+
repo_url, install_dir,
|
|
666
|
+
dry_run=dry_run, method_override=method_override,
|
|
667
|
+
)
|
|
668
|
+
|
|
650
669
|
# Parse repository URL
|
|
651
670
|
repo_info = parse_repo_url(repo_url)
|
|
652
671
|
if not repo_info:
|
|
@@ -663,6 +682,25 @@ def download_and_install(
|
|
|
663
682
|
print_error(f"Invalid repository name: {repo_info['repo']}")
|
|
664
683
|
return None
|
|
665
684
|
|
|
685
|
+
# If release method requested, skip cloning and try release assets
|
|
686
|
+
if method_override == "release":
|
|
687
|
+
if dry_run:
|
|
688
|
+
print(f" [DRY RUN] Would install release assets for: {repo_info['owner']}/{repo_info['repo']}")
|
|
689
|
+
return install_dir / safe_name
|
|
690
|
+
install_dir.mkdir(parents=True, exist_ok=True)
|
|
691
|
+
print(" Attempting release asset install...")
|
|
692
|
+
installed_path = install_release_asset(repo_info, install_dir)
|
|
693
|
+
if installed_path:
|
|
694
|
+
register_app(repo_info["repo"], repo_url, installed_path, "release")
|
|
695
|
+
print()
|
|
696
|
+
print(f" {Colors.CYAN}Summary:{Colors.END}")
|
|
697
|
+
print(f" Name: {repo_info['repo']}")
|
|
698
|
+
print(" Method: release")
|
|
699
|
+
print(f" Location: {installed_path}")
|
|
700
|
+
print(f" Size: {_get_disk_size(installed_path)}")
|
|
701
|
+
return installed_path
|
|
702
|
+
print_warning("Release asset install failed, falling back to clone...")
|
|
703
|
+
|
|
666
704
|
# Dry-run check before doing any I/O
|
|
667
705
|
if dry_run:
|
|
668
706
|
print(f" [DRY RUN] Would install to: {install_dir / safe_name}")
|
|
@@ -732,10 +770,11 @@ def download_and_install(
|
|
|
732
770
|
}
|
|
733
771
|
|
|
734
772
|
install_func = install_funcs.get(install_method, install_binary)
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
773
|
+
try:
|
|
774
|
+
installed_path = install_func(repo_path, install_dir)
|
|
775
|
+
finally:
|
|
776
|
+
# Clean up temp directory
|
|
777
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
739
778
|
|
|
740
779
|
# Register the installation
|
|
741
780
|
if installed_path:
|
|
@@ -773,6 +812,12 @@ def update_app(
|
|
|
773
812
|
url = app_info["url"]
|
|
774
813
|
old_path = Path(app_info["path"])
|
|
775
814
|
|
|
815
|
+
# Check if app is pinned
|
|
816
|
+
if app_info.get("pinned", False):
|
|
817
|
+
print_warning(f"{repo_name} is pinned — skipping update")
|
|
818
|
+
print(" Use 'pluck unpin' first, or --force to override")
|
|
819
|
+
return True
|
|
820
|
+
|
|
776
821
|
print_header(f"Updating {repo_name}")
|
|
777
822
|
print(f" Current: {app_info['installed_at']}")
|
|
778
823
|
print(f" URL: {url}")
|
|
@@ -783,7 +828,12 @@ def update_app(
|
|
|
783
828
|
return True
|
|
784
829
|
|
|
785
830
|
# Remove old installation
|
|
786
|
-
|
|
831
|
+
resolved_shared_paths = {p.resolve() for p in SHARED_PATHS}
|
|
832
|
+
if (
|
|
833
|
+
old_path.exists()
|
|
834
|
+
and old_path.resolve() not in resolved_shared_paths
|
|
835
|
+
and old_path.resolve() != Path.home().resolve()
|
|
836
|
+
):
|
|
787
837
|
if old_path.is_file():
|
|
788
838
|
old_path.unlink()
|
|
789
839
|
else:
|
|
@@ -829,11 +879,13 @@ def info_app(repo_name, json_output=False):
|
|
|
829
879
|
app_info = registry["apps"][repo_name]
|
|
830
880
|
install_path = Path(app_info["path"])
|
|
831
881
|
|
|
882
|
+
pinned = app_info.get("pinned", False)
|
|
832
883
|
if json_output:
|
|
833
884
|
data = {
|
|
834
885
|
"name": repo_name,
|
|
835
886
|
"url": app_info["url"],
|
|
836
887
|
"method": app_info["method"],
|
|
888
|
+
"pinned": pinned,
|
|
837
889
|
"path": app_info["path"],
|
|
838
890
|
"installed_at": app_info["installed_at"],
|
|
839
891
|
"size": _get_disk_size(install_path),
|
|
@@ -843,11 +895,12 @@ def info_app(repo_name, json_output=False):
|
|
|
843
895
|
return True
|
|
844
896
|
|
|
845
897
|
print_header(f"App Info: {repo_name}")
|
|
846
|
-
labels = ["URL", "Method", "Path", "Installed", "Size", "Exists"]
|
|
898
|
+
labels = ["URL", "Method", "Path", "Pinned", "Installed", "Size", "Exists"]
|
|
847
899
|
values = [
|
|
848
900
|
app_info["url"],
|
|
849
901
|
app_info["method"],
|
|
850
902
|
app_info["path"],
|
|
903
|
+
"Yes" if pinned else "No",
|
|
851
904
|
app_info["installed_at"],
|
|
852
905
|
_get_disk_size(install_path),
|
|
853
906
|
"Yes" if install_path.exists() else "No (files may have been moved)",
|
|
@@ -879,7 +932,6 @@ def doctor(json_output=False):
|
|
|
879
932
|
if not found and exe == "python3":
|
|
880
933
|
found = bool(shutil.which("python"))
|
|
881
934
|
results.append({"tool": tool, "required": req, "found": found, "purpose": purpose})
|
|
882
|
-
results.append({"tool": tool, "required": req, "found": found, "purpose": purpose})
|
|
883
935
|
if not found and req == "Required":
|
|
884
936
|
all_ok = False
|
|
885
937
|
|
|
@@ -943,8 +995,10 @@ def _search_print_result(index, name, desc, stars, lang, url, star_char="★"):
|
|
|
943
995
|
print()
|
|
944
996
|
|
|
945
997
|
|
|
946
|
-
def search_github(query, limit=10):
|
|
947
|
-
"""Search repositories using the GitHub API.
|
|
998
|
+
def search_github(query, limit=10, results=None):
|
|
999
|
+
"""Search repositories using the GitHub API.
|
|
1000
|
+
If results (list) is provided, appends result dicts instead of printing.
|
|
1001
|
+
"""
|
|
948
1002
|
print(f" Searching GitHub for '{query}'...")
|
|
949
1003
|
url = f"https://api.github.com/search/repositories?q={urllib.parse.quote(query)}&sort=stars&order=desc&per_page={limit}"
|
|
950
1004
|
try:
|
|
@@ -960,19 +1014,29 @@ def search_github(query, limit=10):
|
|
|
960
1014
|
print_warning("No results found")
|
|
961
1015
|
return
|
|
962
1016
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1017
|
+
if results is not None:
|
|
1018
|
+
for i, repo in enumerate(items, 1):
|
|
1019
|
+
results.append({
|
|
1020
|
+
"index": i, "name": repo["full_name"],
|
|
1021
|
+
"description": repo.get("description") or "No description",
|
|
1022
|
+
"stars": repo["stargazers_count"],
|
|
1023
|
+
"language": repo.get("language") or "Unknown",
|
|
1024
|
+
"url": repo["html_url"],
|
|
1025
|
+
})
|
|
1026
|
+
else:
|
|
1027
|
+
print_header(f"GitHub Results — '{query}' ({len(items)} found)")
|
|
1028
|
+
for i, repo in enumerate(items, 1):
|
|
1029
|
+
_search_print_result(
|
|
1030
|
+
i,
|
|
1031
|
+
repo["full_name"],
|
|
1032
|
+
repo.get("description") or "No description",
|
|
1033
|
+
repo["stargazers_count"],
|
|
1034
|
+
repo.get("language") or "Unknown",
|
|
1035
|
+
repo["html_url"],
|
|
1036
|
+
)
|
|
973
1037
|
|
|
974
1038
|
|
|
975
|
-
def search_gitlab(query, limit=10):
|
|
1039
|
+
def search_gitlab(query, limit=10, collector=None):
|
|
976
1040
|
"""Search repositories using the GitLab API."""
|
|
977
1041
|
print(f" Searching GitLab for '{query}'...")
|
|
978
1042
|
url = f"https://gitlab.com/api/v4/projects?search={urllib.parse.quote(query)}&per_page={limit}&order_by=stars&sort=desc"
|
|
@@ -1003,12 +1067,23 @@ def search_gitlab(query, limit=10):
|
|
|
1003
1067
|
|
|
1004
1068
|
results.sort(key=lambda r: r["stars"], reverse=True)
|
|
1005
1069
|
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1070
|
+
if collector is not None:
|
|
1071
|
+
for i, r in enumerate(results[:limit], 1):
|
|
1072
|
+
collector.append({
|
|
1073
|
+
"index": i, "name": r["name"],
|
|
1074
|
+
"description": r["description"],
|
|
1075
|
+
"stars": r["stars"],
|
|
1076
|
+
"language": r["language"],
|
|
1077
|
+
"url": r["url"],
|
|
1078
|
+
})
|
|
1079
|
+
else:
|
|
1080
|
+
print_header(f"GitLab Results — '{query}' ({len(results)} found)")
|
|
1081
|
+
for i, r in enumerate(results[:limit], 1):
|
|
1082
|
+
_search_print_result(i, r["name"], r["description"],
|
|
1083
|
+
r["stars"], r["language"], r["url"], star_char="\u2605")
|
|
1009
1084
|
|
|
1010
1085
|
|
|
1011
|
-
def search_codeberg(query, limit=10):
|
|
1086
|
+
def search_codeberg(query, limit=10, results=None):
|
|
1012
1087
|
"""Search repositories using the Codeberg (Gitea/Forgejo) API."""
|
|
1013
1088
|
print(f" Searching Codeberg for '{query}'...")
|
|
1014
1089
|
url = f"https://codeberg.org/api/v1/repos/search?q={urllib.parse.quote(query)}&limit={limit}&sort=stars"
|
|
@@ -1030,19 +1105,29 @@ def search_codeberg(query, limit=10):
|
|
|
1030
1105
|
print_warning("No results found")
|
|
1031
1106
|
return
|
|
1032
1107
|
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1108
|
+
if results is not None:
|
|
1109
|
+
for i, repo in enumerate(items, 1):
|
|
1110
|
+
results.append({
|
|
1111
|
+
"index": i, "name": repo.get("full_name", "unknown"),
|
|
1112
|
+
"description": repo.get("description") or "No description",
|
|
1113
|
+
"stars": repo.get("stars_count", 0),
|
|
1114
|
+
"language": repo.get("language") or "Unknown",
|
|
1115
|
+
"url": repo.get("html_url", ""),
|
|
1116
|
+
})
|
|
1117
|
+
else:
|
|
1118
|
+
print_header(f"Codeberg Results — '{query}' ({len(items)} found)")
|
|
1119
|
+
for i, repo in enumerate(items, 1):
|
|
1120
|
+
_search_print_result(
|
|
1121
|
+
i,
|
|
1122
|
+
repo.get("full_name", "unknown"),
|
|
1123
|
+
repo.get("description") or "No description",
|
|
1124
|
+
repo.get("stars_count", 0),
|
|
1125
|
+
repo.get("language") or "Unknown",
|
|
1126
|
+
repo.get("html_url", ""),
|
|
1127
|
+
)
|
|
1043
1128
|
|
|
1044
1129
|
|
|
1045
|
-
def search_bitbucket(query, limit=10):
|
|
1130
|
+
def search_bitbucket(query, limit=10, results=None):
|
|
1046
1131
|
"""Search repositories using the Bitbucket Cloud API."""
|
|
1047
1132
|
print(f" Searching Bitbucket for '{query}'...")
|
|
1048
1133
|
url = f"https://api.bitbucket.org/2.0/repositories?q=name~\"{urllib.parse.quote(query)}\"&sort=-updated_on"
|
|
@@ -1059,13 +1144,23 @@ def search_bitbucket(query, limit=10):
|
|
|
1059
1144
|
print_warning("No results found")
|
|
1060
1145
|
return
|
|
1061
1146
|
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1147
|
+
if results is not None:
|
|
1148
|
+
for i, repo in enumerate(items, 1):
|
|
1149
|
+
results.append({
|
|
1150
|
+
"index": i, "name": repo.get("full_name", "unknown"),
|
|
1151
|
+
"description": repo.get("description") or "No description",
|
|
1152
|
+
"stars": 0,
|
|
1153
|
+
"language": repo.get("language") or "Unknown",
|
|
1154
|
+
"url": repo.get("links", {}).get("html", {}).get("href", ""),
|
|
1155
|
+
})
|
|
1156
|
+
else:
|
|
1157
|
+
print_header(f"Bitbucket Results — '{query}' ({len(items)} found)")
|
|
1158
|
+
for i, repo in enumerate(items, 1):
|
|
1159
|
+
full_name = repo.get("full_name", "unknown")
|
|
1160
|
+
desc = repo.get("description") or "No description"
|
|
1161
|
+
lang = repo.get("language") or "Unknown"
|
|
1162
|
+
url = repo.get("links", {}).get("html", {}).get("href", "")
|
|
1163
|
+
_search_print_result(i, full_name, desc, 0, lang, url, star_char="\u2022")
|
|
1069
1164
|
|
|
1070
1165
|
|
|
1071
1166
|
def export_registry(filepath):
|
|
@@ -1114,6 +1209,7 @@ def register_app(repo_name, repo_url, install_path, install_method, skip_hook=Fa
|
|
|
1114
1209
|
"url": repo_url,
|
|
1115
1210
|
"path": str(install_path),
|
|
1116
1211
|
"method": install_method,
|
|
1212
|
+
"pinned": False,
|
|
1117
1213
|
"installed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
1118
1214
|
}
|
|
1119
1215
|
|
|
@@ -1243,7 +1339,9 @@ def list_installed(json_output=False):
|
|
|
1243
1339
|
install_path = Path(info["path"])
|
|
1244
1340
|
size = _get_disk_size(install_path)
|
|
1245
1341
|
exists = "✓" if install_path.exists() else "✗"
|
|
1246
|
-
|
|
1342
|
+
pinned = info.get("pinned", False)
|
|
1343
|
+
pin_tag = f" {Colors.YELLOW}[PINNED]{Colors.END}" if pinned else ""
|
|
1344
|
+
print(f"\n{Colors.GREEN}{name}{Colors.END}{pin_tag} [{exists}]")
|
|
1247
1345
|
print(f" URL: {info['url']}")
|
|
1248
1346
|
print(f" Method: {info['method']}")
|
|
1249
1347
|
print(f" Path: {info['path']}")
|
|
@@ -1270,7 +1368,8 @@ def uninstall_app(repo_name, force=False):
|
|
|
1270
1368
|
|
|
1271
1369
|
# Remove installed files — but never delete shared system directories
|
|
1272
1370
|
install_path = Path(app_info["path"])
|
|
1273
|
-
|
|
1371
|
+
resolved_shared_paths = {p.resolve() for p in SHARED_PATHS}
|
|
1372
|
+
if install_path.resolve() in resolved_shared_paths or install_path.resolve() == Path.home().resolve():
|
|
1274
1373
|
print_error(f"Refusing to uninstall: {install_path} is a shared directory")
|
|
1275
1374
|
print_warning("Remove files from this directory manually instead")
|
|
1276
1375
|
return False
|
|
@@ -1303,6 +1402,7 @@ def _parse_args(args):
|
|
|
1303
1402
|
verbose = False
|
|
1304
1403
|
timeout = None
|
|
1305
1404
|
retries = 0
|
|
1405
|
+
jobs = 1
|
|
1306
1406
|
urls = []
|
|
1307
1407
|
|
|
1308
1408
|
i = 0
|
|
@@ -1349,6 +1449,12 @@ def _parse_args(args):
|
|
|
1349
1449
|
except ValueError:
|
|
1350
1450
|
pass
|
|
1351
1451
|
i += 2
|
|
1452
|
+
elif args[i] == "--jobs" and i + 1 < len(args):
|
|
1453
|
+
try:
|
|
1454
|
+
jobs = max(1, int(args[i + 1]))
|
|
1455
|
+
except ValueError:
|
|
1456
|
+
pass
|
|
1457
|
+
i += 2
|
|
1352
1458
|
else:
|
|
1353
1459
|
urls.append(args[i])
|
|
1354
1460
|
i += 1
|
|
@@ -1358,7 +1464,8 @@ def _parse_args(args):
|
|
|
1358
1464
|
if no_color:
|
|
1359
1465
|
_enable_colors(False)
|
|
1360
1466
|
|
|
1361
|
-
return install_dir, dry_run, force, shallow, ref, method, json_output,
|
|
1467
|
+
return (install_dir, dry_run, force, shallow, ref, method, json_output,
|
|
1468
|
+
verbose, no_color, timeout, retries, jobs, urls)
|
|
1362
1469
|
|
|
1363
1470
|
|
|
1364
1471
|
def verify_apps(json_output=False):
|
|
@@ -1511,6 +1618,328 @@ def _migrate_old_registry():
|
|
|
1511
1618
|
pass
|
|
1512
1619
|
|
|
1513
1620
|
|
|
1621
|
+
# ── Pin / Unpin ──
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
def pin_app(repo_name):
|
|
1625
|
+
"""Pin an app to prevent updates."""
|
|
1626
|
+
registry = load_registry()
|
|
1627
|
+
if repo_name not in registry["apps"]:
|
|
1628
|
+
print_error(f"{repo_name} is not installed")
|
|
1629
|
+
return False
|
|
1630
|
+
registry["apps"][repo_name]["pinned"] = True
|
|
1631
|
+
save_registry(registry)
|
|
1632
|
+
print_success(f"Pinned {repo_name}")
|
|
1633
|
+
return True
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
def unpin_app(repo_name):
|
|
1637
|
+
"""Unpin an app."""
|
|
1638
|
+
registry = load_registry()
|
|
1639
|
+
if repo_name not in registry["apps"]:
|
|
1640
|
+
print_error(f"{repo_name} is not installed")
|
|
1641
|
+
return False
|
|
1642
|
+
registry["apps"][repo_name]["pinned"] = False
|
|
1643
|
+
save_registry(registry)
|
|
1644
|
+
print_success(f"Unpinned {repo_name}")
|
|
1645
|
+
return True
|
|
1646
|
+
|
|
1647
|
+
|
|
1648
|
+
# ── Cache Management ──
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
def cache_command(action):
|
|
1652
|
+
"""Manage the download cache."""
|
|
1653
|
+
if action == "prune":
|
|
1654
|
+
if not CACHE_DIR.exists():
|
|
1655
|
+
print_success("Cache is already empty")
|
|
1656
|
+
return True
|
|
1657
|
+
total = 0
|
|
1658
|
+
for entry in CACHE_DIR.iterdir():
|
|
1659
|
+
if entry.is_file():
|
|
1660
|
+
total += entry.stat().st_size
|
|
1661
|
+
entry.unlink()
|
|
1662
|
+
elif entry.is_dir():
|
|
1663
|
+
total += sum(f.stat().st_size for f in entry.rglob("*") if f.is_file())
|
|
1664
|
+
shutil.rmtree(entry, ignore_errors=True)
|
|
1665
|
+
CACHE_DIR.rmdir() if CACHE_DIR.exists() else None
|
|
1666
|
+
print_success(f"Cleared cache ({_format_bytes(total)})")
|
|
1667
|
+
return True
|
|
1668
|
+
elif action == "path":
|
|
1669
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
1670
|
+
print(CACHE_DIR)
|
|
1671
|
+
return True
|
|
1672
|
+
else:
|
|
1673
|
+
print_error(f"Unknown cache action: {action} (try: prune, path)")
|
|
1674
|
+
return False
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
# ── Install from Local Path ──
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def _install_local_path(repo_url, install_dir, dry_run=False, method_override=None):
|
|
1681
|
+
"""Install a project from a local directory path."""
|
|
1682
|
+
local_path = Path(repo_url).expanduser().resolve()
|
|
1683
|
+
safe_name = _sanitize_repo_name(local_path.name)
|
|
1684
|
+
if not safe_name:
|
|
1685
|
+
print_error(f"Invalid local path name: {local_path.name}")
|
|
1686
|
+
return None
|
|
1687
|
+
|
|
1688
|
+
if dry_run:
|
|
1689
|
+
print(f" [DRY RUN] Would install from local path: {local_path}")
|
|
1690
|
+
print(f" [DRY RUN] Would use method: {method_override or '(auto-detected)'}")
|
|
1691
|
+
return install_dir / safe_name
|
|
1692
|
+
|
|
1693
|
+
install_dir.mkdir(parents=True, exist_ok=True)
|
|
1694
|
+
print(f" Installing from local path: {local_path}")
|
|
1695
|
+
|
|
1696
|
+
install_method = method_override or detect_install_method(local_path)
|
|
1697
|
+
print(f" Detected install method: {install_method}")
|
|
1698
|
+
|
|
1699
|
+
install_funcs = {
|
|
1700
|
+
"python": install_python, "node": install_node, "go": install_go,
|
|
1701
|
+
"rust": install_rust, "binary": install_binary, "make": install_make,
|
|
1702
|
+
"script": install_script, "download": install_binary,
|
|
1703
|
+
}
|
|
1704
|
+
install_func = install_funcs.get(install_method, install_binary)
|
|
1705
|
+
installed_path = install_func(local_path, install_dir)
|
|
1706
|
+
|
|
1707
|
+
if installed_path:
|
|
1708
|
+
register_app(safe_name, str(local_path), installed_path, install_method)
|
|
1709
|
+
print()
|
|
1710
|
+
print(f" {Colors.CYAN}Summary:{Colors.END}")
|
|
1711
|
+
print(f" Name: {safe_name}")
|
|
1712
|
+
print(f" Method: {install_method}")
|
|
1713
|
+
print(f" Location: {installed_path}")
|
|
1714
|
+
print(f" Size: {_get_disk_size(installed_path)}")
|
|
1715
|
+
return installed_path
|
|
1716
|
+
return None
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
# ── Self-Update ──
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
def self_update():
|
|
1723
|
+
"""Update pluck itself via PyPI."""
|
|
1724
|
+
print_header("Updating pluck")
|
|
1725
|
+
try:
|
|
1726
|
+
subprocess.run(
|
|
1727
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", "pluck-cli"],
|
|
1728
|
+
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
1729
|
+
)
|
|
1730
|
+
print_success("pluck updated to latest version")
|
|
1731
|
+
return True
|
|
1732
|
+
except subprocess.CalledProcessError as e:
|
|
1733
|
+
print_error(f"Update failed: {e}")
|
|
1734
|
+
return False
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
# ── Search All Forges + Export ──
|
|
1738
|
+
|
|
1739
|
+
|
|
1740
|
+
def _search_with_results(query, limit, searcher_func):
|
|
1741
|
+
"""Run a searcher and return results as a list of dicts."""
|
|
1742
|
+
results = []
|
|
1743
|
+
searcher_func(query, limit=limit, results=results)
|
|
1744
|
+
return results
|
|
1745
|
+
|
|
1746
|
+
|
|
1747
|
+
def search_all_forges(query, limit=5, output_file=None):
|
|
1748
|
+
"""Search all supported forges and optionally export results."""
|
|
1749
|
+
all_results = []
|
|
1750
|
+
forges = [
|
|
1751
|
+
("GitHub", search_github),
|
|
1752
|
+
("GitLab", search_gitlab),
|
|
1753
|
+
("Codeberg", search_codeberg),
|
|
1754
|
+
("Bitbucket", search_bitbucket),
|
|
1755
|
+
]
|
|
1756
|
+
|
|
1757
|
+
for name, searcher in forges:
|
|
1758
|
+
print(f" Searching {name} for '{query}'...")
|
|
1759
|
+
results = _search_with_results(query, limit, searcher)
|
|
1760
|
+
all_results.append((name, results))
|
|
1761
|
+
|
|
1762
|
+
# Print aggregated results
|
|
1763
|
+
print_header(f"Aggregated Search Results — '{query}'")
|
|
1764
|
+
for forge_name, results in all_results:
|
|
1765
|
+
if results:
|
|
1766
|
+
print(f"\n{Colors.CYAN}── {forge_name} ({len(results)} results) ──{Colors.END}")
|
|
1767
|
+
for r in results:
|
|
1768
|
+
print(f" {r['index']}. {Colors.GREEN}{r['name']}{Colors.END}")
|
|
1769
|
+
print(f" {r['description']}")
|
|
1770
|
+
print(f" \u2605 {r['stars']:,} | Language: {r['language']}")
|
|
1771
|
+
print(f" URL: {r['url']}")
|
|
1772
|
+
print()
|
|
1773
|
+
|
|
1774
|
+
# Export to file if requested
|
|
1775
|
+
if output_file:
|
|
1776
|
+
out_path = Path(output_file).expanduser()
|
|
1777
|
+
lines = [f"Search Results: '{query}' ({datetime.now():%Y-%m-%d %H:%M})", "=" * 60, ""]
|
|
1778
|
+
for forge_name, results in all_results:
|
|
1779
|
+
if results:
|
|
1780
|
+
lines.append(f"── {forge_name} ──")
|
|
1781
|
+
for r in results:
|
|
1782
|
+
lines.append(f"{r['name']} | \u2605 {r['stars']:,} | {r['language']}")
|
|
1783
|
+
lines.append(f" {r['url']}")
|
|
1784
|
+
lines.append(f" {r['description']}")
|
|
1785
|
+
lines.append("")
|
|
1786
|
+
else:
|
|
1787
|
+
lines.append(f"── {forge_name} ── (no results)")
|
|
1788
|
+
lines.append("")
|
|
1789
|
+
|
|
1790
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1791
|
+
out_path.write_text("\n".join(lines) + "\n")
|
|
1792
|
+
print_success(f"Exported results to {out_path}")
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
# ── Release Asset Install ──
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
def _github_release_url(repo_info, install_dir):
|
|
1799
|
+
"""Try to download a pre-built release asset from GitHub."""
|
|
1800
|
+
owner = repo_info["owner"]
|
|
1801
|
+
repo = repo_info["repo"]
|
|
1802
|
+
api_url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
|
|
1803
|
+
try:
|
|
1804
|
+
headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": "pluck"}
|
|
1805
|
+
req = urllib.request.Request(api_url, headers=headers)
|
|
1806
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
1807
|
+
data = json.loads(resp.read().decode())
|
|
1808
|
+
except Exception as e:
|
|
1809
|
+
print_warning(f"Could not fetch GitHub release: {e}")
|
|
1810
|
+
return None
|
|
1811
|
+
|
|
1812
|
+
assets = data.get("assets", [])
|
|
1813
|
+
if not assets:
|
|
1814
|
+
print_warning("No release assets found")
|
|
1815
|
+
return None
|
|
1816
|
+
|
|
1817
|
+
# Pick a matching asset: prefer the one matching the current platform
|
|
1818
|
+
arch_hints = []
|
|
1819
|
+
if sys.platform == "linux":
|
|
1820
|
+
arch_hints = ["linux", "Linux", "x86_64", "amd64"]
|
|
1821
|
+
elif sys.platform == "darwin":
|
|
1822
|
+
arch_hints = ["macos", "darwin", "Darwin", "macOS", "x86_64", "amd64", "arm64"]
|
|
1823
|
+
|
|
1824
|
+
best = None
|
|
1825
|
+
for asset in assets:
|
|
1826
|
+
name = asset["name"]
|
|
1827
|
+
if all(hint in name for hint in arch_hints):
|
|
1828
|
+
best = asset
|
|
1829
|
+
break
|
|
1830
|
+
|
|
1831
|
+
if not best and assets:
|
|
1832
|
+
# Fallback to first asset
|
|
1833
|
+
best = assets[0]
|
|
1834
|
+
|
|
1835
|
+
if not best:
|
|
1836
|
+
print_warning("No suitable asset found")
|
|
1837
|
+
return None
|
|
1838
|
+
|
|
1839
|
+
# Download the asset
|
|
1840
|
+
print(f" Downloading release asset: {best['name']}")
|
|
1841
|
+
dl_url = best["browser_download_url"]
|
|
1842
|
+
dest = CACHE_DIR / best["name"]
|
|
1843
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
1844
|
+
|
|
1845
|
+
try:
|
|
1846
|
+
req = urllib.request.Request(dl_url, headers={"User-Agent": "pluck"})
|
|
1847
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
1848
|
+
dest.write_bytes(resp.read())
|
|
1849
|
+
except Exception as e:
|
|
1850
|
+
print_error(f"Download failed: {e}")
|
|
1851
|
+
return None
|
|
1852
|
+
|
|
1853
|
+
print_success(f"Downloaded to {dest}")
|
|
1854
|
+
|
|
1855
|
+
# If it's an archive, extract it
|
|
1856
|
+
if best["name"].endswith(".tar.gz") or best["name"].endswith(".tgz"):
|
|
1857
|
+
import tarfile
|
|
1858
|
+
extract_dir = install_dir / repo
|
|
1859
|
+
extract_dir.mkdir(parents=True, exist_ok=True)
|
|
1860
|
+
with tarfile.open(dest) as tar:
|
|
1861
|
+
tar.extractall(extract_dir)
|
|
1862
|
+
print_success(f"Extracted to {extract_dir}")
|
|
1863
|
+
return extract_dir
|
|
1864
|
+
elif best["name"].endswith(".zip"):
|
|
1865
|
+
import zipfile
|
|
1866
|
+
extract_dir = install_dir / repo
|
|
1867
|
+
extract_dir.mkdir(parents=True, exist_ok=True)
|
|
1868
|
+
with zipfile.ZipFile(dest) as zf:
|
|
1869
|
+
zf.extractall(extract_dir)
|
|
1870
|
+
print_success(f"Extracted to {extract_dir}")
|
|
1871
|
+
return extract_dir
|
|
1872
|
+
else:
|
|
1873
|
+
# Single binary
|
|
1874
|
+
dest.chmod(0o755)
|
|
1875
|
+
bin_dir = install_dir / repo
|
|
1876
|
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
1877
|
+
final = bin_dir / best["name"]
|
|
1878
|
+
shutil.move(str(dest), str(final))
|
|
1879
|
+
print_success(f"Installed binary to {final}")
|
|
1880
|
+
return bin_dir
|
|
1881
|
+
|
|
1882
|
+
|
|
1883
|
+
def _gitlab_release_url(repo_info, install_dir):
|
|
1884
|
+
"""Try to download a pre-built release asset from GitLab."""
|
|
1885
|
+
owner = repo_info["owner"]
|
|
1886
|
+
repo = repo_info["repo"]
|
|
1887
|
+
# GitLab generic packages API
|
|
1888
|
+
encoded = urllib.parse.quote(owner + "/" + repo, safe="")
|
|
1889
|
+
api_url = f"https://gitlab.com/api/v4/projects/{encoded}/releases/permalink/latest"
|
|
1890
|
+
try:
|
|
1891
|
+
req = urllib.request.Request(api_url, headers={"Accept": "application/json", "User-Agent": "pluck"})
|
|
1892
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
1893
|
+
data = json.loads(resp.read().decode())
|
|
1894
|
+
except Exception as e:
|
|
1895
|
+
print_warning(f"Could not fetch GitLab release: {e}")
|
|
1896
|
+
return None
|
|
1897
|
+
|
|
1898
|
+
links = data.get("assets", {}).get("links", [])
|
|
1899
|
+
if not links:
|
|
1900
|
+
print_warning("No release assets found")
|
|
1901
|
+
return None
|
|
1902
|
+
|
|
1903
|
+
# Pick first binary link
|
|
1904
|
+
for link in links:
|
|
1905
|
+
url = link.get("direct_asset_url") or link.get("url", "")
|
|
1906
|
+
if url:
|
|
1907
|
+
name = link.get("name", "asset")
|
|
1908
|
+
dest = CACHE_DIR / name
|
|
1909
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
1910
|
+
print(f" Downloading release asset: {name}")
|
|
1911
|
+
try:
|
|
1912
|
+
req = urllib.request.Request(url, headers={"User-Agent": "pluck"})
|
|
1913
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
1914
|
+
dest.write_bytes(resp.read())
|
|
1915
|
+
except Exception as e:
|
|
1916
|
+
print_warning(f"Download failed: {e}")
|
|
1917
|
+
continue
|
|
1918
|
+
|
|
1919
|
+
bin_dir = install_dir / repo
|
|
1920
|
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
1921
|
+
final = bin_dir / name
|
|
1922
|
+
dest.chmod(0o755)
|
|
1923
|
+
shutil.move(str(dest), str(final))
|
|
1924
|
+
print_success(f"Installed release asset to {final}")
|
|
1925
|
+
return bin_dir
|
|
1926
|
+
|
|
1927
|
+
print_warning("Could not download any release asset")
|
|
1928
|
+
return None
|
|
1929
|
+
|
|
1930
|
+
|
|
1931
|
+
def install_release_asset(repo_info, install_dir):
|
|
1932
|
+
"""Install from pre-built release assets instead of cloning."""
|
|
1933
|
+
host_type = repo_info.get("host_type", "")
|
|
1934
|
+
if host_type == "github":
|
|
1935
|
+
return _github_release_url(repo_info, install_dir)
|
|
1936
|
+
elif host_type == "gitlab":
|
|
1937
|
+
return _gitlab_release_url(repo_info, install_dir)
|
|
1938
|
+
else:
|
|
1939
|
+
print_warning(f"Release asset install not yet supported for {host_type}")
|
|
1940
|
+
return None
|
|
1941
|
+
|
|
1942
|
+
|
|
1514
1943
|
def main():
|
|
1515
1944
|
"""Main entry point"""
|
|
1516
1945
|
# Auto-migrate from old gh-install paths
|
|
@@ -1533,9 +1962,8 @@ def main():
|
|
|
1533
1962
|
command = sys.argv[1]
|
|
1534
1963
|
|
|
1535
1964
|
if command == "install":
|
|
1536
|
-
install_dir, dry_run, force, shallow, ref, method, json_output,
|
|
1537
|
-
|
|
1538
|
-
)
|
|
1965
|
+
(install_dir, dry_run, force, shallow, ref, method, json_output,
|
|
1966
|
+
verbose, no_color, timeout, retries, jobs, urls) = _parse_args(sys.argv[2:])
|
|
1539
1967
|
|
|
1540
1968
|
if not urls:
|
|
1541
1969
|
print_error("Please provide a repository URL")
|
|
@@ -1548,24 +1976,42 @@ def main():
|
|
|
1548
1976
|
if dry_run:
|
|
1549
1977
|
print_header("Dry Run — No changes will be made")
|
|
1550
1978
|
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1979
|
+
if jobs > 1 and len(urls) > 1:
|
|
1980
|
+
# Parallel install
|
|
1981
|
+
print_header(f"Installing {len(urls)} repos with {jobs} workers")
|
|
1982
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool:
|
|
1983
|
+
futures = {
|
|
1984
|
+
pool.submit(
|
|
1985
|
+
download_and_install, url,
|
|
1986
|
+
install_dir=install_dir, dry_run=dry_run, shallow=shallow,
|
|
1987
|
+
ref=ref, method_override=method, verbose=verbose,
|
|
1988
|
+
timeout=timeout, retries=retries,
|
|
1989
|
+
): url for url in urls
|
|
1990
|
+
}
|
|
1991
|
+
for future in concurrent.futures.as_completed(futures):
|
|
1992
|
+
url = futures[future]
|
|
1993
|
+
try:
|
|
1994
|
+
future.result()
|
|
1995
|
+
except Exception as e:
|
|
1996
|
+
print_error(f"Failed to install {url}: {e}")
|
|
1997
|
+
else:
|
|
1998
|
+
for url in urls:
|
|
1999
|
+
print(f"\nInstalling: {url}")
|
|
2000
|
+
download_and_install(
|
|
2001
|
+
url,
|
|
2002
|
+
install_dir=install_dir,
|
|
2003
|
+
dry_run=dry_run,
|
|
2004
|
+
shallow=shallow,
|
|
2005
|
+
ref=ref,
|
|
2006
|
+
method_override=method,
|
|
2007
|
+
verbose=verbose,
|
|
2008
|
+
timeout=timeout,
|
|
2009
|
+
retries=retries,
|
|
2010
|
+
)
|
|
1564
2011
|
|
|
1565
2012
|
elif command == "update":
|
|
1566
|
-
install_dir, dry_run, force, shallow, ref, method, json_output,
|
|
1567
|
-
|
|
1568
|
-
)
|
|
2013
|
+
(install_dir, dry_run, force, shallow, ref, method, json_output,
|
|
2014
|
+
verbose, no_color, timeout, retries, jobs, rest) = _parse_args(sys.argv[2:])
|
|
1569
2015
|
if not rest:
|
|
1570
2016
|
print_error("Please provide an app name")
|
|
1571
2017
|
sys.exit(1)
|
|
@@ -1597,9 +2043,8 @@ def main():
|
|
|
1597
2043
|
list_installed(json_output=json_output)
|
|
1598
2044
|
|
|
1599
2045
|
elif command in ("uninstall", "remove"):
|
|
1600
|
-
install_dir, dry_run, force, shallow, ref, method, json_output,
|
|
1601
|
-
|
|
1602
|
-
)
|
|
2046
|
+
(install_dir, dry_run, force, shallow, ref, method, json_output,
|
|
2047
|
+
verbose, no_color, timeout, retries, jobs, rest) = _parse_args(sys.argv[2:])
|
|
1603
2048
|
if not rest:
|
|
1604
2049
|
print_error("Please provide an app name")
|
|
1605
2050
|
sys.exit(1)
|
|
@@ -1612,9 +2057,8 @@ def main():
|
|
|
1612
2057
|
verify_apps(json_output=json_output)
|
|
1613
2058
|
|
|
1614
2059
|
elif command == "clean":
|
|
1615
|
-
install_dir, dry_run, force, shallow, ref, method, json_output,
|
|
1616
|
-
|
|
1617
|
-
)
|
|
2060
|
+
(install_dir, dry_run, force, shallow, ref, method, json_output,
|
|
2061
|
+
verbose, no_color, timeout, retries, jobs, rest) = _parse_args(sys.argv[2:])
|
|
1618
2062
|
clean_registry(dry_run=dry_run, force=force, json_output=json_output)
|
|
1619
2063
|
|
|
1620
2064
|
elif command == "stats":
|
|
@@ -1636,30 +2080,48 @@ def main():
|
|
|
1636
2080
|
print_error("Please provide a search query")
|
|
1637
2081
|
sys.exit(1)
|
|
1638
2082
|
|
|
2083
|
+
# Extract flags
|
|
2084
|
+
output_file = None
|
|
2085
|
+
search_all = False
|
|
1639
2086
|
forge = "github"
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
2087
|
+
cleaned = []
|
|
2088
|
+
i = 0
|
|
2089
|
+
while i < len(args):
|
|
2090
|
+
if args[i] == "--forge" and i + 1 < len(args):
|
|
2091
|
+
forge = args[i + 1].lower()
|
|
2092
|
+
i += 2
|
|
2093
|
+
elif args[i] == "--all":
|
|
2094
|
+
search_all = True
|
|
2095
|
+
i += 1
|
|
2096
|
+
elif args[i] == "--output" and i + 1 < len(args):
|
|
2097
|
+
output_file = args[i + 1]
|
|
2098
|
+
i += 2
|
|
1645
2099
|
else:
|
|
1646
|
-
|
|
1647
|
-
|
|
2100
|
+
cleaned.append(args[i])
|
|
2101
|
+
i += 1
|
|
1648
2102
|
|
|
1649
|
-
query = " ".join(
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
"
|
|
1653
|
-
"codeberg": search_codeberg,
|
|
1654
|
-
"bitbucket": search_bitbucket,
|
|
1655
|
-
}
|
|
1656
|
-
searcher = forge_searchers.get(forge)
|
|
1657
|
-
if searcher:
|
|
1658
|
-
searcher(query)
|
|
1659
|
-
else:
|
|
1660
|
-
print_error(f"Unknown forge: {forge}. Supported: {', '.join(sorted(forge_searchers))}")
|
|
2103
|
+
query = " ".join(cleaned)
|
|
2104
|
+
|
|
2105
|
+
if not query:
|
|
2106
|
+
print_error("Please provide a search query")
|
|
1661
2107
|
sys.exit(1)
|
|
1662
2108
|
|
|
2109
|
+
if search_all:
|
|
2110
|
+
search_all_forges(query, output_file=output_file)
|
|
2111
|
+
else:
|
|
2112
|
+
forge_searchers = {
|
|
2113
|
+
"github": search_github,
|
|
2114
|
+
"gitlab": search_gitlab,
|
|
2115
|
+
"codeberg": search_codeberg,
|
|
2116
|
+
"bitbucket": search_bitbucket,
|
|
2117
|
+
}
|
|
2118
|
+
searcher = forge_searchers.get(forge)
|
|
2119
|
+
if searcher:
|
|
2120
|
+
searcher(query)
|
|
2121
|
+
else:
|
|
2122
|
+
print_error(f"Unknown forge: {forge}. Supported: {', '.join(sorted(forge_searchers))}")
|
|
2123
|
+
sys.exit(1)
|
|
2124
|
+
|
|
1663
2125
|
elif command == "export":
|
|
1664
2126
|
if len(sys.argv) < 3:
|
|
1665
2127
|
print_error("Please provide an output file path")
|
|
@@ -1685,6 +2147,26 @@ def main():
|
|
|
1685
2147
|
print("Supported shells: bash, zsh")
|
|
1686
2148
|
sys.exit(1)
|
|
1687
2149
|
|
|
2150
|
+
elif command == "pin":
|
|
2151
|
+
if len(sys.argv) < 3:
|
|
2152
|
+
print_error("Please provide an app name")
|
|
2153
|
+
sys.exit(1)
|
|
2154
|
+
pin_app(sys.argv[2])
|
|
2155
|
+
|
|
2156
|
+
elif command == "unpin":
|
|
2157
|
+
if len(sys.argv) < 3:
|
|
2158
|
+
print_error("Please provide an app name")
|
|
2159
|
+
sys.exit(1)
|
|
2160
|
+
unpin_app(sys.argv[2])
|
|
2161
|
+
|
|
2162
|
+
elif command == "self-update":
|
|
2163
|
+
if not self_update():
|
|
2164
|
+
sys.exit(1)
|
|
2165
|
+
|
|
2166
|
+
elif command == "cache":
|
|
2167
|
+
action = sys.argv[2] if len(sys.argv) > 2 else "path"
|
|
2168
|
+
cache_command(action)
|
|
2169
|
+
|
|
1688
2170
|
elif command == "version":
|
|
1689
2171
|
print(f"pluck v{__version__}")
|
|
1690
2172
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pluck-cli
|
|
3
|
-
Version: 0.2
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Pluck any git repo from any forge — auto-detect, auto-install, done!
|
|
5
5
|
Author: pluck contributors
|
|
6
6
|
License: MIT
|
|
@@ -175,7 +175,11 @@ Any git hosting platform that follows the standard `host/owner/repo` URL pattern
|
|
|
175
175
|
| `stats` | Show statistics | `pluck stats` |
|
|
176
176
|
| `doctor` | Check tool availability | `pluck doctor` |
|
|
177
177
|
| `config [key] [val]` | View/set config | `pluck config install_dir ~/Apps` |
|
|
178
|
-
| `search <query> [--forge <name>]` | Search repos
|
|
178
|
+
| `search <query> [--forge <name>] [--all] [--output <file>]` | Search repos across forges | `pluck search python installer --all --output results.txt` |
|
|
179
|
+
| `pin <name>` | Pin an app to prevent updates | `pluck pin myapp` |
|
|
180
|
+
| `unpin <name>` | Unpin an app | `pluck unpin myapp` |
|
|
181
|
+
| `self-update` | Update pluck itself | `pluck self-update` |
|
|
182
|
+
| `cache prune` | Clear download cache | `pluck cache prune` |
|
|
179
183
|
| `export <file>` | Export registry | `pluck export ~/backup.json` |
|
|
180
184
|
| `import <file>` | Import registry | `pluck import ~/backup.json` |
|
|
181
185
|
| `completion <shell>` | Generate shell completion | `pluck completion bash` |
|
|
@@ -197,6 +201,8 @@ Any git hosting platform that follows the standard `host/owner/repo` URL pattern
|
|
|
197
201
|
| `--no-color` | Disable colored output |
|
|
198
202
|
| `--timeout <secs>` | Timeout for git clone in seconds |
|
|
199
203
|
| `--retries <n>` | Number of retries for failed git clone |
|
|
204
|
+
| `--jobs <n>` | Number of parallel installs (default: 1) |
|
|
205
|
+
| `--release` | Install from pre-built release assets instead of cloning |
|
|
200
206
|
| `--verbose` | Show detailed git clone output |
|
|
201
207
|
|
|
202
208
|
## 📥 Installation
|
|
@@ -356,10 +356,10 @@ class TestSharedPaths:
|
|
|
356
356
|
|
|
357
357
|
class TestValidMethods:
|
|
358
358
|
def test_valid_methods_not_empty(self):
|
|
359
|
-
assert len(VALID_METHODS) ==
|
|
359
|
+
assert len(VALID_METHODS) == 9
|
|
360
360
|
|
|
361
361
|
def test_all_expected_methods_present(self):
|
|
362
|
-
expected = {"script", "binary", "python", "node", "go", "rust", "make", "download"}
|
|
362
|
+
expected = {"script", "binary", "python", "node", "go", "rust", "make", "download", "release"}
|
|
363
363
|
assert VALID_METHODS == expected
|
|
364
364
|
|
|
365
365
|
|
|
@@ -433,7 +433,7 @@ class TestParseArgs:
|
|
|
433
433
|
def test_urls_only(self):
|
|
434
434
|
(
|
|
435
435
|
install_dir, dry_run, force, shallow, ref,
|
|
436
|
-
method, json_output, verbose, no_color, timeout, retries, urls,
|
|
436
|
+
method, json_output, verbose, no_color, timeout, retries, jobs, urls,
|
|
437
437
|
) = _parse_args(["https://github.com/a/b"])
|
|
438
438
|
assert install_dir is None
|
|
439
439
|
assert dry_run is False
|
|
@@ -446,7 +446,7 @@ class TestParseArgs:
|
|
|
446
446
|
def test_dir_flag(self):
|
|
447
447
|
(
|
|
448
448
|
install_dir, dry_run, force, shallow, ref,
|
|
449
|
-
method, json_output, verbose, no_color, timeout, retries, urls,
|
|
449
|
+
method, json_output, verbose, no_color, timeout, retries, jobs, urls,
|
|
450
450
|
) = _parse_args(["--dir", "/tmp/test", "https://github.com/a/b"])
|
|
451
451
|
assert install_dir == Path("/tmp/test")
|
|
452
452
|
assert urls == ["https://github.com/a/b"]
|
|
@@ -454,49 +454,49 @@ class TestParseArgs:
|
|
|
454
454
|
def test_dry_run_flag(self):
|
|
455
455
|
(
|
|
456
456
|
install_dir, dry_run, force, shallow, ref,
|
|
457
|
-
method, json_output, verbose, no_color, timeout, retries, urls,
|
|
457
|
+
method, json_output, verbose, no_color, timeout, retries, jobs, urls,
|
|
458
458
|
) = _parse_args(["--dry-run", "https://github.com/a/b"])
|
|
459
459
|
assert dry_run is True
|
|
460
460
|
|
|
461
461
|
def test_force_flag(self):
|
|
462
462
|
(
|
|
463
463
|
install_dir, dry_run, force, shallow, ref,
|
|
464
|
-
method, json_output, verbose, no_color, timeout, retries, urls,
|
|
464
|
+
method, json_output, verbose, no_color, timeout, retries, jobs, urls,
|
|
465
465
|
) = _parse_args(["--force", "https://github.com/a/b"])
|
|
466
466
|
assert force is True
|
|
467
467
|
|
|
468
468
|
def test_yes_flag(self):
|
|
469
469
|
(
|
|
470
470
|
install_dir, dry_run, force, shallow, ref,
|
|
471
|
-
method, json_output, verbose, no_color, timeout, retries, urls,
|
|
471
|
+
method, json_output, verbose, no_color, timeout, retries, jobs, urls,
|
|
472
472
|
) = _parse_args(["--yes", "https://github.com/a/b"])
|
|
473
473
|
assert force is True
|
|
474
474
|
|
|
475
475
|
def test_shallow_flag(self):
|
|
476
476
|
(
|
|
477
477
|
install_dir, dry_run, force, shallow, ref,
|
|
478
|
-
method, json_output, verbose, no_color, timeout, retries, urls,
|
|
478
|
+
method, json_output, verbose, no_color, timeout, retries, jobs, urls,
|
|
479
479
|
) = _parse_args(["--shallow", "https://github.com/a/b"])
|
|
480
480
|
assert shallow is True
|
|
481
481
|
|
|
482
482
|
def test_ref_flag(self):
|
|
483
483
|
(
|
|
484
484
|
install_dir, dry_run, force, shallow, ref,
|
|
485
|
-
method, json_output, verbose, no_color, timeout, retries, urls,
|
|
485
|
+
method, json_output, verbose, no_color, timeout, retries, jobs, urls,
|
|
486
486
|
) = _parse_args(["--ref", "v2.0", "https://github.com/a/b"])
|
|
487
487
|
assert ref == "v2.0"
|
|
488
488
|
|
|
489
489
|
def test_method_flag(self):
|
|
490
490
|
(
|
|
491
491
|
install_dir, dry_run, force, shallow, ref,
|
|
492
|
-
method, json_output, verbose, no_color, timeout, retries, urls,
|
|
492
|
+
method, json_output, verbose, no_color, timeout, retries, jobs, urls,
|
|
493
493
|
) = _parse_args(["--method", "python", "https://github.com/a/b"])
|
|
494
494
|
assert method == "python"
|
|
495
495
|
|
|
496
496
|
def test_combined_flags(self):
|
|
497
497
|
(
|
|
498
498
|
install_dir, dry_run, force, shallow, ref,
|
|
499
|
-
method, json_output, verbose, no_color, timeout, retries, urls,
|
|
499
|
+
method, json_output, verbose, no_color, timeout, retries, jobs, urls,
|
|
500
500
|
) = _parse_args([
|
|
501
501
|
"--dir", "/custom", "--dry-run", "--shallow",
|
|
502
502
|
"--ref", "main", "--method", "python",
|
|
@@ -511,7 +511,7 @@ class TestParseArgs:
|
|
|
511
511
|
def test_flags_between_urls(self):
|
|
512
512
|
(
|
|
513
513
|
install_dir, dry_run, force, shallow, ref,
|
|
514
|
-
method, json_output, verbose, no_color, timeout, retries, urls,
|
|
514
|
+
method, json_output, verbose, no_color, timeout, retries, jobs, urls,
|
|
515
515
|
) = _parse_args([
|
|
516
516
|
"https://github.com/a/b", "--dir", "/opt", "https://github.com/c/d",
|
|
517
517
|
])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|