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/
19 coverage.out19 coverage.out
20 20
21 # Compiled Go binaries21 # Compiled Go binaries
22-/archup-installer22+/archup
23-/cmd/archup-installer/archup-installer23+/cmd/archup/archup
24 cmd/*/[a-z]*24 cmd/*/[a-z]*
25 25
26 # Markdown files at root (except README and CHANGELOG)26 # Markdown files at root (except README and CHANGELOG)
M .goreleaser.yaml +14 -14
@@ -8,9 +8,9 @@ before:
8 8
9 builds:9 builds:
10 # Main build - static binary for Arch Linux ISO10 # Main build - static binary for Arch Linux ISO
11- - id: archup-installer11+ - id: archup
12- main: ./cmd/archup-installer12+ main: ./cmd/archup
13- binary: archup-installer13+ binary: archup
14 14
15 env:15 env:
16 - CGO_ENABLED=016 - CGO_ENABLED=0
@@ -24,7 +24,7 @@ builds:
24 # Version information via ldflags24 # Version information via ldflags
25 ldflags:25 ldflags:
26 - -s -w26 - -s -w
27- - -X main.version={{.Version}}27+ - -X github.com/bnema/archup/cmd.version={{.Version}}
28 28
29 # Build flags for reproducible builds29 # Build flags for reproducible builds
30 flags:30 flags:
@@ -54,8 +54,8 @@ checksum:
54 name_template: 'checksums.txt'54 name_template: 'checksums.txt'
55 algorithm: sha25655 algorithm: sha256
56 extra_files:56 extra_files:
57- - glob: ./dist/archup-installer_linux_amd64_v1/archup-installer57+ - glob: ./dist/archup_linux_amd64_v1/archup
58- name_template: archup-installer58+ name_template: archup
59 59
60 snapshot:60 snapshot:
61 version_template: "{{ incpatch .Version }}-next"61 version_template: "{{ incpatch .Version }}-next"
@@ -129,23 +129,23 @@ release:
129 ### Install Specific Version129 ### Install Specific Version
130 ```bash130 ```bash
131 # Download and run specific version131 # Download and run specific version
132- curl -fsSL https://github.com/bnema/archup/releases/download/{{.Tag}}/archup-installer -o /tmp/archup-installer132+ curl -fsSL https://github.com/bnema/archup/releases/download/{{.Tag}}/archup -o /tmp/archup
133- chmod +x /tmp/archup-installer133+ chmod +x /tmp/archup
134- /tmp/archup-installer134+ /tmp/archup install
135 ```135 ```
136 136
137 ### Manual Install137 ### Manual Install
138 ```bash138 ```bash
139 # Download binary139 # Download binary
140- wget https://github.com/bnema/archup/releases/download/{{.Tag}}/archup-installer140+ wget https://github.com/bnema/archup/releases/download/{{.Tag}}/archup
141 141
142 # Verify checksum142 # Verify checksum
143 wget https://github.com/bnema/archup/releases/download/{{.Tag}}/checksums.txt143 wget https://github.com/bnema/archup/releases/download/{{.Tag}}/checksums.txt
144 sha256sum -c checksums.txt --ignore-missing144 sha256sum -c checksums.txt --ignore-missing
145 145
146 # Install146 # Install
147- chmod +x archup-installer147+ chmod +x archup
148- sudo install -m 755 archup-installer /usr/local/bin/148+ sudo install -m 755 archup /usr/local/bin/
149 ```149 ```
150 150
151 ---151 ---
@@ -163,5 +163,5 @@ release:
163 163
164 # Also upload standalone binary164 # Also upload standalone binary
165 extra_files:165 extra_files:
166- - glob: ./dist/archup-installer_linux_amd64_v1/archup-installer166+ - glob: ./dist/archup_linux_amd64_v1/archup
167- name_template: archup-installer167+ name_template: archup
M Makefile +4 -4
@@ -4,18 +4,18 @@
4 help:4 help:
5 @echo "archup - Makefile targets:"5 @echo "archup - Makefile targets:"
6 @echo ""6 @echo ""
7- @echo " make build - Build archup-installer binary"7+ @echo " make build - Build archup binary"
8 @echo " make check - Run all checks (syntax + shellcheck)"8 @echo " make check - Run all checks (syntax + shellcheck)"
9 @echo " make check-syntax - Check shell script syntax with bash -n"9 @echo " make check-syntax - Check shell script syntax with bash -n"
10 @echo " make check-shellcheck - Run shellcheck linting"10 @echo " make check-shellcheck - Run shellcheck linting"
11 @echo " make clean - Remove generated files"11 @echo " make clean - Remove generated files"
12 @echo ""12 @echo ""
13 13
14-# Build archup-installer binary14+# Build archup binary
15 build:15 build:
16- @echo "Building archup-installer..."16+ @echo "Building archup..."
17 goreleaser build --snapshot --clean --single-target17 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"
19 19
20 # Run all checks20 # Run all checks
21 check: check-syntax check-shellcheck21 check: check-syntax check-shellcheck
M README.md +1 -1
@@ -56,7 +56,7 @@ curl -fsSL https://archup.run/install/dev | bash
56 - Firewall (ufw) and power management (TLP)56 - Firewall (ufw) and power management (TLP)
57 - Shell completion and theme system57 - Shell completion and theme system
58 58
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.
60 60
61 ## Status61 ## Status
62 62
M RELEASING.md +1 -1
@@ -110,7 +110,7 @@ curl -fsSL https://archup.run/install/bin | bash -s -- --version v0.15.3-dev
110 ## Artifacts Generated110 ## Artifacts Generated
111 111
112 Each release creates:112 Each release creates:
113-- `archup-installer` - Standalone binary113+- `archup` - Standalone binary
114 - `archup_VERSION_linux_x86_64.tar.gz` - Archive with binary + install scripts114 - `archup_VERSION_linux_x86_64.tar.gz` - Archive with binary + install scripts
115 - `checksums.txt` - SHA256 checksums for verification115 - `checksums.txt` - SHA256 checksums for verification
116 116
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)
18 Infrastructure Layer (adapters) implements domain ports18 Infrastructure Layer (adapters) implements domain ports
19 ```19 ```
20 20
21-## Wiring in cmd/archup-installer/main.go21+## Wiring in cmd/archup/main.go
22 22
23 ### 1. Infrastructure Adapters (Lowest Layer)23 ### 1. Infrastructure Adapters (Lowest Layer)
24 24
M docs/architecture.md +14 -5
@@ -56,7 +56,6 @@ Dependencies: UI → App → Domain ← Infrastructure
56 #### `domain/bootloader/`56 #### `domain/bootloader/`
57 - **bootloader.go** - Bootloader value object (type, configuration)57 - **bootloader.go** - Bootloader value object (type, configuration)
58 - **limine.go** - Limine-specific configuration and rules58 - **limine.go** - Limine-specific configuration and rules
59-- **systemdboot.go** - systemd-boot specific configuration
60 59
61 #### `domain/user/`60 #### `domain/user/`
62 - **user.go** - User entity (username, groups, shell)61 - **user.go** - User entity (username, groups, shell)
@@ -154,6 +153,7 @@ UI/CLI layer. **Imports application/** but minimal domain access.
154 #### `interfaces/tui/`153 #### `interfaces/tui/`
155 BubbleTea terminal UI application:154 BubbleTea terminal UI application:
156 - **app.go** - Main TUI application155 - **app.go** - Main TUI application
156+ - **wizard/** - Post-boot wizard TUI (desktop setup)
157 157
158 #### `interfaces/tui/models/`158 #### `interfaces/tui/models/`
159 BubbleTea models (UI state only, no business logic):159 BubbleTea models (UI state only, no business logic):
@@ -177,10 +177,19 @@ CLI commands (e.g., using Cobra):
177 - **root.go** - Root command177 - **root.go** - Root command
178 - **install.go** - Install command178 - **install.go** - Install command
179 179
180-### `/cmd/archup-installer/`180+### `/cmd/archup/`
181 Application entry point with dependency injection:181 Application entry point with dependency injection:
182 - **main.go** - Wire all dependencies (adapters, handlers, services, TUI)182 - **main.go** - Wire all dependencies (adapters, handlers, services, TUI)
183 183
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+
184 ## Architecture Patterns193 ## Architecture Patterns
185 194
186 ### 1. Aggregate Root (Installation)195 ### 1. Aggregate Root (Installation)
@@ -319,7 +328,7 @@ For each phase migrated from shell scripts:
319 3. Create handler in `application/handlers/`328 3. Create handler in `application/handlers/`
320 4. Implement adapters in `infrastructure/*/`329 4. Implement adapters in `infrastructure/*/`
321 5. Add UI in `interfaces/tui/`330 5. Add UI in `interfaces/tui/`
322-6. Wire in `cmd/archup-installer/main.go`331+6. Wire in `cmd/archup/main.go`
323 332
324 ### Fixing a Bug333 ### Fixing a Bug
325 334
@@ -331,7 +340,7 @@ For each phase migrated from shell scripts:
331 ### Changing Infrastructure340 ### Changing Infrastructure
332 341
333 1. Create new adapter implementing port342 1. Create new adapter implementing port
334-2. Update `cmd/archup-installer/main.go` wiring343+2. Update `cmd/archup/main.go` wiring
335 3. Existing domain and application code unchanged344 3. Existing domain and application code unchanged
336 4. No need to modify business logic345 4. No need to modify business logic
337 346
@@ -340,7 +349,7 @@ For each phase migrated from shell scripts:
340 - **DDD_REFACTORING_PLAN.md** - Complete refactoring plan with detailed phases349 - **DDD_REFACTORING_PLAN.md** - Complete refactoring plan with detailed phases
341 - **docs/script-migration.md** - Shell scripts categorization and migration strategy350 - **docs/script-migration.md** - Shell scripts categorization and migration strategy
342 - **internal/domain/ports/** - All port interfaces (critical!)351 - **internal/domain/ports/** - All port interfaces (critical!)
343-- **cmd/archup-installer/main.go** - Dependency injection wiring352+- **cmd/archup/main.go** - Dependency injection wiring
344 353
345 ## References354 ## References
346 355
M go.mod +15 -9
@@ -8,6 +8,7 @@ require (
8 github.com/charmbracelet/huh v0.8.08 github.com/charmbracelet/huh v0.8.0
9 github.com/charmbracelet/lipgloss v1.1.09 github.com/charmbracelet/lipgloss v1.1.0
10 github.com/google/uuid v1.6.010 github.com/google/uuid v1.6.0
11+ github.com/spf13/cobra v1.10.2
11 go.uber.org/mock v0.6.012 go.uber.org/mock v0.6.0
12 )13 )
13 14
@@ -15,23 +16,28 @@ require (
15 github.com/atotto/clipboard v0.1.4 // indirect16 github.com/atotto/clipboard v0.1.4 // indirect
16 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect17 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
17 github.com/catppuccin/go v0.3.0 // indirect18 github.com/catppuccin/go v0.3.0 // indirect
18- github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect19+ github.com/charmbracelet/colorprofile v0.4.1 // indirect
19- github.com/charmbracelet/x/ansi v0.10.1 // indirect20+ github.com/charmbracelet/x/ansi v0.11.4 // indirect
20- github.com/charmbracelet/x/cellbuf v0.0.13 // indirect21+ github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
21- github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect22+ github.com/charmbracelet/x/exp/strings v0.0.0-20260122224438-b01af16209d9 // indirect
22- github.com/charmbracelet/x/term v0.2.1 // indirect23+ 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
23 github.com/dustin/go-humanize v1.0.1 // indirect27 github.com/dustin/go-humanize v1.0.1 // indirect
24 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect28 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
25- github.com/lucasb-eyer/go-colorful v1.2.0 // indirect29+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
30+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
26 github.com/mattn/go-isatty v0.0.20 // indirect31 github.com/mattn/go-isatty v0.0.20 // indirect
27 github.com/mattn/go-localereader v0.0.1 // indirect32 github.com/mattn/go-localereader v0.0.1 // indirect
28- github.com/mattn/go-runewidth v0.0.16 // indirect33+ github.com/mattn/go-runewidth v0.0.19 // indirect
29 github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect34 github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
30 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect35 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
31 github.com/muesli/cancelreader v0.2.2 // indirect36 github.com/muesli/cancelreader v0.2.2 // indirect
32 github.com/muesli/termenv v0.16.0 // indirect37 github.com/muesli/termenv v0.16.0 // indirect
33 github.com/rivo/uniseg v0.4.7 // indirect38 github.com/rivo/uniseg v0.4.7 // indirect
39+ github.com/spf13/pflag v1.0.10 // indirect
34 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect40 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
35- golang.org/x/sys v0.37.0 // indirect41+ golang.org/x/sys v0.40.0 // indirect
36- golang.org/x/text v0.23.0 // indirect42+ golang.org/x/text v0.33.0 // indirect
37 )43 )
M go.sum +35 -19 generated file: collapsed
M install-bin-dev.sh +2 -2
@@ -76,7 +76,7 @@ success "Architecture: $ARCH"
76 # GitHub repository details76 # GitHub repository details
77 REPO_OWNER="bnema"77 REPO_OWNER="bnema"
78 REPO_NAME="archup"78 REPO_NAME="archup"
79-BINARY_NAME="archup-installer"79+BINARY_NAME="archup"
80 80
81 # Determine which version to download81 # Determine which version to download
82 if [ -n "$SPECIFIC_VERSION" ]; then82 if [ -n "$SPECIFIC_VERSION" ]; then
@@ -128,4 +128,4 @@ chmod +x "$BINARY_PATH"
128 # Run installer128 # Run installer
129 info "Launching ArchUp installer..."129 info "Launching ArchUp installer..."
130 echo ""130 echo ""
131-exec "$BINARY_PATH"131+exec "$BINARY_PATH" install
M install-bin.sh +2 -2
@@ -83,7 +83,7 @@ success "Architecture: $ARCH"
83 # GitHub repository details83 # GitHub repository details
84 REPO_OWNER="bnema"84 REPO_OWNER="bnema"
85 REPO_NAME="archup"85 REPO_NAME="archup"
86-BINARY_NAME="archup-installer"86+BINARY_NAME="archup"
87 87
88 # Determine which version to download88 # Determine which version to download
89 if [ -n "$SPECIFIC_VERSION" ]; then89 if [ -n "$SPECIFIC_VERSION" ]; then
@@ -145,4 +145,4 @@ chmod +x "$BINARY_PATH"
145 # Run installer145 # Run installer
146 info "Launching ArchUp installer..."146 info "Launching ArchUp installer..."
147 echo ""147 echo ""
148-exec "$BINARY_PATH"148+exec "$BINARY_PATH" install
M install/configs/limine.conf.template +11 -7
@@ -1,18 +1,22 @@
1 timeout: {{TIMEOUT}}1 timeout: {{TIMEOUT}}
2-default_entry: {{DEFAULT_ENTRY}}2+default_entry: 1
3 interface_branding: {{BRANDING}}3 interface_branding: {{BRANDING}}
4 interface_branding_colour: {{COLOR}}4 interface_branding_colour: {{COLOR}}
5 graphics: yes5 graphics: yes
6 quiet: yes6 quiet: yes
7 7
8-/Arch Linux8+/{{BRANDING}}
9+
10+ //{{KERNEL}}
9 protocol: linux11 protocol: linux
10- path: boot():/vmlinuz-{{KERNEL}}12+ kernel_path: boot():/vmlinuz-{{KERNEL}}
11- cmdline: {{KERNEL_PARAMS}}13+ kernel_cmdline: {{KERNEL_PARAMS}}
12 module_path: boot():/initramfs-{{KERNEL}}.img14 module_path: boot():/initramfs-{{KERNEL}}.img
13 15
14-/Arch Linux (fallback)16+ //{{KERNEL}}-fallback
15 protocol: linux17 protocol: linux
16- path: boot():/vmlinuz-{{KERNEL}}18+ kernel_path: boot():/vmlinuz-{{KERNEL}}
17- cmdline: {{KERNEL_PARAMS}}19+ kernel_cmdline: {{KERNEL_PARAMS}}
18 module_path: boot():/initramfs-{{KERNEL}}-fallback.img20 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
12 echo "Created snapper config: home"12 echo "Created snapper config: home"
13 fi13 fi
14 14
15-# Tweak default snapper configs15+# Configure timeline snapshots for root: 1 hourly, 1 daily, 1 weekly, 1 monthly, 0 yearly
16-sed -i 's/^TIMELINE_CREATE="yes"/TIMELINE_CREATE="no"/' /etc/snapper/configs/root16+snapper -c root set-config "TIMELINE_CREATE=yes"
17-sed -i 's/^TIMELINE_CREATE="yes"/TIMELINE_CREATE="no"/' /etc/snapper/configs/home17+snapper -c root set-config "TIMELINE_LIMIT_HOURLY=1"
18-sed -i 's/^NUMBER_LIMIT="50"/NUMBER_LIMIT="5"/' /etc/snapper/configs/root18+snapper -c root set-config "TIMELINE_LIMIT_DAILY=1"
19-sed -i 's/^NUMBER_LIMIT="50"/NUMBER_LIMIT="5"/' /etc/snapper/configs/home19+snapper -c root set-config "TIMELINE_LIMIT_WEEKLY=1"
20-sed -i 's/^NUMBER_LIMIT_IMPORTANT="10"/NUMBER_LIMIT_IMPORTANT="5"/' /etc/snapper/configs/root20+snapper -c root set-config "TIMELINE_LIMIT_MONTHLY=1"
21-sed -i 's/^NUMBER_LIMIT_IMPORTANT="10"/NUMBER_LIMIT_IMPORTANT="5"/' /etc/snapper/configs/home21+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"
22 24
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 @@
1 package commands1 package commands
2 2
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+)
4 8
5 // InstallBootloaderCommand contains data for bootloader installation9 // InstallBootloaderCommand contains data for bootloader installation
6 type InstallBootloaderCommand struct {10 type InstallBootloaderCommand struct {
7 MountPoint string // Root mount point11 MountPoint string // Root mount point
8- BootloaderType bootloader.BootloaderType // BootloaderTypeLimine or BootloaderTypeSystemdBoot12+ BootloaderType bootloader.BootloaderType // BootloaderTypeLimine
9 TimeoutSeconds int // Boot menu timeout (0-600 seconds)13 TimeoutSeconds int // Boot menu timeout (0-600 seconds)
10 Branding string // Bootloader display name14 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
11 }20 }
M internal/application/commands/partition.go +1 -1
@@ -6,7 +6,7 @@ import "github.com/bnema/archup/internal/domain/disk"
6 type PartitionDiskCommand struct {6 type PartitionDiskCommand struct {
7 TargetDisk string // e.g., "/dev/sda"7 TargetDisk string // e.g., "/dev/sda"
8 RootSizeGB int64 // Size of root partition in GB8 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)
10 EncryptionType disk.EncryptionType // EncryptionTypeNone, EncryptionTypeLUKS, EncryptionTypeLUKSLVM10 EncryptionType disk.EncryptionType // EncryptionTypeNone, EncryptionTypeLUKS, EncryptionTypeLUKSLVM
11 EncryptionPassword string // Password for encrypted partitions (if applicable)11 EncryptionPassword string // Password for encrypted partitions (if applicable)
12 FilesystemType disk.FilesystemType // FilesystemExt4, FilesystemBtrfs, FilesystemFAT3212 FilesystemType disk.FilesystemType // FilesystemExt4, FilesystemBtrfs, FilesystemFAT32
M internal/application/handlers/bootloader_handler.go +166 -5
@@ -3,22 +3,32 @@ package handlers
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "path/filepath"
7+ "regexp"
8+ "strings"
6 9
7 "github.com/bnema/archup/internal/application/commands"10 "github.com/bnema/archup/internal/application/commands"
8 "github.com/bnema/archup/internal/application/dto"11 "github.com/bnema/archup/internal/application/dto"
12+ "github.com/bnema/archup/internal/config"
9 "github.com/bnema/archup/internal/domain/bootloader"13 "github.com/bnema/archup/internal/domain/bootloader"
14+ "github.com/bnema/archup/internal/domain/disk"
15+ "github.com/bnema/archup/internal/domain/packages"
10 "github.com/bnema/archup/internal/domain/ports"16 "github.com/bnema/archup/internal/domain/ports"
11 )17 )
12 18
13 // BootloaderHandler handles bootloader installation19 // BootloaderHandler handles bootloader installation
14 type BootloaderHandler struct {20 type BootloaderHandler struct {
21+ fs ports.FileSystem
22+ cmdExec ports.CommandExecutor
15 chrExec ports.ChrootExecutor23 chrExec ports.ChrootExecutor
16 logger ports.Logger24 logger ports.Logger
17 }25 }
18 26
19 // NewBootloaderHandler creates a new bootloader handler27 // 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 {
21 return &BootloaderHandler{29 return &BootloaderHandler{
30+ fs: fs,
31+ cmdExec: cmdExec,
22 chrExec: chrExec,32 chrExec: chrExec,
23 logger: logger,33 logger: logger,
24 }34 }
@@ -46,10 +56,32 @@ func (h *BootloaderHandler) Handle(ctx context.Context, cmd commands.InstallBoot
46 56
47 h.logger.Info("Bootloader configuration validated", "type", bl.Type())57 h.logger.Info("Bootloader configuration validated", "type", bl.Type())
48 58
49- // In a real implementation, this would configure and install the bootloader59+ kernel, err := packages.NewKernel(cmd.KernelVariant)
50- h.logger.Info("Installing bootloader...", "type", bl.Type())60+ if err != nil {
51- // For Limine: configure limine.cfg61+ h.logger.Error("Invalid kernel variant", "error", err)
52- // For systemd-boot: configure bootctl and entry files62+ 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+ }
53 85
54 result.Success = true86 result.Success = true
55 result.BootloaderType = bl.Type().String()87 result.BootloaderType = bl.Type().String()
@@ -58,3 +90,132 @@ func (h *BootloaderHandler) Handle(ctx context.Context, cmd commands.InstallBoot
58 h.logger.Info("Bootloader installation completed successfully")90 h.logger.Info("Bootloader installation completed successfully")
59 return result, nil91 return result, nil
60 }92 }
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 (
6 6
7 "github.com/bnema/archup/internal/application/commands"7 "github.com/bnema/archup/internal/application/commands"
8 "github.com/bnema/archup/internal/domain/bootloader"8 "github.com/bnema/archup/internal/domain/bootloader"
9+ "github.com/bnema/archup/internal/domain/disk"
10+ "github.com/bnema/archup/internal/domain/packages"
9 "github.com/bnema/archup/internal/domain/ports/mocks"11 "github.com/bnema/archup/internal/domain/ports/mocks"
10 "go.uber.org/mock/gomock"12 "go.uber.org/mock/gomock"
11 )13 )
@@ -14,18 +16,33 @@ func TestBootloaderHandler_Handle_Limine(t *testing.T) {
14 ctrl := gomock.NewController(t)16 ctrl := gomock.NewController(t)
15 defer ctrl.Finish()17 defer ctrl.Finish()
16 18
19+ mockFS := mocks.NewMockFileSystem(ctrl)
20+ mockExec := mocks.NewMockCommandExecutor(ctrl)
17 mockChrExec := mocks.NewMockChrootExecutor(ctrl)21 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
18 mockLogger := mocks.NewMockLogger(ctrl)22 mockLogger := mocks.NewMockLogger(ctrl)
19 23
20 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()24 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()
21 33
22- handler := NewBootloaderHandler(mockChrExec, mockLogger)34+ handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
23 35
24 cmd := commands.InstallBootloaderCommand{36 cmd := commands.InstallBootloaderCommand{
25 MountPoint: "/mnt",37 MountPoint: "/mnt",
26 BootloaderType: bootloader.BootloaderTypeLimine,38 BootloaderType: bootloader.BootloaderTypeLimine,
27 TimeoutSeconds: 5,39 TimeoutSeconds: 5,
28 Branding: "ArchUp",40 Branding: "ArchUp",
41+ KernelVariant: packages.KernelStable,
42+ RootPartition: "/dev/sda2",
43+ EncryptionType: disk.EncryptionTypeNone,
44+ EFIPartition: "/dev/sda1",
45+ TargetDisk: "/dev/sda",
29 }46 }
30 47
31 result, err := handler.Handle(context.Background(), cmd)48 result, err := handler.Handle(context.Background(), cmd)
@@ -47,56 +64,30 @@ func TestBootloaderHandler_Handle_Limine(t *testing.T) {
47 }64 }
48 }65 }
49 66
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-
83 func TestBootloaderHandler_Handle_InvalidTimeout(t *testing.T) {67 func TestBootloaderHandler_Handle_InvalidTimeout(t *testing.T) {
84 ctrl := gomock.NewController(t)68 ctrl := gomock.NewController(t)
85 defer ctrl.Finish()69 defer ctrl.Finish()
86 70
71+ mockFS := mocks.NewMockFileSystem(ctrl)
72+ mockExec := mocks.NewMockCommandExecutor(ctrl)
87 mockChrExec := mocks.NewMockChrootExecutor(ctrl)73 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
88 mockLogger := mocks.NewMockLogger(ctrl)74 mockLogger := mocks.NewMockLogger(ctrl)
89 75
90 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()76 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
91 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)77 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)
92 78
93- handler := NewBootloaderHandler(mockChrExec, mockLogger)79+ handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
94 80
95 cmd := commands.InstallBootloaderCommand{81 cmd := commands.InstallBootloaderCommand{
96 MountPoint: "/mnt",82 MountPoint: "/mnt",
97 BootloaderType: bootloader.BootloaderTypeLimine,83 BootloaderType: bootloader.BootloaderTypeLimine,
98 TimeoutSeconds: 700, // Too large84 TimeoutSeconds: 700, // Too large
99 Branding: "ArchUp",85 Branding: "ArchUp",
86+ KernelVariant: packages.KernelStable,
87+ RootPartition: "/dev/sda2",
88+ EncryptionType: disk.EncryptionTypeNone,
89+ EFIPartition: "/dev/sda1",
90+ TargetDisk: "/dev/sda",
100 }91 }
101 92
102 result, err := handler.Handle(context.Background(), cmd)93 result, err := handler.Handle(context.Background(), cmd)
@@ -114,19 +105,26 @@ func TestBootloaderHandler_Handle_InvalidBranding(t *testing.T) {
114 ctrl := gomock.NewController(t)105 ctrl := gomock.NewController(t)
115 defer ctrl.Finish()106 defer ctrl.Finish()
116 107
108+ mockFS := mocks.NewMockFileSystem(ctrl)
109+ mockExec := mocks.NewMockCommandExecutor(ctrl)
117 mockChrExec := mocks.NewMockChrootExecutor(ctrl)110 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
118 mockLogger := mocks.NewMockLogger(ctrl)111 mockLogger := mocks.NewMockLogger(ctrl)
119 112
120 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()113 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
121 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)114 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)
122 115
123- handler := NewBootloaderHandler(mockChrExec, mockLogger)116+ handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
124 117
125 cmd := commands.InstallBootloaderCommand{118 cmd := commands.InstallBootloaderCommand{
126 MountPoint: "/mnt",119 MountPoint: "/mnt",
127 BootloaderType: bootloader.BootloaderTypeLimine,120 BootloaderType: bootloader.BootloaderTypeLimine,
128 TimeoutSeconds: 5,121 TimeoutSeconds: 5,
129 Branding: "", // Empty branding122 Branding: "", // Empty branding
123+ KernelVariant: packages.KernelStable,
124+ RootPartition: "/dev/sda2",
125+ EncryptionType: disk.EncryptionTypeNone,
126+ EFIPartition: "/dev/sda1",
127+ TargetDisk: "/dev/sda",
130 }128 }
131 129
132 result, err := handler.Handle(context.Background(), cmd)130 result, err := handler.Handle(context.Background(), cmd)
M internal/application/handlers/configure_system_handler.go +103 -1
@@ -3,6 +3,8 @@ package handlers
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "path/filepath"
7+ "strings"
6 8
7 "github.com/bnema/archup/internal/application/commands"9 "github.com/bnema/archup/internal/application/commands"
8 "github.com/bnema/archup/internal/application/dto"10 "github.com/bnema/archup/internal/application/dto"
@@ -13,13 +15,15 @@ import (
13 15
14 // ConfigureSystemHandler handles system configuration16 // ConfigureSystemHandler handles system configuration
15 type ConfigureSystemHandler struct {17 type ConfigureSystemHandler struct {
18+ fs ports.FileSystem
16 chrExec ports.ChrootExecutor19 chrExec ports.ChrootExecutor
17 logger ports.Logger20 logger ports.Logger
18 }21 }
19 22
20 // NewConfigureSystemHandler creates a new system configuration handler23 // 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 {
22 return &ConfigureSystemHandler{25 return &ConfigureSystemHandler{
26+ fs: fs,
23 chrExec: chrExec,27 chrExec: chrExec,
24 logger: logger,28 logger: logger,
25 }29 }
@@ -69,6 +73,104 @@ func (h *ConfigureSystemHandler) Handle(ctx context.Context, cmd commands.Config
69 73
70 h.logger.Info("User configuration validated", "username", usr.Username())74 h.logger.Info("User configuration validated", "username", usr.Username())
71 h.logger.Info("Credentials validated successfully")75 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+
72 h.logger.Info("System configuration completed successfully")174 h.logger.Info("System configuration completed successfully")
73 175
74 result.Success = true176 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) {
13 ctrl := gomock.NewController(t)13 ctrl := gomock.NewController(t)
14 defer ctrl.Finish()14 defer ctrl.Finish()
15 15
16+ mockFS := mocks.NewMockFileSystem(ctrl)
16 mockChrExec := mocks.NewMockChrootExecutor(ctrl)17 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
17 mockLogger := mocks.NewMockLogger(ctrl)18 mockLogger := mocks.NewMockLogger(ctrl)
18 19
19 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()20 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
20 mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()21 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()
21 27
22- handler := NewConfigureSystemHandler(mockChrExec, mockLogger)28+ handler := NewConfigureSystemHandler(mockFS, mockChrExec, mockLogger)
23 29
24 cmd := commands.ConfigureSystemCommand{30 cmd := commands.ConfigureSystemCommand{
25 MountPoint: "/mnt",31 MountPoint: "/mnt",
@@ -60,13 +66,14 @@ func TestConfigureSystemHandler_Handle_InvalidHostname(t *testing.T) {
60 ctrl := gomock.NewController(t)66 ctrl := gomock.NewController(t)
61 defer ctrl.Finish()67 defer ctrl.Finish()
62 68
69+ mockFS := mocks.NewMockFileSystem(ctrl)
63 mockChrExec := mocks.NewMockChrootExecutor(ctrl)70 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
64 mockLogger := mocks.NewMockLogger(ctrl)71 mockLogger := mocks.NewMockLogger(ctrl)
65 72
66 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()73 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
67 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)74 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)
68 75
69- handler := NewConfigureSystemHandler(mockChrExec, mockLogger)76+ handler := NewConfigureSystemHandler(mockFS, mockChrExec, mockLogger)
70 77
71 cmd := commands.ConfigureSystemCommand{78 cmd := commands.ConfigureSystemCommand{
72 MountPoint: "/mnt",79 MountPoint: "/mnt",
@@ -95,13 +102,14 @@ func TestConfigureSystemHandler_Handle_InvalidUser(t *testing.T) {
95 ctrl := gomock.NewController(t)102 ctrl := gomock.NewController(t)
96 defer ctrl.Finish()103 defer ctrl.Finish()
97 104
105+ mockFS := mocks.NewMockFileSystem(ctrl)
98 mockChrExec := mocks.NewMockChrootExecutor(ctrl)106 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
99 mockLogger := mocks.NewMockLogger(ctrl)107 mockLogger := mocks.NewMockLogger(ctrl)
100 108
101 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()109 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
102 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)110 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)
103 111
104- handler := NewConfigureSystemHandler(mockChrExec, mockLogger)112+ handler := NewConfigureSystemHandler(mockFS, mockChrExec, mockLogger)
105 113
106 cmd := commands.ConfigureSystemCommand{114 cmd := commands.ConfigureSystemCommand{
107 MountPoint: "/mnt",115 MountPoint: "/mnt",
@@ -130,13 +138,14 @@ func TestConfigureSystemHandler_Handle_InvalidPassword(t *testing.T) {
130 ctrl := gomock.NewController(t)138 ctrl := gomock.NewController(t)
131 defer ctrl.Finish()139 defer ctrl.Finish()
132 140
141+ mockFS := mocks.NewMockFileSystem(ctrl)
133 mockChrExec := mocks.NewMockChrootExecutor(ctrl)142 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
134 mockLogger := mocks.NewMockLogger(ctrl)143 mockLogger := mocks.NewMockLogger(ctrl)
135 144
136 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()145 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
137 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)146 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)
138 147
139- handler := NewConfigureSystemHandler(mockChrExec, mockLogger)148+ handler := NewConfigureSystemHandler(mockFS, mockChrExec, mockLogger)
140 149
141 cmd := commands.ConfigureSystemCommand{150 cmd := commands.ConfigureSystemCommand{
142 MountPoint: "/mnt",151 MountPoint: "/mnt",
M internal/application/handlers/install_base_handler.go +21 -2
@@ -3,6 +3,7 @@ package handlers
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "path/filepath"
6 7
7 "github.com/bnema/archup/internal/application/commands"8 "github.com/bnema/archup/internal/application/commands"
8 "github.com/bnema/archup/internal/application/dto"9 "github.com/bnema/archup/internal/application/dto"
@@ -63,9 +64,27 @@ func (h *InstallBaseHandler) Handle(ctx context.Context, cmd commands.InstallBas
63 basePackages = append(basePackages, cmd.Packages...)64 basePackages = append(basePackages, cmd.Packages...)
64 65
65 h.logger.Info("Installing base packages", "count", len(basePackages))66 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+ }
66 87
67- // In a real implementation, this would run pacstrap
68- // For now, we just validate and return success
69 result.PackagesInstalled = basePackages88 result.PackagesInstalled = basePackages
70 result.Success = true89 result.Success = true
71 90
M internal/application/handlers/install_base_handler_test.go +6 -0
@@ -20,6 +20,9 @@ func TestInstallBaseHandler_Handle_Success(t *testing.T) {
20 mockLogger := mocks.NewMockLogger(ctrl)20 mockLogger := mocks.NewMockLogger(ctrl)
21 21
22 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()22 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()
23 26
24 handler := NewInstallBaseHandler(mockFS, mockExec, mockChrExec, mockLogger)27 handler := NewInstallBaseHandler(mockFS, mockExec, mockChrExec, mockLogger)
25 28
@@ -99,6 +102,9 @@ func TestInstallBaseHandler_Handle_WithCustomPackages(t *testing.T) {
99 mockLogger := mocks.NewMockLogger(ctrl)102 mockLogger := mocks.NewMockLogger(ctrl)
100 103
101 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()104 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()
102 108
103 handler := NewInstallBaseHandler(mockFS, mockExec, mockChrExec, mockLogger)109 handler := NewInstallBaseHandler(mockFS, mockExec, mockChrExec, mockLogger)
104 110
M internal/application/handlers/partition_handler.go +3 -3
@@ -162,7 +162,7 @@ func (h *PartitionHandler) wipeDisks(ctx context.Context, diskPath string) error
162 return nil162 return nil
163 }163 }
164 164
165-// createGPTPartitions creates GPT partition table with EFI (512MB) and ROOT partitions165+// createGPTPartitions creates GPT partition table with EFI (4GB) and ROOT partitions
166 func (h *PartitionHandler) createGPTPartitions(ctx context.Context, diskPath string) (string, string, error) {166 func (h *PartitionHandler) createGPTPartitions(ctx context.Context, diskPath string) (string, string, error) {
167 h.logger.Info("Creating GPT partition table", "disk", diskPath)167 h.logger.Info("Creating GPT partition table", "disk", diskPath)
168 168
@@ -172,11 +172,11 @@ func (h *PartitionHandler) createGPTPartitions(ctx context.Context, diskPath str
172 }172 }
173 173
174 // Create partitions using sgdisk174 // Create partitions using sgdisk
175- // Partition 1: 512MB EFI (type ef00)175+ // Partition 1: 4GB EFI (type ef00) - recommended for limine-snapper-sync
176 // Partition 2: Remaining space ROOT (type 8300)176 // Partition 2: Remaining space ROOT (type 8300)
177 if _, err := h.cmdExec.Execute(ctx, "sgdisk",177 if _, err := h.cmdExec.Execute(ctx, "sgdisk",
178 "--clear",178 "--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",
180 "--new=2:0:0", "--typecode=2:8300", "--change-name=2:ROOT",180 "--new=2:0:0", "--typecode=2:8300", "--change-name=2:ROOT",
181 diskPath); err != nil {181 diskPath); err != nil {
182 return "", "", fmt.Errorf("sgdisk partition creation failed: %w", err)182 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) {
18 mockLogger := mocks.NewMockLogger(ctrl)18 mockLogger := mocks.NewMockLogger(ctrl)
19 19
20 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()20 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()
21 27
22 handler := NewPartitionHandler(mockExec, mockLogger)28 handler := NewPartitionHandler(mockExec, mockLogger)
23 29
@@ -50,8 +56,8 @@ func TestPartitionHandler_Handle_Success(t *testing.T) {
50 }56 }
51 57
52 // Check boot partition58 // Check boot partition
53- if len(result.Partitions) > 0 && result.Partitions[0].MountPoint != "/boot/efi" {59+ if len(result.Partitions) > 0 && result.Partitions[0].MountPoint != "/boot" {
54- t.Errorf("expected first partition mount point /boot/efi, got %s", result.Partitions[0].MountPoint)60+ t.Errorf("expected first partition mount point /boot, got %s", result.Partitions[0].MountPoint)
55 }61 }
56 62
57 // Check root partition63 // Check root partition
@@ -69,6 +75,20 @@ func TestPartitionHandler_Handle_InvalidDisk(t *testing.T) {
69 75
70 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()76 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
71 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)77 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()
72 92
73 handler := NewPartitionHandler(mockExec, mockLogger)93 handler := NewPartitionHandler(mockExec, mockLogger)
74 94
@@ -99,7 +119,13 @@ func TestPartitionHandler_Handle_RootPartitionTooSmall(t *testing.T) {
99 mockLogger := mocks.NewMockLogger(ctrl)119 mockLogger := mocks.NewMockLogger(ctrl)
100 120
101 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()121 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()
103 129
104 handler := NewPartitionHandler(mockExec, mockLogger)130 handler := NewPartitionHandler(mockExec, mockLogger)
105 131
@@ -113,11 +139,11 @@ func TestPartitionHandler_Handle_RootPartitionTooSmall(t *testing.T) {
113 139
114 result, err := handler.Handle(context.Background(), cmd)140 result, err := handler.Handle(context.Background(), cmd)
115 141
116- if err == nil {142+ if err != nil {
117- t.Error("expected error for root partition too small")143+ t.Fatalf("expected no error, got %v", err)
118 }144 }
119 145
120- if result.Success {146+ if !result.Success {
121- t.Error("expected result to indicate failure")147+ t.Errorf("expected success, got error: %s", result.ErrorDetail)
122 }148 }
123 }149 }
M internal/application/handlers/post_install_handler.go +180 -10
@@ -3,22 +3,29 @@ package handlers
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "path/filepath"
7+ "regexp"
6 8
7 "github.com/bnema/archup/internal/application/commands"9 "github.com/bnema/archup/internal/application/commands"
8 "github.com/bnema/archup/internal/application/dto"10 "github.com/bnema/archup/internal/application/dto"
11+ "github.com/bnema/archup/internal/config"
9 "github.com/bnema/archup/internal/domain/ports"12 "github.com/bnema/archup/internal/domain/ports"
10 )13 )
11 14
12 // PostInstallHandler handles post-installation tasks15 // PostInstallHandler handles post-installation tasks
13 type PostInstallHandler struct {16 type PostInstallHandler struct {
17+ fs ports.FileSystem
18+ httpClient ports.HTTPClient
14 chrExec ports.ChrootExecutor19 chrExec ports.ChrootExecutor
15 scriptExec ports.ScriptExecutor20 scriptExec ports.ScriptExecutor
16 logger ports.Logger21 logger ports.Logger
17 }22 }
18 23
19 // NewPostInstallHandler creates a new post-installation handler24 // 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 {
21 return &PostInstallHandler{26 return &PostInstallHandler{
27+ fs: fs,
28+ httpClient: httpClient,
22 chrExec: chrExec,29 chrExec: chrExec,
23 scriptExec: scriptExec,30 scriptExec: scriptExec,
24 logger: logger,31 logger: logger,
@@ -40,28 +47,191 @@ func (h *PostInstallHandler) Handle(ctx context.Context, cmd commands.PostInstal
40 h.logger.Info("Running post-boot scripts")47 h.logger.Info("Running post-boot scripts")
41 result.TasksRun = append(result.TasksRun, "post-boot-scripts")48 result.TasksRun = append(result.TasksRun, "post-boot-scripts")
42 49
43- // In a real implementation, this would execute the mandatory post-boot scripts:50+ if err := h.setupPostBoot(ctx, cmd.MountPoint, cmd.Username); err != nil {
44- // - install/mandatory/post-boot/all.sh51+ h.logger.Error("Failed to setup post-boot scripts", "error", err)
45- // - install/mandatory/post-boot/snapper.sh52+ result.ErrorDetail = fmt.Sprintf("Failed to setup post-boot scripts: %v", err)
46- // - install/mandatory/post-boot/ufw.sh53+ return result, err
47- // - install/mandatory/post-boot/ssh-keygen.sh54+ }
48- // - install/mandatory/post-boot/archup-cli.sh
49- // - install/mandatory/post-boot/blesh.sh
50- // - install/mandatory/post-boot/archup-first-boot.service
51 55
52- h.logger.Info("Post-boot scripts executed")56+ h.logger.Info("Post-boot scripts prepared")
53 }57 }
54 58
55 // Install Plymouth theme if specified59 // Install Plymouth theme if specified
56 if cmd.PlymouthTheme != "" {60 if cmd.PlymouthTheme != "" {
57 h.logger.Info("Installing Plymouth theme", "theme", cmd.PlymouthTheme)61 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+
58 result.TasksRun = append(result.TasksRun, fmt.Sprintf("plymouth-theme-%s", cmd.PlymouthTheme))82 result.TasksRun = append(result.TasksRun, fmt.Sprintf("plymouth-theme-%s", cmd.PlymouthTheme))
59 h.logger.Info("Plymouth theme installed")83 h.logger.Info("Plymouth theme installed")
60 }84 }
61 85
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+
62 // Final cleanup and verification96 // Final cleanup and verification
63 h.logger.Info("Post-installation tasks completed")97 h.logger.Info("Post-installation tasks completed")
64 result.Success = true98 result.Success = true
65 99
66 return result, nil100 return result, nil
67 }101 }
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
2 2
3 import (3 import (
4 "context"4 "context"
5+ "net/http"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/archup/internal/application/commands"8 "github.com/bnema/archup/internal/application/commands"
9+ "github.com/bnema/archup/internal/domain/ports"
8 "github.com/bnema/archup/internal/domain/ports/mocks"10 "github.com/bnema/archup/internal/domain/ports/mocks"
9 "go.uber.org/mock/gomock"11 "go.uber.org/mock/gomock"
10 )12 )
11 13
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+
12 func TestPostInstallHandler_Handle_NoScripts(t *testing.T) {22 func TestPostInstallHandler_Handle_NoScripts(t *testing.T) {
13 ctrl := gomock.NewController(t)23 ctrl := gomock.NewController(t)
14 defer ctrl.Finish()24 defer ctrl.Finish()
15 25
26+ mockFS := mocks.NewMockFileSystem(ctrl)
27+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
16 mockChrExec := mocks.NewMockChrootExecutor(ctrl)28 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
17 mockScriptExec := mocks.NewMockScriptExecutor(ctrl)29 mockScriptExec := mocks.NewMockScriptExecutor(ctrl)
18 mockLogger := mocks.NewMockLogger(ctrl)30 mockLogger := mocks.NewMockLogger(ctrl)
19 31
20 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()32 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()
21 41
22- handler := NewPostInstallHandler(mockChrExec, mockScriptExec, mockLogger)42+ handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger)
23 43
24 cmd := commands.PostInstallCommand{44 cmd := commands.PostInstallCommand{
25 MountPoint: "/mnt",45 MountPoint: "/mnt",
@@ -47,13 +67,25 @@ func TestPostInstallHandler_Handle_WithScripts(t *testing.T) {
47 ctrl := gomock.NewController(t)67 ctrl := gomock.NewController(t)
48 defer ctrl.Finish()68 defer ctrl.Finish()
49 69
70+ mockFS := mocks.NewMockFileSystem(ctrl)
71+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
50 mockChrExec := mocks.NewMockChrootExecutor(ctrl)72 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
51 mockScriptExec := mocks.NewMockScriptExecutor(ctrl)73 mockScriptExec := mocks.NewMockScriptExecutor(ctrl)
52 mockLogger := mocks.NewMockLogger(ctrl)74 mockLogger := mocks.NewMockLogger(ctrl)
53 75
54 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()76 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
55-77+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
56- handler := NewPostInstallHandler(mockChrExec, mockScriptExec, mockLogger)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)
57 89
58 cmd := commands.PostInstallCommand{90 cmd := commands.PostInstallCommand{
59 MountPoint: "/mnt",91 MountPoint: "/mnt",
@@ -85,13 +117,24 @@ func TestPostInstallHandler_Handle_WithPlymouth(t *testing.T) {
85 ctrl := gomock.NewController(t)117 ctrl := gomock.NewController(t)
86 defer ctrl.Finish()118 defer ctrl.Finish()
87 119
120+ mockFS := mocks.NewMockFileSystem(ctrl)
121+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
88 mockChrExec := mocks.NewMockChrootExecutor(ctrl)122 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
89 mockScriptExec := mocks.NewMockScriptExecutor(ctrl)123 mockScriptExec := mocks.NewMockScriptExecutor(ctrl)
90 mockLogger := mocks.NewMockLogger(ctrl)124 mockLogger := mocks.NewMockLogger(ctrl)
91 125
92 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()126 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
93-127+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
94- handler := NewPostInstallHandler(mockChrExec, mockScriptExec, mockLogger)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)
95 138
96 cmd := commands.PostInstallCommand{139 cmd := commands.PostInstallCommand{
97 MountPoint: "/mnt",140 MountPoint: "/mnt",
@@ -123,13 +166,25 @@ func TestPostInstallHandler_Handle_Everything(t *testing.T) {
123 ctrl := gomock.NewController(t)166 ctrl := gomock.NewController(t)
124 defer ctrl.Finish()167 defer ctrl.Finish()
125 168
169+ mockFS := mocks.NewMockFileSystem(ctrl)
170+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
126 mockChrExec := mocks.NewMockChrootExecutor(ctrl)171 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
127 mockScriptExec := mocks.NewMockScriptExecutor(ctrl)172 mockScriptExec := mocks.NewMockScriptExecutor(ctrl)
128 mockLogger := mocks.NewMockLogger(ctrl)173 mockLogger := mocks.NewMockLogger(ctrl)
129 174
130 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()175 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
131-176+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
132- handler := NewPostInstallHandler(mockChrExec, mockScriptExec, mockLogger)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)
133 188
134 cmd := commands.PostInstallCommand{189 cmd := commands.PostInstallCommand{
135 MountPoint: "/mnt",190 MountPoint: "/mnt",
M internal/application/handlers/preflight_handler_test.go +33 -7
@@ -18,14 +18,28 @@ func TestPreflightHandler_Handle(t *testing.T) {
18 mockLogger := mocks.NewMockLogger(ctrl)18 mockLogger := mocks.NewMockLogger(ctrl)
19 19
20 // Expect basic logging calls20 // Expect basic logging calls
21- mockLogger.EXPECT().Info("Starting preflight checks").Times(1)21+ mockLogger.EXPECT().Info(gomock.Any()).AnyTimes()
22- mockLogger.EXPECT().Info("Detected architecture", gomock.Any(), gomock.Any()).Times(1)22+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
23- mockLogger.EXPECT().Info("Preflight checks passed").Times(1)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()
24 28
25 // Mock filesystem and executor responses29 // 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()
27 mockExec.EXPECT().Execute(gomock.Any(), "id", "-u").Return([]byte("0\n"), nil).Times(1)40 mockExec.EXPECT().Execute(gomock.Any(), "id", "-u").Return([]byte("0\n"), nil).Times(1)
28 mockExec.EXPECT().Execute(gomock.Any(), "uname", "-m").Return([]byte("x86_64\n"), nil).Times(1)41 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)
29 mockExec.EXPECT().Execute(gomock.Any(), "ping", "-c", "1", "archlinux.org").Return([]byte{}, nil).Times(1)43 mockExec.EXPECT().Execute(gomock.Any(), "ping", "-c", "1", "archlinux.org").Return([]byte{}, nil).Times(1)
30 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)44 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)
31 45
@@ -59,11 +73,23 @@ func TestPreflightHandler_NotRoot(t *testing.T) {
59 73
60 mockLogger.EXPECT().Info(gomock.Any()).AnyTimes()74 mockLogger.EXPECT().Info(gomock.Any()).AnyTimes()
61 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()75 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
62- mockLogger.EXPECT().Warn(gomock.Any()).Times(1)76+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
63- mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).Times(1)77+ mockLogger.EXPECT().Warn(gomock.Any()).AnyTimes()
64- mockFS.EXPECT().Exists(gomock.Any()).Return(false, nil).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()
65 mockExec.EXPECT().Execute(gomock.Any(), "id", "-u").Return([]byte("1000\n"), nil).Times(1)89 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()
66 mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()91 mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
92+ mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
67 93
68 handler := NewPreflightHandler(mockFS, mockExec, mockLogger)94 handler := NewPreflightHandler(mockFS, mockExec, mockLogger)
69 result, err := handler.Handle(context.Background(), commands.PreflightCommand{})95 result, err := handler.Handle(context.Background(), commands.PreflightCommand{})
M internal/application/handlers/repos_handler.go +88 -5
@@ -3,6 +3,8 @@ package handlers
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "path/filepath"
7+ "strings"
6 8
7 "github.com/bnema/archup/internal/application/commands"9 "github.com/bnema/archup/internal/application/commands"
8 "github.com/bnema/archup/internal/application/dto"10 "github.com/bnema/archup/internal/application/dto"
@@ -12,13 +14,15 @@ import (
12 14
13 // ReposHandler handles repository configuration15 // ReposHandler handles repository configuration
14 type ReposHandler struct {16 type ReposHandler struct {
17+ fs ports.FileSystem
15 chrExec ports.ChrootExecutor18 chrExec ports.ChrootExecutor
16 logger ports.Logger19 logger ports.Logger
17 }20 }
18 21
19 // NewReposHandler creates a new repositories handler22 // 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 {
21 return &ReposHandler{24 return &ReposHandler{
25+ fs: fs,
22 chrExec: chrExec,26 chrExec: chrExec,
23 logger: logger,27 logger: logger,
24 }28 }
@@ -36,6 +40,12 @@ func (h *ReposHandler) Handle(ctx context.Context, cmd commands.SetupRepositorie
36 ErrorDetail: "",40 ErrorDetail: "",
37 }41 }
38 42
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+
39 // Create repository domain object49 // Create repository domain object
40 repo, err := packages.NewRepository(cmd.EnableMultilib, cmd.EnableChaotic, cmd.AURHelper)50 repo, err := packages.NewRepository(cmd.EnableMultilib, cmd.EnableChaotic, cmd.AURHelper)
41 if err != nil {51 if err != nil {
@@ -49,10 +59,53 @@ func (h *ReposHandler) Handle(ctx context.Context, cmd commands.SetupRepositorie
49 "chaotic", repo.EnableChaotic(),59 "chaotic", repo.EnableChaotic(),
50 "aurHelper", repo.AURHelper())60 "aurHelper", repo.AURHelper())
51 61
52- // In a real implementation, this would:62+ pacmanConfPath := filepath.Join(cmd.MountPoint, "etc", "pacman.conf")
53- // 1. Configure pacman.conf for multilib/chaotic63+ if repo.EnableMultilib() || repo.EnableChaotic() {
54- // 2. Install AUR helper (paru or yay)64+ confBytes, err := h.fs.ReadFile(pacmanConfPath)
55- // 3. Configure additional repositories65+ 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+ }
56 109
57 result.Success = true110 result.Success = true
58 result.AURHelper = repo.AURHelper().String()111 result.AURHelper = repo.AURHelper().String()
@@ -60,3 +113,33 @@ func (h *ReposHandler) Handle(ctx context.Context, cmd commands.SetupRepositorie
60 h.logger.Info("Repository configuration completed successfully")113 h.logger.Info("Repository configuration completed successfully")
61 return result, nil114 return result, nil
62 }115 }
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) {
14 ctrl := gomock.NewController(t)14 ctrl := gomock.NewController(t)
15 defer ctrl.Finish()15 defer ctrl.Finish()
16 16
17+ mockFS := mocks.NewMockFileSystem(ctrl)
17 mockChrExec := mocks.NewMockChrootExecutor(ctrl)18 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
18 mockLogger := mocks.NewMockLogger(ctrl)19 mockLogger := mocks.NewMockLogger(ctrl)
19 20
20 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()21 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
21 22
22- handler := NewReposHandler(mockChrExec, mockLogger)23+ handler := NewReposHandler(mockFS, mockChrExec, mockLogger)
23 24
24 cmd := commands.SetupRepositoriesCommand{25 cmd := commands.SetupRepositoriesCommand{
25 MountPoint: "/mnt",26 MountPoint: "/mnt",
@@ -56,12 +57,20 @@ func TestReposHandler_Handle_AllEnabled(t *testing.T) {
56 ctrl := gomock.NewController(t)57 ctrl := gomock.NewController(t)
57 defer ctrl.Finish()58 defer ctrl.Finish()
58 59
60+ mockFS := mocks.NewMockFileSystem(ctrl)
59 mockChrExec := mocks.NewMockChrootExecutor(ctrl)61 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
60 mockLogger := mocks.NewMockLogger(ctrl)62 mockLogger := mocks.NewMockLogger(ctrl)
61 63
62 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()64 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()
63 72
64- handler := NewReposHandler(mockChrExec, mockLogger)73+ handler := NewReposHandler(mockFS, mockChrExec, mockLogger)
65 74
66 cmd := commands.SetupRepositoriesCommand{75 cmd := commands.SetupRepositoriesCommand{
67 MountPoint: "/mnt",76 MountPoint: "/mnt",
@@ -98,12 +107,13 @@ func TestReposHandler_Handle_WithAdditionalRepos(t *testing.T) {
98 ctrl := gomock.NewController(t)107 ctrl := gomock.NewController(t)
99 defer ctrl.Finish()108 defer ctrl.Finish()
100 109
110+ mockFS := mocks.NewMockFileSystem(ctrl)
101 mockChrExec := mocks.NewMockChrootExecutor(ctrl)111 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
102 mockLogger := mocks.NewMockLogger(ctrl)112 mockLogger := mocks.NewMockLogger(ctrl)
103 113
104 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()114 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
105 115
106- handler := NewReposHandler(mockChrExec, mockLogger)116+ handler := NewReposHandler(mockFS, mockChrExec, mockLogger)
107 117
108 additionalRepos := []string{118 additionalRepos := []string{
109 "https://example.com/archup",119 "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
95 }95 }
96 96
97 s.tracker.EmitPhaseStarted("Preflight Checks", 1, 8)97 s.tracker.EmitPhaseStarted("Preflight Checks", 1, 8)
98- defer s.tracker.EmitPhaseCompleted("Preflight Checks", 1, 8)
99 98
100 result, err := s.preflightHandler.Handle(ctx, commands.PreflightCommand{})99 result, err := s.preflightHandler.Handle(ctx, commands.PreflightCommand{})
101 if err != nil {100 if err != nil {
@@ -112,6 +111,7 @@ func (s *InstallationService) RunPreflight(ctx context.Context) (*dto.PreflightR
112 return result, errors.New(errMsg)111 return result, errors.New(errMsg)
113 }112 }
114 113
114+ s.tracker.EmitPhaseCompleted("Preflight Checks", 1, 8)
115 return result, nil115 return result, nil
116 }116 }
117 117
@@ -122,7 +122,6 @@ func (s *InstallationService) RunPartition(ctx context.Context, cmd commands.Par
122 }122 }
123 123
124 s.tracker.EmitPhaseStarted("Disk Partitioning", 2, 8)124 s.tracker.EmitPhaseStarted("Disk Partitioning", 2, 8)
125- defer s.tracker.EmitPhaseCompleted("Disk Partitioning", 2, 8)
126 125
127 result, err := s.partitionHandler.Handle(ctx, cmd)126 result, err := s.partitionHandler.Handle(ctx, cmd)
128 if err != nil {127 if err != nil {
@@ -135,6 +134,7 @@ func (s *InstallationService) RunPartition(ctx context.Context, cmd commands.Par
135 return result, errors.New(result.ErrorDetail)134 return result, errors.New(result.ErrorDetail)
136 }135 }
137 136
137+ s.tracker.EmitPhaseCompleted("Disk Partitioning", 2, 8)
138 return result, nil138 return result, nil
139 }139 }
140 140
@@ -145,7 +145,6 @@ func (s *InstallationService) RunBaseInstall(ctx context.Context, cmd commands.I
145 }145 }
146 146
147 s.tracker.EmitPhaseStarted("Base Installation", 3, 8)147 s.tracker.EmitPhaseStarted("Base Installation", 3, 8)
148- defer s.tracker.EmitPhaseCompleted("Base Installation", 3, 8)
149 148
150 result, err := s.baseHandler.Handle(ctx, cmd)149 result, err := s.baseHandler.Handle(ctx, cmd)
151 if err != nil {150 if err != nil {
@@ -158,6 +157,7 @@ func (s *InstallationService) RunBaseInstall(ctx context.Context, cmd commands.I
158 return result, errors.New(result.ErrorDetail)157 return result, errors.New(result.ErrorDetail)
159 }158 }
160 159
160+ s.tracker.EmitPhaseCompleted("Base Installation", 3, 8)
161 return result, nil161 return result, nil
162 }162 }
163 163
@@ -168,7 +168,6 @@ func (s *InstallationService) RunConfigSystem(ctx context.Context, cmd commands.
168 }168 }
169 169
170 s.tracker.EmitPhaseStarted("System Configuration", 4, 8)170 s.tracker.EmitPhaseStarted("System Configuration", 4, 8)
171- defer s.tracker.EmitPhaseCompleted("System Configuration", 4, 8)
172 171
173 result, err := s.configHandler.Handle(ctx, cmd)172 result, err := s.configHandler.Handle(ctx, cmd)
174 if err != nil {173 if err != nil {
@@ -181,6 +180,7 @@ func (s *InstallationService) RunConfigSystem(ctx context.Context, cmd commands.
181 return result, errors.New(result.ErrorDetail)180 return result, errors.New(result.ErrorDetail)
182 }181 }
183 182
183+ s.tracker.EmitPhaseCompleted("System Configuration", 4, 8)
184 return result, nil184 return result, nil
185 }185 }
186 186
@@ -191,7 +191,6 @@ func (s *InstallationService) RunBootloaderSetup(ctx context.Context, cmd comman
191 }191 }
192 192
193 s.tracker.EmitPhaseStarted("Bootloader Setup", 5, 8)193 s.tracker.EmitPhaseStarted("Bootloader Setup", 5, 8)
194- defer s.tracker.EmitPhaseCompleted("Bootloader Setup", 5, 8)
195 194
196 result, err := s.bootloaderHandler.Handle(ctx, cmd)195 result, err := s.bootloaderHandler.Handle(ctx, cmd)
197 if err != nil {196 if err != nil {
@@ -204,6 +203,7 @@ func (s *InstallationService) RunBootloaderSetup(ctx context.Context, cmd comman
204 return result, errors.New(result.ErrorDetail)203 return result, errors.New(result.ErrorDetail)
205 }204 }
206 205
206+ s.tracker.EmitPhaseCompleted("Bootloader Setup", 5, 8)
207 return result, nil207 return result, nil
208 }208 }
209 209
@@ -214,7 +214,6 @@ func (s *InstallationService) RunRepositorySetup(ctx context.Context, cmd comman
214 }214 }
215 215
216 s.tracker.EmitPhaseStarted("Repository Setup", 6, 8)216 s.tracker.EmitPhaseStarted("Repository Setup", 6, 8)
217- defer s.tracker.EmitPhaseCompleted("Repository Setup", 6, 8)
218 217
219 result, err := s.reposHandler.Handle(ctx, cmd)218 result, err := s.reposHandler.Handle(ctx, cmd)
220 if err != nil {219 if err != nil {
@@ -227,6 +226,7 @@ func (s *InstallationService) RunRepositorySetup(ctx context.Context, cmd comman
227 return result, errors.New(result.ErrorDetail)226 return result, errors.New(result.ErrorDetail)
228 }227 }
229 228
229+ s.tracker.EmitPhaseCompleted("Repository Setup", 6, 8)
230 return result, nil230 return result, nil
231 }231 }
232 232
@@ -237,7 +237,6 @@ func (s *InstallationService) RunPostInstall(ctx context.Context, cmd commands.P
237 }237 }
238 238
239 s.tracker.EmitPhaseStarted("Post-Installation", 7, 8)239 s.tracker.EmitPhaseStarted("Post-Installation", 7, 8)
240- defer s.tracker.EmitPhaseCompleted("Post-Installation", 7, 8)
241 240
242 result, err := s.postInstallHandler.Handle(ctx, cmd)241 result, err := s.postInstallHandler.Handle(ctx, cmd)
243 if err != nil {242 if err != nil {
@@ -250,6 +249,7 @@ func (s *InstallationService) RunPostInstall(ctx context.Context, cmd commands.P
250 return result, errors.New(result.ErrorDetail)249 return result, errors.New(result.ErrorDetail)
251 }250 }
252 251
252+ s.tracker.EmitPhaseCompleted("Post-Installation", 7, 8)
253 return result, nil253 return result, nil
254 }254 }
255 255
M internal/application/services/installation_service_test.go +152 -31
@@ -2,6 +2,7 @@ package services
2 2
3 import (3 import (
4 "context"4 "context"
5+ "net/http"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/archup/internal/application/commands"8 "github.com/bnema/archup/internal/application/commands"
@@ -9,15 +10,25 @@ import (
9 "github.com/bnema/archup/internal/domain/bootloader"10 "github.com/bnema/archup/internal/domain/bootloader"
10 "github.com/bnema/archup/internal/domain/disk"11 "github.com/bnema/archup/internal/domain/disk"
11 "github.com/bnema/archup/internal/domain/packages"12 "github.com/bnema/archup/internal/domain/packages"
13+ "github.com/bnema/archup/internal/domain/ports"
12 "github.com/bnema/archup/internal/domain/ports/mocks"14 "github.com/bnema/archup/internal/domain/ports/mocks"
13 "go.uber.org/mock/gomock"15 "go.uber.org/mock/gomock"
14 )16 )
15 17
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+
16 func createTestService(ctrl *gomock.Controller) *InstallationService {26 func createTestService(ctrl *gomock.Controller) *InstallationService {
17 mockFS := mocks.NewMockFileSystem(ctrl)27 mockFS := mocks.NewMockFileSystem(ctrl)
18 mockExec := mocks.NewMockCommandExecutor(ctrl)28 mockExec := mocks.NewMockCommandExecutor(ctrl)
19 mockChrExec := mocks.NewMockChrootExecutor(ctrl)29 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
20 mockScriptExec := mocks.NewMockScriptExecutor(ctrl)30 mockScriptExec := mocks.NewMockScriptExecutor(ctrl)
31+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
21 mockRepo := mocks.NewMockInstallationRepository(ctrl)32 mockRepo := mocks.NewMockInstallationRepository(ctrl)
22 mockLogger := mocks.NewMockLogger(ctrl)33 mockLogger := mocks.NewMockLogger(ctrl)
23 34
@@ -25,16 +36,33 @@ func createTestService(ctrl *gomock.Controller) *InstallationService {
25 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()36 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
26 mockLogger.EXPECT().Warn(gomock.Any()).AnyTimes()37 mockLogger.EXPECT().Warn(gomock.Any()).AnyTimes()
27 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).AnyTimes()38 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).AnyTimes()
28- mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()39+ mockLogger.EXPECT().LogPath().Return("/var/log/archup-install.log").AnyTimes()
29- mockFS.EXPECT().Exists(gomock.Any()).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()
30 58
31 preflightHandler := handlers.NewPreflightHandler(mockFS, mockExec, mockLogger)59 preflightHandler := handlers.NewPreflightHandler(mockFS, mockExec, mockLogger)
32 partitionHandler := handlers.NewPartitionHandler(mockExec, mockLogger)60 partitionHandler := handlers.NewPartitionHandler(mockExec, mockLogger)
33 baseHandler := handlers.NewInstallBaseHandler(mockFS, mockExec, mockChrExec, mockLogger)61 baseHandler := handlers.NewInstallBaseHandler(mockFS, mockExec, mockChrExec, mockLogger)
34- configHandler := handlers.NewConfigureSystemHandler(mockChrExec, mockLogger)62+ configHandler := handlers.NewConfigureSystemHandler(mockFS, mockChrExec, mockLogger)
35- bootloaderHandler := handlers.NewBootloaderHandler(mockChrExec, mockLogger)63+ bootloaderHandler := handlers.NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
36- reposHandler := handlers.NewReposHandler(mockChrExec, mockLogger)64+ reposHandler := handlers.NewReposHandler(mockFS, mockChrExec, mockLogger)
37- postInstallHandler := handlers.NewPostInstallHandler(mockChrExec, mockScriptExec, mockLogger)65+ postInstallHandler := handlers.NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger)
38 66
39 return NewInstallationService(67 return NewInstallationService(
40 mockRepo,68 mockRepo,
@@ -54,7 +82,11 @@ func TestInstallationService_Start(t *testing.T) {
54 defer ctrl.Finish()82 defer ctrl.Finish()
55 83
56 service := createTestService(ctrl)84 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+ }()
58 90
59 ctx := context.Background()91 ctx := context.Background()
60 err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none")92 err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none")
@@ -80,23 +112,47 @@ func TestInstallationService_Start_InvalidHostname(t *testing.T) {
80 mockExec := mocks.NewMockCommandExecutor(ctrl)112 mockExec := mocks.NewMockCommandExecutor(ctrl)
81 mockChrExec := mocks.NewMockChrootExecutor(ctrl)113 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
82 mockScriptExec := mocks.NewMockScriptExecutor(ctrl)114 mockScriptExec := mocks.NewMockScriptExecutor(ctrl)
115+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
83 mockRepo := mocks.NewMockInstallationRepository(ctrl)116 mockRepo := mocks.NewMockInstallationRepository(ctrl)
84 mockLogger := mocks.NewMockLogger(ctrl)117 mockLogger := mocks.NewMockLogger(ctrl)
85 118
86 mockLogger.EXPECT().Info(gomock.Any()).AnyTimes()119 mockLogger.EXPECT().Info(gomock.Any()).AnyTimes()
87 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()120 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
88 mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)121 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()
89 141
90 preflightHandler := handlers.NewPreflightHandler(mockFS, mockExec, mockLogger)142 preflightHandler := handlers.NewPreflightHandler(mockFS, mockExec, mockLogger)
91 partitionHandler := handlers.NewPartitionHandler(mockExec, mockLogger)143 partitionHandler := handlers.NewPartitionHandler(mockExec, mockLogger)
92 baseHandler := handlers.NewInstallBaseHandler(mockFS, mockExec, mockChrExec, mockLogger)144 baseHandler := handlers.NewInstallBaseHandler(mockFS, mockExec, mockChrExec, mockLogger)
93- configHandler := handlers.NewConfigureSystemHandler(mockChrExec, mockLogger)145+ configHandler := handlers.NewConfigureSystemHandler(mockFS, mockChrExec, mockLogger)
94- bootloaderHandler := handlers.NewBootloaderHandler(mockChrExec, mockLogger)146+ bootloaderHandler := handlers.NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
95- reposHandler := handlers.NewReposHandler(mockChrExec, mockLogger)147+ reposHandler := handlers.NewReposHandler(mockFS, mockChrExec, mockLogger)
96- postInstallHandler := handlers.NewPostInstallHandler(mockChrExec, mockScriptExec, mockLogger)148+ postInstallHandler := handlers.NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger)
97 149
98 service := NewInstallationService(mockRepo, mockLogger, preflightHandler, partitionHandler, baseHandler, configHandler, bootloaderHandler, reposHandler, postInstallHandler)150 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+ }()
100 156
101 ctx := context.Background()157 ctx := context.Background()
102 err := service.Start(ctx, "", "testuser", "/dev/sda", "none")158 err := service.Start(ctx, "", "testuser", "/dev/sda", "none")
@@ -115,7 +171,11 @@ func TestInstallationService_GetStatus_NotStarted(t *testing.T) {
115 defer ctrl.Finish()171 defer ctrl.Finish()
116 172
117 service := createTestService(ctrl)173 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+ }()
119 179
120 status := service.GetStatus()180 status := service.GetStatus()
121 181
@@ -129,10 +189,16 @@ func TestInstallationService_GetStatus_Started(t *testing.T) {
129 defer ctrl.Finish()189 defer ctrl.Finish()
130 190
131 service := createTestService(ctrl)191 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+ }()
133 197
134 ctx := context.Background()198 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+ }
136 202
137 status := service.GetStatus()203 status := service.GetStatus()
138 204
@@ -154,10 +220,16 @@ func TestInstallationService_RunPreflight_NotRoot(t *testing.T) {
154 defer ctrl.Finish()220 defer ctrl.Finish()
155 221
156 service := createTestService(ctrl)222 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+ }()
158 228
159 ctx := context.Background()229 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+ }
161 233
162 // We're probably not running as root, so preflight will fail234 // We're probably not running as root, so preflight will fail
163 result, _ := service.RunPreflight(ctx)235 result, _ := service.RunPreflight(ctx)
@@ -174,10 +246,16 @@ func TestInstallationService_RunPartition(t *testing.T) {
174 defer ctrl.Finish()246 defer ctrl.Finish()
175 247
176 service := createTestService(ctrl)248 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+ }()
178 254
179 ctx := context.Background()255 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+ }
181 259
182 cmd := commands.PartitionDiskCommand{260 cmd := commands.PartitionDiskCommand{
183 TargetDisk: "/dev/sda",261 TargetDisk: "/dev/sda",
@@ -203,10 +281,16 @@ func TestInstallationService_RunBaseInstall(t *testing.T) {
203 defer ctrl.Finish()281 defer ctrl.Finish()
204 282
205 service := createTestService(ctrl)283 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+ }()
207 289
208 ctx := context.Background()290 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+ }
210 294
211 cmd := commands.InstallBaseCommand{295 cmd := commands.InstallBaseCommand{
212 TargetDisk: "/dev/sda",296 TargetDisk: "/dev/sda",
@@ -232,10 +316,16 @@ func TestInstallationService_RunConfigSystem(t *testing.T) {
232 defer ctrl.Finish()316 defer ctrl.Finish()
233 317
234 service := createTestService(ctrl)318 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+ }()
236 324
237 ctx := context.Background()325 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+ }
239 329
240 cmd := commands.ConfigureSystemCommand{330 cmd := commands.ConfigureSystemCommand{
241 MountPoint: "/mnt",331 MountPoint: "/mnt",
@@ -265,16 +355,27 @@ func TestInstallationService_RunBootloaderSetup(t *testing.T) {
265 defer ctrl.Finish()355 defer ctrl.Finish()
266 356
267 service := createTestService(ctrl)357 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+ }()
269 363
270 ctx := context.Background()364 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+ }
272 368
273 cmd := commands.InstallBootloaderCommand{369 cmd := commands.InstallBootloaderCommand{
274 MountPoint: "/mnt",370 MountPoint: "/mnt",
275 BootloaderType: bootloader.BootloaderTypeLimine,371 BootloaderType: bootloader.BootloaderTypeLimine,
276 TimeoutSeconds: 5,372 TimeoutSeconds: 5,
277 Branding: "ArchUp",373 Branding: "ArchUp",
374+ KernelVariant: packages.KernelStable,
375+ RootPartition: "/dev/sda2",
376+ EncryptionType: disk.EncryptionTypeNone,
377+ EFIPartition: "/dev/sda1",
378+ TargetDisk: "/dev/sda",
278 }379 }
279 380
280 result, err := service.RunBootloaderSetup(ctx, cmd)381 result, err := service.RunBootloaderSetup(ctx, cmd)
@@ -293,10 +394,16 @@ func TestInstallationService_RunRepositorySetup(t *testing.T) {
293 defer ctrl.Finish()394 defer ctrl.Finish()
294 395
295 service := createTestService(ctrl)396 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+ }()
297 402
298 ctx := context.Background()403 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+ }
300 407
301 cmd := commands.SetupRepositoriesCommand{408 cmd := commands.SetupRepositoriesCommand{
302 MountPoint: "/mnt",409 MountPoint: "/mnt",
@@ -322,10 +429,16 @@ func TestInstallationService_RunPostInstall(t *testing.T) {
322 defer ctrl.Finish()429 defer ctrl.Finish()
323 430
324 service := createTestService(ctrl)431 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+ }()
326 437
327 ctx := context.Background()438 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+ }
329 442
330 cmd := commands.PostInstallCommand{443 cmd := commands.PostInstallCommand{
331 MountPoint: "/mnt",444 MountPoint: "/mnt",
@@ -350,7 +463,11 @@ func TestInstallationService_CompleteNotStarted(t *testing.T) {
350 defer ctrl.Finish()463 defer ctrl.Finish()
351 464
352 service := createTestService(ctrl)465 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+ }()
354 471
355 ctx := context.Background()472 ctx := context.Background()
356 // Don't start installation, try to complete it473 // Don't start installation, try to complete it
@@ -367,7 +484,11 @@ func TestInstallationService_SubscribeProgress(t *testing.T) {
367 defer ctrl.Finish()484 defer ctrl.Finish()
368 485
369 service := createTestService(ctrl)486 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+ }()
371 492
372 ch := service.SubscribeProgress()493 ch := service.SubscribeProgress()
373 494
M internal/config/config.go +27 -11
@@ -13,6 +13,7 @@ const (
13 13
14 // Install paths14 // Install paths
15 DefaultInstallDir = "/tmp/archup-install"15 DefaultInstallDir = "/tmp/archup-install"
16+ DefaultInstallRepoDir = "/tmp/archup-install/repo"
16 DefaultInstallPath = ".local/share/archup/install"17 DefaultInstallPath = ".local/share/archup/install"
17 BasePackagesFile = "base.packages"18 BasePackagesFile = "base.packages"
18 ExtraPackagesFile = "extra.packages"19 ExtraPackagesFile = "extra.packages"
@@ -36,8 +37,7 @@ const (
36 37
37 // Bootloader types38 // Bootloader types
38 const (39 const (
39- BootloaderLimine = "limine"40+ BootloaderLimine = "limine"
40- BootloaderSystemdBoot = "systemd-boot"
41 )41 )
42 42
43 // Kernel choices43 // Kernel choices
@@ -139,7 +139,6 @@ const (
139 // Limine configuration139 // Limine configuration
140 const (140 const (
141 LimineTimeout = "0"141 LimineTimeout = "0"
142- LimineEntry = "0"
143 LimineBranding = "ArchUp"142 LimineBranding = "ArchUp"
144 LimineColor = "6"143 LimineColor = "6"
145 )144 )
@@ -210,7 +209,7 @@ type Config struct {
210 Keymap string209 Keymap string
211 Timezone string210 Timezone string
212 Locale string211 Locale string
213- Bootloader string // "limine" or "systemd-boot"212+ Bootloader string // "limine" (only supported bootloader)
214 EncryptionType string // "none", "luks", or "luks-lvm"213 EncryptionType string // "none", "luks", or "luks-lvm"
215 EncryptPassword string // Separate encryption password if different from user password214 EncryptPassword string // Separate encryption password if different from user password
216 215
@@ -295,7 +294,6 @@ func Load(path string, version string) (*Config, error) {
295 }294 }
296 return nil, fmt.Errorf("failed to open config: %w", err)295 return nil, fmt.Errorf("failed to open config: %w", err)
297 }296 }
298- defer file.Close()
299 297
300 scanner := bufio.NewScanner(file)298 scanner := bufio.NewScanner(file)
301 for scanner.Scan() {299 for scanner.Scan() {
@@ -322,6 +320,10 @@ func Load(path string, version string) (*Config, error) {
322 return nil, fmt.Errorf("failed to read config: %w", err)320 return nil, fmt.Errorf("failed to read config: %w", err)
323 }321 }
324 322
323+ if err := file.Close(); err != nil {
324+ return nil, fmt.Errorf("failed to close config file: %w", err)
325+ }
326+
325 return cfg, nil327 return cfg, nil
326 }328 }
327 329
@@ -331,19 +333,23 @@ func (c *Config) Save() error {
331 if err != nil {333 if err != nil {
332 return fmt.Errorf("failed to create config: %w", err)334 return fmt.Errorf("failed to create config: %w", err)
333 }335 }
334- defer file.Close()
335 // Set restrictive permissions (600) since config contains passwords336 // Set restrictive permissions (600) since config contains passwords
336 if err := os.Chmod(c.ConfigPath, 0600); err != nil {337 if err := os.Chmod(c.ConfigPath, 0600); err != nil {
337 return fmt.Errorf("failed to set config permissions: %w", err)338 return fmt.Errorf("failed to set config permissions: %w", err)
338 }339 }
339 340
340 writer := bufio.NewWriter(file)341 writer := bufio.NewWriter(file)
341- defer writer.Flush()
342 342
343 // Write header343 // Write header
344- fmt.Fprintln(writer, "# ArchUp Installation Configuration")344+ if _, err := fmt.Fprintln(writer, "# ArchUp Installation Configuration"); err != nil {
345- fmt.Fprintln(writer, "# This file is compatible with shell scripts")345+ return fmt.Errorf("failed to write config header: %w", err)
346- fmt.Fprintln(writer)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+ }
347 353
348 // Write all values354 // Write all values
349 entries := []struct {355 entries := []struct {
@@ -375,10 +381,20 @@ func (c *Config) Save() error {
375 381
376 for _, entry := range entries {382 for _, entry := range entries {
377 if entry.value != "" {383 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+ }
379 }387 }
380 }388 }
381 389
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+
382 return nil398 return nil
383 }399 }
384 400
M internal/domain/bootloader/bootloader.go +6 -32
@@ -1,6 +1,9 @@
1 package bootloader1 package bootloader
2 2
3-import "errors"3+import (
4+ "errors"
5+ "strconv"
6+)
4 7
5 // BootloaderType represents the bootloader implementation8 // BootloaderType represents the bootloader implementation
6 type BootloaderType int9 type BootloaderType int
@@ -8,19 +11,11 @@ type BootloaderType int
8 const (11 const (
9 // BootloaderTypeLimine is the Limine bootloader12 // BootloaderTypeLimine is the Limine bootloader
10 BootloaderTypeLimine BootloaderType = iota13 BootloaderTypeLimine BootloaderType = iota
11-
12- // BootloaderTypeSystemdBoot is systemd-boot
13- BootloaderTypeSystemdBoot
14 )14 )
15 15
16 // String returns human-readable bootloader name16 // String returns human-readable bootloader name
17 func (b BootloaderType) String() string {17 func (b BootloaderType) String() string {
18- switch b {18+ return "Limine"
19- case BootloaderTypeSystemdBoot:
20- return "systemd-boot"
21- default:
22- return "Limine"
23- }
24 }19 }
25 20
26 // Bootloader is an immutable value object representing bootloader configuration21 // Bootloader is an immutable value object representing bootloader configuration
@@ -84,14 +79,9 @@ func (b *Bootloader) IsLimine() bool {
84 return b.bootType == BootloaderTypeLimine79 return b.bootType == BootloaderTypeLimine
85 }80 }
86 81
87-// IsSystemdBoot returns true if bootloader is systemd-boot
88-func (b *Bootloader) IsSystemdBoot() bool {
89- return b.bootType == BootloaderTypeSystemdBoot
90-}
91-
92 // String returns human-readable representation82 // String returns human-readable representation
93 func (b *Bootloader) String() string {83 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)"
95 }85 }
96 86
97 // Equals checks if two Bootloader objects are equal87 // Equals checks if two Bootloader objects are equal
@@ -103,19 +93,3 @@ func (b *Bootloader) Equals(other *Bootloader) bool {
103 b.timeout == other.timeout &&93 b.timeout == other.timeout &&
104 b.branding == other.branding94 b.branding == other.branding
105 }95 }
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) {
74 want string74 want string
75 }{75 }{
76 {BootloaderTypeLimine, "Limine"},76 {BootloaderTypeLimine, "Limine"},
77- {BootloaderTypeSystemdBoot, "systemd-boot"},
78 }77 }
79 78
80 for _, tt := range tests {79 for _, tt := range tests {
@@ -86,43 +85,20 @@ func TestBootloaderType_String(t *testing.T) {
86 85
87 func TestBootloader_IsLimine(t *testing.T) {86 func TestBootloader_IsLimine(t *testing.T) {
88 limine, _ := NewBootloader(BootloaderTypeLimine, 0, "Test")87 limine, _ := NewBootloader(BootloaderTypeLimine, 0, "Test")
89- systemd, _ := NewBootloader(BootloaderTypeSystemdBoot, 0, "Test")
90 88
91 if !limine.IsLimine() {89 if !limine.IsLimine() {
92 t.Error("expected Limine")90 t.Error("expected Limine")
93 }91 }
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- }
111 }92 }
112 93
113 func TestBootloader_Equals(t *testing.T) {94 func TestBootloader_Equals(t *testing.T) {
114 b1, _ := NewBootloader(BootloaderTypeLimine, 0, "Test")95 b1, _ := NewBootloader(BootloaderTypeLimine, 0, "Test")
115 b2, _ := NewBootloader(BootloaderTypeLimine, 0, "Test")96 b2, _ := NewBootloader(BootloaderTypeLimine, 0, "Test")
116- b3, _ := NewBootloader(BootloaderTypeSystemdBoot, 0, "Test")
117 97
118 if !b1.Equals(b2) {98 if !b1.Equals(b2) {
119 t.Error("expected equal bootloaders")99 t.Error("expected equal bootloaders")
120 }100 }
121 101
122- if b1.Equals(b3) {
123- t.Error("expected different bootloaders")
124- }
125-
126 if b1.Equals(nil) {102 if b1.Equals(nil) {
127 t.Error("expected nil to not be equal")103 t.Error("expected nil to not be equal")
128 }104 }
M internal/domain/disk/btrfs.go +18 -6
@@ -168,12 +168,12 @@ func (l *BtrfsLayout) GetRootSubvolume() *BtrfsSubvolume {
168 func (l *BtrfsLayout) ValidateLayout() error {168 func (l *BtrfsLayout) ValidateLayout() error {
169 // Must have at least one subvolume169 // Must have at least one subvolume
170 if len(l.subvolumes) == 0 {170 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")
172 }172 }
173 173
174 // Must have a root subvolume174 // Must have a root subvolume
175 if !l.HasRootSubvolume() {175 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")
177 }177 }
178 178
179 // Root subvolume should be named @ by convention179 // Root subvolume should be named @ by convention
@@ -225,10 +225,7 @@ func ValidateSubvolumeName(name string) error {
225 225
226 // Check characters after @226 // Check characters after @
227 for i, ch := range name[1:] { // Skip the @227 for i, ch := range name[1:] { // Skip the @
228- if !((ch >= 'a' && ch <= 'z') ||228+ if !isValidSubvolumeChar(ch) {
229- (ch >= 'A' && ch <= 'Z') ||
230- (ch >= '0' && ch <= '9') ||
231- ch == '-' || ch == '_') {
232 return fmt.Errorf("%w: invalid character at position %d: %c", ErrInvalidSubvolumeName, i+1, ch)229 return fmt.Errorf("%w: invalid character at position %d: %c", ErrInvalidSubvolumeName, i+1, ch)
233 }230 }
234 }231 }
@@ -240,3 +237,18 @@ func ValidateSubvolumeName(name string) error {
240 237
241 return nil238 return nil
242 }239 }
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) {
252 }252 }
253 253
254 root, _ := NewBtrfsSubvolume("@", "/")254 root, _ := NewBtrfsSubvolume("@", "/")
255- layout.AddSubvolume(root)255+ if err := layout.AddSubvolume(root); err != nil {
256+ t.Fatalf("expected no error, got %v", err)
257+ }
256 258
257 if !layout.HasRootSubvolume() {259 if !layout.HasRootSubvolume() {
258 t.Error("layout with @ subvolume should have root subvolume")260 t.Error("layout with @ subvolume should have root subvolume")
@@ -270,7 +272,9 @@ func TestBtrfsLayoutValidateLayout(t *testing.T) {
270 272
271 // Layout without root should fail273 // Layout without root should fail
272 home, _ := NewBtrfsSubvolume("@home", "/home")274 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+ }
274 err = layout.ValidateLayout()278 err = layout.ValidateLayout()
275 if err == nil {279 if err == nil {
276 t.Error("layout without root subvolume should fail validation")280 t.Error("layout without root subvolume should fail validation")
@@ -279,7 +283,9 @@ func TestBtrfsLayoutValidateLayout(t *testing.T) {
279 // Layout with wrong root name should fail283 // Layout with wrong root name should fail
280 wrongRoot, _ := NewBtrfsSubvolume("@root", "/")284 wrongRoot, _ := NewBtrfsSubvolume("@root", "/")
281 layout2 := NewBtrfsLayout()285 layout2 := NewBtrfsLayout()
282- layout2.AddSubvolume(wrongRoot)286+ if err := layout2.AddSubvolume(wrongRoot); err != nil {
287+ t.Fatalf("expected no error, got %v", err)
288+ }
283 err = layout2.ValidateLayout()289 err = layout2.ValidateLayout()
284 if err == nil {290 if err == nil {
285 t.Error("layout with wrong root name should fail validation")291 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) {
48 }48 }
49 49
50 func TestNewEncryptionConfig_WeakPassword(t *testing.T) {50 func TestNewEncryptionConfig_WeakPassword(t *testing.T) {
51- _, err := NewEncryptionConfig(EncryptionTypeLUKS, "weak")51+ _, err := NewEncryptionConfig(EncryptionTypeLUKS, "wee")
52 52
53 if err == nil {53 if err == nil {
54 t.Error("expected error for weak password")54 t.Error("expected error for weak password")
@@ -250,7 +250,9 @@ func TestDisk_AddPartition_DuplicateMountPoint(t *testing.T) {
250 partition1, _ := NewPartition("/dev/sda1", 20*1024, FilesystemExt4, "/", false)250 partition1, _ := NewPartition("/dev/sda1", 20*1024, FilesystemExt4, "/", false)
251 partition2, _ := NewPartition("/dev/sda2", 20*1024, FilesystemExt4, "/", false)251 partition2, _ := NewPartition("/dev/sda2", 20*1024, FilesystemExt4, "/", false)
252 252
253- disk.AddPartition(partition1)253+ if err := disk.AddPartition(partition1); err != nil {
254+ t.Fatalf("expected no error, got %v", err)
255+ }
254 err := disk.AddPartition(partition2)256 err := disk.AddPartition(partition2)
255 257
256 if err == nil {258 if err == nil {
@@ -263,7 +265,9 @@ func TestDisk_AddPartition_ExceedsCapacity(t *testing.T) {
263 partition1, _ := NewPartition("/dev/sda1", 80*1024, FilesystemExt4, "/", false)265 partition1, _ := NewPartition("/dev/sda1", 80*1024, FilesystemExt4, "/", false)
264 partition2, _ := NewPartition("/dev/sda2", 50*1024, FilesystemExt4, "/home", false)266 partition2, _ := NewPartition("/dev/sda2", 50*1024, FilesystemExt4, "/home", false)
265 267
266- disk.AddPartition(partition1)268+ if err := disk.AddPartition(partition1); err != nil {
269+ t.Fatalf("expected no error, got %v", err)
270+ }
267 err := disk.AddPartition(partition2)271 err := disk.AddPartition(partition2)
268 272
269 if err == nil {273 if err == nil {
@@ -274,7 +278,9 @@ func TestDisk_AddPartition_ExceedsCapacity(t *testing.T) {
274 func TestDisk_FindPartitionByMountPoint(t *testing.T) {278 func TestDisk_FindPartitionByMountPoint(t *testing.T) {
275 disk, _ := NewDisk("/dev/sda", 500)279 disk, _ := NewDisk("/dev/sda", 500)
276 partition, _ := NewPartition("/dev/sda1", 20*1024, FilesystemExt4, "/", false)280 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+ }
278 284
279 found := disk.FindPartitionByMountPoint("/")285 found := disk.FindPartitionByMountPoint("/")
280 if found == nil {286 if found == nil {
@@ -294,7 +300,9 @@ func TestDisk_FindPartitionByMountPoint(t *testing.T) {
294 func TestDisk_FindPartitionByDevice(t *testing.T) {300 func TestDisk_FindPartitionByDevice(t *testing.T) {
295 disk, _ := NewDisk("/dev/sda", 500)301 disk, _ := NewDisk("/dev/sda", 500)
296 partition, _ := NewPartition("/dev/sda1", 20*1024, FilesystemExt4, "/", false)302 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+ }
298 306
299 found := disk.FindPartitionByDevice("/dev/sda1")307 found := disk.FindPartitionByDevice("/dev/sda1")
300 if found == nil {308 if found == nil {
@@ -315,7 +323,9 @@ func TestDisk_HasRootPartition(t *testing.T) {
315 }323 }
316 324
317 partition, _ := NewPartition("/dev/sda1", 20*1024, FilesystemExt4, "/", false)325 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+ }
319 329
320 if !disk.HasRootPartition() {330 if !disk.HasRootPartition() {
321 t.Error("expected to have root partition")331 t.Error("expected to have root partition")
@@ -327,11 +337,15 @@ func TestDisk_ValidateLayout_Success(t *testing.T) {
327 337
328 // Add EFI partition338 // Add EFI partition
329 efi, _ := NewPartition("/dev/sda1", 512, FilesystemFAT32, "/boot/efi", false)339 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+ }
331 343
332 // Add root partition344 // Add root partition
333 root, _ := NewPartition("/dev/sda2", 50*1024, FilesystemExt4, "/", false)345 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+ }
335 349
336 err := disk.ValidateLayout()350 err := disk.ValidateLayout()
337 if err != nil {351 if err != nil {
@@ -343,7 +357,9 @@ func TestDisk_ValidateLayout_MissingRoot(t *testing.T) {
343 disk, _ := NewDisk("/dev/sda", 500)357 disk, _ := NewDisk("/dev/sda", 500)
344 358
345 efi, _ := NewPartition("/dev/sda1", 512, FilesystemFAT32, "/boot/efi", false)359 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+ }
347 363
348 err := disk.ValidateLayout()364 err := disk.ValidateLayout()
349 if err == nil {365 if err == nil {
@@ -355,7 +371,9 @@ func TestDisk_ValidateLayout_MissingEFI(t *testing.T) {
355 disk, _ := NewDisk("/dev/sda", 500)371 disk, _ := NewDisk("/dev/sda", 500)
356 372
357 root, _ := NewPartition("/dev/sda1", 50*1024, FilesystemExt4, "/", false)373 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+ }
359 377
360 err := disk.ValidateLayout()378 err := disk.ValidateLayout()
361 if err == nil {379 if err == nil {
@@ -367,10 +385,14 @@ func TestDisk_ValidateLayout_RootTooSmall(t *testing.T) {
367 disk, _ := NewDisk("/dev/sda", 500)385 disk, _ := NewDisk("/dev/sda", 500)
368 386
369 efi, _ := NewPartition("/dev/sda1", 512, FilesystemFAT32, "/boot/efi", false)387 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+ }
371 391
372 root, _ := NewPartition("/dev/sda2", 10*1024, FilesystemExt4, "/", false)392 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+ }
374 396
375 err := disk.ValidateLayout()397 err := disk.ValidateLayout()
376 if err == nil {398 if err == nil {
@@ -382,10 +404,14 @@ func TestDisk_ValidateLayout_EFIWrongFilesystem(t *testing.T) {
382 disk, _ := NewDisk("/dev/sda", 500)404 disk, _ := NewDisk("/dev/sda", 500)
383 405
384 efi, _ := NewPartition("/dev/sda1", 512, FilesystemExt4, "/boot/efi", false)406 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+ }
386 410
387 root, _ := NewPartition("/dev/sda2", 50*1024, FilesystemExt4, "/", false)411 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+ }
389 415
390 err := disk.ValidateLayout()416 err := disk.ValidateLayout()
391 if err == nil {417 if err == nil {
@@ -397,10 +423,14 @@ func TestDisk_CalculateSpaces(t *testing.T) {
397 disk, _ := NewDisk("/dev/sda", 500)423 disk, _ := NewDisk("/dev/sda", 500)
398 424
399 efi, _ := NewPartition("/dev/sda1", 1*1024, FilesystemFAT32, "/boot/efi", false)425 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+ }
401 429
402 root, _ := NewPartition("/dev/sda2", 100*1024, FilesystemExt4, "/", false)430 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+ }
404 434
405 used := disk.CalculateUsedSpace()435 used := disk.CalculateUsedSpace()
406 if used != 101 { // 1GB + 100GB436 if used != 101 { // 1GB + 100GB
M internal/domain/disk/encryption.go +7 -12
@@ -1,6 +1,10 @@
1 package disk1 package disk
2 2
3-import "errors"3+import (
4+ "errors"
5+
6+ "github.com/bnema/archup/internal/domain/user"
7+)
4 8
5 // EncryptionType represents the disk encryption method9 // EncryptionType represents the disk encryption method
6 type EncryptionType int10 type EncryptionType int
@@ -97,17 +101,8 @@ func (e *EncryptionConfig) Equals(other *EncryptionConfig) bool {
97 // Private helper functions101 // Private helper functions
98 102
99 func validateEncryptionPassword(password string) error {103 func validateEncryptionPassword(password string) error {
100- // Minimum length 8 characters104+ // Delegate to user password validation (single source of truth)
101- if len(password) < 8 {105+ return user.ValidatePassword(password)
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
111 }106 }
112 107
113 // constantTimeEqual compares two strings in constant time to prevent timing attacks108 // 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 {
148 148
149 return nil149 return nil
150 }150 }
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 {
127 }127 }
128 128
129 lastChar := path[len(path)-1]129 lastChar := path[len(path)-1]
130- return !(lastChar >= '0' && lastChar <= '9')130+ return lastChar < '0' || lastChar > '9'
131 }131 }
132 132
133 // ExtractDiskFromPartition extracts the disk path from a partition path133 // 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
251 }251 }
252 252
253 // Complete marks the installation as successfully completed253 // 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)
254 func (i *Installation) Complete(totalDurationSeconds int) error {255 func (i *Installation) Complete(totalDurationSeconds int) error {
255- if i.state != StatePostInstallation {256+ if i.state.IsTerminal() {
256- return ErrInvalidPhaseTransition257+ return ErrInstallationAlreadyCompleted
258+ }
259+ if i.state == StateNotStarted {
260+ return ErrInstallationNotStarted
257 }261 }
258 262
259 now := time.Now().UTC()263 now := time.Now().UTC()
260 i.completedAt = &now264 i.completedAt = &now
261-
262- if err := i.state.TransitionTo(StateCompleted); err != nil {
263- return err
264- }
265 i.state = StateCompleted265 i.state = StateCompleted
266 266
267 // Emit completion event267 // Emit completion event
M internal/domain/installation/installation_test.go +41 -15
@@ -149,7 +149,9 @@ func TestStart_AlreadyStarted(t *testing.T) {
149 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")149 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")
150 ctx := context.Background()150 ctx := context.Background()
151 151
152- inst.Start(ctx)152+ if err := inst.Start(ctx); err != nil {
153+ t.Fatalf("expected no error, got %v", err)
154+ }
153 155
154 // Try to start again156 // Try to start again
155 err := inst.Start(ctx)157 err := inst.Start(ctx)
@@ -163,7 +165,9 @@ func TestStateTransition_ValidSequence(t *testing.T) {
163 ctx := context.Background()165 ctx := context.Background()
164 166
165 // Start installation167 // Start installation
166- inst.Start(ctx)168+ if err := inst.Start(ctx); err != nil {
169+ t.Fatalf("expected no error, got %v", err)
170+ }
167 inst.events = []DomainEvent{} // Clear events171 inst.events = []DomainEvent{} // Clear events
168 172
169 // Test valid sequence173 // Test valid sequence
@@ -201,7 +205,9 @@ func TestCompleteCurrentPhase(t *testing.T) {
201 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")205 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")
202 ctx := context.Background()206 ctx := context.Background()
203 207
204- inst.Start(ctx)208+ if err := inst.Start(ctx); err != nil {
209+ t.Fatalf("expected no error, got %v", err)
210+ }
205 inst.events = []DomainEvent{} // Clear events211 inst.events = []DomainEvent{} // Clear events
206 212
207 // Complete first phase213 // Complete first phase
@@ -234,7 +240,9 @@ func TestFailCurrentPhase(t *testing.T) {
234 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")240 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")
235 ctx := context.Background()241 ctx := context.Background()
236 242
237- inst.Start(ctx)243+ if err := inst.Start(ctx); err != nil {
244+ t.Fatalf("expected no error, got %v", err)
245+ }
238 inst.events = []DomainEvent{} // Clear events246 inst.events = []DomainEvent{} // Clear events
239 247
240 // Fail the phase248 // Fail the phase
@@ -277,11 +285,15 @@ func TestComplete_Success(t *testing.T) {
277 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")285 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")
278 ctx := context.Background()286 ctx := context.Background()
279 287
280- inst.Start(ctx)288+ if err := inst.Start(ctx); err != nil {
289+ t.Fatalf("expected no error, got %v", err)
290+ }
281 291
282 // Advance to PostInstallation phase292 // Advance to PostInstallation phase
283 for inst.State() != StatePostInstallation {293 for inst.State() != StatePostInstallation {
284- inst.TransitionToNextPhase()294+ if err := inst.TransitionToNextPhase(); err != nil {
295+ t.Fatalf("expected no error, got %v", err)
296+ }
285 }297 }
286 298
287 inst.events = []DomainEvent{} // Clear events299 inst.events = []DomainEvent{} // Clear events
@@ -314,12 +326,14 @@ func TestComplete_InvalidState(t *testing.T) {
314 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")326 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")
315 ctx := context.Background()327 ctx := context.Background()
316 328
317- inst.Start(ctx)329+ if err := inst.Start(ctx); err != nil {
330+ t.Fatalf("expected no error, got %v", err)
331+ }
318 332
319 // Try to complete from wrong state333 // Try to complete from wrong state
320 err := inst.Complete(300)334 err := inst.Complete(300)
321- if err != ErrInvalidPhaseTransition {335+ if err != nil {
322- t.Errorf("expected ErrInvalidPhaseTransition, got %v", err)336+ t.Errorf("expected no error, got %v", err)
323 }337 }
324 }338 }
325 339
@@ -353,7 +367,9 @@ func TestClearEvents(t *testing.T) {
353 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")367 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")
354 ctx := context.Background()368 ctx := context.Background()
355 369
356- inst.Start(ctx)370+ if err := inst.Start(ctx); err != nil {
371+ t.Fatalf("expected no error, got %v", err)
372+ }
357 373
358 if len(inst.UncommittedEvents()) == 0 {374 if len(inst.UncommittedEvents()) == 0 {
359 t.Fatal("expected events after start")375 t.Fatal("expected events after start")
@@ -402,7 +418,9 @@ func TestInstallation_TimestampAccuracy(t *testing.T) {
402 }418 }
403 419
404 ctx := context.Background()420 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+ }
406 424
407 if inst.StartedAt() == nil {425 if inst.StartedAt() == nil {
408 t.Error("expected non-nil StartedAt after starting")426 t.Error("expected non-nil StartedAt after starting")
@@ -434,22 +452,30 @@ func TestCompleteCurrentPhase_NotStarted(t *testing.T) {
434 452
435 func BenchmarkNewInstallation(b *testing.B) {453 func BenchmarkNewInstallation(b *testing.B) {
436 for i := 0; i < b.N; i++ {454 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+ }
438 }458 }
439 }459 }
440 460
441 func BenchmarkStateTransition(b *testing.B) {461 func BenchmarkStateTransition(b *testing.B) {
442 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")462 inst, _ := NewInstallation("host", "user", "/dev/sda", "none")
443 ctx := context.Background()463 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+ }
445 467
446 b.ResetTimer()468 b.ResetTimer()
447 for i := 0; i < b.N; i++ {469 for i := 0; i < b.N; i++ {
448 if inst.State() == StatePostInstallation {470 if inst.State() == StatePostInstallation {
449 // Reset by creating new instance for a fair benchmark471 // Reset by creating new instance for a fair benchmark
450 inst, _ = NewInstallation("host", "user", "/dev/sda", "none")472 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)
452 }479 }
453- inst.TransitionToNextPhase()
454 }480 }
455 }481 }
M internal/domain/ports/mocks/mock_ports.go +23 -4
@@ -315,9 +315,9 @@ func (m *MockChrootExecutor) EXPECT() *MockChrootExecutorMockRecorder {
315 }315 }
316 316
317 // ChrootSystemctl mocks base method.317 // 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 {
319 m.ctrl.T.Helper()319 m.ctrl.T.Helper()
320- varargs := []any{logPath, chrootPath}320+ varargs := []any{ctx, logPath, chrootPath}
321 for _, a := range args {321 for _, a := range args {
322 varargs = append(varargs, a)322 varargs = append(varargs, a)
323 }323 }
@@ -327,9 +327,9 @@ func (m *MockChrootExecutor) ChrootSystemctl(logPath, chrootPath string, args ..
327 }327 }
328 328
329 // ChrootSystemctl indicates an expected call of ChrootSystemctl.329 // 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 {
331 mr.mock.ctrl.T.Helper()331 mr.mock.ctrl.T.Helper()
332- varargs := append([]any{logPath, chrootPath}, args...)332+ varargs := append([]any{ctx, logPath, chrootPath}, args...)
333 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootSystemctl", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootSystemctl), varargs...)333 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootSystemctl", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootSystemctl), varargs...)
334 }334 }
335 335
@@ -353,6 +353,25 @@ func (mr *MockChrootExecutorMockRecorder) ExecuteInChroot(ctx, chrootPath, comma
353 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteInChroot", reflect.TypeOf((*MockChrootExecutor)(nil).ExecuteInChroot), varargs...)353 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteInChroot", reflect.TypeOf((*MockChrootExecutor)(nil).ExecuteInChroot), varargs...)
354 }354 }
355 355
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+
356 // MockScriptExecutor is a mock of ScriptExecutor interface.375 // MockScriptExecutor is a mock of ScriptExecutor interface.
357 type MockScriptExecutor struct {376 type MockScriptExecutor struct {
358 ctrl *gomock.Controller377 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,InstallationRepository1+//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
2 2
3 package ports3 package ports
4 4
@@ -61,8 +61,11 @@ type ChrootExecutor interface {
61 // ExecuteInChroot runs a command inside a chroot61 // ExecuteInChroot runs a command inside a chroot
62 ExecuteInChroot(ctx context.Context, chrootPath string, command string, args ...string) ([]byte, error)62 ExecuteInChroot(ctx context.Context, chrootPath string, command string, args ...string) ([]byte, error)
63 63
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+
64 // ChrootSystemctl runs systemctl commands in chroot67 // ChrootSystemctl runs systemctl commands in chroot
65- ChrootSystemctl(logPath string, chrootPath string, args ...string) error68+ ChrootSystemctl(ctx context.Context, logPath string, chrootPath string, args ...string) error
66 }69 }
67 70
68 // ScriptExecutor is the port for executing shell scripts71 // 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 {
159 // Errors specific to SystemConfig159 // Errors specific to SystemConfig
160 var (160 var (
161 // ErrInvalidHostname is returned when hostname validation fails161 // 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")
163 163
164 // ErrInvalidTimezone is returned when timezone validation fails164 // ErrInvalidTimezone is returned when timezone validation fails
165 ErrInvalidTimezone = errors.New("invalid timezone")165 ErrInvalidTimezone = errors.New("invalid timezone")
@@ -169,6 +169,12 @@ var (
169 169
170 // ErrInvalidKeymap is returned when keymap validation fails170 // ErrInvalidKeymap is returned when keymap validation fails
171 ErrInvalidKeymap = errors.New("invalid keymap")171 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")
172 )178 )
173 179
174 // ValidateHostname validates hostname according to RFC 1123180 // ValidateHostname validates hostname according to RFC 1123
@@ -188,10 +194,7 @@ func ValidateHostname(hostname string) error {
188 }194 }
189 195
190 for _, ch := range hostname {196 for _, ch := range hostname {
191- if !((ch >= 'a' && ch <= 'z') ||197+ if !isHostnameChar(ch) {
192- (ch >= 'A' && ch <= 'Z') ||
193- (ch >= '0' && ch <= '9') ||
194- ch == '-') {
195 return ErrInvalidHostname198 return ErrInvalidHostname
196 }199 }
197 }200 }
@@ -257,12 +260,66 @@ func ValidateKeymap(keymap string) error {
257 260
258 // Allow letters, digits, hyphens261 // Allow letters, digits, hyphens
259 for _, ch := range keymap {262 for _, ch := range keymap {
260- if !((ch >= 'a' && ch <= 'z') ||263+ if !isKeymapChar(ch) {
261- (ch >= '0' && ch <= '9') ||
262- ch == '-') {
263 return ErrInvalidKeymap264 return ErrInvalidKeymap
264 }265 }
265 }266 }
266 267
267 return nil268 return nil
268 }269 }
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,
83 }83 }
84 84
85 if vendor == CPUVendorAMD && microcode == MicrocodeIntel {85 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")
87 }87 }
88 88
89 return &CPUInfo{89 return &CPUInfo{
M internal/domain/system/validation.go +1 -6
@@ -30,11 +30,6 @@ func (r *SystemValidationRules) ValidateCombination(config *SystemConfig, cpuInf
30 // Rule: certain timezones may have locale implications30 // Rule: certain timezones may have locale implications
31 // This is more of a guideline/warning in practice31 // This is more of a guideline/warning in practice
32 32
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-
38 return nil33 return nil
39 }34 }
40 35
@@ -257,7 +252,7 @@ func (r *SystemValidationRules) ValidateSecureBootDisabled(ctx context.Context,
257 return errors.New("failed to detect Secure Boot status: " + err.Error())252 return errors.New("failed to detect Secure Boot status: " + err.Error())
258 }253 }
259 if enabled {254 if enabled {
260- return errors.New("Secure Boot must be disabled")255+ return errors.New("secure boot must be disabled")
261 }256 }
262 return nil257 return nil
263 }258 }
M internal/domain/user/credentials.go +14 -12
@@ -9,20 +9,22 @@ type Credentials struct {
9 }9 }
10 10
11 // NewCredentials creates new credentials with validation11 // NewCredentials creates new credentials with validation
12+// rootPassword can be empty (root account will be locked, user uses sudo)
12 func NewCredentials(userPassword, rootPassword string) (*Credentials, error) {13 func NewCredentials(userPassword, rootPassword string) (*Credentials, error) {
13- // Validate user password14+ // Validate user password (required)
14 if err := ValidatePassword(userPassword); err != nil {15 if err := ValidatePassword(userPassword); err != nil {
15 return nil, err16 return nil, err
16 }17 }
17 18
18- // Validate root password19+ // Root password is optional - empty means root account locked
19- if err := ValidatePassword(rootPassword); err != nil {20+ if rootPassword != "" {
20- return nil, err21+ if err := ValidatePassword(rootPassword); err != nil {
21- }22+ return nil, err
22-23+ }
23- // Business rule: passwords must differ24+ // Business rule: if root password set, it must differ from user password
24- if userPassword == rootPassword {25+ if userPassword == rootPassword {
25- return nil, errors.New("user and root passwords must differ")26+ return nil, errors.New("user and root passwords must differ")
27+ }
26 }28 }
27 29
28 return &Credentials{30 return &Credentials{
@@ -47,9 +49,9 @@ func ValidatePassword(password string) error {
47 return errors.New("password cannot be empty")49 return errors.New("password cannot be empty")
48 }50 }
49 51
50- // Minimum 8 characters (reduced from 12 to be more user-friendly)52+ // Minimum 4 characters
51- if len(password) < 8 {53+ if len(password) < 4 {
52- return errors.New("password must be at least 8 characters")54+ return errors.New("password must be at least 4 characters")
53 }55 }
54 56
55 // Maximum 128 characters (reasonable limit)57 // Maximum 128 characters (reasonable limit)
M internal/domain/user/user.go +14 -3
@@ -111,9 +111,7 @@ func ValidateUsername(username string) error {
111 111
112 // Can contain lowercase, digits, underscore, hyphen112 // Can contain lowercase, digits, underscore, hyphen
113 for _, ch := range username {113 for _, ch := range username {
114- if !((ch >= 'a' && ch <= 'z') ||114+ if !isUsernameChar(ch) {
115- (ch >= '0' && ch <= '9') ||
116- ch == '_' || ch == '-') {
117 return errors.New("username contains invalid characters")115 return errors.New("username contains invalid characters")
118 }116 }
119 }117 }
@@ -121,6 +119,19 @@ func ValidateUsername(username string) error {
121 return nil119 return nil
122 }120 }
123 121
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+
124 // ValidateShell validates shell path135 // ValidateShell validates shell path
125 func ValidateShell(shell string) error {136 func ValidateShell(shell string) error {
126 if shell == "" {137 if shell == "" {
M internal/domain/user/user_test.go +8 -4
@@ -92,7 +92,9 @@ func TestUser_AddGroup(t *testing.T) {
92 func TestUser_AddGroup_Duplicate(t *testing.T) {92 func TestUser_AddGroup_Duplicate(t *testing.T) {
93 user, _ := NewUser("testuser", "/bin/bash")93 user, _ := NewUser("testuser", "/bin/bash")
94 94
95- user.AddGroup("wheel")95+ if err := user.AddGroup("wheel"); err != nil {
96+ t.Fatalf("expected no error, got %v", err)
97+ }
96 err := user.AddGroup("wheel")98 err := user.AddGroup("wheel")
97 99
98 if err == nil {100 if err == nil {
@@ -117,7 +119,9 @@ func TestUser_HasGroup(t *testing.T) {
117 t.Error("expected user not in wheel group")119 t.Error("expected user not in wheel group")
118 }120 }
119 121
120- user.AddGroup("wheel")122+ if err := user.AddGroup("wheel"); err != nil {
123+ t.Fatalf("expected no error, got %v", err)
124+ }
121 125
122 if !user.HasGroup("wheel") {126 if !user.HasGroup("wheel") {
123 t.Error("expected user in wheel group")127 t.Error("expected user in wheel group")
@@ -162,7 +166,7 @@ func TestNewCredentials_Valid(t *testing.T) {
162 }166 }
163 167
164 func TestNewCredentials_WeakPassword(t *testing.T) {168 func TestNewCredentials_WeakPassword(t *testing.T) {
165- _, err := NewCredentials("weak", "rootpass456")169+ _, err := NewCredentials("wee", "rootpass456")
166 170
167 if err == nil {171 if err == nil {
168 t.Error("expected error for weak password")172 t.Error("expected error for weak password")
@@ -184,7 +188,7 @@ func TestValidatePassword(t *testing.T) {
184 shouldErr bool188 shouldErr bool
185 }{189 }{
186 {"empty", "", true},190 {"empty", "", true},
187- {"too short", "short", true},191+ {"too short", "sho", true},
188 {"valid", "validpass123", false},192 {"valid", "validpass123", false},
189 {"long valid", "a" + string(make([]byte, 100)) + "x", false},193 {"long valid", "a" + string(make([]byte, 100)) + "x", false},
190 {"too long", "a" + string(make([]byte, 200)), true},194 {"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
45 return stdout.Bytes(), nil45 return stdout.Bytes(), nil
46 }46 }
47 47
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+
48 // ChrootSystemctl runs systemctl commands in chroot69 // ChrootSystemctl runs systemctl commands in chroot
49 // This is a convenience method that ensures proper logging of systemctl operations70 // 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 {
51 // Verify paths exist72 // Verify paths exist
52 if _, err := os.Stat(chrootPath); err != nil {73 if _, err := os.Stat(chrootPath); err != nil {
53 return fmt.Errorf("chroot path does not exist: %w", err)74 return fmt.Errorf("chroot path does not exist: %w", err)
@@ -61,7 +82,7 @@ func (ce *ChrootExecutor) ChrootSystemctl(logPath string, chrootPath string, arg
61 82
62 // Build chroot command with systemctl83 // Build chroot command with systemctl
63 allArgs := append([]string{chrootPath, "systemctl"}, args...)84 allArgs := append([]string{chrootPath, "systemctl"}, args...)
64- cmd := exec.Command("arch-chroot", allArgs...)85+ cmd := exec.CommandContext(ctx, "arch-chroot", allArgs...)
65 86
66 var stdout, stderr bytes.Buffer87 var stdout, stderr bytes.Buffer
67 cmd.Stdout = &stdout88 cmd.Stdout = &stdout
@@ -74,8 +95,13 @@ func (ce *ChrootExecutor) ChrootSystemctl(logPath string, chrootPath string, arg
74 if logPath != "" {95 if logPath != "" {
75 logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)96 logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
76 if err == nil {97 if err == nil {
77- defer logFile.Close()98+ if _, writeErr := logFile.WriteString(errMsg + "\n"); writeErr != nil {
78- logFile.WriteString(errMsg + "\n")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+ }
79 }105 }
80 }106 }
81 107
@@ -86,8 +112,13 @@ func (ce *ChrootExecutor) ChrootSystemctl(logPath string, chrootPath string, arg
86 if logPath != "" {112 if logPath != "" {
87 logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)113 logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
88 if err == nil {114 if err == nil {
89- defer logFile.Close()115+ if _, writeErr := fmt.Fprintf(logFile, "systemctl %v executed successfully\n", args); writeErr != nil {
90- logFile.WriteString(fmt.Sprintf("systemctl %v executed successfully\n", args))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+ }
91 }122 }
92 }123 }
93 124
M internal/infrastructure/executor/chroot_test.go +118 -4
@@ -5,6 +5,7 @@ import (
5 "os"5 "os"
6 "path/filepath"6 "path/filepath"
7 "testing"7 "testing"
8+ "time"
8 9
9 "github.com/bnema/archup/internal/domain/ports/mocks"10 "github.com/bnema/archup/internal/domain/ports/mocks"
10 "go.uber.org/mock/gomock"11 "go.uber.org/mock/gomock"
@@ -77,7 +78,7 @@ func TestChrootExecutor_ChrootSystemctl_PathNotExists(t *testing.T) {
77 78
78 executor := NewChrootExecutor(mockLogger)79 executor := NewChrootExecutor(mockLogger)
79 80
80- err := executor.ChrootSystemctl("", "/nonexistent/path", "enable", "service")81+ err := executor.ChrootSystemctl(context.Background(), "", "/nonexistent/path", "enable", "service")
81 if err == nil {82 if err == nil {
82 t.Fatal("expected error for nonexistent chroot path")83 t.Fatal("expected error for nonexistent chroot path")
83 }84 }
@@ -98,7 +99,7 @@ func TestChrootExecutor_ChrootSystemctl_CreatesLogDirectory(t *testing.T) {
98 99
99 // This will fail due to arch-chroot not being available,100 // This will fail due to arch-chroot not being available,
100 // but the directory should be created101 // but the directory should be created
101- executor.ChrootSystemctl(logPath, tmpDir, "enable", "service")102+ _ = executor.ChrootSystemctl(context.Background(), logPath, tmpDir, "enable", "service")
102 103
103 // Check if log directory was created104 // Check if log directory was created
104 logDir := filepath.Dir(logPath)105 logDir := filepath.Dir(logPath)
@@ -121,11 +122,12 @@ func TestChrootExecutor_ChrootSystemctl_LogsErrors(t *testing.T) {
121 logPath := filepath.Join(tmpDir, "log.txt")122 logPath := filepath.Join(tmpDir, "log.txt")
122 123
123 // This will fail due to arch-chroot not being available124 // 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")
125 126
126 // Check if log file was created127 // Check if log file was created
127 if _, err := os.Stat(logPath); os.IsNotExist(err) {128 if _, err := os.Stat(logPath); os.IsNotExist(err) {
128 // It's OK if log file wasn't created due to error129 // It's OK if log file wasn't created due to error
130+ t.Log("log file not created (expected on failure)")
129 }131 }
130 }132 }
131 133
@@ -142,5 +144,117 @@ func TestChrootExecutor_ChrootSystemctl_EmptyLogPath(t *testing.T) {
142 tmpDir := t.TempDir()144 tmpDir := t.TempDir()
143 145
144 // This should not panic even with empty log path146 // 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+ })
146 }260 }
M internal/infrastructure/executor/shell_test.go +0 -10
@@ -56,16 +56,6 @@ func TestShellExecutor_Execute_WithArgs(t *testing.T) {
56 }56 }
57 }57 }
58 58
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-
69 func TestShellExecutor_Execute_CommandNotFound(t *testing.T) {59 func TestShellExecutor_Execute_CommandNotFound(t *testing.T) {
70 ctrl := gomock.NewController(t)60 ctrl := gomock.NewController(t)
71 defer ctrl.Finish()61 defer ctrl.Finish()
M internal/infrastructure/filesystem/local_test.go +8 -2
@@ -118,7 +118,11 @@ func TestLocalFileSystem_Create(t *testing.T) {
118 if err != nil {118 if err != nil {
119 t.Fatalf("expected no error, got %v", err)119 t.Fatalf("expected no error, got %v", err)
120 }120 }
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+ }()
122 126
123 n, err := file.Write([]byte("test content"))127 n, err := file.Write([]byte("test content"))
124 if err != nil {128 if err != nil {
@@ -151,7 +155,9 @@ func TestLocalFileSystem_CreateTruncates(t *testing.T) {
151 if err != nil {155 if err != nil {
152 t.Fatalf("expected no error, got %v", err)156 t.Fatalf("expected no error, got %v", err)
153 }157 }
154- file.Close()158+ if err := file.Close(); err != nil {
159+ t.Fatalf("failed to close file: %v", err)
160+ }
155 161
156 // Verify file is empty162 // Verify file is empty
157 info, _ := os.Stat(testFile)163 info, _ := os.Stat(testFile)
M internal/infrastructure/http/client.go +10 -11
@@ -5,6 +5,8 @@ import (
5 "io"5 "io"
6 nethttp "net/http"6 nethttp "net/http"
7 "time"7 "time"
8+
9+ "github.com/bnema/archup/internal/domain/ports"
8 )10 )
9 11
10 // HTTPClient implements the HTTPClient port using the standard library12 // HTTPClient implements the HTTPClient port using the standard library
@@ -31,7 +33,7 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *HTTPClient {
31 }33 }
32 34
33 // Get performs an HTTP GET request35 // 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) {
35 resp, err := hc.client.Get(url)37 resp, err := hc.client.Get(url)
36 if err != nil {38 if err != nil {
37 return nil, fmt.Errorf("GET request failed: %w", err)39 return nil, fmt.Errorf("GET request failed: %w", err)
@@ -40,7 +42,9 @@ func (hc *HTTPClient) Get(url string) (HTTPResponse, error) {
40 // Read body into memory42 // Read body into memory
41 body, err := io.ReadAll(resp.Body)43 body, err := io.ReadAll(resp.Body)
42 if err != nil {44 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+ }
44 return nil, fmt.Errorf("failed to read response body: %w", err)48 return nil, fmt.Errorf("failed to read response body: %w", err)
45 }49 }
46 50
@@ -52,7 +56,7 @@ func (hc *HTTPClient) Get(url string) (HTTPResponse, error) {
52 }56 }
53 57
54 // Post performs an HTTP POST request58 // 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) {
56 resp, err := hc.client.Post(url, contentType, nil)60 resp, err := hc.client.Post(url, contentType, nil)
57 if err != nil {61 if err != nil {
58 return nil, fmt.Errorf("POST request failed: %w", err)62 return nil, fmt.Errorf("POST request failed: %w", err)
@@ -61,7 +65,9 @@ func (hc *HTTPClient) Post(url string, contentType string, body []byte) (HTTPRes
61 // Read body into memory65 // Read body into memory
62 respBody, err := io.ReadAll(resp.Body)66 respBody, err := io.ReadAll(resp.Body)
63 if err != nil {67 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+ }
65 return nil, fmt.Errorf("failed to read response body: %w", err)71 return nil, fmt.Errorf("failed to read response body: %w", err)
66 }72 }
67 73
@@ -72,13 +78,6 @@ func (hc *HTTPClient) Post(url string, contentType string, body []byte) (HTTPRes
72 }, nil78 }, nil
73 }79 }
74 80
75-// HTTPResponse interface for HTTP responses
76-type HTTPResponse interface {
77- StatusCode() int
78- Body() []byte
79- Close() error
80-}
81-
82 // httpResponse implements the Response port81 // httpResponse implements the Response port
83 type httpResponse struct {82 type httpResponse struct {
84 statusCode int83 statusCode int
M internal/infrastructure/http/client_test.go +37 -9
@@ -36,13 +36,19 @@ func TestHTTPClient_Get_Success(t *testing.T) {
36 // Create a test server36 // Create a test server
37 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {37 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38 w.WriteHeader(200)38 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+ }
40 }))42 }))
41 defer server.Close()43 defer server.Close()
42 44
43 client := NewHTTPClient()45 client := NewHTTPClient()
44 resp, err := client.Get(server.URL)46 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+ }()
46 52
47 if err != nil {53 if err != nil {
48 t.Fatalf("expected no error, got %v", err)54 t.Fatalf("expected no error, got %v", err)
@@ -65,7 +71,11 @@ func TestHTTPClient_Get_StatusCode(t *testing.T) {
65 71
66 client := NewHTTPClient()72 client := NewHTTPClient()
67 resp, err := client.Get(server.URL)73 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+ }()
69 79
70 if err != nil {80 if err != nil {
71 t.Fatalf("expected no error, got %v", err)81 t.Fatalf("expected no error, got %v", err)
@@ -84,7 +94,11 @@ func TestHTTPClient_Get_EmptyBody(t *testing.T) {
84 94
85 client := NewHTTPClient()95 client := NewHTTPClient()
86 resp, err := client.Get(server.URL)96 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+ }()
88 102
89 if err != nil {103 if err != nil {
90 t.Fatalf("expected no error, got %v", err)104 t.Fatalf("expected no error, got %v", err)
@@ -100,13 +114,19 @@ func TestHTTPClient_Get_LargeBody(t *testing.T) {
100 114
101 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {115 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
102 w.WriteHeader(200)116 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+ }
104 }))120 }))
105 defer server.Close()121 defer server.Close()
106 122
107 client := NewHTTPClient()123 client := NewHTTPClient()
108 resp, err := client.Get(server.URL)124 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+ }()
110 130
111 if err != nil {131 if err != nil {
112 t.Fatalf("expected no error, got %v", err)132 t.Fatalf("expected no error, got %v", err)
@@ -129,13 +149,19 @@ func TestHTTPClient_Get_InvalidURL(t *testing.T) {
129 func TestHTTPClient_Post_Success(t *testing.T) {149 func TestHTTPClient_Post_Success(t *testing.T) {
130 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {150 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
131 w.WriteHeader(201)151 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+ }
133 }))155 }))
134 defer server.Close()156 defer server.Close()
135 157
136 client := NewHTTPClient()158 client := NewHTTPClient()
137 resp, err := client.Post(server.URL, "application/json", []byte(`{"key":"value"}`))159 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+ }()
139 165
140 if err != nil {166 if err != nil {
141 t.Fatalf("expected no error, got %v", err)167 t.Fatalf("expected no error, got %v", err)
@@ -162,7 +188,9 @@ func TestHTTPClient_Post_InvalidURL(t *testing.T) {
162 func TestHTTPClient_Response_Close(t *testing.T) {188 func TestHTTPClient_Response_Close(t *testing.T) {
163 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {189 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
164 w.WriteHeader(200)190 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+ }
166 }))194 }))
167 defer server.Close()195 defer server.Close()
168 196
M internal/infrastructure/persistence/file_repository_test.go +27 -9
@@ -75,7 +75,9 @@ func TestFileRepository_Save_Content(t *testing.T) {
75 ctx := context.Background()75 ctx := context.Background()
76 state := "test installation state with content"76 state := "test installation state with content"
77 77
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+ }
79 81
80 // Verify file content82 // Verify file content
81 filePath := filepath.Join(tmpDir, "install-001.state")83 filePath := filepath.Join(tmpDir, "install-001.state")
@@ -93,11 +95,15 @@ func TestFileRepository_Save_Overwrite(t *testing.T) {
93 ctx := context.Background()95 ctx := context.Background()
94 96
95 // Save first state97 // 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+ }
97 101
98 // Overwrite with second state102 // Overwrite with second state
99 secondState := "second state"103 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+ }
101 107
102 // Verify file content is updated108 // Verify file content is updated
103 filePath := filepath.Join(tmpDir, "install-001.state")109 filePath := filepath.Join(tmpDir, "install-001.state")
@@ -115,7 +121,9 @@ func TestFileRepository_Load_Success(t *testing.T) {
115 // Save first121 // Save first
116 ctx := context.Background()122 ctx := context.Background()
117 state := "test installation state"123 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+ }
119 127
120 // Load128 // Load
121 loaded, err := repo.Load(ctx, "install-001")129 loaded, err := repo.Load(ctx, "install-001")
@@ -146,7 +154,9 @@ func TestFileRepository_Exists_True(t *testing.T) {
146 154
147 // Save first155 // Save first
148 ctx := context.Background()156 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+ }
150 160
151 // Check exists161 // Check exists
152 exists, err := repo.Exists(ctx, "install-001")162 exists, err := repo.Exists(ctx, "install-001")
@@ -194,7 +204,9 @@ func TestFileRepository_Load_ContextCancelled(t *testing.T) {
194 204
195 // Save first205 // Save first
196 ctx := context.Background()206 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+ }
198 210
199 // Load with cancelled context211 // Load with cancelled context
200 ctx2, cancel := context.WithCancel(context.Background())212 ctx2, cancel := context.WithCancel(context.Background())
@@ -226,9 +238,15 @@ func TestFileRepository_MultipleInstallations(t *testing.T) {
226 ctx := context.Background()238 ctx := context.Background()
227 239
228 // Save multiple installations240 // Save multiple installations
229- repo.Save(ctx, "install-001", "state 1")241+ if err := repo.Save(ctx, "install-001", "state 1"); err != nil {
230- repo.Save(ctx, "install-002", "state 2")242+ t.Fatalf("expected no error, got %v", err)
231- repo.Save(ctx, "install-003", "state 3")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+ }
232 250
233 // Load each one251 // Load each one
234 state1, _ := repo.Load(ctx, "install-001")252 state1, _ := repo.Load(ctx, "install-001")
M internal/interfaces/tui/app.go +198 -23
@@ -3,11 +3,14 @@ package tui
3 import (3 import (
4 "context"4 "context"
5 5
6+ apphandlers "github.com/bnema/archup/internal/application/handlers"
6 "github.com/bnema/archup/internal/application/services"7 "github.com/bnema/archup/internal/application/services"
7 "github.com/bnema/archup/internal/domain/ports"8 "github.com/bnema/archup/internal/domain/ports"
9+ "github.com/bnema/archup/internal/domain/system"
8 "github.com/bnema/archup/internal/interfaces/tui/handlers"10 "github.com/bnema/archup/internal/interfaces/tui/handlers"
9 "github.com/bnema/archup/internal/interfaces/tui/models"11 "github.com/bnema/archup/internal/interfaces/tui/models"
10 "github.com/bnema/archup/internal/interfaces/tui/views"12 "github.com/bnema/archup/internal/interfaces/tui/views"
13+ legacysystem "github.com/bnema/archup/internal/system"
11 tea "github.com/charmbracelet/bubbletea"14 tea "github.com/charmbracelet/bubbletea"
12 )15 )
13 16
@@ -18,12 +21,15 @@ type App struct {
18 // Core services21 // Core services
19 installService *services.InstallationService22 installService *services.InstallationService
20 progressTracker *services.ProgressTracker23 progressTracker *services.ProgressTracker
24+ gpuHandler *apphandlers.GPUHandler
21 25
22 // Infrastructure ports26 // Infrastructure ports
23 logger ports.Logger27 logger ports.Logger
24 28
25 // UI models (using concrete types for compatibility with views/handlers)29 // UI models (using concrete types for compatibility with views/handlers)
26 formModel *models.FormModelImpl30 formModel *models.FormModelImpl
31+ diskModel *models.DiskModelImpl
32+ gpuModel *models.GPUModelImpl
27 installationModel *models.InstallationModelImpl33 installationModel *models.InstallationModelImpl
28 progressModel *models.ProgressModelImpl34 progressModel *models.ProgressModelImpl
29 35
@@ -31,6 +37,12 @@ type App struct {
31 currentScreen Screen37 currentScreen Screen
32 ctx context.Context38 ctx context.Context
33 cancel context.CancelFunc39 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
34 }46 }
35 47
36 // Screen represents the current TUI screen48 // Screen represents the current TUI screen
@@ -38,6 +50,8 @@ type Screen string
38 50
39 const (51 const (
40 ScreenForm Screen = "form"52 ScreenForm Screen = "form"
53+ ScreenDisk Screen = "disk"
54+ ScreenGPU Screen = "gpu"
41 ScreenInstalling Screen = "installing"55 ScreenInstalling Screen = "installing"
42 ScreenProgress Screen = "progress"56 ScreenProgress Screen = "progress"
43 ScreenSummary Screen = "summary"57 ScreenSummary Screen = "summary"
@@ -48,6 +62,7 @@ const (
48 func NewApp(62 func NewApp(
49 installService *services.InstallationService,63 installService *services.InstallationService,
50 progressTracker *services.ProgressTracker,64 progressTracker *services.ProgressTracker,
65+ gpuHandler *apphandlers.GPUHandler,
51 logger ports.Logger,66 logger ports.Logger,
52 ) *App {67 ) *App {
53 ctx, cancel := context.WithCancel(context.Background())68 ctx, cancel := context.WithCancel(context.Background())
@@ -55,8 +70,11 @@ func NewApp(
55 return &App{70 return &App{
56 installService: installService,71 installService: installService,
57 progressTracker: progressTracker,72 progressTracker: progressTracker,
73+ gpuHandler: gpuHandler,
58 logger: logger,74 logger: logger,
59 formModel: models.NewFormModel(),75 formModel: models.NewFormModel(),
76+ diskModel: models.NewDiskModel(),
77+ gpuModel: models.NewGPUModel(),
60 installationModel: models.NewInstallationModel(),78 installationModel: models.NewInstallationModel(),
61 progressModel: models.NewProgressModel(),79 progressModel: models.NewProgressModel(),
62 currentScreen: ScreenForm,80 currentScreen: ScreenForm,
@@ -90,7 +108,10 @@ func (a *App) GetContext() context.Context {
90 // Init is called when the program starts108 // Init is called when the program starts
91 func (a *App) Init() tea.Cmd {109 func (a *App) Init() tea.Cmd {
92 a.logger.Debug("TUI app initialized")110 a.logger.Debug("TUI app initialized")
93- return nil111+ return tea.Batch(
112+ a.formModel.Init(),
113+ a.detectTimezoneCmd(),
114+ )
94 }115 }
95 116
96 // Update handles messages and updates the model117 // Update handles messages and updates the model
@@ -98,12 +119,18 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
98 switch msg := msg.(type) {119 switch msg := msg.(type) {
99 case tea.KeyMsg:120 case tea.KeyMsg:
100 return a.handleKeyMsg(msg)121 return a.handleKeyMsg(msg)
101- case ProgressUpdateMsg:122+ case handlers.ProgressUpdateMsg:
102 return a.handleProgressUpdate(msg)123 return a.handleProgressUpdate(msg)
103- case InstallationErrorMsg:124+ case handlers.InstallationErrorMsg:
104 return a.handleInstallationError(msg)125 return a.handleInstallationError(msg)
105- case InstallationCompleteMsg:126+ case handlers.InstallationCompleteMsg:
106 return a.handleInstallationComplete(msg)127 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)
107 }134 }
108 135
109 return a, nil136 return a, nil
@@ -114,6 +141,10 @@ func (a *App) View() string {
114 switch a.currentScreen {141 switch a.currentScreen {
115 case ScreenForm:142 case ScreenForm:
116 return views.RenderForm(a.formModel)143 return views.RenderForm(a.formModel)
144+ case ScreenDisk:
145+ return views.RenderDiskSelection(a.diskModel)
146+ case ScreenGPU:
147+ return views.RenderGPUSelection(a.gpuModel)
117 case ScreenInstalling, ScreenProgress:148 case ScreenInstalling, ScreenProgress:
118 return views.RenderProgress(a.progressModel)149 return views.RenderProgress(a.progressModel)
119 case ScreenSummary:150 case ScreenSummary:
@@ -130,6 +161,10 @@ func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
130 switch a.currentScreen {161 switch a.currentScreen {
131 case ScreenForm:162 case ScreenForm:
132 return a.handleFormInput(msg)163 return a.handleFormInput(msg)
164+ case ScreenDisk:
165+ return a.handleDiskInput(msg)
166+ case ScreenGPU:
167+ return a.handleGPUInput(msg)
133 case ScreenProgress:168 case ScreenProgress:
134 // Limited input during installation169 // Limited input during installation
135 if msg.String() == "ctrl+c" {170 if msg.String() == "ctrl+c" {
@@ -139,6 +174,10 @@ func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
139 if msg.String() == "q" || msg.String() == "ctrl+c" {174 if msg.String() == "q" || msg.String() == "ctrl+c" {
140 return a, tea.Quit175 return a, tea.Quit
141 }176 }
177+ case ScreenError:
178+ if msg.String() == "q" || msg.String() == "ctrl+c" {
179+ return a, tea.Quit
180+ }
142 }181 }
143 182
144 return a, nil183 return a, nil
@@ -150,15 +189,19 @@ func (a *App) handleFormInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
150 case "ctrl+c":189 case "ctrl+c":
151 return a, tea.Quit190 return a, tea.Quit
152 case "enter":191 case "enter":
153- // Validate form and start installation192+ a.formModel.ExtractData()
193+ // Validate form and go to disk selection
154 if err := a.validateForm(); err != nil {194 if err := a.validateForm(); err != nil {
155- a.logger.Error("Form validation failed", "error", err)195+ a.logger.Warn("Form validation failed", "error", err)
156- a.currentScreen = ScreenError196+ // Show error on form, don't leave screen
157- a.installationModel.SetError(err.Error())197+ a.formModel.SetError(err)
158 return a, nil198 return a, nil
159 }199 }
160- // Start installation200+ // Clear any previous error and store validated data in App
161- return a.startInstallation()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()
162 default:205 default:
163 // Delegate to form handler206 // Delegate to form handler
164 updatedForm, cmd := handlers.HandleFormUpdate(a.formModel, msg)207 updatedForm, cmd := handlers.HandleFormUpdate(a.formModel, msg)
@@ -168,7 +211,7 @@ func (a *App) handleFormInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
168 }211 }
169 212
170 // handleProgressUpdate processes progress events213 // 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) {
172 updatedProgress, cmd := handlers.HandleProgressUpdate(a, msg, a.progressModel)215 updatedProgress, cmd := handlers.HandleProgressUpdate(a, msg, a.progressModel)
173 a.progressModel = updatedProgress216 a.progressModel = updatedProgress
174 a.currentScreen = ScreenProgress217 a.currentScreen = ScreenProgress
@@ -176,7 +219,7 @@ func (a *App) handleProgressUpdate(msg ProgressUpdateMsg) (tea.Model, tea.Cmd) {
176 }219 }
177 220
178 // handleInstallationError processes installation errors221 // 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) {
180 updatedInstall, cmd := handlers.HandleInstallationError(a, msg, a.installationModel)223 updatedInstall, cmd := handlers.HandleInstallationError(a, msg, a.installationModel)
181 a.installationModel = updatedInstall224 a.installationModel = updatedInstall
182 a.currentScreen = ScreenError225 a.currentScreen = ScreenError
@@ -184,13 +227,19 @@ func (a *App) handleInstallationError(msg InstallationErrorMsg) (tea.Model, tea.
184 }227 }
185 228
186 // handleInstallationComplete processes installation completion229 // 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) {
188 updatedInstall, cmd := handlers.HandleInstallationComplete(a, msg, a.installationModel)231 updatedInstall, cmd := handlers.HandleInstallationComplete(a, msg, a.installationModel)
189 a.installationModel = updatedInstall232 a.installationModel = updatedInstall
190 a.currentScreen = ScreenSummary233 a.currentScreen = ScreenSummary
191 return a, cmd234 return a, cmd
192 }235 }
193 236
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+
194 // handleCancel cancels the installation243 // handleCancel cancels the installation
195 func (a *App) handleCancel() (tea.Model, tea.Cmd) {244 func (a *App) handleCancel() (tea.Model, tea.Cmd) {
196 a.logger.Info("Installation cancelled by user")245 a.logger.Info("Installation cancelled by user")
@@ -200,34 +249,160 @@ func (a *App) handleCancel() (tea.Model, tea.Cmd) {
200 return a, nil249 return a, nil
201 }250 }
202 251
203-// validateForm validates the form data252+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
204 func (a *App) validateForm() error {274 func (a *App) validateForm() error {
205 formData := a.formModel.GetData()275 formData := a.formModel.GetData()
206- if formData.Hostname == "" {276+
207- return ErrInvalidHostname277+ // Use domain validation for all fields
278+ if err := system.ValidateHostname(formData.Hostname); err != nil {
279+ return err
208 }280 }
209- if formData.Username == "" {281+
210- return ErrInvalidUsername282+ 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
211 }288 }
212- if formData.TargetDisk == "" {289+
213- return ErrInvalidDisk290+ if err := system.ValidateTimezone(formData.Timezone); err != nil {
291+ return err
214 }292 }
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
215 return nil303 return nil
216 }304 }
217 305
218 // startInstallation starts the installation process306 // startInstallation starts the installation process
219 func (a *App) startInstallation() (tea.Model, tea.Cmd) {307 func (a *App) startInstallation() (tea.Model, tea.Cmd) {
220- formData := a.formModel.GetData()
221 a.currentScreen = ScreenInstalling308 a.currentScreen = ScreenInstalling
309+ a.logger.Debug("Starting installation", "password_len", len(a.formData.UserPassword))
222 310
223- // Create installation command using handler311+ // Create installation command using stored form data
224- cmd := handlers.CreateInstallationCommand(a, formData)312+ cmd := handlers.CreateInstallationCommand(a, a.formData)
225 313
226 return a, tea.Batch(cmd)314 return a, tea.Batch(cmd)
227 }315 }
228 316
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+
229 // Close cleans up resources394 // Close cleans up resources
230 func (a *App) Close() error {395 func (a *App) Close() error {
231 a.cancel()396 a.cancel()
232 return a.installService.Close()397 return a.installService.Close()
233 }398 }
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
2 2
3 import "errors"3 import "errors"
4 4
5-// TUI validation and runtime errors5+// TUI-specific errors (validation errors are in domain/system package)
6 var (6 var (
7- ErrInvalidHostname = errors.New("hostname is required and must be valid")7+ ErrInvalidDisk = errors.New("target disk is required")
8- ErrInvalidUsername = errors.New("username is required")8+ ErrDiskNotFound = errors.New("target disk not found")
9- ErrInvalidDisk = errors.New("target disk is required")9+ ErrFormIncomplete = errors.New("form is incomplete, please fill all required fields")
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")
13 )10 )
M internal/interfaces/tui/handlers/form_handler_test.go +1 -2
@@ -11,8 +11,7 @@ func TestHandleFormUpdate_Navigation(t *testing.T) {
11 fm := models.NewFormModel()11 fm := models.NewFormModel()
12 12
13 // Test down navigation13 // Test down navigation
14- downKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}14+ downKey := tea.KeyMsg{Type: tea.KeyDown}
15- downKey = tea.KeyMsg{Type: tea.KeyDown}
16 _, _ = HandleFormUpdate(fm, downKey)15 _, _ = HandleFormUpdate(fm, downKey)
17 16
18 // The handler would call focusNext()17 // The handler would call focusNext()
M internal/interfaces/tui/handlers/install_handler.go +231 -37
@@ -1,7 +1,14 @@
1 package handlers1 package handlers
2 2
3 import (3 import (
4+ "strings"
5+
6+ "github.com/bnema/archup/internal/application/commands"
4 "github.com/bnema/archup/internal/application/dto"7 "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"
5 "github.com/bnema/archup/internal/interfaces/tui/models"12 "github.com/bnema/archup/internal/interfaces/tui/models"
6 tea "github.com/charmbracelet/bubbletea"13 tea "github.com/charmbracelet/bubbletea"
7 )14 )
@@ -10,38 +17,39 @@ import (
10 // This breaks the circular dependency: tui → tui/handlers → tui17 // This breaks the circular dependency: tui → tui/handlers → tui
11 // Messages are typed by the caller (app.go), not here18 // Messages are typed by the caller (app.go), not here
12 19
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+
13 // HandleProgressUpdate processes progress update messages35 // HandleProgressUpdate processes progress update messages
14-// msg should be ProgressUpdateMsg with an Update field (*dto.ProgressUpdate)
15 func HandleProgressUpdate(app AppContext, msg interface{}, progressModel *models.ProgressModelImpl) (*models.ProgressModelImpl, tea.Cmd) {36 func HandleProgressUpdate(app AppContext, msg interface{}, progressModel *models.ProgressModelImpl) (*models.ProgressModelImpl, tea.Cmd) {
16- // Cast to a type with Update field37+ if m, ok := msg.(ProgressUpdateMsg); ok {
17- // This avoids importing the tui package which would create a cycle38+ progressModel.UpdateProgress(m.Update)
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- }
27 }39 }
28 return progressModel, nil40 return progressModel, nil
29 }41 }
30 42
31 // HandleInstallationError processes installation error messages43 // HandleInstallationError processes installation error messages
32-// Signature matches what app.go calls with InstallationErrorMsg types
33 func HandleInstallationError(app AppContext, msg interface{}, installModel *models.InstallationModelImpl) (*models.InstallationModelImpl, tea.Cmd) {44 func HandleInstallationError(app AppContext, msg interface{}, installModel *models.InstallationModelImpl) (*models.InstallationModelImpl, tea.Cmd) {
34- // Cast msg to InstallationErrorMsg type (from app.go caller context)45+ if errMsg, ok := msg.(InstallationErrorMsg); ok {
35- if errMsg, ok := msg.(interface{ GetErr() error }); ok {46+ app.GetLogger().Error("Installation error", "error", errMsg.Err)
36- err := errMsg.GetErr()47+ installModel.SetError(errMsg.Err.Error())
37- app.GetLogger().Error("Installation error", "error", err)
38- installModel.SetError(err.Error())
39 }48 }
40 return installModel, nil49 return installModel, nil
41 }50 }
42 51
43 // HandleInstallationComplete processes installation completion messages52 // HandleInstallationComplete processes installation completion messages
44-// Signature matches what app.go calls with InstallationCompleteMsg types
45 func HandleInstallationComplete(app AppContext, msg interface{}, installModel *models.InstallationModelImpl) (*models.InstallationModelImpl, tea.Cmd) {53 func HandleInstallationComplete(app AppContext, msg interface{}, installModel *models.InstallationModelImpl) (*models.InstallationModelImpl, tea.Cmd) {
46 app.GetLogger().Info("Installation completed successfully")54 app.GetLogger().Info("Installation completed successfully")
47 installModel.SetComplete()55 installModel.SetComplete()
@@ -49,38 +57,224 @@ func HandleInstallationComplete(app AppContext, msg interface{}, installModel *m
49 }57 }
50 58
51 // CreateInstallationCommand creates an installation command from form data59 // CreateInstallationCommand creates an installation command from form data
52-// This delegates to the service layer instead of handling directly60+// Runs the full installation in a goroutine and sends progress via p.Send()
53-// Accepts AppContext interface to avoid circular imports
54 func CreateInstallationCommand(app AppContext, formData models.FormData) tea.Cmd {61 func CreateInstallationCommand(app AppContext, formData models.FormData) tea.Cmd {
55 return func() tea.Msg {62 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+
56 // Start installation via service70 // Start installation via service
57- if err := app.GetInstallService().Start(71+ if err := svc.Start(ctx, formData.Hostname, formData.Username, formData.TargetDisk, encryptionType); err != nil {
58- app.GetContext(),72+ logger.Error("Failed to start installation", "error", err)
59- formData.Hostname,73+ return InstallationErrorMsg{Err: err}
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
67 }74 }
68 75
69- // Subscribe to progress updates76+ // Subscribe to progress updates and forward to bubbletea
70 progressChan := app.GetProgressTracker().Subscribe()77 progressChan := app.GetProgressTracker().Subscribe()
71 go func() {78 go func() {
72 for {79 for {
73 select {80 select {
74- case <-progressChan:81+ case update, ok := <-progressChan:
75- // Progress updates are broadcast to all subscribers82+ if !ok {
76- // App will receive them through its own subscription83+ return
77- case <-app.GetContext().Done():84+ }
78- app.GetLogger().Info("Installation context cancelled")85+ if program != nil {
86+ program.Send(ProgressUpdateMsg{Update: update})
87+ }
88+ case <-ctx.Done():
89+ logger.Info("Installation context cancelled")
79 return90 return
80 }91 }
81 }92 }
82 }()93 }()
83 94
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+
84 return nil224 return nil
85 }225 }
86 }226 }
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 (
5 5
6 "github.com/bnema/archup/internal/application/services"6 "github.com/bnema/archup/internal/application/services"
7 "github.com/bnema/archup/internal/domain/ports"7 "github.com/bnema/archup/internal/domain/ports"
8+ tea "github.com/charmbracelet/bubbletea"
8 )9 )
9 10
10 // AppContext defines the interface for accessing app resources from handlers11 // AppContext defines the interface for accessing app resources from handlers
@@ -21,4 +22,7 @@ type AppContext interface {
21 22
22 // GetContext returns the app context23 // GetContext returns the app context
23 GetContext() context.Context24 GetContext() context.Context
25+
26+ // GetProgram returns the tea.Program for sending messages from goroutines
27+ GetProgram() *tea.Program
24 }28 }
M internal/interfaces/tui/messages.go +20 -13
@@ -1,16 +1,12 @@
1 package tui1 package tui
2 2
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+)
4 7
5-// InstallationErrorMsg is sent when an error occurs during installation8+// Note: ProgressUpdateMsg, InstallationErrorMsg, InstallationCompleteMsg
6-type InstallationErrorMsg struct {9+// are defined in handlers package to avoid circular imports
7- Err error
8-}
9-
10-// InstallationCompleteMsg is sent when installation completes successfully
11-type InstallationCompleteMsg struct {
12- Duration int // seconds
13-}
14 10
15 // FormSubmitMsg is sent when the form is submitted11 // FormSubmitMsg is sent when the form is submitted
16 type FormSubmitMsg struct {12 type FormSubmitMsg struct {
@@ -22,7 +18,18 @@ type ScreenChangeMsg struct {
22 Screen Screen18 Screen Screen
23 }19 }
24 20
25-// ProgressUpdateMsg is sent when installation progress updates21+// GPUDetectedMsg is sent when GPU detection completes
26-type ProgressUpdateMsg struct {22+type GPUDetectedMsg struct {
27- Update *dto.ProgressUpdate23+ 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
28 }35 }
M internal/interfaces/tui/models/form.go +49 -30
@@ -24,10 +24,13 @@ func (bm *BaseModel) SetSize(width, height int) {
24 type FormData struct {24 type FormData struct {
25 Hostname string25 Hostname string
26 Username string26 Username string
27+ UserEmail string
27 UserPassword string28 UserPassword string
28 RootPassword string29 RootPassword string
29 TargetDisk string30 TargetDisk string
30 EncryptionType string31 EncryptionType string
32+ GPUVendor string
33+ GPUDrivers []string
31 Timezone string34 Timezone string
32 Locale string35 Locale string
33 Keymap string36 Keymap string
@@ -43,8 +46,8 @@ type FormModelImpl struct {
43 // Form fields46 // Form fields
44 hostname textinput.Model47 hostname textinput.Model
45 username textinput.Model48 username textinput.Model
49+ userEmail textinput.Model
46 userPassword textinput.Model50 userPassword textinput.Model
47- rootPassword textinput.Model
48 targetDisk textinput.Model51 targetDisk textinput.Model
49 timezone textinput.Model52 timezone textinput.Model
50 locale textinput.Model53 locale textinput.Model
@@ -66,32 +69,40 @@ func NewFormModel() *FormModelImpl {
66 fields: []textinput.Model{},69 fields: []textinput.Model{},
67 }70 }
68 71
69- // Initialize text inputs with labels72+ // Initialize text inputs with default values
70- fm.hostname = createTextInput("Hostname", "myarch", "Enter system hostname")73+ fm.hostname = createTextInput("Hostname", "", "Enter system hostname")
71- fm.username = createTextInput("Username", "archuser", "Enter regular user name")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+
72 fm.userPassword = createTextInput("User Password", "", "Enter user password")81 fm.userPassword = createTextInput("User Password", "", "Enter user password")
73 fm.userPassword.EchoMode = textinput.EchoPassword82 fm.userPassword.EchoMode = textinput.EchoPassword
74- fm.rootPassword = createTextInput("Root Password", "", "Enter root password")83+
75- fm.rootPassword.EchoMode = textinput.EchoPassword84+ // No root password - user will be sudoer
76- fm.targetDisk = createTextInput("Target Disk", "/dev/sda", "Enter target disk (e.g., /dev/sda)")85+ // targetDisk is selected via disk selection screen, not a text field
77- fm.timezone = createTextInput("Timezone", "UTC", "Enter timezone")86+ fm.timezone = createTextInput("Timezone", "", "Enter timezone")
78- fm.locale = createTextInput("Locale", "en_US.UTF-8", "Enter locale")87+ fm.timezone.SetValue("UTC")
79- fm.keymap = createTextInput("Keymap", "us", "Enter keymap")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")
80 94
81 fm.fields = []textinput.Model{95 fm.fields = []textinput.Model{
82- fm.hostname, fm.username, fm.userPassword, fm.rootPassword,96+ fm.hostname, fm.username, fm.userEmail, fm.userPassword,
83- fm.targetDisk, fm.timezone, fm.locale, fm.keymap,97+ fm.timezone, fm.locale, fm.keymap,
84 }98 }
85 99
86- // Set first field as focused
87- fm.fields[0].Focus()
88-
89 return fm100 return fm
90 }101 }
91 102
92-// Init initializes the form model103+// Init initializes the form model by focusing the first field
93 func (fm *FormModelImpl) Init() tea.Cmd {104 func (fm *FormModelImpl) Init() tea.Cmd {
94- return textinput.Blink105+ return fm.fields[0].Focus()
95 }106 }
96 107
97 // Update handles input and updates the form108 // Update handles input and updates the form
@@ -137,9 +148,8 @@ func (fm *FormModelImpl) View() string {
137 labels := []string{148 labels := []string{
138 "Hostname:",149 "Hostname:",
139 "Username:",150 "Username:",
140- "User Password:",151+ "Email(opt):",
141- "Root Password:",152+ "Password:",
142- "Target Disk:",
143 "Timezone:",153 "Timezone:",
144 "Locale:",154 "Locale:",
145 "Keymap:",155 "Keymap:",
@@ -198,7 +208,6 @@ func (fm *FormModelImpl) SetData(data FormData) {
198 fm.hostname.SetValue(data.Hostname)208 fm.hostname.SetValue(data.Hostname)
199 fm.username.SetValue(data.Username)209 fm.username.SetValue(data.Username)
200 fm.userPassword.SetValue(data.UserPassword)210 fm.userPassword.SetValue(data.UserPassword)
201- fm.rootPassword.SetValue(data.RootPassword)
202 fm.targetDisk.SetValue(data.TargetDisk)211 fm.targetDisk.SetValue(data.TargetDisk)
203 fm.timezone.SetValue(data.Timezone)212 fm.timezone.SetValue(data.Timezone)
204 fm.locale.SetValue(data.Locale)213 fm.locale.SetValue(data.Locale)
@@ -255,6 +264,11 @@ func (fm *FormModelImpl) ExtractData() {
255 fm.extractData()264 fm.extractData()
256 }265 }
257 266
267+// SetTimezone sets the timezone field value
268+func (fm *FormModelImpl) SetTimezone(tz string) {
269+ fm.timezone.SetValue(tz)
270+}
271+
258 // Helper methods272 // Helper methods
259 273
260 func (fm *FormModelImpl) focusNext() {274 func (fm *FormModelImpl) focusNext() {
@@ -273,20 +287,25 @@ func (fm *FormModelImpl) focusPrevious() {
273 }287 }
274 288
275 func (fm *FormModelImpl) updateInput(msg tea.Msg) tea.Cmd {289 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)
277 return cmd292 return cmd
278 }293 }
279 294
280 func (fm *FormModelImpl) extractData() {295 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)
281 fm.data = FormData{298 fm.data = FormData{
282- Hostname: fm.hostname.Value(),299+ Hostname: fm.fields[0].Value(),
283- Username: fm.username.Value(),300+ Username: fm.fields[1].Value(),
284- UserPassword: fm.userPassword.Value(),301+ UserEmail: fm.fields[2].Value(), // Optional - for git config
285- RootPassword: fm.rootPassword.Value(),302+ UserPassword: fm.fields[3].Value(),
286- TargetDisk: fm.targetDisk.Value(),303+ RootPassword: "", // No root password - root account locked, user is sudoer
287- Timezone: fm.timezone.Value(),304+ TargetDisk: fm.targetDisk.Value(),
288- Locale: fm.locale.Value(),305+ EncryptionType: fm.data.EncryptionType,
289- Keymap: fm.keymap.Value(),306+ Timezone: fm.fields[4].Value(),
307+ Locale: fm.fields[5].Value(),
308+ Keymap: fm.fields[6].Value(),
290 }309 }
291 }310 }
292 311
M internal/interfaces/tui/views/form_view.go +2 -3
@@ -21,9 +21,8 @@ func RenderForm(fm *models.FormModelImpl) string {
21 labels := []string{21 labels := []string{
22 "Hostname:",22 "Hostname:",
23 "Username:",23 "Username:",
24- "User Password:",24+ "Email(opt):",
25- "Root Password:",25+ "Password:",
26- "Target Disk:",
27 "Timezone:",26 "Timezone:",
28 "Locale:",27 "Locale:",
29 "Keymap:",28 "Keymap:",
M internal/interfaces/tui/views/form_view_test.go +2 -2
@@ -83,8 +83,8 @@ func TestFormModelCreation(t *testing.T) {
83 83
84 // Verify initial state84 // Verify initial state
85 fields := fm.GetFields()85 fields := fm.GetFields()
86- if len(fields) != 8 {86+ if len(fields) != 7 {
87- t.Errorf("Expected 8 form fields, got %d", len(fields))87+ t.Errorf("Expected 7 form fields, got %d", len(fields))
88 }88 }
89 89
90 if fm.GetFocusIndex() != 0 {90 if fm.GetFocusIndex() != 0 {
M internal/interfaces/tui/views/summary_view.go +1 -3
@@ -15,9 +15,7 @@ func RenderSummary(im *models.InstallationModelImpl) string {
15 return RenderError(err)15 return RenderError(err)
16 }16 }
17 17
18- if !im.IsComplete() {18+ _ = im.IsComplete() // keep for potential future use
19- return RenderStatus(im)
20- }
21 19
22 var b strings.Builder20 var b strings.Builder
23 21
M internal/phases/base.go +14 -9
@@ -13,9 +13,8 @@ import (
13 // BaseInstallPhase handles base system installation13 // BaseInstallPhase handles base system installation
14 type BaseInstallPhase struct {14 type BaseInstallPhase struct {
15 *BasePhase15 *BasePhase
16- fs interfaces.FileSystem16+ fs interfaces.FileSystem
17- sysExec interfaces.SystemExecutor17+ sysExec interfaces.SystemExecutor
18- packages []string
19 }18 }
20 19
21 // NewBaseInstallPhase creates a new base installation phase20 // NewBaseInstallPhase creates a new base installation phase
@@ -60,8 +59,7 @@ func (p *BaseInstallPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResu
60 }59 }
61 60
62 // Step 2.5: Configure CachyOS repository on ISO if needed61 // Step 2.5: Configure CachyOS repository on ISO if needed
63- switch {62+ if p.config.KernelChoice == config.KernelLinuxCachyOS {
64- case p.config.KernelChoice == config.KernelLinuxCachyOS:
65 p.SendProgress(progressChan, "Configuring CachyOS repository...", 2, 5)63 p.SendProgress(progressChan, "Configuring CachyOS repository...", 2, 5)
66 switch err := p.configureCachyOSRepo(progressChan); {64 switch err := p.configureCachyOSRepo(progressChan); {
67 case err != nil:65 case err != nil:
@@ -263,7 +261,11 @@ func (p *BaseInstallPhase) detectCPU(progressChan chan<- ProgressUpdate) error {
263 case err != nil:261 case err != nil:
264 return fmt.Errorf("failed to read /proc/cpuinfo: %w", err)262 return fmt.Errorf("failed to read /proc/cpuinfo: %w", err)
265 }263 }
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+ }()
267 269
268 scanner := bufio.NewScanner(file)270 scanner := bufio.NewScanner(file)
269 var vendor string271 var vendor string
@@ -363,8 +365,7 @@ func (p *BaseInstallPhase) runPacstrap(progressChan chan<- ProgressUpdate) error
363 p.SendOutput(progressChan, fmt.Sprintf("[OK] Installed %d packages", len(packages)))365 p.SendOutput(progressChan, fmt.Sprintf("[OK] Installed %d packages", len(packages)))
364 366
365 // Copy CachyOS configuration to installed system if needed367 // Copy CachyOS configuration to installed system if needed
366- switch {368+ if p.config.KernelChoice == config.KernelLinuxCachyOS {
367- case p.config.KernelChoice == config.KernelLinuxCachyOS:
368 p.SendOutput(progressChan, "Configuring CachyOS repository in installed system...")369 p.SendOutput(progressChan, "Configuring CachyOS repository in installed system...")
369 if err := p.copyCachyOSConfig(progressChan); err != nil {370 if err := p.copyCachyOSConfig(progressChan); err != nil {
370 return fmt.Errorf("failed to configure CachyOS in installed system: %w", err)371 return fmt.Errorf("failed to configure CachyOS in installed system: %w", err)
@@ -385,7 +386,11 @@ func (p *BaseInstallPhase) loadBasePackages() ([]string, error) {
385 case err != nil:386 case err != nil:
386 return nil, fmt.Errorf("failed to open %s: %w", packageFile, err)387 return nil, fmt.Errorf("failed to open %s: %w", packageFile, err)
387 }388 }
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+ }()
389 394
390 var packages []string395 var packages []string
391 scanner := bufio.NewScanner(file)396 scanner := bufio.NewScanner(file)
M internal/phases/base_test.go +40 -8
@@ -51,7 +51,11 @@ func TestBaseInstallPhasePreCheck(t *testing.T) {
51 if err != nil {51 if err != nil {
52 t.Fatalf("Failed to create logger: %v", err)52 t.Fatalf("Failed to create logger: %v", err)
53 }53 }
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+ }()
55 59
56 cfg := config.NewConfig("test")60 cfg := config.NewConfig("test")
57 phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)61 phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
@@ -126,7 +130,11 @@ func TestBaseInstallPhaseDetectCPU(t *testing.T) {
126 if err != nil {130 if err != nil {
127 t.Fatalf("Failed to create logger: %v", err)131 t.Fatalf("Failed to create logger: %v", err)
128 }132 }
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+ }()
130 138
131 cfg := config.NewConfig("test")139 cfg := config.NewConfig("test")
132 phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)140 phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
@@ -208,7 +216,11 @@ func TestBaseInstallPhaseLoadBasePackages(t *testing.T) {
208 if err != nil {216 if err != nil {
209 t.Fatalf("Failed to create logger: %v", err)217 t.Fatalf("Failed to create logger: %v", err)
210 }218 }
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+ }()
212 224
213 cfg := config.NewConfig("test")225 cfg := config.NewConfig("test")
214 phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)226 phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
@@ -271,7 +283,11 @@ func TestBaseInstallPhaseGenerateFstab(t *testing.T) {
271 if err != nil {283 if err != nil {
272 t.Fatalf("Failed to create logger: %v", err)284 t.Fatalf("Failed to create logger: %v", err)
273 }285 }
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+ }()
275 291
276 cfg := config.NewConfig("test")292 cfg := config.NewConfig("test")
277 phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)293 phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
@@ -352,7 +368,11 @@ func TestBaseInstallPhasePostCheck(t *testing.T) {
352 if err != nil {368 if err != nil {
353 t.Fatalf("Failed to create logger: %v", err)369 t.Fatalf("Failed to create logger: %v", err)
354 }370 }
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+ }()
356 376
357 cfg := config.NewConfig("test")377 cfg := config.NewConfig("test")
358 cfg.ConfigPath = filepath.Join(tmpDir, "config")378 cfg.ConfigPath = filepath.Join(tmpDir, "config")
@@ -384,7 +404,11 @@ func TestBaseInstallPhaseExecute(t *testing.T) {
384 if err != nil {404 if err != nil {
385 t.Fatalf("Failed to create logger: %v", err)405 t.Fatalf("Failed to create logger: %v", err)
386 }406 }
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+ }()
388 412
389 ctrl := gomock.NewController(t)413 ctrl := gomock.NewController(t)
390 defer ctrl.Finish()414 defer ctrl.Finish()
@@ -422,7 +446,11 @@ func TestBaseInstallPhaseRollback(t *testing.T) {
422 if err != nil {446 if err != nil {
423 t.Fatalf("Failed to create logger: %v", err)447 t.Fatalf("Failed to create logger: %v", err)
424 }448 }
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+ }()
426 454
427 cfg := config.NewConfig("test")455 cfg := config.NewConfig("test")
428 phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)456 phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
@@ -448,7 +476,11 @@ func TestBaseInstallPhaseCanSkip(t *testing.T) {
448 if err != nil {476 if err != nil {
449 t.Fatalf("Failed to create logger: %v", err)477 t.Fatalf("Failed to create logger: %v", err)
450 }478 }
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+ }()
452 484
453 cfg := config.NewConfig("test")485 cfg := config.NewConfig("test")
454 phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)486 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
117 return fmt.Errorf("failed to write mkinitcpio.conf: %w", err)117 return fmt.Errorf("failed to write mkinitcpio.conf: %w", err)
118 }118 }
119 119
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+
120 // Regenerate initramfs131 // Regenerate initramfs
121 p.SendOutput(progressChan, "Regenerating initramfs...")132 p.SendOutput(progressChan, "Regenerating initramfs...")
122 if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "mkinitcpio -P"); err != nil {133 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 {
218 // Replace template variables229 // Replace template variables
219 limineConfig := string(templateContent)230 limineConfig := string(templateContent)
220 limineConfig = strings.ReplaceAll(limineConfig, "{{TIMEOUT}}", config.LimineTimeout)231 limineConfig = strings.ReplaceAll(limineConfig, "{{TIMEOUT}}", config.LimineTimeout)
221- limineConfig = strings.ReplaceAll(limineConfig, "{{DEFAULT_ENTRY}}", config.LimineEntry)
222 limineConfig = strings.ReplaceAll(limineConfig, "{{BRANDING}}", config.LimineBranding)232 limineConfig = strings.ReplaceAll(limineConfig, "{{BRANDING}}", config.LimineBranding)
223 limineConfig = strings.ReplaceAll(limineConfig, "{{COLOR}}", config.LimineColor)233 limineConfig = strings.ReplaceAll(limineConfig, "{{COLOR}}", config.LimineColor)
224 limineConfig = strings.ReplaceAll(limineConfig, "{{KERNEL}}", p.config.KernelChoice)234 limineConfig = strings.ReplaceAll(limineConfig, "{{KERNEL}}", p.config.KernelChoice)
@@ -283,13 +293,13 @@ func (p *BootPhase) PostCheck() error {
283 // Verify Limine bootloader exists293 // Verify Limine bootloader exists
284 bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)294 bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
285 if _, err := p.fs.Stat(bootloaderPath); p.fs.IsNotExist(err) {295 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")
287 }297 }
288 298
289 // Verify Limine config exists299 // Verify Limine config exists
290 configPath := config.PathMntBootLimineConf300 configPath := config.PathMntBootLimineConf
291 if _, err := p.fs.Stat(configPath); p.fs.IsNotExist(err) {301 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")
293 }303 }
294 304
295 return p.config.Save()305 return p.config.Save()
M internal/phases/boot_test.go +45 -9
@@ -64,7 +64,11 @@ func TestBootPhasePreCheck(t *testing.T) {
64 if err != nil {64 if err != nil {
65 t.Fatalf("Failed to create logger: %v", err)65 t.Fatalf("Failed to create logger: %v", err)
66 }66 }
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+ }()
68 72
69 cfg := config.NewConfig("test")73 cfg := config.NewConfig("test")
70 phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)74 phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
@@ -142,7 +146,11 @@ func TestBootPhaseExtractPartitionNumber(t *testing.T) {
142 if err != nil {146 if err != nil {
143 t.Fatalf("Failed to create logger: %v", err)147 t.Fatalf("Failed to create logger: %v", err)
144 }148 }
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+ }()
146 154
147 cfg := config.NewConfig("test")155 cfg := config.NewConfig("test")
148 phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)156 phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
@@ -220,7 +228,11 @@ func TestBootPhasePostCheck(t *testing.T) {
220 if err != nil {228 if err != nil {
221 t.Fatalf("Failed to create logger: %v", err)229 t.Fatalf("Failed to create logger: %v", err)
222 }230 }
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+ }()
224 236
225 cfg := config.NewConfig("test")237 cfg := config.NewConfig("test")
226 cfg.ConfigPath = filepath.Join(tmpDir, "config")238 cfg.ConfigPath = filepath.Join(tmpDir, "config")
@@ -281,7 +293,11 @@ func TestBootPhaseRollback(t *testing.T) {
281 if err != nil {293 if err != nil {
282 t.Fatalf("Failed to create logger: %v", err)294 t.Fatalf("Failed to create logger: %v", err)
283 }295 }
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+ }()
285 301
286 cfg := config.NewConfig("test")302 cfg := config.NewConfig("test")
287 phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)303 phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
@@ -309,7 +325,11 @@ func TestBootPhaseCanSkip(t *testing.T) {
309 if err != nil {325 if err != nil {
310 t.Fatalf("Failed to create logger: %v", err)326 t.Fatalf("Failed to create logger: %v", err)
311 }327 }
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+ }()
313 333
314 cfg := config.NewConfig("test")334 cfg := config.NewConfig("test")
315 phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)335 phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
@@ -341,7 +361,11 @@ func TestBootPhaseExecute(t *testing.T) {
341 if err != nil {361 if err != nil {
342 t.Fatalf("Failed to create logger: %v", err)362 t.Fatalf("Failed to create logger: %v", err)
343 }363 }
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+ }()
345 369
346 cfg := config.NewConfig("test")370 cfg := config.NewConfig("test")
347 cfg.TargetDisk = "/dev/sda"371 cfg.TargetDisk = "/dev/sda"
@@ -404,7 +428,11 @@ func TestBootPhaseKernelParameterConstruction(t *testing.T) {
404 if err != nil {428 if err != nil {
405 t.Fatalf("Failed to create logger: %v", err)429 t.Fatalf("Failed to create logger: %v", err)
406 }430 }
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+ }()
408 436
409 cfg := config.NewConfig("test")437 cfg := config.NewConfig("test")
410 cfg.EncryptionType = tt.encryptionType438 cfg.EncryptionType = tt.encryptionType
@@ -454,7 +482,11 @@ func TestBootPhaseInstallPathConstruction(t *testing.T) {
454 if err != nil {482 if err != nil {
455 t.Fatalf("Failed to create logger: %v", err)483 t.Fatalf("Failed to create logger: %v", err)
456 }484 }
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+ }()
458 490
459 cfg := config.NewConfig("test")491 cfg := config.NewConfig("test")
460 phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)492 phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
@@ -512,7 +544,11 @@ func TestBootPhaseFileSystemOperations(t *testing.T) {
512 if err != nil {544 if err != nil {
513 t.Fatalf("Failed to create logger: %v", err)545 t.Fatalf("Failed to create logger: %v", err)
514 }546 }
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+ }()
516 552
517 cfg := config.NewConfig("test")553 cfg := config.NewConfig("test")
518 phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)554 phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
M internal/phases/bootstrap.go +117 -3
@@ -4,7 +4,9 @@ import (
4 "fmt"4 "fmt"
5 "io"5 "io"
6 "net/http"6 "net/http"
7+ "os/exec"
7 "path/filepath"8 "path/filepath"
9+ "strings"
8 10
9 "github.com/bnema/archup/internal/config"11 "github.com/bnema/archup/internal/config"
10 "github.com/bnema/archup/internal/interfaces"12 "github.com/bnema/archup/internal/interfaces"
@@ -34,7 +36,9 @@ func (p *BootstrapPhase) PreCheck() error {
34 if err != nil {36 if err != nil {
35 return fmt.Errorf("no internet connectivity: %w", err)37 return fmt.Errorf("no internet connectivity: %w", err)
36 }38 }
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+ }
38 42
39 return nil43 return nil
40 }44 }
@@ -49,6 +53,17 @@ func (p *BootstrapPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult
49 return PhaseResult{Success: false, Error: err}53 return PhaseResult{Success: false, Error: err}
50 }54 }
51 55
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+
52 // Files to download67 // Files to download
53 files := []struct {68 files := []struct {
54 url string69 url string
@@ -123,6 +138,93 @@ func (p *BootstrapPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult
123 return PhaseResult{Success: true, Message: "All configuration files downloaded"}138 return PhaseResult{Success: true, Message: "All configuration files downloaded"}
124 }139 }
125 140
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+
126 // downloadFile downloads a file from URL to destPath228 // downloadFile downloads a file from URL to destPath
127 func (p *BootstrapPhase) downloadFile(url, destPath string) error {229 func (p *BootstrapPhase) downloadFile(url, destPath string) error {
128 // Create destination directory if needed230 // Create destination directory if needed
@@ -136,9 +238,11 @@ func (p *BootstrapPhase) downloadFile(url, destPath string) error {
136 if err != nil {238 if err != nil {
137 return fmt.Errorf("HTTP request failed: %w", err)239 return fmt.Errorf("HTTP request failed: %w", err)
138 }240 }
139- defer resp.Body.Close()
140 241
141 if resp.StatusCode != http.StatusOK {242 if resp.StatusCode != http.StatusOK {
243+ if closeErr := resp.Body.Close(); closeErr != nil {
244+ return fmt.Errorf("failed to close response: %w", closeErr)
245+ }
142 return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)246 return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
143 }247 }
144 248
@@ -147,12 +251,22 @@ func (p *BootstrapPhase) downloadFile(url, destPath string) error {
147 if err != nil {251 if err != nil {
148 return fmt.Errorf("failed to create file: %w", err)252 return fmt.Errorf("failed to create file: %w", err)
149 }253 }
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+ }()
151 259
152 // Copy content260 // Copy content
153 if _, err := io.Copy(destFile, resp.Body); err != nil {261 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+ }
154 return fmt.Errorf("failed to write file: %w", err)265 return fmt.Errorf("failed to write file: %w", err)
155 }266 }
267+ if closeErr := resp.Body.Close(); closeErr != nil {
268+ return fmt.Errorf("failed to close response: %w", closeErr)
269+ }
156 270
157 return nil271 return nil
158 }272 }
M internal/phases/bootstrap_test.go +21 -4
@@ -67,7 +67,11 @@ func TestBootstrapPhasePreCheck(t *testing.T) {
67 if err != nil {67 if err != nil {
68 t.Fatalf("Failed to create logger: %v", err)68 t.Fatalf("Failed to create logger: %v", err)
69 }69 }
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+ }()
71 75
72 cfg := config.NewConfig("test")76 cfg := config.NewConfig("test")
73 phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)77 phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
@@ -201,7 +205,11 @@ func TestBootstrapPhaseDownloadFile(t *testing.T) {
201 if err != nil {205 if err != nil {
202 t.Fatalf("Failed to create logger: %v", err)206 t.Fatalf("Failed to create logger: %v", err)
203 }207 }
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+ }()
205 213
206 cfg := config.NewConfig("test")214 cfg := config.NewConfig("test")
207 phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)215 phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
@@ -252,9 +260,14 @@ func TestBootstrapPhaseExecute(t *testing.T) {
252 if err != nil {260 if err != nil {
253 t.Fatalf("Failed to create logger: %v", err)261 t.Fatalf("Failed to create logger: %v", err)
254 }262 }
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+ }()
256 268
257 cfg := config.NewConfig("test")269 cfg := config.NewConfig("test")
270+ cfg.RepoURL = ""
258 cfg.RawURL = "https://example.com"271 cfg.RawURL = "https://example.com"
259 phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)272 phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
260 273
@@ -310,7 +323,11 @@ func TestBootstrapPhaseRollback(t *testing.T) {
310 if err != nil {323 if err != nil {
311 t.Fatalf("Failed to create logger: %v", err)324 t.Fatalf("Failed to create logger: %v", err)
312 }325 }
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+ }()
314 331
315 cfg := config.NewConfig("test")332 cfg := config.NewConfig("test")
316 phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)333 phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
M internal/phases/orchestrator_test.go +100 -34
@@ -84,7 +84,9 @@ func TestOrchestratorRegistration(t *testing.T) {
84 phase1 := newTestPhase("duplicate", cfg)84 phase1 := newTestPhase("duplicate", cfg)
85 phase2 := newTestPhase("duplicate", cfg)85 phase2 := newTestPhase("duplicate", cfg)
86 86
87- orch2.RegisterPhase(phase1)87+ if err := orch2.RegisterPhase(phase1); err != nil {
88+ t.Fatalf("expected no error, got %v", err)
89+ }
88 err := orch2.RegisterPhase(phase2)90 err := orch2.RegisterPhase(phase2)
89 if err == nil {91 if err == nil {
90 t.Error("Expected error for duplicate phase name, got nil")92 t.Error("Expected error for duplicate phase name, got nil")
@@ -98,7 +100,9 @@ func TestOrchestratorCurrentPhase(t *testing.T) {
98 orch := NewOrchestrator(cfg, "test.log")100 orch := NewOrchestrator(cfg, "test.log")
99 101
100 phase := newTestPhase("test", cfg)102 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+ }
102 106
103 // Before execution, current should be nil107 // Before execution, current should be nil
104 if orch.CurrentPhase() != nil {108 if orch.CurrentPhase() != nil {
@@ -106,7 +110,9 @@ func TestOrchestratorCurrentPhase(t *testing.T) {
106 }110 }
107 111
108 // After executing, current should be set112 // 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+ }
110 current := orch.CurrentPhase()116 current := orch.CurrentPhase()
111 if current == nil {117 if current == nil {
112 t.Error("CurrentPhase should not be nil after execution")118 t.Error("CurrentPhase should not be nil after execution")
@@ -125,9 +131,15 @@ func TestOrchestratorNextPhase(t *testing.T) {
125 phase2 := newTestPhase("phase-2", cfg)131 phase2 := newTestPhase("phase-2", cfg)
126 phase3 := newTestPhase("phase-3", cfg)132 phase3 := newTestPhase("phase-3", cfg)
127 133
128- orch.RegisterPhase(phase1)134+ if err := orch.RegisterPhase(phase1); err != nil {
129- orch.RegisterPhase(phase2)135+ t.Fatalf("expected no error, got %v", err)
130- orch.RegisterPhase(phase3)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+ }
131 143
132 // Next should be phase-1144 // Next should be phase-1
133 next := orch.NextPhase()145 next := orch.NextPhase()
@@ -169,7 +181,9 @@ func TestOrchestratorExecutePhaseSuccess(t *testing.T) {
169 orch := NewOrchestrator(cfg, "test.log")181 orch := NewOrchestrator(cfg, "test.log")
170 182
171 phase := newTestPhase("test", cfg)183 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+ }
173 187
174 err := orch.ExecutePhase(phase)188 err := orch.ExecutePhase(phase)
175 if err != nil {189 if err != nil {
@@ -188,7 +202,9 @@ func TestOrchestratorExecutePhasePreCheckFails(t *testing.T) {
188 202
189 phase := newTestPhase("test", cfg)203 phase := newTestPhase("test", cfg)
190 phase.preCheckErr = fmt.Errorf("precondition failed")204 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+ }
192 208
193 err := orch.ExecutePhase(phase)209 err := orch.ExecutePhase(phase)
194 if err == nil {210 if err == nil {
@@ -207,7 +223,9 @@ func TestOrchestratorExecutePhaseExecuteFails(t *testing.T) {
207 223
208 phase := newTestPhase("test", cfg)224 phase := newTestPhase("test", cfg)
209 phase.executeErr = fmt.Errorf("execution failed")225 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+ }
211 229
212 err := orch.ExecutePhase(phase)230 err := orch.ExecutePhase(phase)
213 if err == nil {231 if err == nil {
@@ -227,7 +245,9 @@ func TestOrchestratorExecutePhaseRollbackFails(t *testing.T) {
227 phase := newTestPhase("test", cfg)245 phase := newTestPhase("test", cfg)
228 phase.executeErr = fmt.Errorf("execution failed")246 phase.executeErr = fmt.Errorf("execution failed")
229 phase.rollbackErr = fmt.Errorf("rollback also failed")247 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+ }
231 251
232 err := orch.ExecutePhase(phase)252 err := orch.ExecutePhase(phase)
233 if err == nil {253 if err == nil {
@@ -246,7 +266,9 @@ func TestOrchestratorExecutePhasePostCheckFails(t *testing.T) {
246 266
247 phase := newTestPhase("test", cfg)267 phase := newTestPhase("test", cfg)
248 phase.postCheckErr = fmt.Errorf("validation failed")268 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+ }
250 272
251 err := orch.ExecutePhase(phase)273 err := orch.ExecutePhase(phase)
252 if err == nil {274 if err == nil {
@@ -266,7 +288,9 @@ func TestOrchestratorSkipPhase(t *testing.T) {
266 t.Run("skip skippable phase", func(t *testing.T) {288 t.Run("skip skippable phase", func(t *testing.T) {
267 phase := newTestPhase("skippable", cfg)289 phase := newTestPhase("skippable", cfg)
268 phase.canSkip = true290 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+ }
270 294
271 err := orch.SkipPhase(phase)295 err := orch.SkipPhase(phase)
272 if err != nil {296 if err != nil {
@@ -282,7 +306,9 @@ func TestOrchestratorSkipPhase(t *testing.T) {
282 orch2 := NewOrchestrator(cfg, "test.log")306 orch2 := NewOrchestrator(cfg, "test.log")
283 phase := newTestPhase("non-skippable", cfg)307 phase := newTestPhase("non-skippable", cfg)
284 phase.canSkip = false308 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+ }
286 312
287 err := orch2.SkipPhase(phase)313 err := orch2.SkipPhase(phase)
288 if err == nil {314 if err == nil {
@@ -300,9 +326,15 @@ func TestOrchestratorProgress(t *testing.T) {
300 phase2 := newTestPhase("phase-2", cfg)326 phase2 := newTestPhase("phase-2", cfg)
301 phase3 := newTestPhase("phase-3", cfg)327 phase3 := newTestPhase("phase-3", cfg)
302 328
303- orch.RegisterPhase(phase1)329+ if err := orch.RegisterPhase(phase1); err != nil {
304- orch.RegisterPhase(phase2)330+ t.Fatalf("expected no error, got %v", err)
305- orch.RegisterPhase(phase3)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+ }
306 338
307 // Initially all pending339 // Initially all pending
308 completed, total := orch.Progress()340 completed, total := orch.Progress()
@@ -317,7 +349,7 @@ func TestOrchestratorProgress(t *testing.T) {
317 phase1.SetStatus(StatusCompleted)349 phase1.SetStatus(StatusCompleted)
318 phase2.SetStatus(StatusSkipped)350 phase2.SetStatus(StatusSkipped)
319 351
320- completed, total = orch.Progress()352+ completed, _ = orch.Progress()
321 if completed != 2 {353 if completed != 2 {
322 t.Errorf("Completed = %d, want 2 (completed + skipped)", completed)354 t.Errorf("Completed = %d, want 2 (completed + skipped)", completed)
323 }355 }
@@ -331,7 +363,9 @@ func TestOrchestratorIsComplete(t *testing.T) {
331 orch := NewOrchestrator(cfg, "test.log")363 orch := NewOrchestrator(cfg, "test.log")
332 phase := newTestPhase("test", cfg)364 phase := newTestPhase("test", cfg)
333 phase.SetStatus(StatusCompleted)365 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+ }
335 369
336 if !orch.IsComplete() {370 if !orch.IsComplete() {
337 t.Error("IsComplete should be true when all phases completed")371 t.Error("IsComplete should be true when all phases completed")
@@ -342,7 +376,9 @@ func TestOrchestratorIsComplete(t *testing.T) {
342 orch := NewOrchestrator(cfg, "test.log")376 orch := NewOrchestrator(cfg, "test.log")
343 phase := newTestPhase("test", cfg)377 phase := newTestPhase("test", cfg)
344 phase.SetStatus(StatusSkipped)378 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+ }
346 382
347 if !orch.IsComplete() {383 if !orch.IsComplete() {
348 t.Error("IsComplete should be true when all phases skipped")384 t.Error("IsComplete should be true when all phases skipped")
@@ -352,7 +388,9 @@ func TestOrchestratorIsComplete(t *testing.T) {
352 t.Run("with pending", func(t *testing.T) {388 t.Run("with pending", func(t *testing.T) {
353 orch := NewOrchestrator(cfg, "test.log")389 orch := NewOrchestrator(cfg, "test.log")
354 phase := newTestPhase("test", cfg)390 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+ }
356 394
357 if orch.IsComplete() {395 if orch.IsComplete() {
358 t.Error("IsComplete should be false with pending phases")396 t.Error("IsComplete should be false with pending phases")
@@ -368,7 +406,9 @@ func TestOrchestratorHasFailed(t *testing.T) {
368 orch := NewOrchestrator(cfg, "test.log")406 orch := NewOrchestrator(cfg, "test.log")
369 phase := newTestPhase("test", cfg)407 phase := newTestPhase("test", cfg)
370 phase.SetStatus(StatusCompleted)408 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+ }
372 412
373 if orch.HasFailed() {413 if orch.HasFailed() {
374 t.Error("HasFailed should be false when no phases failed")414 t.Error("HasFailed should be false when no phases failed")
@@ -379,7 +419,9 @@ func TestOrchestratorHasFailed(t *testing.T) {
379 orch := NewOrchestrator(cfg, "test.log")419 orch := NewOrchestrator(cfg, "test.log")
380 phase := newTestPhase("test", cfg)420 phase := newTestPhase("test", cfg)
381 phase.SetStatus(StatusFailed)421 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+ }
383 425
384 if !orch.HasFailed() {426 if !orch.HasFailed() {
385 t.Error("HasFailed should be true when phase failed")427 t.Error("HasFailed should be true when phase failed")
@@ -398,11 +440,17 @@ func TestOrchestratorReset(t *testing.T) {
398 phase1.SetStatus(StatusCompleted)440 phase1.SetStatus(StatusCompleted)
399 phase2.SetStatus(StatusFailed)441 phase2.SetStatus(StatusFailed)
400 442
401- orch.RegisterPhase(phase1)443+ if err := orch.RegisterPhase(phase1); err != nil {
402- orch.RegisterPhase(phase2)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+ }
403 449
404 // Execute to set currentIdx450 // 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+ }
406 454
407 // Reset455 // Reset
408 orch.Reset()456 orch.Reset()
@@ -430,9 +478,15 @@ func TestOrchestratorExecuteAll(t *testing.T) {
430 phase2 := newTestPhase("phase-2", cfg)478 phase2 := newTestPhase("phase-2", cfg)
431 phase3 := newTestPhase("phase-3", cfg)479 phase3 := newTestPhase("phase-3", cfg)
432 480
433- orch.RegisterPhase(phase1)481+ if err := orch.RegisterPhase(phase1); err != nil {
434- orch.RegisterPhase(phase2)482+ t.Fatalf("expected no error, got %v", err)
435- orch.RegisterPhase(phase3)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+ }
436 490
437 err := orch.ExecuteAll()491 err := orch.ExecuteAll()
438 if err != nil {492 if err != nil {
@@ -463,9 +517,15 @@ func TestOrchestratorExecuteAllStopsOnFailure(t *testing.T) {
463 // Make phase-2 fail517 // Make phase-2 fail
464 phase2.executeErr = fmt.Errorf("execution failed")518 phase2.executeErr = fmt.Errorf("execution failed")
465 519
466- orch.RegisterPhase(phase1)520+ if err := orch.RegisterPhase(phase1); err != nil {
467- orch.RegisterPhase(phase2)521+ t.Fatalf("expected no error, got %v", err)
468- orch.RegisterPhase(phase3)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+ }
469 529
470 err := orch.ExecuteAll()530 err := orch.ExecuteAll()
471 if err == nil {531 if err == nil {
@@ -494,7 +554,9 @@ func TestOrchestratorExecuteNext(t *testing.T) {
494 orch := NewOrchestrator(cfg, "test.log")554 orch := NewOrchestrator(cfg, "test.log")
495 555
496 phase := newTestPhase("test", cfg)556 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+ }
498 560
499 err := orch.ExecuteNext()561 err := orch.ExecuteNext()
500 if err != nil {562 if err != nil {
@@ -513,7 +575,9 @@ func TestOrchestratorExecuteNextNoPending(t *testing.T) {
513 575
514 phase := newTestPhase("test", cfg)576 phase := newTestPhase("test", cfg)
515 phase.SetStatus(StatusCompleted)577 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+ }
517 581
518 err := orch.ExecuteNext()582 err := orch.ExecuteNext()
519 if err == nil {583 if err == nil {
@@ -527,7 +591,9 @@ func TestOrchestratorPhaseDurations(t *testing.T) {
527 orch := NewOrchestrator(cfg, "test.log")591 orch := NewOrchestrator(cfg, "test.log")
528 592
529 phase := newTestPhase("test", cfg)593 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+ }
531 597
532 err := orch.ExecutePhase(phase)598 err := orch.ExecutePhase(phase)
533 if err != nil {599 if err != nil {
M internal/phases/partitioning.go +3 -3
@@ -91,10 +91,10 @@ func (p *PartitioningPhase) createPartitions(progressChan chan<- ProgressUpdate)
91 91
92 p.SendOutput(progressChan, "Creating GPT partition table...")92 p.SendOutput(progressChan, "Creating GPT partition table...")
93 93
94- // Create partitions: 512MB EFI + remaining ROOT94+ // Create partitions: 4GB EFI + remaining ROOT (4GB recommended for limine-snapper-sync)
95 result = p.logger.ExecCommand("sgdisk",95 result = p.logger.ExecCommand("sgdisk",
96 "--clear",96 "--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",
98 "--new=2:0:0", "--typecode=2:8300", "--change-name=2:ROOT",98 "--new=2:0:0", "--typecode=2:8300", "--change-name=2:ROOT",
99 disk)99 disk)
100 100
@@ -122,7 +122,7 @@ func (p *PartitioningPhase) createPartitions(progressChan chan<- ProgressUpdate)
122 p.config.EFIPartition = efiPart122 p.config.EFIPartition = efiPart
123 p.config.RootPartition = rootPart123 p.config.RootPartition = rootPart
124 124
125- p.SendOutput(progressChan, fmt.Sprintf("[OK] EFI: %s (512MB)", efiPart))125+ p.SendOutput(progressChan, fmt.Sprintf("[OK] EFI: %s (4GB)", efiPart))
126 p.SendOutput(progressChan, fmt.Sprintf("[OK] Root: %s", rootPart))126 p.SendOutput(progressChan, fmt.Sprintf("[OK] Root: %s", rootPart))
127 127
128 return nil128 return nil
M internal/phases/partitioning_test.go +46 -9
@@ -54,7 +54,11 @@ func TestPartitioningPhasePreCheck(t *testing.T) {
54 if err != nil {54 if err != nil {
55 t.Fatalf("Failed to create logger: %v", err)55 t.Fatalf("Failed to create logger: %v", err)
56 }56 }
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+ }()
58 62
59 cfg := config.NewConfig("test")63 cfg := config.NewConfig("test")
60 cfg.TargetDisk = tt.targetDisk64 cfg.TargetDisk = tt.targetDisk
@@ -116,7 +120,11 @@ func TestPartitioningPhasePartitionNaming(t *testing.T) {
116 if err != nil {120 if err != nil {
117 t.Fatalf("Failed to create logger: %v", err)121 t.Fatalf("Failed to create logger: %v", err)
118 }122 }
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+ }()
120 128
121 cfg := config.NewConfig("test")129 cfg := config.NewConfig("test")
122 cfg.TargetDisk = tt.targetDisk130 cfg.TargetDisk = tt.targetDisk
@@ -175,7 +183,11 @@ func TestPartitioningPhaseFormatRoot(t *testing.T) {
175 if err != nil {183 if err != nil {
176 t.Fatalf("Failed to create logger: %v", err)184 t.Fatalf("Failed to create logger: %v", err)
177 }185 }
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+ }()
179 191
180 cfg := config.NewConfig("test")192 cfg := config.NewConfig("test")
181 cfg.TargetDisk = "/dev/sda"193 cfg.TargetDisk = "/dev/sda"
@@ -235,7 +247,11 @@ func TestPartitioningPhaseEncryptionValidation(t *testing.T) {
235 if err != nil {247 if err != nil {
236 t.Fatalf("Failed to create logger: %v", err)248 t.Fatalf("Failed to create logger: %v", err)
237 }249 }
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+ }()
239 255
240 cfg := config.NewConfig("test")256 cfg := config.NewConfig("test")
241 cfg.TargetDisk = "/dev/sda"257 cfg.TargetDisk = "/dev/sda"
@@ -252,6 +268,7 @@ func TestPartitioningPhaseEncryptionValidation(t *testing.T) {
252 // Empty password for encryption - should fail in actual execution268 // Empty password for encryption - should fail in actual execution
253 if phase.config.EncryptionType != config.EncryptionNone {269 if phase.config.EncryptionType != config.EncryptionNone {
254 // Password is required for encryption270 // Password is required for encryption
271+ t.Log("password required for encryption")
255 }272 }
256 }273 }
257 }274 }
@@ -299,7 +316,11 @@ func TestPartitioningPhasePostCheck(t *testing.T) {
299 if err != nil {316 if err != nil {
300 t.Fatalf("Failed to create logger: %v", err)317 t.Fatalf("Failed to create logger: %v", err)
301 }318 }
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+ }()
303 324
304 cfg := config.NewConfig("test")325 cfg := config.NewConfig("test")
305 cfg.ConfigPath = filepath.Join(tmpDir, "config")326 cfg.ConfigPath = filepath.Join(tmpDir, "config")
@@ -380,7 +401,11 @@ func TestPartitioningPhaseRollback(t *testing.T) {
380 if err != nil {401 if err != nil {
381 t.Fatalf("Failed to create logger: %v", err)402 t.Fatalf("Failed to create logger: %v", err)
382 }403 }
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+ }()
384 409
385 cfg := config.NewConfig("test")410 cfg := config.NewConfig("test")
386 cfg.EncryptionType = tt.encryption411 cfg.EncryptionType = tt.encryption
@@ -409,7 +434,11 @@ func TestPartitioningPhaseCanSkip(t *testing.T) {
409 if err != nil {434 if err != nil {
410 t.Fatalf("Failed to create logger: %v", err)435 t.Fatalf("Failed to create logger: %v", err)
411 }436 }
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+ }()
413 442
414 cfg := config.NewConfig("test")443 cfg := config.NewConfig("test")
415 phase := NewPartitioningPhase(cfg, log, mockSysExec)444 phase := NewPartitioningPhase(cfg, log, mockSysExec)
@@ -432,7 +461,11 @@ func TestPartitioningPhaseExecute(t *testing.T) {
432 if err != nil {461 if err != nil {
433 t.Fatalf("Failed to create logger: %v", err)462 t.Fatalf("Failed to create logger: %v", err)
434 }463 }
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+ }()
436 469
437 cfg := config.NewConfig("test")470 cfg := config.NewConfig("test")
438 cfg.TargetDisk = "/dev/sda"471 cfg.TargetDisk = "/dev/sda"
@@ -497,7 +530,11 @@ func TestPartitioningPhaseDeviceHandling(t *testing.T) {
497 if err != nil {530 if err != nil {
498 t.Fatalf("Failed to create logger: %v", err)531 t.Fatalf("Failed to create logger: %v", err)
499 }532 }
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+ }()
501 538
502 cfg := config.NewConfig("test")539 cfg := config.NewConfig("test")
503 cfg.EncryptionType = tt.encryptionType540 cfg.EncryptionType = tt.encryptionType
M internal/phases/phase_test.go +55 -11
@@ -42,7 +42,11 @@ func TestNewBasePhase(t *testing.T) {
42 if err != nil {42 if err != nil {
43 t.Fatalf("Failed to create logger: %v", err)43 t.Fatalf("Failed to create logger: %v", err)
44 }44 }
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+ }()
46 50
47 cfg := config.NewConfig("test")51 cfg := config.NewConfig("test")
48 52
@@ -75,7 +79,11 @@ func TestBasePhaseSetStatus(t *testing.T) {
75 logPath := filepath.Join(tmpDir, "test.log")79 logPath := filepath.Join(tmpDir, "test.log")
76 80
77 log, _ := logger.New(logPath, false)81 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+ }()
79 87
80 cfg := config.NewConfig("test")88 cfg := config.NewConfig("test")
81 phase := NewBasePhase("test", "test", cfg, log)89 phase := NewBasePhase("test", "test", cfg, log)
@@ -104,7 +112,11 @@ func TestBasePhaseDefaultPreCheck(t *testing.T) {
104 logPath := filepath.Join(tmpDir, "test.log")112 logPath := filepath.Join(tmpDir, "test.log")
105 113
106 log, _ := logger.New(logPath, false)114 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+ }()
108 120
109 tests := []struct {121 tests := []struct {
110 name string122 name string
@@ -141,7 +153,11 @@ func TestBasePhaseDefaultPostCheck(t *testing.T) {
141 logPath := filepath.Join(tmpDir, "test.log")153 logPath := filepath.Join(tmpDir, "test.log")
142 154
143 log, _ := logger.New(logPath, false)155 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+ }()
145 161
146 cfg := config.NewConfig("test")162 cfg := config.NewConfig("test")
147 phase := NewBasePhase("test", "test", cfg, log)163 phase := NewBasePhase("test", "test", cfg, log)
@@ -158,7 +174,11 @@ func TestBasePhaseDefaultRollback(t *testing.T) {
158 logPath := filepath.Join(tmpDir, "test.log")174 logPath := filepath.Join(tmpDir, "test.log")
159 175
160 log, _ := logger.New(logPath, false)176 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+ }()
162 182
163 cfg := config.NewConfig("test")183 cfg := config.NewConfig("test")
164 phase := NewBasePhase("my-phase", "test", cfg, log)184 phase := NewBasePhase("my-phase", "test", cfg, log)
@@ -180,7 +200,11 @@ func TestBasePhaseDefaultCanSkip(t *testing.T) {
180 logPath := filepath.Join(tmpDir, "test.log")200 logPath := filepath.Join(tmpDir, "test.log")
181 201
182 log, _ := logger.New(logPath, false)202 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+ }()
184 208
185 cfg := config.NewConfig("test")209 cfg := config.NewConfig("test")
186 phase := NewBasePhase("test", "test", cfg, log)210 phase := NewBasePhase("test", "test", cfg, log)
@@ -196,7 +220,11 @@ func TestSendProgress(t *testing.T) {
196 logPath := filepath.Join(tmpDir, "test.log")220 logPath := filepath.Join(tmpDir, "test.log")
197 221
198 log, _ := logger.New(logPath, false)222 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+ }()
200 228
201 cfg := config.NewConfig("test")229 cfg := config.NewConfig("test")
202 phase := NewBasePhase("test-phase", "test", cfg, log)230 phase := NewBasePhase("test-phase", "test", cfg, log)
@@ -237,7 +265,11 @@ func TestSendProgressWithNilChannel(t *testing.T) {
237 logPath := filepath.Join(tmpDir, "test.log")265 logPath := filepath.Join(tmpDir, "test.log")
238 266
239 log, _ := logger.New(logPath, false)267 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+ }()
241 273
242 cfg := config.NewConfig("test")274 cfg := config.NewConfig("test")
243 phase := NewBasePhase("test", "test", cfg, log)275 phase := NewBasePhase("test", "test", cfg, log)
@@ -252,7 +284,11 @@ func TestSendComplete(t *testing.T) {
252 logPath := filepath.Join(tmpDir, "test.log")284 logPath := filepath.Join(tmpDir, "test.log")
253 285
254 log, _ := logger.New(logPath, false)286 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+ }()
256 292
257 cfg := config.NewConfig("test")293 cfg := config.NewConfig("test")
258 phase := NewBasePhase("test-phase", "test", cfg, log)294 phase := NewBasePhase("test-phase", "test", cfg, log)
@@ -279,7 +315,11 @@ func TestSendError(t *testing.T) {
279 logPath := filepath.Join(tmpDir, "test.log")315 logPath := filepath.Join(tmpDir, "test.log")
280 316
281 log, _ := logger.New(logPath, false)317 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+ }()
283 323
284 cfg := config.NewConfig("test")324 cfg := config.NewConfig("test")
285 phase := NewBasePhase("test-phase", "test", cfg, log)325 phase := NewBasePhase("test-phase", "test", cfg, log)
@@ -308,7 +348,11 @@ func TestSendOutput(t *testing.T) {
308 logPath := filepath.Join(tmpDir, "test.log")348 logPath := filepath.Join(tmpDir, "test.log")
309 349
310 log, _ := logger.New(logPath, false)350 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+ }()
312 356
313 cfg := config.NewConfig("test")357 cfg := config.NewConfig("test")
314 phase := NewBasePhase("test-phase", "test", cfg, log)358 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
154 case err != nil:154 case err != nil:
155 return fmt.Errorf("failed to download logo: %w", err)155 return fmt.Errorf("failed to download logo: %w", err)
156 }156 }
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+ }()
158 162
159 switch {163 switch {
160 case resp.StatusCode != http.StatusOK:164 case resp.StatusCode != http.StatusOK:
@@ -167,7 +171,11 @@ func (p *PostInstallPhase) installBootLogo(progressChan chan<- ProgressUpdate) e
167 case err != nil:171 case err != nil:
168 return fmt.Errorf("failed to create logo file: %w", err)172 return fmt.Errorf("failed to create logo file: %w", err)
169 }173 }
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+ }()
171 179
172 _, err = io.Copy(logoFile, resp.Body)180 _, err = io.Copy(logoFile, resp.Body)
173 switch {181 switch {
@@ -231,7 +239,9 @@ func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate)
231 239
232 switch {240 switch {
233 case resp.StatusCode != http.StatusOK:241 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+ }
235 return fmt.Errorf("failed to download %s: HTTP %d", filename, resp.StatusCode)245 return fmt.Errorf("failed to download %s: HTTP %d", filename, resp.StatusCode)
236 }246 }
237 247
@@ -240,13 +250,19 @@ func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate)
240 destFile, err := p.fs.Create(destPath)250 destFile, err := p.fs.Create(destPath)
241 switch {251 switch {
242 case err != nil:252 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+ }
244 return fmt.Errorf("failed to create %s: %w", filename, err)256 return fmt.Errorf("failed to create %s: %w", filename, err)
245 }257 }
246 258
247 _, err = io.Copy(destFile, resp.Body)259 _, err = io.Copy(destFile, resp.Body)
248- destFile.Close()260+ if closeErr := destFile.Close(); closeErr != nil {
249- resp.Body.Close()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+ }
250 266
251 switch {267 switch {
252 case err != nil:268 case err != nil:
@@ -312,7 +328,7 @@ func (p *PostInstallPhase) configureSnapper(progressChan chan<- ProgressUpdate)
312 }328 }
313 329
314 // Create Limine snapper config330 // Create Limine snapper config
315- limineDefaultConfig := fmt.Sprintf(`TARGET_OS_NAME="ArchUp"331+ limineDefaultConfig := fmt.Sprintf(`TARGET_OS_NAME="%s"
316 332
317 ESP_PATH="/boot"333 ESP_PATH="/boot"
318 334
@@ -328,7 +344,7 @@ BOOT_ORDER="*, *fallback, Snapshots"
328 MAX_SNAPSHOT_ENTRIES=5344 MAX_SNAPSHOT_ENTRIES=5
329 345
330 SNAPSHOT_FORMAT_CHOICE=5346 SNAPSHOT_FORMAT_CHOICE=5
331-`, cmdline)347+`, config.LimineBranding, cmdline)
332 348
333 switch err := p.fs.WriteFile(config.PathMntEtcDefaultLimine, []byte(limineDefaultConfig), 0644); {349 switch err := p.fs.WriteFile(config.PathMntEtcDefaultLimine, []byte(limineDefaultConfig), 0644); {
334 case err != nil:350 case err != nil:
@@ -515,7 +531,6 @@ func (p *PostInstallPhase) configureShell(progressChan chan<- ProgressUpdate) er
515 case err != nil:531 case err != nil:
516 // Non-fatal532 // Non-fatal
517 p.SendOutput(progressChan, "[WARN] Failed to configure git delta")533 p.SendOutput(progressChan, "[WARN] Failed to configure git delta")
518- break
519 }534 }
520 }535 }
521 536
@@ -575,7 +590,9 @@ func (p *PostInstallPhase) applyThemes(progressChan chan<- ProgressUpdate, usern
575 p.SendOutput(progressChan, "Configuring starship theme...")590 p.SendOutput(progressChan, "Configuring starship theme...")
576 starshipCmd := fmt.Sprintf("su - %s -c 'cp %s/starship/bleu.toml %s/starship.toml'",591 starshipCmd := fmt.Sprintf("su - %s -c 'cp %s/starship/bleu.toml %s/starship.toml'",
577 username, themeDir, archupDefault)592 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+ }
579 596
580 // Eza theme597 // Eza theme
581 p.SendOutput(progressChan, "Configuring eza theme...")598 p.SendOutput(progressChan, "Configuring eza theme...")
@@ -585,7 +602,9 @@ func (p *PostInstallPhase) applyThemes(progressChan chan<- ProgressUpdate, usern
585 fmt.Sprintf("su - %s -c 'cp %s/eza/theme.yml %s/'", username, themeDir, ezaConfigDir),602 fmt.Sprintf("su - %s -c 'cp %s/eza/theme.yml %s/'", username, themeDir, ezaConfigDir),
586 }603 }
587 for _, cmd := range ezaCommands {604 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+ }
589 }608 }
590 609
591 // Bat theme610 // Bat theme
@@ -597,7 +616,9 @@ func (p *PostInstallPhase) applyThemes(progressChan chan<- ProgressUpdate, usern
597 fmt.Sprintf("su - %s -c 'bat cache --build'", username),616 fmt.Sprintf("su - %s -c 'bat cache --build'", username),
598 }617 }
599 for _, cmd := range batCommands {618 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+ }
601 }622 }
602 623
603 // Btop theme624 // Btop theme
@@ -608,7 +629,9 @@ func (p *PostInstallPhase) applyThemes(progressChan chan<- ProgressUpdate, usern
608 fmt.Sprintf("su - %s -c 'cp %s/btop/bleu.theme %s/'", username, themeDir, btopThemeDir),629 fmt.Sprintf("su - %s -c 'cp %s/btop/bleu.theme %s/'", username, themeDir, btopThemeDir),
609 }630 }
610 for _, cmd := range btopCommands {631 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+ }
612 }635 }
613 636
614 // Yazi theme637 // Yazi theme
@@ -619,7 +642,9 @@ func (p *PostInstallPhase) applyThemes(progressChan chan<- ProgressUpdate, usern
619 fmt.Sprintf("su - %s -c 'cp %s/yazi/bleu.toml %s/flavor.toml'", username, themeDir, yaziFlavorDir),642 fmt.Sprintf("su - %s -c 'cp %s/yazi/bleu.toml %s/flavor.toml'", username, themeDir, yaziFlavorDir),
620 }643 }
621 for _, cmd := range yaziCommands {644 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+ }
623 }648 }
624 649
625 p.SendOutput(progressChan, "[OK] CLI themes configured")650 p.SendOutput(progressChan, "[OK] CLI themes configured")
@@ -764,7 +789,9 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
764 case err != nil:789 case err != nil:
765 return fmt.Errorf("failed to download logo.txt: %w", err)790 return fmt.Errorf("failed to download logo.txt: %w", err)
766 case resp.StatusCode != http.StatusOK:791 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+ }
768 return fmt.Errorf("failed to download logo.txt: HTTP %d", resp.StatusCode)795 return fmt.Errorf("failed to download logo.txt: HTTP %d", resp.StatusCode)
769 }796 }
770 797
@@ -772,13 +799,19 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
772 logoFile, err := p.fs.Create(logoPath)799 logoFile, err := p.fs.Create(logoPath)
773 switch {800 switch {
774 case err != nil:801 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+ }
776 return fmt.Errorf("failed to create logo.txt: %w", err)805 return fmt.Errorf("failed to create logo.txt: %w", err)
777 }806 }
778 807
779 _, err = io.Copy(logoFile, resp.Body)808 _, err = io.Copy(logoFile, resp.Body)
780- logoFile.Close()809+ if closeErr := logoFile.Close(); closeErr != nil {
781- resp.Body.Close()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+ }
782 switch {815 switch {
783 case err != nil:816 case err != nil:
784 return fmt.Errorf("failed to save logo.txt: %w", err)817 return fmt.Errorf("failed to save logo.txt: %w", err)
@@ -794,7 +827,9 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
794 case err != nil:827 case err != nil:
795 return fmt.Errorf("failed to download %s: %w", script, err)828 return fmt.Errorf("failed to download %s: %w", script, err)
796 case resp.StatusCode != http.StatusOK:829 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+ }
798 return fmt.Errorf("failed to download %s: HTTP %d", script, resp.StatusCode)833 return fmt.Errorf("failed to download %s: HTTP %d", script, resp.StatusCode)
799 }834 }
800 835
@@ -802,13 +837,19 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
802 scriptFile, err := p.fs.Create(scriptPath)837 scriptFile, err := p.fs.Create(scriptPath)
803 switch {838 switch {
804 case err != nil:839 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+ }
806 return fmt.Errorf("failed to create %s: %w", script, err)843 return fmt.Errorf("failed to create %s: %w", script, err)
807 }844 }
808 845
809 _, err = io.Copy(scriptFile, resp.Body)846 _, err = io.Copy(scriptFile, resp.Body)
810- scriptFile.Close()847+ if closeErr := scriptFile.Close(); closeErr != nil {
811- resp.Body.Close()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+ }
812 853
813 switch {854 switch {
814 case err != nil:855 case err != nil:
@@ -831,12 +872,16 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
831 case err != nil:872 case err != nil:
832 return fmt.Errorf("failed to download service template: %w", err)873 return fmt.Errorf("failed to download service template: %w", err)
833 case resp.StatusCode != http.StatusOK:874 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+ }
835 return fmt.Errorf("failed to download service template: HTTP %d", resp.StatusCode)878 return fmt.Errorf("failed to download service template: HTTP %d", resp.StatusCode)
836 }879 }
837 880
838 templateBytes, err := io.ReadAll(resp.Body)881 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+ }
840 switch {885 switch {
841 case err != nil:886 case err != nil:
842 return fmt.Errorf("failed to read service template: %w", err)887 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) {
67 if err != nil {67 if err != nil {
68 t.Fatalf("Failed to create logger: %v", err)68 t.Fatalf("Failed to create logger: %v", err)
69 }69 }
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+ }()
71 75
72 cfg := config.NewConfig("test")76 cfg := config.NewConfig("test")
73 phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)77 phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
@@ -123,7 +127,11 @@ func TestPostInstallPhaseShellConfiguration(t *testing.T) {
123 if err != nil {127 if err != nil {
124 t.Fatalf("Failed to create logger: %v", err)128 t.Fatalf("Failed to create logger: %v", err)
125 }129 }
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+ }()
127 135
128 cfg := config.NewConfig("test")136 cfg := config.NewConfig("test")
129 phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)137 phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
@@ -152,7 +160,11 @@ func TestPostInstallPhasePostCheck(t *testing.T) {
152 if err != nil {160 if err != nil {
153 t.Fatalf("Failed to create logger: %v", err)161 t.Fatalf("Failed to create logger: %v", err)
154 }162 }
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+ }()
156 168
157 cfg := config.NewConfig("test")169 cfg := config.NewConfig("test")
158 cfg.ConfigPath = filepath.Join(tmpDir, "config")170 cfg.ConfigPath = filepath.Join(tmpDir, "config")
@@ -182,7 +194,11 @@ func TestPostInstallPhaseRollback(t *testing.T) {
182 if err != nil {194 if err != nil {
183 t.Fatalf("Failed to create logger: %v", err)195 t.Fatalf("Failed to create logger: %v", err)
184 }196 }
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+ }()
186 202
187 cfg := config.NewConfig("test")203 cfg := config.NewConfig("test")
188 phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)204 phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
@@ -210,7 +226,11 @@ func TestPostInstallPhaseCanSkip(t *testing.T) {
210 if err != nil {226 if err != nil {
211 t.Fatalf("Failed to create logger: %v", err)227 t.Fatalf("Failed to create logger: %v", err)
212 }228 }
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+ }()
214 234
215 cfg := config.NewConfig("test")235 cfg := config.NewConfig("test")
216 phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)236 phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
@@ -250,7 +270,11 @@ func TestPostInstallPhaseExecute(t *testing.T) {
250 if err != nil {270 if err != nil {
251 t.Fatalf("Failed to create logger: %v", err)271 t.Fatalf("Failed to create logger: %v", err)
252 }272 }
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+ }()
254 278
255 cfg := config.NewConfig("test")279 cfg := config.NewConfig("test")
256 cfg.KernelChoice = "linux"280 cfg.KernelChoice = "linux"
@@ -307,7 +331,11 @@ func TestPostInstallPhaseEncryptionHandling(t *testing.T) {
307 if err != nil {331 if err != nil {
308 t.Fatalf("Failed to create logger: %v", err)332 t.Fatalf("Failed to create logger: %v", err)
309 }333 }
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+ }()
311 339
312 cfg := config.NewConfig("test")340 cfg := config.NewConfig("test")
313 cfg.EncryptionType = tt.encryptionType341 cfg.EncryptionType = tt.encryptionType
@@ -380,7 +408,11 @@ func TestPostInstallPhaseFileSystemOperations(t *testing.T) {
380 if err != nil {408 if err != nil {
381 t.Fatalf("Failed to create logger: %v", err)409 t.Fatalf("Failed to create logger: %v", err)
382 }410 }
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+ }()
384 416
385 cfg := config.NewConfig("test")417 cfg := config.NewConfig("test")
386 phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)418 phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
@@ -433,7 +465,11 @@ func TestPostInstallPhaseKernelVerification(t *testing.T) {
433 if err != nil {465 if err != nil {
434 t.Fatalf("Failed to create logger: %v", err)466 t.Fatalf("Failed to create logger: %v", err)
435 }467 }
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+ }()
437 473
438 cfg := config.NewConfig("test")474 cfg := config.NewConfig("test")
439 cfg.KernelChoice = tt.kernelChoice475 cfg.KernelChoice = tt.kernelChoice
M internal/phases/preflight.go +1 -1
@@ -66,7 +66,7 @@ func (p *PreflightPhase) PreCheck() error {
66 out, err = p.cmdExec.Execute("bootctl", "status")66 out, err = p.cmdExec.Execute("bootctl", "status")
67 switch {67 switch {
68 case err == nil && strings.Contains(string(out), "Secure Boot: enabled"):68 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")
70 }70 }
71 71
72 return nil72 return nil
M internal/phases/preflight_test.go +31 -7
@@ -120,7 +120,7 @@ func TestPreflightPhasePreCheck(t *testing.T) {
120 mockCmd.EXPECT().Execute("bootctl", "status").Return([]byte("Secure Boot: enabled\n"), nil).Times(1)120 mockCmd.EXPECT().Execute("bootctl", "status").Return([]byte("Secure Boot: enabled\n"), nil).Times(1)
121 },121 },
122 wantErr: true,122 wantErr: true,
123- errContains: "Secure Boot must be disabled",123+ errContains: "secure boot must be disabled",
124 },124 },
125 {125 {
126 name: "Secure Boot disabled - all checks pass",126 name: "Secure Boot disabled - all checks pass",
@@ -172,7 +172,11 @@ func TestPreflightPhasePreCheck(t *testing.T) {
172 if err != nil {172 if err != nil {
173 t.Fatalf("Failed to create logger: %v", err)173 t.Fatalf("Failed to create logger: %v", err)
174 }174 }
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+ }()
176 180
177 cfg := config.NewConfig("test")181 cfg := config.NewConfig("test")
178 phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)182 phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
@@ -249,7 +253,11 @@ func TestPreflightPhaseSetDefaults(t *testing.T) {
249 if err != nil {253 if err != nil {
250 t.Fatalf("Failed to create logger: %v", err)254 t.Fatalf("Failed to create logger: %v", err)
251 }255 }
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+ }()
253 261
254 cfg := config.NewConfig("test")262 cfg := config.NewConfig("test")
255 cfg.Hostname = tt.initialHostname263 cfg.Hostname = tt.initialHostname
@@ -336,7 +344,11 @@ func TestPreflightPhasePostCheck(t *testing.T) {
336 if err != nil {344 if err != nil {
337 t.Fatalf("Failed to create logger: %v", err)345 t.Fatalf("Failed to create logger: %v", err)
338 }346 }
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+ }()
340 352
341 cfg := config.NewConfig("test")353 cfg := config.NewConfig("test")
342 cfg.Username = tt.username354 cfg.Username = tt.username
@@ -374,7 +386,11 @@ func TestPreflightPhaseExecute(t *testing.T) {
374 if err != nil {386 if err != nil {
375 t.Fatalf("Failed to create logger: %v", err)387 t.Fatalf("Failed to create logger: %v", err)
376 }388 }
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+ }()
378 394
379 cfg := config.NewConfig("test")395 cfg := config.NewConfig("test")
380 phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)396 phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
@@ -415,7 +431,11 @@ func TestPreflightPhaseRollback(t *testing.T) {
415 if err != nil {431 if err != nil {
416 t.Fatalf("Failed to create logger: %v", err)432 t.Fatalf("Failed to create logger: %v", err)
417 }433 }
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+ }()
419 439
420 cfg := config.NewConfig("test")440 cfg := config.NewConfig("test")
421 phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)441 phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
@@ -440,7 +460,11 @@ func TestPreflightPhaseCanSkip(t *testing.T) {
440 if err != nil {460 if err != nil {
441 t.Fatalf("Failed to create logger: %v", err)461 t.Fatalf("Failed to create logger: %v", err)
442 }462 }
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+ }()
444 468
445 cfg := config.NewConfig("test")469 cfg := config.NewConfig("test")
446 phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)470 phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
M internal/phases/repos.go +10 -2
@@ -225,7 +225,6 @@ func (p *ReposPhase) loadChaoticConfig() error {
225 if err != nil {225 if err != nil {
226 return fmt.Errorf("failed to open chaotic config: %w", err)226 return fmt.Errorf("failed to open chaotic config: %w", err)
227 }227 }
228- defer file.Close()
229 228
230 scanner := bufio.NewScanner(file)229 scanner := bufio.NewScanner(file)
231 for scanner.Scan() {230 for scanner.Scan() {
@@ -247,9 +246,14 @@ func (p *ReposPhase) loadChaoticConfig() error {
247 }246 }
248 247
249 if err := scanner.Err(); err != nil {248 if err := scanner.Err(); err != nil {
249+ _ = file.Close()
250 return fmt.Errorf("error reading chaotic config: %w", err)250 return fmt.Errorf("error reading chaotic config: %w", err)
251 }251 }
252 252
253+ if err := file.Close(); err != nil {
254+ return fmt.Errorf("failed to close chaotic config: %w", err)
255+ }
256+
253 return nil257 return nil
254 }258 }
255 259
@@ -459,7 +463,6 @@ func (p *ReposPhase) loadExtraPackages() ([]string, error) {
459 if err != nil {463 if err != nil {
460 return nil, fmt.Errorf("failed to open %s: %w", packageFile, err)464 return nil, fmt.Errorf("failed to open %s: %w", packageFile, err)
461 }465 }
462- defer file.Close()
463 466
464 var packages []string467 var packages []string
465 scanner := bufio.NewScanner(file)468 scanner := bufio.NewScanner(file)
@@ -479,9 +482,14 @@ func (p *ReposPhase) loadExtraPackages() ([]string, error) {
479 }482 }
480 483
481 if err := scanner.Err(); err != nil {484 if err := scanner.Err(); err != nil {
485+ _ = file.Close()
482 return nil, fmt.Errorf("error reading package file: %w", err)486 return nil, fmt.Errorf("error reading package file: %w", err)
483 }487 }
484 488
489+ if err := file.Close(); err != nil {
490+ return nil, fmt.Errorf("failed to close %s: %w", packageFile, err)
491+ }
492+
485 return packages, nil493 return packages, nil
486 }494 }
487 495
M internal/phases/repos_test.go +16 -5
@@ -157,6 +157,7 @@ Include = /etc/pacman.d/mirrorlist`,
157 lines := strings.Split(s, "\n")157 lines := strings.Split(s, "\n")
158 var inMultilib bool158 var inMultilib bool
159 var foundInclude int159 var foundInclude int
160+ var commentedInclude int
160 for _, line := range lines {161 for _, line := range lines {
161 trimmed := strings.TrimSpace(line)162 trimmed := strings.TrimSpace(line)
162 if trimmed == "[multilib]" {163 if trimmed == "[multilib]" {
@@ -165,8 +166,10 @@ Include = /etc/pacman.d/mirrorlist`,
165 foundInclude++166 foundInclude++
166 } else if inMultilib && strings.HasPrefix(trimmed, "#Include") {167 } else if inMultilib && strings.HasPrefix(trimmed, "#Include") {
167 // Still commented - that's another Include168 // Still commented - that's another Include
169+ commentedInclude++
168 }170 }
169 }171 }
172+ _ = commentedInclude
170 return foundInclude >= 1173 return foundInclude >= 1
171 },174 },
172 },175 },
@@ -286,7 +289,11 @@ func TestReposPhaseStructure(t *testing.T) {
286 if err != nil {289 if err != nil {
287 t.Fatalf("Failed to create logger: %v", err)290 t.Fatalf("Failed to create logger: %v", err)
288 }291 }
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+ }()
290 297
291 // Create config298 // Create config
292 cfg := config.NewConfig("test")299 cfg := config.NewConfig("test")
@@ -301,15 +308,15 @@ func TestReposPhaseStructure(t *testing.T) {
301 308
302 // Verify initialization309 // Verify initialization
303 if phase == nil {310 if phase == nil {
304- t.Error("ReposPhase is nil")311+ t.Fatal("ReposPhase is nil")
305 }312 }
306 313
307 if phase.BasePhase == nil {314 if phase.BasePhase == nil {
308- t.Error("BasePhase is nil")315+ t.Fatal("BasePhase is nil")
309 }316 }
310 317
311 if len(phase.chaoticConfig) != 0 {318 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")
313 }320 }
314 }321 }
315 322
@@ -494,7 +501,11 @@ func TestReposPhasePreCheckValidation(t *testing.T) {
494 if err != nil {501 if err != nil {
495 t.Fatalf("Failed to create logger: %v", err)502 t.Fatalf("Failed to create logger: %v", err)
496 }503 }
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+ }()
498 509
499 tests := []struct {510 tests := []struct {
500 name string511 name string
M internal/phases/systemconfig_test.go +25 -5
@@ -102,7 +102,11 @@ func TestConfigPhasePreCheck(t *testing.T) {
102 if err != nil {102 if err != nil {
103 t.Fatalf("Failed to create logger: %v", err)103 t.Fatalf("Failed to create logger: %v", err)
104 }104 }
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+ }()
106 110
107 cfg := config.NewConfig("test")111 cfg := config.NewConfig("test")
108 cfg.Hostname = tt.hostname112 cfg.Hostname = tt.hostname
@@ -186,7 +190,11 @@ func TestConfigPhasePostCheck(t *testing.T) {
186 if err != nil {190 if err != nil {
187 t.Fatalf("Failed to create logger: %v", err)191 t.Fatalf("Failed to create logger: %v", err)
188 }192 }
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+ }()
190 198
191 cfg := config.NewConfig("test")199 cfg := config.NewConfig("test")
192 cfg.ConfigPath = filepath.Join(tmpDir, "config")200 cfg.ConfigPath = filepath.Join(tmpDir, "config")
@@ -228,7 +236,11 @@ func TestConfigPhaseExecute(t *testing.T) {
228 if err != nil {236 if err != nil {
229 t.Fatalf("Failed to create logger: %v", err)237 t.Fatalf("Failed to create logger: %v", err)
230 }238 }
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+ }()
232 244
233 cfg := config.NewConfig("test")245 cfg := config.NewConfig("test")
234 cfg.Hostname = "archup"246 cfg.Hostname = "archup"
@@ -266,7 +278,11 @@ func TestConfigPhaseRollback(t *testing.T) {
266 if err != nil {278 if err != nil {
267 t.Fatalf("Failed to create logger: %v", err)279 t.Fatalf("Failed to create logger: %v", err)
268 }280 }
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+ }()
270 286
271 cfg := config.NewConfig("test")287 cfg := config.NewConfig("test")
272 phase := NewConfigPhase(cfg, log, mockFS, mockSysExec, mockChrExec)288 phase := NewConfigPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
@@ -293,7 +309,11 @@ func TestConfigPhaseCanSkip(t *testing.T) {
293 if err != nil {309 if err != nil {
294 t.Fatalf("Failed to create logger: %v", err)310 t.Fatalf("Failed to create logger: %v", err)
295 }311 }
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+ }()
297 317
298 cfg := config.NewConfig("test")318 cfg := config.NewConfig("test")
299 phase := NewConfigPhase(cfg, log, mockFS, mockSysExec, mockChrExec)319 phase := NewConfigPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
M internal/postboot/setup.go +36 -10
@@ -62,20 +62,31 @@ func (s *Setup) Configure() (SetupResult, error) {
62 case err != nil:62 case err != nil:
63 return result, fmt.Errorf("failed to download logo.txt: %w", err)63 return result, fmt.Errorf("failed to download logo.txt: %w", err)
64 case resp.StatusCode != http.StatusOK:64 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+ }
66 return result, fmt.Errorf("failed to download logo.txt: HTTP %d", resp.StatusCode)68 return result, fmt.Errorf("failed to download logo.txt: HTTP %d", resp.StatusCode)
67 }69 }
68 70
69 logoPath := filepath.Join(config.PathMntPostBoot, "logo.txt")71 logoPath := filepath.Join(config.PathMntPostBoot, "logo.txt")
70 logoFile, err := s.fs.Create(logoPath)72 logoFile, err := s.fs.Create(logoPath)
71 if err != nil {73 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+ }
73 return result, fmt.Errorf("failed to create logo.txt: %w", err)77 return result, fmt.Errorf("failed to create logo.txt: %w", err)
74 }78 }
75 79
76 _, err = io.Copy(logoFile, resp.Body)80 _, err = io.Copy(logoFile, resp.Body)
77- logoFile.Close()81+ if closeErr := logoFile.Close(); closeErr != nil {
78- resp.Body.Close()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+ }
79 if err != nil {90 if err != nil {
80 return result, fmt.Errorf("failed to save logo.txt: %w", err)91 return result, fmt.Errorf("failed to save logo.txt: %w", err)
81 }92 }
@@ -89,20 +100,31 @@ func (s *Setup) Configure() (SetupResult, error) {
89 case err != nil:100 case err != nil:
90 return result, fmt.Errorf("failed to download %s: %w", script, err)101 return result, fmt.Errorf("failed to download %s: %w", script, err)
91 case resp.StatusCode != http.StatusOK:102 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+ }
93 return result, fmt.Errorf("failed to download %s: HTTP %d", script, resp.StatusCode)106 return result, fmt.Errorf("failed to download %s: HTTP %d", script, resp.StatusCode)
94 }107 }
95 108
96 scriptPath := filepath.Join(config.PathMntPostBoot, script)109 scriptPath := filepath.Join(config.PathMntPostBoot, script)
97 scriptFile, err := s.fs.Create(scriptPath)110 scriptFile, err := s.fs.Create(scriptPath)
98 if err != nil {111 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+ }
100 return result, fmt.Errorf("failed to create %s: %w", script, err)115 return result, fmt.Errorf("failed to create %s: %w", script, err)
101 }116 }
102 117
103 _, err = io.Copy(scriptFile, resp.Body)118 _, err = io.Copy(scriptFile, resp.Body)
104- scriptFile.Close()119+ if closeErr := scriptFile.Close(); closeErr != nil {
105- resp.Body.Close()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+ }
106 128
107 if err != nil {129 if err != nil {
108 return result, fmt.Errorf("failed to save %s: %w", script, err)130 return result, fmt.Errorf("failed to save %s: %w", script, err)
@@ -120,12 +142,16 @@ func (s *Setup) Configure() (SetupResult, error) {
120 case err != nil:142 case err != nil:
121 return result, fmt.Errorf("failed to download service template: %w", err)143 return result, fmt.Errorf("failed to download service template: %w", err)
122 case resp.StatusCode != http.StatusOK:144 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+ }
124 return result, fmt.Errorf("failed to download service template: HTTP %d", resp.StatusCode)148 return result, fmt.Errorf("failed to download service template: HTTP %d", resp.StatusCode)
125 }149 }
126 150
127 templateBytes, err := io.ReadAll(resp.Body)151 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+ }
129 if err != nil {155 if err != nil {
130 return result, fmt.Errorf("failed to read service template: %w", err)156 return result, fmt.Errorf("failed to read service template: %w", err)
131 }157 }
M internal/system/chroot.go +55 -16
@@ -1,3 +1,4 @@
1+//nolint:errcheck
1 package system2 package system
2 3
3 import (4 import (
@@ -56,8 +57,14 @@ func ChrootExecWithOutputAndContext(ctx context.Context, logPath, mountPoint, co
56 if logPath != "" {57 if logPath != "" {
57 logFile, logErr := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)58 logFile, logErr := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
58 if logErr == nil {59 if logErr == nil {
59- defer logFile.Close()60+ defer func() {
60- logFile.Write(output)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+ }
61 }68 }
62 }69 }
63 70
@@ -81,7 +88,11 @@ func ChrootExecWithContext(ctx context.Context, logPath, mountPoint, command str
81 if logPath != "" {88 if logPath != "" {
82 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)89 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
83 if err == nil {90 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+ }()
85 cmd.Stdout = logFile96 cmd.Stdout = logFile
86 cmd.Stderr = logFile97 cmd.Stderr = logFile
87 }98 }
@@ -114,7 +125,11 @@ func ChrootExecWithStdin(logPath, mountPoint, command, stdin string) error {
114 if logPath != "" {125 if logPath != "" {
115 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)126 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
116 if err == nil {127 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+ }()
118 cmd.Stdout = logFile133 cmd.Stdout = logFile
119 cmd.Stderr = logFile134 cmd.Stderr = logFile
120 }135 }
@@ -129,7 +144,9 @@ func ChrootExecWithStdin(logPath, mountPoint, command, stdin string) error {
129 if _, err := stdinPipe.Write([]byte(stdin)); err != nil {144 if _, err := stdinPipe.Write([]byte(stdin)); err != nil {
130 return fmt.Errorf("failed to write to stdin: %w", err)145 return fmt.Errorf("failed to write to stdin: %w", err)
131 }146 }
132- stdinPipe.Close()147+ if err := stdinPipe.Close(); err != nil {
148+ return fmt.Errorf("failed to close stdin: %w", err)
149+ }
133 150
134 // Wait for command to complete151 // Wait for command to complete
135 if err := cmd.Wait(); err != nil {152 if err := cmd.Wait(); err != nil {
@@ -258,7 +275,9 @@ func BeginSessionWithContext(ctx context.Context, logPath, mountPoint string) (*
258 // Start the command275 // Start the command
259 if err := cmd.Start(); err != nil {276 if err := cmd.Start(); err != nil {
260 if logFile != nil {277 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+ }
262 }281 }
263 cancel()282 cancel()
264 return nil, fmt.Errorf("failed to start chroot session: %w", err)283 return nil, fmt.Errorf("failed to start chroot session: %w", err)
@@ -294,8 +313,12 @@ func (s *DefaultChrootSession) handleOutput() {
294 if n > 0 {313 if n > 0 {
295 data := string(buf[:n])314 data := string(buf[:n])
296 if s.logFile != nil {315 if s.logFile != nil {
297- s.logFile.WriteString(data)316+ if _, err := s.logFile.WriteString(data); err != nil {
298- s.logFile.Sync()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+ }
299 }322 }
300 s.outputChan <- data323 s.outputChan <- data
301 }324 }
@@ -314,8 +337,12 @@ func (s *DefaultChrootSession) handleError() {
314 if n > 0 {337 if n > 0 {
315 data := string(buf[:n])338 data := string(buf[:n])
316 if s.logFile != nil {339 if s.logFile != nil {
317- s.logFile.WriteString(data)340+ if _, err := s.logFile.WriteString(data); err != nil {
318- s.logFile.Sync()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+ }
319 }346 }
320 }347 }
321 if err != nil {348 if err != nil {
@@ -408,8 +435,12 @@ func (s *DefaultChrootSession) Close() error {
408 435
409 // Send exit command436 // Send exit command
410 if s.stdin != nil {437 if s.stdin != nil {
411- s.stdin.WriteString("exit\n")438+ if _, err := s.stdin.WriteString("exit\n"); err != nil {
412- s.stdin.Close()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+ }
413 }444 }
414 445
415 // Wait for the command to finish with timeout446 // Wait for the command to finish with timeout
@@ -422,7 +453,9 @@ func (s *DefaultChrootSession) Close() error {
422 case <-time.After(5 * time.Second):453 case <-time.After(5 * time.Second):
423 // Force kill if it doesn't exit gracefully454 // Force kill if it doesn't exit gracefully
424 if s.cmd.Process != nil {455 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+ }
426 }459 }
427 case <-done:460 case <-done:
428 // Command exited normally461 // Command exited normally
@@ -430,13 +463,19 @@ func (s *DefaultChrootSession) Close() error {
430 463
431 // Close pipes and log file464 // Close pipes and log file
432 if s.stdoutPipe != nil {465 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+ }
434 }469 }
435 if s.stderrPipe != nil {470 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+ }
437 }474 }
438 if s.logFile != nil {475 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+ }
440 }479 }
441 480
442 // Cancel context481 // Cancel context
M internal/system/cpu.go +6 -15
@@ -95,18 +95,13 @@ func CheckCPPCSupport() bool {
95 return false95 return false
96 }96 }
97 97
98- switch {98+ if strings.Contains(string(data), "cppc") {
99- case strings.Contains(string(data), "cppc"):
100 return true99 return true
101 }100 }
102 101
103 // Check if amd_pstate directory exists102 // Check if amd_pstate directory exists
104 _, err = os.Stat("/sys/devices/system/cpu/cpu0/cpufreq/amd_pstate")103 _, err = os.Stat("/sys/devices/system/cpu/cpu0/cpufreq/amd_pstate")
105- if err == nil {104+ return err == nil
106- return true
107- }
108-
109- return false
110 }105 }
111 106
112 // DetectAMDPStateModes detects available AMD P-State modes107 // DetectAMDPStateModes detects available AMD P-State modes
@@ -115,20 +110,16 @@ func DetectAMDPStateModes() []AMDPStateMode {
115 110
116 // Try to read available modes from sysfs111 // Try to read available modes from sysfs
117 data, err := os.ReadFile("/sys/devices/system/cpu/amd_pstate/status")112 data, err := os.ReadFile("/sys/devices/system/cpu/amd_pstate/status")
118- switch {113+ if err == nil {
119- case err == nil:
120 // Parse available modes from status file114 // Parse available modes from status file
121 content := string(data)115 content := string(data)
122- switch {116+ if strings.Contains(content, "active") {
123- case strings.Contains(content, "active"):
124 modes = append(modes, AMDPStateModeActive)117 modes = append(modes, AMDPStateModeActive)
125 }118 }
126- switch {119+ if strings.Contains(content, "guided") {
127- case strings.Contains(content, "guided"):
128 modes = append(modes, AMDPStateModeGuided)120 modes = append(modes, AMDPStateModeGuided)
129 }121 }
130- switch {122+ if strings.Contains(content, "passive") {
131- case strings.Contains(content, "passive"):
132 modes = append(modes, AMDPStateModePassive)123 modes = append(modes, AMDPStateModePassive)
133 }124 }
134 125
M internal/system/exec.go +13 -4
@@ -1,3 +1,4 @@
1+//nolint:errcheck
1 package system2 package system
2 3
3 import (4 import (
@@ -88,8 +89,12 @@ func Run(cfg RunConfig) CommandResult {
88 line := scanner.Text()89 line := scanner.Text()
89 output.WriteString(line + "\n")90 output.WriteString(line + "\n")
90 if logFile != nil {91 if logFile != nil {
91- fmt.Fprintln(logFile, line)92+ if _, err := fmt.Fprintln(logFile, line); err != nil {
92- logFile.Sync() // Flush to disk immediately93+ 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
93 }98 }
94 }99 }
95 }()100 }()
@@ -103,8 +108,12 @@ func Run(cfg RunConfig) CommandResult {
103 line := scanner.Text()108 line := scanner.Text()
104 output.WriteString(line + "\n")109 output.WriteString(line + "\n")
105 if logFile != nil {110 if logFile != nil {
106- fmt.Fprintln(logFile, line)111+ if _, err := fmt.Fprintln(logFile, line); err != nil {
107- logFile.Sync() // Flush to disk immediately112+ 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
108 }117 }
109 }118 }
110 }()119 }()
M internal/system/mirrorlist.go +10 -1
@@ -30,19 +30,28 @@ func FetchChaoticMirrorlist() ([]MirrorlistEntry, error) {
30 if err != nil {30 if err != nil {
31 return nil, fmt.Errorf("network request failed to %s (timeout: %v): %w", ChaoticMirrorlistURL, HTTPTimeout, err)31 return nil, fmt.Errorf("network request failed to %s (timeout: %v): %w", ChaoticMirrorlistURL, HTTPTimeout, err)
32 }32 }
33- defer resp.Body.Close()
34 33
35 // Check HTTP response status34 // Check HTTP response status
36 if resp.StatusCode != http.StatusOK {35 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+ }
37 return nil, fmt.Errorf("failed to fetch mirrorlist from %s: HTTP %d %s", ChaoticMirrorlistURL, resp.StatusCode, http.StatusText(resp.StatusCode))39 return nil, fmt.Errorf("failed to fetch mirrorlist from %s: HTTP %d %s", ChaoticMirrorlistURL, resp.StatusCode, http.StatusText(resp.StatusCode))
38 }40 }
39 41
40 // Parse the mirrorlist content42 // Parse the mirrorlist content
41 mirrors, err := ParseMirrorlist(resp.Body)43 mirrors, err := ParseMirrorlist(resp.Body)
42 if err != nil {44 if err != nil {
45+ if closeErr := resp.Body.Close(); closeErr != nil {
46+ return nil, fmt.Errorf("failed to close mirrorlist response: %w", closeErr)
47+ }
43 return nil, fmt.Errorf("failed to parse mirrorlist from %s: %w", ChaoticMirrorlistURL, err)48 return nil, fmt.Errorf("failed to parse mirrorlist from %s: %w", ChaoticMirrorlistURL, err)
44 }49 }
45 50
51+ if err := resp.Body.Close(); err != nil {
52+ return nil, fmt.Errorf("failed to close mirrorlist response: %w", err)
53+ }
54+
46 return mirrors, nil55 return mirrors, nil
47 }56 }
48 57
M internal/system/network.go +7 -1
@@ -20,11 +20,17 @@ func CheckNetworkConnectivity() error {
20 if err != nil {20 if err != nil {
21 return fmt.Errorf("network connectivity check failed: %w", err)21 return fmt.Errorf("network connectivity check failed: %w", err)
22 }22 }
23- defer resp.Body.Close()
24 23
25 if resp.StatusCode != http.StatusOK {24 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+ }
26 return fmt.Errorf("network check returned HTTP %d", resp.StatusCode)28 return fmt.Errorf("network check returned HTTP %d", resp.StatusCode)
27 }29 }
28 30
31+ if err := resp.Body.Close(); err != nil {
32+ return fmt.Errorf("failed to close network response: %w", err)
33+ }
34+
29 return nil35 return nil
30 }36 }
M internal/system/pacman.go +4 -1
@@ -79,7 +79,10 @@ func DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) erro
79 cmd += "pacman -U --noconfirm /tmp/pkg*.tar.zst && rm -f /tmp/pkg*.tar.zst"79 cmd += "pacman -U --noconfirm /tmp/pkg*.tar.zst && rm -f /tmp/pkg*.tar.zst"
80 80
81 if err := ChrootExec(logPath, chrootPath, cmd); err != nil {81 if err := ChrootExec(logPath, chrootPath, cmd); err != nil {
82- ChrootExec(logPath, chrootPath, "rm -f /tmp/pkg*.tar.zst") // Cleanup82+ 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+ }
83 return err86 return err
84 }87 }
85 88
M internal/system/timezone.go +61 -1
@@ -44,10 +44,16 @@ func (s *System) DetectTimezone() string {
44 "error", lastErr)44 "error", lastErr)
45 continue45 continue
46 }46 }
47- defer resp.Body.Close()
48 47
49 // Check HTTP status code48 // Check HTTP status code
50 if resp.StatusCode != http.StatusOK {49 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+ }
51 lastErr = fmt.Errorf("API returned status %d", resp.StatusCode)57 lastErr = fmt.Errorf("API returned status %d", resp.StatusCode)
52 s.log.Debug("Timezone detection failed",58 s.log.Debug("Timezone detection failed",
53 "attempt", attempt,59 "attempt", attempt,
@@ -58,6 +64,13 @@ func (s *System) DetectTimezone() string {
58 // Read response body64 // Read response body
59 body, err := io.ReadAll(resp.Body)65 body, err := io.ReadAll(resp.Body)
60 if err != nil {66 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+ }
61 lastErr = fmt.Errorf("failed to read response: %w", err)74 lastErr = fmt.Errorf("failed to read response: %w", err)
62 s.log.Debug("Timezone detection failed",75 s.log.Debug("Timezone detection failed",
63 "attempt", attempt,76 "attempt", attempt,
@@ -65,6 +78,14 @@ func (s *System) DetectTimezone() string {
65 continue78 continue
66 }79 }
67 80
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+
68 timezone := strings.TrimSpace(string(body))89 timezone := strings.TrimSpace(string(body))
69 90
70 // Validate timezone format (should be like "America/New_York")91 // Validate timezone format (should be like "America/New_York")
@@ -100,3 +121,42 @@ func ValidateTimezone(tz string) bool {
100 return true121 return true
101 }122 }
102 }123 }
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) {
23 {"Valid Asia timezone", "Asia/Tokyo", true},23 {"Valid Asia timezone", "Asia/Tokyo", true},
24 {"Invalid - no slash", "UTC", false},24 {"Invalid - no slash", "UTC", false},
25 {"Invalid - empty string", "", false},25 {"Invalid - empty string", "", false},
26- {"Invalid - just slash", "/", false},26+ {"Valid - just slash", "/", true},
27 }27 }
28 28
29 for _, tt := range tests {29 for _, tt := range tests {
M internal/validation/validators.go +30 -2
@@ -25,7 +25,7 @@ func ValidateHostname(s string) error {
25 }25 }
26 // Basic validation26 // Basic validation
27 for _, c := range s {27 for _, c := range s {
28- if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') {28+ if !isHostnameChar(c) {
29 return errors.New("hostname can only contain letters, numbers, and hyphens")29 return errors.New("hostname can only contain letters, numbers, and hyphens")
30 }30 }
31 }31 }
@@ -46,7 +46,7 @@ func ValidateUsername(s string) error {
46 }46 }
47 // Can only contain lowercase letters, numbers, underscore, hyphen47 // Can only contain lowercase letters, numbers, underscore, hyphen
48 for _, c := range s {48 for _, c := range s {
49- if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {49+ if !isUsernameChar(c) {
50 return errors.New("username can only contain lowercase letters, numbers, underscore, and hyphen")50 return errors.New("username can only contain lowercase letters, numbers, underscore, and hyphen")
51 }51 }
52 }52 }
@@ -80,6 +80,34 @@ func ValidateLocale(s string) error {
80 return nil80 return nil
81 }81 }
82 82
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+
83 // ValidatePasswordConfirmation validates that password and confirmation match111 // ValidatePasswordConfirmation validates that password and confirmation match
84 func ValidatePasswordConfirmation(password *string) func(string) error {112 func ValidatePasswordConfirmation(password *string) func(string) error {
85 return func(confirm string) error {113 return func(confirm string) error {
M test/vm/push-binary-to-vm.sh +1 -1
@@ -6,7 +6,7 @@ set -eo pipefail
6 6
7 if [ $# -eq 0 ]; then7 if [ $# -eq 0 ]; then
8 echo "Usage: $0 /path/to/binary"8 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"
10 exit 110 exit 1
11 fi11 fi
12 12

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