This commit is contained in:
mjallen18
2026-01-21 21:17:36 -06:00
parent a94e68514a
commit a336b0cf60
2 changed files with 280 additions and 26 deletions

290
scripts/version_tui.py Normal file → Executable file
View File

@@ -184,6 +184,16 @@ def http_get_json(url: str, token: Optional[str] = None) -> Any:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
def http_get_text(url: str) -> Optional[str]:
try:
# Provide a basic User-Agent to avoid some hosts rejecting the request
req = urllib.request.Request(url, headers={"User-Agent": "version-tui/1.0"})
with urllib.request.urlopen(req) as resp:
return resp.read().decode("utf-8")
except Exception as e:
eprintln(f"http_get_text failed for {url}: {e}")
return None
def gh_latest_release(owner: str, repo: str, token: Optional[str]) -> Optional[str]:
try:
data = http_get_json(f"https://api.github.com/repos/{owner}/{repo}/releases/latest", token)
@@ -201,6 +211,14 @@ def gh_latest_tag(owner: str, repo: str, token: Optional[str]) -> Optional[str]:
eprintln(f"latest_tag failed for {owner}/{repo}: {e}")
return None
def gh_list_tags(owner: str, repo: str, token: Optional[str]) -> List[str]:
try:
data = http_get_json(f"https://api.github.com/repos/{owner}/{repo}/tags?per_page=100", token)
return [t.get("name") for t in data if isinstance(t, dict) and "name" in t]
except Exception as e:
eprintln(f"list_tags failed for {owner}/{repo}: {e}")
return []
def gh_head_commit(owner: str, repo: str) -> Optional[str]:
out = run_get_stdout(["git", "ls-remote", f"https://github.com/{owner}/{repo}.git", "HEAD"])
if not out:
@@ -358,11 +376,46 @@ class PackageDetailScreen(ScreenBase):
repo = comp.get("repo")
if owner and repo:
r = gh_latest_release(owner, repo, self.gh_token)
if r: c["release"] = r
if r:
c["release"] = r
t = gh_latest_tag(owner, repo, self.gh_token)
if t: c["tag"] = t
if t:
c["tag"] = t
m = gh_head_commit(owner, repo)
if m: c["commit"] = m
if m:
c["commit"] = m
# Special-case raspberrypi/linux: prefer latest stable_* tag or series-specific tags
try:
if owner == "raspberrypi" and repo == "linux":
tags_all = gh_list_tags(owner, repo, self.gh_token)
rendered = render_templates(comp, self.merged_vars)
cur_tag = str(rendered.get("tag") or "")
# If current tag uses stable_YYYYMMDD scheme, pick latest stable_* tag
if cur_tag.startswith("stable_"):
stable_tags = sorted(
[x for x in tags_all if re.match(r"^stable_\d{8}$", x)],
reverse=True,
)
if stable_tags:
c["tag"] = stable_tags[0]
else:
# Try to pick a tag matching the current major.minor series if available
mm = str(self.merged_vars.get("modDirVersion") or "")
m2 = re.match(r"^(\d+)\.(\d+)", mm)
if m2:
base = f"rpi-{m2.group(1)}.{m2.group(2)}"
series_tags = [x for x in tags_all if (
x == f"{base}.y"
or x.startswith(f"{base}.y")
or x.startswith(f"{base}.")
)]
series_tags.sort(reverse=True)
if series_tags:
c["tag"] = series_tags[0]
except Exception as _e:
# Fallback to previously computed values
pass
elif fetcher == "git":
url = comp.get("url")
if url:
@@ -414,6 +467,126 @@ class PackageDetailScreen(ScreenBase):
return nix_prefetch_url(url)
return None
def cachyos_suffix(self) -> str:
if self.vidx == 0:
return ""
v = self.variants[self.vidx]
mapping = {"rc": "-rc", "hardened": "-hardened", "lts": "-lts"}
return mapping.get(v, "")
def fetch_cachyos_linux_latest(self, suffix: str) -> Optional[str]:
"""
Try to determine latest linux version from upstream:
- Prefer .SRCINFO (preprocessed)
- Fallback to PKGBUILD (parse pkgver= line)
Tries both 'CachyOS' and 'cachyos' org casing just in case.
"""
bases = [
"https://raw.githubusercontent.com/CachyOS/linux-cachyos/master",
"https://raw.githubusercontent.com/cachyos/linux-cachyos/master",
]
paths = [
f"linux-cachyos{suffix}/.SRCINFO",
f"linux-cachyos{suffix}/PKGBUILD",
]
def parse_srcinfo(text: str) -> Optional[str]:
m = re.search(r"^\s*pkgver\s*=\s*([^\s#]+)\s*$", text, re.MULTILINE)
if not m:
return None
v = m.group(1).strip()
return v
def parse_pkgbuild(text: str) -> Optional[str]:
# Parse assignments and expand variables in pkgver
# Build a simple env map from VAR=value lines
env: Dict[str, str] = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
m_assign = re.match(r'^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$', line)
if m_assign:
key = m_assign.group(1)
val = m_assign.group(2).strip()
# Remove trailing comments
val = re.sub(r'\s+#.*$', '', val).strip()
# Strip surrounding quotes
if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
val = val[1:-1]
env[key] = val
m = re.search(r"^\s*pkgver\s*=\s*(.+)$", text, re.MULTILINE)
if not m:
return None
raw = m.group(1).strip()
# Strip quotes
if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
raw = raw[1:-1]
def expand_vars(s: str) -> str:
def repl_braced(mb):
key = mb.group(1)
return env.get(key, mb.group(0))
def repl_unbraced(mu):
key = mu.group(1)
return env.get(key, mu.group(0))
# Expand ${var} then $var
s = re.sub(r"\$\{([^}]+)\}", repl_braced, s)
s = re.sub(r"\$([A-Za-z_][A-Za-z0-9_]*)", repl_unbraced, s)
return s
v = expand_vars(raw).strip()
# normalize rc form like 6.19.rc6 -> 6.19-rc6
v = v.replace(".rc", "-rc")
return v
# Try .SRCINFO first, then PKGBUILD
for base in bases:
# .SRCINFO
url = f"{base}/{paths[0]}"
text = http_get_text(url)
if text:
ver = parse_srcinfo(text)
if ver:
return ver.replace(".rc", "-rc")
# PKGBUILD fallback
url = f"{base}/{paths[1]}"
text = http_get_text(url)
if text:
ver = parse_pkgbuild(text)
if ver:
return ver.replace(".rc", "-rc")
return None
def linux_tarball_url_for_version(self, version: str) -> str:
# Use torvalds snapshot for -rc, stable releases from CDN
if "-rc" in version:
return f"https://git.kernel.org/torvalds/t/linux-{version}.tar.gz"
parts = version.split(".")
major = parts[0] if parts else "6"
major_minor = ".".join(parts[:2]) if len(parts) >= 2 else version
ver_for_tar = major_minor if version.endswith(".0") else version
return f"https://cdn.kernel.org/pub/linux/kernel/v{major}.x/linux-{ver_for_tar}.tar.xz"
def update_linux_from_pkgbuild(self, name: str):
suffix = self.cachyos_suffix()
latest = self.fetch_cachyos_linux_latest(suffix)
if not latest:
self.set_status("linux: failed to get version from PKGBUILD")
return
url = self.linux_tarball_url_for_version(latest)
sri = nix_prefetch_url(url)
if not sri:
self.set_status("linux: prefetch failed")
return
ts = self.target_dict.setdefault("sources", {})
compw = ts.setdefault(name, {})
compw["version"] = latest
compw["hash"] = sri
self.set_status(f"{name}: updated version to {latest} and refreshed hash")
def set_ref(self, name: str, kind: str, value: str):
# Write to selected target dict (base or variant override)
ts = self.target_dict.setdefault("sources", {})
@@ -511,15 +684,32 @@ class PackageDetailScreen(ScreenBase):
elif ch in (ord('r'),):
if self.snames:
name = self.snames[self.sidx]
self.fetch_candidates_for(name)
cand = self.candidates.get(name, {})
lines = [
f"Candidates for {name}:",
f" latest release: {cand.get('release') or '-'}",
f" latest tag : {cand.get('tag') or '-'}",
f" latest commit : {cand.get('commit') or '-'}",
]
show_popup(self.stdscr, lines)
comp = self.merged_srcs[name]
fetcher = comp.get("fetcher", "none")
if self.pkg_name == "linux-cachyos" and name == "linux":
# Show available linux version from upstream PKGBUILD (.SRCINFO)
suffix = self.cachyos_suffix()
latest = self.fetch_cachyos_linux_latest(suffix)
rendered = render_templates(comp, self.merged_vars)
cur_version = str(rendered.get("version") or "")
url_hint = self.linux_tarball_url_for_version(latest) if latest else "-"
lines = [
f"linux-cachyos ({'base' if self.vidx == 0 else self.variants[self.vidx]}):",
f" current : {cur_version or '-'}",
f" available: {latest or '-'}",
f" tarball : {url_hint}",
]
show_popup(self.stdscr, lines)
else:
self.fetch_candidates_for(name)
cand = self.candidates.get(name, {})
lines = [
f"Candidates for {name}:",
f" latest release: {cand.get('release') or '-'}",
f" latest tag : {cand.get('tag') or '-'}",
f" latest commit : {cand.get('commit') or '-'}",
]
show_popup(self.stdscr, lines)
elif ch in (ord('i'),):
# Show full rendered URL for URL-based sources
if self.snames:
@@ -586,7 +776,24 @@ class PackageDetailScreen(ScreenBase):
("Recompute hash", ("hash", None)),
("Cancel", ("cancel", None)),
]
choice = select_menu(self.stdscr, f"Actions for {name}", [label for label, _ in items])
# Build header with current and available refs
rendered = render_templates(comp, self.merged_vars)
cur_tag = rendered.get("tag") or ""
cur_rev = rendered.get("rev") or ""
cur_version = rendered.get("version") or ""
if cur_tag:
current_str = f"current: tag={cur_tag}"
elif cur_rev:
current_str = f"current: rev={cur_rev[:12]}"
elif cur_version:
current_str = f"current: version={cur_version}"
else:
current_str = "current: -"
header_lines = [
current_str,
f"available: release={cand.get('release') or '-'} tag={cand.get('tag') or '-'} commit={(cand.get('commit') or '')[:12] or '-'}",
]
choice = select_menu(self.stdscr, f"Actions for {name}", [label for label, _ in items], header=header_lines)
if choice is not None:
kind, val = items[choice][1]
if kind in ("release", "tag", "commit"):
@@ -621,7 +828,23 @@ class PackageDetailScreen(ScreenBase):
menu_items.append(("Recompute hash (prefetch)", ("hash", None)))
menu_items.append(("Cancel", ("cancel", None)))
choice = select_menu(self.stdscr, f"Actions for {name}", [label for label, _ in menu_items])
# Build header with current and available release info
base = str(self.merged_vars.get("base") or "")
rel = str(self.merged_vars.get("release") or "")
rp = str(self.merged_vars.get("releasePrefix") or "")
rs = str(self.merged_vars.get("releaseSuffix") or "")
current_tag = f"{rp}{base}-{rel}{rs}" if (base and rel) else ""
if current_tag:
current_str = f"current: {current_tag}"
elif base or rel:
current_str = f"current: base={base or '-'} release={rel or '-'}"
else:
current_str = "current: -"
header_lines = [
current_str,
f"available: tag={(cand.get('tag') or '-') if cand else '-'} base={(cand.get('base') or '-') if cand else '-'} release={(cand.get('release') or '-') if cand else '-'}",
]
choice = select_menu(self.stdscr, f"Actions for {name}", [label for label, _ in menu_items], header=header_lines)
if choice is not None:
kind, payload = menu_items[choice][1]
if kind == "update_vars" and isinstance(payload, dict):
@@ -652,19 +875,50 @@ class PackageDetailScreen(ScreenBase):
else:
pass
else:
show_popup(self.stdscr, [f"{name}: fetcher={fetcher}", "Use 'e' to edit fields manually."])
if self.pkg_name == "linux-cachyos" and name == "linux":
# Offer update of linux version from upstream PKGBUILD (.SRCINFO)
suffix = self.cachyos_suffix()
latest = self.fetch_cachyos_linux_latest(suffix)
rendered = render_templates(comp, self.merged_vars)
cur_version = str(rendered.get("version") or "")
header_lines = [
f"current: version={cur_version or '-'}",
f"available: version={latest or '-'}",
]
opts = []
if latest:
opts.append(f"Update linux version to {latest} from PKGBUILD (.SRCINFO)")
else:
opts.append("Update linux version from PKGBUILD (.SRCINFO)")
opts.append("Cancel")
choice = select_menu(self.stdscr, f"Actions for {name}", opts, header=header_lines)
if choice == 0 and latest:
self.update_linux_from_pkgbuild(name)
else:
pass
else:
show_popup(self.stdscr, [f"{name}: fetcher={fetcher}", "Use 'e' to edit fields manually."])
else:
pass
def select_menu(stdscr, title: str, options: List[str]) -> Optional[int]:
def select_menu(stdscr, title: str, options: List[str], header: Optional[List[str]] = None) -> Optional[int]:
idx = 0
while True:
stdscr.clear()
h, w = stdscr.getmaxyx()
stdscr.addstr(0, 0, title[:w-1])
for i, opt in enumerate(options[:h-3], start=0):
y = 1
if header:
for line in header:
if y >= h-2:
break
stdscr.addstr(y, 0, str(line)[:w-1])
y += 1
start_y = y + 1
max_opts = max(0, h - start_y - 1)
for i, opt in enumerate(options[:max_opts], start=0):
sel = ">" if i == idx else " "
stdscr.addstr(2+i, 0, f"{sel} {opt}"[:w-1])
stdscr.addstr(start_y + i, 0, f"{sel} {opt}"[:w-1])
stdscr.addstr(h-1, 0, "Enter: select | Backspace: cancel")
stdscr.refresh()
ch = stdscr.getch()