diff --git a/.gitignore b/.gitignore index d2ac8a8..c5548a8 100755 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,9 @@ result* .direnv shell.nix .vscode -**/*/*.py .envrc .DS_Store *.qcow2 keys -iso-* \ No newline at end of file +iso-* +**/*/__pycache__ \ No newline at end of file diff --git a/docs/version.schema.json b/docs/version.schema.json index 4b303d0..4874b63 100644 --- a/docs/version.schema.json +++ b/docs/version.schema.json @@ -76,6 +76,7 @@ "repo": { "type": "string", "description": "GitHub repository (github fetcher)." }, "tag": { "type": "string", "description": "Git tag (github fetcher). Mutually exclusive with 'rev'." }, "rev": { "type": "string", "description": "Commit revision (github/git fetchers)." }, + "branch": { "type": "string", "description": "Branch to track for HEAD-commit updates (github/git fetchers). Stored alongside 'rev' to record which branch the pinned commit came from. Has no effect on the Nix fetcher itself — only used by the version management tooling." }, "submodules": { "type": "boolean", "description": "Whether to fetch submodules (github/git fetchers)." }, "url": { "type": "string", "description": "Final URL (url fetcher). May be templated." }, @@ -157,6 +158,7 @@ "repo": { "type": "string" }, "tag": { "type": "string" }, "rev": { "type": "string" }, + "branch": { "type": "string" }, "submodules": { "type": "boolean" }, "url": { "type": "string" }, diff --git a/modules/nixos/network/default.nix b/modules/nixos/network/default.nix index c75f511..873972a 100644 --- a/modules/nixos/network/default.nix +++ b/modules/nixos/network/default.nix @@ -122,6 +122,14 @@ in network.wait-online.enable = false; }; + # Restrict Avahi to the configured LAN interface when one is explicitly set. + # This prevents Avahi from announcing on virtual/container interfaces (veth*, + # podman0, virbr0, etc.) which causes hostname conflicts and suffix mangling + # (e.g. "jallen-nas-4.local" instead of "jallen-nas.local"). + services.avahi = lib.mkIf (cfg.ipv4.interface != "") { + allowInterfaces = [ cfg.ipv4.interface ]; + }; + networking = { hostName = lib.mkForce cfg.hostName; diff --git a/modules/nixos/services/glance/default.nix b/modules/nixos/services/glance/default.nix index b21503c..8553da9 100644 --- a/modules/nixos/services/glance/default.nix +++ b/modules/nixos/services/glance/default.nix @@ -41,14 +41,14 @@ let ) serviceNames; hostedServicesByGroup = builtins.groupBy (svc: svc.hostedService.group) ( - builtins.filter (svc: svc.hostedService.enable) ( + builtins.filter (svc: svc.hostedService != null && svc.hostedService.enable) ( builtins.map ( serviceName: let serviceCfg = config.${namespace}.services.${serviceName}; in { - inherit (serviceCfg) hostedService; + hostedService = serviceCfg.hostedService or null; } ) (builtins.attrNames config.${namespace}.services) ) @@ -349,16 +349,16 @@ let first-day-of-week = "sunday"; } ] - ++ (lib.mkIf cfg.weather.enable { + ++ lib.optional cfg.weather.enable { type = "weather"; units = cfg.weather.units; hour-format = cfg.weather.hour-format; location = cfg.weather.location; - }) - ++ (lib.mkIf (cfg.servers != [ ]) { + } + ++ lib.optional (cfg.servers != [ ]) { type = "server-stats"; servers = cfg.servers; - }); + }; } { size = "full"; @@ -370,7 +370,7 @@ let bangs = cfg.search; } ] - ++ (lib.mkIf cfg.hostedServiceGroups ( + ++ lib.optionals cfg.hostedServiceGroups ( builtins.map ( groupName: makeMonitorWidget groupName ( @@ -381,11 +381,11 @@ let }) (hostedServicesByGroup.${groupName} or [ ]) ) ) (builtins.attrNames hostedServicesByGroup) - )) - ++ (lib.mkIf (!cfg.hostedServiceGroups && cfg.enableHostedServices) [ + ) + ++ lib.optionals (!cfg.hostedServiceGroups && cfg.enableHostedServices) [ (makeMonitorWidget "Services" hostedServiceSites) - ]) - ++ (lib.mkIf (cfg.extraSites != [ ]) ( + ] + ++ lib.optionals (cfg.extraSites != [ ]) ( builtins.map (site: { type = "monitor"; cache = "1m"; @@ -401,17 +401,17 @@ let ) ]; }) cfg.extraSites - )) - ++ (lib.mkIf (cfg.bookmarks != [ ]) { + ) + ++ lib.optional (cfg.bookmarks != [ ]) { type = "bookmarks"; groups = cfg.bookmarks; - }) - ++ (lib.mkIf (cfg.reddit != [ ]) ( + } + ++ lib.optionals (cfg.reddit != [ ]) ( builtins.map (subreddit: { type = "reddit"; inherit subreddit; }) cfg.reddit - )); + ); } ]; } diff --git a/packages/homeassistant/ha-anycubic/default.nix b/packages/homeassistant/ha-anycubic/default.nix index d0a9720..6840147 100644 --- a/packages/homeassistant/ha-anycubic/default.nix +++ b/packages/homeassistant/ha-anycubic/default.nix @@ -1,21 +1,26 @@ { - buildHomeAssistantComponent, - fetchFromGitHub, - pkgs, + lib, namespace, + pkgs, + buildHomeAssistantComponent, ... }: -buildHomeAssistantComponent rec { - owner = "adamoutler"; - domain = "anycubic_wifi"; - version = "HACS-10"; +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; - src = fetchFromGitHub { - owner = owner; - repo = "anycubic-homeassistant"; - rev = version; - hash = "sha256-TfZadwgdEJR11MaL+nfIgEYld3trWg3v6lOHSoxQ98Q="; - }; + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources.anycubic; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; +in +buildHomeAssistantComponent { + owner = src-meta.owner; + domain = "anycubic_wifi"; + inherit version; + + src = sources.anycubic; nativeBuildInputs = [ pkgs.${namespace}.uart-wifi ]; diff --git a/packages/homeassistant/ha-anycubic/version.json b/packages/homeassistant/ha-anycubic/version.json new file mode 100644 index 0000000..d8652a6 --- /dev/null +++ b/packages/homeassistant/ha-anycubic/version.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "HACS-10" + }, + "sources": { + "anycubic": { + "fetcher": "github", + "owner": "adamoutler", + "repo": "anycubic-homeassistant", + "tag": "HACS-10", + "hash": "sha256-TfZadwgdEJR11MaL+nfIgEYld3trWg3v6lOHSoxQ98Q=" + } + } +} diff --git a/packages/homeassistant/ha-bambulab/default.nix b/packages/homeassistant/ha-bambulab/default.nix index 450d878..5b1c419 100644 --- a/packages/homeassistant/ha-bambulab/default.nix +++ b/packages/homeassistant/ha-bambulab/default.nix @@ -1,20 +1,27 @@ { + lib, + namespace, + pkgs, buildHomeAssistantComponent, - fetchFromGitHub, home-assistant, ... }: -buildHomeAssistantComponent rec { - owner = "greghesp"; - domain = "bambu_lab"; - version = "v2.2.21"; +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; - src = fetchFromGitHub { - owner = owner; - repo = "ha-bambulab"; - tag = version; - hash = "sha256-56aAJAsmn+PzLZijFQ9DbTfHSrbeNk+OM/ibu32UHtg="; - }; + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources.bambu_lab; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; +in +buildHomeAssistantComponent { + owner = src-meta.owner; + domain = "bambu_lab"; + inherit version; + + src = sources.bambu_lab; nativeBuildInputs = with home-assistant.python.pkgs; [ beautifulsoup4 diff --git a/packages/homeassistant/ha-bambulab/version.json b/packages/homeassistant/ha-bambulab/version.json new file mode 100644 index 0000000..6e01dec --- /dev/null +++ b/packages/homeassistant/ha-bambulab/version.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "v2.2.21" + }, + "sources": { + "bambu_lab": { + "fetcher": "github", + "owner": "greghesp", + "repo": "ha-bambulab", + "tag": "v2.2.21", + "hash": "sha256-56aAJAsmn+PzLZijFQ9DbTfHSrbeNk+OM/ibu32UHtg=" + } + } +} diff --git a/packages/homeassistant/ha-bedjet/default.nix b/packages/homeassistant/ha-bedjet/default.nix index e20fe60..3fd174d 100644 --- a/packages/homeassistant/ha-bedjet/default.nix +++ b/packages/homeassistant/ha-bedjet/default.nix @@ -1,20 +1,27 @@ { + lib, + namespace, + pkgs, buildHomeAssistantComponent, - fetchFromGitHub, home-assistant, ... }: -buildHomeAssistantComponent rec { - owner = "natekspencer"; - domain = "bedjet"; - version = "2.0.1"; +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; - src = fetchFromGitHub { - owner = owner; - repo = "ha-bedjet"; - tag = version; - hash = "sha256-FAuL3A8wtGwt+GM180A7wMlIvJvGoLmxNLCtnomxV3o="; - }; + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources.bedjet; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; +in +buildHomeAssistantComponent { + owner = src-meta.owner; + domain = "bedjet"; + inherit version; + + src = sources.bedjet; nativeBuildInputs = with home-assistant.python.pkgs; [ beautifulsoup4 diff --git a/packages/homeassistant/ha-bedjet/version.json b/packages/homeassistant/ha-bedjet/version.json new file mode 100644 index 0000000..5bf4d60 --- /dev/null +++ b/packages/homeassistant/ha-bedjet/version.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "2.0.1" + }, + "sources": { + "bedjet": { + "fetcher": "github", + "owner": "natekspencer", + "repo": "ha-bedjet", + "tag": "2.0.1", + "hash": "sha256-FAuL3A8wtGwt+GM180A7wMlIvJvGoLmxNLCtnomxV3o=" + } + } +} diff --git a/packages/homeassistant/ha-gehome/default.nix b/packages/homeassistant/ha-gehome/default.nix index a4e36cd..aeebd73 100644 --- a/packages/homeassistant/ha-gehome/default.nix +++ b/packages/homeassistant/ha-gehome/default.nix @@ -1,22 +1,27 @@ { - buildHomeAssistantComponent, - fetchFromGitHub, - home-assistant, - pkgs, + lib, namespace, + pkgs, + buildHomeAssistantComponent, + home-assistant, ... }: -buildHomeAssistantComponent rec { - owner = "simbaja"; - domain = "ge_home"; - version = "v2026.2.0"; +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; - src = fetchFromGitHub { - owner = owner; - repo = "ha_gehome"; - tag = version; - hash = "sha256-7c2GfTagNsIsSiT/sCqSV+BZZJMcvlsecDD+ZDZx9BA="; - }; + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources.ge_home; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; +in +buildHomeAssistantComponent { + owner = src-meta.owner; + domain = "ge_home"; + inherit version; + + src = sources.ge_home; # gehomesdk and magicattr must be built against HA's Python dependencies = with pkgs.${namespace}; [ diff --git a/packages/homeassistant/ha-gehome/version.json b/packages/homeassistant/ha-gehome/version.json new file mode 100644 index 0000000..2fc4494 --- /dev/null +++ b/packages/homeassistant/ha-gehome/version.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "v2026.2.0" + }, + "sources": { + "ge_home": { + "fetcher": "github", + "owner": "simbaja", + "repo": "ha_gehome", + "tag": "v2026.2.0", + "hash": "sha256-7c2GfTagNsIsSiT/sCqSV+BZZJMcvlsecDD+ZDZx9BA=" + } + } +} diff --git a/packages/homeassistant/ha-govee/default.nix b/packages/homeassistant/ha-govee/default.nix index dc48f8a..b91d430 100644 --- a/packages/homeassistant/ha-govee/default.nix +++ b/packages/homeassistant/ha-govee/default.nix @@ -1,29 +1,36 @@ { + lib, + namespace, + pkgs, buildHomeAssistantComponent, - fetchFromGitHub, home-assistant, ... }: -buildHomeAssistantComponent rec { - owner = "LaggAt"; - domain = "govee"; - version = "2025.7.1"; +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; - src = fetchFromGitHub { - owner = owner; - repo = "hacs-govee"; - rev = version; - hash = "sha256-3SnYjjQU2qRBcKs40bCpN75Ad3HqMcn/hRj1faSSeHw="; - }; + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources.govee; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; +in +buildHomeAssistantComponent { + owner = src-meta.owner; + domain = "govee"; + inherit version; + + src = sources.govee; nativeBuildInputs = with home-assistant.python.pkgs; [ dacite ]; meta = { - changelog = "https://github.com/${owner}/hacs-govee/releases/tag/${version}"; + changelog = "https://github.com/${src-meta.owner}/hacs-govee/releases/tag/${version}"; description = "The Govee integration allows you to control and monitor lights and switches using the Govee API."; - homepage = "https://github.com/${owner}/hacs-govee"; + homepage = "https://github.com/${src-meta.owner}/hacs-govee"; maintainers = [ ]; }; } diff --git a/packages/homeassistant/ha-govee/version.json b/packages/homeassistant/ha-govee/version.json new file mode 100644 index 0000000..b565da4 --- /dev/null +++ b/packages/homeassistant/ha-govee/version.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "2025.7.1" + }, + "sources": { + "govee": { + "fetcher": "github", + "owner": "LaggAt", + "repo": "hacs-govee", + "tag": "2025.7.1", + "hash": "sha256-3SnYjjQU2qRBcKs40bCpN75Ad3HqMcn/hRj1faSSeHw=" + } + } +} diff --git a/packages/homeassistant/ha-icloud3/default.nix b/packages/homeassistant/ha-icloud3/default.nix index 7b11589..4771239 100644 --- a/packages/homeassistant/ha-icloud3/default.nix +++ b/packages/homeassistant/ha-icloud3/default.nix @@ -1,20 +1,27 @@ { + lib, + namespace, + pkgs, buildHomeAssistantComponent, - fetchFromGitHub, home-assistant, ... }: -buildHomeAssistantComponent rec { - owner = "gcobb321"; - domain = "icloud3"; - version = "v3.3.4.4"; +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; - src = fetchFromGitHub { - owner = owner; - repo = "icloud3"; - rev = "${version}"; - hash = "sha256-B63iY4OC00PGXx/3aq/rkiO0xK11hXz66KaglwmgxIk="; - }; + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources.icloud3; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; +in +buildHomeAssistantComponent { + owner = src-meta.owner; + domain = "icloud3"; + inherit version; + + src = sources.icloud3; nativeBuildInputs = with home-assistant.python.pkgs; [ fido2 diff --git a/packages/homeassistant/ha-icloud3/version.json b/packages/homeassistant/ha-icloud3/version.json new file mode 100644 index 0000000..6e395b0 --- /dev/null +++ b/packages/homeassistant/ha-icloud3/version.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "v3.3.4.4" + }, + "sources": { + "icloud3": { + "fetcher": "github", + "owner": "gcobb321", + "repo": "icloud3", + "tag": "v3.3.4.4", + "hash": "sha256-B63iY4OC00PGXx/3aq/rkiO0xK11hXz66KaglwmgxIk=" + } + } +} diff --git a/packages/homeassistant/ha-local-llm/default.nix b/packages/homeassistant/ha-local-llm/default.nix index 45e9f34..a10df9c 100644 --- a/packages/homeassistant/ha-local-llm/default.nix +++ b/packages/homeassistant/ha-local-llm/default.nix @@ -1,20 +1,27 @@ { + lib, + namespace, + pkgs, buildHomeAssistantComponent, - fetchFromGitHub, home-assistant, ... }: -buildHomeAssistantComponent rec { - owner = "acon96"; - domain = "llama_conversation"; - version = "v0.4.6"; +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; - src = fetchFromGitHub { - owner = owner; - repo = "home-llm"; - rev = version; - hash = "sha256-QmpyqNRhmnqFNiKPHm8GKuvZhbuYWDLck3eFC9MlIKQ="; - }; + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources.llama_conversation; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; +in +buildHomeAssistantComponent { + owner = src-meta.owner; + domain = "llama_conversation"; + inherit version; + + src = sources.llama_conversation; nativeBuildInputs = with home-assistant.python.pkgs; [ anthropic diff --git a/packages/homeassistant/ha-local-llm/version.json b/packages/homeassistant/ha-local-llm/version.json new file mode 100644 index 0000000..a838070 --- /dev/null +++ b/packages/homeassistant/ha-local-llm/version.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "v0.4.6" + }, + "sources": { + "llama_conversation": { + "fetcher": "github", + "owner": "acon96", + "repo": "home-llm", + "tag": "v0.4.6", + "hash": "sha256-QmpyqNRhmnqFNiKPHm8GKuvZhbuYWDLck3eFC9MlIKQ=" + } + } +} diff --git a/packages/homeassistant/ha-mail-and-packages/default.nix b/packages/homeassistant/ha-mail-and-packages/default.nix index 3d9bc6d..03c2ce7 100644 --- a/packages/homeassistant/ha-mail-and-packages/default.nix +++ b/packages/homeassistant/ha-mail-and-packages/default.nix @@ -1,20 +1,27 @@ { + lib, + namespace, + pkgs, buildHomeAssistantComponent, - fetchFromGitHub, home-assistant, ... }: -buildHomeAssistantComponent rec { - owner = "moralmunky"; - domain = "mail_and_packages"; - version = "0.5.0"; +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; - src = fetchFromGitHub { - owner = owner; - repo = "Home-Assistant-Mail-And-Packages"; - tag = version; - hash = "sha256-Am3EYkSYCQuYJmm6xdUwCa0h/ldk4hwTxRTxc0BU2j8="; - }; + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources.mail_and_packages; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; +in +buildHomeAssistantComponent { + owner = src-meta.owner; + domain = "mail_and_packages"; + inherit version; + + src = sources.mail_and_packages; nativeBuildInputs = with home-assistant.python.pkgs; [ aioimaplib diff --git a/packages/homeassistant/ha-mail-and-packages/version.json b/packages/homeassistant/ha-mail-and-packages/version.json new file mode 100644 index 0000000..a8e2d82 --- /dev/null +++ b/packages/homeassistant/ha-mail-and-packages/version.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "0.5.0" + }, + "sources": { + "mail_and_packages": { + "fetcher": "github", + "owner": "moralmunky", + "repo": "Home-Assistant-Mail-And-Packages", + "tag": "0.5.0", + "hash": "sha256-Am3EYkSYCQuYJmm6xdUwCa0h/ldk4hwTxRTxc0BU2j8=" + } + } +} diff --git a/packages/homeassistant/ha-nanokvm/default.nix b/packages/homeassistant/ha-nanokvm/default.nix index 9099ff3..86c7896 100644 --- a/packages/homeassistant/ha-nanokvm/default.nix +++ b/packages/homeassistant/ha-nanokvm/default.nix @@ -1,22 +1,38 @@ { + lib, + namespace, + pkgs, buildHomeAssistantComponent, - fetchFromGitHub, home-assistant, ... }: let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; + + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources.nanokvm; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; + + # python-nanokvm must be built against HA's Python interpreter. + # Re-use the source from its own version.json to avoid duplication. + nanokvm-ver = importJSON ../../python/python-nanokvm/version.json; + nanokvm-selected = selectVariant nanokvm-ver null null; + nanokvm-sources = mkAllSources pkgs nanokvm-selected; + python3Packages = home-assistant.python.pkgs; - python-nanokvm = python3Packages.buildPythonPackage rec { + python-nanokvm = python3Packages.buildPythonPackage { pname = "nanokvm"; - version = "0.1.0"; + version = + if nanokvm-selected.sources."python-nanokvm" ? tag then + nanokvm-selected.sources."python-nanokvm".tag + else + nanokvm-selected.sources."python-nanokvm".rev; format = "pyproject"; - src = fetchFromGitHub { - owner = "puddly"; - repo = "python-${pname}"; - rev = "v${version}"; - sha256 = "sha256-vIxvQtjaInnWQce7syiOWpP2kaw0IVw03HPovnB2J5M="; - }; + src = nanokvm-sources."python-nanokvm"; prePatch = '' rm -f pyproject.toml @@ -50,9 +66,7 @@ let EOF ''; - buildInputs = with python3Packages; [ - setuptools - ]; + buildInputs = with python3Packages; [ setuptools ]; propagatedBuildInputs = with python3Packages; [ aiohttp @@ -66,21 +80,14 @@ let doCheck = false; }; in -buildHomeAssistantComponent rec { - owner = "Wouter0100"; +buildHomeAssistantComponent { + owner = src-meta.owner; domain = "nanokvm"; - version = "v0.0.4"; + inherit version; - src = fetchFromGitHub { - owner = owner; - repo = "homeassistant-nanokvm"; - rev = "bdd2ca39d8050e4b38bb7917ee4034f2fcd49471"; - hash = "sha256-S6g9mfPEixqeGQkXVK8PZJ/dnEC5ThKtbELAIAhCANM="; - }; + src = sources.nanokvm; - propagatedBuildInputs = [ - python-nanokvm - ]; + propagatedBuildInputs = [ python-nanokvm ]; postPatch = '' substituteInPlace custom_components/nanokvm/manifest.json \ diff --git a/packages/homeassistant/ha-nanokvm/version.json b/packages/homeassistant/ha-nanokvm/version.json new file mode 100644 index 0000000..013b910 --- /dev/null +++ b/packages/homeassistant/ha-nanokvm/version.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "v0.0.4" + }, + "sources": { + "nanokvm": { + "fetcher": "github", + "owner": "Wouter0100", + "repo": "homeassistant-nanokvm", + "rev": "bdd2ca39d8050e4b38bb7917ee4034f2fcd49471", + "hash": "sha256-S6g9mfPEixqeGQkXVK8PZJ/dnEC5ThKtbELAIAhCANM=" + } + }, + "notes": { + "hint": "The nanokvm component embeds a vendored copy of python-nanokvm. The dep is tracked separately in packages/python/python-nanokvm/version.json." + } +} diff --git a/packages/homeassistant/ha-openhasp/default.nix b/packages/homeassistant/ha-openhasp/default.nix index f574a0d..f84cdfc 100644 --- a/packages/homeassistant/ha-openhasp/default.nix +++ b/packages/homeassistant/ha-openhasp/default.nix @@ -1,22 +1,29 @@ { + lib, + namespace, + pkgs, buildHomeAssistantComponent, - fetchFromGitHub, home-assistant, ... }: -buildHomeAssistantComponent rec { - owner = "HASwitchPlate"; +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; + + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources.openhasp; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; +in +buildHomeAssistantComponent { + owner = src-meta.owner; domain = "openhasp"; - version = "0.7.8"; + inherit version; - src = fetchFromGitHub { - owner = owner; - repo = "openHASP-custom-component"; - rev = version; - hash = "sha256-5h1EqwpnsmWexqB3J/X4OcN9bfBYUxGxLF1Hrmoi5LY="; - }; + src = sources.openhasp; - # Use HA's own Python (3.14) packages to satisfy the manifest check for jsonschema + # Use HA's own Python packages to satisfy the manifest check for jsonschema nativeBuildInputs = [ home-assistant.python.pkgs.jsonschema ]; meta = { diff --git a/packages/homeassistant/ha-openhasp/version.json b/packages/homeassistant/ha-openhasp/version.json new file mode 100644 index 0000000..eba51cb --- /dev/null +++ b/packages/homeassistant/ha-openhasp/version.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "0.7.8" + }, + "sources": { + "openhasp": { + "fetcher": "github", + "owner": "HASwitchPlate", + "repo": "openHASP-custom-component", + "tag": "0.7.8", + "hash": "sha256-5h1EqwpnsmWexqB3J/X4OcN9bfBYUxGxLF1Hrmoi5LY=" + } + } +} diff --git a/packages/homeassistant/ha-overseerr/default.nix b/packages/homeassistant/ha-overseerr/default.nix index 6c8cf1f..c865342 100644 --- a/packages/homeassistant/ha-overseerr/default.nix +++ b/packages/homeassistant/ha-overseerr/default.nix @@ -1,21 +1,26 @@ { - buildHomeAssistantComponent, - fetchFromGitHub, - pkgs, + lib, namespace, + pkgs, + buildHomeAssistantComponent, ... }: -buildHomeAssistantComponent rec { - owner = "vaparr"; - domain = "overseerr"; - version = "0.1.42"; +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; - src = fetchFromGitHub { - owner = owner; - repo = "ha-overseerr"; - rev = version; - hash = "sha256-UvUowCgfay9aRV+iC/AQ9vvJzhGZbH+/1kVjxPFBKcI="; - }; + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources.overseerr; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; +in +buildHomeAssistantComponent { + owner = src-meta.owner; + domain = "overseerr"; + inherit version; + + src = sources.overseerr; nativeBuildInputs = [ pkgs.${namespace}.pyoverseerr ]; diff --git a/packages/homeassistant/ha-overseerr/version.json b/packages/homeassistant/ha-overseerr/version.json new file mode 100644 index 0000000..517aad4 --- /dev/null +++ b/packages/homeassistant/ha-overseerr/version.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "0.1.42" + }, + "sources": { + "overseerr": { + "fetcher": "github", + "owner": "vaparr", + "repo": "ha-overseerr", + "tag": "0.1.42", + "hash": "sha256-UvUowCgfay9aRV+iC/AQ9vvJzhGZbH+/1kVjxPFBKcI=" + } + } +} diff --git a/packages/homeassistant/ha-petlibro/default.nix b/packages/homeassistant/ha-petlibro/default.nix index 56f6d13..bb38cab 100644 --- a/packages/homeassistant/ha-petlibro/default.nix +++ b/packages/homeassistant/ha-petlibro/default.nix @@ -1,15 +1,26 @@ -{ buildHomeAssistantComponent, fetchFromGitHub, ... }: -buildHomeAssistantComponent rec { - owner = "jjjonesjr33"; - domain = "petlibro"; - version = "v1.2.30.7"; +{ + lib, + namespace, + pkgs, + buildHomeAssistantComponent, + ... +}: +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; - src = fetchFromGitHub { - owner = owner; - repo = "petlibro"; - rev = version; - hash = "sha256-+zmeUQHRXrBYQ5pEWLAtu9TZ8ELiwCLliRPktKlpI8k="; - }; + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources.petlibro; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; +in +buildHomeAssistantComponent { + owner = src-meta.owner; + domain = "petlibro"; + inherit version; + + src = sources.petlibro; meta = { changelog = "https://github.com/jjjonesjr33/petlibro/releases/tag/${version}"; diff --git a/packages/homeassistant/ha-petlibro/version.json b/packages/homeassistant/ha-petlibro/version.json new file mode 100644 index 0000000..7a693f8 --- /dev/null +++ b/packages/homeassistant/ha-petlibro/version.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "v1.2.30.7" + }, + "sources": { + "petlibro": { + "fetcher": "github", + "owner": "jjjonesjr33", + "repo": "petlibro", + "tag": "v1.2.30.7", + "hash": "sha256-+zmeUQHRXrBYQ5pEWLAtu9TZ8ELiwCLliRPktKlpI8k=" + } + } +} diff --git a/packages/homeassistant/ha-wyzeapi/default.nix b/packages/homeassistant/ha-wyzeapi/default.nix index 4a0aa79..a295182 100644 --- a/packages/homeassistant/ha-wyzeapi/default.nix +++ b/packages/homeassistant/ha-wyzeapi/default.nix @@ -1,21 +1,26 @@ { - buildHomeAssistantComponent, - fetchFromGitHub, - pkgs, + lib, namespace, + pkgs, + buildHomeAssistantComponent, ... }: -buildHomeAssistantComponent rec { - owner = "SecKatie"; - domain = "wyzeapi"; - version = "0.1.36"; +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; - src = fetchFromGitHub { - owner = owner; - repo = "ha-wyzeapi"; - rev = version; - hash = "sha256-4i5Ne3LYV7DXn6F6e5MCVZhIdDYR7fe3tT2GeSmYb/k="; - }; + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources.wyzeapi; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; +in +buildHomeAssistantComponent { + owner = src-meta.owner; + domain = "wyzeapi"; + inherit version; + + src = sources.wyzeapi; # wyzeapy must be built against HA's Python; pkgs.mjallen.wyzeapy uses home-assistant.python dependencies = [ pkgs.${namespace}.wyzeapy ]; diff --git a/packages/homeassistant/ha-wyzeapi/version.json b/packages/homeassistant/ha-wyzeapi/version.json new file mode 100644 index 0000000..ca21f7e --- /dev/null +++ b/packages/homeassistant/ha-wyzeapi/version.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "0.1.36" + }, + "sources": { + "wyzeapi": { + "fetcher": "github", + "owner": "SecKatie", + "repo": "ha-wyzeapi", + "tag": "0.1.36", + "hash": "sha256-4i5Ne3LYV7DXn6F6e5MCVZhIdDYR7fe3tT2GeSmYb/k=" + } + } +} diff --git a/packages/homeassistant/homeassistant-api/default.nix b/packages/homeassistant/homeassistant-api/default.nix index a07d557..e0400d0 100644 --- a/packages/homeassistant/homeassistant-api/default.nix +++ b/packages/homeassistant/homeassistant-api/default.nix @@ -1,24 +1,38 @@ -{ pkgs, fetchPypi, ... }: -pkgs.python3Packages.buildPythonPackage rec { - pname = "homeassistant_api"; - version = "5.0.0"; - format = "pyproject"; - src = fetchPypi { - inherit pname version; - sha256 = "sha256-UNKTtgInrVJtjHb1WVlUbcbhjBOtTX00eHmm54ww0rY="; - }; +{ + lib, + namespace, + pkgs, + ... +}: +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; + + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + version = selected.variables.version; +in +pkgs.python3Packages.buildPythonPackage { + pname = "homeassistant_api"; + inherit version; + format = "pyproject"; + + src = sources.homeassistant_api; - # do not run tests doCheck = false; + nativeBuildInputs = with pkgs.python3Packages; [ poetry-core requests-cache ]; + dependencies = with pkgs.python3Packages; [ requests-cache pydantic websockets ]; + propagatedBuildInputs = with pkgs.python3Packages; [ aiohttp aiohttp-client-cache @@ -28,11 +42,13 @@ pkgs.python3Packages.buildPythonPackage rec { simplejson websockets ]; + pythonRelaxDeps = [ "requests-cache" "pydantic" "websockets" ]; + pythonImportsCheck = [ "homeassistant_api" ]; diff --git a/packages/homeassistant/homeassistant-api/version.json b/packages/homeassistant/homeassistant-api/version.json new file mode 100644 index 0000000..121f772 --- /dev/null +++ b/packages/homeassistant/homeassistant-api/version.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "5.0.0" + }, + "sources": { + "homeassistant_api": { + "fetcher": "pypi", + "name": "homeassistant_api", + "hash": "sha256-UNKTtgInrVJtjHb1WVlUbcbhjBOtTX00eHmm54ww0rY=" + } + } +} diff --git a/packages/proton-cachyos/version.json b/packages/proton-cachyos/version.json index 28b29ff..dfdd064 100644 --- a/packages/proton-cachyos/version.json +++ b/packages/proton-cachyos/version.json @@ -18,12 +18,12 @@ "cachyos": { "variables": { "base": "10.0", - "release": "20260227", + "release": "20260324", "tarballSuffix": "-x86_64.tar.xz" }, "sources": { "proton": { - "hash": "sha256-kayS0zpBIL2jOM7jxkI0LyhYShQFGCKPdRyiJVOxf6c=" + "hash": "sha256-vswYkpHuXj/YqfjCj+x779SSOsoOCEeZfr99pi1Mfj0=" } } }, @@ -42,24 +42,24 @@ "cachyos-v3": { "variables": { "base": "10.0", - "release": "20260227", + "release": "20260324", "tarballSuffix": "-x86_64_v3.tar.xz" }, "sources": { "proton": { - "hash": "sha256-LI3/Hqe7oNYv5dC5jNz7c+HHNzifeON/bnt6jmD2DRA=" + "hash": "sha256-158b49/TPuYD4kRC9YCd/obVjv1JUBpDIsjjeUP/RRw=" } } }, "cachyos-v4": { "variables": { "base": "10.0", - "release": "20260227", + "release": "20260324", "tarballSuffix": "-x86_64_v4.tar.xz" }, "sources": { "proton": { - "hash": "sha256-kcWSmF+qwClI4qUkv3ShVBQ6plQ8q3jyo59o5uN4ueM=" + "hash": "sha256-qHNpSh2VneqiwLRYqjR/YRV6HPj1L51u13xNu70tyBw=" } } }, diff --git a/packages/python/comfy-aimdo/default.nix b/packages/python/comfy-aimdo/default.nix index de0ae4b..c28f783 100644 --- a/packages/python/comfy-aimdo/default.nix +++ b/packages/python/comfy-aimdo/default.nix @@ -1,15 +1,25 @@ -{ python3Packages, fetchFromGitHub, ... }: -python3Packages.buildPythonPackage rec { +{ + lib, + namespace, + pkgs, + python3Packages, + ... +}: +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; + + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources."comfy-aimdo"; +in +python3Packages.buildPythonPackage { pname = "comfy-aimdo"; - version = "0.1.7"; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; format = "pyproject"; - # Comfy-Org/comfy-aimdo/releases/tag/v0.1.7 - src = fetchFromGitHub { - owner = "Comfy-Org"; - repo = "comfy-aimdo"; - rev = "v${version}"; - sha256 = "sha256-RNORTKtnTHZ4lcEx5gM3jSr+ZffrV8cd+x74NeRhlsM="; - }; + + src = sources."comfy-aimdo"; buildInputs = with python3Packages; [ setuptools diff --git a/packages/python/comfy-aimdo/version.json b/packages/python/comfy-aimdo/version.json new file mode 100644 index 0000000..b1a0513 --- /dev/null +++ b/packages/python/comfy-aimdo/version.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "0.1.7" + }, + "sources": { + "comfy-aimdo": { + "fetcher": "github", + "owner": "Comfy-Org", + "repo": "comfy-aimdo", + "tag": "v0.1.7", + "hash": "sha256-RNORTKtnTHZ4lcEx5gM3jSr+ZffrV8cd+x74NeRhlsM=" + } + } +} diff --git a/packages/python/comfy-kitchen/default.nix b/packages/python/comfy-kitchen/default.nix index 4dd9729..ab4a1c8 100644 --- a/packages/python/comfy-kitchen/default.nix +++ b/packages/python/comfy-kitchen/default.nix @@ -1,12 +1,25 @@ -{ python3Packages, fetchurl, ... }: -python3Packages.buildPythonPackage rec { +{ + lib, + namespace, + pkgs, + python3Packages, + ... +}: +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; + + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + version = selected.variables.version; +in +python3Packages.buildPythonPackage { pname = "comfy-kitchen"; - version = "0.2.7"; + inherit version; format = "wheel"; - # https://files.pythonhosted.org/packages/f8/65/d483613734d0b9753bd9bfa297ff334cb2c7766e82306099db6b259b4e2c/comfy_kitchen-0.2.7-py3-none-any.whl - src = fetchurl { - url = "https://files.pythonhosted.org/packages/f8/65/d483613734d0b9753bd9bfa297ff334cb2c7766e82306099db6b259b4e2c/comfy_kitchen-0.2.7-py3-none-any.whl"; - sha256 = "sha256-+PqlebadMx0vHqwJ6WqVWGwqa5WKVLwZ5/HBp3hS3TY="; - }; + + src = sources."comfy-kitchen"; + doCheck = false; } diff --git a/packages/python/comfy-kitchen/version.json b/packages/python/comfy-kitchen/version.json new file mode 100644 index 0000000..c4973a9 --- /dev/null +++ b/packages/python/comfy-kitchen/version.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "0.2.7" + }, + "sources": { + "comfy-kitchen": { + "fetcher": "url", + "url": "https://files.pythonhosted.org/packages/f8/65/d483613734d0b9753bd9bfa297ff334cb2c7766e82306099db6b259b4e2c/comfy_kitchen-0.2.7-py3-none-any.whl", + "hash": "sha256-+PqlebadMx0vHqwJ6WqVWGwqa5WKVLwZ5/HBp3hS3TY=" + } + } +} diff --git a/packages/python/gehomesdk/default.nix b/packages/python/gehomesdk/default.nix index 824e348..0af4055 100644 --- a/packages/python/gehomesdk/default.nix +++ b/packages/python/gehomesdk/default.nix @@ -1,17 +1,25 @@ { lib, + namespace, + pkgs, home-assistant, + ... }: +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; -home-assistant.python.pkgs.buildPythonPackage rec { + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + version = selected.variables.version; +in +home-assistant.python.pkgs.buildPythonPackage { pname = "gehomesdk"; - version = "2026.2.0"; + inherit version; pyproject = true; - src = home-assistant.python.pkgs.fetchPypi { - inherit pname version; - hash = "sha256-+BWGkUDKd+9QGbdXuLjmJxLm1xUv0dpIRlPlDkUJ25w="; - }; + src = sources.gehomesdk; build-system = with home-assistant.python.pkgs; [ setuptools ]; diff --git a/packages/python/gehomesdk/version.json b/packages/python/gehomesdk/version.json new file mode 100644 index 0000000..4200b76 --- /dev/null +++ b/packages/python/gehomesdk/version.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "2026.2.0" + }, + "sources": { + "gehomesdk": { + "fetcher": "pypi", + "name": "gehomesdk", + "hash": "sha256-+BWGkUDKd+9QGbdXuLjmJxLm1xUv0dpIRlPlDkUJ25w=" + } + } +} diff --git a/packages/python/magicattr/default.nix b/packages/python/magicattr/default.nix index dd93942..0f39753 100644 --- a/packages/python/magicattr/default.nix +++ b/packages/python/magicattr/default.nix @@ -1,20 +1,25 @@ { - fetchFromGitHub, + lib, + namespace, + pkgs, home-assistant, ... }: +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; -home-assistant.python.pkgs.buildPythonPackage rec { + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources.magicattr; +in +home-assistant.python.pkgs.buildPythonPackage { pname = "magicattr"; - version = "0.1.6"; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; format = "setuptools"; - src = fetchFromGitHub { - owner = "frmdstryr"; - repo = pname; - rev = "master"; - sha256 = "sha256-FJtWU5AuunZbdlndGdfD1c9/0s7oRdoTi202pWjuAd8="; - }; + src = sources.magicattr; build-system = [ home-assistant.python.pkgs.setuptools ]; doCheck = false; diff --git a/packages/python/magicattr/version.json b/packages/python/magicattr/version.json new file mode 100644 index 0000000..fdbaac4 --- /dev/null +++ b/packages/python/magicattr/version.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "sources": { + "magicattr": { + "fetcher": "github", + "owner": "frmdstryr", + "repo": "magicattr", + "rev": "master", + "hash": "sha256-FJtWU5AuunZbdlndGdfD1c9/0s7oRdoTi202pWjuAd8=" + } + } +} diff --git a/packages/python/pipewire-python/default.nix b/packages/python/pipewire-python/default.nix index f706537..c916411 100644 --- a/packages/python/pipewire-python/default.nix +++ b/packages/python/pipewire-python/default.nix @@ -1,15 +1,25 @@ -{ python3Packages, fetchFromGitHub, ... }: -python3Packages.buildPythonPackage rec { +{ + lib, + namespace, + pkgs, + python3Packages, + ... +}: +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; + + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources."pipewire-python"; +in +python3Packages.buildPythonPackage { pname = "pipewire-python"; - version = "0.2.3"; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; format = "pyproject"; - src = fetchFromGitHub { - owner = "pablodz"; - repo = "pipewire_python"; - rev = "v${version}"; - sha256 = "sha256-6UIu7vke40q+n91gU8YxwMV/tWjLT6iDmHCMVqnXdMY="; - }; + src = sources."pipewire-python"; buildInputs = with python3Packages; [ flit-core ]; nativeBuildInputs = with python3Packages; [ diff --git a/packages/python/pipewire-python/version.json b/packages/python/pipewire-python/version.json new file mode 100644 index 0000000..798b81e --- /dev/null +++ b/packages/python/pipewire-python/version.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "0.2.3" + }, + "sources": { + "pipewire-python": { + "fetcher": "github", + "owner": "pablodz", + "repo": "pipewire_python", + "tag": "v0.2.3", + "hash": "sha256-6UIu7vke40q+n91gU8YxwMV/tWjLT6iDmHCMVqnXdMY=" + } + } +} diff --git a/packages/python/pyoverseerr/default.nix b/packages/python/pyoverseerr/default.nix index 3f2023c..3a67576 100644 --- a/packages/python/pyoverseerr/default.nix +++ b/packages/python/pyoverseerr/default.nix @@ -1,15 +1,25 @@ -{ fetchFromGitHub, home-assistant, ... }: -home-assistant.python.pkgs.buildPythonPackage rec { +{ + lib, + namespace, + pkgs, + home-assistant, + ... +}: +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; + + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources.pyoverseerr; +in +home-assistant.python.pkgs.buildPythonPackage { pname = "pyoverseerr"; - version = "0.1.40"; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; format = "setuptools"; - src = fetchFromGitHub { - owner = "vaparr"; - repo = pname; - rev = "master"; - sha256 = "sha256-sWYe6EV/IO/tGGXcnKiebb47eidIj0xnM/aZUfdZXyY="; - }; + src = sources.pyoverseerr; build-system = [ home-assistant.python.pkgs.setuptools ]; doCheck = false; # no tests in the PyPI tarball diff --git a/packages/python/pyoverseerr/version.json b/packages/python/pyoverseerr/version.json new file mode 100644 index 0000000..4ef08de --- /dev/null +++ b/packages/python/pyoverseerr/version.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "sources": { + "pyoverseerr": { + "fetcher": "github", + "owner": "vaparr", + "repo": "pyoverseerr", + "rev": "master", + "hash": "sha256-sWYe6EV/IO/tGGXcnKiebb47eidIj0xnM/aZUfdZXyY=" + } + } +} diff --git a/packages/python/python-nanokvm/default.nix b/packages/python/python-nanokvm/default.nix index 2543b35..c197b61 100644 --- a/packages/python/python-nanokvm/default.nix +++ b/packages/python/python-nanokvm/default.nix @@ -1,19 +1,25 @@ { + lib, + namespace, + pkgs, python3Packages, - fetchFromGitHub, ... }: -python3Packages.buildPythonPackage rec { +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; + + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources."python-nanokvm"; +in +python3Packages.buildPythonPackage { pname = "nanokvm"; - version = "0.1.0"; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; format = "pyproject"; - src = fetchFromGitHub { - owner = "puddly"; - repo = "python-${pname}"; - rev = "v${version}"; - sha256 = "sha256-vIxvQtjaInnWQce7syiOWpP2kaw0IVw03HPovnB2J5M="; - }; + src = sources."python-nanokvm"; prePatch = '' rm -f pyproject.toml diff --git a/packages/python/python-nanokvm/version.json b/packages/python/python-nanokvm/version.json new file mode 100644 index 0000000..ec9cc3e --- /dev/null +++ b/packages/python/python-nanokvm/version.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "0.1.0" + }, + "sources": { + "python-nanokvm": { + "fetcher": "github", + "owner": "puddly", + "repo": "python-nanokvm", + "tag": "v0.1.0", + "hash": "sha256-vIxvQtjaInnWQce7syiOWpP2kaw0IVw03HPovnB2J5M=" + } + } +} diff --git a/packages/python/python-steam/default.nix b/packages/python/python-steam/default.nix index 6c85223..8e32935 100644 --- a/packages/python/python-steam/default.nix +++ b/packages/python/python-steam/default.nix @@ -1,14 +1,26 @@ -{ python3Packages, fetchPypi, ... }: +{ + lib, + namespace, + pkgs, + python3Packages, + ... +}: +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; -python3Packages.buildPythonPackage rec { + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources."python-steam"; + version = selected.variables.version; +in +python3Packages.buildPythonPackage { pname = "steam"; - version = "1.4.4"; + inherit version; pyproject = false; - src = fetchPypi { - inherit pname version; - sha256 = "sha256-K1vWkRwNSnMS9EG40WK52NR8i+u478bIhnOTsDI/pS4="; - }; + src = sources."python-steam"; buildInputs = with python3Packages; [ setuptools ]; diff --git a/packages/python/python-steam/version.json b/packages/python/python-steam/version.json new file mode 100644 index 0000000..87491a4 --- /dev/null +++ b/packages/python/python-steam/version.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "1.4.4" + }, + "sources": { + "python-steam": { + "fetcher": "pypi", + "name": "steam", + "hash": "sha256-K1vWkRwNSnMS9EG40WK52NR8i+u478bIhnOTsDI/pS4=" + } + } +} diff --git a/packages/python/pyvesync/default.nix b/packages/python/pyvesync/default.nix index 6e6e874..8ae77ab 100644 --- a/packages/python/pyvesync/default.nix +++ b/packages/python/pyvesync/default.nix @@ -1,22 +1,28 @@ { lib, - fetchFromGitHub, + namespace, + pkgs, python3Packages, + ... }: +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; -python3Packages.buildPythonPackage rec { + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources.pyvesync; + version = selected.variables.version; +in +python3Packages.buildPythonPackage { pname = "pyvesync"; - version = "3.4.1"; + inherit version; pyproject = true; disabled = python3Packages.pythonOlder "3.11"; - src = fetchFromGitHub { - owner = "webdjoe"; - repo = "pyvesync"; - rev = version; - hash = "sha256-iqOKBpP/TYgbs6Tq+eWhxBCu/bHYRELXY7r4zjEXU3Q="; - }; + src = sources.pyvesync; build-system = with python3Packages; [ setuptools ]; @@ -31,7 +37,7 @@ python3Packages.buildPythonPackage rec { meta = with lib; { description = "Python library to manage Etekcity Devices and Levoit Air Purifier"; homepage = "https://github.com/webdjoe/pyvesync"; - changelog = "https://github.com/webdjoe/pyvesync/releases/tag/${src.tag}"; + changelog = "https://github.com/webdjoe/pyvesync/releases/tag/${src-meta.tag}"; license = with licenses; [ mit ]; maintainers = with maintainers; [ fab ]; }; diff --git a/packages/python/pyvesync/version.json b/packages/python/pyvesync/version.json new file mode 100644 index 0000000..be12351 --- /dev/null +++ b/packages/python/pyvesync/version.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "3.4.1" + }, + "sources": { + "pyvesync": { + "fetcher": "github", + "owner": "webdjoe", + "repo": "pyvesync", + "tag": "3.4.1", + "hash": "sha256-iqOKBpP/TYgbs6Tq+eWhxBCu/bHYRELXY7r4zjEXU3Q=" + } + } +} diff --git a/packages/python/wyzeapy/default.nix b/packages/python/wyzeapy/default.nix index c28891e..4bae05d 100644 --- a/packages/python/wyzeapy/default.nix +++ b/packages/python/wyzeapy/default.nix @@ -1,15 +1,25 @@ -{ fetchFromGitHub, home-assistant, ... }: -home-assistant.python.pkgs.buildPythonPackage rec { +{ + lib, + namespace, + pkgs, + home-assistant, + ... +}: +let + inherit (lib.trivial) importJSON; + inherit (lib.${namespace}) selectVariant mkAllSources; + + versionSpec = importJSON ./version.json; + selected = selectVariant versionSpec null null; + sources = mkAllSources pkgs selected; + src-meta = selected.sources.wyzeapy; +in +home-assistant.python.pkgs.buildPythonPackage { pname = "wyzeapy"; - version = "0.5.31"; + version = if src-meta ? tag then src-meta.tag else src-meta.rev; format = "pyproject"; - src = fetchFromGitHub { - owner = "SecKatie"; - repo = "wyzeapy"; - rev = "v${version}"; - sha256 = "sha256-KDCd1G5Tj0YWM2WA3DJK9rTf1rMzz4qBSUl8FOUbvdM="; - }; + src = sources.wyzeapy; build-system = with home-assistant.python.pkgs; [ poetry-core diff --git a/packages/python/wyzeapy/version.json b/packages/python/wyzeapy/version.json new file mode 100644 index 0000000..12b20f3 --- /dev/null +++ b/packages/python/wyzeapy/version.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "variables": { + "version": "0.5.31" + }, + "sources": { + "wyzeapy": { + "fetcher": "github", + "owner": "SecKatie", + "repo": "wyzeapy", + "tag": "v0.5.31", + "hash": "sha256-KDCd1G5Tj0YWM2WA3DJK9rTf1rMzz4qBSUl8FOUbvdM=" + } + } +} diff --git a/scripts/hooks.py b/scripts/hooks.py new file mode 100644 index 0000000..47c2dc3 --- /dev/null +++ b/scripts/hooks.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +Per-package hooks for version management. + +Each hook is a callable registered by package name (the relative path under +packages/, e.g. 'raspberrypi/linux-rpi') and source component name. + +A hook can override: + - fetch_candidates(comp, merged_vars) -> Candidates + - prefetch_source(comp, merged_vars) -> Optional[str] (not yet needed) + +Hooks are invoked by both the CLI updater and the TUI. + +Adding a new hook: + 1. Define a function or class with the required signature. + 2. Register it via register_candidates_hook(pkg_name, src_name, fn) at module + level below. +""" + +from __future__ import annotations + +import re +from typing import Callable, Dict, Optional, Tuple + +from lib import ( + Candidates, + Json, + gh_head_commit, + gh_list_tags, + gh_ref_date, + gh_release_date, + http_get_text, +) + +# --------------------------------------------------------------------------- +# Hook registry +# --------------------------------------------------------------------------- + +# (pkg_name, src_name) -> fn(comp, merged_vars) -> Candidates +_CANDIDATES_HOOKS: Dict[Tuple[str, str], Callable] = {} + + +def register_candidates_hook(pkg: str, src: str, fn: Callable) -> None: + _CANDIDATES_HOOKS[(pkg, src)] = fn + + +def get_candidates_hook(pkg: str, src: str) -> Optional[Callable]: + return _CANDIDATES_HOOKS.get((pkg, src)) + + +# --------------------------------------------------------------------------- +# Raspberry Pi linux — stable_YYYYMMDD tag selection +# --------------------------------------------------------------------------- + + +def _rpi_linux_stable_candidates(comp: Json, merged_vars: Json) -> Candidates: + from lib import render, gh_latest_release, gh_latest_tag + + c = Candidates() + owner = comp.get("owner", "raspberrypi") + repo = comp.get("repo", "linux") + branch: Optional[str] = comp.get("branch") or None + + tags_all = gh_list_tags(owner, repo) + + rendered = render(comp, merged_vars) + cur_tag = str(rendered.get("tag") or "") + + if cur_tag.startswith("stable_") or not branch: + # Pick the most recent stable_YYYYMMDD tag + stable_tags = sorted( + [t for t in tags_all if re.match(r"^stable_\d{8}$", t)], + reverse=True, + ) + if stable_tags: + c.tag = stable_tags[0] + c.tag_date = gh_ref_date(owner, repo, c.tag) + else: + # Series-based tracking: pick latest rpi-X.Y.* tag + mm = str(merged_vars.get("modDirVersion") or "") + m = re.match(r"^(\d+)\.(\d+)", mm) + if m: + base = f"rpi-{m.group(1)}.{m.group(2)}" + series = [ + t + for t in tags_all + if t == f"{base}.y" + or t.startswith(f"{base}.y") + or t.startswith(f"{base}.") + ] + series.sort(reverse=True) + if series: + c.tag = series[0] + c.tag_date = gh_ref_date(owner, repo, c.tag) + + if branch: + commit = gh_head_commit(owner, repo, branch) + if commit: + c.commit = commit + c.commit_date = gh_ref_date(owner, repo, commit) + + return c + + +register_candidates_hook( + "raspberrypi/linux-rpi", "stable", _rpi_linux_stable_candidates +) +register_candidates_hook( + "raspberrypi/linux-rpi", "unstable", _rpi_linux_stable_candidates +) + + +# --------------------------------------------------------------------------- +# CachyOS linux — version from upstream PKGBUILD / .SRCINFO +# --------------------------------------------------------------------------- + + +def _parse_cachyos_linux_version(text: str, is_srcinfo: bool) -> Optional[str]: + if is_srcinfo: + m = re.search(r"^\s*pkgver\s*=\s*([^\s#]+)\s*$", text, re.MULTILINE) + if m: + v = m.group(1).strip().replace(".rc", "-rc") + return v + return None + + # PKGBUILD + env: Dict[str, str] = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + ma = re.match(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$", line) + if ma: + key, val = ma.group(1), ma.group(2).strip() + val = re.sub(r"\s+#.*$", "", val).strip() + if (val.startswith('"') and val.endswith('"')) or ( + val.startswith("'") and val.endswith("'") + ): + val = val[1:-1] + env[key] = val + + m2 = re.search(r"^\s*pkgver\s*=\s*(.+)$", text, re.MULTILINE) + if not m2: + return None + raw = m2.group(1).strip().strip("\"'") + + def expand(s: str) -> str: + s = re.sub(r"\$\{([^}]+)\}", lambda mb: env.get(mb.group(1), mb.group(0)), s) + s = re.sub( + r"\$([A-Za-z_][A-Za-z0-9_]*)", + lambda mu: env.get(mu.group(1), mu.group(0)), + s, + ) + return s + + return expand(raw).strip().replace(".rc", "-rc") + + +def _cachyos_linux_suffix(variant_name: Optional[str]) -> str: + if not variant_name: + return "" + return {"rc": "-rc", "hardened": "-hardened", "lts": "-lts"}.get(variant_name, "") + + +def fetch_cachyos_linux_version(suffix: str) -> Optional[str]: + bases = [ + "https://raw.githubusercontent.com/CachyOS/linux-cachyos/master", + "https://raw.githubusercontent.com/cachyos/linux-cachyos/master", + ] + for base in bases: + text = http_get_text(f"{base}/linux-cachyos{suffix}/.SRCINFO") + if text: + v = _parse_cachyos_linux_version(text, is_srcinfo=True) + if v: + return v + text = http_get_text(f"{base}/linux-cachyos{suffix}/PKGBUILD") + if text: + v = _parse_cachyos_linux_version(text, is_srcinfo=False) + if v: + return v + return None + + +def linux_tarball_url(version: str) -> str: + 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" + ver_for_tar = ".".join(parts[:2]) if version.endswith(".0") else version + return ( + f"https://cdn.kernel.org/pub/linux/kernel/v{major}.x/linux-{ver_for_tar}.tar.xz" + ) + + +# Note: linux-cachyos is not yet in the repo, but the hook is defined here +# so it can be activated when that package is added. +def _cachyos_linux_candidates(comp: Json, merged_vars: Json) -> Candidates: + c = Candidates() + # The variant name is not available here; the TUI/CLI must pass it via merged_vars + suffix = str(merged_vars.get("_cachyos_suffix") or "") + latest = fetch_cachyos_linux_version(suffix) + if latest: + c.tag = latest # use tag slot for display consistency + return c + + +register_candidates_hook("linux-cachyos", "linux", _cachyos_linux_candidates) + + +# --------------------------------------------------------------------------- +# CachyOS ZFS — commit pinned in PKGBUILD +# --------------------------------------------------------------------------- + + +def fetch_cachyos_zfs_commit(suffix: str) -> Optional[str]: + bases = [ + "https://raw.githubusercontent.com/CachyOS/linux-cachyos/master", + "https://raw.githubusercontent.com/cachyos/linux-cachyos/master", + ] + for base in bases: + text = http_get_text(f"{base}/linux-cachyos{suffix}/PKGBUILD") + 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 _cachyos_zfs_candidates(comp: Json, merged_vars: Json) -> Candidates: + c = Candidates() + suffix = str(merged_vars.get("_cachyos_suffix") or "") + sha = fetch_cachyos_zfs_commit(suffix) + if sha: + c.commit = sha + url = comp.get("url") or "" + c.commit_date = ( + gh_ref_date("cachyos", "zfs", sha) if "github.com" in url else "" + ) + return c + + +register_candidates_hook("linux-cachyos", "zfs", _cachyos_zfs_candidates) diff --git a/scripts/lib.py b/scripts/lib.py new file mode 100644 index 0000000..0d0dbdd --- /dev/null +++ b/scripts/lib.py @@ -0,0 +1,857 @@ +#!/usr/bin/env python3 +""" +Shared library for version.json management. + +Provides: + - JSON load/save + - Variable template rendering + - Base+variant merge (mirrors lib/versioning/default.nix) + - GitHub/Git candidate fetching + - Nix hash prefetching (fetchFromGitHub, fetchgit, fetchurl, fetchzip, cargo vendor) + - Package scanning +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +Json = Dict[str, Any] + +ROOT = Path(__file__).resolve().parents[1] +PKGS_DIR = ROOT / "packages" + + +# --------------------------------------------------------------------------- +# I/O +# --------------------------------------------------------------------------- + + +def load_json(path: Path) -> Json: + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def save_json(path: Path, data: Json) -> None: + tmp = path.with_suffix(".tmp") + with tmp.open("w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + tmp.replace(path) + + +def eprint(*args: Any, **kwargs: Any) -> None: + print(*args, file=sys.stderr, **kwargs) + + +# --------------------------------------------------------------------------- +# Template rendering +# --------------------------------------------------------------------------- + + +def render(value: Any, variables: Dict[str, Any]) -> Any: + """Recursively substitute ${var} in strings using the given variable map.""" + if isinstance(value, str): + return re.sub( + r"\$\{([^}]+)\}", + lambda m: str(variables.get(m.group(1), m.group(0))), + value, + ) + if isinstance(value, dict): + return {k: render(v, variables) for k, v in value.items()} + if isinstance(value, list): + return [render(v, variables) for v in value] + return value + + +# --------------------------------------------------------------------------- +# Merge (matches lib/versioning/default.nix) +# --------------------------------------------------------------------------- + + +def _deep_merge(a: Json, b: Json) -> Json: + out = dict(a) + for k, v in b.items(): + if k in out and isinstance(out[k], dict) and isinstance(v, dict): + out[k] = _deep_merge(out[k], v) + else: + out[k] = v + return out + + +def _merge_sources(base: Json, overrides: Json) -> Json: + names = set(base) | set(overrides) + result: Json = {} + for n in names: + if n in base and n in overrides: + b, o = base[n], overrides[n] + result[n] = ( + _deep_merge(b, o) if isinstance(b, dict) and isinstance(o, dict) else o + ) + elif n in overrides: + result[n] = overrides[n] + else: + result[n] = base[n] + return result + + +def merged_view(spec: Json, variant_name: Optional[str]) -> Tuple[Json, Json, Json]: + """ + Return (merged_variables, merged_sources, write_target). + + merged_variables / merged_sources: what to use for display and prefetching. + write_target: the dict to mutate when saving changes (base spec or the + variant sub-dict). + """ + base_vars: Json = spec.get("variables") or {} + base_srcs: Json = spec.get("sources") or {} + + if variant_name: + vdict = (spec.get("variants") or {}).get(variant_name) + if not isinstance(vdict, dict): + raise ValueError(f"Variant '{variant_name}' not found in spec") + v_vars: Json = vdict.get("variables") or {} + v_srcs: Json = vdict.get("sources") or {} + merged_vars = {**base_vars, **v_vars} + merged_srcs = _merge_sources(base_srcs, v_srcs) + return merged_vars, merged_srcs, vdict + + return dict(base_vars), dict(base_srcs), spec + + +# --------------------------------------------------------------------------- +# Shell helpers +# --------------------------------------------------------------------------- + + +def _run(args: List[str], *, capture_stderr: bool = True) -> Tuple[int, str, str]: + p = subprocess.run( + args, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE if capture_stderr else None, + check=False, + ) + return p.returncode, (p.stdout or "").strip(), (p.stderr or "").strip() + + +def _run_out(args: List[str]) -> Optional[str]: + code, out, err = _run(args) + if code != 0: + eprint(f"Command failed: {' '.join(args)}\n{err}") + return None + return out + + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + + +def http_get_json(url: str, token: Optional[str] = None) -> Optional[Any]: + try: + req = urllib.request.Request( + url, headers={"Accept": "application/vnd.github+json"} + ) + if token: + req.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + eprint(f"HTTP {e.code} for {url}: {e.reason}") + except Exception as e: + eprint(f"Request failed for {url}: {e}") + return None + + +def http_get_text(url: str) -> Optional[str]: + try: + req = urllib.request.Request( + url, headers={"User-Agent": "nix-version-manager/2.0"} + ) + with urllib.request.urlopen(req, timeout=15) as resp: + return resp.read().decode("utf-8") + except urllib.error.HTTPError as e: + eprint(f"HTTP {e.code} for {url}: {e.reason}") + except Exception as e: + eprint(f"Request failed for {url}: {e}") + return None + + +# --------------------------------------------------------------------------- +# GitHub API helpers +# --------------------------------------------------------------------------- + + +def gh_token() -> Optional[str]: + return os.environ.get("GITHUB_TOKEN") + + +def gh_latest_release(owner: str, repo: str) -> Optional[str]: + data = http_get_json( + f"https://api.github.com/repos/{owner}/{repo}/releases/latest", gh_token() + ) + return data.get("tag_name") if isinstance(data, dict) else None + + +def gh_latest_tag( + owner: str, repo: str, *, tag_regex: Optional[str] = None +) -> Optional[str]: + data = http_get_json( + f"https://api.github.com/repos/{owner}/{repo}/tags?per_page=100", gh_token() + ) + if not isinstance(data, list): + return None + tags = [t["name"] for t in data if isinstance(t, dict) and t.get("name")] + if tag_regex: + rx = re.compile(tag_regex) + tags = [t for t in tags if rx.search(t)] + return tags[0] if tags else None + + +def gh_list_tags(owner: str, repo: str) -> List[str]: + data = http_get_json( + f"https://api.github.com/repos/{owner}/{repo}/tags?per_page=100", gh_token() + ) + if not isinstance(data, list): + return [] + return [t["name"] for t in data if isinstance(t, dict) and t.get("name")] + + +def gh_head_commit( + owner: str, repo: str, branch: Optional[str] = None +) -> Optional[str]: + ref = f"refs/heads/{branch}" if branch else "HEAD" + out = _run_out(["git", "ls-remote", f"https://github.com/{owner}/{repo}.git", ref]) + if not out: + return None + for line in out.splitlines(): + parts = line.split() + if parts: + return parts[0] + return None + + +def gh_release_tags(owner: str, repo: str) -> List[str]: + data = http_get_json( + f"https://api.github.com/repos/{owner}/{repo}/releases?per_page=50", gh_token() + ) + if not isinstance(data, list): + return [] + return [r["tag_name"] for r in data if isinstance(r, dict) and r.get("tag_name")] + + +def _iso_to_date(iso: str) -> str: + return iso[:10] if iso and len(iso) >= 10 else "" + + +def gh_ref_date(owner: str, repo: str, ref: str) -> str: + data = http_get_json( + f"https://api.github.com/repos/{owner}/{repo}/commits/{urllib.parse.quote(ref, safe='')}", + gh_token(), + ) + if not isinstance(data, dict): + return "" + iso = ( + (data.get("commit") or {}).get("committer", {}).get("date") + or (data.get("commit") or {}).get("author", {}).get("date") + or "" + ) + return _iso_to_date(iso) + + +def gh_release_date(owner: str, repo: str, tag: str) -> str: + data = http_get_json( + f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{urllib.parse.quote(tag, safe='')}", + gh_token(), + ) + if isinstance(data, dict): + iso = data.get("published_at") or data.get("created_at") or "" + if iso: + return _iso_to_date(iso) + return gh_ref_date(owner, repo, tag) + + +def git_branch_commit(url: str, branch: Optional[str] = None) -> Optional[str]: + ref = f"refs/heads/{branch}" if branch else "HEAD" + out = _run_out(["git", "ls-remote", url, ref]) + if not out: + return None + for line in out.splitlines(): + parts = line.split() + if parts: + return parts[0] + return None + + +def git_commit_date_for_github(url: str, sha: str) -> str: + """Only works for github.com URLs; returns YYYY-MM-DD or empty string.""" + try: + parsed = urllib.parse.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") + return gh_ref_date(owner, repo, sha) + except Exception: + return "" + + +# --------------------------------------------------------------------------- +# Nix prefetch helpers +# --------------------------------------------------------------------------- + + +def _nix_fakehash_build(expr: str) -> Optional[str]: + """ + Build a Nix expression that intentionally uses lib.fakeHash, parse the + correct hash from the 'got:' line in the error output. + """ + 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) + eprint(f"nix fakeHash build failed:\n{p.stderr[-800:]}") + return None + + +def prefetch_github( + owner: str, repo: str, rev: str, *, submodules: bool = False +) -> Optional[str]: + """ + Hash for fetchFromGitHub — NAR hash of unpacked tarball. + Must use the fakeHash trick; nix store prefetch-file gives the wrong hash. + """ + 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 prefetch_url(url: str) -> Optional[str]: + """ + Flat (non-unpacked) hash for fetchurl. + Uses nix store prefetch-file; falls back to nix-prefetch-url. + """ + out = _run_out( + ["nix", "store", "prefetch-file", "--hash-type", "sha256", "--json", url] + ) + if out: + try: + data = json.loads(out) + if "hash" in data: + return data["hash"] + except Exception: + pass + + out = _run_out(["nix-prefetch-url", "--type", "sha256", url]) + if out is None: + out = _run_out(["nix-prefetch-url", url]) + if out is None: + return None + return _run_out(["nix", "hash", "to-sri", "--type", "sha256", out]) + + +def prefetch_fetchzip(url: str, *, strip_root: bool = True) -> Optional[str]: + """Hash for fetchzip — NAR of unpacked archive. Must use the fakeHash trick.""" + expr = ( + f"let pkgs = import {{}};\n" + f"in pkgs.fetchzip {{\n" + f' url = "{url}";\n' + f" stripRoot = {'true' if strip_root else 'false'};\n" + f" hash = pkgs.lib.fakeHash;\n" + f"}}" + ) + return _nix_fakehash_build(expr) + + +def prefetch_git(url: str, rev: str) -> Optional[str]: + """Hash for fetchgit.""" + out = _run_out(["nix-prefetch-git", "--no-deepClone", "--rev", rev, url]) + 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: + return _run_out(["nix", "hash", "to-sri", "--type", "sha256", base32]) + + # Fallback: builtins.fetchGit + nix hash path (commit SHA only) + if re.match(r"^[0-9a-f]{40}$", rev): + expr = f'builtins.fetchGit {{ url = "{url}"; rev = "{rev}"; }}' + store_path = _run_out(["nix", "eval", "--raw", "--expr", expr]) + if store_path: + return _run_out(["nix", "hash", "path", "--type", "sha256", store_path]) + + return None + + +def prefetch_cargo_vendor( + fetcher: str, + src_hash: str, + *, + url: str = "", + owner: str = "", + repo: str = "", + rev: str = "", + subdir: str = "", +) -> Optional[str]: + """Compute the cargo vendor hash via fetchCargoVendor + fakeHash.""" + if fetcher == "github" and owner and repo and rev and src_hash: + src_expr = ( + f'pkgs.fetchFromGitHub {{ owner = "{owner}"; repo = "{repo}";' + f' rev = "{rev}"; hash = "{src_hash}"; }}' + ) + elif fetcher == "git" and url and rev and src_hash: + parsed = urllib.parse.urlparse(url) + parts = [p for p in parsed.path.split("/") if p] + if parsed.hostname == "github.com" and len(parts) >= 2: + gh_owner, gh_repo = parts[0], parts[1] + src_expr = ( + f'pkgs.fetchFromGitHub {{ owner = "{gh_owner}"; repo = "{gh_repo}";' + f' rev = "{rev}"; hash = "{src_hash}"; }}' + ) + else: + src_expr = f'pkgs.fetchgit {{ url = "{url}"; rev = "{rev}"; hash = "{src_hash}"; }}' + else: + return None + + subdir_attr = f'sourceRoot = "${{src.name}}/{subdir}";' if subdir else "" + expr = ( + f"let pkgs = import {{}};\n" + f" src = {src_expr};\n" + f"in pkgs.rustPlatform.fetchCargoVendor {{\n" + f" inherit src;\n" + f" {subdir_attr}\n" + f" hash = pkgs.lib.fakeHash;\n" + f"}}" + ) + 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) + eprint(f"cargo vendor prefetch failed:\n{p.stderr[-600:]}") + return None + + +# --------------------------------------------------------------------------- +# Source prefetch dispatch +# --------------------------------------------------------------------------- + + +def prefetch_source(comp: Json, merged_vars: Json) -> Optional[str]: + """ + Compute and return the SRI hash for a source component using the correct + Nix fetcher. Returns None on failure. + """ + fetcher = comp.get("fetcher", "none") + rendered = render(comp, merged_vars) + + if fetcher == "github": + owner = comp.get("owner") or "" + repo = comp.get("repo") or "" + ref = rendered.get("tag") or rendered.get("rev") or "" + submodules = bool(comp.get("submodules", False)) + if owner and repo and ref: + return prefetch_github(owner, repo, ref, submodules=submodules) + + elif fetcher == "git": + url = comp.get("url") or "" + rev = rendered.get("rev") or rendered.get("tag") or "" + if url and rev: + return prefetch_git(url, rev) + + elif fetcher == "url": + url = rendered.get("url") or rendered.get("urlTemplate") or "" + if url: + extra = comp.get("extra") or {} + if extra.get("unpack") == "zip": + return prefetch_fetchzip(url, strip_root=extra.get("stripRoot", True)) + return prefetch_url(url) + + return None + + +# --------------------------------------------------------------------------- +# Candidate fetching (what versions are available upstream) +# --------------------------------------------------------------------------- + + +class Candidates: + """Latest available refs for a source component.""" + + __slots__ = ("release", "release_date", "tag", "tag_date", "commit", "commit_date") + + def __init__(self) -> None: + self.release = self.release_date = "" + self.tag = self.tag_date = "" + self.commit = self.commit_date = "" + + +def fetch_candidates(comp: Json, merged_vars: Json) -> Candidates: + """ + Fetch the latest release, tag, and commit for a source component. + For 'url' fetcher with github variables, fetches the latest release tag. + """ + c = Candidates() + fetcher = comp.get("fetcher", "none") + branch: Optional[str] = comp.get("branch") or None + + if fetcher == "github": + owner = comp.get("owner") or "" + repo = comp.get("repo") or "" + if not (owner and repo): + return c + + if not branch: + r = gh_latest_release(owner, repo) + if r: + c.release = r + c.release_date = gh_release_date(owner, repo, r) + t = gh_latest_tag(owner, repo) + if t: + c.tag = t + c.tag_date = gh_ref_date(owner, repo, t) + + m = gh_head_commit(owner, repo, branch) + if m: + c.commit = m + c.commit_date = gh_ref_date(owner, repo, m) + + elif fetcher == "git": + url = comp.get("url") or "" + if url: + m = git_branch_commit(url, branch) + if m: + c.commit = m + c.commit_date = git_commit_date_for_github(url, m) + + elif fetcher == "url": + url_info = _url_source_info(comp, merged_vars) + kind = url_info.get("kind") + + if kind == "github": + owner = url_info["owner"] + repo = url_info["repo"] + tags = gh_release_tags(owner, repo) + prefix = str(merged_vars.get("releasePrefix") or "") + suffix = str(merged_vars.get("releaseSuffix") or "") + if prefix or suffix: + latest = next( + (t for t in tags if t.startswith(prefix) and t.endswith(suffix)), + None, + ) + else: + latest = tags[0] if tags else None + if latest: + c.release = latest + c.release_date = gh_release_date(owner, repo, latest) + + elif kind == "pypi": + name = url_info["name"] + latest = pypi_latest_version(name) + if latest: + c.release = latest + + elif kind == "openvsx": + publisher = url_info["publisher"] + ext_name = url_info["ext_name"] + latest = openvsx_latest_version(publisher, ext_name) + if latest: + c.release = latest + + return c + + +# --------------------------------------------------------------------------- +# Non-git upstream version helpers +# --------------------------------------------------------------------------- + + +def pypi_latest_version(name: str) -> Optional[str]: + """Return the latest stable release version from PyPI.""" + data = http_get_json(f"https://pypi.org/pypi/{urllib.parse.quote(name)}/json") + if not isinstance(data, dict): + return None + return (data.get("info") or {}).get("version") or None + + +def pypi_hash(name: str, version: str) -> Optional[str]: + """ + Return the SRI hash for a PyPI sdist or wheel using nix-prefetch. + Falls back to a fake-hash Nix build if nix-prefetch-url is unavailable. + """ + data = http_get_json( + f"https://pypi.org/pypi/{urllib.parse.quote(name)}/{urllib.parse.quote(version)}/json" + ) + if not isinstance(data, dict): + return None + + urls = data.get("urls") or [] + # Prefer sdist; fall back to any wheel + sdist_url = next((u["url"] for u in urls if u.get("packagetype") == "sdist"), None) + wheel_url = next( + (u["url"] for u in urls if u.get("packagetype") == "bdist_wheel"), None + ) + url = sdist_url or wheel_url + if not url: + return None + return prefetch_url(url) + + +def openvsx_latest_version(publisher: str, ext_name: str) -> Optional[str]: + """Return the latest version of an extension from Open VSX Registry.""" + data = http_get_json( + f"https://open-vsx.org/api/{urllib.parse.quote(publisher)}/{urllib.parse.quote(ext_name)}" + ) + if not isinstance(data, dict): + return None + return data.get("version") or None + + +def _url_source_info(comp: Json, merged_vars: Json) -> Json: + """ + Classify a url-fetcher source and extract the relevant identifiers. + Returns a dict with at least 'kind' in: + 'github' — GitHub release asset; includes 'owner', 'repo' + 'pypi' — PyPI package; includes 'name', 'version_var' + 'openvsx' — Open VSX extension; includes 'publisher', 'ext_name', 'version_var' + 'plain' — plain URL with a version variable; includes 'version_var' if found + 'static' — hardcoded URL with no variable parts + """ + tmpl = comp.get("urlTemplate") or comp.get("url") or "" + + # Check merged_vars for explicit github owner/repo + owner = str(merged_vars.get("owner") or "") + repo = str(merged_vars.get("repo") or "") + if owner and repo: + return {"kind": "github", "owner": owner, "repo": repo} + + # Detect from URL template + gh_m = re.search(r"github\.com/([^/\$]+)/([^/\$]+)/releases/download", tmpl) + if gh_m: + vvar = _find_version_var(tmpl, merged_vars) + return { + "kind": "github", + "owner": gh_m.group(1), + "repo": gh_m.group(2), + "version_var": vvar, + } + + # Open VSX (open-vsx.org/api/${publisher}/${name}/${version}/...) + vsx_m = re.search( + r"open-vsx\.org/api/([^/\$]+)/([^/\$]+)/(?:\$\{[^}]+\}|[^/]+)/file", tmpl + ) + if not vsx_m: + # Also match when publisher/name come from variables + if "open-vsx.org/api/" in tmpl: + publisher = str(merged_vars.get("publisher") or "") + ext_name = str(merged_vars.get("name") or "") + if publisher and ext_name: + vvar = _find_version_var(tmpl, merged_vars) + return { + "kind": "openvsx", + "publisher": publisher, + "ext_name": ext_name, + "version_var": vvar, + } + if vsx_m: + publisher = vsx_m.group(1) + ext_name = vsx_m.group(2) + # publisher/ext_name may be literal or variable refs + publisher = str(merged_vars.get(publisher.lstrip("${").rstrip("}"), publisher)) + ext_name = str(merged_vars.get(ext_name.lstrip("${").rstrip("}"), ext_name)) + vvar = _find_version_var(tmpl, merged_vars) + return { + "kind": "openvsx", + "publisher": publisher, + "ext_name": ext_name, + "version_var": vvar, + } + + # PyPI: files.pythonhosted.org URLs + if "files.pythonhosted.org" in tmpl or "pypi.org" in tmpl: + pypi_name = str(merged_vars.get("name") or "") + if not pypi_name: + m = re.search(r"/packages/[^/]+/[^/]+/([^/]+)-\d", tmpl) + pypi_name = m.group(1).replace("_", "-") if m else "" + vvar = _find_version_var(tmpl, merged_vars) + return {"kind": "pypi", "name": pypi_name, "version_var": vvar} + + vvar = _find_version_var(tmpl, merged_vars) + if vvar: + return {"kind": "plain", "version_var": vvar} + + return {"kind": "static"} + + +def _find_version_var(tmpl: str, merged_vars: Json) -> str: + """ + Return the name of the variable in merged_vars that looks most like + a version string and appears in the template, or '' if none found. + Prefers keys named 'version', then anything whose value looks like a + semver/calver string. + """ + candidates = [k for k in merged_vars if f"${{{k}}}" in tmpl] + if "version" in candidates: + return "version" + # Pick the one whose value most resembles a version + ver_re = re.compile(r"^\d+[\.\-]\d") + for k in candidates: + if ver_re.match(str(merged_vars.get(k, ""))): + return k + return candidates[0] if candidates else "" + + +def apply_version_update( + comp: Json, + merged_vars: Json, + target_dict: Json, + new_version: str, + version_var: str = "version", +) -> None: + """ + Write `new_version` into the correct location in `target_dict`. + + For url sources the version lives in `variables.`. + For pypi sources it also lives in `variables.version` (the name is fixed). + Clears any URL-path hash so it gets re-prefetched. + """ + # Update the variable + vs = target_dict.setdefault("variables", {}) + vs[version_var] = new_version + + # Clear the old hash on the source so it must be re-prefetched + src_name = None + for k, v in (target_dict.get("sources") or {}).items(): + if isinstance(v, dict) and "hash" in v: + src_name = k + break + # If no source entry yet, or the hash is on the base spec, clear it there too + if src_name: + target_dict["sources"][src_name].pop("hash", None) + else: + # Hash might be at base level (non-variant path) + for k, v in (comp if isinstance(comp, dict) else {}).items(): + pass # read-only; we write through target_dict only + + +# --------------------------------------------------------------------------- +# Package discovery +# --------------------------------------------------------------------------- + + +def find_packages() -> List[Tuple[str, Path]]: + """ + Scan packages/ for version.json files. + Returns sorted list of (display_name, path) tuples. + """ + results: List[Tuple[str, Path]] = [] + for p in PKGS_DIR.rglob("version.json"): + rel = p.relative_to(PKGS_DIR).parent + results.append((str(rel), p)) + results.sort() + return results + + +# --------------------------------------------------------------------------- +# Source display helper +# --------------------------------------------------------------------------- + + +def source_ref_label(comp: Json, merged_vars: Json) -> str: + """Return a short human-readable reference string for a source.""" + fetcher = comp.get("fetcher", "none") + rendered = render(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 "" + 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 "${" in url: + tmpl = comp.get("urlTemplate") or comp.get("url") or url + filename = os.path.basename(urllib.parse.urlparse(tmpl).path) + return re.sub(r"\$\{([^}]+)\}", r"<\1>", filename) + filename = os.path.basename(urllib.parse.urlparse(url).path) + 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 "" + if owner and repo and tag and filename: + return f"{owner}/{repo}@{tag} · {filename}" + return filename or url + + return str(comp.get("version") or comp.get("tag") or comp.get("rev") or "") + + +# --------------------------------------------------------------------------- +# Deep set helper +# --------------------------------------------------------------------------- + + +def deep_set(obj: Json, path: List[str], value: Any) -> None: + cur = obj + for key in path[:-1]: + if key not in cur or not isinstance(cur[key], dict): + cur[key] = {} + cur = cur[key] + cur[path[-1]] = value diff --git a/scripts/update.py b/scripts/update.py new file mode 100644 index 0000000..19eba0a --- /dev/null +++ b/scripts/update.py @@ -0,0 +1,472 @@ +#!/usr/bin/env python3 +""" +version.json CLI updater. + +Usage examples: + # Update a GitHub source to its latest release tag, then recompute hash + scripts/update.py --file packages/edk2/version.json --github-latest-release --prefetch + + # Update a specific component to the latest commit + scripts/update.py --file packages/edk2/version.json --component edk2 --github-latest-commit --prefetch + + # Update all URL-based sources in a file (recompute hash only) + scripts/update.py --file packages/uboot/version.json --url-prefetch + + # Update a variant's variables + scripts/update.py --file packages/proton-cachyos/version.json --variant cachyos-v4 \\ + --set variables.base=10.0 --set variables.release=20260301 + + # Filter tags with a regex (e.g. only stable_* tags) + scripts/update.py --file packages/raspberrypi/linux-rpi/version.json \\ + --component stable --github-latest-tag --tag-regex '^stable_\\d{8}$' --prefetch + + # Update a fetchgit source to HEAD + scripts/update.py --file packages/linux-cachyos/version.json --component zfs --git-latest --prefetch + + # Dry run (show what would change, don't write) + scripts/update.py --file packages/edk2/version.json --github-latest-release --prefetch --dry-run +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path +from typing import List, Optional + +# Ensure scripts/ is on the path so we can import lib and hooks +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import lib +import hooks # noqa: F401 — registers hooks as a side effect + + +def _apply_set_pairs(target: lib.Json, pairs: List[str]) -> bool: + changed = False + for pair in pairs: + if "=" not in pair: + lib.eprint(f"--set: expected KEY=VALUE, got: {pair!r}") + continue + key, val = pair.split("=", 1) + path = [p for p in key.strip().split(".") if p] + lib.deep_set(target, path, val) + lib.eprint(f" set {'.'.join(path)} = {val!r}") + changed = True + return changed + + +def update_components( + spec: lib.Json, + variant: Optional[str], + components: Optional[List[str]], + args: argparse.Namespace, +) -> bool: + changed = False + merged_vars, merged_srcs, target_dict = lib.merged_view(spec, variant) + target_sources: lib.Json = target_dict.setdefault("sources", {}) + + names = ( + list(merged_srcs.keys()) + if not components + else [c for c in components if c in merged_srcs] + ) + if components: + missing = [c for c in components if c not in merged_srcs] + for m in missing: + lib.eprint(f" [warn] component '{m}' not found in merged sources") + + for name in names: + view_comp = merged_srcs[name] + fetcher = view_comp.get("fetcher", "none") + comp = target_sources.setdefault(name, {}) + + if fetcher == "github": + owner = view_comp.get("owner") or "" + repo = view_comp.get("repo") or "" + if not (owner and repo): + lib.eprint(f" [{name}] missing owner/repo, skipping") + continue + + # --set-branch: update branch field and fetch HEAD of that branch + if args.set_branch is not None: + new_branch = args.set_branch or None # empty string → clear branch + if new_branch: + comp["branch"] = new_branch + lib.eprint(f" [{name}] branch -> {new_branch!r}") + else: + comp.pop("branch", None) + lib.eprint(f" [{name}] branch cleared") + changed = True + rev = lib.gh_head_commit(owner, repo, new_branch) + if rev: + comp["rev"] = rev + comp.pop("tag", None) + lib.eprint(f" [{name}] rev -> {rev}") + changed = True + if args.prefetch: + sri = lib.prefetch_github( + owner, + repo, + rev, + submodules=bool(view_comp.get("submodules", False)), + ) + if sri: + comp["hash"] = sri + lib.eprint(f" [{name}] hash -> {sri}") + changed = True + else: + lib.eprint( + f" [{name}] could not resolve HEAD for branch {new_branch!r}" + ) + continue # skip the normal ref-update logic for this component + + new_ref: Optional[str] = None + ref_kind = "" + + if args.github_latest_release: + tag = lib.gh_latest_release(owner, repo) + if tag: + new_ref, ref_kind = tag, "tag" + elif args.github_latest_tag: + tag = lib.gh_latest_tag(owner, repo, tag_regex=args.tag_regex) + if tag: + new_ref, ref_kind = tag, "tag" + elif args.github_latest_commit: + rev = lib.gh_head_commit(owner, repo) + if rev: + new_ref, ref_kind = rev, "rev" + + if new_ref: + if ref_kind == "tag": + comp["tag"] = new_ref + comp.pop("rev", None) + else: + comp["rev"] = new_ref + comp.pop("tag", None) + lib.eprint(f" [{name}] {ref_kind} -> {new_ref}") + changed = True + + if args.prefetch and (new_ref or args.url_prefetch): + # Use merged view with the updated ref for prefetching + merged_vars2, merged_srcs2, _ = lib.merged_view(spec, variant) + view2 = lib.render(merged_srcs2.get(name, view_comp), merged_vars2) + sri = lib.prefetch_github( + owner, + repo, + view2.get("tag") or view2.get("rev") or new_ref or "", + submodules=bool(view_comp.get("submodules", False)), + ) + if sri: + comp["hash"] = sri + lib.eprint(f" [{name}] hash -> {sri}") + changed = True + + elif fetcher == "git": + url = view_comp.get("url") or "" + if not url: + lib.eprint(f" [{name}] missing url for git fetcher, skipping") + continue + + # --set-branch: update branch field and fetch HEAD of that branch + if args.set_branch is not None: + new_branch = args.set_branch or None + if new_branch: + comp["branch"] = new_branch + lib.eprint(f" [{name}] branch -> {new_branch!r}") + else: + comp.pop("branch", None) + lib.eprint(f" [{name}] branch cleared") + changed = True + rev = lib.git_branch_commit(url, new_branch) + if rev: + comp["rev"] = rev + lib.eprint(f" [{name}] rev -> {rev}") + changed = True + if args.prefetch: + sri = lib.prefetch_git(url, rev) + if sri: + comp["hash"] = sri + lib.eprint(f" [{name}] hash -> {sri}") + changed = True + else: + lib.eprint( + f" [{name}] could not resolve HEAD for branch {new_branch!r}" + ) + continue + + if args.git_latest: + rev = lib.git_branch_commit(url, view_comp.get("branch")) + if rev: + comp["rev"] = rev + lib.eprint(f" [{name}] rev -> {rev}") + changed = True + if args.prefetch: + sri = lib.prefetch_git(url, rev) + if sri: + comp["hash"] = sri + lib.eprint(f" [{name}] hash -> {sri}") + changed = True + + elif fetcher == "url": + if args.latest_version: + url_info = lib._url_source_info(view_comp, merged_vars) + kind = url_info.get("kind", "plain") + version_var = url_info.get("version_var") or "version" + new_ver: Optional[str] = None + + if kind == "github": + owner = url_info.get("owner", "") + repo = url_info.get("repo", "") + tags = lib.gh_release_tags(owner, repo) if owner and repo else [] + prefix = str(merged_vars.get("releasePrefix") or "") + suffix = str(merged_vars.get("releaseSuffix") or "") + if prefix or suffix: + tag = next( + ( + t + for t in tags + if t.startswith(prefix) and t.endswith(suffix) + ), + None, + ) + else: + tag = tags[0] if tags else None + if tag: + # Proton-cachyos style: extract base+release from tag + mid = tag + if prefix and mid.startswith(prefix): + mid = mid[len(prefix) :] + if suffix and mid.endswith(suffix): + mid = mid[: -len(suffix)] + parts = mid.split("-") + if ( + len(parts) >= 2 + and "base" in merged_vars + and "release" in merged_vars + ): + lib.eprint( + f" [{name}] latest tag: {tag} (base={parts[0]}, release={parts[-1]})" + ) + vs = target_dict.setdefault("variables", {}) + vs["base"] = parts[0] + vs["release"] = parts[-1] + changed = True + merged_vars2, merged_srcs2, _ = lib.merged_view( + spec, variant + ) + view2 = merged_srcs2.get(name, view_comp) + sri = lib.prefetch_source(view2, merged_vars2) + if sri: + comp["hash"] = sri + lib.eprint(f" [{name}] hash -> {sri}") + changed = True + else: + new_ver = tag + tag = None # avoid fall-through + + elif kind == "openvsx": + publisher = url_info.get("publisher", "") + ext_name = url_info.get("ext_name", "") + new_ver = lib.openvsx_latest_version(publisher, ext_name) + + elif kind == "plain": + lib.eprint( + f" [{name}] url (plain): cannot auto-detect version; use --set" + ) + + if new_ver: + lib.eprint(f" [{name}] latest version: {new_ver}") + vs = target_dict.setdefault("variables", {}) + vs[version_var] = new_ver + changed = True + if args.prefetch: + # Re-render with updated variable + merged_vars2, merged_srcs2, _ = lib.merged_view(spec, variant) + view2 = merged_srcs2.get(name, view_comp) + sri = lib.prefetch_source(view2, merged_vars2) + if sri: + comp["hash"] = sri + lib.eprint(f" [{name}] hash -> {sri}") + changed = True + + elif args.url_prefetch or args.prefetch: + rendered = lib.render(view_comp, merged_vars) + url = rendered.get("url") or rendered.get("urlTemplate") or "" + if not url: + lib.eprint(f" [{name}] no url/urlTemplate for url fetcher") + else: + sri = lib.prefetch_source(view_comp, merged_vars) + if sri: + comp["hash"] = sri + lib.eprint(f" [{name}] hash -> {sri}") + changed = True + + elif fetcher == "pypi": + if args.latest_version: + pkg_name = view_comp.get("name") or str(merged_vars.get("name") or name) + new_ver = lib.pypi_latest_version(pkg_name) + if new_ver: + version_var = ( + lib._url_source_info(view_comp, merged_vars).get("version_var") + or "version" + ) + cur_ver = str(merged_vars.get(version_var) or "") + if new_ver == cur_ver: + lib.eprint(f" [{name}] pypi: already at {new_ver}") + else: + lib.eprint(f" [{name}] pypi: {cur_ver} -> {new_ver}") + vs = target_dict.setdefault("variables", {}) + vs[version_var] = new_ver + changed = True + if args.prefetch: + sri = lib.pypi_hash(pkg_name, new_ver) + if sri: + comp["hash"] = sri + lib.eprint(f" [{name}] hash -> {sri}") + changed = True + else: + lib.eprint(f" [{name}] pypi hash prefetch failed") + else: + lib.eprint( + f" [{name}] pypi: could not fetch latest version for {pkg_name!r}" + ) + elif args.url_prefetch or args.prefetch: + lib.eprint( + f" [{name}] pypi: use --latest-version --prefetch to update hash" + ) + + return changed + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Update version.json files", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__.split("\n", 2)[2], # show the usage block as epilog + ) + ap.add_argument( + "--file", required=True, metavar="PATH", help="Path to version.json" + ) + ap.add_argument( + "--variant", metavar="NAME", help="Variant to target (default: base)" + ) + ap.add_argument( + "--component", + dest="components", + action="append", + metavar="NAME", + help="Limit to specific component(s); can be repeated", + ) + ap.add_argument( + "--github-latest-release", + action="store_true", + help="Update GitHub sources to latest release tag", + ) + ap.add_argument( + "--github-latest-tag", + action="store_true", + help="Update GitHub sources to latest tag", + ) + ap.add_argument( + "--github-latest-commit", + action="store_true", + help="Update GitHub sources to HEAD commit", + ) + ap.add_argument( + "--tag-regex", + metavar="REGEX", + help="Filter tags (used with --github-latest-tag)", + ) + ap.add_argument( + "--set-branch", + metavar="BRANCH", + default=None, + help=( + "Set the branch field on github/git sources, resolve its HEAD commit, " + "and (with --prefetch) recompute the hash. " + "Pass an empty string ('') to clear the branch and switch back to tag/release tracking." + ), + ) + ap.add_argument( + "--git-latest", + action="store_true", + help="Update fetchgit sources to latest HEAD commit", + ) + ap.add_argument( + "--latest-version", + action="store_true", + help=( + "Fetch the latest version from upstream (PyPI, Open VSX, GitHub releases) " + "and update the version variable. Use with --prefetch to also recompute the hash." + ), + ) + ap.add_argument( + "--url-prefetch", + action="store_true", + help="Recompute hash for url/urlTemplate sources", + ) + ap.add_argument( + "--prefetch", + action="store_true", + help="After updating ref, also recompute hash", + ) + ap.add_argument( + "--set", + dest="sets", + action="append", + default=[], + metavar="KEY=VALUE", + help="Set a field (dot-path relative to base or --variant). Can be repeated.", + ) + ap.add_argument( + "--dry-run", action="store_true", help="Show changes without writing" + ) + ap.add_argument( + "--print", + dest="do_print", + action="store_true", + help="Print resulting JSON to stdout", + ) + args = ap.parse_args() + + path = Path(args.file) + if not path.exists(): + lib.eprint(f"File not found: {path}") + return 1 + + spec = lib.load_json(path) + lib.eprint(f"Loaded: {path}") + + # Apply --set mutations + target = spec + if args.variant: + target = spec.setdefault("variants", {}).setdefault(args.variant, {}) + changed = _apply_set_pairs(target, args.sets) + + # Update refs/hashes + if update_components(spec, args.variant, args.components, args): + changed = True + + if changed: + if args.dry_run: + lib.eprint("Dry run: no changes written.") + else: + lib.save_json(path, spec) + lib.eprint(f"Saved: {path}") + else: + lib.eprint("No changes.") + + if args.do_print: + import json + + print(json.dumps(spec, indent=2, ensure_ascii=False)) + + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + sys.exit(130) diff --git a/scripts/update_versions.py b/scripts/update_versions.py deleted file mode 100755 index e06b0bd..0000000 --- a/scripts/update_versions.py +++ /dev/null @@ -1,416 +0,0 @@ -#!/usr/bin/env python3 -""" -Unified version.json updater (TUI-friendly core logic). - -Improvements: -- Correctly merges base + variant variables and sources (component-wise deep merge) -- Updates are written back into the correct dictionary: - - Base: top-level spec["sources"][name] - - Variant: spec["variants"][variant]["sources"][name] (created if missing) -- Hash prefetch uses the merged view with rendered variables - -Supports: -- Updating GitHub components to latest release tag, latest tag, or latest commit -- Updating Git (fetchgit) components to latest commit on default branch -- Recomputing SRI hash for url/urlTemplate, github tarballs, and fetchgit sources -- Setting arbitrary fields (variables.* or sources.*.*) via --set path=value -- Operating on a specific variant or the base (top-level) of a version.json - -Requirements: -- nix-prefetch-url (or `nix prefetch-url`) and `nix hash to-sri` for URL hashing -- nix-prefetch-git + `nix hash to-sri` for Git fetchers -- Network access for GitHub API (optional GITHUB_TOKEN env var) - -Examples: - scripts/update_versions.py --file packages/edk2/version.json --github-latest-release --prefetch - scripts/update_versions.py --file packages/edk2/version.json --component edk2 --github-latest-commit --prefetch - scripts/update_versions.py --file packages/uboot/version.json --url-prefetch - scripts/update_versions.py --file packages/proton-cachyos/version.json --variant cachyos-v4 --set variables.base=10.0 - scripts/update_versions.py --file packages/linux-cachyos/version.json --component zfs --git-latest --prefetch -""" -import argparse -import json -import os -import re -import subprocess -import sys -import urllib.request -import urllib.error -from typing import Any, Dict, List, Optional, Tuple - -Json = Dict[str, Any] - - -def eprintln(*args, **kwargs): - print(*args, file=sys.stderr, **kwargs) - - -def load_json(path: str) -> Json: - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - - -def save_json(path: str, data: Json): - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - f.write("\n") - - -def deep_get(o: Json, path: List[str], default=None): - cur = o - for p in path: - if isinstance(cur, dict) and p in cur: - cur = cur[p] - else: - return default - return cur - - -def deep_set(o: Json, path: List[str], value: Any): - cur = o - for p in path[:-1]: - if p not in cur or not isinstance(cur[p], dict): - cur[p] = {} - cur = cur[p] - cur[path[-1]] = value - - -def parse_set_pair(pair: str) -> Tuple[List[str], str]: - if "=" not in pair: - raise ValueError(f"--set requires KEY=VALUE, got: {pair}") - key, val = pair.split("=", 1) - path = key.strip().split(".") - return path, val - - -def render_templates(value: Any, variables: Dict[str, Any]) -> Any: - # Simple ${var} string replacement across strings/structures - if isinstance(value, str): - def repl(m): - name = m.group(1) - return str(variables.get(name, m.group(0))) - return re.sub(r"\$\{([^}]+)\}", repl, value) - elif isinstance(value, dict): - return {k: render_templates(v, variables) for k, v in value.items()} - elif isinstance(value, list): - return [render_templates(v, variables) for v in value] - return value - - -def http_get_json(url: str, token: Optional[str] = None) -> Any: - req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) - if token: - req.add_header("Authorization", f"Bearer {token}") - with urllib.request.urlopen(req) as resp: - return json.loads(resp.read().decode("utf-8")) - - -def github_latest_release_tag(owner: str, repo: str, token: Optional[str] = None) -> Optional[str]: - url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest" - try: - data = http_get_json(url, token) - tag = data.get("tag_name") - return tag - except urllib.error.HTTPError as e: - eprintln(f"GitHub latest release failed: {e}") - return None - - -def github_latest_tag(owner: str, repo: str, token: Optional[str] = None, tag_regex: Optional[str] = None) -> Optional[str]: - url = f"https://api.github.com/repos/{owner}/{repo}/tags?per_page=100" - try: - data = http_get_json(url, token) - tags = [t.get("name") for t in data if "name" in t] - if tag_regex: - rx = re.compile(tag_regex) - tags = [t for t in tags if rx.search(t)] - return tags[0] if tags else None - except urllib.error.HTTPError as e: - eprintln(f"GitHub tags failed: {e}") - return None - - -def github_head_commit(owner: str, repo: str, token: Optional[str] = None) -> Optional[str]: - # Prefer git ls-remote to avoid API limits - url = f"https://github.com/{owner}/{repo}.git" - try: - out = subprocess.check_output(["git", "ls-remote", url, "HEAD"], text=True).strip() - if out: - sha = out.split()[0] - return sha - except Exception as e: - eprintln(f"git ls-remote failed for {url}: {e}") - return None - - -def run_cmd_get_output(args: List[str]) -> str: - eprintln(f"Running: {' '.join(args)}") - return subprocess.check_output(args, text=True).strip() - - -def nix_prefetch_url(url: str) -> Optional[str]: - # returns SRI (sha256-...) - base32 = None - try: - base32 = run_cmd_get_output(["nix-prefetch-url", "--type", "sha256", url]) - except Exception: - try: - base32 = run_cmd_get_output(["nix", "prefetch-url", url]) - except Exception as e: - eprintln(f"Failed to prefetch url: {url}: {e}") - return None - try: - sri = run_cmd_get_output(["nix", "hash", "to-sri", "--type", "sha256", base32]) - return sri - except Exception as e: - eprintln(f"Failed to convert base32 to SRI: {e}") - return None - - -def github_tarball_url(owner: str, repo: str, ref: str) -> str: - # codeload is stable for tarball - return f"https://codeload.github.com/{owner}/{repo}/tar.gz/{ref}" - - -def nix_prefetch_github_tarball(owner: str, repo: str, ref: str) -> Optional[str]: - url = github_tarball_url(owner, repo, ref) - return nix_prefetch_url(url) - - -def nix_prefetch_git(url: str, rev: str) -> Optional[str]: - # returns SRI - try: - out = run_cmd_get_output(["nix-prefetch-git", "--no-deepClone", "--rev", rev, url]) - try: - data = json.loads(out) - base32 = data.get("sha256") or data.get("hash") - except Exception: - base32 = out.splitlines()[-1].strip() - if not base32: - eprintln(f"Could not parse nix-prefetch-git output for {url}@{rev}") - return None - sri = run_cmd_get_output(["nix", "hash", "to-sri", "--type", "sha256", base32]) - return sri - except Exception as e: - eprintln(f"nix-prefetch-git failed for {url}@{rev}: {e}") - return None - - -# -------------------- Merging logic (match lib/versioning.nix) -------------------- - -def deep_merge(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]: - out = dict(a) - for k, v in b.items(): - if k in out and isinstance(out[k], dict) and isinstance(v, dict): - out[k] = deep_merge(out[k], v) - else: - out[k] = v - return out - - -def merge_sources(base_sources: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]: - names = set(base_sources.keys()) | set(overrides.keys()) - result: Dict[str, Any] = {} - for n in names: - if n in base_sources and n in overrides: - if isinstance(base_sources[n], dict) and isinstance(overrides[n], dict): - result[n] = deep_merge(base_sources[n], overrides[n]) - else: - result[n] = overrides[n] - elif n in overrides: - result[n] = overrides[n] - else: - result[n] = base_sources[n] - return result - - -def merged_view(spec: Json, variant: Optional[str]) -> Tuple[Dict[str, Any], Dict[str, Any], Json, List[str]]: - """ - Returns (merged_variables, merged_sources, target_dict_to_write, base_path) - - merged_*: what to display/prefetch with - - target_dict_to_write: where to write changes (base or variants[variant]) - """ - base_vars = spec.get("variables", {}) or {} - base_sources = spec.get("sources", {}) or {} - if variant: - vdict = spec.get("variants", {}).get(variant) - if not isinstance(vdict, dict): - raise ValueError(f"Variant '{variant}' not found") - v_vars = vdict.get("variables", {}) or {} - v_sources = vdict.get("sources", {}) or {} - merged_vars = dict(base_vars) - merged_vars.update(v_vars) - merged_srcs = merge_sources(base_sources, v_sources) - return merged_vars, merged_srcs, vdict, ["variants", variant] - else: - return dict(base_vars), dict(base_sources), spec, [] - - -# -------------------- Update operations -------------------- - -def update_components(spec: Json, - variant: Optional[str], - components: Optional[List[str]], - args: argparse.Namespace) -> bool: - changed = False - gh_token = os.environ.get("GITHUB_TOKEN") - - merged_vars, merged_srcs, target_dict, base_path = merged_view(spec, variant) - src_names = list(merged_srcs.keys()) if not components else [c for c in components if c in merged_srcs] - - # Ensure target_dict has a sources dict to write into - target_sources = target_dict.setdefault("sources", {}) - - for name in src_names: - view_comp = merged_srcs[name] - fetcher = view_comp.get("fetcher", "none") - - # Ensure a writable component entry exists (always write to the selected target: base or variant override) - comp = target_sources.setdefault(name, {}) - if not isinstance(comp, dict): - comp = target_sources[name] = {} - - if fetcher == "github": - owner = view_comp.get("owner") - repo = view_comp.get("repo") - if not owner or not repo: - eprintln(f"Component {name}: missing owner/repo for github fetcher") - continue - - new_ref = None - ref_kind = None - if args.github_latest_release: - tag = github_latest_release_tag(owner, repo, gh_token) - if tag: - new_ref = tag - ref_kind = "tag" - elif args.github_latest_tag: - tag = github_latest_tag(owner, repo, gh_token, args.tag_regex) - if tag: - new_ref = tag - ref_kind = "tag" - elif args.github_latest_commit: - rev = github_head_commit(owner, repo, gh_token) - if rev: - new_ref = rev - ref_kind = "rev" - - if new_ref: - if ref_kind == "tag": - comp["tag"] = new_ref - if "rev" in comp: - del comp["rev"] - else: - comp["rev"] = new_ref - if "tag" in comp: - del comp["tag"] - eprintln(f"Component {name}: set {ref_kind}={new_ref}") - changed = True - - if args.prefetch: - ref = comp.get("tag") or comp.get("rev") - if not ref: - # fallback to merged view if not in override - ref = view_comp.get("tag") or view_comp.get("rev") - if ref: - sri = nix_prefetch_github_tarball(owner, repo, ref) - if sri: - comp["hash"] = sri - eprintln(f"Component {name}: updated hash={sri}") - changed = True - - elif fetcher == "git": - url = view_comp.get("url") - if not url: - eprintln(f"Component {name}: missing url for git fetcher") - continue - if args.git_latest: - rev = github_head_commit(owner="", repo="", token=None) # placeholder; we will ls-remote below - try: - out = subprocess.check_output(["git", "ls-remote", url, "HEAD"], text=True).strip() - if out: - new_rev = out.split()[0] - comp["rev"] = new_rev - eprintln(f"Component {name}: set rev={new_rev}") - changed = True - if args.prefetch: - sri = nix_prefetch_git(url, new_rev) - if sri: - comp["hash"] = sri - eprintln(f"Component {name}: updated hash={sri}") - changed = True - except Exception as e: - eprintln(f"git ls-remote failed for {name}: {e}") - - elif fetcher == "url": - if args.url_prefetch or args.prefetch: - rendered_comp = render_templates(view_comp, merged_vars) - url = rendered_comp.get("url") or rendered_comp.get("urlTemplate") - if not url: - eprintln(f"Component {name}: missing url/urlTemplate for url fetcher") - else: - sri = nix_prefetch_url(url) - if sri: - comp["hash"] = sri - eprintln(f"Component {name}: updated hash={sri}") - changed = True - - elif fetcher == "pypi": - if args.prefetch: - eprintln(f"Component {name} (pypi): prefetch not implemented; use nix-prefetch-pypi or set hash manually.") - else: - # fetcher == "none" or other: no-op unless user --set a value - pass - - return changed - - -# -------------------- Main -------------------- - -def main(): - ap = argparse.ArgumentParser(description="Update unified version.json files") - ap.add_argument("--file", required=True, help="Path to version.json") - ap.add_argument("--variant", help="Variant name to update (default: base/top-level)") - ap.add_argument("--component", dest="components", action="append", help="Limit to specific component(s); can be repeated") - ap.add_argument("--github-latest-release", action="store_true", help="Update GitHub components to latest release tag") - ap.add_argument("--github-latest-tag", action="store_true", help="Update GitHub components to latest tag") - ap.add_argument("--github-latest-commit", action="store_true", help="Update GitHub components to HEAD commit") - ap.add_argument("--tag-regex", help="Regex to filter tags for --github-latest-tag") - ap.add_argument("--git-latest", action="store_true", help="Update fetchgit components to latest commit (HEAD)") - ap.add_argument("--url-prefetch", action="store_true", help="Recompute hash for url/urlTemplate components") - ap.add_argument("--prefetch", action="store_true", help="After changing refs, recompute hash as needed") - ap.add_argument("--set", dest="sets", action="append", default=[], help="Set a field: KEY=VALUE (dot path), relative to variant/base. Value is treated as string.") - ap.add_argument("--dry-run", action="store_true", help="Do not write changes") - ap.add_argument("--print", dest="do_print", action="store_true", help="Print result JSON to stdout") - args = ap.parse_args() - - path = args.file - spec = load_json(path) - - # Apply --set mutations (relative to base or selected variant) - target = spec if not args.variant else spec.setdefault("variants", {}).setdefault(args.variant, {}) - changed = False - for pair in args.sets: - path_tokens, value = parse_set_pair(pair) - deep_set(target, path_tokens, value) - eprintln(f"Set {'.'.join((['variants', args.variant] if args.variant else []) + path_tokens)} = {value}") - changed = True - - # Update refs/hashes based on fetcher type and flags with merged view - changed = update_components(spec, args.variant, args.components, args) or changed - - if changed and not args.dry_run: - save_json(path, spec) - eprintln(f"Wrote changes to {path}") - else: - eprintln("No changes made.") - - if args.do_print: - print(json.dumps(spec, indent=2, ensure_ascii=False)) - - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - sys.exit(130) diff --git a/scripts/version_tui.py b/scripts/version_tui.py index 4ed8128..1779341 100755 --- a/scripts/version_tui.py +++ b/scripts/version_tui.py @@ -1,3002 +1,209 @@ #!/usr/bin/env python3 """ -Interactive TUI for browsing and updating unified version.json files. +Interactive TUI for browsing and updating version.json files. -Features: -- Scans packages/**/version.json and lists all packages -- Per-package view: - - Choose base or any variant - - List all sources/components with current ref (tag/rev/url/version) and hash - - For GitHub sources: fetch candidates (latest release tag, latest tag, latest commit) - - For Git sources: fetch latest commit (HEAD) - - For URL sources: recompute hash (url/urlTemplate with rendered variables) -- Actions on a component: - - Update to one of the candidates (sets tag or rev) and optionally re-hash - - Recompute hash (prefetch) - - Edit any field via path=value (e.g., variables.version=2025.07) -- Writes changes back to version.json - -Dependencies: -- Standard library + external CLI tools: - - nix-prefetch-url (or `nix prefetch-url`) and `nix hash to-sri` - - nix-prefetch-git - - git -- Optional: GITHUB_TOKEN env var to increase GitHub API rate limits - -Usage: - scripts/version_tui.py Controls: - - Up/Down to navigate lists - - Enter to select - - Backspace to go back - - q to quit - - On component screen: - r = refresh candidates - h = recompute hash (prefetch) - e = edit arbitrary field (path=value) - s = save to disk + Package list: + j/k / arrows navigate + PgUp/PgDn page scroll + g/G top/bottom + f cycle filter (all / github / git / url) + Enter open package detail + q / ESC quit + + Package detail: + j/k / arrows navigate sources + Left/Right cycle variants + r refresh candidates (fetch from upstream) + h recompute hash (prefetch) + c recompute cargo hash (if cargoHash present) + e edit arbitrary field (path=value) + s save to disk + Backspace back to list + q / ESC quit + + Action menu / popup: + j/k / arrows navigate + Enter confirm + Backspace/ESC cancel """ +from __future__ import annotations + import curses import json -import os -import re -import subprocess 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 +from typing import Any, Dict, List, Optional, Tuple -ROOT = Path(__file__).resolve().parents[1] -PKGS_DIR = ROOT / "packages" +sys.path.insert(0, str(Path(__file__).resolve().parent)) -Json = Dict[str, Any] +import lib +import hooks # registers hooks as a side effect # noqa: F401 -# ------------------------------ Utilities ------------------------------ +# --------------------------------------------------------------------------- +# Color pairs +# --------------------------------------------------------------------------- +C_NORMAL = 1 +C_HIGHLIGHT = 2 +C_HEADER = 3 +C_STATUS = 4 +C_ERROR = 5 +C_SUCCESS = 6 +C_BORDER = 7 +C_TITLE = 8 +C_DIM = 9 -def eprintln(*args, **kwargs): - print(*args, file=sys.stderr, **kwargs) - -def load_json(path: Path) -> Json: - with path.open("r", encoding="utf-8") as f: - return json.load(f) - - -def save_json(path: Path, data: Json): - tmp = path.with_suffix(".tmp") - with tmp.open("w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - f.write("\n") - tmp.replace(path) - - -def render_templates(value: Any, variables: Dict[str, Any]) -> Any: - if isinstance(value, str): - - def repl(m): - name = m.group(1) - return str(variables.get(name, m.group(0))) - - return re.sub(r"\$\{([^}]+)\}", repl, value) - elif isinstance(value, dict): - return {k: render_templates(v, variables) for k, v in value.items()} - elif isinstance(value, list): - return [render_templates(v, variables) for v in value] - return value - - -def deep_set(o: Json, path: List[str], value: Any): - cur = o - for p in path[:-1]: - if p not in cur or not isinstance(cur[p], dict): - cur[p] = {} - cur = cur[p] - cur[path[-1]] = value - - -# ------------------------------ Merge helpers (match lib/versioning.nix) ------------------------------ - - -def deep_merge(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]: - out = dict(a) - for k, v in b.items(): - if k in out and isinstance(out[k], dict) and isinstance(v, dict): - out[k] = deep_merge(out[k], v) - else: - out[k] = v - return out - - -def merge_sources( - base_sources: Dict[str, Any], overrides: Dict[str, Any] -) -> Dict[str, Any]: - names = set(base_sources.keys()) | set(overrides.keys()) - result: Dict[str, Any] = {} - for n in names: - if n in base_sources and n in overrides: - if isinstance(base_sources[n], dict) and isinstance(overrides[n], dict): - result[n] = deep_merge(base_sources[n], overrides[n]) - else: - result[n] = overrides[n] - elif n in overrides: - result[n] = overrides[n] - else: - result[n] = base_sources[n] - return result - - -def merged_view( - spec: Json, variant_name: Optional[str] -) -> Tuple[Dict[str, Any], Dict[str, Any], Json]: - """ - Returns (merged_variables, merged_sources, target_dict_to_write) - merged_* are used for display/prefetch; target_dict_to_write is where updates must be written (base or selected variant). - """ - base_vars = spec.get("variables", {}) or {} - base_sources = spec.get("sources", {}) or {} - if variant_name: - vdict = spec.get("variants", {}).get(variant_name) - if not isinstance(vdict, dict): - raise ValueError(f"Variant '{variant_name}' not found") - v_vars = vdict.get("variables", {}) or {} - v_sources = vdict.get("sources", {}) or {} - merged_vars = dict(base_vars) - merged_vars.update(v_vars) - merged_srcs = merge_sources(base_sources, v_sources) - return merged_vars, merged_srcs, vdict - else: - return dict(base_vars), dict(base_sources), spec - - -def run_cmd(args: List[str]) -> Tuple[int, str, str]: - try: - p = subprocess.run( - args, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False - ) - return p.returncode, p.stdout.strip(), p.stderr.strip() - except Exception as e: - return 1, "", str(e) - - -def run_get_stdout(args: List[str]) -> Optional[str]: - code, out, err = run_cmd(args) - if code != 0: - eprintln(f"Command failed: {' '.join(args)}\n{err}") - return None - 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]: - """ - 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] - ) - if out is not None and out.strip(): - try: - data = json.loads(out) - if "hash" in data: - return data["hash"] - except Exception: - pass - - # Fallback to legacy nix-prefetch-url - out = run_get_stdout(["nix-prefetch-url", "--type", "sha256", url]) - if out is None: - out = run_get_stdout(["nix-prefetch-url", url]) - if out is None: - return None - - 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]: - """ - 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 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 - - # 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() - - return None - - -def nix_prefetch_cargo_vendor( - fetcher: str, - src_hash: str, - *, - url: str = "", - owner: str = "", - repo: str = "", - rev: str = "", - subdir: str = "", -) -> Optional[str]: - """ - Compute the cargo vendor hash for a Rust source using nix build + fakeHash. - - Builds rustPlatform.fetchCargoVendor with lib.fakeHash, parses the correct - hash from the 'got:' line in nix's error output. - - Args: - fetcher: "github" or "git" - src_hash: SRI hash of the source (already known) - url: git URL (for "git" fetcher) - owner/repo: GitHub owner and repo (for "github" fetcher) - rev: tag or commit rev - subdir: optional subdirectory within the source that contains Cargo.lock - - Returns: - SRI hash string, or None on failure. - """ - if fetcher == "github" and owner and repo and rev and src_hash: - src_expr = ( - f'pkgs.fetchFromGitHub {{ owner = "{owner}"; repo = "{repo}";' - f' rev = "{rev}"; hash = "{src_hash}"; }}' - ) - elif fetcher == "git" and url and rev and src_hash: - # For GitHub git URLs, fetchFromGitHub is more reliable than fetchgit - parsed = urlparse(url) - parts = [p for p in parsed.path.split("/") if p] - if parsed.hostname in ("github.com",) and len(parts) >= 2: - gh_owner, gh_repo = parts[0], parts[1] - src_expr = ( - f'pkgs.fetchFromGitHub {{ owner = "{gh_owner}"; repo = "{gh_repo}";' - f' rev = "{rev}"; hash = "{src_hash}"; }}' - ) - else: - src_expr = f'pkgs.fetchgit {{ url = "{url}"; rev = "{rev}"; hash = "{src_hash}"; }}' - else: - return None - - subdir_attr = f'sourceRoot = "${{src.name}}/{subdir}";' if subdir else "" - - expr = ( - f"let pkgs = import {{}};\n" - f" src = {src_expr};\n" - f"in pkgs.rustPlatform.fetchCargoVendor {{\n" - f" inherit src;\n" - f" {subdir_attr}\n" - f" hash = pkgs.lib.fakeHash;\n" - f"}}" - ) - - 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_prefetch_cargo_vendor failed:\n{p.stderr[-600:]}") - return None - - -def http_get_json(url: str, token: Optional[str] = None) -> Any: - try: - req = urllib.request.Request( - url, headers={"Accept": "application/vnd.github+json"} - ) - if token: - req.add_header("Authorization", f"Bearer {token}") - with urllib.request.urlopen(req, timeout=10) as resp: - if resp.status != 200: - return None - return json.loads(resp.read().decode("utf-8")) - except urllib.error.HTTPError as e: - eprintln(f"HTTP error for {url}: {e.code} {e.reason}") - return None - except Exception as e: - eprintln(f"Request failed for {url}: {e}") - return None - - -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, timeout=10) as resp: - if resp.status != 200: - return None - return resp.read().decode("utf-8") - except urllib.error.HTTPError as e: - eprintln(f"HTTP error for {url}: {e.code} {e.reason}") - return None - except Exception as e: - eprintln(f"Request 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 - ) - if not data: - return None - return data.get("tag_name") - except Exception as e: - eprintln(f"latest_release failed for {owner}/{repo}: {e}") - return None - - -def gh_latest_tag(owner: str, repo: str, token: Optional[str]) -> Optional[str]: - try: - data = http_get_json( - f"https://api.github.com/repos/{owner}/{repo}/tags?per_page=100", token - ) - if not isinstance(data, list): - return None - tags = [ - t.get("name") - for t in data - if isinstance(t, dict) and "name" in t and t.get("name") is not None - ] - return tags[0] if tags else None - except Exception as e: - 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 [ - str(t.get("name")) - for t in data - if isinstance(t, dict) and "name" in t and t.get("name") is not None - ] - except Exception as e: - eprintln(f"list_tags failed for {owner}/{repo}: {e}") - return [] - - -def gh_head_commit( - owner: str, repo: str, branch: Optional[str] = None -) -> Optional[str]: - """Return the latest commit SHA for a GitHub repo, optionally restricted to a branch.""" - try: - ref = f"refs/heads/{branch}" if branch else "HEAD" - out = run_get_stdout( - ["git", "ls-remote", f"https://github.com/{owner}/{repo}.git", ref] - ) - if not out: - return None - # ls-remote can return multiple lines; take the first match - for line in out.splitlines(): - parts = line.split() - if parts: - return parts[0] - return None - except Exception as e: - eprintln(f"head_commit failed for {owner}/{repo} (branch={branch}): {e}") - 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: - ref = f"refs/heads/{branch}" if branch else "HEAD" - out = run_get_stdout(["git", "ls-remote", url, ref]) - if not out: - return None - for line in out.splitlines(): - parts = line.split() - if parts: - return parts[0] - return None - except Exception as e: - eprintln(f"git_branch_commit failed for {url} (branch={branch}): {e}") - return None - - -def gh_tarball_url(owner: str, repo: str, ref: str) -> str: - return f"https://codeload.github.com/{owner}/{repo}/tar.gz/{ref}" - - -def gh_release_tags_api(owner: str, repo: str, token: Optional[str]) -> List[str]: - """ - Return recent release tag names for a repo using GitHub API. - """ - try: - data = http_get_json( - f"https://api.github.com/repos/{owner}/{repo}/releases?per_page=50", token - ) - return [ - str(r.get("tag_name")) - for r in data - if isinstance(r, dict) and "tag_name" in r and r.get("tag_name") is not None - ] - except Exception as e: - eprintln(f"releases list failed for {owner}/{repo}: {e}") - return [] - - -# ------------------------------ Data scanning ------------------------------ - - -def find_packages() -> List[Tuple[str, Path, bool, bool]]: - results = [] - # Find regular packages with version.json - for p in PKGS_DIR.rglob("version.json"): - # name is directory name under packages (e.g., raspberrypi/linux-rpi => raspberrypi/linux-rpi) - rel = p.relative_to(PKGS_DIR).parent - results.append( - (str(rel), p, False, False) - ) # (name, path, is_python, is_homeassistant) - - # Find Python packages with default.nix - python_dir = PKGS_DIR / "python" - if python_dir.exists(): - for pkg_dir in python_dir.iterdir(): - if pkg_dir.is_dir(): - nix_file = pkg_dir / "default.nix" - if nix_file.exists(): - # name is python/package-name - rel = pkg_dir.relative_to(PKGS_DIR) - results.append( - (str(rel), nix_file, True, False) - ) # (name, path, is_python, is_homeassistant) - - # Find Home Assistant components with default.nix - homeassistant_dir = PKGS_DIR / "homeassistant" - if homeassistant_dir.exists(): - for pkg_dir in homeassistant_dir.iterdir(): - if pkg_dir.is_dir(): - nix_file = pkg_dir / "default.nix" - if nix_file.exists(): - # Only treat as an HA component if it uses buildHomeAssistantComponent; - # otherwise fall through to Python package handling. - try: - nix_content = nix_file.read_text(encoding="utf-8") - except Exception: - nix_content = "" - rel = pkg_dir.relative_to(PKGS_DIR) - if "buildHomeAssistantComponent" in nix_content: - results.append( - (str(rel), nix_file, False, True) - ) # (name, path, is_python, is_homeassistant) - else: - # Treat as a Python package instead - results.append((str(rel), nix_file, True, False)) - - results.sort() - return results - - -def _extract_brace_block(content: str, keyword: str) -> Optional[str]: - """ - Find 'keyword {' in content and return the text between the matching braces, - handling nested braces (e.g. ${var} inside strings). - Returns None if not found. - """ - idx = content.find(keyword + " {") - if idx == -1: - idx = content.find(keyword + "{") - if idx == -1: - return None - start = content.find("{", idx + len(keyword)) - if start == -1: - return None - depth = 0 - for i in range(start, len(content)): - c = content[i] - if c == "{": - depth += 1 - elif c == "}": - depth -= 1 - if depth == 0: - return content[start + 1 : i] - return None - - -def _resolve_nix_str(value: str, pname: str, version: str) -> str: - """Resolve simple Nix string interpolations like ${pname} and ${version}.""" - value = re.sub(r"\$\{pname\}", pname, value) - value = re.sub(r"\$\{version\}", version, value) - return value - - -def _extract_nix_attr(block: str, attr: str) -> str: - """ - Extract attribute value from a Nix attribute set block. - Handles: - attr = "quoted string"; - attr = unquoted_ident; - Returns empty string if not found. - """ - # Quoted string value - m = re.search(rf'\b{attr}\s*=\s*"([^"]*)"', block) - if m: - return m.group(1) - # Unquoted identifier (e.g. repo = pname;) - m = re.search(rf"\b{attr}\s*=\s*([A-Za-z_][A-Za-z0-9_-]*)\s*;", block) - if m: - return m.group(1) - return "" - - -def parse_python_package(path: Path) -> Dict[str, Any]: - """Parse a Python package's default.nix file to extract version and source information.""" - with path.open("r", encoding="utf-8") as f: - content = f.read() - - # Extract version - version_match = re.search(r'version\s*=\s*"([^"]+)"', content) - version = version_match.group(1) if version_match else "" - - # Extract pname (package name) - pname_match = re.search(r'pname\s*=\s*"([^"]+)"', content) - pname = pname_match.group(1) if pname_match else "" - - # Create a structure similar to version.json for compatibility - result: Dict[str, Any] = {"variables": {}, "sources": {}} - - # Only add non-empty values to variables - if version: - result["variables"]["version"] = version - - # Determine source name - use pname or derive from path - source_name = pname.lower() if pname else path.parent.name.lower() - - # Try to extract brace-balanced fetchFromGitHub block (handles ${var} inside strings) - fetch_block = _extract_brace_block(content, "fetchFromGitHub") - - # Check for fetchPypi pattern (simple [^}]+ is fine here as PyPI blocks lack ${}) - fetch_pypi_match = re.search( - r"src\s*=\s*.*fetchPypi\s*\{([^}]+)\}", content, re.DOTALL - ) - - if fetch_block is not None: - owner_raw = _extract_nix_attr(fetch_block, "owner") - repo_raw = _extract_nix_attr(fetch_block, "repo") - rev_raw = _extract_nix_attr(fetch_block, "rev") - hash_match = re.search(r'(sha256|hash)\s*=\s*"([^"]+)"', fetch_block) - hash_value = hash_match.group(2) if hash_match else "" - - def _resolve_nix_ident(raw: str) -> str: - """Resolve unquoted Nix identifier or string-with-interpolation to its value.""" - if raw == "pname": - return pname - if raw == "version": - return version - return _resolve_nix_str(raw, pname, version) - - owner = _resolve_nix_ident(owner_raw) - repo = _resolve_nix_ident(repo_raw) - rev = _resolve_nix_ident(rev_raw) - - # Create source entry - result["sources"][source_name] = { - "fetcher": "github", - "owner": owner, - "repo": repo, - "hash": hash_value, - } - - # Classify rev as tag or commit ref - if rev: - if rev.startswith("v") or "${version}" in rev_raw: - result["sources"][source_name]["tag"] = rev - elif rev in ("master", "main"): - result["sources"][source_name]["rev"] = rev - else: - result["sources"][source_name]["rev"] = rev - - elif fetch_pypi_match: - fetch_block_pypi = fetch_pypi_match.group(1) - - hash_match = re.search(r'(sha256|hash)\s*=\s*"([^"]+)"', fetch_block_pypi) - hash_value = hash_match.group(2) if hash_match else "" - - # Look for GitHub info in meta section - homepage_match = re.search( - r'homepage\s*=\s*"https://github\.com/([^/]+)/([^"]+)"', content - ) - - if homepage_match: - owner = homepage_match.group(1) - repo = homepage_match.group(2) - result["sources"][source_name] = { - "fetcher": "github", - "owner": owner, - "repo": repo, - "hash": hash_value, - "pypi": True, - } - if version: - result["sources"][source_name]["tag"] = f"v{version}" - else: - result["sources"][source_name] = { - "fetcher": "pypi", - "pname": pname, - "version": version, - "hash": hash_value, - } - else: - # Fallback: scan whole file for GitHub or URL info - owner_match = re.search(r'owner\s*=\s*"([^"]+)"', content) - repo_match = re.search(r'repo\s*=\s*"([^"]+)"', content) - rev_match = re.search(r'rev\s*=\s*"([^"]+)"', content) - tag_match = re.search(r'tag\s*=\s*"([^"]+)"', content) - hash_match = re.search(r'(sha256|hash)\s*=\s*"([^"]+)"', content) - url_match = re.search(r'url\s*=\s*"([^"]+)"', content) - homepage_match = re.search( - r'homepage\s*=\s*"https://github\.com/([^/]+)/([^"]+)"', content - ) - - owner = owner_match.group(1) if owner_match else "" - repo = repo_match.group(1) if repo_match else "" - rev = rev_match.group(1) if rev_match else "" - tag = tag_match.group(1) if tag_match else "" - hash_value = hash_match.group(2) if hash_match else "" - url = url_match.group(1) if url_match else "" - - if homepage_match and not (owner and repo): - owner = homepage_match.group(1) - repo = homepage_match.group(2) - - if owner and repo: - result["sources"][source_name] = { - "fetcher": "github", - "owner": owner, - "repo": repo, - "hash": hash_value, - } - if tag: - result["sources"][source_name]["tag"] = tag - elif rev: - result["sources"][source_name]["rev"] = rev - elif url: - result["sources"][source_name] = { - "fetcher": "url", - "url": url, - "hash": hash_value, - } - else: - result["sources"][source_name] = {"fetcher": "unknown", "hash": hash_value} - - return result - - -def update_python_package( - path: Path, source_name: str, updates: Dict[str, Any] -) -> bool: - """Update a Python package's default.nix file with new version and/or hash.""" - with path.open("r", encoding="utf-8") as f: - content = f.read() - - modified = False - - # Update version if provided - if "version" in updates: - new_version = updates["version"] - content, version_count = re.subn( - r'(version\s*=\s*)"([^"]+)"', f'\\1"{new_version}"', content - ) - if version_count > 0: - modified = True - - # Update hash if provided - if "hash" in updates: - new_hash = updates["hash"] - # Match both sha256 and hash attributes - content, hash_count = re.subn( - r'(sha256|hash)\s*=\s*"([^"]+)"', f'\\1 = "{new_hash}"', content - ) - if hash_count > 0: - modified = True - - # Update tag if provided - if "tag" in updates: - new_tag = updates["tag"] - content, tag_count = re.subn( - r'(tag\s*=\s*)"([^"]+)"', f'\\1"{new_tag}"', content - ) - if tag_count > 0: - modified = True - - # Update rev if provided - if "rev" in updates: - new_rev = updates["rev"] - content, rev_count = re.subn( - r'(rev\s*=\s*)"([^"]+)"', f'\\1"{new_rev}"', content - ) - if rev_count > 0: - modified = True - - if modified: - with path.open("w", encoding="utf-8") as f: - f.write(content) - - return modified - - -def parse_homeassistant_component(path: Path) -> Dict[str, Any]: - """Parse a Home Assistant component's default.nix file to extract version and source information.""" - with path.open("r", encoding="utf-8") as f: - content = f.read() - - # Extract domain, version, and owner - domain_match = re.search(r'domain\s*=\s*"([^"]+)"', content) - version_match = re.search(r'version\s*=\s*"([^"]+)"', content) - owner_match = re.search(r'owner\s*=\s*"([^"]+)"', content) - - domain = domain_match.group(1) if domain_match else "" - version = version_match.group(1) if version_match else "" - owner = owner_match.group(1) if owner_match else "" - - # Extract GitHub repo info - repo_match = re.search(r'repo\s*=\s*"([^"]+)"', content) - rev_match = re.search(r'rev\s*=\s*"([^"]+)"', content) - tag_match = re.search(r'tag\s*=\s*"([^"]+)"', content) - hash_match = re.search(r'(sha256|hash)\s*=\s*"([^"]+)"', content) - - repo = repo_match.group(1) if repo_match else "" - rev = rev_match.group(1) if rev_match else "" - tag = tag_match.group(1) if tag_match else "" - hash_value = hash_match.group(2) if hash_match else "" - - # Create a structure similar to version.json for compatibility - result = {"variables": {}, "sources": {}} - - # Only add non-empty values to variables - if version: - result["variables"]["version"] = version - if domain: - result["variables"]["domain"] = domain - - # Determine source name - use domain or directory name - source_name = domain if domain else path.parent.name.lower() - - # Handle GitHub sources - if owner: - repo_name = repo if repo else source_name - result["sources"][source_name] = { - "fetcher": "github", - "owner": owner, - "repo": repo_name, - } - - # Only add non-empty values - if hash_value: - result["sources"][source_name]["hash"] = hash_value - - # Handle tag or rev; resolve ${version} references - if tag: - result["sources"][source_name]["tag"] = _resolve_nix_str(tag, "", version) - elif rev: - rev_resolved = _resolve_nix_str(rev, "", version) - # If rev was a ${version} template or equals version, treat as tag - if "${version}" in rev or rev_resolved == version: - result["sources"][source_name]["tag"] = rev_resolved - else: - result["sources"][source_name]["rev"] = rev_resolved - elif version: # fallback: use version as tag - result["sources"][source_name]["tag"] = version - else: - # Fallback for components with no clear source info - result["sources"][source_name] = {"fetcher": "unknown"} - if hash_value: - result["sources"][source_name]["hash"] = hash_value - - return result - - -def update_homeassistant_component( - path: Path, source_name: str, updates: Dict[str, Any] -) -> bool: - """Update a Home Assistant component's default.nix file with new version and/or hash.""" - with path.open("r", encoding="utf-8") as f: - content = f.read() - - modified = False - - # Update version if provided - if "version" in updates: - new_version = updates["version"] - content, version_count = re.subn( - r'(version\s*=\s*)"([^"]+)"', f'\\1"{new_version}"', content - ) - if version_count > 0: - modified = True - - # Update hash if provided - if "hash" in updates: - new_hash = updates["hash"] - # Match both sha256 and hash attributes in src = fetchFromGitHub { ... } - content, hash_count = re.subn( - r'(src\s*=\s*fetchFromGitHub\s*\{[^}]*)(sha256|hash)\s*=\s*"([^"]+)"([^}]*\})', - f'\\1\\2 = "{new_hash}"\\4', - content, - ) - if hash_count > 0: - modified = True - - # Update tag if provided - if "tag" in updates: - new_tag = updates["tag"] - content, tag_count = re.subn( - r'(src\s*=\s*fetchFromGitHub\s*\{[^}]*)(tag|rev)\s*=\s*"([^"]+)"([^}]*\})', - f'\\1\\2 = "{new_tag}"\\4', - content, - ) - if tag_count == 0: # If no tag/rev found, try to add it - content, tag_count = re.subn( - r'(src\s*=\s*fetchFromGitHub\s*\{[^}]*)(hash\s*=\s*"[^"]+")([^}]*\})', - f'\\1\\2;\n tag = "{new_tag}"\\3', - content, - ) - if tag_count > 0: - modified = True - - # Update rev if provided - if "rev" in updates: - new_rev = updates["rev"] - content, rev_count = re.subn( - r'(src\s*=\s*fetchFromGitHub\s*\{[^}]*)(rev|tag)\s*=\s*"([^"]+)"([^}]*\})', - f'\\1\\2 = "{new_rev}"\\4', - content, - ) - if rev_count == 0: # If no rev/tag found, try to add it - content, rev_count = re.subn( - r'(src\s*=\s*fetchFromGitHub\s*\{[^}]*)(hash\s*=\s*"[^"]+")([^}]*\})', - f'\\1\\2;\n rev = "{new_rev}"\\3', - content, - ) - if rev_count > 0: - modified = True - - if modified: - with path.open("w", encoding="utf-8") as f: - f.write(content) - - 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 -COLOR_NORMAL = 1 -COLOR_HIGHLIGHT = 2 -COLOR_HEADER = 3 -COLOR_STATUS = 4 -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(): - """Initialize color pairs for the TUI.""" +def _init_colors() -> None: curses.start_color() curses.use_default_colors() - - # Define color pairs - curses.init_pair(COLOR_NORMAL, curses.COLOR_WHITE, -1) - curses.init_pair(COLOR_HIGHLIGHT, curses.COLOR_BLACK, curses.COLOR_CYAN) - curses.init_pair(COLOR_HEADER, curses.COLOR_CYAN, -1) - curses.init_pair(COLOR_STATUS, curses.COLOR_YELLOW, -1) - curses.init_pair(COLOR_ERROR, curses.COLOR_RED, -1) - 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) + curses.init_pair(C_NORMAL, curses.COLOR_WHITE, -1) + curses.init_pair(C_HIGHLIGHT, curses.COLOR_BLACK, curses.COLOR_CYAN) + curses.init_pair(C_HEADER, curses.COLOR_CYAN, -1) + curses.init_pair(C_STATUS, curses.COLOR_YELLOW, -1) + curses.init_pair(C_ERROR, curses.COLOR_RED, -1) + curses.init_pair(C_SUCCESS, curses.COLOR_GREEN, -1) + curses.init_pair(C_BORDER, curses.COLOR_BLUE, -1) + curses.init_pair(C_TITLE, curses.COLOR_MAGENTA, -1) + curses.init_pair(C_DIM, curses.COLOR_WHITE, -1) -def draw_border(win, y, x, h, w): - """Draw a border around a region of the window.""" - # Draw corners - win.addch(y, x, curses.ACS_ULCORNER, curses.color_pair(COLOR_BORDER)) - win.addch(y, x + w - 1, curses.ACS_URCORNER, curses.color_pair(COLOR_BORDER)) - win.addch(y + h - 1, x, curses.ACS_LLCORNER, curses.color_pair(COLOR_BORDER)) +# --------------------------------------------------------------------------- +# Drawing helpers +# --------------------------------------------------------------------------- - # Draw bottom-right corner safely + +def _draw_border(win: Any, y: int, x: int, h: int, w: int) -> None: + bp = curses.color_pair(C_BORDER) + win.addch(y, x, curses.ACS_ULCORNER, bp) + win.addch(y, x + w - 1, curses.ACS_URCORNER, bp) + win.addch(y + h - 1, x, curses.ACS_LLCORNER, bp) try: - win.addch( - y + h - 1, x + w - 1, curses.ACS_LRCORNER, curses.color_pair(COLOR_BORDER) - ) + win.addch(y + h - 1, x + w - 1, curses.ACS_LRCORNER, bp) + except curses.error: + pass + for i in range(1, w - 1): + win.addch(y, x + i, curses.ACS_HLINE, bp) + win.addch(y + h - 1, x + i, curses.ACS_HLINE, bp) + for i in range(1, h - 1): + win.addch(y + i, x, curses.ACS_VLINE, bp) + win.addch(y + i, x + w - 1, curses.ACS_VLINE, bp) + + +def _draw_hline(win: Any, y: int, x1: int, x2: int) -> None: + for x in range(x1, x2): + try: + win.addch(y, x, curses.ACS_HLINE, curses.color_pair(C_BORDER)) + except curses.error: + pass + + +def _addstr(win: Any, y: int, x: int, text: str, attr: int = 0, max_w: int = 0) -> None: + """Safe addstr that clips to max_w and ignores curses.error.""" + try: + h, w = win.getmaxyx() + avail = (w - x - 1) if max_w <= 0 else min(max_w, w - x - 1) + if avail <= 0: + return + win.addstr(y, x, text[:avail], attr) except curses.error: - # This is expected when trying to write to the bottom-right corner pass - # Draw horizontal lines - for i in range(1, w - 1): - win.addch(y, x + i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER)) - win.addch(y + h - 1, x + i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER)) - # Draw vertical lines - for i in range(1, h - 1): - win.addch(y + i, x, curses.ACS_VLINE, curses.color_pair(COLOR_BORDER)) - win.addch(y + i, x + w - 1, curses.ACS_VLINE, curses.color_pair(COLOR_BORDER)) - - -class ScreenBase: - def __init__(self, stdscr): - self.stdscr = stdscr - self.status = "" - self.status_type = "normal" # "normal", "error", "success" - - def draw_status(self, height, width): - if self.status: - color = COLOR_STATUS - if self.status_type == "error": - color = COLOR_ERROR - elif self.status_type == "success": - color = COLOR_SUCCESS - self.stdscr.addstr( - height - 1, - 0, - self.status[: max(0, width - 1)], - curses.color_pair(color), - ) - # else: leave the bottom line blank when no status message - - def set_status(self, text: str, status_type="normal"): - self.status = text - self.status_type = status_type - - def run(self): - raise NotImplementedError - - -def prompt_input(stdscr, prompt: str) -> Optional[str]: - curses.echo() - stdscr.addstr(prompt, curses.color_pair(COLOR_HEADER)) - stdscr.clrtoeol() - s = stdscr.getstr().decode("utf-8") - curses.noecho() - return s - - -def show_popup(stdscr, lines: List[str], title: str = ""): +def _show_popup(stdscr: Any, lines: List[str], title: str = "") -> None: h, w = stdscr.getmaxyx() - box_h = min(len(lines) + 4, h - 2) - box_w = min(max(max(len(l) for l in lines), len(title)) + 6, w - 2) + content_w = max((len(l) for l in lines), default=0) + box_w = min(w - 4, max(44, len(title) + 4, content_w + 4)) + box_h = min(h - 4, len(lines) + 4) top = (h - box_h) // 2 left = (w - box_w) // 2 win = curses.newwin(box_h, box_w, top, left) - - # Draw fancy border - draw_border(win, 0, 0, box_h, box_w) - - # Add title if provided + _draw_border(win, 0, 0, box_h, box_w) if title: - title_x = (box_w - len(title)) // 2 - win.addstr(0, title_x, f" {title} ", curses.color_pair(COLOR_TITLE)) - - # Add content - for i, line in enumerate(lines, start=1): - if i >= box_h - 1: + tx = max(1, (box_w - len(title) - 2) // 2) + _addstr(win, 0, tx, f" {title} ", curses.color_pair(C_TITLE)) + for i, line in enumerate(lines, 1): + if i >= box_h - 2: break - win.addstr(i, 2, line[: box_w - 4], curses.color_pair(COLOR_NORMAL)) - - # Add footer - footer = "Press any key to continue" - footer_x = (box_w - len(footer)) // 2 - win.addstr(box_h - 1, footer_x, footer, curses.color_pair(COLOR_STATUS)) - + _addstr(win, i, 2, line, curses.color_pair(C_NORMAL), box_w - 4) + footer = "any key to close" + _addstr( + win, + box_h - 1, + max(1, (box_w - len(footer)) // 2), + footer, + curses.color_pair(C_STATUS), + ) win.refresh() win.getch() -# ------------------------------ Screens ------------------------------ - - -class PackagesScreen(ScreenBase): - def __init__(self, stdscr): - super().__init__(stdscr) - self.packages = find_packages() - self.idx = 0 - self.filter_mode = "all" # "all", "regular", "python" - self.scroll_offset = 0 # Add scroll offset to handle long lists - - def run(self): - while True: - self.stdscr.clear() - h, w = self.stdscr.getmaxyx() - - # Determine split layout - left_w = max(30, min(60, w // 3)) - right_x = left_w + 1 - right_w = max(0, w - right_x) - - # Draw borders for left and right panes - draw_border(self.stdscr, 0, 0, h - 1, left_w) - if right_w >= 20: - draw_border(self.stdscr, 0, right_x, h - 1, right_w) - - # Filter packages based on mode - if self.filter_mode == "regular": - filtered_packages = [p for p in self.packages if not p[2] and not p[3]] - elif self.filter_mode == "python": - 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: - filtered_packages = self.packages - - # 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 - ) - - # Implement scrolling for long lists - max_rows = h - 3 - total_packages = len(filtered_packages) - - # Adjust scroll offset if needed - if self.idx >= self.scroll_offset + max_rows: - self.scroll_offset = self.idx - max_rows + 1 - elif self.idx < self.scroll_offset: - self.scroll_offset = self.idx - - # Display visible packages with scroll offset - visible_packages = filtered_packages[ - self.scroll_offset : self.scroll_offset + max_rows - ] - - # Show scroll indicators if needed - if self.scroll_offset > 0: - self.stdscr.addstr(1, left_w - 3, "↑", curses.color_pair(COLOR_STATUS)) - if self.scroll_offset + max_rows < total_packages: - self.stdscr.addstr( - min(1 + len(visible_packages), h - 2), - left_w - 3, - "↓", - curses.color_pair(COLOR_STATUS), - ) - - for i, (name, _path, is_python, is_homeassistant) in enumerate( - visible_packages, start=0 - ): - # Use consistent display style for all packages - pkg_type = "" # Remove the [Py] prefix for consistent display - - # Highlight the selected item - if i + self.scroll_offset == self.idx: - attr = curses.color_pair(COLOR_HIGHLIGHT) - sel = "►" # Use a fancier selector - else: - attr = curses.color_pair(COLOR_NORMAL) - sel = " " - - # Type badge: [py] [ha] shown in a fixed column before name - if is_python: - badge = "[py]" - elif is_homeassistant: - badge = "[ha]" - else: - badge = " " - - 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: - try: - name, path, is_python, is_homeassistant = filtered_packages[ - self.idx - ] - - # 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, - hdr[: max(0, right_w - 2)], - curses.color_pair(COLOR_TITLE) | curses.A_BOLD, - ) - - # 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 + 2, - rel_path[: max(0, right_w - 3)], - curses.color_pair(COLOR_NORMAL) | curses.A_DIM, - ) - - # Sources header - self.stdscr.addstr( - 2, right_x + 2, "Sources:", curses.color_pair(COLOR_HEADER) - ) - - if is_python: - spec = parse_python_package(path) - elif is_homeassistant: - spec = parse_homeassistant_component(path) - else: - spec = load_json(path) - merged_vars, merged_srcs, _ = merged_view(spec, None) - snames = sorted(list(merged_srcs.keys())) - max_src_rows = max(0, h - 6) - for i2, sname in enumerate(snames[:max_src_rows], start=0): - comp = merged_srcs[sname] - fetcher = comp.get("fetcher", "none") - 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 "" - ) - - fetcher_color = COLOR_NORMAL - if fetcher == "github": - fetcher_color = COLOR_SUCCESS - elif fetcher == "url": - fetcher_color = COLOR_STATUS - elif fetcher == "git": - fetcher_color = COLOR_HEADER - - self.stdscr.addstr( - 3 + i2, - right_x + 2, - f"{sname[:NAME_W]:<{NAME_W}}", - curses.color_pair(COLOR_NORMAL), - ) - self.stdscr.addstr( - 3 + i2, - right_x + 2 + NAME_W + 1, - f"{fetcher[:FETCH_W]:<{FETCH_W}}", - curses.color_pair(fetcher_color), - ) - self.stdscr.addstr( - 3 + i2, - ref_col, - ref_short[: max(0, right_w - (ref_col - right_x) - 1)], - curses.color_pair(COLOR_NORMAL), - ) - - # 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 - 2, - hint_x, - hint[: max(0, right_w - 2)], - curses.color_pair(COLOR_STATUS), - ) - except Exception as e: - self.stdscr.addstr( - 2, right_x + 2, "Error:", curses.color_pair(COLOR_ERROR) - ) - self.stdscr.addstr( - 2, - right_x + 9, - f"{e}"[: max(0, right_w - 11)], - curses.color_pair(COLOR_ERROR), - ) - - self.draw_status(h, w) - self.stdscr.refresh() - ch = self.stdscr.getch() - if ch in (ord("q"), 27): # q or ESC - return None - elif ch in (curses.KEY_UP, ord("k")): - self.idx = max(0, self.idx - 1) - elif ch in (curses.KEY_DOWN, ord("j")): - self.idx = min(len(filtered_packages) - 1, self.idx + 1) - elif ch == curses.KEY_PPAGE: # Page Up - self.idx = max(0, self.idx - (h - 4)) - elif ch == curses.KEY_NPAGE: # Page Down - self.idx = min(len(filtered_packages) - 1, self.idx + (h - 4)) - elif ch == ord("g"): # Go to top - self.idx = 0 - elif ch == ord("G"): # Go to bottom - self.idx = max(0, len(filtered_packages) - 1) - elif ch == ord("f"): - # 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": - filtered_packages = [p for p in self.packages if not p[2]] - elif self.filter_mode == "python": - filtered_packages = [p for p in self.packages if p[2]] - - if not filtered_packages: - continue - - name, path, is_python, is_homeassistant = filtered_packages[self.idx] - try: - if is_python: - spec = parse_python_package(path) - elif is_homeassistant: - spec = parse_homeassistant_component(path) - else: - spec = load_json(path) - except Exception as e: - self.set_status(f"Failed to load {path}: {e}") - continue - screen = PackageDetailScreen( - self.stdscr, name, path, spec, is_python, is_homeassistant - ) - ret = screen.run() - if ret == "reload": - # re-scan on save - self.packages = find_packages() - self.idx = min(self.idx, len(self.packages) - 1) - else: - pass - - -class PackageDetailScreen(ScreenBase): - def __init__( - self, - stdscr, - pkg_name: str, - path: Path, - spec: Json, - is_python: bool = False, - is_homeassistant: bool = False, - ): - super().__init__(stdscr) - self.pkg_name = pkg_name - self.path = path - self.spec = spec - self.is_python = is_python - self.is_homeassistant = is_homeassistant - # 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] - ] = {} # name -> {release, tag, commit} - self.url_candidates: Dict[ - str, Dict[str, str] - ] = {} # name -> {base, release, tag} - # initialize view - self.recompute_view() - - def select_variant(self): - # Recompute merged and target views when variant changes - self.recompute_view() - - def recompute_view(self): - # Set cursor to base or selected variant dict for manual edits - if self.vidx == 0: - self.cursor = self.spec - variant_name = None - else: - variant_name = self.variants[self.vidx] - self.cursor = self.spec["variants"][variant_name] - # Compute merged view and target dict for writing - self.merged_vars, self.merged_srcs, self.target_dict = merged_view( - self.spec, variant_name - ) - self.snames = sorted(list(self.merged_srcs.keys())) - self.sidx = 0 - - def fetch_candidates_for(self, name: str): - comp = self.merged_srcs[name] - fetcher = comp.get("fetcher", "none") - branch = comp.get("branch") or None # optional branch override - c: Dict[str, str] = { - "release": "", - "tag": "", - "commit": "", - "release_date": "", - "tag_date": "", - "commit_date": "", - } - if fetcher == "github": - owner = comp.get("owner") - repo = comp.get("repo") - if owner and repo: - # Only fetch release/tag candidates when not locked to a specific branch - if not branch: - 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) - if not branch: - 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: - 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 "") - 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: - 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: - # 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") - repo = self.merged_vars.get("repo") - if owner and repo: - tags = gh_release_tags_api(str(owner), str(repo), self.gh_token) - prefix = str(self.merged_vars.get("releasePrefix", "")) - suffix = str(self.merged_vars.get("releaseSuffix", "")) - latest = next( - ( - t - for t in tags - if (t and t.startswith(prefix) and t.endswith(suffix)) - ), - None, - ) - 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) :] - if suffix and mid.endswith(suffix): - mid = mid[: -len(suffix)] - parts = mid.split("-") - if len(parts) >= 2: - base, rel = parts[0], parts[-1] - self.url_candidates[name] = { - "base": base, - "release": rel, - "tag": latest, - } - self.candidates[name] = c - - def prefetch_hash_for(self, name: str) -> Optional[str]: - comp = self.merged_srcs[name] - fetcher = comp.get("fetcher", "none") - if fetcher == "github": - owner = comp.get("owner") - 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: - # 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") - if url and rev: - return nix_prefetch_git(url, rev) - elif fetcher == "url": - rendered = render_templates(comp, self.merged_vars) - url = rendered.get("url") or rendered.get("urlTemplate") - if 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]: - """ - Compute the cargo vendor hash for a source component that carries a - 'cargoHash' field (or a linked 'cargoDeps' sibling source). - - Uses nix_prefetch_cargo_vendor() which builds fetchCargoVendor with - lib.fakeHash and parses the correct hash from the error output. - """ - comp = self.merged_srcs[name] - fetcher = comp.get("fetcher", "none") - src_hash = comp.get("hash", "") - subdir = comp.get("cargoSubdir", "") - - rendered = render_templates(comp, self.merged_vars) - - if fetcher == "github": - owner = comp.get("owner", "") - repo = comp.get("repo", "") - ref = rendered.get("tag") or rendered.get("rev") or "" - if owner and repo and ref and src_hash: - return nix_prefetch_cargo_vendor( - "github", - src_hash, - owner=owner, - repo=repo, - rev=ref, - subdir=subdir, - ) - elif fetcher == "git": - url = comp.get("url", "") - rev = rendered.get("rev") or rendered.get("tag") or "" - if url and rev and src_hash: - return nix_prefetch_cargo_vendor( - "git", - src_hash, - url=url, - rev=rev, - subdir=subdir, - ) - return None - - def _source_has_cargo(self, name: str) -> bool: - """Return True if this source carries a cargoHash field.""" - comp = self.merged_srcs.get(name, {}) - return "cargoHash" in comp - - def _apply_cargo_hash_to_sibling(self, name: str, cargo_hash: str): - """ - Propagate a freshly-computed cargo hash to any sibling source that mirrors - the cargoDeps pattern — a source whose only meaningful field is "hash" and - which is meant to stay in sync with the main source's cargoHash. - - Detection heuristic (any match triggers update): - - Sibling is literally named "cargoDeps", OR - - Sibling has no fetcher and its only field is "hash" (pure hash mirror) - """ - ts = self.target_dict.setdefault("sources", {}) - for sibling_name, sibling in list(self.merged_srcs.items()): - if sibling_name == name: - continue - has_fetcher = bool(sibling.get("fetcher")) - non_fetcher_keys = [k for k in sibling.keys() if k != "fetcher"] - is_cargo_deps = sibling_name == "cargoDeps" - is_hash_only = not has_fetcher and non_fetcher_keys == ["hash"] - if is_cargo_deps or is_hash_only: - sw = ts.setdefault(sibling_name, {}) - sw["hash"] = cargo_hash - - 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)) or mb.group(0) - - def repl_unbraced(mu): - key = mu.group(1) - return env.get(key, mu.group(0)) or 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 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) - 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._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] - self.merged_vars, self.merged_srcs, self.target_dict = merged_view( - self.spec, variant_name - ) - self.snames = sorted(list(self.merged_srcs.keys())) - # Clamp sidx in case source list changed - if self.snames: - self.sidx = min(self.sidx, len(self.snames) - 1) - - 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", {}) - comp = ts.setdefault(name, {}) - if kind in ("release", "tag"): - comp["tag"] = value - if "rev" in comp: - del comp["rev"] - elif kind == "commit": - comp["rev"] = value - if "tag" in comp: - del comp["tag"] - # Refresh merged_srcs so prefetch_hash_for sees the updated ref - self._refresh_merged() - - def save(self): - if self.is_python: - # For Python packages, update the default.nix file - for name in self.snames: - source = self.merged_srcs[name] - updates = {} - - # Get version from variables - if "version" in self.merged_vars: - updates["version"] = self.merged_vars["version"] - - # Get hash from source - if "hash" in source: - updates["hash"] = source["hash"] - - # Get tag from source - if "tag" in source: - updates["tag"] = source["tag"] - - # Get rev from source - if "rev" in source: - updates["rev"] = source["rev"] - - if updates: - update_python_package(self.path, name, updates) - return True - elif self.is_homeassistant: - # For Home Assistant components, update the default.nix file - for name in self.snames: - source = self.merged_srcs[name] - updates = {} - - # Get version from variables - if "version" in self.merged_vars: - updates["version"] = self.merged_vars["version"] - - # Get hash from source - if "hash" in source: - updates["hash"] = source["hash"] - - # Get tag from source - if "tag" in source: - updates["tag"] = source["tag"] - - # Get rev from source - if "rev" in source: - updates["rev"] = source["rev"] - - if updates: - update_homeassistant_component(self.path, name, updates) - return True - else: - # For regular packages, save to version.json - save_json(self.path, self.spec) - return True - - def run(self): - while True: - self.stdscr.clear() - h, w = self.stdscr.getmaxyx() - - # Draw main border around the entire screen - draw_border(self.stdscr, 0, 0, h - 1, w) - - # Row 0: package name + type badge centred in border - if self.is_python: - 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, - title[: w - 2], - curses.color_pair(COLOR_TITLE) | curses.A_BOLD, - ) - - # 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: - 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( - DETAIL_HDR_ROW, - x_pos, - " | ", - curses.color_pair(COLOR_NORMAL), - ) - x_pos += 3 - if i == self.vidx: - self.stdscr.addstr( - DETAIL_HDR_ROW, - x_pos, - f"[{v}]", - curses.color_pair(COLOR_HIGHLIGHT), - ) - else: - 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: - version = self.merged_vars.get("version", "") - 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) - ) - - # Separator + Sources header - for i in range(1, w - 1): - self.stdscr.addch( - DETAIL_SEP_ROW, i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER) - ) - 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") - 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 "" - ) - - if i == self.sidx: - row_attr = curses.color_pair(COLOR_HIGHLIGHT) - sel = "►" - else: - row_attr = curses.color_pair(COLOR_NORMAL) - sel = " " - - fetcher_color = COLOR_NORMAL - if fetcher == "github": - fetcher_color = COLOR_SUCCESS - elif fetcher == "url": - fetcher_color = COLOR_STATUS - elif fetcher == "git": - fetcher_color = COLOR_HEADER - - row = DETAIL_SRC_ROW + i - self.stdscr.addstr( - 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 - for i in range(1, w - 1): - self.stdscr.addch( - y_latest, i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER) - ) - - # Latest candidates section for selected component (auto-fetched) - if self.snames: - _sel_name = self.snames[self.sidx] - _comp = self.merged_srcs[_sel_name] - _fetcher = _comp.get("fetcher", "none") - # Preload candidates lazily for selected item - if ( - _fetcher in ("github", "git", "url") - and _sel_name not in self.candidates - ): - self.fetch_candidates_for(_sel_name) - - # Latest header with decoration — show branch if locked - _branch = _comp.get("branch") or "" - _latest_hdr = ( - f"Latest Versions: (branch: {_branch})" - if _branch - else "Latest Versions:" - ) - self.stdscr.addstr( - y_latest + 1, - 2, - _latest_hdr[: w - 4], - curses.color_pair(COLOR_HEADER) | curses.A_BOLD, - ) - - if _fetcher in ("github", "git"): - _cand = self.candidates.get(_sel_name, {}) - _dim = curses.color_pair(COLOR_DIM) | curses.A_DIM - - 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"): - _put_cand( - _row, - "Release:", - _cand["release"], - _cand.get("release_date", ""), - COLOR_SUCCESS, - ) - _row += 1 - if _cand.get("tag"): - _put_cand( - _row, - "Tag:", - _cand["tag"], - _cand.get("tag_date", ""), - COLOR_SUCCESS, - ) - _row += 1 - if _cand.get("commit"): - _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 {} - _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 - - _tag = _cand_u.get("tag") or (_cand_r.get("release") or "") - if _tag: - self.stdscr.addstr( - _urow, 4, "Tag: ", curses.color_pair(COLOR_HEADER) - ) - self.stdscr.addstr( - _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") and _cand_u.get("release"): - _b = _cand_u["base"] - _r = _cand_u["release"] - self.stdscr.addstr( - _urow, - 4, - f"base={_b} release={_r}"[: w - 6], - curses.color_pair(COLOR_NORMAL), - ) - - else: - if self.pkg_name == "linux-cachyos" and _sel_name == "linux": - _suffix = self.cachyos_suffix() - _latest = self.fetch_cachyos_linux_latest(_suffix) - self.stdscr.addstr( - y_latest + 2, - 4, - "PKGBUILD version:", - curses.color_pair(COLOR_HEADER), - ) - 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, - 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, 21, "-", curses.color_pair(COLOR_NORMAL) - ) - else: - self.stdscr.addstr( - y_latest + 2, - 4, - "No candidates available", - curses.color_pair(COLOR_NORMAL), - ) - - # Separator before footer - for i in range(1, w - 1): - self.stdscr.addch( - h - 4, i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER) - ) - - # 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) - self.stdscr.refresh() - - ch = self.stdscr.getch() - if ch in (ord("q"), 27): - return None - elif ch == curses.KEY_BACKSPACE or ch == 127: - return "reload" - elif ch in (curses.KEY_LEFT,): - self.vidx = max(0, self.vidx - 1) - self.select_variant() - elif ch in (curses.KEY_RIGHT,): - self.vidx = min(len(self.variants) - 1, self.vidx + 1) - self.select_variant() - elif ch in (curses.KEY_UP, ord("k")): - self.sidx = max(0, self.sidx - 1) - elif ch in (curses.KEY_DOWN, ord("j")): - self.sidx = min(len(self.snames) - 1, self.sidx + 1) - elif ch in (ord("r"),): - if self.snames: - name = self.snames[self.sidx] - 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) - 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: {_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"),): - # Show full rendered URL for URL-based sources - if self.snames: - name = self.snames[self.sidx] - comp = self.merged_srcs[name] - if comp.get("fetcher", "none") == "url": - rendered = render_templates(comp, self.merged_vars) - url = rendered.get("url") or rendered.get("urlTemplate") or "" - if url: - show_popup(self.stdscr, ["Full URL:", url]) - else: - self.set_status("No URL available") - elif ch in (ord("h"),): - if self.snames: - name = self.snames[self.sidx] - sri = self.prefetch_hash_for(name) - if sri: - ts = self.target_dict.setdefault("sources", {}) - compw = ts.setdefault(name, {}) - compw["hash"] = sri - self._refresh_merged() - # If this source also has a cargoHash, recompute it now - if self._source_has_cargo(name): - self.set_status( - f"{name}: updated hash; computing cargo hash..." - ) - self.stdscr.refresh() - cargo_sri = self.prefetch_cargo_hash_for(name) - if cargo_sri: - compw["cargoHash"] = cargo_sri - self._apply_cargo_hash_to_sibling(name, cargo_sri) - self._refresh_merged() - self.set_status(f"{name}: updated hash + cargo hash") - else: - self.set_status( - f"{name}: updated hash; cargo hash failed" - ) - else: - self.set_status(f"{name}: updated hash") - else: - self.set_status(f"{name}: hash prefetch failed") - elif ch in (ord("e"),): - s = prompt_input( - self.stdscr, "Edit path=value (relative to selected base/variant): " - ) - if s: - if "=" not in s: - self.set_status("Invalid input, expected key.path=value") - else: - k, v = s.split("=", 1) - path = [p for p in k.split(".") if p] - deep_set(self.cursor, path, v) - self.set_status(f"Set {k}={v}") - elif ch in (ord("s"),): - try: - self.save() - self.set_status("Saved.") - except Exception as e: - self.set_status(f"Save failed: {e}") - elif ch in (curses.KEY_ENTER, 10, 13): - if not self.snames: - continue - name = self.snames[self.sidx] - comp = self.merged_srcs[name] - fetcher = comp.get("fetcher", "none") - if fetcher in ("github", "git"): - # Ensure candidates loaded - if name not in self.candidates: - self.fetch_candidates_for(name) - cand = self.candidates.get(name, {}) - branch = comp.get("branch") or "" - # Present small menu - items = [] - if fetcher == "github": - # When branch-locked, only offer latest commit (tags are irrelevant) - if branch: - items = [ - ( - "Use latest commit (rev)", - ("commit", cand.get("commit")), - ), - ("Recompute hash", ("hash", None)), - ("Cancel", ("cancel", None)), - ] - else: - items = [ - ( - "Use latest release (tag)", - ("release", cand.get("release")), - ), - ("Use latest tag", ("tag", cand.get("tag"))), - ( - "Use latest commit (rev)", - ("commit", cand.get("commit")), - ), - ("Recompute hash", ("hash", None)), - ("Cancel", ("cancel", None)), - ] - else: - items = [ - ("Use latest commit (rev)", ("commit", cand.get("commit"))), - ("Recompute hash", ("hash", None)), - ("Cancel", ("cancel", None)), - ] - # Inject cargo hash option before Cancel when applicable - has_cargo = self._source_has_cargo(name) - if has_cargo: - items = [item for item in items if item[1][0] != "cancel"] + [ - ("Recompute cargo hash", ("cargo_hash", None)), - ("Cancel", ("cancel", None)), - ] - # 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: -" - 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:", - 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( - f"cargoHash: {cur_cargo[:32] + '...' if len(cur_cargo) > 32 else cur_cargo 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"): - if val: - self.set_ref(name, kind, val) - # update src hash - sri = self.prefetch_hash_for(name) - if sri: - ts = self.target_dict.setdefault("sources", {}) - compw = ts.setdefault(name, {}) - compw["hash"] = sri - self._refresh_merged() - # also update cargo hash if applicable - if has_cargo: - self.set_status( - f"{name}: set {kind}, hashing (src)..." - ) - cargo_sri = self.prefetch_cargo_hash_for(name) - if cargo_sri: - ts = self.target_dict.setdefault("sources", {}) - compw = ts.setdefault(name, {}) - compw["cargoHash"] = cargo_sri - self._apply_cargo_hash_to_sibling( - name, cargo_sri - ) - self._refresh_merged() - self.set_status( - f"{name}: set {kind}, updated src + cargo hash" - ) - else: - self.set_status( - f"{name}: set {kind}, updated src hash; cargo hash failed" - ) - else: - self.set_status( - f"{name}: set {kind} and updated hash" - ) - else: - self.set_status(f"No candidate {kind}") - elif kind == "hash": - 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(f"{name}: updated hash") - else: - self.set_status("hash prefetch failed") - elif kind == "cargo_hash": - self.set_status(f"{name}: computing cargo hash...") - self.stdscr.refresh() - cargo_sri = self.prefetch_cargo_hash_for(name) - if cargo_sri: - ts = self.target_dict.setdefault("sources", {}) - compw = ts.setdefault(name, {}) - compw["cargoHash"] = cargo_sri - self._apply_cargo_hash_to_sibling(name, cargo_sri) - self._refresh_merged() - self.set_status(f"{name}: updated cargo hash") - else: - self.set_status( - f"{name}: cargo hash computation failed" - ) - else: - pass - elif fetcher == "url": - # Offer latest release update (for proton-cachyos-like schemas) and/or hash recompute - cand = self.url_candidates.get(name) - menu_items: List[ - Tuple[str, Tuple[str, Optional[Dict[str, str]]]] - ] = [] - if cand and cand.get("base") and cand.get("release"): - menu_items.append( - ( - "Use latest release (update variables.base/release)", - ("update_vars", cand), - ) - ) - menu_items.append(("Recompute hash (prefetch)", ("hash", None))) - menu_items.append(("Cancel", ("cancel", None))) - - # 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): - # Write variables into selected base/variant dict - vars_dict = self.target_dict.setdefault("variables", {}) - vars_dict["base"] = payload["base"] - vars_dict["release"] = payload["release"] - # Recompute merged view to reflect new variables - self.recompute_view() - # Prefetch and update hash - sri = self.prefetch_hash_for(name) - if sri: - ts = self.target_dict.setdefault("sources", {}) - compw = ts.setdefault(name, {}) - compw["hash"] = sri - self.set_status( - f"{name}: updated to {payload['base']}.{payload['release']} and refreshed hash" - ) - else: - self.set_status( - "hash prefetch failed after variable update" - ) - elif kind == "hash": - sri = self.prefetch_hash_for(name) - if sri: - ts = self.target_dict.setdefault("sources", {}) - compw = ts.setdefault(name, {}) - compw["hash"] = sri - self.set_status(f"{name}: updated hash") - else: - self.set_status("hash prefetch failed") - else: - pass - else: - 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 "") - 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: - opts.append( - f"Update linux version to {latest} from PKGBUILD (.SRCINFO)" - ) - 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, - f"Actions for {name}", - opts, - header=header_lines, - ) - 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, - [ - f"{name}: fetcher={fetcher}", - "Use 'e' to edit fields manually.", - ], - ) - else: - pass - - -def select_menu( - stdscr, title: str, options: List[str], header: Optional[List[str]] = None +def _select_menu( + stdscr: Any, + title: str, + options: List[str], + header: Optional[List[str]] = None, ) -> Optional[int]: + """Show a centered menu; returns chosen index or None on cancel.""" idx = 0 while True: stdscr.clear() h, w = stdscr.getmaxyx() - - # 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(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 - start_y = (h - menu_height) // 2 - - # Draw border around menu - draw_border(stdscr, start_y, start_x, menu_height, menu_width) - - # Draw title - title_x = start_x + (menu_width - len(title)) // 2 - stdscr.addstr( - start_y, - title_x, - f" {title} ", - curses.color_pair(COLOR_TITLE) | curses.A_BOLD, + hdr_lines = header or [] + opt_h = len(options) + box_h = min(h - 4, opt_h + len(hdr_lines) + (2 if hdr_lines else 0) + 4) + max_len = max( + [len(title) + 4] + + [len(o) + 4 for o in options] + + [len(str(l)) + 4 for l in hdr_lines] + ) + box_w = min(w - 4, max(44, max_len)) + sy = (h - box_h) // 2 + sx = (w - box_w) // 2 + _draw_border(stdscr, sy, sx, box_h, box_w) + tx = sx + max(1, (box_w - len(title) - 2) // 2) + _addstr( + stdscr, sy, tx, f" {title} ", curses.color_pair(C_TITLE) | curses.A_BOLD ) - # Draw header if provided - y = start_y + 1 - if header: - for line in header: - if y >= start_y + menu_height - 2: - break - # Ensure we don't write beyond menu width - line_str = str(line) - if len(line_str) > menu_width - 4: - line_str = line_str[: menu_width - 7] + "..." - stdscr.addstr( - y, - start_x + 2, - line_str, - curses.color_pair(COLOR_HEADER), - ) - y += 1 - - # Add separator line after header - for i in range(1, menu_width - 1): - stdscr.addch( - y, start_x + i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER) - ) + y = sy + 1 + for line in hdr_lines: + if y >= sy + box_h - 2: + break + _addstr( + stdscr, y, sx + 2, str(line), curses.color_pair(C_HEADER), box_w - 4 + ) + y += 1 + if hdr_lines: + _draw_hline(stdscr, y, sx + 1, sx + box_w - 1) y += 1 - # Draw options - options_start_y = y - max_visible_options = max(1, start_y + menu_height - options_start_y - 1) - visible_options = min(len(options), max_visible_options) + opt_start = y + for i, opt in enumerate(options): + if y >= sy + box_h - 1: + break + sel = "►" if i == idx else " " + attr = ( + curses.color_pair(C_HIGHLIGHT) + if i == idx + else curses.color_pair(C_NORMAL) + ) + _addstr(stdscr, y, sx + 2, f"{sel} {opt}", attr, box_w - 4) + y += 1 - for i, opt in enumerate(options[:visible_options], start=0): - # Highlight selected option - if i == idx: - attr = curses.color_pair(COLOR_HIGHLIGHT) - sel = "►" # Use a fancier selector - else: - attr = curses.color_pair(COLOR_NORMAL) - sel = " " - - # Truncate long options to fit in menu - opt_text = f"{sel} {opt}" - if len(opt_text) > menu_width - 4: - opt_text = opt_text[: menu_width - 7] + "..." - stdscr.addstr(options_start_y + i, start_x + 2, opt_text, attr) - - # Draw footer - footer = "Enter: select | Backspace: cancel" - footer_x = start_x + (menu_width - len(footer)) // 2 - stdscr.addstr( - start_y + menu_height - 1, footer_x, footer, curses.color_pair(COLOR_STATUS) + footer = "Enter:select Bksp/ESC:cancel" + _addstr( + stdscr, + sy + box_h - 1, + sx + max(1, (box_w - len(footer)) // 2), + footer, + curses.color_pair(C_STATUS), ) - stdscr.refresh() + ch = stdscr.getch() if ch in (curses.KEY_UP, ord("k")): idx = max(0, idx - 1) @@ -3004,24 +211,1197 @@ def select_menu( idx = min(len(options) - 1, idx + 1) elif ch in (curses.KEY_ENTER, 10, 13): return idx - elif ch == curses.KEY_BACKSPACE or ch == 127 or ch == 27: + elif ch in (curses.KEY_BACKSPACE, 127, 27): return None -# ------------------------------ main ------------------------------ - - -def main(stdscr): - curses.curs_set(0) # Hide cursor - stdscr.nodelay(False) # Blocking input - - # Initialize colors - if curses.has_colors(): - init_colors() - +def _prompt(stdscr: Any, prompt: str) -> Optional[str]: + h, w = stdscr.getmaxyx() + curses.echo() + _addstr( + stdscr, + h - 1, + 0, + prompt + " " * (w - len(prompt) - 1), + curses.color_pair(C_HEADER), + ) + stdscr.move(h - 1, len(prompt)) + stdscr.refresh() try: - screen = PackagesScreen(stdscr) - screen.run() + s = stdscr.getstr().decode("utf-8").strip() + except Exception: + s = "" + curses.noecho() + return s or None + + +# --------------------------------------------------------------------------- +# Status bar mixin +# --------------------------------------------------------------------------- + + +class _StatusMixin: + def __init__(self) -> None: + self._status = "" + self._status_color = C_STATUS + + def set_status(self, text: str, *, error: bool = False, ok: bool = False) -> None: + self._status = text + self._status_color = C_ERROR if error else (C_SUCCESS if ok else C_STATUS) + + def draw_status(self, win: Any, row: int) -> None: + if not self._status: + return + h, w = win.getmaxyx() + _addstr(win, row, 0, self._status, curses.color_pair(self._status_color), w - 1) + + +# --------------------------------------------------------------------------- +# Package list screen +# --------------------------------------------------------------------------- + +_FETCHER_FILTERS = ["all", "github", "git", "url", "pypi"] + + +class PackagesScreen(_StatusMixin): + def __init__(self, stdscr: Any) -> None: + super().__init__() + self.stdscr = stdscr + self.packages: List[Tuple[str, Path]] = lib.find_packages() + self.idx = 0 + self.scroll = 0 + self.filter_fetcher = "all" # filter by primary fetcher + + def _filtered(self) -> List[Tuple[str, Path]]: + if self.filter_fetcher == "all": + return self.packages + result = [] + for name, path in self.packages: + try: + spec = lib.load_json(path) + srcs = spec.get("sources") or {} + fetchers = {(s.get("fetcher") or "none") for s in srcs.values()} + if self.filter_fetcher in fetchers: + result.append((name, path)) + except Exception: + pass + return result + + def run(self) -> None: + while True: + filtered = self._filtered() + self._draw(filtered) + ch = self.stdscr.getch() + + if ch in (ord("q"), 27): + return + elif ch in (curses.KEY_UP, ord("k")): + self.idx = max(0, self.idx - 1) + elif ch in (curses.KEY_DOWN, ord("j")): + self.idx = min(max(0, len(filtered) - 1), self.idx + 1) + elif ch == curses.KEY_PPAGE: + h, _ = self.stdscr.getmaxyx() + self.idx = max(0, self.idx - (h - 4)) + elif ch == curses.KEY_NPAGE: + h, _ = self.stdscr.getmaxyx() + self.idx = min(max(0, len(filtered) - 1), self.idx + (h - 4)) + elif ch == ord("g"): + self.idx = 0 + elif ch == ord("G"): + self.idx = max(0, len(filtered) - 1) + elif ch == ord("f"): + fi = _FETCHER_FILTERS.index(self.filter_fetcher) + self.filter_fetcher = _FETCHER_FILTERS[(fi + 1) % len(_FETCHER_FILTERS)] + self.idx = 0 + self.scroll = 0 + elif ch in (curses.KEY_ENTER, 10, 13): + if not filtered: + continue + name, path = filtered[self.idx] + try: + spec = lib.load_json(path) + except Exception as e: + self.set_status(f"Failed to load {path.name}: {e}", error=True) + continue + detail = PackageDetailScreen(self.stdscr, name, path, spec) + detail.run() + # Reload in case something changed on disk + self.packages = lib.find_packages() + self.idx = min(self.idx, max(0, len(self._filtered()) - 1)) + + def _draw(self, filtered: List[Tuple[str, Path]]) -> None: + self.stdscr.clear() + h, w = self.stdscr.getmaxyx() + left_w = max(30, min(55, w // 3)) + right_x = left_w + 1 + right_w = max(0, w - right_x) + + _draw_border(self.stdscr, 0, 0, h - 1, left_w) + if right_w >= 20: + _draw_border(self.stdscr, 0, right_x, h - 1, right_w) + + filt_label = "" if self.filter_fetcher == "all" else f" [{self.filter_fetcher}]" + title = f" Packages{filt_label} [{len(filtered)}] f:filter " + tx = max(1, (left_w - len(title)) // 2) + _addstr(self.stdscr, 0, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD) + + max_rows = h - 3 + if self.idx >= self.scroll + max_rows: + self.scroll = self.idx - max_rows + 1 + elif self.idx < self.scroll: + self.scroll = self.idx + + visible = filtered[self.scroll : self.scroll + max_rows] + + if self.scroll > 0: + _addstr(self.stdscr, 1, left_w - 3, "↑", curses.color_pair(C_STATUS)) + if self.scroll + max_rows < len(filtered): + _addstr( + self.stdscr, + min(1 + len(visible), h - 2), + left_w - 3, + "↓", + curses.color_pair(C_STATUS), + ) + + for i, (name, _path) in enumerate(visible): + row = i + self.scroll + if row == self.idx: + attr = curses.color_pair(C_HIGHLIGHT) + sel = "►" + else: + attr = curses.color_pair(C_NORMAL) + sel = " " + _addstr(self.stdscr, 1 + i, 2, f"{sel} {name}", attr, left_w - 4) + + # Right pane: preview + if right_w >= 20 and filtered: + try: + name, path = filtered[self.idx] + hdr = f" {name} " + _addstr( + self.stdscr, + 0, + right_x + max(1, (right_w - len(hdr)) // 2), + hdr, + curses.color_pair(C_TITLE) | curses.A_BOLD, + right_w - 2, + ) + try: + rel_path = str(path.relative_to(lib.ROOT)) + except ValueError: + rel_path = str(path) + _addstr( + self.stdscr, + 1, + right_x + 2, + rel_path, + curses.color_pair(C_NORMAL) | curses.A_DIM, + right_w - 3, + ) + _addstr( + self.stdscr, 2, right_x + 2, "Sources:", curses.color_pair(C_HEADER) + ) + + spec = lib.load_json(path) + mvars, msrcs, _ = lib.merged_view(spec, None) + NAME_W, FETCH_W = 16, 7 + ref_col = right_x + 2 + NAME_W + 1 + FETCH_W + 1 + for i2, (sname, comp) in enumerate(sorted(msrcs.items())): + if 3 + i2 >= h - 3: + break + fetcher = comp.get("fetcher", "none") + ref = lib.source_ref_label(comp, mvars) + max_ref = max(0, right_w - (NAME_W + 1 + FETCH_W + 1) - 3) + fc = ( + C_SUCCESS + if fetcher == "github" + else ( + C_STATUS + if fetcher == "url" + else (C_HEADER if fetcher == "git" else C_NORMAL) + ) + ) + _addstr( + self.stdscr, + 3 + i2, + right_x + 2, + f"{sname[:NAME_W]:<{NAME_W}}", + curses.color_pair(C_NORMAL), + ) + _addstr( + self.stdscr, + 3 + i2, + right_x + 2 + NAME_W + 1, + f"{fetcher[:FETCH_W]:<{FETCH_W}}", + curses.color_pair(fc), + ) + _addstr( + self.stdscr, + 3 + i2, + ref_col, + ref[:max_ref] + ("…" if len(ref) > max_ref else ""), + curses.color_pair(C_NORMAL), + ) + + hint = "Enter:details j/k:move f:filter q:quit" + _addstr( + self.stdscr, + h - 2, + right_x + max(1, (right_w - len(hint)) // 2), + hint, + curses.color_pair(C_STATUS), + right_w - 2, + ) + except Exception as e: + _addstr( + self.stdscr, + 2, + right_x + 2, + f"Error: {e}", + curses.color_pair(C_ERROR), + right_w - 4, + ) + + self.draw_status(self.stdscr, h - 1) + self.stdscr.refresh() + + +# --------------------------------------------------------------------------- +# Package detail screen +# --------------------------------------------------------------------------- + + +class PackageDetailScreen(_StatusMixin): + # Layout constants + _SRC_NAME_W = 20 + _SRC_FETCH_W = 6 + _SRC_REF_COL = 2 + 20 + 1 + 6 + 1 # = 30 + + # How many rows the "latest" + "footer" sections occupy at the bottom + _BOTTOM_ROWS = 9 # sep + 3 latest rows + sep + 2 footer + status + + def __init__( + self, + stdscr: Any, + pkg_name: str, + path: Path, + spec: lib.Json, + ) -> None: + super().__init__() + self.stdscr = stdscr + self.pkg_name = pkg_name + self.path = path + self.spec = spec + + self.variants: List[str] = [""] + list( + (spec.get("variants") or {}).keys() + ) + default = spec.get("defaultVariant") + self.vidx = self.variants.index(default) if default in self.variants else 0 # type: ignore[arg-type] + + self.sidx = 0 + self.candidates: Dict[str, lib.Candidates] = {} + self.url_candidates: Dict[ + str, Dict[str, str] + ] = {} # name -> {base, release, tag} + + self._refresh_view() + + # ------------------------------------------------------------------ + # View management + # ------------------------------------------------------------------ + + def _variant_name(self) -> Optional[str]: + return None if self.vidx == 0 else self.variants[self.vidx] + + def _refresh_view(self) -> None: + vname = self._variant_name() + self.merged_vars, self.merged_srcs, self.target_dict = lib.merged_view( + self.spec, vname + ) + self.snames = sorted(self.merged_srcs.keys()) + self.sidx = min(self.sidx, max(0, len(self.snames) - 1)) + # Inject variant suffix hint for special hooks + if self.pkg_name == "linux-cachyos": + from hooks import _cachyos_linux_suffix + + self.merged_vars["_cachyos_suffix"] = _cachyos_linux_suffix(vname) + + # ------------------------------------------------------------------ + # Candidate fetching + # ------------------------------------------------------------------ + + def _fetch_candidates_for(self, name: str) -> None: + comp = self.merged_srcs.get(name, {}) + hook = hooks.get_candidates_hook(self.pkg_name, name) + if hook: + self.candidates[name] = hook(comp, self.merged_vars) + else: + self.candidates[name] = lib.fetch_candidates(comp, self.merged_vars) + + # For URL fetcher with github variables, parse base/release from the tag + c = self.candidates[name] + if comp.get("fetcher") == "url" and c.release: + prefix = str(self.merged_vars.get("releasePrefix") or "") + suffix = str(self.merged_vars.get("releaseSuffix") or "") + mid = c.release + if prefix and mid.startswith(prefix): + mid = mid[len(prefix) :] + if suffix and mid.endswith(suffix): + mid = mid[: -len(suffix)] + parts = mid.split("-") + if len(parts) >= 2: + self.url_candidates[name] = { + "base": parts[0], + "release": parts[-1], + "tag": c.release, + } + + # ------------------------------------------------------------------ + # Hash prefetch + # ------------------------------------------------------------------ + + def _prefetch_hash(self, name: str) -> Optional[str]: + comp = self.merged_srcs[name] + return lib.prefetch_source(comp, self.merged_vars) + + def _has_cargo(self, name: str) -> bool: + return "cargoHash" in self.merged_srcs.get(name, {}) + + def _prefetch_cargo(self, name: str) -> Optional[str]: + comp = self.merged_srcs[name] + rendered = lib.render(comp, self.merged_vars) + fetcher = comp.get("fetcher", "none") + src_hash = comp.get("hash", "") + subdir = comp.get("cargoSubdir", "") + return lib.prefetch_cargo_vendor( + fetcher, + src_hash, + url=comp.get("url", ""), + owner=comp.get("owner", ""), + repo=comp.get("repo", ""), + rev=rendered.get("tag") or rendered.get("rev") or "", + subdir=subdir, + ) + + def _propagate_cargo_hash(self, name: str, cargo_hash: str) -> None: + """Copy cargo hash to any sibling cargoDeps or hash-only source.""" + ts = self.target_dict.setdefault("sources", {}) + for sib_name, sib in self.merged_srcs.items(): + if sib_name == name: + continue + is_cargo_deps = sib_name == "cargoDeps" + is_hash_only = not sib.get("fetcher") and list(sib.keys()) == ["hash"] + if is_cargo_deps or is_hash_only: + ts.setdefault(sib_name, {})["hash"] = cargo_hash + + # ------------------------------------------------------------------ + # Write helpers + # ------------------------------------------------------------------ + + def _set_ref(self, name: str, kind: str, value: str) -> None: + ts = self.target_dict.setdefault("sources", {}) + comp = ts.setdefault(name, {}) + if kind in ("release", "tag"): + comp["tag"] = value + comp.pop("rev", None) + elif kind == "commit": + comp["rev"] = value + comp.pop("tag", None) + self._refresh_view() + + def _write_hash(self, name: str, sri: str) -> None: + ts = self.target_dict.setdefault("sources", {}) + ts.setdefault(name, {})["hash"] = sri + self._refresh_view() + + def _write_cargo_hash(self, name: str, sri: str) -> None: + ts = self.target_dict.setdefault("sources", {}) + ts.setdefault(name, {})["cargoHash"] = sri + self._propagate_cargo_hash(name, sri) + self._refresh_view() + + def _save(self) -> None: + lib.save_json(self.path, self.spec) + + # ------------------------------------------------------------------ + # Drawing + # ------------------------------------------------------------------ + + def _draw(self) -> None: + self.stdscr.clear() + h, w = self.stdscr.getmaxyx() + _draw_border(self.stdscr, 0, 0, h - 1, w) + + # Title + title = f" {self.pkg_name} " + _addstr( + self.stdscr, + 0, + max(1, (w - len(title)) // 2), + title, + curses.color_pair(C_TITLE) | curses.A_BOLD, + ) + + # Path + try: + rel = str(self.path.relative_to(lib.ROOT)) + except ValueError: + rel = str(self.path) + _addstr( + self.stdscr, 1, 2, rel, curses.color_pair(C_NORMAL) | curses.A_DIM, w - 4 + ) + + # Variants row + _addstr(self.stdscr, 2, 2, "Variants:", curses.color_pair(C_HEADER)) + xp = 12 + for i, v in enumerate(self.variants): + if xp >= w - 4: + break + if i > 0: + _addstr(self.stdscr, 2, xp, " | ", curses.color_pair(C_NORMAL)) + xp += 3 + if i == self.vidx: + _addstr(self.stdscr, 2, xp, f"[{v}]", curses.color_pair(C_HIGHLIGHT)) + xp += len(v) + 2 + else: + _addstr(self.stdscr, 2, xp, v, curses.color_pair(C_NORMAL)) + xp += len(v) + + # Sources section separator + _draw_hline(self.stdscr, 3, 1, w - 1) + _addstr( + self.stdscr, 3, 2, " Sources ", curses.color_pair(C_HEADER) | curses.A_BOLD + ) + + # Latest section layout: separator at y_latest, 3 content rows below + y_latest = h - self._BOTTOM_ROWS + _max_src_rows = max(0, y_latest - 4) + + for i, name in enumerate(self.snames[:_max_src_rows]): + comp = self.merged_srcs[name] + fetcher = comp.get("fetcher", "none") + ref = lib.source_ref_label(comp, self.merged_vars) + branch = comp.get("branch") or "" + has_cargo = "cargoHash" in comp + badges = (f" [{branch}]" if branch else "") + ( + " [cargo]" if has_cargo else "" + ) + ref_text = ref + badges + ref_short = ref_text[: w - self._SRC_REF_COL - 2] + if len(ref_text) > w - self._SRC_REF_COL - 2: + ref_short = ref_short[:-1] + "…" + + if i == self.sidx: + row_attr = curses.color_pair(C_HIGHLIGHT) + sel = "►" + else: + row_attr = curses.color_pair(C_NORMAL) + sel = " " + + fc = ( + C_SUCCESS + if fetcher == "github" + else ( + C_STATUS + if fetcher == "url" + else (C_HEADER if fetcher == "git" else C_NORMAL) + ) + ) + + row = 4 + i + _addstr( + self.stdscr, + row, + 2, + f"{sel} {name[: self._SRC_NAME_W - 2]:<{self._SRC_NAME_W - 2}}", + row_attr, + ) + _addstr( + self.stdscr, + row, + 2 + self._SRC_NAME_W, + f"{fetcher[: self._SRC_FETCH_W]:<{self._SRC_FETCH_W}}", + curses.color_pair(fc), + ) + _addstr( + self.stdscr, + row, + self._SRC_REF_COL, + ref_short, + curses.color_pair(C_NORMAL), + ) + + # Latest candidates section + _draw_hline(self.stdscr, y_latest, 1, w - 1) + self._draw_candidates(y_latest, w) + + # Footer separator + keys + _draw_hline(self.stdscr, h - 4, 1, w - 1) + f1 = "Enter:actions r:refresh h:hash c:cargo e:edit s:save" + f2 = "←/→:variant j/k:source Bksp:back q:quit" + _addstr( + self.stdscr, + h - 3, + max(1, (w - len(f1)) // 2), + f1, + curses.color_pair(C_STATUS), + ) + _addstr( + self.stdscr, + h - 2, + max(1, (w - len(f2)) // 2), + f2, + curses.color_pair(C_STATUS), + ) + + self.draw_status(self.stdscr, h - 1) + self.stdscr.refresh() + + def _draw_candidates(self, y: int, w: int) -> None: + if not self.snames: + return + name = self.snames[self.sidx] + comp = self.merged_srcs[name] + fetcher = comp.get("fetcher", "none") + branch = comp.get("branch") or "" + + hdr = "Latest Versions:" + (f" (branch: {branch})" if branch else "") + _addstr(self.stdscr, y + 1, 2, hdr, curses.color_pair(C_HEADER) | curses.A_BOLD) + + # Lazy-load candidates on first draw of this source + if fetcher in ("github", "git", "url", "pypi") and name not in self.candidates: + self._fetch_candidates_for(name) + + c = self.candidates.get(name) + dim = curses.color_pair(C_DIM) | curses.A_DIM + + def _row(r: int, label: str, value: str, date: str, color: int) -> None: + lw = 9 + _addstr(self.stdscr, r, 4, f"{label:<{lw}}", curses.color_pair(C_HEADER)) + _addstr( + self.stdscr, r, 4 + lw, value[: w - lw - 6], curses.color_pair(color) + ) + if date and 4 + lw + len(value) + 2 < w - 2: + _addstr(self.stdscr, r, 4 + lw + len(value) + 1, date, dim) + + if c and fetcher in ("github", "git"): + row = y + 2 + if c.release: + _row(row, "Release:", c.release, c.release_date, C_SUCCESS) + row += 1 + if c.tag: + _row(row, "Tag:", c.tag, c.tag_date, C_SUCCESS) + row += 1 + if c.commit: + _row(row, "Commit:", c.commit[:12], c.commit_date, C_NORMAL) + + elif fetcher in ("url", "pypi"): + url_info = lib._url_source_info(comp, self.merged_vars) + kind = url_info.get("kind", "plain") + version_var = url_info.get("version_var") or "version" + cur_ver = str(self.merged_vars.get(version_var) or "") + + if kind == "github": + uc = self.url_candidates.get(name) + tag = (uc or {}).get("tag") or (c.release if c else "") + if tag: + same = tag == cur_ver or ( + uc and f"{uc.get('base', '')}-{uc.get('release', '')}" in tag + ) + _row( + y + 2, + "Latest:", + tag, + (c.release_date if c else ""), + C_NORMAL if same else C_SUCCESS, + ) + if uc and uc.get("base") and uc.get("release"): + _addstr( + self.stdscr, + y + 3, + 4, + f"base={uc['base']} release={uc['release']}", + curses.color_pair(C_NORMAL), + w - 6, + ) + else: + # pypi / openvsx / plain + latest = c.release if c else "" + if cur_ver: + _addstr( + self.stdscr, + y + 2, + 4, + f"{'current':<9}{cur_ver}", + curses.color_pair(C_NORMAL), + w - 6, + ) + if latest: + same = latest == cur_ver + _row(y + 3, "Latest:", latest, "", C_NORMAL if same else C_SUCCESS) + if same: + _addstr( + self.stdscr, + y + 4, + 4, + "(up to date)", + curses.color_pair(C_DIM) | curses.A_DIM, + ) + else: + _addstr( + self.stdscr, + y + 3, + 4, + "No candidates (press r to fetch)", + curses.color_pair(C_NORMAL), + ) + + else: + # Special case display: CachyOS hooks return value in c.tag slot + if c and c.tag: + _row(y + 2, "Latest:", c.tag, c.tag_date, C_SUCCESS) + elif c and c.commit: + _row(y + 2, "Commit:", c.commit[:12], c.commit_date, C_NORMAL) + else: + _addstr( + self.stdscr, + y + 2, + 4, + "No candidates (press r to fetch)", + curses.color_pair(C_NORMAL), + ) + + # ------------------------------------------------------------------ + # Action dispatch + # ------------------------------------------------------------------ + + def _action_for_source(self, name: str) -> None: + comp = self.merged_srcs[name] + fetcher = comp.get("fetcher", "none") + has_cargo = self._has_cargo(name) + + if fetcher in ("github", "git"): + self._action_github_git(name, comp, fetcher, has_cargo) + elif fetcher in ("url", "pypi"): + self._action_url(name, comp) + else: + _show_popup( + self.stdscr, + [f"fetcher: {fetcher}", "Use 'e' to edit fields manually."], + title=name, + ) + + def _action_github_git( + self, name: str, comp: lib.Json, fetcher: str, has_cargo: bool + ) -> None: + if name not in self.candidates: + self._fetch_candidates_for(name) + c = self.candidates.get(name, lib.Candidates()) + branch = comp.get("branch") or "" + + rendered = lib.render(comp, self.merged_vars) + cur_tag = rendered.get("tag") or "" + cur_rev = rendered.get("rev") or "" + cur_str = ( + f"current: tag={cur_tag}" + if cur_tag + else f"current: rev={cur_rev[:12]}" + if cur_rev + else "current: -" + ) + if branch: + cur_str += f" (branch: {branch})" + + def _av(v: str, d: str) -> str: + return f"{v} {d}" if v and d else (v or "-") + + hdr = [ + cur_str, + "available:", + f" release : {_av(c.release, c.release_date)}", + f" tag : {_av(c.tag, c.tag_date)}", + f" commit : {_av(c.commit[:12] if c.commit else '', c.commit_date)}", + ] + if has_cargo: + cargo = comp.get("cargoHash", "") + hdr.append( + f"cargoHash: {cargo[:32]}{'...' if len(cargo) > 32 else cargo if not cargo else ''}" + ) + + items: List[Tuple[str, Tuple[str, str]]] = [] + if fetcher == "github" and not branch: + if c.release: + items.append( + (f"Use latest release ({c.release})", ("release", c.release)) + ) + if c.tag: + items.append((f"Use latest tag ({c.tag})", ("tag", c.tag))) + if c.commit: + items.append( + (f"Use latest commit ({c.commit[:12]})", ("commit", c.commit)) + ) + items.append(("Recompute hash", ("hash", ""))) + if has_cargo: + items.append(("Recompute cargo hash", ("cargo_hash", ""))) + items.append(("Change branch", ("change_branch", ""))) + items.append(("Cancel", ("cancel", ""))) + + choice = _select_menu( + self.stdscr, + f"Actions: {name}", + [label for label, _ in items], + header=hdr, + ) + if choice is None: + return + + kind, val = items[choice][1] + if kind == "cancel": + return + + if kind in ("release", "tag", "commit"): + if not val: + self.set_status(f"No candidate for {kind}", error=True) + return + self._set_ref(name, kind, val) + self.set_status(f"{name}: fetching hash...") + self.stdscr.refresh() + sri = self._prefetch_hash(name) + if sri: + self._write_hash(name, sri) + if has_cargo: + self.set_status(f"{name}: computing cargo hash...") + self.stdscr.refresh() + cargo = self._prefetch_cargo(name) + if cargo: + self._write_cargo_hash(name, cargo) + self.set_status( + f"{name}: updated ref + hash + cargo hash", ok=True + ) + else: + self.set_status( + f"{name}: updated ref + hash; cargo hash failed", error=True + ) + else: + self.set_status(f"{name}: updated ref and hash", ok=True) + else: + self.set_status(f"{name}: hash prefetch failed", error=True) + + elif kind == "hash": + self.set_status(f"{name}: fetching hash...") + self.stdscr.refresh() + sri = self._prefetch_hash(name) + if sri: + self._write_hash(name, sri) + self.set_status(f"{name}: hash updated", ok=True) + else: + self.set_status(f"{name}: hash prefetch failed", error=True) + + elif kind == "cargo_hash": + self.set_status(f"{name}: computing cargo hash...") + self.stdscr.refresh() + cargo = self._prefetch_cargo(name) + if cargo: + self._write_cargo_hash(name, cargo) + self.set_status(f"{name}: cargo hash updated", ok=True) + else: + self.set_status(f"{name}: cargo hash failed", error=True) + + elif kind == "change_branch": + self._action_change_branch(name, comp, fetcher, has_cargo) + + def _action_change_branch( + self, name: str, comp: lib.Json, fetcher: str, has_cargo: bool + ) -> None: + cur_branch = comp.get("branch") or "" + prompt = ( + f"New branch for '{name}' (blank to clear, current: {cur_branch!r}): " + if cur_branch + else f"Branch to track for '{name}' (blank to cancel): " + ) + new_branch = _prompt(self.stdscr, prompt) + + # blank input when there was no branch → cancelled + if new_branch is None: + self.set_status("Cancelled.") + return + + ts = self.target_dict.setdefault("sources", {}) + comp_w = ts.setdefault(name, {}) + + if new_branch: + comp_w["branch"] = new_branch + else: + comp_w.pop("branch", None) + # Also remove from merged view target if previously set at this level + if not new_branch and not cur_branch: + self.set_status("No branch to clear.") + return + + # Resolve HEAD of the new branch + self.set_status( + f"{name}: resolving HEAD of {new_branch!r}..." + if new_branch + else f"{name}: branch cleared, fetching HEAD..." + ) + self.stdscr.refresh() + self._refresh_view() + + if fetcher == "github": + owner = comp.get("owner") or "" + repo = comp.get("repo") or "" + rev = ( + lib.gh_head_commit(owner, repo, new_branch or None) + if (owner and repo) + else None + ) + else: # git + url = comp.get("url") or "" + rev = lib.git_branch_commit(url, new_branch or None) if url else None + + if not rev: + self.set_status( + f"{name}: branch {'set' if new_branch else 'cleared'} but could not resolve HEAD", + error=True, + ) + return + + comp_w["rev"] = rev + comp_w.pop("tag", None) + self._refresh_view() + + # Invalidate cached candidates so next fetch uses the new branch + self.candidates.pop(name, None) + + self.set_status(f"{name}: fetching hash for {rev[:12]}...") + self.stdscr.refresh() + sri = self._prefetch_hash(name) + if not sri: + self.set_status( + f"{name}: branch updated to {rev[:12]}; hash prefetch failed", + error=True, + ) + return + + self._write_hash(name, sri) + + if has_cargo: + self.set_status(f"{name}: computing cargo hash...") + self.stdscr.refresh() + cargo = self._prefetch_cargo(name) + if cargo: + self._write_cargo_hash(name, cargo) + result = f"branch={'none' if not new_branch else new_branch!r}, rev={rev[:12]}, hash+cargo updated" + else: + result = f"branch={'none' if not new_branch else new_branch!r}, rev={rev[:12]}, hash updated; cargo hash failed" + self.set_status( + f"{name}: {result}", ok=cargo is not None, error=cargo is None + ) + else: + self.set_status( + f"{name}: branch={'none' if not new_branch else repr(new_branch)}, rev={rev[:12]}, hash updated", + ok=True, + ) + + def _action_url(self, name: str, comp: lib.Json) -> None: + if name not in self.candidates: + self._fetch_candidates_for(name) + c = self.candidates.get(name, lib.Candidates()) + + url_info = lib._url_source_info(comp, self.merged_vars) + kind_label = url_info.get("kind", "plain") + + # Determine current version display + cur_version = "" + version_var = url_info.get("version_var") or "version" + if kind_label in ("pypi", "openvsx", "plain"): + cur_version = str(self.merged_vars.get(version_var) or "") + elif kind_label == "github": + # proton-cachyos style: base+release variables + uc = self.url_candidates.get(name) + 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 "") + cur_version = ( + f"{rp}{base}-{rel}{rs}" + if (base and rel) + else (lib.source_ref_label(comp, self.merged_vars)) + ) + + latest = c.release or "" + hdr = [ + f"type : {kind_label}", + f"current : {cur_version or '-'}", + f"latest : {latest or '(press r to fetch)'}", + ] + + items: List[Tuple[str, str]] = [] + + if kind_label == "github": + uc = self.url_candidates.get(name) + if uc and uc.get("base") and uc.get("release"): + items.append((f"Use latest release ({uc['tag']})", "update_vars")) + elif latest: + items.append((f"Use latest release ({latest})", "update_version")) + + elif kind_label in ("pypi", "openvsx"): + if latest and latest != cur_version: + items.append((f"Update to {latest}", "update_version")) + elif latest: + hdr.append("(already at latest)") + + elif kind_label == "plain": + if latest and latest != cur_version: + items.append((f"Update to {latest}", "update_version")) + + items.append(("Recompute hash", "hash")) + items.append(("Cancel", "cancel")) + + choice = _select_menu( + self.stdscr, + f"Actions: {name}", + [label for label, _ in items], + header=hdr, + ) + if choice is None: + return + _, action = items[choice] + if action == "cancel": + return + + if action == "update_vars": + # GitHub release with base+release variable split (proton-cachyos style) + uc = self.url_candidates.get(name) + if uc: + vs = self.target_dict.setdefault("variables", {}) + vs["base"] = uc["base"] + vs["release"] = uc["release"] + self._refresh_view() + self.set_status(f"{name}: fetching hash for {uc['tag']}...") + self.stdscr.refresh() + sri = self._prefetch_hash(name) + if sri: + self._write_hash(name, sri) + self.set_status(f"{name}: updated to {uc['tag']}", ok=True) + else: + self.set_status( + f"{name}: variables updated; hash prefetch failed", error=True + ) + + elif action == "update_version": + new_ver = latest + self.set_status(f"{name}: updating to {new_ver}...") + self.stdscr.refresh() + + if kind_label == "pypi": + # For pypi fetcher: also need to fetch a new hash from PyPI directly + pkg_name = url_info.get("name") or str( + self.merged_vars.get("name") or name + ) + vs = self.target_dict.setdefault("variables", {}) + vs[version_var] = new_ver + self._refresh_view() + self.set_status(f"{name}: fetching PyPI hash for {new_ver}...") + self.stdscr.refresh() + sri = lib.pypi_hash(pkg_name, new_ver) + if sri: + self._write_hash(name, sri) + self.set_status(f"{name}: updated to {new_ver}", ok=True) + else: + self.set_status( + f"{name}: version updated; hash prefetch failed", error=True + ) + + else: + # url/openvsx/plain: update variable, render new URL, prefetch + vs = self.target_dict.setdefault("variables", {}) + vs[version_var] = new_ver + # For GitHub release assets, also update the tag field on the source + if kind_label == "github": + tag_tmpl = comp.get("tag") or "" + if tag_tmpl: + ts = self.target_dict.setdefault("sources", {}) + ts.setdefault(name, {})["tag"] = lib.render( + tag_tmpl, {**self.merged_vars, version_var: new_ver} + ) + self._refresh_view() + self.set_status(f"{name}: fetching hash for {new_ver}...") + self.stdscr.refresh() + sri = self._prefetch_hash(name) + if sri: + self._write_hash(name, sri) + self.set_status(f"{name}: updated to {new_ver}", ok=True) + else: + self.set_status( + f"{name}: version updated; hash prefetch failed", error=True + ) + + elif action == "hash": + self.set_status(f"{name}: fetching hash...") + self.stdscr.refresh() + sri = self._prefetch_hash(name) + if sri: + self._write_hash(name, sri) + self.set_status(f"{name}: hash updated", ok=True) + else: + self.set_status(f"{name}: hash prefetch failed", error=True) + + # ------------------------------------------------------------------ + # Main loop + # ------------------------------------------------------------------ + + def run(self) -> None: + while True: + self._draw() + ch = self.stdscr.getch() + + if ch in (ord("q"), 27): + return + elif ch in (curses.KEY_BACKSPACE, 127): + return + elif ch in (curses.KEY_LEFT,): + self.vidx = max(0, self.vidx - 1) + self.candidates.clear() + self.url_candidates.clear() + self._refresh_view() + elif ch in (curses.KEY_RIGHT,): + self.vidx = min(len(self.variants) - 1, self.vidx + 1) + self.candidates.clear() + self.url_candidates.clear() + self._refresh_view() + elif ch in (curses.KEY_UP, ord("k")): + self.sidx = max(0, self.sidx - 1) + elif ch in (curses.KEY_DOWN, ord("j")): + self.sidx = min(max(0, len(self.snames) - 1), self.sidx + 1) + elif ch == ord("r"): + if self.snames: + name = self.snames[self.sidx] + self.set_status(f"{name}: fetching candidates...") + self.stdscr.refresh() + self._fetch_candidates_for(name) + c = self.candidates.get(name, lib.Candidates()) + + def _fv(v: str, d: str) -> str: + return f"{v} {d}" if v and d else (v or "-") + + _show_popup( + self.stdscr, + [ + f"Candidates for {name}:", + f" release : {_fv(c.release, c.release_date)}", + f" tag : {_fv(c.tag, c.tag_date)}", + f" commit : {_fv(c.commit[:12] if c.commit else '', c.commit_date)}", + ], + title=name, + ) + self.set_status("") + + elif ch == ord("h"): + if self.snames: + name = self.snames[self.sidx] + self.set_status(f"{name}: fetching hash...") + self.stdscr.refresh() + sri = self._prefetch_hash(name) + if sri: + self._write_hash(name, sri) + if self._has_cargo(name): + self.set_status(f"{name}: computing cargo hash...") + self.stdscr.refresh() + cargo = self._prefetch_cargo(name) + if cargo: + self._write_cargo_hash(name, cargo) + self.set_status( + f"{name}: updated hash + cargo hash", ok=True + ) + else: + self.set_status( + f"{name}: updated hash; cargo hash failed", + error=True, + ) + else: + self.set_status(f"{name}: hash updated", ok=True) + else: + self.set_status(f"{name}: hash prefetch failed", error=True) + + elif ch == ord("c"): + if self.snames: + name = self.snames[self.sidx] + if self._has_cargo(name): + self.set_status(f"{name}: computing cargo hash...") + self.stdscr.refresh() + cargo = self._prefetch_cargo(name) + if cargo: + self._write_cargo_hash(name, cargo) + self.set_status(f"{name}: cargo hash updated", ok=True) + else: + self.set_status(f"{name}: cargo hash failed", error=True) + else: + self.set_status(f"{self.snames[self.sidx]}: no cargoHash field") + + elif ch == ord("e"): + val = _prompt( + self.stdscr, "Edit path=value (relative to selected base/variant):" + ) + if val and "=" in val: + k, v = val.split("=", 1) + path_tokens = [p for p in k.split(".") if p] + # Write to the current base/variant cursor dict + cursor = ( + self.spec + if self.vidx == 0 + else ( + self.spec.get("variants", {}).get( + self._variant_name() or "", self.spec + ) + ) + ) + lib.deep_set(cursor, path_tokens, v) + self._refresh_view() + self.set_status(f"Set {k} = {v!r}", ok=True) + elif val: + self.set_status( + "Invalid format; expected key.path=value", error=True + ) + + elif ch == ord("i"): + # Show full rendered URL for url fetcher sources + if self.snames: + name = self.snames[self.sidx] + comp = self.merged_srcs[name] + if comp.get("fetcher") == "url": + rendered = lib.render(comp, self.merged_vars) + url = rendered.get("url") or rendered.get("urlTemplate") or "" + _show_popup(self.stdscr, ["Full URL:", url], title=name) + else: + self.set_status(f"Not a url fetcher source") + + elif ch == ord("s"): + try: + self._save() + self.set_status("Saved.", ok=True) + except Exception as e: + self.set_status(f"Save failed: {e}", error=True) + + elif ch in (curses.KEY_ENTER, 10, 13): + if self.snames: + self._action_for_source(self.snames[self.sidx]) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def _main(stdscr: Any) -> None: + curses.curs_set(0) + stdscr.nodelay(False) + if curses.has_colors(): + _init_colors() + try: + PackagesScreen(stdscr).run() except Exception: curses.endwin() traceback.print_exc() @@ -3029,4 +1409,4 @@ def main(stdscr): if __name__ == "__main__": - curses.wrapper(main) + curses.wrapper(_main) diff --git a/update.py b/update.py deleted file mode 100755 index 6619d52..0000000 --- a/update.py +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env python3 -import json -import re -import subprocess -import sys -from pathlib import Path -from urllib.request import Request, urlopen -from urllib.error import HTTPError - -GITHUB_API = "https://api.github.com" -CODEBERG_API = "https://codeberg.org/api/v1" - -def run(cmd): - p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - if p.returncode != 0: - raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{p.stderr.strip()}") - return p.stdout.strip() - -def http_get_json(url, token=None): - headers = {"Accept": "application/json"} - if token: - headers["Authorization"] = f"token {token}" - req = Request(url, headers=headers) - with urlopen(req) as resp: - return json.load(resp) - -def github_latest_release(owner, repo, token=None): - url = f"{GITHUB_API}/repos/{owner}/{repo}/releases/latest" - return http_get_json(url, token=token) - -def github_latest_commit(owner, repo, token=None): - url = f"{GITHUB_API}/repos/{owner}/{repo}/commits?per_page=1" - data = http_get_json(url, token=token) - return data[0]["sha"] - -def codeberg_latest_release(owner, repo, token=None): - url = f"{CODEBERG_API}/repos/{owner}/{repo}/releases/latest" - return http_get_json(url, token=token) - -def codeberg_latest_commit(owner, repo, token=None): - url = f"{CODEBERG_API}/repos/{owner}/{repo}/commits?limit=1" - data = http_get_json(url, token=token) - return data[0]["sha"] - -def nix_hash_to_sri(hash_str): - # Convert nix-base32 to SRI - return run(["nix", "hash", "to-sri", "--type", "sha256", hash_str]) - -def prefetch_git(url, rev): - out = run(["nix-prefetch-git", "--url", url, "--rev", rev, "--fetch-submodules"]) - data = json.loads(out) - return nix_hash_to_sri(data["sha256"]) - -def prefetch_url(url, unpack=False): - cmd = ["nix-prefetch-url", url] - if unpack: - cmd.insert(1, "--unpack") - hash_str = run(cmd) - return nix_hash_to_sri(hash_str) - -def is_archive_url(url): - return bool(re.search(r"\.(tar\.gz|tar\.xz|tar\.bz2|zip)$", url)) - -def build_repo_url(location, owner, repo): - if location == "github": - return f"https://github.com/{owner}/{repo}.git" - if location == "codeberg": - return f"https://codeberg.org/{owner}/{repo}.git" - raise ValueError(f"Unknown repo location: {location}") - -def build_release_tarball_url(location, owner, repo, tag): - if location == "github": - return f"https://github.com/{owner}/{repo}/archive/refs/tags/{tag}.tar.gz" - if location == "codeberg": - return f"https://codeberg.org/{owner}/{repo}/archive/{tag}.tar.gz" - raise ValueError(f"Unknown repo location: {location}") - -def update_entry(name, entry, gh_token=None, cb_token=None): - location = entry.get("location") - owner = entry.get("owner") - repo = entry.get("repo") - url = entry.get("url") - - if url and (location == "url" or location == "archive"): - # Direct URL source - unpack = is_archive_url(url) - new_hash = prefetch_url(url, unpack=unpack) - entry["hash"] = new_hash - return True - - if location in ("github", "codeberg"): - if entry.get("tag"): - # Use latest release tag - if location == "github": - rel = github_latest_release(owner, repo, token=gh_token) - tag = rel["tag_name"] - else: - rel = codeberg_latest_release(owner, repo, token=cb_token) - tag = rel["tag_name"] - if tag != entry["tag"]: - entry["tag"] = tag - tar_url = build_release_tarball_url(location, owner, repo, tag) - entry["hash"] = prefetch_url(tar_url, unpack=True) - return True - - if entry.get("rev"): - # Use latest commit - if location == "github": - sha = github_latest_commit(owner, repo, token=gh_token) - else: - sha = codeberg_latest_commit(owner, repo, token=cb_token) - if sha != entry["rev"]: - entry["rev"] = sha - repo_url = build_repo_url(location, owner, repo) - entry["hash"] = prefetch_git(repo_url, sha) - return True - - return False - -def process_file(path, gh_token=None, cb_token=None): - data = json.loads(path.read_text()) - changed = False - for name, entry in data.items(): - try: - changed = update_entry(name, entry, gh_token=gh_token, cb_token=cb_token) - except HTTPError as e: - print(f"[WARN] {path}: {name}: HTTP error {e.code}", file=sys.stderr) - except Exception as e: - print(f"[WARN] {path}: {name}: {e}", file=sys.stderr) - if changed: - path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n") - return changed - -def main(root): - gh_token = None - cb_token = None - # Optional tokens from environment - # import os - # gh_token = os.environ.get("GITHUB_TOKEN") - # cb_token = os.environ.get("CODEBERG_TOKEN") - - root = Path(root) - files = list(root.rglob("version*.json")) - if not files: - print("No version*.json files found") - return 1 - - updated = 0 - for f in files: - if process_file(f, gh_token=gh_token, cb_token=cb_token): - print(f"Updated: {f}") - updated += 1 - - print(f"Done. Updated {updated} file(s).") - return 0 - -if __name__ == "__main__": - if len(sys.argv) != 2: - print(f"Usage: {sys.argv[0]} ") - sys.exit(2) - sys.exit(main(sys.argv[1])) \ No newline at end of file