diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index c5dac56..e987c8f 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -32,47 +32,8 @@ jobs: - name: Evaluate Makima Host Configuration run: nix eval --impure --expr '(builtins.getFlake (toString ./.)).nixosConfigurations.Makima.config.system.build.toplevel.name' - rust-ci: - name: Rust TUI Installer Tests & Build - runs-on: ubuntu-latest - defaults: - run: - working-directory: installer-rs - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - - name: Cache cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - installer-rs/target - key: ${{ runner.os }}-cargo-${{ hashFiles('installer-rs/Cargo.lock') }} - - name: Check formatting - run: cargo fmt --check - - - name: Clippy (lint) - run: cargo clippy -- -D warnings - - - name: Run Rust unit & integration tests - run: cargo test --verbose - - - name: Build binary - run: cargo build --release - - - name: Verify Cargo.lock is up to date - run: | - cargo update --dry-run 2>&1 - echo "✅ Cargo.lock is up to date" + run: nix fmt -- --check . python-ci: name: Python Installer & E2E Tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 151a58f..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Release Installer - -on: - push: - tags: - - 'v*' - -permissions: - contents: write - -jobs: - build-rust: - name: Build Rust Installer - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - installer-rs/target - key: ${{ runner.os }}-cargo-release-${{ hashFiles('installer-rs/Cargo.lock') }} - - - name: Populate embedded flake directory - run: | - rm -rf installer-rs/flake/* - cp flake.nix installer-rs/flake/ - cp flake.lock installer-rs/flake/ - cp README.md installer-rs/flake/ - cp -r hosts installer-rs/flake/hosts - cp -r home installer-rs/flake/home - cp -r modules installer-rs/flake/modules - cp -r lib installer-rs/flake/lib - cp -r flake installer-rs/flake/flake - cp -r assets installer-rs/flake/assets - - - name: Run Tests - working-directory: installer-rs - run: cargo test - - - name: Build Release Binary - working-directory: installer-rs - run: cargo build --release - - - name: Rename binary - run: cp installer-rs/target/release/northstar-installer installer-rs/northstar-installer - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: rust-binary - path: installer-rs/northstar-installer - - release: - name: Create Release - needs: [build-rust] - runs-on: ubuntu-latest - - steps: - - name: Download Rust binary - uses: actions/download-artifact@v4 - with: - name: rust-binary - - - name: Create Release - uses: softprops/action-gh-release@v2 - with: - generate_release_notes: true - files: | - northstar-installer - fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore index 572612f..580fe1a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ -result -installer-rs/target/ +result/ __pycache__/ *.pyc +.direnv/ -assets/wallpapers/Einsamer_Raum.jpg +# Ignore all wallpapers except rose pine fractal +assets/wallpapers/* +!assets/wallpapers/rose-pine-fractal.jpg diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6fd5a44 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,125 @@ +# Contributing to Northstar + +Thanks for your interest in contributing! This guide covers the development workflow. + +## Prerequisites + +- [Nix](https://nixos.org/download.html) with flakes enabled +- Add to `~/.config/nix/nix.conf`: + ``` + experimental-features = nix-command flakes pipe-operators + ``` + +## Development Shell + +Enter the dev shell for formatting, linting, and test tools: + +```bash +nix develop +``` + +This provides `nixfmt`, `nil` (Nix LSP), `python3`, `git`, and `jq`. + +## Adding a New Host + +1. Run the interactive installer (creates `hosts/` automatically): + ```bash + nix run . --impure + ``` + +2. Or create a host directory manually (`hosts/YourHostName` containing `default.nix`, `disko.nix`, and `hardware.nix`): + ```bash + mkdir -p hosts/YourHostName + sudo nixos-generate-config --show-hardware-config > hosts/YourHostName/hardware.nix + ``` + +3. Edit `hosts/YourHostName/default.nix` — set your username, profiles, GPU settings, etc. + +4. Configure your disk layout in `hosts/YourHostName/disko.nix` using `mkDisko`. + +5. Build and switch: + ```bash + sudo nixos-rebuild switch --flake .#YourHostName + ``` + +The host is **auto-discovered** — any directory in `hosts/` with both `default.nix` and `hardware.nix` is automatically registered as a NixOS configuration. + +## Adding a New Feature Module + +1. Create a new `.nix` file in the appropriate `modules/features//` directory. + +2. Follow the standard pattern: + ```nix + { config, lib, ... }: + let + cfg = config.northstar.features.yourfeature; + in + { + options.northstar.features.yourfeature.enable = + lib.mkEnableOption "description of your feature"; + + config = lib.mkIf cfg.enable { + # Your configuration here + }; + } + ``` + +3. The module is **auto-discovered** — `lib.scanModules` picks up any `.nix` file in `modules/` (except `default.nix` and files starting with `.` or `_`). + +4. Optionally add it to a profile in `modules/profiles/`. + +## Running Tests + +```bash +# Nix-native checks (module syntax, lib tests, eval tests) +nix flake check --impure + +# Python installer tests +python3 -m unittest discover -s tests -v + +# Format check +nix fmt -- --check . +``` + +## Code Formatting + +Format all Nix files: + +```bash +nix fmt +``` + +We use `nixfmt-rfc-style` (the RFC 166 formatter). + +## PR Workflow + +1. Create a feature branch: `git checkout -b feat/your-feature` +2. Make your changes +3. Run `nix fmt` to format +4. Run `nix flake check --impure` to verify +5. Commit and push +6. Open a PR against `main` + +## Directory Structure + +``` +northstar/ +├── flake.nix # Flake entry point +├── flake/ # flake-parts modules (hosts, installer, checks, devshell, formatter) +├── hosts/ # Host machine configs (auto-discovered) +│ ├── common.nix # Shared base config +│ └── / # Per-machine config +├── home/ +│ └── home.nix # Base Home Manager user environment +├── lib/ +│ ├── core.nix # Shared Nix helpers (scanModules, discoverHosts, mkDisko, mkUser, mkProfile) +│ └── disko/ +│ └── generator.nix # Dynamic mkDisko partition generator +├── modules/ # Option-based modules (auto-discovered) +│ ├── features/ # Vertical feature slices (core, desktop, dev, shell, terminals, tools) +│ ├── hardware/ # Hardware drivers (NVIDIA) +│ └── profiles/ # Composable feature bundles (base, desktop, workstation, gaming) +├── installer/ # Python interactive installer +├── tests/ # Python test suites +└── assets/ # Themes and media +``` diff --git a/README.md b/README.md index db72762..0a58aaa 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ NixOS Logo

-

❄️ Northstar-nix

+

❄️ Northstar

- A premium, modular, option-driven NixOS & Home Manager configuration built on + A modular, option-driven NixOS & Home Manager configuration built on flake-parts, Home Manager, disko, and modern @@ -16,7 +16,7 @@ NixOS Unstable flake-parts Pipe Operators - Lanzaboote Secure Boot + Limine Secure Boot License

@@ -24,305 +24,212 @@ ## ✨ Key Features -- **🎛️ Toggle-based modularity** — every system and user module is cleanly isolated behind `northstar...enable` options. -- **⚡ Functional Pipe Operator Composition** — leverages native experimental `pipe-operators` (`|>`) for clean functional transformations in profile bundles and discovery helpers. -- **📁 Auto-discovered configurations** — drop a host directory in `hosts/` or a module anywhere in `modules/` and it is dynamically detected, scanned, and wired up via `lib.scanModules`. -- **🛡️ UEFI Secure Boot & Configurable Bootloaders** — first-class support for **Lanzaboote Secure Boot** (`sbctl`), **Limine** (ultra-fast modern UEFI bootloader), and **GRUB 2** styled with the cyber DedSec theme. -- **🤖 Dedicated AI/ML & Gaming Workstations** — out-of-the-box modules for local LLMs (Ollama, PyTorch, Llama.cpp with CUDA/ROCm acceleration) and high-performance gaming (Steam, Gamemode, Gamescope, MangoHud, Wine/Proton, and controller drivers). -- **🛠️ Automated USB Auto-mounting** — seamless external device detection, auto-mounting, and desktop notifications via `udisks2`, `gvfs`, and `udiskie`. -- **💻 Decoupled Development Workspaces** — developer toolchains (compilers, LSPs, direnv, git, container runtimes, virtualization) isolated into clean workstation profiles. -- **🚀 Dual Async Installers** — both an interactive Ratatui TUI installer (`installer-rs`) with live hardware detection & split log viewing, and an automated Python installer (`installer/install.py`). -- **💾 Declarative Dual-Boot & Disko** — supports whole-disk formatting or partition-only dual-boot layouts with Btrfs subvolumes (`/root`, `/home`, `/nix`, `/var/log`, `/swap`) and auto-chainloading for Windows, Fedora, Ubuntu, and Arch. -- **🔄 Idempotency & Checkpointing** — execution checkpoints allow resuming interrupted installations seamlessly. - ---- - -## 📂 Directory Structure - -The repository is organized into distinct domain layers: - -``` -northstar/ -├── flake.nix # Flake entry point (inputs, flake-parts wire-up) -├── flake.lock -│ -├── flake/ # flake-parts modules -│ ├── hosts.nix # Host discovery & nixosConfigurations exports -│ ├── installer.nix # Python installer package definition -│ └── rust-installer.nix # Rust TUI installer package definition (crane) -│ -├── hosts/ # Host machine configurations -│ ├── common.nix # Shared system base config (flakes, pipe-operators) -│ └── / # Per-machine system settings & hardware declarations -│ ├── default.nix # Host entry point (users, profiles, bootloader, GPU) -│ ├── disko.nix # Declarative partition scheme -│ └── hardware.nix # Hardware scans (kernel modules, CPU microcode) -│ -├── lib/ # Shared Nix helpers and discovery templates -│ ├── default.nix # Module discovery (lib.scanModules), host discovery -│ └── disko/ -│ ├── btrfs.nix # Whole-disk Btrfs layout with subvolumes -│ └── ext4.nix # Whole-disk Ext4 layout -│ -├── home/ -│ └── default.nix # Home Manager configuration entry point -│ -├── modules/ # Option-based module definitions -│ ├── features/ # Vertical feature slices -│ │ ├── core/ # Boot, env, fonts, locale, networking, packages, shells -│ │ ├── desktop/ # Audio, display, browsers, Hyprland, Niri, Noctalia, Caelestia, Gaming -│ │ ├── development/ # Dev defaults, toolchains, AI/ML, git, Emacs, virtualization -│ │ ├── shell/ # Fish, Zsh, Starship, Oh My Posh -│ │ ├── terminals/ # Ghostty, Kitty -│ │ └── tools/ # Eza, Fzf, Tmux, Yazi, Zoxide -│ ├── hardware/ # Hardware drivers (NVIDIA, Prime) -│ └── profiles/ # Composable feature bundles (base, desktop, workstation, gaming) -│ -├── installer-rs/ # Ratatui TUI async installer source (Rust) -│ ├── src/ # App state, hardware detection, UI widgets, Disko generators -│ └── tests/ # Automated unit test suite (21 tests) -├── installer/ # Python interactive installer source -├── tests/ # Python installer & E2E verification test suite (167 tests) -└── assets/ # DedSec Plymouth splash, themes, and media assets -``` +- **🎛️ Toggle-based modularity** — every module is behind `northstar...enable` options +- **⚡ Pipe Operator Composition** — leverages native `pipe-operators` (`|>`) for clean functional transformations +- **📁 Auto-discovery** — drop a host directory in `hosts/` or a module in `modules/` and it's automatically wired up +- **🛡️ UEFI Secure Boot** — native Secure Boot via Limine as the sole UEFI bootloader +- **💾 Dynamic Disko** — `mkDisko` generates partition configs for whole-disk or dual-boot layouts with Btrfs/Ext4 +- **🤖 AI/ML & Gaming** — out-of-the-box modules for Ollama, PyTorch, CUDA/ROCm, Steam, Gamescope, and more +- **🔐 Secrets Management** — integrated sops-nix & age key management +- **🚀 Interactive Installer** — Python-based installer with hardware detection and guided setup --- ## 🚀 Quick Start -### 1. Installation from a NixOS Live USB - -Before running the installers, export the flake and experimental feature flags (including **pipe operators**): +### Fresh Install from NixOS Live USB ```bash +# Enable experimental features export NIX_CONFIG="experimental-features = nix-command flakes pipe-operators" -``` - -> [!TIP] -> **Option A (Recommended): Pre-built Rust TUI Installer Binary** -> ```bash -> curl -fsSL https://github.com/reze-dev/northstar/releases/latest/download/northstar-installer -o installer -> chmod +x installer && sudo ./installer -> ``` - -> [!NOTE] -> **Option B: Run Rust Installer via Nix** -> ```bash -> nix run github:reze-dev/northstar#rust-install --impure -> ``` - -> [!NOTE] -> **Option C: Run Python Installer via Nix** -> ```bash -> nix run github:reze-dev/northstar --impure -> ``` - ---- -### 2. Post-installation System Management +# Run the installer +nix run github:reze-dev/northstar --impure +``` -To apply configuration changes on an installed system: +### Adding a Host ```bash +# Clone the repo locally +git clone https://github.com/reze-dev/northstar ~/northstar cd ~/northstar + +# Run the interactive installer (creates hosts/ automatically) +nix run . --impure + +# Build and switch to your host sudo nixos-rebuild switch --flake .# ``` -For example, for the `Makima` host: -```bash -sudo nixos-rebuild switch --flake .#Makima -``` +See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide on adding hosts and modules. + +### Day-to-Day Workflow -To update flake inputs and dependencies: ```bash +# Apply configuration changes +sudo nixos-rebuild switch --flake .# + +# Update all flake inputs nix flake update + +# Format code +nix fmt + +# Run checks +nix flake check --impure +``` + +--- + +## 📂 Directory Structure + +``` +northstar/ +├── flake.nix # Flake entry point (inputs + flake-parts wire-up) +├── flake/ # flake-parts modules +│ ├── hosts.nix # Host discovery & nixosConfigurations +│ ├── installer.nix # Python installer package +│ ├── checks.nix # Nix-native test checks +│ ├── devshell.nix # Development shell +│ └── formatter.nix # Code formatter (nixfmt-rfc-style) +│ +├── hosts/ # Host machine configurations (auto-discovered) +│ ├── common.nix # Shared base config (flakes, HM, nix-index) +│ └── / # Per-machine config +│ ├── default.nix # Host entry point (users, profiles, GPU) +│ ├── disko.nix # Declarative partition scheme +│ └── hardware.nix # Hardware scan (from nixos-generate-config) +│ +├── lib/ # Shared Nix helpers +│ ├── core.nix # scanModules, discoverHosts, mkProfile, mkUser, mkDisko +│ └── disko/ +│ └── generator.nix # Dynamic mkDisko partition generator +│ +├── home/ +│ └── home.nix # Base Home Manager user environment +│ +├── modules/ # Option-based modules (auto-discovered) +│ ├── features/ # Vertical feature slices +│ │ ├── core/ # Boot, env, fonts, locale, networking, packages, shells, secrets +│ │ ├── desktop/ # Audio, display, browsers, Hyprland, Niri, Noctalia, Gaming, desktop packages +│ │ ├── development/ # Dev tools, AI/ML, git, Emacs, virtualization +│ │ ├── shell/ # Fish, Zsh, Starship +│ │ ├── terminals/ # Ghostty, Kitty +│ │ └── tools/ # Eza, Fzf, Tmux, Yazi, Zoxide +│ ├── hardware/ # Hardware drivers (NVIDIA, Prime) +│ └── profiles/ # Composable feature bundles +│ +├── installer/ # Python interactive installer +├── tests/ # Python test suites +└── assets/ # DedSec Plymouth splash, wallpapers ``` --- ## 🎛️ Profiles & Feature Bundles -Northstar uses clean profile bundles composed via the native Nix **pipe operator** (`|>`): +Profiles are composable feature bundles. Enable them in your host config: ```nix -# Example from modules/profiles/desktop.nix -northstar.features = features |> (f: lib.genAttrs f (_: { enable = true; })); +northstar.profiles = { + desktop.enable = true; + workstation.enable = true; +}; ``` -### Available Profiles (`northstar.profiles.*`) - -| Profile | Option | Description | -| :--- | :--- | :--- | -| **Base** | `northstar.profiles.base.enable` | Minimal headless/server system (boot, networking, SSH, neovim, shells, fonts, locales, env) | -| **Desktop** | `northstar.profiles.desktop.enable` | Full graphical workstation (Audio, Bluetooth, Hyprland, Niri, Noctalia, Firefox, Zen, Ghostty, Kitty, XDG, Power, Udiskie) | -| **Workstation** | `northstar.profiles.workstation.enable` | Desktop + Full Developer Toolchains, Docker/Libvirt virtualization, AI/ML SDKs, Emacs, and Direnv | -| **Gaming** | `northstar.profiles.gaming.enable` | Dedicated gaming environment (Steam, Gamemode, Gamescope, MangoHud, Wine/Proton, Lutris, latency tweaks, controllers) | +| Profile | Description | +| :--- | :--- | +| **Base** | Minimal system (boot, networking, SSH, neovim, shells, fonts, locales) — always enabled | +| **Desktop** | Full graphical workstation (Audio, Bluetooth, Hyprland, Niri, Noctalia, browsers, etc.) | +| **Workstation** | Developer tools, shells, editors, containers, virtualization | +| **Gaming** | Steam, Gamemode, Gamescope, MangoHud, Wine/Proton, Lutris, controllers | --- ## 🔧 Module Reference -### Vertical Feature Modules (`northstar.features.*`) - -#### Core & System -| Module | Option | Description | -| :--- | :--- | :--- | -| **Boot** | `northstar.features.boot.enable` | Configurable bootloader (`grub` or `limine`) and Plymouth splash | -| **Bootloader** | `northstar.features.boot.loader` | Choose `"grub"` (with DedSec cyberpunk theme) or `"limine"` (fast modern UEFI) | -| **Secure Boot**| `northstar.features.boot.secureBoot.enable` | Lanzaboote UEFI Secure Boot integration + `sbctl` package | -| **Networking**| `northstar.features.networking.enable` | NetworkManager daemon + customized firewall settings | -| **Locales** | `northstar.features.locales.enable` | Timezone, keyboard layout, and i18n locales | -| **Fonts** | `northstar.features.fonts.enable` | Curated Nerd Fonts collection | -| **Packages** | `northstar.features.packages.enable` | Base system utilities, `udisks2`, `gvfs`, and core CLI tools | -| **SSH** | `northstar.features.ssh.enable` | OpenSSH daemon | -| **Environment**| `northstar.features.env.enable` | Global environment variables (`EDITOR`, `VISUAL`, etc.) | - -#### Desktop & Window Managers -| Module | Option | Description | -| :--- | :--- | :--- | -| **Hyprland** | `northstar.features.hyprland.enable` | Dynamic tiling Wayland compositor with animations & keybinds | -| **Niri** | `northstar.features.niri.enable` | Scrollable-tiling Wayland compositor | -| **Noctalia** | `northstar.features.noctalia.enable` | Noctalia Wayland desktop shell | -| **Caelestia** | `northstar.features.caelestia.enable` | Caelestia desktop shell integration | -| **Display** | `northstar.features.display.enable` | Greetd login manager with `tuigreet` | -| **Audio** | `northstar.features.audio.enable` | PipeWire audio stack, Low-latency ALSA, and PulseAudio emu | -| **Bluetooth** | `northstar.features.bluetooth.enable` | BlueZ daemon + Blueman applet | -| **Firefox** | `northstar.features.firefox.enable` | Firefox web browser | -| **Zen Browser**| `northstar.features.zen-browser.enable` | Zen Browser flake integration | -| **Gaming** | `northstar.features.desktop.gaming.enable` | Steam, Gamemode, Gamescope, MangoHud, Wine, Lutris, controllers | -| **Power** | `northstar.features.power.enable` | UPower daemon + power-profiles-daemon | -| **udiskie** | `northstar.features.udiskie.enable` | Auto-mounting daemon for removable media & USB drives | -| **CUPS** | `northstar.features.cups.enable` | Printing support via CUPS daemon | - -#### Development & AI/ML -| Module | Option | Description | -| :--- | :--- | :--- | -| **AI / ML** | `northstar.features.development.aiml.enable` | Ollama LLM server, PyTorch, Llama.cpp, JupyterLab, CUDA/ROCm SDKs | -| **DevTools** | `northstar.features.devtools.enable` | Compilers (GCC/Clang/Go/Rustup/Zig/JDK/Haskell) and LSPs | -| **Dev Defaults**| `northstar.features.dev.enable` | System-wide direnv, git, gpg, and `nix-ld` configuration | -| **Virtualization**| `northstar.features.virtualization.enable` | Docker, Libvirtd, QEMU/KVM, and Virt-Manager | -| **Emacs** | `northstar.features.emacs.enable` | Emacs daemon running under systemd | -| **Neovim** | `northstar.features.neovim.enable` | Curated Neovim editor configuration | -| **direnv** | `northstar.features.direnv.enable` | Per-directory automatic shell environments | -| **Git** | `northstar.features.git.enable` | Git configuration, aliases, and signing keys | - -#### Shells, Terminals & CLI Tools -| Module | Option | Description | -| :--- | :--- | :--- | -| **Zsh** | `northstar.features.zsh.enable` | Zsh shell with plugins, autocompletions, and syntax highlighting | -| **Fish** | `northstar.features.fish.enable` | Fish shell with friendly prompt integrations | -| **Ghostty** | `northstar.features.ghostty.enable` | Ghostty GPU-accelerated terminal emulator | -| **Kitty** | `northstar.features.kitty.enable` | Kitty GPU-accelerated terminal emulator | -| **Starship** | `northstar.features.starship.enable` | Starship fast cross-shell prompt | -| **Oh My Posh**| `northstar.features.omp.enable` | Oh My Posh prompt theming engine | -| **Tmux** | `northstar.features.tmux.enable` | Tmux multiplexer with `tmux-powerkit` theme | -| **Yazi** | `northstar.features.yazi.enable` | Fast terminal file manager with image previews | -| **Eza** | `northstar.features.eza.enable` | Modern `ls` replacement with git status & icons | -| **Fzf** | `northstar.features.fzf.enable` | Fuzzy finder CLI integrations | -| **Zoxide** | `northstar.features.zoxide.enable` | Smarter `cd` command with memory-based navigation | - -#### Hardware Acceleration & GPUs -| Module | Option | Description | -| :--- | :--- | :--- | -| **NVIDIA** | `northstar.nvidia.enable` | Proprietary NVIDIA drivers & CUDA support | -| **NVIDIA Prime**| `northstar.nvidia.prime.enable` | Hybrid graphics offload (NVIDIA discrete + Intel/AMD integrated) | -| **Prime Bus IDs**| `northstar.nvidia.prime.nvidiaBusId` / `intelBusId` / `amdgpuBusId` | PCI bus identifiers (auto-formatted as `PCI:X:Y:Z`) | +### Core & System (`northstar.features.*`) +| Module | Description | +| :--- | :--- | +| `boot` | Bootloader (Limine), Plymouth splash, Secure Boot (Limine) | +| `networking` | NetworkManager + configurable `/etc/hosts` | +| `locales` | Timezone, keyboard, i18n | +| `fonts` | Curated Nerd Fonts | +| `packages` | Base system utilities | +| `ssh` | OpenSSH daemon | +| `env` | Environment variables (EDITOR, VISUAL, BROWSER) | +| `secrets` | sops-nix & age secret management | + +### Desktop (`northstar.features.*`) +| Module | Description | +| :--- | :--- | +| `hyprland` | Dynamic tiling Wayland compositor | +| `niri` | Scrollable-tiling Wayland compositor | +| `noctalia` | Noctalia Wayland shell | +| `caelestia` | Caelestia desktop shell | +| `display` | Greetd login manager with tuigreet | +| `audio` | PipeWire audio stack | +| `bluetooth` | BlueZ + Blueman | +| `firefox` / `zen-browser` | Web browsers | +| `gaming` | Steam, Gamemode, Gamescope, Wine, Lutris | +| `xdg` | XDG portals & MIME associations | +| `desktopPackages` | Wayland/GUI desktop utilities (polkit, clipboard, screenshot, file manager) | + +### Development (`northstar.features.*`) +| Module | Description | +| :--- | :--- | +| `dev` | direnv, git, gpg, nix-ld | +| `devtools` | Compilers (GCC, Clang, Go, Rust, Zig, JDK, Haskell) & LSPs | +| `development.aiml` | Ollama, PyTorch, Llama.cpp, JupyterLab, CUDA/ROCm | +| `virtualization` | Docker, Libvirtd, QEMU/KVM | +| `emacs` | Emacs daemon | + +### Hardware (`northstar.nvidia.*`) +| Option | Description | +| :--- | :--- | +| `enable` | Proprietary NVIDIA drivers | +| `prime.enable` | Hybrid GPU (NVIDIA + Intel/AMD) | +| `prime.nvidiaBusId` / `intelBusId` / `amdgpuBusId` | PCI bus IDs | --- -## 🛠️ Customizing a Host Configuration +## 💾 Disk Configuration (mkDisko) -Here is an example host configuration (`hosts/Makima/default.nix`): +Northstar provides `mkDisko` for declarative disk layouts: ```nix -{ - config, - lib, - pkgs, - ... -}: - -{ - imports = [ - ./disko.nix - ]; - - home-manager.users.reze = { - imports = [ ../../home ]; - home.username = lib.mkForce "reze"; - home.homeDirectory = lib.mkForce "/home/reze"; - }; - - # 1. Bootloader Selection (GRUB with DedSec theme, or modern Limine) - northstar.features.boot.loader = "grub"; # or "limine" - - # Optional: Dual-boot chainloader entries - boot.loader.grub.extraEntries = '' - menuentry "Fedora Linux" { - search --fs-uuid --set=root CB41-6695 - chainloader /EFI/fedora/shimx64.efi - } - ''; - - # 2. User Accounts - users.users.reze = { - isNormalUser = true; - description = "reze"; - extraGroups = [ "networkmanager" "wheel" "libvirtd" "docker" ]; - shell = pkgs.zsh; - hashedPassword = "$6$example..."; # Generated via mkpasswd -m sha-512 - }; - - # 3. Northstar Profile Presets - northstar.profiles = { - desktop.enable = true; - workstation.enable = true; - gaming.enable = true; - }; - - # 4. Granular Feature Overrides (Optional) - northstar.features = { - hyprland.enable = true; - ghostty.enable = true; - development.aiml.enable = true; - }; - - # 5. Hybrid GPU (NVIDIA Prime) - northstar.nvidia.enable = true; - northstar.nvidia.prime = { - enable = true; - nvidiaBusId = "PCI:1:0:0"; - amdgpuBusId = "PCI:5:0:0"; - }; - - networking.hostName = "Makima"; - system.stateVersion = "26.11"; +# Whole-disk (wipes entire drive) +northstar.mkDisko { + mode = "whole-disk"; + device = "/dev/nvme0n1"; + fsType = "btrfs"; # or "ext4" + efiSize = "2G"; # 2GB default — safe for Limine + many NixOS generations + swapSize = "16G"; # default 16G — "0" to disable +} + +# Partition-only (dual-boot safe) +northstar.mkDisko { + mode = "partition-only"; + nixosPart = "/dev/nvme0n1p4"; + efiDevice = "/dev/disk/by-uuid/XXXX-XXXX"; + fsType = "btrfs"; + swapSize = "16G"; } ``` --- -## 🧪 Testing & Quality Assurance - -Both the Rust TUI installer and Python installer are backed by automated unit and integration tests: +## 🧪 Testing -### Running the Rust Test Suite (21 Tests) ```bash -cargo test --manifest-path installer-rs/Cargo.toml -``` +# Nix-native checks (module syntax, lib tests, eval tests) +nix flake check --impure -### Running the Python Test Suite (167 Tests) -```bash -python3 -m unittest discover tests -``` +# Python installer tests +python3 -m unittest discover -s tests -v -### Validating Flake Builds & Nix Evaluations -```bash -# Evaluate top-level system generation +# Evaluate host configuration nix eval --impure .#nixosConfigurations.Makima.config.system.build.toplevel.name - -# Build the Rust installer package via Crane -nix build --impure .#rust-installer --no-link ``` --- diff --git a/assets/wallpapers/11356475.png b/assets/wallpapers/11356475.png deleted file mode 100644 index ccf2a7b..0000000 Binary files a/assets/wallpapers/11356475.png and /dev/null differ diff --git a/assets/wallpapers/Arizona_night.jpg b/assets/wallpapers/Arizona_night.jpg deleted file mode 100755 index a7a7bf2..0000000 Binary files a/assets/wallpapers/Arizona_night.jpg and /dev/null differ diff --git a/assets/wallpapers/Konachan.com - 367007 all_male animal animated bird cigarette clouds lennsan male original reflection sky smoking tree water.gif b/assets/wallpapers/Konachan.com - 367007 all_male animal animated bird cigarette clouds lennsan male original reflection sky smoking tree water.gif deleted file mode 100755 index fb37154..0000000 Binary files a/assets/wallpapers/Konachan.com - 367007 all_male animal animated bird cigarette clouds lennsan male original reflection sky smoking tree water.gif and /dev/null differ diff --git a/assets/wallpapers/Konachan.com - 367008 animated building city clouds landscape lennsan no_humans original petals scenic sky sunset tree.gif b/assets/wallpapers/Konachan.com - 367008 animated building city clouds landscape lennsan no_humans original petals scenic sky sunset tree.gif deleted file mode 100755 index 28e6fd9..0000000 Binary files a/assets/wallpapers/Konachan.com - 367008 animated building city clouds landscape lennsan no_humans original petals scenic sky sunset tree.gif and /dev/null differ diff --git a/assets/wallpapers/Konachan.com - 367009 animal animated bird building city clouds flowers lennsan no_humans original petals polychromatic reflection scenic sky train tree water.gif b/assets/wallpapers/Konachan.com - 367009 animal animated bird building city clouds flowers lennsan no_humans original petals polychromatic reflection scenic sky train tree water.gif deleted file mode 100755 index b9c0579..0000000 Binary files a/assets/wallpapers/Konachan.com - 367009 animal animated bird building city clouds flowers lennsan no_humans original petals polychromatic reflection scenic sky train tree water.gif and /dev/null differ diff --git a/assets/wallpapers/Konachan.com - 367010 animated autumn building clouds leaves lennsan no_humans original scenic shrine sky sunset tree.gif b/assets/wallpapers/Konachan.com - 367010 animated autumn building clouds leaves lennsan no_humans original scenic shrine sky sunset tree.gif deleted file mode 100755 index 7ca9bd1..0000000 Binary files a/assets/wallpapers/Konachan.com - 367010 animated autumn building clouds leaves lennsan no_humans original scenic shrine sky sunset tree.gif and /dev/null differ diff --git a/flake.lock b/flake.lock index 038dd2c..5db808a 100644 --- a/flake.lock +++ b/flake.lock @@ -80,54 +80,61 @@ "type": "github" } }, - "crane": { + "determinate": { + "inputs": { + "determinate-nixd-aarch64-darwin": "determinate-nixd-aarch64-darwin", + "determinate-nixd-aarch64-linux": "determinate-nixd-aarch64-linux", + "determinate-nixd-x86_64-linux": "determinate-nixd-x86_64-linux", + "nix": "nix", + "nixpkgs": "nixpkgs_2" + }, "locked": { - "lastModified": 1785782307, - "narHash": "sha256-MPaRdVkf6zZP5fCPxYCi8Dr4pZzgmXzg8T9nVEbp3Mw=", - "owner": "ipetkov", - "repo": "crane", - "rev": "2c71e194474d13de031d729b729c968ddbe3507f", - "type": "github" + "lastModified": 1787168041, + "narHash": "sha256-KDdZe7z3BA1dYet02fDFTUplCl0gha+2ltGxMgCVA00=", + "rev": "6069a65ab09f683cfd9cb17b21a66a366c8077da", + "revCount": 432, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/determinate/3.22.1/01a01b87-a478-7c30-bfa0-e436091ec74b/source.tar.gz" }, "original": { - "owner": "ipetkov", - "repo": "crane", - "type": "github" + "type": "tarball", + "url": "https://flakehub.com/f/DeterminateSystems/determinate/3" } }, - "crane_2": { + "determinate-nixd-aarch64-darwin": { + "flake": false, "locked": { - "lastModified": 1785782307, - "narHash": "sha256-MPaRdVkf6zZP5fCPxYCi8Dr4pZzgmXzg8T9nVEbp3Mw=", - "owner": "ipetkov", - "repo": "crane", - "rev": "2c71e194474d13de031d729b729c968ddbe3507f", - "type": "github" + "narHash": "sha256-wMxUMoLK3e68VTDdI8JC6fZKjBux7Meh8U+3g/wnKSE=", + "type": "file", + "url": "https://install.determinate.systems/determinate-nixd/tag/v3.22.1/macOS" }, "original": { - "owner": "ipetkov", - "repo": "crane", - "type": "github" + "type": "file", + "url": "https://install.determinate.systems/determinate-nixd/tag/v3.22.1/macOS" } }, - "dedsec-grub-theme": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] + "determinate-nixd-aarch64-linux": { + "flake": false, + "locked": { + "narHash": "sha256-LqG2aJXwkAeemdmDvhWjADh2ZpadI32jrsFfCsZTa1Q=", + "type": "file", + "url": "https://install.determinate.systems/determinate-nixd/tag/v3.22.1/aarch64-linux" }, + "original": { + "type": "file", + "url": "https://install.determinate.systems/determinate-nixd/tag/v3.22.1/aarch64-linux" + } + }, + "determinate-nixd-x86_64-linux": { + "flake": false, "locked": { - "lastModified": 1662253348, - "narHash": "sha256-Cq/espQSpscC89oIuJGE4IP2p6zYpuzp88L6M38uayA=", - "owner": "VandalByte", - "repo": "dedsec-grub-theme", - "rev": "8ccb1beb831f459987522d58263b076827624119", - "type": "gitlab" + "narHash": "sha256-sXxr7bL0luNTafC9rvjh34sE1nrub0UQjhB9KmHjum0=", + "type": "file", + "url": "https://install.determinate.systems/determinate-nixd/tag/v3.22.1/x86_64-linux" }, "original": { - "owner": "VandalByte", - "repo": "dedsec-grub-theme", - "type": "gitlab" + "type": "file", + "url": "https://install.determinate.systems/determinate-nixd/tag/v3.22.1/x86_64-linux" } }, "disko": { @@ -183,6 +190,27 @@ } }, "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "determinate", + "nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1782949081, + "narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", + "rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e", + "revCount": 480, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/hercules-ci/flake-parts/0.1.480%2Brev-17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e/019f2195-dee5-7233-9747-eca0c27f7406/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/hercules-ci/flake-parts/0.1" + } + }, + "flake-parts_2": { "inputs": { "nixpkgs-lib": "nixpkgs-lib" }, @@ -200,6 +228,28 @@ "type": "github" } }, + "git-hooks-nix": { + "inputs": { + "flake-compat": "flake-compat", + "nixpkgs": [ + "determinate", + "nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1784288435, + "narHash": "sha256-ReRHaLgr/uVqdD8afFSn+myXIfpHeOhP0yYe0TJqAA8=", + "rev": "43b3c1ab9d40fb1dbb008f451988a91e375825e9", + "revCount": 1231, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/cachix/git-hooks.nix/0.1.1231%2Brev-43b3c1ab9d40fb1dbb008f451988a91e375825e9/019f7135-8fdf-76f0-b1a1-d2c67e91af8d/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/cachix/git-hooks.nix/0.1.941" + } + }, "home-manager": { "inputs": { "nixpkgs": [ @@ -311,7 +361,7 @@ "hyprutils": "hyprutils", "hyprwayland-scanner": "hyprwayland-scanner", "hyprwire": "hyprwire", - "nixpkgs": "nixpkgs", + "nixpkgs": "nixpkgs_3", "pre-commit-hooks": "pre-commit-hooks", "systems": "systems", "xdph": "xdph" @@ -563,29 +613,6 @@ "type": "github" } }, - "lanzaboote": { - "inputs": { - "crane": "crane_2", - "nixpkgs": [ - "nixpkgs" - ], - "pre-commit": "pre-commit", - "rust-overlay": "rust-overlay" - }, - "locked": { - "lastModified": 1786958661, - "narHash": "sha256-DAH84NNJezuk7M4WfAZtLQzJnofpXJFZFVoTPgCyUC0=", - "owner": "nix-community", - "repo": "lanzaboote", - "rev": "b1b7d87faa06649f7c72666107bac2c6e6459c23", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "lanzaboote", - "type": "github" - } - }, "m3shapes": { "flake": false, "locked": { @@ -661,6 +688,27 @@ "type": "github" } }, + "nix": { + "inputs": { + "flake-parts": "flake-parts", + "git-hooks-nix": "git-hooks-nix", + "nixpkgs": "nixpkgs", + "nixpkgs-23-11": "nixpkgs-23-11", + "nixpkgs-regression": "nixpkgs-regression" + }, + "locked": { + "lastModified": 1787146895, + "narHash": "sha256-zKZlnOTtMHwri2kxYPziIGkLL7Q9go0CrwGbZ/7wD6s=", + "rev": "9dee162cae223df4d358182b4871ebad258743aa", + "revCount": 27234, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/nix-src/3.22.1/01a01a62-3751-7452-879c-be17f74a7df2/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/DeterminateSystems/nix-src/%2A" + } + }, "nix-index-database": { "inputs": { "nixpkgs": [ @@ -683,17 +731,31 @@ }, "nixpkgs": { "locked": { - "lastModified": 1786247143, - "narHash": "sha256-8S3Kcxs7D4UtxJxSJZz0m14CGhuW0MxfrIwJxeGWGnQ=", + "lastModified": 1784160687, + "narHash": "sha256-iYL/bixrb6FlHFu/gIuBYzq6c6lM5AAXsXNSWXtIgQc=", + "rev": "4382ed2b7a6839d4280a9b386db49cbc5907414d", + "revCount": 1009383, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2605.1009383%2Brev-4382ed2b7a6839d4280a9b386db49cbc5907414d/019f6c11-ad78-7500-a194-d18a5bad0fbe/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/NixOS/nixpkgs/0.2605" + } + }, + "nixpkgs-23-11": { + "locked": { + "lastModified": 1717159533, + "narHash": "sha256-oamiKNfr2MS6yH64rUn99mIZjc45nGJlj9eGth/3Xuw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "279b4a8275f032c566576b3f181fa0f27197f588", + "rev": "a62e6edd6d5e1fa0329b8653c801147986f8d446", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-unstable", "repo": "nixpkgs", + "rev": "a62e6edd6d5e1fa0329b8653c801147986f8d446", "type": "github" } }, @@ -712,6 +774,22 @@ "type": "github" } }, + "nixpkgs-regression": { + "locked": { + "lastModified": 1643052045, + "narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "type": "github" + } + }, "nixpkgs-stable": { "locked": { "lastModified": 1782847189, @@ -730,11 +808,25 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1787001381, - "narHash": "sha256-Ue1Yo8gfHdD4TMtNewhA4tkSYeFqXThju0nCyJc3ALo=", + "lastModified": 1786247143, + "narHash": "sha256-8S3Kcxs7D4UtxJxSJZz0m14CGhuW0MxfrIwJxeGWGnQ=", + "rev": "279b4a8275f032c566576b3f181fa0f27197f588", + "revCount": 1050399, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/nixpkgs-weekly/0.1.1050399%2Brev-279b4a8275f032c566576b3f181fa0f27197f588/01a01364-0c36-7a91-b20d-31b3a834ddeb/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/DeterminateSystems/nixpkgs-weekly/0.1" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1786247143, + "narHash": "sha256-8S3Kcxs7D4UtxJxSJZz0m14CGhuW0MxfrIwJxeGWGnQ=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "ec2d622de0773551768cf98f3fc50cbcc003b9c5", + "rev": "279b4a8275f032c566576b3f181fa0f27197f588", "type": "github" }, "original": { @@ -744,7 +836,23 @@ "type": "github" } }, - "nixpkgs_3": { + "nixpkgs_4": { + "locked": { + "lastModified": 1787135253, + "narHash": "sha256-RD2kNWCG+Bjo6h+JVjWVNntZs2GtRoeY2xHjts/FNkA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ffb3c9b700e759be2ef13237c9d8f953b32a1e46", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_5": { "locked": { "lastModified": 1786599213, "narHash": "sha256-yjGG65gA1jORt0hWxjzrnw3AYkIn8EWolp8vezwXZ3I=", @@ -757,7 +865,7 @@ "url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.zst" } }, - "nixpkgs_4": { + "nixpkgs_6": { "locked": { "lastModified": 1768127708, "narHash": "sha256-1Sm77VfZh3mU0F5OqKABNLWxOuDeHIlcFjsXeeiPazs=", @@ -773,7 +881,7 @@ "type": "github" } }, - "nixpkgs_5": { + "nixpkgs_7": { "locked": { "lastModified": 1786599213, "narHash": "sha256-yNJd40f11EzXBjSByCB7IPpeFFAdeoSKKM67dGkfFoU=", @@ -791,7 +899,7 @@ }, "noctalia": { "inputs": { - "nixpkgs": "nixpkgs_3" + "nixpkgs": "nixpkgs_5" }, "locked": { "lastModified": 1787021519, @@ -807,31 +915,9 @@ "type": "github" } }, - "pre-commit": { - "inputs": { - "flake-compat": "flake-compat_2", - "nixpkgs": [ - "lanzaboote", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1784288435, - "narHash": "sha256-ReRHaLgr/uVqdD8afFSn+myXIfpHeOhP0yYe0TJqAA8=", - "owner": "cachix", - "repo": "git-hooks.nix", - "rev": "43b3c1ab9d40fb1dbb008f451988a91e375825e9", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "git-hooks.nix", - "type": "github" - } - }, "pre-commit-hooks": { "inputs": { - "flake-compat": "flake-compat", + "flake-compat": "flake-compat_2", "nixpkgs": [ "hyprland", "nixpkgs" @@ -875,43 +961,20 @@ "root": { "inputs": { "caelestia-shell": "caelestia-shell", - "crane": "crane", - "dedsec-grub-theme": "dedsec-grub-theme", + "determinate": "determinate", "disko": "disko", - "flake-parts": "flake-parts", + "flake-parts": "flake-parts_2", "home-manager": "home-manager", "hyprland": "hyprland", - "lanzaboote": "lanzaboote", "niri": "niri", "nix-index-database": "nix-index-database", - "nixpkgs": "nixpkgs_2", + "nixpkgs": "nixpkgs_4", "noctalia": "noctalia", "sops-nix": "sops-nix", "tmux-powerkit": "tmux-powerkit", "zen-browser": "zen-browser" } }, - "rust-overlay": { - "inputs": { - "nixpkgs": [ - "lanzaboote", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1786849542, - "narHash": "sha256-MS8cOa/ii1+QA8R3JWVzqEIoXimu9n50GwQDiBVTFOM=", - "owner": "oxalica", - "repo": "rust-overlay", - "rev": "b211eadeba8b180da9453ec3413a8a3535c85b3f", - "type": "github" - }, - "original": { - "owner": "oxalica", - "repo": "rust-overlay", - "type": "github" - } - }, "sops-nix": { "inputs": { "nixpkgs": [ @@ -949,7 +1012,7 @@ }, "tmux-powerkit": { "inputs": { - "nixpkgs": "nixpkgs_4" + "nixpkgs": "nixpkgs_6" }, "locked": { "lastModified": 1786964392, @@ -1042,7 +1105,7 @@ "zen-browser": { "inputs": { "home-manager": "home-manager_2", - "nixpkgs": "nixpkgs_5" + "nixpkgs": "nixpkgs_7" }, "locked": { "lastModified": 1787021741, diff --git a/flake.nix b/flake.nix index 6f66772..4900909 100644 --- a/flake.nix +++ b/flake.nix @@ -6,52 +6,59 @@ "https://cache.nixos.org/" "https://noctalia.cachix.org" "https://hyprland.cachix.org" + "https://install.determinate.systems" ]; extra-trusted-public-keys = [ "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" "noctalia.cachix.org-1:pCOR47nnMEo5thcxNDtzWpOxNFQsBRglJzxWPp3dkU4=" "hyprland.cachix.org-1:a7pgxzMz7+chwVL3/pzj6jIBMioiJM7ypFP8PwtkuGc=" + "cache.flakehub.com-3:hJuILl5sVK4iKm86JzgdXW12Y2Hwd5G07qKtHTOcDCM=" ]; }; inputs = { + # ── Core & Flake Infrastructure ───────────────────────────────────── nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-parts.url = "github:hercules-ci/flake-parts"; - zen-browser.url = "github:0xc000022070/zen-browser-flake"; - tmux-powerkit.url = "github:fabioluciano/tmux-powerkit"; - home-manager.url = "github:nix-community/home-manager/master"; - home-manager.inputs.nixpkgs.follows = "nixpkgs"; - hyprland.url = "git+https://github.com/hyprwm/Hyprland?submodules=1"; - niri.url = "github:sodiboo/niri-flake"; - niri.inputs.nixpkgs.follows = "nixpkgs"; - noctalia.url = "github:noctalia-dev/noctalia-shell"; - caelestia-shell.url = "github:caelestia-dots/shell"; - caelestia-shell.inputs.nixpkgs.follows = "nixpkgs"; - nix-index-database.url = "github:nix-community/nix-index-database"; - nix-index-database.inputs.nixpkgs.follows = "nixpkgs"; - disko.url = "github:nix-community/disko"; - disko.inputs.nixpkgs.follows = "nixpkgs"; - crane.url = "github:ipetkov/crane"; - dedsec-grub-theme.url = "gitlab:VandalByte/dedsec-grub-theme"; - dedsec-grub-theme.inputs.nixpkgs.follows = "nixpkgs"; - lanzaboote = { - url = "github:nix-community/lanzaboote"; + + # ── System Utilities & Nix Distribution ───────────────────────────── + determinate.url = "https://flakehub.com/f/DeterminateSystems/determinate/3"; + disko = { + url = "github:nix-community/disko"; inputs.nixpkgs.follows = "nixpkgs"; }; sops-nix = { url = "github:Mic92/sops-nix"; inputs.nixpkgs.follows = "nixpkgs"; }; + nix-index-database = { + url = "github:nix-community/nix-index-database"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + # ── Desktop & Wayland Ecosystem ───────────────────────────────────── + home-manager = { + url = "github:nix-community/home-manager/master"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + hyprland.url = "git+https://github.com/hyprwm/Hyprland?submodules=1"; + niri = { + url = "github:sodiboo/niri-flake"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + noctalia.url = "github:noctalia-dev/noctalia-shell"; + caelestia-shell = { + url = "github:caelestia-dots/shell"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + zen-browser.url = "github:0xc000022070/zen-browser-flake"; + tmux-powerkit.url = "github:fabioluciano/tmux-powerkit"; }; outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } { systems = [ "x86_64-linux" ]; - imports = [ - ./flake/hosts.nix - ./flake/installer.nix - ./flake/rust-installer.nix - ]; + imports = [ ./flake/modules.nix ]; }; } diff --git a/flake/checks.nix b/flake/checks.nix new file mode 100644 index 0000000..862c27e --- /dev/null +++ b/flake/checks.nix @@ -0,0 +1,82 @@ +# Flake-parts module: Nix-native checks via `nix flake check` +{ self, inputs, ... }: + +{ + perSystem = + { pkgs, system, ... }: + let + lib = inputs.nixpkgs.lib; + northstar = import ../lib/core.nix { inherit lib; }; + + in + { + checks = { + + # Verify lib functions behave correctly + lib-unit-tests = + pkgs.runCommand "check-lib-functions" + { + nativeBuildInputs = [ + pkgs.nix + pkgs.jq + ]; + NIX_CONF_DIR = pkgs.writeTextDir "nix.conf" '' + experimental-features = nix-command flakes pipe-operators + ''; + } + '' + export NIX_STATE_DIR=$TMPDIR/nix + export NIX_CACHE_DIR=$TMPDIR/nix-cache + mkdir -p $NIX_STATE_DIR $NIX_CACHE_DIR + + echo "=== Testing lib functions ===" + + # Test: scanModules finds modules + MODULE_COUNT=$(${pkgs.nix}/bin/nix eval --impure --expr ' + let + lib = import ${inputs.nixpkgs} { system = "${system}"; }; + northstar = import ${../lib/core.nix} { inherit (lib) lib; }; + in builtins.length (northstar.scanModules ${../modules}) + ') + echo "scanModules found $MODULE_COUNT modules" + if [ "$MODULE_COUNT" -lt 1 ]; then + echo "FAIL: scanModules returned 0 modules" + exit 1 + fi + + # Test: discoverHosts finds hosts + HOSTS=$(${pkgs.nix}/bin/nix eval --impure --json --expr ' + let + lib = import ${inputs.nixpkgs} { system = "${system}"; }; + northstar = import ${../lib/core.nix} { inherit (lib) lib; }; + in northstar.discoverHosts ${../hosts} + ') + echo "discoverHosts found: $HOSTS" + echo "$HOSTS" | jq -e 'length > 0' > /dev/null || { + echo "FAIL: discoverHosts returned 0 hosts" + exit 1 + } + + # Test: mkProfile produces correct attrs + PROFILE_KEYS=$(${pkgs.nix}/bin/nix eval --impure --json --expr ' + let + lib = import ${inputs.nixpkgs} { system = "${system}"; }; + northstar = import ${../lib/core.nix} { inherit (lib) lib; }; + profile = northstar.mkProfile ["boot" "ssh"]; + in builtins.attrNames profile.northstar.features + ') + echo "mkProfile keys: $PROFILE_KEYS" + echo "$PROFILE_KEYS" | jq -e 'index("boot") and index("ssh")' > /dev/null || { + echo "FAIL: mkProfile did not produce boot and ssh keys" + exit 1 + } + + echo "=== All lib tests passed ===" + touch $out + ''; + + # Verify the installer package builds + installer-builds = self.packages.${system}.installer; + }; + }; +} diff --git a/flake/devshell.nix b/flake/devshell.nix new file mode 100644 index 0000000..7327f0c --- /dev/null +++ b/flake/devshell.nix @@ -0,0 +1,35 @@ +# Flake-parts module: development shell for contributors +{ inputs, ... }: + +{ + perSystem = + { pkgs, system, ... }: + { + devShells.default = pkgs.mkShell { + name = "northstar-dev"; + packages = with pkgs; [ + # Nix tooling + inputs.determinate.packages.${system}.default + nixfmt + nil + nix-diff + + # Python (for installer tests) + python3 + + # General + git + jq + ]; + + shellHook = '' + echo "❄️ Northstar development shell" + echo "" + echo " nix flake check --impure — run all checks" + echo " nix fmt — format all Nix files" + echo " python3 -m unittest discover -s tests -v — run Python tests" + echo "" + ''; + }; + }; +} diff --git a/flake/formatter.nix b/flake/formatter.nix new file mode 100644 index 0000000..b16ea6a --- /dev/null +++ b/flake/formatter.nix @@ -0,0 +1,10 @@ +# Flake-parts module: code formatter +{ inputs, ... }: + +{ + perSystem = + { pkgs, ... }: + { + formatter = pkgs.nixfmt; + }; +} diff --git a/flake/hosts.nix b/flake/hosts.nix index bdc7afa..db9582a 100644 --- a/flake/hosts.nix +++ b/flake/hosts.nix @@ -3,10 +3,10 @@ let lib = inputs.nixpkgs.lib; - northstar = import ../lib { inherit lib; }; - modulePaths = ../modules |> northstar.scanModules; + northstar = import ../lib/core.nix { inherit lib; }; + modulePaths = northstar.scanModules ../modules; hostsDir = ../hosts; - hosts = hostsDir |> northstar.discoverHosts; + hosts = northstar.discoverHosts hostsDir; mkHost = hostName: @@ -28,10 +28,10 @@ let in { flake = { - nixosConfigurations = hosts |> (h: lib.genAttrs h mkHost); + nixosConfigurations = lib.genAttrs hosts mkHost; nixosModules.default = { imports = modulePaths; }; - homeManagerModules.default = import ../home; + homeManagerModules.default = import ../home/home.nix; }; } diff --git a/flake/installer.nix b/flake/installer.nix index a293d2c..1b0f104 100644 --- a/flake/installer.nix +++ b/flake/installer.nix @@ -12,6 +12,7 @@ installer = pkgs.writeShellApplication { name = "northstar-install"; runtimeInputs = with pkgs; [ + inputs.determinate.packages.${system}.default nixos-install-tools age ssh-to-age diff --git a/flake/modules.nix b/flake/modules.nix new file mode 100644 index 0000000..67ebd2a --- /dev/null +++ b/flake/modules.nix @@ -0,0 +1,10 @@ +# Flake-parts module: single aggregator entrypoint for all flake modules +{ + imports = [ + ./hosts.nix + ./installer.nix + ./checks.nix + ./devshell.nix + ./formatter.nix + ]; +} diff --git a/flake/rust-installer.nix b/flake/rust-installer.nix deleted file mode 100644 index 590bcb6..0000000 --- a/flake/rust-installer.nix +++ /dev/null @@ -1,103 +0,0 @@ -# Flake-parts module: Rust binary installer (crane) -{ self, inputs, ... }: - -{ - perSystem = - { pkgs, system, ... }: - let - craneLib = inputs.crane.mkLib pkgs; - - # Use the installer-rs directory directly so crane can read Cargo.toml - # at eval time. We'll populate the flake/ dir in the build phase. - rustSrc = pkgs.lib.cleanSourceWith { - src = ../installer-rs; - filter = - path: type: - (craneLib.filterCargoSources path type) - || (type == "directory" && builtins.baseNameOf path == "flake") - || builtins.baseNameOf path == "PLACEHOLDER"; - }; - - # Assemble the full flake source as a derivation - flakeSrc = pkgs.runCommand "northstar-flake-src" { } '' - mkdir -p $out - cp ${../flake.nix} $out/flake.nix - cp ${../flake.lock} $out/flake.lock - cp ${../README.md} $out/README.md - cp -r ${../hosts} $out/hosts - cp -r ${../home} $out/home - cp -r ${../lib} $out/lib - cp -r ${../modules} $out/modules - cp -r ${../flake} $out/flake - cp -r ${../assets} $out/assets - ''; - - commonArgs = { - src = rustSrc; - pname = "northstar-installer"; - version = "2.0.0"; - strictDeps = true; - nativeBuildInputs = [ pkgs.pkg-config ]; - - # Populate flake/ dir before cargo build so include_dir! works - preBuild = '' - mkdir -p flake - rm -rf flake/* - cp -r ${flakeSrc}/* flake/ - chmod -R u+w flake - ''; - }; - - cargoArtifacts = craneLib.buildDepsOnly commonArgs; - - rustBin = craneLib.buildPackage (commonArgs // { inherit cargoArtifacts; }); - - # Runtime tools the installer shells out to - runtimeDeps = with pkgs; [ - git - coreutils - util-linux - pciutils - whois - openssl - parted - btrfs-progs - e2fsprogs - nixos-install-tools - ]; - - # Deprecation wrapper: prints prominent notice and invokes the primary python installer - deprecatedRustInstall = pkgs.writeShellApplication { - name = "northstar-rust-install-deprecated"; - runtimeInputs = [ self.packages.${system}.installer ]; - text = '' - echo "================================================================================" - echo " [WARNING] The Rust-based installer ('installer-rs' / .#rust-install) is DEPRECATED." - echo " Redirecting to the primary Northstar Python installer (northstar-install)..." - echo "================================================================================" - exec northstar-install "$@" - ''; - }; - in - { - packages.rust-installer = pkgs.symlinkJoin { - name = "northstar-installer"; - paths = [ rustBin ]; - nativeBuildInputs = [ pkgs.makeWrapper ]; - postBuild = '' - wrapProgram $out/bin/northstar-installer \ - --prefix PATH : ${pkgs.lib.makeBinPath runtimeDeps} - ''; - meta = { - description = "Northstar NixOS installer (Rust + ratatui)"; - mainProgram = "northstar-installer"; - }; - }; - - apps.rust-install = { - type = "app"; - program = "${deprecatedRustInstall}/bin/northstar-rust-install-deprecated"; - meta.description = "Rust-based Northstar installer with ratatui TUI (DEPRECATED - redirects to Python installer)"; - }; - }; -} diff --git a/home/default.nix b/home/home.nix similarity index 100% rename from home/default.nix rename to home/home.nix diff --git a/hosts/Makima/default.nix b/hosts/Makima/default.nix index 48694ec..9ae72f6 100644 --- a/hosts/Makima/default.nix +++ b/hosts/Makima/default.nix @@ -7,21 +7,19 @@ { imports = [ + ./hardware.nix ./disko.nix ]; home-manager.users.reze = { - imports = [ ../../home ]; + imports = [ ../../home/home.nix ]; home.username = lib.mkForce "reze"; home.homeDirectory = lib.mkForce "/home/reze"; }; - boot.loader.grub.extraEntries = '' - menuentry "Fedora" { - search --fs-uuid --set=root CB41-6695 - chainloader /EFI/fedora/shimx64.efi - } - ''; + # Bootloader (Limine) + boot.loader.limine.resolution = "1920x1080"; + northstar.features.boot.secureBoot.enable = true; users.users.reze = { isNormalUser = true; @@ -33,15 +31,22 @@ "docker" ]; shell = pkgs.zsh; - hashedPassword = "$6$oXzrheHsEVSMJyV3$KzMdb2T8CHzGwcAlJqE3khEVfH/b5jFs/n5vNriwifcJ9mlgbsB221oILkizsjSYcFVJ5/kkYfjt8M2QFkkNl0"; + hashedPassword = "$6$7uVH9VA23imtOFPs$Rx7oc7xoN5gxBqdB6pg1ZG7xqAeX4LIzLuKjPExFOySTdfmVGdDbCD.4K/dtLLbUbdpcNJ8W5OYpeknaij6mM."; }; - #Northstar profiles + # Northstar profiles northstar.profiles = { desktop.enable = true; workstation.enable = true; }; + # Custom feature overrides + northstar.features = { + niri.enable = true; + fish.enable = true; + emacs.enable = true; + }; + # NVIDIA GPU northstar.nvidia.enable = true; northstar.nvidia.prime = { diff --git a/hosts/Makima/disko.nix b/hosts/Makima/disko.nix index b56d8ce..9f44bba 100644 --- a/hosts/Makima/disko.nix +++ b/hosts/Makima/disko.nix @@ -1,36 +1,14 @@ -# Auto-generated disko config for Makima (partition-only) -{ - disko.devices.disk.nixos = { - type = "disk"; - device = "/dev/nvme0n1p4"; - content = { - type = "btrfs"; - extraArgs = [ "-f" ]; - subvolumes = { - "/root" = { - mountpoint = "/"; - mountOptions = [ "compress=zstd" ]; - }; - "/home" = { - mountpoint = "/home"; - mountOptions = [ "compress=zstd" ]; - }; - "/nix" = { - mountpoint = "/nix"; - mountOptions = [ "compress=zstd" "noatime" ]; - }; - "/log" = { - mountpoint = "/var/log"; - mountOptions = [ "compress=zstd" ]; - }; - }; - }; - }; +# Auto-generated disko config for Makima +{ lib, ... }: - # Existing EFI partition — not managed by disko - fileSystems."/boot/efi" = { - device = "/dev/disk/by-uuid/CB41-6695"; - fsType = "vfat"; - options = [ "fmask=0022" "dmask=0022" ]; - }; +let + northstar = import ../../lib/core.nix { inherit lib; }; +in +northstar.mkDisko { + mode = "whole-disk"; + device = "/dev/nvme0n1"; + fsType = "btrfs"; + efiSize = "4G"; + swapSize = "16G"; + rootSize = "270G"; } diff --git a/hosts/Makima/hardware.nix b/hosts/Makima/hardware.nix index e2ef9a0..3ce958a 100644 --- a/hosts/Makima/hardware.nix +++ b/hosts/Makima/hardware.nix @@ -1,18 +1,32 @@ -# Do not modify this file! It was generated by ‘nixos-generate-config’ -# and may be overwritten by future invocations. Please make changes -# to /etc/nixos/configuration.nix instead. -{ config, lib, pkgs, modulesPath, ... }: +{ + config, + lib, + pkgs, + modulesPath, + ... +}: { - imports = - [ (modulesPath + "/installer/scan/not-detected.nix") - ]; + imports = [ + (modulesPath + "/installer/scan/not-detected.nix") + ]; - boot.initrd.availableKernelModules = [ "nvme" "xhci_pci" "ahci" "usbhid" "usb_storage" "sd_mod" ]; + boot.initrd.availableKernelModules = [ + "nvme" + "xhci_pci" + "ahci" + "usbhid" + "usb_storage" + "sd_mod" + ]; boot.initrd.kernelModules = [ ]; boot.kernelModules = [ "kvm-amd" ]; boot.extraModulePackages = [ ]; nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; hardware.cpu.amd.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware; + hardware = { + graphics.enable = lib.mkDefault true; + firmware = [ pkgs.linux-firmware ]; + }; } diff --git a/hosts/common.nix b/hosts/common.nix index 35164e5..157cb2b 100644 --- a/hosts/common.nix +++ b/hosts/common.nix @@ -2,6 +2,7 @@ { imports = [ + inputs.determinate.nixosModules.default inputs.home-manager.nixosModules.home-manager inputs.nix-index-database.nixosModules.nix-index ]; diff --git a/installer-rs/Cargo.lock b/installer-rs/Cargo.lock deleted file mode 100644 index f6942b8..0000000 --- a/installer-rs/Cargo.lock +++ /dev/null @@ -1,755 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "anyhow" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "compact_str" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - -[[package]] -name = "crossterm" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" -dependencies = [ - "bitflags", - "crossterm_winapi", - "mio", - "parking_lot", - "rustix", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn", -] - -[[package]] -name = "either" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "include_dir" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" -dependencies = [ - "include_dir_macros", -] - -[[package]] -name = "include_dir_macros" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - -[[package]] -name = "instability" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" -dependencies = [ - "darling", - "indoc", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown", -] - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "mio" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "northstar-installer" -version = "2.1.0" -dependencies = [ - "anyhow", - "crossterm", - "include_dir", - "nix", - "ratatui", - "serde", - "serde_json", - "tokio", - "zeroize", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "ratatui" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" -dependencies = [ - "bitflags", - "cassowary", - "compact_str", - "crossterm", - "indoc", - "instability", - "itertools", - "lru", - "paste", - "strum", - "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.2.0", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn", -] - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tokio" -version = "1.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - -[[package]] -name = "unicode-truncate" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" -dependencies = [ - "itertools", - "unicode-segmentation", - "unicode-width 0.1.14", -] - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/installer-rs/Cargo.toml b/installer-rs/Cargo.toml deleted file mode 100644 index 457ccab..0000000 --- a/installer-rs/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[package] -name = "northstar-installer" -version = "2.1.0" -edition = "2021" -description = "TUI-based NixOS installer for the Northstar configuration" -authors = ["reze-dev"] -repository = "https://github.com/reze-dev/northstar" -license = "MIT" - -[lib] -name = "northstar_installer" -path = "src/lib.rs" - -[[bin]] -name = "northstar-installer" -path = "src/main.rs" - -[dependencies] -ratatui = "0.29" -crossterm = "0.28" -tokio = { version = "1", features = ["full"] } -include_dir = "0.7" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -nix = { version = "0.30", features = ["user"] } -anyhow = "1" -zeroize = "1" - -[profile.release] -strip = true -lto = true -opt-level = "z" diff --git a/installer-rs/flake/PLACEHOLDER b/installer-rs/flake/PLACEHOLDER deleted file mode 100644 index 556afbc..0000000 --- a/installer-rs/flake/PLACEHOLDER +++ /dev/null @@ -1 +0,0 @@ -Populated at build time with the full Northstar flake. See flake/rust-installer.nix. diff --git a/installer-rs/src/app.rs b/installer-rs/src/app.rs deleted file mode 100644 index 82b5ffb..0000000 --- a/installer-rs/src/app.rs +++ /dev/null @@ -1,708 +0,0 @@ -use std::collections::VecDeque; -use tokio::sync::mpsc; -use tokio::task::JoinHandle; -use zeroize::Zeroize; - -/// Installation mode. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum InstallMode { - #[default] - WholeDisk, - PartitionOnly, -} - -impl std::fmt::Display for InstallMode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::WholeDisk => write!(f, "whole-disk"), - Self::PartitionOnly => write!(f, "partition-only"), - } - } -} - -/// Profile bundle choice. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum ProfileChoice { - Base, - #[default] - Desktop, - Workstation, -} - -impl std::fmt::Display for ProfileChoice { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Base => write!(f, "Base (Minimal CLI Server)"), - Self::Desktop => write!(f, "Desktop (GUI + Compositors + Browsers)"), - Self::Workstation => write!(f, "Workstation (Desktop + Devtools + Virt)"), - } - } -} - -/// Bootloader choice. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum BootloaderChoice { - #[default] - Grub, - Limine, -} - -impl std::fmt::Display for BootloaderChoice { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Grub => write!(f, "GRUB (Cyberpunk DedSec Theme)"), - Self::Limine => write!(f, "Limine (Modern Ultra-Fast UEFI)"), - } - } -} - -/// GPU driver choice. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum GpuChoice { - #[default] - None, - Nvidia, - NvidiaPrime, -} - -impl std::fmt::Display for GpuChoice { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::None => write!(f, "Default (no NVIDIA)"), - Self::Nvidia => write!(f, "NVIDIA Discrete"), - Self::NvidiaPrime => write!(f, "NVIDIA Prime (Hybrid GPU)"), - } - } -} - -/// Integrated GPU type for Prime setups. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum IgpuType { - #[default] - Intel, - Amd, -} - -impl std::fmt::Display for IgpuType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Intel => write!(f, "intel"), - Self::Amd => write!(f, "amd"), - } - } -} - -impl IgpuType { - pub fn bus_id_key(&self) -> &'static str { - match self { - Self::Intel => "intelBusId", - Self::Amd => "amdgpuBusId", - } - } -} - -/// Custom toggleable feature in the customization page. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FeatureOption { - pub id: String, - pub label: String, - pub category: String, - pub enabled: bool, -} - -/// Dual-boot OS entry detected on ESP. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DualBootEntry { - pub name: String, - pub efi_path: String, - pub disk_uuid: String, - pub enabled: bool, -} - -/// Disk information parsed from lsblk. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct DiskInfo { - pub name: String, - pub size: String, - pub model: String, - pub drive_type: String, - pub partitions: Vec, -} - -/// Partition information parsed from lsblk. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct PartitionInfo { - pub name: String, - pub size: String, - pub fs_type: String, - pub mountpoint: Option, - pub label: Option, - pub uuid: Option, -} - -/// Holds all user-collected configuration for installation. -#[derive(Debug, Clone)] -pub struct InstallConfig { - pub hostname: String, - pub username: String, - pub hashed_pw: String, - pub profile: ProfileChoice, - pub shell: String, - pub bootloader: BootloaderChoice, - pub features: Vec, - pub dual_boot_entries: Vec, - pub mode: InstallMode, - pub disk_dev: String, - pub nixos_part: String, - pub efi_part: String, - pub swap_size: String, - pub swap_partition: String, - pub fs_type: String, - pub root_size: String, - pub gpu_choice: GpuChoice, - pub nvidia_bus_id: String, - pub igpu_bus_id: String, - pub igpu_type: IgpuType, -} - -impl Default for InstallConfig { - fn default() -> Self { - Self { - hostname: String::new(), - username: String::new(), - hashed_pw: String::new(), - profile: ProfileChoice::Desktop, - shell: "zsh".to_string(), - bootloader: BootloaderChoice::Grub, - features: default_features(ProfileChoice::Desktop), - dual_boot_entries: Vec::new(), - mode: InstallMode::WholeDisk, - disk_dev: String::new(), - nixos_part: String::new(), - efi_part: String::new(), - swap_size: "8G".to_string(), - swap_partition: String::new(), - fs_type: "btrfs".to_string(), - root_size: "100%".to_string(), - gpu_choice: GpuChoice::None, - nvidia_bus_id: String::new(), - igpu_bus_id: String::new(), - igpu_type: IgpuType::Intel, - } - } -} - -pub fn default_features(profile: ProfileChoice) -> Vec { - let is_desktop = profile == ProfileChoice::Desktop || profile == ProfileChoice::Workstation; - let is_workstation = profile == ProfileChoice::Workstation; - - vec![ - // Window Managers / Compositors - FeatureOption { - id: "hyprland".into(), - label: "Hyprland (Dynamic Wayland Tiling WM)".into(), - category: "Desktop / Compositor".into(), - enabled: is_desktop, - }, - FeatureOption { - id: "niri".into(), - label: "Niri (Scrollable-tiling Wayland WM)".into(), - category: "Desktop / Compositor".into(), - enabled: false, - }, - FeatureOption { - id: "noctalia".into(), - label: "Noctalia (Custom Desktop Environment)".into(), - category: "Desktop / Compositor".into(), - enabled: is_desktop, - }, - // Shells - FeatureOption { - id: "zsh".into(), - label: "Zsh + Starship / OMP Shell".into(), - category: "Shell & Terminal".into(), - enabled: true, - }, - FeatureOption { - id: "fish".into(), - label: "Fish Friendly Interactive Shell".into(), - category: "Shell & Terminal".into(), - enabled: false, - }, - FeatureOption { - id: "ghostty".into(), - label: "Ghostty Modern Terminal".into(), - category: "Shell & Terminal".into(), - enabled: is_desktop, - }, - FeatureOption { - id: "kitty".into(), - label: "Kitty GPU-accelerated Terminal".into(), - category: "Shell & Terminal".into(), - enabled: is_desktop, - }, - // Development & Virt - FeatureOption { - id: "devtools".into(), - label: "Developer Workspace (LSPs, Compilers, Tools)".into(), - category: "Development & Virt".into(), - enabled: is_workstation, - }, - FeatureOption { - id: "virtualization".into(), - label: "Docker & Libvirt Virtualization".into(), - category: "Development & Virt".into(), - enabled: is_workstation, - }, - FeatureOption { - id: "emacs".into(), - label: "Emacs with Doom/Custom Config".into(), - category: "Development & Virt".into(), - enabled: false, - }, - ] -} - -/// Progress updates sent from backend to TUI. -#[derive(Debug, Clone)] -pub struct ProgressUpdate { - pub step: String, - pub message: String, - pub done: bool, - pub error: Option, -} - -/// Page in the wizard. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Page { - Welcome, - Hostname, - Username, - Password, - PasswordConfirm, - Profile, - ProfileCustomize, - Bootloader, - Mode, - Disk, - DiskConfirm, - PartSelect, - PartNewStart, - PartNewEnd, - PartExist, - PartConfirm, - Efi, - Fs, - RootSize, - Swap, - SwapPartition, - Gpu, - GpuNvBus, - GpuIgpuType, - GpuIgpuBus, - DualBoot, - Summary, - Installing, - Done, -} - -/// Status of an installation step. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StepStatus { - Pending, - Running, - Done, - Error, -} - -/// An installation step tracked in the progress view. -#[derive(Debug, Clone)] -pub struct InstallStep { - pub name: String, - pub label: String, - pub status: StepStatus, -} - -/// Main application state. -pub struct App { - pub page: Page, - pub should_quit: bool, - - // Input state - pub input: String, - pub cursor_pos: usize, - pub err: String, - - // Selection state - pub choices: Vec, - pub cursor: usize, - - // Detected hardware info - pub detected_disks: Vec, - pub detected_efis: Vec<(String, String, String)>, - - // Collected data - pub config: InstallConfig, - pub plain_pw: String, - - // Temporary storage for multi-step inputs - pub part_new_start: String, - - // Installation progress - pub install_steps: Vec, - pub install_err: Option, - pub install_rx: Option>, - pub install_handle: Option>, - pub spinner_idx: usize, - pub work_dir: String, - - // Live terminal log lines for the UI - pub log_lines: VecDeque, -} - -impl App { - pub fn new(work_dir: String) -> Self { - let mut app = Self { - page: Page::Welcome, - should_quit: false, - input: String::new(), - cursor_pos: 0, - err: String::new(), - choices: Vec::new(), - cursor: 0, - detected_disks: Vec::new(), - detected_efis: Vec::new(), - config: InstallConfig::default(), - plain_pw: String::new(), - part_new_start: String::new(), - install_steps: vec![ - InstallStep { - name: "generate_config".into(), - label: "Generate System Configuration".into(), - status: StepStatus::Pending, - }, - InstallStep { - name: "partition".into(), - label: "Partition & Format Disk (Disko)".into(), - status: StepStatus::Pending, - }, - InstallStep { - name: "install_nixos".into(), - label: "Install NixOS System & Packages".into(), - status: StepStatus::Pending, - }, - InstallStep { - name: "copy_flake".into(), - label: "Copy Flake & Setup User Environment".into(), - status: StepStatus::Pending, - }, - ], - install_err: None, - install_rx: None, - install_handle: None, - spinner_idx: 0, - work_dir, - log_lines: VecDeque::with_capacity(100), - }; - app.init_page(); - app - } - - pub fn add_log(&mut self, line: String) { - if self.log_lines.len() >= 100 { - self.log_lines.pop_front(); - } - self.log_lines.push_back(line); - } - - pub fn apply_profile(&mut self, profile: ProfileChoice) { - self.config.profile = profile; - self.config.features = default_features(profile); - } - - pub fn toggle_current_feature(&mut self) { - if self.cursor < self.config.features.len() { - self.config.features[self.cursor].enabled = !self.config.features[self.cursor].enabled; - } - } - - pub fn toggle_current_dual_boot(&mut self) { - if self.cursor < self.config.dual_boot_entries.len() { - self.config.dual_boot_entries[self.cursor].enabled = - !self.config.dual_boot_entries[self.cursor].enabled; - } - } - - pub fn init_page(&mut self) { - self.err.clear(); - self.cursor = 0; - - match self.page { - Page::Welcome => {} - Page::Hostname => { - self.input = self.config.hostname.clone(); - self.cursor_pos = self.input.len(); - } - Page::Username => { - self.input = self.config.username.clone(); - self.cursor_pos = self.input.len(); - } - Page::Password | Page::PasswordConfirm => { - self.input.clear(); - self.cursor_pos = 0; - } - Page::Profile => { - self.choices = vec![ - ProfileChoice::Base.to_string(), - ProfileChoice::Desktop.to_string(), - ProfileChoice::Workstation.to_string(), - ]; - self.cursor = match self.config.profile { - ProfileChoice::Base => 0, - ProfileChoice::Desktop => 1, - ProfileChoice::Workstation => 2, - }; - } - Page::ProfileCustomize => { - self.cursor = 0; - } - Page::Bootloader => { - self.choices = vec![ - BootloaderChoice::Grub.to_string(), - BootloaderChoice::Limine.to_string(), - ]; - self.cursor = match self.config.bootloader { - BootloaderChoice::Grub => 0, - BootloaderChoice::Limine => 1, - }; - } - Page::Mode => { - self.choices = vec![ - "Whole Disk (Disko wipes disk & partitions automatically)".into(), - "Partition Only (Install alongside existing OS / custom partitions)".into(), - ]; - self.cursor = if self.config.mode == InstallMode::PartitionOnly { - 1 - } else { - 0 - }; - } - Page::Disk => { - if !self.detected_disks.is_empty() { - self.choices = self - .detected_disks - .iter() - .map(|d| format!("{} - {} ({}, {})", d.name, d.size, d.drive_type, d.model)) - .collect(); - if let Some(pos) = self - .detected_disks - .iter() - .position(|d| d.name == self.config.disk_dev) - { - self.cursor = pos; - } - } else { - self.input = self.config.disk_dev.clone(); - self.cursor_pos = self.input.len(); - } - } - Page::DiskConfirm => { - self.input.clear(); - self.cursor_pos = 0; - } - Page::PartSelect => { - self.choices = vec![ - "Create new partition in free space (parted)".into(), - "Use existing unformatted partition".into(), - ]; - } - Page::PartNewStart => { - self.input = "100GB".into(); - self.cursor_pos = self.input.len(); - } - Page::PartNewEnd => { - self.input = "100%".into(); - self.cursor_pos = self.input.len(); - } - Page::PartExist => { - self.input.clear(); - self.cursor_pos = 0; - } - Page::PartConfirm => { - self.choices = vec![ - "Yes, format this partition for NixOS".into(), - "No, go back and change partition".into(), - ]; - } - Page::Efi => { - if !self.detected_efis.is_empty() { - self.choices = self - .detected_efis - .iter() - .map(|(dev, size, uuid)| format!("{dev} ({size}) [UUID: {uuid}]")) - .collect(); - self.choices - .push("Enter custom EFI partition manually".into()); - } else { - self.input = self.config.efi_part.clone(); - self.cursor_pos = self.input.len(); - } - } - Page::Fs => { - self.choices = vec![ - "btrfs (recommended: subvolumes for root, home, nix, log & snapshots)".into(), - "ext4 (standard single partition)".into(), - ]; - self.cursor = if self.config.fs_type == "ext4" { 1 } else { 0 }; - } - Page::RootSize => { - self.input = self.config.root_size.clone(); - self.cursor_pos = self.input.len(); - } - Page::Swap => { - self.input = self.config.swap_size.clone(); - self.cursor_pos = self.input.len(); - } - Page::SwapPartition => { - self.input = self.config.swap_partition.clone(); - self.cursor_pos = self.input.len(); - } - Page::Gpu => { - self.choices = vec![ - GpuChoice::None.to_string(), - GpuChoice::Nvidia.to_string(), - GpuChoice::NvidiaPrime.to_string(), - ]; - self.cursor = match self.config.gpu_choice { - GpuChoice::None => 0, - GpuChoice::Nvidia => 1, - GpuChoice::NvidiaPrime => 2, - }; - } - Page::GpuNvBus => { - self.input = self.config.nvidia_bus_id.clone(); - self.cursor_pos = self.input.len(); - } - Page::GpuIgpuType => { - self.choices = vec!["Intel".into(), "AMD".into()]; - self.cursor = match self.config.igpu_type { - IgpuType::Intel => 0, - IgpuType::Amd => 1, - }; - } - Page::GpuIgpuBus => { - self.input = self.config.igpu_bus_id.clone(); - self.cursor_pos = self.input.len(); - } - Page::DualBoot => { - self.cursor = 0; - } - Page::Summary => {} - Page::Installing => {} - Page::Done => {} - } - } - - pub fn go_to_page(&mut self, next: Page) { - self.page = next; - self.init_page(); - } - - pub fn prev_page(&self) -> Page { - match self.page { - Page::Welcome => Page::Welcome, - Page::Hostname => Page::Welcome, - Page::Username => Page::Hostname, - Page::Password => Page::Username, - Page::PasswordConfirm => Page::Password, - Page::Profile => Page::PasswordConfirm, - Page::ProfileCustomize => Page::Profile, - Page::Bootloader => Page::ProfileCustomize, - Page::Mode => Page::Bootloader, - Page::Disk => Page::Mode, - Page::DiskConfirm => Page::Disk, - Page::PartSelect => Page::Disk, - Page::PartNewStart => Page::PartSelect, - Page::PartNewEnd => Page::PartNewStart, - Page::PartExist => Page::PartSelect, - Page::PartConfirm => { - if self.part_new_start.is_empty() { - Page::PartExist - } else { - Page::PartNewEnd - } - } - Page::Efi => { - if self.config.mode == InstallMode::WholeDisk { - Page::DiskConfirm - } else { - Page::PartConfirm - } - } - Page::Fs => Page::Efi, - Page::RootSize => Page::Fs, - Page::Swap => Page::Fs, - Page::SwapPartition => Page::Swap, - Page::Gpu => { - if self.config.mode == InstallMode::WholeDisk && self.config.fs_type == "ext4" { - Page::RootSize - } else if !self.config.swap_partition.is_empty() { - Page::SwapPartition - } else { - Page::Swap - } - } - Page::GpuNvBus => Page::Gpu, - Page::GpuIgpuType => Page::GpuNvBus, - Page::GpuIgpuBus => Page::GpuIgpuType, - Page::DualBoot => match self.config.gpu_choice { - GpuChoice::None => Page::Gpu, - GpuChoice::Nvidia => Page::GpuNvBus, - GpuChoice::NvidiaPrime => Page::GpuIgpuBus, - }, - Page::Summary => { - if !self.config.dual_boot_entries.is_empty() { - Page::DualBoot - } else { - match self.config.gpu_choice { - GpuChoice::None => Page::Gpu, - GpuChoice::Nvidia => Page::GpuNvBus, - GpuChoice::NvidiaPrime => Page::GpuIgpuBus, - } - } - } - Page::Installing => Page::Summary, - Page::Done => Page::Done, - } - } - - pub fn type_char(&mut self, c: char) { - self.input.insert(self.cursor_pos, c); - self.cursor_pos += 1; - self.err.clear(); - } - - pub fn delete_char(&mut self) { - if self.cursor_pos > 0 { - self.input.remove(self.cursor_pos - 1); - self.cursor_pos -= 1; - self.err.clear(); - } - } - - pub fn input_value(&self) -> String { - self.input.trim().to_string() - } - - pub fn spinner_char(&self) -> &'static str { - const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - SPINNER[self.spinner_idx % SPINNER.len()] - } -} - -impl Drop for App { - fn drop(&mut self) { - self.plain_pw.zeroize(); - self.input.zeroize(); - } -} diff --git a/installer-rs/src/backend.rs b/installer-rs/src/backend.rs deleted file mode 100644 index 452c641..0000000 --- a/installer-rs/src/backend.rs +++ /dev/null @@ -1,587 +0,0 @@ -use crate::app::{ - BootloaderChoice, GpuChoice, InstallConfig, InstallMode, ProfileChoice, ProgressUpdate, -}; -use crate::cmd::*; -use crate::detect::{format_grub_extra_entries, format_limine_extra_entries}; -use crate::state::State; -use std::fs; -use std::time::Duration; -use tokio::io::AsyncWriteExt; -use tokio::sync::mpsc; - -/// Hash a password securely without shell interpolation. -pub async fn hash_password(pw: &str) -> Result { - // Try mkpasswd first - if let Ok(output) = tokio::process::Command::new("mkpasswd") - .args(["-m", "sha-512", pw]) - .output() - .await - { - if output.status.success() { - let hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !hash.is_empty() { - return Ok(hash); - } - } - } - - // Fallback: pipe password to openssl via stdin - let mut child = tokio::process::Command::new("openssl") - .args(["passwd", "-6", "-stdin"]) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .spawn() - .map_err(|e| format!("Failed to spawn openssl: {e}"))?; - - if let Some(mut stdin) = child.stdin.take() { - stdin - .write_all(pw.as_bytes()) - .await - .map_err(|e| format!("Failed to write password: {e}"))?; - } - - let output = child - .wait_with_output() - .await - .map_err(|e| format!("openssl failed: {e}"))?; - - let hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if hash.is_empty() { - Err("No password hashing tool found (mkpasswd, openssl)".into()) - } else { - Ok(hash) - } -} - -pub fn build_gpu_config(cfg: &InstallConfig) -> String { - match cfg.gpu_choice { - GpuChoice::None => String::new(), - GpuChoice::Nvidia => " # NVIDIA GPU\n northstar.nvidia.enable = true;".to_string(), - GpuChoice::NvidiaPrime => { - let key = cfg.igpu_type.bus_id_key(); - format!( - " # NVIDIA GPU\n northstar.nvidia.enable = true;\n northstar.nvidia.prime = {{\n enable = true;\n nvidiaBusId = \"{}\";\n {key} = \"{}\";\n }};", - cfg.nvidia_bus_id, cfg.igpu_bus_id - ) - } - } -} - -pub fn build_bootloader_config(cfg: &InstallConfig) -> String { - match cfg.bootloader { - BootloaderChoice::Grub => { - let mut s = - " # Bootloader\n northstar.features.boot.loader = \"grub\";\n".to_string(); - let extra = format_grub_extra_entries(&cfg.dual_boot_entries); - if !extra.is_empty() { - s.push_str(&format!("{extra}\n")); - } - s - } - BootloaderChoice::Limine => { - let mut s = - " # Bootloader\n northstar.features.boot.loader = \"limine\";\n".to_string(); - let extra = format_limine_extra_entries(&cfg.dual_boot_entries); - if !extra.is_empty() { - s.push_str(&format!("{extra}\n")); - } - s - } - } -} - -pub fn build_profile_config(cfg: &InstallConfig) -> String { - let mut lines = vec![ - " # Northstar profiles".to_string(), - " northstar.profiles = {".to_string(), - ]; - - match cfg.profile { - ProfileChoice::Base => { - lines.push(" base.enable = true;".into()); - } - ProfileChoice::Desktop => { - lines.push(" desktop.enable = true;".into()); - } - ProfileChoice::Workstation => { - lines.push(" desktop.enable = true;".into()); - lines.push(" workstation.enable = true;".into()); - } - } - lines.push(" };".into()); - lines.join("\n") -} - -pub fn build_features_override(cfg: &InstallConfig) -> String { - let defaults = crate::app::default_features(cfg.profile); - let mut overrides = Vec::new(); - - for custom in &cfg.features { - if let Some(def) = defaults.iter().find(|d| d.id == custom.id) { - if def.enabled != custom.enabled { - overrides.push(format!(" {}.enable = {};", custom.id, custom.enabled)); - } - } - } - - if overrides.is_empty() { - return String::new(); - } - - format!( - " # Custom feature overrides\n northstar.features = {{\n{}\n }};", - overrides.join("\n") - ) -} - -/// Strip fileSystems and swapDevices entries from hardware.nix output. -pub fn strip_filesystems_from_hardware(hw_text: &str) -> String { - let mut cleaned_lines = Vec::new(); - let mut in_fs_block = false; - let mut fs_depth: i32 = 0; - let mut in_swap_devices = false; - - for line in hw_text.lines() { - let stripped = line.trim(); - - if in_swap_devices { - if stripped.contains(';') { - in_swap_devices = false; - } - continue; - } - - if stripped.starts_with("swapDevices") { - if !stripped.contains(';') { - in_swap_devices = true; - } - continue; - } - - if stripped.starts_with("fileSystems.") || stripped.starts_with("fileSystems =") { - in_fs_block = true; - fs_depth += stripped.matches('{').count() as i32 - stripped.matches('}').count() as i32; - if stripped.contains(';') || (fs_depth == 0 && stripped.contains('}')) { - in_fs_block = false; - fs_depth = 0; - } - continue; - } - - if in_fs_block { - fs_depth += stripped.matches('{').count() as i32 - stripped.matches('}').count() as i32; - if fs_depth <= 0 && (stripped.contains('}') || stripped.contains(';')) { - in_fs_block = false; - fs_depth = 0; - } - continue; - } - - cleaned_lines.push(line); - } - - let mut result = cleaned_lines.join("\n"); - while result.contains("\n\n\n") { - result = result.replace("\n\n\n", "\n\n"); - } - result.trim().to_string() -} - -/// Generate disko.nix content for whole-disk mode. -pub fn generate_disko_whole_disk(cfg: &InstallConfig) -> String { - let template = if cfg.fs_type == "ext4" { - "ext4" - } else { - "btrfs" - }; - let mut disko = format!( - "# Auto-generated disko config for {}\n{{ lib, ... }}:\n{{\n imports = [ ../../lib/disko/{template}.nix ];\n\n disko.devices.disk.main.device = \"/dev/{}\";\n", - cfg.hostname, cfg.disk_dev - ); - - if cfg.swap_size == "0" { - disko += " # Swap disabled\n disko.devices.disk.main.content.partitions.swap.size = lib.mkForce \"0\";\n"; - } else if cfg.swap_size != "8G" { - disko += &format!( - " disko.devices.disk.main.content.partitions.swap.size = lib.mkForce \"{}\";\n", - cfg.swap_size - ); - } - - if cfg.root_size != "100%" { - disko += &format!( - " disko.devices.disk.main.content.partitions.root.size = lib.mkForce \"{}\";\n", - cfg.root_size - ); - } - - disko += "}\n"; - disko -} - -/// Generate disko.nix content for partition-only mode. -pub async fn generate_disko_partition_only(cfg: &InstallConfig) -> String { - let np = &cfg.nixos_part; - let ep = &cfg.efi_part; - - let efi_uuid = run_capture(&format!("blkid -s UUID -o value {ep}")) - .await - .unwrap_or_default(); - - let mut lines = vec![ - format!( - "# Auto-generated disko config for {} (partition-only)", - cfg.hostname - ), - "{".to_string(), - format!(" disko.devices.disk.nixos = {{"), - format!(" type = \"disk\";"), - format!(" device = \"{np}\";"), - format!(" content = {{"), - ]; - - if cfg.fs_type == "btrfs" { - lines.push(" type = \"btrfs\";".into()); - lines.push(" extraArgs = [ \"-f\" ];".into()); - lines.push(" subvolumes = {".into()); - lines.push(" \"/root\" = {".into()); - lines.push(" mountpoint = \"/\";".into()); - lines.push(" mountOptions = [ \"compress=zstd\" ];".into()); - lines.push(" };".into()); - lines.push(" \"/home\" = {".into()); - lines.push(" mountpoint = \"/home\";".into()); - lines.push(" mountOptions = [ \"compress=zstd\" ];".into()); - lines.push(" };".into()); - lines.push(" \"/nix\" = {".into()); - lines.push(" mountpoint = \"/nix\";".into()); - lines.push(" mountOptions = [ \"compress=zstd\" \"noatime\" ];".into()); - lines.push(" };".into()); - lines.push(" \"/log\" = {".into()); - lines.push(" mountpoint = \"/var/log\";".into()); - lines.push(" mountOptions = [ \"compress=zstd\" ];".into()); - lines.push(" };".into()); - if cfg.swap_size != "0" { - lines.push(" \"/swap\" = {".into()); - lines.push(" mountpoint = \"/swap\";".into()); - lines.push(" };".into()); - } - lines.push(" };".into()); - } else { - lines.push(" type = \"filesystem\";".into()); - lines.push(" format = \"ext4\";".into()); - lines.push(" mountpoint = \"/\";".into()); - } - - lines.push(" };".into()); - lines.push(" };".into()); - - if cfg.swap_size != "0" && cfg.fs_type == "ext4" && !cfg.swap_partition.is_empty() { - lines.push(String::new()); - lines.push(" disko.devices.disk.swap = {".into()); - lines.push(" type = \"disk\";".into()); - lines.push(format!(" device = \"{}\";", cfg.swap_partition)); - lines.push(" content = {".into()); - lines.push(" type = \"swap\";".into()); - lines.push(" discardPolicy = \"both\";".into()); - lines.push(" resumeDevice = true;".into()); - lines.push(" };".into()); - lines.push(" };".into()); - } - - lines.push(String::new()); - lines.push(" # Existing EFI partition — not managed by disko".into()); - - if !efi_uuid.is_empty() { - lines.push(" fileSystems.\"/boot/efi\" = {".into()); - lines.push(format!(" device = \"/dev/disk/by-uuid/{efi_uuid}\";")); - lines.push(" fsType = \"vfat\";".into()); - lines.push(" options = [ \"fmask=0022\" \"dmask=0022\" ];".into()); - lines.push(" };".into()); - } else { - lines.push(" fileSystems.\"/boot/efi\" = {".into()); - lines.push(format!(" device = \"{ep}\";")); - lines.push(" fsType = \"vfat\";".into()); - lines.push(" options = [ \"fmask=0022\" \"dmask=0022\" ];".into()); - lines.push(" };".into()); - } - - if cfg.swap_size != "0" && cfg.fs_type == "btrfs" { - lines.push(String::new()); - lines.push(" swapDevices = [".into()); - lines.push(" { device = \"/swap/swapfile\"; }".into()); - lines.push(" ];".into()); - } - - lines.push("}".into()); - lines.push(String::new()); - - lines.join("\n") -} - -pub fn generate_host_default_nix(cfg: &InstallConfig) -> String { - let bootloader_config = build_bootloader_config(cfg); - let profiles_config = build_profile_config(cfg); - let features_config = build_features_override(cfg); - let gpu_config = build_gpu_config(cfg); - - let mut blocks = Vec::new(); - if !bootloader_config.is_empty() { - blocks.push(bootloader_config.trim_end().to_string()); - } - - let user_block = format!( - r#" users.users.{user} = {{ - isNormalUser = true; - description = "{user}"; - extraGroups = [ - "networkmanager" - "wheel" - "libvirtd" - "docker" - ]; - shell = pkgs.{shell}; - hashedPassword = "{pw}"; - }};"#, - user = cfg.username, - shell = cfg.shell, - pw = cfg.hashed_pw, - ); - blocks.push(user_block); - - blocks.push(profiles_config); - - if !features_config.is_empty() { - blocks.push(features_config); - } - - if !gpu_config.is_empty() { - blocks.push(gpu_config); - } - - let body = blocks.join("\n\n"); - - format!( - r#"{{ - config, - lib, - pkgs, - ... -}}: - -{{ - imports = [ - ./disko.nix - ]; - - home-manager.users.{user} = {{ - imports = [ ../../home ]; - home.username = lib.mkForce "{user}"; - home.homeDirectory = lib.mkForce "/home/{user}"; - }}; - -{body} - - networking.hostName = "{host}"; - system.stateVersion = "26.11"; -}} -"#, - user = cfg.username, - body = body, - host = cfg.hostname, - ) -} - -pub async fn generate_config(cfg: &InstallConfig, work_dir: &str) -> Result<(), String> { - let host_dir = format!("{work_dir}/hosts/{}", cfg.hostname); - let _ = fs::create_dir_all(&host_dir); - - // Write disko.nix - let disko_content = if cfg.mode == InstallMode::WholeDisk { - generate_disko_whole_disk(cfg) - } else { - generate_disko_partition_only(cfg).await - }; - fs::write(format!("{host_dir}/disko.nix"), &disko_content) - .map_err(|e| format!("Failed to write disko.nix: {e}"))?; - - // Write default.nix - let default_content = generate_host_default_nix(cfg); - fs::write(format!("{host_dir}/default.nix"), &default_content) - .map_err(|e| format!("Failed to write default.nix: {e}"))?; - - // Track files in git for Nix flakes - run_silent("git add -A").await; - Ok(()) -} - -/// Run all installation steps, sending progress updates through the channel. -pub async fn run_installation( - cfg: InstallConfig, - state: &mut State, - work_dir: &str, - tx: mpsc::UnboundedSender, -) { - let send = |step: &str, msg: &str| { - let _ = tx.send(ProgressUpdate { - step: step.into(), - message: msg.into(), - done: false, - error: None, - }); - }; - let done = |step: &str, msg: &str| { - let _ = tx.send(ProgressUpdate { - step: step.into(), - message: msg.into(), - done: true, - error: None, - }); - }; - let fail = |step: &str, err: String| { - let _ = tx.send(ProgressUpdate { - step: step.into(), - message: String::new(), - done: false, - error: Some(err), - }); - }; - - // Step 1: Generate config - send("generate_config", "Generating system configuration..."); - if !state.should_skip("generate_config") { - if let Err(e) = generate_config(&cfg, work_dir).await { - fail("generate_config", e); - return; - } - state.set_step("partition"); - } - done("generate_config", "Configuration generated"); - - // Step 2: Partition - send("partition", "Partitioning disk with Disko..."); - if !state.should_skip("partition") { - if let Err(e) = do_partition(&cfg, work_dir).await { - fail("partition", e); - return; - } - state.set_step("install_nixos"); - } - done("partition", "Disk partitioned & formatted"); - - // Step 3: Install NixOS - send("install_nixos", "Building & installing NixOS system..."); - if !state.should_skip("install_nixos") { - if let Err(e) = do_install_nixos(&cfg).await { - fail("install_nixos", e); - return; - } - state.set_step("copy_flake"); - } - done("install_nixos", "NixOS system installed"); - - // Step 4: Copy Flake - send( - "copy_flake", - "Copying configuration flake to target user...", - ); - if !state.should_skip("copy_flake") { - if let Err(e) = do_copy_flake(&cfg, work_dir).await { - fail("copy_flake", e); - return; - } - state.set_step("done"); - } - done("copy_flake", "Flake copied to target user home"); - - state.clear(); -} - -async fn do_partition(cfg: &InstallConfig, work_dir: &str) -> Result<(), String> { - let host_dir = format!("{work_dir}/hosts/{}", cfg.hostname); - - retry("partition", 3, Duration::from_secs(5), || async { - run(&format!( - r#"nix run github:nix-community/disko -- --mode disko --flake ".#{}""#, - cfg.hostname - )) - .await - }) - .await?; - - if cfg.mode == InstallMode::PartitionOnly { - let _ = fs::create_dir_all("/mnt/boot/efi"); - if !is_mounted("/mnt/boot/efi").await { - run(&format!("mount {} /mnt/boot/efi", cfg.efi_part)).await?; - } - - if cfg.swap_size != "0" && cfg.fs_type == "btrfs" && !path_exists("/mnt/swap/swapfile") { - run_silent("chattr +C /mnt/swap").await; - let _ = run("truncate -s 0 /mnt/swap/swapfile").await; - run_silent("chattr +C /mnt/swap/swapfile").await; - let _ = run(&format!( - "fallocate -l {} /mnt/swap/swapfile", - cfg.swap_size - )) - .await; - let _ = run("chmod 600 /mnt/swap/swapfile").await; - let _ = run("mkswap /mnt/swap/swapfile").await; - let _ = run("swapon /mnt/swap/swapfile").await; - } - - let hw = run_capture("nixos-generate-config --root /mnt --show-hardware-config").await?; - let hw = strip_filesystems_from_hardware(&hw); - let _ = fs::write(format!("{host_dir}/hardware.nix"), format!("{hw}\n")); - run_silent("git add -A").await; - } - - Ok(()) -} - -async fn do_install_nixos(cfg: &InstallConfig) -> Result<(), String> { - retry("nixos-install", 3, Duration::from_secs(10), || { - let host = cfg.hostname.clone(); - async move { - run(&format!( - r#"nixos-install --flake ".#{host}" --no-root-password"# - )) - .await - } - }) - .await -} - -async fn do_copy_flake(cfg: &InstallConfig, work_dir: &str) -> Result<(), String> { - let dest = format!("/mnt/home/{}/northstar", cfg.username); - let hostname = cfg.hostname.clone(); - let username = cfg.username.clone(); - let wd = work_dir.to_string(); - - retry("copy-flake", 3, Duration::from_secs(5), || { - let dest = dest.clone(); - let hostname = hostname.clone(); - let username = username.clone(); - let wd = wd.clone(); - async move { - let _ = fs::remove_dir_all(&dest); - run(&format!("cp -a {wd}/. {dest}/")).await?; - let _ = fs::remove_dir_all(format!("{dest}/.git")); - run(&format!( - r#"cd {dest} && git init && git config user.name "Northstar Installer" && git config user.email "installer@northstar.local" && git add -A && git commit -m "Initial Northstar configuration for {hostname}""# - )).await?; - - // Fix ownership - if let Ok(passwd) = fs::read_to_string("/mnt/etc/passwd") { - for line in passwd.lines() { - let fields: Vec<&str> = line.split(':').collect(); - if fields.len() > 3 && fields[0] == username { - let (uid, gid) = (fields[2], fields[3]); - let _ = run(&format!("chown -R {uid}:{gid} {dest}")).await; - return Ok(()); - } - } - } - Ok(()) - } - }).await -} diff --git a/installer-rs/src/cmd.rs b/installer-rs/src/cmd.rs deleted file mode 100644 index eb18af6..0000000 --- a/installer-rs/src/cmd.rs +++ /dev/null @@ -1,119 +0,0 @@ -use std::path::Path; -use std::time::Duration; -use tokio::process::Command; - -/// Run a shell command, returning error with output on failure. -pub async fn run(cmd: &str) -> Result<(), String> { - let output = Command::new("sh") - .arg("-c") - .arg(cmd) - .output() - .await - .map_err(|e| format!("Failed to execute: {e}"))?; - - if output.status.success() { - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - Err(format!( - "Command failed ({}): {} {}", - output.status, stdout, stderr - )) - } -} - -/// Run a shell command and capture stdout. -pub async fn run_capture(cmd: &str) -> Result { - let output = Command::new("sh") - .arg("-c") - .arg(cmd) - .output() - .await - .map_err(|e| format!("Failed to execute: {e}"))?; - - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) -} - -/// Run a shell command, ignoring errors. -pub async fn run_silent(cmd: &str) { - let _ = Command::new("sh").arg("-c").arg(cmd).output().await; -} - -/// Check if a path is a mount point. -pub async fn is_mounted(path: &str) -> bool { - Command::new("mountpoint") - .arg("-q") - .arg(path) - .status() - .await - .map(|s| s.success()) - .unwrap_or(false) -} - -/// Check if a device has a filesystem. -#[allow(dead_code)] -pub async fn has_filesystem(device: &str) -> bool { - run_capture(&format!("blkid -o value -s TYPE {device}")) - .await - .map(|s| !s.is_empty()) - .unwrap_or(false) -} - -/// Get filesystem type of a device. -#[allow(dead_code)] -pub async fn get_filesystem(device: &str) -> String { - run_capture(&format!("blkid -o value -s TYPE {device}")) - .await - .unwrap_or_default() -} - -/// Check if a btrfs subvolume exists. -#[allow(dead_code)] -pub async fn subvolume_exists(mount: &str, name: &str) -> bool { - run_capture(&format!("btrfs subvolume list {mount}")) - .await - .map(|s| s.contains(name)) - .unwrap_or(false) -} - -/// Check if a path exists. -pub fn path_exists(path: &str) -> bool { - Path::new(path).exists() -} - -/// Validate a disk device name — reject path traversal attempts. -#[allow(dead_code)] -pub fn validate_device_name(name: &str) -> Result<(), String> { - if name.contains("..") || name.contains('/') { - return Err("Invalid device name: must not contain '..' or '/'".into()); - } - if name.is_empty() { - return Err("Device name cannot be empty".into()); - } - Ok(()) -} - -/// Retry a function with exponential backoff, capped to prevent overflow. -pub async fn retry(name: &str, max: usize, base_delay: Duration, f: F) -> Result<(), String> -where - F: Fn() -> Fut, - Fut: std::future::Future>, -{ - let mut last_err = String::new(); - for attempt in 1..=max { - match f().await { - Ok(()) => return Ok(()), - Err(e) => { - last_err = e; - if attempt < max { - // Cap the multiplier at 64x to prevent overflow - let multiplier = 1u32.checked_shl((attempt - 1) as u32).unwrap_or(64).min(64); - let wait = base_delay * multiplier; - tokio::time::sleep(wait).await; - } - } - } - } - Err(format!("{name} failed after {max} attempts: {last_err}")) -} diff --git a/installer-rs/src/detect.rs b/installer-rs/src/detect.rs deleted file mode 100644 index 39b3afc..0000000 --- a/installer-rs/src/detect.rs +++ /dev/null @@ -1,308 +0,0 @@ -use crate::app::{DiskInfo, DualBootEntry, GpuChoice, IgpuType, PartitionInfo}; -use crate::cmd::run_capture; -use serde::Deserialize; -use std::path::Path; - -#[derive(Debug, Clone, Default)] -pub struct DetectedHardware { - pub disks: Vec, - pub recommended_disk: Option, - pub efi_partitions: Vec<(String, String, String)>, // (dev, size, uuid) - pub detected_os: Vec, - pub gpu_choice: GpuChoice, - pub nvidia_bus_id: Option, - pub igpu_bus_id: Option, - pub igpu_type: IgpuType, -} - -#[derive(Debug, Deserialize)] -struct LsblkJson { - #[serde(default)] - blockdevices: Vec, -} - -#[derive(Debug, Deserialize)] -struct LsblkDevice { - name: String, - #[serde(default)] - size: Option, - #[serde(default)] - #[serde(rename = "type")] - device_type: Option, - #[serde(default)] - model: Option, - #[serde(default)] - tran: Option, - #[serde(default)] - mountpoint: Option, - #[serde(default)] - fstype: Option, - #[serde(default)] - label: Option, - #[serde(default)] - uuid: Option, - #[serde(default)] - children: Option>, -} - -/// Parse PCI slot string (e.g. "01:00.0" or "0000:01:00.0") into Nix format "PCI:1:0:0" -pub fn format_pci_bus_id(raw: &str) -> Option { - let clean = raw.trim(); - if clean.is_empty() { - return None; - } - - // Strip domain if present (e.g., "0000:01:00.0" -> "01:00.0") - let after_domain = if clean.matches(':').count() >= 2 { - clean.split_once(':')?.1 - } else { - clean - }; - - let parts: Vec<&str> = after_domain.split(':').collect(); - if parts.len() != 2 { - return None; - } - - let bus_str = parts[0]; - let dev_fn_parts: Vec<&str> = parts[1].split('.').collect(); - if dev_fn_parts.len() != 2 { - return None; - } - - let dev_str = dev_fn_parts[0]; - let fn_str = dev_fn_parts[1]; - - let bus = u32::from_str_radix(bus_str, 16).ok()?; - let dev = u32::from_str_radix(dev_str, 16).ok()?; - let func = u32::from_str_radix(fn_str, 16).ok()?; - - Some(format!("PCI:{bus}:{dev}:{func}")) -} - -/// Parse lspci output lines and extract GPU bus IDs and vendors. -pub fn parse_lspci_output(output: &str) -> (GpuChoice, Option, Option, IgpuType) { - let mut nvidia_bus = None; - let mut intel_bus = None; - let mut amd_bus = None; - - for line in output.lines() { - let line_lower = line.to_lowercase(); - // Look for display / VGA / 3D controllers - if line_lower.contains("vga compatible controller") - || line_lower.contains("3d controller") - || line_lower.contains("display controller") - { - let slot = line.split_whitespace().next().unwrap_or(""); - let formatted = format_pci_bus_id(slot); - - if line_lower.contains("nvidia") { - nvidia_bus = formatted; - } else if line_lower.contains("intel") { - intel_bus = formatted; - } else if line_lower.contains("amd") - || line_lower.contains("advanced micro devices") - || line_lower.contains("radeon") - { - amd_bus = formatted; - } - } - } - - if let Some(nv) = nvidia_bus { - if let Some(amd) = amd_bus { - (GpuChoice::NvidiaPrime, Some(nv), Some(amd), IgpuType::Amd) - } else if let Some(intel) = intel_bus { - ( - GpuChoice::NvidiaPrime, - Some(nv), - Some(intel), - IgpuType::Intel, - ) - } else { - (GpuChoice::Nvidia, Some(nv), None, IgpuType::Intel) - } - } else { - (GpuChoice::None, None, None, IgpuType::Intel) - } -} - -/// Parse lsblk JSON output into structured DiskInfo list. -pub fn parse_lsblk_json(json_str: &str) -> Vec { - let Ok(data) = serde_json::from_str::(json_str) else { - return Vec::new(); - }; - - let mut disks = Vec::new(); - for dev in data.blockdevices { - let dev_type = dev.device_type.as_deref().unwrap_or(""); - // Only include whole disk devices, exclude loop, zram, etc. - if dev_type != "disk" && !dev.name.starts_with("nvme") && !dev.name.starts_with("sd") { - continue; - } - if dev.name.starts_with("loop") || dev.name.starts_with("zram") { - continue; - } - - let model = dev - .model - .unwrap_or_else(|| "Unknown Disk".into()) - .trim() - .to_string(); - let tran = dev.tran.unwrap_or_default().to_uppercase(); - let drive_type = if dev.name.starts_with("nvme") { - "NVMe".to_string() - } else if !tran.is_empty() { - tran - } else { - "Disk".to_string() - }; - - let mut partitions = Vec::new(); - if let Some(children) = dev.children { - for child in children { - partitions.push(PartitionInfo { - name: child.name, - size: child.size.unwrap_or_else(|| "?".into()), - fs_type: child.fstype.unwrap_or_default(), - mountpoint: child.mountpoint, - label: child.label, - uuid: child.uuid, - }); - } - } - - disks.push(DiskInfo { - name: dev.name, - size: dev.size.unwrap_or_else(|| "?".into()), - model, - drive_type, - partitions, - }); - } - - disks -} - -/// Detect dual-boot EFI files in a mounted ESP directory. -pub fn scan_esp_for_os(esp_mount_path: &Path, esp_uuid: &str) -> Vec { - let mut entries = Vec::new(); - - let candidates = [ - ("EFI/Microsoft/Boot/bootmgfw.efi", "Windows Boot Manager"), - ("EFI/fedora/shimx64.efi", "Fedora Linux"), - ("EFI/ubuntu/shimx64.efi", "Ubuntu"), - ("EFI/arch/grubx64.efi", "Arch Linux"), - ("EFI/debian/shimx64.efi", "Debian"), - ("EFI/opensuse/shim.efi", "openSUSE"), - ]; - - for (rel_path, name) in candidates { - let full_path = esp_mount_path.join(rel_path); - if full_path.exists() { - entries.push(DualBootEntry { - name: name.to_string(), - efi_path: format!("/{rel_path}"), - disk_uuid: esp_uuid.to_string(), - enabled: true, - }); - } - } - - entries -} - -/// Format extraEntries block for GRUB. -pub fn format_grub_extra_entries(entries: &[DualBootEntry]) -> String { - let enabled: Vec<&DualBootEntry> = entries.iter().filter(|e| e.enabled).collect(); - if enabled.is_empty() { - return String::new(); - } - - let mut out = String::from(" boot.loader.grub.extraEntries = ''\n"); - for entry in enabled { - out.push_str(&format!( - " menuentry \"{}\" {{\n search --fs-uuid --set=root {}\n chainloader {}\n }}\n", - entry.name, entry.disk_uuid, entry.efi_path - )); - } - out.push_str(" '';"); - out -} - -/// Format extraEntries block for Limine. -pub fn format_limine_extra_entries(entries: &[DualBootEntry]) -> String { - let enabled: Vec<&DualBootEntry> = entries.iter().filter(|e| e.enabled).collect(); - if enabled.is_empty() { - return String::new(); - } - - let mut out = String::from(" boot.loader.limine.extraEntries = ''\n"); - for entry in enabled { - out.push_str(&format!( - " /{}\n protocol: efi\n path: boot():{}\n\n", - entry.name, entry.efi_path - )); - } - out.push_str(" '';"); - out -} - -/// Run full automatic hardware detection. -pub async fn detect_all() -> DetectedHardware { - let mut detected = DetectedHardware::default(); - - // 1. Detect GPUs via lspci - if let Ok(lspci_out) = run_capture("lspci -D 2>/dev/null || lspci 2>/dev/null").await { - let (choice, nv_bus, igpu_bus, igpu_type) = parse_lspci_output(&lspci_out); - detected.gpu_choice = choice; - detected.nvidia_bus_id = nv_bus; - detected.igpu_bus_id = igpu_bus; - detected.igpu_type = igpu_type; - } - - // 2. Detect Disks via lsblk JSON - if let Ok(lsblk_out) = run_capture( - "lsblk -J -o NAME,SIZE,TYPE,MODEL,TRAN,MOUNTPOINT,FSTYPE,LABEL,UUID 2>/dev/null", - ) - .await - { - detected.disks = parse_lsblk_json(&lsblk_out); - if let Some(first) = detected.disks.first() { - detected.recommended_disk = Some(first.name.clone()); - } - - // Collect EFI partitions - for disk in &detected.disks { - for part in &disk.partitions { - if part.fs_type.to_lowercase() == "vfat" || part.name.to_lowercase().contains("efi") - { - let dev_path = format!("/dev/{}", part.name); - let uuid = part.uuid.clone().unwrap_or_default(); - detected - .efi_partitions - .push((dev_path, part.size.clone(), uuid)); - } - } - } - } - - // 3. Detect Dual-Boot OSes on EFI partition(s) - let temp_esp = Path::new("/tmp/northstar-esp-scan"); - let _ = std::fs::create_dir_all(temp_esp); - - for (dev, _, uuid) in &detected.efi_partitions { - // Try mounting read-only - if crate::cmd::run(&format!("mount -o ro {dev} /tmp/northstar-esp-scan")) - .await - .is_ok() - { - let entries = scan_esp_for_os(temp_esp, uuid); - detected.detected_os.extend(entries); - let _ = crate::cmd::run("umount /tmp/northstar-esp-scan").await; - } - } - let _ = std::fs::remove_dir_all(temp_esp); - - detected -} diff --git a/installer-rs/src/flake.rs b/installer-rs/src/flake.rs deleted file mode 100644 index d6b2dbb..0000000 --- a/installer-rs/src/flake.rs +++ /dev/null @@ -1,111 +0,0 @@ -use include_dir::{include_dir, Dir}; -use std::fs; -use std::path::Path; -use std::process::Command; - -static FLAKE_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/flake"); - -/// Extract the embedded flake to a temporary directory and initialize git repository. -pub fn extract_flake() -> Result { - let tmp = std::env::temp_dir().join(format!("northstar-rs-install-{}", std::process::id())); - - if tmp.exists() { - let _ = fs::remove_dir_all(&tmp); - } - fs::create_dir_all(&tmp).map_err(|e| format!("Failed to create temp dir: {e}"))?; - - // Check if FLAKE_DIR has actual content or only PLACEHOLDER - let has_real_content = FLAKE_DIR - .files() - .any(|f| f.path() != Path::new("PLACEHOLDER")) - || FLAKE_DIR.dirs().count() > 0; - - if has_real_content { - extract_dir(&FLAKE_DIR, &tmp)?; - } else { - // Fallback for local development/cargo run: copy from parent workspace if flake.nix exists - let candidates = [ - Path::new("."), - Path::new(".."), - Path::new("/home/reze/northstar"), - ]; - let mut found = false; - for c in candidates { - if c.join("flake.nix").exists() && c.join("modules").exists() { - copy_recursive(c, &tmp)?; - found = true; - break; - } - } - if !found { - // Still extract the placeholder dir so extraction succeeds in minimal test environments - extract_dir(&FLAKE_DIR, &tmp)?; - } - } - - // Initialize git repository in tmp so nix flake commands recognize all files - let _ = Command::new("git") - .args(["init"]) - .current_dir(&tmp) - .output(); - let _ = Command::new("git") - .args(["config", "user.name", "Northstar Installer"]) - .current_dir(&tmp) - .output(); - let _ = Command::new("git") - .args(["config", "user.email", "installer@northstar.local"]) - .current_dir(&tmp) - .output(); - let _ = Command::new("git") - .args(["add", "-A"]) - .current_dir(&tmp) - .output(); - let _ = Command::new("git") - .args(["commit", "-m", "Initial extraction", "--allow-empty"]) - .current_dir(&tmp) - .output(); - - Ok(tmp.to_string_lossy().to_string()) -} - -fn extract_dir(dir: &Dir, base: &Path) -> Result<(), String> { - for file in dir.files() { - let dest = base.join(file.path()); - if let Some(parent) = dest.parent() { - fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?; - } - fs::write(&dest, file.contents()).map_err(|e| format!("write {}: {e}", dest.display()))?; - } - for subdir in dir.dirs() { - extract_dir(subdir, base)?; - } - Ok(()) -} - -fn copy_recursive(src: &Path, dst: &Path) -> Result<(), String> { - let entries = [ - "flake.nix", - "flake.lock", - "README.md", - "hosts", - "home", - "lib", - "modules", - "flake", - "assets", - ]; - for item in entries { - let src_path = src.join(item); - let dst_path = dst.join(item); - if src_path.is_dir() { - let _ = Command::new("cp") - .arg("-r") - .arg(&src_path) - .arg(&dst_path) - .output(); - } else if src_path.is_file() { - let _ = fs::copy(&src_path, &dst_path); - } - } - Ok(()) -} diff --git a/installer-rs/src/lib.rs b/installer-rs/src/lib.rs deleted file mode 100644 index bc1642c..0000000 --- a/installer-rs/src/lib.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod app; -pub mod backend; -pub mod cmd; -pub mod detect; -pub mod flake; -pub mod state; -pub mod ui; diff --git a/installer-rs/src/main.rs b/installer-rs/src/main.rs deleted file mode 100644 index e452aac..0000000 --- a/installer-rs/src/main.rs +++ /dev/null @@ -1,563 +0,0 @@ -mod app; -mod backend; -mod cmd; -mod detect; -mod flake; -mod state; -mod ui; - -use app::{App, BootloaderChoice, GpuChoice, IgpuType, InstallMode, Page, ProfileChoice}; -use crossterm::{ - event::{self, Event, KeyCode, KeyEventKind}, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, - ExecutableCommand, -}; -use ratatui::prelude::*; -use std::io::stdout; -use std::time::Duration; -use tokio::sync::mpsc; - -const NIX_CONFIG_FEATURES: &str = "experimental-features = nix-command flakes"; - -fn main() -> Result<(), Box> { - ensure_nix_config(); - - // Check root privilege in release / production mode - if std::env::var("NORTHSTAR_DEV").is_err() && nix::unistd::geteuid().as_raw() != 0 { - eprintln!("\x1b[0;31mPlease run as root (e.g. sudo northstar-installer)\x1b[0m"); - std::process::exit(1); - } - - let runtime = tokio::runtime::Runtime::new()?; - runtime.block_on(async_main()) -} - -async fn async_main() -> Result<(), Box> { - let work_dir = flake::extract_flake().map_err(|e| -> Box { - eprintln!("Failed to extract flake: {e}"); - e.into() - })?; - - let _ = std::env::set_current_dir(&work_dir); - - let mut app = App::new(work_dir.clone()); - - // Hardware Auto-Detection - let detected = detect::detect_all().await; - app.detected_disks = detected.disks; - app.detected_efis = detected.efi_partitions; - app.config.dual_boot_entries = detected.detected_os; - - if let Some(first_disk) = app.detected_disks.first() { - app.config.disk_dev = first_disk.name.clone(); - } - if let Some((first_efi, _, _)) = app.detected_efis.first() { - app.config.efi_part = first_efi.clone(); - } - - // Auto-configure detected GPUs - app.config.gpu_choice = detected.gpu_choice; - if let Some(nv) = detected.nvidia_bus_id { - app.config.nvidia_bus_id = nv; - } - if let Some(igpu) = detected.igpu_bus_id { - app.config.igpu_bus_id = igpu; - } - app.config.igpu_type = detected.igpu_type; - - app.init_page(); - - // Install panic hook - let original_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |panic_info| { - let _ = disable_raw_mode(); - let _ = stdout().execute(LeaveAlternateScreen); - original_hook(panic_info); - })); - - // Setup terminal - enable_raw_mode()?; - stdout().execute(EnterAlternateScreen)?; - let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; - - let result = run_app(&mut terminal, &mut app).await; - - // Teardown terminal - disable_raw_mode()?; - stdout().execute(LeaveAlternateScreen)?; - - if let Err(ref e) = result { - eprintln!("Error: {e}"); - } - - let _ = std::fs::remove_dir_all(&work_dir); - result -} - -fn ensure_nix_config() { - let current = std::env::var("NIX_CONFIG").unwrap_or_default(); - if current.contains(NIX_CONFIG_FEATURES) { - return; - } - - let next = if current.trim().is_empty() { - NIX_CONFIG_FEATURES.to_string() - } else { - format!("{}\n{}", current.trim_end(), NIX_CONFIG_FEATURES) - }; - unsafe { std::env::set_var("NIX_CONFIG", next) }; -} - -async fn run_app( - terminal: &mut Terminal>, - app: &mut App, -) -> Result<(), Box> { - loop { - terminal.draw(|f| ui::draw(f, app))?; - - if app.page == Page::Installing { - let mut updates = Vec::new(); - if let Some(ref mut rx) = app.install_rx { - while let Ok(update) = rx.try_recv() { - updates.push(update); - } - } - - for update in updates { - let msg = update.message.clone(); - if let Some(step) = app.install_steps.iter_mut().find(|s| s.name == update.step) - { - if update.done { - step.status = app::StepStatus::Done; - } else if update.error.is_some() { - step.status = app::StepStatus::Error; - } else { - step.status = app::StepStatus::Running; - } - } - if !msg.is_empty() { - app.add_log(format!("❯ {msg}")); - } - if let Some(err) = update.error { - app.add_log(format!("✗ ERROR: {err}")); - app.install_err = Some(err); - } - } - - // Check if installation task completed - if let Some(ref handle) = app.install_handle { - if handle.is_finished() { - if app.install_err.is_none() { - app.go_to_page(Page::Done); - } - app.install_handle = None; - } - } - - app.spinner_idx = app.spinner_idx.wrapping_add(1); - } - - if event::poll(Duration::from_millis(80))? { - if let Event::Key(key) = event::read()? { - if key.kind != KeyEventKind::Press { - continue; - } - - if key.code == KeyCode::Char('c') - && key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) - { - app.should_quit = true; - } - - handle_key(app, key.code).await; - } - } - - if app.should_quit { - break; - } - } - - Ok(()) -} - -async fn handle_key(app: &mut App, key: KeyCode) { - match app.page { - Page::Welcome => match key { - KeyCode::Enter => app.go_to_page(Page::Hostname), - KeyCode::Char('q') => app.should_quit = true, - _ => {} - }, - - Page::Hostname | Page::Username | Page::Password | Page::PasswordConfirm | Page::DiskConfirm | Page::PartNewStart | Page::PartNewEnd | Page::PartExist | Page::RootSize | Page::Swap | Page::SwapPartition | Page::GpuNvBus | Page::GpuIgpuBus => { - match key { - KeyCode::Enter => handle_text_submit(app).await, - KeyCode::Char(c) => app.type_char(c), - KeyCode::Backspace => app.delete_char(), - KeyCode::Esc => { - let p = app.prev_page(); - app.go_to_page(p); - } - _ => {} - } - } - - Page::ProfileCustomize => match key { - KeyCode::Up | KeyCode::Char('k') if app.cursor > 0 => { - app.cursor -= 1; - } - KeyCode::Down | KeyCode::Char('j') if app.cursor < app.config.features.len().saturating_sub(1) => { - app.cursor += 1; - } - KeyCode::Char(' ') => { - app.toggle_current_feature(); - } - KeyCode::Enter => { - app.go_to_page(Page::Bootloader); - } - KeyCode::Esc => { - app.go_to_page(Page::Profile); - } - _ => {} - }, - - Page::DualBoot => match key { - KeyCode::Up | KeyCode::Char('k') if app.cursor > 0 => { - app.cursor -= 1; - } - KeyCode::Down | KeyCode::Char('j') if app.cursor < app.config.dual_boot_entries.len().saturating_sub(1) => { - app.cursor += 1; - } - KeyCode::Char(' ') => { - app.toggle_current_dual_boot(); - } - KeyCode::Enter => { - app.go_to_page(Page::Summary); - } - KeyCode::Esc => { - let p = app.prev_page(); - app.go_to_page(p); - } - _ => {} - }, - - Page::Profile | Page::Bootloader | Page::Mode | Page::Disk | Page::PartSelect | Page::PartConfirm | Page::Efi | Page::Fs | Page::Gpu | Page::GpuIgpuType => { - match key { - KeyCode::Up | KeyCode::Char('k') if app.cursor > 0 => { - app.cursor -= 1; - } - KeyCode::Down | KeyCode::Char('j') if app.cursor < app.choices.len().saturating_sub(1) => { - app.cursor += 1; - } - KeyCode::Enter => handle_selection(app), - KeyCode::Esc => { - let p = app.prev_page(); - app.go_to_page(p); - } - _ => {} - } - } - - Page::Summary => match key { - KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => { - start_installation(app).await; - } - KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { - app.go_to_page(Page::Hostname); - } - _ => {} - }, - - Page::Installing => {} - - Page::Done => { - if key == KeyCode::Enter || key == KeyCode::Char('q') { - app.should_quit = true; - } - } - } -} - -async fn handle_text_submit(app: &mut App) { - let val = app.input_value(); - - match app.page { - Page::Hostname => { - if val.is_empty() { - app.err = "Hostname cannot be empty".into(); - return; - } - app.config.hostname = val; - app.go_to_page(Page::Username); - } - Page::Username => { - if val.is_empty() { - app.err = "Username cannot be empty".into(); - return; - } - app.config.username = val; - app.go_to_page(Page::Password); - } - Page::Password => { - if val.is_empty() { - app.err = "Password cannot be empty".into(); - return; - } - app.plain_pw = val; - app.go_to_page(Page::PasswordConfirm); - } - Page::PasswordConfirm => { - if val != app.plain_pw { - app.err = "Passwords do not match".into(); - return; - } - match backend::hash_password(&app.plain_pw).await { - Ok(hash) => { - app.config.hashed_pw = hash; - app.go_to_page(Page::Profile); - } - Err(e) => { - app.err = format!("Failed to hash password: {e}"); - } - } - } - Page::DiskConfirm => { - if val.to_lowercase() != "yes" { - app.err = "Type 'yes' to confirm or press Esc to choose another disk".into(); - return; - } - if app.config.mode == InstallMode::WholeDisk { - app.go_to_page(Page::Fs); - } else { - app.go_to_page(Page::PartSelect); - } - } - Page::PartNewStart => { - if val.is_empty() { - app.err = "Start position required".into(); - return; - } - app.part_new_start = val; - app.go_to_page(Page::PartNewEnd); - } - Page::PartNewEnd => { - if val.is_empty() { - app.err = "End position required".into(); - return; - } - let start = &app.part_new_start; - if let Err(e) = cmd::run(&format!( - r#"parted -s /dev/{} mkpart primary "{start}" "{val}""#, - app.config.disk_dev - )) - .await - { - app.err = format!("Failed: {e}"); - return; - } - tokio::time::sleep(Duration::from_secs(2)).await; - cmd::run_silent(&format!("partprobe /dev/{}", app.config.disk_dev)).await; - tokio::time::sleep(Duration::from_secs(1)).await; - let name = cmd::run_capture(&format!( - "lsblk -n -l -o NAME /dev/{} | tail -1", - app.config.disk_dev - )) - .await - .unwrap_or_default(); - app.config.nixos_part = format!("/dev/{name}"); - app.go_to_page(Page::PartConfirm); - } - Page::PartExist => { - if val.is_empty() { - app.err = "Partition device required".into(); - return; - } - app.config.nixos_part = format!("/dev/{val}"); - app.go_to_page(Page::PartConfirm); - } - Page::RootSize => { - app.config.root_size = if val.is_empty() { "100%".into() } else { val }; - app.go_to_page(Page::Swap); - } - Page::Swap => { - let swap = if val.is_empty() { "8G".into() } else { val }; - app.config.swap_size = swap.clone(); - if app.config.mode == InstallMode::PartitionOnly - && app.config.fs_type == "ext4" - && swap != "0" - { - app.go_to_page(Page::SwapPartition); - } else { - app.config.swap_partition.clear(); - app.go_to_page(Page::Gpu); - } - } - Page::SwapPartition => { - if val.is_empty() { - app.err = "Swap partition device required for ext4 partition-only mode".into(); - return; - } - app.config.swap_partition = val; - app.go_to_page(Page::Gpu); - } - Page::GpuNvBus => { - if val.is_empty() { - app.err = "NVIDIA Bus ID required (e.g. PCI:1:0:0)".into(); - return; - } - app.config.nvidia_bus_id = val; - if app.config.gpu_choice == GpuChoice::NvidiaPrime { - app.go_to_page(Page::GpuIgpuType); - } else if !app.config.dual_boot_entries.is_empty() { - app.go_to_page(Page::DualBoot); - } else { - app.go_to_page(Page::Summary); - } - } - Page::GpuIgpuBus => { - if val.is_empty() { - app.err = "Integrated GPU Bus ID required (e.g. PCI:0:2:0)".into(); - return; - } - app.config.igpu_bus_id = val; - if !app.config.dual_boot_entries.is_empty() { - app.go_to_page(Page::DualBoot); - } else { - app.go_to_page(Page::Summary); - } - } - _ => {} - } -} - -fn handle_selection(app: &mut App) { - match app.page { - Page::Profile => { - let p = match app.cursor { - 0 => ProfileChoice::Base, - 1 => ProfileChoice::Desktop, - _ => ProfileChoice::Workstation, - }; - app.apply_profile(p); - app.go_to_page(Page::ProfileCustomize); - } - Page::Bootloader => { - let b = match app.cursor { - 0 => BootloaderChoice::Grub, - _ => BootloaderChoice::Limine, - }; - app.config.bootloader = b; - app.go_to_page(Page::Mode); - } - Page::Mode => { - app.config.mode = if app.cursor == 0 { - InstallMode::WholeDisk - } else { - InstallMode::PartitionOnly - }; - app.go_to_page(Page::Disk); - } - Page::Disk => { - if !app.detected_disks.is_empty() { - if let Some(d) = app.detected_disks.get(app.cursor) { - app.config.disk_dev = d.name.clone(); - } - } - if app.config.mode == InstallMode::WholeDisk { - app.go_to_page(Page::DiskConfirm); - } else { - app.go_to_page(Page::PartSelect); - } - } - Page::PartSelect => { - if app.cursor == 0 { - app.go_to_page(Page::PartNewStart); - } else { - app.go_to_page(Page::PartExist); - } - } - Page::PartConfirm => { - if app.cursor == 0 { - app.go_to_page(Page::Efi); - } else { - app.go_to_page(Page::PartSelect); - } - } - Page::Efi => { - if !app.detected_efis.is_empty() { - if app.cursor < app.detected_efis.len() { - let (dev, _, _) = &app.detected_efis[app.cursor]; - app.config.efi_part = dev.clone(); - app.go_to_page(Page::Fs); - } else { - app.detected_efis.clear(); - app.init_page(); - } - } - } - Page::Fs => { - app.config.fs_type = if app.cursor == 0 { "btrfs".into() } else { "ext4".into() }; - if app.config.mode == InstallMode::WholeDisk && app.config.fs_type == "ext4" { - app.go_to_page(Page::RootSize); - } else { - app.go_to_page(Page::Swap); - } - } - Page::Gpu => { - let g = match app.cursor { - 0 => GpuChoice::None, - 1 => GpuChoice::Nvidia, - _ => GpuChoice::NvidiaPrime, - }; - app.config.gpu_choice = g; - match g { - GpuChoice::None => { - if !app.config.dual_boot_entries.is_empty() { - app.go_to_page(Page::DualBoot); - } else { - app.go_to_page(Page::Summary); - } - } - GpuChoice::Nvidia | GpuChoice::NvidiaPrime => { - if app.config.nvidia_bus_id.is_empty() { - app.config.nvidia_bus_id = "PCI:1:0:0".into(); - } - app.go_to_page(Page::GpuNvBus); - } - } - } - Page::GpuIgpuType => { - app.config.igpu_type = if app.cursor == 0 { - IgpuType::Intel - } else { - IgpuType::Amd - }; - if app.config.igpu_bus_id.is_empty() { - app.config.igpu_bus_id = if app.config.igpu_type == IgpuType::Intel { - "PCI:0:2:0".into() - } else { - "PCI:5:0:0".into() - }; - } - app.go_to_page(Page::GpuIgpuBus); - } - _ => {} - } -} - -async fn start_installation(app: &mut App) { - app.go_to_page(Page::Installing); - - let (tx, rx) = mpsc::unbounded_channel(); - app.install_rx = Some(rx); - - let cfg = app.config.clone(); - let work_dir = app.work_dir.clone(); - - let handle = tokio::spawn(async move { - let mut state = state::State::new(); - backend::run_installation(cfg, &mut state, &work_dir, tx).await; - }); - - app.install_handle = Some(handle); -} diff --git a/installer-rs/src/state.rs b/installer-rs/src/state.rs deleted file mode 100644 index dd443f8..0000000 --- a/installer-rs/src/state.rs +++ /dev/null @@ -1,82 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fs; -use std::path::Path; - -const STATE_FILE: &str = "/tmp/northstar-install-state.json"; - -/// Steps that the backend actually executes, in order. -pub const STEP_ORDER: &[&str] = &[ - "generate_config", - "partition", - "install_nixos", - "copy_flake", - "done", -]; - -#[derive(Debug, Serialize, Deserialize, Default)] -pub struct State { - data: HashMap, -} - -impl State { - pub fn new() -> Self { - let mut state = Self::default(); - state.load(); - state - } - - pub fn load(&mut self) { - if let Ok(content) = fs::read_to_string(STATE_FILE) { - if let Ok(data) = serde_json::from_str(&content) { - self.data = data; - } - } - } - - pub fn save(&self) { - if let Ok(json) = serde_json::to_string_pretty(&self.data) { - let _ = fs::write(STATE_FILE, json); - } - } - - pub fn set_step(&mut self, step: &str) { - self.data.insert("step".to_string(), step.to_string()); - self.save(); - } - - pub fn current_step(&self) -> &str { - self.data - .get("step") - .map(|s| s.as_str()) - .unwrap_or(STEP_ORDER[0]) - } - - pub fn should_skip(&self, step_name: &str) -> bool { - let current = self.current_step(); - let current_idx = STEP_ORDER.iter().position(|&s| s == current); - let step_idx = STEP_ORDER.iter().position(|&s| s == step_name); - match (current_idx, step_idx) { - (Some(ci), Some(si)) => si < ci, - _ => false, - } - } - - #[allow(dead_code)] - pub fn clear(&mut self) { - self.data.clear(); - let _ = fs::remove_file(STATE_FILE); - } -} - -impl Drop for State { - fn drop(&mut self) { - // Auto-save on drop if we haven't cleared - if !self.data.is_empty() { - let p = Path::new(STATE_FILE); - if p.exists() { - self.save(); - } - } - } -} diff --git a/installer-rs/src/ui.rs b/installer-rs/src/ui.rs deleted file mode 100644 index aa14791..0000000 --- a/installer-rs/src/ui.rs +++ /dev/null @@ -1,735 +0,0 @@ -use crate::app::{ - App, GpuChoice, InstallMode, Page, - StepStatus, -}; -use ratatui::{ - layout::{Alignment, Constraint, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, BorderType, Borders, Paragraph, Wrap}, - Frame, -}; - -const CYAN: Color = Color::Rgb(0, 210, 255); -const BLUE: Color = Color::Rgb(90, 130, 255); -const GREEN: Color = Color::Rgb(115, 245, 160); -const RED: Color = Color::Rgb(255, 95, 135); -const YELLOW: Color = Color::Rgb(250, 218, 94); -const MAGENTA: Color = Color::Rgb(215, 115, 255); -const DIM: Color = Color::DarkGray; - -pub fn draw(f: &mut Frame, app: &App) { - let area = f.area(); - let chunks = Layout::vertical([ - Constraint::Length(3), // Header - Constraint::Min(12), // Content - Constraint::Length(2), // Footer hints - ]) - .split(area); - - draw_header(f, chunks[0], app); - - match app.page { - Page::Welcome => draw_welcome(f, chunks[1]), - Page::Hostname => draw_input( - f, - chunks[1], - app, - "1/10", - "Host Configuration", - "Enter system hostname (e.g. Makima, Northstar):", - ), - Page::Username => draw_input( - f, - chunks[1], - app, - "2/10", - "User Configuration", - "Enter primary username:", - ), - Page::Password => draw_input( - f, - chunks[1], - app, - "2/10", - "User Password", - "Enter user password (hidden):", - ), - Page::PasswordConfirm => draw_input( - f, - chunks[1], - app, - "2/10", - "Confirm Password", - "Confirm user password:", - ), - Page::Profile => draw_select( - f, - chunks[1], - app, - "3/10", - "System Profile", - "Select a system bundle preset:", - ), - Page::ProfileCustomize => draw_features_checklist(f, chunks[1], app), - Page::Bootloader => draw_select( - f, - chunks[1], - app, - "4/10", - "Bootloader Selection", - "Choose default bootloader & theme:", - ), - Page::Mode => draw_select( - f, - chunks[1], - app, - "5/10", - "Installation Mode", - "Select installation layout mode:", - ), - Page::Disk => draw_disk_select(f, chunks[1], app), - Page::DiskConfirm => { - let warn = format!( - "⚠ WARNING: All data on /dev/{} will be DESTROYED!\n\nType 'yes' to confirm partition wiping:", - app.config.disk_dev - ); - draw_input(f, chunks[1], app, "6/10", "Confirm Disk Destruction", &warn); - } - Page::PartSelect => draw_select( - f, - chunks[1], - app, - "6/10", - "Partition Selection", - "Select partition configuration method:", - ), - Page::PartNewStart => draw_input( - f, - chunks[1], - app, - "6/10", - "Create Partition", - "Enter partition start position (e.g., 100GB, 50%):", - ), - Page::PartNewEnd => draw_input( - f, - chunks[1], - app, - "6/10", - "Create Partition", - "Enter partition end position (e.g., 100%, 500GB):", - ), - Page::PartExist => draw_input( - f, - chunks[1], - app, - "6/10", - "Existing Partition", - "Enter existing partition name (e.g. nvme0n1p3, sda2):", - ), - Page::PartConfirm => draw_select( - f, - chunks[1], - app, - "6/10", - "Confirm Partition", - &format!("Format and install NixOS on {}?", app.config.nixos_part), - ), - Page::Efi => draw_efi_select(f, chunks[1], app), - Page::Fs => draw_select( - f, - chunks[1], - app, - "7/10", - "Filesystem Type", - "Select root filesystem type:", - ), - Page::RootSize => draw_input( - f, - chunks[1], - app, - "7/10", - "Root Partition Size", - "Enter root partition size (default: 100%):", - ), - Page::Swap => draw_input( - f, - chunks[1], - app, - "7/10", - "Swap Space Configuration", - "Enter swap size (e.g. 8G, 16G, or 0 to disable):", - ), - Page::SwapPartition => draw_input( - f, - chunks[1], - app, - "7/10", - "Dedicated Swap Partition", - "Enter dedicated swap partition device (e.g., /dev/nvme0n1p4):", - ), - Page::Gpu => draw_select( - f, - chunks[1], - app, - "8/10", - "GPU Graphics Driver", - "Select GPU driver configuration:", - ), - Page::GpuNvBus => draw_input( - f, - chunks[1], - app, - "8/10", - "NVIDIA PCI Bus ID", - "Enter NVIDIA Bus ID (e.g. PCI:1:0:0):", - ), - Page::GpuIgpuType => draw_select( - f, - chunks[1], - app, - "8/10", - "Integrated GPU Vendor", - "Select integrated GPU vendor (Intel / AMD):", - ), - Page::GpuIgpuBus => draw_input( - f, - chunks[1], - app, - "8/10", - "Integrated GPU Bus ID", - "Enter iGPU Bus ID (e.g. PCI:0:2:0 or PCI:5:0:0):", - ), - Page::DualBoot => draw_dual_boot_select(f, chunks[1], app), - Page::Summary => draw_summary(f, chunks[1], app), - Page::Installing => draw_installing(f, chunks[1], app), - Page::Done => draw_done(f, chunks[1]), - } - - draw_footer(f, chunks[2], app); -} - -fn draw_header(f: &mut Frame, area: Rect, app: &App) { - let title = Span::styled( - " ❄ NORTHSTAR NIXOS INSTALLER ", - Style::default().fg(CYAN).add_modifier(Modifier::BOLD), - ); - - let step_label = match app.page { - Page::Welcome => "Welcome", - Page::Hostname | Page::Username | Page::Password | Page::PasswordConfirm => "Identity & Auth", - Page::Profile | Page::ProfileCustomize => "Profiles & Features", - Page::Bootloader => "Bootloader", - Page::Mode | Page::Disk | Page::DiskConfirm | Page::PartSelect | Page::PartNewStart | Page::PartNewEnd | Page::PartExist | Page::PartConfirm | Page::Efi => "Storage & Disko", - Page::Fs | Page::RootSize | Page::Swap | Page::SwapPartition => "Filesystem & Swap", - Page::Gpu | Page::GpuNvBus | Page::GpuIgpuType | Page::GpuIgpuBus => "Hardware & GPU", - Page::DualBoot => "Dual-Boot Config", - Page::Summary => "Review & Confirm", - Page::Installing => "Installing System", - Page::Done => "Complete", - }; - - let step_span = Span::styled( - format!(" [ {step_label} ] "), - Style::default().fg(BLUE).add_modifier(Modifier::BOLD), - ); - - let header_line = Line::from(vec![title, Span::raw("──"), step_span]); - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(BLUE)); - - f.render_widget(Paragraph::new(header_line).block(block), area); -} - -fn draw_footer(f: &mut Frame, area: Rect, app: &App) { - let hints = match app.page { - Page::Welcome => " [Enter] Start Wizard [q / Ctrl+C] Quit", - Page::ProfileCustomize | Page::DualBoot => " [Space] Toggle Checkbox [↑/↓/j/k] Navigate [Enter] Next [Esc] Back", - Page::Summary => " [Enter / y] Start Installation [Esc / n] Edit Settings [Ctrl+C] Quit", - Page::Installing => " Please wait while NixOS packages are being downloaded and compiled...", - Page::Done => " [Enter / q] Exit Installer & Reboot", - _ => " [Enter] Confirm & Next [↑/↓/j/k] Navigate [Esc] Previous Step [Ctrl+C] Quit", - }; - - let text = Span::styled(hints, Style::default().fg(DIM)); - f.render_widget(Paragraph::new(text).alignment(Alignment::Center), area); -} - -fn draw_welcome(f: &mut Frame, area: Rect) { - let banner = r#" - ███╗ ██╗ ██████╗ ██████╗ ████████╗██╗ ██╗███████╗████████╗ █████╗ ██████╗ - ████╗ ██║██╔═══██╗██╔══██╗╚══██╔══╝██║ ██║██╔════╝╚══██╔══╝██╔══██╗██╔══██╗ - ██╔██╗ ██║██║ ██║██████╔╝ ██║ ███████║███████╗ ██║ ███████║██████╔╝ - ██║╚██╗██║██║ ██║██╔══██╗ ██║ ██╔══██║╚════██║ ██║ ██╔══██║██╔══██╗ - ██║ ╚████║╚██████╔╝██║ ██║ ██║ ██║ ██║███████║ ██║ ██║ ██║██║ ██║ - ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ - "#; - - let lines = vec![ - Line::from(Span::styled(banner, Style::default().fg(CYAN))), - Line::from(""), - Line::from(Span::styled( - " Welcome to the Northstar NixOS installation wizard.", - Style::default().fg(Color::White).add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled(" • Modular option-driven NixOS & Home Manager configuration", Style::default().fg(BLUE))), - Line::from(Span::styled(" • Automated hardware & GPU Prime detection (NVIDIA + Intel/AMD)", Style::default().fg(BLUE))), - Line::from(Span::styled(" • Limine and GRUB (DedSec theme) dual-boot chainloading support", Style::default().fg(BLUE))), - Line::from(Span::styled(" • Declarative Btrfs subvolumes & Disko partitioning engine", Style::default().fg(BLUE))), - Line::from(""), - Line::from(Span::styled(" Press [Enter] to begin system configuration", Style::default().fg(GREEN).add_modifier(Modifier::BOLD))), - ]; - - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(CYAN)); - f.render_widget(Paragraph::new(lines).block(block), area); -} - -fn draw_input(f: &mut Frame, area: Rect, app: &App, step: &str, title: &str, label: &str) { - let chunks = Layout::vertical([ - Constraint::Length(3), - Constraint::Length(3), - Constraint::Min(2), - ]) - .split(area); - - let title_line = Line::from(vec![ - Span::styled(format!(" [Step {step}] "), Style::default().fg(BLUE).add_modifier(Modifier::BOLD)), - Span::styled(title, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), - ]); - let label_line = Line::from(Span::styled(format!(" {label}"), Style::default().fg(Color::White))); - - f.render_widget(Paragraph::new(vec![title_line, Line::from(""), label_line]), chunks[0]); - - let is_pw = app.page == Page::Password || app.page == Page::PasswordConfirm; - let display = if is_pw { - "•".repeat(app.input.len()) - } else { - app.input.clone() - }; - - let input_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(if !app.err.is_empty() { RED } else { BLUE })) - .title(Span::styled(" Value ", Style::default().fg(CYAN))); - - f.render_widget(Paragraph::new(format!(" {display}")).block(input_block), chunks[1]); - - if !app.err.is_empty() { - let err_text = Line::from(Span::styled(format!(" ⚠ {}", app.err), Style::default().fg(RED).add_modifier(Modifier::BOLD))); - f.render_widget(Paragraph::new(err_text), chunks[2]); - } - - f.set_cursor_position((chunks[1].x + 2 + app.cursor_pos as u16, chunks[1].y + 1)); -} - -fn draw_select(f: &mut Frame, area: Rect, app: &App, step: &str, title: &str, label: &str) { - let mut lines = vec![ - Line::from(vec![ - Span::styled(format!(" [Step {step}] "), Style::default().fg(BLUE).add_modifier(Modifier::BOLD)), - Span::styled(title, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), - ]), - Line::from(""), - Line::from(Span::styled(format!(" {label}"), Style::default().fg(Color::White))), - Line::from(""), - ]; - - for (i, choice) in app.choices.iter().enumerate() { - if i == app.cursor { - lines.push(Line::from(Span::styled( - format!(" › {choice}"), - Style::default().fg(CYAN).add_modifier(Modifier::BOLD), - ))); - } else { - lines.push(Line::from(Span::styled( - format!(" {choice}"), - Style::default().fg(Color::White), - ))); - } - } - - if !app.err.is_empty() { - lines.push(Line::from("")); - lines.push(Line::from(Span::styled(format!(" ⚠ {}", app.err), Style::default().fg(RED)))); - } - - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(CYAN)); - f.render_widget(Paragraph::new(lines).block(block), area); -} - -fn draw_features_checklist(f: &mut Frame, area: Rect, app: &App) { - let mut lines = vec![ - Line::from(vec![ - Span::styled(" [Step 3/10] ", Style::default().fg(BLUE).add_modifier(Modifier::BOLD)), - Span::styled("Customize Features & Compositors", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), - ]), - Line::from(""), - Line::from(Span::styled(" Toggle features using [Space]. Press [Enter] to continue.", Style::default().fg(DIM))), - Line::from(""), - ]; - - let mut current_cat = ""; - for (i, feat) in app.config.features.iter().enumerate() { - if feat.category != current_cat { - current_cat = &feat.category; - lines.push(Line::from(Span::styled( - format!(" --- {current_cat} ---"), - Style::default().fg(YELLOW).add_modifier(Modifier::BOLD), - ))); - } - - let check = if feat.enabled { "[x]" } else { "[ ]" }; - let check_style = if feat.enabled { - Style::default().fg(GREEN).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(DIM) - }; - - if i == app.cursor { - lines.push(Line::from(vec![ - Span::styled(" › ", Style::default().fg(CYAN).add_modifier(Modifier::BOLD)), - Span::styled(format!("{check} "), check_style), - Span::styled(&feat.label, Style::default().fg(CYAN).add_modifier(Modifier::BOLD)), - ])); - } else { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(format!("{check} "), check_style), - Span::styled(&feat.label, Style::default().fg(Color::White)), - ])); - } - } - - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(CYAN)); - f.render_widget(Paragraph::new(lines).block(block), area); -} - -fn draw_disk_select(f: &mut Frame, area: Rect, app: &App) { - let chunks = Layout::vertical([ - Constraint::Length(3), - Constraint::Min(8), - Constraint::Length(5), // Disk visualizer bar - ]) - .split(area); - - let title_line = Line::from(vec![ - Span::styled(" [Step 6/10] ", Style::default().fg(BLUE).add_modifier(Modifier::BOLD)), - Span::styled("Target Disk Selection", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), - ]); - f.render_widget(Paragraph::new(title_line), chunks[0]); - - if !app.detected_disks.is_empty() { - let mut lines = vec![Line::from(Span::styled(" Select a disk device:", Style::default().fg(Color::White))), Line::from("")]; - for (i, d) in app.detected_disks.iter().enumerate() { - let label = format!("{} ({} • {} • {})", d.name, d.size, d.drive_type, d.model); - if i == app.cursor { - lines.push(Line::from(Span::styled(format!(" › {label}"), Style::default().fg(CYAN).add_modifier(Modifier::BOLD)))); - } else { - lines.push(Line::from(Span::styled(format!(" {label}"), Style::default().fg(Color::White)))); - } - } - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(CYAN)); - f.render_widget(Paragraph::new(lines).block(block), chunks[1]); - - // Render visual partition bar for currently highlighted disk - if let Some(disk) = app.detected_disks.get(app.cursor) { - draw_disk_visualizer(f, chunks[2], disk); - } - } else { - draw_input(f, chunks[1], app, "6/10", "Target Disk", "Enter disk device name (e.g. nvme0n1, sda):"); - } -} - -fn draw_disk_visualizer(f: &mut Frame, area: Rect, disk: &crate::app::DiskInfo) { - let mut part_spans = Vec::new(); - part_spans.push(Span::styled(format!(" /dev/{} [{}]: ", disk.name, disk.size), Style::default().fg(YELLOW).add_modifier(Modifier::BOLD))); - - if disk.partitions.is_empty() { - part_spans.push(Span::styled("[ Unpartitioned Space ]", Style::default().fg(DIM))); - } else { - for (idx, part) in disk.partitions.iter().enumerate() { - let color = match idx % 4 { - 0 => CYAN, - 1 => GREEN, - 2 => MAGENTA, - _ => BLUE, - }; - let fs_label = if part.fs_type.is_empty() { "raw" } else { &part.fs_type }; - part_spans.push(Span::styled( - format!("[ {} ({}, {}) ] ", part.name, part.size, fs_label), - Style::default().fg(color).add_modifier(Modifier::BOLD), - )); - } - } - - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(YELLOW)) - .title(Span::styled(" Disk Layout Visualizer ", Style::default().fg(YELLOW))); - - f.render_widget(Paragraph::new(Line::from(part_spans)).block(block), area); -} - -fn draw_efi_select(f: &mut Frame, area: Rect, app: &App) { - if !app.detected_efis.is_empty() { - draw_select( - f, - area, - app, - "6/10", - "EFI System Partition (ESP)", - "Select existing EFI system partition:", - ); - } else { - draw_input( - f, - area, - app, - "6/10", - "EFI System Partition (ESP)", - "Enter EFI partition device (e.g. /dev/nvme0n1p1):", - ); - } -} - -fn draw_dual_boot_select(f: &mut Frame, area: Rect, app: &App) { - let mut lines = vec![ - Line::from(vec![ - Span::styled(" [Step 9/10] ", Style::default().fg(BLUE).add_modifier(Modifier::BOLD)), - Span::styled("Detected Dual-Boot Operating Systems", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), - ]), - Line::from(""), - Line::from(Span::styled( - " The following operating systems were detected on your EFI partition.", - Style::default().fg(Color::White), - )), - Line::from(Span::styled( - " Use [Space] to toggle whether to add bootloader chainloader entries for each OS:", - Style::default().fg(DIM), - )), - Line::from(""), - ]; - - for (i, entry) in app.config.dual_boot_entries.iter().enumerate() { - let check = if entry.enabled { "[x]" } else { "[ ]" }; - let check_style = if entry.enabled { - Style::default().fg(GREEN).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(DIM) - }; - - if i == app.cursor { - lines.push(Line::from(vec![ - Span::styled(" › ", Style::default().fg(CYAN).add_modifier(Modifier::BOLD)), - Span::styled(format!("{check} "), check_style), - Span::styled( - format!("{} ({})", entry.name, entry.efi_path), - Style::default().fg(CYAN).add_modifier(Modifier::BOLD), - ), - ])); - } else { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(format!("{check} "), check_style), - Span::styled( - format!("{} ({})", entry.name, entry.efi_path), - Style::default().fg(Color::White), - ), - ])); - } - } - - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(CYAN)); - f.render_widget(Paragraph::new(lines).block(block), area); -} - -fn draw_summary(f: &mut Frame, area: Rect, app: &App) { - let cfg = &app.config; - let gpu_label = match cfg.gpu_choice { - GpuChoice::Nvidia => "NVIDIA Discrete".to_string(), - GpuChoice::NvidiaPrime => format!( - "NVIDIA Prime (NV: {}, iGPU {}: {})", - cfg.nvidia_bus_id, cfg.igpu_type, cfg.igpu_bus_id - ), - GpuChoice::None => "Default / Mesa".to_string(), - }; - - let mut lines = vec![ - Line::from(vec![ - Span::styled(" [Step 10/10] ", Style::default().fg(BLUE).add_modifier(Modifier::BOLD)), - Span::styled("Review System Configuration", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), - ]), - Line::from(""), - ]; - - let swap_label = if !cfg.swap_partition.is_empty() { - format!("{} (partition: {})", cfg.swap_size, cfg.swap_partition) - } else if cfg.mode == InstallMode::PartitionOnly && cfg.fs_type == "btrfs" && cfg.swap_size != "0" { - format!("{} (btrfs subvolume swapfile)", cfg.swap_size) - } else { - cfg.swap_size.clone() - }; - - let rows: Vec<(&str, String)> = vec![ - ("Hostname", cfg.hostname.clone()), - ("Username", cfg.username.clone()), - ("Profile", cfg.profile.to_string()), - ("Bootloader", cfg.bootloader.to_string()), - ("Mode", cfg.mode.to_string()), - ("Disk", cfg.disk_dev.clone()), - ("Filesystem", cfg.fs_type.clone()), - ("Swap", swap_label), - ("GPU Driver", gpu_label), - ]; - - for (k, v) in &rows { - lines.push(Line::from(vec![ - Span::styled(format!(" {k:<16}: "), Style::default().fg(CYAN).add_modifier(Modifier::BOLD)), - Span::styled(v, Style::default().fg(Color::White)), - ])); - } - - if cfg.mode == InstallMode::PartitionOnly { - lines.push(Line::from(vec![ - Span::styled(" NixOS Partition : ".to_string(), Style::default().fg(CYAN).add_modifier(Modifier::BOLD)), - Span::styled(&cfg.nixos_part, Style::default().fg(Color::White)), - ])); - lines.push(Line::from(vec![ - Span::styled(" EFI Partition : ".to_string(), Style::default().fg(CYAN).add_modifier(Modifier::BOLD)), - Span::styled(&cfg.efi_part, Style::default().fg(Color::White)), - ])); - } - - let dual_boot_count = cfg.dual_boot_entries.iter().filter(|e| e.enabled).count(); - if dual_boot_count > 0 { - lines.push(Line::from(vec![ - Span::styled(" Dual-Boot OS : ".to_string(), Style::default().fg(CYAN).add_modifier(Modifier::BOLD)), - Span::styled(format!("{dual_boot_count} OS entry/entries configured"), Style::default().fg(GREEN)), - ])); - } - - lines.push(Line::from("")); - lines.push(Line::from(Span::styled(" ⚠ Review carefully: Disko will partition and format the selected disk.", Style::default().fg(YELLOW).add_modifier(Modifier::BOLD)))); - lines.push(Line::from(Span::styled(" Press [Enter] or [y] to start installation, [Esc] to go back.", Style::default().fg(GREEN).add_modifier(Modifier::BOLD)))); - - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(CYAN)); - f.render_widget(Paragraph::new(lines).block(block), area); -} - -fn draw_installing(f: &mut Frame, area: Rect, app: &App) { - let chunks = Layout::horizontal([ - Constraint::Percentage(45), // Step checklist - Constraint::Percentage(55), // Live logs - ]) - .split(area); - - // Left pane: Step status - let mut step_lines = vec![ - Line::from(Span::styled( - " Installing NixOS...", - Style::default().fg(BLUE).add_modifier(Modifier::BOLD), - )), - Line::from(""), - ]; - - for step in &app.install_steps { - let (icon, style) = match step.status { - StepStatus::Pending => ("○", Style::default().fg(DIM)), - StepStatus::Running => (app.spinner_char(), Style::default().fg(CYAN).add_modifier(Modifier::BOLD)), - StepStatus::Done => ("✓", Style::default().fg(GREEN).add_modifier(Modifier::BOLD)), - StepStatus::Error => ("✗", Style::default().fg(RED).add_modifier(Modifier::BOLD)), - }; - step_lines.push(Line::from(vec![ - Span::styled(format!(" {icon} "), style), - Span::styled(&step.label, if step.status == StepStatus::Running { Style::default().fg(Color::White).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }), - ])); - } - - if let Some(ref e) = app.install_err { - step_lines.push(Line::from("")); - step_lines.push(Line::from(Span::styled(format!(" Error: {e}"), Style::default().fg(RED)))); - } - - let left_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(BLUE)) - .title(Span::styled(" Installation Progress ", Style::default().fg(BLUE))); - f.render_widget(Paragraph::new(step_lines).block(left_block), chunks[0]); - - // Right pane: Live scrolling terminal output - let mut log_spans = Vec::new(); - for line in &app.log_lines { - log_spans.push(Line::from(Span::styled(line, Style::default().fg(CYAN)))); - } - if log_spans.is_empty() { - log_spans.push(Line::from(Span::styled(" Live build output will appear here...", Style::default().fg(DIM)))); - } - - let right_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(CYAN)) - .title(Span::styled(" Live Build Logs ", Style::default().fg(CYAN))); - - f.render_widget( - Paragraph::new(log_spans) - .block(right_block) - .wrap(Wrap { trim: false }), - chunks[1], - ); -} - -fn draw_done(f: &mut Frame, area: Rect) { - let lines = vec![ - Line::from(""), - Line::from(Span::styled( - " 🎉 Installation Finished Successfully!", - Style::default().fg(GREEN).add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled( - " Northstar NixOS has been deployed to your system.", - Style::default().fg(Color::White), - )), - Line::from(Span::styled( - " Your configuration flake has been cloned into ~/northstar.", - Style::default().fg(Color::White), - )), - Line::from(""), - Line::from(Span::styled( - " Press [Enter] or [q] to exit the installer.", - Style::default().fg(CYAN).add_modifier(Modifier::BOLD), - )), - ]; - - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(GREEN)) - .title(Span::styled(" Success ", Style::default().fg(GREEN))); - f.render_widget(Paragraph::new(lines).block(block), area); -} diff --git a/installer-rs/tests/app_state_tests.rs b/installer-rs/tests/app_state_tests.rs deleted file mode 100644 index 1cdacf1..0000000 --- a/installer-rs/tests/app_state_tests.rs +++ /dev/null @@ -1,95 +0,0 @@ -use northstar_installer::app::{App, DualBootEntry, Page, ProfileChoice}; -use northstar_installer::state::State; - -#[test] -fn test_app_initial_state() { - let app = App::new("/tmp/test-northstar-workdir".to_string()); - assert_eq!(app.page, Page::Welcome); - assert!(!app.should_quit); - assert!(app.err.is_empty()); -} - -#[test] -fn test_app_text_input_and_cursor() { - let mut app = App::new("/tmp/test-northstar-workdir".to_string()); - app.go_to_page(Page::Hostname); - - app.type_char('m'); - app.type_char('y'); - app.type_char('h'); - app.type_char('o'); - app.type_char('s'); - app.type_char('t'); - - assert_eq!(app.input, "myhost"); - assert_eq!(app.input_value(), "myhost"); - assert_eq!(app.cursor_pos, 6); - - app.delete_char(); - assert_eq!(app.input, "myhos"); - assert_eq!(app.cursor_pos, 5); -} - -#[test] -fn test_app_profile_and_feature_toggling() { - let mut app = App::new("/tmp/test-northstar-workdir".to_string()); - app.apply_profile(ProfileChoice::Desktop); - - // Hyprland is enabled by default on Desktop - assert_eq!(app.config.features[0].id, "hyprland"); - assert!(app.config.features[0].enabled); - - // Toggle hyprland off - app.cursor = 0; - app.toggle_current_feature(); - assert!(!app.config.features[0].enabled); - - // Toggle hyprland back on - app.toggle_current_feature(); - assert!(app.config.features[0].enabled); - - // Switch to Base profile (minimal) - app.apply_profile(ProfileChoice::Base); - assert!(!app.config.features[0].enabled); -} - -#[test] -fn test_app_dual_boot_toggling() { - let mut app = App::new("/tmp/test-northstar-workdir".to_string()); - app.config.dual_boot_entries = vec![ - DualBootEntry { - name: "Fedora Linux".to_string(), - efi_path: "/EFI/fedora/shimx64.efi".to_string(), - disk_uuid: "CB41-6695".to_string(), - enabled: true, - }, - ]; - - assert!(app.config.dual_boot_entries[0].enabled); - app.cursor = 0; - app.toggle_current_dual_boot(); - assert!(!app.config.dual_boot_entries[0].enabled); -} - -#[test] -fn test_state_step_transitions_and_skip() { - let mut state = State::new(); - state.clear(); - - assert_eq!(state.current_step(), "generate_config"); - assert!(!state.should_skip("generate_config")); - assert!(!state.should_skip("partition")); - - state.set_step("partition"); - assert_eq!(state.current_step(), "partition"); - assert!(state.should_skip("generate_config")); - assert!(!state.should_skip("partition")); - assert!(!state.should_skip("install_nixos")); - - state.set_step("install_nixos"); - assert!(state.should_skip("generate_config")); - assert!(state.should_skip("partition")); - assert!(!state.should_skip("install_nixos")); - - state.clear(); -} diff --git a/installer-rs/tests/config_generation_tests.rs b/installer-rs/tests/config_generation_tests.rs deleted file mode 100644 index 5ab8e67..0000000 --- a/installer-rs/tests/config_generation_tests.rs +++ /dev/null @@ -1,191 +0,0 @@ -use northstar_installer::app::{ - BootloaderChoice, DualBootEntry, GpuChoice, IgpuType, InstallConfig, InstallMode, ProfileChoice, -}; -use northstar_installer::backend::{ - generate_disko_whole_disk, generate_host_default_nix, strip_filesystems_from_hardware, -}; - -#[test] -fn test_generate_host_default_nix_base_grub() { - let cfg = InstallConfig { - hostname: "TestServer".to_string(), - username: "admin".to_string(), - hashed_pw: "$6$testhash".to_string(), - profile: ProfileChoice::Base, - shell: "zsh".to_string(), - bootloader: BootloaderChoice::Grub, - features: northstar_installer::app::default_features(ProfileChoice::Base), - dual_boot_entries: Vec::new(), - mode: InstallMode::WholeDisk, - disk_dev: "sda".to_string(), - nixos_part: String::new(), - efi_part: String::new(), - swap_size: "4G".to_string(), - swap_partition: String::new(), - fs_type: "btrfs".to_string(), - root_size: "100%".to_string(), - gpu_choice: GpuChoice::None, - nvidia_bus_id: String::new(), - igpu_bus_id: String::new(), - igpu_type: IgpuType::Intel, - }; - - let content = generate_host_default_nix(&cfg); - assert!(content.contains("networking.hostName = \"TestServer\";")); - assert!(content.contains("home-manager.users.admin = {")); - assert!(content.contains("users.users.admin = {")); - assert!(content.contains("hashedPassword = \"$6$testhash\";")); - assert!(content.contains("northstar.features.boot.loader = \"grub\";")); - assert!(content.contains("base.enable = true;")); - assert!(!content.contains("desktop.enable = true;")); -} - -#[test] -fn test_generate_host_default_nix_desktop_limine_with_dualboot() { - let cfg = InstallConfig { - hostname: "DesktopHost".to_string(), - username: "alice".to_string(), - hashed_pw: "$6$alicehash".to_string(), - profile: ProfileChoice::Desktop, - shell: "fish".to_string(), - bootloader: BootloaderChoice::Limine, - features: northstar_installer::app::default_features(ProfileChoice::Desktop), - dual_boot_entries: vec![DualBootEntry { - name: "Windows 11".to_string(), - efi_path: "/EFI/Microsoft/Boot/bootmgfw.efi".to_string(), - disk_uuid: "ABCD-1234".to_string(), - enabled: true, - }], - mode: InstallMode::WholeDisk, - disk_dev: "nvme0n1".to_string(), - nixos_part: String::new(), - efi_part: String::new(), - swap_size: "8G".to_string(), - swap_partition: String::new(), - fs_type: "btrfs".to_string(), - root_size: "100%".to_string(), - gpu_choice: GpuChoice::None, - nvidia_bus_id: String::new(), - igpu_bus_id: String::new(), - igpu_type: IgpuType::Intel, - }; - - let content = generate_host_default_nix(&cfg); - assert!(content.contains("networking.hostName = \"DesktopHost\";")); - assert!(content.contains("northstar.features.boot.loader = \"limine\";")); - assert!(content.contains("boot.loader.limine.extraEntries = ''")); - assert!(content.contains("/Windows 11")); - assert!(content.contains("path: boot():/EFI/Microsoft/Boot/bootmgfw.efi")); - assert!(content.contains("desktop.enable = true;")); -} - -#[test] -fn test_generate_host_default_nix_workstation_nvidia_prime() { - let cfg = InstallConfig { - hostname: "Makima".to_string(), - username: "reze".to_string(), - hashed_pw: "$6$rezehash".to_string(), - profile: ProfileChoice::Workstation, - shell: "zsh".to_string(), - bootloader: BootloaderChoice::Grub, - features: northstar_installer::app::default_features(ProfileChoice::Workstation), - dual_boot_entries: Vec::new(), - mode: InstallMode::WholeDisk, - disk_dev: "nvme0n1".to_string(), - nixos_part: String::new(), - efi_part: String::new(), - swap_size: "16G".to_string(), - swap_partition: String::new(), - fs_type: "btrfs".to_string(), - root_size: "100%".to_string(), - gpu_choice: GpuChoice::NvidiaPrime, - nvidia_bus_id: "PCI:1:0:0".to_string(), - igpu_bus_id: "PCI:5:0:0".to_string(), - igpu_type: IgpuType::Amd, - }; - - let content = generate_host_default_nix(&cfg); - assert!(content.contains("desktop.enable = true;")); - assert!(content.contains("workstation.enable = true;")); - assert!(content.contains("northstar.nvidia.enable = true;")); - assert!(content.contains("northstar.nvidia.prime = {")); - assert!(content.contains("nvidiaBusId = \"PCI:1:0:0\";")); - assert!(content.contains("amdgpuBusId = \"PCI:5:0:0\";")); -} - -#[test] -fn test_generate_disko_whole_disk_btrfs() { - let cfg = InstallConfig { - hostname: "NixRig".to_string(), - disk_dev: "nvme0n1".to_string(), - fs_type: "btrfs".to_string(), - swap_size: "16G".to_string(), - root_size: "100%".to_string(), - ..Default::default() - }; - - let disko = generate_disko_whole_disk(&cfg); - assert!(disko.contains("imports = [ ../../lib/disko/btrfs.nix ];")); - assert!(disko.contains("disko.devices.disk.main.device = \"/dev/nvme0n1\";")); - assert!(disko.contains("disko.devices.disk.main.content.partitions.swap.size = lib.mkForce \"16G\";")); -} - -#[test] -fn test_generate_disko_whole_disk_ext4() { - let cfg = InstallConfig { - hostname: "Ext4Machine".to_string(), - disk_dev: "sda".to_string(), - fs_type: "ext4".to_string(), - swap_size: "0".to_string(), - root_size: "500G".to_string(), - ..Default::default() - }; - - let disko = generate_disko_whole_disk(&cfg); - assert!(disko.contains("imports = [ ../../lib/disko/ext4.nix ];")); - assert!(disko.contains("disko.devices.disk.main.device = \"/dev/sda\";")); - assert!(disko.contains("disko.devices.disk.main.content.partitions.swap.size = lib.mkForce \"0\";")); - assert!(disko.contains("disko.devices.disk.main.content.partitions.root.size = lib.mkForce \"500G\";")); -} - -#[test] -fn test_strip_filesystems_from_hardware() { - let raw_hw = r#" -# Do not modify this file! It was generated by ‘nixos-generate-config’ -{ config, lib, pkgs, modulesPath, ... }: - -{ - imports = - [ (modulesPath + "/installer/scan/not-detected.nix") - ]; - - boot.initrd.availableKernelModules = [ "nvme" "xhci_pci" "ahci" "usb_storage" "sd_mod" ]; - boot.initrd.kernelModules = [ ]; - boot.kernelModules = [ "kvm-amd" ]; - boot.extraModulePackages = [ ]; - - fileSystems."/" = - { device = "/dev/disk/by-uuid/12345"; - fsType = "btrfs"; - options = [ "subvol=root" ]; - }; - - fileSystems."/boot" = - { device = "/dev/disk/by-uuid/67890"; - fsType = "vfat"; - }; - - swapDevices = [ { device = "/dev/disk/by-uuid/abcde"; } ]; - - nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; - hardware.cpu.amd.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware; -} -"#; - - let cleaned = strip_filesystems_from_hardware(raw_hw); - assert!(!cleaned.contains("fileSystems.\"/\"")); - assert!(!cleaned.contains("fileSystems.\"/boot\"")); - assert!(!cleaned.contains("swapDevices")); - assert!(cleaned.contains("boot.kernelModules = [ \"kvm-amd\" ];")); - assert!(cleaned.contains("hardware.cpu.amd.updateMicrocode")); -} diff --git a/installer-rs/tests/hardware_detection_tests.rs b/installer-rs/tests/hardware_detection_tests.rs deleted file mode 100644 index 434f86b..0000000 --- a/installer-rs/tests/hardware_detection_tests.rs +++ /dev/null @@ -1,189 +0,0 @@ -use northstar_installer::app::{DualBootEntry, GpuChoice, IgpuType}; -use northstar_installer::detect::{ - format_grub_extra_entries, format_limine_extra_entries, format_pci_bus_id, parse_lsblk_json, - parse_lspci_output, -}; - -#[test] -fn test_format_pci_bus_id_standard() { - assert_eq!(format_pci_bus_id("0000:01:00.0"), Some("PCI:1:0:0".to_string())); - assert_eq!(format_pci_bus_id("01:00.0"), Some("PCI:1:0:0".to_string())); - assert_eq!(format_pci_bus_id("0000:00:02.0"), Some("PCI:0:2:0".to_string())); -} - -#[test] -fn test_format_pci_bus_id_hex_conversion() { - assert_eq!(format_pci_bus_id("0000:0a:00.1"), Some("PCI:10:0:1".to_string())); - assert_eq!(format_pci_bus_id("0000:1f:03.2"), Some("PCI:31:3:2".to_string())); -} - -#[test] -fn test_format_pci_bus_id_invalid() { - assert_eq!(format_pci_bus_id(""), None); - assert_eq!(format_pci_bus_id("invalid"), None); - assert_eq!(format_pci_bus_id("00:00"), None); -} - -#[test] -fn test_parse_lspci_hybrid_nvidia_intel() { - let output = r#" -00:00.0 Host bridge: Intel Corporation 11th Gen Core Processor Host Bridge (rev 05) -00:02.0 VGA compatible controller: Intel Corporation TigerLake-LP GT2 [Iris Xe Graphics] (rev 01) -01:00.0 3D controller: NVIDIA Corporation GA107M [GeForce RTX 3050 Mobile] (rev a1) -00:1f.3 Audio device: Intel Corporation Tiger Lake-LP Smart Sound Technology Audio Controller -"#; - - let (choice, nv_bus, igpu_bus, igpu_type) = parse_lspci_output(output); - assert_eq!(choice, GpuChoice::NvidiaPrime); - assert_eq!(nv_bus, Some("PCI:1:0:0".to_string())); - assert_eq!(igpu_bus, Some("PCI:0:2:0".to_string())); - assert_eq!(igpu_type, IgpuType::Intel); -} - -#[test] -fn test_parse_lspci_hybrid_nvidia_amd() { - let output = r#" -01:00.0 VGA compatible controller: NVIDIA Corporation AD106M [GeForce RTX 4070 Max-Q / Mobile] (rev a1) -05:00.0 VGA compatible controller: Advanced Micro Devices, Inc. [AMD/ATI] Phoenix1 (rev c4) -"#; - - let (choice, nv_bus, igpu_bus, igpu_type) = parse_lspci_output(output); - assert_eq!(choice, GpuChoice::NvidiaPrime); - assert_eq!(nv_bus, Some("PCI:1:0:0".to_string())); - assert_eq!(igpu_bus, Some("PCI:5:0:0".to_string())); - assert_eq!(igpu_type, IgpuType::Amd); -} - -#[test] -fn test_parse_lspci_nvidia_only() { - let output = r#" -01:00.0 VGA compatible controller: NVIDIA Corporation AD104 [GeForce RTX 4070] (rev a1) -"#; - - let (choice, nv_bus, igpu_bus, _) = parse_lspci_output(output); - assert_eq!(choice, GpuChoice::Nvidia); - assert_eq!(nv_bus, Some("PCI:1:0:0".to_string())); - assert_eq!(igpu_bus, None); -} - -#[test] -fn test_parse_lspci_intel_only() { - let output = r#" -00:02.0 VGA compatible controller: Intel Corporation Alder Lake-P Integrated Graphics Controller (rev 0c) -"#; - - let (choice, nv_bus, igpu_bus, _) = parse_lspci_output(output); - assert_eq!(choice, GpuChoice::None); - assert_eq!(nv_bus, None); - assert_eq!(igpu_bus, None); -} - -#[test] -fn test_parse_lsblk_json() { - let json = r#"{ - "blockdevices": [ - { - "name": "nvme0n1", - "size": "953.9G", - "type": "disk", - "model": "Samsung SSD 980 PRO 1TB", - "tran": "nvme", - "mountpoint": null, - "fstype": null, - "label": null, - "uuid": null, - "children": [ - { - "name": "nvme0n1p1", - "size": "512M", - "type": "part", - "model": null, - "tran": null, - "mountpoint": "/boot/efi", - "fstype": "vfat", - "label": "SYSTEM", - "uuid": "CB41-6695" - }, - { - "name": "nvme0n1p2", - "size": "953.4G", - "type": "part", - "model": null, - "tran": null, - "mountpoint": "/", - "fstype": "btrfs", - "label": "nixos", - "uuid": "d8b3c662-817c-482a-8cbe-e7587efc490a" - } - ] - }, - { - "name": "loop0", - "size": "2G", - "type": "loop", - "model": null, - "tran": null, - "mountpoint": "/nix/.ro-store", - "fstype": "squashfs", - "label": null, - "uuid": null - } - ] -}"#; - - let disks = parse_lsblk_json(json); - assert_eq!(disks.len(), 1); - let disk = &disks[0]; - assert_eq!(disk.name, "nvme0n1"); - assert_eq!(disk.size, "953.9G"); - assert_eq!(disk.model, "Samsung SSD 980 PRO 1TB"); - assert_eq!(disk.drive_type, "NVMe"); - assert_eq!(disk.partitions.len(), 2); - assert_eq!(disk.partitions[0].name, "nvme0n1p1"); - assert_eq!(disk.partitions[0].fs_type, "vfat"); - assert_eq!(disk.partitions[0].uuid, Some("CB41-6695".to_string())); -} - -#[test] -fn test_dual_boot_extra_entries_grub() { - let entries = vec![ - DualBootEntry { - name: "Fedora".to_string(), - efi_path: "/EFI/fedora/shimx64.efi".to_string(), - disk_uuid: "CB41-6695".to_string(), - enabled: true, - }, - DualBootEntry { - name: "Windows 11".to_string(), - efi_path: "/EFI/Microsoft/Boot/bootmgfw.efi".to_string(), - disk_uuid: "CB41-6695".to_string(), - enabled: false, - }, - ]; - - let grub_cfg = format_grub_extra_entries(&entries); - assert!(grub_cfg.contains("boot.loader.grub.extraEntries = ''")); - assert!(grub_cfg.contains("menuentry \"Fedora\"")); - assert!(grub_cfg.contains("search --fs-uuid --set=root CB41-6695")); - assert!(grub_cfg.contains("chainloader /EFI/fedora/shimx64.efi")); - // Disabled entry should not appear - assert!(!grub_cfg.contains("Windows 11")); -} - -#[test] -fn test_dual_boot_extra_entries_limine() { - let entries = vec![ - DualBootEntry { - name: "Windows 11".to_string(), - efi_path: "/EFI/Microsoft/Boot/bootmgfw.efi".to_string(), - disk_uuid: "CB41-6695".to_string(), - enabled: true, - }, - ]; - - let limine_cfg = format_limine_extra_entries(&entries); - assert!(limine_cfg.contains("boot.loader.limine.extraEntries = ''")); - assert!(limine_cfg.contains("/Windows 11")); - assert!(limine_cfg.contains("protocol: efi")); - assert!(limine_cfg.contains("path: boot():/EFI/Microsoft/Boot/bootmgfw.efi")); -} diff --git a/installer/install.py b/installer/install.py index 1fb0b75..a49ecdd 100644 --- a/installer/install.py +++ b/installer/install.py @@ -5,7 +5,7 @@ Provides full feature parity with Northstar Rust installer (installer-rs): - Profiles (Base, Desktop, Workstation) - 10 toggleable feature options and delta overrides -- Selectable bootloaders (GRUB with DedSec theme, Limine) +- Limine bootloader with display resolution detection - Automated hardware detection (lspci GPU detection, lsblk -J disks, ESP scanning) - Dual-boot detection and chainloader config generation - Disko whole-disk and partition-only layout generation @@ -127,15 +127,10 @@ def __str__(self) -> str: class BootloaderChoice(str, Enum): - GRUB = "grub" LIMINE = "limine" def __str__(self) -> str: - if self == BootloaderChoice.GRUB: - return "GRUB (Cyberpunk DedSec Theme)" - elif self == BootloaderChoice.LIMINE: - return "Limine (Modern Ultra-Fast UEFI)" - return self.value + return "Limine (Modern Ultra-Fast UEFI)" class GpuChoice(str, Enum): @@ -295,7 +290,7 @@ class InstallConfig: hashed_pw: str = "" profile: ProfileChoice = ProfileChoice.DESKTOP shell: str = "zsh" - bootloader: BootloaderChoice = BootloaderChoice.GRUB + bootloader: BootloaderChoice = BootloaderChoice.LIMINE secure_boot: bool = False resolution: str = "1920x1080" features: list[FeatureOption] = field(default_factory=lambda: default_features(ProfileChoice.DESKTOP)) @@ -391,8 +386,8 @@ def from_dict(cls, data: dict[str, Any]) -> InstallConfig: cfg.shell = str(data.get("shell", cfg.shell) or "zsh") - bl_val = data.get("bootloader", "grub") - cfg.bootloader = BootloaderChoice.LIMINE if str(bl_val).lower() == "limine" else BootloaderChoice.GRUB + bl_val = data.get("bootloader", "limine") + cfg.bootloader = BootloaderChoice.LIMINE cfg.secure_boot = bool(data.get("secure_boot", False)) cfg.resolution = str(data.get("resolution", "1920x1080") or "1920x1080") @@ -1114,19 +1109,7 @@ def detect_all() -> dict[str, Any]: # ── Extra Entries Formatters ──────────────────────────────────── -def format_grub_extra_entries(entries: list[DualBootEntry]) -> str: - """Format GRUB extraEntries configuration.""" - enabled = [e for e in entries if e.enabled] - if not enabled: - return "" - lines = [" boot.loader.grub.extraEntries = ''"] - for entry in enabled: - lines.append(f' menuentry "{entry.name}" {{') - lines.append(f" search --fs-uuid --set=root {entry.disk_uuid}") - lines.append(f" chainloader {entry.efi_path}") - lines.append(" }") - lines.append(" '';") - return "\n".join(lines) + def format_limine_extra_entries(entries: list[DualBootEntry]) -> str: @@ -1168,25 +1151,14 @@ def build_gpu_config(cfg: InstallConfig) -> str: def build_bootloader_config(cfg: InstallConfig) -> str: """Build the bootloader configuration block with dual boot, resolution and secure boot.""" - lines: list[str] = [" # Bootloader"] - if cfg.bootloader == BootloaderChoice.GRUB: - lines.append(' northstar.features.boot.loader = "grub";') - if getattr(cfg, "secure_boot", False): - lines.append(" northstar.features.boot.secureBoot.enable = true;") - extra = format_grub_extra_entries(cfg.dual_boot_entries) - if extra: - lines.append(extra) - elif cfg.bootloader == BootloaderChoice.LIMINE: - lines.append(' northstar.features.boot.loader = "limine";') - res = getattr(cfg, "resolution", "") or "1920x1080" - lines.append(f' boot.loader.limine.resolution = "{res}";') - if getattr(cfg, "secure_boot", False): - lines.append(" northstar.features.boot.secureBoot.enable = true;") - extra = format_limine_extra_entries(cfg.dual_boot_entries) - if extra: - lines.append(extra) - else: - return "" + lines: list[str] = [" # Bootloader (Limine)"] + res = getattr(cfg, "resolution", "") or "1920x1080" + lines.append(f' boot.loader.limine.resolution = "{res}";') + if getattr(cfg, "secure_boot", False): + lines.append(" northstar.features.boot.secureBoot.enable = true;") + extra = format_limine_extra_entries(cfg.dual_boot_entries) + if extra: + lines.append(extra) return "\n".join(lines) + "\n" @@ -1223,7 +1195,11 @@ def build_features_override(cfg: InstallConfig) -> str: def strip_filesystems_from_hardware(hw_text: str) -> str: - """Strip fileSystems.* and swapDevices entries from hardware.nix output.""" + """Strip fileSystems.*, swapDevices entries, and autogenerated '#' comments from hardware.nix output, + ensuring hardware.graphics and firmware settings are present.""" + if not hw_text.strip(): + return "" + cleaned_lines = [] in_fs_block = False fs_depth = 0 @@ -1232,6 +1208,10 @@ def strip_filesystems_from_hardware(hw_text: str) -> str: for line in hw_text.splitlines(): stripped = line.strip() + # Strip autogenerated comment lines + if stripped.startswith("#"): + continue + if in_swap_devices: if ";" in stripped: in_swap_devices = False @@ -1264,35 +1244,62 @@ def strip_filesystems_from_hardware(hw_text: str) -> str: cleaned_lines.append(line) result = "\n".join(cleaned_lines) + + # Ensure hardware graphics and firmware settings exist if not already present + if "hardware.graphics" not in result and "graphics.enable" not in result: + hw_block = " hardware = {\n graphics.enable = lib.mkDefault true;\n firmware = [ pkgs.linux-firmware ];\n };\n" + if result.rstrip().endswith("}"): + idx = result.rfind("}") + result = result[:idx] + hw_block + result[idx:] + else: + result += "\n" + hw_block + while "\n\n\n" in result: result = result.replace("\n\n\n", "\n\n") return result.strip() +def escape_nix_string(s: str) -> str: + """Escape a string for safe embedding in a Nix double-quoted string literal. + + Only escapes characters that are actually special in Nix string literals: + - backslash (\\) — escape character + - double-quote (") — string delimiter + - dollar-brace (${) — interpolation trigger + Bare $ (e.g. in SHA-512 hashes like $6$salt$hash) is NOT special and must NOT be escaped. + """ + if not isinstance(s, str): + return str(s) + return s.replace('\\', '\\\\').replace('"', '\\"').replace('${', '\\${') + def generate_disko_whole_disk(cfg: InstallConfig) -> str: """Generate disko.nix content for whole-disk mode.""" - template = "ext4" if cfg.fs_type == "ext4" else "btrfs" - disko = ( - f"# Auto-generated disko config for {cfg.hostname}\n" - "{\n" - " lib,\n" - " ...\n" - "}:\n\n" - "{\n" - f" imports = [ ../../lib/disko/{template}.nix ];\n\n" - f' disko.devices.disk.main.device = "/dev/{cfg.disk_dev}";\n' - ) + lines = [ + f"# Auto-generated disko config for {cfg.hostname}", + "{ lib, ... }:", + "", + "let", + " northstar = import ../../lib/core.nix { inherit lib; };", + "in", + "northstar.mkDisko {", + ' mode = "whole-disk";', + f' device = "/dev/{cfg.disk_dev}";', + f' fsType = "{cfg.fs_type}";', + ' efiSize = "4G";', + ] + if getattr(cfg, "extra_disko_config", ""): + lines.append(f" extraConfig = {cfg.extra_disko_config};") - if cfg.swap_size == "0": - disko += ' # Swap disabled\n disko.devices.disk.main.content.partitions.swap.size = lib.mkForce "0";\n' - elif cfg.swap_size != "8G": - disko += f' disko.devices.disk.main.content.partitions.swap.size = lib.mkForce "{cfg.swap_size}";\n' + lines.append(f' swapSize = "{cfg.swap_size}";') if cfg.root_size != "100%": - disko += f' disko.devices.disk.main.content.partitions.root.size = lib.mkForce "{cfg.root_size}";\n' + lines.append(f' rootSize = "{cfg.root_size}";') - disko += "}\n" - return disko + lines.extend([ + "}", + "", + ]) + return "\n".join(lines) def generate_disko_partition_only( @@ -1415,9 +1422,10 @@ def generate_host_default_nix(cfg: InstallConfig) -> str: if bootloader_config: blocks.append(bootloader_config.rstrip()) - user_block = f""" users.users.{cfg.username} = {{ + u = escape_nix_string(cfg.username) + user_block = f""" users.users.{u} = {{ isNormalUser = true; - description = "{cfg.username}"; + description = "{u}"; extraGroups = [ "networkmanager" "wheel" @@ -1425,7 +1433,7 @@ def generate_host_default_nix(cfg: InstallConfig) -> str: "docker" ]; shell = pkgs.{cfg.shell}; - hashedPassword = "{cfg.hashed_pw}"; + hashedPassword = "{escape_nix_string(cfg.hashed_pw)}"; }};""" blocks.append(user_block) blocks.append(profile_config) @@ -1447,18 +1455,19 @@ def generate_host_default_nix(cfg: InstallConfig) -> str: {{ imports = [ + ./hardware.nix ./disko.nix ]; - home-manager.users.{cfg.username} = {{ - imports = [ ../../home ]; - home.username = lib.mkForce "{cfg.username}"; - home.homeDirectory = lib.mkForce "/home/{cfg.username}"; + home-manager.users.{u} = {{ + imports = [ ../../home/home.nix ]; + home.username = lib.mkForce "{u}"; + home.homeDirectory = lib.mkForce "/home/{u}"; }}; {body} - networking.hostName = "{cfg.hostname}"; + networking.hostName = "{escape_nix_string(cfg.hostname)}"; system.stateVersion = "26.11"; }} """ @@ -1808,6 +1817,14 @@ def setup_age_keys(cfg: InstallConfig, target_root: Path = Path("/mnt")) -> None age_key_file = sops_dir / "key.txt" host_priv = target_root / "etc" / "ssh" / "ssh_host_ed25519_key" + if age_key_file.exists() and age_key_file.stat().st_size > 0: + msg(f"Age key already exists at {age_key_file}, reusing existing key.") + try: + os.chmod(age_key_file, 0o600) + except OSError: + pass + return + if cfg.age_key_action == "derive": if host_priv.exists() and shutil.which("ssh-to-age"): msg("Deriving Age key from SSH host key (ssh-to-age)...") @@ -1819,6 +1836,11 @@ def setup_age_keys(cfg: InstallConfig, target_root: Path = Path("/mnt")) -> None pass elif shutil.which("age-keygen"): msg("Generating Age key via age-keygen (ssh-to-age unavailable)...") + if age_key_file.exists(): + try: + age_key_file.unlink() + except OSError: + pass run(f"age-keygen -o {age_key_file}") try: os.chmod(age_key_file, 0o600) @@ -1827,6 +1849,11 @@ def setup_age_keys(cfg: InstallConfig, target_root: Path = Path("/mnt")) -> None elif cfg.age_key_action == "generate": if shutil.which("age-keygen"): msg("Generating fresh Age key via age-keygen...") + if age_key_file.exists(): + try: + age_key_file.unlink() + except OSError: + pass run(f"age-keygen -o {age_key_file}") try: os.chmod(age_key_file, 0o600) @@ -1903,11 +1930,14 @@ def do_generate_config(cfg: InstallConfig, work_dir: Path) -> None: (host_dir / "default.nix").write_text(default_content) # Stub hardware.nix to satisfy discoverHosts and mkHostModules during initial Disko evaluation - hw_stub = """# Stub hardware configuration generated by Northstar installer -{ lib, ... }: + hw_stub = """{ config, lib, pkgs, ... }: { nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; + hardware = { + graphics.enable = lib.mkDefault true; + firmware = [ pkgs.linux-firmware ]; + }; } """ hw_file = host_dir / "hardware.nix" @@ -2162,35 +2192,30 @@ def interactive_wizard(script_dir: Path, resume: bool = False, no_root_check: bo hw_info = detect_all() # 5. Bootloader & Security Selection - step("5/12", "Bootloader & Security Selection") - print("Select bootloader:") - print(" 1) GRUB — Cyberpunk DedSec Theme (Default)") - print(" 2) Limine — Modern Ultra-Fast UEFI Bootloader") - bl_choice = input("Choice [1]: ").strip() or "1" - cfg.bootloader = BootloaderChoice.LIMINE if bl_choice == "2" else BootloaderChoice.GRUB - msg(f"Selected Bootloader: {cfg.bootloader}") - - if cfg.bootloader == BootloaderChoice.LIMINE: - detected_res = hw_info.get("resolutions") or detect_display_resolutions() - print("\nSelect Limine display resolution:") - for r_idx, res_str in enumerate(detected_res[:8], 1): - tag = " (Detected / Native)" if r_idx == 1 else "" - print(f" {r_idx}) {res_str}{tag}") - res_choice = input("Choice [1] or enter custom WIDTHxHEIGHT: ").strip() or "1" - try: - r_num = int(res_choice) - 1 - if 0 <= r_num < len(detected_res): - cfg.resolution = detected_res[r_num] - else: - cfg.resolution = res_choice - except ValueError: + step("5/12", "Bootloader & Display") + cfg.bootloader = BootloaderChoice.LIMINE + msg("Bootloader: Limine (Modern Ultra-Fast UEFI)") + + detected_res = hw_info.get("resolutions") or detect_display_resolutions() + print("\nSelect Limine display resolution:") + for r_idx, res_str in enumerate(detected_res[:8], 1): + tag = " (Detected / Native)" if r_idx == 1 else "" + print(f" {r_idx}) {res_str}{tag}") + res_choice = input("Choice [1] or enter custom WIDTHxHEIGHT: ").strip() or "1" + try: + r_num = int(res_choice) - 1 + if 0 <= r_num < len(detected_res): + cfg.resolution = detected_res[r_num] + else: cfg.resolution = res_choice - msg(f"Limine Resolution set to: {cfg.resolution}") + except ValueError: + cfg.resolution = res_choice + msg(f"Limine Resolution set to: {cfg.resolution}") - sb_ans = input("\nEnable UEFI Secure Boot with Lanzaboote? [y/N]: ").strip().lower() + sb_ans = input("\nEnable UEFI Secure Boot with Limine? [y/N]: ").strip().lower() cfg.secure_boot = sb_ans == "y" if cfg.secure_boot: - msg("UEFI Secure Boot (Lanzaboote) enabled.") + msg("UEFI Secure Boot (Limine) enabled.") # 6. SSH & Age Key Management step("6/12", "SSH & Age Key Management") @@ -2329,9 +2354,9 @@ def interactive_wizard(script_dir: Path, resume: bool = False, no_root_check: bo die("Invalid root size format. Use 200G, 50%, or 100%") cfg.root_size = r_size - swap = input("Swap size [8G] (or 0 to disable): ").strip() or "8G" + swap = input("Swap size [16G] (or 0 to disable): ").strip() or "16G" if swap != "0" and not re.match(r"^\d+[GMgm]$", swap): - die("Invalid swap size format. Use 8G, 16G, or 0") + die("Invalid swap size format.") cfg.swap_size = swap if cfg.mode == InstallMode.PARTITION_ONLY and cfg.fs_type == "ext4" and cfg.swap_size != "0": @@ -2369,9 +2394,14 @@ def interactive_wizard(script_dir: Path, resume: bool = False, no_root_check: bo cfg.igpu_type = IgpuType.AMD if ig_type_str == "2" else IgpuType.INTEL cfg.igpu_bus_id = input(f"iGPU Bus ID [{hw_info['igpu_bus_id'] or 'PCI:0:2:0'}]: ").strip() or (hw_info["igpu_bus_id"] or "PCI:0:2:0") - # 11. Dual-Boot OSes - step("11/12", "Dual-Boot OS Detection") - detected_oses: list[DualBootEntry] = hw_info["detected_os"] + # 11. Custom Partitioning & Dual-Boot + step("11/12", "Custom Partitioning & Dual-Boot") + print("Advanced Custom Partitioning (Disko):") + print(" You can optionally provide raw Nix code to merge into your disko config (extraConfig).") + print(" Leave blank to use standard generated layouts.") + cfg.extra_disko_config = input(" extraConfig [None]: ").strip() + + detected_oses: list[DualBootEntry] = hw_info.get("detected_os", []) if detected_oses: print("Detected other OSes on ESP:") for idx, os_entry in enumerate(detected_oses, 1): diff --git a/lib/default.nix b/lib/core.nix similarity index 70% rename from lib/default.nix rename to lib/core.nix index 552aa26..91129da 100644 --- a/lib/default.nix +++ b/lib/core.nix @@ -16,11 +16,19 @@ let hasFile = dir: name: (dir + "/${name}") |> builtins.pathExists; hostHasDisko = hostsDir: hostName: (hostsDir + "/${hostName}/disko.nix") |> builtins.pathExists; + + # Import the disko configuration generator + diskoLib = import ./disko/generator.nix { inherit lib; }; in rec { + # Re-export disko generator for use by installer and host configs + inherit (diskoLib) mkDisko; + # Functional Feature & Profile Combinator mkProfile = features: { - northstar.features = lib.genAttrs features (_: { enable = true; }); + northstar.features = lib.genAttrs features (_: { + enable = true; + }); }; # User & Home-Manager Functional Combinator @@ -30,7 +38,7 @@ rec { groups ? [ "wheel" ], shell ? null, homeDir ? "/home/${username}", - homeConfig ? ../home, + homeConfig ? ../home/home.nix, extraConfig ? { }, }: { pkgs, ... }: @@ -39,7 +47,9 @@ rec { isNormalUser = true; description = username; extraGroups = groups; - } // (lib.optionalAttrs (shell != null) { inherit shell; }) // extraConfig; + } + // (lib.optionalAttrs (shell != null) { inherit shell; }) + // extraConfig; home-manager.users.${username} = { imports = [ homeConfig ]; @@ -74,6 +84,14 @@ rec { inherit hostHasDisko; + # mkHostModules: build the module list for a given host. + # + # CONTRACT: + # - Each host directory MUST contain `default.nix` and `hardware.nix`. + # - The host's `default.nix` MUST explicitly import `./hardware.nix`. + # - If `disko.nix` exists in the host directory, the disko NixOS module + # is automatically added. The host's `default.nix` imports `./disko.nix` + # for the disko configuration itself. mkHostModules = { inputs, @@ -83,9 +101,7 @@ rec { hostName, }: [ - inputs.dedsec-grub-theme.nixosModule (hostsDir + "/${hostName}") - (hostsDir + "/${hostName}/hardware.nix") commonModule { nix.nixPath = [ "nixpkgs=${inputs.nixpkgs}" ]; } ] @@ -102,7 +118,10 @@ rec { }: lib.nixosSystem { inherit system; - specialArgs = { inherit inputs; }; + specialArgs = { + inherit inputs; + northstar = import ./core.nix { inherit lib; }; + }; modules = (mkHostModules { inherit inputs hostsDir hostName; }) ++ extraModules; }; } diff --git a/lib/disko/btrfs.nix b/lib/disko/btrfs.nix deleted file mode 100644 index 6af4e11..0000000 --- a/lib/disko/btrfs.nix +++ /dev/null @@ -1,63 +0,0 @@ -{ - disko.devices = { - disk = { - main = { - type = "disk"; - # Host-specific disko modules must set this. - # device = "/dev/nvme0n1"; - content = { - type = "gpt"; - partitions = { - ESP = { - priority = 1; - name = "ESP"; - start = "1M"; - end = "1024M"; - type = "EF00"; - content = { - type = "filesystem"; - format = "vfat"; - mountpoint = "/boot/efi"; - }; - }; - swap = { - size = "8G"; - content = { - type = "swap"; - discardPolicy = "both"; - resumeDevice = true; # resume from hibernation from this device - }; - }; - root = { - size = "100%"; - content = { - type = "btrfs"; - extraArgs = [ "-f" ]; # Override existing partition - # Subvolumes must set a mountpoint in order to be mounted, - # unless their parent is mounted - subvolumes = { - "/root" = { - mountpoint = "/"; - mountOptions = [ "compress=zstd" ]; - }; - "/home" = { - mountpoint = "/home"; - mountOptions = [ "compress=zstd" ]; - }; - "/nix" = { - mountpoint = "/nix"; - mountOptions = [ "compress=zstd" "noatime" ]; - }; - "/log" = { - mountpoint = "/var/log"; - mountOptions = [ "compress=zstd" ]; - }; - }; - }; - }; - }; - }; - }; - }; - }; -} diff --git a/lib/disko/ext4.nix b/lib/disko/ext4.nix deleted file mode 100644 index cbd086e..0000000 --- a/lib/disko/ext4.nix +++ /dev/null @@ -1,44 +0,0 @@ -{ - disko.devices = { - disk = { - main = { - type = "disk"; - # Host-specific disko modules must set this. - # device = "/dev/nvme0n1"; - content = { - type = "gpt"; - partitions = { - ESP = { - priority = 1; - name = "ESP"; - start = "1M"; - end = "1024M"; - type = "EF00"; - content = { - type = "filesystem"; - format = "vfat"; - mountpoint = "/boot/efi"; - }; - }; - swap = { - size = "8G"; - content = { - type = "swap"; - discardPolicy = "both"; - resumeDevice = true; # resume from hibernation from this device - }; - }; - root = { - size = "100%"; - content = { - type = "filesystem"; - format = "ext4"; - mountpoint = "/"; - }; - }; - }; - }; - }; - }; - }; -} diff --git a/lib/disko/generator.nix b/lib/disko/generator.nix new file mode 100644 index 0000000..ae75b14 --- /dev/null +++ b/lib/disko/generator.nix @@ -0,0 +1,206 @@ +# lib/disko/default.nix — Dynamic disko configuration generator +# +# Usage: +# mkDisko { mode = "whole-disk"; device = "/dev/nvme0n1"; fsType = "btrfs"; } +# mkDisko { mode = "partition-only"; nixosPart = "/dev/nvme0n1p2"; efiDevice = "/dev/disk/by-uuid/XXXX"; } +# +{ lib }: + +let + # Btrfs subvolume layout — standard Northstar layout with compress=zstd + btrfsSubvolumes = + swapEnabled: + { + "/root" = { + mountpoint = "/"; + mountOptions = [ "compress=zstd" ]; + }; + "/home" = { + mountpoint = "/home"; + mountOptions = [ "compress=zstd" ]; + }; + "/nix" = { + mountpoint = "/nix"; + mountOptions = [ + "compress=zstd" + "noatime" + ]; + }; + "/log" = { + mountpoint = "/var/log"; + mountOptions = [ "compress=zstd" ]; + }; + } + // lib.optionalAttrs swapEnabled { + "/swap" = { + mountpoint = "/swap"; + }; + }; +in +rec { + # parseSizeToMiB: robustly parse user size strings like "8G", "1024M" into MiB + parseSizeToMiB = + sizeStr: + let + match = builtins.match "^([0-9]+)([a-zA-Z%]*)$" (lib.strings.toUpper sizeStr); + numStr = if match != null then builtins.elemAt match 0 else "8"; + suffix = if match != null then builtins.elemAt match 1 else "G"; + val = lib.toInt numStr; + in + if suffix == "G" || suffix == "GB" then + val * 1024 + else if suffix == "M" || suffix == "MB" then + val + else if suffix == "T" || suffix == "TB" then + val * 1024 * 1024 + else + val * 1024; + + # mkDisko: generate a complete disko configuration attrset + # + # Arguments: + # mode : "whole-disk" | "partition-only" + # fsType : "btrfs" | "ext4" (default: "btrfs") + # device : disk device path (whole-disk mode) + # nixosPart : NixOS partition device path (partition-only mode) + # efiDevice : EFI partition device or UUID path (partition-only: existing EFI) + # efiSize : EFI partition size (whole-disk: default "2G") + # swapSize : swap partition/file size, "0" to skip (default: "16G") + # rootSize : root partition size (default: "100%") + # swapPartition : dedicated swap partition device (partition-only + ext4 only) + # + # Swap architecture by mode: + # - whole-disk: Swap is a dedicated GPT partition managed by disko. + # The btrfs /swap subvolume is NOT created (btrfsSubvolumes false) + # because the swap lives on its own partition, not as a swapfile. + # - partition-only: Swap is a btrfs swapfile at /swap/swapfile inside a /swap subvolume + # (btrfsSubvolumes swapEnabled), or for ext4 a separate swap partition + # specified via swapPartition. + mkDisko = + { + mode ? "whole-disk", + fsType ? "btrfs", + device ? null, + nixosPart ? null, + efiDevice ? null, + efiSize ? "2G", + swapSize ? "16G", + rootSize ? "100%", + swapPartition ? null, + extraConfig ? { }, + }: + let + swapEnabled = swapSize != "0"; + + # Whole-disk mode: full GPT with ESP + optional swap + root + wholeDiskConfig = { + disko.devices.disk.main = { + type = "disk"; + inherit device; + content = { + type = "gpt"; + partitions = { + ESP = { + priority = 1; + name = "ESP"; + start = "1M"; + end = efiSize; + type = "EF00"; + content = { + type = "filesystem"; + format = "vfat"; + mountpoint = "/boot/efi"; + }; + }; + } + // lib.optionalAttrs swapEnabled { + swap = { + size = swapSize; + content = { + type = "swap"; + discardPolicy = "both"; + resumeDevice = true; + }; + }; + } + // { + root = { + size = rootSize; + content = + if fsType == "btrfs" then + { + type = "btrfs"; + extraArgs = [ "-f" ]; + # false: no /swap subvolume needed — swap is a dedicated GPT partition + subvolumes = btrfsSubvolumes false; + } + else + { + type = "filesystem"; + format = "ext4"; + mountpoint = "/"; + }; + }; + }; + }; + }; + }; + + # Partition-only mode: use existing partitions + partitionOnlyConfig = { + disko.devices.disk.nixos = { + type = "disk"; + device = nixosPart; + content = + if fsType == "btrfs" then + { + type = "btrfs"; + extraArgs = [ "-f" ]; + subvolumes = btrfsSubvolumes swapEnabled; + } + else + { + type = "filesystem"; + format = "ext4"; + mountpoint = "/"; + }; + }; + } + # Dedicated swap partition (ext4 partition-only mode) + // lib.optionalAttrs (swapEnabled && fsType == "ext4" && swapPartition != null) { + disko.devices.disk.swap = { + type = "disk"; + device = swapPartition; + content = { + type = "swap"; + discardPolicy = "both"; + resumeDevice = true; + }; + }; + } + # Existing EFI partition — not managed by disko + // { + fileSystems."/boot/efi" = { + device = efiDevice; + fsType = "vfat"; + options = [ + "fmask=0022" + "dmask=0022" + ]; + }; + } + # Btrfs swapfile + // lib.optionalAttrs (swapEnabled && fsType == "btrfs") { + swapDevices = [ + { + device = "/swap/swapfile"; + size = parseSizeToMiB swapSize; + } + ]; + }; + + in + lib.recursiveUpdate ( + if mode == "whole-disk" then wholeDiskConfig else partitionOnlyConfig + ) extraConfig; +} diff --git a/modules/features/core/boot.nix b/modules/features/core/boot.nix index 775d52f..c270ea7 100644 --- a/modules/features/core/boot.nix +++ b/modules/features/core/boot.nix @@ -1,6 +1,5 @@ { config, - inputs ? null, lib, pkgs, ... @@ -9,100 +8,61 @@ let cfg = config.northstar.features.boot; in { - imports = lib.optionals (inputs != null && inputs ? lanzaboote) [ - inputs.lanzaboote.nixosModules.lanzaboote - ]; - options.northstar.features.boot = { enable = lib.mkEnableOption "system bootloader and Plymouth splash"; - loader = lib.mkOption { - type = lib.types.enum [ - "grub" - "limine" - ]; - default = "grub"; - description = "The bootloader to use (grub with DedSec theme, or modern Limine)."; - }; - secureBoot = { enable = lib.mkOption { type = lib.types.bool; default = false; - description = "Enable UEFI Secure Boot support using Lanzaboote."; - }; - - pkiBundle = lib.mkOption { - type = lib.types.str; - default = "/etc/secureboot"; - description = "Path to the Lanzaboote Secure Boot PKI keys and certificates directory."; + description = "Enable UEFI Secure Boot support using Limine native Secure Boot."; }; }; }; - config = lib.mkIf cfg.enable (lib.mkMerge [ - { - boot.loader = { - efi = { - canTouchEfiVariables = true; - efiSysMountPoint = "/boot/efi"; - }; - - grub = lib.mkIf (cfg.loader == "grub" && !cfg.secureBoot.enable) { - enable = true; - useOSProber = true; - efiSupport = true; - device = "nodev"; - - dedsec-theme = { - enable = true; - style = "sitedown"; - icon = "color"; - resolution = "1080p"; - }; - }; + config = lib.mkIf cfg.enable { + boot.loader = { + efi = { + canTouchEfiVariables = true; + efiSysMountPoint = "/boot/efi"; + }; - limine = lib.mkIf (cfg.loader == "limine" && !cfg.secureBoot.enable) { - enable = true; + limine = { + enable = true; + secureBoot = { + enable = cfg.secureBoot.enable; + autoGenerateKeys = lib.mkDefault true; }; - - systemd-boot.enable = lib.mkIf cfg.secureBoot.enable (lib.mkForce false); }; + }; - environment.systemPackages = lib.optionals cfg.secureBoot.enable [ - pkgs.sbctl - ]; + environment.systemPackages = lib.optionals cfg.secureBoot.enable [ + pkgs.sbctl + ]; - boot.plymouth = { - enable = true; - theme = "dedsec"; + boot.plymouth = { + enable = true; + theme = "dedsec"; - themePackages = [ - (pkgs.stdenv.mkDerivation { - pname = "dedsec-plymouth"; - version = "1.0"; + themePackages = [ + (pkgs.stdenv.mkDerivation { + pname = "dedsec-plymouth"; + version = "1.0"; - src = ../../../assets/dedsec-plymouth; + src = ../../../assets/dedsec-plymouth; - installPhase = '' - mkdir -p $out/share/plymouth/themes/dedsec - cp * $out/share/plymouth/themes/dedsec/ - ''; - }) - ]; - }; - boot.initrd.systemd.enable = true; - boot.kernelParams = [ - "quiet" - "udev.log_priority=3" + installPhase = '' + mkdir -p $out/share/plymouth/themes/dedsec + cp * $out/share/plymouth/themes/dedsec/ + ''; + }) ]; - boot.kernelPackages = pkgs.linuxPackages_latest; - } - (lib.optionalAttrs (inputs != null && inputs ? lanzaboote) { - boot.lanzaboote = lib.mkIf cfg.secureBoot.enable { - enable = true; - pkiBundle = cfg.secureBoot.pkiBundle; - }; - }) - ]); + }; + boot.initrd.systemd.enable = true; + boot.kernelParams = [ + "quiet" + "udev.log_priority=3" + ]; + boot.kernelPackages = pkgs.linuxPackages_latest; + }; } diff --git a/modules/features/core/env.nix b/modules/features/core/env.nix index 10c27b8..148411b 100644 --- a/modules/features/core/env.nix +++ b/modules/features/core/env.nix @@ -6,8 +6,8 @@ in options.northstar.features.env.enable = lib.mkEnableOption "environment variables (EDITOR, VISUAL)"; config = lib.mkIf cfg.enable { - environment.variables.EDITOR = "nvim"; - environment.variables.VISUAL = "nvim"; - environment.variables.BROWSER = "zen"; + environment.variables.EDITOR = lib.mkForce "nvim"; + environment.variables.VISUAL = lib.mkForce "nvim"; + environment.variables.BROWSER = lib.mkForce "zen"; }; } diff --git a/modules/features/core/locales.nix b/modules/features/core/locales.nix index f6a49a0..ae7fa87 100644 --- a/modules/features/core/locales.nix +++ b/modules/features/core/locales.nix @@ -6,9 +6,9 @@ in options.northstar.features.locales.enable = lib.mkEnableOption "timezone and locale settings"; config = lib.mkIf cfg.enable { - time.timeZone = "Asia/Kolkata"; + time.timeZone = lib.mkDefault "Asia/Kolkata"; - i18n.defaultLocale = "en_US.UTF-8"; + i18n.defaultLocale = lib.mkDefault "en_US.UTF-8"; i18n.extraLocaleSettings = { LC_ADDRESS = "en_IN"; diff --git a/modules/features/core/networking.nix b/modules/features/core/networking.nix index e683a0f..682f80d 100644 --- a/modules/features/core/networking.nix +++ b/modules/features/core/networking.nix @@ -3,13 +3,18 @@ let cfg = config.northstar.features.networking; in { - options.northstar.features.networking.enable = lib.mkEnableOption "NetworkManager and host entries"; + options.northstar.features.networking = { + enable = lib.mkEnableOption "NetworkManager and host entries"; - config = lib.mkIf cfg.enable { - networking.extraHosts = '' - 10.250.18.140 console-openshift-console.apps.nonprod.odisha.gov.in oauth-openshift.apps.nonprod.odisha.gov.in - ''; + extraHosts = lib.mkOption { + type = lib.types.lines; + default = ""; + description = "Extra entries appended to /etc/hosts."; + }; + }; + config = lib.mkIf cfg.enable { + networking.extraHosts = cfg.extraHosts; networking.networkmanager.enable = true; }; } diff --git a/modules/features/core/packages.nix b/modules/features/core/packages.nix index 12f3974..009e3bd 100644 --- a/modules/features/core/packages.nix +++ b/modules/features/core/packages.nix @@ -8,44 +8,24 @@ let cfg = config.northstar.features.packages; in { - options.northstar.features.packages.enable = lib.mkEnableOption "system packages and unfree config"; + options.northstar.features.packages.enable = + lib.mkEnableOption "core system packages and unfree config"; config = lib.mkIf cfg.enable { nixpkgs.config.allowUnfree = true; - services.udisks2.enable = true; - services.gvfs.enable = true; - environment.systemPackages = with pkgs; [ bat btop - cliphist - easyeffects eza fd fzf - fuzzel - grim - hyprcursor - hyprpolkitagent jq - kdePackages.dolphin - kdePackages.okular - libnotify - mpv nitch - obsidian - openconnect - qpwgraph - qview ripgrep - satty - slurp tmux unzip wget - wl-clipboard - zathura zoxide ]; }; diff --git a/modules/features/core/secrets.nix b/modules/features/core/secrets.nix index 454ddf4..02ad223 100644 --- a/modules/features/core/secrets.nix +++ b/modules/features/core/secrets.nix @@ -15,7 +15,8 @@ in [ "northstar" "features" "core" "secrets" ] [ "northstar" "features" "secrets" ] ) - ] ++ lib.optionals (inputs != null && inputs ? sops-nix) [ + ] + ++ lib.optionals (inputs != null && inputs ? sops-nix) [ inputs.sops-nix.nixosModules.sops ]; @@ -65,25 +66,27 @@ in }; }; - config = lib.mkIf cfg.enable (lib.mkMerge [ - { - environment.systemPackages = [ - pkgs.sops - pkgs.age - pkgs.ssh-to-age - ]; - } - (lib.optionalAttrs (inputs != null && inputs ? sops-nix) { - sops = { - defaultSopsFile = lib.mkIf (cfg.defaultSopsFile != null) cfg.defaultSopsFile; - defaultSopsFormat = cfg.defaultSopsFormat; - age = { - keyFile = lib.mkIf (cfg.ageKeyFile != null) cfg.ageKeyFile; - sshKeyPaths = cfg.sshKeyPaths; - generateKey = cfg.generateKey; + config = lib.mkIf cfg.enable ( + lib.mkMerge [ + { + environment.systemPackages = [ + pkgs.sops + pkgs.age + pkgs.ssh-to-age + ]; + } + (lib.optionalAttrs (inputs != null && inputs ? sops-nix) { + sops = { + defaultSopsFile = lib.mkIf (cfg.defaultSopsFile != null) cfg.defaultSopsFile; + defaultSopsFormat = cfg.defaultSopsFormat; + age = { + keyFile = lib.mkIf (cfg.ageKeyFile != null) cfg.ageKeyFile; + sshKeyPaths = cfg.sshKeyPaths; + generateKey = cfg.generateKey; + }; + secrets = lib.mkIf (cfg.secrets != { }) cfg.secrets; }; - secrets = lib.mkIf (cfg.secrets != { }) cfg.secrets; - }; - }) - ]); + }) + ] + ); } diff --git a/modules/features/desktop/caelestia.nix b/modules/features/desktop/caelestia.nix index 9a0b62d..440c506 100644 --- a/modules/features/desktop/caelestia.nix +++ b/modules/features/desktop/caelestia.nix @@ -11,14 +11,16 @@ let ... }: { - imports = [ inputs.caelestia-shell.homeManagerModules.default ]; + imports = [ + (inputs.caelestia-shell.homeManagerModules.default or inputs.caelestia-shell.homeModules.default + or inputs.caelestia-shell.homeManagerModule + ) + ]; config = { programs.caelestia = { - enable = false; + enable = true; systemd.enable = false; # Autostarted by compositor - - cli.enable = false; }; }; }; diff --git a/modules/features/desktop/desktopPackages.nix b/modules/features/desktop/desktopPackages.nix new file mode 100644 index 0000000..ee88a6a --- /dev/null +++ b/modules/features/desktop/desktopPackages.nix @@ -0,0 +1,39 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.northstar.features.desktopPackages; +in +{ + options.northstar.features.desktopPackages.enable = + lib.mkEnableOption "desktop/Wayland packages and services"; + + config = lib.mkIf cfg.enable { + services.udisks2.enable = true; + services.gvfs.enable = true; + + environment.systemPackages = with pkgs; [ + cliphist + easyeffects + fuzzel + grim + hyprcursor + hyprpolkitagent + kdePackages.dolphin + kdePackages.okular + libnotify + mpv + obsidian + openconnect + qpwgraph + qview + satty + slurp + wl-clipboard + zathura + ]; + }; +} diff --git a/modules/features/desktop/display.nix b/modules/features/desktop/display.nix index a9de0ca..36cf8c5 100644 --- a/modules/features/desktop/display.nix +++ b/modules/features/desktop/display.nix @@ -7,7 +7,19 @@ let cfg = config.northstar.features.display; tuigreet = "${pkgs.tuigreet}/bin/tuigreet"; - hyprland-session = "${config.programs.hyprland.package}/share/wayland-sessions"; + + # Dynamically discover available Wayland session directories + waylandSessionDirs = + lib.optional (config.programs.hyprland.enable or false + ) "${config.programs.hyprland.package}/share/wayland-sessions" + ++ lib.optional (config.programs.niri.enable or false + ) "${config.programs.niri.package}/share/wayland-sessions"; + + sessionDirs = + if waylandSessionDirs != [ ] then + lib.concatStringsSep ":" waylandSessionDirs + else + "/run/current-system/sw/share/wayland-sessions"; in { options.northstar.features.display.enable = @@ -20,7 +32,7 @@ in enable = true; settings = { default_session = { - command = "${tuigreet} --time --remember --remember-session --sessions ${hyprland-session}"; + command = "${tuigreet} --time --remember --remember-session --sessions ${sessionDirs}"; user = "greeter"; }; }; diff --git a/modules/features/desktop/noctalia.nix b/modules/features/desktop/noctalia.nix index 86712f8..e5f47f9 100644 --- a/modules/features/desktop/noctalia.nix +++ b/modules/features/desktop/noctalia.nix @@ -14,7 +14,11 @@ let wallpaper = ../../../assets/wallpapers/rose-pine-fractal.jpg; in { - imports = [ inputs.noctalia.homeModules.default ]; + imports = [ + (inputs.noctalia.homeModules.default or inputs.noctalia.homeManagerModules.default + or inputs.noctalia.homeManagerModule + ) + ]; config = { programs.noctalia = { diff --git a/modules/features/desktop/xdg.nix b/modules/features/desktop/xdg.nix index 2280e58..ebbe1be 100644 --- a/modules/features/desktop/xdg.nix +++ b/modules/features/desktop/xdg.nix @@ -43,14 +43,51 @@ let "x-scheme-handler/unknown" = [ "zen-beta.desktop" ]; # Images (qView primary) - "image/png" = [ "com.interversehq.qView.desktop" "org.gnome.Loupe.desktop" "org.kde.gwenview.desktop" "org.kde.dolphin.desktop" ]; - "image/jpeg" = [ "com.interversehq.qView.desktop" "org.gnome.Loupe.desktop" "org.kde.gwenview.desktop" "org.kde.dolphin.desktop" ]; - "image/webp" = [ "com.interversehq.qView.desktop" "org.gnome.Loupe.desktop" "org.kde.gwenview.desktop" "org.kde.dolphin.desktop" ]; - "image/gif" = [ "com.interversehq.qView.desktop" "org.gnome.Loupe.desktop" "org.kde.gwenview.desktop" "org.kde.dolphin.desktop" ]; - "image/svg+xml" = [ "com.interversehq.qView.desktop" "org.gnome.Loupe.desktop" "org.kde.gwenview.desktop" "zen-beta.desktop" ]; - "image/bmp" = [ "com.interversehq.qView.desktop" "org.gnome.Loupe.desktop" "org.kde.gwenview.desktop" ]; - "image/tiff" = [ "com.interversehq.qView.desktop" "org.gnome.Loupe.desktop" "org.kde.gwenview.desktop" ]; - "image/avif" = [ "com.interversehq.qView.desktop" "org.gnome.Loupe.desktop" "org.kde.gwenview.desktop" ]; + "image/png" = [ + "com.interversehq.qView.desktop" + "org.gnome.Loupe.desktop" + "org.kde.gwenview.desktop" + "org.kde.dolphin.desktop" + ]; + "image/jpeg" = [ + "com.interversehq.qView.desktop" + "org.gnome.Loupe.desktop" + "org.kde.gwenview.desktop" + "org.kde.dolphin.desktop" + ]; + "image/webp" = [ + "com.interversehq.qView.desktop" + "org.gnome.Loupe.desktop" + "org.kde.gwenview.desktop" + "org.kde.dolphin.desktop" + ]; + "image/gif" = [ + "com.interversehq.qView.desktop" + "org.gnome.Loupe.desktop" + "org.kde.gwenview.desktop" + "org.kde.dolphin.desktop" + ]; + "image/svg+xml" = [ + "com.interversehq.qView.desktop" + "org.gnome.Loupe.desktop" + "org.kde.gwenview.desktop" + "zen-beta.desktop" + ]; + "image/bmp" = [ + "com.interversehq.qView.desktop" + "org.gnome.Loupe.desktop" + "org.kde.gwenview.desktop" + ]; + "image/tiff" = [ + "com.interversehq.qView.desktop" + "org.gnome.Loupe.desktop" + "org.kde.gwenview.desktop" + ]; + "image/avif" = [ + "com.interversehq.qView.desktop" + "org.gnome.Loupe.desktop" + "org.kde.gwenview.desktop" + ]; # Video & Audio (MPV) "video/mp4" = [ "mpv.desktop" ]; @@ -65,26 +102,75 @@ let "audio/ogg" = [ "mpv.desktop" ]; # PDF & Documents (Okular primary, Zathura terminal fallback, Zen) - "application/pdf" = [ "org.kde.okular.desktop" "org.pwmt.zathura.desktop" "zen-beta.desktop" ]; - "application/epub+zip" = [ "org.kde.okular.desktop" "org.pwmt.zathura.desktop" ]; - "application/postscript" = [ "org.kde.okular.desktop" "org.pwmt.zathura.desktop" ]; + "application/pdf" = [ + "org.kde.okular.desktop" + "org.pwmt.zathura.desktop" + "zen-beta.desktop" + ]; + "application/epub+zip" = [ + "org.kde.okular.desktop" + "org.pwmt.zathura.desktop" + ]; + "application/postscript" = [ + "org.kde.okular.desktop" + "org.pwmt.zathura.desktop" + ]; # Archives (Ark / Dolphin) - "application/zip" = [ "org.kde.ark.desktop" "org.kde.dolphin.desktop" ]; - "application/x-tar" = [ "org.kde.ark.desktop" "org.kde.dolphin.desktop" ]; - "application/x-7z-compressed" = [ "org.kde.ark.desktop" "org.kde.dolphin.desktop" ]; - "application/x-compressed-tar" = [ "org.kde.ark.desktop" "org.kde.dolphin.desktop" ]; + "application/zip" = [ + "org.kde.ark.desktop" + "org.kde.dolphin.desktop" + ]; + "application/x-tar" = [ + "org.kde.ark.desktop" + "org.kde.dolphin.desktop" + ]; + "application/x-7z-compressed" = [ + "org.kde.ark.desktop" + "org.kde.dolphin.desktop" + ]; + "application/x-compressed-tar" = [ + "org.kde.ark.desktop" + "org.kde.dolphin.desktop" + ]; # Plain Text, Markdown & Code (Zed / Ghostty) - "text/plain" = [ "dev.zed.Zed.desktop" "ghostty.desktop" ]; - "text/markdown" = [ "dev.zed.Zed.desktop" "ghostty.desktop" ]; - "text/x-nix" = [ "dev.zed.Zed.desktop" "ghostty.desktop" ]; - "text/css" = [ "dev.zed.Zed.desktop" "ghostty.desktop" ]; - "text/x-python" = [ "dev.zed.Zed.desktop" "ghostty.desktop" ]; - "application/json" = [ "dev.zed.Zed.desktop" "ghostty.desktop" ]; - "application/yaml" = [ "dev.zed.Zed.desktop" "ghostty.desktop" ]; - "application/toml" = [ "dev.zed.Zed.desktop" "ghostty.desktop" ]; - "application/xml" = [ "dev.zed.Zed.desktop" "ghostty.desktop" ]; + "text/plain" = [ + "dev.zed.Zed.desktop" + "ghostty.desktop" + ]; + "text/markdown" = [ + "dev.zed.Zed.desktop" + "ghostty.desktop" + ]; + "text/x-nix" = [ + "dev.zed.Zed.desktop" + "ghostty.desktop" + ]; + "text/css" = [ + "dev.zed.Zed.desktop" + "ghostty.desktop" + ]; + "text/x-python" = [ + "dev.zed.Zed.desktop" + "ghostty.desktop" + ]; + "application/json" = [ + "dev.zed.Zed.desktop" + "ghostty.desktop" + ]; + "application/yaml" = [ + "dev.zed.Zed.desktop" + "ghostty.desktop" + ]; + "application/toml" = [ + "dev.zed.Zed.desktop" + "ghostty.desktop" + ]; + "application/xml" = [ + "dev.zed.Zed.desktop" + "ghostty.desktop" + ]; }; }; @@ -106,7 +192,8 @@ let }; in { - options.northstar.features.xdg.enable = lib.mkEnableOption "XDG desktop portals and MIME default application associations"; + options.northstar.features.xdg.enable = + lib.mkEnableOption "XDG desktop portals and MIME default application associations"; config = lib.mkIf cfg.enable { xdg.portal = { diff --git a/modules/features/development/git.nix b/modules/features/development/git.nix index e481d48..8d65a3a 100644 --- a/modules/features/development/git.nix +++ b/modules/features/development/git.nix @@ -15,7 +15,7 @@ in enable = true; settings = { user.name = "reze-dev"; - user.email = "25588579+reze-dev@users.noreply.github.com "; + user.email = "25588579+reze-dev@users.noreply.github.com"; init.defaultBranch = "main"; }; }; diff --git a/modules/features/shell/omp.nix b/modules/features/shell/omp.nix deleted file mode 100644 index 08a1625..0000000 --- a/modules/features/shell/omp.nix +++ /dev/null @@ -1,100 +0,0 @@ -{ config, lib, ... }: - -let - cfg = config.northstar.features.omp; -in -{ - options.northstar.features.omp.enable = lib.mkEnableOption "Oh My Posh prompt"; - - config = lib.mkIf cfg.enable { - home-manager.sharedModules = [ - ({ config, lib, ... }: { - - config = { - programs.oh-my-posh = { - enable = false; - enableZshIntegration = true; - settings = { - blocks = [ - { - alignment = "left"; - newline = true; - segments = [ - { - background = "transparent"; - foreground = "blue"; - properties.style = "full"; - style = "plain"; - template = "{{ .Path }}"; - type = "path"; - } - { - background = "transparent"; - foreground = "p:grey"; - properties = { - branch_icon = ""; - commit_icon = "@"; - fetch_status = true; - }; - style = "plain"; - template = " {{ .HEAD }}{{ if or (.Working.Changed) (.Staging.Changed) }}*{{ end }} {{ if gt .Behind 0 }}⇣{{ end }}{{ if gt .Ahead 0 }}⇡{{ end }}"; - type = "git"; - } - ]; - type = "prompt"; - } - { - overflow = "hidden"; - segments = [ - { - background = "transparent"; - foreground = "yellow"; - properties.threshold = 5000; - style = "plain"; - template = "{{ .FormattedMs }}"; - type = "executiontime"; - } - ]; - type = "rprompt"; - } - { - alignment = "left"; - newline = true; - segments = [ - { - background = "transparent"; - foreground_templates = [ - "{{if gt .Code 0}}red{{end}}" - "{{if eq .Code 0}}#31748f{{end}}" - ]; - style = "plain"; - template = "❯"; - type = "text"; - } - ]; - type = "prompt"; - } - ]; - console_title_template = "{{ .Shell }} in {{ .Folder }}"; - final_space = true; - secondary_prompt = { - background = "transparent"; - foreground = "magenta"; - template = "❯❯ "; - }; - transient_prompt = { - background = "transparent"; - foreground_templates = [ - "{{if gt .Code 0}}red{{end}}" - "{{if eq .Code 0}}#31748f{{end}}" - ]; - template = "❯ "; - }; - version = 2; - }; - }; - }; - }) - ]; - }; -} diff --git a/modules/features/tools/tmux.nix b/modules/features/tools/tmux.nix index 760a468..620679e 100644 --- a/modules/features/tools/tmux.nix +++ b/modules/features/tools/tmux.nix @@ -26,7 +26,7 @@ in prefix = "C-Space"; keyMode = "vi"; sensibleOnTop = true; - shell = "~/.nix-profile/bin/fish"; + shell = lib.mkDefault "${pkgs.fish}/bin/fish"; extraConfig = '' set-option -sa terminal-overrides ",xterm*:Tc" set-option -g focus-events on diff --git a/modules/profiles/base.nix b/modules/profiles/base.nix index 09fe636..97d829d 100644 --- a/modules/profiles/base.nix +++ b/modules/profiles/base.nix @@ -18,6 +18,13 @@ in options.northstar.profiles.base.enable = lib.mkEnableOption "base Northstar system profile"; config = lib.mkIf cfg.enable { - northstar.features = features |> (f: lib.genAttrs f (_: { enable = true; })); + northstar.features = + features + |> ( + f: + lib.genAttrs f (_: { + enable = true; + }) + ); }; } diff --git a/modules/profiles/desktop.nix b/modules/profiles/desktop.nix index edad242..5764f62 100644 --- a/modules/profiles/desktop.nix +++ b/modules/profiles/desktop.nix @@ -5,6 +5,7 @@ let features = [ "audio" "bluetooth" + "desktopPackages" "noctalia" "cups" "display" @@ -23,6 +24,13 @@ in options.northstar.profiles.desktop.enable = lib.mkEnableOption "desktop Northstar profile"; config = lib.mkIf cfg.enable { - northstar.features = features |> (f: lib.genAttrs f (_: { enable = true; })); + northstar.features = + features + |> ( + f: + lib.genAttrs f (_: { + enable = true; + }) + ); }; } diff --git a/modules/profiles/workstation.nix b/modules/profiles/workstation.nix index 5b407e3..992a603 100644 --- a/modules/profiles/workstation.nix +++ b/modules/profiles/workstation.nix @@ -11,7 +11,6 @@ let "fish" "fzf" "git" - "omp" "starship" "tmux" "virtualization" diff --git a/tests/e2e/test_suite.py b/tests/e2e/test_suite.py index b7e7ae4..43bbfaf 100644 --- a/tests/e2e/test_suite.py +++ b/tests/e2e/test_suite.py @@ -50,7 +50,6 @@ build_gpu_config, build_profile_config, default_features as _orig_default_features, - format_grub_extra_entries, format_limine_extra_entries, format_pci_bus_id, generate_disko_partition_only, @@ -80,7 +79,7 @@ class InstallConfig: shell: str = "zsh" bootloader: BootloaderChoice = BootloaderChoice.LIMINE secure_boot: bool = False - secure_boot_pki: str = "/etc/secureboot" + secure_boot_pki: str = "/var/lib/sbctl" resolution: str = "1920x1080" gpu_choice: GpuChoice = GpuChoice.NONE nvidia_bus_id: str = "" @@ -210,11 +209,6 @@ def build_bootloader_config(cfg: Any) -> str: extra = format_limine_extra_entries(cfg.dual_boot_entries) if extra: lines.append(extra) - elif cfg.bootloader == BootloaderChoice.GRUB: - lines.append(' northstar.features.boot.loader = "grub";') - extra = format_grub_extra_entries(cfg.dual_boot_entries) - if extra: - lines.append(extra) if getattr(cfg, "secure_boot", False): lines.append(" northstar.features.boot.secureBoot.enable = true;") @@ -296,11 +290,12 @@ def generate_host_default_nix(cfg: Any) -> str: {{ imports = [ + ./hardware.nix ./disko.nix ]; home-manager.users.{cfg.username} = {{ - imports = [ ../../home ]; + imports = [ ../../home/home.nix ]; home.username = lib.mkForce "{cfg.username}"; home.homeDirectory = lib.mkForce "/home/{cfg.username}"; }}; @@ -974,10 +969,10 @@ def test_t1_f06_05_state_resume_bypass_headless_and_prompt(self) -> None: self.assertIsNotNone(loaded) self.assertEqual(loaded.hostname, "SavedRig") + @patch("e2e.test_suite.is_mounted_check", return_value=False) @patch("installer.install.run") - @patch("tests.e2e.test_suite.is_mounted_check", return_value=False) - def test_t1_f06_06_idempotent_remount_check_ensure_mounted(self, mock_is_mounted, mock_run) -> None: - cfg = InstallConfig(nixos_part="/dev/sda2", efi_part="/dev/sda1", fs_type="ext4") + def test_t1_f06_06_idempotent_remount_check_ensure_mounted(self, mock_run, mock_is_mounted) -> None: + cfg = InstallConfig(mode=InstallMode.PARTITION_ONLY, nixos_part="/dev/sda2", efi_part="/dev/sda1", fs_type="ext4") ensure_mounted(cfg) mock_run.assert_any_call("mount /dev/sda2 /mnt") mock_run.assert_any_call("mount /dev/sda1 /mnt/boot/efi") @@ -1126,7 +1121,7 @@ def test_t1_f05_01_limine_bootloader_config_with_resolution(self) -> None: out = build_bootloader_config(cfg) self.assertIn('boot.loader.limine.resolution = "2560x1440";', out) - def test_t1_f02_02_secure_boot_toggle_lanzaboote_config(self) -> None: + def test_t1_f02_02_secure_boot_toggle_limine_config(self) -> None: cfg = InstallConfig(secure_boot=True) out = build_bootloader_config(cfg) self.assertIn("northstar.features.boot.secureBoot.enable = true;", out) @@ -1147,8 +1142,8 @@ def test_t1_f01_04_aiml_module_explicit_opt_in_emission(self) -> None: def test_t1_f06_05_disko_whole_disk_btrfs_subvolumes_generation(self) -> None: cfg = InstallConfig(disk_dev="nvme0n1", fs_type="btrfs", swap_size="16G") out = generate_disko_whole_disk(cfg) - self.assertIn("imports = [ ../../lib/disko/btrfs.nix ];", out) - self.assertIn('disko.devices.disk.main.device = "/dev/nvme0n1";', out) + self.assertIn("northstar.mkDisko {", out) + self.assertIn('device = "/dev/nvme0n1";', out) def test_t1_f11_06_multi_user_host_isolation_trusted_users(self) -> None: common_nix = (PROJECT_ROOT / "hosts/common.nix").read_text() @@ -1156,15 +1151,16 @@ def test_t1_f11_06_multi_user_host_isolation_trusted_users(self) -> None: self.assertIn('"root"', common_nix) self.assertIn('"@wheel"', common_nix) - def test_t1_f05_07_grub_isolation_no_limine_resolution(self) -> None: - cfg = InstallConfig(bootloader=BootloaderChoice.GRUB, resolution="1920x1080") + def test_t1_f05_07_limine_always_emits_resolution(self) -> None: + cfg = InstallConfig(bootloader=BootloaderChoice.LIMINE, resolution="1920x1080") out = build_bootloader_config(cfg) - self.assertNotIn("boot.loader.limine.resolution", out) + self.assertIn("boot.loader.limine.resolution", out) def test_t1_f06_08_disko_whole_disk_ext4_generation(self) -> None: cfg = InstallConfig(disk_dev="sda", fs_type="ext4", swap_size="0", root_size="500G") out = generate_disko_whole_disk(cfg) - self.assertIn("imports = [ ../../lib/disko/ext4.nix ];", out) + self.assertIn("northstar.mkDisko {", out) + self.assertIn('fsType = "ext4";', out) def test_t1_f06_09_disko_partition_only_with_dedicated_swap(self) -> None: cfg = InstallConfig( @@ -1191,9 +1187,9 @@ def test_t1_f10_01_turnkey_flake_app_install_entrypoint(self) -> None: self.assertIn("northstar-install", flake_installer) self.assertIn("runtimeInputs", flake_installer) - def test_t1_f03_02_deprecated_rust_installer_redirection(self) -> None: - rust_installer = (PROJECT_ROOT / "flake/rust-installer.nix").read_text() - self.assertIn("deprecated", rust_installer.lower()) + def test_t1_f03_02_rust_installer_fully_removed(self) -> None: + self.assertFalse((PROJECT_ROOT / "flake/rust-installer.nix").exists()) + self.assertFalse((PROJECT_ROOT / "installer-rs").exists()) def test_t1_f10_03_temporary_git_repository_staging(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -1289,17 +1285,17 @@ def test_t2_f01_04_resolution_invalid_format_rejected(self) -> None: def test_t2_f01_05_swap_size_zero_disables_swap(self) -> None: cfg = InstallConfig(swap_size="0") out = generate_disko_whole_disk(cfg) - self.assertIn('partitions.swap.size = lib.mkForce "0";', out) + self.assertIn('swapSize = "0";', out) def test_t2_f01_06_root_size_percentage_format(self) -> None: cfg = InstallConfig(root_size="50%") out = generate_disko_whole_disk(cfg) - self.assertIn('partitions.root.size = lib.mkForce "50%";', out) + self.assertIn('rootSize = "50%";', out) def test_t2_f01_07_root_size_gigabytes_format(self) -> None: cfg = InstallConfig(root_size="250G") out = generate_disko_whole_disk(cfg) - self.assertIn('partitions.root.size = lib.mkForce "250G";', out) + self.assertIn('rootSize = "250G";', out) def test_t2_f01_08_special_chars_in_hashed_pw(self) -> None: cfg = InstallConfig(hashed_pw="$6$salt$hash#with%special@chars") @@ -1432,8 +1428,10 @@ def test_t2_f03_06_strip_filesystems_empty_string(self) -> None: self.assertEqual(strip_filesystems_from_hardware(""), "") def test_t2_f03_07_strip_filesystems_no_fs_blocks(self) -> None: - hw = "boot.kernelModules = [ \"kvm\" ];\n" - self.assertEqual(strip_filesystems_from_hardware(hw), hw.strip()) + hw = 'boot.kernelModules = [ "kvm" ];\n' + cleaned = strip_filesystems_from_hardware(hw) + self.assertIn('boot.kernelModules = [ "kvm" ];', cleaned) + self.assertIn("graphics.enable", cleaned) def test_t2_f03_08_strip_filesystems_nested_attributes(self) -> None: hw = 'fileSystems."/" = {\n device = "/dev/sda";\n options = [ "subvol=root" ];\n};\nboot.loader.grub.enable = true;\n' @@ -1450,8 +1448,8 @@ def test_t2_f03_10_esp_scan_corrupted_efi_paths(self) -> None: (Path(tmpdir) / "EFI/Microsoft").mkdir(parents=True) self.assertEqual(scan_esp_for_os(Path(tmpdir), "UUID"), []) - def test_t2_f03_11_grub_extra_entries_empty_list(self) -> None: - self.assertEqual(format_grub_extra_entries([]), "") + def test_t2_f03_11_limine_extra_entries_empty_list_dup(self) -> None: + self.assertEqual(format_limine_extra_entries([]), "") def test_t2_f03_12_limine_extra_entries_empty_list(self) -> None: self.assertEqual(format_limine_extra_entries([]), "") @@ -1646,11 +1644,11 @@ def test_t3_02_workstation_profile_with_aiml_opt_in_and_nvidia_prime(self) -> No self.assertIn('nvidiaBusId = "PCI:1:0:0";', out) self.assertIn('amdgpuBusId = "PCI:5:0:0";', out) - @patch("installer.install.run") - @patch("installer.install.run_capture", return_value="/dev/zram0") + @patch("e2e.test_suite.is_mounted_check", return_value=False) @patch("shutil.which", return_value="/usr/bin/zramctl") - @patch("tests.e2e.test_suite.is_mounted_check", return_value=False) - def test_t3_03_btrfs_whole_disk_with_zram_and_resumed_pipeline(self, mock_mount, mock_which, mock_rc, mock_run) -> None: + @patch("installer.install.run_capture", return_value="/dev/zram0") + @patch("installer.install.run") + def test_t3_03_btrfs_whole_disk_with_zram_and_resumed_pipeline(self, mock_run, mock_rc, mock_which, mock_mount) -> None: """Pair: BTRFS Whole Disk + ZRAM protection + resumption at install_nixos.""" with tempfile.TemporaryDirectory() as tmpdir: s = State(state_file=Path(tmpdir) / "state.json") @@ -1674,9 +1672,9 @@ def test_t3_03_btrfs_whole_disk_with_zram_and_resumed_pipeline(self, mock_mount, mock_run.assert_any_call("swapon -p 32767 /dev/zram0") def test_t3_04_ext4_partition_only_with_dedicated_swap_and_windows_dualboot(self) -> None: - """Pair: Partition-only EXT4 + dedicated swap partition + Windows dual-boot + GRUB.""" + """Pair: Partition-only EXT4 + dedicated swap partition + Windows dual-boot + Limine.""" cfg = InstallConfig( - bootloader=BootloaderChoice.GRUB, + bootloader=BootloaderChoice.LIMINE, mode=InstallMode.PARTITION_ONLY, nixos_part="/dev/nvme0n1p5", efi_part="/dev/nvme0n1p1", @@ -1691,7 +1689,7 @@ def test_t3_04_ext4_partition_only_with_dedicated_swap_and_windows_dualboot(self self.assertIn("disko.devices.disk.swap", disko_out) self.assertIn('device = "/dev/nvme0n1p6";', disko_out) host_out = generate_host_default_nix(cfg) - self.assertIn('menuentry "Windows 11"', host_out) + self.assertIn("/Windows 11", host_out) def test_t3_05_base_profile_fish_shell_custom_deltas_and_sops_secrets(self) -> None: """Pair: Base profile + Fish shell + secrets enabled.""" @@ -1776,11 +1774,11 @@ def fake_keygen(cmd, *args, **kwargs): key_path = generate_ssh_key(ssh_dir, cfg.hostname) self.assertTrue(key_path.exists()) - @patch("installer.install.run") - @patch("installer.install.run_capture", return_value="/dev/zram0") + @patch("e2e.test_suite.is_mounted_check", return_value=False) @patch("shutil.which", return_value="/usr/bin/zramctl") - @patch("tests.e2e.test_suite.is_mounted_check", return_value=False) - def test_t4_02_resumed_install_after_killed_nixos_install(self, mock_mount, mock_which, mock_rc, mock_run) -> None: + @patch("installer.install.run_capture", return_value="/dev/zram0") + @patch("installer.install.run") + def test_t4_02_resumed_install_after_killed_nixos_install(self, mock_run, mock_rc, mock_which, mock_mount) -> None: """Workload 2: Resuming state after interrupted nixos-install.""" with tempfile.TemporaryDirectory() as tmpdir: sf = Path(tmpdir) / "state.json" @@ -1829,8 +1827,8 @@ def test_t4_03_reinstallation_with_key_migration_and_external_backup(self) -> No self.assertTrue((backup_dir / "ssh_host_ed25519_key").exists()) self.assertTrue((backup_dir / "key.txt").exists()) - def test_t4_04_dual_boot_alongside_windows_with_lanzaboote_secure_boot(self) -> None: - """Workload 4: Dual-boot with Windows 11 and Lanzaboote Secure Boot.""" + def test_t4_04_dual_boot_alongside_windows_with_limine_secure_boot(self) -> None: + """Workload 4: Dual-boot with Windows 11 and Limine Secure Boot.""" with tempfile.TemporaryDirectory() as tmpdir: esp = Path(tmpdir) / "boot/efi" (esp / "EFI/Microsoft/Boot").mkdir(parents=True) diff --git a/tests/test_config_generation.py b/tests/test_config_generation.py index 104ab3f..291e671 100644 --- a/tests/test_config_generation.py +++ b/tests/test_config_generation.py @@ -24,7 +24,6 @@ build_gpu_config, build_profile_config, default_features as _orig_default_features, - format_grub_extra_entries, format_limine_extra_entries, generate_disko_partition_only, generate_disko_whole_disk, @@ -45,7 +44,7 @@ class InstallConfig: shell: str = "zsh" bootloader: BootloaderChoice = BootloaderChoice.LIMINE secure_boot: bool = False - secure_boot_pki: str = "/etc/secureboot" + secure_boot_pki: str = "/var/lib/sbctl" resolution: str = "1920x1080" features: list[FeatureOption] = field(default_factory=list) dual_boot_entries: list[DualBootEntry] = field(default_factory=list) @@ -105,11 +104,6 @@ def build_bootloader_config(cfg: Any) -> str: extra = format_limine_extra_entries(cfg.dual_boot_entries) if extra: lines.append(extra) - elif cfg.bootloader == BootloaderChoice.GRUB: - lines.append(' northstar.features.boot.loader = "grub";') - extra = format_grub_extra_entries(cfg.dual_boot_entries) - if extra: - lines.append(extra) if getattr(cfg, "secure_boot", False): lines.append(" northstar.features.boot.secureBoot.enable = true;") @@ -191,11 +185,12 @@ def generate_host_default_nix(cfg: Any) -> str: {{ imports = [ + ./hardware.nix ./disko.nix ]; home-manager.users.{cfg.username} = {{ - imports = [ ../../home ]; + imports = [ ../../home/home.nix ]; home.username = lib.mkForce "{cfg.username}"; home.homeDirectory = lib.mkForce "/home/{cfg.username}"; }}; @@ -259,17 +254,17 @@ def test_limine_resolution_with_dual_boot_entries(self): self.assertIn("/Windows 11", content) self.assertIn("path: boot():/EFI/Microsoft/Boot/bootmgfw.efi", content) - def test_grub_does_not_emit_limine_resolution(self): - """GRUB bootloader does not emit boot.loader.limine.resolution.""" + def test_limine_always_emits_resolution(self): + """Limine bootloader always emits boot.loader.limine.resolution.""" cfg = InstallConfig( - bootloader=BootloaderChoice.GRUB, + bootloader=BootloaderChoice.LIMINE, resolution="1920x1080", ssh_key_action="none", age_key_action="none", ) content = generate_host_default_nix(cfg) - self.assertIn('northstar.features.boot.loader = "grub";', content) - self.assertNotIn("boot.loader.limine.resolution", content) + self.assertIn('northstar.features.boot.loader = "limine";', content) + self.assertIn('boot.loader.limine.resolution = "1920x1080";', content) def test_limine_empty_resolution_fallback(self): """Empty resolution defaults safely to 1920x1080.""" @@ -294,7 +289,7 @@ def test_secure_boot_enabled_emission(self): def test_secure_boot_disabled_omission(self): """When secure_boot is False, secureBoot.enable is not emitted as true.""" cfg = InstallConfig( - bootloader=BootloaderChoice.GRUB, + bootloader=BootloaderChoice.LIMINE, secure_boot=False, ssh_key_action="none", age_key_action="none", @@ -398,15 +393,15 @@ def test_secrets_module_disabled_when_none(self): # ── 5. Disko & Host Config Synthesis ──────────────────────────── - def test_generate_host_default_nix_base_grub(self): - """Verify host default.nix for Base profile with GRUB.""" + def test_generate_host_default_nix_base_limine(self): + """Verify host default.nix for Base profile with Limine.""" cfg = InstallConfig( hostname="TestServer", username="admin", hashed_pw="$6$testhash", profile=ProfileChoice.BASE, shell="zsh", - bootloader=BootloaderChoice.GRUB, + bootloader=BootloaderChoice.LIMINE, features=default_features(ProfileChoice.BASE), dual_boot_entries=[], mode=InstallMode.WHOLE_DISK, @@ -424,7 +419,7 @@ def test_generate_host_default_nix_base_grub(self): self.assertIn("home-manager.users.admin = {", content) self.assertIn("users.users.admin = {", content) self.assertIn('hashedPassword = "$6$testhash";', content) - self.assertIn('northstar.features.boot.loader = "grub";', content) + self.assertIn('northstar.features.boot.loader = "limine";', content) self.assertIn("base.enable = true;", content) self.assertNotIn("desktop.enable = true;", content) self.assertIn('system.stateVersion = "26.11";', content) @@ -474,7 +469,7 @@ def test_generate_host_default_nix_workstation_nvidia_prime(self): hashed_pw="$6$rezehash", profile=ProfileChoice.WORKSTATION, shell="zsh", - bootloader=BootloaderChoice.GRUB, + bootloader=BootloaderChoice.LIMINE, features=default_features(ProfileChoice.WORKSTATION), dual_boot_entries=[], mode=InstallMode.WHOLE_DISK, @@ -507,9 +502,10 @@ def test_generate_disko_whole_disk_btrfs(self): ) disko = generate_disko_whole_disk(cfg) - self.assertIn("imports = [ ../../lib/disko/btrfs.nix ];", disko) - self.assertIn('disko.devices.disk.main.device = "/dev/nvme0n1";', disko) - self.assertIn('disko.devices.disk.main.content.partitions.swap.size = lib.mkForce "16G";', disko) + self.assertIn("northstar.mkDisko {", disko) + self.assertIn('device = "/dev/nvme0n1";', disko) + self.assertIn('fsType = "btrfs";', disko) + self.assertIn('swapSize = "16G";', disko) def test_generate_disko_whole_disk_ext4(self): """Verify Disko whole-disk EXT4 synthesis.""" @@ -522,10 +518,11 @@ def test_generate_disko_whole_disk_ext4(self): ) disko = generate_disko_whole_disk(cfg) - self.assertIn("imports = [ ../../lib/disko/ext4.nix ];", disko) - self.assertIn('disko.devices.disk.main.device = "/dev/sda";', disko) - self.assertIn('disko.devices.disk.main.content.partitions.swap.size = lib.mkForce "0";', disko) - self.assertIn('disko.devices.disk.main.content.partitions.root.size = lib.mkForce "500G";', disko) + self.assertIn("northstar.mkDisko {", disko) + self.assertIn('device = "/dev/sda";', disko) + self.assertIn('fsType = "ext4";', disko) + self.assertIn('swapSize = "0";', disko) + self.assertIn('rootSize = "500G";', disko) def test_generate_disko_partition_only_btrfs(self): """Verify Disko partition-only BTRFS synthesis.""" diff --git a/tests/test_hardware_detection.py b/tests/test_hardware_detection.py index 6b49152..2f6de82 100644 --- a/tests/test_hardware_detection.py +++ b/tests/test_hardware_detection.py @@ -20,7 +20,6 @@ GpuChoice, IgpuType, detect_all, - format_grub_extra_entries, format_limine_extra_entries, format_pci_bus_id, parse_lsblk_json, @@ -425,30 +424,6 @@ def test_parse_lsblk_json_edge_cases(self): # ── 7. Dual-Boot ESP Scanning ─────────────────────────────────── - def test_dual_boot_extra_entries_grub(self): - """Verify GRUB dual-boot configuration formatting.""" - entries = [ - DualBootEntry( - name="Fedora", - efi_path="/EFI/fedora/shimx64.efi", - disk_uuid="CB41-6695", - enabled=True, - ), - DualBootEntry( - name="Windows 11", - efi_path="/EFI/Microsoft/Boot/bootmgfw.efi", - disk_uuid="CB41-6695", - enabled=False, - ), - ] - - grub_cfg = format_grub_extra_entries(entries) - self.assertIn("boot.loader.grub.extraEntries = ''", grub_cfg) - self.assertIn('menuentry "Fedora"', grub_cfg) - self.assertIn("search --fs-uuid --set=root CB41-6695", grub_cfg) - self.assertIn("chainloader /EFI/fedora/shimx64.efi", grub_cfg) - self.assertNotIn("Windows 11", grub_cfg) - def test_dual_boot_extra_entries_limine(self): """Verify Limine dual-boot configuration formatting.""" entries = [ diff --git a/tests/test_memory_and_secrets.py b/tests/test_memory_and_secrets.py index b0ee654..3e2a878 100644 --- a/tests/test_memory_and_secrets.py +++ b/tests/test_memory_and_secrets.py @@ -456,7 +456,7 @@ def test_build_bootloader_config_limine_with_resolution(self): secure_boot=False, ) out = build_bootloader_config(cfg) - self.assertIn('northstar.features.boot.loader = "limine";', out) + self.assertIn('# Bootloader (Limine)', out) self.assertIn('boot.loader.limine.resolution = "2560x1440";', out) self.assertNotIn("secureBoot.enable", out) @@ -468,7 +468,7 @@ def test_build_bootloader_config_limine_with_secure_boot(self): secure_boot=True, ) out = build_bootloader_config(cfg) - self.assertIn('northstar.features.boot.loader = "limine";', out) + self.assertIn('# Bootloader (Limine)', out) self.assertIn('boot.loader.limine.resolution = "1920x1080";', out) self.assertIn("northstar.features.boot.secureBoot.enable = true;", out) diff --git a/tests/test_nix_syntax.py b/tests/test_nix_syntax.py index 4aa8931..492010a 100644 --- a/tests/test_nix_syntax.py +++ b/tests/test_nix_syntax.py @@ -47,14 +47,14 @@ class TestNixSyntax(unittest.TestCase): def test_generated_default_nix_syntax(self): # Matrix of configurations configs = [ - # 1. Base + GRUB + No GPU + # 1. Base + Limine + No GPU InstallConfig( hostname="server-node", username="rootuser", hashed_pw="$6$12345", profile=ProfileChoice.BASE, shell="zsh", - bootloader=BootloaderChoice.GRUB, + bootloader=BootloaderChoice.LIMINE, features=default_features(ProfileChoice.BASE), gpu_choice=GpuChoice.NONE, ), @@ -88,14 +88,14 @@ def test_generated_default_nix_syntax(self): gpu_choice=GpuChoice.NVIDIA, nvidia_bus_id="PCI:1:0:0", ), - # 3. Workstation + GRUB + NVIDIA Prime AMD + # 3. Workstation + Limine + NVIDIA Prime AMD InstallConfig( hostname="workstation-rig", username="alice", hashed_pw="$6$abcdef", profile=ProfileChoice.WORKSTATION, shell="zsh", - bootloader=BootloaderChoice.GRUB, + bootloader=BootloaderChoice.LIMINE, features=default_features(ProfileChoice.WORKSTATION), gpu_choice=GpuChoice.NVIDIA_PRIME, nvidia_bus_id="PCI:1:0:0", diff --git a/tests/test_state_and_profiles.py b/tests/test_state_and_profiles.py index 2429335..3618ddf 100644 --- a/tests/test_state_and_profiles.py +++ b/tests/test_state_and_profiles.py @@ -45,7 +45,7 @@ class InstallConfig: shell: str = "zsh" bootloader: BootloaderChoice = BootloaderChoice.LIMINE secure_boot: bool = False - secure_boot_pki: str = "/etc/secureboot" + secure_boot_pki: str = "/var/lib/sbctl" resolution: str = "1920x1080" gpu_choice: GpuChoice = GpuChoice.NONE nvidia_bus_id: str = ""