pluck-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gh_install.py
ADDED
|
@@ -0,0 +1,1692 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
GitHub App Installer - Paste URL, Auto-Install, Done!
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
# Configuration
|
|
24
|
+
DEFAULT_INSTALL_DIR_MACOS = Path.home() / "Applications"
|
|
25
|
+
DEFAULT_INSTALL_DIR_LINUX = Path.home() / ".local" / "opt"
|
|
26
|
+
if sys.platform == "darwin":
|
|
27
|
+
DEFAULT_INSTALL_DIR = DEFAULT_INSTALL_DIR_MACOS
|
|
28
|
+
else:
|
|
29
|
+
DEFAULT_INSTALL_DIR = DEFAULT_INSTALL_DIR_LINUX
|
|
30
|
+
APP_REGISTRY_FILE = Path.home() / ".pluck-registry.json"
|
|
31
|
+
_CONFIG_OLD_REGISTRY = Path.home() / ".gh-install-registry.json"
|
|
32
|
+
CONFIG_FILE = (
|
|
33
|
+
Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "pluck" / "config.json"
|
|
34
|
+
)
|
|
35
|
+
_CONFIG_OLD_DIR = Path.home() / ".config" / "gh-install"
|
|
36
|
+
SHARED_PATHS = {
|
|
37
|
+
Path.home() / "go" / "bin",
|
|
38
|
+
Path.home() / "Applications",
|
|
39
|
+
Path.home() / ".local" / "opt",
|
|
40
|
+
Path.home() / "bin",
|
|
41
|
+
}
|
|
42
|
+
VALID_METHODS = {"script", "binary", "python", "node", "go", "rust", "make", "download"}
|
|
43
|
+
GIST_PATTERN = r"gist\.github\.com[:/]([^/]+)/([a-f0-9]+)"
|
|
44
|
+
# GitLab personal snippet: gitlab.com/-/snippets/12345
|
|
45
|
+
# GitLab project snippet: gitlab.com/owner/repo/-/snippets/12345
|
|
46
|
+
SNIPPET_PATTERNS = [
|
|
47
|
+
r"gitlab\.com/-/snippets/(\d+)",
|
|
48
|
+
r"gitlab\.com/([^/]+)/([^/]+)/-/snippets/(\d+)",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# Color auto-detection
|
|
52
|
+
_COLORS_ENABLED = sys.stdout.isatty()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _enable_colors(enabled: bool) -> None:
|
|
56
|
+
global _COLORS_ENABLED
|
|
57
|
+
_COLORS_ENABLED = enabled
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_user_config():
|
|
61
|
+
"""Load user config file if it exists."""
|
|
62
|
+
if CONFIG_FILE.exists():
|
|
63
|
+
try:
|
|
64
|
+
with open(CONFIG_FILE) as f:
|
|
65
|
+
return json.load(f)
|
|
66
|
+
except (json.JSONDecodeError, OSError):
|
|
67
|
+
pass
|
|
68
|
+
return {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _save_user_config(config):
|
|
72
|
+
"""Save user config file."""
|
|
73
|
+
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
with open(CONFIG_FILE, "w") as f:
|
|
75
|
+
json.dump(config, f, indent=2)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def print_usage():
|
|
79
|
+
commands = [
|
|
80
|
+
("install <url> [opts]", "Install from any git repo URL"),
|
|
81
|
+
("update <name> [--force]", "Update an installed app"),
|
|
82
|
+
("info <name>", "Show app details"),
|
|
83
|
+
("list", "List installed apps"),
|
|
84
|
+
("uninstall <name> [--force]", "Uninstall an app"),
|
|
85
|
+
("remove <name> [--force]", "Alias for uninstall"),
|
|
86
|
+
("verify", "Check installed apps validity"),
|
|
87
|
+
("clean [--force]", "Remove orphaned registry entries"),
|
|
88
|
+
("stats", "Show installation statistics"),
|
|
89
|
+
("doctor", "Check tool availability"),
|
|
90
|
+
("config [key] [value]", "View/set config"),
|
|
91
|
+
("search <query> [--forge <name>]", "Search repos (github|gitlab|codeberg)"),
|
|
92
|
+
("export <file>", "Export registry"),
|
|
93
|
+
("import <file>", "Import registry"),
|
|
94
|
+
("completion <shell>", "Generate shell completion"),
|
|
95
|
+
("version", "Show version"),
|
|
96
|
+
("help", "Show this help"),
|
|
97
|
+
]
|
|
98
|
+
opts = [
|
|
99
|
+
("--dir <path>", "Install to a custom directory"),
|
|
100
|
+
("--dry-run", "Show what would be done without making changes"),
|
|
101
|
+
("--force", "Skip confirmation prompts"),
|
|
102
|
+
("--shallow", "Use shallow clone (--depth 1)"),
|
|
103
|
+
("--ref <ref>", "Clone a specific branch or tag"),
|
|
104
|
+
("--method <method>", "Force install method"),
|
|
105
|
+
("--yes", "Non-interactive mode (alias for --force)"),
|
|
106
|
+
("--json", "Output in JSON format (for scripting)"),
|
|
107
|
+
("--no-color", "Disable colored output"),
|
|
108
|
+
("--timeout <secs>", "Timeout for git clone in seconds"),
|
|
109
|
+
("--retries <n>", "Number of retries for failed git clone"),
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
print("Usage:")
|
|
113
|
+
print(" pluck <command> [args] [options]")
|
|
114
|
+
print()
|
|
115
|
+
print("Commands:")
|
|
116
|
+
max_cmd = max(len(c[0]) for c in commands)
|
|
117
|
+
for cmd, desc in commands:
|
|
118
|
+
print(f" {cmd:<{max_cmd}} {desc}")
|
|
119
|
+
print()
|
|
120
|
+
print("Options:")
|
|
121
|
+
max_opt = max(len(o[0]) for o in opts)
|
|
122
|
+
for opt, desc in opts:
|
|
123
|
+
print(f" {opt:<{max_opt}} {desc}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _parse_snippet_url(url):
|
|
127
|
+
"""Extract snippet/gist info from gist or code snippet URLs.
|
|
128
|
+
|
|
129
|
+
Supports:
|
|
130
|
+
- GitHub Gists: gist.github.com/user/id
|
|
131
|
+
- GitLab personal snippets: gitlab.com/-/snippets/id
|
|
132
|
+
- GitLab project snippets: gitlab.com/owner/repo/-/snippets/id
|
|
133
|
+
"""
|
|
134
|
+
# GitHub Gist
|
|
135
|
+
match = re.search(GIST_PATTERN, url)
|
|
136
|
+
if match:
|
|
137
|
+
return {
|
|
138
|
+
"host": "gist.github.com",
|
|
139
|
+
"host_type": "github",
|
|
140
|
+
"owner": match.group(1),
|
|
141
|
+
"repo": f"gist-{match.group(2)}",
|
|
142
|
+
"url": f"https://gist.github.com/{match.group(1)}/{match.group(2)}.git",
|
|
143
|
+
"is_gist": True,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# GitLab personal snippet
|
|
147
|
+
match = re.search(SNIPPET_PATTERNS[0], url)
|
|
148
|
+
if match:
|
|
149
|
+
snippet_id = match.group(1)
|
|
150
|
+
return {
|
|
151
|
+
"host": "gitlab.com",
|
|
152
|
+
"host_type": "gitlab",
|
|
153
|
+
"owner": "-",
|
|
154
|
+
"repo": f"snippet-{snippet_id}",
|
|
155
|
+
"url": f"https://gitlab.com/-/snippets/{snippet_id}.git",
|
|
156
|
+
"is_gist": True,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# GitLab project snippet
|
|
160
|
+
match = re.search(SNIPPET_PATTERNS[1], url)
|
|
161
|
+
if match:
|
|
162
|
+
owner = match.group(1)
|
|
163
|
+
project = match.group(2)
|
|
164
|
+
snippet_id = match.group(3)
|
|
165
|
+
return {
|
|
166
|
+
"host": "gitlab.com",
|
|
167
|
+
"host_type": "gitlab",
|
|
168
|
+
"owner": f"{owner}/{project}",
|
|
169
|
+
"repo": f"snippet-{snippet_id}",
|
|
170
|
+
"url": f"https://gitlab.com/{owner}/{project}/-/snippets/{snippet_id}.git",
|
|
171
|
+
"is_gist": True,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# Backward compat alias
|
|
178
|
+
_parse_gist_url = _parse_snippet_url
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _completion_script(shell):
|
|
182
|
+
"""Return shell completion script for bash or zsh."""
|
|
183
|
+
if shell == "bash":
|
|
184
|
+
return """_pluck_completion() {
|
|
185
|
+
local cur="${COMP_WORDS[COMP_CWORD]}"
|
|
186
|
+
local prev="${COMP_WORDS[COMP_CWORD-1]}"
|
|
187
|
+
local commands="install update info list uninstall doctor config search export import completion version help"
|
|
188
|
+
|
|
189
|
+
case "${COMP_WORDS[1]}" in
|
|
190
|
+
install)
|
|
191
|
+
if [[ "$prev" == "--dir" ]]; then
|
|
192
|
+
_filedir -d
|
|
193
|
+
else
|
|
194
|
+
opts="--dir --dry-run --force --shallow --ref --method --yes"
|
|
195
|
+
COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
|
|
196
|
+
fi
|
|
197
|
+
return
|
|
198
|
+
;;
|
|
199
|
+
update|uninstall|info)
|
|
200
|
+
local apps
|
|
201
|
+
apps=$(python3 -c "
|
|
202
|
+
import json, os
|
|
203
|
+
p = os.path.expanduser('~/.pluck-registry.json')
|
|
204
|
+
if os.path.exists(p):
|
|
205
|
+
data = json.load(open(p))
|
|
206
|
+
print(' '.join(data.get('apps', {}).keys()))
|
|
207
|
+
" 2>/dev/null)
|
|
208
|
+
COMPREPLY=( $(compgen -W "$apps --force --dry-run" -- "$cur") )
|
|
209
|
+
return
|
|
210
|
+
;;
|
|
211
|
+
list|version|help|doctor)
|
|
212
|
+
return
|
|
213
|
+
;;
|
|
214
|
+
completion)
|
|
215
|
+
COMPREPLY=( $(compgen -W "bash zsh" -- "$cur") )
|
|
216
|
+
return
|
|
217
|
+
;;
|
|
218
|
+
config)
|
|
219
|
+
COMPREPLY=( $(compgen -W "install_dir method_priority" -- "$cur") )
|
|
220
|
+
return
|
|
221
|
+
;;
|
|
222
|
+
*)
|
|
223
|
+
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
|
|
224
|
+
return
|
|
225
|
+
;;
|
|
226
|
+
esac
|
|
227
|
+
}
|
|
228
|
+
complete -F _pluck_completion pluck
|
|
229
|
+
"""
|
|
230
|
+
elif shell == "zsh":
|
|
231
|
+
return """#compdef pluck
|
|
232
|
+
_pluck() {
|
|
233
|
+
local -a commands
|
|
234
|
+
commands=(
|
|
235
|
+
'install:Install from any git repo URL'
|
|
236
|
+
'update:Update an installed app'
|
|
237
|
+
'info:Show app details'
|
|
238
|
+
'list:List installed apps'
|
|
239
|
+
'uninstall:Uninstall an app'
|
|
240
|
+
'doctor:Check tool availability'
|
|
241
|
+
'config:View/set config'
|
|
242
|
+
'search:Search GitHub repos'
|
|
243
|
+
'export:Export registry'
|
|
244
|
+
'import:Import registry'
|
|
245
|
+
'completion:Generate shell completion'
|
|
246
|
+
'version:Show version'
|
|
247
|
+
'help:Show help'
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
_arguments -C \\
|
|
251
|
+
'1: :->command' \\
|
|
252
|
+
'*: :->args'
|
|
253
|
+
|
|
254
|
+
case $state in
|
|
255
|
+
command)
|
|
256
|
+
_describe 'command' commands
|
|
257
|
+
;;
|
|
258
|
+
args)
|
|
259
|
+
case $words[1] in
|
|
260
|
+
install)
|
|
261
|
+
_arguments \\
|
|
262
|
+
'--dir[Install to custom directory]:directory:_directories' \\
|
|
263
|
+
'--dry-run[Preview without changes]' \\
|
|
264
|
+
'--force[Skip confirmation]' \\
|
|
265
|
+
'--shallow[Use shallow clone]' \\
|
|
266
|
+
'--ref[Clone specific branch/tag]:ref:' \\
|
|
267
|
+
'--method[Force install method]:(script python node go rust' \
|
|
268
|
+
'make binary download)' \
|
|
269
|
+
'--yes[Non-interactive mode]'
|
|
270
|
+
;;
|
|
271
|
+
update|uninstall|info)
|
|
272
|
+
local apps
|
|
273
|
+
apps=($(python3 -c "
|
|
274
|
+
import json, os
|
|
275
|
+
p = os.path.expanduser('~/.pluck-registry.json')
|
|
276
|
+
if os.path.exists(p):
|
|
277
|
+
data = json.load(open(p))
|
|
278
|
+
print(' '.join(data.get('apps', {}).keys()))
|
|
279
|
+
" 2>/dev/null))
|
|
280
|
+
_arguments \\
|
|
281
|
+
'--force[Skip confirmation]' \\
|
|
282
|
+
'--dry-run[Preview without changes]' \\
|
|
283
|
+
"1:app:($apps)"
|
|
284
|
+
;;
|
|
285
|
+
completion)
|
|
286
|
+
_arguments '1:shell:(bash zsh)'
|
|
287
|
+
;;
|
|
288
|
+
esac
|
|
289
|
+
;;
|
|
290
|
+
esac
|
|
291
|
+
}
|
|
292
|
+
_pluck
|
|
293
|
+
"""
|
|
294
|
+
else:
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _get_app_names():
|
|
299
|
+
"""Return list of installed app names for completion."""
|
|
300
|
+
try:
|
|
301
|
+
if APP_REGISTRY_FILE.exists():
|
|
302
|
+
with open(APP_REGISTRY_FILE) as f:
|
|
303
|
+
data = json.load(f)
|
|
304
|
+
return list(data.get("apps", {}).keys())
|
|
305
|
+
except (json.JSONDecodeError, OSError):
|
|
306
|
+
pass
|
|
307
|
+
return []
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _sanitize_repo_name(name):
|
|
311
|
+
"""Reject repo names that could cause path traversal."""
|
|
312
|
+
if ".." in name or name.startswith("/") or name.startswith("\\"):
|
|
313
|
+
return None
|
|
314
|
+
return name
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class Colors:
|
|
318
|
+
GREEN = "\033[92m" if _COLORS_ENABLED else ""
|
|
319
|
+
YELLOW = "\033[93m" if _COLORS_ENABLED else ""
|
|
320
|
+
RED = "\033[91m" if _COLORS_ENABLED else ""
|
|
321
|
+
BLUE = "\033[94m" if _COLORS_ENABLED else ""
|
|
322
|
+
CYAN = "\033[96m" if _COLORS_ENABLED else ""
|
|
323
|
+
END = "\033[0m" if _COLORS_ENABLED else ""
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def print_header(text):
|
|
327
|
+
print(f"\n{Colors.BLUE}{'=' * 60}{Colors.END}")
|
|
328
|
+
print(f"{Colors.GREEN} {text}{Colors.END}")
|
|
329
|
+
print(f"{Colors.BLUE}{'=' * 60}{Colors.END}\n")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def print_success(text):
|
|
333
|
+
print(f"{Colors.GREEN}✓ {text}{Colors.END}")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def print_warning(text):
|
|
337
|
+
print(f"{Colors.YELLOW}⚠ {text}{Colors.END}")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def print_error(text):
|
|
341
|
+
print(f"{Colors.RED}✗ {text}{Colors.END}")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _detect_host_type(host):
|
|
345
|
+
"""Identify the forge type from a git hosting domain."""
|
|
346
|
+
host_lower = host.lower()
|
|
347
|
+
if host_lower.startswith("www."):
|
|
348
|
+
host_lower = host_lower[4:]
|
|
349
|
+
forge_map = {
|
|
350
|
+
"github.com": "github",
|
|
351
|
+
"gitlab.com": "gitlab",
|
|
352
|
+
"codeberg.org": "codeberg",
|
|
353
|
+
"bitbucket.org": "bitbucket",
|
|
354
|
+
"git.sr.ht": "sourcehut",
|
|
355
|
+
"gitea.com": "gitea",
|
|
356
|
+
"gogs.io": "gogs",
|
|
357
|
+
"pagure.io": "pagure",
|
|
358
|
+
"forgejo.org": "forgejo",
|
|
359
|
+
}
|
|
360
|
+
return forge_map.get(host_lower, "generic")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def parse_repo_url(url):
|
|
364
|
+
"""Extract owner/repo from any git hosting URL.
|
|
365
|
+
|
|
366
|
+
Supports GitHub, GitLab, Codeberg, Bitbucket, SourceHut, Gitea,
|
|
367
|
+
self-hosted instances, and any other standard git hosting.
|
|
368
|
+
"""
|
|
369
|
+
# Try gist detection first
|
|
370
|
+
gist_info = _parse_snippet_url(url)
|
|
371
|
+
if gist_info:
|
|
372
|
+
return gist_info
|
|
373
|
+
|
|
374
|
+
# Normalize: strip trailing slash
|
|
375
|
+
url = url.rstrip("/")
|
|
376
|
+
|
|
377
|
+
patterns = [
|
|
378
|
+
# HTTPS: https://host/owner/repo[.git][/extra/path]
|
|
379
|
+
r"https?://([^/]+)/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$",
|
|
380
|
+
# SSH git@host:owner/repo[.git]
|
|
381
|
+
r"git@([^:]+):([^/]+)/([^/]+?)(?:\.git)?$",
|
|
382
|
+
# SSH ssh://git@host/owner/repo[.git]
|
|
383
|
+
r"ssh://git@([^/]+)/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$",
|
|
384
|
+
# git protocol: git://host/owner/repo[.git]
|
|
385
|
+
r"git://([^/]+)/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$",
|
|
386
|
+
]
|
|
387
|
+
|
|
388
|
+
for pattern in patterns:
|
|
389
|
+
match = re.search(pattern, url)
|
|
390
|
+
if match:
|
|
391
|
+
host = match.group(1)
|
|
392
|
+
owner = match.group(2)
|
|
393
|
+
repo = match.group(3)
|
|
394
|
+
host_type = _detect_host_type(host)
|
|
395
|
+
normalized_url = f"https://{host}/{owner}/{repo}"
|
|
396
|
+
return {
|
|
397
|
+
"host": host,
|
|
398
|
+
"host_type": host_type,
|
|
399
|
+
"owner": owner,
|
|
400
|
+
"repo": repo,
|
|
401
|
+
"url": normalized_url,
|
|
402
|
+
"is_gist": False,
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# Backward-compat alias — remove in a future release
|
|
409
|
+
parse_github_url = parse_repo_url
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def detect_install_method(repo_path, method_priority=None):
|
|
413
|
+
"""Detect the best installation method for a repository"""
|
|
414
|
+
if method_priority:
|
|
415
|
+
methods = [m for m in method_priority if m in VALID_METHODS]
|
|
416
|
+
else:
|
|
417
|
+
methods = ["script", "binary", "python", "node", "go", "rust", "make", "download"]
|
|
418
|
+
|
|
419
|
+
for method in methods:
|
|
420
|
+
if method == "script" and (repo_path / "install.sh").exists():
|
|
421
|
+
return "script"
|
|
422
|
+
if method == "binary" and (
|
|
423
|
+
(repo_path / "release" / "linux").exists()
|
|
424
|
+
or (repo_path / "bin" / "linux").exists()
|
|
425
|
+
or list(repo_path.glob("*.AppImage"))
|
|
426
|
+
or list(repo_path.glob("*.deb"))
|
|
427
|
+
):
|
|
428
|
+
return "binary"
|
|
429
|
+
if method == "python" and (
|
|
430
|
+
(repo_path / "pyproject.toml").exists() or (repo_path / "setup.py").exists()
|
|
431
|
+
):
|
|
432
|
+
return "python"
|
|
433
|
+
if method == "node" and (repo_path / "package.json").exists():
|
|
434
|
+
return "node"
|
|
435
|
+
if method == "go" and ((repo_path / "go.mod").exists() or list(repo_path.glob("*.go"))):
|
|
436
|
+
return "go"
|
|
437
|
+
if method == "rust" and (repo_path / "Cargo.toml").exists():
|
|
438
|
+
return "rust"
|
|
439
|
+
if method == "make" and (repo_path / "Makefile").exists():
|
|
440
|
+
return "make"
|
|
441
|
+
|
|
442
|
+
return "download"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def install_script(repo_path, install_dir):
|
|
446
|
+
"""Install using install.sh script"""
|
|
447
|
+
print(" Running install.sh script...")
|
|
448
|
+
|
|
449
|
+
try:
|
|
450
|
+
subprocess.run(["bash", "install.sh", "--yes"], cwd=repo_path, check=True)
|
|
451
|
+
print_success("Installation script completed")
|
|
452
|
+
return install_dir
|
|
453
|
+
except subprocess.CalledProcessError:
|
|
454
|
+
print_warning("Install script failed, copying directory instead")
|
|
455
|
+
return install_binary(repo_path, install_dir)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def install_python(repo_path, install_dir):
|
|
459
|
+
"""Install Python project"""
|
|
460
|
+
print(" Installing Python project...")
|
|
461
|
+
|
|
462
|
+
try:
|
|
463
|
+
app_dir = install_dir / repo_path.name
|
|
464
|
+
venv_path = app_dir / ".venv"
|
|
465
|
+
venv_path.parent.mkdir(parents=True, exist_ok=True)
|
|
466
|
+
subprocess.run([sys.executable, "-m", "venv", str(venv_path)], check=True)
|
|
467
|
+
|
|
468
|
+
pip_path = venv_path / "bin" / "pip"
|
|
469
|
+
subprocess.run([str(pip_path), "install", "-e", str(repo_path)], check=True)
|
|
470
|
+
|
|
471
|
+
print_success(f"Installed to {app_dir}")
|
|
472
|
+
|
|
473
|
+
if (venv_path / "bin" / repo_path.name).exists():
|
|
474
|
+
bin_file = venv_path / "bin" / repo_path.name
|
|
475
|
+
link_path = install_dir / repo_path.name
|
|
476
|
+
if link_path.exists() or link_path.is_symlink():
|
|
477
|
+
link_path.unlink()
|
|
478
|
+
link_path.symlink_to(bin_file)
|
|
479
|
+
print_success(f"Created symlink: {link_path}")
|
|
480
|
+
return app_dir
|
|
481
|
+
|
|
482
|
+
return app_dir
|
|
483
|
+
except subprocess.CalledProcessError as e:
|
|
484
|
+
print_error(f"Python installation failed: {e}")
|
|
485
|
+
return None
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def install_node(repo_path, install_dir):
|
|
489
|
+
"""Install Node.js project"""
|
|
490
|
+
print(" Installing Node.js project...")
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
dest = install_dir / repo_path.name
|
|
494
|
+
ignore = shutil.ignore_patterns("node_modules", ".git")
|
|
495
|
+
shutil.copytree(repo_path, dest, dirs_exist_ok=True, ignore=ignore)
|
|
496
|
+
|
|
497
|
+
subprocess.run(["npm", "install"], cwd=dest, check=True)
|
|
498
|
+
|
|
499
|
+
print_success(f"Installed to {dest}")
|
|
500
|
+
return dest
|
|
501
|
+
except subprocess.CalledProcessError as e:
|
|
502
|
+
print_error(f"Node.js installation failed: {e}")
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def install_go(repo_path, install_dir):
|
|
507
|
+
"""Install Go project"""
|
|
508
|
+
print(" Installing Go project...")
|
|
509
|
+
|
|
510
|
+
try:
|
|
511
|
+
subprocess.run(
|
|
512
|
+
["go", "build", "-o", str(install_dir / repo_path.name), "."],
|
|
513
|
+
cwd=repo_path,
|
|
514
|
+
check=True,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
binary_path = install_dir / repo_path.name
|
|
518
|
+
if binary_path.exists():
|
|
519
|
+
print_success(f"Installed to {binary_path}")
|
|
520
|
+
return binary_path
|
|
521
|
+
|
|
522
|
+
return install_dir
|
|
523
|
+
except subprocess.CalledProcessError as e:
|
|
524
|
+
print_error(f"Go installation failed: {e}")
|
|
525
|
+
return None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def install_rust(repo_path, install_dir):
|
|
529
|
+
"""Install Rust project"""
|
|
530
|
+
print(" Installing Rust project...")
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
subprocess.run(["cargo", "build", "--release"], cwd=repo_path, check=True)
|
|
534
|
+
|
|
535
|
+
target_dir = repo_path / "target" / "release"
|
|
536
|
+
binaries = list(target_dir.glob("*"))
|
|
537
|
+
binaries = [b for b in binaries if b.is_file() and not b.suffix]
|
|
538
|
+
|
|
539
|
+
if binaries:
|
|
540
|
+
for binary in binaries:
|
|
541
|
+
dest = install_dir / binary.name
|
|
542
|
+
shutil.copy2(binary, dest)
|
|
543
|
+
print_success(f"Installed {binary.name} to {dest}")
|
|
544
|
+
|
|
545
|
+
return install_dir
|
|
546
|
+
|
|
547
|
+
return None
|
|
548
|
+
except subprocess.CalledProcessError as e:
|
|
549
|
+
print_error(f"Rust installation failed: {e}")
|
|
550
|
+
return None
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _is_executable(item):
|
|
554
|
+
"""Check if a file is likely an executable binary or script."""
|
|
555
|
+
if not item.is_file():
|
|
556
|
+
return False
|
|
557
|
+
if os.access(item, os.X_OK):
|
|
558
|
+
return True
|
|
559
|
+
executable_extensions = {".exe", ".bin", ".sh", ".py", ".pl", ".rb", ".app"}
|
|
560
|
+
if item.suffix.lower() in executable_extensions:
|
|
561
|
+
return True
|
|
562
|
+
if "." not in item.name:
|
|
563
|
+
return True
|
|
564
|
+
return False
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def install_binary(repo_path, install_dir):
|
|
568
|
+
"""Install pre-built binary"""
|
|
569
|
+
print(" Installing pre-built binary...")
|
|
570
|
+
|
|
571
|
+
binary_dirs = ["release", "bin", "dist"]
|
|
572
|
+
|
|
573
|
+
for dir_name in binary_dirs:
|
|
574
|
+
binary_dir = repo_path / dir_name
|
|
575
|
+
if binary_dir.exists():
|
|
576
|
+
for item in binary_dir.iterdir():
|
|
577
|
+
if _is_executable(item):
|
|
578
|
+
dest = install_dir / item.name
|
|
579
|
+
shutil.copy2(item, dest)
|
|
580
|
+
print_success(f"Installed {item.name} to {dest}")
|
|
581
|
+
|
|
582
|
+
return install_dir
|
|
583
|
+
|
|
584
|
+
# Fallback: copy entire directory
|
|
585
|
+
dest = install_dir / repo_path.name
|
|
586
|
+
shutil.copytree(repo_path, dest, dirs_exist_ok=True)
|
|
587
|
+
print_success(f"Installed to {dest}")
|
|
588
|
+
return dest
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def install_make(repo_path, install_dir):
|
|
592
|
+
"""Install using Makefile"""
|
|
593
|
+
print(" Installing using Makefile...")
|
|
594
|
+
|
|
595
|
+
try:
|
|
596
|
+
subprocess.run(["make", "install", f"PREFIX={install_dir}"], cwd=repo_path, check=True)
|
|
597
|
+
print_success(f"Installed to {install_dir}")
|
|
598
|
+
return install_dir
|
|
599
|
+
except subprocess.CalledProcessError:
|
|
600
|
+
subprocess.run(["make"], cwd=repo_path, check=True)
|
|
601
|
+
return install_binary(repo_path, install_dir)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _get_disk_size(path):
|
|
605
|
+
"""Get disk size of a path in human-readable format."""
|
|
606
|
+
try:
|
|
607
|
+
total = 0
|
|
608
|
+
p = Path(path)
|
|
609
|
+
if p.is_file():
|
|
610
|
+
total = p.stat().st_size
|
|
611
|
+
elif p.is_dir():
|
|
612
|
+
for dirpath, _, filenames in os.walk(p):
|
|
613
|
+
for f in filenames:
|
|
614
|
+
fp = os.path.join(dirpath, f)
|
|
615
|
+
if not os.path.islink(fp):
|
|
616
|
+
total += os.path.getsize(fp)
|
|
617
|
+
if total >= 1024 * 1024 * 1024:
|
|
618
|
+
return f"{total / (1024 * 1024 * 1024):.1f} GB"
|
|
619
|
+
elif total >= 1024 * 1024:
|
|
620
|
+
return f"{total / (1024 * 1024):.1f} MB"
|
|
621
|
+
elif total >= 1024:
|
|
622
|
+
return f"{total / 1024:.1f} KB"
|
|
623
|
+
return f"{total} B"
|
|
624
|
+
except OSError:
|
|
625
|
+
return "unknown"
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def download_and_install(
|
|
629
|
+
repo_url,
|
|
630
|
+
install_dir=None,
|
|
631
|
+
dry_run=False,
|
|
632
|
+
shallow=False,
|
|
633
|
+
ref=None,
|
|
634
|
+
method_override=None,
|
|
635
|
+
timeout=None,
|
|
636
|
+
retries=0,
|
|
637
|
+
):
|
|
638
|
+
"""Download and install a repository from any git hosting URL"""
|
|
639
|
+
|
|
640
|
+
if install_dir is None:
|
|
641
|
+
user_config = _load_user_config()
|
|
642
|
+
config_dir = user_config.get("install_dir")
|
|
643
|
+
if config_dir:
|
|
644
|
+
install_dir = Path(config_dir).expanduser()
|
|
645
|
+
else:
|
|
646
|
+
install_dir = DEFAULT_INSTALL_DIR
|
|
647
|
+
|
|
648
|
+
# Parse repository URL
|
|
649
|
+
repo_info = parse_repo_url(repo_url)
|
|
650
|
+
if not repo_info:
|
|
651
|
+
print_error(f"Invalid repository URL: {repo_url}")
|
|
652
|
+
return None
|
|
653
|
+
|
|
654
|
+
repo_type = "Gist" if repo_info.get("is_gist") else "Repository"
|
|
655
|
+
host_label = repo_info.get("host", "unknown")
|
|
656
|
+
print(f" {repo_type}: {repo_info['owner']}/{repo_info['repo']} ({host_label})")
|
|
657
|
+
|
|
658
|
+
# Validate repo name to prevent path traversal
|
|
659
|
+
safe_name = _sanitize_repo_name(repo_info["repo"])
|
|
660
|
+
if not safe_name:
|
|
661
|
+
print_error(f"Invalid repository name: {repo_info['repo']}")
|
|
662
|
+
return None
|
|
663
|
+
|
|
664
|
+
# Dry-run check before doing any I/O
|
|
665
|
+
if dry_run:
|
|
666
|
+
print(f" [DRY RUN] Would install to: {install_dir / safe_name}")
|
|
667
|
+
print(f" [DRY RUN] Would use method: {method_override or '(auto-detected after clone)'}")
|
|
668
|
+
return install_dir / safe_name
|
|
669
|
+
|
|
670
|
+
# Create install directory if it doesn't exist
|
|
671
|
+
install_dir.mkdir(parents=True, exist_ok=True)
|
|
672
|
+
|
|
673
|
+
# Clone to temp directory
|
|
674
|
+
temp_dir = Path(tempfile.mkdtemp())
|
|
675
|
+
repo_path = temp_dir / safe_name
|
|
676
|
+
|
|
677
|
+
clone_cmd = ["git", "clone"]
|
|
678
|
+
if shallow:
|
|
679
|
+
clone_cmd.extend(["--depth", "1"])
|
|
680
|
+
if ref:
|
|
681
|
+
clone_cmd.extend(["--branch", ref])
|
|
682
|
+
clone_cmd.extend([repo_info["url"], str(repo_path)])
|
|
683
|
+
|
|
684
|
+
attempts = retries + 1
|
|
685
|
+
for attempt in range(attempts):
|
|
686
|
+
if attempts > 1:
|
|
687
|
+
print(f" Downloading... (attempt {attempt + 1}/{attempts})")
|
|
688
|
+
else:
|
|
689
|
+
print(" Downloading...")
|
|
690
|
+
|
|
691
|
+
try:
|
|
692
|
+
subprocess.run(clone_cmd, check=True, timeout=timeout)
|
|
693
|
+
break
|
|
694
|
+
except subprocess.TimeoutExpired:
|
|
695
|
+
print_error(f"Clone timed out after {timeout}s")
|
|
696
|
+
if attempt < attempts - 1:
|
|
697
|
+
time.sleep(2)
|
|
698
|
+
continue
|
|
699
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
700
|
+
return None
|
|
701
|
+
except subprocess.CalledProcessError as e:
|
|
702
|
+
if attempt < attempts - 1:
|
|
703
|
+
print_warning("Clone failed, retrying...")
|
|
704
|
+
time.sleep(2)
|
|
705
|
+
continue
|
|
706
|
+
print_error(f"Failed to clone repository: {repo_info['url']}")
|
|
707
|
+
if e.stderr:
|
|
708
|
+
print_error(e.stderr.strip())
|
|
709
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
710
|
+
return None
|
|
711
|
+
|
|
712
|
+
# Detect install method
|
|
713
|
+
user_config = _load_user_config()
|
|
714
|
+
method_priority = user_config.get("method_priority")
|
|
715
|
+
install_method = method_override or detect_install_method(repo_path, method_priority)
|
|
716
|
+
print(f" Detected install method: {install_method}")
|
|
717
|
+
|
|
718
|
+
# Install based on method
|
|
719
|
+
install_funcs = {
|
|
720
|
+
"python": install_python,
|
|
721
|
+
"node": install_node,
|
|
722
|
+
"go": install_go,
|
|
723
|
+
"rust": install_rust,
|
|
724
|
+
"binary": install_binary,
|
|
725
|
+
"make": install_make,
|
|
726
|
+
"script": install_script,
|
|
727
|
+
"download": install_binary,
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
install_func = install_funcs.get(install_method, install_binary)
|
|
731
|
+
installed_path = install_func(repo_path, install_dir)
|
|
732
|
+
|
|
733
|
+
# Clean up temp directory
|
|
734
|
+
shutil.rmtree(temp_dir)
|
|
735
|
+
|
|
736
|
+
# Register the installation
|
|
737
|
+
if installed_path:
|
|
738
|
+
register_app(repo_info["repo"], repo_url, installed_path, install_method)
|
|
739
|
+
# Post-install summary
|
|
740
|
+
print()
|
|
741
|
+
print(f" {Colors.CYAN}Summary:{Colors.END}")
|
|
742
|
+
print(f" Name: {repo_info['repo']}")
|
|
743
|
+
print(f" Method: {install_method}")
|
|
744
|
+
print(f" Location: {installed_path}")
|
|
745
|
+
print(f" Size: {_get_disk_size(installed_path)}")
|
|
746
|
+
return installed_path
|
|
747
|
+
|
|
748
|
+
return None
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def update_app(
|
|
752
|
+
repo_name,
|
|
753
|
+
install_dir=None,
|
|
754
|
+
dry_run=False,
|
|
755
|
+
force=False,
|
|
756
|
+
shallow=False,
|
|
757
|
+
ref=None,
|
|
758
|
+
timeout=None,
|
|
759
|
+
retries=0,
|
|
760
|
+
):
|
|
761
|
+
"""Update an installed application"""
|
|
762
|
+
registry = load_registry()
|
|
763
|
+
|
|
764
|
+
if repo_name not in registry["apps"]:
|
|
765
|
+
print_error(f"{repo_name} is not installed")
|
|
766
|
+
return False
|
|
767
|
+
|
|
768
|
+
app_info = registry["apps"][repo_name]
|
|
769
|
+
url = app_info["url"]
|
|
770
|
+
old_path = Path(app_info["path"])
|
|
771
|
+
|
|
772
|
+
print_header(f"Updating {repo_name}")
|
|
773
|
+
print(f" Current: {app_info['installed_at']}")
|
|
774
|
+
print(f" URL: {url}")
|
|
775
|
+
|
|
776
|
+
if dry_run:
|
|
777
|
+
print(f" [DRY RUN] Would re-install from: {url}")
|
|
778
|
+
print(f" [DRY RUN] Would update: {old_path}")
|
|
779
|
+
return True
|
|
780
|
+
|
|
781
|
+
# Remove old installation
|
|
782
|
+
if old_path.exists() and old_path.resolve() not in SHARED_PATHS:
|
|
783
|
+
if old_path.is_file():
|
|
784
|
+
old_path.unlink()
|
|
785
|
+
else:
|
|
786
|
+
shutil.rmtree(old_path, ignore_errors=True)
|
|
787
|
+
|
|
788
|
+
# Remove from registry before re-installing
|
|
789
|
+
del registry["apps"][repo_name]
|
|
790
|
+
save_registry(registry)
|
|
791
|
+
|
|
792
|
+
# Re-install
|
|
793
|
+
target_dir = old_path.parent if old_path.parent.exists() else install_dir
|
|
794
|
+
result = download_and_install(
|
|
795
|
+
url,
|
|
796
|
+
install_dir=target_dir,
|
|
797
|
+
shallow=shallow,
|
|
798
|
+
ref=ref,
|
|
799
|
+
timeout=timeout,
|
|
800
|
+
retries=retries,
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
if result:
|
|
804
|
+
print_success(f"Updated {repo_name}")
|
|
805
|
+
return True
|
|
806
|
+
else:
|
|
807
|
+
print_error(f"Failed to update {repo_name}")
|
|
808
|
+
# Restore old registry entry
|
|
809
|
+
registry["apps"][repo_name] = app_info
|
|
810
|
+
save_registry(registry)
|
|
811
|
+
return False
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def info_app(repo_name, json_output=False):
|
|
815
|
+
"""Show detailed info about an installed app"""
|
|
816
|
+
registry = load_registry()
|
|
817
|
+
|
|
818
|
+
if repo_name not in registry["apps"]:
|
|
819
|
+
if json_output:
|
|
820
|
+
print(json.dumps({"error": f"{repo_name} is not installed"}))
|
|
821
|
+
else:
|
|
822
|
+
print_error(f"{repo_name} is not installed")
|
|
823
|
+
return False
|
|
824
|
+
|
|
825
|
+
app_info = registry["apps"][repo_name]
|
|
826
|
+
install_path = Path(app_info["path"])
|
|
827
|
+
|
|
828
|
+
if json_output:
|
|
829
|
+
data = {
|
|
830
|
+
"name": repo_name,
|
|
831
|
+
"url": app_info["url"],
|
|
832
|
+
"method": app_info["method"],
|
|
833
|
+
"path": app_info["path"],
|
|
834
|
+
"installed_at": app_info["installed_at"],
|
|
835
|
+
"size": _get_disk_size(install_path),
|
|
836
|
+
"exists": install_path.exists(),
|
|
837
|
+
}
|
|
838
|
+
print(json.dumps(data, indent=2))
|
|
839
|
+
return True
|
|
840
|
+
|
|
841
|
+
print_header(f"App Info: {repo_name}")
|
|
842
|
+
labels = ["URL", "Method", "Path", "Installed", "Size", "Exists"]
|
|
843
|
+
values = [
|
|
844
|
+
app_info["url"],
|
|
845
|
+
app_info["method"],
|
|
846
|
+
app_info["path"],
|
|
847
|
+
app_info["installed_at"],
|
|
848
|
+
_get_disk_size(install_path),
|
|
849
|
+
"Yes" if install_path.exists() else "No (files may have been moved)",
|
|
850
|
+
]
|
|
851
|
+
max_label = max(len(label) for label in labels)
|
|
852
|
+
for label, value in zip(labels, values):
|
|
853
|
+
print(f" {Colors.CYAN}{label}:{Colors.END}{' ' * (max_label - len(label) + 1)}{value}")
|
|
854
|
+
|
|
855
|
+
return True
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def doctor(json_output=False):
|
|
859
|
+
"""Check if all required and optional tools are available"""
|
|
860
|
+
tools = {
|
|
861
|
+
"git": ("Required", "Cloning repositories"),
|
|
862
|
+
"python3": ("Required", "Running this tool"),
|
|
863
|
+
"npm": ("Optional", "Node.js project installs"),
|
|
864
|
+
"go": ("Optional", "Go project installs"),
|
|
865
|
+
"cargo": ("Optional", "Rust project installs"),
|
|
866
|
+
"make": ("Optional", "Makefile-based installs"),
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
all_ok = True
|
|
870
|
+
results = []
|
|
871
|
+
for tool, (req, purpose) in tools.items():
|
|
872
|
+
# Check the canonical name, but fall back for python3 → python
|
|
873
|
+
exe = tool
|
|
874
|
+
found = bool(shutil.which(exe))
|
|
875
|
+
if not found and exe == "python3":
|
|
876
|
+
found = bool(shutil.which("python"))
|
|
877
|
+
results.append({"tool": tool, "required": req, "found": found, "purpose": purpose})
|
|
878
|
+
results.append({"tool": tool, "required": req, "found": found, "purpose": purpose})
|
|
879
|
+
if not found and req == "Required":
|
|
880
|
+
all_ok = False
|
|
881
|
+
|
|
882
|
+
if json_output:
|
|
883
|
+
print(json.dumps({"tools": results, "all_ok": all_ok}, indent=2))
|
|
884
|
+
return all_ok
|
|
885
|
+
|
|
886
|
+
print_header("Doctor — Tool Availability Check")
|
|
887
|
+
|
|
888
|
+
for r in results:
|
|
889
|
+
status = f"{Colors.GREEN}✓{Colors.END}" if r["found"] else f"{Colors.RED}✗{Colors.END}"
|
|
890
|
+
label = f"{Colors.YELLOW}[{r['required']}]{Colors.END}"
|
|
891
|
+
print(f" {status} {r['tool']:<10} {label:<12} {r['purpose']}")
|
|
892
|
+
|
|
893
|
+
print()
|
|
894
|
+
if all_ok:
|
|
895
|
+
print_success("All required tools are available")
|
|
896
|
+
else:
|
|
897
|
+
print_error("Some required tools are missing")
|
|
898
|
+
|
|
899
|
+
return all_ok
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def config_command(key=None, value=None):
|
|
903
|
+
"""View or set configuration values"""
|
|
904
|
+
config = _load_user_config()
|
|
905
|
+
|
|
906
|
+
if key is None:
|
|
907
|
+
# Show all config
|
|
908
|
+
print_header("Configuration")
|
|
909
|
+
if not config:
|
|
910
|
+
print_warning("No configuration set")
|
|
911
|
+
print(f"\n Config file: {CONFIG_FILE}")
|
|
912
|
+
print(f" Install dir (default): {DEFAULT_INSTALL_DIR}")
|
|
913
|
+
else:
|
|
914
|
+
for k, v in config.items():
|
|
915
|
+
print(f" {k}: {v}")
|
|
916
|
+
print(f"\n Config file: {CONFIG_FILE}")
|
|
917
|
+
return
|
|
918
|
+
|
|
919
|
+
if value is None:
|
|
920
|
+
# Show specific key
|
|
921
|
+
if key in config:
|
|
922
|
+
print(f" {key}: {config[key]}")
|
|
923
|
+
else:
|
|
924
|
+
print_warning(f"Config key '{key}' is not set")
|
|
925
|
+
return
|
|
926
|
+
|
|
927
|
+
# Set value
|
|
928
|
+
config[key] = value
|
|
929
|
+
_save_user_config(config)
|
|
930
|
+
print_success(f"Set {key} = {value}")
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _search_print_result(index, name, desc, stars, lang, url, star_char="★"):
|
|
934
|
+
"""Print a formatted search result."""
|
|
935
|
+
print(f" {index}. {Colors.GREEN}{name}{Colors.END}")
|
|
936
|
+
print(f" {desc}")
|
|
937
|
+
print(f" {Colors.CYAN}{star_char}{Colors.END} {stars:,} | Language: {lang}")
|
|
938
|
+
print(f" URL: {url}")
|
|
939
|
+
print()
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def search_github(query, limit=10):
|
|
943
|
+
"""Search repositories using the GitHub API."""
|
|
944
|
+
print(f" Searching GitHub for '{query}'...")
|
|
945
|
+
url = f"https://api.github.com/search/repositories?q={urllib.parse.quote(query)}&sort=stars&order=desc&per_page={limit}"
|
|
946
|
+
try:
|
|
947
|
+
req = urllib.request.Request(url, headers={"Accept": "application/vnd.github.v3+json"})
|
|
948
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
949
|
+
data = json.loads(resp.read().decode())
|
|
950
|
+
except Exception as e:
|
|
951
|
+
print_error(f"Search failed: {e}")
|
|
952
|
+
return
|
|
953
|
+
|
|
954
|
+
items = data.get("items", [])
|
|
955
|
+
if not items:
|
|
956
|
+
print_warning("No results found")
|
|
957
|
+
return
|
|
958
|
+
|
|
959
|
+
print_header(f"GitHub Results — '{query}' ({len(items)} found)")
|
|
960
|
+
for i, repo in enumerate(items, 1):
|
|
961
|
+
_search_print_result(
|
|
962
|
+
i,
|
|
963
|
+
repo["full_name"],
|
|
964
|
+
repo.get("description") or "No description",
|
|
965
|
+
repo["stargazers_count"],
|
|
966
|
+
repo.get("language") or "Unknown",
|
|
967
|
+
repo["html_url"],
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def search_gitlab(query, limit=10):
|
|
972
|
+
"""Search repositories using the GitLab API."""
|
|
973
|
+
print(f" Searching GitLab for '{query}'...")
|
|
974
|
+
url = f"https://gitlab.com/api/v4/projects?search={urllib.parse.quote(query)}&per_page={limit}&order_by=stars&sort=desc"
|
|
975
|
+
# Note: GitLab search is unauthenticated but rate-limited (600 req/h per IP)
|
|
976
|
+
try:
|
|
977
|
+
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
978
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
979
|
+
data = json.loads(resp.read().decode())
|
|
980
|
+
except Exception as e:
|
|
981
|
+
print_error(f"Search failed: {e}")
|
|
982
|
+
return
|
|
983
|
+
|
|
984
|
+
if not data:
|
|
985
|
+
print_warning("No results found")
|
|
986
|
+
return
|
|
987
|
+
|
|
988
|
+
results = []
|
|
989
|
+
for project in data:
|
|
990
|
+
# GitLab returns projects ordered by last_activity by default;
|
|
991
|
+
# sort with our own star sort since we requested order_by=stars
|
|
992
|
+
results.append({
|
|
993
|
+
"name": project.get("path_with_namespace", project["path"]),
|
|
994
|
+
"description": project.get("description") or "No description",
|
|
995
|
+
"stars": project.get("star_count", 0),
|
|
996
|
+
"language": project.get("programming_language") or project.get("language") or "Unknown",
|
|
997
|
+
"url": project.get("web_url", project.get("http_url_to_repo", "")),
|
|
998
|
+
})
|
|
999
|
+
|
|
1000
|
+
results.sort(key=lambda r: r["stars"], reverse=True)
|
|
1001
|
+
|
|
1002
|
+
print_header(f"GitLab Results — '{query}' ({len(results)} found)")
|
|
1003
|
+
for i, r in enumerate(results[:limit], 1):
|
|
1004
|
+
_search_print_result(i, r["name"], r["description"], r["stars"], r["language"], r["url"], star_char="\u2605")
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def search_codeberg(query, limit=10):
|
|
1008
|
+
"""Search repositories using the Codeberg (Gitea/Forgejo) API."""
|
|
1009
|
+
print(f" Searching Codeberg for '{query}'...")
|
|
1010
|
+
url = f"https://codeberg.org/api/v1/repos/search?q={urllib.parse.quote(query)}&limit={limit}&sort=stars"
|
|
1011
|
+
try:
|
|
1012
|
+
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
1013
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
1014
|
+
data = json.loads(resp.read().decode())
|
|
1015
|
+
except Exception as e:
|
|
1016
|
+
print_error(f"Search failed: {e}")
|
|
1017
|
+
return
|
|
1018
|
+
|
|
1019
|
+
items = data.get("data", []) if isinstance(data, dict) else data
|
|
1020
|
+
if not items or (isinstance(data, dict) and data.get("ok") is False):
|
|
1021
|
+
print_warning("No results found")
|
|
1022
|
+
return
|
|
1023
|
+
|
|
1024
|
+
ok_flag = data.get("ok", True) if isinstance(data, dict) else True
|
|
1025
|
+
if not ok_flag or not items:
|
|
1026
|
+
print_warning("No results found")
|
|
1027
|
+
return
|
|
1028
|
+
|
|
1029
|
+
print_header(f"Codeberg Results — '{query}' ({len(items)} found)")
|
|
1030
|
+
for i, repo in enumerate(items, 1):
|
|
1031
|
+
_search_print_result(
|
|
1032
|
+
i,
|
|
1033
|
+
repo.get("full_name", "unknown"),
|
|
1034
|
+
repo.get("description") or "No description",
|
|
1035
|
+
repo.get("stars_count", 0),
|
|
1036
|
+
repo.get("language") or "Unknown",
|
|
1037
|
+
repo.get("html_url", ""),
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
url = f"https://api.github.com/search/repositories?q={urllib.parse.quote(query)}&sort=stars&order=desc&per_page={limit}"
|
|
1041
|
+
try:
|
|
1042
|
+
req = urllib.request.Request(url, headers={"Accept": "application/vnd.github.v3+json"})
|
|
1043
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
1044
|
+
data = json.loads(resp.read().decode())
|
|
1045
|
+
except Exception as e:
|
|
1046
|
+
print_error(f"Search failed: {e}")
|
|
1047
|
+
return
|
|
1048
|
+
|
|
1049
|
+
items = data.get("items", [])
|
|
1050
|
+
if not items:
|
|
1051
|
+
print_warning("No results found")
|
|
1052
|
+
return
|
|
1053
|
+
|
|
1054
|
+
print_header(f"Search Results for '{query}' ({len(items)} found)")
|
|
1055
|
+
for i, repo in enumerate(items, 1):
|
|
1056
|
+
name = repo["full_name"]
|
|
1057
|
+
stars = repo["stargazers_count"]
|
|
1058
|
+
desc = repo.get("description") or "No description"
|
|
1059
|
+
lang = repo.get("language") or "Unknown"
|
|
1060
|
+
print(f" {i}. {Colors.GREEN}{name}{Colors.END}")
|
|
1061
|
+
print(f" {desc}")
|
|
1062
|
+
print(f" {Colors.CYAN}★{Colors.END} {stars:,} | Language: {lang}")
|
|
1063
|
+
print(f" URL: {repo['html_url']}")
|
|
1064
|
+
print()
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def export_registry(filepath):
|
|
1068
|
+
"""Export the app registry to a file"""
|
|
1069
|
+
registry = load_registry()
|
|
1070
|
+
path = Path(filepath).expanduser()
|
|
1071
|
+
with open(path, "w") as f:
|
|
1072
|
+
json.dump(registry, f, indent=2)
|
|
1073
|
+
print_success(f"Exported {len(registry['apps'])} apps to {path}")
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def import_registry(filepath):
|
|
1077
|
+
"""Import the app registry from a file"""
|
|
1078
|
+
path = Path(filepath).expanduser()
|
|
1079
|
+
if not path.exists():
|
|
1080
|
+
print_error(f"File not found: {path}")
|
|
1081
|
+
return False
|
|
1082
|
+
|
|
1083
|
+
with open(path) as f:
|
|
1084
|
+
data = json.load(f)
|
|
1085
|
+
|
|
1086
|
+
if "apps" not in data:
|
|
1087
|
+
print_error("Invalid registry file format")
|
|
1088
|
+
return False
|
|
1089
|
+
|
|
1090
|
+
registry = load_registry()
|
|
1091
|
+
imported = 0
|
|
1092
|
+
for name, info in data["apps"].items():
|
|
1093
|
+
if name not in registry["apps"]:
|
|
1094
|
+
registry["apps"][name] = info
|
|
1095
|
+
imported += 1
|
|
1096
|
+
else:
|
|
1097
|
+
print_warning(f"Skipping {name} (already installed)")
|
|
1098
|
+
|
|
1099
|
+
save_registry(registry)
|
|
1100
|
+
print_success(f"Imported {imported} new apps")
|
|
1101
|
+
return True
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def register_app(repo_name, repo_url, install_path, install_method, skip_hook=False):
|
|
1105
|
+
"""Register an installed application"""
|
|
1106
|
+
|
|
1107
|
+
registry = load_registry()
|
|
1108
|
+
|
|
1109
|
+
registry["apps"][repo_name] = {
|
|
1110
|
+
"url": repo_url,
|
|
1111
|
+
"path": str(install_path),
|
|
1112
|
+
"method": install_method,
|
|
1113
|
+
"installed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
save_registry(registry)
|
|
1117
|
+
print_success(f"Registered {repo_name}")
|
|
1118
|
+
|
|
1119
|
+
if not skip_hook:
|
|
1120
|
+
_run_post_install_hook(repo_name, str(install_path), install_method)
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def _run_post_install_hook(repo_name, install_path, method):
|
|
1124
|
+
"""Run user-defined post-install hook if configured."""
|
|
1125
|
+
hook_dir = CONFIG_FILE.parent / "pluck" / "hooks"
|
|
1126
|
+
hook_file = hook_dir / "post-install.sh"
|
|
1127
|
+
|
|
1128
|
+
if hook_file.exists():
|
|
1129
|
+
env = os.environ.copy()
|
|
1130
|
+
env["PLUCK_APP"] = repo_name
|
|
1131
|
+
env["PLUCK_PATH"] = install_path
|
|
1132
|
+
env["PLUCK_METHOD"] = method
|
|
1133
|
+
# Legacy aliases — remove in a future release
|
|
1134
|
+
env["GH_INSTALL_APP"] = repo_name
|
|
1135
|
+
env["GH_INSTALL_PATH"] = install_path
|
|
1136
|
+
env["GH_INSTALL_METHOD"] = method
|
|
1137
|
+
|
|
1138
|
+
try:
|
|
1139
|
+
subprocess.run(["bash", str(hook_file)], env=env, check=True)
|
|
1140
|
+
except subprocess.CalledProcessError as e:
|
|
1141
|
+
print_warning(f"Post-install hook failed with exit code {e.returncode}")
|
|
1142
|
+
except FileNotFoundError:
|
|
1143
|
+
print_warning("Post-install hook requires bash")
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def clean_registry(dry_run=False, force=False, json_output=False):
|
|
1147
|
+
"""Remove orphaned registry entries (apps whose paths no longer exist)"""
|
|
1148
|
+
registry = load_registry()
|
|
1149
|
+
orphaned = []
|
|
1150
|
+
|
|
1151
|
+
for name, info in registry["apps"].items():
|
|
1152
|
+
install_path = Path(info["path"])
|
|
1153
|
+
if not install_path.exists():
|
|
1154
|
+
orphaned.append({"name": name, "path": info["path"]})
|
|
1155
|
+
|
|
1156
|
+
if not orphaned:
|
|
1157
|
+
if json_output:
|
|
1158
|
+
print(json.dumps({"orphaned": []}))
|
|
1159
|
+
else:
|
|
1160
|
+
print_success("No orphaned entries found")
|
|
1161
|
+
return 0
|
|
1162
|
+
|
|
1163
|
+
if json_output:
|
|
1164
|
+
data = {"orphaned": orphaned, "count": len(orphaned)}
|
|
1165
|
+
if dry_run:
|
|
1166
|
+
data["dry_run"] = True
|
|
1167
|
+
print(json.dumps(data, indent=2))
|
|
1168
|
+
return len(orphaned)
|
|
1169
|
+
|
|
1170
|
+
print_header(f"Found {len(orphaned)} orphaned entries")
|
|
1171
|
+
for entry in orphaned:
|
|
1172
|
+
print(f" {Colors.RED}{entry['name']}{Colors.END} — {entry['path']} (missing)")
|
|
1173
|
+
|
|
1174
|
+
if dry_run:
|
|
1175
|
+
print(f"\n {Colors.YELLOW}[DRY RUN] Would remove {len(orphaned)} entries{Colors.END}")
|
|
1176
|
+
return len(orphaned)
|
|
1177
|
+
|
|
1178
|
+
if not force:
|
|
1179
|
+
confirm = input(f"\nRemove {len(orphaned)} orphaned entries? [y/N]: ")
|
|
1180
|
+
if confirm.lower() != "y":
|
|
1181
|
+
print("Cancelled")
|
|
1182
|
+
return 0
|
|
1183
|
+
|
|
1184
|
+
for entry in orphaned:
|
|
1185
|
+
del registry["apps"][entry["name"]]
|
|
1186
|
+
|
|
1187
|
+
save_registry(registry)
|
|
1188
|
+
print_success(f"Removed {len(orphaned)} orphaned entries")
|
|
1189
|
+
return len(orphaned)
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def load_registry():
|
|
1193
|
+
"""Load the app registry"""
|
|
1194
|
+
if APP_REGISTRY_FILE.exists():
|
|
1195
|
+
with open(APP_REGISTRY_FILE) as f:
|
|
1196
|
+
return json.load(f)
|
|
1197
|
+
|
|
1198
|
+
return {"apps": {}}
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def save_registry(registry):
|
|
1202
|
+
"""Save the app registry"""
|
|
1203
|
+
with open(APP_REGISTRY_FILE, "w") as f:
|
|
1204
|
+
json.dump(registry, f, indent=2)
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def list_installed(json_output=False):
|
|
1208
|
+
"""List all installed applications"""
|
|
1209
|
+
registry = load_registry()
|
|
1210
|
+
|
|
1211
|
+
if not registry["apps"]:
|
|
1212
|
+
if json_output:
|
|
1213
|
+
print(json.dumps({"apps": []}))
|
|
1214
|
+
else:
|
|
1215
|
+
print_warning("No applications installed yet")
|
|
1216
|
+
return
|
|
1217
|
+
|
|
1218
|
+
if json_output:
|
|
1219
|
+
apps = []
|
|
1220
|
+
for name, info in registry["apps"].items():
|
|
1221
|
+
install_path = Path(info["path"])
|
|
1222
|
+
apps.append(
|
|
1223
|
+
{
|
|
1224
|
+
"name": name,
|
|
1225
|
+
"url": info["url"],
|
|
1226
|
+
"method": info["method"],
|
|
1227
|
+
"path": info["path"],
|
|
1228
|
+
"size": _get_disk_size(install_path),
|
|
1229
|
+
"exists": install_path.exists(),
|
|
1230
|
+
"installed_at": info["installed_at"],
|
|
1231
|
+
}
|
|
1232
|
+
)
|
|
1233
|
+
print(json.dumps({"apps": apps}, indent=2))
|
|
1234
|
+
return
|
|
1235
|
+
|
|
1236
|
+
print_header(f"Installed Applications ({len(registry['apps'])})")
|
|
1237
|
+
|
|
1238
|
+
for name, info in registry["apps"].items():
|
|
1239
|
+
install_path = Path(info["path"])
|
|
1240
|
+
size = _get_disk_size(install_path)
|
|
1241
|
+
exists = "✓" if install_path.exists() else "✗"
|
|
1242
|
+
print(f"\n{Colors.GREEN}{name}{Colors.END} [{exists}]")
|
|
1243
|
+
print(f" URL: {info['url']}")
|
|
1244
|
+
print(f" Method: {info['method']}")
|
|
1245
|
+
print(f" Path: {info['path']}")
|
|
1246
|
+
print(f" Size: {size}")
|
|
1247
|
+
print(f" Installed: {info['installed_at']}")
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def uninstall_app(repo_name, force=False):
|
|
1251
|
+
"""Uninstall an application"""
|
|
1252
|
+
registry = load_registry()
|
|
1253
|
+
|
|
1254
|
+
if repo_name not in registry["apps"]:
|
|
1255
|
+
print_error(f"{repo_name} is not installed")
|
|
1256
|
+
return False
|
|
1257
|
+
|
|
1258
|
+
app_info = registry["apps"][repo_name]
|
|
1259
|
+
|
|
1260
|
+
# Ask for confirmation
|
|
1261
|
+
if not force:
|
|
1262
|
+
confirm = input(f"Uninstall {repo_name}? [y/N]: ")
|
|
1263
|
+
if confirm.lower() != "y":
|
|
1264
|
+
print("Cancelled")
|
|
1265
|
+
return False
|
|
1266
|
+
|
|
1267
|
+
# Remove installed files — but never delete shared system directories
|
|
1268
|
+
install_path = Path(app_info["path"])
|
|
1269
|
+
if install_path.resolve() in SHARED_PATHS or install_path.resolve() == Path.home():
|
|
1270
|
+
print_error(f"Refusing to uninstall: {install_path} is a shared directory")
|
|
1271
|
+
print_warning("Remove files from this directory manually instead")
|
|
1272
|
+
return False
|
|
1273
|
+
|
|
1274
|
+
if install_path.exists():
|
|
1275
|
+
if install_path.is_file():
|
|
1276
|
+
install_path.unlink()
|
|
1277
|
+
else:
|
|
1278
|
+
shutil.rmtree(install_path, ignore_errors=True)
|
|
1279
|
+
|
|
1280
|
+
# Remove from registry
|
|
1281
|
+
del registry["apps"][repo_name]
|
|
1282
|
+
save_registry(registry)
|
|
1283
|
+
|
|
1284
|
+
print_success(f"Uninstalled {repo_name}")
|
|
1285
|
+
return True
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def _parse_args(args):
|
|
1289
|
+
"""Parse all CLI flags from a list of arguments."""
|
|
1290
|
+
install_dir = None
|
|
1291
|
+
dry_run = False
|
|
1292
|
+
force = False
|
|
1293
|
+
yes = False
|
|
1294
|
+
shallow = False
|
|
1295
|
+
ref = None
|
|
1296
|
+
method = None
|
|
1297
|
+
json_output = False
|
|
1298
|
+
no_color = False
|
|
1299
|
+
timeout = None
|
|
1300
|
+
retries = 0
|
|
1301
|
+
urls = []
|
|
1302
|
+
|
|
1303
|
+
i = 0
|
|
1304
|
+
while i < len(args):
|
|
1305
|
+
if args[i] == "--dir" and i + 1 < len(args):
|
|
1306
|
+
install_dir = Path(args[i + 1]).expanduser()
|
|
1307
|
+
i += 2
|
|
1308
|
+
elif args[i] == "--dry-run":
|
|
1309
|
+
dry_run = True
|
|
1310
|
+
i += 1
|
|
1311
|
+
elif args[i] == "--force":
|
|
1312
|
+
force = True
|
|
1313
|
+
i += 1
|
|
1314
|
+
elif args[i] == "--yes":
|
|
1315
|
+
yes = True
|
|
1316
|
+
i += 1
|
|
1317
|
+
elif args[i] == "--shallow":
|
|
1318
|
+
shallow = True
|
|
1319
|
+
i += 1
|
|
1320
|
+
elif args[i] == "--ref" and i + 1 < len(args):
|
|
1321
|
+
ref = args[i + 1]
|
|
1322
|
+
i += 2
|
|
1323
|
+
elif args[i] == "--method" and i + 1 < len(args):
|
|
1324
|
+
method = args[i + 1]
|
|
1325
|
+
i += 2
|
|
1326
|
+
elif args[i] == "--json":
|
|
1327
|
+
json_output = True
|
|
1328
|
+
i += 1
|
|
1329
|
+
elif args[i] == "--no-color":
|
|
1330
|
+
no_color = True
|
|
1331
|
+
i += 1
|
|
1332
|
+
elif args[i] == "--timeout" and i + 1 < len(args):
|
|
1333
|
+
try:
|
|
1334
|
+
timeout = int(args[i + 1])
|
|
1335
|
+
except ValueError:
|
|
1336
|
+
pass
|
|
1337
|
+
i += 2
|
|
1338
|
+
elif args[i] == "--retries" and i + 1 < len(args):
|
|
1339
|
+
try:
|
|
1340
|
+
retries = int(args[i + 1])
|
|
1341
|
+
except ValueError:
|
|
1342
|
+
pass
|
|
1343
|
+
i += 2
|
|
1344
|
+
else:
|
|
1345
|
+
urls.append(args[i])
|
|
1346
|
+
i += 1
|
|
1347
|
+
|
|
1348
|
+
if yes:
|
|
1349
|
+
force = True
|
|
1350
|
+
if no_color:
|
|
1351
|
+
_enable_colors(False)
|
|
1352
|
+
|
|
1353
|
+
return install_dir, dry_run, force, shallow, ref, method, json_output, timeout, retries, urls
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
def verify_apps(json_output=False):
|
|
1357
|
+
"""Check if installed apps are still valid (files exist, not corrupted)."""
|
|
1358
|
+
registry = load_registry()
|
|
1359
|
+
results = []
|
|
1360
|
+
|
|
1361
|
+
for name, info in registry["apps"].items():
|
|
1362
|
+
install_path = Path(info["path"])
|
|
1363
|
+
exists = install_path.exists()
|
|
1364
|
+
size = _get_disk_size(install_path) if exists else "N/A"
|
|
1365
|
+
results.append({
|
|
1366
|
+
"name": name,
|
|
1367
|
+
"url": info["url"],
|
|
1368
|
+
"path": info["path"],
|
|
1369
|
+
"exists": exists,
|
|
1370
|
+
"size": size,
|
|
1371
|
+
"installed_at": info["installed_at"],
|
|
1372
|
+
})
|
|
1373
|
+
|
|
1374
|
+
valid_count = sum(1 for r in results if r["exists"])
|
|
1375
|
+
invalid_count = len(results) - valid_count
|
|
1376
|
+
|
|
1377
|
+
if json_output:
|
|
1378
|
+
print(json.dumps({
|
|
1379
|
+
"total": len(results),
|
|
1380
|
+
"valid": valid_count,
|
|
1381
|
+
"invalid": invalid_count,
|
|
1382
|
+
"apps": results,
|
|
1383
|
+
}, indent=2))
|
|
1384
|
+
return valid_count == len(results)
|
|
1385
|
+
|
|
1386
|
+
print_header(f"Verification ({len(results)} apps)")
|
|
1387
|
+
for r in results:
|
|
1388
|
+
status = f"{Colors.GREEN}✓{Colors.END}" if r["exists"] else f"{Colors.RED}✗{Colors.END}"
|
|
1389
|
+
print(f" {status} {Colors.CYAN}{r['name']}{Colors.END} — {r['path']} ({r['size']})")
|
|
1390
|
+
|
|
1391
|
+
print()
|
|
1392
|
+
if invalid_count == 0:
|
|
1393
|
+
print_success(f"All {valid_count} apps are valid")
|
|
1394
|
+
else:
|
|
1395
|
+
print_warning(f"{valid_count} valid, {invalid_count} missing")
|
|
1396
|
+
|
|
1397
|
+
return valid_count == len(results)
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def stats_command(json_output=False):
|
|
1401
|
+
"""Show installation statistics."""
|
|
1402
|
+
registry = load_registry()
|
|
1403
|
+
apps = registry["apps"]
|
|
1404
|
+
|
|
1405
|
+
total = len(apps)
|
|
1406
|
+
valid = 0
|
|
1407
|
+
orphaned = 0
|
|
1408
|
+
total_size = 0
|
|
1409
|
+
method_counts = {}
|
|
1410
|
+
|
|
1411
|
+
for name, info in apps.items():
|
|
1412
|
+
install_path = Path(info["path"])
|
|
1413
|
+
method = info.get("method", "unknown")
|
|
1414
|
+
method_counts[method] = method_counts.get(method, 0) + 1
|
|
1415
|
+
|
|
1416
|
+
if install_path.exists():
|
|
1417
|
+
valid += 1
|
|
1418
|
+
try:
|
|
1419
|
+
if install_path.is_file():
|
|
1420
|
+
total_size += install_path.stat().st_size
|
|
1421
|
+
elif install_path.is_dir():
|
|
1422
|
+
for dirpath, _, filenames in os.walk(install_path):
|
|
1423
|
+
for f in filenames:
|
|
1424
|
+
fp = os.path.join(dirpath, f)
|
|
1425
|
+
if not os.path.islink(fp):
|
|
1426
|
+
total_size += os.path.getsize(fp)
|
|
1427
|
+
except OSError:
|
|
1428
|
+
pass
|
|
1429
|
+
else:
|
|
1430
|
+
orphaned += 1
|
|
1431
|
+
|
|
1432
|
+
if json_output:
|
|
1433
|
+
print(json.dumps({
|
|
1434
|
+
"total_apps": total,
|
|
1435
|
+
"valid": valid,
|
|
1436
|
+
"orphaned": orphaned,
|
|
1437
|
+
"total_size_bytes": total_size,
|
|
1438
|
+
"total_size_human": _format_bytes(total_size),
|
|
1439
|
+
"by_method": method_counts,
|
|
1440
|
+
}, indent=2))
|
|
1441
|
+
return
|
|
1442
|
+
|
|
1443
|
+
print_header("Installation Statistics")
|
|
1444
|
+
print(f" Total apps: {total}")
|
|
1445
|
+
print(f" Valid: {valid}")
|
|
1446
|
+
print(f" Orphaned: {orphaned}")
|
|
1447
|
+
print(f" Total size: {_format_bytes(total_size)}")
|
|
1448
|
+
print()
|
|
1449
|
+
print(f" {Colors.CYAN}By Method:{Colors.END}")
|
|
1450
|
+
for method, count in sorted(method_counts.items(), key=lambda x: -x[1]):
|
|
1451
|
+
print(f" {method:<10} {count}")
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _format_bytes(size):
|
|
1455
|
+
"""Format byte count into human-readable string."""
|
|
1456
|
+
if size >= 1024 * 1024 * 1024:
|
|
1457
|
+
return f"{size / (1024 * 1024 * 1024):.1f} GB"
|
|
1458
|
+
elif size >= 1024 * 1024:
|
|
1459
|
+
return f"{size / (1024 * 1024):.1f} MB"
|
|
1460
|
+
elif size >= 1024:
|
|
1461
|
+
return f"{size / 1024:.1f} KB"
|
|
1462
|
+
return f"{size} B"
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def _extract_global_flags(args):
|
|
1466
|
+
"""Extract global flags (--json, --no-color) from an arg list, returning (cleaned_args, json_output, no_color)."""
|
|
1467
|
+
json_output = False
|
|
1468
|
+
no_color = False
|
|
1469
|
+
cleaned = []
|
|
1470
|
+
i = 0
|
|
1471
|
+
while i < len(args):
|
|
1472
|
+
if args[i] == "--json":
|
|
1473
|
+
json_output = True
|
|
1474
|
+
elif args[i] == "--no-color":
|
|
1475
|
+
no_color = True
|
|
1476
|
+
else:
|
|
1477
|
+
cleaned.append(args[i])
|
|
1478
|
+
i += 1
|
|
1479
|
+
if no_color:
|
|
1480
|
+
_enable_colors(False)
|
|
1481
|
+
return cleaned, json_output
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def _migrate_old_registry():
|
|
1485
|
+
"""Migrate from old .gh-install-registry.json to .pluck-registry.json."""
|
|
1486
|
+
if _CONFIG_OLD_REGISTRY.exists() and not APP_REGISTRY_FILE.exists():
|
|
1487
|
+
try:
|
|
1488
|
+
data = _CONFIG_OLD_REGISTRY.read_text()
|
|
1489
|
+
APP_REGISTRY_FILE.write_text(data)
|
|
1490
|
+
_CONFIG_OLD_REGISTRY.unlink()
|
|
1491
|
+
print_warning("Migrated registry from .gh-install-registry.json to .pluck-registry.json")
|
|
1492
|
+
except OSError:
|
|
1493
|
+
pass
|
|
1494
|
+
if _CONFIG_OLD_DIR.exists() and not CONFIG_FILE.exists():
|
|
1495
|
+
try:
|
|
1496
|
+
config_data = _CONFIG_OLD_DIR / "config.json"
|
|
1497
|
+
if config_data.exists():
|
|
1498
|
+
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
1499
|
+
config_data.rename(CONFIG_FILE)
|
|
1500
|
+
_CONFIG_OLD_DIR.rmdir()
|
|
1501
|
+
print_warning("Migrated config from ~/.config/gh-install/ to ~/.config/pluck/")
|
|
1502
|
+
except OSError:
|
|
1503
|
+
pass
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
def main():
|
|
1507
|
+
"""Main entry point"""
|
|
1508
|
+
# Auto-migrate from old gh-install paths
|
|
1509
|
+
_migrate_old_registry()
|
|
1510
|
+
|
|
1511
|
+
# Initialize flags shared across command branches
|
|
1512
|
+
json_output = False
|
|
1513
|
+
force = False
|
|
1514
|
+
dry_run = False
|
|
1515
|
+
|
|
1516
|
+
if len(sys.argv) < 2:
|
|
1517
|
+
print_usage()
|
|
1518
|
+
sys.exit(0)
|
|
1519
|
+
|
|
1520
|
+
# Handle global --version flag before command dispatch
|
|
1521
|
+
if sys.argv[1] in ("--version", "-v"):
|
|
1522
|
+
print(f"pluck v{__version__}")
|
|
1523
|
+
sys.exit(0)
|
|
1524
|
+
|
|
1525
|
+
command = sys.argv[1]
|
|
1526
|
+
|
|
1527
|
+
if command == "install":
|
|
1528
|
+
install_dir, dry_run, force, shallow, ref, method, json_output, timeout, retries, urls = (
|
|
1529
|
+
_parse_args(sys.argv[2:])
|
|
1530
|
+
)
|
|
1531
|
+
|
|
1532
|
+
if not urls:
|
|
1533
|
+
print_error("Please provide a repository URL")
|
|
1534
|
+
sys.exit(1)
|
|
1535
|
+
|
|
1536
|
+
if method and method not in VALID_METHODS:
|
|
1537
|
+
print_error(f"Invalid method: {method}. Valid: {', '.join(sorted(VALID_METHODS))}")
|
|
1538
|
+
sys.exit(1)
|
|
1539
|
+
|
|
1540
|
+
if dry_run:
|
|
1541
|
+
print_header("Dry Run — No changes will be made")
|
|
1542
|
+
|
|
1543
|
+
for url in urls:
|
|
1544
|
+
print(f"\nInstalling: {url}")
|
|
1545
|
+
download_and_install(
|
|
1546
|
+
url,
|
|
1547
|
+
install_dir=install_dir,
|
|
1548
|
+
dry_run=dry_run,
|
|
1549
|
+
shallow=shallow,
|
|
1550
|
+
ref=ref,
|
|
1551
|
+
method_override=method,
|
|
1552
|
+
timeout=timeout,
|
|
1553
|
+
retries=retries,
|
|
1554
|
+
)
|
|
1555
|
+
|
|
1556
|
+
elif command == "update":
|
|
1557
|
+
install_dir, dry_run, force, shallow, ref, method, json_output, timeout, retries, rest = (
|
|
1558
|
+
_parse_args(sys.argv[2:])
|
|
1559
|
+
)
|
|
1560
|
+
if not rest:
|
|
1561
|
+
print_error("Please provide an app name")
|
|
1562
|
+
sys.exit(1)
|
|
1563
|
+
|
|
1564
|
+
if dry_run:
|
|
1565
|
+
print_header("Dry Run — No changes will be made")
|
|
1566
|
+
|
|
1567
|
+
for name in rest:
|
|
1568
|
+
update_app(
|
|
1569
|
+
name,
|
|
1570
|
+
install_dir=install_dir,
|
|
1571
|
+
dry_run=dry_run,
|
|
1572
|
+
force=force,
|
|
1573
|
+
shallow=shallow,
|
|
1574
|
+
ref=ref,
|
|
1575
|
+
timeout=timeout,
|
|
1576
|
+
retries=retries,
|
|
1577
|
+
)
|
|
1578
|
+
|
|
1579
|
+
elif command == "info":
|
|
1580
|
+
rest, json_output = _extract_global_flags(sys.argv[2:])
|
|
1581
|
+
if not rest:
|
|
1582
|
+
print_error("Please provide an app name")
|
|
1583
|
+
sys.exit(1)
|
|
1584
|
+
info_app(rest[0], json_output=json_output)
|
|
1585
|
+
|
|
1586
|
+
elif command == "list":
|
|
1587
|
+
_, json_output = _extract_global_flags(sys.argv[2:])
|
|
1588
|
+
list_installed(json_output=json_output)
|
|
1589
|
+
|
|
1590
|
+
elif command in ("uninstall", "remove"):
|
|
1591
|
+
install_dir, dry_run, force, shallow, ref, method, json_output, timeout, retries, rest = (
|
|
1592
|
+
_parse_args(sys.argv[2:])
|
|
1593
|
+
)
|
|
1594
|
+
if not rest:
|
|
1595
|
+
print_error("Please provide an app name")
|
|
1596
|
+
sys.exit(1)
|
|
1597
|
+
|
|
1598
|
+
for name in rest:
|
|
1599
|
+
uninstall_app(name, force=force)
|
|
1600
|
+
|
|
1601
|
+
elif command == "verify":
|
|
1602
|
+
_, json_output = _extract_global_flags(sys.argv[2:])
|
|
1603
|
+
verify_apps(json_output=json_output)
|
|
1604
|
+
|
|
1605
|
+
elif command == "clean":
|
|
1606
|
+
install_dir, dry_run, force, shallow, ref, method, json_output, timeout, retries, rest = (
|
|
1607
|
+
_parse_args(sys.argv[2:])
|
|
1608
|
+
)
|
|
1609
|
+
clean_registry(dry_run=dry_run, force=force, json_output=json_output)
|
|
1610
|
+
|
|
1611
|
+
elif command == "stats":
|
|
1612
|
+
_, json_output = _extract_global_flags(sys.argv[2:])
|
|
1613
|
+
stats_command(json_output=json_output)
|
|
1614
|
+
|
|
1615
|
+
elif command == "doctor":
|
|
1616
|
+
_, json_output = _extract_global_flags(sys.argv[2:])
|
|
1617
|
+
doctor(json_output=json_output)
|
|
1618
|
+
|
|
1619
|
+
elif command == "config":
|
|
1620
|
+
key = sys.argv[2] if len(sys.argv) > 2 else None
|
|
1621
|
+
value = sys.argv[3] if len(sys.argv) > 3 else None
|
|
1622
|
+
config_command(key, value)
|
|
1623
|
+
|
|
1624
|
+
elif command == "search":
|
|
1625
|
+
args = sys.argv[2:]
|
|
1626
|
+
if not args:
|
|
1627
|
+
print_error("Please provide a search query")
|
|
1628
|
+
sys.exit(1)
|
|
1629
|
+
|
|
1630
|
+
forge = "github"
|
|
1631
|
+
if "--forge" in args:
|
|
1632
|
+
idx = args.index("--forge")
|
|
1633
|
+
if idx + 1 < len(args):
|
|
1634
|
+
forge = args[idx + 1].lower()
|
|
1635
|
+
args = args[:idx] + args[idx + 2:]
|
|
1636
|
+
else:
|
|
1637
|
+
print_error("Missing forge name after --forge (try: github, gitlab, codeberg)")
|
|
1638
|
+
sys.exit(1)
|
|
1639
|
+
|
|
1640
|
+
query = " ".join(args)
|
|
1641
|
+
forge_searchers = {
|
|
1642
|
+
"github": search_github,
|
|
1643
|
+
"gitlab": search_gitlab,
|
|
1644
|
+
"codeberg": search_codeberg,
|
|
1645
|
+
}
|
|
1646
|
+
searcher = forge_searchers.get(forge)
|
|
1647
|
+
if searcher:
|
|
1648
|
+
searcher(query)
|
|
1649
|
+
else:
|
|
1650
|
+
print_error(f"Unknown forge: {forge}. Supported: {', '.join(sorted(forge_searchers))}")
|
|
1651
|
+
sys.exit(1)
|
|
1652
|
+
|
|
1653
|
+
elif command == "export":
|
|
1654
|
+
if len(sys.argv) < 3:
|
|
1655
|
+
print_error("Please provide an output file path")
|
|
1656
|
+
sys.exit(1)
|
|
1657
|
+
export_registry(sys.argv[2])
|
|
1658
|
+
|
|
1659
|
+
elif command == "import":
|
|
1660
|
+
if len(sys.argv) < 3:
|
|
1661
|
+
print_error("Please provide an input file path")
|
|
1662
|
+
sys.exit(1)
|
|
1663
|
+
import_registry(sys.argv[2])
|
|
1664
|
+
|
|
1665
|
+
elif command == "completion":
|
|
1666
|
+
if len(sys.argv) < 3:
|
|
1667
|
+
print_error("Please specify a shell: bash or zsh")
|
|
1668
|
+
sys.exit(1)
|
|
1669
|
+
shell = sys.argv[2]
|
|
1670
|
+
script = _completion_script(shell)
|
|
1671
|
+
if script:
|
|
1672
|
+
print(script)
|
|
1673
|
+
else:
|
|
1674
|
+
print_error(f"Unsupported shell: {shell}")
|
|
1675
|
+
print("Supported shells: bash, zsh")
|
|
1676
|
+
sys.exit(1)
|
|
1677
|
+
|
|
1678
|
+
elif command == "version":
|
|
1679
|
+
print(f"pluck v{__version__}")
|
|
1680
|
+
|
|
1681
|
+
elif command == "help":
|
|
1682
|
+
print_usage()
|
|
1683
|
+
|
|
1684
|
+
else:
|
|
1685
|
+
print_error(f"Unknown command: {command}")
|
|
1686
|
+
print()
|
|
1687
|
+
print_usage()
|
|
1688
|
+
sys.exit(1)
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
if __name__ == "__main__":
|
|
1692
|
+
main()
|