diff --git a/README.md b/README.md index 251ac9d..cb1e2c8 100755 --- a/README.md +++ b/README.md @@ -113,6 +113,17 @@ sudo nixos-rebuild switch --flake .#hostname home-manager switch --flake .#username@hostname ``` +## Documentation + +Comprehensive documentation is available in the [docs](./docs) directory: + +- [Getting Started](./docs/getting-started.md) - Instructions for setting up new systems +- [Architecture](./docs/architecture.md) - Overview of the repository structure +- [System Configurations](./docs/systems/README.md) - Details about each system +- [Home Assistant](./docs/home-assistant/README.md) - Home Assistant setup and automations +- [Custom Modules](./docs/modules/README.md) - Details about reusable configuration modules +- [Troubleshooting](./docs/troubleshooting.md) - Common issues and solutions + ## License This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..1573be3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,12 @@ +# Documentation + +This directory contains comprehensive documentation for the NixOS configuration. + +## Contents + +- [Getting Started](./getting-started.md) - Instructions for setting up new systems +- [System Configurations](./systems/README.md) - Detailed information about each system +- [Home Assistant](./home-assistant/README.md) - Documentation for the Home Assistant setup +- [Custom Modules](./modules/README.md) - Information about reusable modules +- [Architecture](./architecture.md) - Overview of the repository architecture +- [Troubleshooting](./troubleshooting.md) - Common issues and solutions \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..4ac3457 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,105 @@ +# Repository Architecture + +This document provides an overview of the repository architecture, explaining how the various components fit together. + +## Overview + +This NixOS configuration repository is built using [Nix Flakes](https://nixos.wiki/wiki/Flakes) and [Snowfall Lib](https://github.com/snowfallorg/lib) to provide a modular, maintainable configuration for multiple systems. + +## Directory Structure + +``` +. +├── checks/ # Pre-commit hooks and other checks +├── flake.nix # Main flake configuration +├── homes/ # Home-manager configurations for users +│ ├── aarch64-darwin/ # macOS home configurations +│ ├── aarch64-linux/ # ARM Linux home configurations +│ └── x86_64-linux/ # x86 Linux home configurations +├── modules/ # Reusable configuration modules +│ ├── home/ # Home-manager modules +│ └── nixos/ # NixOS system modules +│ ├── boot/ # Boot configuration modules +│ ├── desktop/ # Desktop environment modules +│ ├── hardware/ # Hardware-specific modules +│ ├── homeassistant/ # Home Assistant modules +│ ├── network/ # Network configuration modules +│ ├── services/ # Service configuration modules +│ └── ... # Other module categories +├── overlays/ # Nixpkgs overlays +├── packages/ # Custom package definitions +├── secrets/ # Encrypted secrets (managed with sops-nix) +└── systems/ # System-specific configurations + ├── aarch64-darwin/ # macOS system configurations + ├── aarch64-linux/ # ARM Linux system configurations + └── x86_64-linux/ # x86 Linux system configurations + ├── jallen-nas/ # NAS server configuration + ├── matt-nixos/ # Desktop configuration + ├── nuc-nixos/ # NUC configuration + ├── pi4/ # Raspberry Pi 4 configuration + └── ... # Other system configurations +``` + +## Flake Structure + +The `flake.nix` file defines the inputs (external dependencies) and outputs (configurations) of this repository: + +### Inputs + +- **nixpkgs-unstable**: The unstable channel of Nixpkgs +- **nixpkgs-stable**: The stable channel of Nixpkgs (25.11) +- **home-manager**: User environment management +- **snowfall-lib**: Library for structuring flake repositories +- **impermanence**: Persistent state management +- **lanzaboote**: Secure boot implementation +- **nixos-hardware**: Hardware-specific configurations +- **sops-nix**: Secret management +- **disko**: Disk partitioning and formatting +- **And more specialized inputs** + +### Outputs + +The outputs are generated using Snowfall Lib's `mkFlake` function, which automatically discovers and assembles: + +- **NixOS system configurations**: For each system in the `systems/` directory +- **Home Manager configurations**: For each configuration in the `homes/` directory +- **Packages**: From the `packages/` directory +- **Modules**: From the `modules/` directory +- **Overlays**: From the `overlays/` directory + +## Module System + +The module system uses a modular approach where: + +1. **Common modules** are defined in `modules/nixos/` and `modules/home/` +2. **System-specific modules** are defined in `systems///` + +Each module follows the NixOS module pattern, with: +- `default.nix`: Main module implementation +- `options.nix`: Option declarations + +## Integration with Snowfall Lib + +Snowfall Lib provides: +1. **Automatic discovery** of modules, overlays, and packages +2. **Consistent structure** across the repository +3. **Common utilities** for working with flakes + +## Secrets Management + +Secrets are managed using [sops-nix](https://github.com/Mic92/sops-nix), with: +- Encrypted secret files in the `secrets/` directory +- `.sops.yaml` configuration file in the root +- Key management integrated into the configuration + +## Deployment Process + +Systems are built and deployed using: +```bash +nixos-rebuild switch --flake .#hostname +``` + +This command: +1. Evaluates the flake for the specified hostname +2. Builds the resulting configuration +3. Activates it on the current system \ No newline at end of file diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..68d799c --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,172 @@ +# Getting Started + +This guide will help you get started with this NixOS configuration repository. + +## Prerequisites + +- Basic knowledge of NixOS and the Nix language +- Git installed on your system +- Physical access to the machine you want to configure + +## Initial Setup + +### 1. Cloning the Repository + +Clone this repository to your local machine: + +```bash +git clone ssh://nix-apps@localhost:2222/mjallen/nix-config.git +cd nix-config +``` + +### 2. Setting Up a New System + +#### Option 1: Using an Existing Configuration + +If you're setting up a new machine that should be identical to an existing configuration: + +1. Boot from a NixOS installation media +2. Mount your target partitions to `/mnt` +3. Clone this repository: + ```bash + nixos-enter + cd /mnt + mkdir -p /mnt/etc/nixos + git clone ssh://nix-apps@localhost:2222/mjallen/nix-config.git /mnt/etc/nixos + ``` +4. Install NixOS with the desired system profile: + ```bash + nixos-install --flake /mnt/etc/nixos#hostname + ``` + Replace `hostname` with the target system name (e.g., `matt-nixos`, `jallen-nas`, etc.) + +#### Option 2: Creating a New System Configuration + +If you're adding a completely new system: + +1. Create a new directory for your system configuration: + ```bash + mkdir -p systems/$(uname -m)-linux/new-hostname + ``` + +2. Create the basic configuration files: + ```bash + cat > systems/$(uname -m)-linux/new-hostname/default.nix << EOF + { lib, pkgs, ... }: + { + imports = [ + ./hardware-configuration.nix + # Add other needed module imports here + ]; + + networking.hostName = "new-hostname"; + + # Add your system-specific configuration here + } + EOF + ``` + +3. Generate the hardware configuration: + ```bash + nixos-generate-config --no-filesystems --dir systems/$(uname -m)-linux/new-hostname/ + ``` + +4. Add your new system to the flake by adding it to the `hosts` section in `flake.nix` + +5. Build and install the configuration: + ```bash + sudo nixos-rebuild switch --flake .#new-hostname + ``` + +## Secret Management + +### Setting Up Sops-Nix + +1. Create a GPG key if you don't already have one: + ```bash + gpg --full-generate-key + ``` + +2. Add your key to `.sops.yaml`: + ```bash + # Get your key fingerprint + gpg --list-secret-keys --keyid-format=long + + # Edit the .sops.yaml file to add your key + ``` + +3. Create a new encrypted secret: + ```bash + sops secrets/newsecret.yaml + ``` + +## Common Tasks + +### Updating the Repository + +```bash +git pull +sudo nixos-rebuild switch --flake .#hostname +``` + +### Adding a New Package + +1. For standard packages, add them to your system or home configuration: + ```nix + environment.systemPackages = with pkgs; [ + new-package + ]; + ``` + +2. For custom packages, add them to the `packages` directory: + ```bash + mkdir -p packages/new-package + # Create the necessary Nix files + ``` + +### Adding a New Module + +1. Create a new module directory: + ```bash + mkdir -p modules/nixos/new-module + ``` + +2. Create the module files: + ```bash + # Create options.nix + cat > modules/nixos/new-module/options.nix << EOF + { lib, namespace, ... }: + with lib; + { + options.${namespace}.new-module = { + enable = mkEnableOption "Enable new module"; + # Add other options here + }; + } + EOF + + # Create default.nix + cat > modules/nixos/new-module/default.nix << EOF + { config, lib, namespace, ... }: + let + cfg = config.${namespace}.new-module; + in + { + imports = [ ./options.nix ]; + + config = lib.mkIf cfg.enable { + # Add your configuration here + }; + } + EOF + ``` + +3. Import your module in your system configuration: + ```nix + imports = [ + # ... + ../../../modules/nixos/new-module + ]; + + ${namespace}.new-module.enable = true; + ``` \ No newline at end of file diff --git a/docs/home-assistant/README.md b/docs/home-assistant/README.md new file mode 100644 index 0000000..f01c2df --- /dev/null +++ b/docs/home-assistant/README.md @@ -0,0 +1,188 @@ +# Home Assistant Configuration + +This document provides comprehensive information about the Home Assistant setup in this NixOS configuration. + +## Overview + +Home Assistant is configured as a NixOS service with custom components, integrations, and automations. The configuration uses a modular approach with separate files for different aspects of the setup. + +## Module Structure + +The Home Assistant configuration is organized in the following structure: + +``` +modules/nixos/homeassistant/ +├── automations/ # Automation configurations +│ ├── lightswitch/ # Light switch automations +│ └── motion-light/ # Motion-activated light automations +├── default.nix # Main module configuration +├── options.nix # Module options definition +└── services/ # Related service configurations + ├── govee2mqtt/ # Govee integration via MQTT + ├── homeassistant/ # Core Home Assistant service + ├── music-assistant/ # Music Assistant integration + ├── thread/ # Thread border router + └── zigbee2mqtt/ # Zigbee to MQTT bridge +``` + +## Installation + +The Home Assistant module is enabled in the system configuration by setting: + +```nix +mjallen.services.home-assistant.enable = true; +``` + +This activates Home Assistant and related services such as MQTT, Zigbee2MQTT, and the Matter server. + +## Configuration Options + +The module provides several configuration options: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enable` | boolean | `false` | Enable Home Assistant and related services | +| `mosquittoPort` | integer | `1883` | Port for the MQTT broker | +| `zigbee2mqttPort` | integer | `8080` | Port for the Zigbee2MQTT web interface | +| `zigbeeDevicePath` | string | `/dev/ttyUSB0` | Path to the Zigbee USB device | + +## Core Services + +### Home Assistant + +The main Home Assistant service is configured in `services/homeassistant/default.nix` with: + +- PostgreSQL database backend +- Custom components +- Custom Lovelace modules +- HTTPS access with authentication +- Integration with other services + +### MQTT + +MQTT is used as a messaging protocol for various smart home devices. The Mosquitto MQTT broker is automatically configured when Home Assistant is enabled. + +### Zigbee2MQTT + +Zigbee2MQTT allows integration with Zigbee devices. It's configured with: + +- Automatic discovery for Home Assistant +- OTA updates for Zigbee devices +- Web interface for management + +### Thread Border Router + +The Thread Border Router provides integration with Thread-based devices like Matter devices. + +## Custom Components + +The following custom components are included: + +- `ha-anycubic` - Anycubic 3D printer integration +- `ha-bambulab` - Bambu Lab 3D printer integration +- `ha-bedjet` - BedJet climate control integration +- `ha-gehome` - GE Home appliance integration +- `ha-icloud3` - Enhanced iCloud device tracking +- `ha-local-llm` - Local LLM integration +- `ha-mail-and-packages` - Mail and package delivery tracking +- `ha-nanokvm` - NanoKVM integration +- `ha-openhasp` - openHASP integration for DIY displays +- `ha-overseerr` - Overseerr media request integration +- `ha-petlibro` - PetLibro pet feeder integration +- `ha-wyzeapi` - Wyze device integration + +## Automations + +### Light Switch Automations + +The light switch automations handle physical switch inputs for controlling smart lights. + +### Motion Light Automations + +Motion light automations turn lights on when motion is detected and off after a period of inactivity. + +### Custom Automations + +Additional automations are placed in the `/etc/hass` directory and are included in the Home Assistant configuration. These include: + +- `fountain_automation.yaml` - Toggles the water dispensing mode on the Dockstream Smart RFID Fountain every 15 minutes between constant and intermittent flow. + +## Smart Home Devices + +The configuration includes support for various smart home devices: + +### Lighting + +- Various smart lights throughout the home + +### Climate + +- Smart thermostat +- Humidifier control + +### Pet Care + +- Dockstream Smart RFID Fountain with scheduling +- Smart pet feeders for pets named Joey and Luci +- Litter-Robot 4 smart litter box + +### Media + +- Google Cast devices +- Smart TVs +- Media players + +### Sensors + +- Temperature, humidity, and motion sensors +- Door and window sensors +- Presence detection + +## Integration with Other Services + +Home Assistant is integrated with: + +- **Music Assistant** - For enhanced music streaming capabilities +- **Govee Integration** - For Govee smart devices +- **Matter** - For Matter-compatible devices + +## Adding New Automations + +To add a new automation: + +1. Create a YAML file with the automation definition +2. Place it in `/etc/hass` +3. The automation will be automatically included in Home Assistant + +Example automation format: + +```yaml +alias: "Automation Name" +description: "Description of what the automation does" +trigger: + - platform: state + entity_id: binary_sensor.motion_sensor + to: "on" +condition: [] +action: + - service: light.turn_on + target: + entity_id: light.living_room +mode: single +``` + +## Troubleshooting + +### Common Issues + +1. **Zigbee Device Pairing Issues** + - Make sure the Zigbee coordinator is properly connected + - Check the Zigbee2MQTT logs for errors + +2. **Service Unavailable** + - Check if all related services are running + - Verify firewall rules allow access to the services + +3. **Database Issues** + - Check PostgreSQL service status + - Verify database connection settings \ No newline at end of file diff --git a/docs/home-assistant/automations.md b/docs/home-assistant/automations.md new file mode 100644 index 0000000..6906bee --- /dev/null +++ b/docs/home-assistant/automations.md @@ -0,0 +1,148 @@ +# Home Assistant Automations + +This document details the automations configured in the Home Assistant setup. + +## Automation Types + +Automations in this configuration are managed in several ways: + +1. **Module-Based Automations**: Defined in Nix modules within the `modules/nixos/homeassistant/automations/` directory +2. **YAML Automations**: Defined in YAML files and included via the `automation manual` directive +3. **UI-Created Automations**: Created through the Home Assistant UI and stored in `automations.yaml` + +## Module-Based Automations + +### Light Switch Automations + +**Location**: `modules/nixos/homeassistant/automations/lightswitch/` + +These automations link physical light switches to smart lights: + +- **Bedroom Light Switch**: Controls the bedroom lights +- **Living Room Light Switch**: Controls the living room lights +- **Bedroom Closet Lights**: Controls the closet lights + +### Motion-Activated Light Automations + +**Location**: `modules/nixos/homeassistant/automations/motion-light/` + +These automations turn lights on when motion is detected and off after a period of inactivity. + +## YAML Automations + +### Fountain Cycling Automation + +**Location**: `/etc/nixos/fountain_automation.yaml` + +This automation toggles the water dispensing mode on the Dockstream Smart RFID Fountain every 15 minutes: + +```yaml +alias: "Fountain Cycle Mode" +description: "Toggles fountain water mode every 15 minutes between constant and intermittent flow" +trigger: + - platform: time_pattern + minutes: "/15" # Every 15 minutes +condition: [] +action: + - service: select.select_next + target: + entity_id: select.dockstream_smart_rfid_fountain_water_dispensing_mode +mode: single +id: fountain_cycle_mode +``` + +This automation: +1. Triggers every 15 minutes +2. Uses the `select.select_next` service to toggle between the two available options: + - "Flowing Water (Constant)" + - "Intermittent Water (Scheduled)" + +The fountain is also configured with: +- Water Interval: 10 minutes +- Water Dispensing Duration: 15 minutes + +## Creating New Automations + +### Method 1: Module-Based Automation + +For reusable, complex automations that should be managed in code: + +1. Create a new directory in `modules/nixos/homeassistant/automations/` +2. Create a `default.nix` file with the automation logic + +Example: +```nix +{ config, lib, ... }: +{ + config = { + services.home-assistant.config."automation manual" = [ + { + alias = "Example Automation"; + description = "Example automation created via Nix module"; + trigger = [ + { + platform = "state"; + entity_id = "binary_sensor.example_sensor"; + to = "on"; + } + ]; + action = [ + { + service = "light.turn_on"; + target.entity_id = "light.example_light"; + } + ]; + mode = "single"; + } + ]; + }; +} +``` + +### Method 2: YAML Automation + +For simpler automations: + +1. Create a YAML file with the automation definition +2. Place it in `/etc/hass/` + +Example: +```yaml +alias: "Example Automation" +description: "Example automation in YAML" +trigger: + - platform: state + entity_id: binary_sensor.example_sensor + to: "on" +action: + - service: light.turn_on + target: + entity_id: light.example_light +mode: single +``` + +### Method 3: UI Creation + +For quick prototyping or simple automations: + +1. Go to Home Assistant UI > Settings > Automations & Scenes +2. Click "+ Add Automation" +3. Configure using the UI editor + +## Testing Automations + +To test an automation: + +1. In the Home Assistant UI, go to Developer Tools > Services +2. Select `automation.trigger` as the service +3. Enter the entity_id of your automation in the service data field +4. Click "Call Service" to trigger the automation manually + +## Troubleshooting + +If an automation isn't working as expected: + +1. Check the Home Assistant logs for errors +2. Verify entity names and service calls are correct +3. Test individual triggers and actions separately +4. Use the "Debug" section in the automation editor to trace execution \ No newline at end of file diff --git a/docs/home-assistant/fountain-automation.md b/docs/home-assistant/fountain-automation.md new file mode 100644 index 0000000..724743f --- /dev/null +++ b/docs/home-assistant/fountain-automation.md @@ -0,0 +1,96 @@ +# Pet Fountain Automation + +This document details the automation for the Dockstream Smart RFID Fountain device. + +## Overview + +The Dockstream Smart RFID Fountain is a smart pet fountain controlled through Home Assistant. A custom automation has been created to toggle the water dispensing mode between constant flow and intermittent flow every 15 minutes. This cycling helps keep the water fresh while reducing energy consumption. + +## Fountain Configuration + +The Dockstream Smart RFID Fountain has the following settings in Home Assistant: + +| Setting | Entity ID | Value | Description | +|---------|-----------|-------|-------------| +| Water Dispensing Mode | `select.dockstream_smart_rfid_fountain_water_dispensing_mode` | Toggles between modes | Controls how water flows | +| Water Interval | `number.dockstream_smart_rfid_fountain_water_interval` | 10 minutes | Time between water dispensing in intermittent mode | +| Water Dispensing Duration | `number.dockstream_smart_rfid_fountain_water_dispensing_duration` | 15 minutes | How long water flows in intermittent mode | +| Cleaning Cycle | `number.dockstream_smart_rfid_fountain_cleaning_cycle` | 14 days | Reminder interval for cleaning | + +## Available Modes + +The fountain supports two water dispensing modes: + +1. **Flowing Water (Constant)** - Water flows continuously +2. **Intermittent Water (Scheduled)** - Water flows according to the interval and duration settings + +## Automation Details + +The fountain cycling automation is defined in `/etc/nixos/fountain_automation.yaml`: + +```yaml +alias: "Fountain Cycle Mode" +description: "Toggles fountain water mode every 15 minutes between constant and intermittent flow" +trigger: + - platform: time_pattern + minutes: "/15" # Every 15 minutes +condition: [] +action: + - service: select.select_next + target: + entity_id: select.dockstream_smart_rfid_fountain_water_dispensing_mode +mode: single +id: fountain_cycle_mode +``` + +### How It Works + +1. **Trigger**: The automation runs every 15 minutes based on the time pattern trigger +2. **Action**: It uses the `select.select_next` service to toggle to the next available option +3. **Mode**: Set to "single" to prevent multiple executions if triggers overlap + +## Installation + +The automation is included in Home Assistant via the `automation manual` directive in the Home Assistant configuration: + +```yaml +"automation manual" = "!include_dir_merge_list /etc/hass"; +``` + +The YAML file needs to be placed in the `/etc/hass` directory to be loaded. + +## Testing + +To manually test the automation: + +1. In Home Assistant UI, go to Developer Tools > Services +2. Select `automation.trigger` as the service +3. Enter the following service data: + ```yaml + entity_id: automation.fountain_cycle_mode + ``` +4. Click "Call Service" to trigger the automation + +## Customizing + +To adjust the cycling interval: + +1. Edit the YAML file at `/etc/nixos/fountain_automation.yaml` +2. Change the `minutes` value in the trigger section (e.g., from `"/15"` to `"/30"` for every 30 minutes) +3. Save the file +4. Restart Home Assistant or reload automations + +To adjust fountain settings: + +1. In Home Assistant UI, go to Settings > Devices & Services +2. Find the Dockstream Smart RFID Fountain device +3. Adjust the water interval or dispensing duration settings + +## Troubleshooting + +If the automation is not working as expected: + +1. Check that the entity ID is correct and the fountain is online +2. Verify that Home Assistant is including the automation file correctly +3. Look for errors in the Home Assistant logs related to the automation or the fountain +4. Try manually controlling the fountain to ensure it responds to commands \ No newline at end of file diff --git a/docs/modules/README.md b/docs/modules/README.md new file mode 100644 index 0000000..f45d021 --- /dev/null +++ b/docs/modules/README.md @@ -0,0 +1,116 @@ +# Custom Modules + +This directory contains documentation for the custom modules used in this NixOS configuration. + +## Module Types + +The repository uses two main types of modules: + +1. **NixOS Modules** - System-level configurations in `modules/nixos/` +2. **Home Manager Modules** - User-level configurations in `modules/home/` + +## NixOS Modules + +These modules configure the system-level aspects of NixOS: + +- [Boot Modules](./boot.md) - Boot loader and kernel configurations +- [Desktop Modules](./desktop.md) - Desktop environment configurations +- [Development Modules](./development.md) - Development tools and environments +- [Hardware Modules](./hardware.md) - Hardware-specific configurations +- [Home Assistant Modules](./homeassistant.md) - Home automation configuration +- [Networking Modules](./network.md) - Network configuration and services +- [Security Modules](./security.md) - Security-related configurations +- [Services Modules](./services.md) - Various service configurations +- [System Modules](./system.md) - General system configurations +- [Virtualization Modules](./virtualization.md) - Virtualization and containerization + +## Home Manager Modules + +These modules configure user environments: + +- [Applications](./home/applications.md) - User applications +- [Desktop](./home/desktop.md) - User desktop environments +- [Development](./home/development.md) - User development environments +- [Media](./home/media.md) - Media applications +- [Shell](./home/shell.md) - Shell configurations + +## Module Structure + +Each module follows a standard structure: + +``` +modules/nixos/example-module/ +├── default.nix # Main implementation +├── options.nix # Option declarations +└── submodule/ # Optional submodules + └── default.nix # Submodule implementation +``` + +### default.nix + +The `default.nix` file contains the main implementation of the module: + +```nix +{ + config, + lib, + pkgs, + namespace, + ... +}: +let + cfg = config.${namespace}.example-module; +in +{ + imports = [ ./options.nix ]; + + config = lib.mkIf cfg.enable { + # Module implementation when enabled + }; +} +``` + +### options.nix + +The `options.nix` file declares the module's configuration options: + +```nix +{ lib, namespace, ... }: +with lib; +let + inherit (lib.${namespace}) mkOpt; +in +{ + options.${namespace}.example-module = { + enable = mkEnableOption "enable example module"; + # Other option declarations + }; +} +``` + +## Using Modules + +To use a module in your system configuration: + +1. Enable the module in your system configuration: + +```nix +{ config, ... }: +{ + mjallen.example-module = { + enable = true; + # Other options + }; +} +``` + +## Creating New Modules + +To create a new module: + +1. Create a new directory in `modules/nixos/` or `modules/home/` +2. Create `default.nix` and `options.nix` files +3. Implement your module functionality +4. Import the module in your system configuration + +See the [Getting Started](../getting-started.md) guide for more details on creating modules. \ No newline at end of file diff --git a/docs/modules/homeassistant.md b/docs/modules/homeassistant.md new file mode 100644 index 0000000..d71ab82 --- /dev/null +++ b/docs/modules/homeassistant.md @@ -0,0 +1,190 @@ +# Home Assistant Module + +This document details the Home Assistant module configuration. + +## Module Structure + +The Home Assistant module is organized in the following structure: + +``` +modules/nixos/homeassistant/ +├── automations/ # Automation configurations +│ ├── lightswitch/ # Light switch automations +│ └── motion-light/ # Motion-activated light automations +├── default.nix # Main module configuration +├── options.nix # Module options definition +└── services/ # Related service configurations + ├── govee2mqtt/ # Govee integration via MQTT + ├── homeassistant/ # Core Home Assistant service + ├── music-assistant/ # Music Assistant integration + ├── thread/ # Thread border router + └── zigbee2mqtt/ # Zigbee to MQTT bridge +``` + +## Module Options + +The module is configured through options defined in `options.nix`: + +```nix +options.${namespace}.services.home-assistant = { + enable = mkEnableOption "enable home-assistant"; + mosquittoPort = mkOpt types.int 1883 "Port for MQTT"; + zigbee2mqttPort = mkOpt types.int 8080 "Port for zigbee2mqtt web interface"; + zigbeeDevicePath = mkOpt types.str "/dev/ttyUSB0" "Path to zigbee usb device"; +}; +``` + +## Main Configuration + +The main module configuration in `default.nix` includes: + +1. **Activation Scripts** - For setting up custom components +2. **Service Configurations** - For Matter, PostgreSQL, etc. +3. **Firewall Rules** - For allowing required ports + +```nix +config = lib.mkIf cfg.enable { + # Activation script for custom components + system.activationScripts.installCustomComponents = '' + chown -R hass:hass ${config.services.home-assistant.configDir} + chmod -R 750 ${config.services.home-assistant.configDir} + ''; + + # Service configurations + services = { + matter-server.enable = true; + postgresql = { + enable = false; + ensureDatabases = [ "hass" ]; + ensureUsers = [ + { + name = "hass"; + ensureDBOwnership = true; + } + ]; + }; + }; + + # Firewall rules + networking.firewall.allowedTCPPorts = [ + cfg.mosquittoPort + cfg.zigbee2mqttPort + 8095 # music-assistant + 8097 # home-assistant + 5580 # matter-server + ]; +}; +``` + +## Home Assistant Service + +The core Home Assistant service configuration in `services/homeassistant/default.nix` includes: + +1. **Package Selection** - Using the standard Home Assistant package +2. **Component Configuration** - Enabling required components +3. **Custom Components** - Adding custom components from packages +4. **Lovelace Modules** - Adding custom UI components +5. **Integration Configuration** - Setting up integrations with other systems + +```nix +services.home-assistant = { + enable = true; + package = pkgs.home-assistant; + openFirewall = true; + configDir = "/var/lib/homeassistant"; + configWritable = true; + + # Components + extraComponents = [ + "mqtt" + "zha" + "homekit" + # ... many more components + ]; + + # Custom components + customComponents = [ + # ... custom components + ]; + + # Lovelace modules + customLovelaceModules = [ + # ... custom UI modules + ]; + + # Configuration + config = { + # ... Home Assistant configuration + }; +}; +``` + +## Related Services + +### Zigbee2MQTT + +The Zigbee2MQTT service in `services/zigbee2mqtt/default.nix` connects Zigbee devices to MQTT: + +```nix +services.zigbee2mqtt = { + enable = true; + settings = { + mqtt = { + server = "mqtt://localhost:${toString cfg.mosquittoPort}"; + }; + serial = { + port = cfg.zigbeeDevicePath; + }; + # ... additional settings + }; +}; +``` + +### MQTT + +MQTT is configured as a dependency for the Home Assistant module. + +### Thread Border Router + +The Thread Border Router in `services/thread/default.nix` provides Thread network connectivity for Matter devices. + +## Automations + +The module includes predefined automations in the `automations/` directory: + +1. **Light Switch Automations** - For controlling lights via physical switches +2. **Motion Light Automations** - For motion-activated lighting + +## Using the Module + +To use this module in a system configuration: + +```nix +{ config, ... }: +{ + mjallen.services.home-assistant = { + enable = true; + # Optional: customize ports and device paths + mosquittoPort = 1883; + zigbee2mqttPort = 8080; + zigbeeDevicePath = "/dev/ttyUSB0"; + }; +} +``` + +## Extending the Module + +### Adding Custom Components + +To add a custom component: + +1. Add the package to `packages/` +2. Add it to the `customComponents` list in `services/homeassistant/default.nix` + +### Adding Custom Automations + +To add a custom automation: + +1. Create a new directory in `automations/` +2. Implement the automation in `default.nix` +3. Import it in the system configuration \ No newline at end of file diff --git a/docs/systems/README.md b/docs/systems/README.md new file mode 100644 index 0000000..560e272 --- /dev/null +++ b/docs/systems/README.md @@ -0,0 +1,23 @@ +# System Configurations + +This directory contains documentation for each system configuration in this repository. + +## Systems + +- [Desktop (matt-nixos)](./matt-nixos.md) - Main desktop computer +- [NAS (jallen-nas)](./jallen-nas.md) - Home server and NAS +- [NUC (nuc-nixos)](./nuc-nixos.md) - Intel NUC +- [Raspberry Pi 4](./pi4.md) - Raspberry Pi 4 +- [Raspberry Pi 5](./pi5.md) - Raspberry Pi 5 +- [MacBook Pro (nixOS)](./macbook-pro-nixos.md) - MacBook Pro running NixOS + +## Common Configuration + +All systems share certain common configurations through the modules system. These include: + +- Base system configuration +- User management +- Network configuration +- Security settings + +Each system then adds its specific configurations on top of these common modules. \ No newline at end of file diff --git a/docs/systems/jallen-nas.md b/docs/systems/jallen-nas.md new file mode 100644 index 0000000..00c749a --- /dev/null +++ b/docs/systems/jallen-nas.md @@ -0,0 +1,101 @@ +# NAS Server (jallen-nas) + +This document describes the configuration for the NAS server system. + +## Hardware + +The NAS server is built on AMD hardware: + +- CPU: AMD processor +- Hardware-specific modules: + - `nixos-hardware.nixosModules.common-pc` + - `nixos-hardware.nixosModules.common-cpu-amd` + - `nixos-hardware.nixosModules.common-cpu-amd-pstate` + - `nixos-hardware.nixosModules.common-hidpi` + +## Services + +The NAS hosts various services: + +### Media Services + +- **Jellyfin** - Media server +- **Jellyseerr** - Media request manager +- **Sonarr** - TV show management +- **Radarr** - Movie management +- **Lidarr** - Music management +- **Bazarr** - Subtitle management +- **Music Assistant** - Music streaming integration with Home Assistant + +### Download Services + +- **Transmission** - Torrent client +- **NZBGet** - Usenet downloader +- **Prowlarr** - Indexer manager + +### Document Management + +- **Paperless-ngx** - Document management system + +### File Sharing + +- **Samba** - Windows file sharing +- **Nextcloud** - Self-hosted cloud storage + +### AI Services + +- **Ollama** - Local AI model hosting + +### Smart Home + +- **Home Assistant** - Smart home controller +- **Zigbee2MQTT** - Zigbee device integration +- **MQTT** - Message broker for IoT devices +- **Thread Border Router** - Thread network for smart home devices + +## Storage Configuration + +The NAS uses multiple storage devices: + +1. **System Drive** - For the operating system +2. **Data Drives** - Configured as a storage array for media and data + +## Network Configuration + +The NAS is configured with: + +- Static IP address +- Firewall rules for the various services +- Tailscale for secure remote access + +## Backup Strategy + +The NAS implements a comprehensive backup strategy: + +1. **System Backup** - Regular backups of the NixOS configuration +2. **Data Backup** - Backups of important data to secondary storage +3. **Off-site Backup** - Critical data is backed up off-site + +## Usage and Management + +### Accessing Services + +Most services are available through a reverse proxy, which provides: +- HTTPS access +- Authentication via Authentik +- Subdomain-based routing + +### Adding Storage + +To add additional storage to the NAS: + +1. Add the physical drive to the system +2. Update the disko configuration +3. Rebuild the system with `nixos-rebuild switch` + +### Monitoring + +The system can be monitored through: +- Prometheus metrics +- Grafana dashboards +- Home Assistant sensors \ No newline at end of file diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..8c80117 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,213 @@ +# Troubleshooting Guide + +This guide provides solutions for common issues that may arise when using this NixOS configuration. + +## System Issues + +### Failed System Build + +**Problem**: `nixos-rebuild switch` fails with an error. + +**Solutions**: + +1. **Syntax Errors**: + - Check the error message for file and line number information + - Verify the syntax in the mentioned file + - Common issues include missing semicolons, curly braces, or mismatched quotes + +2. **Missing Dependencies**: + - If the error mentions a missing package or dependency: + ``` + git pull # Update to the latest version + nix flake update # Update the flake inputs + ``` + +3. **Conflicting Modules**: + - Look for modules that might be configuring the same options incompatibly + - Disable one of the conflicting modules or adjust their configurations + +4. **Disk Space Issues**: + - Check available disk space with `df -h` + - Clear old generations: `sudo nix-collect-garbage -d` + +### Boot Issues + +**Problem**: System fails to boot after a configuration change. + +**Solutions**: + +1. **Boot into a Previous Generation**: + - At the boot menu, select an older generation + - Once booted, revert the problematic change: + ``` + cd /etc/nixos + git revert HEAD # Or edit the files directly + sudo nixos-rebuild switch + ``` + +2. **Boot from Installation Media**: + - Boot from a NixOS installation media + - Mount your system: + ``` + sudo mount /dev/disk/by-label/nixos /mnt + sudo mount /dev/disk/by-label/boot /mnt/boot # If separate boot partition + ``` + - Chroot into your system: + ``` + sudo nixos-enter --root /mnt + cd /etc/nixos + git revert HEAD # Or edit the files directly + nixos-rebuild switch --install-bootloader + ``` + +## Home Assistant Issues + +### Home Assistant Fails to Start + +**Problem**: Home Assistant service fails to start. + +**Solutions**: + +1. **Check Service Status**: + ``` + systemctl status home-assistant + journalctl -u home-assistant -n 100 + ``` + +2. **Database Issues**: + - Check PostgreSQL is running: `systemctl status postgresql` + - Verify database connection settings in Home Assistant configuration + +3. **Permission Issues**: + - Check ownership and permissions on config directory: + ``` + ls -la /var/lib/homeassistant + sudo chown -R hass:hass /var/lib/homeassistant + sudo chmod -R 750 /var/lib/homeassistant + ``` + +4. **Custom Component Issues**: + - Try disabling custom components to isolate the issue: + - Edit `modules/nixos/homeassistant/services/homeassistant/default.nix` + - Comment out the `customComponents` section + - Rebuild: `sudo nixos-rebuild switch` + +### Zigbee Device Connection Issues + +**Problem**: Zigbee devices fail to connect or are unstable. + +**Solutions**: + +1. **Verify Device Path**: + - Check the Zigbee coordinator is properly detected: + ``` + ls -la /dev/ttyUSB* + ``` + - Update the device path if needed: + - Edit your system configuration + - Set `mjallen.services.home-assistant.zigbeeDevicePath` to the correct path + - Rebuild: `sudo nixos-rebuild switch` + +2. **Interference Issues**: + - Move the Zigbee coordinator away from other wireless devices + - Try a USB extension cable to improve positioning + - Change Zigbee channel in Zigbee2MQTT configuration + +3. **Reset Zigbee2MQTT**: + ``` + systemctl restart zigbee2mqtt + ``` + +### Automation Issues + +**Problem**: Automations don't run as expected. + +**Solutions**: + +1. **Check Automation Status**: + - In Home Assistant UI, verify the automation is enabled + - Check Home Assistant logs for automation execution errors + +2. **Entity Issues**: + - Verify entity IDs are correct + - Check if entities are available/connected + - Test direct service calls to verify entity control works + +3. **Trigger Issues**: + - Test the automation manually via Developer Tools > Services + - Use `automation.trigger` service with the automation's entity_id + +## Flake Issues + +### Flake Input Update Errors + +**Problem**: `nix flake update` fails or causes issues. + +**Solutions**: + +1. **Selective Updates**: + - Update specific inputs instead of all at once: + ``` + nix flake lock --update-input nixpkgs + ``` + +2. **Rollback Flake Lock**: + - If an update causes issues, revert to previous flake.lock: + ``` + git checkout HEAD^ -- flake.lock + ``` + +3. **Pin to Specific Revisions**: + - In `flake.nix`, pin problematic inputs to specific revisions: + ```nix + nixpkgs-stable.url = "github:NixOS/nixpkgs/5233fd2ba76a3accb05f88b08917450363be8899"; + ``` + +## Secret Management Issues + +### Sops Decryption Errors + +**Problem**: Sops fails to decrypt secrets. + +**Solutions**: + +1. **Key Issues**: + - Verify your GPG key is available and unlocked + - Check `.sops.yaml` includes your key fingerprint + +2. **Permission Issues**: + - Check file permissions on secret files + - Make sure the user running `nixos-rebuild` has access to the GPG key + +## Network Issues + +### Firewall Blocks Services + +**Problem**: Services are not accessible due to firewall rules. + +**Solutions**: + +1. **Check Firewall Status**: + ``` + sudo nix-shell -p iptables --run "iptables -L" + ``` + +2. **Verify Firewall Configuration**: + - Check if ports are properly allowed in the configuration + - Add missing ports if necessary + +3. **Temporary Disable Firewall** (for testing only): + ``` + sudo systemctl stop firewall + # After testing + sudo systemctl start firewall + ``` + +## Getting Help + +If you encounter an issue not covered in this guide: + +1. Check the NixOS Wiki: https://nixos.wiki/ +2. Search the NixOS Discourse forum: https://discourse.nixos.org/ +3. Join the NixOS Matrix/Discord community for real-time help +4. File an issue in the repository if you believe you've found a bug \ No newline at end of file diff --git a/fountain_automation.yaml b/fountain_automation.yaml new file mode 100644 index 0000000..c9805af --- /dev/null +++ b/fountain_automation.yaml @@ -0,0 +1,12 @@ +alias: "Fountain Cycle Mode" +description: "Toggles fountain water mode every 15 minutes between constant and intermittent flow" +trigger: + - platform: time_pattern + minutes: "/15" # Every 15 minutes +condition: [] +action: + - service: select.select_next + target: + entity_id: select.dockstream_smart_rfid_fountain_water_dispensing_mode +mode: single +id: fountain_cycle_mode \ No newline at end of file diff --git a/homes/x86_64-linux/admin@jallen-nas/default.nix b/homes/x86_64-linux/admin@jallen-nas/default.nix index f9dd787..a447193 100755 --- a/homes/x86_64-linux/admin@jallen-nas/default.nix +++ b/homes/x86_64-linux/admin@jallen-nas/default.nix @@ -1,4 +1,4 @@ -{ pkgs, namespace, ... }: +{ config, pkgs, namespace, ... }: { home = { username = "admin"; @@ -6,6 +6,10 @@ with pkgs; [ heroic + python3 + python3Packages.requests + python3Packages.mcp + jq ] ++ (with pkgs.${namespace}; [ moondeck-buddy @@ -52,6 +56,13 @@ }; programs = { + bash = { + shellAliases = { + "llama-status" = + "curl -s http://localhost:8127/health 2>/dev/null && echo 'LLaMA.cpp server is running' || echo 'LLaMA.cpp server is not responding'"; + }; + }; + neovim = { enable = true; viAlias = true; @@ -83,5 +94,75 @@ }; }; }; + + opencode = { + enable = true; + enableMcpIntegration = true; + settings = { + provider = { + nas = { + npm = "@ai-sdk/openai-compatible"; + name = "llama-server (local)"; + options = { + baseURL = "http://jallen-nas.local:8127/v1"; + }; + models = { + Qwen3-Coder-Next-Q4_0 = { + name = "Qwen3 Coder (local)"; + modalities = { + input = [ + "image" + "text" + ]; + output = [ "text" ]; + }; + limit = { + context = 262144; + output = 262144; + }; + }; + # "GLM-4.7-Flash-REAP-23B-A3B-UD-Q3_K_XL": { + # "name": "GLM 4.7 Flash (local)", + # "modalities": { "input": ["image", "text"], "output": ["text"] }, + # "limit": { + # "context": 262144, + # "output": 262144 + # } + # }; + # "Nemotron-3-Nano-30B-A3B-IQ4_XS": { + # "name": "Nemotron-3-Nano (local)", + # "modalities": { "input": ["image", "text"], "output": ["text"] }, + # "limit": { + # "context": 262144, + # "output": 262144 + # } + # }; + }; + }; + }; + }; + }; + + mcp = { + enable = true; + servers = { + nixos = { + command = "nix"; + args = [ + "run" + "github:utensils/mcp-nixos" + "--" + ]; + }; + hass-mcp = { + command = "uvx"; + args = [ "hass-mcp" ]; + env = { + HA_URL = "http://nuc-nixos.local:8123"; + HA_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI1ZDM2MTliNWNjMGY0ZGI2OWQzOTQ4Mjk0ZDFmNjAxMCIsImlhdCI6MTc3MDc2MjA1NywiZXhwIjoyMDg2MTIyMDU3fQ.P52jeX8GQcdGdzpbU3NCWZMUjkJZHFnOeR8--jy9dF8"; + }; + }; + }; + }; }; } diff --git a/modules/nixos/services/ai/default.nix b/modules/nixos/services/ai/default.nix index 4dd085c..d6c0b4e 100755 --- a/modules/nixos/services/ai/default.nix +++ b/modules/nixos/services/ai/default.nix @@ -7,12 +7,11 @@ }: with lib; let - name = "ai"; - cfg = config.${namespace}.services.${name}; + cfg = config.${namespace}.services.ai; aiConfig = lib.${namespace}.mkModule { - inherit config name; - serviceName = "open-webui"; # todo multiple? + inherit config; + name = "ai"; description = "AI Services"; options = { }; moduleConfig = { @@ -43,14 +42,25 @@ let "--seed" "3407" "--temp" - "1.0" + "0.7" "--top-p" - "0.95" + "0.9" "--min-p" - "0.01" + "0.05" "--top-k" - "40" + "30" "--jinja" + "--ctx-size" + "4096" + "--threads" + "8" + "--batch-size" + "512" + "--gpu-layers" + "999" + "--flash-attn" + "auto" + "--mlock" ]; }; @@ -79,16 +89,52 @@ let }; }; }; + + # Model update script using HuggingFace Hub + environment.systemPackages = with pkgs; [ + amdgpu_top + python3Packages.huggingface-hub + ]; + + # Systemd service for automatic model updates + systemd.services.update-qwen-model = { + description = "Update Qwen3-Coder-Next model from HuggingFace"; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${pkgs.writeShellScript "update-qwen-model" '' + set -euo pipefail + + MODEL_DIR="${cfg.configDir}/llama-cpp/models" + MODEL_NAME="Qwen3-Coder-Next-Q4_0.gguf" + REPO_ID="unsloth/Qwen3-Coder-Next-GGUF" + + # Create model directory if it doesn't exist + mkdir -p "$MODEL_DIR" + + # Download the latest version of the model + echo "Updating $MODEL_NAME from HuggingFace..." + ${pkgs.python3Packages.huggingface-hub}/bin/huggingface-cli download \ + "$REPO_ID" \ + "$MODEL_NAME" \ + --local-dir "$MODEL_DIR" + + echo "Model updated successfully" + ''}"; + User = "nix-apps"; + Group = "jallen-nas"; + }; + # Run daily at 3 AM + startAt = "*-*-* 03:00:00"; + }; + + # Ensure model is available before llama-cpp starts + systemd.services.llama-cpp = { + after = [ "update-qwen-model.service" ]; + wants = [ "update-qwen-model.service" ]; + }; }; }; in { imports = [ aiConfig ]; - - config = lib.mkIf cfg.enable { - environment.systemPackages = with pkgs; [ - amdgpu_top - python3Packages.huggingface-hub - ]; - }; } diff --git a/scripts/README_tui.md b/scripts/README_tui.md new file mode 100644 index 0000000..ac6c2d6 --- /dev/null +++ b/scripts/README_tui.md @@ -0,0 +1,315 @@ +# Version TUI Documentation + +## Overview + +The `version_tui.py` script is an interactive terminal interface for managing package versions in a NixOS repository. It provides a unified way to update version.json files, Python packages, and Home Assistant components. + +## Architecture + +### Core Components + +1. **Package Discovery**: Scans the repository for different package types +2. **Version Management**: Handles version.json, Python default.nix, and Home Assistant components +3. **Hash Computation**: Uses modern Nix commands for reliable hash generation +4. **Interactive Interface**: Curses-based TUI with color support and keyboard navigation + +## Package Types Supported + +### 1. Regular Packages (`packages/**/version.json`) +- Standard version.json format with variables and sources +- Support for variants (base + overrides) +- Multiple fetcher types: github, git, url + +### 2. Python Packages (`packages/python/*/default.nix`) +- Parses version, pname, and source info from default.nix +- Supports fetchFromGitHub and fetchPypi patterns +- Updates version, hash, tag, and rev fields + +### 3. Home Assistant Components (`packages/homeassistant/*/default.nix`) +- Specialized parsing for HA component structure +- Handles domain, version, owner, and repo extraction +- GitHub-aware source management + +## Fetcher Types + +### GitHub Fetcher +```json +{ + "fetcher": "github", + "owner": "owner", + "repo": "repo", + "tag": "v1.0.0", + "rev": "commit-hash", + "hash": "sha256-..." +} +``` + +### Git Fetcher +```json +{ + "fetcher": "git", + "url": "https://github.com/user/repo.git", + "rev": "commit-hash", + "hash": "sha256-..." +} +``` + +### URL Fetcher +```json +{ + "fetcher": "url", + "url": "https://example.com/file.tar.gz", + "hash": "sha256-..." +} +``` + +## Key Functions + +### Hash Computation + +#### `nix_prefetch_url(url: str)` +**Purpose**: Compute SHA256 SRI hash for URL sources +**Method**: +1. Try `nix store prefetch-file` (modern, SRI output) +2. Fallback to `nix-prefetch-url` (legacy, needs conversion) +3. Convert to SRI format if needed + +#### `nix_prefetch_git(url: str, rev: str)` +**Purpose**: Compute SHA256 SRI hash for Git repositories +**Method**: +1. Use `nix eval` with `builtins.fetchGit` to get store path +2. Hash the store path with `nix hash path --type sha256` +3. Fallback to `nix-prefetch-git` if available + +### API Functions + +#### `gh_latest_release(owner, repo, token)` +- Gets latest release from GitHub API +- Returns tag_name of latest release +- Handles API errors gracefully + +#### `gh_latest_tag(owner, repo, token)` +- Gets all tags and returns the first one +- Limited to 100 tags per page +- Sorts by GitHub's default ordering + +#### `gh_head_commit(owner, repo)` +- Uses `git ls-remote` to get HEAD commit +- No API token required +- Fallback for when GitHub API fails + +### Special Cases + +#### Raspberry Pi Linux +- Prefers `stable_YYYYMMDD` tags over latest +- Falls back to series-specific tags (`rpi-X.Y`) +- Maintains compatibility with existing tagging schemes + +#### CachyOS Linux +- Fetches latest version from upstream PKGBUILD +- Supports different suffixes (rc, hardened, lts) +- Handles both .SRCINFO and PKGBUILD parsing + +## UI Navigation + +### Main Package List +``` +↑/k/j : Navigate up/down +g/G : Go to top/bottom +Enter : Open package details +f : Cycle filters (all → regular → python → all) +q/ESC : Quit +``` + +### Package Details Screen +``` +←/h/l : Switch variants (if available) +↑/k/j : Navigate sources +r : Refresh candidates +h : Recompute hash +e : Edit field (path=value) +s : Save changes +i : Show full URL (URL sources) +Enter : Action menu for selected source +Backspace : Return to package list +q/ESC : Quit +``` + +### Action Menu +``` +↑/k/j : Select option +Enter : Execute selected action +Backspace : Cancel +``` + +## File Structure Analysis + +### version.json Format +```json +{ + "schemaVersion": 1, + "variables": { + "key": "value" + }, + "sources": { + "name": { + "fetcher": "type", + "...": "..." + } + }, + "variants": { + "variant-name": { + "sources": { + "name": { + "override": "value" + } + } + } + } +} +``` + +### Python Package Parsing +The script extracts information from patterns like: +```nix +{ + pname = "package-name"; + version = "1.0.0"; + src = fetchFromGitHub { + owner = "user"; + repo = "repo"; + rev = "v1.0.0"; + sha256 = "..."; + }; +} +``` + +### Home Assistant Component Parsing +```nix +{ + domain = "component_name"; + version = "1.0.0"; + src = fetchFromGitHub { + owner = "user"; + repo = "repo"; + rev = "v1.0.0"; + sha256 = "..."; + }; +} +``` + +## Error Handling + +### Network Errors +- Timeouts: 10-second timeout on HTTP requests +- HTTP status codes: Proper handling of 4xx/5xx responses +- Retry logic: Graceful fallback when APIs fail + +### API Rate Limiting +- Uses `GITHUB_TOKEN` environment variable if available +- Provides clear error messages when rate limited +- Falls back to git commands when API fails + +### Git Command Failures +- Robust error handling for git ls-remote +- Validation of git command output +- Clear error messages for debugging + +## Configuration + +### Environment Variables +- `GITHUB_TOKEN`: GitHub personal access token (optional) +- Increases API rate limits from 60/hour to 5000/hour + +### Dependencies +- **Required**: `nix` command with experimental features +- **Optional**: `nix-prefetch-git`, `git` commands +- **Python**: Standard library + curses + +## Troubleshooting + +### Hash Mismatches +1. Check if using correct Nix version +2. Verify network connectivity to GitHub +3. Try manual hash computation: + ```bash + nix store prefetch-file --hash-type sha256 + ``` + +### Missing Candidates +1. Check GitHub token availability +2. Verify repository exists and is accessible +3. Check network connectivity +4. Try manual API calls with curl + +### Display Issues +1. Ensure terminal supports colors and Unicode +2. Check terminal size (minimum 80x24 recommended) +3. Try resizing terminal window +4. Use `-r` flag to force redraw + +## Development + +### Adding New Fetcher Types +1. Update `fetch_candidates_for()` method +2. Add parsing logic to appropriate `parse_*()` function +3. Update hash computation methods +4. Add UI handling for new type + +### Testing Changes +```bash +# Test individual functions +python3 -c " +from scripts.version_tui import nix_prefetch_url +print(nix_prefetch_url('https://example.com/file.tar.gz')) +" + +# Test package parsing +python3 -c " +from scripts.version_tui import parse_python_package +print(parse_python_package(Path('packages/python/example/default.nix'))) +" +``` + +## Architecture Decisions + +### Why Multiple Hash Methods? +- Different Nix versions have different available commands +- Modern Nix uses `nix store prefetch-file` and `nix eval` +- Legacy systems use `nix-prefetch-url` and `nix-prefetch-git` +- Script tries modern methods first, falls back gracefully + +### Why Template Rendering? +- Variables allow flexible version specifications +- Supports `${variable}` substitution in URLs and tags +- Enables consistent updates across variants +- Reduces duplication in version.json files + +### Why Two-Step Git Hash? +- `builtins.fetchGit` provides store paths, not hashes +- `nix hash path` works reliably on store paths +- More consistent than trying to parse git command output +- Works with all git repository formats + +## File Organization + +``` +scripts/ +├── version_tui.py # Main script +├── __pycache__/ # Python cache +└── README_tui.md # This documentation +``` + +## Security Considerations + +- No sensitive data is logged +- GitHub tokens are handled securely via environment +- Hash computation uses read-only operations +- No credentials are stored or transmitted unnecessarily + +## Performance + +- Lazy loading of candidates (only when needed) +- Caching of computed hashes during session +- Parallel network requests where possible +- Efficient terminal rendering with minimal refreshes \ No newline at end of file diff --git a/scripts/version_tui.py b/scripts/version_tui.py index 8dc4c46..c17cc71 100755 --- a/scripts/version_tui.py +++ b/scripts/version_tui.py @@ -57,13 +57,16 @@ Json = Dict[str, Any] # ------------------------------ Utilities ------------------------------ + 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: @@ -71,11 +74,14 @@ def save_json(path: Path, data: Json): 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()} @@ -83,6 +89,7 @@ def render_templates(value: Any, variables: Dict[str, Any]) -> Any: 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]: @@ -91,8 +98,10 @@ def deep_set(o: Json, path: List[str], value: Any): 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(): @@ -102,7 +111,10 @@ def deep_merge(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]: out[k] = v return out -def merge_sources(base_sources: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]: + +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: @@ -117,7 +129,10 @@ def merge_sources(base_sources: Dict[str, Any], overrides: Dict[str, Any]) -> Di result[n] = base_sources[n] return result -def merged_view(spec: Json, variant_name: Optional[str]) -> Tuple[Dict[str, Any], Dict[str, Any], Json]: + +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). @@ -130,19 +145,24 @@ def merged_view(spec: Json, variant_name: Optional[str]) -> Tuple[Dict[str, Any] 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_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) + 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: @@ -150,20 +170,50 @@ def run_get_stdout(args: List[str]) -> Optional[str]: return None return out + def nix_prefetch_url(url: str) -> Optional[str]: # returns SRI + # Try modern nix store prefetch-file first (it returns SRI format directly) + 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]) + out = run_get_stdout(["nix-prefetch-url", url]) if out is None: return None + + # Convert to SRI sri = run_get_stdout(["nix", "hash", "to-sri", "--type", "sha256", out.strip()]) return sri + def nix_prefetch_git(url: str, rev: str) -> Optional[str]: + # Try two-step approach: fetchGit then hash path + expr = f'builtins.fetchGit {{ url = "{url}"; rev = "{rev}"; }}' + out = run_get_stdout(["nix", "eval", "--raw", "--expr", expr]) + if out is not None and out.strip(): + # Now hash the fetched path + hash_out = run_get_stdout( + ["nix", "hash", "path", "--type", "sha256", out.strip()] + ) + if hash_out is not None and hash_out.strip(): + return hash_out.strip() + + # Fallback to nix-prefetch-git out = run_get_stdout(["nix-prefetch-git", "--no-deepClone", "--rev", rev, url]) if out is None: return None + base32 = None try: data = json.loads(out) @@ -172,83 +222,145 @@ def nix_prefetch_git(url: str, rev: str) -> Optional[str]: lines = [l for l in out.splitlines() if l.strip()] if lines: base32 = lines[-1].strip() + if not base32: return None + + # Convert to SRI sri = run_get_stdout(["nix", "hash", "to-sri", "--type", "sha256", base32]) return sri + 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")) + 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) as resp: + with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status != 200: + return None return resp.read().decode("utf-8") - except Exception as e: - eprintln(f"http_get_text failed for {url}: {e}") + 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) + 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) - tags = [t.get("name") for t in data if "name" in t] + 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 [t.get("name") for t in data if isinstance(t, dict) and "name" in t] + 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) -> Optional[str]: - out = run_get_stdout(["git", "ls-remote", f"https://github.com/{owner}/{repo}.git", "HEAD"]) - if not out: + try: + out = run_get_stdout( + ["git", "ls-remote", f"https://github.com/{owner}/{repo}.git", "HEAD"] + ) + if not out: + return None + parts = out.split() + return parts[0] if parts else None + except Exception as e: + eprintln(f"head_commit failed for {owner}/{repo}: {e}") return None - return out.split()[0] + 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 [r.get("tag_name") for r in data if isinstance(r, dict) and "tag_name" in r] + 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) - + 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(): @@ -258,8 +370,10 @@ def find_packages() -> List[Tuple[str, Path, bool, bool]]: 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) - + 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(): @@ -269,40 +383,44 @@ def find_packages() -> List[Tuple[str, Path, bool, bool]]: if nix_file.exists(): # name is homeassistant/component-name rel = pkg_dir.relative_to(PKGS_DIR) - results.append((str(rel), nix_file, False, True)) # (name, path, is_python, is_homeassistant) - + results.append( + (str(rel), nix_file, False, True) + ) # (name, path, is_python, is_homeassistant) + results.sort() return results + 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 "" - + # Check for fetchFromGitHub pattern - fetch_github_match = re.search(r'src\s*=\s*fetchFromGitHub\s*\{([^}]+)\}', content, re.DOTALL) - + fetch_github_match = re.search( + r"src\s*=\s*fetchFromGitHub\s*\{([^}]+)\}", content, re.DOTALL + ) + # Check for fetchPypi pattern - fetch_pypi_match = re.search(r'src\s*=\s*.*fetchPypi\s*\{([^}]+)\}', content, re.DOTALL) - + fetch_pypi_match = re.search( + r"src\s*=\s*.*fetchPypi\s*\{([^}]+)\}", content, re.DOTALL + ) + # Create a structure similar to version.json for compatibility - result = { - "variables": {}, - "sources": {} - } - + result = {"variables": {}, "sources": {}} + # Only add non-empty values to variables if version: result["variables"]["version"] = version - + # Determine source name - use pname, repo name, or derive from path source_name = "" if pname: @@ -310,30 +428,30 @@ def parse_python_package(path: Path) -> Dict[str, Any]: else: # Use directory name as source name source_name = path.parent.name.lower() - + # Handle fetchFromGitHub pattern if fetch_github_match: fetch_block = fetch_github_match.group(1) - + # Extract GitHub info from the fetchFromGitHub block owner_match = re.search(r'owner\s*=\s*"([^"]+)"', fetch_block) repo_match = re.search(r'repo\s*=\s*"([^"]+)"', fetch_block) rev_match = re.search(r'rev\s*=\s*"([^"]+)"', fetch_block) hash_match = re.search(r'(sha256|hash)\s*=\s*"([^"]+)"', fetch_block) - + 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 "" hash_value = hash_match.group(2) if hash_match else "" - + # Create source entry result["sources"][source_name] = { "fetcher": "github", "owner": owner, "repo": repo, - "hash": hash_value + "hash": hash_value, } - + # Handle rev field which might contain a tag or version reference if rev: # Check if it's a tag reference (starts with v) @@ -351,27 +469,29 @@ def parse_python_package(path: Path) -> Dict[str, Any]: # Handle fetchPypi pattern elif fetch_pypi_match: fetch_block = fetch_pypi_match.group(1) - + # Extract PyPI info hash_match = re.search(r'(sha256|hash)\s*=\s*"([^"]+)"', fetch_block) 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) - + 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) - + # Create source entry with GitHub info result["sources"][source_name] = { "fetcher": "github", "owner": owner, "repo": repo, "hash": hash_value, - "pypi": True # Mark as PyPI source + "pypi": True, # Mark as PyPI source } - + # Add version as tag if available if version: result["sources"][source_name]["tag"] = f"v{version}" @@ -381,7 +501,7 @@ def parse_python_package(path: Path) -> Dict[str, Any]: "fetcher": "pypi", "pname": pname, "version": version, - "hash": hash_value + "hash": hash_value, } else: # Try to extract standalone GitHub info if present @@ -390,32 +510,34 @@ def parse_python_package(path: Path) -> Dict[str, Any]: 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) - + 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 "" - + # Try to extract URL if GitHub info is not present url_match = re.search(r'url\s*=\s*"([^"]+)"', content) url = url_match.group(1) if url_match else "" - + # Check for GitHub homepage in meta section - homepage_match = re.search(r'homepage\s*=\s*"https://github.com/([^/]+)/([^"]+)"', content) + homepage_match = re.search( + r'homepage\s*=\s*"https://github.com/([^/]+)/([^"]+)"', content + ) if homepage_match and not (owner and repo): owner = homepage_match.group(1) repo = homepage_match.group(2) - + # Handle GitHub sources if owner and repo: result["sources"][source_name] = { "fetcher": "github", "owner": owner, "repo": repo, - "hash": hash_value + "hash": hash_value, } - + # Handle tag if tag: result["sources"][source_name]["tag"] = tag @@ -427,216 +549,208 @@ def parse_python_package(path: Path) -> Dict[str, Any]: result["sources"][source_name] = { "fetcher": "url", "url": url, - "hash": hash_value + "hash": hash_value, } # Fallback for packages with no clear source info else: # Create a minimal source entry so the package shows up in the UI - result["sources"][source_name] = { - "fetcher": "unknown", - "hash": hash_value - } - + 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: + +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 + 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 + 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 + 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 + 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": {} - } - + 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 + "repo": repo_name, } - + # Only add non-empty values if hash_value: result["sources"][source_name]["hash"] = hash_value - + # Handle tag or rev if tag: result["sources"][source_name]["tag"] = tag elif rev: result["sources"][source_name]["rev"] = rev - elif version: # If no tag or rev specified, but version exists, use version as tag + elif ( + version + ): # If no tag or rev specified, but version exists, 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" - } + 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: + +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 + 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 + 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 + 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 + 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 + 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 + 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 + # ------------------------------ TUI helpers ------------------------------ # Define color pairs @@ -649,11 +763,12 @@ COLOR_SUCCESS = 6 COLOR_BORDER = 7 COLOR_TITLE = 8 + def init_colors(): """Initialize color pairs for the TUI.""" 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) @@ -664,30 +779,34 @@ def init_colors(): curses.init_pair(COLOR_BORDER, curses.COLOR_BLUE, -1) curses.init_pair(COLOR_TITLE, curses.COLOR_MAGENTA, -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)) - + # Draw bottom-right corner safely 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, curses.color_pair(COLOR_BORDER) + ) 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 @@ -701,9 +820,19 @@ class ScreenBase: 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)) + self.stdscr.addstr( + height - 1, + 0, + self.status[: max(0, width - 1)], + curses.color_pair(color), + ) else: - self.stdscr.addstr(height-1, 0, "q: quit, Backspace: back, Enter: select", curses.color_pair(COLOR_STATUS)) + self.stdscr.addstr( + height - 1, + 0, + "q: quit, Backspace: back, Enter: select", + curses.color_pair(COLOR_STATUS), + ) def set_status(self, text: str, status_type="normal"): self.status = text @@ -712,6 +841,7 @@ class ScreenBase: def run(self): raise NotImplementedError + def prompt_input(stdscr, prompt: str) -> Optional[str]: curses.echo() stdscr.addstr(prompt, curses.color_pair(COLOR_HEADER)) @@ -720,38 +850,41 @@ def prompt_input(stdscr, prompt: str) -> Optional[str]: curses.noecho() return s + def show_popup(stdscr, lines: List[str], title: str = ""): 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) - top = (h - box_h)//2 - left = (w - box_w)//2 + box_h = min(len(lines) + 4, h - 2) + box_w = min(max(max(len(l) for l in lines), len(title)) + 6, w - 2) + 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 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: + if i >= box_h - 1: break - win.addstr(i, 2, line[:box_w-4], curses.color_pair(COLOR_NORMAL)) - + 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)) - + win.addstr(box_h - 1, footer_x, footer, curses.color_pair(COLOR_STATUS)) + win.refresh() win.getch() + # ------------------------------ Screens ------------------------------ + class PackagesScreen(ScreenBase): def __init__(self, stdscr): super().__init__(stdscr) @@ -771,9 +904,9 @@ class PackagesScreen(ScreenBase): right_w = max(0, w - right_x) # Draw borders for left and right panes - draw_border(self.stdscr, 0, 0, h-1, left_w) + 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) + draw_border(self.stdscr, 0, right_x, h - 1, right_w) # Left pane: package list title = "Packages" @@ -783,41 +916,56 @@ class PackagesScreen(ScreenBase): title = "Python Packages" else: title = "All Packages [f to filter]" - + # Center the title in the left pane title_x = (left_w - len(title)) // 2 - self.stdscr.addstr(0, title_x, f" {title} ", curses.color_pair(COLOR_TITLE) | curses.A_BOLD) - + self.stdscr.addstr( + 0, title_x, f" {title} ", curses.color_pair(COLOR_TITLE) | curses.A_BOLD + ) + # Filter packages based on mode filtered_packages = self.packages if self.filter_mode == "regular": - filtered_packages = [p for p in self.packages if not p[2]] # Not Python packages + filtered_packages = [ + p for p in self.packages if not p[2] + ] # Not Python packages elif self.filter_mode == "python": - filtered_packages = [p for p in self.packages if p[2]] # Only Python packages - + filtered_packages = [ + p for p in self.packages if p[2] + ] # Only Python packages + # Implement scrolling for long lists max_rows = h - 3 total_packages = len(filtered_packages) - + # 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] - + 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): + 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) @@ -825,30 +973,48 @@ class PackagesScreen(ScreenBase): else: attr = curses.color_pair(COLOR_NORMAL) sel = " " - + # Add a small icon for Python packages or Home Assistant components if is_python: pkg_type = "🐍 " # Python icon elif is_homeassistant: pkg_type = "🏠 " # Home Assistant icon - - self.stdscr.addstr(1 + i, 2, f"{sel} {pkg_type}{name}"[:max(0, left_w-5)], attr) + + self.stdscr.addstr( + 1 + i, 2, f"{sel} {pkg_type}{name}"[: max(0, left_w - 5)], 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] - + name, path, is_python, is_homeassistant = filtered_packages[ + self.idx + ] + # Center the package name in the right pane header title_x = right_x + (right_w - len(name)) // 2 - self.stdscr.addstr(0, title_x, f" {name} ", curses.color_pair(COLOR_TITLE) | curses.A_BOLD) - + self.stdscr.addstr( + 0, + title_x, + f" {name} ", + curses.color_pair(COLOR_TITLE) | curses.A_BOLD, + ) + # Path with a nice label - self.stdscr.addstr(1, right_x + 2, "Path:", curses.color_pair(COLOR_HEADER)) - self.stdscr.addstr(1, right_x + 8, f"{path}"[:max(0, right_w-10)], curses.color_pair(COLOR_NORMAL)) - + self.stdscr.addstr( + 1, right_x + 2, "Path:", curses.color_pair(COLOR_HEADER) + ) + self.stdscr.addstr( + 1, + right_x + 8, + f"{path}"[: max(0, right_w - 10)], + curses.color_pair(COLOR_NORMAL), + ) + # Sources header - self.stdscr.addstr(2, right_x + 2, "Sources:", curses.color_pair(COLOR_HEADER)) + self.stdscr.addstr( + 2, right_x + 2, "Sources:", curses.color_pair(COLOR_HEADER) + ) if is_python: spec = parse_python_package(path) @@ -863,13 +1029,20 @@ class PackagesScreen(ScreenBase): comp = merged_srcs[sname] fetcher = comp.get("fetcher", "none") # Construct concise reference similar to detail view - display_ref = comp.get("tag") or comp.get("rev") or comp.get("version") or "" + display_ref = ( + comp.get("tag") + or comp.get("rev") + or comp.get("version") + or "" + ) if fetcher == "github": rendered = render_templates(comp, merged_vars) tag = rendered.get("tag") rev = rendered.get("rev") - owner = (rendered.get("owner") or merged_vars.get("owner") or "") - repo = (rendered.get("repo") or merged_vars.get("repo") or "") + owner = ( + rendered.get("owner") or merged_vars.get("owner") or "" + ) + repo = rendered.get("repo") or merged_vars.get("repo") or "" if tag and owner and repo: display_ref = f"{owner}/{repo}@{tag}" elif tag: @@ -880,7 +1053,9 @@ class PackagesScreen(ScreenBase): display_ref = rev[:12] elif fetcher == "url": rendered = render_templates(comp, merged_vars) - url = rendered.get("url") or rendered.get("urlTemplate") or "" + url = ( + rendered.get("url") or rendered.get("urlTemplate") or "" + ) if url: owner = str(merged_vars.get("owner", "") or "") repo = str(merged_vars.get("repo", "") or "") @@ -890,7 +1065,11 @@ class PackagesScreen(ScreenBase): rel = str(merged_vars.get("release", "") or "") tag = f"{rp}{base}-{rel}{rs}" if (base and rel) else "" parsed = urlparse(url) - filename = os.path.basename(parsed.path) if parsed and parsed.path else "" + filename = ( + os.path.basename(parsed.path) + if parsed and parsed.path + else "" + ) if owner and repo and tag and filename: display_ref = f"{owner}/{repo}@{tag} · {filename}" elif filename: @@ -902,10 +1081,12 @@ class PackagesScreen(ScreenBase): # Truncate reference to fit right pane if isinstance(display_ref, str): max_ref = max(0, right_w - 30) - ref_short = (display_ref[:max_ref] + ("..." if len(display_ref) > max_ref else "")) + ref_short = display_ref[:max_ref] + ( + "..." if len(display_ref) > max_ref else "" + ) else: ref_short = display_ref - + # Color-code the fetcher type fetcher_color = COLOR_NORMAL if fetcher == "github": @@ -914,43 +1095,70 @@ class PackagesScreen(ScreenBase): fetcher_color = COLOR_STATUS elif fetcher == "git": fetcher_color = COLOR_HEADER - + # Display source name - self.stdscr.addstr(3 + i2, right_x + 2, f"{sname:<18}", curses.color_pair(COLOR_NORMAL)) - + self.stdscr.addstr( + 3 + i2, + right_x + 2, + f"{sname:<18}", + curses.color_pair(COLOR_NORMAL), + ) + # Display fetcher with color - self.stdscr.addstr(3 + i2, right_x + 21, f"{fetcher:<7}", curses.color_pair(fetcher_color)) - + self.stdscr.addstr( + 3 + i2, + right_x + 21, + f"{fetcher:<7}", + curses.color_pair(fetcher_color), + ) + # Display reference - self.stdscr.addstr(3 + i2, right_x + 29, f"{ref_short}"[:max(0, right_w-31)], curses.color_pair(COLOR_NORMAL)) + self.stdscr.addstr( + 3 + i2, + right_x + 29, + f"{ref_short}"[: max(0, right_w - 31)], + curses.color_pair(COLOR_NORMAL), + ) # Hint line for workflow hint = "Enter: open details | k/j: move | q: quit" if h >= 5: hint_x = right_x + (right_w - len(hint)) // 2 - self.stdscr.addstr(h - 5, hint_x, hint[:max(0, right_w-1)], curses.color_pair(COLOR_STATUS)) + self.stdscr.addstr( + h - 5, + hint_x, + hint[: max(0, right_w - 1)], + 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.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 + 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(self.packages)-1, self.idx+1) + 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(self.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(self.packages)-1, self.idx + (h - 4)) - elif ch == ord('g'): # Go to top + self.idx = min(len(self.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 = len(self.packages)-1 - elif ch == ord('f'): + elif ch == ord("G"): # Go to bottom + self.idx = len(self.packages) - 1 + elif ch == ord("f"): # Cycle through filter modes if self.filter_mode == "all": self.filter_mode = "regular" @@ -965,10 +1173,10 @@ class PackagesScreen(ScreenBase): 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: @@ -980,17 +1188,28 @@ class PackagesScreen(ScreenBase): 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) + 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) + 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): + 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 @@ -1000,8 +1219,12 @@ class PackageDetailScreen(ScreenBase): self.variants = [""] + sorted(list(self.spec.get("variants", {}).keys())) 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} + 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() @@ -1018,7 +1241,9 @@ class PackageDetailScreen(ScreenBase): 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.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 @@ -1060,11 +1285,15 @@ class PackageDetailScreen(ScreenBase): 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 = [ + x + for x in tags_all + if ( + x == f"{base}.y" + or x.startswith(f"{base}.y") + or x.startswith(f"{base}.") + ) + ] series_tags.sort(reverse=True) if series_tags: c["tag"] = series_tags[0] @@ -1085,18 +1314,29 @@ class PackageDetailScreen(ScreenBase): 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) + latest = next( + ( + t + for t in tags + if (t and t.startswith(prefix) and t.endswith(suffix)) + ), + None, + ) if latest: c["release"] = latest mid = latest if prefix and mid.startswith(prefix): - mid = mid[len(prefix):] + mid = mid[len(prefix) :] if suffix and mid.endswith(suffix): - mid = mid[:-len(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.url_candidates[name] = { + "base": base, + "release": rel, + "tag": latest, + } self.candidates[name] = c def prefetch_hash_for(self, name: str) -> Optional[str]: @@ -1160,14 +1400,16 @@ class PackageDetailScreen(ScreenBase): 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) + 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() + val = re.sub(r"\s+#.*$", "", val).strip() # Strip surrounding quotes - if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")): + if (val.startswith('"') and val.endswith('"')) or ( + val.startswith("'") and val.endswith("'") + ): val = val[1:-1] env[key] = val @@ -1176,16 +1418,20 @@ class PackageDetailScreen(ScreenBase): return None raw = m.group(1).strip() # Strip quotes - if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")): + 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)) + 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)) + 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) @@ -1261,23 +1507,23 @@ class PackageDetailScreen(ScreenBase): 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 @@ -1286,23 +1532,23 @@ class PackageDetailScreen(ScreenBase): 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 @@ -1315,19 +1561,21 @@ class PackageDetailScreen(ScreenBase): 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) - + draw_border(self.stdscr, 0, 0, h - 1, w) + # Title with package name and path title = f"{self.pkg_name} [{self.path}]" if self.is_python: title += " [Python Package]" - + # Center the title title_x = (w - len(title)) // 2 - self.stdscr.addstr(0, title_x, f" {title} ", curses.color_pair(COLOR_TITLE) | curses.A_BOLD) - + self.stdscr.addstr( + 0, title_x, f" {title} ", curses.color_pair(COLOR_TITLE) | curses.A_BOLD + ) + # Variant line with highlighting for selected variant if not self.is_python: vline_parts = [] @@ -1336,19 +1584,23 @@ class PackageDetailScreen(ScreenBase): vline_parts.append(f"[{v}]") else: vline_parts.append(v) - + vline = "Variants: " + " | ".join(vline_parts) self.stdscr.addstr(1, 2, "Variants:", curses.color_pair(COLOR_HEADER)) - + # Display each variant with appropriate highlighting x_pos = 12 # Position after "Variants: " for i, v in enumerate(self.variants): if i > 0: - self.stdscr.addstr(1, x_pos, " | ", curses.color_pair(COLOR_NORMAL)) + self.stdscr.addstr( + 1, x_pos, " | ", curses.color_pair(COLOR_NORMAL) + ) x_pos += 3 - + if i == self.vidx: - self.stdscr.addstr(1, x_pos, f"[{v}]", curses.color_pair(COLOR_HIGHLIGHT)) + self.stdscr.addstr( + 1, x_pos, f"[{v}]", curses.color_pair(COLOR_HIGHLIGHT) + ) x_pos += len(f"[{v}]") else: self.stdscr.addstr(1, x_pos, v, curses.color_pair(COLOR_NORMAL)) @@ -1358,25 +1610,31 @@ class PackageDetailScreen(ScreenBase): version = self.merged_vars.get("version", "") self.stdscr.addstr(1, 2, "Version:", curses.color_pair(COLOR_HEADER)) self.stdscr.addstr(1, 11, version, curses.color_pair(COLOR_SUCCESS)) - + # Sources header with decoration - self.stdscr.addstr(2, 2, "Sources:", curses.color_pair(COLOR_HEADER) | curses.A_BOLD) - + self.stdscr.addstr( + 2, 2, "Sources:", curses.color_pair(COLOR_HEADER) | curses.A_BOLD + ) + # Draw a separator line under the header - for i in range(1, w-1): - self.stdscr.addch(3, i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER)) + for i in range(1, w - 1): + self.stdscr.addch( + 3, i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER) + ) # List sources - for i, name in enumerate(self.snames[:h-10], start=0): + for i, name in enumerate(self.snames[: h - 10], start=0): comp = self.merged_srcs[name] fetcher = comp.get("fetcher", "none") # Render refs so variables resolve; compress long forms for display - display_ref = comp.get("tag") or comp.get("rev") or comp.get("version") or "" + display_ref = ( + comp.get("tag") or comp.get("rev") or comp.get("version") or "" + ) if fetcher == "github": rendered = render_templates(comp, self.merged_vars) tag = rendered.get("tag") rev = rendered.get("rev") - owner = (rendered.get("owner") or self.merged_vars.get("owner") or "") - repo = (rendered.get("repo") or self.merged_vars.get("repo") or "") + owner = rendered.get("owner") or self.merged_vars.get("owner") or "" + repo = rendered.get("repo") or self.merged_vars.get("repo") or "" if tag and owner and repo: display_ref = f"{owner}/{repo}@{tag}" elif tag: @@ -1398,7 +1656,11 @@ class PackageDetailScreen(ScreenBase): rel = str(self.merged_vars.get("release", "") or "") tag = f"{rp}{base}-{rel}{rs}" if (base and rel) else "" parsed = urlparse(url) - filename = os.path.basename(parsed.path) if parsed and parsed.path else "" + filename = ( + os.path.basename(parsed.path) + if parsed and parsed.path + else "" + ) if owner and repo and tag and filename: display_ref = f"{owner}/{repo}@{tag} · {filename}" elif filename: @@ -1407,8 +1669,12 @@ class PackageDetailScreen(ScreenBase): display_ref = url else: display_ref = "" - ref_short = display_ref if not isinstance(display_ref, str) else (display_ref[:60] + ("..." if len(display_ref) > 60 else "")) - + ref_short = ( + display_ref + if not isinstance(display_ref, str) + else (display_ref[:60] + ("..." if len(display_ref) > 60 else "")) + ) + # Determine colors and styles based on selection and fetcher type if i == self.sidx: # Selected item @@ -1418,7 +1684,7 @@ class PackageDetailScreen(ScreenBase): # Non-selected item attr = curses.color_pair(COLOR_NORMAL) sel = " " - + # Determine fetcher color fetcher_color = COLOR_NORMAL if fetcher == "github": @@ -1427,106 +1693,181 @@ class PackageDetailScreen(ScreenBase): fetcher_color = COLOR_STATUS elif fetcher == "git": fetcher_color = COLOR_HEADER - + # Display source name with selection indicator - self.stdscr.addstr(4+i, 2, f"{sel} {name:<20}", attr) - + self.stdscr.addstr(4 + i, 2, f"{sel} {name:<20}", attr) + # Display fetcher with appropriate color - self.stdscr.addstr(4+i, 24, fetcher, curses.color_pair(fetcher_color)) - + self.stdscr.addstr(4 + i, 24, fetcher, curses.color_pair(fetcher_color)) + # Display reference - self.stdscr.addstr(4+i, 32, f"ref={ref_short}"[:w-34], curses.color_pair(COLOR_NORMAL)) + self.stdscr.addstr( + 4 + i, + 32, + f"ref={ref_short}"[: w - 34], + curses.color_pair(COLOR_NORMAL), + ) # Draw a separator line before the latest candidates section y_latest = h - 8 - for i in range(1, w-1): - self.stdscr.addch(y_latest, i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER)) - + 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: + if ( + _fetcher in ("github", "git", "url") + and _sel_name not in self.candidates + ): self.fetch_candidates_for(_sel_name) - + # Latest header with decoration - self.stdscr.addstr(y_latest+1, 2, "Latest Versions:", curses.color_pair(COLOR_HEADER) | curses.A_BOLD) - + self.stdscr.addstr( + y_latest + 1, + 2, + "Latest Versions:", + curses.color_pair(COLOR_HEADER) | curses.A_BOLD, + ) + if _fetcher in ("github", "git"): _cand = self.candidates.get(_sel_name, {}) - + # Display each candidate with appropriate color - if _cand.get('release'): - self.stdscr.addstr(y_latest+2, 4, "Release:", curses.color_pair(COLOR_HEADER)) - self.stdscr.addstr(y_latest+2, 13, _cand.get('release'), curses.color_pair(COLOR_SUCCESS)) - - if _cand.get('tag'): - self.stdscr.addstr(y_latest+2, 30, "Tag:", curses.color_pair(COLOR_HEADER)) - self.stdscr.addstr(y_latest+2, 35, _cand.get('tag'), curses.color_pair(COLOR_SUCCESS)) - - if _cand.get('commit'): - self.stdscr.addstr(y_latest+3, 4, "Commit:", curses.color_pair(COLOR_HEADER)) - self.stdscr.addstr(y_latest+3, 12, (_cand.get('commit') or '')[:12], curses.color_pair(COLOR_NORMAL)) - + if _cand.get("release"): + self.stdscr.addstr( + y_latest + 2, 4, "Release:", curses.color_pair(COLOR_HEADER) + ) + self.stdscr.addstr( + y_latest + 2, + 13, + _cand.get("release"), + curses.color_pair(COLOR_SUCCESS), + ) + + if _cand.get("tag"): + self.stdscr.addstr( + y_latest + 2, 30, "Tag:", curses.color_pair(COLOR_HEADER) + ) + self.stdscr.addstr( + y_latest + 2, + 35, + _cand.get("tag"), + curses.color_pair(COLOR_SUCCESS), + ) + + if _cand.get("commit"): + self.stdscr.addstr( + y_latest + 3, 4, "Commit:", curses.color_pair(COLOR_HEADER) + ) + self.stdscr.addstr( + y_latest + 3, + 12, + (_cand.get("commit") or "")[:12], + curses.color_pair(COLOR_NORMAL), + ) + elif _fetcher == "url": _cand_u = self.url_candidates.get(_sel_name, {}) or {} - _tag = _cand_u.get("tag") or (self.candidates.get(_sel_name, {}).get("release") or "-") - + _tag = _cand_u.get("tag") or ( + self.candidates.get(_sel_name, {}).get("release") or "-" + ) + if _tag != "-": - self.stdscr.addstr(y_latest+2, 4, "Tag:", curses.color_pair(COLOR_HEADER)) - self.stdscr.addstr(y_latest+2, 9, _tag, curses.color_pair(COLOR_SUCCESS)) - - if _cand_u.get('base'): - self.stdscr.addstr(y_latest+2, 30, "Base:", curses.color_pair(COLOR_HEADER)) - self.stdscr.addstr(y_latest+2, 36, _cand_u.get('base'), curses.color_pair(COLOR_NORMAL)) - - if _cand_u.get('release'): - self.stdscr.addstr(y_latest+3, 4, "Release:", curses.color_pair(COLOR_HEADER)) - self.stdscr.addstr(y_latest+3, 13, _cand_u.get('release'), curses.color_pair(COLOR_NORMAL)) - + self.stdscr.addstr( + y_latest + 2, 4, "Tag:", curses.color_pair(COLOR_HEADER) + ) + self.stdscr.addstr( + y_latest + 2, 9, _tag, curses.color_pair(COLOR_SUCCESS) + ) + + if _cand_u.get("base"): + self.stdscr.addstr( + y_latest + 2, 30, "Base:", curses.color_pair(COLOR_HEADER) + ) + self.stdscr.addstr( + y_latest + 2, + 36, + _cand_u.get("base"), + curses.color_pair(COLOR_NORMAL), + ) + + if _cand_u.get("release"): + self.stdscr.addstr( + y_latest + 3, 4, "Release:", curses.color_pair(COLOR_HEADER) + ) + self.stdscr.addstr( + y_latest + 3, + 13, + _cand_u.get("release"), + 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, "Linux from PKGBUILD:", curses.color_pair(COLOR_HEADER)) + self.stdscr.addstr( + y_latest + 2, + 4, + "Linux from PKGBUILD:", + curses.color_pair(COLOR_HEADER), + ) if _latest: - self.stdscr.addstr(y_latest+2, 24, _latest, curses.color_pair(COLOR_SUCCESS)) + self.stdscr.addstr( + y_latest + 2, + 24, + _latest, + curses.color_pair(COLOR_SUCCESS), + ) else: - self.stdscr.addstr(y_latest+2, 24, "-", curses.color_pair(COLOR_NORMAL)) + self.stdscr.addstr( + y_latest + 2, 24, "-", curses.color_pair(COLOR_NORMAL) + ) else: - self.stdscr.addstr(y_latest+2, 4, "No candidates available", curses.color_pair(COLOR_NORMAL)) + self.stdscr.addstr( + y_latest + 2, + 4, + "No candidates available", + curses.color_pair(COLOR_NORMAL), + ) # Draw a separator line before the footer - for i in range(1, w-1): - self.stdscr.addch(h-5, i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER)) - + for i in range(1, w - 1): + self.stdscr.addch( + h - 5, i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER) + ) + # Footer instructions with better formatting footer = "Enter: component actions | r: refresh | h: hash | e: edit | s: save | Backspace: back | q: quit" footer_x = (w - len(footer)) // 2 - self.stdscr.addstr(h-4, footer_x, footer, curses.color_pair(COLOR_STATUS)) - + self.stdscr.addstr(h - 4, footer_x, footer, 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): + if ch in (ord("q"), 27): return None elif ch == curses.KEY_BACKSPACE or ch == 127: return "reload" - elif ch in (curses.KEY_LEFT, ord('h')): - self.vidx = max(0, self.vidx-1) + elif ch in (curses.KEY_LEFT, ord("h")): + self.vidx = max(0, self.vidx - 1) self.select_variant() - elif ch in (curses.KEY_RIGHT, ord('l')): - self.vidx = min(len(self.variants)-1, self.vidx+1) + elif ch in (curses.KEY_RIGHT, ord("l")): + 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'),): + 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] @@ -1537,7 +1878,11 @@ class PackageDetailScreen(ScreenBase): latest = self.fetch_cachyos_linux_latest(suffix) rendered = render_templates(comp, self.merged_vars) cur_version = str(rendered.get("version") or "") - url_hint = self.linux_tarball_url_for_version(latest) if latest else "-" + 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 '-'}", @@ -1555,7 +1900,7 @@ class PackageDetailScreen(ScreenBase): f" latest commit : {cand.get('commit') or '-'}", ] show_popup(self.stdscr, lines) - elif ch in (ord('i'),): + elif ch in (ord("i"),): # Show full rendered URL for URL-based sources if self.snames: name = self.snames[self.sidx] @@ -1567,7 +1912,7 @@ class PackageDetailScreen(ScreenBase): show_popup(self.stdscr, ["Full URL:", url]) else: self.set_status("No URL available") - elif ch in (ord('h'),): + elif ch in (ord("h"),): if self.snames: name = self.snames[self.sidx] sri = self.prefetch_hash_for(name) @@ -1578,8 +1923,10 @@ class PackageDetailScreen(ScreenBase): 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): ") + 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") @@ -1588,7 +1935,7 @@ class PackageDetailScreen(ScreenBase): 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'),): + elif ch in (ord("s"),): try: self.save() self.set_status("Saved.") @@ -1609,7 +1956,10 @@ class PackageDetailScreen(ScreenBase): items = [] if fetcher == "github": items = [ - ("Use latest release (tag)", ("release", cand.get("release"))), + ( + "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)), @@ -1638,7 +1988,12 @@ class PackageDetailScreen(ScreenBase): current_str, f"available: release={cand.get('release') or '-'} tag={cand.get('tag') or '-'} commit={(cand.get('commit') or '')[:12] or '-'}", ] - choice = select_menu(self.stdscr, f"Actions for {name}", [label for label, _ in items], header=header_lines) + 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"): @@ -1667,9 +2022,16 @@ class PackageDetailScreen(ScreenBase): 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]]]]] = [] + 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( + ( + "Use latest release (update variables.base/release)", + ("update_vars", cand), + ) + ) menu_items.append(("Recompute hash (prefetch)", ("hash", None))) menu_items.append(("Cancel", ("cancel", None))) @@ -1682,14 +2044,21 @@ class PackageDetailScreen(ScreenBase): if current_tag: current_str = f"current: {current_tag}" elif base or rel: - current_str = f"current: base={base or '-'} release={rel or '-'}" + 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) + 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): @@ -1705,9 +2074,13 @@ class PackageDetailScreen(ScreenBase): 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") + self.set_status( + f"{name}: updated to {payload['base']}.{payload['release']} and refreshed hash" + ) else: - self.set_status("hash prefetch failed after variable update") + self.set_status( + "hash prefetch failed after variable update" + ) elif kind == "hash": sri = self.prefetch_hash_for(name) if sri: @@ -1732,59 +2105,94 @@ class PackageDetailScreen(ScreenBase): ] opts = [] if latest: - opts.append(f"Update linux version to {latest} from PKGBUILD (.SRCINFO)") + opts.append( + f"Update linux version to {latest} from PKGBUILD (.SRCINFO)" + ) else: opts.append("Update linux version from PKGBUILD (.SRCINFO)") opts.append("Cancel") - choice = select_menu(self.stdscr, f"Actions for {name}", opts, header=header_lines) + choice = select_menu( + self.stdscr, + f"Actions for {name}", + opts, + header=header_lines, + ) if choice == 0 and latest: self.update_linux_from_pkgbuild(name) else: pass else: - show_popup(self.stdscr, [f"{name}: fetcher={fetcher}", "Use 'e' to edit fields manually."]) + 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) -> Optional[int]: + +def select_menu( + stdscr, title: str, options: List[str], header: Optional[List[str]] = None +) -> Optional[int]: idx = 0 while True: stdscr.clear() h, w = stdscr.getmaxyx() - - # Calculate menu dimensions - menu_width = min(w - 4, max(40, max(len(title) + 4, max(len(opt) + 4 for opt in options)))) + + # Calculate menu dimensions with better bounds checking + max_opt_len = max((len(opt) + 4 for opt in options), default=0) + title_len = len(title) + 4 + menu_width = min(w - 4, max(40, max(title_len, max_opt_len))) menu_height = min(h - 4, len(options) + (len(header) if header else 0) + 4) - + # 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) - + stdscr.addstr( + start_y, + title_x, + f" {title} ", + curses.color_pair(COLOR_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 - stdscr.addstr(y, start_x + 2, str(line)[:menu_width-4], curses.color_pair(COLOR_HEADER)) + # 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)) + stdscr.addch( + y, start_x + i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER) + ) y += 1 - + # Draw options options_start_y = y - visible_options = min(len(options), start_y + menu_height - options_start_y - 1) - + max_visible_options = max(1, start_y + menu_height - options_start_y - 1) + visible_options = min(len(options), max_visible_options) + for i, opt in enumerate(options[:visible_options], start=0): # Highlight selected option if i == idx: @@ -1793,35 +2201,43 @@ def select_menu(stdscr, title: str, options: List[str], header: Optional[List[st else: attr = curses.color_pair(COLOR_NORMAL) sel = " " - - stdscr.addstr(options_start_y + i, start_x + 2, f"{sel} {opt}"[:menu_width-4], attr) - + + # 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)) - + stdscr.addstr( + start_y + menu_height - 1, footer_x, footer, curses.color_pair(COLOR_STATUS) + ) + stdscr.refresh() ch = stdscr.getch() - if ch in (curses.KEY_UP, ord('k')): - idx = max(0, idx-1) - elif ch in (curses.KEY_DOWN, ord('j')): - idx = min(len(options)-1, idx+1) + if ch in (curses.KEY_UP, ord("k")): + idx = max(0, idx - 1) + elif ch in (curses.KEY_DOWN, ord("j")): + 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: 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() - + try: # Display welcome screen h, w = stdscr.getmaxyx() @@ -1836,23 +2252,32 @@ def main(stdscr): "║ ║", "╚═══════════════════════════════════════════╝", "", - "Loading packages..." + "Loading packages...", ] - + # Center the welcome message start_y = (h - len(welcome_lines)) // 2 for i, line in enumerate(welcome_lines): if start_y + i < h: start_x = (w - len(line)) // 2 if "NixOS Package Version Manager" in line: - stdscr.addstr(start_y + i, start_x, line, curses.color_pair(COLOR_TITLE) | curses.A_BOLD) + stdscr.addstr( + start_y + i, + start_x, + line, + curses.color_pair(COLOR_TITLE) | curses.A_BOLD, + ) elif "Loading packages..." in line: - stdscr.addstr(start_y + i, start_x, line, curses.color_pair(COLOR_STATUS)) + stdscr.addstr( + start_y + i, start_x, line, curses.color_pair(COLOR_STATUS) + ) else: - stdscr.addstr(start_y + i, start_x, line, curses.color_pair(COLOR_NORMAL)) - + stdscr.addstr( + start_y + i, start_x, line, curses.color_pair(COLOR_NORMAL) + ) + stdscr.refresh() - + # Start the main screen after a short delay screen = PackagesScreen(stdscr) screen.run() @@ -1861,5 +2286,6 @@ def main(stdscr): traceback.print_exc() sys.exit(1) + if __name__ == "__main__": curses.wrapper(main) diff --git a/systems/x86_64-linux/jallen-nas/apps.nix b/systems/x86_64-linux/jallen-nas/apps.nix index 381a121..86eca20 100755 --- a/systems/x86_64-linux/jallen-nas/apps.nix +++ b/systems/x86_64-linux/jallen-nas/apps.nix @@ -16,7 +16,9 @@ in createUser = true; reverseProxy = enabled; }; - ai = enabled; + ai = { + enable = true; + }; arrs = { enable = true; enableVpn = true;