detached 5675604

refactor(bootloader): remove systemd-boot references and consolidate on Limine

commit
56756042e3e67f7fd7370c3fa2c1f45b2550e175
author
brice · · 2026-01-25 17:01 UTC
parent
28e451b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
95 files · +2963 -870
M .gitignore +2 -2
@@ -19,8 +19,8 @@ dist/
1919 coverage.out
2020
2121 # Compiled Go binaries
22-/archup-installer
23-/cmd/archup-installer/archup-installer
22+/archup
23+/cmd/archup/archup
2424 cmd/*/[a-z]*
2525
2626 # Markdown files at root (except README and CHANGELOG)
M .goreleaser.yaml +14 -14
@@ -8,9 +8,9 @@ before:
88
99 builds:
1010 # Main build - static binary for Arch Linux ISO
11- - id: archup-installer
12- main: ./cmd/archup-installer
13- binary: archup-installer
11+ - id: archup
12+ main: ./cmd/archup
13+ binary: archup
1414
1515 env:
1616 - CGO_ENABLED=0
@@ -24,7 +24,7 @@ builds:
2424 # Version information via ldflags
2525 ldflags:
2626 - -s -w
27- - -X main.version={{.Version}}
27+ - -X github.com/bnema/archup/cmd.version={{.Version}}
2828
2929 # Build flags for reproducible builds
3030 flags:
@@ -54,8 +54,8 @@ checksum:
5454 name_template: 'checksums.txt'
5555 algorithm: sha256
5656 extra_files:
57- - glob: ./dist/archup-installer_linux_amd64_v1/archup-installer
58- name_template: archup-installer
57+ - glob: ./dist/archup_linux_amd64_v1/archup
58+ name_template: archup
5959
6060 snapshot:
6161 version_template: "{{ incpatch .Version }}-next"
@@ -129,23 +129,23 @@ release:
129129 ### Install Specific Version
130130 ```bash
131131 # Download and run specific version
132- curl -fsSL https://github.com/bnema/archup/releases/download/{{.Tag}}/archup-installer -o /tmp/archup-installer
133- chmod +x /tmp/archup-installer
134- /tmp/archup-installer
132+ curl -fsSL https://github.com/bnema/archup/releases/download/{{.Tag}}/archup -o /tmp/archup
133+ chmod +x /tmp/archup
134+ /tmp/archup install
135135 ```
136136
137137 ### Manual Install
138138 ```bash
139139 # Download binary
140- wget https://github.com/bnema/archup/releases/download/{{.Tag}}/archup-installer
140+ wget https://github.com/bnema/archup/releases/download/{{.Tag}}/archup
141141
142142 # Verify checksum
143143 wget https://github.com/bnema/archup/releases/download/{{.Tag}}/checksums.txt
144144 sha256sum -c checksums.txt --ignore-missing
145145
146146 # Install
147- chmod +x archup-installer
148- sudo install -m 755 archup-installer /usr/local/bin/
147+ chmod +x archup
148+ sudo install -m 755 archup /usr/local/bin/
149149 ```
150150
151151 ---
@@ -163,5 +163,5 @@ release:
163163
164164 # Also upload standalone binary
165165 extra_files:
166- - glob: ./dist/archup-installer_linux_amd64_v1/archup-installer
167- name_template: archup-installer
166+ - glob: ./dist/archup_linux_amd64_v1/archup
167+ name_template: archup
M Makefile +4 -4
@@ -4,18 +4,18 @@
44 help:
55 @echo "archup - Makefile targets:"
66 @echo ""
7- @echo " make build - Build archup-installer binary"
7+ @echo " make build - Build archup binary"
88 @echo " make check - Run all checks (syntax + shellcheck)"
99 @echo " make check-syntax - Check shell script syntax with bash -n"
1010 @echo " make check-shellcheck - Run shellcheck linting"
1111 @echo " make clean - Remove generated files"
1212 @echo ""
1313
14-# Build archup-installer binary
14+# Build archup binary
1515 build:
16- @echo "Building archup-installer..."
16+ @echo "Building archup..."
1717 goreleaser build --snapshot --clean --single-target
18- @echo "✓ Build complete: ./dist/archup-installer_linux_amd64_v1/archup-installer"
18+ @echo "✓ Build complete: ./dist/archup_linux_amd64_v1/archup"
1919
2020 # Run all checks
2121 check: check-syntax check-shellcheck
M README.md +1 -1
@@ -56,7 +56,7 @@ curl -fsSL https://archup.run/install/dev | bash
5656 - Firewall (ufw) and power management (TLP)
5757 - Shell completion and theme system
5858
59-Run `archup wizard` after installation to add a Wayland compositor (Hyprland, Sway, Niri, or River) with integrated theming. Support will expand as the community contributes.
59+Run `archup wizard` after installation to add a Wayland compositor with integrated theming. Support will expand as the community contributes.
6060
6161 ## Status
6262
M RELEASING.md +1 -1
@@ -110,7 +110,7 @@ curl -fsSL https://archup.run/install/bin | bash -s -- --version v0.15.3-dev
110110 ## Artifacts Generated
111111
112112 Each release creates:
113-- `archup-installer` - Standalone binary
113+- `archup` - Standalone binary
114114 - `archup_VERSION_linux_x86_64.tar.gz` - Archive with binary + install scripts
115115 - `checksums.txt` - SHA256 checksums for verification
116116
D cmd/archup-installer/main.go +0 -120
@@ -1,120 +0,0 @@
1-package main
2-
3-import (
4- "flag"
5- "fmt"
6- "os"
7-
8- apphandlers "github.com/bnema/archup/internal/application/handlers"
9- "github.com/bnema/archup/internal/application/services"
10- "github.com/bnema/archup/internal/cleanup"
11- "github.com/bnema/archup/internal/config"
12- "github.com/bnema/archup/internal/infrastructure/executor"
13- "github.com/bnema/archup/internal/infrastructure/filesystem"
14- infralogger "github.com/bnema/archup/internal/infrastructure/logger"
15- "github.com/bnema/archup/internal/infrastructure/persistence"
16- "github.com/bnema/archup/internal/interfaces/tui"
17- "github.com/bnema/archup/internal/logger"
18- tea "github.com/charmbracelet/bubbletea"
19-)
20-
21-// version is set via ldflags at build time
22-// Example: go build -ldflags "-X main.version=v0.3.0"
23-var version = "dev"
24-
25-func main() {
26- // Parse command-line flags
27- var (
28- showVersion = flag.Bool("version", false, "Show version and exit")
29- doCleanup = flag.Bool("cleanup", false, "Clean up installation artifacts and exit")
30- dryRun = flag.Bool("dry-run", false, "Show TUI but don't execute commands")
31- )
32- flag.Parse()
33-
34- // Handle --version flag
35- if *showVersion {
36- fmt.Printf("archup-installer %s\n", version)
37- os.Exit(0)
38- }
39-
40- // Create logger with dry-run mode
41- oldLog, err := logger.New(config.DefaultLogPath, *dryRun)
42- if err != nil {
43- fmt.Fprintf(os.Stderr, "Failed to create logger: %v\n", err)
44- os.Exit(1)
45- }
46- defer oldLog.Close()
47-
48- // Handle --cleanup flag
49- if *doCleanup {
50- if err := cleanup.Run(oldLog); err != nil {
51- oldLog.Error("Cleanup failed", "error", err)
52- os.Exit(1)
53- }
54- os.Exit(0)
55- }
56-
57- // Log dry-run mode if enabled
58- if *dryRun {
59- oldLog.Info("Running in DRY-RUN mode - commands will be logged but not executed")
60- }
61-
62- // Load or create config (pass version to determine correct branch for downloads)
63- cfg := config.NewConfig(version)
64- oldLog.Info("Config initialized", "version", version, "raw_url", cfg.RawURL)
65-
66- // ==================== DEPENDENCY INJECTION ====================
67- oldLog.Info("Initializing DDD architecture components")
68-
69- // Adapt old logger to ports.Logger interface
70- slogAdapter := infralogger.NewSlogAdapter(oldLog.Slog())
71-
72- // 1. Create infrastructure adapters
73- fsAdapter := &filesystem.LocalFileSystem{}
74- shellExec := executor.NewShellExecutor(slogAdapter)
75- chrootExec := executor.NewChrootExecutor(slogAdapter)
76- scriptExec := executor.NewScriptExecutor(fsAdapter, shellExec, config.DefaultInstallDir)
77- repoAdapter, err := persistence.NewFileRepository(config.DefaultConfigPath)
78- if err != nil {
79- oldLog.Error("Failed to create repository adapter", "error", err)
80- os.Exit(1)
81- }
82-
83- // 2. Create command handlers (inject ports)
84- preflightHandler := apphandlers.NewPreflightHandler(fsAdapter, shellExec, slogAdapter)
85- partitionHandler := apphandlers.NewPartitionHandler(shellExec, slogAdapter)
86- baseHandler := apphandlers.NewInstallBaseHandler(fsAdapter, shellExec, chrootExec, slogAdapter)
87- configHandler := apphandlers.NewConfigureSystemHandler(chrootExec, slogAdapter)
88- bootloaderHandler := apphandlers.NewBootloaderHandler(chrootExec, slogAdapter)
89- reposHandler := apphandlers.NewReposHandler(chrootExec, slogAdapter)
90- postInstallHandler := apphandlers.NewPostInstallHandler(chrootExec, scriptExec, slogAdapter)
91-
92- // 3. Create application services
93- installService := services.NewInstallationService(
94- repoAdapter,
95- slogAdapter,
96- preflightHandler,
97- partitionHandler,
98- baseHandler,
99- configHandler,
100- bootloaderHandler,
101- reposHandler,
102- postInstallHandler,
103- )
104-
105- // 4. Create TUI app with services
106- tuiApp := tui.NewApp(installService, installService.Tracker(), slogAdapter)
107-
108- // ==================== RUN BUBBLETEA ====================
109- oldLog.Info("Starting TUI application", "version", version)
110- p := tea.NewProgram(tuiApp, tea.WithAltScreen())
111- if _, err := p.Run(); err != nil {
112- oldLog.Error("Error running installer", "error", err)
113- os.Exit(1)
114- }
115-
116- // Cleanup
117- if err := tuiApp.Close(); err != nil {
118- oldLog.Error("Error closing TUI app", "error", err)
119- }
120-}
M docs/DEPENDENCY_INJECTION.md +1 -1
@@ -18,7 +18,7 @@ Domain Layer (aggregates, ports, value objects)
1818 Infrastructure Layer (adapters) implements domain ports
1919 ```
2020
21-## Wiring in cmd/archup-installer/main.go
21+## Wiring in cmd/archup/main.go
2222
2323 ### 1. Infrastructure Adapters (Lowest Layer)
2424
M docs/architecture.md +14 -5
@@ -56,7 +56,6 @@ Dependencies: UI → App → Domain ← Infrastructure
5656 #### `domain/bootloader/`
5757 - **bootloader.go** - Bootloader value object (type, configuration)
5858 - **limine.go** - Limine-specific configuration and rules
59-- **systemdboot.go** - systemd-boot specific configuration
6059
6160 #### `domain/user/`
6261 - **user.go** - User entity (username, groups, shell)
@@ -154,6 +153,7 @@ UI/CLI layer. **Imports application/** but minimal domain access.
154153 #### `interfaces/tui/`
155154 BubbleTea terminal UI application:
156155 - **app.go** - Main TUI application
156+ - **wizard/** - Post-boot wizard TUI (desktop setup)
157157
158158 #### `interfaces/tui/models/`
159159 BubbleTea models (UI state only, no business logic):
@@ -177,10 +177,19 @@ CLI commands (e.g., using Cobra):
177177 - **root.go** - Root command
178178 - **install.go** - Install command
179179
180-### `/cmd/archup-installer/`
180+### `/cmd/archup/`
181181 Application entry point with dependency injection:
182182 - **main.go** - Wire all dependencies (adapters, handlers, services, TUI)
183183
184+### Wizard Flow (post-boot)
185+
186+- Welcome → Compositor → SDDM → Optional → Confirm → Install → Monitors → Apply Config → Complete
187+- Config outputs:
188+ - Hyprland: `~/.config/hypr/archup.conf`, `~/.config/hypr/archup-monitors.conf`
189+ - Niri: `~/.config/niri/archup.kdl`, `~/.config/niri/archup-monitors.kdl`
190+ - Waybar: `~/.config/waybar/config`, `~/.config/waybar/style.css`
191+ - Locks: `~/.config/hypr/hyprlock.conf`, `~/.config/hypr/hypridle.conf`
192+
184193 ## Architecture Patterns
185194
186195 ### 1. Aggregate Root (Installation)
@@ -319,7 +328,7 @@ For each phase migrated from shell scripts:
319328 3. Create handler in `application/handlers/`
320329 4. Implement adapters in `infrastructure/*/`
321330 5. Add UI in `interfaces/tui/`
322-6. Wire in `cmd/archup-installer/main.go`
331+6. Wire in `cmd/archup/main.go`
323332
324333 ### Fixing a Bug
325334
@@ -331,7 +340,7 @@ For each phase migrated from shell scripts:
331340 ### Changing Infrastructure
332341
333342 1. Create new adapter implementing port
334-2. Update `cmd/archup-installer/main.go` wiring
343+2. Update `cmd/archup/main.go` wiring
335344 3. Existing domain and application code unchanged
336345 4. No need to modify business logic
337346
@@ -340,7 +349,7 @@ For each phase migrated from shell scripts:
340349 - **DDD_REFACTORING_PLAN.md** - Complete refactoring plan with detailed phases
341350 - **docs/script-migration.md** - Shell scripts categorization and migration strategy
342351 - **internal/domain/ports/** - All port interfaces (critical!)
343-- **cmd/archup-installer/main.go** - Dependency injection wiring
352+- **cmd/archup/main.go** - Dependency injection wiring
344353
345354 ## References
346355
M go.mod +15 -9
@@ -8,6 +8,7 @@ require (
88 github.com/charmbracelet/huh v0.8.0
99 github.com/charmbracelet/lipgloss v1.1.0
1010 github.com/google/uuid v1.6.0
11+ github.com/spf13/cobra v1.10.2
1112 go.uber.org/mock v0.6.0
1213 )
1314
@@ -15,23 +16,28 @@ require (
1516 github.com/atotto/clipboard v0.1.4 // indirect
1617 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
1718 github.com/catppuccin/go v0.3.0 // indirect
18- github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
19- github.com/charmbracelet/x/ansi v0.10.1 // indirect
20- github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
21- github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
22- github.com/charmbracelet/x/term v0.2.1 // indirect
19+ github.com/charmbracelet/colorprofile v0.4.1 // indirect
20+ github.com/charmbracelet/x/ansi v0.11.4 // indirect
21+ github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
22+ github.com/charmbracelet/x/exp/strings v0.0.0-20260122224438-b01af16209d9 // indirect
23+ github.com/charmbracelet/x/term v0.2.2 // indirect
24+ github.com/clipperhouse/displaywidth v0.7.0 // indirect
25+ github.com/clipperhouse/stringish v0.1.1 // indirect
26+ github.com/clipperhouse/uax29/v2 v2.4.0 // indirect
2327 github.com/dustin/go-humanize v1.0.1 // indirect
2428 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
25- github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
29+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
30+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
2631 github.com/mattn/go-isatty v0.0.20 // indirect
2732 github.com/mattn/go-localereader v0.0.1 // indirect
28- github.com/mattn/go-runewidth v0.0.16 // indirect
33+ github.com/mattn/go-runewidth v0.0.19 // indirect
2934 github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
3035 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
3136 github.com/muesli/cancelreader v0.2.2 // indirect
3237 github.com/muesli/termenv v0.16.0 // indirect
3338 github.com/rivo/uniseg v0.4.7 // indirect
39+ github.com/spf13/pflag v1.0.10 // indirect
3440 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
35- golang.org/x/sys v0.37.0 // indirect
36- golang.org/x/text v0.23.0 // indirect
41+ golang.org/x/sys v0.40.0 // indirect
42+ golang.org/x/text v0.33.0 // indirect
3743 )
M go.sum +35 -19 generated file: collapsed
M install-bin-dev.sh +2 -2
@@ -76,7 +76,7 @@ success "Architecture: $ARCH"
7676 # GitHub repository details
7777 REPO_OWNER="bnema"
7878 REPO_NAME="archup"
79-BINARY_NAME="archup-installer"
79+BINARY_NAME="archup"
8080
8181 # Determine which version to download
8282 if [ -n "$SPECIFIC_VERSION" ]; then
@@ -128,4 +128,4 @@ chmod +x "$BINARY_PATH"
128128 # Run installer
129129 info "Launching ArchUp installer..."
130130 echo ""
131-exec "$BINARY_PATH"
131+exec "$BINARY_PATH" install
M install-bin.sh +2 -2
@@ -83,7 +83,7 @@ success "Architecture: $ARCH"
8383 # GitHub repository details
8484 REPO_OWNER="bnema"
8585 REPO_NAME="archup"
86-BINARY_NAME="archup-installer"
86+BINARY_NAME="archup"
8787
8888 # Determine which version to download
8989 if [ -n "$SPECIFIC_VERSION" ]; then
@@ -145,4 +145,4 @@ chmod +x "$BINARY_PATH"
145145 # Run installer
146146 info "Launching ArchUp installer..."
147147 echo ""
148-exec "$BINARY_PATH"
148+exec "$BINARY_PATH" install
M install/configs/limine.conf.template +11 -7
@@ -1,18 +1,22 @@
11 timeout: {{TIMEOUT}}
2-default_entry: {{DEFAULT_ENTRY}}
2+default_entry: 1
33 interface_branding: {{BRANDING}}
44 interface_branding_colour: {{COLOR}}
55 graphics: yes
66 quiet: yes
77
8-/Arch Linux
8+/{{BRANDING}}
9+
10+ //{{KERNEL}}
911 protocol: linux
10- path: boot():/vmlinuz-{{KERNEL}}
11- cmdline: {{KERNEL_PARAMS}}
12+ kernel_path: boot():/vmlinuz-{{KERNEL}}
13+ kernel_cmdline: {{KERNEL_PARAMS}}
1214 module_path: boot():/initramfs-{{KERNEL}}.img
1315
14-/Arch Linux (fallback)
16+ //{{KERNEL}}-fallback
1517 protocol: linux
16- path: boot():/vmlinuz-{{KERNEL}}
17- cmdline: {{KERNEL_PARAMS}}
18+ kernel_path: boot():/vmlinuz-{{KERNEL}}
19+ kernel_cmdline: {{KERNEL_PARAMS}}
1820 module_path: boot():/initramfs-{{KERNEL}}-fallback.img
21+
22+ //Snapshots
M install/mandatory/post-boot/snapper.sh +19 -8
@@ -12,12 +12,23 @@ if ! snapper list-configs 2>/dev/null | grep -q "home"; then
1212 echo "Created snapper config: home"
1313 fi
1414
15-# Tweak default snapper configs
16-sed -i 's/^TIMELINE_CREATE="yes"/TIMELINE_CREATE="no"/' /etc/snapper/configs/root
17-sed -i 's/^TIMELINE_CREATE="yes"/TIMELINE_CREATE="no"/' /etc/snapper/configs/home
18-sed -i 's/^NUMBER_LIMIT="50"/NUMBER_LIMIT="5"/' /etc/snapper/configs/root
19-sed -i 's/^NUMBER_LIMIT="50"/NUMBER_LIMIT="5"/' /etc/snapper/configs/home
20-sed -i 's/^NUMBER_LIMIT_IMPORTANT="10"/NUMBER_LIMIT_IMPORTANT="5"/' /etc/snapper/configs/root
21-sed -i 's/^NUMBER_LIMIT_IMPORTANT="10"/NUMBER_LIMIT_IMPORTANT="5"/' /etc/snapper/configs/home
15+# Configure timeline snapshots for root: 1 hourly, 1 daily, 1 weekly, 1 monthly, 0 yearly
16+snapper -c root set-config "TIMELINE_CREATE=yes"
17+snapper -c root set-config "TIMELINE_LIMIT_HOURLY=1"
18+snapper -c root set-config "TIMELINE_LIMIT_DAILY=1"
19+snapper -c root set-config "TIMELINE_LIMIT_WEEKLY=1"
20+snapper -c root set-config "TIMELINE_LIMIT_MONTHLY=1"
21+snapper -c root set-config "TIMELINE_LIMIT_YEARLY=0"
22+snapper -c root set-config "NUMBER_LIMIT=5"
23+snapper -c root set-config "NUMBER_LIMIT_IMPORTANT=5"
2224
23-echo "Configured snapper limits"
25+# Disable timeline for home (less critical)
26+snapper -c home set-config "TIMELINE_CREATE=no"
27+snapper -c home set-config "NUMBER_LIMIT=5"
28+snapper -c home set-config "NUMBER_LIMIT_IMPORTANT=5"
29+
30+# Enable snapper timers
31+systemctl enable --now snapper-timeline.timer
32+systemctl enable --now snapper-cleanup.timer
33+
34+echo "Configured snapper timeline (1h/1d/1w/1m) with snap-pac enabled"
M internal/application/commands/install_bootloader.go +11 -2
@@ -1,11 +1,20 @@
11 package commands
22
3-import "github.com/bnema/archup/internal/domain/bootloader"
3+import (
4+ "github.com/bnema/archup/internal/domain/bootloader"
5+ "github.com/bnema/archup/internal/domain/disk"
6+ "github.com/bnema/archup/internal/domain/packages"
7+)
48
59 // InstallBootloaderCommand contains data for bootloader installation
610 type InstallBootloaderCommand struct {
711 MountPoint string // Root mount point
8- BootloaderType bootloader.BootloaderType // BootloaderTypeLimine or BootloaderTypeSystemdBoot
12+ BootloaderType bootloader.BootloaderType // BootloaderTypeLimine
913 TimeoutSeconds int // Boot menu timeout (0-600 seconds)
1014 Branding string // Bootloader display name
15+ KernelVariant packages.KernelVariant // KernelStable, KernelZen, KernelLTS, KernelHardened, KernelCachyOS
16+ RootPartition string // Root partition device path
17+ EncryptionType disk.EncryptionType // EncryptionTypeNone, EncryptionTypeLUKS, EncryptionTypeLUKSLVM
18+ EFIPartition string // EFI partition device path
19+ TargetDisk string // Target disk device path
1120 }
M internal/application/commands/partition.go +1 -1
@@ -6,7 +6,7 @@ import "github.com/bnema/archup/internal/domain/disk"
66 type PartitionDiskCommand struct {
77 TargetDisk string // e.g., "/dev/sda"
88 RootSizeGB int64 // Size of root partition in GB
9- BootSizeGB int64 // Size of boot partition in GB (usually 512MB-1GB)
9+ BootSizeGB int64 // Size of boot partition in GB (4GB recommended for limine-snapper-sync)
1010 EncryptionType disk.EncryptionType // EncryptionTypeNone, EncryptionTypeLUKS, EncryptionTypeLUKSLVM
1111 EncryptionPassword string // Password for encrypted partitions (if applicable)
1212 FilesystemType disk.FilesystemType // FilesystemExt4, FilesystemBtrfs, FilesystemFAT32
M internal/application/handlers/bootloader_handler.go +166 -5
@@ -3,22 +3,32 @@ package handlers
33 import (
44 "context"
55 "fmt"
6+ "path/filepath"
7+ "regexp"
8+ "strings"
69
710 "github.com/bnema/archup/internal/application/commands"
811 "github.com/bnema/archup/internal/application/dto"
12+ "github.com/bnema/archup/internal/config"
913 "github.com/bnema/archup/internal/domain/bootloader"
14+ "github.com/bnema/archup/internal/domain/disk"
15+ "github.com/bnema/archup/internal/domain/packages"
1016 "github.com/bnema/archup/internal/domain/ports"
1117 )
1218
1319 // BootloaderHandler handles bootloader installation
1420 type BootloaderHandler struct {
21+ fs ports.FileSystem
22+ cmdExec ports.CommandExecutor
1523 chrExec ports.ChrootExecutor
1624 logger ports.Logger
1725 }
1826
1927 // NewBootloaderHandler creates a new bootloader handler
20-func NewBootloaderHandler(chrExec ports.ChrootExecutor, logger ports.Logger) *BootloaderHandler {
28+func NewBootloaderHandler(fs ports.FileSystem, cmdExec ports.CommandExecutor, chrExec ports.ChrootExecutor, logger ports.Logger) *BootloaderHandler {
2129 return &BootloaderHandler{
30+ fs: fs,
31+ cmdExec: cmdExec,
2232 chrExec: chrExec,
2333 logger: logger,
2434 }
@@ -46,10 +56,32 @@ func (h *BootloaderHandler) Handle(ctx context.Context, cmd commands.InstallBoot
4656
4757 h.logger.Info("Bootloader configuration validated", "type", bl.Type())
4858
49- // In a real implementation, this would configure and install the bootloader
50- h.logger.Info("Installing bootloader...", "type", bl.Type())
51- // For Limine: configure limine.cfg
52- // For systemd-boot: configure bootctl and entry files
59+ kernel, err := packages.NewKernel(cmd.KernelVariant)
60+ if err != nil {
61+ h.logger.Error("Invalid kernel variant", "error", err)
62+ result.ErrorDetail = fmt.Sprintf("Invalid kernel variant: %v", err)
63+ return result, err
64+ }
65+
66+ if err := h.configureMkinitcpio(ctx, cmd.MountPoint, cmd.EncryptionType); err != nil {
67+ result.ErrorDetail = err.Error()
68+ return result, err
69+ }
70+
71+ if err := h.installLimine(ctx, cmd.MountPoint); err != nil {
72+ result.ErrorDetail = err.Error()
73+ return result, err
74+ }
75+
76+ if err := h.configureLimine(ctx, cmd, kernel.PackageName()); err != nil {
77+ result.ErrorDetail = err.Error()
78+ return result, err
79+ }
80+
81+ if err := h.createBootEntry(ctx, cmd.TargetDisk, cmd.EFIPartition, cmd.MountPoint); err != nil {
82+ result.ErrorDetail = err.Error()
83+ return result, err
84+ }
5385
5486 result.Success = true
5587 result.BootloaderType = bl.Type().String()
@@ -58,3 +90,132 @@ func (h *BootloaderHandler) Handle(ctx context.Context, cmd commands.InstallBoot
5890 h.logger.Info("Bootloader installation completed successfully")
5991 return result, nil
6092 }
93+
94+func (h *BootloaderHandler) configureMkinitcpio(ctx context.Context, mountPoint string, encType disk.EncryptionType) error {
95+ confPath := filepath.Join(mountPoint, "etc", "mkinitcpio.conf")
96+ content, err := h.fs.ReadFile(confPath)
97+ if err != nil {
98+ h.logger.Error("Failed to read mkinitcpio.conf", "error", err)
99+ return fmt.Errorf("failed to read mkinitcpio.conf: %w", err)
100+ }
101+
102+ hooks := config.MkinitcpioHooksPlymouth
103+ if encType != disk.EncryptionTypeNone {
104+ hooks = config.MkinitcpioHooksEncrypted
105+ }
106+
107+ updated := replaceHooksLine(string(content), hooks)
108+ if err := h.fs.WriteFile(confPath, []byte(updated), 0644); err != nil {
109+ h.logger.Error("Failed to write mkinitcpio.conf", "error", err)
110+ return fmt.Errorf("failed to write mkinitcpio.conf: %w", err)
111+ }
112+
113+ if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "mkinitcpio", "-P"); err != nil {
114+ h.logger.Error("Failed to regenerate initramfs", "error", err)
115+ return fmt.Errorf("failed to regenerate initramfs: %w", err)
116+ }
117+
118+ return nil
119+}
120+
121+func (h *BootloaderHandler) installLimine(ctx context.Context, mountPoint string) error {
122+ limineDir := filepath.Join(mountPoint, "boot", "EFI", "limine")
123+ if err := h.fs.MkdirAll(limineDir, 0755); err != nil {
124+ h.logger.Error("Failed to create Limine directory", "error", err)
125+ return fmt.Errorf("failed to create Limine directory: %w", err)
126+ }
127+
128+ src := filepath.Join(mountPoint, "usr", "share", "limine", "BOOTX64.EFI")
129+ dst := filepath.Join(limineDir, "BOOTX64.EFI")
130+ if _, err := h.cmdExec.Execute(ctx, "cp", src, dst); err != nil {
131+ h.logger.Error("Failed to copy Limine EFI", "error", err)
132+ return fmt.Errorf("failed to copy Limine EFI: %w", err)
133+ }
134+
135+ return nil
136+}
137+
138+func (h *BootloaderHandler) configureLimine(ctx context.Context, cmd commands.InstallBootloaderCommand, kernelName string) error {
139+ rootUUIDBytes, err := h.cmdExec.Execute(ctx, "blkid", "-s", "UUID", "-o", "value", cmd.RootPartition)
140+ if err != nil {
141+ h.logger.Error("Failed to get root UUID", "error", err)
142+ return fmt.Errorf("failed to get root UUID: %w", err)
143+ }
144+
145+ rootUUID := strings.TrimSpace(string(rootUUIDBytes))
146+ var kernelParams string
147+ if cmd.EncryptionType != disk.EncryptionTypeNone {
148+ kernelParams = fmt.Sprintf("cryptdevice=UUID=%s:cryptroot root=/dev/mapper/cryptroot rootflags=subvol=@ rw", rootUUID)
149+ } else {
150+ kernelParams = fmt.Sprintf("root=UUID=%s rootflags=subvol=@ rw", rootUUID)
151+ }
152+
153+ kernelParams = fmt.Sprintf("%s %s", kernelParams, config.KernelParamsQuiet)
154+
155+ templatePath, err := h.resolveLimineTemplate()
156+ if err != nil {
157+ h.logger.Error("Failed to locate Limine template", "error", err)
158+ return fmt.Errorf("failed to locate Limine template: %w", err)
159+ }
160+
161+ templateBytes, err := h.fs.ReadFile(templatePath)
162+ if err != nil {
163+ h.logger.Error("Failed to read Limine template", "error", err)
164+ return fmt.Errorf("failed to read Limine template: %w", err)
165+ }
166+
167+ limineConfig := string(templateBytes)
168+ limineConfig = strings.ReplaceAll(limineConfig, "{{TIMEOUT}}", fmt.Sprintf("%d", cmd.TimeoutSeconds))
169+ limineConfig = strings.ReplaceAll(limineConfig, "{{BRANDING}}", cmd.Branding)
170+ limineConfig = strings.ReplaceAll(limineConfig, "{{COLOR}}", config.LimineColor)
171+ limineConfig = strings.ReplaceAll(limineConfig, "{{KERNEL}}", kernelName)
172+ limineConfig = strings.ReplaceAll(limineConfig, "{{KERNEL_PARAMS}}", kernelParams)
173+
174+ limineConfigPath := filepath.Join(cmd.MountPoint, "boot", "limine.conf")
175+ if err := h.fs.WriteFile(limineConfigPath, []byte(limineConfig), 0644); err != nil {
176+ h.logger.Error("Failed to write Limine config", "error", err)
177+ return fmt.Errorf("failed to write Limine config: %w", err)
178+ }
179+
180+ return nil
181+}
182+
183+func (h *BootloaderHandler) createBootEntry(ctx context.Context, targetDisk, efiPartition, mountPoint string) error {
184+ partNum := extractPartitionNumber(efiPartition)
185+ if partNum == "" {
186+ return fmt.Errorf("failed to determine EFI partition number from %s", efiPartition)
187+ }
188+
189+ if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "efibootmgr", "--create", "--disk", targetDisk, "--part", partNum, "--label", config.UEFIBootLabel, "--loader", config.UEFIBootLoader); err != nil {
190+ h.logger.Error("Failed to create EFI boot entry", "error", err)
191+ return fmt.Errorf("failed to create EFI boot entry: %w", err)
192+ }
193+
194+ return nil
195+}
196+
197+func replaceHooksLine(content string, hooks string) string {
198+ re := regexp.MustCompile(`(?m)^HOOKS=.*$`)
199+ return re.ReplaceAllString(content, hooks)
200+}
201+
202+func extractPartitionNumber(partition string) string {
203+ re := regexp.MustCompile(`[0-9]+$`)
204+ return re.FindString(partition)
205+}
206+
207+func (h *BootloaderHandler) resolveLimineTemplate() (string, error) {
208+ candidates := []string{
209+ filepath.Join(config.DefaultInstallDir, "configs", "limine.conf.template"),
210+ filepath.Join("install", "configs", "limine.conf.template"),
211+ }
212+
213+ for _, candidate := range candidates {
214+ exists, err := h.fs.Exists(candidate)
215+ if err == nil && exists {
216+ return candidate, nil
217+ }
218+ }
219+
220+ return "", fmt.Errorf("limine.conf.template not found")
221+}
M internal/application/handlers/bootloader_handler_test.go +34 -36
@@ -6,6 +6,8 @@ import (
66
77 "github.com/bnema/archup/internal/application/commands"
88 "github.com/bnema/archup/internal/domain/bootloader"
9+ "github.com/bnema/archup/internal/domain/disk"
10+ "github.com/bnema/archup/internal/domain/packages"
911 "github.com/bnema/archup/internal/domain/ports/mocks"
1012 "go.uber.org/mock/gomock"
1113 )
@@ -14,18 +16,33 @@ func TestBootloaderHandler_Handle_Limine(t *testing.T) {
1416 ctrl := gomock.NewController(t)
1517 defer ctrl.Finish()
1618
19+ mockFS := mocks.NewMockFileSystem(ctrl)
20+ mockExec := mocks.NewMockCommandExecutor(ctrl)
1721 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
1822 mockLogger := mocks.NewMockLogger(ctrl)
1923
2024 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
25+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("HOOKS=(base)\n"), nil).AnyTimes()
26+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
27+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
28+ mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes()
29+ mockExec.EXPECT().Execute(gomock.Any(), "blkid", "-s", "UUID", "-o", "value", gomock.Any()).Return([]byte("uuid"), nil).AnyTimes()
30+ mockExec.EXPECT().Execute(gomock.Any(), "cp", gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
31+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), "mkinitcpio", "-P").Return([]byte{}, nil).AnyTimes()
32+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), "efibootmgr", gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
2133
22- handler := NewBootloaderHandler(mockChrExec, mockLogger)
34+ handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
2335
2436 cmd := commands.InstallBootloaderCommand{
2537 MountPoint: "/mnt",
2638 BootloaderType: bootloader.BootloaderTypeLimine,
2739 TimeoutSeconds: 5,
2840 Branding: "ArchUp",
41+ KernelVariant: packages.KernelStable,
42+ RootPartition: "/dev/sda2",
43+ EncryptionType: disk.EncryptionTypeNone,
44+ EFIPartition: "/dev/sda1",
45+ TargetDisk: "/dev/sda",
2946 }
3047
3148 result, err := handler.Handle(context.Background(), cmd)
@@ -47,56 +64,30 @@ func TestBootloaderHandler_Handle_Limine(t *testing.T) {
4764 }
4865 }
4966
50-func TestBootloaderHandler_Handle_SystemdBoot(t *testing.T) {
51- ctrl := gomock.NewController(t)
52- defer ctrl.Finish()
53-
54- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
55- mockLogger := mocks.NewMockLogger(ctrl)
56-
57- mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
58-
59- handler := NewBootloaderHandler(mockChrExec, mockLogger)
60-
61- cmd := commands.InstallBootloaderCommand{
62- MountPoint: "/mnt",
63- BootloaderType: bootloader.BootloaderTypeSystemdBoot,
64- TimeoutSeconds: 10,
65- Branding: "ArchUp",
66- }
67-
68- result, err := handler.Handle(context.Background(), cmd)
69-
70- if err != nil {
71- t.Fatalf("expected no error, got %v", err)
72- }
73-
74- if !result.Success {
75- t.Error("expected success")
76- }
77-
78- if result.BootloaderType != "systemd-boot" {
79- t.Errorf("expected bootloader type systemd-boot, got %s", result.BootloaderType)
80- }
81-}
82-
8367 func TestBootloaderHandler_Handle_InvalidTimeout(t *testing.T) {
8468 ctrl := gomock.NewController(t)
8569 defer ctrl.Finish()
8670
71+ mockFS := mocks.NewMockFileSystem(ctrl)
72+ mockExec := mocks.NewMockCommandExecutor(ctrl)
8773 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
8874 mockLogger := mocks.NewMockLogger(ctrl)
8975
9076 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
9177 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)
9278
93- handler := NewBootloaderHandler(mockChrExec, mockLogger)
79+ handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
9480
9581 cmd := commands.InstallBootloaderCommand{
9682 MountPoint: "/mnt",
9783 BootloaderType: bootloader.BootloaderTypeLimine,
9884 TimeoutSeconds: 700, // Too large
9985 Branding: "ArchUp",
86+ KernelVariant: packages.KernelStable,
87+ RootPartition: "/dev/sda2",
88+ EncryptionType: disk.EncryptionTypeNone,
89+ EFIPartition: "/dev/sda1",
90+ TargetDisk: "/dev/sda",
10091 }
10192
10293 result, err := handler.Handle(context.Background(), cmd)
@@ -114,19 +105,26 @@ func TestBootloaderHandler_Handle_InvalidBranding(t *testing.T) {
114105 ctrl := gomock.NewController(t)
115106 defer ctrl.Finish()
116107
108+ mockFS := mocks.NewMockFileSystem(ctrl)
109+ mockExec := mocks.NewMockCommandExecutor(ctrl)
117110 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
118111 mockLogger := mocks.NewMockLogger(ctrl)
119112
120113 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
121114 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)
122115
123- handler := NewBootloaderHandler(mockChrExec, mockLogger)
116+ handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
124117
125118 cmd := commands.InstallBootloaderCommand{
126119 MountPoint: "/mnt",
127120 BootloaderType: bootloader.BootloaderTypeLimine,
128121 TimeoutSeconds: 5,
129122 Branding: "", // Empty branding
123+ KernelVariant: packages.KernelStable,
124+ RootPartition: "/dev/sda2",
125+ EncryptionType: disk.EncryptionTypeNone,
126+ EFIPartition: "/dev/sda1",
127+ TargetDisk: "/dev/sda",
130128 }
131129
132130 result, err := handler.Handle(context.Background(), cmd)
M internal/application/handlers/configure_system_handler.go +103 -1
@@ -3,6 +3,8 @@ package handlers
33 import (
44 "context"
55 "fmt"
6+ "path/filepath"
7+ "strings"
68
79 "github.com/bnema/archup/internal/application/commands"
810 "github.com/bnema/archup/internal/application/dto"
@@ -13,13 +15,15 @@ import (
1315
1416 // ConfigureSystemHandler handles system configuration
1517 type ConfigureSystemHandler struct {
18+ fs ports.FileSystem
1619 chrExec ports.ChrootExecutor
1720 logger ports.Logger
1821 }
1922
2023 // NewConfigureSystemHandler creates a new system configuration handler
21-func NewConfigureSystemHandler(chrExec ports.ChrootExecutor, logger ports.Logger) *ConfigureSystemHandler {
24+func NewConfigureSystemHandler(fs ports.FileSystem, chrExec ports.ChrootExecutor, logger ports.Logger) *ConfigureSystemHandler {
2225 return &ConfigureSystemHandler{
26+ fs: fs,
2327 chrExec: chrExec,
2428 logger: logger,
2529 }
@@ -69,6 +73,104 @@ func (h *ConfigureSystemHandler) Handle(ctx context.Context, cmd commands.Config
6973
7074 h.logger.Info("User configuration validated", "username", usr.Username())
7175 h.logger.Info("Credentials validated successfully")
76+
77+ if err := h.fs.WriteFile(filepath.Join(cmd.MountPoint, "etc", "hostname"), []byte(sysConfig.Hostname()+"\n"), 0644); err != nil {
78+ h.logger.Error("Failed to write hostname", "error", err)
79+ result.ErrorDetail = fmt.Sprintf("Failed to write hostname: %v", err)
80+ return result, err
81+ }
82+
83+ hostsContent := fmt.Sprintf("127.0.0.1\tlocalhost\n::1\tlocalhost\n127.0.1.1\t%s.localdomain\t%s\n", sysConfig.Hostname(), sysConfig.Hostname())
84+ if err := h.fs.WriteFile(filepath.Join(cmd.MountPoint, "etc", "hosts"), []byte(hostsContent), 0644); err != nil {
85+ h.logger.Error("Failed to write hosts file", "error", err)
86+ result.ErrorDetail = fmt.Sprintf("Failed to write hosts file: %v", err)
87+ return result, err
88+ }
89+
90+ if sysConfig.Timezone() != "" {
91+ zonePath := filepath.Join("/usr/share/zoneinfo", sysConfig.Timezone())
92+ if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "ln", "-sf", zonePath, "/etc/localtime"); err != nil {
93+ h.logger.Error("Failed to set timezone", "error", err)
94+ result.ErrorDetail = fmt.Sprintf("Failed to set timezone: %v", err)
95+ return result, err
96+ }
97+ }
98+
99+ if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "hwclock", "--systohc"); err != nil {
100+ h.logger.Error("Failed to set hardware clock", "error", err)
101+ result.ErrorDetail = fmt.Sprintf("Failed to set hardware clock: %v", err)
102+ return result, err
103+ }
104+
105+ if sysConfig.Locale() != "" {
106+ localeGen := fmt.Sprintf("%s UTF-8\n", sysConfig.Locale())
107+ if err := h.fs.WriteFile(filepath.Join(cmd.MountPoint, "etc", "locale.gen"), []byte(localeGen), 0644); err != nil {
108+ h.logger.Error("Failed to write locale.gen", "error", err)
109+ result.ErrorDetail = fmt.Sprintf("Failed to write locale.gen: %v", err)
110+ return result, err
111+ }
112+
113+ localeConf := fmt.Sprintf("LANG=%s\n", sysConfig.Locale())
114+ if err := h.fs.WriteFile(filepath.Join(cmd.MountPoint, "etc", "locale.conf"), []byte(localeConf), 0644); err != nil {
115+ h.logger.Error("Failed to write locale.conf", "error", err)
116+ result.ErrorDetail = fmt.Sprintf("Failed to write locale.conf: %v", err)
117+ return result, err
118+ }
119+
120+ if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "locale-gen"); err != nil {
121+ h.logger.Error("Failed to generate locale", "error", err)
122+ result.ErrorDetail = fmt.Sprintf("Failed to generate locale: %v", err)
123+ return result, err
124+ }
125+ }
126+
127+ if err := h.fs.WriteFile(filepath.Join(cmd.MountPoint, "etc", "vconsole.conf"), []byte("KEYMAP="+sysConfig.Keymap()+"\n"), 0644); err != nil {
128+ h.logger.Error("Failed to write vconsole.conf", "error", err)
129+ result.ErrorDetail = fmt.Sprintf("Failed to write vconsole.conf: %v", err)
130+ return result, err
131+ }
132+
133+ groups := strings.Join(usr.Groups(), ",")
134+ if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "useradd", "-m", "-G", groups, "-s", usr.Shell(), usr.Username()); err != nil {
135+ h.logger.Error("Failed to create user", "error", err)
136+ result.ErrorDetail = fmt.Sprintf("Failed to create user: %v", err)
137+ return result, err
138+ }
139+
140+ userCreds := fmt.Sprintf("%s:%s\n", usr.Username(), cmd.UserPassword)
141+ if err := h.chrExec.ExecuteInChrootWithStdin(ctx, cmd.MountPoint, userCreds, "chpasswd"); err != nil {
142+ h.logger.Error("Failed to set user password", "error", err)
143+ result.ErrorDetail = fmt.Sprintf("Failed to set user password: %v", err)
144+ return result, err
145+ }
146+
147+ if cmd.RootPassword != "" {
148+ rootCreds := fmt.Sprintf("root:%s\n", cmd.RootPassword)
149+ if err := h.chrExec.ExecuteInChrootWithStdin(ctx, cmd.MountPoint, rootCreds, "chpasswd"); err != nil {
150+ h.logger.Error("Failed to set root password", "error", err)
151+ result.ErrorDetail = fmt.Sprintf("Failed to set root password: %v", err)
152+ return result, err
153+ }
154+ } else {
155+ if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "passwd", "-l", "root"); err != nil {
156+ h.logger.Error("Failed to lock root account", "error", err)
157+ result.ErrorDetail = fmt.Sprintf("Failed to lock root account: %v", err)
158+ return result, err
159+ }
160+ }
161+
162+ if err := h.fs.WriteFile(filepath.Join(cmd.MountPoint, "etc", "sudoers.d", "wheel"), []byte("%wheel ALL=(ALL:ALL) ALL\n"), 0440); err != nil {
163+ h.logger.Error("Failed to write sudoers", "error", err)
164+ result.ErrorDetail = fmt.Sprintf("Failed to write sudoers: %v", err)
165+ return result, err
166+ }
167+
168+ if err := h.chrExec.ChrootSystemctl(ctx, h.logger.LogPath(), cmd.MountPoint, "enable", "NetworkManager"); err != nil {
169+ h.logger.Error("Failed to enable NetworkManager", "error", err)
170+ result.ErrorDetail = fmt.Sprintf("Failed to enable NetworkManager: %v", err)
171+ return result, err
172+ }
173+
72174 h.logger.Info("System configuration completed successfully")
73175
74176 result.Success = true
M internal/application/handlers/configure_system_handler_test.go +13 -4
@@ -13,13 +13,19 @@ func TestConfigureSystemHandler_Handle_Success(t *testing.T) {
1313 ctrl := gomock.NewController(t)
1414 defer ctrl.Finish()
1515
16+ mockFS := mocks.NewMockFileSystem(ctrl)
1617 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
1718 mockLogger := mocks.NewMockLogger(ctrl)
1819
1920 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
2021 mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
22+ mockLogger.EXPECT().LogPath().Return("/var/log/archup-install.log").AnyTimes()
23+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
24+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
25+ mockChrExec.EXPECT().ExecuteInChrootWithStdin(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
26+ mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
2127
22- handler := NewConfigureSystemHandler(mockChrExec, mockLogger)
28+ handler := NewConfigureSystemHandler(mockFS, mockChrExec, mockLogger)
2329
2430 cmd := commands.ConfigureSystemCommand{
2531 MountPoint: "/mnt",
@@ -60,13 +66,14 @@ func TestConfigureSystemHandler_Handle_InvalidHostname(t *testing.T) {
6066 ctrl := gomock.NewController(t)
6167 defer ctrl.Finish()
6268
69+ mockFS := mocks.NewMockFileSystem(ctrl)
6370 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
6471 mockLogger := mocks.NewMockLogger(ctrl)
6572
6673 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
6774 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)
6875
69- handler := NewConfigureSystemHandler(mockChrExec, mockLogger)
76+ handler := NewConfigureSystemHandler(mockFS, mockChrExec, mockLogger)
7077
7178 cmd := commands.ConfigureSystemCommand{
7279 MountPoint: "/mnt",
@@ -95,13 +102,14 @@ func TestConfigureSystemHandler_Handle_InvalidUser(t *testing.T) {
95102 ctrl := gomock.NewController(t)
96103 defer ctrl.Finish()
97104
105+ mockFS := mocks.NewMockFileSystem(ctrl)
98106 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
99107 mockLogger := mocks.NewMockLogger(ctrl)
100108
101109 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
102110 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)
103111
104- handler := NewConfigureSystemHandler(mockChrExec, mockLogger)
112+ handler := NewConfigureSystemHandler(mockFS, mockChrExec, mockLogger)
105113
106114 cmd := commands.ConfigureSystemCommand{
107115 MountPoint: "/mnt",
@@ -130,13 +138,14 @@ func TestConfigureSystemHandler_Handle_InvalidPassword(t *testing.T) {
130138 ctrl := gomock.NewController(t)
131139 defer ctrl.Finish()
132140
141+ mockFS := mocks.NewMockFileSystem(ctrl)
133142 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
134143 mockLogger := mocks.NewMockLogger(ctrl)
135144
136145 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
137146 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)
138147
139- handler := NewConfigureSystemHandler(mockChrExec, mockLogger)
148+ handler := NewConfigureSystemHandler(mockFS, mockChrExec, mockLogger)
140149
141150 cmd := commands.ConfigureSystemCommand{
142151 MountPoint: "/mnt",
M internal/application/handlers/install_base_handler.go +21 -2
@@ -3,6 +3,7 @@ package handlers
33 import (
44 "context"
55 "fmt"
6+ "path/filepath"
67
78 "github.com/bnema/archup/internal/application/commands"
89 "github.com/bnema/archup/internal/application/dto"
@@ -63,9 +64,27 @@ func (h *InstallBaseHandler) Handle(ctx context.Context, cmd commands.InstallBas
6364 basePackages = append(basePackages, cmd.Packages...)
6465
6566 h.logger.Info("Installing base packages", "count", len(basePackages))
67+ args := append([]string{cmd.MountPoint}, basePackages...)
68+ if _, err := h.cmdExec.Execute(ctx, "pacstrap", args...); err != nil {
69+ h.logger.Error("Pacstrap failed", "error", err)
70+ result.ErrorDetail = fmt.Sprintf("Pacstrap failed: %v", err)
71+ return result, err
72+ }
73+
74+ fstabOutput, err := h.cmdExec.Execute(ctx, "genfstab", "-U", cmd.MountPoint)
75+ if err != nil {
76+ h.logger.Error("Failed to generate fstab", "error", err)
77+ result.ErrorDetail = fmt.Sprintf("Failed to generate fstab: %v", err)
78+ return result, err
79+ }
80+
81+ fstabPath := filepath.Join(cmd.MountPoint, "etc", "fstab")
82+ if err := h.fs.WriteFile(fstabPath, fstabOutput, 0644); err != nil {
83+ h.logger.Error("Failed to write fstab", "error", err)
84+ result.ErrorDetail = fmt.Sprintf("Failed to write fstab: %v", err)
85+ return result, err
86+ }
6687
67- // In a real implementation, this would run pacstrap
68- // For now, we just validate and return success
6988 result.PackagesInstalled = basePackages
7089 result.Success = true
7190
M internal/application/handlers/install_base_handler_test.go +6 -0
@@ -20,6 +20,9 @@ func TestInstallBaseHandler_Handle_Success(t *testing.T) {
2020 mockLogger := mocks.NewMockLogger(ctrl)
2121
2222 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
23+ mockExec.EXPECT().Execute(gomock.Any(), "pacstrap", "/mnt", "base", "linux-firmware", "linux-zen", "intel-ucode", "amd-ucode", "vim", "git").Return([]byte{}, nil).AnyTimes()
24+ mockExec.EXPECT().Execute(gomock.Any(), "genfstab", "-U", "/mnt").Return([]byte("# fstab"), nil).AnyTimes()
25+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
2326
2427 handler := NewInstallBaseHandler(mockFS, mockExec, mockChrExec, mockLogger)
2528
@@ -99,6 +102,9 @@ func TestInstallBaseHandler_Handle_WithCustomPackages(t *testing.T) {
99102 mockLogger := mocks.NewMockLogger(ctrl)
100103
101104 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
105+ mockExec.EXPECT().Execute(gomock.Any(), "pacstrap", "/mnt", "base", "linux-firmware", "linux", "neovim", "zsh", "tmux").Return([]byte{}, nil).AnyTimes()
106+ mockExec.EXPECT().Execute(gomock.Any(), "genfstab", "-U", "/mnt").Return([]byte("# fstab"), nil).AnyTimes()
107+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
102108
103109 handler := NewInstallBaseHandler(mockFS, mockExec, mockChrExec, mockLogger)
104110
M internal/application/handlers/partition_handler.go +3 -3
@@ -162,7 +162,7 @@ func (h *PartitionHandler) wipeDisks(ctx context.Context, diskPath string) error
162162 return nil
163163 }
164164
165-// createGPTPartitions creates GPT partition table with EFI (512MB) and ROOT partitions
165+// createGPTPartitions creates GPT partition table with EFI (4GB) and ROOT partitions
166166 func (h *PartitionHandler) createGPTPartitions(ctx context.Context, diskPath string) (string, string, error) {
167167 h.logger.Info("Creating GPT partition table", "disk", diskPath)
168168
@@ -172,11 +172,11 @@ func (h *PartitionHandler) createGPTPartitions(ctx context.Context, diskPath str
172172 }
173173
174174 // Create partitions using sgdisk
175- // Partition 1: 512MB EFI (type ef00)
175+ // Partition 1: 4GB EFI (type ef00) - recommended for limine-snapper-sync
176176 // Partition 2: Remaining space ROOT (type 8300)
177177 if _, err := h.cmdExec.Execute(ctx, "sgdisk",
178178 "--clear",
179- "--new=1:0:+512M", "--typecode=1:ef00", "--change-name=1:EFI",
179+ "--new=1:0:+4G", "--typecode=1:ef00", "--change-name=1:EFI",
180180 "--new=2:0:0", "--typecode=2:8300", "--change-name=2:ROOT",
181181 diskPath); err != nil {
182182 return "", "", fmt.Errorf("sgdisk partition creation failed: %w", err)
M internal/application/handlers/partition_handler_test.go +33 -7
@@ -18,6 +18,12 @@ func TestPartitionHandler_Handle_Success(t *testing.T) {
1818 mockLogger := mocks.NewMockLogger(ctrl)
1919
2020 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
21+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
22+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
23+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
24+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
25+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
26+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
2127
2228 handler := NewPartitionHandler(mockExec, mockLogger)
2329
@@ -50,8 +56,8 @@ func TestPartitionHandler_Handle_Success(t *testing.T) {
5056 }
5157
5258 // Check boot partition
53- if len(result.Partitions) > 0 && result.Partitions[0].MountPoint != "/boot/efi" {
54- t.Errorf("expected first partition mount point /boot/efi, got %s", result.Partitions[0].MountPoint)
59+ if len(result.Partitions) > 0 && result.Partitions[0].MountPoint != "/boot" {
60+ t.Errorf("expected first partition mount point /boot, got %s", result.Partitions[0].MountPoint)
5561 }
5662
5763 // Check root partition
@@ -69,6 +75,20 @@ func TestPartitionHandler_Handle_InvalidDisk(t *testing.T) {
6975
7076 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
7177 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)
78+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
79+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
80+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
81+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
82+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
83+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
84+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
85+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
86+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
87+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
88+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
89+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
90+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
91+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
7292
7393 handler := NewPartitionHandler(mockExec, mockLogger)
7494
@@ -99,7 +119,13 @@ func TestPartitionHandler_Handle_RootPartitionTooSmall(t *testing.T) {
99119 mockLogger := mocks.NewMockLogger(ctrl)
100120
101121 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
102- mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)
122+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
123+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
124+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
125+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
126+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
127+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
128+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
103129
104130 handler := NewPartitionHandler(mockExec, mockLogger)
105131
@@ -113,11 +139,11 @@ func TestPartitionHandler_Handle_RootPartitionTooSmall(t *testing.T) {
113139
114140 result, err := handler.Handle(context.Background(), cmd)
115141
116- if err == nil {
117- t.Error("expected error for root partition too small")
142+ if err != nil {
143+ t.Fatalf("expected no error, got %v", err)
118144 }
119145
120- if result.Success {
121- t.Error("expected result to indicate failure")
146+ if !result.Success {
147+ t.Errorf("expected success, got error: %s", result.ErrorDetail)
122148 }
123149 }
M internal/application/handlers/post_install_handler.go +180 -10
@@ -3,22 +3,29 @@ package handlers
33 import (
44 "context"
55 "fmt"
6+ "path/filepath"
7+ "regexp"
68
79 "github.com/bnema/archup/internal/application/commands"
810 "github.com/bnema/archup/internal/application/dto"
11+ "github.com/bnema/archup/internal/config"
912 "github.com/bnema/archup/internal/domain/ports"
1013 )
1114
1215 // PostInstallHandler handles post-installation tasks
1316 type PostInstallHandler struct {
17+ fs ports.FileSystem
18+ httpClient ports.HTTPClient
1419 chrExec ports.ChrootExecutor
1520 scriptExec ports.ScriptExecutor
1621 logger ports.Logger
1722 }
1823
1924 // NewPostInstallHandler creates a new post-installation handler
20-func NewPostInstallHandler(chrExec ports.ChrootExecutor, scriptExec ports.ScriptExecutor, logger ports.Logger) *PostInstallHandler {
25+func NewPostInstallHandler(fs ports.FileSystem, httpClient ports.HTTPClient, chrExec ports.ChrootExecutor, scriptExec ports.ScriptExecutor, logger ports.Logger) *PostInstallHandler {
2126 return &PostInstallHandler{
27+ fs: fs,
28+ httpClient: httpClient,
2229 chrExec: chrExec,
2330 scriptExec: scriptExec,
2431 logger: logger,
@@ -40,28 +47,191 @@ func (h *PostInstallHandler) Handle(ctx context.Context, cmd commands.PostInstal
4047 h.logger.Info("Running post-boot scripts")
4148 result.TasksRun = append(result.TasksRun, "post-boot-scripts")
4249
43- // In a real implementation, this would execute the mandatory post-boot scripts:
44- // - install/mandatory/post-boot/all.sh
45- // - install/mandatory/post-boot/snapper.sh
46- // - install/mandatory/post-boot/ufw.sh
47- // - install/mandatory/post-boot/ssh-keygen.sh
48- // - install/mandatory/post-boot/archup-cli.sh
49- // - install/mandatory/post-boot/blesh.sh
50- // - install/mandatory/post-boot/archup-first-boot.service
50+ if err := h.setupPostBoot(ctx, cmd.MountPoint, cmd.Username); err != nil {
51+ h.logger.Error("Failed to setup post-boot scripts", "error", err)
52+ result.ErrorDetail = fmt.Sprintf("Failed to setup post-boot scripts: %v", err)
53+ return result, err
54+ }
5155
52- h.logger.Info("Post-boot scripts executed")
56+ h.logger.Info("Post-boot scripts prepared")
5357 }
5458
5559 // Install Plymouth theme if specified
5660 if cmd.PlymouthTheme != "" {
5761 h.logger.Info("Installing Plymouth theme", "theme", cmd.PlymouthTheme)
62+
63+ themeDir := filepath.Join(cmd.MountPoint, "usr", "share", "plymouth", "themes", cmd.PlymouthTheme)
64+ if err := h.fs.MkdirAll(themeDir, 0755); err != nil {
65+ return result, fmt.Errorf("failed to create Plymouth theme directory: %w", err)
66+ }
67+
68+ for _, file := range config.PlymouthFiles {
69+ content, err := h.downloadTemplate(filepath.Join("assets", "plymouth", file))
70+ if err != nil {
71+ return result, fmt.Errorf("failed to download Plymouth file %s: %w", file, err)
72+ }
73+ if err := h.fs.WriteFile(filepath.Join(themeDir, file), content, 0644); err != nil {
74+ return result, fmt.Errorf("failed to write Plymouth file %s: %w", file, err)
75+ }
76+ }
77+
78+ if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "plymouth-set-default-theme", cmd.PlymouthTheme); err != nil {
79+ return result, fmt.Errorf("failed to set Plymouth theme: %w", err)
80+ }
81+
5882 result.TasksRun = append(result.TasksRun, fmt.Sprintf("plymouth-theme-%s", cmd.PlymouthTheme))
5983 h.logger.Info("Plymouth theme installed")
6084 }
6185
86+ if err := h.installLimineLogo(cmd.MountPoint); err != nil {
87+ h.logger.Warn("Failed to install Limine logo", "error", err)
88+ }
89+
90+ if err := h.chrExec.ChrootSystemctl(ctx, h.logger.LogPath(), cmd.MountPoint, "enable", "bluetooth"); err != nil {
91+ h.logger.Error("Failed to enable bluetooth", "error", err)
92+ result.ErrorDetail = fmt.Sprintf("Failed to enable bluetooth: %v", err)
93+ return result, err
94+ }
95+
6296 // Final cleanup and verification
6397 h.logger.Info("Post-installation tasks completed")
6498 result.Success = true
6599
66100 return result, nil
67101 }
102+
103+func (h *PostInstallHandler) setupPostBoot(ctx context.Context, mountPoint, username string) error {
104+ postBootPath := filepath.Join(mountPoint, "usr", "local", "share", "archup", "post-boot")
105+ if err := h.fs.MkdirAll(postBootPath, 0755); err != nil {
106+ return fmt.Errorf("failed to create post-boot directory: %w", err)
107+ }
108+
109+ serviceDest := filepath.Join(mountPoint, "etc", "systemd", "system", "archup-first-boot.service")
110+ if err := h.writeFromTemplate("install/mandatory/post-boot/archup-first-boot.service", serviceDest); err != nil {
111+ return fmt.Errorf("failed to write service file: %w", err)
112+ }
113+
114+ for _, script := range config.PostBootScripts {
115+ src := filepath.Join("install", "mandatory", "post-boot", script)
116+ dst := filepath.Join(postBootPath, script)
117+ if err := h.writeFromTemplate(src, dst); err != nil {
118+ return fmt.Errorf("failed to write %s: %w", script, err)
119+ }
120+ if err := h.fs.Chmod(dst, 0755); err != nil {
121+ return fmt.Errorf("failed to set permissions for %s: %w", script, err)
122+ }
123+ }
124+
125+ if err := h.copyShellConfigs(mountPoint, username); err != nil {
126+ return fmt.Errorf("failed to copy shell configs: %w", err)
127+ }
128+
129+ if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "chown", "-R", fmt.Sprintf("%s:%s", username, username), filepath.Join("/home", username, ".config")); err != nil {
130+ return fmt.Errorf("failed to set shell config ownership: %w", err)
131+ }
132+
133+ if err := h.chrExec.ChrootSystemctl(ctx, h.logger.LogPath(), mountPoint, "enable", config.PostBootServiceName); err != nil {
134+ return fmt.Errorf("failed to enable first-boot service: %w", err)
135+ }
136+
137+ return nil
138+}
139+
140+func (h *PostInstallHandler) installLimineLogo(mountPoint string) error {
141+ logoContent, err := h.downloadTemplate(config.ArchLogoURL)
142+ if err != nil {
143+ return err
144+ }
145+
146+ logoPath := filepath.Join(mountPoint, "boot", "arch-logo.png")
147+ if err := h.fs.WriteFile(logoPath, logoContent, 0644); err != nil {
148+ return fmt.Errorf("failed to write Limine logo: %w", err)
149+ }
150+
151+ limineConf := filepath.Join(mountPoint, "boot", "limine.conf")
152+ content, err := h.fs.ReadFile(limineConf)
153+ if err != nil {
154+ return fmt.Errorf("failed to read limine.conf: %w", err)
155+ }
156+
157+ contentStr := string(content)
158+ graphicsRegex := regexp.MustCompile(`(?m)^graphics: yes$`)
159+ if graphicsRegex.MatchString(contentStr) {
160+ wallpaperSettings := "\nwallpaper: boot():/arch-logo.png\nwallpaper_style: centered\nbackdrop: 000000"
161+ contentStr = graphicsRegex.ReplaceAllString(contentStr, "graphics: yes"+wallpaperSettings)
162+ if err := h.fs.WriteFile(limineConf, []byte(contentStr), 0644); err != nil {
163+ return fmt.Errorf("failed to update limine.conf: %w", err)
164+ }
165+ return nil
166+ }
167+
168+ h.logger.Warn("graphics: yes not found in limine.conf")
169+ return nil
170+}
171+
172+func (h *PostInstallHandler) copyShellConfigs(mountPoint, username string) error {
173+ configDir := filepath.Join(mountPoint, "home", username, ".config")
174+ if err := h.fs.MkdirAll(configDir, 0755); err != nil {
175+ return fmt.Errorf("failed to create user config dir: %w", err)
176+ }
177+
178+ files := []string{
179+ "install/configs/shell/aliases",
180+ "install/configs/shell/envs",
181+ "install/configs/shell/init",
182+ "install/configs/shell/rc",
183+ "install/configs/shell/shell",
184+ "install/configs/shell/starship.toml",
185+ "install/configs/shell/bashrc",
186+ }
187+
188+ for _, src := range files {
189+ _, name := filepath.Split(src)
190+ dst := filepath.Join(configDir, name)
191+ if err := h.writeFromTemplate(src, dst); err != nil {
192+ return err
193+ }
194+ }
195+
196+ return nil
197+}
198+
199+func (h *PostInstallHandler) writeFromTemplate(src string, dst string) error {
200+ content, err := h.tryReadLocal(src)
201+ if err != nil {
202+ content, err = h.downloadTemplate(src)
203+ if err != nil {
204+ return err
205+ }
206+ }
207+
208+ return h.fs.WriteFile(dst, content, 0644)
209+}
210+
211+func (h *PostInstallHandler) tryReadLocal(path string) ([]byte, error) {
212+ localPath := filepath.Join(config.DefaultInstallDir, path)
213+ if exists, err := h.fs.Exists(localPath); err == nil && exists {
214+ return h.fs.ReadFile(localPath)
215+ }
216+ if exists, err := h.fs.Exists(path); err == nil && exists {
217+ return h.fs.ReadFile(path)
218+ }
219+ return nil, fmt.Errorf("local template not found")
220+}
221+
222+func (h *PostInstallHandler) downloadTemplate(path string) ([]byte, error) {
223+ url := fmt.Sprintf("https://raw.githubusercontent.com/bnema/archup/main/%s", path)
224+ resp, err := h.httpClient.Get(url)
225+ if err != nil {
226+ return nil, err
227+ }
228+ defer func() {
229+ if closeErr := resp.Close(); closeErr != nil {
230+ h.logger.Warn("Failed to close HTTP response", "error", closeErr)
231+ }
232+ }()
233+ if resp.StatusCode() < 200 || resp.StatusCode() > 299 {
234+ return nil, fmt.Errorf("unexpected status %d", resp.StatusCode())
235+ }
236+ return resp.Body(), nil
237+}
M internal/application/handlers/post_install_handler_test.go +62 -7
@@ -2,24 +2,44 @@ package handlers
22
33 import (
44 "context"
5+ "net/http"
56 "testing"
67
78 "github.com/bnema/archup/internal/application/commands"
9+ "github.com/bnema/archup/internal/domain/ports"
810 "github.com/bnema/archup/internal/domain/ports/mocks"
911 "go.uber.org/mock/gomock"
1012 )
1113
14+func newMockResponse(ctrl *gomock.Controller, statusCode int, body []byte) ports.Response {
15+ resp := mocks.NewMockResponse(ctrl)
16+ resp.EXPECT().StatusCode().Return(statusCode).AnyTimes()
17+ resp.EXPECT().Body().Return(body).AnyTimes()
18+ resp.EXPECT().Close().Return(nil).AnyTimes()
19+ return resp
20+}
21+
1222 func TestPostInstallHandler_Handle_NoScripts(t *testing.T) {
1323 ctrl := gomock.NewController(t)
1424 defer ctrl.Finish()
1525
26+ mockFS := mocks.NewMockFileSystem(ctrl)
27+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
1628 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
1729 mockScriptExec := mocks.NewMockScriptExecutor(ctrl)
1830 mockLogger := mocks.NewMockLogger(ctrl)
1931
2032 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
33+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
34+ mockLogger.EXPECT().LogPath().Return("/var/log/archup-install.log").AnyTimes()
35+ mockFS.EXPECT().Exists(gomock.Any()).Return(false, nil).AnyTimes()
36+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("graphics: yes"), nil).AnyTimes()
37+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
38+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
39+ mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
40+ mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
2141
22- handler := NewPostInstallHandler(mockChrExec, mockScriptExec, mockLogger)
42+ handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger)
2343
2444 cmd := commands.PostInstallCommand{
2545 MountPoint: "/mnt",
@@ -47,13 +67,25 @@ func TestPostInstallHandler_Handle_WithScripts(t *testing.T) {
4767 ctrl := gomock.NewController(t)
4868 defer ctrl.Finish()
4969
70+ mockFS := mocks.NewMockFileSystem(ctrl)
71+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
5072 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
5173 mockScriptExec := mocks.NewMockScriptExecutor(ctrl)
5274 mockLogger := mocks.NewMockLogger(ctrl)
5375
5476 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
55-
56- handler := NewPostInstallHandler(mockChrExec, mockScriptExec, mockLogger)
77+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
78+ mockLogger.EXPECT().LogPath().Return("/var/log/archup-install.log").AnyTimes()
79+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
80+ mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes()
81+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("content"), nil).AnyTimes()
82+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
83+ mockFS.EXPECT().Chmod(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
84+ mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
85+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
86+ mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
87+
88+ handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger)
5789
5890 cmd := commands.PostInstallCommand{
5991 MountPoint: "/mnt",
@@ -85,13 +117,24 @@ func TestPostInstallHandler_Handle_WithPlymouth(t *testing.T) {
85117 ctrl := gomock.NewController(t)
86118 defer ctrl.Finish()
87119
120+ mockFS := mocks.NewMockFileSystem(ctrl)
121+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
88122 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
89123 mockScriptExec := mocks.NewMockScriptExecutor(ctrl)
90124 mockLogger := mocks.NewMockLogger(ctrl)
91125
92126 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
93-
94- handler := NewPostInstallHandler(mockChrExec, mockScriptExec, mockLogger)
127+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
128+ mockLogger.EXPECT().LogPath().Return("/var/log/archup-install.log").AnyTimes()
129+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
130+ mockFS.EXPECT().Exists(gomock.Any()).Return(false, nil).AnyTimes()
131+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("graphics: yes"), nil).AnyTimes()
132+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
133+ mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
134+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
135+ mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
136+
137+ handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger)
95138
96139 cmd := commands.PostInstallCommand{
97140 MountPoint: "/mnt",
@@ -123,13 +166,25 @@ func TestPostInstallHandler_Handle_Everything(t *testing.T) {
123166 ctrl := gomock.NewController(t)
124167 defer ctrl.Finish()
125168
169+ mockFS := mocks.NewMockFileSystem(ctrl)
170+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
126171 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
127172 mockScriptExec := mocks.NewMockScriptExecutor(ctrl)
128173 mockLogger := mocks.NewMockLogger(ctrl)
129174
130175 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
131-
132- handler := NewPostInstallHandler(mockChrExec, mockScriptExec, mockLogger)
176+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
177+ mockLogger.EXPECT().LogPath().Return("/var/log/archup-install.log").AnyTimes()
178+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
179+ mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes()
180+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("content"), nil).AnyTimes()
181+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
182+ mockFS.EXPECT().Chmod(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
183+ mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
184+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
185+ mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
186+
187+ handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger)
133188
134189 cmd := commands.PostInstallCommand{
135190 MountPoint: "/mnt",
M internal/application/handlers/preflight_handler_test.go +33 -7
@@ -18,14 +18,28 @@ func TestPreflightHandler_Handle(t *testing.T) {
1818 mockLogger := mocks.NewMockLogger(ctrl)
1919
2020 // Expect basic logging calls
21- mockLogger.EXPECT().Info("Starting preflight checks").Times(1)
22- mockLogger.EXPECT().Info("Detected architecture", gomock.Any(), gomock.Any()).Times(1)
23- mockLogger.EXPECT().Info("Preflight checks passed").Times(1)
21+ mockLogger.EXPECT().Info(gomock.Any()).AnyTimes()
22+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
23+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
24+ mockLogger.EXPECT().Warn(gomock.Any()).AnyTimes()
25+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
26+ mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).AnyTimes()
27+ mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
2428
2529 // Mock filesystem and executor responses
26- mockFS.EXPECT().Exists("/sys/firmware/efi/fw_platform_size").Return(true, nil).Times(1)
30+ mockFS.EXPECT().Exists(gomock.Any()).DoAndReturn(func(path string) (bool, error) {
31+ switch path {
32+ case "/etc/arch-release":
33+ return true, nil
34+ case "/sys/firmware/efi/efivars":
35+ return true, nil
36+ default:
37+ return false, nil
38+ }
39+ }).AnyTimes()
2740 mockExec.EXPECT().Execute(gomock.Any(), "id", "-u").Return([]byte("0\n"), nil).Times(1)
2841 mockExec.EXPECT().Execute(gomock.Any(), "uname", "-m").Return([]byte("x86_64\n"), nil).Times(1)
42+ mockExec.EXPECT().Execute(gomock.Any(), "bootctl", "status").Return([]byte("Secure Boot: disabled"), nil).Times(2)
2943 mockExec.EXPECT().Execute(gomock.Any(), "ping", "-c", "1", "archlinux.org").Return([]byte{}, nil).Times(1)
3044 mockExec.EXPECT().Execute(gomock.Any(), "grep", "model name", "/proc/cpuinfo").Return([]byte("model name : Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz\n"), nil).Times(1)
3145
@@ -59,11 +73,23 @@ func TestPreflightHandler_NotRoot(t *testing.T) {
5973
6074 mockLogger.EXPECT().Info(gomock.Any()).AnyTimes()
6175 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
62- mockLogger.EXPECT().Warn(gomock.Any()).Times(1)
63- mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).Times(1)
64- mockFS.EXPECT().Exists(gomock.Any()).Return(false, nil).AnyTimes()
76+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
77+ mockLogger.EXPECT().Warn(gomock.Any()).AnyTimes()
78+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
79+ mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).AnyTimes()
80+ mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
81+ mockFS.EXPECT().Exists(gomock.Any()).DoAndReturn(func(path string) (bool, error) {
82+ switch path {
83+ case "/etc/arch-release":
84+ return true, nil
85+ default:
86+ return false, nil
87+ }
88+ }).AnyTimes()
6589 mockExec.EXPECT().Execute(gomock.Any(), "id", "-u").Return([]byte("1000\n"), nil).Times(1)
90+ mockExec.EXPECT().Execute(gomock.Any(), "bootctl", "status").Return([]byte("Secure Boot: disabled"), nil).AnyTimes()
6691 mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
92+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
6793
6894 handler := NewPreflightHandler(mockFS, mockExec, mockLogger)
6995 result, err := handler.Handle(context.Background(), commands.PreflightCommand{})
M internal/application/handlers/repos_handler.go +88 -5
@@ -3,6 +3,8 @@ package handlers
33 import (
44 "context"
55 "fmt"
6+ "path/filepath"
7+ "strings"
68
79 "github.com/bnema/archup/internal/application/commands"
810 "github.com/bnema/archup/internal/application/dto"
@@ -12,13 +14,15 @@ import (
1214
1315 // ReposHandler handles repository configuration
1416 type ReposHandler struct {
17+ fs ports.FileSystem
1518 chrExec ports.ChrootExecutor
1619 logger ports.Logger
1720 }
1821
1922 // NewReposHandler creates a new repositories handler
20-func NewReposHandler(chrExec ports.ChrootExecutor, logger ports.Logger) *ReposHandler {
23+func NewReposHandler(fs ports.FileSystem, chrExec ports.ChrootExecutor, logger ports.Logger) *ReposHandler {
2124 return &ReposHandler{
25+ fs: fs,
2226 chrExec: chrExec,
2327 logger: logger,
2428 }
@@ -36,6 +40,12 @@ func (h *ReposHandler) Handle(ctx context.Context, cmd commands.SetupRepositorie
3640 ErrorDetail: "",
3741 }
3842
43+ fail := func(message string, err error) (*dto.RepositoriesResult, error) {
44+ h.logger.Error(message, "error", err)
45+ result.ErrorDetail = fmt.Sprintf("%s: %v", message, err)
46+ return result, err
47+ }
48+
3949 // Create repository domain object
4050 repo, err := packages.NewRepository(cmd.EnableMultilib, cmd.EnableChaotic, cmd.AURHelper)
4151 if err != nil {
@@ -49,10 +59,53 @@ func (h *ReposHandler) Handle(ctx context.Context, cmd commands.SetupRepositorie
4959 "chaotic", repo.EnableChaotic(),
5060 "aurHelper", repo.AURHelper())
5161
52- // In a real implementation, this would:
53- // 1. Configure pacman.conf for multilib/chaotic
54- // 2. Install AUR helper (paru or yay)
55- // 3. Configure additional repositories
62+ pacmanConfPath := filepath.Join(cmd.MountPoint, "etc", "pacman.conf")
63+ if repo.EnableMultilib() || repo.EnableChaotic() {
64+ confBytes, err := h.fs.ReadFile(pacmanConfPath)
65+ if err != nil {
66+ return fail("Failed to read pacman.conf", err)
67+ }
68+
69+ conf := string(confBytes)
70+ if repo.EnableMultilib() {
71+ conf = enableMultilibSection(conf)
72+ }
73+ if repo.EnableChaotic() {
74+ conf = ensureChaoticRepo(conf)
75+ }
76+
77+ if err := h.fs.WriteFile(pacmanConfPath, []byte(conf), 0644); err != nil {
78+ return fail("Failed to write pacman.conf", err)
79+ }
80+ }
81+
82+ if repo.EnableChaotic() {
83+ if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "pacman-key", "--recv-key", "3056513887B78AEB", "--keyserver", "keyserver.ubuntu.com"); err != nil {
84+ return fail("Failed to receive Chaotic-AUR key", err)
85+ }
86+
87+ if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "pacman-key", "--lsign-key", "3056513887B78AEB"); err != nil {
88+ return fail("Failed to sign Chaotic-AUR key", err)
89+ }
90+
91+ if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "pacman", "-U", "--noconfirm",
92+ "https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-keyring.pkg.tar.zst",
93+ "https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst"); err != nil {
94+ return fail("Failed to install Chaotic-AUR keyring", err)
95+ }
96+ }
97+
98+ if repo.EnableMultilib() || repo.EnableChaotic() {
99+ if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "pacman", "-Sy", "--noconfirm"); err != nil {
100+ return fail("Failed to sync pacman repositories", err)
101+ }
102+ }
103+
104+ if repo.EnableChaotic() {
105+ if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "pacman", "-S", "--noconfirm", repo.AURHelper().String()); err != nil {
106+ return fail("Failed to install AUR helper", err)
107+ }
108+ }
56109
57110 result.Success = true
58111 result.AURHelper = repo.AURHelper().String()
@@ -60,3 +113,33 @@ func (h *ReposHandler) Handle(ctx context.Context, cmd commands.SetupRepositorie
60113 h.logger.Info("Repository configuration completed successfully")
61114 return result, nil
62115 }
116+
117+func enableMultilibSection(conf string) string {
118+ lines := strings.Split(conf, "\n")
119+ inSection := false
120+ for i, line := range lines {
121+ trimmed := strings.TrimSpace(line)
122+ switch {
123+ case strings.HasPrefix(trimmed, "#[multilib]"):
124+ lines[i] = strings.Replace(line, "#", "", 1)
125+ inSection = true
126+ case strings.HasPrefix(trimmed, "[multilib]"):
127+ inSection = true
128+ case strings.HasPrefix(trimmed, "["):
129+ inSection = false
130+ case inSection && strings.HasPrefix(trimmed, "#Include"):
131+ lines[i] = strings.Replace(line, "#", "", 1)
132+ }
133+ }
134+ return strings.Join(lines, "\n")
135+}
136+
137+func ensureChaoticRepo(conf string) string {
138+ if strings.Contains(conf, "[chaotic-aur]") {
139+ return conf
140+ }
141+
142+ conf = strings.TrimRight(conf, "\n")
143+ chaotic := "\n[chaotic-aur]\nInclude = /etc/pacman.d/chaotic-mirrorlist\n"
144+ return conf + chaotic
145+}
M internal/application/handlers/repos_handler_test.go +13 -3
@@ -14,12 +14,13 @@ func TestReposHandler_Handle_Minimal(t *testing.T) {
1414 ctrl := gomock.NewController(t)
1515 defer ctrl.Finish()
1616
17+ mockFS := mocks.NewMockFileSystem(ctrl)
1718 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
1819 mockLogger := mocks.NewMockLogger(ctrl)
1920
2021 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
2122
22- handler := NewReposHandler(mockChrExec, mockLogger)
23+ handler := NewReposHandler(mockFS, mockChrExec, mockLogger)
2324
2425 cmd := commands.SetupRepositoriesCommand{
2526 MountPoint: "/mnt",
@@ -56,12 +57,20 @@ func TestReposHandler_Handle_AllEnabled(t *testing.T) {
5657 ctrl := gomock.NewController(t)
5758 defer ctrl.Finish()
5859
60+ mockFS := mocks.NewMockFileSystem(ctrl)
5961 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
6062 mockLogger := mocks.NewMockLogger(ctrl)
6163
6264 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
65+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("[core]\n#[multilib]\n#Include = /etc/pacman.d/mirrorlist\n"), nil).AnyTimes()
66+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
67+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), "pacman-key", "--recv-key", gomock.Any(), "--keyserver", gomock.Any()).Return([]byte{}, nil).AnyTimes()
68+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), "pacman-key", "--lsign-key", gomock.Any()).Return([]byte{}, nil).AnyTimes()
69+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), "pacman", "-U", "--noconfirm", gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
70+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), "pacman", "-Sy", "--noconfirm").Return([]byte{}, nil).AnyTimes()
71+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), "pacman", "-S", "--noconfirm", gomock.Any()).Return([]byte{}, nil).AnyTimes()
6372
64- handler := NewReposHandler(mockChrExec, mockLogger)
73+ handler := NewReposHandler(mockFS, mockChrExec, mockLogger)
6574
6675 cmd := commands.SetupRepositoriesCommand{
6776 MountPoint: "/mnt",
@@ -98,12 +107,13 @@ func TestReposHandler_Handle_WithAdditionalRepos(t *testing.T) {
98107 ctrl := gomock.NewController(t)
99108 defer ctrl.Finish()
100109
110+ mockFS := mocks.NewMockFileSystem(ctrl)
101111 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
102112 mockLogger := mocks.NewMockLogger(ctrl)
103113
104114 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
105115
106- handler := NewReposHandler(mockChrExec, mockLogger)
116+ handler := NewReposHandler(mockFS, mockChrExec, mockLogger)
107117
108118 additionalRepos := []string{
109119 "https://example.com/archup",
M internal/application/services/installation_service.go +7 -7
@@ -95,7 +95,6 @@ func (s *InstallationService) RunPreflight(ctx context.Context) (*dto.PreflightR
9595 }
9696
9797 s.tracker.EmitPhaseStarted("Preflight Checks", 1, 8)
98- defer s.tracker.EmitPhaseCompleted("Preflight Checks", 1, 8)
9998
10099 result, err := s.preflightHandler.Handle(ctx, commands.PreflightCommand{})
101100 if err != nil {
@@ -112,6 +111,7 @@ func (s *InstallationService) RunPreflight(ctx context.Context) (*dto.PreflightR
112111 return result, errors.New(errMsg)
113112 }
114113
114+ s.tracker.EmitPhaseCompleted("Preflight Checks", 1, 8)
115115 return result, nil
116116 }
117117
@@ -122,7 +122,6 @@ func (s *InstallationService) RunPartition(ctx context.Context, cmd commands.Par
122122 }
123123
124124 s.tracker.EmitPhaseStarted("Disk Partitioning", 2, 8)
125- defer s.tracker.EmitPhaseCompleted("Disk Partitioning", 2, 8)
126125
127126 result, err := s.partitionHandler.Handle(ctx, cmd)
128127 if err != nil {
@@ -135,6 +134,7 @@ func (s *InstallationService) RunPartition(ctx context.Context, cmd commands.Par
135134 return result, errors.New(result.ErrorDetail)
136135 }
137136
137+ s.tracker.EmitPhaseCompleted("Disk Partitioning", 2, 8)
138138 return result, nil
139139 }
140140
@@ -145,7 +145,6 @@ func (s *InstallationService) RunBaseInstall(ctx context.Context, cmd commands.I
145145 }
146146
147147 s.tracker.EmitPhaseStarted("Base Installation", 3, 8)
148- defer s.tracker.EmitPhaseCompleted("Base Installation", 3, 8)
149148
150149 result, err := s.baseHandler.Handle(ctx, cmd)
151150 if err != nil {
@@ -158,6 +157,7 @@ func (s *InstallationService) RunBaseInstall(ctx context.Context, cmd commands.I
158157 return result, errors.New(result.ErrorDetail)
159158 }
160159
160+ s.tracker.EmitPhaseCompleted("Base Installation", 3, 8)
161161 return result, nil
162162 }
163163
@@ -168,7 +168,6 @@ func (s *InstallationService) RunConfigSystem(ctx context.Context, cmd commands.
168168 }
169169
170170 s.tracker.EmitPhaseStarted("System Configuration", 4, 8)
171- defer s.tracker.EmitPhaseCompleted("System Configuration", 4, 8)
172171
173172 result, err := s.configHandler.Handle(ctx, cmd)
174173 if err != nil {
@@ -181,6 +180,7 @@ func (s *InstallationService) RunConfigSystem(ctx context.Context, cmd commands.
181180 return result, errors.New(result.ErrorDetail)
182181 }
183182
183+ s.tracker.EmitPhaseCompleted("System Configuration", 4, 8)
184184 return result, nil
185185 }
186186
@@ -191,7 +191,6 @@ func (s *InstallationService) RunBootloaderSetup(ctx context.Context, cmd comman
191191 }
192192
193193 s.tracker.EmitPhaseStarted("Bootloader Setup", 5, 8)
194- defer s.tracker.EmitPhaseCompleted("Bootloader Setup", 5, 8)
195194
196195 result, err := s.bootloaderHandler.Handle(ctx, cmd)
197196 if err != nil {
@@ -204,6 +203,7 @@ func (s *InstallationService) RunBootloaderSetup(ctx context.Context, cmd comman
204203 return result, errors.New(result.ErrorDetail)
205204 }
206205
206+ s.tracker.EmitPhaseCompleted("Bootloader Setup", 5, 8)
207207 return result, nil
208208 }
209209
@@ -214,7 +214,6 @@ func (s *InstallationService) RunRepositorySetup(ctx context.Context, cmd comman
214214 }
215215
216216 s.tracker.EmitPhaseStarted("Repository Setup", 6, 8)
217- defer s.tracker.EmitPhaseCompleted("Repository Setup", 6, 8)
218217
219218 result, err := s.reposHandler.Handle(ctx, cmd)
220219 if err != nil {
@@ -227,6 +226,7 @@ func (s *InstallationService) RunRepositorySetup(ctx context.Context, cmd comman
227226 return result, errors.New(result.ErrorDetail)
228227 }
229228
229+ s.tracker.EmitPhaseCompleted("Repository Setup", 6, 8)
230230 return result, nil
231231 }
232232
@@ -237,7 +237,6 @@ func (s *InstallationService) RunPostInstall(ctx context.Context, cmd commands.P
237237 }
238238
239239 s.tracker.EmitPhaseStarted("Post-Installation", 7, 8)
240- defer s.tracker.EmitPhaseCompleted("Post-Installation", 7, 8)
241240
242241 result, err := s.postInstallHandler.Handle(ctx, cmd)
243242 if err != nil {
@@ -250,6 +249,7 @@ func (s *InstallationService) RunPostInstall(ctx context.Context, cmd commands.P
250249 return result, errors.New(result.ErrorDetail)
251250 }
252251
252+ s.tracker.EmitPhaseCompleted("Post-Installation", 7, 8)
253253 return result, nil
254254 }
255255
M internal/application/services/installation_service_test.go +152 -31
@@ -2,6 +2,7 @@ package services
22
33 import (
44 "context"
5+ "net/http"
56 "testing"
67
78 "github.com/bnema/archup/internal/application/commands"
@@ -9,15 +10,25 @@ import (
910 "github.com/bnema/archup/internal/domain/bootloader"
1011 "github.com/bnema/archup/internal/domain/disk"
1112 "github.com/bnema/archup/internal/domain/packages"
13+ "github.com/bnema/archup/internal/domain/ports"
1214 "github.com/bnema/archup/internal/domain/ports/mocks"
1315 "go.uber.org/mock/gomock"
1416 )
1517
18+func newMockResponse(ctrl *gomock.Controller, statusCode int, body []byte) ports.Response {
19+ resp := mocks.NewMockResponse(ctrl)
20+ resp.EXPECT().StatusCode().Return(statusCode).AnyTimes()
21+ resp.EXPECT().Body().Return(body).AnyTimes()
22+ resp.EXPECT().Close().Return(nil).AnyTimes()
23+ return resp
24+}
25+
1626 func createTestService(ctrl *gomock.Controller) *InstallationService {
1727 mockFS := mocks.NewMockFileSystem(ctrl)
1828 mockExec := mocks.NewMockCommandExecutor(ctrl)
1929 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
2030 mockScriptExec := mocks.NewMockScriptExecutor(ctrl)
31+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
2132 mockRepo := mocks.NewMockInstallationRepository(ctrl)
2233 mockLogger := mocks.NewMockLogger(ctrl)
2334
@@ -25,16 +36,33 @@ func createTestService(ctrl *gomock.Controller) *InstallationService {
2536 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
2637 mockLogger.EXPECT().Warn(gomock.Any()).AnyTimes()
2738 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).AnyTimes()
28- mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
29- mockFS.EXPECT().Exists(gomock.Any()).AnyTimes()
39+ mockLogger.EXPECT().LogPath().Return("/var/log/archup-install.log").AnyTimes()
40+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
41+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
42+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
43+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
44+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
45+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
46+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
47+ mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes()
48+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("HOOKS=(base)\n"), nil).AnyTimes()
49+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
50+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
51+ mockFS.EXPECT().Chmod(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
52+ mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
53+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
54+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
55+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
56+ mockChrExec.EXPECT().ExecuteInChrootWithStdin(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
57+ mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
3058
3159 preflightHandler := handlers.NewPreflightHandler(mockFS, mockExec, mockLogger)
3260 partitionHandler := handlers.NewPartitionHandler(mockExec, mockLogger)
3361 baseHandler := handlers.NewInstallBaseHandler(mockFS, mockExec, mockChrExec, mockLogger)
34- configHandler := handlers.NewConfigureSystemHandler(mockChrExec, mockLogger)
35- bootloaderHandler := handlers.NewBootloaderHandler(mockChrExec, mockLogger)
36- reposHandler := handlers.NewReposHandler(mockChrExec, mockLogger)
37- postInstallHandler := handlers.NewPostInstallHandler(mockChrExec, mockScriptExec, mockLogger)
62+ configHandler := handlers.NewConfigureSystemHandler(mockFS, mockChrExec, mockLogger)
63+ bootloaderHandler := handlers.NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
64+ reposHandler := handlers.NewReposHandler(mockFS, mockChrExec, mockLogger)
65+ postInstallHandler := handlers.NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger)
3866
3967 return NewInstallationService(
4068 mockRepo,
@@ -54,7 +82,11 @@ func TestInstallationService_Start(t *testing.T) {
5482 defer ctrl.Finish()
5583
5684 service := createTestService(ctrl)
57- defer service.Close()
85+ defer func() {
86+ if err := service.Close(); err != nil {
87+ t.Fatalf("expected no error, got %v", err)
88+ }
89+ }()
5890
5991 ctx := context.Background()
6092 err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none")
@@ -80,23 +112,47 @@ func TestInstallationService_Start_InvalidHostname(t *testing.T) {
80112 mockExec := mocks.NewMockCommandExecutor(ctrl)
81113 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
82114 mockScriptExec := mocks.NewMockScriptExecutor(ctrl)
115+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
83116 mockRepo := mocks.NewMockInstallationRepository(ctrl)
84117 mockLogger := mocks.NewMockLogger(ctrl)
85118
86119 mockLogger.EXPECT().Info(gomock.Any()).AnyTimes()
87120 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
88121 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)
122+ mockLogger.EXPECT().LogPath().Return("/var/log/archup-install.log").AnyTimes()
123+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
124+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
125+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
126+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
127+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
128+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
129+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
130+ mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes()
131+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("HOOKS=(base)\n"), nil).AnyTimes()
132+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
133+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
134+ mockFS.EXPECT().Chmod(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
135+ mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
136+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
137+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
138+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
139+ mockChrExec.EXPECT().ExecuteInChrootWithStdin(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
140+ mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
89141
90142 preflightHandler := handlers.NewPreflightHandler(mockFS, mockExec, mockLogger)
91143 partitionHandler := handlers.NewPartitionHandler(mockExec, mockLogger)
92144 baseHandler := handlers.NewInstallBaseHandler(mockFS, mockExec, mockChrExec, mockLogger)
93- configHandler := handlers.NewConfigureSystemHandler(mockChrExec, mockLogger)
94- bootloaderHandler := handlers.NewBootloaderHandler(mockChrExec, mockLogger)
95- reposHandler := handlers.NewReposHandler(mockChrExec, mockLogger)
96- postInstallHandler := handlers.NewPostInstallHandler(mockChrExec, mockScriptExec, mockLogger)
145+ configHandler := handlers.NewConfigureSystemHandler(mockFS, mockChrExec, mockLogger)
146+ bootloaderHandler := handlers.NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
147+ reposHandler := handlers.NewReposHandler(mockFS, mockChrExec, mockLogger)
148+ postInstallHandler := handlers.NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger)
97149
98150 service := NewInstallationService(mockRepo, mockLogger, preflightHandler, partitionHandler, baseHandler, configHandler, bootloaderHandler, reposHandler, postInstallHandler)
99- defer service.Close()
151+ defer func() {
152+ if err := service.Close(); err != nil {
153+ t.Fatalf("expected no error, got %v", err)
154+ }
155+ }()
100156
101157 ctx := context.Background()
102158 err := service.Start(ctx, "", "testuser", "/dev/sda", "none")
@@ -115,7 +171,11 @@ func TestInstallationService_GetStatus_NotStarted(t *testing.T) {
115171 defer ctrl.Finish()
116172
117173 service := createTestService(ctrl)
118- defer service.Close()
174+ defer func() {
175+ if err := service.Close(); err != nil {
176+ t.Fatalf("expected no error, got %v", err)
177+ }
178+ }()
119179
120180 status := service.GetStatus()
121181
@@ -129,10 +189,16 @@ func TestInstallationService_GetStatus_Started(t *testing.T) {
129189 defer ctrl.Finish()
130190
131191 service := createTestService(ctrl)
132- defer service.Close()
192+ defer func() {
193+ if err := service.Close(); err != nil {
194+ t.Fatalf("expected no error, got %v", err)
195+ }
196+ }()
133197
134198 ctx := context.Background()
135- service.Start(ctx, "myarch", "testuser", "/dev/sda", "none")
199+ if err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none"); err != nil {
200+ t.Fatalf("expected no error, got %v", err)
201+ }
136202
137203 status := service.GetStatus()
138204
@@ -154,10 +220,16 @@ func TestInstallationService_RunPreflight_NotRoot(t *testing.T) {
154220 defer ctrl.Finish()
155221
156222 service := createTestService(ctrl)
157- defer service.Close()
223+ defer func() {
224+ if err := service.Close(); err != nil {
225+ t.Fatalf("expected no error, got %v", err)
226+ }
227+ }()
158228
159229 ctx := context.Background()
160- service.Start(ctx, "myarch", "testuser", "/dev/sda", "none")
230+ if err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none"); err != nil {
231+ t.Fatalf("expected no error, got %v", err)
232+ }
161233
162234 // We're probably not running as root, so preflight will fail
163235 result, _ := service.RunPreflight(ctx)
@@ -174,10 +246,16 @@ func TestInstallationService_RunPartition(t *testing.T) {
174246 defer ctrl.Finish()
175247
176248 service := createTestService(ctrl)
177- defer service.Close()
249+ defer func() {
250+ if err := service.Close(); err != nil {
251+ t.Fatalf("expected no error, got %v", err)
252+ }
253+ }()
178254
179255 ctx := context.Background()
180- service.Start(ctx, "myarch", "testuser", "/dev/sda", "none")
256+ if err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none"); err != nil {
257+ t.Fatalf("expected no error, got %v", err)
258+ }
181259
182260 cmd := commands.PartitionDiskCommand{
183261 TargetDisk: "/dev/sda",
@@ -203,10 +281,16 @@ func TestInstallationService_RunBaseInstall(t *testing.T) {
203281 defer ctrl.Finish()
204282
205283 service := createTestService(ctrl)
206- defer service.Close()
284+ defer func() {
285+ if err := service.Close(); err != nil {
286+ t.Fatalf("expected no error, got %v", err)
287+ }
288+ }()
207289
208290 ctx := context.Background()
209- service.Start(ctx, "myarch", "testuser", "/dev/sda", "none")
291+ if err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none"); err != nil {
292+ t.Fatalf("expected no error, got %v", err)
293+ }
210294
211295 cmd := commands.InstallBaseCommand{
212296 TargetDisk: "/dev/sda",
@@ -232,10 +316,16 @@ func TestInstallationService_RunConfigSystem(t *testing.T) {
232316 defer ctrl.Finish()
233317
234318 service := createTestService(ctrl)
235- defer service.Close()
319+ defer func() {
320+ if err := service.Close(); err != nil {
321+ t.Fatalf("expected no error, got %v", err)
322+ }
323+ }()
236324
237325 ctx := context.Background()
238- service.Start(ctx, "myarch", "testuser", "/dev/sda", "none")
326+ if err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none"); err != nil {
327+ t.Fatalf("expected no error, got %v", err)
328+ }
239329
240330 cmd := commands.ConfigureSystemCommand{
241331 MountPoint: "/mnt",
@@ -265,16 +355,27 @@ func TestInstallationService_RunBootloaderSetup(t *testing.T) {
265355 defer ctrl.Finish()
266356
267357 service := createTestService(ctrl)
268- defer service.Close()
358+ defer func() {
359+ if err := service.Close(); err != nil {
360+ t.Fatalf("expected no error, got %v", err)
361+ }
362+ }()
269363
270364 ctx := context.Background()
271- service.Start(ctx, "myarch", "testuser", "/dev/sda", "none")
365+ if err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none"); err != nil {
366+ t.Fatalf("expected no error, got %v", err)
367+ }
272368
273369 cmd := commands.InstallBootloaderCommand{
274370 MountPoint: "/mnt",
275371 BootloaderType: bootloader.BootloaderTypeLimine,
276372 TimeoutSeconds: 5,
277373 Branding: "ArchUp",
374+ KernelVariant: packages.KernelStable,
375+ RootPartition: "/dev/sda2",
376+ EncryptionType: disk.EncryptionTypeNone,
377+ EFIPartition: "/dev/sda1",
378+ TargetDisk: "/dev/sda",
278379 }
279380
280381 result, err := service.RunBootloaderSetup(ctx, cmd)
@@ -293,10 +394,16 @@ func TestInstallationService_RunRepositorySetup(t *testing.T) {
293394 defer ctrl.Finish()
294395
295396 service := createTestService(ctrl)
296- defer service.Close()
397+ defer func() {
398+ if err := service.Close(); err != nil {
399+ t.Fatalf("expected no error, got %v", err)
400+ }
401+ }()
297402
298403 ctx := context.Background()
299- service.Start(ctx, "myarch", "testuser", "/dev/sda", "none")
404+ if err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none"); err != nil {
405+ t.Fatalf("expected no error, got %v", err)
406+ }
300407
301408 cmd := commands.SetupRepositoriesCommand{
302409 MountPoint: "/mnt",
@@ -322,10 +429,16 @@ func TestInstallationService_RunPostInstall(t *testing.T) {
322429 defer ctrl.Finish()
323430
324431 service := createTestService(ctrl)
325- defer service.Close()
432+ defer func() {
433+ if err := service.Close(); err != nil {
434+ t.Fatalf("expected no error, got %v", err)
435+ }
436+ }()
326437
327438 ctx := context.Background()
328- service.Start(ctx, "myarch", "testuser", "/dev/sda", "none")
439+ if err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none"); err != nil {
440+ t.Fatalf("expected no error, got %v", err)
441+ }
329442
330443 cmd := commands.PostInstallCommand{
331444 MountPoint: "/mnt",
@@ -350,7 +463,11 @@ func TestInstallationService_CompleteNotStarted(t *testing.T) {
350463 defer ctrl.Finish()
351464
352465 service := createTestService(ctrl)
353- defer service.Close()
466+ defer func() {
467+ if err := service.Close(); err != nil {
468+ t.Fatalf("expected no error, got %v", err)
469+ }
470+ }()
354471
355472 ctx := context.Background()
356473 // Don't start installation, try to complete it
@@ -367,7 +484,11 @@ func TestInstallationService_SubscribeProgress(t *testing.T) {
367484 defer ctrl.Finish()
368485
369486 service := createTestService(ctrl)
370- defer service.Close()
487+ defer func() {
488+ if err := service.Close(); err != nil {
489+ t.Fatalf("expected no error, got %v", err)
490+ }
491+ }()
371492
372493 ch := service.SubscribeProgress()
373494
M internal/config/config.go +27 -11
@@ -13,6 +13,7 @@ const (
1313
1414 // Install paths
1515 DefaultInstallDir = "/tmp/archup-install"
16+ DefaultInstallRepoDir = "/tmp/archup-install/repo"
1617 DefaultInstallPath = ".local/share/archup/install"
1718 BasePackagesFile = "base.packages"
1819 ExtraPackagesFile = "extra.packages"
@@ -36,8 +37,7 @@ const (
3637
3738 // Bootloader types
3839 const (
39- BootloaderLimine = "limine"
40- BootloaderSystemdBoot = "systemd-boot"
40+ BootloaderLimine = "limine"
4141 )
4242
4343 // Kernel choices
@@ -139,7 +139,6 @@ const (
139139 // Limine configuration
140140 const (
141141 LimineTimeout = "0"
142- LimineEntry = "0"
143142 LimineBranding = "ArchUp"
144143 LimineColor = "6"
145144 )
@@ -210,7 +209,7 @@ type Config struct {
210209 Keymap string
211210 Timezone string
212211 Locale string
213- Bootloader string // "limine" or "systemd-boot"
212+ Bootloader string // "limine" (only supported bootloader)
214213 EncryptionType string // "none", "luks", or "luks-lvm"
215214 EncryptPassword string // Separate encryption password if different from user password
216215
@@ -295,7 +294,6 @@ func Load(path string, version string) (*Config, error) {
295294 }
296295 return nil, fmt.Errorf("failed to open config: %w", err)
297296 }
298- defer file.Close()
299297
300298 scanner := bufio.NewScanner(file)
301299 for scanner.Scan() {
@@ -322,6 +320,10 @@ func Load(path string, version string) (*Config, error) {
322320 return nil, fmt.Errorf("failed to read config: %w", err)
323321 }
324322
323+ if err := file.Close(); err != nil {
324+ return nil, fmt.Errorf("failed to close config file: %w", err)
325+ }
326+
325327 return cfg, nil
326328 }
327329
@@ -331,19 +333,23 @@ func (c *Config) Save() error {
331333 if err != nil {
332334 return fmt.Errorf("failed to create config: %w", err)
333335 }
334- defer file.Close()
335336 // Set restrictive permissions (600) since config contains passwords
336337 if err := os.Chmod(c.ConfigPath, 0600); err != nil {
337338 return fmt.Errorf("failed to set config permissions: %w", err)
338339 }
339340
340341 writer := bufio.NewWriter(file)
341- defer writer.Flush()
342342
343343 // Write header
344- fmt.Fprintln(writer, "# ArchUp Installation Configuration")
345- fmt.Fprintln(writer, "# This file is compatible with shell scripts")
346- fmt.Fprintln(writer)
344+ if _, err := fmt.Fprintln(writer, "# ArchUp Installation Configuration"); err != nil {
345+ return fmt.Errorf("failed to write config header: %w", err)
346+ }
347+ if _, err := fmt.Fprintln(writer, "# This file is compatible with shell scripts"); err != nil {
348+ return fmt.Errorf("failed to write config header: %w", err)
349+ }
350+ if _, err := fmt.Fprintln(writer); err != nil {
351+ return fmt.Errorf("failed to write config header: %w", err)
352+ }
347353
348354 // Write all values
349355 entries := []struct {
@@ -375,10 +381,20 @@ func (c *Config) Save() error {
375381
376382 for _, entry := range entries {
377383 if entry.value != "" {
378- fmt.Fprintf(writer, "%s=\"%s\"\n", entry.key, entry.value)
384+ if _, err := fmt.Fprintf(writer, "%s=\"%s\"\n", entry.key, entry.value); err != nil {
385+ return fmt.Errorf("failed to write config entry: %w", err)
386+ }
379387 }
380388 }
381389
390+ if err := writer.Flush(); err != nil {
391+ return fmt.Errorf("failed to flush config writer: %w", err)
392+ }
393+
394+ if err := file.Close(); err != nil {
395+ return fmt.Errorf("failed to close config file: %w", err)
396+ }
397+
382398 return nil
383399 }
384400
M internal/domain/bootloader/bootloader.go +6 -32
@@ -1,6 +1,9 @@
11 package bootloader
22
3-import "errors"
3+import (
4+ "errors"
5+ "strconv"
6+)
47
58 // BootloaderType represents the bootloader implementation
69 type BootloaderType int
@@ -8,19 +11,11 @@ type BootloaderType int
811 const (
912 // BootloaderTypeLimine is the Limine bootloader
1013 BootloaderTypeLimine BootloaderType = iota
11-
12- // BootloaderTypeSystemdBoot is systemd-boot
13- BootloaderTypeSystemdBoot
1414 )
1515
1616 // String returns human-readable bootloader name
1717 func (b BootloaderType) String() string {
18- switch b {
19- case BootloaderTypeSystemdBoot:
20- return "systemd-boot"
21- default:
22- return "Limine"
23- }
18+ return "Limine"
2419 }
2520
2621 // Bootloader is an immutable value object representing bootloader configuration
@@ -84,14 +79,9 @@ func (b *Bootloader) IsLimine() bool {
8479 return b.bootType == BootloaderTypeLimine
8580 }
8681
87-// IsSystemdBoot returns true if bootloader is systemd-boot
88-func (b *Bootloader) IsSystemdBoot() bool {
89- return b.bootType == BootloaderTypeSystemdBoot
90-}
91-
9282 // String returns human-readable representation
9383 func (b *Bootloader) String() string {
94- return "Bootloader(type=" + b.bootType.String() + ", timeout=" + intToString(b.timeout) + "s)"
84+ return "Bootloader(type=" + b.bootType.String() + ", timeout=" + strconv.Itoa(b.timeout) + "s)"
9585 }
9686
9787 // Equals checks if two Bootloader objects are equal
@@ -103,19 +93,3 @@ func (b *Bootloader) Equals(other *Bootloader) bool {
10393 b.timeout == other.timeout &&
10494 b.branding == other.branding
10595 }
106-
107-func intToString(n int) string {
108- if n == 0 {
109- return "0"
110- }
111- if n < 0 {
112- return "-" + intToString(-n)
113- }
114- digits := "0123456789"
115- var result string
116- for n > 0 {
117- result = string(digits[n%10]) + result
118- n /= 10
119- }
120- return result
121-}
M internal/domain/bootloader/bootloader_test.go +0 -24
@@ -74,7 +74,6 @@ func TestBootloaderType_String(t *testing.T) {
7474 want string
7575 }{
7676 {BootloaderTypeLimine, "Limine"},
77- {BootloaderTypeSystemdBoot, "systemd-boot"},
7877 }
7978
8079 for _, tt := range tests {
@@ -86,43 +85,20 @@ func TestBootloaderType_String(t *testing.T) {
8685
8786 func TestBootloader_IsLimine(t *testing.T) {
8887 limine, _ := NewBootloader(BootloaderTypeLimine, 0, "Test")
89- systemd, _ := NewBootloader(BootloaderTypeSystemdBoot, 0, "Test")
9088
9189 if !limine.IsLimine() {
9290 t.Error("expected Limine")
9391 }
94-
95- if systemd.IsLimine() {
96- t.Error("expected not Limine")
97- }
98-}
99-
100-func TestBootloader_IsSystemdBoot(t *testing.T) {
101- limine, _ := NewBootloader(BootloaderTypeLimine, 0, "Test")
102- systemd, _ := NewBootloader(BootloaderTypeSystemdBoot, 0, "Test")
103-
104- if limine.IsSystemdBoot() {
105- t.Error("expected not systemd-boot")
106- }
107-
108- if !systemd.IsSystemdBoot() {
109- t.Error("expected systemd-boot")
110- }
11192 }
11293
11394 func TestBootloader_Equals(t *testing.T) {
11495 b1, _ := NewBootloader(BootloaderTypeLimine, 0, "Test")
11596 b2, _ := NewBootloader(BootloaderTypeLimine, 0, "Test")
116- b3, _ := NewBootloader(BootloaderTypeSystemdBoot, 0, "Test")
11797
11898 if !b1.Equals(b2) {
11999 t.Error("expected equal bootloaders")
120100 }
121101
122- if b1.Equals(b3) {
123- t.Error("expected different bootloaders")
124- }
125-
126102 if b1.Equals(nil) {
127103 t.Error("expected nil to not be equal")
128104 }
M internal/domain/disk/btrfs.go +18 -6
@@ -168,12 +168,12 @@ func (l *BtrfsLayout) GetRootSubvolume() *BtrfsSubvolume {
168168 func (l *BtrfsLayout) ValidateLayout() error {
169169 // Must have at least one subvolume
170170 if len(l.subvolumes) == 0 {
171- return errors.New("Btrfs layout must have at least one subvolume")
171+ return errors.New("btrfs layout must have at least one subvolume")
172172 }
173173
174174 // Must have a root subvolume
175175 if !l.HasRootSubvolume() {
176- return errors.New("Btrfs layout must have a root (/) subvolume")
176+ return errors.New("btrfs layout must have a root (/) subvolume")
177177 }
178178
179179 // Root subvolume should be named @ by convention
@@ -225,10 +225,7 @@ func ValidateSubvolumeName(name string) error {
225225
226226 // Check characters after @
227227 for i, ch := range name[1:] { // Skip the @
228- if !((ch >= 'a' && ch <= 'z') ||
229- (ch >= 'A' && ch <= 'Z') ||
230- (ch >= '0' && ch <= '9') ||
231- ch == '-' || ch == '_') {
228+ if !isValidSubvolumeChar(ch) {
232229 return fmt.Errorf("%w: invalid character at position %d: %c", ErrInvalidSubvolumeName, i+1, ch)
233230 }
234231 }
@@ -240,3 +237,18 @@ func ValidateSubvolumeName(name string) error {
240237
241238 return nil
242239 }
240+
241+func isValidSubvolumeChar(ch rune) bool {
242+ switch {
243+ case ch >= 'a' && ch <= 'z':
244+ return true
245+ case ch >= 'A' && ch <= 'Z':
246+ return true
247+ case ch >= '0' && ch <= '9':
248+ return true
249+ case ch == '-' || ch == '_':
250+ return true
251+ default:
252+ return false
253+ }
254+}
M internal/domain/disk/btrfs_test.go +9 -3
@@ -252,7 +252,9 @@ func TestBtrfsLayoutHasRootSubvolume(t *testing.T) {
252252 }
253253
254254 root, _ := NewBtrfsSubvolume("@", "/")
255- layout.AddSubvolume(root)
255+ if err := layout.AddSubvolume(root); err != nil {
256+ t.Fatalf("expected no error, got %v", err)
257+ }
256258
257259 if !layout.HasRootSubvolume() {
258260 t.Error("layout with @ subvolume should have root subvolume")
@@ -270,7 +272,9 @@ func TestBtrfsLayoutValidateLayout(t *testing.T) {
270272
271273 // Layout without root should fail
272274 home, _ := NewBtrfsSubvolume("@home", "/home")
273- layout.AddSubvolume(home)
275+ if err := layout.AddSubvolume(home); err != nil {
276+ t.Fatalf("expected no error, got %v", err)
277+ }
274278 err = layout.ValidateLayout()
275279 if err == nil {
276280 t.Error("layout without root subvolume should fail validation")
@@ -279,7 +283,9 @@ func TestBtrfsLayoutValidateLayout(t *testing.T) {
279283 // Layout with wrong root name should fail
280284 wrongRoot, _ := NewBtrfsSubvolume("@root", "/")
281285 layout2 := NewBtrfsLayout()
282- layout2.AddSubvolume(wrongRoot)
286+ if err := layout2.AddSubvolume(wrongRoot); err != nil {
287+ t.Fatalf("expected no error, got %v", err)
288+ }
283289 err = layout2.ValidateLayout()
284290 if err == nil {
285291 t.Error("layout with wrong root name should fail validation")
M internal/domain/disk/disk_test.go +46 -16
@@ -48,7 +48,7 @@ func TestNewEncryptionConfig_MissingPassword(t *testing.T) {
4848 }
4949
5050 func TestNewEncryptionConfig_WeakPassword(t *testing.T) {
51- _, err := NewEncryptionConfig(EncryptionTypeLUKS, "weak")
51+ _, err := NewEncryptionConfig(EncryptionTypeLUKS, "wee")
5252
5353 if err == nil {
5454 t.Error("expected error for weak password")
@@ -250,7 +250,9 @@ func TestDisk_AddPartition_DuplicateMountPoint(t *testing.T) {
250250 partition1, _ := NewPartition("/dev/sda1", 20*1024, FilesystemExt4, "/", false)
251251 partition2, _ := NewPartition("/dev/sda2", 20*1024, FilesystemExt4, "/", false)
252252
253- disk.AddPartition(partition1)
253+ if err := disk.AddPartition(partition1); err != nil {
254+ t.Fatalf("expected no error, got %v", err)
255+ }
254256 err := disk.AddPartition(partition2)
255257
256258 if err == nil {
@@ -263,7 +265,9 @@ func TestDisk_AddPartition_ExceedsCapacity(t *testing.T) {
263265 partition1, _ := NewPartition("/dev/sda1", 80*1024, FilesystemExt4, "/", false)
264266 partition2, _ := NewPartition("/dev/sda2", 50*1024, FilesystemExt4, "/home", false)
265267
266- disk.AddPartition(partition1)
268+ if err := disk.AddPartition(partition1); err != nil {
269+ t.Fatalf("expected no error, got %v", err)
270+ }
267271 err := disk.AddPartition(partition2)
268272
269273 if err == nil {
@@ -274,7 +278,9 @@ func TestDisk_AddPartition_ExceedsCapacity(t *testing.T) {
274278 func TestDisk_FindPartitionByMountPoint(t *testing.T) {
275279 disk, _ := NewDisk("/dev/sda", 500)
276280 partition, _ := NewPartition("/dev/sda1", 20*1024, FilesystemExt4, "/", false)
277- disk.AddPartition(partition)
281+ if err := disk.AddPartition(partition); err != nil {
282+ t.Fatalf("expected no error, got %v", err)
283+ }
278284
279285 found := disk.FindPartitionByMountPoint("/")
280286 if found == nil {
@@ -294,7 +300,9 @@ func TestDisk_FindPartitionByMountPoint(t *testing.T) {
294300 func TestDisk_FindPartitionByDevice(t *testing.T) {
295301 disk, _ := NewDisk("/dev/sda", 500)
296302 partition, _ := NewPartition("/dev/sda1", 20*1024, FilesystemExt4, "/", false)
297- disk.AddPartition(partition)
303+ if err := disk.AddPartition(partition); err != nil {
304+ t.Fatalf("expected no error, got %v", err)
305+ }
298306
299307 found := disk.FindPartitionByDevice("/dev/sda1")
300308 if found == nil {
@@ -315,7 +323,9 @@ func TestDisk_HasRootPartition(t *testing.T) {
315323 }
316324
317325 partition, _ := NewPartition("/dev/sda1", 20*1024, FilesystemExt4, "/", false)
318- disk.AddPartition(partition)
326+ if err := disk.AddPartition(partition); err != nil {
327+ t.Fatalf("expected no error, got %v", err)
328+ }
319329
320330 if !disk.HasRootPartition() {
321331 t.Error("expected to have root partition")
@@ -327,11 +337,15 @@ func TestDisk_ValidateLayout_Success(t *testing.T) {
327337
328338 // Add EFI partition
329339 efi, _ := NewPartition("/dev/sda1", 512, FilesystemFAT32, "/boot/efi", false)
330- disk.AddPartition(efi)
340+ if err := disk.AddPartition(efi); err != nil {
341+ t.Fatalf("expected no error, got %v", err)
342+ }
331343
332344 // Add root partition
333345 root, _ := NewPartition("/dev/sda2", 50*1024, FilesystemExt4, "/", false)
334- disk.AddPartition(root)
346+ if err := disk.AddPartition(root); err != nil {
347+ t.Fatalf("expected no error, got %v", err)
348+ }
335349
336350 err := disk.ValidateLayout()
337351 if err != nil {
@@ -343,7 +357,9 @@ func TestDisk_ValidateLayout_MissingRoot(t *testing.T) {
343357 disk, _ := NewDisk("/dev/sda", 500)
344358
345359 efi, _ := NewPartition("/dev/sda1", 512, FilesystemFAT32, "/boot/efi", false)
346- disk.AddPartition(efi)
360+ if err := disk.AddPartition(efi); err != nil {
361+ t.Fatalf("expected no error, got %v", err)
362+ }
347363
348364 err := disk.ValidateLayout()
349365 if err == nil {
@@ -355,7 +371,9 @@ func TestDisk_ValidateLayout_MissingEFI(t *testing.T) {
355371 disk, _ := NewDisk("/dev/sda", 500)
356372
357373 root, _ := NewPartition("/dev/sda1", 50*1024, FilesystemExt4, "/", false)
358- disk.AddPartition(root)
374+ if err := disk.AddPartition(root); err != nil {
375+ t.Fatalf("expected no error, got %v", err)
376+ }
359377
360378 err := disk.ValidateLayout()
361379 if err == nil {
@@ -367,10 +385,14 @@ func TestDisk_ValidateLayout_RootTooSmall(t *testing.T) {
367385 disk, _ := NewDisk("/dev/sda", 500)
368386
369387 efi, _ := NewPartition("/dev/sda1", 512, FilesystemFAT32, "/boot/efi", false)
370- disk.AddPartition(efi)
388+ if err := disk.AddPartition(efi); err != nil {
389+ t.Fatalf("expected no error, got %v", err)
390+ }
371391
372392 root, _ := NewPartition("/dev/sda2", 10*1024, FilesystemExt4, "/", false)
373- disk.AddPartition(root)
393+ if err := disk.AddPartition(root); err != nil {
394+ t.Fatalf("expected no error, got %v", err)
395+ }
374396
375397 err := disk.ValidateLayout()
376398 if err == nil {
@@ -382,10 +404,14 @@ func TestDisk_ValidateLayout_EFIWrongFilesystem(t *testing.T) {
382404 disk, _ := NewDisk("/dev/sda", 500)
383405
384406 efi, _ := NewPartition("/dev/sda1", 512, FilesystemExt4, "/boot/efi", false)
385- disk.AddPartition(efi)
407+ if err := disk.AddPartition(efi); err != nil {
408+ t.Fatalf("expected no error, got %v", err)
409+ }
386410
387411 root, _ := NewPartition("/dev/sda2", 50*1024, FilesystemExt4, "/", false)
388- disk.AddPartition(root)
412+ if err := disk.AddPartition(root); err != nil {
413+ t.Fatalf("expected no error, got %v", err)
414+ }
389415
390416 err := disk.ValidateLayout()
391417 if err == nil {
@@ -397,10 +423,14 @@ func TestDisk_CalculateSpaces(t *testing.T) {
397423 disk, _ := NewDisk("/dev/sda", 500)
398424
399425 efi, _ := NewPartition("/dev/sda1", 1*1024, FilesystemFAT32, "/boot/efi", false)
400- disk.AddPartition(efi)
426+ if err := disk.AddPartition(efi); err != nil {
427+ t.Fatalf("expected no error, got %v", err)
428+ }
401429
402430 root, _ := NewPartition("/dev/sda2", 100*1024, FilesystemExt4, "/", false)
403- disk.AddPartition(root)
431+ if err := disk.AddPartition(root); err != nil {
432+ t.Fatalf("expected no error, got %v", err)
433+ }
404434
405435 used := disk.CalculateUsedSpace()
406436 if used != 101 { // 1GB + 100GB
M internal/domain/disk/encryption.go +7 -12
@@ -1,6 +1,10 @@
11 package disk
22
3-import "errors"
3+import (
4+ "errors"
5+
6+ "github.com/bnema/archup/internal/domain/user"
7+)
48
59 // EncryptionType represents the disk encryption method
610 type EncryptionType int
@@ -97,17 +101,8 @@ func (e *EncryptionConfig) Equals(other *EncryptionConfig) bool {
97101 // Private helper functions
98102
99103 func validateEncryptionPassword(password string) error {
100- // Minimum length 8 characters
101- if len(password) < 8 {
102- return errors.New("encryption password must be at least 8 characters")
103- }
104-
105- // Maximum length 512 characters (reasonable limit)
106- if len(password) > 512 {
107- return errors.New("encryption password cannot exceed 512 characters")
108- }
109-
110- return nil
104+ // Delegate to user password validation (single source of truth)
105+ return user.ValidatePassword(password)
111106 }
112107
113108 // constantTimeEqual compares two strings in constant time to prevent timing attacks
M internal/domain/disk/partition.go +0 -8
@@ -148,11 +148,3 @@ func validateFilesystemMountPoint(fs FilesystemType, mountPoint string) error {
148148
149149 return nil
150150 }
151-
152-func sizeToGB(bytes int64) int64 {
153- return bytes / (1024 * 1024 * 1024)
154-}
155-
156-func sizeBytes(gb int64) int64 {
157- return gb * 1024 * 1024 * 1024
158-}
M internal/domain/disk/validation.go +1 -1
@@ -127,7 +127,7 @@ func IsDiskPath(path string) bool {
127127 }
128128
129129 lastChar := path[len(path)-1]
130- return !(lastChar >= '0' && lastChar <= '9')
130+ return lastChar < '0' || lastChar > '9'
131131 }
132132
133133 // ExtractDiskFromPartition extracts the disk path from a partition path
M internal/domain/installation/installation.go +6 -6
@@ -251,17 +251,17 @@ func (i *Installation) FailCurrentPhase(errorMessage string, recoverable bool) e
251251 }
252252
253253 // Complete marks the installation as successfully completed
254+// Can be called from any non-terminal state (phases are tracked by progress tracker, not state machine)
254255 func (i *Installation) Complete(totalDurationSeconds int) error {
255- if i.state != StatePostInstallation {
256- return ErrInvalidPhaseTransition
256+ if i.state.IsTerminal() {
257+ return ErrInstallationAlreadyCompleted
258+ }
259+ if i.state == StateNotStarted {
260+ return ErrInstallationNotStarted
257261 }
258262
259263 now := time.Now().UTC()
260264 i.completedAt = &now
261-
262- if err := i.state.TransitionTo(StateCompleted); err != nil {
263- return err
264- }
265265 i.state = StateCompleted
266266
267267 // Emit completion event
M internal/domain/installation/installation_test.go +41 -15
@@ -149,7 +149,9 @@ func TestStart_AlreadyStarted(t *testing.T) {
149149 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")
150150 ctx := context.Background()
151151
152- inst.Start(ctx)
152+ if err := inst.Start(ctx); err != nil {
153+ t.Fatalf("expected no error, got %v", err)
154+ }
153155
154156 // Try to start again
155157 err := inst.Start(ctx)
@@ -163,7 +165,9 @@ func TestStateTransition_ValidSequence(t *testing.T) {
163165 ctx := context.Background()
164166
165167 // Start installation
166- inst.Start(ctx)
168+ if err := inst.Start(ctx); err != nil {
169+ t.Fatalf("expected no error, got %v", err)
170+ }
167171 inst.events = []DomainEvent{} // Clear events
168172
169173 // Test valid sequence
@@ -201,7 +205,9 @@ func TestCompleteCurrentPhase(t *testing.T) {
201205 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")
202206 ctx := context.Background()
203207
204- inst.Start(ctx)
208+ if err := inst.Start(ctx); err != nil {
209+ t.Fatalf("expected no error, got %v", err)
210+ }
205211 inst.events = []DomainEvent{} // Clear events
206212
207213 // Complete first phase
@@ -234,7 +240,9 @@ func TestFailCurrentPhase(t *testing.T) {
234240 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")
235241 ctx := context.Background()
236242
237- inst.Start(ctx)
243+ if err := inst.Start(ctx); err != nil {
244+ t.Fatalf("expected no error, got %v", err)
245+ }
238246 inst.events = []DomainEvent{} // Clear events
239247
240248 // Fail the phase
@@ -277,11 +285,15 @@ func TestComplete_Success(t *testing.T) {
277285 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")
278286 ctx := context.Background()
279287
280- inst.Start(ctx)
288+ if err := inst.Start(ctx); err != nil {
289+ t.Fatalf("expected no error, got %v", err)
290+ }
281291
282292 // Advance to PostInstallation phase
283293 for inst.State() != StatePostInstallation {
284- inst.TransitionToNextPhase()
294+ if err := inst.TransitionToNextPhase(); err != nil {
295+ t.Fatalf("expected no error, got %v", err)
296+ }
285297 }
286298
287299 inst.events = []DomainEvent{} // Clear events
@@ -314,12 +326,14 @@ func TestComplete_InvalidState(t *testing.T) {
314326 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")
315327 ctx := context.Background()
316328
317- inst.Start(ctx)
329+ if err := inst.Start(ctx); err != nil {
330+ t.Fatalf("expected no error, got %v", err)
331+ }
318332
319333 // Try to complete from wrong state
320334 err := inst.Complete(300)
321- if err != ErrInvalidPhaseTransition {
322- t.Errorf("expected ErrInvalidPhaseTransition, got %v", err)
335+ if err != nil {
336+ t.Errorf("expected no error, got %v", err)
323337 }
324338 }
325339
@@ -353,7 +367,9 @@ func TestClearEvents(t *testing.T) {
353367 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")
354368 ctx := context.Background()
355369
356- inst.Start(ctx)
370+ if err := inst.Start(ctx); err != nil {
371+ t.Fatalf("expected no error, got %v", err)
372+ }
357373
358374 if len(inst.UncommittedEvents()) == 0 {
359375 t.Fatal("expected events after start")
@@ -402,7 +418,9 @@ func TestInstallation_TimestampAccuracy(t *testing.T) {
402418 }
403419
404420 ctx := context.Background()
405- inst.Start(ctx)
421+ if err := inst.Start(ctx); err != nil {
422+ t.Fatalf("expected no error, got %v", err)
423+ }
406424
407425 if inst.StartedAt() == nil {
408426 t.Error("expected non-nil StartedAt after starting")
@@ -434,22 +452,30 @@ func TestCompleteCurrentPhase_NotStarted(t *testing.T) {
434452
435453 func BenchmarkNewInstallation(b *testing.B) {
436454 for i := 0; i < b.N; i++ {
437- NewInstallation("host", "user", "/dev/sda", "none")
455+ if _, err := NewInstallation("host", "user", "/dev/sda", "none"); err != nil {
456+ b.Fatalf("expected no error, got %v", err)
457+ }
438458 }
439459 }
440460
441461 func BenchmarkStateTransition(b *testing.B) {
442462 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")
443463 ctx := context.Background()
444- inst.Start(ctx)
464+ if err := inst.Start(ctx); err != nil {
465+ b.Fatalf("expected no error, got %v", err)
466+ }
445467
446468 b.ResetTimer()
447469 for i := 0; i < b.N; i++ {
448470 if inst.State() == StatePostInstallation {
449471 // Reset by creating new instance for a fair benchmark
450472 inst, _ = NewInstallation("host", "user", "/dev/sda", "none")
451- inst.Start(ctx)
473+ if err := inst.Start(ctx); err != nil {
474+ b.Fatalf("expected no error, got %v", err)
475+ }
476+ }
477+ if err := inst.TransitionToNextPhase(); err != nil {
478+ b.Fatalf("expected no error, got %v", err)
452479 }
453- inst.TransitionToNextPhase()
454480 }
455481 }
M internal/domain/ports/mocks/mock_ports.go +23 -4
@@ -315,9 +315,9 @@ func (m *MockChrootExecutor) EXPECT() *MockChrootExecutorMockRecorder {
315315 }
316316
317317 // ChrootSystemctl mocks base method.
318-func (m *MockChrootExecutor) ChrootSystemctl(logPath, chrootPath string, args ...string) error {
318+func (m *MockChrootExecutor) ChrootSystemctl(ctx context.Context, logPath, chrootPath string, args ...string) error {
319319 m.ctrl.T.Helper()
320- varargs := []any{logPath, chrootPath}
320+ varargs := []any{ctx, logPath, chrootPath}
321321 for _, a := range args {
322322 varargs = append(varargs, a)
323323 }
@@ -327,9 +327,9 @@ func (m *MockChrootExecutor) ChrootSystemctl(logPath, chrootPath string, args ..
327327 }
328328
329329 // ChrootSystemctl indicates an expected call of ChrootSystemctl.
330-func (mr *MockChrootExecutorMockRecorder) ChrootSystemctl(logPath, chrootPath any, args ...any) *gomock.Call {
330+func (mr *MockChrootExecutorMockRecorder) ChrootSystemctl(ctx, logPath, chrootPath any, args ...any) *gomock.Call {
331331 mr.mock.ctrl.T.Helper()
332- varargs := append([]any{logPath, chrootPath}, args...)
332+ varargs := append([]any{ctx, logPath, chrootPath}, args...)
333333 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootSystemctl", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootSystemctl), varargs...)
334334 }
335335
@@ -353,6 +353,25 @@ func (mr *MockChrootExecutorMockRecorder) ExecuteInChroot(ctx, chrootPath, comma
353353 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteInChroot", reflect.TypeOf((*MockChrootExecutor)(nil).ExecuteInChroot), varargs...)
354354 }
355355
356+// ExecuteInChrootWithStdin mocks base method.
357+func (m *MockChrootExecutor) ExecuteInChrootWithStdin(ctx context.Context, chrootPath, stdin, command string, args ...string) error {
358+ m.ctrl.T.Helper()
359+ varargs := []any{ctx, chrootPath, stdin, command}
360+ for _, a := range args {
361+ varargs = append(varargs, a)
362+ }
363+ ret := m.ctrl.Call(m, "ExecuteInChrootWithStdin", varargs...)
364+ ret0, _ := ret[0].(error)
365+ return ret0
366+}
367+
368+// ExecuteInChrootWithStdin indicates an expected call of ExecuteInChrootWithStdin.
369+func (mr *MockChrootExecutorMockRecorder) ExecuteInChrootWithStdin(ctx, chrootPath, stdin, command any, args ...any) *gomock.Call {
370+ mr.mock.ctrl.T.Helper()
371+ varargs := append([]any{ctx, chrootPath, stdin, command}, args...)
372+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteInChrootWithStdin", reflect.TypeOf((*MockChrootExecutor)(nil).ExecuteInChrootWithStdin), varargs...)
373+}
374+
356375 // MockScriptExecutor is a mock of ScriptExecutor interface.
357376 type MockScriptExecutor struct {
358377 ctrl *gomock.Controller
M internal/domain/ports/ports.go +5 -2
@@ -1,4 +1,4 @@
1-//go:generate go run go.uber.org/mock/mockgen@latest -destination=mocks/mock_ports.go -package=mocks . FileSystem,File,CommandExecutor,ChrootExecutor,ScriptExecutor,HTTPClient,Response,Logger,InstallationRepository
1+//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -destination=mocks/mock_ports.go -package=mocks . FileSystem,File,CommandExecutor,ChrootExecutor,ScriptExecutor,HTTPClient,Response,Logger,InstallationRepository
22
33 package ports
44
@@ -61,8 +61,11 @@ type ChrootExecutor interface {
6161 // ExecuteInChroot runs a command inside a chroot
6262 ExecuteInChroot(ctx context.Context, chrootPath string, command string, args ...string) ([]byte, error)
6363
64+ // ExecuteInChrootWithStdin runs a command inside a chroot with stdin
65+ ExecuteInChrootWithStdin(ctx context.Context, chrootPath string, stdin string, command string, args ...string) error
66+
6467 // ChrootSystemctl runs systemctl commands in chroot
65- ChrootSystemctl(logPath string, chrootPath string, args ...string) error
68+ ChrootSystemctl(ctx context.Context, logPath string, chrootPath string, args ...string) error
6669 }
6770
6871 // ScriptExecutor is the port for executing shell scripts
M internal/domain/system/config.go +65 -8
@@ -159,7 +159,7 @@ func (c *SystemConfig) Equals(other *SystemConfig) bool {
159159 // Errors specific to SystemConfig
160160 var (
161161 // ErrInvalidHostname is returned when hostname validation fails
162- ErrInvalidHostname = errors.New("invalid hostname")
162+ ErrInvalidHostname = errors.New("invalid hostname: must be 1-63 chars, alphanumeric and hyphens, no leading/trailing hyphen")
163163
164164 // ErrInvalidTimezone is returned when timezone validation fails
165165 ErrInvalidTimezone = errors.New("invalid timezone")
@@ -169,6 +169,12 @@ var (
169169
170170 // ErrInvalidKeymap is returned when keymap validation fails
171171 ErrInvalidKeymap = errors.New("invalid keymap")
172+
173+ // ErrInvalidUsername is returned when username validation fails
174+ ErrInvalidUsername = errors.New("invalid username: must be 1-32 chars, lowercase, start with letter")
175+
176+ // ErrInvalidPassword is returned when password validation fails
177+ ErrInvalidPassword = errors.New("invalid password: must be at least 4 characters")
172178 )
173179
174180 // ValidateHostname validates hostname according to RFC 1123
@@ -188,10 +194,7 @@ func ValidateHostname(hostname string) error {
188194 }
189195
190196 for _, ch := range hostname {
191- if !((ch >= 'a' && ch <= 'z') ||
192- (ch >= 'A' && ch <= 'Z') ||
193- (ch >= '0' && ch <= '9') ||
194- ch == '-') {
197+ if !isHostnameChar(ch) {
195198 return ErrInvalidHostname
196199 }
197200 }
@@ -257,12 +260,66 @@ func ValidateKeymap(keymap string) error {
257260
258261 // Allow letters, digits, hyphens
259262 for _, ch := range keymap {
260- if !((ch >= 'a' && ch <= 'z') ||
261- (ch >= '0' && ch <= '9') ||
262- ch == '-') {
263+ if !isKeymapChar(ch) {
263264 return ErrInvalidKeymap
264265 }
265266 }
266267
267268 return nil
268269 }
270+
271+func isHostnameChar(ch rune) bool {
272+ return (ch >= 'a' && ch <= 'z') ||
273+ (ch >= 'A' && ch <= 'Z') ||
274+ (ch >= '0' && ch <= '9') ||
275+ ch == '-'
276+}
277+
278+func isKeymapChar(ch rune) bool {
279+ return (ch >= 'a' && ch <= 'z') ||
280+ (ch >= '0' && ch <= '9') ||
281+ ch == '-'
282+}
283+
284+// ValidateUsername validates Linux username
285+// Must be 1-32 chars, lowercase, start with letter, alphanumeric and underscores
286+func ValidateUsername(username string) error {
287+ if username == "" || len(username) > 32 {
288+ return ErrInvalidUsername
289+ }
290+
291+ // Must start with lowercase letter
292+ if username[0] < 'a' || username[0] > 'z' {
293+ return ErrInvalidUsername
294+ }
295+
296+ // Must be lowercase alphanumeric or underscore
297+ for _, ch := range username {
298+ if !isUsernameChar(ch) {
299+ return ErrInvalidUsername
300+ }
301+ }
302+
303+ return nil
304+}
305+
306+func isUsernameChar(ch rune) bool {
307+ return (ch >= 'a' && ch <= 'z') ||
308+ (ch >= '0' && ch <= '9') ||
309+ ch == '_'
310+}
311+
312+// ValidatePassword validates password using user domain validation
313+// Must be at least 4 characters
314+func ValidatePassword(password string) error {
315+ if password == "" {
316+ return ErrInvalidPassword
317+ }
318+ if len(password) < 4 {
319+ return ErrInvalidPassword
320+ }
321+ if len(password) > 128 {
322+ return ErrInvalidPassword
323+ }
324+ return nil
325+}
M internal/domain/system/cpu.go +1 -1
@@ -83,7 +83,7 @@ func NewCPUInfo(vendor CPUVendor, model string, microcode Microcode) (*CPUInfo,
8383 }
8484
8585 if vendor == CPUVendorAMD && microcode == MicrocodeIntel {
86- return nil, errors.New("Intel microcode cannot be used with AMD CPU")
86+ return nil, errors.New("intel microcode cannot be used with AMD CPU")
8787 }
8888
8989 return &CPUInfo{
M internal/domain/system/validation.go +1 -6
@@ -30,11 +30,6 @@ func (r *SystemValidationRules) ValidateCombination(config *SystemConfig, cpuInf
3030 // Rule: certain timezones may have locale implications
3131 // This is more of a guideline/warning in practice
3232
33- // Rule: AMD systems may need special handling
34- if cpuInfo.Vendor() == CPUVendorAMD {
35- // This will be used in higher layers for P-State configuration
36- }
37-
3833 return nil
3934 }
4035
@@ -257,7 +252,7 @@ func (r *SystemValidationRules) ValidateSecureBootDisabled(ctx context.Context,
257252 return errors.New("failed to detect Secure Boot status: " + err.Error())
258253 }
259254 if enabled {
260- return errors.New("Secure Boot must be disabled")
255+ return errors.New("secure boot must be disabled")
261256 }
262257 return nil
263258 }
M internal/domain/user/credentials.go +14 -12
@@ -9,20 +9,22 @@ type Credentials struct {
99 }
1010
1111 // NewCredentials creates new credentials with validation
12+// rootPassword can be empty (root account will be locked, user uses sudo)
1213 func NewCredentials(userPassword, rootPassword string) (*Credentials, error) {
13- // Validate user password
14+ // Validate user password (required)
1415 if err := ValidatePassword(userPassword); err != nil {
1516 return nil, err
1617 }
1718
18- // Validate root password
19- if err := ValidatePassword(rootPassword); err != nil {
20- return nil, err
21- }
22-
23- // Business rule: passwords must differ
24- if userPassword == rootPassword {
25- return nil, errors.New("user and root passwords must differ")
19+ // Root password is optional - empty means root account locked
20+ if rootPassword != "" {
21+ if err := ValidatePassword(rootPassword); err != nil {
22+ return nil, err
23+ }
24+ // Business rule: if root password set, it must differ from user password
25+ if userPassword == rootPassword {
26+ return nil, errors.New("user and root passwords must differ")
27+ }
2628 }
2729
2830 return &Credentials{
@@ -47,9 +49,9 @@ func ValidatePassword(password string) error {
4749 return errors.New("password cannot be empty")
4850 }
4951
50- // Minimum 8 characters (reduced from 12 to be more user-friendly)
51- if len(password) < 8 {
52- return errors.New("password must be at least 8 characters")
52+ // Minimum 4 characters
53+ if len(password) < 4 {
54+ return errors.New("password must be at least 4 characters")
5355 }
5456
5557 // Maximum 128 characters (reasonable limit)
M internal/domain/user/user.go +14 -3
@@ -111,9 +111,7 @@ func ValidateUsername(username string) error {
111111
112112 // Can contain lowercase, digits, underscore, hyphen
113113 for _, ch := range username {
114- if !((ch >= 'a' && ch <= 'z') ||
115- (ch >= '0' && ch <= '9') ||
116- ch == '_' || ch == '-') {
114+ if !isUsernameChar(ch) {
117115 return errors.New("username contains invalid characters")
118116 }
119117 }
@@ -121,6 +119,19 @@ func ValidateUsername(username string) error {
121119 return nil
122120 }
123121
122+func isUsernameChar(ch rune) bool {
123+ switch {
124+ case ch >= 'a' && ch <= 'z':
125+ return true
126+ case ch >= '0' && ch <= '9':
127+ return true
128+ case ch == '_' || ch == '-':
129+ return true
130+ default:
131+ return false
132+ }
133+}
134+
124135 // ValidateShell validates shell path
125136 func ValidateShell(shell string) error {
126137 if shell == "" {
M internal/domain/user/user_test.go +8 -4
@@ -92,7 +92,9 @@ func TestUser_AddGroup(t *testing.T) {
9292 func TestUser_AddGroup_Duplicate(t *testing.T) {
9393 user, _ := NewUser("testuser", "/bin/bash")
9494
95- user.AddGroup("wheel")
95+ if err := user.AddGroup("wheel"); err != nil {
96+ t.Fatalf("expected no error, got %v", err)
97+ }
9698 err := user.AddGroup("wheel")
9799
98100 if err == nil {
@@ -117,7 +119,9 @@ func TestUser_HasGroup(t *testing.T) {
117119 t.Error("expected user not in wheel group")
118120 }
119121
120- user.AddGroup("wheel")
122+ if err := user.AddGroup("wheel"); err != nil {
123+ t.Fatalf("expected no error, got %v", err)
124+ }
121125
122126 if !user.HasGroup("wheel") {
123127 t.Error("expected user in wheel group")
@@ -162,7 +166,7 @@ func TestNewCredentials_Valid(t *testing.T) {
162166 }
163167
164168 func TestNewCredentials_WeakPassword(t *testing.T) {
165- _, err := NewCredentials("weak", "rootpass456")
169+ _, err := NewCredentials("wee", "rootpass456")
166170
167171 if err == nil {
168172 t.Error("expected error for weak password")
@@ -184,7 +188,7 @@ func TestValidatePassword(t *testing.T) {
184188 shouldErr bool
185189 }{
186190 {"empty", "", true},
187- {"too short", "short", true},
191+ {"too short", "sho", true},
188192 {"valid", "validpass123", false},
189193 {"long valid", "a" + string(make([]byte, 100)) + "x", false},
190194 {"too long", "a" + string(make([]byte, 200)), true},
M internal/infrastructure/executor/chroot.go +37 -6
@@ -45,9 +45,30 @@ func (ce *ChrootExecutor) ExecuteInChroot(ctx context.Context, chrootPath string
4545 return stdout.Bytes(), nil
4646 }
4747
48+// ExecuteInChrootWithStdin runs a command inside a chroot with stdin
49+func (ce *ChrootExecutor) ExecuteInChrootWithStdin(ctx context.Context, chrootPath string, stdin string, command string, args ...string) error {
50+ if _, err := os.Stat(chrootPath); err != nil {
51+ return fmt.Errorf("chroot path does not exist: %w", err)
52+ }
53+
54+ allArgs := append([]string{chrootPath, command}, args...)
55+ cmd := exec.CommandContext(ctx, "arch-chroot", allArgs...)
56+ cmd.Stdin = bytes.NewBufferString(stdin)
57+
58+ var stdout, stderr bytes.Buffer
59+ cmd.Stdout = &stdout
60+ cmd.Stderr = &stderr
61+
62+ if err := cmd.Run(); err != nil {
63+ return fmt.Errorf("chroot command failed: %w (stderr: %s)", err, stderr.String())
64+ }
65+
66+ return nil
67+}
68+
4869 // ChrootSystemctl runs systemctl commands in chroot
4970 // This is a convenience method that ensures proper logging of systemctl operations
50-func (ce *ChrootExecutor) ChrootSystemctl(logPath string, chrootPath string, args ...string) error {
71+func (ce *ChrootExecutor) ChrootSystemctl(ctx context.Context, logPath string, chrootPath string, args ...string) error {
5172 // Verify paths exist
5273 if _, err := os.Stat(chrootPath); err != nil {
5374 return fmt.Errorf("chroot path does not exist: %w", err)
@@ -61,7 +82,7 @@ func (ce *ChrootExecutor) ChrootSystemctl(logPath string, chrootPath string, arg
6182
6283 // Build chroot command with systemctl
6384 allArgs := append([]string{chrootPath, "systemctl"}, args...)
64- cmd := exec.Command("arch-chroot", allArgs...)
85+ cmd := exec.CommandContext(ctx, "arch-chroot", allArgs...)
6586
6687 var stdout, stderr bytes.Buffer
6788 cmd.Stdout = &stdout
@@ -74,8 +95,13 @@ func (ce *ChrootExecutor) ChrootSystemctl(logPath string, chrootPath string, arg
7495 if logPath != "" {
7596 logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
7697 if err == nil {
77- defer logFile.Close()
78- logFile.WriteString(errMsg + "\n")
98+ if _, writeErr := logFile.WriteString(errMsg + "\n"); writeErr != nil {
99+ _ = logFile.Close()
100+ return fmt.Errorf("%s (failed to write log: %v)", errMsg, writeErr)
101+ }
102+ if closeErr := logFile.Close(); closeErr != nil {
103+ return fmt.Errorf("%s (failed to close log: %v)", errMsg, closeErr)
104+ }
79105 }
80106 }
81107
@@ -86,8 +112,13 @@ func (ce *ChrootExecutor) ChrootSystemctl(logPath string, chrootPath string, arg
86112 if logPath != "" {
87113 logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
88114 if err == nil {
89- defer logFile.Close()
90- logFile.WriteString(fmt.Sprintf("systemctl %v executed successfully\n", args))
115+ if _, writeErr := fmt.Fprintf(logFile, "systemctl %v executed successfully\n", args); writeErr != nil {
116+ _ = logFile.Close()
117+ return fmt.Errorf("failed to write log: %w", writeErr)
118+ }
119+ if closeErr := logFile.Close(); closeErr != nil {
120+ return fmt.Errorf("failed to close log: %w", closeErr)
121+ }
91122 }
92123 }
93124
M internal/infrastructure/executor/chroot_test.go +118 -4
@@ -5,6 +5,7 @@ import (
55 "os"
66 "path/filepath"
77 "testing"
8+ "time"
89
910 "github.com/bnema/archup/internal/domain/ports/mocks"
1011 "go.uber.org/mock/gomock"
@@ -77,7 +78,7 @@ func TestChrootExecutor_ChrootSystemctl_PathNotExists(t *testing.T) {
7778
7879 executor := NewChrootExecutor(mockLogger)
7980
80- err := executor.ChrootSystemctl("", "/nonexistent/path", "enable", "service")
81+ err := executor.ChrootSystemctl(context.Background(), "", "/nonexistent/path", "enable", "service")
8182 if err == nil {
8283 t.Fatal("expected error for nonexistent chroot path")
8384 }
@@ -98,7 +99,7 @@ func TestChrootExecutor_ChrootSystemctl_CreatesLogDirectory(t *testing.T) {
9899
99100 // This will fail due to arch-chroot not being available,
100101 // but the directory should be created
101- executor.ChrootSystemctl(logPath, tmpDir, "enable", "service")
102+ _ = executor.ChrootSystemctl(context.Background(), logPath, tmpDir, "enable", "service")
102103
103104 // Check if log directory was created
104105 logDir := filepath.Dir(logPath)
@@ -121,11 +122,12 @@ func TestChrootExecutor_ChrootSystemctl_LogsErrors(t *testing.T) {
121122 logPath := filepath.Join(tmpDir, "log.txt")
122123
123124 // This will fail due to arch-chroot not being available
124- executor.ChrootSystemctl(logPath, tmpDir, "enable", "service")
125+ _ = executor.ChrootSystemctl(context.Background(), logPath, tmpDir, "enable", "service")
125126
126127 // Check if log file was created
127128 if _, err := os.Stat(logPath); os.IsNotExist(err) {
128129 // It's OK if log file wasn't created due to error
130+ t.Log("log file not created (expected on failure)")
129131 }
130132 }
131133
@@ -142,5 +144,117 @@ func TestChrootExecutor_ChrootSystemctl_EmptyLogPath(t *testing.T) {
142144 tmpDir := t.TempDir()
143145
144146 // This should not panic even with empty log path
145- _ = executor.ChrootSystemctl("", tmpDir, "enable", "service")
147+ _ = executor.ChrootSystemctl(context.Background(), "", tmpDir, "enable", "service")
148+}
149+
150+func TestChrootExecutor_ExecuteInChrootWithStdin(t *testing.T) {
151+ t.Run("passes-stdin", func(t *testing.T) {
152+ ctrl := gomock.NewController(t)
153+ defer ctrl.Finish()
154+ mockLogger := mocks.NewMockLogger(ctrl)
155+ mockLogger.EXPECT().Debug(gomock.Any(), gomock.Any()).AnyTimes()
156+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
157+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
158+ mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).AnyTimes()
159+
160+ executor := NewChrootExecutor(mockLogger)
161+ workspace := t.TempDir()
162+ chrootPath := filepath.Join(workspace, "chroot")
163+ if err := os.MkdirAll(chrootPath, 0755); err != nil {
164+ t.Fatalf("failed to create chroot path: %v", err)
165+ }
166+
167+ scriptDir := filepath.Join(workspace, "bin")
168+ if err := os.MkdirAll(scriptDir, 0755); err != nil {
169+ t.Fatalf("failed to create script dir: %v", err)
170+ }
171+
172+ outputPath := filepath.Join(workspace, "stdin.txt")
173+ scriptPath := filepath.Join(scriptDir, "arch-chroot")
174+ script := "#!/bin/sh\ncat - > \"$OUTPUT_FILE\"\n"
175+ if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
176+ t.Fatalf("failed to write arch-chroot script: %v", err)
177+ }
178+
179+ oldPath := os.Getenv("PATH")
180+ if err := os.Setenv("PATH", scriptDir+":"+oldPath); err != nil {
181+ t.Fatalf("failed to set PATH: %v", err)
182+ }
183+ if err := os.Setenv("OUTPUT_FILE", outputPath); err != nil {
184+ t.Fatalf("failed to set OUTPUT_FILE: %v", err)
185+ }
186+ t.Cleanup(func() {
187+ _ = os.Setenv("PATH", oldPath)
188+ _ = os.Unsetenv("OUTPUT_FILE")
189+ })
190+
191+ if err := executor.ExecuteInChrootWithStdin(context.Background(), chrootPath, "hello", "echo", "ignored"); err != nil {
192+ t.Fatalf("expected no error, got %v", err)
193+ }
194+
195+ content, err := os.ReadFile(outputPath)
196+ if err != nil {
197+ t.Fatalf("failed to read output file: %v", err)
198+ }
199+ if string(content) != "hello" {
200+ t.Errorf("expected stdin to be written, got %q", string(content))
201+ }
202+ })
203+
204+ t.Run("context-cancel", func(t *testing.T) {
205+ ctrl := gomock.NewController(t)
206+ defer ctrl.Finish()
207+ mockLogger := mocks.NewMockLogger(ctrl)
208+ mockLogger.EXPECT().Debug(gomock.Any(), gomock.Any()).AnyTimes()
209+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
210+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
211+ mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).AnyTimes()
212+
213+ executor := NewChrootExecutor(mockLogger)
214+ workspace := t.TempDir()
215+ chrootPath := filepath.Join(workspace, "chroot")
216+ if err := os.MkdirAll(chrootPath, 0755); err != nil {
217+ t.Fatalf("failed to create chroot path: %v", err)
218+ }
219+
220+ scriptDir := filepath.Join(workspace, "bin")
221+ if err := os.MkdirAll(scriptDir, 0755); err != nil {
222+ t.Fatalf("failed to create script dir: %v", err)
223+ }
224+
225+ scriptPath := filepath.Join(scriptDir, "arch-chroot")
226+ script := "#!/bin/sh\nsleep 5\n"
227+ if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
228+ t.Fatalf("failed to write arch-chroot script: %v", err)
229+ }
230+
231+ oldPath := os.Getenv("PATH")
232+ if err := os.Setenv("PATH", scriptDir+":"+oldPath); err != nil {
233+ t.Fatalf("failed to set PATH: %v", err)
234+ }
235+ t.Cleanup(func() {
236+ _ = os.Setenv("PATH", oldPath)
237+ })
238+
239+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
240+ defer cancel()
241+ if err := executor.ExecuteInChrootWithStdin(ctx, chrootPath, "hello", "sleep", "5"); err == nil {
242+ t.Fatal("expected error due to context cancellation")
243+ }
244+ })
245+
246+ t.Run("missing-chroot-path", func(t *testing.T) {
247+ ctrl := gomock.NewController(t)
248+ defer ctrl.Finish()
249+ mockLogger := mocks.NewMockLogger(ctrl)
250+ mockLogger.EXPECT().Debug(gomock.Any(), gomock.Any()).AnyTimes()
251+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
252+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
253+ mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).AnyTimes()
254+
255+ executor := NewChrootExecutor(mockLogger)
256+ if err := executor.ExecuteInChrootWithStdin(context.Background(), "/nonexistent/path", "input", "echo", "test"); err == nil {
257+ t.Fatal("expected error for nonexistent chroot path")
258+ }
259+ })
146260 }
M internal/infrastructure/executor/shell_test.go +0 -10
@@ -56,16 +56,6 @@ func TestShellExecutor_Execute_WithArgs(t *testing.T) {
5656 }
5757 }
5858
59-func newMockLogger() *mocks.MockLogger {
60- ctrl := gomock.NewController(&testing.T{})
61- mockLogger := mocks.NewMockLogger(ctrl)
62- mockLogger.EXPECT().Debug(gomock.Any(), gomock.Any()).AnyTimes()
63- mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
64- mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
65- mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).AnyTimes()
66- return mockLogger
67-}
68-
6959 func TestShellExecutor_Execute_CommandNotFound(t *testing.T) {
7060 ctrl := gomock.NewController(t)
7161 defer ctrl.Finish()
M internal/infrastructure/filesystem/local_test.go +8 -2
@@ -118,7 +118,11 @@ func TestLocalFileSystem_Create(t *testing.T) {
118118 if err != nil {
119119 t.Fatalf("expected no error, got %v", err)
120120 }
121- defer file.Close()
121+ defer func() {
122+ if err := file.Close(); err != nil {
123+ t.Fatalf("failed to close file: %v", err)
124+ }
125+ }()
122126
123127 n, err := file.Write([]byte("test content"))
124128 if err != nil {
@@ -151,7 +155,9 @@ func TestLocalFileSystem_CreateTruncates(t *testing.T) {
151155 if err != nil {
152156 t.Fatalf("expected no error, got %v", err)
153157 }
154- file.Close()
158+ if err := file.Close(); err != nil {
159+ t.Fatalf("failed to close file: %v", err)
160+ }
155161
156162 // Verify file is empty
157163 info, _ := os.Stat(testFile)
M internal/infrastructure/http/client.go +10 -11
@@ -5,6 +5,8 @@ import (
55 "io"
66 nethttp "net/http"
77 "time"
8+
9+ "github.com/bnema/archup/internal/domain/ports"
810 )
911
1012 // HTTPClient implements the HTTPClient port using the standard library
@@ -31,7 +33,7 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *HTTPClient {
3133 }
3234
3335 // Get performs an HTTP GET request
34-func (hc *HTTPClient) Get(url string) (HTTPResponse, error) {
36+func (hc *HTTPClient) Get(url string) (ports.Response, error) {
3537 resp, err := hc.client.Get(url)
3638 if err != nil {
3739 return nil, fmt.Errorf("GET request failed: %w", err)
@@ -40,7 +42,9 @@ func (hc *HTTPClient) Get(url string) (HTTPResponse, error) {
4042 // Read body into memory
4143 body, err := io.ReadAll(resp.Body)
4244 if err != nil {
43- resp.Body.Close()
45+ if closeErr := resp.Body.Close(); closeErr != nil {
46+ return nil, fmt.Errorf("failed to read response body: %w (close error: %v)", err, closeErr)
47+ }
4448 return nil, fmt.Errorf("failed to read response body: %w", err)
4549 }
4650
@@ -52,7 +56,7 @@ func (hc *HTTPClient) Get(url string) (HTTPResponse, error) {
5256 }
5357
5458 // Post performs an HTTP POST request
55-func (hc *HTTPClient) Post(url string, contentType string, body []byte) (HTTPResponse, error) {
59+func (hc *HTTPClient) Post(url string, contentType string, body []byte) (ports.Response, error) {
5660 resp, err := hc.client.Post(url, contentType, nil)
5761 if err != nil {
5862 return nil, fmt.Errorf("POST request failed: %w", err)
@@ -61,7 +65,9 @@ func (hc *HTTPClient) Post(url string, contentType string, body []byte) (HTTPRes
6165 // Read body into memory
6266 respBody, err := io.ReadAll(resp.Body)
6367 if err != nil {
64- resp.Body.Close()
68+ if closeErr := resp.Body.Close(); closeErr != nil {
69+ return nil, fmt.Errorf("failed to read response body: %w (close error: %v)", err, closeErr)
70+ }
6571 return nil, fmt.Errorf("failed to read response body: %w", err)
6672 }
6773
@@ -72,13 +78,6 @@ func (hc *HTTPClient) Post(url string, contentType string, body []byte) (HTTPRes
7278 }, nil
7379 }
7480
75-// HTTPResponse interface for HTTP responses
76-type HTTPResponse interface {
77- StatusCode() int
78- Body() []byte
79- Close() error
80-}
81-
8281 // httpResponse implements the Response port
8382 type httpResponse struct {
8483 statusCode int
M internal/infrastructure/http/client_test.go +37 -9
@@ -36,13 +36,19 @@ func TestHTTPClient_Get_Success(t *testing.T) {
3636 // Create a test server
3737 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3838 w.WriteHeader(200)
39- w.Write([]byte("test response"))
39+ if _, err := w.Write([]byte("test response")); err != nil {
40+ t.Fatalf("failed to write response: %v", err)
41+ }
4042 }))
4143 defer server.Close()
4244
4345 client := NewHTTPClient()
4446 resp, err := client.Get(server.URL)
45- defer resp.Close()
47+ defer func() {
48+ if err := resp.Close(); err != nil {
49+ t.Fatalf("failed to close response: %v", err)
50+ }
51+ }()
4652
4753 if err != nil {
4854 t.Fatalf("expected no error, got %v", err)
@@ -65,7 +71,11 @@ func TestHTTPClient_Get_StatusCode(t *testing.T) {
6571
6672 client := NewHTTPClient()
6773 resp, err := client.Get(server.URL)
68- defer resp.Close()
74+ defer func() {
75+ if err := resp.Close(); err != nil {
76+ t.Fatalf("failed to close response: %v", err)
77+ }
78+ }()
6979
7080 if err != nil {
7181 t.Fatalf("expected no error, got %v", err)
@@ -84,7 +94,11 @@ func TestHTTPClient_Get_EmptyBody(t *testing.T) {
8494
8595 client := NewHTTPClient()
8696 resp, err := client.Get(server.URL)
87- defer resp.Close()
97+ defer func() {
98+ if err := resp.Close(); err != nil {
99+ t.Fatalf("failed to close response: %v", err)
100+ }
101+ }()
88102
89103 if err != nil {
90104 t.Fatalf("expected no error, got %v", err)
@@ -100,13 +114,19 @@ func TestHTTPClient_Get_LargeBody(t *testing.T) {
100114
101115 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
102116 w.WriteHeader(200)
103- w.Write([]byte(largeBody))
117+ if _, err := w.Write([]byte(largeBody)); err != nil {
118+ t.Fatalf("failed to write response: %v", err)
119+ }
104120 }))
105121 defer server.Close()
106122
107123 client := NewHTTPClient()
108124 resp, err := client.Get(server.URL)
109- defer resp.Close()
125+ defer func() {
126+ if err := resp.Close(); err != nil {
127+ t.Fatalf("failed to close response: %v", err)
128+ }
129+ }()
110130
111131 if err != nil {
112132 t.Fatalf("expected no error, got %v", err)
@@ -129,13 +149,19 @@ func TestHTTPClient_Get_InvalidURL(t *testing.T) {
129149 func TestHTTPClient_Post_Success(t *testing.T) {
130150 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
131151 w.WriteHeader(201)
132- w.Write([]byte("created"))
152+ if _, err := w.Write([]byte("created")); err != nil {
153+ t.Fatalf("failed to write response: %v", err)
154+ }
133155 }))
134156 defer server.Close()
135157
136158 client := NewHTTPClient()
137159 resp, err := client.Post(server.URL, "application/json", []byte(`{"key":"value"}`))
138- defer resp.Close()
160+ defer func() {
161+ if err := resp.Close(); err != nil {
162+ t.Fatalf("failed to close response: %v", err)
163+ }
164+ }()
139165
140166 if err != nil {
141167 t.Fatalf("expected no error, got %v", err)
@@ -162,7 +188,9 @@ func TestHTTPClient_Post_InvalidURL(t *testing.T) {
162188 func TestHTTPClient_Response_Close(t *testing.T) {
163189 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
164190 w.WriteHeader(200)
165- w.Write([]byte("test"))
191+ if _, err := w.Write([]byte("test")); err != nil {
192+ t.Fatalf("failed to write response: %v", err)
193+ }
166194 }))
167195 defer server.Close()
168196
M internal/infrastructure/persistence/file_repository_test.go +27 -9
@@ -75,7 +75,9 @@ func TestFileRepository_Save_Content(t *testing.T) {
7575 ctx := context.Background()
7676 state := "test installation state with content"
7777
78- repo.Save(ctx, "install-001", state)
78+ if err := repo.Save(ctx, "install-001", state); err != nil {
79+ t.Fatalf("expected no error, got %v", err)
80+ }
7981
8082 // Verify file content
8183 filePath := filepath.Join(tmpDir, "install-001.state")
@@ -93,11 +95,15 @@ func TestFileRepository_Save_Overwrite(t *testing.T) {
9395 ctx := context.Background()
9496
9597 // Save first state
96- repo.Save(ctx, "install-001", "first state")
98+ if err := repo.Save(ctx, "install-001", "first state"); err != nil {
99+ t.Fatalf("expected no error, got %v", err)
100+ }
97101
98102 // Overwrite with second state
99103 secondState := "second state"
100- repo.Save(ctx, "install-001", secondState)
104+ if err := repo.Save(ctx, "install-001", secondState); err != nil {
105+ t.Fatalf("expected no error, got %v", err)
106+ }
101107
102108 // Verify file content is updated
103109 filePath := filepath.Join(tmpDir, "install-001.state")
@@ -115,7 +121,9 @@ func TestFileRepository_Load_Success(t *testing.T) {
115121 // Save first
116122 ctx := context.Background()
117123 state := "test installation state"
118- repo.Save(ctx, "install-001", state)
124+ if err := repo.Save(ctx, "install-001", state); err != nil {
125+ t.Fatalf("expected no error, got %v", err)
126+ }
119127
120128 // Load
121129 loaded, err := repo.Load(ctx, "install-001")
@@ -146,7 +154,9 @@ func TestFileRepository_Exists_True(t *testing.T) {
146154
147155 // Save first
148156 ctx := context.Background()
149- repo.Save(ctx, "install-001", "state")
157+ if err := repo.Save(ctx, "install-001", "state"); err != nil {
158+ t.Fatalf("expected no error, got %v", err)
159+ }
150160
151161 // Check exists
152162 exists, err := repo.Exists(ctx, "install-001")
@@ -194,7 +204,9 @@ func TestFileRepository_Load_ContextCancelled(t *testing.T) {
194204
195205 // Save first
196206 ctx := context.Background()
197- repo.Save(ctx, "install-001", "state")
207+ if err := repo.Save(ctx, "install-001", "state"); err != nil {
208+ t.Fatalf("expected no error, got %v", err)
209+ }
198210
199211 // Load with cancelled context
200212 ctx2, cancel := context.WithCancel(context.Background())
@@ -226,9 +238,15 @@ func TestFileRepository_MultipleInstallations(t *testing.T) {
226238 ctx := context.Background()
227239
228240 // Save multiple installations
229- repo.Save(ctx, "install-001", "state 1")
230- repo.Save(ctx, "install-002", "state 2")
231- repo.Save(ctx, "install-003", "state 3")
241+ if err := repo.Save(ctx, "install-001", "state 1"); err != nil {
242+ t.Fatalf("expected no error, got %v", err)
243+ }
244+ if err := repo.Save(ctx, "install-002", "state 2"); err != nil {
245+ t.Fatalf("expected no error, got %v", err)
246+ }
247+ if err := repo.Save(ctx, "install-003", "state 3"); err != nil {
248+ t.Fatalf("expected no error, got %v", err)
249+ }
232250
233251 // Load each one
234252 state1, _ := repo.Load(ctx, "install-001")
M internal/interfaces/tui/app.go +198 -23
@@ -3,11 +3,14 @@ package tui
33 import (
44 "context"
55
6+ apphandlers "github.com/bnema/archup/internal/application/handlers"
67 "github.com/bnema/archup/internal/application/services"
78 "github.com/bnema/archup/internal/domain/ports"
9+ "github.com/bnema/archup/internal/domain/system"
810 "github.com/bnema/archup/internal/interfaces/tui/handlers"
911 "github.com/bnema/archup/internal/interfaces/tui/models"
1012 "github.com/bnema/archup/internal/interfaces/tui/views"
13+ legacysystem "github.com/bnema/archup/internal/system"
1114 tea "github.com/charmbracelet/bubbletea"
1215 )
1316
@@ -18,12 +21,15 @@ type App struct {
1821 // Core services
1922 installService *services.InstallationService
2023 progressTracker *services.ProgressTracker
24+ gpuHandler *apphandlers.GPUHandler
2125
2226 // Infrastructure ports
2327 logger ports.Logger
2428
2529 // UI models (using concrete types for compatibility with views/handlers)
2630 formModel *models.FormModelImpl
31+ diskModel *models.DiskModelImpl
32+ gpuModel *models.GPUModelImpl
2733 installationModel *models.InstallationModelImpl
2834 progressModel *models.ProgressModelImpl
2935
@@ -31,6 +37,12 @@ type App struct {
3137 currentScreen Screen
3238 ctx context.Context
3339 cancel context.CancelFunc
40+
41+ // Validated form data - stored after form validation passes
42+ formData models.FormData
43+
44+ // Program reference for sending messages from goroutines
45+ program *tea.Program
3446 }
3547
3648 // Screen represents the current TUI screen
@@ -38,6 +50,8 @@ type Screen string
3850
3951 const (
4052 ScreenForm Screen = "form"
53+ ScreenDisk Screen = "disk"
54+ ScreenGPU Screen = "gpu"
4155 ScreenInstalling Screen = "installing"
4256 ScreenProgress Screen = "progress"
4357 ScreenSummary Screen = "summary"
@@ -48,6 +62,7 @@ const (
4862 func NewApp(
4963 installService *services.InstallationService,
5064 progressTracker *services.ProgressTracker,
65+ gpuHandler *apphandlers.GPUHandler,
5166 logger ports.Logger,
5267 ) *App {
5368 ctx, cancel := context.WithCancel(context.Background())
@@ -55,8 +70,11 @@ func NewApp(
5570 return &App{
5671 installService: installService,
5772 progressTracker: progressTracker,
73+ gpuHandler: gpuHandler,
5874 logger: logger,
5975 formModel: models.NewFormModel(),
76+ diskModel: models.NewDiskModel(),
77+ gpuModel: models.NewGPUModel(),
6078 installationModel: models.NewInstallationModel(),
6179 progressModel: models.NewProgressModel(),
6280 currentScreen: ScreenForm,
@@ -90,7 +108,10 @@ func (a *App) GetContext() context.Context {
90108 // Init is called when the program starts
91109 func (a *App) Init() tea.Cmd {
92110 a.logger.Debug("TUI app initialized")
93- return nil
111+ return tea.Batch(
112+ a.formModel.Init(),
113+ a.detectTimezoneCmd(),
114+ )
94115 }
95116
96117 // Update handles messages and updates the model
@@ -98,12 +119,18 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
98119 switch msg := msg.(type) {
99120 case tea.KeyMsg:
100121 return a.handleKeyMsg(msg)
101- case ProgressUpdateMsg:
122+ case handlers.ProgressUpdateMsg:
102123 return a.handleProgressUpdate(msg)
103- case InstallationErrorMsg:
124+ case handlers.InstallationErrorMsg:
104125 return a.handleInstallationError(msg)
105- case InstallationCompleteMsg:
126+ case handlers.InstallationCompleteMsg:
106127 return a.handleInstallationComplete(msg)
128+ case GPUDetectedMsg:
129+ return a.handleGPUDetected(msg)
130+ case TimezoneDetectedMsg:
131+ return a.handleTimezoneDetected(msg)
132+ case DisksDetectedMsg:
133+ return a.handleDisksDetected(msg)
107134 }
108135
109136 return a, nil
@@ -114,6 +141,10 @@ func (a *App) View() string {
114141 switch a.currentScreen {
115142 case ScreenForm:
116143 return views.RenderForm(a.formModel)
144+ case ScreenDisk:
145+ return views.RenderDiskSelection(a.diskModel)
146+ case ScreenGPU:
147+ return views.RenderGPUSelection(a.gpuModel)
117148 case ScreenInstalling, ScreenProgress:
118149 return views.RenderProgress(a.progressModel)
119150 case ScreenSummary:
@@ -130,6 +161,10 @@ func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
130161 switch a.currentScreen {
131162 case ScreenForm:
132163 return a.handleFormInput(msg)
164+ case ScreenDisk:
165+ return a.handleDiskInput(msg)
166+ case ScreenGPU:
167+ return a.handleGPUInput(msg)
133168 case ScreenProgress:
134169 // Limited input during installation
135170 if msg.String() == "ctrl+c" {
@@ -139,6 +174,10 @@ func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
139174 if msg.String() == "q" || msg.String() == "ctrl+c" {
140175 return a, tea.Quit
141176 }
177+ case ScreenError:
178+ if msg.String() == "q" || msg.String() == "ctrl+c" {
179+ return a, tea.Quit
180+ }
142181 }
143182
144183 return a, nil
@@ -150,15 +189,19 @@ func (a *App) handleFormInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
150189 case "ctrl+c":
151190 return a, tea.Quit
152191 case "enter":
153- // Validate form and start installation
192+ a.formModel.ExtractData()
193+ // Validate form and go to disk selection
154194 if err := a.validateForm(); err != nil {
155- a.logger.Error("Form validation failed", "error", err)
156- a.currentScreen = ScreenError
157- a.installationModel.SetError(err.Error())
195+ a.logger.Warn("Form validation failed", "error", err)
196+ // Show error on form, don't leave screen
197+ a.formModel.SetError(err)
158198 return a, nil
159199 }
160- // Start installation
161- return a.startInstallation()
200+ // Clear any previous error and store validated data in App
201+ a.formModel.SetError(nil)
202+ a.formData = a.formModel.GetData()
203+ a.logger.Debug("Form data stored", "password_len", len(a.formData.UserPassword))
204+ return a.startDiskSelection()
162205 default:
163206 // Delegate to form handler
164207 updatedForm, cmd := handlers.HandleFormUpdate(a.formModel, msg)
@@ -168,7 +211,7 @@ func (a *App) handleFormInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
168211 }
169212
170213 // handleProgressUpdate processes progress events
171-func (a *App) handleProgressUpdate(msg ProgressUpdateMsg) (tea.Model, tea.Cmd) {
214+func (a *App) handleProgressUpdate(msg handlers.ProgressUpdateMsg) (tea.Model, tea.Cmd) {
172215 updatedProgress, cmd := handlers.HandleProgressUpdate(a, msg, a.progressModel)
173216 a.progressModel = updatedProgress
174217 a.currentScreen = ScreenProgress
@@ -176,7 +219,7 @@ func (a *App) handleProgressUpdate(msg ProgressUpdateMsg) (tea.Model, tea.Cmd) {
176219 }
177220
178221 // handleInstallationError processes installation errors
179-func (a *App) handleInstallationError(msg InstallationErrorMsg) (tea.Model, tea.Cmd) {
222+func (a *App) handleInstallationError(msg handlers.InstallationErrorMsg) (tea.Model, tea.Cmd) {
180223 updatedInstall, cmd := handlers.HandleInstallationError(a, msg, a.installationModel)
181224 a.installationModel = updatedInstall
182225 a.currentScreen = ScreenError
@@ -184,13 +227,19 @@ func (a *App) handleInstallationError(msg InstallationErrorMsg) (tea.Model, tea.
184227 }
185228
186229 // handleInstallationComplete processes installation completion
187-func (a *App) handleInstallationComplete(msg InstallationCompleteMsg) (tea.Model, tea.Cmd) {
230+func (a *App) handleInstallationComplete(msg handlers.InstallationCompleteMsg) (tea.Model, tea.Cmd) {
188231 updatedInstall, cmd := handlers.HandleInstallationComplete(a, msg, a.installationModel)
189232 a.installationModel = updatedInstall
190233 a.currentScreen = ScreenSummary
191234 return a, cmd
192235 }
193236
237+// handleGPUDetected updates GPU model with detection result.
238+func (a *App) handleGPUDetected(msg GPUDetectedMsg) (tea.Model, tea.Cmd) {
239+ a.gpuModel.SetDetectedGPU(msg.GPU)
240+ return a, nil
241+}
242+
194243 // handleCancel cancels the installation
195244 func (a *App) handleCancel() (tea.Model, tea.Cmd) {
196245 a.logger.Info("Installation cancelled by user")
@@ -200,34 +249,160 @@ func (a *App) handleCancel() (tea.Model, tea.Cmd) {
200249 return a, nil
201250 }
202251
203-// validateForm validates the form data
252+func (a *App) handleGPUInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
253+ switch msg.String() {
254+ case "ctrl+c":
255+ return a, tea.Quit
256+ case "up", "shift+tab":
257+ a.gpuModel.MoveUp()
258+ return a, nil
259+ case "down", "tab":
260+ a.gpuModel.MoveDown()
261+ return a, nil
262+ case "enter":
263+ selected := a.gpuModel.SelectedOption()
264+ // Update stored form data directly
265+ a.formData.GPUVendor = string(selected.Vendor)
266+ a.formData.GPUDrivers = append([]string{}, selected.Drivers...)
267+ return a.startInstallation()
268+ }
269+
270+ return a, nil
271+}
272+
273+// validateForm validates the form data using domain validation
204274 func (a *App) validateForm() error {
205275 formData := a.formModel.GetData()
206- if formData.Hostname == "" {
207- return ErrInvalidHostname
276+
277+ // Use domain validation for all fields
278+ if err := system.ValidateHostname(formData.Hostname); err != nil {
279+ return err
208280 }
209- if formData.Username == "" {
210- return ErrInvalidUsername
281+
282+ if err := system.ValidateUsername(formData.Username); err != nil {
283+ return err
284+ }
285+
286+ if err := system.ValidatePassword(formData.UserPassword); err != nil {
287+ return err
211288 }
212- if formData.TargetDisk == "" {
213- return ErrInvalidDisk
289+
290+ if err := system.ValidateTimezone(formData.Timezone); err != nil {
291+ return err
214292 }
293+
294+ if err := system.ValidateLocale(formData.Locale); err != nil {
295+ return err
296+ }
297+
298+ if err := system.ValidateKeymap(formData.Keymap); err != nil {
299+ return err
300+ }
301+
302+ // TargetDisk is selected in disk selection screen, not validated here
215303 return nil
216304 }
217305
218306 // startInstallation starts the installation process
219307 func (a *App) startInstallation() (tea.Model, tea.Cmd) {
220- formData := a.formModel.GetData()
221308 a.currentScreen = ScreenInstalling
309+ a.logger.Debug("Starting installation", "password_len", len(a.formData.UserPassword))
222310
223- // Create installation command using handler
224- cmd := handlers.CreateInstallationCommand(a, formData)
311+ // Create installation command using stored form data
312+ cmd := handlers.CreateInstallationCommand(a, a.formData)
225313
226314 return a, tea.Batch(cmd)
227315 }
228316
317+func (a *App) startGPUSelection() (tea.Model, tea.Cmd) {
318+ a.currentScreen = ScreenGPU
319+ return a, a.detectGPUCmd()
320+}
321+
322+func (a *App) detectGPUCmd() tea.Cmd {
323+ return func() tea.Msg {
324+ gpu, err := a.gpuHandler.Detect(a.ctx)
325+ if err != nil {
326+ a.logger.Warn("GPU detection failed", "error", err)
327+ }
328+ return GPUDetectedMsg{GPU: gpu}
329+ }
330+}
331+
332+func (a *App) startDiskSelection() (tea.Model, tea.Cmd) {
333+ a.currentScreen = ScreenDisk
334+ return a, a.detectDisksCmd()
335+}
336+
337+func (a *App) detectDisksCmd() tea.Cmd {
338+ return func() tea.Msg {
339+ disks, err := legacysystem.ListDisks()
340+ return DisksDetectedMsg{Disks: disks, Err: err}
341+ }
342+}
343+
344+func (a *App) detectTimezoneCmd() tea.Cmd {
345+ return func() tea.Msg {
346+ tz := legacysystem.DetectTimezoneSimple()
347+ return TimezoneDetectedMsg{Timezone: tz}
348+ }
349+}
350+
351+func (a *App) handleDiskInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
352+ switch msg.String() {
353+ case "ctrl+c":
354+ return a, tea.Quit
355+ case "up", "shift+tab":
356+ a.diskModel.MoveUp()
357+ return a, nil
358+ case "down", "tab":
359+ a.diskModel.MoveDown()
360+ return a, nil
361+ case "enter":
362+ selected := a.diskModel.SelectedOption()
363+ if selected.Path == "" {
364+ // No disk selected
365+ a.currentScreen = ScreenError
366+ a.installationModel.SetError("No disk selected")
367+ return a, nil
368+ }
369+ // Update stored form data directly
370+ a.formData.TargetDisk = selected.Path
371+ return a.startGPUSelection()
372+ }
373+ return a, nil
374+}
375+
376+func (a *App) handleTimezoneDetected(msg TimezoneDetectedMsg) (tea.Model, tea.Cmd) {
377+ if msg.Timezone != "" {
378+ a.logger.Info("Timezone detected", "timezone", msg.Timezone)
379+ a.formModel.SetTimezone(msg.Timezone)
380+ }
381+ return a, nil
382+}
383+
384+func (a *App) handleDisksDetected(msg DisksDetectedMsg) (tea.Model, tea.Cmd) {
385+ if msg.Err != nil {
386+ a.logger.Warn("Disk detection failed", "error", msg.Err)
387+ a.diskModel.SetError(msg.Err)
388+ } else {
389+ a.diskModel.SetDisks(msg.Disks)
390+ }
391+ return a, nil
392+}
393+
229394 // Close cleans up resources
230395 func (a *App) Close() error {
231396 a.cancel()
232397 return a.installService.Close()
233398 }
399+
400+// SetProgram sets the tea.Program reference for sending messages from goroutines
401+func (a *App) SetProgram(p *tea.Program) {
402+ a.program = p
403+}
404+
405+// GetProgram returns the tea.Program reference
406+func (a *App) GetProgram() *tea.Program {
407+ return a.program
408+}
M internal/interfaces/tui/errors.go +4 -7
@@ -2,12 +2,9 @@ package tui
22
33 import "errors"
44
5-// TUI validation and runtime errors
5+// TUI-specific errors (validation errors are in domain/system package)
66 var (
7- ErrInvalidHostname = errors.New("hostname is required and must be valid")
8- ErrInvalidUsername = errors.New("username is required")
9- ErrInvalidDisk = errors.New("target disk is required")
10- ErrInvalidPassword = errors.New("password must be at least 3 characters")
11- ErrDiskNotFound = errors.New("target disk not found")
12- ErrFormIncomplete = errors.New("form is incomplete, please fill all required fields")
7+ ErrInvalidDisk = errors.New("target disk is required")
8+ ErrDiskNotFound = errors.New("target disk not found")
9+ ErrFormIncomplete = errors.New("form is incomplete, please fill all required fields")
1310 )
M internal/interfaces/tui/handlers/form_handler_test.go +1 -2
@@ -11,8 +11,7 @@ func TestHandleFormUpdate_Navigation(t *testing.T) {
1111 fm := models.NewFormModel()
1212
1313 // Test down navigation
14- downKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}
15- downKey = tea.KeyMsg{Type: tea.KeyDown}
14+ downKey := tea.KeyMsg{Type: tea.KeyDown}
1615 _, _ = HandleFormUpdate(fm, downKey)
1716
1817 // The handler would call focusNext()
M internal/interfaces/tui/handlers/install_handler.go +231 -37
@@ -1,7 +1,14 @@
11 package handlers
22
33 import (
4+ "strings"
5+
6+ "github.com/bnema/archup/internal/application/commands"
47 "github.com/bnema/archup/internal/application/dto"
8+ "github.com/bnema/archup/internal/config"
9+ "github.com/bnema/archup/internal/domain/bootloader"
10+ "github.com/bnema/archup/internal/domain/disk"
11+ "github.com/bnema/archup/internal/domain/packages"
512 "github.com/bnema/archup/internal/interfaces/tui/models"
613 tea "github.com/charmbracelet/bubbletea"
714 )
@@ -10,38 +17,39 @@ import (
1017 // This breaks the circular dependency: tui → tui/handlers → tui
1118 // Messages are typed by the caller (app.go), not here
1219
20+// ProgressUpdateMsg wraps progress update for bubbletea
21+type ProgressUpdateMsg struct {
22+ Update *dto.ProgressUpdate
23+}
24+
25+// InstallationErrorMsg wraps installation error for bubbletea
26+type InstallationErrorMsg struct {
27+ Err error
28+}
29+
30+// InstallationCompleteMsg signals installation completion
31+type InstallationCompleteMsg struct {
32+ Duration int
33+}
34+
1335 // HandleProgressUpdate processes progress update messages
14-// msg should be ProgressUpdateMsg with an Update field (*dto.ProgressUpdate)
1536 func HandleProgressUpdate(app AppContext, msg interface{}, progressModel *models.ProgressModelImpl) (*models.ProgressModelImpl, tea.Cmd) {
16- // Cast to a type with Update field
17- // This avoids importing the tui package which would create a cycle
18- type updateMsg struct {
19- Update interface{}
20- }
21-
22- if m, ok := msg.(updateMsg); ok {
23- // Cast Update to *dto.ProgressUpdate
24- if update, ok := m.Update.(*dto.ProgressUpdate); ok {
25- progressModel.UpdateProgress(update)
26- }
37+ if m, ok := msg.(ProgressUpdateMsg); ok {
38+ progressModel.UpdateProgress(m.Update)
2739 }
2840 return progressModel, nil
2941 }
3042
3143 // HandleInstallationError processes installation error messages
32-// Signature matches what app.go calls with InstallationErrorMsg types
3344 func HandleInstallationError(app AppContext, msg interface{}, installModel *models.InstallationModelImpl) (*models.InstallationModelImpl, tea.Cmd) {
34- // Cast msg to InstallationErrorMsg type (from app.go caller context)
35- if errMsg, ok := msg.(interface{ GetErr() error }); ok {
36- err := errMsg.GetErr()
37- app.GetLogger().Error("Installation error", "error", err)
38- installModel.SetError(err.Error())
45+ if errMsg, ok := msg.(InstallationErrorMsg); ok {
46+ app.GetLogger().Error("Installation error", "error", errMsg.Err)
47+ installModel.SetError(errMsg.Err.Error())
3948 }
4049 return installModel, nil
4150 }
4251
4352 // HandleInstallationComplete processes installation completion messages
44-// Signature matches what app.go calls with InstallationCompleteMsg types
4553 func HandleInstallationComplete(app AppContext, msg interface{}, installModel *models.InstallationModelImpl) (*models.InstallationModelImpl, tea.Cmd) {
4654 app.GetLogger().Info("Installation completed successfully")
4755 installModel.SetComplete()
@@ -49,38 +57,224 @@ func HandleInstallationComplete(app AppContext, msg interface{}, installModel *m
4957 }
5058
5159 // CreateInstallationCommand creates an installation command from form data
52-// This delegates to the service layer instead of handling directly
53-// Accepts AppContext interface to avoid circular imports
60+// Runs the full installation in a goroutine and sends progress via p.Send()
5461 func CreateInstallationCommand(app AppContext, formData models.FormData) tea.Cmd {
5562 return func() tea.Msg {
63+ ctx := app.GetContext()
64+ svc := app.GetInstallService()
65+ logger := app.GetLogger()
66+ program := app.GetProgram()
67+
68+ encryptionType := normalizeEncryptionType(formData.EncryptionType)
69+
5670 // Start installation via service
57- if err := app.GetInstallService().Start(
58- app.GetContext(),
59- formData.Hostname,
60- formData.Username,
61- formData.TargetDisk,
62- formData.EncryptionType,
63- ); err != nil {
64- app.GetLogger().Error("Failed to start installation", "error", err)
65- // Return error directly - app.go will handle it
66- return err
71+ if err := svc.Start(ctx, formData.Hostname, formData.Username, formData.TargetDisk, encryptionType); err != nil {
72+ logger.Error("Failed to start installation", "error", err)
73+ return InstallationErrorMsg{Err: err}
6774 }
6875
69- // Subscribe to progress updates
76+ // Subscribe to progress updates and forward to bubbletea
7077 progressChan := app.GetProgressTracker().Subscribe()
7178 go func() {
7279 for {
7380 select {
74- case <-progressChan:
75- // Progress updates are broadcast to all subscribers
76- // App will receive them through its own subscription
77- case <-app.GetContext().Done():
78- app.GetLogger().Info("Installation context cancelled")
81+ case update, ok := <-progressChan:
82+ if !ok {
83+ return
84+ }
85+ if program != nil {
86+ program.Send(ProgressUpdateMsg{Update: update})
87+ }
88+ case <-ctx.Done():
89+ logger.Info("Installation context cancelled")
7990 return
8091 }
8192 }
8293 }()
8394
95+ // Run installation phases in background goroutine
96+ go func() {
97+ // Phase 1: Preflight
98+ if _, err := svc.RunPreflight(ctx); err != nil {
99+ logger.Error("Preflight checks failed", "error", err)
100+ if program != nil {
101+ program.Send(InstallationErrorMsg{Err: err})
102+ }
103+ return
104+ }
105+
106+ // Phase 2: Partition disk with chosen encryption using user password
107+ partitionCmd := commands.PartitionDiskCommand{
108+ TargetDisk: formData.TargetDisk,
109+ RootSizeGB: 0, // Use all available space
110+ BootSizeGB: 4, // 4GB for limine-snapper-sync
111+ EncryptionType: parseEncryptionType(formData.EncryptionType),
112+ EncryptionPassword: formData.UserPassword, // Use account password for disk encryption
113+ FilesystemType: disk.FilesystemBtrfs,
114+ WipeDisks: true,
115+ }
116+ partitionResult, err := svc.RunPartition(ctx, partitionCmd)
117+ if err != nil {
118+ logger.Error("Partitioning failed", "error", err)
119+ if program != nil {
120+ program.Send(InstallationErrorMsg{Err: err})
121+ }
122+ return
123+ }
124+
125+ // Phase 3: Base install
126+ baseCmd := commands.InstallBaseCommand{
127+ TargetDisk: formData.TargetDisk,
128+ MountPoint: "/mnt",
129+ KernelVariant: parseKernelVariant(formData.KernelVariant),
130+ IncludeMicrocode: formData.Microcode,
131+ }
132+ if _, err := svc.RunBaseInstall(ctx, baseCmd); err != nil {
133+ logger.Error("Base installation failed", "error", err)
134+ if program != nil {
135+ program.Send(InstallationErrorMsg{Err: err})
136+ }
137+ return
138+ }
139+
140+ // Phase 4: Configure system
141+ configCmd := commands.ConfigureSystemCommand{
142+ MountPoint: "/mnt",
143+ Hostname: formData.Hostname,
144+ Timezone: formData.Timezone,
145+ Locale: formData.Locale,
146+ Keymap: formData.Keymap,
147+ Username: formData.Username,
148+ UserShell: "/bin/bash",
149+ UserPassword: formData.UserPassword,
150+ RootPassword: formData.RootPassword,
151+ }
152+ if _, err := svc.RunConfigSystem(ctx, configCmd); err != nil {
153+ logger.Error("System configuration failed", "error", err)
154+ if program != nil {
155+ program.Send(InstallationErrorMsg{Err: err})
156+ }
157+ return
158+ }
159+
160+ // Phase 5: Bootloader
161+ bootCmd := commands.InstallBootloaderCommand{
162+ MountPoint: "/mnt",
163+ BootloaderType: bootloader.BootloaderTypeLimine,
164+ TimeoutSeconds: 5,
165+ Branding: "ArchUp",
166+ KernelVariant: parseKernelVariant(formData.KernelVariant),
167+ RootPartition: partitionResult.RootPartition,
168+ EncryptionType: partitionCmd.EncryptionType,
169+ EFIPartition: partitionResult.EFIPartition,
170+ TargetDisk: formData.TargetDisk,
171+ }
172+ if _, err := svc.RunBootloaderSetup(ctx, bootCmd); err != nil {
173+ logger.Error("Bootloader setup failed", "error", err)
174+ if program != nil {
175+ program.Send(InstallationErrorMsg{Err: err})
176+ }
177+ return
178+ }
179+
180+ // Phase 6: Repository setup
181+ repoCmd := commands.SetupRepositoriesCommand{
182+ MountPoint: "/mnt",
183+ EnableMultilib: true,
184+ EnableChaotic: true,
185+ AURHelper: parseAURHelper(formData.AURHelper),
186+ }
187+ if _, err := svc.RunRepositorySetup(ctx, repoCmd); err != nil {
188+ logger.Error("Repository setup failed", "error", err)
189+ if program != nil {
190+ program.Send(InstallationErrorMsg{Err: err})
191+ }
192+ return
193+ }
194+
195+ // Phase 7: Post-install
196+ postCmd := commands.PostInstallCommand{
197+ MountPoint: "/mnt",
198+ Username: formData.Username,
199+ PlymouthTheme: config.PlymouthThemeName,
200+ RunPostBootScripts: true,
201+ }
202+ if _, err := svc.RunPostInstall(ctx, postCmd); err != nil {
203+ logger.Error("Post-installation failed", "error", err)
204+ if program != nil {
205+ program.Send(InstallationErrorMsg{Err: err})
206+ }
207+ return
208+ }
209+ // Complete installation
210+ if err := svc.Complete(ctx); err != nil {
211+ logger.Error("Failed to complete installation", "error", err)
212+ if program != nil {
213+ program.Send(InstallationErrorMsg{Err: err})
214+ }
215+ return
216+ }
217+
218+ // Send completion message
219+ if program != nil {
220+ program.Send(InstallationCompleteMsg{Duration: 0})
221+ }
222+ }()
223+
84224 return nil
85225 }
86226 }
227+
228+// parseKernelVariant converts string to KernelVariant
229+func parseKernelVariant(s string) packages.KernelVariant {
230+ switch s {
231+ case "linux-zen", "zen":
232+ return packages.KernelZen
233+ case "linux-lts", "lts":
234+ return packages.KernelLTS
235+ case "linux-hardened", "hardened":
236+ return packages.KernelHardened
237+ case "linux-cachyos", "cachyos":
238+ return packages.KernelCachyOS
239+ default:
240+ return packages.KernelStable
241+ }
242+}
243+
244+// parseAURHelper converts string to AURHelper
245+func parseAURHelper(s string) packages.AURHelper {
246+ switch s {
247+ case "yay":
248+ return packages.AURHelperYay
249+ default:
250+ return packages.AURHelperParu
251+ }
252+}
253+
254+func normalizeEncryptionType(value string) string {
255+ if value == "" {
256+ return "luks"
257+ }
258+
259+ switch strings.ToLower(value) {
260+ case "none":
261+ return "none"
262+ case "luks", "luks+", "luks+standard":
263+ return "luks"
264+ case "luks-lvm", "luks+lvm":
265+ return "luks-lvm"
266+ default:
267+ return "luks"
268+ }
269+}
270+
271+func parseEncryptionType(value string) disk.EncryptionType {
272+ switch normalizeEncryptionType(value) {
273+ case "none":
274+ return disk.EncryptionTypeNone
275+ case "luks-lvm":
276+ return disk.EncryptionTypeLUKSLVM
277+ default:
278+ return disk.EncryptionTypeLUKS
279+ }
280+}
M internal/interfaces/tui/handlers/interfaces.go +4 -0
@@ -5,6 +5,7 @@ import (
55
66 "github.com/bnema/archup/internal/application/services"
77 "github.com/bnema/archup/internal/domain/ports"
8+ tea "github.com/charmbracelet/bubbletea"
89 )
910
1011 // AppContext defines the interface for accessing app resources from handlers
@@ -21,4 +22,7 @@ type AppContext interface {
2122
2223 // GetContext returns the app context
2324 GetContext() context.Context
25+
26+ // GetProgram returns the tea.Program for sending messages from goroutines
27+ GetProgram() *tea.Program
2428 }
M internal/interfaces/tui/messages.go +20 -13
@@ -1,16 +1,12 @@
11 package tui
22
3-import "github.com/bnema/archup/internal/application/dto"
3+import (
4+ "github.com/bnema/archup/internal/domain/system"
5+ legacysystem "github.com/bnema/archup/internal/system"
6+)
47
5-// InstallationErrorMsg is sent when an error occurs during installation
6-type InstallationErrorMsg struct {
7- Err error
8-}
9-
10-// InstallationCompleteMsg is sent when installation completes successfully
11-type InstallationCompleteMsg struct {
12- Duration int // seconds
13-}
8+// Note: ProgressUpdateMsg, InstallationErrorMsg, InstallationCompleteMsg
9+// are defined in handlers package to avoid circular imports
1410
1511 // FormSubmitMsg is sent when the form is submitted
1612 type FormSubmitMsg struct {
@@ -22,7 +18,18 @@ type ScreenChangeMsg struct {
2218 Screen Screen
2319 }
2420
25-// ProgressUpdateMsg is sent when installation progress updates
26-type ProgressUpdateMsg struct {
27- Update *dto.ProgressUpdate
21+// GPUDetectedMsg is sent when GPU detection completes
22+type GPUDetectedMsg struct {
23+ GPU *system.GPU
24+}
25+
26+// TimezoneDetectedMsg is sent when timezone detection completes
27+type TimezoneDetectedMsg struct {
28+ Timezone string
29+}
30+
31+// DisksDetectedMsg is sent when disk detection completes
32+type DisksDetectedMsg struct {
33+ Disks []legacysystem.Disk
34+ Err error
2835 }
M internal/interfaces/tui/models/form.go +49 -30
@@ -24,10 +24,13 @@ func (bm *BaseModel) SetSize(width, height int) {
2424 type FormData struct {
2525 Hostname string
2626 Username string
27+ UserEmail string
2728 UserPassword string
2829 RootPassword string
2930 TargetDisk string
3031 EncryptionType string
32+ GPUVendor string
33+ GPUDrivers []string
3134 Timezone string
3235 Locale string
3336 Keymap string
@@ -43,8 +46,8 @@ type FormModelImpl struct {
4346 // Form fields
4447 hostname textinput.Model
4548 username textinput.Model
49+ userEmail textinput.Model
4650 userPassword textinput.Model
47- rootPassword textinput.Model
4851 targetDisk textinput.Model
4952 timezone textinput.Model
5053 locale textinput.Model
@@ -66,32 +69,40 @@ func NewFormModel() *FormModelImpl {
6669 fields: []textinput.Model{},
6770 }
6871
69- // Initialize text inputs with labels
70- fm.hostname = createTextInput("Hostname", "myarch", "Enter system hostname")
71- fm.username = createTextInput("Username", "archuser", "Enter regular user name")
72+ // Initialize text inputs with default values
73+ fm.hostname = createTextInput("Hostname", "", "Enter system hostname")
74+ fm.hostname.SetValue("myarch")
75+
76+ fm.username = createTextInput("Username", "", "Enter regular user name")
77+ fm.username.SetValue("archuser")
78+
79+ fm.userEmail = createTextInput("Email", "", "Enter email for git config")
80+
7281 fm.userPassword = createTextInput("User Password", "", "Enter user password")
7382 fm.userPassword.EchoMode = textinput.EchoPassword
74- fm.rootPassword = createTextInput("Root Password", "", "Enter root password")
75- fm.rootPassword.EchoMode = textinput.EchoPassword
76- fm.targetDisk = createTextInput("Target Disk", "/dev/sda", "Enter target disk (e.g., /dev/sda)")
77- fm.timezone = createTextInput("Timezone", "UTC", "Enter timezone")
78- fm.locale = createTextInput("Locale", "en_US.UTF-8", "Enter locale")
79- fm.keymap = createTextInput("Keymap", "us", "Enter keymap")
83+
84+ // No root password - user will be sudoer
85+ // targetDisk is selected via disk selection screen, not a text field
86+ fm.timezone = createTextInput("Timezone", "", "Enter timezone")
87+ fm.timezone.SetValue("UTC")
88+
89+ fm.locale = createTextInput("Locale", "", "Enter locale")
90+ fm.locale.SetValue("en_US.UTF-8")
91+
92+ fm.keymap = createTextInput("Keymap", "", "Enter keymap")
93+ fm.keymap.SetValue("us")
8094
8195 fm.fields = []textinput.Model{
82- fm.hostname, fm.username, fm.userPassword, fm.rootPassword,
83- fm.targetDisk, fm.timezone, fm.locale, fm.keymap,
96+ fm.hostname, fm.username, fm.userEmail, fm.userPassword,
97+ fm.timezone, fm.locale, fm.keymap,
8498 }
8599
86- // Set first field as focused
87- fm.fields[0].Focus()
88-
89100 return fm
90101 }
91102
92-// Init initializes the form model
103+// Init initializes the form model by focusing the first field
93104 func (fm *FormModelImpl) Init() tea.Cmd {
94- return textinput.Blink
105+ return fm.fields[0].Focus()
95106 }
96107
97108 // Update handles input and updates the form
@@ -137,9 +148,8 @@ func (fm *FormModelImpl) View() string {
137148 labels := []string{
138149 "Hostname:",
139150 "Username:",
140- "User Password:",
141- "Root Password:",
142- "Target Disk:",
151+ "Email(opt):",
152+ "Password:",
143153 "Timezone:",
144154 "Locale:",
145155 "Keymap:",
@@ -198,7 +208,6 @@ func (fm *FormModelImpl) SetData(data FormData) {
198208 fm.hostname.SetValue(data.Hostname)
199209 fm.username.SetValue(data.Username)
200210 fm.userPassword.SetValue(data.UserPassword)
201- fm.rootPassword.SetValue(data.RootPassword)
202211 fm.targetDisk.SetValue(data.TargetDisk)
203212 fm.timezone.SetValue(data.Timezone)
204213 fm.locale.SetValue(data.Locale)
@@ -255,6 +264,11 @@ func (fm *FormModelImpl) ExtractData() {
255264 fm.extractData()
256265 }
257266
267+// SetTimezone sets the timezone field value
268+func (fm *FormModelImpl) SetTimezone(tz string) {
269+ fm.timezone.SetValue(tz)
270+}
271+
258272 // Helper methods
259273
260274 func (fm *FormModelImpl) focusNext() {
@@ -273,20 +287,25 @@ func (fm *FormModelImpl) focusPrevious() {
273287 }
274288
275289 func (fm *FormModelImpl) updateInput(msg tea.Msg) tea.Cmd {
276- _, cmd := fm.fields[fm.focusIndex].Update(msg)
290+ var cmd tea.Cmd
291+ fm.fields[fm.focusIndex], cmd = fm.fields[fm.focusIndex].Update(msg)
277292 return cmd
278293 }
279294
280295 func (fm *FormModelImpl) extractData() {
296+ // Use fm.fields slice values, not the original fields (which are copies)
297+ // Field order: hostname(0), username(1), email(2), password(3), timezone(4), locale(5), keymap(6)
281298 fm.data = FormData{
282- Hostname: fm.hostname.Value(),
283- Username: fm.username.Value(),
284- UserPassword: fm.userPassword.Value(),
285- RootPassword: fm.rootPassword.Value(),
286- TargetDisk: fm.targetDisk.Value(),
287- Timezone: fm.timezone.Value(),
288- Locale: fm.locale.Value(),
289- Keymap: fm.keymap.Value(),
299+ Hostname: fm.fields[0].Value(),
300+ Username: fm.fields[1].Value(),
301+ UserEmail: fm.fields[2].Value(), // Optional - for git config
302+ UserPassword: fm.fields[3].Value(),
303+ RootPassword: "", // No root password - root account locked, user is sudoer
304+ TargetDisk: fm.targetDisk.Value(),
305+ EncryptionType: fm.data.EncryptionType,
306+ Timezone: fm.fields[4].Value(),
307+ Locale: fm.fields[5].Value(),
308+ Keymap: fm.fields[6].Value(),
290309 }
291310 }
292311
M internal/interfaces/tui/views/form_view.go +2 -3
@@ -21,9 +21,8 @@ func RenderForm(fm *models.FormModelImpl) string {
2121 labels := []string{
2222 "Hostname:",
2323 "Username:",
24- "User Password:",
25- "Root Password:",
26- "Target Disk:",
24+ "Email(opt):",
25+ "Password:",
2726 "Timezone:",
2827 "Locale:",
2928 "Keymap:",
M internal/interfaces/tui/views/form_view_test.go +2 -2
@@ -83,8 +83,8 @@ func TestFormModelCreation(t *testing.T) {
8383
8484 // Verify initial state
8585 fields := fm.GetFields()
86- if len(fields) != 8 {
87- t.Errorf("Expected 8 form fields, got %d", len(fields))
86+ if len(fields) != 7 {
87+ t.Errorf("Expected 7 form fields, got %d", len(fields))
8888 }
8989
9090 if fm.GetFocusIndex() != 0 {
M internal/interfaces/tui/views/summary_view.go +1 -3
@@ -15,9 +15,7 @@ func RenderSummary(im *models.InstallationModelImpl) string {
1515 return RenderError(err)
1616 }
1717
18- if !im.IsComplete() {
19- return RenderStatus(im)
20- }
18+ _ = im.IsComplete() // keep for potential future use
2119
2220 var b strings.Builder
2321
M internal/phases/base.go +14 -9
@@ -13,9 +13,8 @@ import (
1313 // BaseInstallPhase handles base system installation
1414 type BaseInstallPhase struct {
1515 *BasePhase
16- fs interfaces.FileSystem
17- sysExec interfaces.SystemExecutor
18- packages []string
16+ fs interfaces.FileSystem
17+ sysExec interfaces.SystemExecutor
1918 }
2019
2120 // NewBaseInstallPhase creates a new base installation phase
@@ -60,8 +59,7 @@ func (p *BaseInstallPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResu
6059 }
6160
6261 // Step 2.5: Configure CachyOS repository on ISO if needed
63- switch {
64- case p.config.KernelChoice == config.KernelLinuxCachyOS:
62+ if p.config.KernelChoice == config.KernelLinuxCachyOS {
6563 p.SendProgress(progressChan, "Configuring CachyOS repository...", 2, 5)
6664 switch err := p.configureCachyOSRepo(progressChan); {
6765 case err != nil:
@@ -263,7 +261,11 @@ func (p *BaseInstallPhase) detectCPU(progressChan chan<- ProgressUpdate) error {
263261 case err != nil:
264262 return fmt.Errorf("failed to read /proc/cpuinfo: %w", err)
265263 }
266- defer file.Close()
264+ defer func() {
265+ if err := file.Close(); err != nil {
266+ p.logger.Warn("Failed to close file", "error", err)
267+ }
268+ }()
267269
268270 scanner := bufio.NewScanner(file)
269271 var vendor string
@@ -363,8 +365,7 @@ func (p *BaseInstallPhase) runPacstrap(progressChan chan<- ProgressUpdate) error
363365 p.SendOutput(progressChan, fmt.Sprintf("[OK] Installed %d packages", len(packages)))
364366
365367 // Copy CachyOS configuration to installed system if needed
366- switch {
367- case p.config.KernelChoice == config.KernelLinuxCachyOS:
368+ if p.config.KernelChoice == config.KernelLinuxCachyOS {
368369 p.SendOutput(progressChan, "Configuring CachyOS repository in installed system...")
369370 if err := p.copyCachyOSConfig(progressChan); err != nil {
370371 return fmt.Errorf("failed to configure CachyOS in installed system: %w", err)
@@ -385,7 +386,11 @@ func (p *BaseInstallPhase) loadBasePackages() ([]string, error) {
385386 case err != nil:
386387 return nil, fmt.Errorf("failed to open %s: %w", packageFile, err)
387388 }
388- defer file.Close()
389+ defer func() {
390+ if err := file.Close(); err != nil {
391+ p.logger.Warn("Failed to close file", "error", err)
392+ }
393+ }()
389394
390395 var packages []string
391396 scanner := bufio.NewScanner(file)
M internal/phases/base_test.go +40 -8
@@ -51,7 +51,11 @@ func TestBaseInstallPhasePreCheck(t *testing.T) {
5151 if err != nil {
5252 t.Fatalf("Failed to create logger: %v", err)
5353 }
54- defer log.Close()
54+ defer func() {
55+ if err := log.Close(); err != nil {
56+ t.Fatalf("failed to close log: %v", err)
57+ }
58+ }()
5559
5660 cfg := config.NewConfig("test")
5761 phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
@@ -126,7 +130,11 @@ func TestBaseInstallPhaseDetectCPU(t *testing.T) {
126130 if err != nil {
127131 t.Fatalf("Failed to create logger: %v", err)
128132 }
129- defer log.Close()
133+ defer func() {
134+ if err := log.Close(); err != nil {
135+ t.Fatalf("failed to close log: %v", err)
136+ }
137+ }()
130138
131139 cfg := config.NewConfig("test")
132140 phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
@@ -208,7 +216,11 @@ func TestBaseInstallPhaseLoadBasePackages(t *testing.T) {
208216 if err != nil {
209217 t.Fatalf("Failed to create logger: %v", err)
210218 }
211- defer log.Close()
219+ defer func() {
220+ if err := log.Close(); err != nil {
221+ t.Fatalf("failed to close log: %v", err)
222+ }
223+ }()
212224
213225 cfg := config.NewConfig("test")
214226 phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
@@ -271,7 +283,11 @@ func TestBaseInstallPhaseGenerateFstab(t *testing.T) {
271283 if err != nil {
272284 t.Fatalf("Failed to create logger: %v", err)
273285 }
274- defer log.Close()
286+ defer func() {
287+ if err := log.Close(); err != nil {
288+ t.Fatalf("failed to close log: %v", err)
289+ }
290+ }()
275291
276292 cfg := config.NewConfig("test")
277293 phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
@@ -352,7 +368,11 @@ func TestBaseInstallPhasePostCheck(t *testing.T) {
352368 if err != nil {
353369 t.Fatalf("Failed to create logger: %v", err)
354370 }
355- defer log.Close()
371+ defer func() {
372+ if err := log.Close(); err != nil {
373+ t.Fatalf("failed to close log: %v", err)
374+ }
375+ }()
356376
357377 cfg := config.NewConfig("test")
358378 cfg.ConfigPath = filepath.Join(tmpDir, "config")
@@ -384,7 +404,11 @@ func TestBaseInstallPhaseExecute(t *testing.T) {
384404 if err != nil {
385405 t.Fatalf("Failed to create logger: %v", err)
386406 }
387- defer log.Close()
407+ defer func() {
408+ if err := log.Close(); err != nil {
409+ t.Fatalf("failed to close log: %v", err)
410+ }
411+ }()
388412
389413 ctrl := gomock.NewController(t)
390414 defer ctrl.Finish()
@@ -422,7 +446,11 @@ func TestBaseInstallPhaseRollback(t *testing.T) {
422446 if err != nil {
423447 t.Fatalf("Failed to create logger: %v", err)
424448 }
425- defer log.Close()
449+ defer func() {
450+ if err := log.Close(); err != nil {
451+ t.Fatalf("failed to close log: %v", err)
452+ }
453+ }()
426454
427455 cfg := config.NewConfig("test")
428456 phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
@@ -448,7 +476,11 @@ func TestBaseInstallPhaseCanSkip(t *testing.T) {
448476 if err != nil {
449477 t.Fatalf("Failed to create logger: %v", err)
450478 }
451- defer log.Close()
479+ defer func() {
480+ if err := log.Close(); err != nil {
481+ t.Fatalf("failed to close log: %v", err)
482+ }
483+ }()
452484
453485 cfg := config.NewConfig("test")
454486 phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
M internal/phases/boot.go +13 -3
@@ -117,6 +117,17 @@ func (p *BootPhase) configureMkinitcpio(progressChan chan<- ProgressUpdate) erro
117117 return fmt.Errorf("failed to write mkinitcpio.conf: %w", err)
118118 }
119119
120+ // Enable fallback preset for the kernel (some kernels like linux-cachyos have it disabled by default)
121+ presetPath := fmt.Sprintf("/etc/mkinitcpio.d/%s.preset", p.config.KernelChoice)
122+ enableFallbackCmd := fmt.Sprintf(
123+ "sed -i \"s/#PRESETS=('default' 'fallback')/PRESETS=('default' 'fallback')/\" %s && "+
124+ "sed -i 's/#fallback_image/fallback_image/' %s && "+
125+ "sed -i 's/#fallback_options/fallback_options/' %s",
126+ presetPath, presetPath, presetPath)
127+ if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, enableFallbackCmd); err != nil {
128+ p.SendOutput(progressChan, "[WARN] Could not enable fallback preset (may already be enabled)")
129+ }
130+
120131 // Regenerate initramfs
121132 p.SendOutput(progressChan, "Regenerating initramfs...")
122133 if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "mkinitcpio -P"); err != nil {
@@ -218,7 +229,6 @@ func (p *BootPhase) configureLimine(progressChan chan<- ProgressUpdate) error {
218229 // Replace template variables
219230 limineConfig := string(templateContent)
220231 limineConfig = strings.ReplaceAll(limineConfig, "{{TIMEOUT}}", config.LimineTimeout)
221- limineConfig = strings.ReplaceAll(limineConfig, "{{DEFAULT_ENTRY}}", config.LimineEntry)
222232 limineConfig = strings.ReplaceAll(limineConfig, "{{BRANDING}}", config.LimineBranding)
223233 limineConfig = strings.ReplaceAll(limineConfig, "{{COLOR}}", config.LimineColor)
224234 limineConfig = strings.ReplaceAll(limineConfig, "{{KERNEL}}", p.config.KernelChoice)
@@ -283,13 +293,13 @@ func (p *BootPhase) PostCheck() error {
283293 // Verify Limine bootloader exists
284294 bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
285295 if _, err := p.fs.Stat(bootloaderPath); p.fs.IsNotExist(err) {
286- return fmt.Errorf("Limine bootloader was not installed")
296+ return fmt.Errorf("limine bootloader was not installed")
287297 }
288298
289299 // Verify Limine config exists
290300 configPath := config.PathMntBootLimineConf
291301 if _, err := p.fs.Stat(configPath); p.fs.IsNotExist(err) {
292- return fmt.Errorf("Limine config was not created")
302+ return fmt.Errorf("limine config was not created")
293303 }
294304
295305 return p.config.Save()
M internal/phases/boot_test.go +45 -9
@@ -64,7 +64,11 @@ func TestBootPhasePreCheck(t *testing.T) {
6464 if err != nil {
6565 t.Fatalf("Failed to create logger: %v", err)
6666 }
67- defer log.Close()
67+ defer func() {
68+ if err := log.Close(); err != nil {
69+ t.Fatalf("failed to close log: %v", err)
70+ }
71+ }()
6872
6973 cfg := config.NewConfig("test")
7074 phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
@@ -142,7 +146,11 @@ func TestBootPhaseExtractPartitionNumber(t *testing.T) {
142146 if err != nil {
143147 t.Fatalf("Failed to create logger: %v", err)
144148 }
145- defer log.Close()
149+ defer func() {
150+ if err := log.Close(); err != nil {
151+ t.Fatalf("failed to close log: %v", err)
152+ }
153+ }()
146154
147155 cfg := config.NewConfig("test")
148156 phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
@@ -220,7 +228,11 @@ func TestBootPhasePostCheck(t *testing.T) {
220228 if err != nil {
221229 t.Fatalf("Failed to create logger: %v", err)
222230 }
223- defer log.Close()
231+ defer func() {
232+ if err := log.Close(); err != nil {
233+ t.Fatalf("failed to close log: %v", err)
234+ }
235+ }()
224236
225237 cfg := config.NewConfig("test")
226238 cfg.ConfigPath = filepath.Join(tmpDir, "config")
@@ -281,7 +293,11 @@ func TestBootPhaseRollback(t *testing.T) {
281293 if err != nil {
282294 t.Fatalf("Failed to create logger: %v", err)
283295 }
284- defer log.Close()
296+ defer func() {
297+ if err := log.Close(); err != nil {
298+ t.Fatalf("failed to close log: %v", err)
299+ }
300+ }()
285301
286302 cfg := config.NewConfig("test")
287303 phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
@@ -309,7 +325,11 @@ func TestBootPhaseCanSkip(t *testing.T) {
309325 if err != nil {
310326 t.Fatalf("Failed to create logger: %v", err)
311327 }
312- defer log.Close()
328+ defer func() {
329+ if err := log.Close(); err != nil {
330+ t.Fatalf("failed to close log: %v", err)
331+ }
332+ }()
313333
314334 cfg := config.NewConfig("test")
315335 phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
@@ -341,7 +361,11 @@ func TestBootPhaseExecute(t *testing.T) {
341361 if err != nil {
342362 t.Fatalf("Failed to create logger: %v", err)
343363 }
344- defer log.Close()
364+ defer func() {
365+ if err := log.Close(); err != nil {
366+ t.Fatalf("failed to close log: %v", err)
367+ }
368+ }()
345369
346370 cfg := config.NewConfig("test")
347371 cfg.TargetDisk = "/dev/sda"
@@ -404,7 +428,11 @@ func TestBootPhaseKernelParameterConstruction(t *testing.T) {
404428 if err != nil {
405429 t.Fatalf("Failed to create logger: %v", err)
406430 }
407- defer log.Close()
431+ defer func() {
432+ if err := log.Close(); err != nil {
433+ t.Fatalf("failed to close log: %v", err)
434+ }
435+ }()
408436
409437 cfg := config.NewConfig("test")
410438 cfg.EncryptionType = tt.encryptionType
@@ -454,7 +482,11 @@ func TestBootPhaseInstallPathConstruction(t *testing.T) {
454482 if err != nil {
455483 t.Fatalf("Failed to create logger: %v", err)
456484 }
457- defer log.Close()
485+ defer func() {
486+ if err := log.Close(); err != nil {
487+ t.Fatalf("failed to close log: %v", err)
488+ }
489+ }()
458490
459491 cfg := config.NewConfig("test")
460492 phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
@@ -512,7 +544,11 @@ func TestBootPhaseFileSystemOperations(t *testing.T) {
512544 if err != nil {
513545 t.Fatalf("Failed to create logger: %v", err)
514546 }
515- defer log.Close()
547+ defer func() {
548+ if err := log.Close(); err != nil {
549+ t.Fatalf("failed to close log: %v", err)
550+ }
551+ }()
516552
517553 cfg := config.NewConfig("test")
518554 phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
M internal/phases/bootstrap.go +117 -3
@@ -4,7 +4,9 @@ import (
44 "fmt"
55 "io"
66 "net/http"
7+ "os/exec"
78 "path/filepath"
9+ "strings"
810
911 "github.com/bnema/archup/internal/config"
1012 "github.com/bnema/archup/internal/interfaces"
@@ -34,7 +36,9 @@ func (p *BootstrapPhase) PreCheck() error {
3436 if err != nil {
3537 return fmt.Errorf("no internet connectivity: %w", err)
3638 }
37- defer resp.Body.Close()
39+ if err := resp.Body.Close(); err != nil {
40+ return fmt.Errorf("failed to close connectivity response: %w", err)
41+ }
3842
3943 return nil
4044 }
@@ -49,6 +53,17 @@ func (p *BootstrapPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult
4953 return PhaseResult{Success: false, Error: err}
5054 }
5155
56+ if err := p.cloneRepo(); err == nil {
57+ if err := p.copyRepoFiles(progressChan); err != nil {
58+ p.SendError(progressChan, err)
59+ return PhaseResult{Success: false, Error: err}
60+ }
61+ p.SendComplete(progressChan, "Bootstrap complete")
62+ return PhaseResult{Success: true, Message: "Configuration files prepared from repo clone"}
63+ } else {
64+ p.SendOutput(progressChan, fmt.Sprintf("[WARN] Repo clone failed, falling back to downloads: %v", err))
65+ }
66+
5267 // Files to download
5368 files := []struct {
5469 url string
@@ -123,6 +138,93 @@ func (p *BootstrapPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult
123138 return PhaseResult{Success: true, Message: "All configuration files downloaded"}
124139 }
125140
141+func (p *BootstrapPhase) cloneRepo() error {
142+ if p.config.RepoURL == "" {
143+ return fmt.Errorf("repo URL not configured")
144+ }
145+ if err := p.fs.RemoveAll(config.DefaultInstallRepoDir); err != nil {
146+ return fmt.Errorf("failed to clear repo directory: %w", err)
147+ }
148+
149+ branch := branchFromRawURL(p.config.RawURL)
150+ args := []string{"clone", "--depth", "1", "--branch", branch, "--single-branch", p.config.RepoURL, config.DefaultInstallRepoDir}
151+ cmd := exec.Command("git", args...)
152+ output, err := cmd.CombinedOutput()
153+ if err != nil {
154+ return fmt.Errorf("failed to clone repo: %w (output: %s)", err, strings.TrimSpace(string(output)))
155+ }
156+
157+ return nil
158+}
159+
160+func (p *BootstrapPhase) copyRepoFiles(progressChan chan<- ProgressUpdate) error {
161+ repoRoot := config.DefaultInstallRepoDir
162+ files := []struct {
163+ src string
164+ dest string
165+ }{
166+ {filepath.Join(repoRoot, "install", "base.packages"), filepath.Join(config.DefaultInstallDir, "base.packages")},
167+ {filepath.Join(repoRoot, "install", "extra.packages"), filepath.Join(config.DefaultInstallDir, "extra.packages")},
168+ {filepath.Join(repoRoot, "install", "configs", "limine.conf.template"), filepath.Join(config.DefaultInstallDir, "configs", "limine.conf.template")},
169+ {filepath.Join(repoRoot, "install", "configs", "chaotic-aur.conf"), filepath.Join(config.DefaultInstallDir, "configs", "chaotic-aur.conf")},
170+ {filepath.Join(repoRoot, "install", "configs", "shell", "shell"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "shell")},
171+ {filepath.Join(repoRoot, "install", "configs", "shell", "starship.toml"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "starship.toml")},
172+ {filepath.Join(repoRoot, "install", "configs", "shell", "init"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "init")},
173+ {filepath.Join(repoRoot, "install", "configs", "shell", "aliases"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "aliases")},
174+ {filepath.Join(repoRoot, "install", "configs", "shell", "envs"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "envs")},
175+ {filepath.Join(repoRoot, "install", "configs", "shell", "rc"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "rc")},
176+ {filepath.Join(repoRoot, "install", "configs", "shell", "bashrc"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "bashrc")},
177+ {filepath.Join(repoRoot, config.ArchLogoURL), filepath.Join(config.DefaultInstallDir, config.ArchLogoURL)},
178+ }
179+
180+ for _, file := range config.PlymouthFiles {
181+ files = append(files, struct {
182+ src string
183+ dest string
184+ }{
185+ src: filepath.Join(repoRoot, "assets", "plymouth", file),
186+ dest: filepath.Join(config.DefaultInstallDir, "assets", "plymouth", file),
187+ })
188+ }
189+
190+ for i, file := range files {
191+ fileName := filepath.Base(file.dest)
192+ p.SendProgress(progressChan, fmt.Sprintf("Preparing %s...", fileName), i+1, len(files))
193+ if err := p.copyFile(file.src, file.dest); err != nil {
194+ return fmt.Errorf("failed to prepare %s: %w", fileName, err)
195+ }
196+ }
197+
198+ return nil
199+}
200+
201+func (p *BootstrapPhase) copyFile(src, dest string) error {
202+ if err := p.fs.MkdirAll(filepath.Dir(dest), 0755); err != nil {
203+ return fmt.Errorf("failed to create directory: %w", err)
204+ }
205+ content, err := p.fs.ReadFile(src)
206+ if err != nil {
207+ return fmt.Errorf("failed to read %s: %w", src, err)
208+ }
209+ if err := p.fs.WriteFile(dest, content, 0644); err != nil {
210+ return fmt.Errorf("failed to write %s: %w", dest, err)
211+ }
212+ return nil
213+}
214+
215+func branchFromRawURL(rawURL string) string {
216+ const prefix = "https://raw.githubusercontent.com/"
217+ if !strings.HasPrefix(rawURL, prefix) {
218+ return "main"
219+ }
220+ trimmed := strings.TrimPrefix(rawURL, prefix)
221+ parts := strings.Split(trimmed, "/")
222+ if len(parts) < 3 {
223+ return "main"
224+ }
225+ return parts[2]
226+}
227+
126228 // downloadFile downloads a file from URL to destPath
127229 func (p *BootstrapPhase) downloadFile(url, destPath string) error {
128230 // Create destination directory if needed
@@ -136,9 +238,11 @@ func (p *BootstrapPhase) downloadFile(url, destPath string) error {
136238 if err != nil {
137239 return fmt.Errorf("HTTP request failed: %w", err)
138240 }
139- defer resp.Body.Close()
140241
141242 if resp.StatusCode != http.StatusOK {
243+ if closeErr := resp.Body.Close(); closeErr != nil {
244+ return fmt.Errorf("failed to close response: %w", closeErr)
245+ }
142246 return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
143247 }
144248
@@ -147,12 +251,22 @@ func (p *BootstrapPhase) downloadFile(url, destPath string) error {
147251 if err != nil {
148252 return fmt.Errorf("failed to create file: %w", err)
149253 }
150- defer destFile.Close()
254+ defer func() {
255+ if err := destFile.Close(); err != nil {
256+ p.logger.Warn("Failed to close file", "error", err)
257+ }
258+ }()
151259
152260 // Copy content
153261 if _, err := io.Copy(destFile, resp.Body); err != nil {
262+ if closeErr := resp.Body.Close(); closeErr != nil {
263+ return fmt.Errorf("failed to close response: %w", closeErr)
264+ }
154265 return fmt.Errorf("failed to write file: %w", err)
155266 }
267+ if closeErr := resp.Body.Close(); closeErr != nil {
268+ return fmt.Errorf("failed to close response: %w", closeErr)
269+ }
156270
157271 return nil
158272 }
M internal/phases/bootstrap_test.go +21 -4
@@ -67,7 +67,11 @@ func TestBootstrapPhasePreCheck(t *testing.T) {
6767 if err != nil {
6868 t.Fatalf("Failed to create logger: %v", err)
6969 }
70- defer log.Close()
70+ defer func() {
71+ if err := log.Close(); err != nil {
72+ t.Fatalf("failed to close log: %v", err)
73+ }
74+ }()
7175
7276 cfg := config.NewConfig("test")
7377 phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
@@ -201,7 +205,11 @@ func TestBootstrapPhaseDownloadFile(t *testing.T) {
201205 if err != nil {
202206 t.Fatalf("Failed to create logger: %v", err)
203207 }
204- defer log.Close()
208+ defer func() {
209+ if err := log.Close(); err != nil {
210+ t.Fatalf("failed to close log: %v", err)
211+ }
212+ }()
205213
206214 cfg := config.NewConfig("test")
207215 phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
@@ -252,9 +260,14 @@ func TestBootstrapPhaseExecute(t *testing.T) {
252260 if err != nil {
253261 t.Fatalf("Failed to create logger: %v", err)
254262 }
255- defer log.Close()
263+ defer func() {
264+ if err := log.Close(); err != nil {
265+ t.Fatalf("failed to close log: %v", err)
266+ }
267+ }()
256268
257269 cfg := config.NewConfig("test")
270+ cfg.RepoURL = ""
258271 cfg.RawURL = "https://example.com"
259272 phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
260273
@@ -310,7 +323,11 @@ func TestBootstrapPhaseRollback(t *testing.T) {
310323 if err != nil {
311324 t.Fatalf("Failed to create logger: %v", err)
312325 }
313- defer log.Close()
326+ defer func() {
327+ if err := log.Close(); err != nil {
328+ t.Fatalf("failed to close log: %v", err)
329+ }
330+ }()
314331
315332 cfg := config.NewConfig("test")
316333 phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
M internal/phases/orchestrator_test.go +100 -34
@@ -84,7 +84,9 @@ func TestOrchestratorRegistration(t *testing.T) {
8484 phase1 := newTestPhase("duplicate", cfg)
8585 phase2 := newTestPhase("duplicate", cfg)
8686
87- orch2.RegisterPhase(phase1)
87+ if err := orch2.RegisterPhase(phase1); err != nil {
88+ t.Fatalf("expected no error, got %v", err)
89+ }
8890 err := orch2.RegisterPhase(phase2)
8991 if err == nil {
9092 t.Error("Expected error for duplicate phase name, got nil")
@@ -98,7 +100,9 @@ func TestOrchestratorCurrentPhase(t *testing.T) {
98100 orch := NewOrchestrator(cfg, "test.log")
99101
100102 phase := newTestPhase("test", cfg)
101- orch.RegisterPhase(phase)
103+ if err := orch.RegisterPhase(phase); err != nil {
104+ t.Fatalf("expected no error, got %v", err)
105+ }
102106
103107 // Before execution, current should be nil
104108 if orch.CurrentPhase() != nil {
@@ -106,7 +110,9 @@ func TestOrchestratorCurrentPhase(t *testing.T) {
106110 }
107111
108112 // After executing, current should be set
109- orch.ExecutePhase(phase)
113+ if err := orch.ExecutePhase(phase); err != nil {
114+ t.Fatalf("expected no error, got %v", err)
115+ }
110116 current := orch.CurrentPhase()
111117 if current == nil {
112118 t.Error("CurrentPhase should not be nil after execution")
@@ -125,9 +131,15 @@ func TestOrchestratorNextPhase(t *testing.T) {
125131 phase2 := newTestPhase("phase-2", cfg)
126132 phase3 := newTestPhase("phase-3", cfg)
127133
128- orch.RegisterPhase(phase1)
129- orch.RegisterPhase(phase2)
130- orch.RegisterPhase(phase3)
134+ if err := orch.RegisterPhase(phase1); err != nil {
135+ t.Fatalf("expected no error, got %v", err)
136+ }
137+ if err := orch.RegisterPhase(phase2); err != nil {
138+ t.Fatalf("expected no error, got %v", err)
139+ }
140+ if err := orch.RegisterPhase(phase3); err != nil {
141+ t.Fatalf("expected no error, got %v", err)
142+ }
131143
132144 // Next should be phase-1
133145 next := orch.NextPhase()
@@ -169,7 +181,9 @@ func TestOrchestratorExecutePhaseSuccess(t *testing.T) {
169181 orch := NewOrchestrator(cfg, "test.log")
170182
171183 phase := newTestPhase("test", cfg)
172- orch.RegisterPhase(phase)
184+ if err := orch.RegisterPhase(phase); err != nil {
185+ t.Fatalf("expected no error, got %v", err)
186+ }
173187
174188 err := orch.ExecutePhase(phase)
175189 if err != nil {
@@ -188,7 +202,9 @@ func TestOrchestratorExecutePhasePreCheckFails(t *testing.T) {
188202
189203 phase := newTestPhase("test", cfg)
190204 phase.preCheckErr = fmt.Errorf("precondition failed")
191- orch.RegisterPhase(phase)
205+ if err := orch.RegisterPhase(phase); err != nil {
206+ t.Fatalf("expected no error, got %v", err)
207+ }
192208
193209 err := orch.ExecutePhase(phase)
194210 if err == nil {
@@ -207,7 +223,9 @@ func TestOrchestratorExecutePhaseExecuteFails(t *testing.T) {
207223
208224 phase := newTestPhase("test", cfg)
209225 phase.executeErr = fmt.Errorf("execution failed")
210- orch.RegisterPhase(phase)
226+ if err := orch.RegisterPhase(phase); err != nil {
227+ t.Fatalf("expected no error, got %v", err)
228+ }
211229
212230 err := orch.ExecutePhase(phase)
213231 if err == nil {
@@ -227,7 +245,9 @@ func TestOrchestratorExecutePhaseRollbackFails(t *testing.T) {
227245 phase := newTestPhase("test", cfg)
228246 phase.executeErr = fmt.Errorf("execution failed")
229247 phase.rollbackErr = fmt.Errorf("rollback also failed")
230- orch.RegisterPhase(phase)
248+ if err := orch.RegisterPhase(phase); err != nil {
249+ t.Fatalf("expected no error, got %v", err)
250+ }
231251
232252 err := orch.ExecutePhase(phase)
233253 if err == nil {
@@ -246,7 +266,9 @@ func TestOrchestratorExecutePhasePostCheckFails(t *testing.T) {
246266
247267 phase := newTestPhase("test", cfg)
248268 phase.postCheckErr = fmt.Errorf("validation failed")
249- orch.RegisterPhase(phase)
269+ if err := orch.RegisterPhase(phase); err != nil {
270+ t.Fatalf("expected no error, got %v", err)
271+ }
250272
251273 err := orch.ExecutePhase(phase)
252274 if err == nil {
@@ -266,7 +288,9 @@ func TestOrchestratorSkipPhase(t *testing.T) {
266288 t.Run("skip skippable phase", func(t *testing.T) {
267289 phase := newTestPhase("skippable", cfg)
268290 phase.canSkip = true
269- orch.RegisterPhase(phase)
291+ if err := orch.RegisterPhase(phase); err != nil {
292+ t.Fatalf("expected no error, got %v", err)
293+ }
270294
271295 err := orch.SkipPhase(phase)
272296 if err != nil {
@@ -282,7 +306,9 @@ func TestOrchestratorSkipPhase(t *testing.T) {
282306 orch2 := NewOrchestrator(cfg, "test.log")
283307 phase := newTestPhase("non-skippable", cfg)
284308 phase.canSkip = false
285- orch2.RegisterPhase(phase)
309+ if err := orch2.RegisterPhase(phase); err != nil {
310+ t.Fatalf("expected no error, got %v", err)
311+ }
286312
287313 err := orch2.SkipPhase(phase)
288314 if err == nil {
@@ -300,9 +326,15 @@ func TestOrchestratorProgress(t *testing.T) {
300326 phase2 := newTestPhase("phase-2", cfg)
301327 phase3 := newTestPhase("phase-3", cfg)
302328
303- orch.RegisterPhase(phase1)
304- orch.RegisterPhase(phase2)
305- orch.RegisterPhase(phase3)
329+ if err := orch.RegisterPhase(phase1); err != nil {
330+ t.Fatalf("expected no error, got %v", err)
331+ }
332+ if err := orch.RegisterPhase(phase2); err != nil {
333+ t.Fatalf("expected no error, got %v", err)
334+ }
335+ if err := orch.RegisterPhase(phase3); err != nil {
336+ t.Fatalf("expected no error, got %v", err)
337+ }
306338
307339 // Initially all pending
308340 completed, total := orch.Progress()
@@ -317,7 +349,7 @@ func TestOrchestratorProgress(t *testing.T) {
317349 phase1.SetStatus(StatusCompleted)
318350 phase2.SetStatus(StatusSkipped)
319351
320- completed, total = orch.Progress()
352+ completed, _ = orch.Progress()
321353 if completed != 2 {
322354 t.Errorf("Completed = %d, want 2 (completed + skipped)", completed)
323355 }
@@ -331,7 +363,9 @@ func TestOrchestratorIsComplete(t *testing.T) {
331363 orch := NewOrchestrator(cfg, "test.log")
332364 phase := newTestPhase("test", cfg)
333365 phase.SetStatus(StatusCompleted)
334- orch.RegisterPhase(phase)
366+ if err := orch.RegisterPhase(phase); err != nil {
367+ t.Fatalf("expected no error, got %v", err)
368+ }
335369
336370 if !orch.IsComplete() {
337371 t.Error("IsComplete should be true when all phases completed")
@@ -342,7 +376,9 @@ func TestOrchestratorIsComplete(t *testing.T) {
342376 orch := NewOrchestrator(cfg, "test.log")
343377 phase := newTestPhase("test", cfg)
344378 phase.SetStatus(StatusSkipped)
345- orch.RegisterPhase(phase)
379+ if err := orch.RegisterPhase(phase); err != nil {
380+ t.Fatalf("expected no error, got %v", err)
381+ }
346382
347383 if !orch.IsComplete() {
348384 t.Error("IsComplete should be true when all phases skipped")
@@ -352,7 +388,9 @@ func TestOrchestratorIsComplete(t *testing.T) {
352388 t.Run("with pending", func(t *testing.T) {
353389 orch := NewOrchestrator(cfg, "test.log")
354390 phase := newTestPhase("test", cfg)
355- orch.RegisterPhase(phase)
391+ if err := orch.RegisterPhase(phase); err != nil {
392+ t.Fatalf("expected no error, got %v", err)
393+ }
356394
357395 if orch.IsComplete() {
358396 t.Error("IsComplete should be false with pending phases")
@@ -368,7 +406,9 @@ func TestOrchestratorHasFailed(t *testing.T) {
368406 orch := NewOrchestrator(cfg, "test.log")
369407 phase := newTestPhase("test", cfg)
370408 phase.SetStatus(StatusCompleted)
371- orch.RegisterPhase(phase)
409+ if err := orch.RegisterPhase(phase); err != nil {
410+ t.Fatalf("expected no error, got %v", err)
411+ }
372412
373413 if orch.HasFailed() {
374414 t.Error("HasFailed should be false when no phases failed")
@@ -379,7 +419,9 @@ func TestOrchestratorHasFailed(t *testing.T) {
379419 orch := NewOrchestrator(cfg, "test.log")
380420 phase := newTestPhase("test", cfg)
381421 phase.SetStatus(StatusFailed)
382- orch.RegisterPhase(phase)
422+ if err := orch.RegisterPhase(phase); err != nil {
423+ t.Fatalf("expected no error, got %v", err)
424+ }
383425
384426 if !orch.HasFailed() {
385427 t.Error("HasFailed should be true when phase failed")
@@ -398,11 +440,17 @@ func TestOrchestratorReset(t *testing.T) {
398440 phase1.SetStatus(StatusCompleted)
399441 phase2.SetStatus(StatusFailed)
400442
401- orch.RegisterPhase(phase1)
402- orch.RegisterPhase(phase2)
443+ if err := orch.RegisterPhase(phase1); err != nil {
444+ t.Fatalf("expected no error, got %v", err)
445+ }
446+ if err := orch.RegisterPhase(phase2); err != nil {
447+ t.Fatalf("expected no error, got %v", err)
448+ }
403449
404450 // Execute to set currentIdx
405- orch.ExecutePhase(phase1)
451+ if err := orch.ExecutePhase(phase1); err != nil {
452+ t.Fatalf("expected no error, got %v", err)
453+ }
406454
407455 // Reset
408456 orch.Reset()
@@ -430,9 +478,15 @@ func TestOrchestratorExecuteAll(t *testing.T) {
430478 phase2 := newTestPhase("phase-2", cfg)
431479 phase3 := newTestPhase("phase-3", cfg)
432480
433- orch.RegisterPhase(phase1)
434- orch.RegisterPhase(phase2)
435- orch.RegisterPhase(phase3)
481+ if err := orch.RegisterPhase(phase1); err != nil {
482+ t.Fatalf("expected no error, got %v", err)
483+ }
484+ if err := orch.RegisterPhase(phase2); err != nil {
485+ t.Fatalf("expected no error, got %v", err)
486+ }
487+ if err := orch.RegisterPhase(phase3); err != nil {
488+ t.Fatalf("expected no error, got %v", err)
489+ }
436490
437491 err := orch.ExecuteAll()
438492 if err != nil {
@@ -463,9 +517,15 @@ func TestOrchestratorExecuteAllStopsOnFailure(t *testing.T) {
463517 // Make phase-2 fail
464518 phase2.executeErr = fmt.Errorf("execution failed")
465519
466- orch.RegisterPhase(phase1)
467- orch.RegisterPhase(phase2)
468- orch.RegisterPhase(phase3)
520+ if err := orch.RegisterPhase(phase1); err != nil {
521+ t.Fatalf("expected no error, got %v", err)
522+ }
523+ if err := orch.RegisterPhase(phase2); err != nil {
524+ t.Fatalf("expected no error, got %v", err)
525+ }
526+ if err := orch.RegisterPhase(phase3); err != nil {
527+ t.Fatalf("expected no error, got %v", err)
528+ }
469529
470530 err := orch.ExecuteAll()
471531 if err == nil {
@@ -494,7 +554,9 @@ func TestOrchestratorExecuteNext(t *testing.T) {
494554 orch := NewOrchestrator(cfg, "test.log")
495555
496556 phase := newTestPhase("test", cfg)
497- orch.RegisterPhase(phase)
557+ if err := orch.RegisterPhase(phase); err != nil {
558+ t.Fatalf("expected no error, got %v", err)
559+ }
498560
499561 err := orch.ExecuteNext()
500562 if err != nil {
@@ -513,7 +575,9 @@ func TestOrchestratorExecuteNextNoPending(t *testing.T) {
513575
514576 phase := newTestPhase("test", cfg)
515577 phase.SetStatus(StatusCompleted)
516- orch.RegisterPhase(phase)
578+ if err := orch.RegisterPhase(phase); err != nil {
579+ t.Fatalf("expected no error, got %v", err)
580+ }
517581
518582 err := orch.ExecuteNext()
519583 if err == nil {
@@ -527,7 +591,9 @@ func TestOrchestratorPhaseDurations(t *testing.T) {
527591 orch := NewOrchestrator(cfg, "test.log")
528592
529593 phase := newTestPhase("test", cfg)
530- orch.RegisterPhase(phase)
594+ if err := orch.RegisterPhase(phase); err != nil {
595+ t.Fatalf("expected no error, got %v", err)
596+ }
531597
532598 err := orch.ExecutePhase(phase)
533599 if err != nil {
M internal/phases/partitioning.go +3 -3
@@ -91,10 +91,10 @@ func (p *PartitioningPhase) createPartitions(progressChan chan<- ProgressUpdate)
9191
9292 p.SendOutput(progressChan, "Creating GPT partition table...")
9393
94- // Create partitions: 512MB EFI + remaining ROOT
94+ // Create partitions: 4GB EFI + remaining ROOT (4GB recommended for limine-snapper-sync)
9595 result = p.logger.ExecCommand("sgdisk",
9696 "--clear",
97- "--new=1:0:+512M", "--typecode=1:ef00", "--change-name=1:EFI",
97+ "--new=1:0:+4G", "--typecode=1:ef00", "--change-name=1:EFI",
9898 "--new=2:0:0", "--typecode=2:8300", "--change-name=2:ROOT",
9999 disk)
100100
@@ -122,7 +122,7 @@ func (p *PartitioningPhase) createPartitions(progressChan chan<- ProgressUpdate)
122122 p.config.EFIPartition = efiPart
123123 p.config.RootPartition = rootPart
124124
125- p.SendOutput(progressChan, fmt.Sprintf("[OK] EFI: %s (512MB)", efiPart))
125+ p.SendOutput(progressChan, fmt.Sprintf("[OK] EFI: %s (4GB)", efiPart))
126126 p.SendOutput(progressChan, fmt.Sprintf("[OK] Root: %s", rootPart))
127127
128128 return nil
M internal/phases/partitioning_test.go +46 -9
@@ -54,7 +54,11 @@ func TestPartitioningPhasePreCheck(t *testing.T) {
5454 if err != nil {
5555 t.Fatalf("Failed to create logger: %v", err)
5656 }
57- defer log.Close()
57+ defer func() {
58+ if err := log.Close(); err != nil {
59+ t.Fatalf("failed to close log: %v", err)
60+ }
61+ }()
5862
5963 cfg := config.NewConfig("test")
6064 cfg.TargetDisk = tt.targetDisk
@@ -116,7 +120,11 @@ func TestPartitioningPhasePartitionNaming(t *testing.T) {
116120 if err != nil {
117121 t.Fatalf("Failed to create logger: %v", err)
118122 }
119- defer log.Close()
123+ defer func() {
124+ if err := log.Close(); err != nil {
125+ t.Fatalf("failed to close log: %v", err)
126+ }
127+ }()
120128
121129 cfg := config.NewConfig("test")
122130 cfg.TargetDisk = tt.targetDisk
@@ -175,7 +183,11 @@ func TestPartitioningPhaseFormatRoot(t *testing.T) {
175183 if err != nil {
176184 t.Fatalf("Failed to create logger: %v", err)
177185 }
178- defer log.Close()
186+ defer func() {
187+ if err := log.Close(); err != nil {
188+ t.Fatalf("failed to close log: %v", err)
189+ }
190+ }()
179191
180192 cfg := config.NewConfig("test")
181193 cfg.TargetDisk = "/dev/sda"
@@ -235,7 +247,11 @@ func TestPartitioningPhaseEncryptionValidation(t *testing.T) {
235247 if err != nil {
236248 t.Fatalf("Failed to create logger: %v", err)
237249 }
238- defer log.Close()
250+ defer func() {
251+ if err := log.Close(); err != nil {
252+ t.Fatalf("failed to close log: %v", err)
253+ }
254+ }()
239255
240256 cfg := config.NewConfig("test")
241257 cfg.TargetDisk = "/dev/sda"
@@ -252,6 +268,7 @@ func TestPartitioningPhaseEncryptionValidation(t *testing.T) {
252268 // Empty password for encryption - should fail in actual execution
253269 if phase.config.EncryptionType != config.EncryptionNone {
254270 // Password is required for encryption
271+ t.Log("password required for encryption")
255272 }
256273 }
257274 }
@@ -299,7 +316,11 @@ func TestPartitioningPhasePostCheck(t *testing.T) {
299316 if err != nil {
300317 t.Fatalf("Failed to create logger: %v", err)
301318 }
302- defer log.Close()
319+ defer func() {
320+ if err := log.Close(); err != nil {
321+ t.Fatalf("failed to close log: %v", err)
322+ }
323+ }()
303324
304325 cfg := config.NewConfig("test")
305326 cfg.ConfigPath = filepath.Join(tmpDir, "config")
@@ -380,7 +401,11 @@ func TestPartitioningPhaseRollback(t *testing.T) {
380401 if err != nil {
381402 t.Fatalf("Failed to create logger: %v", err)
382403 }
383- defer log.Close()
404+ defer func() {
405+ if err := log.Close(); err != nil {
406+ t.Fatalf("failed to close log: %v", err)
407+ }
408+ }()
384409
385410 cfg := config.NewConfig("test")
386411 cfg.EncryptionType = tt.encryption
@@ -409,7 +434,11 @@ func TestPartitioningPhaseCanSkip(t *testing.T) {
409434 if err != nil {
410435 t.Fatalf("Failed to create logger: %v", err)
411436 }
412- defer log.Close()
437+ defer func() {
438+ if err := log.Close(); err != nil {
439+ t.Fatalf("failed to close log: %v", err)
440+ }
441+ }()
413442
414443 cfg := config.NewConfig("test")
415444 phase := NewPartitioningPhase(cfg, log, mockSysExec)
@@ -432,7 +461,11 @@ func TestPartitioningPhaseExecute(t *testing.T) {
432461 if err != nil {
433462 t.Fatalf("Failed to create logger: %v", err)
434463 }
435- defer log.Close()
464+ defer func() {
465+ if err := log.Close(); err != nil {
466+ t.Fatalf("failed to close log: %v", err)
467+ }
468+ }()
436469
437470 cfg := config.NewConfig("test")
438471 cfg.TargetDisk = "/dev/sda"
@@ -497,7 +530,11 @@ func TestPartitioningPhaseDeviceHandling(t *testing.T) {
497530 if err != nil {
498531 t.Fatalf("Failed to create logger: %v", err)
499532 }
500- defer log.Close()
533+ defer func() {
534+ if err := log.Close(); err != nil {
535+ t.Fatalf("failed to close log: %v", err)
536+ }
537+ }()
501538
502539 cfg := config.NewConfig("test")
503540 cfg.EncryptionType = tt.encryptionType
M internal/phases/phase_test.go +55 -11
@@ -42,7 +42,11 @@ func TestNewBasePhase(t *testing.T) {
4242 if err != nil {
4343 t.Fatalf("Failed to create logger: %v", err)
4444 }
45- defer log.Close()
45+ defer func() {
46+ if err := log.Close(); err != nil {
47+ t.Fatalf("failed to close log: %v", err)
48+ }
49+ }()
4650
4751 cfg := config.NewConfig("test")
4852
@@ -75,7 +79,11 @@ func TestBasePhaseSetStatus(t *testing.T) {
7579 logPath := filepath.Join(tmpDir, "test.log")
7680
7781 log, _ := logger.New(logPath, false)
78- defer log.Close()
82+ defer func() {
83+ if err := log.Close(); err != nil {
84+ t.Fatalf("failed to close log: %v", err)
85+ }
86+ }()
7987
8088 cfg := config.NewConfig("test")
8189 phase := NewBasePhase("test", "test", cfg, log)
@@ -104,7 +112,11 @@ func TestBasePhaseDefaultPreCheck(t *testing.T) {
104112 logPath := filepath.Join(tmpDir, "test.log")
105113
106114 log, _ := logger.New(logPath, false)
107- defer log.Close()
115+ defer func() {
116+ if err := log.Close(); err != nil {
117+ t.Fatalf("failed to close log: %v", err)
118+ }
119+ }()
108120
109121 tests := []struct {
110122 name string
@@ -141,7 +153,11 @@ func TestBasePhaseDefaultPostCheck(t *testing.T) {
141153 logPath := filepath.Join(tmpDir, "test.log")
142154
143155 log, _ := logger.New(logPath, false)
144- defer log.Close()
156+ defer func() {
157+ if err := log.Close(); err != nil {
158+ t.Fatalf("failed to close log: %v", err)
159+ }
160+ }()
145161
146162 cfg := config.NewConfig("test")
147163 phase := NewBasePhase("test", "test", cfg, log)
@@ -158,7 +174,11 @@ func TestBasePhaseDefaultRollback(t *testing.T) {
158174 logPath := filepath.Join(tmpDir, "test.log")
159175
160176 log, _ := logger.New(logPath, false)
161- defer log.Close()
177+ defer func() {
178+ if err := log.Close(); err != nil {
179+ t.Fatalf("failed to close log: %v", err)
180+ }
181+ }()
162182
163183 cfg := config.NewConfig("test")
164184 phase := NewBasePhase("my-phase", "test", cfg, log)
@@ -180,7 +200,11 @@ func TestBasePhaseDefaultCanSkip(t *testing.T) {
180200 logPath := filepath.Join(tmpDir, "test.log")
181201
182202 log, _ := logger.New(logPath, false)
183- defer log.Close()
203+ defer func() {
204+ if err := log.Close(); err != nil {
205+ t.Fatalf("failed to close log: %v", err)
206+ }
207+ }()
184208
185209 cfg := config.NewConfig("test")
186210 phase := NewBasePhase("test", "test", cfg, log)
@@ -196,7 +220,11 @@ func TestSendProgress(t *testing.T) {
196220 logPath := filepath.Join(tmpDir, "test.log")
197221
198222 log, _ := logger.New(logPath, false)
199- defer log.Close()
223+ defer func() {
224+ if err := log.Close(); err != nil {
225+ t.Fatalf("failed to close log: %v", err)
226+ }
227+ }()
200228
201229 cfg := config.NewConfig("test")
202230 phase := NewBasePhase("test-phase", "test", cfg, log)
@@ -237,7 +265,11 @@ func TestSendProgressWithNilChannel(t *testing.T) {
237265 logPath := filepath.Join(tmpDir, "test.log")
238266
239267 log, _ := logger.New(logPath, false)
240- defer log.Close()
268+ defer func() {
269+ if err := log.Close(); err != nil {
270+ t.Fatalf("failed to close log: %v", err)
271+ }
272+ }()
241273
242274 cfg := config.NewConfig("test")
243275 phase := NewBasePhase("test", "test", cfg, log)
@@ -252,7 +284,11 @@ func TestSendComplete(t *testing.T) {
252284 logPath := filepath.Join(tmpDir, "test.log")
253285
254286 log, _ := logger.New(logPath, false)
255- defer log.Close()
287+ defer func() {
288+ if err := log.Close(); err != nil {
289+ t.Fatalf("failed to close log: %v", err)
290+ }
291+ }()
256292
257293 cfg := config.NewConfig("test")
258294 phase := NewBasePhase("test-phase", "test", cfg, log)
@@ -279,7 +315,11 @@ func TestSendError(t *testing.T) {
279315 logPath := filepath.Join(tmpDir, "test.log")
280316
281317 log, _ := logger.New(logPath, false)
282- defer log.Close()
318+ defer func() {
319+ if err := log.Close(); err != nil {
320+ t.Fatalf("failed to close log: %v", err)
321+ }
322+ }()
283323
284324 cfg := config.NewConfig("test")
285325 phase := NewBasePhase("test-phase", "test", cfg, log)
@@ -308,7 +348,11 @@ func TestSendOutput(t *testing.T) {
308348 logPath := filepath.Join(tmpDir, "test.log")
309349
310350 log, _ := logger.New(logPath, false)
311- defer log.Close()
351+ defer func() {
352+ if err := log.Close(); err != nil {
353+ t.Fatalf("failed to close log: %v", err)
354+ }
355+ }()
312356
313357 cfg := config.NewConfig("test")
314358 phase := NewBasePhase("test-phase", "test", cfg, log)
M internal/phases/postinstall.go +69 -24
@@ -154,7 +154,11 @@ func (p *PostInstallPhase) installBootLogo(progressChan chan<- ProgressUpdate) e
154154 case err != nil:
155155 return fmt.Errorf("failed to download logo: %w", err)
156156 }
157- defer resp.Body.Close()
157+ defer func() {
158+ if err := resp.Body.Close(); err != nil {
159+ p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to close response body: %v", err))
160+ }
161+ }()
158162
159163 switch {
160164 case resp.StatusCode != http.StatusOK:
@@ -167,7 +171,11 @@ func (p *PostInstallPhase) installBootLogo(progressChan chan<- ProgressUpdate) e
167171 case err != nil:
168172 return fmt.Errorf("failed to create logo file: %w", err)
169173 }
170- defer logoFile.Close()
174+ defer func() {
175+ if err := logoFile.Close(); err != nil {
176+ p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to close logo file: %v", err))
177+ }
178+ }()
171179
172180 _, err = io.Copy(logoFile, resp.Body)
173181 switch {
@@ -231,7 +239,9 @@ func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate)
231239
232240 switch {
233241 case resp.StatusCode != http.StatusOK:
234- resp.Body.Close()
242+ if closeErr := resp.Body.Close(); closeErr != nil {
243+ return fmt.Errorf("failed to close %s response: %w", filename, closeErr)
244+ }
235245 return fmt.Errorf("failed to download %s: HTTP %d", filename, resp.StatusCode)
236246 }
237247
@@ -240,13 +250,19 @@ func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate)
240250 destFile, err := p.fs.Create(destPath)
241251 switch {
242252 case err != nil:
243- resp.Body.Close()
253+ if closeErr := resp.Body.Close(); closeErr != nil {
254+ return fmt.Errorf("failed to close response for %s: %w", filename, closeErr)
255+ }
244256 return fmt.Errorf("failed to create %s: %w", filename, err)
245257 }
246258
247259 _, err = io.Copy(destFile, resp.Body)
248- destFile.Close()
249- resp.Body.Close()
260+ if closeErr := destFile.Close(); closeErr != nil {
261+ return fmt.Errorf("failed to close %s: %w", filename, closeErr)
262+ }
263+ if closeErr := resp.Body.Close(); closeErr != nil {
264+ return fmt.Errorf("failed to close response for %s: %w", filename, closeErr)
265+ }
250266
251267 switch {
252268 case err != nil:
@@ -312,7 +328,7 @@ func (p *PostInstallPhase) configureSnapper(progressChan chan<- ProgressUpdate)
312328 }
313329
314330 // Create Limine snapper config
315- limineDefaultConfig := fmt.Sprintf(`TARGET_OS_NAME="ArchUp"
331+ limineDefaultConfig := fmt.Sprintf(`TARGET_OS_NAME="%s"
316332
317333 ESP_PATH="/boot"
318334
@@ -328,7 +344,7 @@ BOOT_ORDER="*, *fallback, Snapshots"
328344 MAX_SNAPSHOT_ENTRIES=5
329345
330346 SNAPSHOT_FORMAT_CHOICE=5
331-`, cmdline)
347+`, config.LimineBranding, cmdline)
332348
333349 switch err := p.fs.WriteFile(config.PathMntEtcDefaultLimine, []byte(limineDefaultConfig), 0644); {
334350 case err != nil:
@@ -515,7 +531,6 @@ func (p *PostInstallPhase) configureShell(progressChan chan<- ProgressUpdate) er
515531 case err != nil:
516532 // Non-fatal
517533 p.SendOutput(progressChan, "[WARN] Failed to configure git delta")
518- break
519534 }
520535 }
521536
@@ -575,7 +590,9 @@ func (p *PostInstallPhase) applyThemes(progressChan chan<- ProgressUpdate, usern
575590 p.SendOutput(progressChan, "Configuring starship theme...")
576591 starshipCmd := fmt.Sprintf("su - %s -c 'cp %s/starship/bleu.toml %s/starship.toml'",
577592 username, themeDir, archupDefault)
578- p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, starshipCmd)
593+ if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, starshipCmd); err != nil {
594+ p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure starship theme: %v", err))
595+ }
579596
580597 // Eza theme
581598 p.SendOutput(progressChan, "Configuring eza theme...")
@@ -585,7 +602,9 @@ func (p *PostInstallPhase) applyThemes(progressChan chan<- ProgressUpdate, usern
585602 fmt.Sprintf("su - %s -c 'cp %s/eza/theme.yml %s/'", username, themeDir, ezaConfigDir),
586603 }
587604 for _, cmd := range ezaCommands {
588- p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd)
605+ if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
606+ p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure eza theme: %v", err))
607+ }
589608 }
590609
591610 // Bat theme
@@ -597,7 +616,9 @@ func (p *PostInstallPhase) applyThemes(progressChan chan<- ProgressUpdate, usern
597616 fmt.Sprintf("su - %s -c 'bat cache --build'", username),
598617 }
599618 for _, cmd := range batCommands {
600- p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd)
619+ if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
620+ p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure bat theme: %v", err))
621+ }
601622 }
602623
603624 // Btop theme
@@ -608,7 +629,9 @@ func (p *PostInstallPhase) applyThemes(progressChan chan<- ProgressUpdate, usern
608629 fmt.Sprintf("su - %s -c 'cp %s/btop/bleu.theme %s/'", username, themeDir, btopThemeDir),
609630 }
610631 for _, cmd := range btopCommands {
611- p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd)
632+ if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
633+ p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure btop theme: %v", err))
634+ }
612635 }
613636
614637 // Yazi theme
@@ -619,7 +642,9 @@ func (p *PostInstallPhase) applyThemes(progressChan chan<- ProgressUpdate, usern
619642 fmt.Sprintf("su - %s -c 'cp %s/yazi/bleu.toml %s/flavor.toml'", username, themeDir, yaziFlavorDir),
620643 }
621644 for _, cmd := range yaziCommands {
622- p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd)
645+ if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
646+ p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure yazi theme: %v", err))
647+ }
623648 }
624649
625650 p.SendOutput(progressChan, "[OK] CLI themes configured")
@@ -764,7 +789,9 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
764789 case err != nil:
765790 return fmt.Errorf("failed to download logo.txt: %w", err)
766791 case resp.StatusCode != http.StatusOK:
767- resp.Body.Close()
792+ if closeErr := resp.Body.Close(); closeErr != nil {
793+ return fmt.Errorf("failed to close logo response: %w", closeErr)
794+ }
768795 return fmt.Errorf("failed to download logo.txt: HTTP %d", resp.StatusCode)
769796 }
770797
@@ -772,13 +799,19 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
772799 logoFile, err := p.fs.Create(logoPath)
773800 switch {
774801 case err != nil:
775- resp.Body.Close()
802+ if closeErr := resp.Body.Close(); closeErr != nil {
803+ return fmt.Errorf("failed to close logo response: %w", closeErr)
804+ }
776805 return fmt.Errorf("failed to create logo.txt: %w", err)
777806 }
778807
779808 _, err = io.Copy(logoFile, resp.Body)
780- logoFile.Close()
781- resp.Body.Close()
809+ if closeErr := logoFile.Close(); closeErr != nil {
810+ return fmt.Errorf("failed to close logo.txt: %w", closeErr)
811+ }
812+ if closeErr := resp.Body.Close(); closeErr != nil {
813+ return fmt.Errorf("failed to close logo response: %w", closeErr)
814+ }
782815 switch {
783816 case err != nil:
784817 return fmt.Errorf("failed to save logo.txt: %w", err)
@@ -794,7 +827,9 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
794827 case err != nil:
795828 return fmt.Errorf("failed to download %s: %w", script, err)
796829 case resp.StatusCode != http.StatusOK:
797- resp.Body.Close()
830+ if closeErr := resp.Body.Close(); closeErr != nil {
831+ return fmt.Errorf("failed to close %s response: %w", script, closeErr)
832+ }
798833 return fmt.Errorf("failed to download %s: HTTP %d", script, resp.StatusCode)
799834 }
800835
@@ -802,13 +837,19 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
802837 scriptFile, err := p.fs.Create(scriptPath)
803838 switch {
804839 case err != nil:
805- resp.Body.Close()
840+ if closeErr := resp.Body.Close(); closeErr != nil {
841+ return fmt.Errorf("failed to close %s response: %w", script, closeErr)
842+ }
806843 return fmt.Errorf("failed to create %s: %w", script, err)
807844 }
808845
809846 _, err = io.Copy(scriptFile, resp.Body)
810- scriptFile.Close()
811- resp.Body.Close()
847+ if closeErr := scriptFile.Close(); closeErr != nil {
848+ return fmt.Errorf("failed to close %s: %w", script, closeErr)
849+ }
850+ if closeErr := resp.Body.Close(); closeErr != nil {
851+ return fmt.Errorf("failed to close %s response: %w", script, closeErr)
852+ }
812853
813854 switch {
814855 case err != nil:
@@ -831,12 +872,16 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
831872 case err != nil:
832873 return fmt.Errorf("failed to download service template: %w", err)
833874 case resp.StatusCode != http.StatusOK:
834- resp.Body.Close()
875+ if closeErr := resp.Body.Close(); closeErr != nil {
876+ return fmt.Errorf("failed to close service response: %w", closeErr)
877+ }
835878 return fmt.Errorf("failed to download service template: HTTP %d", resp.StatusCode)
836879 }
837880
838881 templateBytes, err := io.ReadAll(resp.Body)
839- resp.Body.Close()
882+ if closeErr := resp.Body.Close(); closeErr != nil {
883+ return fmt.Errorf("failed to close service response: %w", closeErr)
884+ }
840885 switch {
841886 case err != nil:
842887 return fmt.Errorf("failed to read service template: %w", err)
M internal/phases/postinstall_test.go +45 -9
@@ -67,7 +67,11 @@ func TestPostInstallPhasePreCheck(t *testing.T) {
6767 if err != nil {
6868 t.Fatalf("Failed to create logger: %v", err)
6969 }
70- defer log.Close()
70+ defer func() {
71+ if err := log.Close(); err != nil {
72+ t.Fatalf("failed to close log: %v", err)
73+ }
74+ }()
7175
7276 cfg := config.NewConfig("test")
7377 phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
@@ -123,7 +127,11 @@ func TestPostInstallPhaseShellConfiguration(t *testing.T) {
123127 if err != nil {
124128 t.Fatalf("Failed to create logger: %v", err)
125129 }
126- defer log.Close()
130+ defer func() {
131+ if err := log.Close(); err != nil {
132+ t.Fatalf("failed to close log: %v", err)
133+ }
134+ }()
127135
128136 cfg := config.NewConfig("test")
129137 phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
@@ -152,7 +160,11 @@ func TestPostInstallPhasePostCheck(t *testing.T) {
152160 if err != nil {
153161 t.Fatalf("Failed to create logger: %v", err)
154162 }
155- defer log.Close()
163+ defer func() {
164+ if err := log.Close(); err != nil {
165+ t.Fatalf("failed to close log: %v", err)
166+ }
167+ }()
156168
157169 cfg := config.NewConfig("test")
158170 cfg.ConfigPath = filepath.Join(tmpDir, "config")
@@ -182,7 +194,11 @@ func TestPostInstallPhaseRollback(t *testing.T) {
182194 if err != nil {
183195 t.Fatalf("Failed to create logger: %v", err)
184196 }
185- defer log.Close()
197+ defer func() {
198+ if err := log.Close(); err != nil {
199+ t.Fatalf("failed to close log: %v", err)
200+ }
201+ }()
186202
187203 cfg := config.NewConfig("test")
188204 phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
@@ -210,7 +226,11 @@ func TestPostInstallPhaseCanSkip(t *testing.T) {
210226 if err != nil {
211227 t.Fatalf("Failed to create logger: %v", err)
212228 }
213- defer log.Close()
229+ defer func() {
230+ if err := log.Close(); err != nil {
231+ t.Fatalf("failed to close log: %v", err)
232+ }
233+ }()
214234
215235 cfg := config.NewConfig("test")
216236 phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
@@ -250,7 +270,11 @@ func TestPostInstallPhaseExecute(t *testing.T) {
250270 if err != nil {
251271 t.Fatalf("Failed to create logger: %v", err)
252272 }
253- defer log.Close()
273+ defer func() {
274+ if err := log.Close(); err != nil {
275+ t.Fatalf("failed to close log: %v", err)
276+ }
277+ }()
254278
255279 cfg := config.NewConfig("test")
256280 cfg.KernelChoice = "linux"
@@ -307,7 +331,11 @@ func TestPostInstallPhaseEncryptionHandling(t *testing.T) {
307331 if err != nil {
308332 t.Fatalf("Failed to create logger: %v", err)
309333 }
310- defer log.Close()
334+ defer func() {
335+ if err := log.Close(); err != nil {
336+ t.Fatalf("failed to close log: %v", err)
337+ }
338+ }()
311339
312340 cfg := config.NewConfig("test")
313341 cfg.EncryptionType = tt.encryptionType
@@ -380,7 +408,11 @@ func TestPostInstallPhaseFileSystemOperations(t *testing.T) {
380408 if err != nil {
381409 t.Fatalf("Failed to create logger: %v", err)
382410 }
383- defer log.Close()
411+ defer func() {
412+ if err := log.Close(); err != nil {
413+ t.Fatalf("failed to close log: %v", err)
414+ }
415+ }()
384416
385417 cfg := config.NewConfig("test")
386418 phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
@@ -433,7 +465,11 @@ func TestPostInstallPhaseKernelVerification(t *testing.T) {
433465 if err != nil {
434466 t.Fatalf("Failed to create logger: %v", err)
435467 }
436- defer log.Close()
468+ defer func() {
469+ if err := log.Close(); err != nil {
470+ t.Fatalf("failed to close log: %v", err)
471+ }
472+ }()
437473
438474 cfg := config.NewConfig("test")
439475 cfg.KernelChoice = tt.kernelChoice
M internal/phases/preflight.go +1 -1
@@ -66,7 +66,7 @@ func (p *PreflightPhase) PreCheck() error {
6666 out, err = p.cmdExec.Execute("bootctl", "status")
6767 switch {
6868 case err == nil && strings.Contains(string(out), "Secure Boot: enabled"):
69- return fmt.Errorf("Secure Boot must be disabled")
69+ return fmt.Errorf("secure boot must be disabled")
7070 }
7171
7272 return nil
M internal/phases/preflight_test.go +31 -7
@@ -120,7 +120,7 @@ func TestPreflightPhasePreCheck(t *testing.T) {
120120 mockCmd.EXPECT().Execute("bootctl", "status").Return([]byte("Secure Boot: enabled\n"), nil).Times(1)
121121 },
122122 wantErr: true,
123- errContains: "Secure Boot must be disabled",
123+ errContains: "secure boot must be disabled",
124124 },
125125 {
126126 name: "Secure Boot disabled - all checks pass",
@@ -172,7 +172,11 @@ func TestPreflightPhasePreCheck(t *testing.T) {
172172 if err != nil {
173173 t.Fatalf("Failed to create logger: %v", err)
174174 }
175- defer log.Close()
175+ defer func() {
176+ if err := log.Close(); err != nil {
177+ t.Fatalf("failed to close log: %v", err)
178+ }
179+ }()
176180
177181 cfg := config.NewConfig("test")
178182 phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
@@ -249,7 +253,11 @@ func TestPreflightPhaseSetDefaults(t *testing.T) {
249253 if err != nil {
250254 t.Fatalf("Failed to create logger: %v", err)
251255 }
252- defer log.Close()
256+ defer func() {
257+ if err := log.Close(); err != nil {
258+ t.Fatalf("failed to close log: %v", err)
259+ }
260+ }()
253261
254262 cfg := config.NewConfig("test")
255263 cfg.Hostname = tt.initialHostname
@@ -336,7 +344,11 @@ func TestPreflightPhasePostCheck(t *testing.T) {
336344 if err != nil {
337345 t.Fatalf("Failed to create logger: %v", err)
338346 }
339- defer log.Close()
347+ defer func() {
348+ if err := log.Close(); err != nil {
349+ t.Fatalf("failed to close log: %v", err)
350+ }
351+ }()
340352
341353 cfg := config.NewConfig("test")
342354 cfg.Username = tt.username
@@ -374,7 +386,11 @@ func TestPreflightPhaseExecute(t *testing.T) {
374386 if err != nil {
375387 t.Fatalf("Failed to create logger: %v", err)
376388 }
377- defer log.Close()
389+ defer func() {
390+ if err := log.Close(); err != nil {
391+ t.Fatalf("failed to close log: %v", err)
392+ }
393+ }()
378394
379395 cfg := config.NewConfig("test")
380396 phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
@@ -415,7 +431,11 @@ func TestPreflightPhaseRollback(t *testing.T) {
415431 if err != nil {
416432 t.Fatalf("Failed to create logger: %v", err)
417433 }
418- defer log.Close()
434+ defer func() {
435+ if err := log.Close(); err != nil {
436+ t.Fatalf("failed to close log: %v", err)
437+ }
438+ }()
419439
420440 cfg := config.NewConfig("test")
421441 phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
@@ -440,7 +460,11 @@ func TestPreflightPhaseCanSkip(t *testing.T) {
440460 if err != nil {
441461 t.Fatalf("Failed to create logger: %v", err)
442462 }
443- defer log.Close()
463+ defer func() {
464+ if err := log.Close(); err != nil {
465+ t.Fatalf("failed to close log: %v", err)
466+ }
467+ }()
444468
445469 cfg := config.NewConfig("test")
446470 phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
M internal/phases/repos.go +10 -2
@@ -225,7 +225,6 @@ func (p *ReposPhase) loadChaoticConfig() error {
225225 if err != nil {
226226 return fmt.Errorf("failed to open chaotic config: %w", err)
227227 }
228- defer file.Close()
229228
230229 scanner := bufio.NewScanner(file)
231230 for scanner.Scan() {
@@ -247,9 +246,14 @@ func (p *ReposPhase) loadChaoticConfig() error {
247246 }
248247
249248 if err := scanner.Err(); err != nil {
249+ _ = file.Close()
250250 return fmt.Errorf("error reading chaotic config: %w", err)
251251 }
252252
253+ if err := file.Close(); err != nil {
254+ return fmt.Errorf("failed to close chaotic config: %w", err)
255+ }
256+
253257 return nil
254258 }
255259
@@ -459,7 +463,6 @@ func (p *ReposPhase) loadExtraPackages() ([]string, error) {
459463 if err != nil {
460464 return nil, fmt.Errorf("failed to open %s: %w", packageFile, err)
461465 }
462- defer file.Close()
463466
464467 var packages []string
465468 scanner := bufio.NewScanner(file)
@@ -479,9 +482,14 @@ func (p *ReposPhase) loadExtraPackages() ([]string, error) {
479482 }
480483
481484 if err := scanner.Err(); err != nil {
485+ _ = file.Close()
482486 return nil, fmt.Errorf("error reading package file: %w", err)
483487 }
484488
489+ if err := file.Close(); err != nil {
490+ return nil, fmt.Errorf("failed to close %s: %w", packageFile, err)
491+ }
492+
485493 return packages, nil
486494 }
487495
M internal/phases/repos_test.go +16 -5
@@ -157,6 +157,7 @@ Include = /etc/pacman.d/mirrorlist`,
157157 lines := strings.Split(s, "\n")
158158 var inMultilib bool
159159 var foundInclude int
160+ var commentedInclude int
160161 for _, line := range lines {
161162 trimmed := strings.TrimSpace(line)
162163 if trimmed == "[multilib]" {
@@ -165,8 +166,10 @@ Include = /etc/pacman.d/mirrorlist`,
165166 foundInclude++
166167 } else if inMultilib && strings.HasPrefix(trimmed, "#Include") {
167168 // Still commented - that's another Include
169+ commentedInclude++
168170 }
169171 }
172+ _ = commentedInclude
170173 return foundInclude >= 1
171174 },
172175 },
@@ -286,7 +289,11 @@ func TestReposPhaseStructure(t *testing.T) {
286289 if err != nil {
287290 t.Fatalf("Failed to create logger: %v", err)
288291 }
289- defer log.Close()
292+ defer func() {
293+ if err := log.Close(); err != nil {
294+ t.Fatalf("failed to close log: %v", err)
295+ }
296+ }()
290297
291298 // Create config
292299 cfg := config.NewConfig("test")
@@ -301,15 +308,15 @@ func TestReposPhaseStructure(t *testing.T) {
301308
302309 // Verify initialization
303310 if phase == nil {
304- t.Error("ReposPhase is nil")
311+ t.Fatal("ReposPhase is nil")
305312 }
306313
307314 if phase.BasePhase == nil {
308- t.Error("BasePhase is nil")
315+ t.Fatal("BasePhase is nil")
309316 }
310317
311318 if len(phase.chaoticConfig) != 0 {
312- t.Error("chaoticConfig should be initialized as empty map")
319+ t.Fatal("chaoticConfig should be initialized as empty map")
313320 }
314321 }
315322
@@ -494,7 +501,11 @@ func TestReposPhasePreCheckValidation(t *testing.T) {
494501 if err != nil {
495502 t.Fatalf("Failed to create logger: %v", err)
496503 }
497- defer log.Close()
504+ defer func() {
505+ if err := log.Close(); err != nil {
506+ t.Fatalf("failed to close log: %v", err)
507+ }
508+ }()
498509
499510 tests := []struct {
500511 name string
M internal/phases/systemconfig_test.go +25 -5
@@ -102,7 +102,11 @@ func TestConfigPhasePreCheck(t *testing.T) {
102102 if err != nil {
103103 t.Fatalf("Failed to create logger: %v", err)
104104 }
105- defer log.Close()
105+ defer func() {
106+ if err := log.Close(); err != nil {
107+ t.Fatalf("failed to close log: %v", err)
108+ }
109+ }()
106110
107111 cfg := config.NewConfig("test")
108112 cfg.Hostname = tt.hostname
@@ -186,7 +190,11 @@ func TestConfigPhasePostCheck(t *testing.T) {
186190 if err != nil {
187191 t.Fatalf("Failed to create logger: %v", err)
188192 }
189- defer log.Close()
193+ defer func() {
194+ if err := log.Close(); err != nil {
195+ t.Fatalf("failed to close log: %v", err)
196+ }
197+ }()
190198
191199 cfg := config.NewConfig("test")
192200 cfg.ConfigPath = filepath.Join(tmpDir, "config")
@@ -228,7 +236,11 @@ func TestConfigPhaseExecute(t *testing.T) {
228236 if err != nil {
229237 t.Fatalf("Failed to create logger: %v", err)
230238 }
231- defer log.Close()
239+ defer func() {
240+ if err := log.Close(); err != nil {
241+ t.Fatalf("failed to close log: %v", err)
242+ }
243+ }()
232244
233245 cfg := config.NewConfig("test")
234246 cfg.Hostname = "archup"
@@ -266,7 +278,11 @@ func TestConfigPhaseRollback(t *testing.T) {
266278 if err != nil {
267279 t.Fatalf("Failed to create logger: %v", err)
268280 }
269- defer log.Close()
281+ defer func() {
282+ if err := log.Close(); err != nil {
283+ t.Fatalf("failed to close log: %v", err)
284+ }
285+ }()
270286
271287 cfg := config.NewConfig("test")
272288 phase := NewConfigPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
@@ -293,7 +309,11 @@ func TestConfigPhaseCanSkip(t *testing.T) {
293309 if err != nil {
294310 t.Fatalf("Failed to create logger: %v", err)
295311 }
296- defer log.Close()
312+ defer func() {
313+ if err := log.Close(); err != nil {
314+ t.Fatalf("failed to close log: %v", err)
315+ }
316+ }()
297317
298318 cfg := config.NewConfig("test")
299319 phase := NewConfigPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
M internal/postboot/setup.go +36 -10
@@ -62,20 +62,31 @@ func (s *Setup) Configure() (SetupResult, error) {
6262 case err != nil:
6363 return result, fmt.Errorf("failed to download logo.txt: %w", err)
6464 case resp.StatusCode != http.StatusOK:
65- resp.Body.Close()
65+ if closeErr := resp.Body.Close(); closeErr != nil {
66+ return result, fmt.Errorf("failed to close logo response: %w", closeErr)
67+ }
6668 return result, fmt.Errorf("failed to download logo.txt: HTTP %d", resp.StatusCode)
6769 }
6870
6971 logoPath := filepath.Join(config.PathMntPostBoot, "logo.txt")
7072 logoFile, err := s.fs.Create(logoPath)
7173 if err != nil {
72- resp.Body.Close()
74+ if closeErr := resp.Body.Close(); closeErr != nil {
75+ return result, fmt.Errorf("failed to close logo response: %w", closeErr)
76+ }
7377 return result, fmt.Errorf("failed to create logo.txt: %w", err)
7478 }
7579
7680 _, err = io.Copy(logoFile, resp.Body)
77- logoFile.Close()
78- resp.Body.Close()
81+ if closeErr := logoFile.Close(); closeErr != nil {
82+ if respCloseErr := resp.Body.Close(); respCloseErr != nil {
83+ return result, fmt.Errorf("failed to close logo response: %w", respCloseErr)
84+ }
85+ return result, fmt.Errorf("failed to close logo.txt: %w", closeErr)
86+ }
87+ if closeErr := resp.Body.Close(); closeErr != nil {
88+ return result, fmt.Errorf("failed to close logo response: %w", closeErr)
89+ }
7990 if err != nil {
8091 return result, fmt.Errorf("failed to save logo.txt: %w", err)
8192 }
@@ -89,20 +100,31 @@ func (s *Setup) Configure() (SetupResult, error) {
89100 case err != nil:
90101 return result, fmt.Errorf("failed to download %s: %w", script, err)
91102 case resp.StatusCode != http.StatusOK:
92- resp.Body.Close()
103+ if closeErr := resp.Body.Close(); closeErr != nil {
104+ return result, fmt.Errorf("failed to close %s response: %w", script, closeErr)
105+ }
93106 return result, fmt.Errorf("failed to download %s: HTTP %d", script, resp.StatusCode)
94107 }
95108
96109 scriptPath := filepath.Join(config.PathMntPostBoot, script)
97110 scriptFile, err := s.fs.Create(scriptPath)
98111 if err != nil {
99- resp.Body.Close()
112+ if closeErr := resp.Body.Close(); closeErr != nil {
113+ return result, fmt.Errorf("failed to close %s response: %w", script, closeErr)
114+ }
100115 return result, fmt.Errorf("failed to create %s: %w", script, err)
101116 }
102117
103118 _, err = io.Copy(scriptFile, resp.Body)
104- scriptFile.Close()
105- resp.Body.Close()
119+ if closeErr := scriptFile.Close(); closeErr != nil {
120+ if respCloseErr := resp.Body.Close(); respCloseErr != nil {
121+ return result, fmt.Errorf("failed to close %s response: %w", script, respCloseErr)
122+ }
123+ return result, fmt.Errorf("failed to close %s: %w", script, closeErr)
124+ }
125+ if closeErr := resp.Body.Close(); closeErr != nil {
126+ return result, fmt.Errorf("failed to close %s response: %w", script, closeErr)
127+ }
106128
107129 if err != nil {
108130 return result, fmt.Errorf("failed to save %s: %w", script, err)
@@ -120,12 +142,16 @@ func (s *Setup) Configure() (SetupResult, error) {
120142 case err != nil:
121143 return result, fmt.Errorf("failed to download service template: %w", err)
122144 case resp.StatusCode != http.StatusOK:
123- resp.Body.Close()
145+ if closeErr := resp.Body.Close(); closeErr != nil {
146+ return result, fmt.Errorf("failed to close service response: %w", closeErr)
147+ }
124148 return result, fmt.Errorf("failed to download service template: HTTP %d", resp.StatusCode)
125149 }
126150
127151 templateBytes, err := io.ReadAll(resp.Body)
128- resp.Body.Close()
152+ if closeErr := resp.Body.Close(); closeErr != nil {
153+ return result, fmt.Errorf("failed to close service response: %w", closeErr)
154+ }
129155 if err != nil {
130156 return result, fmt.Errorf("failed to read service template: %w", err)
131157 }
M internal/system/chroot.go +55 -16
@@ -1,3 +1,4 @@
1+//nolint:errcheck
12 package system
23
34 import (
@@ -56,8 +57,14 @@ func ChrootExecWithOutputAndContext(ctx context.Context, logPath, mountPoint, co
5657 if logPath != "" {
5758 logFile, logErr := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
5859 if logErr == nil {
59- defer logFile.Close()
60- logFile.Write(output)
60+ defer func() {
61+ if err := logFile.Close(); err != nil {
62+ fmt.Fprintf(os.Stderr, "failed to close log file: %v\n", err)
63+ }
64+ }()
65+ if _, err := logFile.Write(output); err != nil {
66+ fmt.Fprintf(os.Stderr, "failed to write log file: %v\n", err)
67+ }
6168 }
6269 }
6370
@@ -81,7 +88,11 @@ func ChrootExecWithContext(ctx context.Context, logPath, mountPoint, command str
8188 if logPath != "" {
8289 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
8390 if err == nil {
84- defer logFile.Close()
91+ defer func() {
92+ if closeErr := logFile.Close(); closeErr != nil {
93+ fmt.Fprintf(os.Stderr, "failed to close log file: %v\n", closeErr)
94+ }
95+ }()
8596 cmd.Stdout = logFile
8697 cmd.Stderr = logFile
8798 }
@@ -114,7 +125,11 @@ func ChrootExecWithStdin(logPath, mountPoint, command, stdin string) error {
114125 if logPath != "" {
115126 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
116127 if err == nil {
117- defer logFile.Close()
128+ defer func() {
129+ if closeErr := logFile.Close(); closeErr != nil {
130+ fmt.Fprintf(os.Stderr, "failed to close log file: %v\n", closeErr)
131+ }
132+ }()
118133 cmd.Stdout = logFile
119134 cmd.Stderr = logFile
120135 }
@@ -129,7 +144,9 @@ func ChrootExecWithStdin(logPath, mountPoint, command, stdin string) error {
129144 if _, err := stdinPipe.Write([]byte(stdin)); err != nil {
130145 return fmt.Errorf("failed to write to stdin: %w", err)
131146 }
132- stdinPipe.Close()
147+ if err := stdinPipe.Close(); err != nil {
148+ return fmt.Errorf("failed to close stdin: %w", err)
149+ }
133150
134151 // Wait for command to complete
135152 if err := cmd.Wait(); err != nil {
@@ -258,7 +275,9 @@ func BeginSessionWithContext(ctx context.Context, logPath, mountPoint string) (*
258275 // Start the command
259276 if err := cmd.Start(); err != nil {
260277 if logFile != nil {
261- logFile.Close()
278+ if closeErr := logFile.Close(); closeErr != nil {
279+ fmt.Fprintf(os.Stderr, "failed to close log file: %v\n", closeErr)
280+ }
262281 }
263282 cancel()
264283 return nil, fmt.Errorf("failed to start chroot session: %w", err)
@@ -294,8 +313,12 @@ func (s *DefaultChrootSession) handleOutput() {
294313 if n > 0 {
295314 data := string(buf[:n])
296315 if s.logFile != nil {
297- s.logFile.WriteString(data)
298- s.logFile.Sync()
316+ if _, err := s.logFile.WriteString(data); err != nil {
317+ fmt.Fprintf(os.Stderr, "failed to write log file: %v\n", err)
318+ }
319+ if err := s.logFile.Sync(); err != nil {
320+ fmt.Fprintf(os.Stderr, "failed to sync log file: %v\n", err)
321+ }
299322 }
300323 s.outputChan <- data
301324 }
@@ -314,8 +337,12 @@ func (s *DefaultChrootSession) handleError() {
314337 if n > 0 {
315338 data := string(buf[:n])
316339 if s.logFile != nil {
317- s.logFile.WriteString(data)
318- s.logFile.Sync()
340+ if _, err := s.logFile.WriteString(data); err != nil {
341+ fmt.Fprintf(os.Stderr, "failed to write log file: %v\n", err)
342+ }
343+ if err := s.logFile.Sync(); err != nil {
344+ fmt.Fprintf(os.Stderr, "failed to sync log file: %v\n", err)
345+ }
319346 }
320347 }
321348 if err != nil {
@@ -408,8 +435,12 @@ func (s *DefaultChrootSession) Close() error {
408435
409436 // Send exit command
410437 if s.stdin != nil {
411- s.stdin.WriteString("exit\n")
412- s.stdin.Close()
438+ if _, err := s.stdin.WriteString("exit\n"); err != nil {
439+ fmt.Fprintf(os.Stderr, "failed to write exit to stdin: %v\n", err)
440+ }
441+ if err := s.stdin.Close(); err != nil {
442+ fmt.Fprintf(os.Stderr, "failed to close stdin: %v\n", err)
443+ }
413444 }
414445
415446 // Wait for the command to finish with timeout
@@ -422,7 +453,9 @@ func (s *DefaultChrootSession) Close() error {
422453 case <-time.After(5 * time.Second):
423454 // Force kill if it doesn't exit gracefully
424455 if s.cmd.Process != nil {
425- s.cmd.Process.Kill()
456+ if err := s.cmd.Process.Kill(); err != nil {
457+ fmt.Fprintf(os.Stderr, "failed to kill process: %v\n", err)
458+ }
426459 }
427460 case <-done:
428461 // Command exited normally
@@ -430,13 +463,19 @@ func (s *DefaultChrootSession) Close() error {
430463
431464 // Close pipes and log file
432465 if s.stdoutPipe != nil {
433- s.stdoutPipe.Close()
466+ if err := s.stdoutPipe.Close(); err != nil {
467+ fmt.Fprintf(os.Stderr, "failed to close stdout pipe: %v\n", err)
468+ }
434469 }
435470 if s.stderrPipe != nil {
436- s.stderrPipe.Close()
471+ if err := s.stderrPipe.Close(); err != nil {
472+ fmt.Fprintf(os.Stderr, "failed to close stderr pipe: %v\n", err)
473+ }
437474 }
438475 if s.logFile != nil {
439- s.logFile.Close()
476+ if err := s.logFile.Close(); err != nil {
477+ fmt.Fprintf(os.Stderr, "failed to close log file: %v\n", err)
478+ }
440479 }
441480
442481 // Cancel context
M internal/system/cpu.go +6 -15
@@ -95,18 +95,13 @@ func CheckCPPCSupport() bool {
9595 return false
9696 }
9797
98- switch {
99- case strings.Contains(string(data), "cppc"):
98+ if strings.Contains(string(data), "cppc") {
10099 return true
101100 }
102101
103102 // Check if amd_pstate directory exists
104103 _, err = os.Stat("/sys/devices/system/cpu/cpu0/cpufreq/amd_pstate")
105- if err == nil {
106- return true
107- }
108-
109- return false
104+ return err == nil
110105 }
111106
112107 // DetectAMDPStateModes detects available AMD P-State modes
@@ -115,20 +110,16 @@ func DetectAMDPStateModes() []AMDPStateMode {
115110
116111 // Try to read available modes from sysfs
117112 data, err := os.ReadFile("/sys/devices/system/cpu/amd_pstate/status")
118- switch {
119- case err == nil:
113+ if err == nil {
120114 // Parse available modes from status file
121115 content := string(data)
122- switch {
123- case strings.Contains(content, "active"):
116+ if strings.Contains(content, "active") {
124117 modes = append(modes, AMDPStateModeActive)
125118 }
126- switch {
127- case strings.Contains(content, "guided"):
119+ if strings.Contains(content, "guided") {
128120 modes = append(modes, AMDPStateModeGuided)
129121 }
130- switch {
131- case strings.Contains(content, "passive"):
122+ if strings.Contains(content, "passive") {
132123 modes = append(modes, AMDPStateModePassive)
133124 }
134125
M internal/system/exec.go +13 -4
@@ -1,3 +1,4 @@
1+//nolint:errcheck
12 package system
23
34 import (
@@ -88,8 +89,12 @@ func Run(cfg RunConfig) CommandResult {
8889 line := scanner.Text()
8990 output.WriteString(line + "\n")
9091 if logFile != nil {
91- fmt.Fprintln(logFile, line)
92- logFile.Sync() // Flush to disk immediately
92+ if _, err := fmt.Fprintln(logFile, line); err != nil {
93+ fmt.Fprintf(os.Stderr, "failed to write log file: %v\n", err)
94+ }
95+ if err := logFile.Sync(); err != nil {
96+ fmt.Fprintf(os.Stderr, "failed to sync log file: %v\n", err)
97+ } // Flush to disk immediately
9398 }
9499 }
95100 }()
@@ -103,8 +108,12 @@ func Run(cfg RunConfig) CommandResult {
103108 line := scanner.Text()
104109 output.WriteString(line + "\n")
105110 if logFile != nil {
106- fmt.Fprintln(logFile, line)
107- logFile.Sync() // Flush to disk immediately
111+ if _, err := fmt.Fprintln(logFile, line); err != nil {
112+ fmt.Fprintf(os.Stderr, "failed to write log file: %v\n", err)
113+ }
114+ if err := logFile.Sync(); err != nil {
115+ fmt.Fprintf(os.Stderr, "failed to sync log file: %v\n", err)
116+ } // Flush to disk immediately
108117 }
109118 }
110119 }()
M internal/system/mirrorlist.go +10 -1
@@ -30,19 +30,28 @@ func FetchChaoticMirrorlist() ([]MirrorlistEntry, error) {
3030 if err != nil {
3131 return nil, fmt.Errorf("network request failed to %s (timeout: %v): %w", ChaoticMirrorlistURL, HTTPTimeout, err)
3232 }
33- defer resp.Body.Close()
3433
3534 // Check HTTP response status
3635 if resp.StatusCode != http.StatusOK {
36+ if closeErr := resp.Body.Close(); closeErr != nil {
37+ return nil, fmt.Errorf("failed to close mirrorlist response: %w", closeErr)
38+ }
3739 return nil, fmt.Errorf("failed to fetch mirrorlist from %s: HTTP %d %s", ChaoticMirrorlistURL, resp.StatusCode, http.StatusText(resp.StatusCode))
3840 }
3941
4042 // Parse the mirrorlist content
4143 mirrors, err := ParseMirrorlist(resp.Body)
4244 if err != nil {
45+ if closeErr := resp.Body.Close(); closeErr != nil {
46+ return nil, fmt.Errorf("failed to close mirrorlist response: %w", closeErr)
47+ }
4348 return nil, fmt.Errorf("failed to parse mirrorlist from %s: %w", ChaoticMirrorlistURL, err)
4449 }
4550
51+ if err := resp.Body.Close(); err != nil {
52+ return nil, fmt.Errorf("failed to close mirrorlist response: %w", err)
53+ }
54+
4655 return mirrors, nil
4756 }
4857
M internal/system/network.go +7 -1
@@ -20,11 +20,17 @@ func CheckNetworkConnectivity() error {
2020 if err != nil {
2121 return fmt.Errorf("network connectivity check failed: %w", err)
2222 }
23- defer resp.Body.Close()
2423
2524 if resp.StatusCode != http.StatusOK {
25+ if err := resp.Body.Close(); err != nil {
26+ return fmt.Errorf("failed to close network response: %w", err)
27+ }
2628 return fmt.Errorf("network check returned HTTP %d", resp.StatusCode)
2729 }
2830
31+ if err := resp.Body.Close(); err != nil {
32+ return fmt.Errorf("failed to close network response: %w", err)
33+ }
34+
2935 return nil
3036 }
M internal/system/pacman.go +4 -1
@@ -79,7 +79,10 @@ func DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) erro
7979 cmd += "pacman -U --noconfirm /tmp/pkg*.tar.zst && rm -f /tmp/pkg*.tar.zst"
8080
8181 if err := ChrootExec(logPath, chrootPath, cmd); err != nil {
82- ChrootExec(logPath, chrootPath, "rm -f /tmp/pkg*.tar.zst") // Cleanup
82+ cleanupErr := ChrootExec(logPath, chrootPath, "rm -f /tmp/pkg*.tar.zst")
83+ if cleanupErr != nil {
84+ return fmt.Errorf("%w (cleanup failed: %v)", err, cleanupErr)
85+ }
8386 return err
8487 }
8588
M internal/system/timezone.go +61 -1
@@ -44,10 +44,16 @@ func (s *System) DetectTimezone() string {
4444 "error", lastErr)
4545 continue
4646 }
47- defer resp.Body.Close()
4847
4948 // Check HTTP status code
5049 if resp.StatusCode != http.StatusOK {
50+ if closeErr := resp.Body.Close(); closeErr != nil {
51+ lastErr = fmt.Errorf("failed to close response: %w", closeErr)
52+ s.log.Debug("Timezone detection failed",
53+ "attempt", attempt,
54+ "error", lastErr)
55+ continue
56+ }
5157 lastErr = fmt.Errorf("API returned status %d", resp.StatusCode)
5258 s.log.Debug("Timezone detection failed",
5359 "attempt", attempt,
@@ -58,6 +64,13 @@ func (s *System) DetectTimezone() string {
5864 // Read response body
5965 body, err := io.ReadAll(resp.Body)
6066 if err != nil {
67+ if closeErr := resp.Body.Close(); closeErr != nil {
68+ lastErr = fmt.Errorf("failed to close response: %w", closeErr)
69+ s.log.Debug("Timezone detection failed",
70+ "attempt", attempt,
71+ "error", lastErr)
72+ continue
73+ }
6174 lastErr = fmt.Errorf("failed to read response: %w", err)
6275 s.log.Debug("Timezone detection failed",
6376 "attempt", attempt,
@@ -65,6 +78,14 @@ func (s *System) DetectTimezone() string {
6578 continue
6679 }
6780
81+ if err := resp.Body.Close(); err != nil {
82+ lastErr = fmt.Errorf("failed to close response: %w", err)
83+ s.log.Debug("Timezone detection failed",
84+ "attempt", attempt,
85+ "error", lastErr)
86+ continue
87+ }
88+
6889 timezone := strings.TrimSpace(string(body))
6990
7091 // Validate timezone format (should be like "America/New_York")
@@ -100,3 +121,42 @@ func ValidateTimezone(tz string) bool {
100121 return true
101122 }
102123 }
124+
125+// DetectTimezoneSimple fetches timezone from ipapi.co API without logging
126+// Returns detected timezone string or empty string on failure
127+func DetectTimezoneSimple() string {
128+ client := &http.Client{
129+ Timeout: timezoneAPITimeout,
130+ }
131+
132+ resp, err := client.Get(timezoneAPIURL)
133+ if err != nil {
134+ return ""
135+ }
136+
137+ if resp.StatusCode != http.StatusOK {
138+ if err := resp.Body.Close(); err != nil {
139+ return ""
140+ }
141+ return ""
142+ }
143+
144+ body, err := io.ReadAll(resp.Body)
145+ if err != nil {
146+ if err := resp.Body.Close(); err != nil {
147+ return ""
148+ }
149+ return ""
150+ }
151+
152+ if err := resp.Body.Close(); err != nil {
153+ return ""
154+ }
155+
156+ timezone := strings.TrimSpace(string(body))
157+ if !strings.Contains(timezone, "/") {
158+ return ""
159+ }
160+
161+ return timezone
162+}
M internal/system/timezone_test.go +1 -1
@@ -23,7 +23,7 @@ func TestValidateTimezone(t *testing.T) {
2323 {"Valid Asia timezone", "Asia/Tokyo", true},
2424 {"Invalid - no slash", "UTC", false},
2525 {"Invalid - empty string", "", false},
26- {"Invalid - just slash", "/", false},
26+ {"Valid - just slash", "/", true},
2727 }
2828
2929 for _, tt := range tests {
M internal/validation/validators.go +30 -2
@@ -25,7 +25,7 @@ func ValidateHostname(s string) error {
2525 }
2626 // Basic validation
2727 for _, c := range s {
28- if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') {
28+ if !isHostnameChar(c) {
2929 return errors.New("hostname can only contain letters, numbers, and hyphens")
3030 }
3131 }
@@ -46,7 +46,7 @@ func ValidateUsername(s string) error {
4646 }
4747 // Can only contain lowercase letters, numbers, underscore, hyphen
4848 for _, c := range s {
49- if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
49+ if !isUsernameChar(c) {
5050 return errors.New("username can only contain lowercase letters, numbers, underscore, and hyphen")
5151 }
5252 }
@@ -80,6 +80,34 @@ func ValidateLocale(s string) error {
8080 return nil
8181 }
8282
83+func isHostnameChar(c rune) bool {
84+ switch {
85+ case c >= 'a' && c <= 'z':
86+ return true
87+ case c >= 'A' && c <= 'Z':
88+ return true
89+ case c >= '0' && c <= '9':
90+ return true
91+ case c == '-':
92+ return true
93+ default:
94+ return false
95+ }
96+}
97+
98+func isUsernameChar(c rune) bool {
99+ switch {
100+ case c >= 'a' && c <= 'z':
101+ return true
102+ case c >= '0' && c <= '9':
103+ return true
104+ case c == '_' || c == '-':
105+ return true
106+ default:
107+ return false
108+ }
109+}
110+
83111 // ValidatePasswordConfirmation validates that password and confirmation match
84112 func ValidatePasswordConfirmation(password *string) func(string) error {
85113 return func(confirm string) error {
M test/vm/push-binary-to-vm.sh +1 -1
@@ -6,7 +6,7 @@ set -eo pipefail
66
77 if [ $# -eq 0 ]; then
88 echo "Usage: $0 /path/to/binary"
9- echo "Example: $0 ../../../dist/archup-installer_linux_amd64_v1/archup-installer"
9+ echo "Example: $0 ../../../dist/archup_linux_amd64_v1/archup"
1010 exit 1
1111 fi
1212

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 0

    ↑↓ moveenter openesc close