From 23f5f6c3b31bd5c9490e13025436301480396ada Mon Sep 17 00:00:00 2001 From: mjallen18 Date: Wed, 4 Mar 2026 15:31:10 -0600 Subject: [PATCH] versions --- packages/arm-trusted-firmware/version.json | 2 +- packages/edk2/version.json | 4 +- .../x86_64-linux/cachyos-gcc.x86_64-linux.nix | 4 +- .../cachyos-lto-full.x86_64-linux.nix | 4 +- .../x86_64-linux/cachyos-lto.x86_64-linux.nix | 4 +- .../cachyos-server-lto.x86_64-linux.nix | 4 +- .../cachyos-server.x86_64-linux.nix | 4 +- packages/linux-cachyos/version.json | 28 +- packages/proton-cachyos/version.json | 21 +- scripts/version_tui.py | 1190 +++++++++++------ 10 files changed, 839 insertions(+), 426 deletions(-) diff --git a/packages/arm-trusted-firmware/version.json b/packages/arm-trusted-firmware/version.json index 0b3ca60..0a37567 100644 --- a/packages/arm-trusted-firmware/version.json +++ b/packages/arm-trusted-firmware/version.json @@ -6,7 +6,7 @@ "owner": "ARM-software", "repo": "arm-trusted-firmware", "rev": "1a7dbe28dbefd8bc0461ea623d100bee028529ae", - "hash": "sha256-Yf24Uo+4gA00bsseZxCbph1swIDzcsflUgeadHeFIYI=" + "hash": "sha256-KOCFn7urfQ4vD/hKpnmrxvO+cHn6p2i7s27AzHAIjGU=" } } } diff --git a/packages/edk2/version.json b/packages/edk2/version.json index b369756..708c26b 100644 --- a/packages/edk2/version.json +++ b/packages/edk2/version.json @@ -16,7 +16,7 @@ "repo": "edk2-non-osi", "name": "edk2-non-osi", "rev": "94d048981116e2e3eda52dad1a89958ee404098d", - "hash": "sha256-ki5y2545MJB9J8pJ3HYubLkjbU+6ipCw86Mhvunv7VI=" + "hash": "sha256-6yuvVvmGn4yaEksbbvGDX1ZcKpdWBKnwaNjLGvgAWyk=" }, "edk2-platforms": { "fetcher": "github", @@ -24,7 +24,7 @@ "repo": "edk2-platforms", "name": "edk2-platforms", "rev": "23625e812490e6cf66ab9e74972c6a5129bf3e2a", - "hash": "sha256-8GZ06ozr0lR6y/8CRSJr+XFZ1S35MLYWHEFp7w+kEhU=" + "hash": "sha256-p/y+i5fww2yIUqYCaRljL9rKiqgwBR6pEdRJCWI8XGc=" } }, "variants": { diff --git a/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-gcc.x86_64-linux.nix b/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-gcc.x86_64-linux.nix index 528d306..efd60c8 100644 --- a/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-gcc.x86_64-linux.nix +++ b/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-gcc.x86_64-linux.nix @@ -3248,7 +3248,6 @@ "CONFIG_ATH9K_BTCOEX_SUPPORT" = "y"; "CONFIG_ATH9K" = "m"; "CONFIG_ATH9K_PCI" = "y"; - "CONFIG_ATH9K_AHB" = "y"; "CONFIG_ATH9K_DEBUGFS" = "y"; "CONFIG_ATH9K_STATION_STATISTICS" = "y"; "CONFIG_ATH9K_DYNACK" = "y"; @@ -5424,7 +5423,7 @@ "CONFIG_DVB_PLATFORM_DRIVERS" = "y"; "CONFIG_V4L_MEM2MEM_DRIVERS" = "y"; "CONFIG_VIDEO_MEM2MEM_DEINTERLACE" = "m"; - "CONFIG_AMD_ISP4" = "m"; + "CONFIG_VIDEO_AMD_ISP4_CAPTURE" = "m"; "CONFIG_VIDEO_CADENCE_CSI2RX" = "m"; "CONFIG_VIDEO_CADENCE_CSI2TX" = "m"; "CONFIG_VIDEO_CAFE_CCIC" = "m"; @@ -7815,6 +7814,7 @@ "CONFIG_ASUS_WIRELESS" = "m"; "CONFIG_ASUS_ARMOURY" = "m"; "CONFIG_ASUS_WMI" = "m"; + "CONFIG_ASUS_WMI_DEPRECATED_ATTRS" = "y"; "CONFIG_ASUS_NB_WMI" = "m"; "CONFIG_ASUS_TF103C_DOCK" = "m"; "CONFIG_AYANEO_EC" = "m"; diff --git a/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-lto-full.x86_64-linux.nix b/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-lto-full.x86_64-linux.nix index 855bb0a..974c546 100644 --- a/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-lto-full.x86_64-linux.nix +++ b/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-lto-full.x86_64-linux.nix @@ -3247,7 +3247,6 @@ "CONFIG_ATH9K_BTCOEX_SUPPORT" = "y"; "CONFIG_ATH9K" = "m"; "CONFIG_ATH9K_PCI" = "y"; - "CONFIG_ATH9K_AHB" = "y"; "CONFIG_ATH9K_DEBUGFS" = "y"; "CONFIG_ATH9K_STATION_STATISTICS" = "y"; "CONFIG_ATH9K_DYNACK" = "y"; @@ -5422,7 +5421,7 @@ "CONFIG_DVB_PLATFORM_DRIVERS" = "y"; "CONFIG_V4L_MEM2MEM_DRIVERS" = "y"; "CONFIG_VIDEO_MEM2MEM_DEINTERLACE" = "m"; - "CONFIG_AMD_ISP4" = "m"; + "CONFIG_VIDEO_AMD_ISP4_CAPTURE" = "m"; "CONFIG_VIDEO_CADENCE_CSI2RX" = "m"; "CONFIG_VIDEO_CADENCE_CSI2TX" = "m"; "CONFIG_VIDEO_CAFE_CCIC" = "m"; @@ -7813,6 +7812,7 @@ "CONFIG_ASUS_WIRELESS" = "m"; "CONFIG_ASUS_ARMOURY" = "m"; "CONFIG_ASUS_WMI" = "m"; + "CONFIG_ASUS_WMI_DEPRECATED_ATTRS" = "y"; "CONFIG_ASUS_NB_WMI" = "m"; "CONFIG_ASUS_TF103C_DOCK" = "m"; "CONFIG_AYANEO_EC" = "m"; diff --git a/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-lto.x86_64-linux.nix b/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-lto.x86_64-linux.nix index feb41b0..0381c89 100644 --- a/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-lto.x86_64-linux.nix +++ b/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-lto.x86_64-linux.nix @@ -3247,7 +3247,6 @@ "CONFIG_ATH9K_BTCOEX_SUPPORT" = "y"; "CONFIG_ATH9K" = "m"; "CONFIG_ATH9K_PCI" = "y"; - "CONFIG_ATH9K_AHB" = "y"; "CONFIG_ATH9K_DEBUGFS" = "y"; "CONFIG_ATH9K_STATION_STATISTICS" = "y"; "CONFIG_ATH9K_DYNACK" = "y"; @@ -5422,7 +5421,7 @@ "CONFIG_DVB_PLATFORM_DRIVERS" = "y"; "CONFIG_V4L_MEM2MEM_DRIVERS" = "y"; "CONFIG_VIDEO_MEM2MEM_DEINTERLACE" = "m"; - "CONFIG_AMD_ISP4" = "m"; + "CONFIG_VIDEO_AMD_ISP4_CAPTURE" = "m"; "CONFIG_VIDEO_CADENCE_CSI2RX" = "m"; "CONFIG_VIDEO_CADENCE_CSI2TX" = "m"; "CONFIG_VIDEO_CAFE_CCIC" = "m"; @@ -7813,6 +7812,7 @@ "CONFIG_ASUS_WIRELESS" = "m"; "CONFIG_ASUS_ARMOURY" = "m"; "CONFIG_ASUS_WMI" = "m"; + "CONFIG_ASUS_WMI_DEPRECATED_ATTRS" = "y"; "CONFIG_ASUS_NB_WMI" = "m"; "CONFIG_ASUS_TF103C_DOCK" = "m"; "CONFIG_AYANEO_EC" = "m"; diff --git a/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-server-lto.x86_64-linux.nix b/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-server-lto.x86_64-linux.nix index 18ad54f..bbf2e9c 100644 --- a/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-server-lto.x86_64-linux.nix +++ b/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-server-lto.x86_64-linux.nix @@ -3241,7 +3241,6 @@ "CONFIG_ATH9K_BTCOEX_SUPPORT" = "y"; "CONFIG_ATH9K" = "m"; "CONFIG_ATH9K_PCI" = "y"; - "CONFIG_ATH9K_AHB" = "y"; "CONFIG_ATH9K_DEBUGFS" = "y"; "CONFIG_ATH9K_STATION_STATISTICS" = "y"; "CONFIG_ATH9K_DYNACK" = "y"; @@ -5414,7 +5413,7 @@ "CONFIG_DVB_PLATFORM_DRIVERS" = "y"; "CONFIG_V4L_MEM2MEM_DRIVERS" = "y"; "CONFIG_VIDEO_MEM2MEM_DEINTERLACE" = "m"; - "CONFIG_AMD_ISP4" = "m"; + "CONFIG_VIDEO_AMD_ISP4_CAPTURE" = "m"; "CONFIG_VIDEO_CADENCE_CSI2RX" = "m"; "CONFIG_VIDEO_CADENCE_CSI2TX" = "m"; "CONFIG_VIDEO_CAFE_CCIC" = "m"; @@ -7804,6 +7803,7 @@ "CONFIG_ASUS_WIRELESS" = "m"; "CONFIG_ASUS_ARMOURY" = "m"; "CONFIG_ASUS_WMI" = "m"; + "CONFIG_ASUS_WMI_DEPRECATED_ATTRS" = "y"; "CONFIG_ASUS_NB_WMI" = "m"; "CONFIG_ASUS_TF103C_DOCK" = "m"; "CONFIG_AYANEO_EC" = "m"; diff --git a/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-server.x86_64-linux.nix b/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-server.x86_64-linux.nix index 8ef7ddb..4248367 100644 --- a/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-server.x86_64-linux.nix +++ b/packages/linux-cachyos/config-nix/x86_64-linux/cachyos-server.x86_64-linux.nix @@ -3242,7 +3242,6 @@ "CONFIG_ATH9K_BTCOEX_SUPPORT" = "y"; "CONFIG_ATH9K" = "m"; "CONFIG_ATH9K_PCI" = "y"; - "CONFIG_ATH9K_AHB" = "y"; "CONFIG_ATH9K_DEBUGFS" = "y"; "CONFIG_ATH9K_STATION_STATISTICS" = "y"; "CONFIG_ATH9K_DYNACK" = "y"; @@ -5416,7 +5415,7 @@ "CONFIG_DVB_PLATFORM_DRIVERS" = "y"; "CONFIG_V4L_MEM2MEM_DRIVERS" = "y"; "CONFIG_VIDEO_MEM2MEM_DEINTERLACE" = "m"; - "CONFIG_AMD_ISP4" = "m"; + "CONFIG_VIDEO_AMD_ISP4_CAPTURE" = "m"; "CONFIG_VIDEO_CADENCE_CSI2RX" = "m"; "CONFIG_VIDEO_CADENCE_CSI2TX" = "m"; "CONFIG_VIDEO_CAFE_CCIC" = "m"; @@ -7806,6 +7805,7 @@ "CONFIG_ASUS_WIRELESS" = "m"; "CONFIG_ASUS_ARMOURY" = "m"; "CONFIG_ASUS_WMI" = "m"; + "CONFIG_ASUS_WMI_DEPRECATED_ATTRS" = "y"; "CONFIG_ASUS_NB_WMI" = "m"; "CONFIG_ASUS_TF103C_DOCK" = "m"; "CONFIG_AYANEO_EC" = "m"; diff --git a/packages/linux-cachyos/version.json b/packages/linux-cachyos/version.json index 6f3df27..6ebb02e 100644 --- a/packages/linux-cachyos/version.json +++ b/packages/linux-cachyos/version.json @@ -6,48 +6,44 @@ "sources": { "linux": { "fetcher": "none", - "version": "6.19.3", - "hash": "sha256-DkdJaK38vuMpFv0BqJ2Mz9EWjY0yVp52pcZkx5MZjr4=" + "version": "6.19.5", + "hash": "sha256-la4FyMcJ41PA6FBsBy78VZjYW4t7Vkoeusfug0UEL/o=" }, "config": { "fetcher": "github", "owner": "CachyOS", "repo": "linux-cachyos", - "rev": "39737576a25091a3c4ca00729b769a1f92ec98d5", - "hash": "sha256-+zDtnmXNyMd3hMepErdPDZzqYS0PiZA0Anbbx9Pvs4g=" + "rev": "4a363451cc86ff5304514c8bf25eac42eb46b8c8", + "hash": "sha256-jIQpfzcPBXe1URbf82p/9JxJguZuZZBlMJnW1x7B5jE=" }, "patches": { "fetcher": "github", "owner": "CachyOS", "repo": "kernel-patches", - "rev": "505aef2086e584ba683a5ac1cb8ed8252fea2cfd", - "hash": "sha256-SuockPZgd2bfjWGmdT8AUBTnBZWvxdA+b8Ss98lNC6c=" + "rev": "088c9b4ef9fa9ea661c261c4ec77cabb49dd6c02", + "hash": "sha256-nKPjfdjWwuXqKd6miyjSu5KMxu6yJ6qx+K1P7QPVakk=" }, "zfs": { "fetcher": "git", "url": "https://github.com/cachyos/zfs.git", - "rev": "3bf17cf5387fa5e0044a6321e663aead38b45969", - "hash": "sha256-ZYN+l7iaIkC+Lma1QT96iAwHt+1bLEmiHQFgTk8/8B0=" + "rev": "1c702dda346a59e05cfd3029569bbb1d5d91c54b", + "hash": "sha256-gapM2PNVOjhwGw6TAZF6QDxLza7oqOf1tpj7q0EN9Vg=" } }, "variants": { "lts": { "sources": { "linux": { - "version": "6.18.13", - "hash": "sha256-7Sw8Vf045oNsCU/ONW8lZ/lRYTC3M1SimFeWA2jFaH8=" + "version": "6.18.16", + "hash": "sha256-TyHAH00EwdGz7XlBU/iQCALJJJe+YgsHxIaVMPLSjuM=" } } }, "rc": { "sources": { "linux": { - "version": "7.0-rc1", - "hash": "sha256-bWsr0T/pXqSMSQHKW0Ueu8U2xjAQSdH7cFaNXYdWqts=" - }, - "zfs": { - "rev": "3bf17cf5387fa5e0044a6321e663aead38b45969", - "hash": "sha256-KaN24a1nXwOoHahglRWSypqxlE5jM1uZVIOVd1CDrqQ=" + "version": "7.0-rc2", + "hash": "sha256-BlKlJdEYvwDN6iWJfuOvd1gcm6lN6McJ/vmMwOmzHdc=" }, "config": { "rev": "a66bf7797191c614066a517921246ced3b263434", diff --git a/packages/proton-cachyos/version.json b/packages/proton-cachyos/version.json index 5a8f344..28b29ff 100644 --- a/packages/proton-cachyos/version.json +++ b/packages/proton-cachyos/version.json @@ -6,8 +6,7 @@ "repo": "proton-cachyos", "releasePrefix": "cachyos-", "releaseSuffix": "-slr", - "tarballPrefix": "proton-", - "tarballSuffix": "-x86_64_v4.tar.xz" + "tarballPrefix": "proton-" }, "sources": { "proton": { @@ -19,7 +18,8 @@ "cachyos": { "variables": { "base": "10.0", - "release": "20260227" + "release": "20260227", + "tarballSuffix": "-x86_64.tar.xz" }, "sources": { "proton": { @@ -30,7 +30,8 @@ "cachyos-v2": { "variables": { "base": "10.0", - "release": "20260227" + "release": "20260227", + "tarballSuffix": "-x86_64_v2.tar.xz" }, "sources": { "proton": { @@ -41,7 +42,8 @@ "cachyos-v3": { "variables": { "base": "10.0", - "release": "20260227" + "release": "20260227", + "tarballSuffix": "-x86_64_v3.tar.xz" }, "sources": { "proton": { @@ -52,7 +54,8 @@ "cachyos-v4": { "variables": { "base": "10.0", - "release": "20260227" + "release": "20260227", + "tarballSuffix": "-x86_64_v4.tar.xz" }, "sources": { "proton": { @@ -63,7 +66,8 @@ "ge": { "variables": { "base": "10", - "release": "26" + "release": "26", + "tarballSuffix": "-x86_64_v4.tar.xz" }, "sources": { "proton": { @@ -73,6 +77,7 @@ } }, "notes": { - "consumption": "default.nix currently computes the URL from base/release and suffixes. With this schema, keep using those variables (variant.variables.base/release) and the per-variant proton.hash until migrated to an explicit urlTemplate." + "consumption": "default.nix reads base/release/tarballSuffix from the selected variant's variables. The urlTemplate uses ${tarballSuffix} which each variant overrides to point at the correct CPU-optimised tarball.", + "ge": "The ge variant tracks GE-Proton (base=major, release=minor) but uses the CachyOS release infrastructure. Verify the release tag and URL are correct before updating." } } diff --git a/scripts/version_tui.py b/scripts/version_tui.py index c18f7dc..4ed8128 100755 --- a/scripts/version_tui.py +++ b/scripts/version_tui.py @@ -46,6 +46,7 @@ import sys import traceback import urllib.request import urllib.error +import urllib.parse from urllib.parse import urlparse from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Union @@ -171,9 +172,55 @@ def run_get_stdout(args: List[str]) -> Optional[str]: return out +def _nix_fakeHash_build(expr: str) -> Optional[str]: + """ + Run `nix build --impure --expr expr` with lib.fakeHash and parse the correct + hash from the 'got:' line in nix's error output. + Returns the SRI hash string, or None on failure. + """ + p = subprocess.run( + ["nix", "build", "--impure", "--expr", expr], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + m = re.search(r"got:\s+(sha256-[A-Za-z0-9+/=]+)", p.stderr) + if m: + return m.group(1) + eprintln(f"nix fakeHash build failed:\n{p.stderr[-600:]}") + return None + + +def nix_prefetch_github( + owner: str, repo: str, rev: str, submodules: bool = False +) -> Optional[str]: + """ + Compute the hash that pkgs.fetchFromGitHub expects for the given revision. + Uses nix build with lib.fakeHash to get the exact NAR hash of the unpacked + tarball, which is what Nix stores and validates against. + """ + sub = "true" if submodules else "false" + expr = ( + f"let pkgs = import {{}};\n" + f"in pkgs.fetchFromGitHub {{\n" + f' owner = "{owner}";\n' + f' repo = "{repo}";\n' + f' rev = "{rev}";\n' + f" fetchSubmodules = {sub};\n" + f" hash = pkgs.lib.fakeHash;\n" + f"}}" + ) + return _nix_fakeHash_build(expr) + + def nix_prefetch_url(url: str) -> Optional[str]: - # returns SRI - # Try modern nix store prefetch-file first (it returns SRI format directly) + """ + Compute the flat (non-unpacked) hash for a fetchurl source. + Uses nix store prefetch-file which gives the same hash as pkgs.fetchurl. + Do NOT use this for fetchFromGitHub or fetchzip — those need the NAR hash + of the unpacked content (use nix_prefetch_github or nix_prefetch_fetchzip). + """ out = run_get_stdout( ["nix", "store", "prefetch-file", "--hash-type", "sha256", "--json", url] ) @@ -192,43 +239,64 @@ def nix_prefetch_url(url: str) -> Optional[str]: if out is None: return None - # Convert to SRI sri = run_get_stdout(["nix", "hash", "to-sri", "--type", "sha256", out.strip()]) return sri +def nix_prefetch_fetchzip(url: str, strip_root: bool = True) -> Optional[str]: + """ + Compute the hash that pkgs.fetchzip expects for the given URL. + fetchzip hashes the NAR of the UNPACKED archive, which differs from the + flat hash of the zip file itself. Uses nix build with lib.fakeHash. + """ + strip_attr = "true" if strip_root else "false" + expr = ( + f"let pkgs = import {{}};\n" + f"in pkgs.fetchzip {{\n" + f' url = "{url}";\n' + f" stripRoot = {strip_attr};\n" + f" hash = pkgs.lib.fakeHash;\n" + f"}}" + ) + return _nix_fakeHash_build(expr) + + def nix_prefetch_git(url: str, rev: str) -> Optional[str]: - # Try two-step approach: fetchGit then hash path - expr = f'builtins.fetchGit {{ url = "{url}"; rev = "{rev}"; }}' - out = run_get_stdout(["nix", "eval", "--raw", "--expr", expr]) - if out is not None and out.strip(): - # Now hash the fetched path - hash_out = run_get_stdout( - ["nix", "hash", "path", "--type", "sha256", out.strip()] - ) - if hash_out is not None and hash_out.strip(): - return hash_out.strip() - - # Fallback to nix-prefetch-git + """ + Compute the hash that pkgs.fetchgit expects. + Uses nix-prefetch-git as the primary method (reliable for both commit SHAs + and tag names). Falls back to builtins.fetchGit + nix hash path for commit + SHAs when nix-prefetch-git is unavailable. + """ + # Primary: nix-prefetch-git (works for both commit SHAs and tag names) out = run_get_stdout(["nix-prefetch-git", "--no-deepClone", "--rev", rev, url]) - if out is None: - return None + if out is not None: + base32 = None + try: + data = json.loads(out) + base32 = data.get("sha256") or data.get("hash") + except Exception: + lines = [l for l in out.splitlines() if l.strip()] + if lines: + base32 = lines[-1].strip() + if base32: + sri = run_get_stdout(["nix", "hash", "to-sri", "--type", "sha256", base32]) + if sri: + return sri - base32 = None - try: - data = json.loads(out) - base32 = data.get("sha256") or data.get("hash") - except Exception: - lines = [l for l in out.splitlines() if l.strip()] - if lines: - base32 = lines[-1].strip() + # Fallback: builtins.fetchGit + nix hash path (commit SHA only — tag names fail) + # Only attempt if rev looks like a commit SHA (40 hex chars) + if re.match(r"^[0-9a-f]{40}$", rev): + expr = f'builtins.fetchGit {{ url = "{url}"; rev = "{rev}"; }}' + store_path = run_get_stdout(["nix", "eval", "--raw", "--expr", expr]) + if store_path is not None and store_path.strip(): + hash_out = run_get_stdout( + ["nix", "hash", "path", "--type", "sha256", store_path.strip()] + ) + if hash_out is not None and hash_out.strip(): + return hash_out.strip() - if not base32: - return None - - # Convert to SRI - sri = run_get_stdout(["nix", "hash", "to-sri", "--type", "sha256", base32]) - return sri + return None def nix_prefetch_cargo_vendor( @@ -407,6 +475,74 @@ def gh_head_commit( return None +def _iso_to_date(iso: str) -> str: + """Convert an ISO-8601 timestamp like '2026-03-02T13:32:38Z' to 'YYYY-MM-DD'.""" + if iso and len(iso) >= 10: + return iso[:10] + return "" + + +def gh_ref_date(owner: str, repo: str, ref: str, token: Optional[str]) -> str: + """ + Return the committer date (YYYY-MM-DD) for any ref on a GitHub repo. + Works for commit SHAs, tag names, and branch names. + Returns empty string on failure. + """ + try: + data = http_get_json( + f"https://api.github.com/repos/{owner}/{repo}/commits/{urllib.parse.quote(ref, safe='')}", + token, + ) + if not isinstance(data, dict): + return "" + iso = ( + data.get("commit", {}).get("committer", {}).get("date") + or data.get("commit", {}).get("author", {}).get("date") + or "" + ) + return _iso_to_date(iso) + except Exception: + return "" + + +def gh_release_date(owner: str, repo: str, tag: str, token: Optional[str]) -> str: + """ + Return the published date (YYYY-MM-DD) for a GitHub release by tag name. + Falls back to gh_ref_date if the release is not found. + """ + try: + data = http_get_json( + f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{urllib.parse.quote(tag, safe='')}", + token, + ) + if isinstance(data, dict): + iso = data.get("published_at") or data.get("created_at") or "" + if iso: + return _iso_to_date(iso) + except Exception: + pass + return gh_ref_date(owner, repo, tag, token) + + +def git_commit_date(url: str, sha: str) -> str: + """ + Return the committer date (YYYY-MM-DD) for a commit SHA on a plain git repo. + Only works for GitHub URLs (uses the REST API). Returns '' for others. + """ + try: + parsed = urlparse(url) + if parsed.hostname != "github.com": + return "" + parts = [p for p in parsed.path.split("/") if p] + if len(parts) < 2: + return "" + owner = parts[0] + repo = parts[1].removesuffix(".git") # strip .git suffix if present + return gh_ref_date(owner, repo, sha, None) + except Exception: + return "" + + def git_branch_commit(url: str, branch: Optional[str] = None) -> Optional[str]: """Return the latest commit SHA for a git URL, optionally restricted to a branch.""" try: @@ -888,6 +1024,77 @@ def update_homeassistant_component( return modified +# ------------------------------ Display helpers ------------------------------ + + +def source_display_ref(comp: Dict[str, Any], merged_vars: Dict[str, Any]) -> str: + """ + Build a concise human-readable reference string for a source component. + + Rules per fetcher: + github -> owner/repo@tag or owner/repo@rev[:7] (fully rendered) + git -> tag-or-rev[:12] (commit SHAs truncated) + url -> owner/repo@release-tag · filename (when vars are resolved) + filename only (when no release tag) + urlTemplate pattern (when vars still unresolved) + none -> version field or empty + """ + fetcher = comp.get("fetcher", "none") + rendered = render_templates(comp, merged_vars) + + if fetcher == "github": + tag = rendered.get("tag") or "" + rev = rendered.get("rev") or "" + owner = rendered.get("owner") or str(merged_vars.get("owner") or "") + repo = rendered.get("repo") or str(merged_vars.get("repo") or "") + if tag and owner and repo: + return f"{owner}/{repo}@{tag}" + if tag: + return tag + if rev and owner and repo: + return f"{owner}/{repo}@{rev[:7]}" + if rev: + return rev[:12] + return "" + + if fetcher == "git": + ref = rendered.get("tag") or rendered.get("rev") or comp.get("version") or "" + # Truncate bare commit SHAs to 12 chars; keep short tags intact + if len(ref) == 40 and all(c in "0123456789abcdef" for c in ref): + return ref[:12] + return ref + + if fetcher == "url": + url = rendered.get("url") or rendered.get("urlTemplate") or "" + if not url: + return "" + # If the rendered URL still contains unresolved ${…} templates, show the + # filename portion with remaining placeholders rendered as so the + # user sees a meaningful pattern rather than literal '${base}' strings. + if "${" in url: + tmpl = comp.get("urlTemplate") or comp.get("url") or url + filename = os.path.basename(urlparse(tmpl).path) if tmpl else tmpl + # Replace remaining ${var} with for readability + filename = re.sub(r"\$\{([^}]+)\}", r"<\1>", filename) + return filename + owner = str(merged_vars.get("owner") or "") + repo = str(merged_vars.get("repo") or "") + rp = str(merged_vars.get("releasePrefix") or "") + rs = str(merged_vars.get("releaseSuffix") or "") + base = str(merged_vars.get("base") or "") + rel = str(merged_vars.get("release") or "") + tag = f"{rp}{base}-{rel}{rs}" if (base and rel) else "" + filename = os.path.basename(urlparse(url).path) if url else "" + if owner and repo and tag and filename: + return f"{owner}/{repo}@{tag} · {filename}" + if filename: + return filename + return url + + # none / pypi / unknown – fall back to version or empty + return str(comp.get("version") or comp.get("tag") or comp.get("rev") or "") + + # ------------------------------ TUI helpers ------------------------------ # Define color pairs @@ -899,6 +1106,7 @@ COLOR_ERROR = 5 COLOR_SUCCESS = 6 COLOR_BORDER = 7 COLOR_TITLE = 8 +COLOR_DIM = 9 # muted text used for dates / secondary info def init_colors(): @@ -915,6 +1123,8 @@ def init_colors(): curses.init_pair(COLOR_SUCCESS, curses.COLOR_GREEN, -1) curses.init_pair(COLOR_BORDER, curses.COLOR_BLUE, -1) curses.init_pair(COLOR_TITLE, curses.COLOR_MAGENTA, -1) + # Dim colour for secondary info like dates (white + A_DIM applied at render time) + curses.init_pair(COLOR_DIM, curses.COLOR_WHITE, -1) def draw_border(win, y, x, h, w): @@ -963,13 +1173,7 @@ class ScreenBase: self.status[: max(0, width - 1)], curses.color_pair(color), ) - else: - self.stdscr.addstr( - height - 1, - 0, - "q: quit, Backspace: back, Enter: select", - curses.color_pair(COLOR_STATUS), - ) + # else: leave the bottom line blank when no status message def set_status(self, text: str, status_type="normal"): self.status = text @@ -1045,32 +1249,32 @@ class PackagesScreen(ScreenBase): if right_w >= 20: draw_border(self.stdscr, 0, right_x, h - 1, right_w) - # Left pane: package list - title = "Packages" + # Filter packages based on mode if self.filter_mode == "regular": - title = "Packages (version.json)" + filtered_packages = [p for p in self.packages if not p[2] and not p[3]] elif self.filter_mode == "python": - title = "Python Packages" + filtered_packages = [p for p in self.packages if p[2]] + elif self.filter_mode == "homeassistant": + filtered_packages = [p for p in self.packages if p[3]] else: - title = "All Packages [f to filter]" + filtered_packages = self.packages - # Center the title in the left pane - title_x = (left_w - len(title)) // 2 + # Left pane title with count and active filter + count = len(filtered_packages) + if self.filter_mode == "regular": + title = f"Packages [{count}] f:filter" + elif self.filter_mode == "python": + title = f"Python [{count}] f:filter" + elif self.filter_mode == "homeassistant": + title = f"Home Assistant [{count}] f:filter" + else: + title = f"All Packages [{count}] f:filter" + + title_x = max(1, (left_w - len(title)) // 2) self.stdscr.addstr( 0, title_x, f" {title} ", curses.color_pair(COLOR_TITLE) | curses.A_BOLD ) - # Filter packages based on mode - filtered_packages = self.packages - if self.filter_mode == "regular": - filtered_packages = [ - p for p in self.packages if not p[2] - ] # Not Python packages - elif self.filter_mode == "python": - filtered_packages = [ - p for p in self.packages if p[2] - ] # Only Python packages - # Implement scrolling for long lists max_rows = h - 3 total_packages = len(filtered_packages) @@ -1111,15 +1315,16 @@ class PackagesScreen(ScreenBase): attr = curses.color_pair(COLOR_NORMAL) sel = " " - # Add a small icon for Python packages or Home Assistant components + # Type badge: [py] [ha] shown in a fixed column before name if is_python: - pkg_type = "🐍 " # Python icon + badge = "[py]" elif is_homeassistant: - pkg_type = "🏠 " # Home Assistant icon + badge = "[ha]" + else: + badge = " " - self.stdscr.addstr( - 1 + i, 2, f"{sel} {pkg_type}{name}"[: max(0, left_w - 5)], attr - ) + name_col_w = max(0, left_w - 9) + self.stdscr.addstr(1 + i, 2, f"{sel} {badge} {name[:name_col_w]}", attr) # Right pane: preview of selected package (non-interactive summary) if right_w >= 20 and filtered_packages: @@ -1128,24 +1333,29 @@ class PackagesScreen(ScreenBase): self.idx ] - # Center the package name in the right pane header - title_x = right_x + (right_w - len(name)) // 2 + # Right pane header: package name centred + type_badge = ( + " [py]" if is_python else (" [ha]" if is_homeassistant else "") + ) + hdr = f" {name}{type_badge} " + title_x = right_x + max(1, (right_w - len(hdr)) // 2) self.stdscr.addstr( 0, title_x, - f" {name} ", + hdr[: max(0, right_w - 2)], curses.color_pair(COLOR_TITLE) | curses.A_BOLD, ) - # Path with a nice label - self.stdscr.addstr( - 1, right_x + 2, "Path:", curses.color_pair(COLOR_HEADER) - ) + # Show path relative to /etc/nixos + try: + rel_path = str(path.relative_to(Path("/etc/nixos"))) + except ValueError: + rel_path = str(path) self.stdscr.addstr( 1, - right_x + 8, - f"{path}"[: max(0, right_w - 10)], - curses.color_pair(COLOR_NORMAL), + right_x + 2, + rel_path[: max(0, right_w - 3)], + curses.color_pair(COLOR_NORMAL) | curses.A_DIM, ) # Sources header @@ -1165,66 +1375,16 @@ class PackagesScreen(ScreenBase): for i2, sname in enumerate(snames[:max_src_rows], start=0): comp = merged_srcs[sname] fetcher = comp.get("fetcher", "none") - # Construct concise reference similar to detail view - display_ref = ( - comp.get("tag") - or comp.get("rev") - or comp.get("version") - or "" - ) - if fetcher == "github": - rendered = render_templates(comp, merged_vars) - tag = rendered.get("tag") - rev = rendered.get("rev") - owner = ( - rendered.get("owner") or merged_vars.get("owner") or "" - ) - repo = rendered.get("repo") or merged_vars.get("repo") or "" - if tag and owner and repo: - display_ref = f"{owner}/{repo}@{tag}" - elif tag: - display_ref = tag - elif rev and owner and repo: - display_ref = f"{owner}/{repo}@{rev[:7]}" - elif rev: - display_ref = rev[:12] - elif fetcher == "url": - rendered = render_templates(comp, merged_vars) - url = ( - rendered.get("url") or rendered.get("urlTemplate") or "" - ) - if url: - owner = str(merged_vars.get("owner", "") or "") - repo = str(merged_vars.get("repo", "") or "") - rp = str(merged_vars.get("releasePrefix", "") or "") - rs = str(merged_vars.get("releaseSuffix", "") or "") - base = str(merged_vars.get("base", "") or "") - rel = str(merged_vars.get("release", "") or "") - tag = f"{rp}{base}-{rel}{rs}" if (base and rel) else "" - parsed = urlparse(url) - filename = ( - os.path.basename(parsed.path) - if parsed and parsed.path - else "" - ) - if owner and repo and tag and filename: - display_ref = f"{owner}/{repo}@{tag} · {filename}" - elif filename: - display_ref = filename - else: - display_ref = url - else: - display_ref = "" - # Truncate reference to fit right pane - if isinstance(display_ref, str): - max_ref = max(0, right_w - 30) - ref_short = display_ref[:max_ref] + ( - "..." if len(display_ref) > max_ref else "" - ) - else: - ref_short = display_ref + display_ref = source_display_ref(comp, merged_vars) + + # Column layout: name(16) fetcher(7) ref(rest) + NAME_W, FETCH_W = 16, 7 + ref_col = right_x + 2 + NAME_W + 1 + FETCH_W + 1 + max_ref = max(0, right_w - (NAME_W + 1 + FETCH_W + 1) - 3) + ref_short = display_ref[:max_ref] + ( + "..." if len(display_ref) > max_ref else "" + ) - # Color-code the fetcher type fetcher_color = COLOR_NORMAL if fetcher == "github": fetcher_color = COLOR_SUCCESS @@ -1233,38 +1393,33 @@ class PackagesScreen(ScreenBase): elif fetcher == "git": fetcher_color = COLOR_HEADER - # Display source name self.stdscr.addstr( 3 + i2, right_x + 2, - f"{sname:<18}", + f"{sname[:NAME_W]:<{NAME_W}}", curses.color_pair(COLOR_NORMAL), ) - - # Display fetcher with color self.stdscr.addstr( 3 + i2, - right_x + 21, - f"{fetcher:<7}", + right_x + 2 + NAME_W + 1, + f"{fetcher[:FETCH_W]:<{FETCH_W}}", curses.color_pair(fetcher_color), ) - - # Display reference self.stdscr.addstr( 3 + i2, - right_x + 29, - f"{ref_short}"[: max(0, right_w - 31)], + ref_col, + ref_short[: max(0, right_w - (ref_col - right_x) - 1)], curses.color_pair(COLOR_NORMAL), ) - # Hint line for workflow - hint = "Enter: open details | k/j: move | q: quit" - if h >= 5: - hint_x = right_x + (right_w - len(hint)) // 2 + # Hint line just above the bottom border + hint = "Enter: details k/j: move f: filter q: quit" + if h >= 4: + hint_x = right_x + max(1, (right_w - len(hint)) // 2) self.stdscr.addstr( - h - 5, + h - 2, hint_x, - hint[: max(0, right_w - 1)], + hint[: max(0, right_w - 2)], curses.color_pair(COLOR_STATUS), ) except Exception as e: @@ -1296,14 +1451,13 @@ class PackagesScreen(ScreenBase): elif ch == ord("G"): # Go to bottom self.idx = max(0, len(filtered_packages) - 1) elif ch == ord("f"): - # Cycle through filter modes - if self.filter_mode == "all": - self.filter_mode = "regular" - elif self.filter_mode == "regular": - self.filter_mode = "python" - else: - self.filter_mode = "all" - self.idx = 0 # Reset selection when changing filters + # Cycle: all -> regular -> python -> homeassistant -> all + modes = ["all", "regular", "python", "homeassistant"] + self.filter_mode = modes[ + (modes.index(self.filter_mode) + 1) % len(modes) + ] + self.idx = 0 + self.scroll_offset = 0 elif ch in (curses.KEY_ENTER, 10, 13): filtered_packages = self.packages if self.filter_mode == "regular": @@ -1353,8 +1507,16 @@ class PackageDetailScreen(ScreenBase): self.spec = spec self.is_python = is_python self.is_homeassistant = is_homeassistant - self.variants = [""] + sorted(list(self.spec.get("variants", {}).keys())) - self.vidx = 0 + # Preserve JSON insertion order for variants (do not sort alphabetically) + self.variants = [""] + list(self.spec.get("variants", {}).keys()) + # Honour the spec's defaultVariant — pre-select it so the user lands on a + # meaningful view immediately (e.g. proton-cachyos opens at cachyos-v4, not + # the uninformative which has no base/release variables populated). + default = self.spec.get("defaultVariant") + if default and default in self.variants: + self.vidx = self.variants.index(default) + else: + self.vidx = 0 self.gh_token = os.environ.get("GITHUB_TOKEN") self.candidates: Dict[ str, Dict[str, str] @@ -1388,7 +1550,14 @@ class PackageDetailScreen(ScreenBase): comp = self.merged_srcs[name] fetcher = comp.get("fetcher", "none") branch = comp.get("branch") or None # optional branch override - c = {"release": "", "tag": "", "commit": ""} + c: Dict[str, str] = { + "release": "", + "tag": "", + "commit": "", + "release_date": "", + "tag_date": "", + "commit_date": "", + } if fetcher == "github": owner = comp.get("owner") repo = comp.get("repo") @@ -1398,13 +1567,18 @@ class PackageDetailScreen(ScreenBase): r = gh_latest_release(owner, repo, self.gh_token) if r: c["release"] = r + c["release_date"] = gh_release_date( + owner, repo, r, self.gh_token + ) t = gh_latest_tag(owner, repo, self.gh_token) if t: c["tag"] = t + c["tag_date"] = gh_ref_date(owner, repo, t, self.gh_token) m = gh_head_commit(owner, repo, branch) if m: c["commit"] = m + c["commit_date"] = gh_ref_date(owner, repo, m, self.gh_token) # Special-case raspberrypi/linux: prefer latest stable_* tag or series-specific tags # (only when not branch-locked, as branch-locked tracks a rolling branch via commit) @@ -1425,7 +1599,12 @@ class PackageDetailScreen(ScreenBase): reverse=True, ) if stable_tags: - c["tag"] = stable_tags[0] + new_tag = stable_tags[0] + if new_tag != c["tag"]: + c["tag"] = new_tag + c["tag_date"] = gh_ref_date( + owner, repo, new_tag, self.gh_token + ) else: # Try to pick a tag matching the current major.minor series if available mm = str(self.merged_vars.get("modDirVersion") or "") @@ -1443,16 +1622,35 @@ class PackageDetailScreen(ScreenBase): ] series_tags.sort(reverse=True) if series_tags: - c["tag"] = series_tags[0] + new_tag = series_tags[0] + if new_tag != c["tag"]: + c["tag"] = new_tag + c["tag_date"] = gh_ref_date( + owner, repo, new_tag, self.gh_token + ) except Exception as _e: # Fallback to previously computed values pass elif fetcher == "git": url = comp.get("url") if url: - commit = git_branch_commit(url, branch) - if commit: - c["commit"] = commit + # Special-case: CachyOS ZFS — read commit from PKGBUILD rather than + # tracking HEAD of the repo (the repo has many branches and HEAD is + # not necessarily what the kernel package uses). + if ( + self.pkg_name == "linux-cachyos" + and name == "zfs" + and "cachyos/zfs" in url + ): + pkgbuild_commit = self.fetch_cachyos_zfs_commit() + if pkgbuild_commit: + c["commit"] = pkgbuild_commit + c["commit_date"] = git_commit_date(url, pkgbuild_commit) + else: + commit = git_branch_commit(url, branch) + if commit: + c["commit"] = commit + c["commit_date"] = git_commit_date(url, commit) elif fetcher == "url": # Heuristic for GitHub release assets with variables in version.json (e.g., proton-cachyos) owner = self.merged_vars.get("owner") @@ -1471,6 +1669,9 @@ class PackageDetailScreen(ScreenBase): ) if latest: c["release"] = latest + c["release_date"] = gh_release_date( + str(owner), str(repo), latest, self.gh_token + ) mid = latest if prefix and mid.startswith(prefix): mid = mid[len(prefix) :] @@ -1494,9 +1695,11 @@ class PackageDetailScreen(ScreenBase): repo = comp.get("repo") rendered = render_templates(comp, self.merged_vars) ref = rendered.get("tag") or rendered.get("rev") + submodules = bool(comp.get("submodules", False)) if owner and repo and ref: - url = gh_tarball_url(owner, repo, ref) - return nix_prefetch_url(url) + # fetchFromGitHub hashes the NAR of the unpacked tarball, not the + # raw tarball file — must use nix_prefetch_github (fakeHash method). + return nix_prefetch_github(owner, repo, ref, submodules=submodules) elif fetcher == "git": url = comp.get("url") rev = comp.get("rev") @@ -1506,7 +1709,13 @@ class PackageDetailScreen(ScreenBase): rendered = render_templates(comp, self.merged_vars) url = rendered.get("url") or rendered.get("urlTemplate") if url: - return nix_prefetch_url(url) + extra = comp.get("extra") or {} + if extra.get("unpack") == "zip": + # fetchzip hashes the NAR of extracted content, not the zip file. + strip_root = extra.get("stripRoot", True) + return nix_prefetch_fetchzip(url, strip_root=strip_root) + else: + return nix_prefetch_url(url) return None def prefetch_cargo_hash_for(self, name: str) -> Optional[str]: @@ -1686,6 +1895,31 @@ class PackageDetailScreen(ScreenBase): 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 fetch_cachyos_zfs_commit(self) -> Optional[str]: + """ + Read the CachyOS PKGBUILD for the current variant and extract the ZFS + commit SHA from the source line: + git+https://github.com/cachyos/zfs.git#commit= + Returns the commit SHA string, or None on failure. + """ + suffix = self.cachyos_suffix() + bases = [ + "https://raw.githubusercontent.com/CachyOS/linux-cachyos/master", + "https://raw.githubusercontent.com/cachyos/linux-cachyos/master", + ] + for base in bases: + url = f"{base}/linux-cachyos{suffix}/PKGBUILD" + text = http_get_text(url) + if not text: + continue + m = re.search( + r"git\+https://github\.com/cachyos/zfs\.git#commit=([0-9a-f]+)", + text, + ) + if m: + return m.group(1) + return None + def update_linux_from_pkgbuild(self, name: str): suffix = self.cachyos_suffix() latest = self.fetch_cachyos_linux_latest(suffix) @@ -1704,6 +1938,105 @@ class PackageDetailScreen(ScreenBase): self._refresh_merged() self.set_status(f"{name}: updated version to {latest} and refreshed hash") + def _cachyos_config_nix_dir(self) -> Optional[Path]: + """Return the config-nix/x86_64-linux dir relative to this package.""" + d = self.path.parent / "config-nix" / "x86_64-linux" + return d if d.is_dir() else None + + def _cachyos_regen_flavors(self) -> List[str]: + """ + Parse regen-config.sh to extract the flavor list, so TUI stays in sync + with whatever the script defines. Falls back to a hard-coded default. + """ + script = self.path.parent / "regen-config.sh" + if script.exists(): + text = script.read_text() + # Match: for flavor in cachyos{-a,-b,...}; do OR for flavor in a b c; do + m = re.search(r"for\s+flavor\s+in\s+(cachyos\{[^}]+\}|[^\n;]+?)\s*;", text) + if m: + raw = m.group(1).strip() + # Brace expansion: cachyos{-gcc,-lto} → ["cachyos-gcc", "cachyos-lto"] + bm = re.match(r"^(\w+)\{([^}]+)\}$", raw) + if bm: + prefix = bm.group(1) + suffixes = [s.strip() for s in bm.group(2).split(",")] + return [f"{prefix}{s}" for s in suffixes] + # Plain space-separated list + return raw.split() + # Hard-coded fallback matching regen-config.sh + return [ + "cachyos-gcc", + "cachyos-lto", + "cachyos-lto-full", + "cachyos-server", + "cachyos-lts", + "cachyos-hardened", + "cachyos-server-lto", + "cachyos-lts-lto", + "cachyos-hardened-lto", + ] + + def _flake_root(self) -> Optional[Path]: + """Walk up from self.path to find the directory containing flake.nix.""" + d = self.path.parent + for _ in range(10): + if (d / "flake.nix").exists(): + return d + parent = d.parent + if parent == d: + break + d = parent + return None + + def regen_config_nix(self): + """ + For each flavor in regen-config.sh, run: + nix build .#nixosConfigurations.jallen-nas.pkgs.mjallen.linuxPackages_.kernel.kconfigToNix + --no-link --print-out-paths + then copy the output store path into config-nix/x86_64-linux/.x86_64-linux.nix. + Shows live progress in the status bar. + """ + config_dir = self._cachyos_config_nix_dir() + if config_dir is None: + self.set_status("regen: config-nix/x86_64-linux/ not found") + return + flake_root = self._flake_root() + if flake_root is None: + self.set_status("regen: could not find flake.nix root") + return + flavors = self._cachyos_regen_flavors() + n = len(flavors) + errors: List[str] = [] + for i, flavor in enumerate(flavors): + self.set_status(f"regen [{i + 1}/{n}] building {flavor}...") + self.stdscr.refresh() + attr = f".#nixosConfigurations.jallen-nas.pkgs.mjallen.linuxPackages_{flavor}.kernel.kconfigToNix" + code, out, err = run_cmd( + ["nix", "build", attr, "--no-link", "--print-out-paths"], + ) + if code != 0 or not out: + errors.append(flavor) + eprintln(f"regen {flavor} failed:\n{err[-400:]}") + continue + store_path = out.strip().splitlines()[0].strip() + try: + content = Path(store_path).read_text() + except Exception as e: + errors.append(flavor) + eprintln(f"regen {flavor}: read {store_path} failed: {e}") + continue + dest = config_dir / f"{flavor}.x86_64-linux.nix" + try: + dest.write_text(content) + except Exception as e: + errors.append(flavor) + eprintln(f"regen {flavor}: write {dest} failed: {e}") + continue + if errors: + self.set_status(f"regen: done with errors on: {', '.join(errors)}") + else: + self.set_status(f"regen: updated {n} config.nix files") + def _refresh_merged(self): """Re-compute merged_vars/merged_srcs/target_dict without resetting sidx.""" variant_name = None if self.vidx == 0 else self.variants[self.vidx] @@ -1794,127 +2127,121 @@ class PackageDetailScreen(ScreenBase): # Draw main border around the entire screen draw_border(self.stdscr, 0, 0, h - 1, w) - # Title with package name and path - title = f"{self.pkg_name} [{self.path}]" + # Row 0: package name + type badge centred in border if self.is_python: - title += " [Python Package]" - - # Center the title - title_x = (w - len(title)) // 2 + type_tag = " [py]" + elif self.is_homeassistant: + type_tag = " [ha]" + else: + type_tag = "" + title = f" {self.pkg_name}{type_tag} " + title_x = max(1, (w - len(title)) // 2) self.stdscr.addstr( - 0, title_x, f" {title} ", curses.color_pair(COLOR_TITLE) | curses.A_BOLD + 0, + title_x, + title[: w - 2], + curses.color_pair(COLOR_TITLE) | curses.A_BOLD, ) - # Variant line with highlighting for selected variant + # Row 1 left: relative path (dim) + try: + rel_path = str(self.path.relative_to(Path("/etc/nixos"))) + except ValueError: + rel_path = str(self.path) + self.stdscr.addstr( + 1, 2, rel_path[: w - 4], curses.color_pair(COLOR_NORMAL) | curses.A_DIM + ) + + # Row 2: Variants or Version + DETAIL_HDR_ROW = 2 # variant/version row + DETAIL_SEP_ROW = 3 # separator + DETAIL_SRC_ROW = 4 # first source row + if not self.is_python: - vline_parts = [] - for i, v in enumerate(self.variants): - if i == self.vidx: - vline_parts.append(f"[{v}]") - else: - vline_parts.append(v) - - vline = "Variants: " + " | ".join(vline_parts) - self.stdscr.addstr(1, 2, "Variants:", curses.color_pair(COLOR_HEADER)) - - # Display each variant with appropriate highlighting - x_pos = 12 # Position after "Variants: " + self.stdscr.addstr( + DETAIL_HDR_ROW, 2, "Variants:", curses.color_pair(COLOR_HEADER) + ) + x_pos = 12 for i, v in enumerate(self.variants): + if x_pos >= w - 4: + break if i > 0: self.stdscr.addstr( - 1, x_pos, " | ", curses.color_pair(COLOR_NORMAL) + DETAIL_HDR_ROW, + x_pos, + " | ", + curses.color_pair(COLOR_NORMAL), ) x_pos += 3 - if i == self.vidx: self.stdscr.addstr( - 1, x_pos, f"[{v}]", curses.color_pair(COLOR_HIGHLIGHT) + DETAIL_HDR_ROW, + x_pos, + f"[{v}]", + curses.color_pair(COLOR_HIGHLIGHT), ) - x_pos += len(f"[{v}]") else: - self.stdscr.addstr(1, x_pos, v, curses.color_pair(COLOR_NORMAL)) - x_pos += len(v) + self.stdscr.addstr( + DETAIL_HDR_ROW, x_pos, v, curses.color_pair(COLOR_NORMAL) + ) + x_pos += len(v) + (2 if i == self.vidx else 0) else: - # For Python packages, show version instead of variants version = self.merged_vars.get("version", "") - self.stdscr.addstr(1, 2, "Version:", curses.color_pair(COLOR_HEADER)) - self.stdscr.addstr(1, 11, version, curses.color_pair(COLOR_SUCCESS)) + self.stdscr.addstr( + DETAIL_HDR_ROW, 2, "Version:", curses.color_pair(COLOR_HEADER) + ) + self.stdscr.addstr( + DETAIL_HDR_ROW, 11, version, curses.color_pair(COLOR_SUCCESS) + ) - # Sources header with decoration - self.stdscr.addstr( - 2, 2, "Sources:", curses.color_pair(COLOR_HEADER) | curses.A_BOLD - ) - - # Draw a separator line under the header + # Separator + Sources header for i in range(1, w - 1): self.stdscr.addch( - 3, i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER) + DETAIL_SEP_ROW, i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER) ) - # List sources - for i, name in enumerate(self.snames[: h - 10], start=0): + self.stdscr.addstr( + DETAIL_SEP_ROW, + 2, + " Sources ", + curses.color_pair(COLOR_HEADER) | curses.A_BOLD, + ) + + # footer occupies h-4 (sep), h-3, h-2, h-1 (status) + # latest section: separator + up to 3 content rows → y_latest = h-9 + y_latest = h - 9 + + # Source rows — columns: sel+name(20) | fetcher(6) | ref + SRC_NAME_W = 20 + SRC_FETCH_W = 6 + SRC_REF_COL = 2 + SRC_NAME_W + 1 + SRC_FETCH_W + 1 # col 30 + + # Source rows fit between DETAIL_SRC_ROW and y_latest-1 + _max_src_rows = max(0, y_latest - DETAIL_SRC_ROW) + for i, name in enumerate(self.snames[:_max_src_rows], start=0): comp = self.merged_srcs[name] fetcher = comp.get("fetcher", "none") - # Render refs so variables resolve; compress long forms for display - display_ref = ( - comp.get("tag") or comp.get("rev") or comp.get("version") or "" - ) - if fetcher == "github": - rendered = render_templates(comp, self.merged_vars) - tag = rendered.get("tag") - rev = rendered.get("rev") - owner = rendered.get("owner") or self.merged_vars.get("owner") or "" - repo = rendered.get("repo") or self.merged_vars.get("repo") or "" - if tag and owner and repo: - display_ref = f"{owner}/{repo}@{tag}" - elif tag: - display_ref = tag - elif rev and owner and repo: - display_ref = f"{owner}/{repo}@{rev[:7]}" - elif rev: - display_ref = rev[:12] - elif fetcher == "url": - rendered = render_templates(comp, self.merged_vars) - url = rendered.get("url") or rendered.get("urlTemplate") or "" - if url: - # Prefer a concise label like owner/repo@tag · filename - owner = str(self.merged_vars.get("owner", "") or "") - repo = str(self.merged_vars.get("repo", "") or "") - rp = str(self.merged_vars.get("releasePrefix", "") or "") - rs = str(self.merged_vars.get("releaseSuffix", "") or "") - base = str(self.merged_vars.get("base", "") or "") - rel = str(self.merged_vars.get("release", "") or "") - tag = f"{rp}{base}-{rel}{rs}" if (base and rel) else "" - parsed = urlparse(url) - filename = ( - os.path.basename(parsed.path) - if parsed and parsed.path - else "" - ) - if owner and repo and tag and filename: - display_ref = f"{owner}/{repo}@{tag} · {filename}" - elif filename: - display_ref = filename - else: - display_ref = url - else: - display_ref = "" - ref_short = ( - display_ref - if not isinstance(display_ref, str) - else (display_ref[:60] + ("..." if len(display_ref) > 60 else "")) + display_ref = source_display_ref(comp, self.merged_vars) + branch = comp.get("branch") or "" + has_cargo = "cargoHash" in comp + + # Append badge tokens to the ref string + badges = "" + if branch: + badges += f" [{branch}]" + if has_cargo: + badges += " [cargo]" + ref_text = display_ref + badges + ref_short = ref_text[: w - SRC_REF_COL - 2] + ( + "…" if len(ref_text) > w - SRC_REF_COL - 2 else "" ) - # Determine colors and styles based on selection and fetcher type if i == self.sidx: - # Selected item - attr = curses.color_pair(COLOR_HIGHLIGHT) - sel = "►" # Use a fancier selector + row_attr = curses.color_pair(COLOR_HIGHLIGHT) + sel = "►" else: - # Non-selected item - attr = curses.color_pair(COLOR_NORMAL) + row_attr = curses.color_pair(COLOR_NORMAL) sel = " " - # Determine fetcher color fetcher_color = COLOR_NORMAL if fetcher == "github": fetcher_color = COLOR_SUCCESS @@ -1923,26 +2250,24 @@ class PackageDetailScreen(ScreenBase): elif fetcher == "git": fetcher_color = COLOR_HEADER - # Display source name with selection indicator - self.stdscr.addstr(4 + i, 2, f"{sel} {name:<20}", attr) - - # Display fetcher with appropriate color - self.stdscr.addstr(4 + i, 24, fetcher, curses.color_pair(fetcher_color)) - - # Display reference, with optional branch and cargo indicators - branch = comp.get("branch") or "" - branch_suffix = f" [{branch}]" if branch else "" - cargo_suffix = " [cargo]" if "cargoHash" in comp else "" - ref_with_extras = f"ref={ref_short}{branch_suffix}{cargo_suffix}" + row = DETAIL_SRC_ROW + i self.stdscr.addstr( - 4 + i, - 32, - ref_with_extras[: w - 34], - curses.color_pair(COLOR_NORMAL), + row, + 2, + f"{sel} {name[: SRC_NAME_W - 2]:<{SRC_NAME_W - 2}}", + row_attr, + ) + self.stdscr.addstr( + row, + 2 + SRC_NAME_W, + f"{fetcher[:SRC_FETCH_W]:<{SRC_FETCH_W}}", + curses.color_pair(fetcher_color), + ) + self.stdscr.addstr( + row, SRC_REF_COL, ref_short, curses.color_pair(COLOR_NORMAL) ) # Draw a separator line before the latest candidates section - y_latest = h - 8 for i in range(1, w - 1): self.stdscr.addch( y_latest, i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER) @@ -1976,74 +2301,82 @@ class PackageDetailScreen(ScreenBase): if _fetcher in ("github", "git"): _cand = self.candidates.get(_sel_name, {}) + _dim = curses.color_pair(COLOR_DIM) | curses.A_DIM - # Display each candidate with appropriate color + def _put_cand( + row: int, label: str, value: str, date: str, val_color: int + ): + """Write one candidate row: Label value date""" + lbl_w = 9 # "Release: " etc + self.stdscr.addstr( + row, 4, f"{label:<{lbl_w}}", curses.color_pair(COLOR_HEADER) + ) + val_end = 4 + lbl_w + len(value) + self.stdscr.addstr( + row, + 4 + lbl_w, + value[: w - 4 - lbl_w - 2], + curses.color_pair(val_color), + ) + if date and val_end + 2 < w - 2: + self.stdscr.addstr(row, val_end + 1, date, _dim) + + _row = y_latest + 2 if _cand.get("release"): - self.stdscr.addstr( - y_latest + 2, 4, "Release:", curses.color_pair(COLOR_HEADER) + _put_cand( + _row, + "Release:", + _cand["release"], + _cand.get("release_date", ""), + COLOR_SUCCESS, ) - self.stdscr.addstr( - y_latest + 2, - 13, - _cand.get("release"), - curses.color_pair(COLOR_SUCCESS), - ) - + _row += 1 if _cand.get("tag"): - self.stdscr.addstr( - y_latest + 2, 30, "Tag:", curses.color_pair(COLOR_HEADER) + _put_cand( + _row, + "Tag:", + _cand["tag"], + _cand.get("tag_date", ""), + COLOR_SUCCESS, ) - self.stdscr.addstr( - y_latest + 2, - 35, - _cand.get("tag"), - curses.color_pair(COLOR_SUCCESS), - ) - + _row += 1 if _cand.get("commit"): - self.stdscr.addstr( - y_latest + 3, 4, "Commit:", curses.color_pair(COLOR_HEADER) - ) - self.stdscr.addstr( - y_latest + 3, - 12, - (_cand.get("commit") or "")[:12], - curses.color_pair(COLOR_NORMAL), + _put_cand( + _row, + "Commit:", + (_cand["commit"] or "")[:12], + _cand.get("commit_date", ""), + COLOR_NORMAL, ) elif _fetcher == "url": _cand_u = self.url_candidates.get(_sel_name, {}) or {} - _tag = _cand_u.get("tag") or ( - self.candidates.get(_sel_name, {}).get("release") or "-" - ) + _cand_r = self.candidates.get(_sel_name, {}) + _dim = curses.color_pair(COLOR_DIM) | curses.A_DIM + _url_date = _cand_r.get("release_date", "") + _urow = y_latest + 2 - if _tag != "-": + _tag = _cand_u.get("tag") or (_cand_r.get("release") or "") + if _tag: self.stdscr.addstr( - y_latest + 2, 4, "Tag:", curses.color_pair(COLOR_HEADER) + _urow, 4, "Tag: ", curses.color_pair(COLOR_HEADER) ) self.stdscr.addstr( - y_latest + 2, 9, _tag, curses.color_pair(COLOR_SUCCESS) + _urow, 13, _tag[: w - 16], curses.color_pair(COLOR_SUCCESS) ) + if _url_date and 13 + len(_tag) + 2 < w - 2: + self.stdscr.addstr( + _urow, 13 + len(_tag) + 1, _url_date, _dim + ) + _urow += 1 - if _cand_u.get("base"): + if _cand_u.get("base") and _cand_u.get("release"): + _b = _cand_u["base"] + _r = _cand_u["release"] self.stdscr.addstr( - y_latest + 2, 30, "Base:", curses.color_pair(COLOR_HEADER) - ) - self.stdscr.addstr( - y_latest + 2, - 36, - _cand_u.get("base"), - curses.color_pair(COLOR_NORMAL), - ) - - if _cand_u.get("release"): - self.stdscr.addstr( - y_latest + 3, 4, "Release:", curses.color_pair(COLOR_HEADER) - ) - self.stdscr.addstr( - y_latest + 3, - 13, - _cand_u.get("release"), + _urow, + 4, + f"base={_b} release={_r}"[: w - 6], curses.color_pair(COLOR_NORMAL), ) @@ -2054,19 +2387,43 @@ class PackageDetailScreen(ScreenBase): self.stdscr.addstr( y_latest + 2, 4, - "Linux from PKGBUILD:", + "PKGBUILD version:", curses.color_pair(COLOR_HEADER), ) - if _latest: + self.stdscr.addstr( + y_latest + 2, + 21, + _latest or "-", + curses.color_pair( + COLOR_SUCCESS if _latest else COLOR_NORMAL + ), + ) + elif self.pkg_name == "linux-cachyos" and _sel_name == "zfs": + _pkgb_commit = self.fetch_cachyos_zfs_commit() + _cur_rev = self.merged_srcs.get("zfs", {}).get("rev", "") + _dim = curses.color_pair(COLOR_DIM) | curses.A_DIM + self.stdscr.addstr( + y_latest + 2, + 4, + "PKGBUILD commit:", + curses.color_pair(COLOR_HEADER), + ) + if _pkgb_commit: + _same = _pkgb_commit == _cur_rev + _col = COLOR_NORMAL if _same else COLOR_SUCCESS self.stdscr.addstr( y_latest + 2, - 24, - _latest, - curses.color_pair(COLOR_SUCCESS), + 21, + _pkgb_commit[:12], + curses.color_pair(_col), ) + if _same: + self.stdscr.addstr( + y_latest + 2, 34, "(up to date)", _dim + ) else: self.stdscr.addstr( - y_latest + 2, 24, "-", curses.color_pair(COLOR_NORMAL) + y_latest + 2, 21, "-", curses.color_pair(COLOR_NORMAL) ) else: self.stdscr.addstr( @@ -2076,16 +2433,23 @@ class PackageDetailScreen(ScreenBase): curses.color_pair(COLOR_NORMAL), ) - # Draw a separator line before the footer + # Separator before footer for i in range(1, w - 1): self.stdscr.addch( - h - 5, i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER) + h - 4, i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER) ) - # Footer instructions with better formatting - footer = "Enter: component actions | r: refresh | h: hash | i: url | e: edit | s: save | ←/→: variant | Backspace: back | q: quit" - footer_x = (w - len(footer)) // 2 - self.stdscr.addstr(h - 4, footer_x, footer, curses.color_pair(COLOR_STATUS)) + # Footer: two concise lines + footer1 = "Enter:actions r:refresh h:hash i:url e:edit s:save" + footer2 = "←/→:variant k/j:source Bksp:back q:quit" + f1x = max(1, (w - len(footer1)) // 2) + f2x = max(1, (w - len(footer2)) // 2) + self.stdscr.addstr( + h - 3, f1x, footer1[: w - 2], curses.color_pair(COLOR_STATUS) + ) + self.stdscr.addstr( + h - 2, f2x, footer2[: w - 2], curses.color_pair(COLOR_STATUS) + ) # Draw status at the bottom self.draw_status(h, w) @@ -2129,16 +2493,31 @@ class PackageDetailScreen(ScreenBase): f" tarball : {url_hint}", ] show_popup(self.stdscr, lines) + elif self.pkg_name == "linux-cachyos" and name == "zfs": + pkgbuild_commit = self.fetch_cachyos_zfs_commit() + cur_rev = comp.get("rev", "") + up_to_date = pkgbuild_commit and pkgbuild_commit == cur_rev + lines = [ + f"linux-cachyos/zfs ({'base' if self.vidx == 0 else self.variants[self.vidx]}):", + f" current : {cur_rev[:12] or '-'}", + f" PKGBUILD : {pkgbuild_commit[:12] if pkgbuild_commit else '-'}", + f" status : {'up to date' if up_to_date else 'update available' if pkgbuild_commit else 'unknown'}", + ] + show_popup(self.stdscr, lines) else: self.fetch_candidates_for(name) cand = self.candidates.get(name, {}) branch = comp.get("branch") or "" + + def _fmt(val: str, date: str) -> str: + return f"{val} {date}" if val and date else (val or "-") + lines = [ f"Candidates for {name}:" + (f" (branch: {branch})" if branch else ""), - f" latest release: {cand.get('release') or '-'}", - f" latest tag : {cand.get('tag') or '-'}", - f" latest commit : {cand.get('commit') or '-'}", + f" latest release: {_fmt(cand.get('release', ''), cand.get('release_date', ''))}", + f" latest tag : {_fmt(cand.get('tag', ''), cand.get('tag_date', ''))}", + f" latest commit : {_fmt(cand.get('commit', '')[:12] if cand.get('commit') else '', cand.get('commit_date', ''))}", ] show_popup(self.stdscr, lines) elif ch in (ord("i"),): @@ -2268,9 +2647,17 @@ class PackageDetailScreen(ScreenBase): if branch: current_str += f" (branch: {branch})" cur_cargo = comp.get("cargoHash", "") + + def _av(val: str, date: str) -> str: + v = val or "-" + return f"{v} {date}" if val and date else v + header_lines = [ current_str, - f"available: release={cand.get('release') or '-'} tag={cand.get('tag') or '-'} commit={(cand.get('commit') or '')[:12] or '-'}", + f"available:", + f" release : {_av(cand.get('release', ''), cand.get('release_date', ''))}", + f" tag : {_av(cand.get('tag', ''), cand.get('tag_date', ''))}", + f" commit : {_av((cand.get('commit') or '')[:12], cand.get('commit_date', ''))}", ] if has_cargo: header_lines.append( @@ -2428,9 +2815,11 @@ class PackageDetailScreen(ScreenBase): latest = self.fetch_cachyos_linux_latest(suffix) rendered = render_templates(comp, self.merged_vars) cur_version = str(rendered.get("version") or "") + regen_flavors = self._cachyos_regen_flavors() header_lines = [ f"current: version={cur_version or '-'}", f"available: version={latest or '-'}", + f"regen flavors ({len(regen_flavors)}): {', '.join(regen_flavors[:4])}{'...' if len(regen_flavors) > 4 else ''}", ] opts = [] if latest: @@ -2439,6 +2828,9 @@ class PackageDetailScreen(ScreenBase): ) else: opts.append("Update linux version from PKGBUILD (.SRCINFO)") + opts.append( + f"Regen all config.nix files ({len(regen_flavors)} flavors)" + ) opts.append("Cancel") choice = select_menu( self.stdscr, @@ -2446,10 +2838,69 @@ class PackageDetailScreen(ScreenBase): opts, header=header_lines, ) - if choice == 0 and latest: - self.update_linux_from_pkgbuild(name) - else: - pass + if choice is not None: + chosen = opts[choice] + if chosen.startswith("Update linux version") and latest: + self.update_linux_from_pkgbuild(name) + elif chosen.startswith("Regen all config.nix"): + self.regen_config_nix() + elif self.pkg_name == "linux-cachyos" and name == "zfs": + # ZFS commit is pinned in the PKGBUILD — read it from there + pkgbuild_commit = self.fetch_cachyos_zfs_commit() + rendered = render_templates(comp, self.merged_vars) + cur_rev = str(comp.get("rev") or "") + header_lines = [ + f"current: {cur_rev[:12] or '-'}", + f"PKGBUILD: {pkgbuild_commit[:12] if pkgbuild_commit else '-'}", + ] + opts = [] + if pkgbuild_commit: + opts.append( + f"Update to PKGBUILD commit ({pkgbuild_commit[:12]})" + ) + opts.append("Recompute hash") + opts.append("Cancel") + choice = select_menu( + self.stdscr, + f"Actions for {name}", + opts, + header=header_lines, + ) + if choice is not None: + chosen = opts[choice] + if ( + chosen.startswith("Update to PKGBUILD") + and pkgbuild_commit + ): + self.set_status("zfs: fetching commit and hash...") + self.stdscr.refresh() + ts = self.target_dict.setdefault("sources", {}) + compw = ts.setdefault(name, {}) + compw["rev"] = pkgbuild_commit + self._refresh_merged() + sri = self.prefetch_hash_for(name) + if sri: + compw["hash"] = sri + self._refresh_merged() + self.set_status( + f"zfs: updated to {pkgbuild_commit[:12]} and refreshed hash" + ) + else: + self.set_status( + "zfs: updated rev but hash prefetch failed" + ) + elif chosen == "Recompute hash": + self.set_status("zfs: recomputing hash...") + self.stdscr.refresh() + sri = self.prefetch_hash_for(name) + if sri: + ts = self.target_dict.setdefault("sources", {}) + compw = ts.setdefault(name, {}) + compw["hash"] = sri + self._refresh_merged() + self.set_status("zfs: updated hash") + else: + self.set_status("zfs: hash prefetch failed") else: show_popup( self.stdscr, @@ -2470,11 +2921,12 @@ def select_menu( stdscr.clear() h, w = stdscr.getmaxyx() - # Calculate menu dimensions with better bounds checking + # Calculate menu dimensions — account for title, header lines, and options max_opt_len = max((len(opt) + 4 for opt in options), default=0) + max_hdr_len = max((len(str(l)) + 4 for l in header), default=0) if header else 0 title_len = len(title) + 4 - menu_width = min(w - 4, max(40, max(title_len, max_opt_len))) - menu_height = min(h - 4, len(options) + (len(header) if header else 0) + 4) + menu_width = min(w - 4, max(44, title_len, max_opt_len, max_hdr_len)) + menu_height = min(h - 4, len(options) + (len(header) + 1 if header else 0) + 4) # Calculate position for centered menu start_x = (w - menu_width) // 2 @@ -2568,46 +3020,6 @@ def main(stdscr): init_colors() try: - # Display welcome screen - h, w = stdscr.getmaxyx() - welcome_lines = [ - "╔═══════════════════════════════════════════╗", - "║ ║", - "║ NixOS Package Version Manager ║", - "║ ║", - "║ Browse and update package versions with ║", - "║ this interactive TUI. Navigate using ║", - "║ arrow keys and Enter to select. ║", - "║ ║", - "╚═══════════════════════════════════════════╝", - "", - "Loading packages...", - ] - - # Center the welcome message - start_y = (h - len(welcome_lines)) // 2 - for i, line in enumerate(welcome_lines): - if start_y + i < h: - start_x = (w - len(line)) // 2 - if "NixOS Package Version Manager" in line: - stdscr.addstr( - start_y + i, - start_x, - line, - curses.color_pair(COLOR_TITLE) | curses.A_BOLD, - ) - elif "Loading packages..." in line: - stdscr.addstr( - start_y + i, start_x, line, curses.color_pair(COLOR_STATUS) - ) - else: - stdscr.addstr( - start_y + i, start_x, line, curses.color_pair(COLOR_NORMAL) - ) - - stdscr.refresh() - - # Start the main screen after a short delay screen = PackagesScreen(stdscr) screen.run() except Exception: