baska 1.0.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.
@@ -0,0 +1,1312 @@
1
+ #!python
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import base64
6
+ import csv
7
+ import getpass
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import platform as py_platform
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ import textwrap
18
+ import time
19
+ import urllib.error
20
+ import urllib.parse
21
+ import urllib.request
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ VERSION = "1.0.0"
26
+ OWNER = os.getenv("BASKA_OWNER", "baska-pro")
27
+ HUB_REPO = os.getenv("BASKA_HUB_REPO", f"{OWNER}/baska-hub")
28
+ HUB_BRANCH = os.getenv("BASKA_HUB_BRANCH", "main")
29
+ RAW_BASE = f"https://raw.githubusercontent.com/{HUB_REPO}/{HUB_BRANCH}"
30
+
31
+ HOME = Path(os.getenv("BASKA_HOME", str(Path.home() / ".baska"))).expanduser()
32
+ CACHE = HOME / "cache"
33
+ PACKAGES = HOME / "packages"
34
+ STATE_FILE = HOME / "state.json"
35
+ CONFIG_FILE = HOME / "config.json"
36
+ PRIVATE_FILE = HOME / "private_catalog.json"
37
+ TOKEN_FILE = HOME / "auth" / "token"
38
+ CATALOG_FILE = CACHE / "catalog.json"
39
+ COLLECTIONS_FILE = CACHE / "collections.json"
40
+
41
+ for p in (HOME, CACHE, PACKAGES, TOKEN_FILE.parent):
42
+ p.mkdir(parents=True, exist_ok=True)
43
+
44
+ IS_TTY = sys.stdout.isatty()
45
+ C = {
46
+ "reset": "\033[0m" if IS_TTY else "",
47
+ "bold": "\033[1m" if IS_TTY else "",
48
+ "dim": "\033[2m" if IS_TTY else "",
49
+ "cyan": "\033[36m" if IS_TTY else "",
50
+ "green": "\033[32m" if IS_TTY else "",
51
+ "yellow": "\033[33m" if IS_TTY else "",
52
+ "red": "\033[31m" if IS_TTY else "",
53
+ }
54
+
55
+ def color(name: str, text: str) -> str:
56
+ return f"{C[name]}{text}{C['reset']}"
57
+
58
+ def now_iso() -> str:
59
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
60
+
61
+ def load_json(path: Path, default):
62
+ try:
63
+ return json.loads(path.read_text(encoding="utf-8"))
64
+ except (FileNotFoundError, json.JSONDecodeError):
65
+ return default
66
+
67
+ def save_json(path: Path, data) -> None:
68
+ path.parent.mkdir(parents=True, exist_ok=True)
69
+ tmp = path.with_suffix(path.suffix + ".tmp")
70
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
71
+ tmp.replace(path)
72
+
73
+ def state() -> dict:
74
+ return load_json(STATE_FILE, {"schema_version": 1, "installed": {}, "notifications": [], "catalog_ids": []})
75
+
76
+ def save_state(data: dict) -> None:
77
+ save_json(STATE_FILE, data)
78
+
79
+ def config() -> dict:
80
+ return load_json(CONFIG_FILE, {
81
+ "schema_version": 1,
82
+ "github_initialized": False,
83
+ "github_user": None,
84
+ "auto_refresh": True,
85
+ "smart_install": True,
86
+ "favorites": [],
87
+ })
88
+
89
+ def save_config(data: dict) -> None:
90
+ save_json(CONFIG_FILE, data)
91
+
92
+ def run(cmd, cwd: Path | None = None, check: bool = True, capture: bool = False,
93
+ env: dict | None = None, shell: bool = False):
94
+ kwargs = {
95
+ "cwd": str(cwd) if cwd else None,
96
+ "check": check,
97
+ "text": True,
98
+ "env": env,
99
+ "shell": shell,
100
+ }
101
+ if capture:
102
+ kwargs["stdout"] = subprocess.PIPE
103
+ kwargs["stderr"] = subprocess.PIPE
104
+ return subprocess.run(cmd, **kwargs)
105
+
106
+ def command_exists(name: str) -> bool:
107
+ return shutil.which(name) is not None
108
+
109
+ def detect_platform() -> str:
110
+ if os.getenv("TERMUX_VERSION") or "com.termux" in os.getenv("PREFIX", ""):
111
+ return "termux"
112
+ sysname = py_platform.system().lower()
113
+ if sysname == "windows":
114
+ return "windows"
115
+ if sysname == "darwin":
116
+ return "macos"
117
+ if sysname == "linux":
118
+ return "linux"
119
+ msystem = os.getenv("MSYSTEM", "").lower()
120
+ if "mingw" in msystem or "msys" in msystem or "cygwin" in msystem:
121
+ return "windows"
122
+ return sysname or "unknown"
123
+
124
+ def detect_arch() -> str:
125
+ machine = py_platform.machine().lower()
126
+ aliases = {
127
+ "amd64": "x86_64",
128
+ "x64": "x86_64",
129
+ "aarch64": "arm64",
130
+ "arm64": "arm64",
131
+ }
132
+ return aliases.get(machine, machine or "unknown")
133
+
134
+ def http_get(url: str, token: str | None = None, timeout: int = 25):
135
+ headers = {
136
+ "Accept": "application/vnd.github+json",
137
+ "User-Agent": f"baska-cli/{VERSION}",
138
+ "X-GitHub-Api-Version": "2022-11-28",
139
+ }
140
+ if token:
141
+ headers["Authorization"] = f"Bearer {token}"
142
+ req = urllib.request.Request(url, headers=headers)
143
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
144
+ content_type = resp.headers.get("Content-Type", "")
145
+ data = resp.read()
146
+ if "json" in content_type or url.startswith("https://api.github.com/"):
147
+ return json.loads(data.decode("utf-8"))
148
+ return data
149
+
150
+ def download(url: str, target: Path, token: str | None = None) -> Path:
151
+ target.parent.mkdir(parents=True, exist_ok=True)
152
+ headers = {"User-Agent": f"baska-cli/{VERSION}"}
153
+ if token:
154
+ headers["Authorization"] = f"Bearer {token}"
155
+ req = urllib.request.Request(url, headers=headers)
156
+ with urllib.request.urlopen(req, timeout=60) as resp, target.open("wb") as fh:
157
+ shutil.copyfileobj(resp, fh)
158
+ return target
159
+
160
+ def notify(kind: str, message: str) -> None:
161
+ s = state()
162
+ item = {"id": f"{int(time.time()*1000)}", "time": now_iso(), "kind": kind, "message": message, "read": False}
163
+ s.setdefault("notifications", []).insert(0, item)
164
+ s["notifications"] = s["notifications"][:100]
165
+ save_state(s)
166
+
167
+ def refresh(silent: bool = False) -> None:
168
+ old_catalog = load_json(CATALOG_FILE, {"packages": []})
169
+ old_ids = {p.get("id") for p in old_catalog.get("packages", [])}
170
+ try:
171
+ download(f"{RAW_BASE}/registry/catalog.json", CATALOG_FILE)
172
+ download(f"{RAW_BASE}/registry/collections.json", COLLECTIONS_FILE)
173
+ except Exception as exc:
174
+ if not silent:
175
+ print(color("red", f"Gagal refresh: {exc}"))
176
+ if not CATALOG_FILE.exists():
177
+ raise
178
+ return
179
+
180
+ new_catalog = load_json(CATALOG_FILE, {"packages": []})
181
+ new_ids = {p.get("id") for p in new_catalog.get("packages", [])}
182
+ added = [p for p in new_catalog.get("packages", []) if p.get("id") in (new_ids - old_ids)]
183
+ if old_ids and added:
184
+ names = ", ".join(p.get("slug", p.get("id")) for p in added[:5])
185
+ notify("catalog", f"{len(added)} paket/repo baru: {names}")
186
+ s = state()
187
+ s["catalog_ids"] = sorted(x for x in new_ids if x)
188
+ s["last_refresh"] = now_iso()
189
+ save_state(s)
190
+ if not silent:
191
+ print(color("green", "Katalog diperbarui."))
192
+
193
+ def ensure_catalog() -> None:
194
+ cfg = config()
195
+ if not CATALOG_FILE.exists():
196
+ refresh(silent=True)
197
+ elif cfg.get("auto_refresh"):
198
+ try:
199
+ age = time.time() - CATALOG_FILE.stat().st_mtime
200
+ if age > 3600:
201
+ refresh(silent=True)
202
+ except OSError:
203
+ pass
204
+
205
+ def gh_token() -> str | None:
206
+ for key in ("BASKA_GITHUB_TOKEN", "GH_TOKEN"):
207
+ if os.getenv(key):
208
+ return os.getenv(key)
209
+ if command_exists("gh"):
210
+ try:
211
+ p = run(["gh", "auth", "token"], check=True, capture=True)
212
+ token = p.stdout.strip()
213
+ if token:
214
+ return token
215
+ except Exception:
216
+ pass
217
+ if TOKEN_FILE.exists():
218
+ return TOKEN_FILE.read_text(encoding="utf-8").strip() or None
219
+ return None
220
+
221
+ def verify_github_auth(token: str | None = None, silent: bool = False) -> tuple[bool, str | None]:
222
+ token = token or gh_token()
223
+ if not token:
224
+ return False, None
225
+ try:
226
+ user = http_get("https://api.github.com/user", token=token)
227
+ login = user.get("login")
228
+ ok = bool(login and login.lower() == OWNER.lower())
229
+ if not ok and not silent:
230
+ print(color("red", f"Akun GitHub aktif '{login}', tetapi BASKA membutuhkan '{OWNER}'."))
231
+ return ok, login
232
+ except Exception as exc:
233
+ if not silent:
234
+ print(color("red", f"Gagal memverifikasi GitHub: {exc}"))
235
+ return False, None
236
+
237
+ def fetch_private_repositories(token: str) -> list[dict]:
238
+ repos = []
239
+ page = 1
240
+ while True:
241
+ url = (
242
+ "https://api.github.com/user/repos?"
243
+ + urllib.parse.urlencode({
244
+ "per_page": 100,
245
+ "page": page,
246
+ "affiliation": "owner",
247
+ "visibility": "private",
248
+ "sort": "created",
249
+ "direction": "desc",
250
+ })
251
+ )
252
+ data = http_get(url, token=token)
253
+ if not data:
254
+ break
255
+ repos.extend(
256
+ r for r in data
257
+ if r.get("owner", {}).get("login", "").lower() == OWNER.lower()
258
+ and r.get("private")
259
+ )
260
+ if len(data) < 100:
261
+ break
262
+ page += 1
263
+ repos.sort(key=lambda r: (r.get("created_at") or "", r.get("id", 0)), reverse=True)
264
+ return repos
265
+
266
+ def private_package_from_repo(repo: dict, package_id: str) -> dict:
267
+ name = repo["name"]
268
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
269
+ topics = list(repo.get("topics") or [])
270
+ language = repo.get("language")
271
+ if language:
272
+ topics.append(language.lower())
273
+ return {
274
+ "id": package_id,
275
+ "slug": slug,
276
+ "aliases": [slug.replace("-", "")],
277
+ "name": name,
278
+ "category": "repositories",
279
+ "type": "repo",
280
+ "version": "repo",
281
+ "platforms": ["all"],
282
+ "architectures": ["all"],
283
+ "source": repo["clone_url"],
284
+ "action": "smart",
285
+ "trust": "reviewed",
286
+ "status": "discovered",
287
+ "visibility": "private",
288
+ "repository_id": repo["id"],
289
+ "created_at": repo.get("created_at"),
290
+ "updated_at": repo.get("updated_at"),
291
+ "pushed_at": repo.get("pushed_at"),
292
+ "language": language,
293
+ "tags": sorted(set(topics)),
294
+ "dependencies": ["git"],
295
+ "description": repo.get("description") or f"Private repository {repo['full_name']}.",
296
+ "latest_release": None,
297
+ }
298
+
299
+ def init_github() -> None:
300
+ token = gh_token()
301
+ if not token:
302
+ if not sys.stdin.isatty():
303
+ raise SystemExit("Tidak ada autentikasi GitHub. Gunakan GH_TOKEN/BASKA_GITHUB_TOKEN atau jalankan interaktif.")
304
+ print("Masukkan GitHub Personal Access Token dengan akses read ke repo private.")
305
+ print("Token hanya disimpan lokal di ~/.baska/auth/token dengan permission 600.")
306
+ token = getpass.getpass("GitHub token: ").strip()
307
+ if not token:
308
+ raise SystemExit("Dibatalkan.")
309
+
310
+ ok, login = verify_github_auth(token)
311
+ if not ok:
312
+ raise SystemExit(1)
313
+
314
+ repos = fetch_private_repositories(token)
315
+ old = load_json(PRIVATE_FILE, {"schema_version": 1, "repositories": {}, "packages": []})
316
+ mapping = old.get("repositories", {})
317
+ used = []
318
+ for data in mapping.values():
319
+ m = re.fullmatch(r"P(\d{5})", str(data.get("package_id", "")))
320
+ if m:
321
+ used.append(int(m.group(1)))
322
+
323
+ if not mapping:
324
+ next_down = 50000
325
+ for repo in repos:
326
+ mapping[str(repo["id"])] = {
327
+ "package_id": f"P{next_down:05d}",
328
+ "full_name": repo["full_name"],
329
+ "created_at": repo.get("created_at"),
330
+ }
331
+ next_down -= 1
332
+ else:
333
+ next_up = max(used or [49999]) + 1
334
+ next_down = min(used or [50001]) - 1
335
+ existing_dates = [v.get("created_at") for v in mapping.values() if v.get("created_at")]
336
+ newest_date = max(existing_dates) if existing_dates else ""
337
+ oldest_date = min(existing_dates) if existing_dates else ""
338
+ for repo in repos:
339
+ key = str(repo["id"])
340
+ if key in mapping:
341
+ mapping[key]["full_name"] = repo["full_name"]
342
+ mapping[key]["created_at"] = repo.get("created_at")
343
+ continue
344
+ created = repo.get("created_at") or ""
345
+ if created >= newest_date:
346
+ pid = f"P{next_up:05d}"
347
+ next_up += 1
348
+ elif created <= oldest_date:
349
+ pid = f"P{max(1, next_down):05d}"
350
+ next_down -= 1
351
+ else:
352
+ pid = f"P{next_up:05d}"
353
+ next_up += 1
354
+ mapping[key] = {"package_id": pid, "full_name": repo["full_name"], "created_at": created}
355
+
356
+ packages = [
357
+ private_package_from_repo(repo, mapping[str(repo["id"])]["package_id"])
358
+ for repo in repos
359
+ ]
360
+ save_json(PRIVATE_FILE, {
361
+ "schema_version": 1,
362
+ "owner": OWNER,
363
+ "generated_at": now_iso(),
364
+ "repositories": mapping,
365
+ "packages": packages,
366
+ })
367
+
368
+ if not command_exists("gh") and token:
369
+ TOKEN_FILE.write_text(token + "\n", encoding="utf-8")
370
+ try:
371
+ TOKEN_FILE.chmod(0o600)
372
+ except OSError:
373
+ pass
374
+ elif command_exists("gh"):
375
+ try:
376
+ run(["gh", "auth", "setup-git"], check=False)
377
+ except Exception:
378
+ pass
379
+
380
+ cfg = config()
381
+ cfg["github_initialized"] = True
382
+ cfg["github_user"] = login
383
+ save_config(cfg)
384
+ print(color("green", f"GitHub terhubung sebagai {login}."))
385
+ print(f"{len(packages)} repo private tersedia secara lokal dan tidak ditulis ke katalog publik.")
386
+
387
+ def logout_github() -> None:
388
+ cfg = config()
389
+ cfg["github_initialized"] = False
390
+ cfg["github_user"] = None
391
+ save_config(cfg)
392
+ if PRIVATE_FILE.exists():
393
+ PRIVATE_FILE.unlink()
394
+ if TOKEN_FILE.exists():
395
+ TOKEN_FILE.unlink()
396
+ print("Sesi private BASKA dihapus. Login GitHub CLI tidak diubah.")
397
+
398
+ def private_enabled() -> bool:
399
+ cfg = config()
400
+ if not cfg.get("github_initialized") or cfg.get("github_user", "").lower() != OWNER.lower():
401
+ return False
402
+ ok, _ = verify_github_auth(silent=True)
403
+ return ok
404
+
405
+ def catalog_packages(include_private: bool = True) -> list[dict]:
406
+ ensure_catalog()
407
+ public = load_json(CATALOG_FILE, {"packages": []}).get("packages", [])
408
+ packages = list(public)
409
+ if include_private and private_enabled():
410
+ packages.extend(load_json(PRIVATE_FILE, {"packages": []}).get("packages", []))
411
+ return packages
412
+
413
+ def package_sort_key(p: dict):
414
+ created = p.get("created_at") or ""
415
+ pid = str(p.get("id", ""))
416
+ match = re.search(r"(\d+)$", pid)
417
+ num = int(match.group(1)) if match else 0
418
+ return (1 if p.get("type") == "repo" else 0, created, num)
419
+
420
+ def resolve_package(query: str) -> dict | None:
421
+ q = query.lower()
422
+ for p in catalog_packages():
423
+ candidates = [str(p.get("id", "")).lower(), str(p.get("slug", "")).lower()]
424
+ candidates.extend(str(a).lower() for a in p.get("aliases", []))
425
+ if q in candidates:
426
+ return p
427
+ return None
428
+
429
+ def split_target(value: str) -> tuple[str, str | None]:
430
+ if "@" not in value:
431
+ return value, None
432
+ name, ref = value.rsplit("@", 1)
433
+ return name, ref or None
434
+
435
+ def display_packages(packages: list[dict]) -> None:
436
+ if not packages:
437
+ print("Tidak ada paket.")
438
+ return
439
+ width = max(16, min(34, max(len(str(p.get("slug", ""))) for p in packages)))
440
+ print(f"{'ID':<8} {'SLUG':<{width}} {'AKSES':<8} {'STATUS':<15} {'PLATFORM':<16} KETERANGAN")
441
+ print("-" * min(120, 8 + width + 8 + 15 + 16 + 40))
442
+ for p in sorted(packages, key=package_sort_key, reverse=True):
443
+ vis = "PRIVATE" if p.get("visibility") == "private" else "PUBLIC"
444
+ status = p.get("status") or "ready"
445
+ platforms = ",".join(p.get("platforms") or ["all"])
446
+ desc = (p.get("description") or "").replace("\n", " ")
447
+ if len(desc) > 42:
448
+ desc = desc[:39] + "..."
449
+ print(f"{p.get('id',''):<8} {p.get('slug',''):<{width}} {vis:<8} {status:<15} {platforms:<16} {desc}")
450
+
451
+ def list_command(category: str | None = None) -> None:
452
+ pkgs = catalog_packages()
453
+ if category:
454
+ pkgs = [p for p in pkgs if p.get("category") == category or category in p.get("tags", [])]
455
+ display_packages(pkgs)
456
+
457
+ def search_command(term: str) -> None:
458
+ needle = term.lower()
459
+ matches = []
460
+ for p in catalog_packages():
461
+ hay = " ".join([
462
+ str(p.get("id", "")),
463
+ str(p.get("slug", "")),
464
+ str(p.get("name", "")),
465
+ str(p.get("description", "")),
466
+ " ".join(p.get("aliases", [])),
467
+ " ".join(p.get("tags", [])),
468
+ ]).lower()
469
+ if needle in hay:
470
+ matches.append(p)
471
+ display_packages(matches)
472
+
473
+ def info_command(query: str) -> None:
474
+ p = resolve_package(query)
475
+ if not p:
476
+ raise SystemExit(f"Paket '{query}' tidak ditemukan.")
477
+ fields = [
478
+ ("ID", p.get("id")),
479
+ ("Slug", p.get("slug")),
480
+ ("Nama", p.get("name")),
481
+ ("Akses", p.get("visibility", "public")),
482
+ ("Status", p.get("status")),
483
+ ("Trust", p.get("trust")),
484
+ ("Kategori", p.get("category")),
485
+ ("Versi", p.get("version")),
486
+ ("Platform", ", ".join(p.get("platforms") or [])),
487
+ ("Arsitektur", ", ".join(p.get("architectures") or [])),
488
+ ("Bahasa", p.get("language")),
489
+ ("Dibuat", p.get("created_at")),
490
+ ("Update", p.get("updated_at")),
491
+ ("Source", p.get("source")),
492
+ ("Action", p.get("action")),
493
+ ("Dependencies", ", ".join(p.get("dependencies") or [])),
494
+ ("Tags", ", ".join(p.get("tags") or [])),
495
+ ("Deskripsi", p.get("description")),
496
+ ]
497
+ for key, value in fields:
498
+ if value not in (None, "", []):
499
+ print(f"{key:<13}: {value}")
500
+ release = p.get("latest_release")
501
+ if release:
502
+ print(f"{'Release':<13}: {release.get('tag_name')} ({release.get('published_at') or '-'})")
503
+
504
+ def compatible(package: dict) -> bool:
505
+ supported = set(package.get("platforms") or ["all"])
506
+ current = detect_platform()
507
+ if "all" in supported:
508
+ return True
509
+ if current in supported:
510
+ return True
511
+ if current == "termux" and "linux" in supported:
512
+ return True
513
+ return False
514
+
515
+ def private_git_env(package: dict) -> dict:
516
+ env = os.environ.copy()
517
+ if package.get("visibility") != "private":
518
+ return env
519
+ token = gh_token()
520
+ if not token:
521
+ raise SystemExit("Repo private memerlukan 'baska init' atau autentikasi GitHub CLI.")
522
+ basic = base64.b64encode(f"x-access-token:{token}".encode()).decode()
523
+ env["GIT_CONFIG_COUNT"] = "1"
524
+ env["GIT_CONFIG_KEY_0"] = "http.extraHeader"
525
+ env["GIT_CONFIG_VALUE_0"] = f"Authorization: Basic {basic}"
526
+ env["GIT_TERMINAL_PROMPT"] = "0"
527
+ return env
528
+
529
+ def choose_ref(package: dict, requested_ref: str | None) -> str | None:
530
+ if requested_ref == "latest":
531
+ release = package.get("latest_release") or {}
532
+ return release.get("tag_name")
533
+ if requested_ref:
534
+ return requested_ref
535
+ if package.get("preferred_delivery") == "release":
536
+ release = package.get("latest_release") or {}
537
+ return release.get("tag_name")
538
+ return None
539
+
540
+ def git_clone_or_update(package: dict, requested_ref: str | None = None) -> tuple[Path, str | None]:
541
+ if not command_exists("git"):
542
+ ensure_dependency("git", assume_yes=False)
543
+ slug = package["slug"]
544
+ target = PACKAGES / slug
545
+ env = private_git_env(package)
546
+ source = package["source"]
547
+ chosen_ref = choose_ref(package, requested_ref)
548
+
549
+ if target.exists() and not (target / ".git").exists():
550
+ raise SystemExit(f"{target} sudah ada tetapi bukan Git repository.")
551
+
552
+ if not target.exists():
553
+ cmd = ["git", "clone", source, str(target)]
554
+ run(cmd, env=env)
555
+ else:
556
+ if git_dirty(target):
557
+ raise SystemExit(f"{slug} memiliki perubahan lokal. Commit/stash dahulu sebelum update.")
558
+ run(["git", "fetch", "--all", "--tags", "--prune"], cwd=target, env=env)
559
+
560
+ previous = git_head(target)
561
+ if chosen_ref:
562
+ run(["git", "checkout", "--detach", chosen_ref], cwd=target, env=env)
563
+ else:
564
+ branch = git_default_branch(target, env)
565
+ if branch:
566
+ run(["git", "checkout", branch], cwd=target, env=env, check=False)
567
+ run(["git", "pull", "--ff-only"], cwd=target, env=env, check=False)
568
+ return target, previous
569
+
570
+ def git_head(path: Path) -> str | None:
571
+ try:
572
+ return run(["git", "rev-parse", "HEAD"], cwd=path, capture=True).stdout.strip()
573
+ except Exception:
574
+ return None
575
+
576
+ def git_dirty(path: Path) -> bool:
577
+ try:
578
+ return bool(run(["git", "status", "--porcelain"], cwd=path, capture=True).stdout.strip())
579
+ except Exception:
580
+ return False
581
+
582
+ def git_default_branch(path: Path, env: dict | None = None) -> str | None:
583
+ try:
584
+ out = run(["git", "symbolic-ref", "refs/remotes/origin/HEAD"], cwd=path, capture=True, env=env).stdout.strip()
585
+ return out.rsplit("/", 1)[-1]
586
+ except Exception:
587
+ for candidate in ("main", "master"):
588
+ try:
589
+ run(["git", "show-ref", "--verify", f"refs/remotes/origin/{candidate}"], cwd=path, capture=True)
590
+ return candidate
591
+ except Exception:
592
+ pass
593
+ return None
594
+
595
+ def system_install_command(dep: str) -> list[str] | None:
596
+ plat = detect_platform()
597
+ mapping = {
598
+ "git": {"apt": "git", "dnf": "git", "pacman": "git", "apk": "git", "pkg": "git", "brew": "git", "winget": "Git.Git", "choco": "git"},
599
+ "python": {"apt": "python3 python3-venv python3-pip", "dnf": "python3 python3-pip", "pacman": "python python-pip", "apk": "python3 py3-pip", "pkg": "python", "brew": "python", "winget": "Python.Python.3.12", "choco": "python"},
600
+ "node": {"apt": "nodejs npm", "dnf": "nodejs npm", "pacman": "nodejs npm", "apk": "nodejs npm", "pkg": "nodejs", "brew": "node", "winget": "OpenJS.NodeJS.LTS", "choco": "nodejs-lts"},
601
+ "docker": {"apt": "docker.io docker-compose-v2", "dnf": "docker docker-compose-plugin", "pacman": "docker docker-compose", "apk": "docker docker-cli-compose", "brew": "docker", "winget": "Docker.DockerDesktop", "choco": "docker-desktop"},
602
+ }
603
+ pkg = mapping.get(dep)
604
+ if not pkg:
605
+ return None
606
+ if plat == "termux" and command_exists("pkg"):
607
+ return ["pkg", "install", "-y"] + pkg.get("pkg", dep).split()
608
+ if plat in ("linux", "macos"):
609
+ if command_exists("apt-get"):
610
+ prefix = [] if os.geteuid() == 0 else (["sudo"] if command_exists("sudo") else [])
611
+ return prefix + ["apt-get", "install", "-y"] + pkg["apt"].split()
612
+ if command_exists("dnf"):
613
+ prefix = [] if os.geteuid() == 0 else (["sudo"] if command_exists("sudo") else [])
614
+ return prefix + ["dnf", "install", "-y"] + pkg["dnf"].split()
615
+ if command_exists("pacman"):
616
+ prefix = [] if os.geteuid() == 0 else (["sudo"] if command_exists("sudo") else [])
617
+ return prefix + ["pacman", "-S", "--noconfirm"] + pkg["pacman"].split()
618
+ if command_exists("apk"):
619
+ prefix = [] if os.geteuid() == 0 else (["sudo"] if command_exists("sudo") else [])
620
+ return prefix + ["apk", "add"] + pkg["apk"].split()
621
+ if command_exists("brew"):
622
+ return ["brew", "install"] + pkg["brew"].split()
623
+ if plat == "windows":
624
+ if command_exists("winget"):
625
+ return ["winget", "install", "--id", pkg["winget"], "-e", "--accept-source-agreements", "--accept-package-agreements"]
626
+ if command_exists("choco"):
627
+ return ["choco", "install", "-y", pkg["choco"]]
628
+ return None
629
+
630
+ def dependency_present(dep: str) -> bool:
631
+ checks = {
632
+ "git": ["git"],
633
+ "python": ["python3", "python"],
634
+ "node": ["node"],
635
+ "npm": ["npm"],
636
+ "docker": ["docker"],
637
+ "powershell": ["pwsh", "powershell"],
638
+ }
639
+ return any(command_exists(x) for x in checks.get(dep, [dep]))
640
+
641
+ def confirm(prompt: str, default: bool = False) -> bool:
642
+ if not sys.stdin.isatty():
643
+ return default
644
+ suffix = " [Y/n] " if default else " [y/N] "
645
+ ans = input(prompt + suffix).strip().lower()
646
+ if not ans:
647
+ return default
648
+ return ans in ("y", "yes", "ya")
649
+
650
+ def ensure_dependency(dep: str, assume_yes: bool = False) -> bool:
651
+ if dependency_present(dep):
652
+ return True
653
+ cmd = system_install_command(dep)
654
+ if not cmd:
655
+ print(color("yellow", f"Dependency '{dep}' belum ada dan installer otomatis tidak tersedia."))
656
+ return False
657
+ print(f"Dependency belum ada: {dep}")
658
+ print("Command:", " ".join(cmd))
659
+ if not assume_yes and not confirm(f"Install {dep}?"):
660
+ return False
661
+ try:
662
+ run(cmd)
663
+ return dependency_present(dep)
664
+ except Exception as exc:
665
+ print(color("red", f"Gagal memasang {dep}: {exc}"))
666
+ return False
667
+
668
+ def smart_plan(path: Path) -> list[dict]:
669
+ plan = []
670
+ if (path / "install.sh").is_file():
671
+ plan.append({"kind": "script", "description": "Jalankan install.sh", "deps": [], "cmd": ["sh", "install.sh"]})
672
+ return plan
673
+ if (path / "install.ps1").is_file() and detect_platform() == "windows":
674
+ ps = "pwsh" if command_exists("pwsh") else "powershell"
675
+ plan.append({"kind": "script", "description": "Jalankan install.ps1", "deps": ["powershell"], "cmd": [ps, "-ExecutionPolicy", "Bypass", "-File", "install.ps1"]})
676
+ return plan
677
+
678
+ reqs = sorted(path.glob("requirements*.txt"))
679
+ pyproject = path / "pyproject.toml"
680
+ setup_py = path / "setup.py"
681
+ if reqs or pyproject.exists() or setup_py.exists():
682
+ plan.append({"kind": "python", "description": "Siapkan Python virtualenv dan dependency", "deps": ["python"], "requirements": str(reqs[0].name) if reqs else None, "editable": pyproject.exists() or setup_py.exists()})
683
+
684
+ if (path / "package.json").is_file():
685
+ plan.append({"kind": "node", "description": "Install dependency Node.js", "deps": ["node", "npm"], "cmd": ["npm", "install"]})
686
+
687
+ compose = None
688
+ for name in ("compose.yml", "compose.yaml", "docker-compose.yml", "docker-compose.yaml"):
689
+ if (path / name).is_file():
690
+ compose = name
691
+ break
692
+ if compose:
693
+ plan.append({"kind": "compose", "description": f"Validasi Docker Compose ({compose})", "deps": ["docker"], "cmd": ["docker", "compose", "-f", compose, "config"]})
694
+ elif (path / "Dockerfile").is_file():
695
+ plan.append({"kind": "docker", "description": "Build Docker image", "deps": ["docker"], "cmd": ["docker", "build", "-t", f"baska-{path.name}", "."]})
696
+ return plan
697
+
698
+ def execute_python_plan(path: Path, item: dict) -> None:
699
+ py = shutil.which("python3") or shutil.which("python")
700
+ if not py:
701
+ raise RuntimeError("Python tidak tersedia.")
702
+ venv = path / ".venv"
703
+ if not venv.exists():
704
+ run([py, "-m", "venv", str(venv)], cwd=path)
705
+ if detect_platform() == "windows":
706
+ pip = venv / "Scripts" / "pip.exe"
707
+ else:
708
+ pip = venv / "bin" / "pip"
709
+ run([str(pip), "install", "--upgrade", "pip"], cwd=path)
710
+ if item.get("requirements"):
711
+ run([str(pip), "install", "-r", item["requirements"]], cwd=path)
712
+ if item.get("editable"):
713
+ run([str(pip), "install", "-e", "."], cwd=path)
714
+
715
+ def run_smart_install(package: dict, path: Path, assume_yes: bool = False) -> None:
716
+ plan = smart_plan(path)
717
+ if not plan:
718
+ print(color("yellow", "Tidak ditemukan installer standar; repository berhasil di-clone saja."))
719
+ return
720
+ print(color("cyan", "Smart install plan:"))
721
+ for i, item in enumerate(plan, 1):
722
+ print(f" {i}. {item['description']}")
723
+ trust = package.get("trust", "discovered")
724
+ allowed = assume_yes or trust == "trusted"
725
+ if not allowed:
726
+ allowed = confirm("Jalankan rencana instalasi di atas?")
727
+ if not allowed:
728
+ print("Smart install dilewati. Repository tetap tersedia.")
729
+ return
730
+ for item in plan:
731
+ for dep in item.get("deps", []):
732
+ if not ensure_dependency(dep, assume_yes=assume_yes):
733
+ raise SystemExit(f"Dependency '{dep}' belum tersedia.")
734
+ if item["kind"] == "python":
735
+ execute_python_plan(path, item)
736
+ else:
737
+ run(item["cmd"], cwd=path)
738
+
739
+ def execute_recipe(package: dict, path: Path, assume_yes: bool = False) -> None:
740
+ recipe = package.get("recipe")
741
+ if not recipe:
742
+ action = package.get("action", "")
743
+ if action.startswith("recipe:"):
744
+ recipe = action.split(":", 1)[1]
745
+ if not recipe:
746
+ return
747
+ tmp = CACHE / f"recipe-{package['id']}.sh"
748
+ download(f"{RAW_BASE}/{recipe}", tmp)
749
+ tmp.chmod(0o700)
750
+ env = os.environ.copy()
751
+ env.update({
752
+ "BASKA_PACKAGE_ID": str(package["id"]),
753
+ "BASKA_PACKAGE_SLUG": package["slug"],
754
+ "BASKA_PACKAGE_SOURCE": package["source"],
755
+ "BASKA_PACKAGE_HOME": str(path),
756
+ })
757
+ run(["sh", str(tmp)], env=env)
758
+
759
+ def install_file_package(package: dict) -> None:
760
+ source = package["source"]
761
+ filename = Path(urllib.parse.urlparse(source).path).name or package["slug"]
762
+ if package.get("type") == "apk" and (Path.home() / "storage" / "downloads").is_dir():
763
+ out = Path.home() / "storage" / "downloads" / filename
764
+ else:
765
+ out = PACKAGES / package["slug"] / filename
766
+ download(source, out)
767
+ expected = package.get("sha256")
768
+ if expected:
769
+ actual = hashlib.sha256(out.read_bytes()).hexdigest()
770
+ if actual.lower() != expected.lower():
771
+ out.unlink(missing_ok=True)
772
+ raise SystemExit("SHA-256 tidak cocok. File dihapus.")
773
+ print(color("green", f"Downloaded: {out}"))
774
+ if package.get("action") == "open":
775
+ if command_exists("termux-open"):
776
+ run(["termux-open", str(out)], check=False)
777
+ elif command_exists("xdg-open"):
778
+ run(["xdg-open", str(out)], check=False)
779
+
780
+ def record_install(package: dict, path: Path | None, previous_commit: str | None, ref: str | None) -> None:
781
+ s = state()
782
+ current = git_head(path) if path and (path / ".git").exists() else None
783
+ old = s.setdefault("installed", {}).get(package["slug"], {})
784
+ s["installed"][package["slug"]] = {
785
+ "id": package["id"],
786
+ "slug": package["slug"],
787
+ "path": str(path) if path else None,
788
+ "type": package.get("type"),
789
+ "source": package.get("source"),
790
+ "visibility": package.get("visibility", "public"),
791
+ "installed_at": old.get("installed_at") or now_iso(),
792
+ "updated_at": now_iso(),
793
+ "ref": ref,
794
+ "current_commit": current,
795
+ "previous_commit": previous_commit or old.get("current_commit"),
796
+ "version": package.get("version"),
797
+ }
798
+ save_state(s)
799
+
800
+ def install_command(target: str, assume_yes: bool = False) -> None:
801
+ name, requested_ref = split_target(target)
802
+ package = resolve_package(name)
803
+ if not package:
804
+ raise SystemExit(f"Paket '{name}' tidak ditemukan.")
805
+ if not compatible(package):
806
+ raise SystemExit(f"{package['slug']} tidak kompatibel dengan platform {detect_platform()}.")
807
+ if package.get("visibility") == "private" and not private_enabled():
808
+ raise SystemExit("Repo private hanya tersedia setelah 'baska init' dengan akun GitHub yang benar.")
809
+
810
+ if package.get("type") != "repo":
811
+ install_file_package(package)
812
+ record_install(package, None, None, requested_ref)
813
+ return
814
+
815
+ path, previous = git_clone_or_update(package, requested_ref)
816
+ action = package.get("action", "smart")
817
+ if action.startswith("recipe") or package.get("recipe"):
818
+ execute_recipe(package, path, assume_yes=assume_yes)
819
+ elif action == "smart":
820
+ run_smart_install(package, path, assume_yes=assume_yes)
821
+ else:
822
+ print(color("green", f"Repository tersedia: {path}"))
823
+ record_install(package, path, previous, requested_ref)
824
+ print(color("green", f"Install selesai: {package['slug']} ({package['id']})"))
825
+
826
+ def installed_rows() -> list[dict]:
827
+ s = state()
828
+ return list(s.get("installed", {}).values())
829
+
830
+ def status_command() -> None:
831
+ rows = installed_rows()
832
+ if not rows:
833
+ print("Belum ada paket yang tercatat terpasang.")
834
+ return
835
+ print(f"{'SLUG':<28} {'STATUS':<14} {'REF':<18} PATH")
836
+ print("-" * 100)
837
+ for item in rows:
838
+ path = Path(item["path"]) if item.get("path") else None
839
+ if path and not path.exists():
840
+ st = "MISSING"
841
+ elif path and (path / ".git").exists() and git_dirty(path):
842
+ st = "MODIFIED"
843
+ else:
844
+ st = "OK"
845
+ print(f"{item['slug']:<28} {st:<14} {(item.get('ref') or '-'):<18} {item.get('path') or '-'}")
846
+
847
+ def update_one(query: str, assume_yes: bool = False) -> None:
848
+ package = resolve_package(query)
849
+ if not package:
850
+ raise SystemExit(f"Paket '{query}' tidak ditemukan.")
851
+ s = state()
852
+ installed = s.get("installed", {}).get(package["slug"])
853
+ if not installed:
854
+ return install_command(query, assume_yes=assume_yes)
855
+ if package.get("type") != "repo":
856
+ return install_command(query, assume_yes=assume_yes)
857
+ path = Path(installed["path"])
858
+ if git_dirty(path):
859
+ raise SystemExit(f"{package['slug']} memiliki perubahan lokal; update dibatalkan.")
860
+ old = git_head(path)
861
+ env = private_git_env(package)
862
+ run(["git", "fetch", "--all", "--tags", "--prune"], cwd=path, env=env)
863
+ ref = installed.get("ref")
864
+ if ref:
865
+ chosen = choose_ref(package, ref) or ref
866
+ run(["git", "checkout", "--detach", chosen], cwd=path, env=env)
867
+ else:
868
+ branch = git_default_branch(path, env)
869
+ if branch:
870
+ run(["git", "checkout", branch], cwd=path, env=env, check=False)
871
+ run(["git", "pull", "--ff-only"], cwd=path, env=env)
872
+ new = git_head(path)
873
+ if new != old:
874
+ installed["previous_commit"] = old
875
+ installed["current_commit"] = new
876
+ installed["updated_at"] = now_iso()
877
+ save_state(s)
878
+ notify("update", f"{package['slug']} diperbarui ke {new[:8] if new else 'latest'}")
879
+ if package.get("action") == "smart":
880
+ run_smart_install(package, path, assume_yes=assume_yes)
881
+ print(color("green", f"{package['slug']} diperbarui."))
882
+ else:
883
+ print(f"{package['slug']} sudah terbaru.")
884
+
885
+ def update_all(assume_yes: bool = False) -> None:
886
+ for item in installed_rows():
887
+ try:
888
+ update_one(item["slug"], assume_yes=assume_yes)
889
+ except Exception as exc:
890
+ print(color("red", f"{item['slug']}: {exc}"))
891
+
892
+ def outdated_command() -> None:
893
+ found = 0
894
+ for item in installed_rows():
895
+ path = Path(item["path"]) if item.get("path") else None
896
+ if not path or not (path / ".git").exists():
897
+ continue
898
+ package = resolve_package(item["slug"])
899
+ if not package:
900
+ continue
901
+ if git_dirty(path):
902
+ print(f"{item['slug']}: modified (skip)")
903
+ continue
904
+ env = private_git_env(package)
905
+ try:
906
+ run(["git", "fetch", "--quiet", "origin"], cwd=path, env=env)
907
+ branch = git_default_branch(path, env)
908
+ if not branch:
909
+ continue
910
+ head = git_head(path)
911
+ remote = run(["git", "rev-parse", f"origin/{branch}"], cwd=path, capture=True, env=env).stdout.strip()
912
+ if head != remote:
913
+ print(f"{item['slug']}: UPDATE {head[:8] if head else '-'} -> {remote[:8]}")
914
+ found += 1
915
+ except Exception as exc:
916
+ print(f"{item['slug']}: tidak dapat dicek ({exc})")
917
+ if not found:
918
+ print("Tidak ada update repo yang terdeteksi.")
919
+ else:
920
+ notify("update", f"{found} paket memiliki update.")
921
+
922
+ def remove_command(query: str, assume_yes: bool = False) -> None:
923
+ package = resolve_package(query)
924
+ slug = package["slug"] if package else query
925
+ s = state()
926
+ item = s.get("installed", {}).get(slug)
927
+ if not item:
928
+ raise SystemExit(f"{slug} tidak tercatat terpasang.")
929
+ path = Path(item["path"]) if item.get("path") else None
930
+ if path and path.exists():
931
+ if not assume_yes and not confirm(f"Hapus {path}?"):
932
+ print("Dibatalkan.")
933
+ return
934
+ shutil.rmtree(path)
935
+ del s["installed"][slug]
936
+ save_state(s)
937
+ print(color("green", f"{slug} dihapus dari BASKA."))
938
+
939
+ def rollback_command(query: str, assume_yes: bool = False) -> None:
940
+ package = resolve_package(query)
941
+ slug = package["slug"] if package else query
942
+ s = state()
943
+ item = s.get("installed", {}).get(slug)
944
+ if not item or not item.get("path"):
945
+ raise SystemExit("Paket tidak ditemukan di state instalasi.")
946
+ path = Path(item["path"])
947
+ prev = item.get("previous_commit")
948
+ if not prev:
949
+ raise SystemExit("Tidak ada commit rollback yang tersimpan.")
950
+ if git_dirty(path):
951
+ raise SystemExit("Repository memiliki perubahan lokal; rollback dibatalkan.")
952
+ if not assume_yes and not confirm(f"Rollback {slug} ke {prev[:8]}?"):
953
+ return
954
+ current = git_head(path)
955
+ run(["git", "reset", "--hard", prev], cwd=path)
956
+ item["current_commit"] = prev
957
+ item["previous_commit"] = current
958
+ item["updated_at"] = now_iso()
959
+ save_state(s)
960
+ print(color("green", f"{slug} di-rollback ke {prev[:8]}."))
961
+
962
+ def repair_command(query: str, assume_yes: bool = False) -> None:
963
+ package = resolve_package(query)
964
+ if not package:
965
+ raise SystemExit("Paket tidak ditemukan.")
966
+ s = state()
967
+ item = s.get("installed", {}).get(package["slug"])
968
+ if not item:
969
+ return install_command(query, assume_yes=assume_yes)
970
+ path = Path(item["path"]) if item.get("path") else None
971
+ if not path or not path.exists():
972
+ print("Folder instalasi hilang; memasang ulang.")
973
+ return install_command(query, assume_yes=assume_yes)
974
+ if (path / ".git").exists():
975
+ run(["git", "fsck", "--no-progress"], cwd=path, check=False)
976
+ if package.get("action") == "smart":
977
+ run_smart_install(package, path, assume_yes=assume_yes)
978
+ print(color("green", f"Repair selesai: {package['slug']}"))
979
+
980
+ def versions_command(query: str) -> None:
981
+ package = resolve_package(query)
982
+ if not package or package.get("type") != "repo":
983
+ raise SystemExit("Versions hanya tersedia untuk repository package.")
984
+ env = private_git_env(package)
985
+ p = run(["git", "ls-remote", "--tags", "--refs", package["source"]], capture=True, env=env)
986
+ tags = []
987
+ for line in p.stdout.splitlines():
988
+ if "\trefs/tags/" in line:
989
+ tags.append(line.split("\trefs/tags/", 1)[1])
990
+ release = package.get("latest_release") or {}
991
+ if release.get("tag_name"):
992
+ print(f"Latest release: {release['tag_name']}")
993
+ if not tags:
994
+ print("Tidak ada tag.")
995
+ return
996
+ for tag in tags[-30:][::-1]:
997
+ print(tag)
998
+
999
+ def favorites_command(action: str, value: str | None = None) -> None:
1000
+ cfg = config()
1001
+ fav = cfg.setdefault("favorites", [])
1002
+ if action == "list":
1003
+ display_packages([p for p in catalog_packages() if p.get("slug") in fav])
1004
+ return
1005
+ if not value:
1006
+ raise SystemExit("Slug/ID diperlukan.")
1007
+ p = resolve_package(value)
1008
+ if not p:
1009
+ raise SystemExit("Paket tidak ditemukan.")
1010
+ if action == "add" and p["slug"] not in fav:
1011
+ fav.append(p["slug"])
1012
+ elif action == "remove" and p["slug"] in fav:
1013
+ fav.remove(p["slug"])
1014
+ save_config(cfg)
1015
+ print("Favorites diperbarui.")
1016
+
1017
+ def collections() -> dict:
1018
+ ensure_catalog()
1019
+ return load_json(COLLECTIONS_FILE, {"collections": {}}).get("collections", {})
1020
+
1021
+ def profile_list() -> None:
1022
+ for key, item in collections().items():
1023
+ print(f"{key:<16} {item.get('name', key)} — {item.get('description','')}")
1024
+
1025
+ def profile_show(name: str) -> None:
1026
+ item = collections().get(name)
1027
+ if not item:
1028
+ raise SystemExit("Profile tidak ditemukan.")
1029
+ print(f"{item.get('name', name)}")
1030
+ print(item.get("description", ""))
1031
+ members = []
1032
+ for value in item.get("members", []):
1033
+ p = resolve_package(value)
1034
+ if p:
1035
+ members.append(p)
1036
+ display_packages(members)
1037
+
1038
+ def profile_setup(name: str, assume_yes: bool = False) -> None:
1039
+ item = collections().get(name)
1040
+ if not item:
1041
+ raise SystemExit("Profile tidak ditemukan.")
1042
+ for member in item.get("members", []):
1043
+ try:
1044
+ install_command(member, assume_yes=assume_yes)
1045
+ except Exception as exc:
1046
+ print(color("red", f"{member}: {exc}"))
1047
+
1048
+ def notifications_command(action: str = "list") -> None:
1049
+ s = state()
1050
+ notes = s.setdefault("notifications", [])
1051
+ if action == "clear":
1052
+ s["notifications"] = []
1053
+ save_state(s)
1054
+ print("Notifikasi dibersihkan.")
1055
+ return
1056
+ if not notes:
1057
+ print("Tidak ada notifikasi.")
1058
+ return
1059
+ for n in notes[:30]:
1060
+ mark = "*" if not n.get("read") else " "
1061
+ print(f"{mark} {n.get('time','')} [{n.get('kind','info')}] {n.get('message','')}")
1062
+ n["read"] = True
1063
+ save_state(s)
1064
+
1065
+ def doctor() -> None:
1066
+ ok, login = verify_github_auth(silent=True)
1067
+ checks = {
1068
+ "BASKA": f"v{VERSION}",
1069
+ "Platform": detect_platform(),
1070
+ "Architecture": detect_arch(),
1071
+ "Python": py_platform.python_version(),
1072
+ "Home": str(HOME),
1073
+ "Git": shutil.which("git") or "not found",
1074
+ "Node": shutil.which("node") or "not found",
1075
+ "Docker": shutil.which("docker") or "not found",
1076
+ "GitHub": login if ok else "not initialized/unauthorized",
1077
+ "Catalog": str(CATALOG_FILE) if CATALOG_FILE.exists() else "not cached",
1078
+ }
1079
+ for k, v in checks.items():
1080
+ print(f"{k:<14}: {v}")
1081
+
1082
+ def open_catalog() -> None:
1083
+ url = f"https://{OWNER}.github.io/baska-hub/"
1084
+ if command_exists("termux-open-url"):
1085
+ run(["termux-open-url", url], check=False)
1086
+ elif command_exists("xdg-open"):
1087
+ run(["xdg-open", url], check=False)
1088
+ else:
1089
+ print(url)
1090
+
1091
+ def settings_menu() -> None:
1092
+ cfg = config()
1093
+ while True:
1094
+ print("\nSettings")
1095
+ print(f"1. Auto refresh : {'ON' if cfg.get('auto_refresh') else 'OFF'}")
1096
+ print(f"2. Smart install: {'ON' if cfg.get('smart_install') else 'OFF'}")
1097
+ print("0. Kembali")
1098
+ choice = input("Pilih: ").strip()
1099
+ if choice == "1":
1100
+ cfg["auto_refresh"] = not cfg.get("auto_refresh")
1101
+ elif choice == "2":
1102
+ cfg["smart_install"] = not cfg.get("smart_install")
1103
+ elif choice == "0":
1104
+ break
1105
+ save_config(cfg)
1106
+
1107
+ def clear_screen() -> None:
1108
+ if IS_TTY:
1109
+ print("\033[2J\033[H", end="")
1110
+
1111
+ def dashboard_header() -> None:
1112
+ cfg = config()
1113
+ s = state()
1114
+ unread = sum(1 for n in s.get("notifications", []) if not n.get("read"))
1115
+ auth = cfg.get("github_user") if private_enabled() else "public"
1116
+ print(color("bold", "BASKA HUB"))
1117
+ print(f"v{VERSION} | {detect_platform()}/{detect_arch()} | GitHub: {auth} | Notifikasi: {unread}")
1118
+ print("=" * 72)
1119
+
1120
+ def prompt_package(label: str = "ID/slug") -> str:
1121
+ return input(f"{label}: ").strip()
1122
+
1123
+ def dashboard() -> None:
1124
+ ensure_catalog()
1125
+ while True:
1126
+ clear_screen()
1127
+ dashboard_header()
1128
+ print("""
1129
+ 1. Daftar repository & paket
1130
+ 2. Cari
1131
+ 3. Detail paket
1132
+ 4. Install
1133
+ 5. Paket terpasang / status
1134
+ 6. Cek update
1135
+ 7. Update paket
1136
+ 8. Update semua
1137
+ 9. Remove
1138
+ 10. Repair
1139
+ 11. Rollback
1140
+ 12. Versi / tag
1141
+ 13. Profiles & collections
1142
+ 14. Favorites
1143
+ 15. Notifikasi
1144
+ 16. GitHub init / private repo
1145
+ 17. Refresh katalog
1146
+ 18. Doctor
1147
+ 19. Web catalog
1148
+ 20. Settings
1149
+ 0. Keluar
1150
+ """.strip())
1151
+ choice = input("\nPilih menu: ").strip()
1152
+ try:
1153
+ if choice == "1":
1154
+ list_command()
1155
+ elif choice == "2":
1156
+ search_command(input("Kata pencarian: ").strip())
1157
+ elif choice == "3":
1158
+ info_command(prompt_package())
1159
+ elif choice == "4":
1160
+ install_command(prompt_package("ID/slug[@versi]"))
1161
+ elif choice == "5":
1162
+ status_command()
1163
+ elif choice == "6":
1164
+ outdated_command()
1165
+ elif choice == "7":
1166
+ update_one(prompt_package())
1167
+ elif choice == "8":
1168
+ update_all()
1169
+ elif choice == "9":
1170
+ remove_command(prompt_package())
1171
+ elif choice == "10":
1172
+ repair_command(prompt_package())
1173
+ elif choice == "11":
1174
+ rollback_command(prompt_package())
1175
+ elif choice == "12":
1176
+ versions_command(prompt_package())
1177
+ elif choice == "13":
1178
+ profile_list()
1179
+ sub = input("Profile (kosong untuk kembali): ").strip()
1180
+ if sub:
1181
+ profile_show(sub)
1182
+ if confirm("Install seluruh profile?"):
1183
+ profile_setup(sub)
1184
+ elif choice == "14":
1185
+ print("a=add, r=remove, l=list")
1186
+ a = input("Aksi: ").strip().lower()
1187
+ if a == "l":
1188
+ favorites_command("list")
1189
+ elif a == "a":
1190
+ favorites_command("add", prompt_package())
1191
+ elif a == "r":
1192
+ favorites_command("remove", prompt_package())
1193
+ elif choice == "15":
1194
+ notifications_command()
1195
+ if confirm("Bersihkan semua notifikasi?"):
1196
+ notifications_command("clear")
1197
+ elif choice == "16":
1198
+ if private_enabled():
1199
+ print(f"Terhubung ke {OWNER}.")
1200
+ if confirm("Logout sesi private BASKA?"):
1201
+ logout_github()
1202
+ else:
1203
+ init_github()
1204
+ elif choice == "17":
1205
+ refresh()
1206
+ elif choice == "18":
1207
+ doctor()
1208
+ elif choice == "19":
1209
+ open_catalog()
1210
+ elif choice == "20":
1211
+ settings_menu()
1212
+ elif choice == "0":
1213
+ return
1214
+ else:
1215
+ print("Pilihan tidak dikenal.")
1216
+ except (KeyboardInterrupt, EOFError):
1217
+ print("\nDibatalkan.")
1218
+ except Exception as exc:
1219
+ print(color("red", f"Error: {exc}"))
1220
+ if choice != "0":
1221
+ input("\nEnter untuk kembali ke dashboard...")
1222
+
1223
+ def build_parser() -> argparse.ArgumentParser:
1224
+ parser = argparse.ArgumentParser(prog="baska", description="BASKA Hub package/repository manager")
1225
+ parser.add_argument("--yes", "-y", action="store_true", help="konfirmasi otomatis untuk operasi yang didukung")
1226
+ sub = parser.add_subparsers(dest="command")
1227
+
1228
+ sub.add_parser("dashboard")
1229
+ sub.add_parser("list")
1230
+ p = sub.add_parser("search"); p.add_argument("term")
1231
+ p = sub.add_parser("info"); p.add_argument("package")
1232
+ p = sub.add_parser("install"); p.add_argument("package")
1233
+ p = sub.add_parser("update"); p.add_argument("package", nargs="?")
1234
+ sub.add_parser("update-all")
1235
+ sub.add_parser("outdated")
1236
+ p = sub.add_parser("remove"); p.add_argument("package")
1237
+ p = sub.add_parser("repair"); p.add_argument("package")
1238
+ p = sub.add_parser("rollback"); p.add_argument("package")
1239
+ p = sub.add_parser("versions"); p.add_argument("package")
1240
+ sub.add_parser("status")
1241
+ sub.add_parser("refresh")
1242
+ sub.add_parser("doctor")
1243
+ sub.add_parser("init")
1244
+ sub.add_parser("logout")
1245
+ sub.add_parser("catalog")
1246
+ sub.add_parser("notifications")
1247
+ p = sub.add_parser("favorite"); p.add_argument("action", choices=["add","remove","list"]); p.add_argument("package", nargs="?")
1248
+ p = sub.add_parser("profile"); p.add_argument("action", choices=["list","show","setup"]); p.add_argument("name", nargs="?")
1249
+ sub.add_parser("version")
1250
+ return parser
1251
+
1252
+ def main() -> None:
1253
+ parser = build_parser()
1254
+ args = parser.parse_args()
1255
+ cmd = args.command
1256
+ if cmd is None or cmd == "dashboard":
1257
+ return dashboard()
1258
+ if cmd == "list":
1259
+ return list_command()
1260
+ if cmd == "search":
1261
+ return search_command(args.term)
1262
+ if cmd == "info":
1263
+ return info_command(args.package)
1264
+ if cmd == "install":
1265
+ return install_command(args.package, assume_yes=args.yes)
1266
+ if cmd == "update":
1267
+ if args.package:
1268
+ return update_one(args.package, assume_yes=args.yes)
1269
+ return refresh()
1270
+ if cmd == "update-all":
1271
+ return update_all(assume_yes=args.yes)
1272
+ if cmd == "outdated":
1273
+ return outdated_command()
1274
+ if cmd == "remove":
1275
+ return remove_command(args.package, assume_yes=args.yes)
1276
+ if cmd == "repair":
1277
+ return repair_command(args.package, assume_yes=args.yes)
1278
+ if cmd == "rollback":
1279
+ return rollback_command(args.package, assume_yes=args.yes)
1280
+ if cmd == "versions":
1281
+ return versions_command(args.package)
1282
+ if cmd == "status":
1283
+ return status_command()
1284
+ if cmd == "refresh":
1285
+ return refresh()
1286
+ if cmd == "doctor":
1287
+ return doctor()
1288
+ if cmd == "init":
1289
+ return init_github()
1290
+ if cmd == "logout":
1291
+ return logout_github()
1292
+ if cmd == "catalog":
1293
+ return open_catalog()
1294
+ if cmd == "notifications":
1295
+ return notifications_command()
1296
+ if cmd == "favorite":
1297
+ return favorites_command(args.action, args.package)
1298
+ if cmd == "profile":
1299
+ if args.action == "list":
1300
+ return profile_list()
1301
+ if not args.name:
1302
+ raise SystemExit("Nama profile diperlukan.")
1303
+ if args.action == "show":
1304
+ return profile_show(args.name)
1305
+ return profile_setup(args.name, assume_yes=args.yes)
1306
+ if cmd == "version":
1307
+ print(VERSION)
1308
+ return
1309
+ parser.print_help()
1310
+
1311
+ if __name__ == "__main__":
1312
+ main()