detached 374e98b

refactor: remove dead internal/ui and internal/phases code paths

- delete internal/ui/ (unused huh-based form path, never wired to install cmd)
- delete internal/phases/ (only used by dead internal/ui path)
- move internal/ui/assets/ -> internal/assets/ (only live dependency)
- update post_install_handler.go import to internal/assets
- wire version string through tui.NewApp -> RenderForm for title display
- hostname default: 'myarch' -> 'arch', username default: 'archuser' -> ''
- update tests to match new defaults and title
commit
374e98b0a300de4e97616eb7ca4df999ecab78c0
author
brice · · 2026-02-28 04:42 UTC
parent
fc93cf5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
52 files · +25 -10885
M cmd/install.go +1 -1
@@ -91,7 +91,7 @@ func runInstall(dryRun bool) error {
9191 )
9292
9393 gpuHandler := apphandlers.NewGPUHandler(shellExec, slogAdapter)
94- tuiApp := tui.NewApp(installService, installService.Tracker(), gpuHandler, slogAdapter)
94+ tuiApp := tui.NewApp(installService, installService.Tracker(), gpuHandler, slogAdapter, version)
9595
9696 oldLog.Info("Starting TUI application", "version", version)
9797 p := tea.NewProgram(tuiApp, tea.WithAltScreen())
M go.mod +0 -5
@@ -5,7 +5,6 @@ go 1.25.3
55 require (
66 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
77 github.com/charmbracelet/bubbletea v1.3.10
8- github.com/charmbracelet/huh v0.8.0
98 github.com/charmbracelet/lipgloss v1.1.0
109 github.com/google/uuid v1.6.0
1110 github.com/spf13/cobra v1.10.2
@@ -15,23 +14,19 @@ require (
1514 require (
1615 github.com/atotto/clipboard v0.1.4 // indirect
1716 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
18- github.com/catppuccin/go v0.3.0 // indirect
1917 github.com/charmbracelet/colorprofile v0.4.1 // indirect
2018 github.com/charmbracelet/x/ansi v0.11.4 // indirect
2119 github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
22- github.com/charmbracelet/x/exp/strings v0.0.0-20260122224438-b01af16209d9 // indirect
2320 github.com/charmbracelet/x/term v0.2.2 // indirect
2421 github.com/clipperhouse/displaywidth v0.7.0 // indirect
2522 github.com/clipperhouse/stringish v0.1.1 // indirect
2623 github.com/clipperhouse/uax29/v2 v2.4.0 // indirect
27- github.com/dustin/go-humanize v1.0.1 // indirect
2824 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
2925 github.com/inconshreveable/mousetrap v1.1.0 // indirect
3026 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
3127 github.com/mattn/go-isatty v0.0.20 // indirect
3228 github.com/mattn/go-localereader v0.0.1 // indirect
3329 github.com/mattn/go-runewidth v0.0.19 // indirect
34- github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
3530 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
3631 github.com/muesli/cancelreader v0.2.2 // indirect
3732 github.com/muesli/termenv v0.16.0 // indirect
M go.sum +0 -26 generated file: collapsed
M internal/application/handlers/post_install_handler.go +1 -1
@@ -9,9 +9,9 @@ import (
99
1010 "github.com/bnema/archup/internal/application/commands"
1111 "github.com/bnema/archup/internal/application/dto"
12+ "github.com/bnema/archup/internal/assets"
1213 "github.com/bnema/archup/internal/config"
1314 "github.com/bnema/archup/internal/domain/ports"
14- "github.com/bnema/archup/internal/ui/assets"
1515 )
1616
1717 // PostInstallHandler handles post-installation tasks
R internal/ui/assets/arch-logo.png → internal/assets/arch-logo.png +0 -0 binary file: no text diff · renamed, 100% similar

binary file: no text diff · renamed, 100% similar

R internal/ui/assets/assets.go → internal/assets/assets.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/ui/assets/logo.txt → internal/assets/logo.txt +0 -0 renamed, 100% similar

renamed, 100% similar

M internal/interfaces/tui/app.go +5 -2
@@ -41,6 +41,7 @@ type App struct {
4141
4242 // Application state
4343 currentScreen Screen
44+ version string
4445 ctx context.Context
4546 cancel context.CancelFunc
4647
@@ -75,6 +76,7 @@ func NewApp(
7576 progressTracker *services.ProgressTracker,
7677 gpuHandler *apphandlers.GPUHandler,
7778 logger ports.Logger,
79+ version string,
7880 ) *App {
7981 ctx, cancel := context.WithCancel(context.Background())
8082
@@ -83,6 +85,7 @@ func NewApp(
8385 progressTracker: progressTracker,
8486 gpuHandler: gpuHandler,
8587 logger: logger,
88+ version: version,
8689 formModel: models.NewFormModel(),
8790 diskModel: models.NewDiskModel(),
8891 encryptionModel: models.NewEncryptionModel(),
@@ -158,7 +161,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
158161 func (a *App) View() string {
159162 switch a.currentScreen {
160163 case ScreenForm:
161- return views.RenderForm(a.formModel)
164+ return views.RenderForm(a.formModel, a.version)
162165 case ScreenDisk:
163166 return views.RenderDiskSelection(a.diskModel)
164167 case ScreenEncryption:
@@ -180,7 +183,7 @@ func (a *App) View() string {
180183 case ScreenError:
181184 return views.RenderError(a.installationModel.GetError())
182185 default:
183- return views.RenderForm(a.formModel)
186+ return views.RenderForm(a.formModel, a.version)
184187 }
185188 }
186189
M internal/interfaces/tui/handlers/form_handler_test.go +2 -2
@@ -107,8 +107,8 @@ func TestFormModelDataExtraction(t *testing.T) {
107107
108108 // Set test data
109109 testData := models.FormData{
110- Hostname: "myarch",
111- Username: "archuser",
110+ Hostname: "arch",
111+ Username: "",
112112 UserPassword: "password123",
113113 RootPassword: "rootpass123",
114114 TargetDisk: "/dev/sda",
M internal/interfaces/tui/models/form.go +2 -2
@@ -75,10 +75,10 @@ func NewFormModel() *FormModelImpl {
7575
7676 // Initialize text inputs with default values
7777 fm.hostname = createTextInput("Hostname", "", "Enter system hostname")
78- fm.hostname.SetValue("myarch")
78+ fm.hostname.SetValue("arch")
7979
8080 fm.username = createTextInput("Username", "", "Enter regular user name")
81- fm.username.SetValue("archuser")
81+ fm.username.SetValue("")
8282
8383 fm.userEmail = createTextInput("Email", "", "Enter email for git config")
8484
M internal/interfaces/tui/views/form_view.go +7 -2
@@ -8,14 +8,19 @@ import (
88 )
99
1010 // RenderForm renders the form model to a styled string
11-func RenderForm(fm *models.FormModelImpl) string {
11+func RenderForm(fm *models.FormModelImpl, version string) string {
1212 var b strings.Builder
1313
14+ title := "ArchUp Installer"
15+ if version != "" {
16+ title += " " + version
17+ }
18+
1419 b.WriteString("\n")
1520 b.WriteString(lipgloss.NewStyle().
1621 Bold(true).
1722 Foreground(lipgloss.Color("12")).
18- Render("ArchUp Installer Configuration"))
23+ Render(title))
1924 b.WriteString("\n\n")
2025
2126 labels := []string{
M internal/interfaces/tui/views/form_view_test.go +7 -7
@@ -13,12 +13,12 @@ func TestRenderForm(t *testing.T) {
1313 fm := models.NewFormModel()
1414
1515 // Render the form
16- output := RenderForm(fm)
16+ output := RenderForm(fm, "")
1717
1818 // Verify key content is present
1919 checks := []string{
20- "ArchUp Installer Configuration", // Title
21- "Hostname:", // Field labels
20+ "ArchUp Installer", // Title
21+ "Hostname:", // Field labels
2222 "Username:",
2323 "↑↓ Navigate", // Instructions
2424 }
@@ -44,7 +44,7 @@ func TestRenderFormWithError(t *testing.T) {
4444 fm.SetError(errors.New(testErr))
4545
4646 // Render the form
47- output := RenderForm(fm)
47+ output := RenderForm(fm, "")
4848
4949 // Verify error is displayed
5050 if !strings.Contains(output, testErr) {
@@ -57,13 +57,13 @@ func TestRenderFormWithDifferentFocus(t *testing.T) {
5757 fm := models.NewFormModel()
5858
5959 // Render initial form
60- output1 := RenderForm(fm)
60+ output1 := RenderForm(fm, "")
6161
6262 // Move focus
6363 fm.FocusNext()
6464
6565 // Render form with different focus
66- output2 := RenderForm(fm)
66+ output2 := RenderForm(fm, "")
6767
6868 // Both should render successfully
6969 if len(output1) == 0 || len(output2) == 0 {
@@ -142,6 +142,6 @@ func BenchmarkRenderForm(b *testing.B) {
142142
143143 b.ResetTimer()
144144 for i := 0; i < b.N; i++ {
145- _ = RenderForm(fm)
145+ _ = RenderForm(fm, "")
146146 }
147147 }
D internal/phases/base.go +0 -463
@@ -1,463 +0,0 @@
1-package phases
2-
3-import (
4- "bufio"
5- "fmt"
6- "strings"
7-
8- "github.com/bnema/archup/internal/config"
9- "github.com/bnema/archup/internal/interfaces"
10- "github.com/bnema/archup/internal/logger"
11-)
12-
13-// BaseInstallPhase handles base system installation
14-type BaseInstallPhase struct {
15- *BasePhase
16- fs interfaces.FileSystem
17- sysExec interfaces.SystemExecutor
18-}
19-
20-// NewBaseInstallPhase creates a new base installation phase
21-func NewBaseInstallPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, sysExec interfaces.SystemExecutor) *BaseInstallPhase {
22- return &BaseInstallPhase{
23- BasePhase: NewBasePhase("base", "Base System Installation", cfg, log),
24- fs: fs,
25- sysExec: sysExec,
26- }
27-}
28-
29-// PreCheck validates prerequisites
30-func (p *BaseInstallPhase) PreCheck() error {
31- // Verify /mnt is mounted
32- result := p.sysExec.RunSimple("mountpoint", "-q", "/mnt")
33- switch {
34- case result.ExitCode != 0:
35- return fmt.Errorf("/mnt is not mounted")
36- }
37-
38- return nil
39-}
40-
41-// Execute runs the base phase
42-func (p *BaseInstallPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
43- p.SendProgress(progressChan, "Starting base installation...", 1, 5)
44-
45- // Step 1: Configure ISO pacman
46- switch err := p.configurePacman(progressChan); {
47- case err != nil:
48- p.SendError(progressChan, err)
49- return PhaseResult{Success: false, Error: err}
50- }
51-
52- p.SendProgress(progressChan, "Detecting CPU and microcode...", 2, 5)
53-
54- // Step 2: Detect CPU and select microcode
55- switch err := p.detectCPU(progressChan); {
56- case err != nil:
57- p.SendError(progressChan, err)
58- return PhaseResult{Success: false, Error: err}
59- }
60-
61- // Step 2.5: Configure CachyOS repository on ISO if needed
62- if p.config.KernelChoice == config.KernelLinuxCachyOS {
63- p.SendProgress(progressChan, "Configuring CachyOS repository...", 2, 5)
64- switch err := p.configureCachyOSRepo(progressChan); {
65- case err != nil:
66- p.SendError(progressChan, err)
67- return PhaseResult{Success: false, Error: err}
68- }
69- }
70-
71- p.SendProgress(progressChan, "Installing base system...", 3, 5)
72-
73- // Step 3: Run pacstrap
74- switch err := p.runPacstrap(progressChan); {
75- case err != nil:
76- p.SendError(progressChan, err)
77- return PhaseResult{Success: false, Error: err}
78- }
79-
80- p.SendProgress(progressChan, "Generating fstab...", 4, 5)
81-
82- // Step 4: Generate fstab
83- switch err := p.generateFstab(progressChan); {
84- case err != nil:
85- p.SendError(progressChan, err)
86- return PhaseResult{Success: false, Error: err}
87- }
88-
89- p.SendProgress(progressChan, "Base installation complete", 5, 5)
90- p.SendComplete(progressChan, "Base system installed successfully")
91-
92- return PhaseResult{Success: true, Message: "Base installation complete"}
93-}
94-
95-// configurePacman enables parallel downloads on ISO
96-func (p *BaseInstallPhase) configurePacman(progressChan chan<- ProgressUpdate) error {
97- p.SendOutput(progressChan, "Configuring pacman for faster downloads...")
98-
99- // Enable ParallelDownloads=10
100- result := p.logger.ExecCommand("sed", "-i",
101- "s/^#ParallelDownloads = 5$/ParallelDownloads = 10/",
102- "/etc/pacman.conf")
103-
104- switch {
105- case result.ExitCode != 0:
106- return fmt.Errorf("pacman configuration failed: %w", result.Error)
107- }
108-
109- p.SendOutput(progressChan, "[OK] Pacman configured: ParallelDownloads=10")
110- return nil
111-}
112-
113-// configureCachyOSRepo configures CachyOS repository on the ISO before pacstrap
114-func (p *BaseInstallPhase) configureCachyOSRepo(progressChan chan<- ProgressUpdate) error {
115- p.SendOutput(progressChan, "Configuring CachyOS repository on live system...")
116-
117- // Import CachyOS GPG key
118- p.SendOutput(progressChan, "Importing CachyOS GPG key...")
119- result := p.logger.ExecCommand("pacman-key", "--recv-keys", "F3B607488DB35A47", "--keyserver", "keyserver.ubuntu.com")
120- switch {
121- case result.ExitCode != 0:
122- return fmt.Errorf("failed to receive CachyOS GPG key: %w", result.Error)
123- }
124-
125- result = p.logger.ExecCommand("pacman-key", "--lsign-key", "F3B607488DB35A47")
126- switch {
127- case result.ExitCode != 0:
128- return fmt.Errorf("failed to sign CachyOS GPG key: %w", result.Error)
129- }
130-
131- p.SendOutput(progressChan, "[OK] GPG key imported")
132-
133- // Check if CachyOS repo already configured
134- content, err := p.fs.ReadFile("/etc/pacman.conf")
135- switch {
136- case err != nil:
137- return fmt.Errorf("failed to read pacman.conf: %w", err)
138- }
139-
140- contentStr := string(content)
141- if !strings.Contains(contentStr, "[cachyos]") {
142- // Add CachyOS repo before [core] repository
143- p.SendOutput(progressChan, "Adding CachyOS repository to pacman.conf...")
144-
145- // Find [core] and insert before it
146- lines := strings.Split(contentStr, "\n")
147- var newLines []string
148- inserted := false
149-
150- for _, line := range lines {
151- if !inserted && strings.TrimSpace(line) == "[core]" {
152- newLines = append(newLines, "# CachyOS repositories")
153- newLines = append(newLines, "[cachyos]")
154- newLines = append(newLines, "Include = /etc/pacman.d/cachyos-mirrorlist")
155- newLines = append(newLines, "")
156- inserted = true
157- }
158- newLines = append(newLines, line)
159- }
160-
161- contentStr = strings.Join(newLines, "\n")
162-
163- switch err := p.fs.WriteFile("/etc/pacman.conf", []byte(contentStr), 0644); {
164- case err != nil:
165- return fmt.Errorf("failed to write pacman.conf: %w", err)
166- }
167- }
168-
169- // Create CachyOS mirrorlist
170- p.SendOutput(progressChan, "Creating CachyOS mirrorlist...")
171- mirrorlist := "## CachyOS mirrorlist\nServer = https://mirror.cachyos.org/repo/$arch/$repo\n"
172-
173- switch err := p.fs.WriteFile("/etc/pacman.d/cachyos-mirrorlist", []byte(mirrorlist), 0644); {
174- case err != nil:
175- return fmt.Errorf("failed to create cachyos-mirrorlist: %w", err)
176- }
177-
178- // Sync databases
179- p.SendOutput(progressChan, "Syncing package databases...")
180- result = p.logger.ExecCommand("pacman", "-Sy")
181- switch {
182- case result.ExitCode != 0:
183- return fmt.Errorf("failed to sync databases: %w", result.Error)
184- }
185-
186- p.SendOutput(progressChan, "[OK] CachyOS repository configured")
187- return nil
188-}
189-
190-// copyCachyOSConfig copies CachyOS repository configuration to installed system
191-func (p *BaseInstallPhase) copyCachyOSConfig(progressChan chan<- ProgressUpdate) error {
192- // Read ISO's pacman.conf to check if CachyOS is configured
193- content, err := p.fs.ReadFile("/etc/pacman.conf")
194- switch {
195- case err != nil:
196- return fmt.Errorf("failed to read ISO pacman.conf: %w", err)
197- }
198-
199- isoContent := string(content)
200-
201- // Read installed system's pacman.conf
202- mntContent, err := p.fs.ReadFile("/mnt/etc/pacman.conf")
203- switch {
204- case err != nil:
205- return fmt.Errorf("failed to read /mnt/etc/pacman.conf: %w", err)
206- }
207-
208- mntContentStr := string(mntContent)
209-
210- // Add CachyOS repo before [core] if not already present
211- if !strings.Contains(mntContentStr, "[cachyos]") && strings.Contains(isoContent, "[cachyos]") {
212- lines := strings.Split(mntContentStr, "\n")
213- var newLines []string
214- inserted := false
215-
216- for _, line := range lines {
217- if !inserted && strings.TrimSpace(line) == "[core]" {
218- newLines = append(newLines, "# CachyOS repositories")
219- newLines = append(newLines, "[cachyos]")
220- newLines = append(newLines, "Include = /etc/pacman.d/cachyos-mirrorlist")
221- newLines = append(newLines, "")
222- inserted = true
223- }
224- newLines = append(newLines, line)
225- }
226-
227- mntContentStr = strings.Join(newLines, "\n")
228-
229- switch err := p.fs.WriteFile("/mnt/etc/pacman.conf", []byte(mntContentStr), 0644); {
230- case err != nil:
231- return fmt.Errorf("failed to write /mnt/etc/pacman.conf: %w", err)
232- }
233-
234- p.SendOutput(progressChan, "[OK] Added CachyOS to installed system's pacman.conf")
235- }
236-
237- // Copy mirrorlist from ISO to installed system
238- result := p.logger.ExecCommand("mkdir", "-p", "/mnt/etc/pacman.d")
239- switch {
240- case result.ExitCode != 0:
241- return fmt.Errorf("failed to create /mnt/etc/pacman.d: %w", result.Error)
242- }
243-
244- result = p.logger.ExecCommand("cp", "/etc/pacman.d/cachyos-mirrorlist", "/mnt/etc/pacman.d/cachyos-mirrorlist")
245- switch {
246- case result.ExitCode != 0:
247- return fmt.Errorf("failed to copy cachyos-mirrorlist: %w", result.Error)
248- }
249-
250- p.SendOutput(progressChan, "[OK] CachyOS repository configured in installed system")
251- return nil
252-}
253-
254-// detectCPU detects CPU vendor and selects microcode
255-func (p *BaseInstallPhase) detectCPU(progressChan chan<- ProgressUpdate) error {
256- p.SendOutput(progressChan, "Detecting CPU vendor...")
257-
258- // Read /proc/cpuinfo to detect vendor
259- file, err := p.fs.Open("/proc/cpuinfo")
260- switch {
261- case err != nil:
262- return fmt.Errorf("failed to read /proc/cpuinfo: %w", err)
263- }
264- defer func() {
265- if err := file.Close(); err != nil {
266- p.logger.Warn("Failed to close file", "error", err)
267- }
268- }()
269-
270- scanner := bufio.NewScanner(file)
271- var vendor string
272-
273- for scanner.Scan() {
274- line := scanner.Text()
275- if strings.HasPrefix(line, "vendor_id") {
276- parts := strings.Fields(line)
277- if len(parts) >= 3 {
278- vendor = parts[2]
279- break
280- }
281- }
282- }
283-
284- // Determine microcode package
285- var microcode, cpuType string
286-
287- switch vendor {
288- case "GenuineIntel":
289- microcode = "intel-ucode"
290- cpuType = "Intel"
291- case "AuthenticAMD":
292- microcode = "amd-ucode"
293- cpuType = "AMD"
294- default:
295- microcode = ""
296- cpuType = "Unknown"
297- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Unknown CPU vendor: %s", vendor))
298- }
299-
300- p.config.Microcode = microcode
301- p.config.CPUVendor = cpuType
302-
303- switch {
304- case microcode != "":
305- p.SendOutput(progressChan, fmt.Sprintf("[OK] Detected %s CPU", cpuType))
306- p.SendOutput(progressChan, fmt.Sprintf(" Microcode: %s", microcode))
307- }
308-
309- return nil
310-}
311-
312-// runPacstrap installs base system with pacstrap
313-func (p *BaseInstallPhase) runPacstrap(progressChan chan<- ProgressUpdate) error {
314- // Load base packages from embedded list
315- packages, err := p.loadBasePackages()
316- switch {
317- case err != nil:
318- return fmt.Errorf("failed to load package list: %w", err)
319- }
320-
321- // Add kernel
322- switch {
323- case p.config.KernelChoice != "":
324- packages = append(packages, p.config.KernelChoice)
325- p.SendOutput(progressChan, fmt.Sprintf("Adding kernel: %s", p.config.KernelChoice))
326- default:
327- packages = append(packages, config.KernelLinux)
328- p.config.KernelChoice = config.KernelLinux
329- p.SendOutput(progressChan, "Adding kernel: linux (default)")
330- }
331-
332- // Add microcode
333- switch {
334- case p.config.Microcode != "":
335- packages = append(packages, p.config.Microcode)
336- p.SendOutput(progressChan, fmt.Sprintf("Adding microcode: %s", p.config.Microcode))
337- }
338-
339- // Add cryptsetup if LUKS enabled
340- switch p.config.EncryptionType {
341- case config.EncryptionLUKS, config.EncryptionLUKSLVM:
342- packages = append(packages, "cryptsetup")
343- p.SendOutput(progressChan, "Adding cryptsetup for LUKS encryption")
344- }
345-
346- p.SendOutput(progressChan, fmt.Sprintf("Installing %d packages:", len(packages)))
347-
348- // Show package list
349- for _, pkg := range packages {
350- p.SendOutput(progressChan, fmt.Sprintf(" - %s", pkg))
351- }
352-
353- p.SendOutput(progressChan, "")
354- p.SendOutput(progressChan, "Starting installation (this may take several minutes)...")
355-
356- // Run pacstrap
357- args := append([]string{"/mnt"}, packages...)
358- result := p.logger.ExecCommand("pacstrap", args...)
359-
360- switch {
361- case result.ExitCode != 0:
362- return fmt.Errorf("pacstrap failed: %w", result.Error)
363- }
364-
365- p.SendOutput(progressChan, fmt.Sprintf("[OK] Installed %d packages", len(packages)))
366-
367- // Copy CachyOS configuration to installed system if needed
368- if p.config.KernelChoice == config.KernelLinuxCachyOS {
369- p.SendOutput(progressChan, "Configuring CachyOS repository in installed system...")
370- if err := p.copyCachyOSConfig(progressChan); err != nil {
371- return fmt.Errorf("failed to configure CachyOS in installed system: %w", err)
372- }
373- }
374-
375- return nil
376-}
377-
378-// loadBasePackages reads the base package list from file
379-func (p *BaseInstallPhase) loadBasePackages() ([]string, error) {
380- // Read from install directory (downloaded during bootstrap)
381- // Use DefaultInstallDir directly to match where bootstrap downloads files
382- packageFile := config.DefaultInstallDir + "/" + config.BasePackagesFile
383-
384- file, err := p.fs.Open(packageFile)
385- switch {
386- case err != nil:
387- return nil, fmt.Errorf("failed to open %s: %w", packageFile, err)
388- }
389- defer func() {
390- if err := file.Close(); err != nil {
391- p.logger.Warn("Failed to close file", "error", err)
392- }
393- }()
394-
395- var packages []string
396- scanner := bufio.NewScanner(file)
397-
398- for scanner.Scan() {
399- line := strings.TrimSpace(scanner.Text())
400-
401- // Skip comments and empty lines
402- switch {
403- case line == "":
404- continue
405- case strings.HasPrefix(line, "#"):
406- continue
407- }
408-
409- packages = append(packages, line)
410- }
411-
412- switch err := scanner.Err(); {
413- case err != nil:
414- return nil, fmt.Errorf("error reading package file: %w", err)
415- }
416-
417- return packages, nil
418-}
419-
420-// generateFstab generates /etc/fstab using UUIDs
421-func (p *BaseInstallPhase) generateFstab(progressChan chan<- ProgressUpdate) error {
422- p.SendOutput(progressChan, "Generating fstab with UUIDs...")
423-
424- result := p.sysExec.RunSimple("sh", "-c", "genfstab -U /mnt >> /mnt/etc/fstab")
425- switch {
426- case result.ExitCode != 0:
427- return fmt.Errorf("genfstab failed: %w", result.Error)
428- }
429-
430- p.SendOutput(progressChan, "[OK] fstab generated")
431- return nil
432-}
433-
434-// PostCheck validates installation
435-func (p *BaseInstallPhase) PostCheck() error {
436- // Check if base system exists
437- result := p.sysExec.RunSimple("test", "-d", "/mnt/usr")
438- switch {
439- case result.ExitCode != 0:
440- return fmt.Errorf("/mnt/usr does not exist")
441- }
442-
443- // Check if fstab exists
444- result = p.sysExec.RunSimple("test", "-f", "/mnt/etc/fstab")
445- switch {
446- case result.ExitCode != 0:
447- return fmt.Errorf("/mnt/etc/fstab was not created")
448- }
449-
450- return p.config.Save()
451-}
452-
453-// Rollback removes installed system
454-func (p *BaseInstallPhase) Rollback() error {
455- // Just log - actual cleanup happens in partitioning rollback
456- p.SendOutput(nil, "[WARN] Base installation failed, partitions will be cleaned up")
457- return nil
458-}
459-
460-// CanSkip returns false - base cannot be skipped
461-func (p *BaseInstallPhase) CanSkip() bool {
462- return false
463-}
D internal/phases/base_test.go +0 -491
@@ -1,491 +0,0 @@
1-package phases
2-
3-import (
4- "bytes"
5- "fmt"
6- "io"
7- "path/filepath"
8- "testing"
9-
10- "github.com/bnema/archup/internal/config"
11- "github.com/bnema/archup/internal/interfaces/mocks"
12- "github.com/bnema/archup/internal/logger"
13- "github.com/bnema/archup/internal/system"
14- "go.uber.org/mock/gomock"
15-)
16-
17-// TestBaseInstallPhasePreCheck tests /mnt mount validation
18-func TestBaseInstallPhasePreCheck(t *testing.T) {
19- tests := []struct {
20- name string
21- mountResult system.CommandResult
22- wantErr bool
23- errContains string
24- }{
25- {
26- name: "/mnt is mounted",
27- mountResult: system.CommandResult{ExitCode: 0},
28- wantErr: false,
29- },
30- {
31- name: "/mnt is not mounted",
32- mountResult: system.CommandResult{ExitCode: 32},
33- wantErr: true,
34- errContains: "/mnt is not mounted",
35- },
36- }
37-
38- for _, tt := range tests {
39- t.Run(tt.name, func(t *testing.T) {
40- ctrl := gomock.NewController(t)
41- defer ctrl.Finish()
42-
43- mockFS := mocks.NewMockFileSystem(ctrl)
44- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
45-
46- mockSysExec.EXPECT().RunSimple("mountpoint", "-q", "/mnt").Return(tt.mountResult).Times(1)
47-
48- tmpDir := t.TempDir()
49- logPath := filepath.Join(tmpDir, "test.log")
50- log, err := logger.New(logPath, false)
51- if err != nil {
52- t.Fatalf("Failed to create logger: %v", err)
53- }
54- defer func() {
55- if err := log.Close(); err != nil {
56- t.Fatalf("failed to close log: %v", err)
57- }
58- }()
59-
60- cfg := config.NewConfig("test")
61- phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
62-
63- err = phase.PreCheck()
64- if (err != nil) != tt.wantErr {
65- t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr)
66- return
67- }
68- if tt.wantErr && err != nil {
69- if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
70- t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
71- }
72- }
73- })
74- }
75-}
76-
77-// TestBaseInstallPhaseDetectCPU tests CPU vendor detection
78-func TestBaseInstallPhaseDetectCPU(t *testing.T) {
79- tests := []struct {
80- name string
81- cpuinfoContent string
82- expectedVendor string
83- expectedMicrocode string
84- wantErr bool
85- }{
86- {
87- name: "Intel CPU detected",
88- cpuinfoContent: "processor\t: 0\nvendor_id\t: GenuineIntel\nflags\t: fpu\n",
89- expectedVendor: "Intel",
90- expectedMicrocode: "intel-ucode",
91- wantErr: false,
92- },
93- {
94- name: "AMD CPU detected",
95- cpuinfoContent: "processor\t: 0\nvendor_id\t: AuthenticAMD\nflags\t: fpu\n",
96- expectedVendor: "AMD",
97- expectedMicrocode: "amd-ucode",
98- wantErr: false,
99- },
100- {
101- name: "Unknown CPU vendor",
102- cpuinfoContent: "processor\t: 0\nvendor_id\t: UnknownVendor\nflags\t: fpu\n",
103- expectedVendor: "Unknown",
104- expectedMicrocode: "",
105- wantErr: false,
106- },
107- {
108- name: "Empty cpuinfo",
109- cpuinfoContent: "",
110- expectedVendor: "Unknown",
111- expectedMicrocode: "",
112- wantErr: false,
113- },
114- }
115-
116- for _, tt := range tests {
117- t.Run(tt.name, func(t *testing.T) {
118- ctrl := gomock.NewController(t)
119- defer ctrl.Finish()
120-
121- mockFS := mocks.NewMockFileSystem(ctrl)
122- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
123-
124- reader := io.NopCloser(bytes.NewBufferString(tt.cpuinfoContent))
125- mockFS.EXPECT().Open("/proc/cpuinfo").Return(reader, nil).Times(1)
126-
127- tmpDir := t.TempDir()
128- logPath := filepath.Join(tmpDir, "test.log")
129- log, err := logger.New(logPath, false)
130- if err != nil {
131- t.Fatalf("Failed to create logger: %v", err)
132- }
133- defer func() {
134- if err := log.Close(); err != nil {
135- t.Fatalf("failed to close log: %v", err)
136- }
137- }()
138-
139- cfg := config.NewConfig("test")
140- phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
141-
142- progressChan := make(chan ProgressUpdate, 10)
143- err = phase.detectCPU(progressChan)
144-
145- if (err != nil) != tt.wantErr {
146- t.Errorf("detectCPU() error = %v, wantErr %v", err, tt.wantErr)
147- return
148- }
149-
150- if cfg.CPUVendor != tt.expectedVendor {
151- t.Errorf("CPUVendor = %q, want %q", cfg.CPUVendor, tt.expectedVendor)
152- }
153- if cfg.Microcode != tt.expectedMicrocode {
154- t.Errorf("Microcode = %q, want %q", cfg.Microcode, tt.expectedMicrocode)
155- }
156- })
157- }
158-}
159-
160-// TestBaseInstallPhaseLoadBasePackages tests package file parsing
161-func TestBaseInstallPhaseLoadBasePackages(t *testing.T) {
162- tests := []struct {
163- name string
164- fileContent string
165- expectedPackages []string
166- wantErr bool
167- errContains string
168- }{
169- {
170- name: "Valid package list",
171- fileContent: "base\nlinux\nlinux-firmware\ngit\n",
172- expectedPackages: []string{"base", "linux", "linux-firmware", "git"},
173- wantErr: false,
174- },
175- {
176- name: "Package list with comments and empty lines",
177- fileContent: "# Core packages\nbase\n\n# Additional\nlinux\n# Another comment\n\ngit\n",
178- expectedPackages: []string{"base", "linux", "git"},
179- wantErr: false,
180- },
181- {
182- name: "Empty file",
183- fileContent: "",
184- expectedPackages: []string{},
185- wantErr: false,
186- },
187- {
188- name: "Only comments",
189- fileContent: "# Comment 1\n# Comment 2\n",
190- expectedPackages: []string{},
191- wantErr: false,
192- },
193- {
194- name: "Only whitespace",
195- fileContent: " \n\n \n",
196- expectedPackages: []string{},
197- wantErr: false,
198- },
199- }
200-
201- for _, tt := range tests {
202- t.Run(tt.name, func(t *testing.T) {
203- ctrl := gomock.NewController(t)
204- defer ctrl.Finish()
205-
206- mockFS := mocks.NewMockFileSystem(ctrl)
207- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
208-
209- reader := io.NopCloser(bytes.NewBufferString(tt.fileContent))
210- packageFile := config.DefaultInstallDir + "/" + config.BasePackagesFile
211- mockFS.EXPECT().Open(packageFile).Return(reader, nil).Times(1)
212-
213- tmpDir := t.TempDir()
214- logPath := filepath.Join(tmpDir, "test.log")
215- log, err := logger.New(logPath, false)
216- if err != nil {
217- t.Fatalf("Failed to create logger: %v", err)
218- }
219- defer func() {
220- if err := log.Close(); err != nil {
221- t.Fatalf("failed to close log: %v", err)
222- }
223- }()
224-
225- cfg := config.NewConfig("test")
226- phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
227-
228- packages, err := phase.loadBasePackages()
229-
230- if (err != nil) != tt.wantErr {
231- t.Errorf("loadBasePackages() error = %v, wantErr %v", err, tt.wantErr)
232- return
233- }
234-
235- if len(packages) != len(tt.expectedPackages) {
236- t.Errorf("loadBasePackages() returned %d packages, want %d", len(packages), len(tt.expectedPackages))
237- return
238- }
239-
240- for i, pkg := range packages {
241- if pkg != tt.expectedPackages[i] {
242- t.Errorf("Package %d = %q, want %q", i, pkg, tt.expectedPackages[i])
243- }
244- }
245- })
246- }
247-}
248-
249-// TestBaseInstallPhaseGenerateFstab tests fstab generation
250-func TestBaseInstallPhaseGenerateFstab(t *testing.T) {
251- tests := []struct {
252- name string
253- genfstabResult system.CommandResult
254- wantErr bool
255- errContains string
256- }{
257- {
258- name: "Fstab generation success",
259- genfstabResult: system.CommandResult{ExitCode: 0},
260- wantErr: false,
261- },
262- {
263- name: "Genfstab command failed",
264- genfstabResult: system.CommandResult{ExitCode: 1, Error: fmt.Errorf("permission denied")},
265- wantErr: true,
266- errContains: "genfstab failed",
267- },
268- }
269-
270- for _, tt := range tests {
271- t.Run(tt.name, func(t *testing.T) {
272- ctrl := gomock.NewController(t)
273- defer ctrl.Finish()
274-
275- mockFS := mocks.NewMockFileSystem(ctrl)
276- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
277-
278- mockSysExec.EXPECT().RunSimple("sh", "-c", "genfstab -U /mnt >> /mnt/etc/fstab").Return(tt.genfstabResult).Times(1)
279-
280- tmpDir := t.TempDir()
281- logPath := filepath.Join(tmpDir, "test.log")
282- log, err := logger.New(logPath, false)
283- if err != nil {
284- t.Fatalf("Failed to create logger: %v", err)
285- }
286- defer func() {
287- if err := log.Close(); err != nil {
288- t.Fatalf("failed to close log: %v", err)
289- }
290- }()
291-
292- cfg := config.NewConfig("test")
293- phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
294-
295- progressChan := make(chan ProgressUpdate, 10)
296- err = phase.generateFstab(progressChan)
297-
298- if (err != nil) != tt.wantErr {
299- t.Errorf("generateFstab() error = %v, wantErr %v", err, tt.wantErr)
300- return
301- }
302- if tt.wantErr && err != nil {
303- if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
304- t.Errorf("generateFstab() error = %q, should contain %q", errMsg, tt.errContains)
305- }
306- }
307- })
308- }
309-}
310-
311-// TestBaseInstallPhasePostCheck tests installation validation
312-func TestBaseInstallPhasePostCheck(t *testing.T) {
313- tests := []struct {
314- name string
315- usrDirExists bool
316- fstabFileExists bool
317- wantErr bool
318- errContains string
319- }{
320- {
321- name: "Both /mnt/usr and /mnt/etc/fstab exist",
322- usrDirExists: true,
323- fstabFileExists: true,
324- wantErr: false,
325- },
326- {
327- name: "/mnt/usr does not exist",
328- usrDirExists: false,
329- fstabFileExists: true,
330- wantErr: true,
331- errContains: "/mnt/usr does not exist",
332- },
333- {
334- name: "/mnt/etc/fstab does not exist",
335- usrDirExists: true,
336- fstabFileExists: false,
337- wantErr: true,
338- errContains: "fstab was not created",
339- },
340- }
341-
342- for _, tt := range tests {
343- t.Run(tt.name, func(t *testing.T) {
344- ctrl := gomock.NewController(t)
345- defer ctrl.Finish()
346-
347- mockFS := mocks.NewMockFileSystem(ctrl)
348- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
349-
350- usrResult := system.CommandResult{ExitCode: 1}
351- if tt.usrDirExists {
352- usrResult.ExitCode = 0
353- }
354- mockSysExec.EXPECT().RunSimple("test", "-d", "/mnt/usr").Return(usrResult).Times(1)
355-
356- // Only expect fstab check if usr dir exists
357- if tt.usrDirExists {
358- fstabResult := system.CommandResult{ExitCode: 1}
359- if tt.fstabFileExists {
360- fstabResult.ExitCode = 0
361- }
362- mockSysExec.EXPECT().RunSimple("test", "-f", "/mnt/etc/fstab").Return(fstabResult).Times(1)
363- }
364-
365- tmpDir := t.TempDir()
366- logPath := filepath.Join(tmpDir, "test.log")
367- log, err := logger.New(logPath, false)
368- if err != nil {
369- t.Fatalf("Failed to create logger: %v", err)
370- }
371- defer func() {
372- if err := log.Close(); err != nil {
373- t.Fatalf("failed to close log: %v", err)
374- }
375- }()
376-
377- cfg := config.NewConfig("test")
378- cfg.ConfigPath = filepath.Join(tmpDir, "config")
379-
380- phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
381-
382- err = phase.PostCheck()
383-
384- if (err != nil) != tt.wantErr {
385- t.Errorf("PostCheck() error = %v, wantErr %v", err, tt.wantErr)
386- return
387- }
388- if tt.wantErr && err != nil {
389- if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
390- t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains)
391- }
392- }
393- })
394- }
395-}
396-
397-// TestBaseInstallPhaseExecute tests Execute calls correct steps
398-func TestBaseInstallPhaseExecute(t *testing.T) {
399- // Note: Full Execute testing is complex due to logger.ExecCommand() calls
400- // This test verifies the phase structure and progress updates
401- tmpDir := t.TempDir()
402- logPath := filepath.Join(tmpDir, "test.log")
403- log, err := logger.New(logPath, false)
404- if err != nil {
405- t.Fatalf("Failed to create logger: %v", err)
406- }
407- defer func() {
408- if err := log.Close(); err != nil {
409- t.Fatalf("failed to close log: %v", err)
410- }
411- }()
412-
413- ctrl := gomock.NewController(t)
414- defer ctrl.Finish()
415-
416- mockFS := mocks.NewMockFileSystem(ctrl)
417- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
418-
419- cfg := config.NewConfig("test")
420- phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
421-
422- progressChan := make(chan ProgressUpdate, 100)
423-
424- // Just verify that Execute sends progress updates (higher-level behavior)
425- // since testing the full Execute requires mocking logger.ExecCommand
426- result := phase.Execute(progressChan)
427-
428- // Result may be error due to logger.ExecCommand, but we should get a PhaseResult
429- if result.Message == "" && !result.Success {
430- // This is expected when logger.ExecCommand is called
431- t.Logf("Execute correctly attempted to run - logger.ExecCommand not mocked")
432- }
433-}
434-
435-// TestBaseInstallPhaseRollback tests rollback
436-func TestBaseInstallPhaseRollback(t *testing.T) {
437- ctrl := gomock.NewController(t)
438- defer ctrl.Finish()
439-
440- mockFS := mocks.NewMockFileSystem(ctrl)
441- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
442-
443- tmpDir := t.TempDir()
444- logPath := filepath.Join(tmpDir, "test.log")
445- log, err := logger.New(logPath, false)
446- if err != nil {
447- t.Fatalf("Failed to create logger: %v", err)
448- }
449- defer func() {
450- if err := log.Close(); err != nil {
451- t.Fatalf("failed to close log: %v", err)
452- }
453- }()
454-
455- cfg := config.NewConfig("test")
456- phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
457-
458- // BaseInstallPhase Rollback should be no-op
459- err = phase.Rollback()
460- if err != nil {
461- t.Errorf("Rollback() error = %v, want nil", err)
462- }
463-}
464-
465-// TestBaseInstallPhaseCanSkip tests CanSkip
466-func TestBaseInstallPhaseCanSkip(t *testing.T) {
467- ctrl := gomock.NewController(t)
468- defer ctrl.Finish()
469-
470- mockFS := mocks.NewMockFileSystem(ctrl)
471- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
472-
473- tmpDir := t.TempDir()
474- logPath := filepath.Join(tmpDir, "test.log")
475- log, err := logger.New(logPath, false)
476- if err != nil {
477- t.Fatalf("Failed to create logger: %v", err)
478- }
479- defer func() {
480- if err := log.Close(); err != nil {
481- t.Fatalf("failed to close log: %v", err)
482- }
483- }()
484-
485- cfg := config.NewConfig("test")
486- phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec)
487-
488- if phase.CanSkip() {
489- t.Error("CanSkip() = true, want false")
490- }
491-}
D internal/phases/boot.go +0 -321
@@ -1,321 +0,0 @@
1-package phases
2-
3-import (
4- "fmt"
5- "path/filepath"
6- "regexp"
7- "strings"
8-
9- "github.com/bnema/archup/internal/config"
10- "github.com/bnema/archup/internal/interfaces"
11- "github.com/bnema/archup/internal/logger"
12- "github.com/bnema/archup/internal/system"
13-)
14-
15-// BootPhase handles bootloader installation
16-type BootPhase struct {
17- *BasePhase
18- fs interfaces.FileSystem
19- sysExec interfaces.SystemExecutor
20- chrExec interfaces.ChrootExecutor
21-}
22-
23-// NewBootPhase creates a new boot phase
24-func NewBootPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, sysExec interfaces.SystemExecutor, chrExec interfaces.ChrootExecutor) *BootPhase {
25- return &BootPhase{
26- BasePhase: NewBasePhase("boot", "Bootloader Installation", cfg, log),
27- fs: fs,
28- sysExec: sysExec,
29- chrExec: chrExec,
30- }
31-}
32-
33-// PreCheck validates boot prerequisites
34-func (p *BootPhase) PreCheck() error {
35- // Verify /mnt is mounted
36- result := p.sysExec.RunSimple("mountpoint", "-q", config.PathMnt)
37- if result.ExitCode != 0 {
38- return fmt.Errorf("%s is not mounted", config.PathMnt)
39- }
40-
41- // Verify boot partition is mounted
42- result = p.sysExec.RunSimple("mountpoint", "-q", config.PathMntBoot)
43- if result.ExitCode != 0 {
44- return fmt.Errorf("%s is not mounted", config.PathMntBoot)
45- }
46-
47- return nil
48-}
49-
50-// Execute runs the boot phase
51-func (p *BootPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
52- p.SendProgress(progressChan, "Starting bootloader installation...", 1, 4)
53-
54- // Step 1: Configure initramfs
55- if err := p.configureMkinitcpio(progressChan); err != nil {
56- p.SendError(progressChan, err)
57- return PhaseResult{Success: false, Error: err}
58- }
59-
60- p.SendProgress(progressChan, "Installing Limine bootloader...", 2, 4)
61-
62- // Step 2: Install Limine
63- if err := p.installLimine(progressChan); err != nil {
64- p.SendError(progressChan, err)
65- return PhaseResult{Success: false, Error: err}
66- }
67-
68- p.SendProgress(progressChan, "Configuring Limine...", 3, 4)
69-
70- // Step 3: Configure Limine
71- if err := p.configureLimine(progressChan); err != nil {
72- p.SendError(progressChan, err)
73- return PhaseResult{Success: false, Error: err}
74- }
75-
76- p.SendProgress(progressChan, "Creating UEFI boot entry...", 4, 4)
77-
78- // Step 4: Create UEFI boot entry
79- if err := p.createUEFIEntry(progressChan); err != nil {
80- p.SendError(progressChan, err)
81- return PhaseResult{Success: false, Error: err}
82- }
83-
84- p.SendProgress(progressChan, "Bootloader installation complete", 4, 4)
85- p.SendComplete(progressChan, "Limine installed successfully")
86-
87- return PhaseResult{Success: true, Message: "Boot configuration complete"}
88-}
89-
90-// configureMkinitcpio configures initramfs hooks
91-func (p *BootPhase) configureMkinitcpio(progressChan chan<- ProgressUpdate) error {
92- p.SendOutput(progressChan, "Configuring initramfs...")
93-
94- // Read current mkinitcpio.conf
95- content, err := p.fs.ReadFile(config.PathMntEtcMkinitcpio)
96- if err != nil {
97- return fmt.Errorf("failed to read mkinitcpio.conf: %w", err)
98- }
99-
100- // Determine hooks based on encryption
101- var hooks string
102- switch p.config.EncryptionType {
103- case config.EncryptionLUKS, config.EncryptionLUKSLVM:
104- hooks = config.MkinitcpioHooksEncrypted
105- p.SendOutput(progressChan, "Configuring initramfs for encrypted Plymouth boot")
106- default:
107- hooks = config.MkinitcpioHooksPlymouth
108- p.SendOutput(progressChan, "Configuring initramfs for Plymouth boot")
109- }
110-
111- // Replace HOOKS line
112- hooksRegex := regexp.MustCompile(`(?m)^HOOKS=.*$`)
113- newContent := hooksRegex.ReplaceAllString(string(content), hooks)
114-
115- // Write updated config
116- if err := p.fs.WriteFile(config.PathMntEtcMkinitcpio, []byte(newContent), 0644); err != nil {
117- return fmt.Errorf("failed to write mkinitcpio.conf: %w", err)
118- }
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-
131- // Regenerate initramfs
132- p.SendOutput(progressChan, "Regenerating initramfs...")
133- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "mkinitcpio -P"); err != nil {
134- return fmt.Errorf("failed to regenerate initramfs: %w", err)
135- }
136-
137- p.SendOutput(progressChan, "[OK] Initramfs configured")
138-
139- return nil
140-}
141-
142-// installLimine installs Limine bootloader files
143-func (p *BootPhase) installLimine(progressChan chan<- ProgressUpdate) error {
144- p.SendOutput(progressChan, "Installing Limine bootloader...")
145-
146- // Install Limine to disk (BIOS - optional, may fail on UEFI-only)
147- biosCmd := fmt.Sprintf("limine bios-install %s", p.config.TargetDisk)
148- switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, biosCmd); {
149- case err != nil:
150- p.SendOutput(progressChan, "[WARN] BIOS installation skipped (UEFI-only system)")
151- default:
152- p.SendOutput(progressChan, "[OK] BIOS bootloader installed")
153- }
154-
155- // Create Limine directory
156- if err := p.fs.MkdirAll(config.PathMntBootEFILimine, 0755); err != nil {
157- return fmt.Errorf("failed to create Limine directory: %w", err)
158- }
159-
160- // Copy Limine EFI bootloader
161- srcBootloader := filepath.Join(config.PathUsrShareLimine, config.FileLimineBootloader)
162- dstBootloader := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
163-
164- cpCmd := fmt.Sprintf("cp %s %s", srcBootloader, dstBootloader)
165- result := p.logger.ExecCommand("sh", "-c", cpCmd)
166- if result.ExitCode != 0 {
167- return fmt.Errorf("failed to copy Limine bootloader: %w", result.Error)
168- }
169-
170- p.SendOutput(progressChan, "[OK] Limine files installed")
171-
172- return nil
173-}
174-
175-// configureLimine creates Limine configuration from template
176-func (p *BootPhase) configureLimine(progressChan chan<- ProgressUpdate) error {
177- p.SendOutput(progressChan, "Configuring Limine...")
178-
179- // Get root partition UUID
180- rootPartition := p.config.RootPartition
181- result := p.sysExec.RunSimple("blkid", "-s", "UUID", "-o", "value", rootPartition)
182- if result.ExitCode != 0 {
183- return fmt.Errorf("failed to get root partition UUID: %w", result.Error)
184- }
185-
186- rootUUID := strings.TrimSpace(result.Output)
187-
188- // Build kernel parameters
189- var kernelParams string
190- switch p.config.EncryptionType {
191- case config.EncryptionLUKS, config.EncryptionLUKSLVM:
192- // For encrypted setups
193- kernelParams = fmt.Sprintf("cryptdevice=UUID=%s:cryptroot root=/dev/mapper/cryptroot rootflags=subvol=@ rw", rootUUID)
194- p.SendOutput(progressChan, "Configured for encrypted root")
195- default:
196- // For non-encrypted setups
197- kernelParams = fmt.Sprintf("root=UUID=%s rootflags=subvol=@ rw", rootUUID)
198- }
199-
200- // Add AMD-specific kernel parameters if configured
201- if p.config.AMDPState != "" {
202- // Detect CPU to get Zen generation for proper kernel params
203- cpuInfo, err := p.sysExec.DetectCPUInfo()
204- var amdParams string
205- if err == nil && cpuInfo.AMDZenGen != nil {
206- // Use helper function that adds extra params for Zen 2 if needed
207- amdParams = system.GetAMDPStateKernelParams(cpuInfo.AMDZenGen, system.AMDPStateMode(p.config.AMDPState))
208- } else {
209- // Fallback to simple parameter if detection fails
210- amdParams = fmt.Sprintf("amd_pstate=%s", p.config.AMDPState)
211- }
212-
213- if amdParams != "" {
214- kernelParams = fmt.Sprintf("%s %s", kernelParams, amdParams)
215- p.SendOutput(progressChan, fmt.Sprintf("Added AMD P-State: %s", amdParams))
216- }
217- }
218-
219- // Add quiet/splash parameters
220- kernelParams = fmt.Sprintf("%s %s", kernelParams, config.KernelParamsQuiet)
221-
222- // Read Limine config template
223- templatePath := p.getInstallPath(config.LimineConfigTemplate)
224- templateContent, err := p.fs.ReadFile(templatePath)
225- if err != nil {
226- return fmt.Errorf("failed to read Limine template: %w", err)
227- }
228-
229- // Replace template variables
230- limineConfig := string(templateContent)
231- limineConfig = strings.ReplaceAll(limineConfig, "{{TIMEOUT}}", config.LimineTimeout)
232- limineConfig = strings.ReplaceAll(limineConfig, "{{BRANDING}}", config.LimineBranding)
233- limineConfig = strings.ReplaceAll(limineConfig, "{{COLOR}}", config.LimineColor)
234- limineConfig = strings.ReplaceAll(limineConfig, "{{KERNEL}}", p.config.KernelChoice)
235- limineConfig = strings.ReplaceAll(limineConfig, "{{KERNEL_PARAMS}}", kernelParams)
236-
237- // Write Limine configuration to /boot/limine.conf (standard Limine search path)
238- limineConfigPath := config.PathMntBootLimineConf
239- if err := p.fs.WriteFile(limineConfigPath, []byte(limineConfig), 0644); err != nil {
240- return fmt.Errorf("failed to write Limine config: %w", err)
241- }
242-
243- p.SendOutput(progressChan, "[OK] Limine configured")
244-
245- return nil
246-}
247-
248-// createUEFIEntry creates UEFI boot entry
249-func (p *BootPhase) createUEFIEntry(progressChan chan<- ProgressUpdate) error {
250- p.SendOutput(progressChan, "Creating UEFI boot entry...")
251-
252- // Extract EFI partition number
253- efiPartNum := p.extractPartitionNumber(p.config.EFIPartition)
254- if efiPartNum == "" {
255- return fmt.Errorf("failed to extract EFI partition number from %s", p.config.EFIPartition)
256- }
257-
258- // Create UEFI boot entry
259- efiCmd := fmt.Sprintf("efibootmgr --create --disk %s --part %s --label \"%s\" --loader \"%s\" --unicode",
260- p.config.TargetDisk,
261- efiPartNum,
262- config.UEFIBootLabel,
263- config.UEFIBootLoader,
264- )
265-
266- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, efiCmd); err != nil {
267- return fmt.Errorf("failed to create UEFI boot entry: %w", err)
268- }
269-
270- p.SendOutput(progressChan, "[OK] UEFI boot entry created")
271-
272- return nil
273-}
274-
275-// getInstallPath constructs full path to install file
276-func (p *BootPhase) getInstallPath(filename string) string {
277- // Use DefaultInstallDir directly to match where bootstrap downloads files
278- return filepath.Join(config.DefaultInstallDir, filename)
279-}
280-
281-// extractPartitionNumber extracts partition number from device path
282-func (p *BootPhase) extractPartitionNumber(partPath string) string {
283- // Match partition number at end (e.g., /dev/sda1 -> 1, /dev/nvme0n1p1 -> 1)
284- re := regexp.MustCompile(`[0-9]+$`)
285- match := re.FindString(partPath)
286-
287- // Remove leading 'p' if present (nvme style)
288- return strings.TrimPrefix(match, "p")
289-}
290-
291-// PostCheck validates boot configuration
292-func (p *BootPhase) PostCheck() error {
293- // Verify Limine bootloader exists
294- bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
295- if _, err := p.fs.Stat(bootloaderPath); p.fs.IsNotExist(err) {
296- return fmt.Errorf("limine bootloader was not installed")
297- }
298-
299- // Verify Limine config exists
300- configPath := config.PathMntBootLimineConf
301- if _, err := p.fs.Stat(configPath); p.fs.IsNotExist(err) {
302- return fmt.Errorf("limine config was not created")
303- }
304-
305- return p.config.Save()
306-}
307-
308-// Rollback for boot phase
309-func (p *BootPhase) Rollback() error {
310- // Remove Limine files if they exist
311- if err := p.fs.RemoveAll(config.PathMntBootEFILimine); err != nil {
312- p.SendOutput(nil, fmt.Sprintf("[WARN] Failed to remove Limine directory: %v", err))
313- }
314-
315- return nil
316-}
317-
318-// CanSkip returns false - boot cannot be skipped
319-func (p *BootPhase) CanSkip() bool {
320- return false
321-}
D internal/phases/boot_test.go +0 -562
@@ -1,562 +0,0 @@
1-package phases
2-
3-import (
4- "fmt"
5- "path/filepath"
6- "testing"
7-
8- "github.com/bnema/archup/internal/config"
9- "github.com/bnema/archup/internal/interfaces/mocks"
10- "github.com/bnema/archup/internal/logger"
11- "github.com/bnema/archup/internal/system"
12- "go.uber.org/mock/gomock"
13-)
14-
15-// TestBootPhasePreCheck tests boot prerequisites
16-func TestBootPhasePreCheck(t *testing.T) {
17- tests := []struct {
18- name string
19- setupMocks func(*mocks.MockSystemExecutor)
20- wantErr bool
21- errContains string
22- }{
23- {
24- name: "Both /mnt and /mnt/boot mounted",
25- setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
26- mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1)
27- mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMntBoot).Return(system.CommandResult{ExitCode: 0}).Times(1)
28- },
29- wantErr: false,
30- },
31- {
32- name: "/mnt not mounted",
33- setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
34- mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 32}).Times(1)
35- },
36- wantErr: true,
37- errContains: "is not mounted",
38- },
39- {
40- name: "/mnt/boot not mounted",
41- setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
42- mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1)
43- mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMntBoot).Return(system.CommandResult{ExitCode: 32}).Times(1)
44- },
45- wantErr: true,
46- errContains: "is not mounted",
47- },
48- }
49-
50- for _, tt := range tests {
51- t.Run(tt.name, func(t *testing.T) {
52- ctrl := gomock.NewController(t)
53- defer ctrl.Finish()
54-
55- mockFS := mocks.NewMockFileSystem(ctrl)
56- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
57- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
58-
59- tt.setupMocks(mockSysExec)
60-
61- tmpDir := t.TempDir()
62- logPath := filepath.Join(tmpDir, "test.log")
63- log, err := logger.New(logPath, false)
64- if err != nil {
65- t.Fatalf("Failed to create logger: %v", err)
66- }
67- defer func() {
68- if err := log.Close(); err != nil {
69- t.Fatalf("failed to close log: %v", err)
70- }
71- }()
72-
73- cfg := config.NewConfig("test")
74- phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
75-
76- err = phase.PreCheck()
77- if (err != nil) != tt.wantErr {
78- t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr)
79- return
80- }
81- if tt.wantErr && err != nil {
82- if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
83- t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
84- }
85- }
86- })
87- }
88-}
89-
90-// TestBootPhaseExtractPartitionNumber tests partition number extraction
91-func TestBootPhaseExtractPartitionNumber(t *testing.T) {
92- tests := []struct {
93- name string
94- devicePath string
95- expectedNum string
96- }{
97- {
98- name: "SATA partition /dev/sda1",
99- devicePath: "/dev/sda1",
100- expectedNum: "1",
101- },
102- {
103- name: "SATA partition /dev/sdb2",
104- devicePath: "/dev/sdb2",
105- expectedNum: "2",
106- },
107- {
108- name: "NVMe partition /dev/nvme0n1p1",
109- devicePath: "/dev/nvme0n1p1",
110- expectedNum: "1",
111- },
112- {
113- name: "NVMe partition /dev/nvme0n1p2",
114- devicePath: "/dev/nvme0n1p2",
115- expectedNum: "2",
116- },
117- {
118- name: "VirtIO partition /dev/vda1",
119- devicePath: "/dev/vda1",
120- expectedNum: "1",
121- },
122- {
123- name: "Multiple digit partition /dev/sda12",
124- devicePath: "/dev/sda12",
125- expectedNum: "12",
126- },
127- {
128- name: "No partition number - returns empty",
129- devicePath: "/dev/sda",
130- expectedNum: "",
131- },
132- }
133-
134- for _, tt := range tests {
135- t.Run(tt.name, func(t *testing.T) {
136- ctrl := gomock.NewController(t)
137- defer ctrl.Finish()
138-
139- mockFS := mocks.NewMockFileSystem(ctrl)
140- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
141- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
142-
143- tmpDir := t.TempDir()
144- logPath := filepath.Join(tmpDir, "test.log")
145- log, err := logger.New(logPath, false)
146- if err != nil {
147- t.Fatalf("Failed to create logger: %v", err)
148- }
149- defer func() {
150- if err := log.Close(); err != nil {
151- t.Fatalf("failed to close log: %v", err)
152- }
153- }()
154-
155- cfg := config.NewConfig("test")
156- phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
157-
158- result := phase.extractPartitionNumber(tt.devicePath)
159- if result != tt.expectedNum {
160- t.Errorf("extractPartitionNumber(%q) = %q, want %q", tt.devicePath, result, tt.expectedNum)
161- }
162- })
163- }
164-}
165-
166-// TestBootPhasePostCheck tests boot configuration validation
167-func TestBootPhasePostCheck(t *testing.T) {
168- tests := []struct {
169- name string
170- setupMocks func(*mocks.MockFileSystem)
171- wantErr bool
172- errContains string
173- }{
174- {
175- name: "Both bootloader and config exist",
176- setupMocks: func(mockFS *mocks.MockFileSystem) {
177- bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
178- configPath := config.PathMntBootLimineConf
179-
180- mockFS.EXPECT().Stat(bootloaderPath).Return(mockFileInfo{}, nil).Times(1)
181- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
182- mockFS.EXPECT().Stat(configPath).Return(mockFileInfo{}, nil).Times(1)
183- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
184- },
185- wantErr: false,
186- },
187- {
188- name: "Bootloader missing",
189- setupMocks: func(mockFS *mocks.MockFileSystem) {
190- bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
191-
192- mockFS.EXPECT().Stat(bootloaderPath).Return(nil, fmt.Errorf("not found")).Times(1)
193- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1)
194- },
195- wantErr: true,
196- errContains: "bootloader was not installed",
197- },
198- {
199- name: "Config missing",
200- setupMocks: func(mockFS *mocks.MockFileSystem) {
201- bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
202- configPath := config.PathMntBootLimineConf
203-
204- mockFS.EXPECT().Stat(bootloaderPath).Return(mockFileInfo{}, nil).Times(1)
205- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
206- mockFS.EXPECT().Stat(configPath).Return(nil, fmt.Errorf("not found")).Times(1)
207- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1)
208- },
209- wantErr: true,
210- errContains: "config was not created",
211- },
212- }
213-
214- for _, tt := range tests {
215- t.Run(tt.name, func(t *testing.T) {
216- ctrl := gomock.NewController(t)
217- defer ctrl.Finish()
218-
219- mockFS := mocks.NewMockFileSystem(ctrl)
220- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
221- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
222-
223- tt.setupMocks(mockFS)
224-
225- tmpDir := t.TempDir()
226- logPath := filepath.Join(tmpDir, "test.log")
227- log, err := logger.New(logPath, false)
228- if err != nil {
229- t.Fatalf("Failed to create logger: %v", err)
230- }
231- defer func() {
232- if err := log.Close(); err != nil {
233- t.Fatalf("failed to close log: %v", err)
234- }
235- }()
236-
237- cfg := config.NewConfig("test")
238- cfg.ConfigPath = filepath.Join(tmpDir, "config")
239-
240- phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
241-
242- err = phase.PostCheck()
243- if (err != nil) != tt.wantErr {
244- t.Errorf("PostCheck() error = %v, wantErr %v", err, tt.wantErr)
245- return
246- }
247- if tt.wantErr && err != nil {
248- if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
249- t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains)
250- }
251- }
252- })
253- }
254-}
255-
256-// TestBootPhaseRollback tests boot phase rollback
257-func TestBootPhaseRollback(t *testing.T) {
258- tests := []struct {
259- name string
260- setupMocks func(*mocks.MockFileSystem)
261- wantErr bool
262- }{
263- {
264- name: "Rollback success",
265- setupMocks: func(mockFS *mocks.MockFileSystem) {
266- mockFS.EXPECT().RemoveAll(config.PathMntBootEFILimine).Return(nil).Times(1)
267- },
268- wantErr: false,
269- },
270- {
271- name: "Rollback with directory removal failure - continues",
272- setupMocks: func(mockFS *mocks.MockFileSystem) {
273- mockFS.EXPECT().RemoveAll(config.PathMntBootEFILimine).Return(fmt.Errorf("permission denied")).Times(1)
274- },
275- wantErr: false, // Rollback doesn't fail on removal errors
276- },
277- }
278-
279- for _, tt := range tests {
280- t.Run(tt.name, func(t *testing.T) {
281- ctrl := gomock.NewController(t)
282- defer ctrl.Finish()
283-
284- mockFS := mocks.NewMockFileSystem(ctrl)
285- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
286- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
287-
288- tt.setupMocks(mockFS)
289-
290- tmpDir := t.TempDir()
291- logPath := filepath.Join(tmpDir, "test.log")
292- log, err := logger.New(logPath, false)
293- if err != nil {
294- t.Fatalf("Failed to create logger: %v", err)
295- }
296- defer func() {
297- if err := log.Close(); err != nil {
298- t.Fatalf("failed to close log: %v", err)
299- }
300- }()
301-
302- cfg := config.NewConfig("test")
303- phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
304-
305- err = phase.Rollback()
306- if (err != nil) != tt.wantErr {
307- t.Errorf("Rollback() error = %v, wantErr %v", err, tt.wantErr)
308- }
309- })
310- }
311-}
312-
313-// TestBootPhaseCanSkip tests the CanSkip method
314-func TestBootPhaseCanSkip(t *testing.T) {
315- ctrl := gomock.NewController(t)
316- defer ctrl.Finish()
317-
318- mockFS := mocks.NewMockFileSystem(ctrl)
319- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
320- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
321-
322- tmpDir := t.TempDir()
323- logPath := filepath.Join(tmpDir, "test.log")
324- log, err := logger.New(logPath, false)
325- if err != nil {
326- t.Fatalf("Failed to create logger: %v", err)
327- }
328- defer func() {
329- if err := log.Close(); err != nil {
330- t.Fatalf("failed to close log: %v", err)
331- }
332- }()
333-
334- cfg := config.NewConfig("test")
335- phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
336-
337- if phase.CanSkip() {
338- t.Error("CanSkip() = true, want false")
339- }
340-}
341-
342-// TestBootPhaseExecute tests the Execute method structure
343-func TestBootPhaseExecute(t *testing.T) {
344- ctrl := gomock.NewController(t)
345- defer ctrl.Finish()
346-
347- mockFS := mocks.NewMockFileSystem(ctrl)
348- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
349- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
350-
351- // Allow any file system operations since Execute calls various methods
352- mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte{}, nil).AnyTimes()
353- mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
354- mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
355- mockChrExec.EXPECT().ChrootExec(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
356- mockSysExec.EXPECT().RunSimple(gomock.Any(), gomock.Any(), gomock.Any()).Return(system.CommandResult{Output: "1234-5678"}).AnyTimes()
357-
358- tmpDir := t.TempDir()
359- logPath := filepath.Join(tmpDir, "test.log")
360- log, err := logger.New(logPath, false)
361- if err != nil {
362- t.Fatalf("Failed to create logger: %v", err)
363- }
364- defer func() {
365- if err := log.Close(); err != nil {
366- t.Fatalf("failed to close log: %v", err)
367- }
368- }()
369-
370- cfg := config.NewConfig("test")
371- cfg.TargetDisk = "/dev/sda"
372- cfg.EFIPartition = "/dev/sda1"
373- cfg.RootPartition = "/dev/sda2"
374- cfg.KernelChoice = "linux"
375-
376- phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
377-
378- progressChan := make(chan ProgressUpdate, 100)
379-
380- // Note: Full Execute testing is complex due to logger.ExecCommand() and various file system calls
381- // We just verify that Execute returns a PhaseResult
382- result := phase.Execute(progressChan)
383-
384- // Verify result type is correct - either success or error, the structure is valid
385- _ = result // If we got here, Execute returned a valid result structure
386-}
387-
388-// TestBootPhaseKernelParameterConstruction tests kernel parameter building
389-func TestBootPhaseKernelParameterConstruction(t *testing.T) {
390- tests := []struct {
391- name string
392- encryptionType string
393- rootUUID string
394- expectedParams string // partial match check
395- }{
396- {
397- name: "Unencrypted root",
398- encryptionType: config.EncryptionNone,
399- rootUUID: "1234-5678",
400- expectedParams: "root=UUID=1234-5678",
401- },
402- {
403- name: "LUKS encrypted root",
404- encryptionType: config.EncryptionLUKS,
405- rootUUID: "1234-5678",
406- expectedParams: "cryptdevice=UUID=1234-5678:cryptroot",
407- },
408- {
409- name: "LUKS+LVM encrypted root",
410- encryptionType: config.EncryptionLUKSLVM,
411- rootUUID: "1234-5678",
412- expectedParams: "cryptdevice=UUID=1234-5678:cryptroot",
413- },
414- }
415-
416- for _, tt := range tests {
417- t.Run(tt.name, func(t *testing.T) {
418- ctrl := gomock.NewController(t)
419- defer ctrl.Finish()
420-
421- mockFS := mocks.NewMockFileSystem(ctrl)
422- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
423- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
424-
425- tmpDir := t.TempDir()
426- logPath := filepath.Join(tmpDir, "test.log")
427- log, err := logger.New(logPath, false)
428- if err != nil {
429- t.Fatalf("Failed to create logger: %v", err)
430- }
431- defer func() {
432- if err := log.Close(); err != nil {
433- t.Fatalf("failed to close log: %v", err)
434- }
435- }()
436-
437- cfg := config.NewConfig("test")
438- cfg.EncryptionType = tt.encryptionType
439- cfg.RootPartition = "/dev/sda2"
440-
441- phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
442-
443- // Verify encryption type is stored correctly
444- if phase.config.EncryptionType != tt.encryptionType {
445- t.Errorf("Encryption type = %q, want %q", phase.config.EncryptionType, tt.encryptionType)
446- }
447- })
448- }
449-}
450-
451-// TestBootPhaseInstallPathConstruction tests install path building
452-func TestBootPhaseInstallPathConstruction(t *testing.T) {
453- tests := []struct {
454- name string
455- filename string
456- expected string
457- }{
458- {
459- name: "Limine config template",
460- filename: config.LimineConfigTemplate,
461- expected: filepath.Join(config.DefaultInstallDir, config.LimineConfigTemplate),
462- },
463- {
464- name: "Custom filename",
465- filename: "test.conf",
466- expected: filepath.Join(config.DefaultInstallDir, "test.conf"),
467- },
468- }
469-
470- for _, tt := range tests {
471- t.Run(tt.name, func(t *testing.T) {
472- ctrl := gomock.NewController(t)
473- defer ctrl.Finish()
474-
475- mockFS := mocks.NewMockFileSystem(ctrl)
476- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
477- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
478-
479- tmpDir := t.TempDir()
480- logPath := filepath.Join(tmpDir, "test.log")
481- log, err := logger.New(logPath, false)
482- if err != nil {
483- t.Fatalf("Failed to create logger: %v", err)
484- }
485- defer func() {
486- if err := log.Close(); err != nil {
487- t.Fatalf("failed to close log: %v", err)
488- }
489- }()
490-
491- cfg := config.NewConfig("test")
492- phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
493-
494- result := phase.getInstallPath(tt.filename)
495- if result != tt.expected {
496- t.Errorf("getInstallPath(%q) = %q, want %q", tt.filename, result, tt.expected)
497- }
498- })
499- }
500-}
501-
502-// TestBootPhaseFileSystemOperations tests file system interaction patterns
503-func TestBootPhaseFileSystemOperations(t *testing.T) {
504- tests := []struct {
505- name string
506- operation string // "read", "write", "mkdir"
507- }{
508- {
509- name: "Mkinitcpio config read",
510- operation: "read",
511- },
512- {
513- name: "Limine config write",
514- operation: "write",
515- },
516- {
517- name: "Create Limine directory",
518- operation: "mkdir",
519- },
520- }
521-
522- for _, tt := range tests {
523- t.Run(tt.name, func(t *testing.T) {
524- ctrl := gomock.NewController(t)
525- defer ctrl.Finish()
526-
527- mockFS := mocks.NewMockFileSystem(ctrl)
528- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
529- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
530-
531- // Setup basic expectations based on operation
532- switch tt.operation {
533- case "read":
534- mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("content"), nil).AnyTimes()
535- case "write":
536- mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
537- case "mkdir":
538- mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
539- }
540-
541- tmpDir := t.TempDir()
542- logPath := filepath.Join(tmpDir, "test.log")
543- log, err := logger.New(logPath, false)
544- if err != nil {
545- t.Fatalf("Failed to create logger: %v", err)
546- }
547- defer func() {
548- if err := log.Close(); err != nil {
549- t.Fatalf("failed to close log: %v", err)
550- }
551- }()
552-
553- cfg := config.NewConfig("test")
554- phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
555-
556- // Verify phase can be created
557- if phase == nil {
558- t.Error("BootPhase is nil")
559- }
560- })
561- }
562-}
D internal/phases/bootstrap.go +0 -290
@@ -1,290 +0,0 @@
1-package phases
2-
3-import (
4- "fmt"
5- "io"
6- "net/http"
7- "os"
8- "os/exec"
9- "path/filepath"
10- "strings"
11-
12- "github.com/bnema/archup/internal/config"
13- "github.com/bnema/archup/internal/interfaces"
14- "github.com/bnema/archup/internal/logger"
15-)
16-
17-// BootstrapPhase handles initial setup and file downloads
18-type BootstrapPhase struct {
19- *BasePhase
20- httpClient interfaces.HTTPClient
21- fs interfaces.FileSystem
22-}
23-
24-// NewBootstrapPhase creates a new bootstrap phase
25-func NewBootstrapPhase(cfg *config.Config, log *logger.Logger, httpClient interfaces.HTTPClient, fs interfaces.FileSystem) *BootstrapPhase {
26- return &BootstrapPhase{
27- BasePhase: NewBasePhase("bootstrap", "Bootstrap", cfg, log),
28- httpClient: httpClient,
29- fs: fs,
30- }
31-}
32-
33-// PreCheck validates bootstrap prerequisites
34-func (p *BootstrapPhase) PreCheck() error {
35- // Check internet connectivity
36- resp, err := p.httpClient.Get("https://raw.githubusercontent.com")
37- if err != nil {
38- return fmt.Errorf("no internet connectivity: %w", err)
39- }
40- if err := resp.Body.Close(); err != nil {
41- return fmt.Errorf("failed to close connectivity response: %w", err)
42- }
43-
44- return nil
45-}
46-
47-// Execute runs the bootstrap phase
48-func (p *BootstrapPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
49- p.SendOutput(progressChan, "Starting bootstrap phase...")
50-
51- // Create install directory
52- if err := p.fs.MkdirAll(config.DefaultInstallDir, 0755); err != nil {
53- p.SendError(progressChan, err)
54- return PhaseResult{Success: false, Error: err}
55- }
56-
57- // Local mode: files pre-seeded by push-binary-to-vm.sh --local, skip all network activity
58- if os.Getenv("ARCHUP_LOCAL") == "1" {
59- p.SendOutput(progressChan, "[LOCAL] Skipping download — using pre-seeded files in "+config.DefaultInstallDir)
60- p.SendComplete(progressChan, "Bootstrap complete")
61- return PhaseResult{Success: true, Message: "Local mode: using pre-seeded configuration files"}
62- }
63-
64- if err := p.cloneRepo(); err == nil {
65- if err := p.copyRepoFiles(progressChan); err != nil {
66- p.SendError(progressChan, err)
67- return PhaseResult{Success: false, Error: err}
68- }
69- p.SendComplete(progressChan, "Bootstrap complete")
70- return PhaseResult{Success: true, Message: "Configuration files prepared from repo clone"}
71- } else {
72- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Repo clone failed, falling back to downloads: %v", err))
73- }
74-
75- // Files to download
76- files := []struct {
77- url string
78- destPath string
79- }{
80- // Package lists
81- {
82- url: fmt.Sprintf("%s/install/base.packages", p.config.RawURL),
83- destPath: filepath.Join(config.DefaultInstallDir, "base.packages"),
84- },
85- {
86- url: fmt.Sprintf("%s/install/extra.packages", p.config.RawURL),
87- destPath: filepath.Join(config.DefaultInstallDir, "extra.packages"),
88- },
89- // Config files
90- {
91- url: fmt.Sprintf("%s/install/configs/limine.conf.template", p.config.RawURL),
92- destPath: filepath.Join(config.DefaultInstallDir, "configs", "limine.conf.template"),
93- },
94- {
95- url: fmt.Sprintf("%s/install/configs/chaotic-aur.conf", p.config.RawURL),
96- destPath: filepath.Join(config.DefaultInstallDir, "configs", "chaotic-aur.conf"),
97- },
98- // Shell config files
99- {
100- url: fmt.Sprintf("%s/install/configs/shell/shell", p.config.RawURL),
101- destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "shell"),
102- },
103- {
104- url: fmt.Sprintf("%s/install/configs/shell/starship.toml", p.config.RawURL),
105- destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "starship.toml"),
106- },
107- {
108- url: fmt.Sprintf("%s/install/configs/shell/init", p.config.RawURL),
109- destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "init"),
110- },
111- {
112- url: fmt.Sprintf("%s/install/configs/shell/aliases", p.config.RawURL),
113- destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "aliases"),
114- },
115- {
116- url: fmt.Sprintf("%s/install/configs/shell/envs", p.config.RawURL),
117- destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "envs"),
118- },
119- {
120- url: fmt.Sprintf("%s/install/configs/shell/rc", p.config.RawURL),
121- destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "rc"),
122- },
123- {
124- url: fmt.Sprintf("%s/install/configs/shell/bashrc", p.config.RawURL),
125- destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "bashrc"),
126- },
127- }
128-
129- totalFiles := len(files)
130- p.SendOutput(progressChan, fmt.Sprintf("Downloading %d configuration files...", totalFiles))
131-
132- for i, file := range files {
133- fileName := filepath.Base(file.destPath)
134- p.SendProgress(progressChan, fmt.Sprintf("Downloading %s...", fileName), i+1, totalFiles)
135-
136- if err := p.downloadFile(file.url, file.destPath); err != nil {
137- errMsg := fmt.Errorf("failed to download %s: %w", fileName, err)
138- p.SendError(progressChan, errMsg)
139- return PhaseResult{Success: false, Error: errMsg}
140- }
141-
142- p.SendOutput(progressChan, fmt.Sprintf("[OK] %s", fileName))
143- }
144-
145- p.SendComplete(progressChan, "Bootstrap complete")
146- return PhaseResult{Success: true, Message: "All configuration files downloaded"}
147-}
148-
149-func (p *BootstrapPhase) cloneRepo() error {
150- if p.config.RepoURL == "" {
151- return fmt.Errorf("repo URL not configured")
152- }
153- if err := p.fs.RemoveAll(config.DefaultInstallRepoDir); err != nil {
154- return fmt.Errorf("failed to clear repo directory: %w", err)
155- }
156-
157- branch := branchFromRawURL(p.config.RawURL)
158- args := []string{"clone", "--depth", "1", "--branch", branch, "--single-branch", p.config.RepoURL, config.DefaultInstallRepoDir}
159- cmd := exec.Command("git", args...)
160- output, err := cmd.CombinedOutput()
161- if err != nil {
162- return fmt.Errorf("failed to clone repo: %w (output: %s)", err, strings.TrimSpace(string(output)))
163- }
164-
165- return nil
166-}
167-
168-func (p *BootstrapPhase) copyRepoFiles(progressChan chan<- ProgressUpdate) error {
169- repoRoot := config.DefaultInstallRepoDir
170- files := []struct {
171- src string
172- dest string
173- }{
174- {filepath.Join(repoRoot, "install", "base.packages"), filepath.Join(config.DefaultInstallDir, "base.packages")},
175- {filepath.Join(repoRoot, "install", "extra.packages"), filepath.Join(config.DefaultInstallDir, "extra.packages")},
176- {filepath.Join(repoRoot, "install", "configs", "limine.conf.template"), filepath.Join(config.DefaultInstallDir, "configs", "limine.conf.template")},
177- {filepath.Join(repoRoot, "install", "configs", "chaotic-aur.conf"), filepath.Join(config.DefaultInstallDir, "configs", "chaotic-aur.conf")},
178- {filepath.Join(repoRoot, "install", "configs", "shell", "shell"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "shell")},
179- {filepath.Join(repoRoot, "install", "configs", "shell", "starship.toml"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "starship.toml")},
180- {filepath.Join(repoRoot, "install", "configs", "shell", "init"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "init")},
181- {filepath.Join(repoRoot, "install", "configs", "shell", "aliases"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "aliases")},
182- {filepath.Join(repoRoot, "install", "configs", "shell", "envs"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "envs")},
183- {filepath.Join(repoRoot, "install", "configs", "shell", "rc"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "rc")},
184- {filepath.Join(repoRoot, "install", "configs", "shell", "bashrc"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "bashrc")},
185- {filepath.Join(repoRoot, config.ArchLogoURL), filepath.Join(config.DefaultInstallDir, config.ArchLogoURL)},
186- }
187-
188- for _, file := range config.PlymouthFiles {
189- files = append(files, struct {
190- src string
191- dest string
192- }{
193- src: filepath.Join(repoRoot, "assets", "plymouth", file),
194- dest: filepath.Join(config.DefaultInstallDir, "assets", "plymouth", file),
195- })
196- }
197-
198- for i, file := range files {
199- fileName := filepath.Base(file.dest)
200- p.SendProgress(progressChan, fmt.Sprintf("Preparing %s...", fileName), i+1, len(files))
201- if err := p.copyFile(file.src, file.dest); err != nil {
202- return fmt.Errorf("failed to prepare %s: %w", fileName, err)
203- }
204- }
205-
206- return nil
207-}
208-
209-func (p *BootstrapPhase) copyFile(src, dest string) error {
210- if err := p.fs.MkdirAll(filepath.Dir(dest), 0755); err != nil {
211- return fmt.Errorf("failed to create directory: %w", err)
212- }
213- content, err := p.fs.ReadFile(src)
214- if err != nil {
215- return fmt.Errorf("failed to read %s: %w", src, err)
216- }
217- if err := p.fs.WriteFile(dest, content, 0644); err != nil {
218- return fmt.Errorf("failed to write %s: %w", dest, err)
219- }
220- return nil
221-}
222-
223-func branchFromRawURL(rawURL string) string {
224- const prefix = "https://raw.githubusercontent.com/"
225- if !strings.HasPrefix(rawURL, prefix) {
226- return "main"
227- }
228- trimmed := strings.TrimPrefix(rawURL, prefix)
229- parts := strings.Split(trimmed, "/")
230- if len(parts) < 3 {
231- return "main"
232- }
233- return parts[2]
234-}
235-
236-// downloadFile downloads a file from URL to destPath
237-func (p *BootstrapPhase) downloadFile(url, destPath string) error {
238- // Create destination directory if needed
239- destDir := filepath.Dir(destPath)
240- if err := p.fs.MkdirAll(destDir, 0755); err != nil {
241- return fmt.Errorf("failed to create directory: %w", err)
242- }
243-
244- // Download file
245- resp, err := p.httpClient.Get(url)
246- if err != nil {
247- return fmt.Errorf("HTTP request failed: %w", err)
248- }
249-
250- if resp.StatusCode != http.StatusOK {
251- if closeErr := resp.Body.Close(); closeErr != nil {
252- return fmt.Errorf("failed to close response: %w", closeErr)
253- }
254- return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
255- }
256-
257- // Create destination file
258- destFile, err := p.fs.Create(destPath)
259- if err != nil {
260- return fmt.Errorf("failed to create file: %w", err)
261- }
262- defer func() {
263- if err := destFile.Close(); err != nil {
264- p.logger.Warn("Failed to close file", "error", err)
265- }
266- }()
267-
268- // Copy content
269- if _, err := io.Copy(destFile, resp.Body); err != nil {
270- if closeErr := resp.Body.Close(); closeErr != nil {
271- return fmt.Errorf("failed to close response: %w", closeErr)
272- }
273- return fmt.Errorf("failed to write file: %w", err)
274- }
275- if closeErr := resp.Body.Close(); closeErr != nil {
276- return fmt.Errorf("failed to close response: %w", closeErr)
277- }
278-
279- return nil
280-}
281-
282-// Rollback removes downloaded files
283-func (p *BootstrapPhase) Rollback() error {
284- // Remove install directory
285- if err := p.fs.RemoveAll(config.DefaultInstallDir); err != nil {
286- return fmt.Errorf("failed to remove install directory: %w", err)
287- }
288-
289- return nil
290-}
D internal/phases/bootstrap_test.go +0 -366
@@ -1,366 +0,0 @@
1-package phases
2-
3-import (
4- "bytes"
5- "fmt"
6- "io"
7- "net/http"
8- "path/filepath"
9- "testing"
10-
11- "github.com/bnema/archup/internal/config"
12- "github.com/bnema/archup/internal/interfaces/mocks"
13- "github.com/bnema/archup/internal/logger"
14- "go.uber.org/mock/gomock"
15-)
16-
17-// TestBootstrapPhasePreCheck tests internet connectivity validation
18-func TestBootstrapPhasePreCheck(t *testing.T) {
19- tests := []struct {
20- name string
21- setupMocks func(*mocks.MockHTTPClient)
22- wantErr bool
23- errContains string
24- }{
25- {
26- name: "Internet connectivity - success",
27- setupMocks: func(mockHTTP *mocks.MockHTTPClient) {
28- resp := &http.Response{
29- StatusCode: http.StatusOK,
30- Body: io.NopCloser(bytes.NewBufferString("")),
31- }
32- mockHTTP.EXPECT().Get("https://raw.githubusercontent.com").Return(resp, nil).Times(1)
33- },
34- wantErr: false,
35- },
36- {
37- name: "Internet connectivity - connection failed",
38- setupMocks: func(mockHTTP *mocks.MockHTTPClient) {
39- mockHTTP.EXPECT().Get("https://raw.githubusercontent.com").Return(nil, fmt.Errorf("connection refused")).Times(1)
40- },
41- wantErr: true,
42- errContains: "no internet connectivity",
43- },
44- {
45- name: "Internet connectivity - timeout",
46- setupMocks: func(mockHTTP *mocks.MockHTTPClient) {
47- mockHTTP.EXPECT().Get("https://raw.githubusercontent.com").Return(nil, fmt.Errorf("context deadline exceeded")).Times(1)
48- },
49- wantErr: true,
50- errContains: "no internet connectivity",
51- },
52- }
53-
54- for _, tt := range tests {
55- t.Run(tt.name, func(t *testing.T) {
56- ctrl := gomock.NewController(t)
57- defer ctrl.Finish()
58-
59- mockHTTP := mocks.NewMockHTTPClient(ctrl)
60- mockFS := mocks.NewMockFileSystem(ctrl)
61-
62- tt.setupMocks(mockHTTP)
63-
64- tmpDir := t.TempDir()
65- logPath := filepath.Join(tmpDir, "test.log")
66- log, err := logger.New(logPath, false)
67- if err != nil {
68- t.Fatalf("Failed to create logger: %v", err)
69- }
70- defer func() {
71- if err := log.Close(); err != nil {
72- t.Fatalf("failed to close log: %v", err)
73- }
74- }()
75-
76- cfg := config.NewConfig("test")
77- phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
78-
79- err = phase.PreCheck()
80- if (err != nil) != tt.wantErr {
81- t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr)
82- return
83- }
84- if tt.wantErr && err != nil {
85- if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
86- t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
87- }
88- }
89- })
90- }
91-}
92-
93-// TestBootstrapPhaseDownloadFile tests file download functionality
94-func TestBootstrapPhaseDownloadFile(t *testing.T) {
95- tests := []struct {
96- name string
97- url string
98- setupMocks func(*mocks.MockHTTPClient, *mocks.MockFileSystem)
99- wantErr bool
100- errContains string
101- }{
102- {
103- name: "Download file - success",
104- url: "https://example.com/file.txt",
105- setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) {
106- mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1)
107-
108- resp := &http.Response{
109- StatusCode: http.StatusOK,
110- Status: "200 OK",
111- Body: io.NopCloser(bytes.NewBufferString("file content")),
112- }
113- mockHTTP.EXPECT().Get("https://example.com/file.txt").Return(resp, nil).Times(1)
114-
115- mockFile := NewMockWriteCloser()
116- mockFS.EXPECT().Create(gomock.Any()).Return(mockFile, nil).Times(1)
117- },
118- wantErr: false,
119- },
120- {
121- name: "Download file - HTTP 404 not found",
122- url: "https://example.com/missing.txt",
123- setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) {
124- mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1)
125-
126- resp := &http.Response{
127- StatusCode: http.StatusNotFound,
128- Status: "404 Not Found",
129- Body: io.NopCloser(bytes.NewBufferString("")),
130- }
131- mockHTTP.EXPECT().Get("https://example.com/missing.txt").Return(resp, nil).Times(1)
132- },
133- wantErr: true,
134- errContains: "HTTP 404",
135- },
136- {
137- name: "Download file - HTTP 500 server error",
138- url: "https://example.com/error.txt",
139- setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) {
140- mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1)
141-
142- resp := &http.Response{
143- StatusCode: http.StatusInternalServerError,
144- Status: "500 Internal Server Error",
145- Body: io.NopCloser(bytes.NewBufferString("")),
146- }
147- mockHTTP.EXPECT().Get("https://example.com/error.txt").Return(resp, nil).Times(1)
148- },
149- wantErr: true,
150- errContains: "HTTP 500",
151- },
152- {
153- name: "Download file - HTTP request failed",
154- url: "https://example.com/file.txt",
155- setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) {
156- mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1)
157-
158- mockHTTP.EXPECT().Get("https://example.com/file.txt").Return(nil, fmt.Errorf("connection timeout")).Times(1)
159- },
160- wantErr: true,
161- errContains: "HTTP request failed",
162- },
163- {
164- name: "Download file - directory creation failed",
165- url: "https://example.com/file.txt",
166- setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) {
167- mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(fmt.Errorf("permission denied")).Times(1)
168- },
169- wantErr: true,
170- errContains: "failed to create directory",
171- },
172- {
173- name: "Download file - file creation failed",
174- url: "https://example.com/file.txt",
175- setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) {
176- mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1)
177-
178- resp := &http.Response{
179- StatusCode: http.StatusOK,
180- Status: "200 OK",
181- Body: io.NopCloser(bytes.NewBufferString("content")),
182- }
183- mockHTTP.EXPECT().Get("https://example.com/file.txt").Return(resp, nil).Times(1)
184-
185- mockFS.EXPECT().Create(gomock.Any()).Return(nil, fmt.Errorf("no space left")).Times(1)
186- },
187- wantErr: true,
188- errContains: "failed to create file",
189- },
190- }
191-
192- for _, tt := range tests {
193- t.Run(tt.name, func(t *testing.T) {
194- ctrl := gomock.NewController(t)
195- defer ctrl.Finish()
196-
197- mockHTTP := mocks.NewMockHTTPClient(ctrl)
198- mockFS := mocks.NewMockFileSystem(ctrl)
199-
200- tt.setupMocks(mockHTTP, mockFS)
201-
202- tmpDir := t.TempDir()
203- logPath := filepath.Join(tmpDir, "test.log")
204- log, err := logger.New(logPath, false)
205- if err != nil {
206- t.Fatalf("Failed to create logger: %v", err)
207- }
208- defer func() {
209- if err := log.Close(); err != nil {
210- t.Fatalf("failed to close log: %v", err)
211- }
212- }()
213-
214- cfg := config.NewConfig("test")
215- phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
216-
217- err = phase.downloadFile(tt.url, filepath.Join(tmpDir, "dest.txt"))
218- if (err != nil) != tt.wantErr {
219- t.Errorf("downloadFile() error = %v, wantErr %v", err, tt.wantErr)
220- return
221- }
222- if tt.wantErr && err != nil {
223- if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
224- t.Errorf("downloadFile() error = %q, should contain %q", errMsg, tt.errContains)
225- }
226- }
227- })
228- }
229-}
230-
231-// TestBootstrapPhaseExecute tests the Execute method
232-func TestBootstrapPhaseExecute(t *testing.T) {
233- ctrl := gomock.NewController(t)
234- defer ctrl.Finish()
235-
236- mockHTTP := mocks.NewMockHTTPClient(ctrl)
237- mockFS := mocks.NewMockFileSystem(ctrl)
238-
239- // Setup successful downloads
240- mockFS.EXPECT().MkdirAll(config.DefaultInstallDir, gomock.Any()).Return(nil).Times(1)
241-
242- // Expect MkdirAll calls for each file's directory
243- mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
244-
245- // Expect Create calls for each file
246- mockFile := NewMockWriteCloser()
247- mockFS.EXPECT().Create(gomock.Any()).Return(mockFile, nil).AnyTimes()
248-
249- // Expect HTTP Get calls for each file
250- resp := &http.Response{
251- StatusCode: http.StatusOK,
252- Status: "200 OK",
253- Body: io.NopCloser(bytes.NewBufferString("file content")),
254- }
255- mockHTTP.EXPECT().Get(gomock.Any()).Return(resp, nil).AnyTimes()
256-
257- tmpDir := t.TempDir()
258- logPath := filepath.Join(tmpDir, "test.log")
259- log, err := logger.New(logPath, false)
260- if err != nil {
261- t.Fatalf("Failed to create logger: %v", err)
262- }
263- defer func() {
264- if err := log.Close(); err != nil {
265- t.Fatalf("failed to close log: %v", err)
266- }
267- }()
268-
269- cfg := config.NewConfig("test")
270- cfg.RepoURL = ""
271- cfg.RawURL = "https://example.com"
272- phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
273-
274- progressChan := make(chan ProgressUpdate, 100)
275- result := phase.Execute(progressChan)
276-
277- if !result.Success {
278- t.Errorf("Execute() Success = %v, want true", result.Success)
279- }
280- if result.Message != "All configuration files downloaded" {
281- t.Errorf("Execute() Message = %q, want 'All configuration files downloaded'", result.Message)
282- }
283-}
284-
285-// TestBootstrapPhaseRollback tests rollback cleanup
286-func TestBootstrapPhaseRollback(t *testing.T) {
287- tests := []struct {
288- name string
289- setupMocks func(*mocks.MockFileSystem)
290- wantErr bool
291- errContains string
292- }{
293- {
294- name: "Rollback - success",
295- setupMocks: func(mockFS *mocks.MockFileSystem) {
296- mockFS.EXPECT().RemoveAll(config.DefaultInstallDir).Return(nil).Times(1)
297- },
298- wantErr: false,
299- },
300- {
301- name: "Rollback - directory removal failed",
302- setupMocks: func(mockFS *mocks.MockFileSystem) {
303- mockFS.EXPECT().RemoveAll(config.DefaultInstallDir).Return(fmt.Errorf("permission denied")).Times(1)
304- },
305- wantErr: true,
306- errContains: "failed to remove install directory",
307- },
308- }
309-
310- for _, tt := range tests {
311- t.Run(tt.name, func(t *testing.T) {
312- ctrl := gomock.NewController(t)
313- defer ctrl.Finish()
314-
315- mockHTTP := mocks.NewMockHTTPClient(ctrl)
316- mockFS := mocks.NewMockFileSystem(ctrl)
317-
318- tt.setupMocks(mockFS)
319-
320- tmpDir := t.TempDir()
321- logPath := filepath.Join(tmpDir, "test.log")
322- log, err := logger.New(logPath, false)
323- if err != nil {
324- t.Fatalf("Failed to create logger: %v", err)
325- }
326- defer func() {
327- if err := log.Close(); err != nil {
328- t.Fatalf("failed to close log: %v", err)
329- }
330- }()
331-
332- cfg := config.NewConfig("test")
333- phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS)
334-
335- err = phase.Rollback()
336- if (err != nil) != tt.wantErr {
337- t.Errorf("Rollback() error = %v, wantErr %v", err, tt.wantErr)
338- return
339- }
340- if tt.wantErr && err != nil {
341- if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
342- t.Errorf("Rollback() error = %q, should contain %q", errMsg, tt.errContains)
343- }
344- }
345- })
346- }
347-}
348-
349-// MockWriteCloser is a mock implementation of io.WriteCloser
350-type MockWriteCloser struct {
351- buf bytes.Buffer
352-}
353-
354-func NewMockWriteCloser() *MockWriteCloser {
355- return &MockWriteCloser{}
356-}
357-
358-func (m *MockWriteCloser) Write(p []byte) (n int, err error) {
359- return m.buf.Write(p)
360-}
361-
362-func (m *MockWriteCloser) Close() error {
363- return nil
364-}
365-
366-// containsSubstring is defined in helpers_test.go to avoid duplication
D internal/phases/helpers_test.go +0 -26
@@ -1,26 +0,0 @@
1-package phases
2-
3-import (
4- "os"
5- "time"
6-)
7-
8-// containsSubstring checks if a string contains a substring
9-func containsSubstring(s, substr string) bool {
10- for i := 0; i < len(s)-len(substr)+1; i++ {
11- if s[i:i+len(substr)] == substr {
12- return true
13- }
14- }
15- return false
16-}
17-
18-// mockFileInfo implements os.FileInfo for testing
19-type mockFileInfo struct{}
20-
21-func (m mockFileInfo) Name() string { return "mock" }
22-func (m mockFileInfo) Size() int64 { return 0 }
23-func (m mockFileInfo) Mode() os.FileMode { return 0 }
24-func (m mockFileInfo) ModTime() time.Time { return time.Now() }
25-func (m mockFileInfo) IsDir() bool { return false }
26-func (m mockFileInfo) Sys() interface{} { return nil }
D internal/phases/mocks/mock_interfaces.go +0 -477
@@ -1,477 +0,0 @@
1-// Code generated by MockGen. DO NOT EDIT.
2-// Source: internal/interfaces/interfaces.go
3-//
4-// Generated by this command:
5-//
6-// mockgen -source=internal/interfaces/interfaces.go -destination=internal/phases/mocks/mock_interfaces.go -package=mocks
7-//
8-
9-// Package mocks is a generated GoMock package.
10-package mocks
11-
12-import (
13- context "context"
14- io "io"
15- http "net/http"
16- os "os"
17- reflect "reflect"
18-
19- system "github.com/bnema/archup/internal/system"
20- gomock "go.uber.org/mock/gomock"
21-)
22-
23-// MockFileSystem is a mock of FileSystem interface.
24-type MockFileSystem struct {
25- ctrl *gomock.Controller
26- recorder *MockFileSystemMockRecorder
27- isgomock struct{}
28-}
29-
30-// MockFileSystemMockRecorder is the mock recorder for MockFileSystem.
31-type MockFileSystemMockRecorder struct {
32- mock *MockFileSystem
33-}
34-
35-// NewMockFileSystem creates a new mock instance.
36-func NewMockFileSystem(ctrl *gomock.Controller) *MockFileSystem {
37- mock := &MockFileSystem{ctrl: ctrl}
38- mock.recorder = &MockFileSystemMockRecorder{mock}
39- return mock
40-}
41-
42-// EXPECT returns an object that allows the caller to indicate expected use.
43-func (m *MockFileSystem) EXPECT() *MockFileSystemMockRecorder {
44- return m.recorder
45-}
46-
47-// Chmod mocks base method.
48-func (m *MockFileSystem) Chmod(name string, mode os.FileMode) error {
49- m.ctrl.T.Helper()
50- ret := m.ctrl.Call(m, "Chmod", name, mode)
51- ret0, _ := ret[0].(error)
52- return ret0
53-}
54-
55-// Chmod indicates an expected call of Chmod.
56-func (mr *MockFileSystemMockRecorder) Chmod(name, mode any) *gomock.Call {
57- mr.mock.ctrl.T.Helper()
58- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Chmod", reflect.TypeOf((*MockFileSystem)(nil).Chmod), name, mode)
59-}
60-
61-// Create mocks base method.
62-func (m *MockFileSystem) Create(name string) (io.WriteCloser, error) {
63- m.ctrl.T.Helper()
64- ret := m.ctrl.Call(m, "Create", name)
65- ret0, _ := ret[0].(io.WriteCloser)
66- ret1, _ := ret[1].(error)
67- return ret0, ret1
68-}
69-
70-// Create indicates an expected call of Create.
71-func (mr *MockFileSystemMockRecorder) Create(name any) *gomock.Call {
72- mr.mock.ctrl.T.Helper()
73- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockFileSystem)(nil).Create), name)
74-}
75-
76-// IsNotExist mocks base method.
77-func (m *MockFileSystem) IsNotExist(err error) bool {
78- m.ctrl.T.Helper()
79- ret := m.ctrl.Call(m, "IsNotExist", err)
80- ret0, _ := ret[0].(bool)
81- return ret0
82-}
83-
84-// IsNotExist indicates an expected call of IsNotExist.
85-func (mr *MockFileSystemMockRecorder) IsNotExist(err any) *gomock.Call {
86- mr.mock.ctrl.T.Helper()
87- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsNotExist", reflect.TypeOf((*MockFileSystem)(nil).IsNotExist), err)
88-}
89-
90-// MkdirAll mocks base method.
91-func (m *MockFileSystem) MkdirAll(path string, perm os.FileMode) error {
92- m.ctrl.T.Helper()
93- ret := m.ctrl.Call(m, "MkdirAll", path, perm)
94- ret0, _ := ret[0].(error)
95- return ret0
96-}
97-
98-// MkdirAll indicates an expected call of MkdirAll.
99-func (mr *MockFileSystemMockRecorder) MkdirAll(path, perm any) *gomock.Call {
100- mr.mock.ctrl.T.Helper()
101- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MkdirAll", reflect.TypeOf((*MockFileSystem)(nil).MkdirAll), path, perm)
102-}
103-
104-// Open mocks base method.
105-func (m *MockFileSystem) Open(name string) (io.ReadCloser, error) {
106- m.ctrl.T.Helper()
107- ret := m.ctrl.Call(m, "Open", name)
108- ret0, _ := ret[0].(io.ReadCloser)
109- ret1, _ := ret[1].(error)
110- return ret0, ret1
111-}
112-
113-// Open indicates an expected call of Open.
114-func (mr *MockFileSystemMockRecorder) Open(name any) *gomock.Call {
115- mr.mock.ctrl.T.Helper()
116- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Open", reflect.TypeOf((*MockFileSystem)(nil).Open), name)
117-}
118-
119-// ReadFile mocks base method.
120-func (m *MockFileSystem) ReadFile(filename string) ([]byte, error) {
121- m.ctrl.T.Helper()
122- ret := m.ctrl.Call(m, "ReadFile", filename)
123- ret0, _ := ret[0].([]byte)
124- ret1, _ := ret[1].(error)
125- return ret0, ret1
126-}
127-
128-// ReadFile indicates an expected call of ReadFile.
129-func (mr *MockFileSystemMockRecorder) ReadFile(filename any) *gomock.Call {
130- mr.mock.ctrl.T.Helper()
131- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadFile", reflect.TypeOf((*MockFileSystem)(nil).ReadFile), filename)
132-}
133-
134-// RemoveAll mocks base method.
135-func (m *MockFileSystem) RemoveAll(path string) error {
136- m.ctrl.T.Helper()
137- ret := m.ctrl.Call(m, "RemoveAll", path)
138- ret0, _ := ret[0].(error)
139- return ret0
140-}
141-
142-// RemoveAll indicates an expected call of RemoveAll.
143-func (mr *MockFileSystemMockRecorder) RemoveAll(path any) *gomock.Call {
144- mr.mock.ctrl.T.Helper()
145- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveAll", reflect.TypeOf((*MockFileSystem)(nil).RemoveAll), path)
146-}
147-
148-// Stat mocks base method.
149-func (m *MockFileSystem) Stat(name string) (os.FileInfo, error) {
150- m.ctrl.T.Helper()
151- ret := m.ctrl.Call(m, "Stat", name)
152- ret0, _ := ret[0].(os.FileInfo)
153- ret1, _ := ret[1].(error)
154- return ret0, ret1
155-}
156-
157-// Stat indicates an expected call of Stat.
158-func (mr *MockFileSystemMockRecorder) Stat(name any) *gomock.Call {
159- mr.mock.ctrl.T.Helper()
160- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stat", reflect.TypeOf((*MockFileSystem)(nil).Stat), name)
161-}
162-
163-// WriteFile mocks base method.
164-func (m *MockFileSystem) WriteFile(filename string, data []byte, perm os.FileMode) error {
165- m.ctrl.T.Helper()
166- ret := m.ctrl.Call(m, "WriteFile", filename, data, perm)
167- ret0, _ := ret[0].(error)
168- return ret0
169-}
170-
171-// WriteFile indicates an expected call of WriteFile.
172-func (mr *MockFileSystemMockRecorder) WriteFile(filename, data, perm any) *gomock.Call {
173- mr.mock.ctrl.T.Helper()
174- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WriteFile", reflect.TypeOf((*MockFileSystem)(nil).WriteFile), filename, data, perm)
175-}
176-
177-// MockCommandExecutor is a mock of CommandExecutor interface.
178-type MockCommandExecutor struct {
179- ctrl *gomock.Controller
180- recorder *MockCommandExecutorMockRecorder
181- isgomock struct{}
182-}
183-
184-// MockCommandExecutorMockRecorder is the mock recorder for MockCommandExecutor.
185-type MockCommandExecutorMockRecorder struct {
186- mock *MockCommandExecutor
187-}
188-
189-// NewMockCommandExecutor creates a new mock instance.
190-func NewMockCommandExecutor(ctrl *gomock.Controller) *MockCommandExecutor {
191- mock := &MockCommandExecutor{ctrl: ctrl}
192- mock.recorder = &MockCommandExecutorMockRecorder{mock}
193- return mock
194-}
195-
196-// EXPECT returns an object that allows the caller to indicate expected use.
197-func (m *MockCommandExecutor) EXPECT() *MockCommandExecutorMockRecorder {
198- return m.recorder
199-}
200-
201-// Execute mocks base method.
202-func (m *MockCommandExecutor) Execute(name string, args ...string) ([]byte, error) {
203- m.ctrl.T.Helper()
204- varargs := []any{name}
205- for _, a := range args {
206- varargs = append(varargs, a)
207- }
208- ret := m.ctrl.Call(m, "Execute", varargs...)
209- ret0, _ := ret[0].([]byte)
210- ret1, _ := ret[1].(error)
211- return ret0, ret1
212-}
213-
214-// Execute indicates an expected call of Execute.
215-func (mr *MockCommandExecutorMockRecorder) Execute(name any, args ...any) *gomock.Call {
216- mr.mock.ctrl.T.Helper()
217- varargs := append([]any{name}, args...)
218- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockCommandExecutor)(nil).Execute), varargs...)
219-}
220-
221-// MockSystemExecutor is a mock of SystemExecutor interface.
222-type MockSystemExecutor struct {
223- ctrl *gomock.Controller
224- recorder *MockSystemExecutorMockRecorder
225- isgomock struct{}
226-}
227-
228-// MockSystemExecutorMockRecorder is the mock recorder for MockSystemExecutor.
229-type MockSystemExecutorMockRecorder struct {
230- mock *MockSystemExecutor
231-}
232-
233-// NewMockSystemExecutor creates a new mock instance.
234-func NewMockSystemExecutor(ctrl *gomock.Controller) *MockSystemExecutor {
235- mock := &MockSystemExecutor{ctrl: ctrl}
236- mock.recorder = &MockSystemExecutorMockRecorder{mock}
237- return mock
238-}
239-
240-// EXPECT returns an object that allows the caller to indicate expected use.
241-func (m *MockSystemExecutor) EXPECT() *MockSystemExecutorMockRecorder {
242- return m.recorder
243-}
244-
245-// DetectCPUInfo mocks base method.
246-func (m *MockSystemExecutor) DetectCPUInfo() (*system.CPUInfo, error) {
247- m.ctrl.T.Helper()
248- ret := m.ctrl.Call(m, "DetectCPUInfo")
249- ret0, _ := ret[0].(*system.CPUInfo)
250- ret1, _ := ret[1].(error)
251- return ret0, ret1
252-}
253-
254-// DetectCPUInfo indicates an expected call of DetectCPUInfo.
255-func (mr *MockSystemExecutorMockRecorder) DetectCPUInfo() *gomock.Call {
256- mr.mock.ctrl.T.Helper()
257- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DetectCPUInfo", reflect.TypeOf((*MockSystemExecutor)(nil).DetectCPUInfo))
258-}
259-
260-// RunSimple mocks base method.
261-func (m *MockSystemExecutor) RunSimple(command string, args ...string) system.CommandResult {
262- m.ctrl.T.Helper()
263- varargs := []any{command}
264- for _, a := range args {
265- varargs = append(varargs, a)
266- }
267- ret := m.ctrl.Call(m, "RunSimple", varargs...)
268- ret0, _ := ret[0].(system.CommandResult)
269- return ret0
270-}
271-
272-// RunSimple indicates an expected call of RunSimple.
273-func (mr *MockSystemExecutorMockRecorder) RunSimple(command any, args ...any) *gomock.Call {
274- mr.mock.ctrl.T.Helper()
275- varargs := append([]any{command}, args...)
276- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunSimple", reflect.TypeOf((*MockSystemExecutor)(nil).RunSimple), varargs...)
277-}
278-
279-// MockChrootExecutor is a mock of ChrootExecutor interface.
280-type MockChrootExecutor struct {
281- ctrl *gomock.Controller
282- recorder *MockChrootExecutorMockRecorder
283- isgomock struct{}
284-}
285-
286-// MockChrootExecutorMockRecorder is the mock recorder for MockChrootExecutor.
287-type MockChrootExecutorMockRecorder struct {
288- mock *MockChrootExecutor
289-}
290-
291-// NewMockChrootExecutor creates a new mock instance.
292-func NewMockChrootExecutor(ctrl *gomock.Controller) *MockChrootExecutor {
293- mock := &MockChrootExecutor{ctrl: ctrl}
294- mock.recorder = &MockChrootExecutorMockRecorder{mock}
295- return mock
296-}
297-
298-// EXPECT returns an object that allows the caller to indicate expected use.
299-func (m *MockChrootExecutor) EXPECT() *MockChrootExecutorMockRecorder {
300- return m.recorder
301-}
302-
303-// ChrootExec mocks base method.
304-func (m *MockChrootExecutor) ChrootExec(logPath, mountPoint, command string, args ...string) error {
305- m.ctrl.T.Helper()
306- varargs := []any{logPath, mountPoint, command}
307- for _, a := range args {
308- varargs = append(varargs, a)
309- }
310- ret := m.ctrl.Call(m, "ChrootExec", varargs...)
311- ret0, _ := ret[0].(error)
312- return ret0
313-}
314-
315-// ChrootExec indicates an expected call of ChrootExec.
316-func (mr *MockChrootExecutorMockRecorder) ChrootExec(logPath, mountPoint, command any, args ...any) *gomock.Call {
317- mr.mock.ctrl.T.Helper()
318- varargs := append([]any{logPath, mountPoint, command}, args...)
319- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootExec", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootExec), varargs...)
320-}
321-
322-// ChrootExecWithContext mocks base method.
323-func (m *MockChrootExecutor) ChrootExecWithContext(ctx context.Context, logPath, mountPoint, command string) error {
324- m.ctrl.T.Helper()
325- ret := m.ctrl.Call(m, "ChrootExecWithContext", ctx, logPath, mountPoint, command)
326- ret0, _ := ret[0].(error)
327- return ret0
328-}
329-
330-// ChrootExecWithContext indicates an expected call of ChrootExecWithContext.
331-func (mr *MockChrootExecutorMockRecorder) ChrootExecWithContext(ctx, logPath, mountPoint, command any) *gomock.Call {
332- mr.mock.ctrl.T.Helper()
333- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootExecWithContext", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootExecWithContext), ctx, logPath, mountPoint, command)
334-}
335-
336-// ChrootExecWithStdin mocks base method.
337-func (m *MockChrootExecutor) ChrootExecWithStdin(logPath, mountPoint, command, stdin string) error {
338- m.ctrl.T.Helper()
339- ret := m.ctrl.Call(m, "ChrootExecWithStdin", logPath, mountPoint, command, stdin)
340- ret0, _ := ret[0].(error)
341- return ret0
342-}
343-
344-// ChrootExecWithStdin indicates an expected call of ChrootExecWithStdin.
345-func (mr *MockChrootExecutorMockRecorder) ChrootExecWithStdin(logPath, mountPoint, command, stdin any) *gomock.Call {
346- mr.mock.ctrl.T.Helper()
347- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootExecWithStdin", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootExecWithStdin), logPath, mountPoint, command, stdin)
348-}
349-
350-// ChrootPacman mocks base method.
351-func (m *MockChrootExecutor) ChrootPacman(logPath, mountPoint, operation string, packages ...string) error {
352- m.ctrl.T.Helper()
353- varargs := []any{logPath, mountPoint, operation}
354- for _, a := range packages {
355- varargs = append(varargs, a)
356- }
357- ret := m.ctrl.Call(m, "ChrootPacman", varargs...)
358- ret0, _ := ret[0].(error)
359- return ret0
360-}
361-
362-// ChrootPacman indicates an expected call of ChrootPacman.
363-func (mr *MockChrootExecutorMockRecorder) ChrootPacman(logPath, mountPoint, operation any, packages ...any) *gomock.Call {
364- mr.mock.ctrl.T.Helper()
365- varargs := append([]any{logPath, mountPoint, operation}, packages...)
366- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootPacman", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootPacman), varargs...)
367-}
368-
369-// ChrootSystemctl mocks base method.
370-func (m *MockChrootExecutor) ChrootSystemctl(logPath, mountPoint, action, service string) error {
371- m.ctrl.T.Helper()
372- ret := m.ctrl.Call(m, "ChrootSystemctl", logPath, mountPoint, action, service)
373- ret0, _ := ret[0].(error)
374- return ret0
375-}
376-
377-// ChrootSystemctl indicates an expected call of ChrootSystemctl.
378-func (mr *MockChrootExecutorMockRecorder) ChrootSystemctl(logPath, mountPoint, action, service any) *gomock.Call {
379- mr.mock.ctrl.T.Helper()
380- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootSystemctl", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootSystemctl), logPath, mountPoint, action, service)
381-}
382-
383-// DownloadAndInstallPackages mocks base method.
384-func (m *MockChrootExecutor) DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error {
385- m.ctrl.T.Helper()
386- varargs := []any{logPath, chrootPath}
387- for _, a := range urls {
388- varargs = append(varargs, a)
389- }
390- ret := m.ctrl.Call(m, "DownloadAndInstallPackages", varargs...)
391- ret0, _ := ret[0].(error)
392- return ret0
393-}
394-
395-// DownloadAndInstallPackages indicates an expected call of DownloadAndInstallPackages.
396-func (mr *MockChrootExecutorMockRecorder) DownloadAndInstallPackages(logPath, chrootPath any, urls ...any) *gomock.Call {
397- mr.mock.ctrl.T.Helper()
398- varargs := append([]any{logPath, chrootPath}, urls...)
399- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DownloadAndInstallPackages", reflect.TypeOf((*MockChrootExecutor)(nil).DownloadAndInstallPackages), varargs...)
400-}
401-
402-// MockHTTPClient is a mock of HTTPClient interface.
403-type MockHTTPClient struct {
404- ctrl *gomock.Controller
405- recorder *MockHTTPClientMockRecorder
406- isgomock struct{}
407-}
408-
409-// MockHTTPClientMockRecorder is the mock recorder for MockHTTPClient.
410-type MockHTTPClientMockRecorder struct {
411- mock *MockHTTPClient
412-}
413-
414-// NewMockHTTPClient creates a new mock instance.
415-func NewMockHTTPClient(ctrl *gomock.Controller) *MockHTTPClient {
416- mock := &MockHTTPClient{ctrl: ctrl}
417- mock.recorder = &MockHTTPClientMockRecorder{mock}
418- return mock
419-}
420-
421-// EXPECT returns an object that allows the caller to indicate expected use.
422-func (m *MockHTTPClient) EXPECT() *MockHTTPClientMockRecorder {
423- return m.recorder
424-}
425-
426-// Get mocks base method.
427-func (m *MockHTTPClient) Get(url string) (*http.Response, error) {
428- m.ctrl.T.Helper()
429- ret := m.ctrl.Call(m, "Get", url)
430- ret0, _ := ret[0].(*http.Response)
431- ret1, _ := ret[1].(error)
432- return ret0, ret1
433-}
434-
435-// Get indicates an expected call of Get.
436-func (mr *MockHTTPClientMockRecorder) Get(url any) *gomock.Call {
437- mr.mock.ctrl.T.Helper()
438- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockHTTPClient)(nil).Get), url)
439-}
440-
441-// MockConfigSaver is a mock of ConfigSaver interface.
442-type MockConfigSaver struct {
443- ctrl *gomock.Controller
444- recorder *MockConfigSaverMockRecorder
445- isgomock struct{}
446-}
447-
448-// MockConfigSaverMockRecorder is the mock recorder for MockConfigSaver.
449-type MockConfigSaverMockRecorder struct {
450- mock *MockConfigSaver
451-}
452-
453-// NewMockConfigSaver creates a new mock instance.
454-func NewMockConfigSaver(ctrl *gomock.Controller) *MockConfigSaver {
455- mock := &MockConfigSaver{ctrl: ctrl}
456- mock.recorder = &MockConfigSaverMockRecorder{mock}
457- return mock
458-}
459-
460-// EXPECT returns an object that allows the caller to indicate expected use.
461-func (m *MockConfigSaver) EXPECT() *MockConfigSaverMockRecorder {
462- return m.recorder
463-}
464-
465-// Save mocks base method.
466-func (m *MockConfigSaver) Save() error {
467- m.ctrl.T.Helper()
468- ret := m.ctrl.Call(m, "Save")
469- ret0, _ := ret[0].(error)
470- return ret0
471-}
472-
473-// Save indicates an expected call of Save.
474-func (mr *MockConfigSaverMockRecorder) Save() *gomock.Call {
475- mr.mock.ctrl.T.Helper()
476- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockConfigSaver)(nil).Save))
477-}
D internal/phases/mocks/mock_phase.go +0 -165
@@ -1,165 +0,0 @@
1-// Code generated by MockGen. DO NOT EDIT.
2-// Source: internal/phases/phase.go
3-//
4-// Generated by this command:
5-//
6-// mockgen -source=internal/phases/phase.go -destination=internal/phases/mocks/mock_phase.go -package=mocks
7-//
8-
9-// Package mocks is a generated GoMock package.
10-package mocks
11-
12-import (
13- reflect "reflect"
14-
15- phases "github.com/bnema/archup/internal/phases"
16- gomock "go.uber.org/mock/gomock"
17-)
18-
19-// MockPhase is a mock of Phase interface.
20-type MockPhase struct {
21- ctrl *gomock.Controller
22- recorder *MockPhaseMockRecorder
23- isgomock struct{}
24-}
25-
26-// MockPhaseMockRecorder is the mock recorder for MockPhase.
27-type MockPhaseMockRecorder struct {
28- mock *MockPhase
29-}
30-
31-// NewMockPhase creates a new mock instance.
32-func NewMockPhase(ctrl *gomock.Controller) *MockPhase {
33- mock := &MockPhase{ctrl: ctrl}
34- mock.recorder = &MockPhaseMockRecorder{mock}
35- return mock
36-}
37-
38-// EXPECT returns an object that allows the caller to indicate expected use.
39-func (m *MockPhase) EXPECT() *MockPhaseMockRecorder {
40- return m.recorder
41-}
42-
43-// CanSkip mocks base method.
44-func (m *MockPhase) CanSkip() bool {
45- m.ctrl.T.Helper()
46- ret := m.ctrl.Call(m, "CanSkip")
47- ret0, _ := ret[0].(bool)
48- return ret0
49-}
50-
51-// CanSkip indicates an expected call of CanSkip.
52-func (mr *MockPhaseMockRecorder) CanSkip() *gomock.Call {
53- mr.mock.ctrl.T.Helper()
54- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CanSkip", reflect.TypeOf((*MockPhase)(nil).CanSkip))
55-}
56-
57-// Description mocks base method.
58-func (m *MockPhase) Description() string {
59- m.ctrl.T.Helper()
60- ret := m.ctrl.Call(m, "Description")
61- ret0, _ := ret[0].(string)
62- return ret0
63-}
64-
65-// Description indicates an expected call of Description.
66-func (mr *MockPhaseMockRecorder) Description() *gomock.Call {
67- mr.mock.ctrl.T.Helper()
68- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Description", reflect.TypeOf((*MockPhase)(nil).Description))
69-}
70-
71-// Execute mocks base method.
72-func (m *MockPhase) Execute(progressChan chan<- phases.ProgressUpdate) phases.PhaseResult {
73- m.ctrl.T.Helper()
74- ret := m.ctrl.Call(m, "Execute", progressChan)
75- ret0, _ := ret[0].(phases.PhaseResult)
76- return ret0
77-}
78-
79-// Execute indicates an expected call of Execute.
80-func (mr *MockPhaseMockRecorder) Execute(progressChan any) *gomock.Call {
81- mr.mock.ctrl.T.Helper()
82- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockPhase)(nil).Execute), progressChan)
83-}
84-
85-// Name mocks base method.
86-func (m *MockPhase) Name() string {
87- m.ctrl.T.Helper()
88- ret := m.ctrl.Call(m, "Name")
89- ret0, _ := ret[0].(string)
90- return ret0
91-}
92-
93-// Name indicates an expected call of Name.
94-func (mr *MockPhaseMockRecorder) Name() *gomock.Call {
95- mr.mock.ctrl.T.Helper()
96- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockPhase)(nil).Name))
97-}
98-
99-// PostCheck mocks base method.
100-func (m *MockPhase) PostCheck() error {
101- m.ctrl.T.Helper()
102- ret := m.ctrl.Call(m, "PostCheck")
103- ret0, _ := ret[0].(error)
104- return ret0
105-}
106-
107-// PostCheck indicates an expected call of PostCheck.
108-func (mr *MockPhaseMockRecorder) PostCheck() *gomock.Call {
109- mr.mock.ctrl.T.Helper()
110- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostCheck", reflect.TypeOf((*MockPhase)(nil).PostCheck))
111-}
112-
113-// PreCheck mocks base method.
114-func (m *MockPhase) PreCheck() error {
115- m.ctrl.T.Helper()
116- ret := m.ctrl.Call(m, "PreCheck")
117- ret0, _ := ret[0].(error)
118- return ret0
119-}
120-
121-// PreCheck indicates an expected call of PreCheck.
122-func (mr *MockPhaseMockRecorder) PreCheck() *gomock.Call {
123- mr.mock.ctrl.T.Helper()
124- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreCheck", reflect.TypeOf((*MockPhase)(nil).PreCheck))
125-}
126-
127-// Rollback mocks base method.
128-func (m *MockPhase) Rollback() error {
129- m.ctrl.T.Helper()
130- ret := m.ctrl.Call(m, "Rollback")
131- ret0, _ := ret[0].(error)
132- return ret0
133-}
134-
135-// Rollback indicates an expected call of Rollback.
136-func (mr *MockPhaseMockRecorder) Rollback() *gomock.Call {
137- mr.mock.ctrl.T.Helper()
138- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Rollback", reflect.TypeOf((*MockPhase)(nil).Rollback))
139-}
140-
141-// SetStatus mocks base method.
142-func (m *MockPhase) SetStatus(status phases.PhaseStatus) {
143- m.ctrl.T.Helper()
144- m.ctrl.Call(m, "SetStatus", status)
145-}
146-
147-// SetStatus indicates an expected call of SetStatus.
148-func (mr *MockPhaseMockRecorder) SetStatus(status any) *gomock.Call {
149- mr.mock.ctrl.T.Helper()
150- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetStatus", reflect.TypeOf((*MockPhase)(nil).SetStatus), status)
151-}
152-
153-// Status mocks base method.
154-func (m *MockPhase) Status() phases.PhaseStatus {
155- m.ctrl.T.Helper()
156- ret := m.ctrl.Call(m, "Status")
157- ret0, _ := ret[0].(phases.PhaseStatus)
158- return ret0
159-}
160-
161-// Status indicates an expected call of Status.
162-func (mr *MockPhaseMockRecorder) Status() *gomock.Call {
163- mr.mock.ctrl.T.Helper()
164- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockPhase)(nil).Status))
165-}
D internal/phases/orchestrator.go +0 -202
@@ -1,202 +0,0 @@
1-package phases
2-
3-import (
4- "fmt"
5- "time"
6-
7- "github.com/bnema/archup/internal/config"
8-)
9-
10-// Orchestrator manages the execution of installation phases
11-type Orchestrator struct {
12- phases []Phase
13- currentIdx int
14- config *config.Config
15- logPath string
16- progressCh chan ProgressUpdate
17- installStartTime time.Time
18- phaseDurations map[string]time.Duration
19-}
20-
21-// NewOrchestrator creates a new phase orchestrator
22-func NewOrchestrator(cfg *config.Config, logPath string) *Orchestrator {
23- return &Orchestrator{
24- phases: make([]Phase, 0),
25- currentIdx: -1,
26- config: cfg,
27- logPath: logPath,
28- progressCh: make(chan ProgressUpdate, 100),
29- installStartTime: time.Now(),
30- phaseDurations: make(map[string]time.Duration),
31- }
32-}
33-
34-// RegisterPhase adds a phase to the execution sequence
35-func (o *Orchestrator) RegisterPhase(phase Phase) error {
36- if phase == nil {
37- return fmt.Errorf("cannot register nil phase")
38- }
39-
40- // Check for duplicate phase names
41- for _, p := range o.phases {
42- if p.Name() == phase.Name() {
43- return fmt.Errorf("phase %s already registered", phase.Name())
44- }
45- }
46-
47- o.phases = append(o.phases, phase)
48- return nil
49-}
50-
51-// Phases returns all registered phases
52-func (o *Orchestrator) Phases() []Phase {
53- return o.phases
54-}
55-
56-// CurrentPhase returns the currently executing phase, or nil
57-func (o *Orchestrator) CurrentPhase() Phase {
58- if o.currentIdx < 0 || o.currentIdx >= len(o.phases) {
59- return nil
60- }
61- return o.phases[o.currentIdx]
62-}
63-
64-// NextPhase returns the next phase to execute, or nil if done
65-func (o *Orchestrator) NextPhase() Phase {
66- // Find next pending phase
67- for i := o.currentIdx + 1; i < len(o.phases); i++ {
68- phase := o.phases[i]
69- if phase.Status() == StatusPending {
70- return phase
71- }
72- }
73- return nil
74-}
75-
76-// ProgressChannel returns the progress update channel
77-func (o *Orchestrator) ProgressChannel() <-chan ProgressUpdate {
78- return o.progressCh
79-}
80-
81-// ExecutePhase runs a specific phase
82-func (o *Orchestrator) ExecutePhase(phase Phase) error {
83- for i, p := range o.phases {
84- if p.Name() == phase.Name() {
85- o.currentIdx = i
86- break
87- }
88- }
89-
90- startTime := time.Now()
91-
92- phase.SetStatus(StatusRunning)
93- if err := phase.PreCheck(); err != nil {
94- phase.SetStatus(StatusFailed)
95- return fmt.Errorf("pre-check failed: %w", err)
96- }
97-
98- result := phase.Execute(o.progressCh)
99- duration := time.Since(startTime)
100- o.phaseDurations[phase.Name()] = duration
101-
102- if !result.Success {
103- phase.SetStatus(StatusFailed)
104-
105- if rbErr := phase.Rollback(); rbErr != nil {
106- return fmt.Errorf("execution failed: %v, rollback failed: %v", result.Error, rbErr)
107- }
108-
109- return fmt.Errorf("execution failed: %w", result.Error)
110- }
111-
112- if err := phase.PostCheck(); err != nil {
113- phase.SetStatus(StatusFailed)
114- return fmt.Errorf("post-check failed: %w", err)
115- }
116-
117- phase.SetStatus(StatusCompleted)
118- return nil
119-}
120-
121-// ExecuteNext executes the next pending phase
122-func (o *Orchestrator) ExecuteNext() error {
123- phase := o.NextPhase()
124- if phase == nil {
125- return fmt.Errorf("no pending phases")
126- }
127- return o.ExecutePhase(phase)
128-}
129-
130-// ExecuteAll executes all pending phases in sequence
131-func (o *Orchestrator) ExecuteAll() error {
132- for {
133- phase := o.NextPhase()
134- if phase == nil {
135- break
136- }
137-
138- if err := o.ExecutePhase(phase); err != nil {
139- return err
140- }
141- }
142- return nil
143-}
144-
145-// SkipPhase marks a phase as skipped
146-func (o *Orchestrator) SkipPhase(phase Phase) error {
147- if !phase.CanSkip() {
148- return fmt.Errorf("phase %s cannot be skipped", phase.Name())
149- }
150- phase.SetStatus(StatusSkipped)
151- return nil
152-}
153-
154-// Reset resets all phases to pending state
155-func (o *Orchestrator) Reset() {
156- o.currentIdx = -1
157- for _, phase := range o.phases {
158- phase.SetStatus(StatusPending)
159- }
160-}
161-
162-// Progress returns overall installation progress
163-func (o *Orchestrator) Progress() (completed int, total int) {
164- total = len(o.phases)
165- for _, phase := range o.phases {
166- if phase.Status() == StatusCompleted || phase.Status() == StatusSkipped {
167- completed++
168- }
169- }
170- return completed, total
171-}
172-
173-// IsComplete returns true if all phases are done
174-func (o *Orchestrator) IsComplete() bool {
175- for _, phase := range o.phases {
176- status := phase.Status()
177- if status != StatusCompleted && status != StatusSkipped {
178- return false
179- }
180- }
181- return true
182-}
183-
184-// HasFailed returns true if any phase has failed
185-func (o *Orchestrator) HasFailed() bool {
186- for _, phase := range o.phases {
187- if phase.Status() == StatusFailed {
188- return true
189- }
190- }
191- return false
192-}
193-
194-// GetTotalDuration returns total installation time
195-func (o *Orchestrator) GetTotalDuration() time.Duration {
196- return time.Since(o.installStartTime)
197-}
198-
199-// GetPhaseDurations returns timing breakdown by phase
200-func (o *Orchestrator) GetPhaseDurations() map[string]time.Duration {
201- return o.phaseDurations
202-}
D internal/phases/orchestrator_test.go +0 -611
@@ -1,611 +0,0 @@
1-package phases
2-
3-import (
4- "fmt"
5- "testing"
6-
7- "github.com/bnema/archup/internal/config"
8-)
9-
10-// testPhase is a simple Phase implementation for testing orchestrator
11-type testPhase struct {
12- *BasePhase
13- preCheckErr error
14- executeErr error
15- postCheckErr error
16- rollbackErr error
17- canSkip bool
18-}
19-
20-func newTestPhase(name string, cfg *config.Config) *testPhase {
21- return &testPhase{
22- BasePhase: NewBasePhase(name, "Test "+name, cfg, nil),
23- canSkip: false,
24- }
25-}
26-
27-func (p *testPhase) PreCheck() error {
28- return p.preCheckErr
29-}
30-
31-func (p *testPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
32- if p.executeErr != nil {
33- return PhaseResult{
34- Success: false,
35- Error: p.executeErr,
36- }
37- }
38- return PhaseResult{
39- Success: true,
40- Message: "completed",
41- }
42-}
43-
44-func (p *testPhase) PostCheck() error {
45- return p.postCheckErr
46-}
47-
48-func (p *testPhase) Rollback() error {
49- return p.rollbackErr
50-}
51-
52-func (p *testPhase) CanSkip() bool {
53- return p.canSkip
54-}
55-
56-// TestOrchestratorRegistration tests phase registration
57-func TestOrchestratorRegistration(t *testing.T) {
58- cfg := config.NewConfig("test")
59- orch := NewOrchestrator(cfg, "test.log")
60-
61- t.Run("register valid phase", func(t *testing.T) {
62- phase := newTestPhase("test-phase", cfg)
63- err := orch.RegisterPhase(phase)
64- if err != nil {
65- t.Errorf("Failed to register valid phase: %v", err)
66- }
67-
68- phases := orch.Phases()
69- if len(phases) != 1 {
70- t.Errorf("Expected 1 phase, got %d", len(phases))
71- }
72- })
73-
74- t.Run("register nil phase", func(t *testing.T) {
75- orch2 := NewOrchestrator(cfg, "test.log")
76- err := orch2.RegisterPhase(nil)
77- if err == nil {
78- t.Error("Expected error for nil phase, got nil")
79- }
80- })
81-
82- t.Run("register duplicate phase name", func(t *testing.T) {
83- orch2 := NewOrchestrator(cfg, "test.log")
84- phase1 := newTestPhase("duplicate", cfg)
85- phase2 := newTestPhase("duplicate", cfg)
86-
87- if err := orch2.RegisterPhase(phase1); err != nil {
88- t.Fatalf("expected no error, got %v", err)
89- }
90- err := orch2.RegisterPhase(phase2)
91- if err == nil {
92- t.Error("Expected error for duplicate phase name, got nil")
93- }
94- })
95-}
96-
97-// TestOrchestratorCurrentPhase tests CurrentPhase navigation
98-func TestOrchestratorCurrentPhase(t *testing.T) {
99- cfg := config.NewConfig("test")
100- orch := NewOrchestrator(cfg, "test.log")
101-
102- phase := newTestPhase("test", cfg)
103- if err := orch.RegisterPhase(phase); err != nil {
104- t.Fatalf("expected no error, got %v", err)
105- }
106-
107- // Before execution, current should be nil
108- if orch.CurrentPhase() != nil {
109- t.Error("CurrentPhase should be nil before execution")
110- }
111-
112- // After executing, current should be set
113- if err := orch.ExecutePhase(phase); err != nil {
114- t.Fatalf("expected no error, got %v", err)
115- }
116- current := orch.CurrentPhase()
117- if current == nil {
118- t.Error("CurrentPhase should not be nil after execution")
119- }
120- if current.Name() != "test" {
121- t.Errorf("CurrentPhase name = %q, want %q", current.Name(), "test")
122- }
123-}
124-
125-// TestOrchestratorNextPhase tests NextPhase logic
126-func TestOrchestratorNextPhase(t *testing.T) {
127- cfg := config.NewConfig("test")
128- orch := NewOrchestrator(cfg, "test.log")
129-
130- phase1 := newTestPhase("phase-1", cfg)
131- phase2 := newTestPhase("phase-2", cfg)
132- phase3 := newTestPhase("phase-3", cfg)
133-
134- if err := orch.RegisterPhase(phase1); err != nil {
135- t.Fatalf("expected no error, got %v", err)
136- }
137- if err := orch.RegisterPhase(phase2); err != nil {
138- t.Fatalf("expected no error, got %v", err)
139- }
140- if err := orch.RegisterPhase(phase3); err != nil {
141- t.Fatalf("expected no error, got %v", err)
142- }
143-
144- // Next should be phase-1
145- next := orch.NextPhase()
146- if next == nil || next.Name() != "phase-1" {
147- t.Errorf("NextPhase = %v, want phase-1", next)
148- }
149-
150- // Mark phase-1 completed
151- phase1.SetStatus(StatusCompleted)
152-
153- // Next should be phase-2
154- next = orch.NextPhase()
155- if next == nil || next.Name() != "phase-2" {
156- t.Errorf("NextPhase = %v, want phase-2", next)
157- }
158-
159- // Mark phase-2 skipped
160- phase2.SetStatus(StatusSkipped)
161-
162- // Next should be phase-3
163- next = orch.NextPhase()
164- if next == nil || next.Name() != "phase-3" {
165- t.Errorf("NextPhase = %v, want phase-3", next)
166- }
167-
168- // Mark all done
169- phase3.SetStatus(StatusCompleted)
170-
171- // Next should be nil
172- next = orch.NextPhase()
173- if next != nil {
174- t.Errorf("NextPhase = %v, want nil when all done", next)
175- }
176-}
177-
178-// TestOrchestratorExecutePhaseSuccess tests successful phase execution
179-func TestOrchestratorExecutePhaseSuccess(t *testing.T) {
180- cfg := config.NewConfig("test")
181- orch := NewOrchestrator(cfg, "test.log")
182-
183- phase := newTestPhase("test", cfg)
184- if err := orch.RegisterPhase(phase); err != nil {
185- t.Fatalf("expected no error, got %v", err)
186- }
187-
188- err := orch.ExecutePhase(phase)
189- if err != nil {
190- t.Errorf("ExecutePhase failed: %v", err)
191- }
192-
193- if phase.Status() != StatusCompleted {
194- t.Errorf("Status = %v, want %v", phase.Status(), StatusCompleted)
195- }
196-}
197-
198-// TestOrchestratorExecutePhasePreCheckFails tests PreCheck failure
199-func TestOrchestratorExecutePhasePreCheckFails(t *testing.T) {
200- cfg := config.NewConfig("test")
201- orch := NewOrchestrator(cfg, "test.log")
202-
203- phase := newTestPhase("test", cfg)
204- phase.preCheckErr = fmt.Errorf("precondition failed")
205- if err := orch.RegisterPhase(phase); err != nil {
206- t.Fatalf("expected no error, got %v", err)
207- }
208-
209- err := orch.ExecutePhase(phase)
210- if err == nil {
211- t.Error("Expected error from failed PreCheck")
212- }
213-
214- if phase.Status() != StatusFailed {
215- t.Errorf("Status = %v, want %v", phase.Status(), StatusFailed)
216- }
217-}
218-
219-// TestOrchestratorExecutePhaseExecuteFails tests Execute failure with Rollback
220-func TestOrchestratorExecutePhaseExecuteFails(t *testing.T) {
221- cfg := config.NewConfig("test")
222- orch := NewOrchestrator(cfg, "test.log")
223-
224- phase := newTestPhase("test", cfg)
225- phase.executeErr = fmt.Errorf("execution failed")
226- if err := orch.RegisterPhase(phase); err != nil {
227- t.Fatalf("expected no error, got %v", err)
228- }
229-
230- err := orch.ExecutePhase(phase)
231- if err == nil {
232- t.Error("Expected error from failed Execute")
233- }
234-
235- if phase.Status() != StatusFailed {
236- t.Errorf("Status = %v, want %v", phase.Status(), StatusFailed)
237- }
238-}
239-
240-// TestOrchestratorExecutePhaseRollbackFails tests Rollback failure
241-func TestOrchestratorExecutePhaseRollbackFails(t *testing.T) {
242- cfg := config.NewConfig("test")
243- orch := NewOrchestrator(cfg, "test.log")
244-
245- phase := newTestPhase("test", cfg)
246- phase.executeErr = fmt.Errorf("execution failed")
247- phase.rollbackErr = fmt.Errorf("rollback also failed")
248- if err := orch.RegisterPhase(phase); err != nil {
249- t.Fatalf("expected no error, got %v", err)
250- }
251-
252- err := orch.ExecutePhase(phase)
253- if err == nil {
254- t.Error("Expected error from failed Rollback")
255- }
256-
257- if phase.Status() != StatusFailed {
258- t.Errorf("Status = %v, want %v", phase.Status(), StatusFailed)
259- }
260-}
261-
262-// TestOrchestratorExecutePhasePostCheckFails tests PostCheck failure
263-func TestOrchestratorExecutePhasePostCheckFails(t *testing.T) {
264- cfg := config.NewConfig("test")
265- orch := NewOrchestrator(cfg, "test.log")
266-
267- phase := newTestPhase("test", cfg)
268- phase.postCheckErr = fmt.Errorf("validation failed")
269- if err := orch.RegisterPhase(phase); err != nil {
270- t.Fatalf("expected no error, got %v", err)
271- }
272-
273- err := orch.ExecutePhase(phase)
274- if err == nil {
275- t.Error("Expected error from failed PostCheck")
276- }
277-
278- if phase.Status() != StatusFailed {
279- t.Errorf("Status = %v, want %v", phase.Status(), StatusFailed)
280- }
281-}
282-
283-// TestOrchestratorSkipPhase tests skipping phases
284-func TestOrchestratorSkipPhase(t *testing.T) {
285- cfg := config.NewConfig("test")
286- orch := NewOrchestrator(cfg, "test.log")
287-
288- t.Run("skip skippable phase", func(t *testing.T) {
289- phase := newTestPhase("skippable", cfg)
290- phase.canSkip = true
291- if err := orch.RegisterPhase(phase); err != nil {
292- t.Fatalf("expected no error, got %v", err)
293- }
294-
295- err := orch.SkipPhase(phase)
296- if err != nil {
297- t.Errorf("SkipPhase failed: %v", err)
298- }
299-
300- if phase.Status() != StatusSkipped {
301- t.Errorf("Status = %v, want %v", phase.Status(), StatusSkipped)
302- }
303- })
304-
305- t.Run("skip non-skippable phase", func(t *testing.T) {
306- orch2 := NewOrchestrator(cfg, "test.log")
307- phase := newTestPhase("non-skippable", cfg)
308- phase.canSkip = false
309- if err := orch2.RegisterPhase(phase); err != nil {
310- t.Fatalf("expected no error, got %v", err)
311- }
312-
313- err := orch2.SkipPhase(phase)
314- if err == nil {
315- t.Error("Expected error when skipping non-skippable phase")
316- }
317- })
318-}
319-
320-// TestOrchestratorProgress tests progress calculation
321-func TestOrchestratorProgress(t *testing.T) {
322- cfg := config.NewConfig("test")
323- orch := NewOrchestrator(cfg, "test.log")
324-
325- phase1 := newTestPhase("phase-1", cfg)
326- phase2 := newTestPhase("phase-2", cfg)
327- phase3 := newTestPhase("phase-3", cfg)
328-
329- if err := orch.RegisterPhase(phase1); err != nil {
330- t.Fatalf("expected no error, got %v", err)
331- }
332- if err := orch.RegisterPhase(phase2); err != nil {
333- t.Fatalf("expected no error, got %v", err)
334- }
335- if err := orch.RegisterPhase(phase3); err != nil {
336- t.Fatalf("expected no error, got %v", err)
337- }
338-
339- // Initially all pending
340- completed, total := orch.Progress()
341- if total != 3 {
342- t.Errorf("Total = %d, want 3", total)
343- }
344- if completed != 0 {
345- t.Errorf("Completed = %d, want 0", completed)
346- }
347-
348- // Mark one completed, one skipped
349- phase1.SetStatus(StatusCompleted)
350- phase2.SetStatus(StatusSkipped)
351-
352- completed, _ = orch.Progress()
353- if completed != 2 {
354- t.Errorf("Completed = %d, want 2 (completed + skipped)", completed)
355- }
356-}
357-
358-// TestOrchestratorIsComplete tests completion detection
359-func TestOrchestratorIsComplete(t *testing.T) {
360- cfg := config.NewConfig("test")
361-
362- t.Run("all completed", func(t *testing.T) {
363- orch := NewOrchestrator(cfg, "test.log")
364- phase := newTestPhase("test", cfg)
365- phase.SetStatus(StatusCompleted)
366- if err := orch.RegisterPhase(phase); err != nil {
367- t.Fatalf("expected no error, got %v", err)
368- }
369-
370- if !orch.IsComplete() {
371- t.Error("IsComplete should be true when all phases completed")
372- }
373- })
374-
375- t.Run("with skipped", func(t *testing.T) {
376- orch := NewOrchestrator(cfg, "test.log")
377- phase := newTestPhase("test", cfg)
378- phase.SetStatus(StatusSkipped)
379- if err := orch.RegisterPhase(phase); err != nil {
380- t.Fatalf("expected no error, got %v", err)
381- }
382-
383- if !orch.IsComplete() {
384- t.Error("IsComplete should be true when all phases skipped")
385- }
386- })
387-
388- t.Run("with pending", func(t *testing.T) {
389- orch := NewOrchestrator(cfg, "test.log")
390- phase := newTestPhase("test", cfg)
391- if err := orch.RegisterPhase(phase); err != nil {
392- t.Fatalf("expected no error, got %v", err)
393- }
394-
395- if orch.IsComplete() {
396- t.Error("IsComplete should be false with pending phases")
397- }
398- })
399-}
400-
401-// TestOrchestratorHasFailed tests failure detection
402-func TestOrchestratorHasFailed(t *testing.T) {
403- cfg := config.NewConfig("test")
404-
405- t.Run("no failures", func(t *testing.T) {
406- orch := NewOrchestrator(cfg, "test.log")
407- phase := newTestPhase("test", cfg)
408- phase.SetStatus(StatusCompleted)
409- if err := orch.RegisterPhase(phase); err != nil {
410- t.Fatalf("expected no error, got %v", err)
411- }
412-
413- if orch.HasFailed() {
414- t.Error("HasFailed should be false when no phases failed")
415- }
416- })
417-
418- t.Run("with failure", func(t *testing.T) {
419- orch := NewOrchestrator(cfg, "test.log")
420- phase := newTestPhase("test", cfg)
421- phase.SetStatus(StatusFailed)
422- if err := orch.RegisterPhase(phase); err != nil {
423- t.Fatalf("expected no error, got %v", err)
424- }
425-
426- if !orch.HasFailed() {
427- t.Error("HasFailed should be true when phase failed")
428- }
429- })
430-}
431-
432-// TestOrchestratorReset tests Reset functionality
433-func TestOrchestratorReset(t *testing.T) {
434- cfg := config.NewConfig("test")
435- orch := NewOrchestrator(cfg, "test.log")
436-
437- phase1 := newTestPhase("phase-1", cfg)
438- phase2 := newTestPhase("phase-2", cfg)
439-
440- phase1.SetStatus(StatusCompleted)
441- phase2.SetStatus(StatusFailed)
442-
443- if err := orch.RegisterPhase(phase1); err != nil {
444- t.Fatalf("expected no error, got %v", err)
445- }
446- if err := orch.RegisterPhase(phase2); err != nil {
447- t.Fatalf("expected no error, got %v", err)
448- }
449-
450- // Execute to set currentIdx
451- if err := orch.ExecutePhase(phase1); err != nil {
452- t.Fatalf("expected no error, got %v", err)
453- }
454-
455- // Reset
456- orch.Reset()
457-
458- // Verify currentIdx reset
459- if orch.CurrentPhase() != nil {
460- t.Error("CurrentPhase should be nil after Reset")
461- }
462-
463- // Verify statuses reset
464- if phase1.Status() != StatusPending {
465- t.Errorf("phase1 Status = %v, want %v", phase1.Status(), StatusPending)
466- }
467- if phase2.Status() != StatusPending {
468- t.Errorf("phase2 Status = %v, want %v", phase2.Status(), StatusPending)
469- }
470-}
471-
472-// TestOrchestratorExecuteAll tests full sequence execution
473-func TestOrchestratorExecuteAll(t *testing.T) {
474- cfg := config.NewConfig("test")
475- orch := NewOrchestrator(cfg, "test.log")
476-
477- phase1 := newTestPhase("phase-1", cfg)
478- phase2 := newTestPhase("phase-2", cfg)
479- phase3 := newTestPhase("phase-3", cfg)
480-
481- if err := orch.RegisterPhase(phase1); err != nil {
482- t.Fatalf("expected no error, got %v", err)
483- }
484- if err := orch.RegisterPhase(phase2); err != nil {
485- t.Fatalf("expected no error, got %v", err)
486- }
487- if err := orch.RegisterPhase(phase3); err != nil {
488- t.Fatalf("expected no error, got %v", err)
489- }
490-
491- err := orch.ExecuteAll()
492- if err != nil {
493- t.Errorf("ExecuteAll failed: %v", err)
494- }
495-
496- // Verify all completed
497- if phase1.Status() != StatusCompleted {
498- t.Errorf("phase1 Status = %v, want %v", phase1.Status(), StatusCompleted)
499- }
500- if phase2.Status() != StatusCompleted {
501- t.Errorf("phase2 Status = %v, want %v", phase2.Status(), StatusCompleted)
502- }
503- if phase3.Status() != StatusCompleted {
504- t.Errorf("phase3 Status = %v, want %v", phase3.Status(), StatusCompleted)
505- }
506-}
507-
508-// TestOrchestratorExecuteAllStopsOnFailure tests that ExecuteAll stops on first failure
509-func TestOrchestratorExecuteAllStopsOnFailure(t *testing.T) {
510- cfg := config.NewConfig("test")
511- orch := NewOrchestrator(cfg, "test.log")
512-
513- phase1 := newTestPhase("phase-1", cfg)
514- phase2 := newTestPhase("phase-2", cfg)
515- phase3 := newTestPhase("phase-3", cfg)
516-
517- // Make phase-2 fail
518- phase2.executeErr = fmt.Errorf("execution failed")
519-
520- if err := orch.RegisterPhase(phase1); err != nil {
521- t.Fatalf("expected no error, got %v", err)
522- }
523- if err := orch.RegisterPhase(phase2); err != nil {
524- t.Fatalf("expected no error, got %v", err)
525- }
526- if err := orch.RegisterPhase(phase3); err != nil {
527- t.Fatalf("expected no error, got %v", err)
528- }
529-
530- err := orch.ExecuteAll()
531- if err == nil {
532- t.Error("Expected error from ExecuteAll when phase-2 fails")
533- }
534-
535- // Verify phase-1 completed
536- if phase1.Status() != StatusCompleted {
537- t.Errorf("phase1 Status = %v, want %v", phase1.Status(), StatusCompleted)
538- }
539-
540- // Verify phase-2 failed
541- if phase2.Status() != StatusFailed {
542- t.Errorf("phase2 Status = %v, want %v", phase2.Status(), StatusFailed)
543- }
544-
545- // Verify phase-3 never executed
546- if phase3.Status() != StatusPending {
547- t.Errorf("phase3 Status = %v, want %v (should not execute)", phase3.Status(), StatusPending)
548- }
549-}
550-
551-// TestOrchestratorExecuteNext tests ExecuteNext
552-func TestOrchestratorExecuteNext(t *testing.T) {
553- cfg := config.NewConfig("test")
554- orch := NewOrchestrator(cfg, "test.log")
555-
556- phase := newTestPhase("test", cfg)
557- if err := orch.RegisterPhase(phase); err != nil {
558- t.Fatalf("expected no error, got %v", err)
559- }
560-
561- err := orch.ExecuteNext()
562- if err != nil {
563- t.Errorf("ExecuteNext failed: %v", err)
564- }
565-
566- if phase.Status() != StatusCompleted {
567- t.Errorf("Status = %v, want %v", phase.Status(), StatusCompleted)
568- }
569-}
570-
571-// TestOrchestratorExecuteNextNoPending tests ExecuteNext with no pending phases
572-func TestOrchestratorExecuteNextNoPending(t *testing.T) {
573- cfg := config.NewConfig("test")
574- orch := NewOrchestrator(cfg, "test.log")
575-
576- phase := newTestPhase("test", cfg)
577- phase.SetStatus(StatusCompleted)
578- if err := orch.RegisterPhase(phase); err != nil {
579- t.Fatalf("expected no error, got %v", err)
580- }
581-
582- err := orch.ExecuteNext()
583- if err == nil {
584- t.Error("Expected error when no pending phases")
585- }
586-}
587-
588-// TestOrchestratorPhaseDurations tests duration tracking
589-func TestOrchestratorPhaseDurations(t *testing.T) {
590- cfg := config.NewConfig("test")
591- orch := NewOrchestrator(cfg, "test.log")
592-
593- phase := newTestPhase("test", cfg)
594- if err := orch.RegisterPhase(phase); err != nil {
595- t.Fatalf("expected no error, got %v", err)
596- }
597-
598- err := orch.ExecutePhase(phase)
599- if err != nil {
600- t.Fatalf("ExecutePhase failed: %v", err)
601- }
602-
603- durations := orch.GetPhaseDurations()
604- duration, exists := durations["test"]
605- if !exists {
606- t.Error("Expected duration for 'test' phase")
607- }
608- if duration == 0 {
609- t.Error("Duration should be greater than 0")
610- }
611-}
D internal/phases/partitioning.go +0 -342
@@ -1,342 +0,0 @@
1-package phases
2-
3-import (
4- "fmt"
5- "strings"
6-
7- "github.com/bnema/archup/internal/config"
8- "github.com/bnema/archup/internal/interfaces"
9- "github.com/bnema/archup/internal/logger"
10-)
11-
12-// PartitioningPhase handles disk partitioning, formatting, and mounting
13-type PartitioningPhase struct {
14- *BasePhase
15- sysExec interfaces.SystemExecutor
16-}
17-
18-// NewPartitioningPhase creates a new partitioning phase
19-func NewPartitioningPhase(cfg *config.Config, log *logger.Logger, sysExec interfaces.SystemExecutor) *PartitioningPhase {
20- return &PartitioningPhase{
21- BasePhase: NewBasePhase("partitioning", "Disk Partitioning", cfg, log),
22- sysExec: sysExec,
23- }
24-}
25-
26-// PreCheck validates partitioning prerequisites
27-func (p *PartitioningPhase) PreCheck() error {
28- if p.config.TargetDisk == "" {
29- return fmt.Errorf("target disk not selected")
30- }
31-
32- return nil
33-}
34-
35-// Execute runs the partitioning phase
36-func (p *PartitioningPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
37- p.SendProgress(progressChan, "Starting partitioning...", 1, 5)
38-
39- // Step 1: Wipe and partition
40- if err := p.createPartitions(progressChan); err != nil {
41- p.SendError(progressChan, err)
42- return PhaseResult{Success: false, Error: err}
43- }
44-
45- p.SendProgress(progressChan, "Formatting partitions...", 2, 5)
46-
47- // Step 2: Format partitions
48- if err := p.formatPartitions(progressChan); err != nil {
49- p.SendError(progressChan, err)
50- return PhaseResult{Success: false, Error: err}
51- }
52-
53- p.SendProgress(progressChan, "Creating btrfs subvolumes...", 3, 5)
54-
55- // Step 3: Create subvolumes
56- if err := p.createSubvolumes(progressChan); err != nil {
57- p.SendError(progressChan, err)
58- return PhaseResult{Success: false, Error: err}
59- }
60-
61- p.SendProgress(progressChan, "Mounting filesystems...", 4, 5)
62-
63- // Step 4: Mount
64- if err := p.mountFilesystems(progressChan); err != nil {
65- p.SendError(progressChan, err)
66- return PhaseResult{Success: false, Error: err}
67- }
68-
69- p.SendProgress(progressChan, "Partitioning complete", 5, 5)
70- p.SendComplete(progressChan, "Disk prepared successfully")
71-
72- return PhaseResult{Success: true, Message: "Partitioning complete"}
73-}
74-
75-// createPartitions wipes disk and creates GPT partitions
76-func (p *PartitioningPhase) createPartitions(progressChan chan<- ProgressUpdate) error {
77- disk := p.config.TargetDisk
78- p.SendOutput(progressChan, fmt.Sprintf("Wiping disk %s...", disk))
79-
80- // Wipe filesystem signatures
81- result := p.logger.ExecCommand("wipefs", "-af", disk)
82- if result.ExitCode != 0 {
83- return fmt.Errorf("wipefs failed: %w", result.Error)
84- }
85-
86- // Zap GPT and MBR
87- result = p.logger.ExecCommand("sgdisk", "--zap-all", disk)
88- if result.ExitCode != 0 {
89- return fmt.Errorf("sgdisk zap failed: %w", result.Error)
90- }
91-
92- p.SendOutput(progressChan, "Creating GPT partition table...")
93-
94- // Create partitions: 4GB EFI + remaining ROOT (4GB recommended for limine-snapper-sync)
95- result = p.logger.ExecCommand("sgdisk",
96- "--clear",
97- "--new=1:0:+4G", "--typecode=1:ef00", "--change-name=1:EFI",
98- "--new=2:0:0", "--typecode=2:8300", "--change-name=2:ROOT",
99- disk)
100-
101- if result.ExitCode != 0 {
102- return fmt.Errorf("sgdisk partition creation failed: %w", result.Error)
103- }
104-
105- // Inform kernel of partition table changes
106- result = p.logger.ExecCommand("partprobe", disk)
107- if result.ExitCode != 0 {
108- return fmt.Errorf("partprobe failed: %w", result.Error)
109- }
110-
111- // Determine partition naming (nvme vs sata/ssd)
112- var efiPart, rootPart string
113- switch {
114- case strings.Contains(disk, "nvme"):
115- efiPart = fmt.Sprintf("%sp1", disk)
116- rootPart = fmt.Sprintf("%sp2", disk)
117- default:
118- efiPart = fmt.Sprintf("%s1", disk)
119- rootPart = fmt.Sprintf("%s2", disk)
120- }
121-
122- p.config.EFIPartition = efiPart
123- p.config.RootPartition = rootPart
124-
125- p.SendOutput(progressChan, fmt.Sprintf("[OK] EFI: %s (4GB)", efiPart))
126- p.SendOutput(progressChan, fmt.Sprintf("[OK] Root: %s", rootPart))
127-
128- return nil
129-}
130-
131-// formatPartitions formats EFI and ROOT partitions
132-func (p *PartitioningPhase) formatPartitions(progressChan chan<- ProgressUpdate) error {
133- // Format EFI as FAT32
134- p.SendOutput(progressChan, "Formatting EFI partition...")
135-
136- result := p.logger.ExecCommand("wipefs", "-af", p.config.EFIPartition)
137- if result.ExitCode != 0 {
138- return fmt.Errorf("wipefs EFI failed: %w", result.Error)
139- }
140-
141- result = p.logger.ExecCommand("mkfs.fat", "-F32", "-n", "EFI", p.config.EFIPartition)
142- if result.ExitCode != 0 {
143- return fmt.Errorf("mkfs.fat failed: %w", result.Error)
144- }
145-
146- p.SendOutput(progressChan, "[OK] EFI formatted as FAT32")
147-
148- // Handle root partition with optional encryption
149- switch p.config.EncryptionType {
150- case config.EncryptionLUKS, config.EncryptionLUKSLVM:
151- return p.formatEncryptedRoot(progressChan)
152- default:
153- return p.formatPlainRoot(progressChan)
154- }
155-}
156-
157-// formatEncryptedRoot sets up LUKS encryption on root partition
158-func (p *PartitioningPhase) formatEncryptedRoot(progressChan chan<- ProgressUpdate) error {
159- p.SendOutput(progressChan, "Setting up LUKS encryption...")
160-
161- // Use user password for encryption (as per preflight)
162- password := p.config.UserPassword
163- if password == "" {
164- return fmt.Errorf("password required for encryption")
165- }
166-
167- // Wipe root partition
168- result := p.logger.ExecCommand("wipefs", "-af", p.config.RootPartition)
169- if result.ExitCode != 0 {
170- return fmt.Errorf("wipefs root failed: %w", result.Error)
171- }
172-
173- // Create LUKS container with Argon2id
174- p.SendOutput(progressChan, "Creating LUKS container (this may take a moment)...")
175-
176- // Note: In real implementation, pipe password securely via stdin
177- // For now, documenting the approach
178- result = p.sysExec.RunSimple("sh", "-c",
179- fmt.Sprintf("printf '%%s' '%s' | cryptsetup luksFormat --type luks2 --batch-mode --pbkdf argon2id --iter-time 2000 --label ARCHUP_LUKS --key-file - %s",
180- password, p.config.RootPartition))
181-
182- if result.ExitCode != 0 {
183- return fmt.Errorf("luksFormat failed: %w", result.Error)
184- }
185-
186- // Open LUKS container
187- result = p.sysExec.RunSimple("sh", "-c",
188- fmt.Sprintf("printf '%%s' '%s' | cryptsetup open --key-file=- %s cryptroot",
189- password, p.config.RootPartition))
190-
191- if result.ExitCode != 0 {
192- return fmt.Errorf("cryptsetup open failed: %w", result.Error)
193- }
194-
195- p.config.CryptDevice = "/dev/mapper/cryptroot"
196- p.SendOutput(progressChan, "[OK] LUKS container created and opened")
197-
198- // Format encrypted device with btrfs
199- result = p.logger.ExecCommand("mkfs.btrfs", "-f", "-L", "ROOT", p.config.CryptDevice)
200- if result.ExitCode != 0 {
201- return fmt.Errorf("mkfs.btrfs on encrypted device failed: %w", result.Error)
202- }
203-
204- p.SendOutput(progressChan, "[OK] Encrypted partition formatted as btrfs")
205- return nil
206-}
207-
208-// formatPlainRoot formats root partition without encryption
209-func (p *PartitioningPhase) formatPlainRoot(progressChan chan<- ProgressUpdate) error {
210- p.SendOutput(progressChan, "Formatting root partition...")
211-
212- result := p.logger.ExecCommand("mkfs.btrfs", "-f", "-L", "ROOT", p.config.RootPartition)
213- if result.ExitCode != 0 {
214- return fmt.Errorf("mkfs.btrfs failed: %w", result.Error)
215- }
216-
217- p.SendOutput(progressChan, "[OK] Root formatted as btrfs")
218- return nil
219-}
220-
221-// createSubvolumes creates btrfs subvolumes
222-func (p *PartitioningPhase) createSubvolumes(progressChan chan<- ProgressUpdate) error {
223- // Determine device to mount
224- var device string
225- switch p.config.EncryptionType {
226- case config.EncryptionLUKS, config.EncryptionLUKSLVM:
227- device = p.config.CryptDevice
228- default:
229- device = p.config.RootPartition
230- }
231-
232- // Mount temporarily
233- result := p.logger.ExecCommand("mount", device, "/mnt")
234- if result.ExitCode != 0 {
235- return fmt.Errorf("temporary mount failed: %w", result.Error)
236- }
237-
238- // Create @ subvolume
239- result = p.logger.ExecCommand("btrfs", "subvolume", "create", "/mnt/@")
240- if result.ExitCode != 0 {
241- p.sysExec.RunSimple("umount", "/mnt") // Cleanup
242- return fmt.Errorf("@ subvolume creation failed: %w", result.Error)
243- }
244-
245- // Create @home subvolume
246- result = p.logger.ExecCommand("btrfs", "subvolume", "create", "/mnt/@home")
247- if result.ExitCode != 0 {
248- p.sysExec.RunSimple("umount", "/mnt") // Cleanup
249- return fmt.Errorf("@home subvolume creation failed: %w", result.Error)
250- }
251-
252- // Unmount
253- result = p.logger.ExecCommand("umount", "/mnt")
254- if result.ExitCode != 0 {
255- return fmt.Errorf("unmount after subvolume creation failed: %w", result.Error)
256- }
257-
258- p.SendOutput(progressChan, "[OK] Btrfs subvolumes created: @ and @home")
259- return nil
260-}
261-
262-// mountFilesystems mounts all filesystems to /mnt
263-func (p *PartitioningPhase) mountFilesystems(progressChan chan<- ProgressUpdate) error {
264- // Determine device
265- var device string
266- switch p.config.EncryptionType {
267- case config.EncryptionLUKS, config.EncryptionLUKSLVM:
268- device = p.config.CryptDevice
269- default:
270- device = p.config.RootPartition
271- }
272-
273- // Mount @ subvolume as root
274- result := p.logger.ExecCommand("mount", "-o", "noatime,compress=zstd,subvol=@", device, "/mnt")
275- if result.ExitCode != 0 {
276- return fmt.Errorf("mount @ failed: %w", result.Error)
277- }
278-
279- p.SendOutput(progressChan, "[OK] Mounted @ to /mnt")
280-
281- // Create home directory
282- result = p.logger.ExecCommand("mkdir", "-p", "/mnt/home")
283- if result.ExitCode != 0 {
284- return fmt.Errorf("mkdir /mnt/home failed: %w", result.Error)
285- }
286-
287- // Mount @home subvolume
288- result = p.logger.ExecCommand("mount", "-o", "noatime,compress=zstd,subvol=@home", device, "/mnt/home")
289- if result.ExitCode != 0 {
290- return fmt.Errorf("mount @home failed: %w", result.Error)
291- }
292-
293- p.SendOutput(progressChan, "[OK] Mounted @home to /mnt/home")
294-
295- // Create and mount EFI
296- result = p.logger.ExecCommand("mkdir", "-p", "/mnt/boot")
297- if result.ExitCode != 0 {
298- return fmt.Errorf("mkdir /mnt/boot failed: %w", result.Error)
299- }
300-
301- result = p.logger.ExecCommand("mount", p.config.EFIPartition, "/mnt/boot")
302- if result.ExitCode != 0 {
303- return fmt.Errorf("mount EFI failed: %w", result.Error)
304- }
305-
306- p.SendOutput(progressChan, "[OK] Mounted EFI to /mnt/boot")
307- return nil
308-}
309-
310-// PostCheck validates mounts
311-func (p *PartitioningPhase) PostCheck() error {
312- // Verify /mnt is mounted
313- result := p.sysExec.RunSimple("mountpoint", "-q", "/mnt")
314- if result.ExitCode != 0 {
315- return fmt.Errorf("/mnt is not mounted")
316- }
317-
318- return p.config.Save()
319-}
320-
321-// Rollback unmounts filesystems
322-func (p *PartitioningPhase) Rollback() error {
323- // Unmount in reverse order
324- p.sysExec.RunSimple("umount", "/mnt/boot")
325- p.sysExec.RunSimple("umount", "/mnt/home")
326- p.sysExec.RunSimple("umount", "/mnt")
327-
328- // Close LUKS if encrypted
329- switch p.config.EncryptionType {
330- case config.EncryptionLUKS, config.EncryptionLUKSLVM:
331- if p.config.CryptDevice != "" {
332- p.sysExec.RunSimple("cryptsetup", "close", "cryptroot")
333- }
334- }
335-
336- return nil
337-}
338-
339-// CanSkip returns false - partitioning cannot be skipped
340-func (p *PartitioningPhase) CanSkip() bool {
341- return false
342-}
D internal/phases/partitioning_test.go +0 -560
@@ -1,560 +0,0 @@
1-package phases
2-
3-import (
4- "fmt"
5- "path/filepath"
6- "reflect"
7- "testing"
8-
9- "github.com/bnema/archup/internal/config"
10- "github.com/bnema/archup/internal/interfaces/mocks"
11- "github.com/bnema/archup/internal/logger"
12- "github.com/bnema/archup/internal/system"
13- "go.uber.org/mock/gomock"
14-)
15-
16-// TestPartitioningPhasePreCheck tests partitioning prerequisites
17-func TestPartitioningPhasePreCheck(t *testing.T) {
18- tests := []struct {
19- name string
20- targetDisk string
21- wantErr bool
22- errContains string
23- }{
24- {
25- name: "Valid target disk",
26- targetDisk: "/dev/sda",
27- wantErr: false,
28- errContains: "",
29- },
30- {
31- name: "Missing target disk",
32- targetDisk: "",
33- wantErr: true,
34- errContains: "target disk not selected",
35- },
36- {
37- name: "NVMe target disk",
38- targetDisk: "/dev/nvme0n1",
39- wantErr: false,
40- errContains: "",
41- },
42- }
43-
44- for _, tt := range tests {
45- t.Run(tt.name, func(t *testing.T) {
46- ctrl := gomock.NewController(t)
47- defer ctrl.Finish()
48-
49- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
50-
51- tmpDir := t.TempDir()
52- logPath := filepath.Join(tmpDir, "test.log")
53- log, err := logger.New(logPath, false)
54- if err != nil {
55- t.Fatalf("Failed to create logger: %v", err)
56- }
57- defer func() {
58- if err := log.Close(); err != nil {
59- t.Fatalf("failed to close log: %v", err)
60- }
61- }()
62-
63- cfg := config.NewConfig("test")
64- cfg.TargetDisk = tt.targetDisk
65-
66- phase := NewPartitioningPhase(cfg, log, mockSysExec)
67-
68- err = phase.PreCheck()
69- if (err != nil) != tt.wantErr {
70- t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr)
71- return
72- }
73- if tt.wantErr && err != nil {
74- if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
75- t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
76- }
77- }
78- })
79- }
80-}
81-
82-// TestPartitioningPhasePartitionNaming tests partition naming logic
83-func TestPartitioningPhasePartitionNaming(t *testing.T) {
84- tests := []struct {
85- name string
86- targetDisk string
87- expectedEFI string
88- expectedRoot string
89- }{
90- {
91- name: "NVMe disk naming",
92- targetDisk: "/dev/nvme0n1",
93- expectedEFI: "/dev/nvme0n1p1",
94- expectedRoot: "/dev/nvme0n1p2",
95- },
96- {
97- name: "SATA disk naming",
98- targetDisk: "/dev/sda",
99- expectedEFI: "/dev/sda1",
100- expectedRoot: "/dev/sda2",
101- },
102- {
103- name: "VirtIO disk naming",
104- targetDisk: "/dev/vda",
105- expectedEFI: "/dev/vda1",
106- expectedRoot: "/dev/vda2",
107- },
108- }
109-
110- for _, tt := range tests {
111- t.Run(tt.name, func(t *testing.T) {
112- ctrl := gomock.NewController(t)
113- defer ctrl.Finish()
114-
115- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
116-
117- tmpDir := t.TempDir()
118- logPath := filepath.Join(tmpDir, "test.log")
119- log, err := logger.New(logPath, false)
120- if err != nil {
121- t.Fatalf("Failed to create logger: %v", err)
122- }
123- defer func() {
124- if err := log.Close(); err != nil {
125- t.Fatalf("failed to close log: %v", err)
126- }
127- }()
128-
129- cfg := config.NewConfig("test")
130- cfg.TargetDisk = tt.targetDisk
131-
132- phase := NewPartitioningPhase(cfg, log, mockSysExec)
133-
134- // Verify partition names are set correctly in createPartitions logic
135- // This is done by checking the naming convention
136- phase.config.EFIPartition = tt.expectedEFI
137- phase.config.RootPartition = tt.expectedRoot
138-
139- if phase.config.EFIPartition != tt.expectedEFI {
140- t.Errorf("EFI partition = %q, want %q", phase.config.EFIPartition, tt.expectedEFI)
141- }
142- if phase.config.RootPartition != tt.expectedRoot {
143- t.Errorf("Root partition = %q, want %q", phase.config.RootPartition, tt.expectedRoot)
144- }
145- })
146- }
147-}
148-
149-// TestPartitioningPhaseFormatRoot tests format method branching
150-func TestPartitioningPhaseFormatRoot(t *testing.T) {
151- tests := []struct {
152- name string
153- encryptionType string
154- expectedCallType string // "LUKS" or "Plain"
155- }{
156- {
157- name: "Unencrypted root",
158- encryptionType: config.EncryptionNone,
159- expectedCallType: "Plain",
160- },
161- {
162- name: "LUKS encrypted root",
163- encryptionType: config.EncryptionLUKS,
164- expectedCallType: "LUKS",
165- },
166- {
167- name: "LUKS with LVM",
168- encryptionType: config.EncryptionLUKSLVM,
169- expectedCallType: "LUKS",
170- },
171- }
172-
173- for _, tt := range tests {
174- t.Run(tt.name, func(t *testing.T) {
175- ctrl := gomock.NewController(t)
176- defer ctrl.Finish()
177-
178- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
179-
180- tmpDir := t.TempDir()
181- logPath := filepath.Join(tmpDir, "test.log")
182- log, err := logger.New(logPath, false)
183- if err != nil {
184- t.Fatalf("Failed to create logger: %v", err)
185- }
186- defer func() {
187- if err := log.Close(); err != nil {
188- t.Fatalf("failed to close log: %v", err)
189- }
190- }()
191-
192- cfg := config.NewConfig("test")
193- cfg.TargetDisk = "/dev/sda"
194- cfg.EFIPartition = "/dev/sda1"
195- cfg.RootPartition = "/dev/sda2"
196- cfg.EncryptionType = tt.encryptionType
197- cfg.UserPassword = "password123"
198-
199- phase := NewPartitioningPhase(cfg, log, mockSysExec)
200-
201- // Verify encryption type branching
202- if phase.config.EncryptionType != tt.encryptionType {
203- t.Errorf("Encryption type = %q, want %q", phase.config.EncryptionType, tt.encryptionType)
204- }
205- })
206- }
207-}
208-
209-// TestPartitioningPhaseEncryptionValidation tests encryption password requirement
210-func TestPartitioningPhaseEncryptionValidation(t *testing.T) {
211- tests := []struct {
212- name string
213- encryptionType string
214- password string
215- shouldRequire bool
216- }{
217- {
218- name: "Unencrypted doesn't require password",
219- encryptionType: config.EncryptionNone,
220- password: "",
221- shouldRequire: false,
222- },
223- {
224- name: "LUKS requires password",
225- encryptionType: config.EncryptionLUKS,
226- password: "secure123",
227- shouldRequire: true,
228- },
229- {
230- name: "LUKS with empty password fails validation",
231- encryptionType: config.EncryptionLUKS,
232- password: "",
233- shouldRequire: true, // Should have required one
234- },
235- }
236-
237- for _, tt := range tests {
238- t.Run(tt.name, func(t *testing.T) {
239- ctrl := gomock.NewController(t)
240- defer ctrl.Finish()
241-
242- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
243-
244- tmpDir := t.TempDir()
245- logPath := filepath.Join(tmpDir, "test.log")
246- log, err := logger.New(logPath, false)
247- if err != nil {
248- t.Fatalf("Failed to create logger: %v", err)
249- }
250- defer func() {
251- if err := log.Close(); err != nil {
252- t.Fatalf("failed to close log: %v", err)
253- }
254- }()
255-
256- cfg := config.NewConfig("test")
257- cfg.TargetDisk = "/dev/sda"
258- cfg.EFIPartition = "/dev/sda1"
259- cfg.RootPartition = "/dev/sda2"
260- cfg.EncryptionType = tt.encryptionType
261- cfg.UserPassword = tt.password
262-
263- phase := NewPartitioningPhase(cfg, log, mockSysExec)
264-
265- // Validate password requirement
266- if tt.shouldRequire && tt.encryptionType != config.EncryptionNone {
267- if phase.config.UserPassword == "" && tt.password == "" {
268- // Empty password for encryption - should fail in actual execution
269- if phase.config.EncryptionType != config.EncryptionNone {
270- // Password is required for encryption
271- t.Log("password required for encryption")
272- }
273- }
274- }
275- })
276- }
277-}
278-
279-// TestPartitioningPhasePostCheck tests mount point validation
280-func TestPartitioningPhasePostCheck(t *testing.T) {
281- tests := []struct {
282- name string
283- setupMocks func(*mocks.MockSystemExecutor)
284- wantErr bool
285- errContains string
286- }{
287- {
288- name: "/mnt is mounted - postcheck passes",
289- setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
290- mockSysExec.EXPECT().RunSimple("mountpoint", "-q", "/mnt").Return(system.CommandResult{ExitCode: 0}).Times(1)
291- },
292- wantErr: false,
293- },
294- {
295- name: "/mnt not mounted - postcheck fails",
296- setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
297- mockSysExec.EXPECT().RunSimple("mountpoint", "-q", "/mnt").Return(system.CommandResult{ExitCode: 32}).Times(1)
298- },
299- wantErr: true,
300- errContains: "is not mounted",
301- },
302- }
303-
304- for _, tt := range tests {
305- t.Run(tt.name, func(t *testing.T) {
306- ctrl := gomock.NewController(t)
307- defer ctrl.Finish()
308-
309- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
310-
311- tt.setupMocks(mockSysExec)
312-
313- tmpDir := t.TempDir()
314- logPath := filepath.Join(tmpDir, "test.log")
315- log, err := logger.New(logPath, false)
316- if err != nil {
317- t.Fatalf("Failed to create logger: %v", err)
318- }
319- defer func() {
320- if err := log.Close(); err != nil {
321- t.Fatalf("failed to close log: %v", err)
322- }
323- }()
324-
325- cfg := config.NewConfig("test")
326- cfg.ConfigPath = filepath.Join(tmpDir, "config")
327-
328- phase := NewPartitioningPhase(cfg, log, mockSysExec)
329-
330- err = phase.PostCheck()
331- if (err != nil) != tt.wantErr {
332- t.Errorf("PostCheck() error = %v, wantErr %v", err, tt.wantErr)
333- return
334- }
335- if tt.wantErr && err != nil {
336- if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
337- t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains)
338- }
339- }
340- })
341- }
342-}
343-
344-// TestPartitioningPhaseRollback tests rollback cleanup
345-func TestPartitioningPhaseRollback(t *testing.T) {
346- tests := []struct {
347- name string
348- setupMocks func(*mocks.MockSystemExecutor)
349- encryption string
350- cryptDevice string
351- wantErr bool
352- }{
353- {
354- name: "Rollback unencrypted - success",
355- setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
356- mockSysExec.EXPECT().RunSimple("umount", "/mnt/boot").Return(system.CommandResult{}).Times(1)
357- mockSysExec.EXPECT().RunSimple("umount", "/mnt/home").Return(system.CommandResult{}).Times(1)
358- mockSysExec.EXPECT().RunSimple("umount", "/mnt").Return(system.CommandResult{}).Times(1)
359- },
360- encryption: config.EncryptionNone,
361- cryptDevice: "",
362- wantErr: false,
363- },
364- {
365- name: "Rollback encrypted - closes LUKS",
366- setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
367- mockSysExec.EXPECT().RunSimple("umount", "/mnt/boot").Return(system.CommandResult{}).Times(1)
368- mockSysExec.EXPECT().RunSimple("umount", "/mnt/home").Return(system.CommandResult{}).Times(1)
369- mockSysExec.EXPECT().RunSimple("umount", "/mnt").Return(system.CommandResult{}).Times(1)
370- mockSysExec.EXPECT().RunSimple("cryptsetup", "close", "cryptroot").Return(system.CommandResult{}).Times(1)
371- },
372- encryption: config.EncryptionLUKS,
373- cryptDevice: "/dev/mapper/cryptroot",
374- wantErr: false,
375- },
376- {
377- name: "Rollback umount failure - continues anyway",
378- setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
379- mockSysExec.EXPECT().RunSimple("umount", "/mnt/boot").Return(system.CommandResult{ExitCode: 32, Error: fmt.Errorf("not mounted")}).Times(1)
380- mockSysExec.EXPECT().RunSimple("umount", "/mnt/home").Return(system.CommandResult{ExitCode: 32}).Times(1)
381- mockSysExec.EXPECT().RunSimple("umount", "/mnt").Return(system.CommandResult{ExitCode: 32}).Times(1)
382- },
383- encryption: config.EncryptionNone,
384- cryptDevice: "",
385- wantErr: false, // Rollback doesn't fail on umount errors
386- },
387- }
388-
389- for _, tt := range tests {
390- t.Run(tt.name, func(t *testing.T) {
391- ctrl := gomock.NewController(t)
392- defer ctrl.Finish()
393-
394- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
395-
396- tt.setupMocks(mockSysExec)
397-
398- tmpDir := t.TempDir()
399- logPath := filepath.Join(tmpDir, "test.log")
400- log, err := logger.New(logPath, false)
401- if err != nil {
402- t.Fatalf("Failed to create logger: %v", err)
403- }
404- defer func() {
405- if err := log.Close(); err != nil {
406- t.Fatalf("failed to close log: %v", err)
407- }
408- }()
409-
410- cfg := config.NewConfig("test")
411- cfg.EncryptionType = tt.encryption
412- cfg.CryptDevice = tt.cryptDevice
413-
414- phase := NewPartitioningPhase(cfg, log, mockSysExec)
415-
416- err = phase.Rollback()
417- if (err != nil) != tt.wantErr {
418- t.Errorf("Rollback() error = %v, wantErr %v", err, tt.wantErr)
419- }
420- })
421- }
422-}
423-
424-// TestPartitioningPhaseCanSkip tests the CanSkip method
425-func TestPartitioningPhaseCanSkip(t *testing.T) {
426- ctrl := gomock.NewController(t)
427- defer ctrl.Finish()
428-
429- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
430-
431- tmpDir := t.TempDir()
432- logPath := filepath.Join(tmpDir, "test.log")
433- log, err := logger.New(logPath, false)
434- if err != nil {
435- t.Fatalf("Failed to create logger: %v", err)
436- }
437- defer func() {
438- if err := log.Close(); err != nil {
439- t.Fatalf("failed to close log: %v", err)
440- }
441- }()
442-
443- cfg := config.NewConfig("test")
444- phase := NewPartitioningPhase(cfg, log, mockSysExec)
445-
446- if phase.CanSkip() {
447- t.Error("CanSkip() = true, want false")
448- }
449-}
450-
451-// TestPartitioningPhaseExecute tests the Execute method structure
452-func TestPartitioningPhaseExecute(t *testing.T) {
453- ctrl := gomock.NewController(t)
454- defer ctrl.Finish()
455-
456- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
457-
458- tmpDir := t.TempDir()
459- logPath := filepath.Join(tmpDir, "test.log")
460- log, err := logger.New(logPath, false)
461- if err != nil {
462- t.Fatalf("Failed to create logger: %v", err)
463- }
464- defer func() {
465- if err := log.Close(); err != nil {
466- t.Fatalf("failed to close log: %v", err)
467- }
468- }()
469-
470- cfg := config.NewConfig("test")
471- cfg.TargetDisk = "/dev/sda"
472-
473- phase := NewPartitioningPhase(cfg, log, mockSysExec)
474-
475- progressChan := make(chan ProgressUpdate, 100)
476-
477- // Verify phase can be created and Execute returns a PhaseResult
478- // Note: Full Execute testing is complex due to logger.ExecCommand() calls
479- // The return type and structure are validated here
480- result := phase.Execute(progressChan)
481-
482- // Verify result has the expected type
483- if reflect.TypeOf(result) != reflect.TypeOf(PhaseResult{}) {
484- t.Errorf("Execute() returned unexpected type: %T", result)
485- }
486-}
487-
488-// TestPartitioningPhaseDeviceHandling tests both encrypted and unencrypted device handling
489-func TestPartitioningPhaseDeviceHandling(t *testing.T) {
490- tests := []struct {
491- name string
492- encryptionType string
493- cryptDevice string
494- rootPartition string
495- expectedDevice string
496- }{
497- {
498- name: "Unencrypted uses root partition",
499- encryptionType: config.EncryptionNone,
500- cryptDevice: "",
501- rootPartition: "/dev/sda2",
502- expectedDevice: "/dev/sda2",
503- },
504- {
505- name: "Encrypted uses crypt device",
506- encryptionType: config.EncryptionLUKS,
507- cryptDevice: "/dev/mapper/cryptroot",
508- rootPartition: "/dev/sda2",
509- expectedDevice: "/dev/mapper/cryptroot",
510- },
511- {
512- name: "LUKS+LVM uses crypt device",
513- encryptionType: config.EncryptionLUKSLVM,
514- cryptDevice: "/dev/mapper/cryptroot",
515- rootPartition: "/dev/sda2",
516- expectedDevice: "/dev/mapper/cryptroot",
517- },
518- }
519-
520- for _, tt := range tests {
521- t.Run(tt.name, func(t *testing.T) {
522- ctrl := gomock.NewController(t)
523- defer ctrl.Finish()
524-
525- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
526-
527- tmpDir := t.TempDir()
528- logPath := filepath.Join(tmpDir, "test.log")
529- log, err := logger.New(logPath, false)
530- if err != nil {
531- t.Fatalf("Failed to create logger: %v", err)
532- }
533- defer func() {
534- if err := log.Close(); err != nil {
535- t.Fatalf("failed to close log: %v", err)
536- }
537- }()
538-
539- cfg := config.NewConfig("test")
540- cfg.EncryptionType = tt.encryptionType
541- cfg.CryptDevice = tt.cryptDevice
542- cfg.RootPartition = tt.rootPartition
543-
544- phase := NewPartitioningPhase(cfg, log, mockSysExec)
545-
546- // Determine which device would be used
547- var device string
548- switch phase.config.EncryptionType {
549- case config.EncryptionLUKS, config.EncryptionLUKSLVM:
550- device = phase.config.CryptDevice
551- default:
552- device = phase.config.RootPartition
553- }
554-
555- if device != tt.expectedDevice {
556- t.Errorf("Device selection = %q, want %q", device, tt.expectedDevice)
557- }
558- })
559- }
560-}
D internal/phases/phase.go +0 -216
@@ -1,216 +0,0 @@
1-package phases
2-
3-import (
4- "fmt"
5-
6- "github.com/bnema/archup/internal/config"
7- "github.com/bnema/archup/internal/logger"
8-)
9-
10-// PhaseStatus represents the current status of a phase
11-type PhaseStatus int
12-
13-const (
14- StatusPending PhaseStatus = iota
15- StatusRunning
16- StatusCompleted
17- StatusFailed
18- StatusSkipped
19-)
20-
21-func (s PhaseStatus) String() string {
22- switch s {
23- case StatusPending:
24- return "pending"
25- case StatusRunning:
26- return "running"
27- case StatusCompleted:
28- return "completed"
29- case StatusFailed:
30- return "failed"
31- case StatusSkipped:
32- return "skipped"
33- default:
34- return "unknown"
35- }
36-}
37-
38-// PhaseResult represents the result of a phase execution
39-type PhaseResult struct {
40- Success bool
41- Message string
42- Error error
43-}
44-
45-// ProgressUpdate represents a progress update from a phase
46-type ProgressUpdate struct {
47- PhaseName string
48- Step string
49- OutputLine string
50- Current int
51- Total int
52- IsComplete bool
53- IsError bool
54- ErrorMsg string
55-}
56-
57-// Phase represents a single installation phase
58-type Phase interface {
59- // Name returns the phase identifier
60- Name() string
61-
62- // Description returns a human-readable description
63- Description() string
64-
65- // PreCheck validates if the phase can run
66- // Returns nil if ready, error if preconditions not met
67- PreCheck() error
68-
69- // Execute runs the phase logic
70- // Should send ProgressUpdate via progressChan
71- Execute(progressChan chan<- ProgressUpdate) PhaseResult
72-
73- // PostCheck validates the phase completed successfully
74- // Returns nil if validation passes, error otherwise
75- PostCheck() error
76-
77- // Rollback attempts to undo phase changes on failure
78- // Returns nil if rollback successful, error otherwise
79- Rollback() error
80-
81- // CanSkip returns true if this phase can be skipped
82- CanSkip() bool
83-
84- // Status returns the current phase status
85- Status() PhaseStatus
86-
87- // SetStatus updates the phase status
88- SetStatus(status PhaseStatus)
89-}
90-
91-// BasePhase provides common functionality for all phases
92-type BasePhase struct {
93- name string
94- description string
95- status PhaseStatus
96- config *config.Config
97- logger *logger.Logger
98-}
99-
100-// NewBasePhase creates a new base phase
101-func NewBasePhase(name, description string, cfg *config.Config, log *logger.Logger) *BasePhase {
102- return &BasePhase{
103- name: name,
104- description: description,
105- status: StatusPending,
106- config: cfg,
107- logger: log,
108- }
109-}
110-
111-// Name returns the phase name
112-func (b *BasePhase) Name() string {
113- return b.name
114-}
115-
116-// Description returns the phase description
117-func (b *BasePhase) Description() string {
118- return b.description
119-}
120-
121-// Status returns the current status
122-func (b *BasePhase) Status() PhaseStatus {
123- return b.status
124-}
125-
126-// SetStatus updates the status
127-func (b *BasePhase) SetStatus(status PhaseStatus) {
128- b.status = status
129-}
130-
131-// Config returns the configuration
132-func (b *BasePhase) Config() *config.Config {
133- return b.config
134-}
135-
136-// Logger returns the logger
137-func (b *BasePhase) Logger() *logger.Logger {
138- return b.logger
139-}
140-
141-// SendProgress is a helper to send progress updates
142-func (b *BasePhase) SendProgress(progressChan chan<- ProgressUpdate, step string, current, total int) {
143- if progressChan != nil {
144- progressChan <- ProgressUpdate{
145- PhaseName: b.name,
146- Step: step,
147- Current: current,
148- Total: total,
149- IsComplete: false,
150- IsError: false,
151- }
152- }
153-}
154-
155-// SendComplete is a helper to send completion
156-func (b *BasePhase) SendComplete(progressChan chan<- ProgressUpdate, message string) {
157- if progressChan != nil {
158- progressChan <- ProgressUpdate{
159- PhaseName: b.name,
160- Step: message,
161- IsComplete: true,
162- IsError: false,
163- }
164- }
165-}
166-
167-// SendError is a helper to send error
168-func (b *BasePhase) SendError(progressChan chan<- ProgressUpdate, err error) {
169- if progressChan != nil {
170- progressChan <- ProgressUpdate{
171- PhaseName: b.name,
172- IsComplete: true,
173- IsError: true,
174- ErrorMsg: err.Error(),
175- }
176- }
177-}
178-
179-// SendOutput is a helper to send command output line
180-func (b *BasePhase) SendOutput(progressChan chan<- ProgressUpdate, line string) {
181- // Log the output
182- if b.logger != nil {
183- b.logger.Info(line)
184- }
185-
186- // Send to progress channel for TUI display
187- if progressChan != nil {
188- progressChan <- ProgressUpdate{
189- PhaseName: b.name,
190- OutputLine: line,
191- }
192- }
193-}
194-
195-// DefaultPreCheck provides a basic pre-check (override in specific phases)
196-func (b *BasePhase) PreCheck() error {
197- if b.config == nil {
198- return fmt.Errorf("config not initialized")
199- }
200- return nil
201-}
202-
203-// DefaultPostCheck provides a basic post-check (override in specific phases)
204-func (b *BasePhase) PostCheck() error {
205- return nil
206-}
207-
208-// DefaultRollback provides a basic rollback (override in specific phases)
209-func (b *BasePhase) Rollback() error {
210- return fmt.Errorf("rollback not implemented for phase: %s", b.name)
211-}
212-
213-// DefaultCanSkip returns false by default (override in specific phases)
214-func (b *BasePhase) CanSkip() bool {
215- return false
216-}
D internal/phases/phase_test.go +0 -372
@@ -1,372 +0,0 @@
1-package phases
2-
3-import (
4- "fmt"
5- "path/filepath"
6- "testing"
7-
8- "github.com/bnema/archup/internal/config"
9- "github.com/bnema/archup/internal/logger"
10-)
11-
12-// TestPhaseStatusString tests the String() method of PhaseStatus enum
13-func TestPhaseStatusString(t *testing.T) {
14- tests := []struct {
15- status PhaseStatus
16- expected string
17- }{
18- {StatusPending, "pending"},
19- {StatusRunning, "running"},
20- {StatusCompleted, "completed"},
21- {StatusFailed, "failed"},
22- {StatusSkipped, "skipped"},
23- {PhaseStatus(999), "unknown"},
24- }
25-
26- for _, tt := range tests {
27- t.Run(tt.expected, func(t *testing.T) {
28- got := tt.status.String()
29- if got != tt.expected {
30- t.Errorf("PhaseStatus.String() = %q, want %q", got, tt.expected)
31- }
32- })
33- }
34-}
35-
36-// TestNewBasePhase verifies BasePhase initialization
37-func TestNewBasePhase(t *testing.T) {
38- tmpDir := t.TempDir()
39- logPath := filepath.Join(tmpDir, "test.log")
40-
41- log, err := logger.New(logPath, false)
42- if err != nil {
43- t.Fatalf("Failed to create logger: %v", err)
44- }
45- defer func() {
46- if err := log.Close(); err != nil {
47- t.Fatalf("failed to close log: %v", err)
48- }
49- }()
50-
51- cfg := config.NewConfig("test")
52-
53- phase := NewBasePhase("test-phase", "Test phase description", cfg, log)
54-
55- if phase.Name() != "test-phase" {
56- t.Errorf("Name() = %q, want %q", phase.Name(), "test-phase")
57- }
58-
59- if phase.Description() != "Test phase description" {
60- t.Errorf("Description() = %q, want %q", phase.Description(), "Test phase description")
61- }
62-
63- if phase.Status() != StatusPending {
64- t.Errorf("Status() = %v, want %v", phase.Status(), StatusPending)
65- }
66-
67- if phase.Config() != cfg {
68- t.Errorf("Config() returned different instance")
69- }
70-
71- if phase.Logger() != log {
72- t.Errorf("Logger() returned different instance")
73- }
74-}
75-
76-// TestBasePhaseSetStatus verifies status updates
77-func TestBasePhaseSetStatus(t *testing.T) {
78- tmpDir := t.TempDir()
79- logPath := filepath.Join(tmpDir, "test.log")
80-
81- log, _ := logger.New(logPath, false)
82- defer func() {
83- if err := log.Close(); err != nil {
84- t.Fatalf("failed to close log: %v", err)
85- }
86- }()
87-
88- cfg := config.NewConfig("test")
89- phase := NewBasePhase("test", "test", cfg, log)
90-
91- statuses := []PhaseStatus{
92- StatusPending,
93- StatusRunning,
94- StatusCompleted,
95- StatusFailed,
96- StatusSkipped,
97- }
98-
99- for _, status := range statuses {
100- t.Run(status.String(), func(t *testing.T) {
101- phase.SetStatus(status)
102- if phase.Status() != status {
103- t.Errorf("Status() = %v, want %v", phase.Status(), status)
104- }
105- })
106- }
107-}
108-
109-// TestBasePhaseDefaultPreCheck verifies default PreCheck implementation
110-func TestBasePhaseDefaultPreCheck(t *testing.T) {
111- tmpDir := t.TempDir()
112- logPath := filepath.Join(tmpDir, "test.log")
113-
114- log, _ := logger.New(logPath, false)
115- defer func() {
116- if err := log.Close(); err != nil {
117- t.Fatalf("failed to close log: %v", err)
118- }
119- }()
120-
121- tests := []struct {
122- name string
123- cfg *config.Config
124- wantError bool
125- }{
126- {
127- name: "with valid config",
128- cfg: config.NewConfig("test"),
129- wantError: false,
130- },
131- {
132- name: "with nil config",
133- cfg: nil,
134- wantError: true,
135- },
136- }
137-
138- for _, tt := range tests {
139- t.Run(tt.name, func(t *testing.T) {
140- phase := NewBasePhase("test", "test", tt.cfg, log)
141- err := phase.PreCheck()
142-
143- if (err != nil) != tt.wantError {
144- t.Errorf("PreCheck() error = %v, wantError %v", err, tt.wantError)
145- }
146- })
147- }
148-}
149-
150-// TestBasePhaseDefaultPostCheck verifies default PostCheck (always succeeds)
151-func TestBasePhaseDefaultPostCheck(t *testing.T) {
152- tmpDir := t.TempDir()
153- logPath := filepath.Join(tmpDir, "test.log")
154-
155- log, _ := logger.New(logPath, false)
156- defer func() {
157- if err := log.Close(); err != nil {
158- t.Fatalf("failed to close log: %v", err)
159- }
160- }()
161-
162- cfg := config.NewConfig("test")
163- phase := NewBasePhase("test", "test", cfg, log)
164-
165- err := phase.PostCheck()
166- if err != nil {
167- t.Errorf("PostCheck() error = %v, want nil", err)
168- }
169-}
170-
171-// TestBasePhaseDefaultRollback verifies default Rollback returns error
172-func TestBasePhaseDefaultRollback(t *testing.T) {
173- tmpDir := t.TempDir()
174- logPath := filepath.Join(tmpDir, "test.log")
175-
176- log, _ := logger.New(logPath, false)
177- defer func() {
178- if err := log.Close(); err != nil {
179- t.Fatalf("failed to close log: %v", err)
180- }
181- }()
182-
183- cfg := config.NewConfig("test")
184- phase := NewBasePhase("my-phase", "test", cfg, log)
185-
186- err := phase.Rollback()
187- if err == nil {
188- t.Error("Rollback() error = nil, want error")
189- }
190-
191- expectedMsg := "rollback not implemented for phase: my-phase"
192- if err.Error() != expectedMsg {
193- t.Errorf("Rollback() error = %q, want %q", err.Error(), expectedMsg)
194- }
195-}
196-
197-// TestBasePhaseDefaultCanSkip verifies default CanSkip returns false
198-func TestBasePhaseDefaultCanSkip(t *testing.T) {
199- tmpDir := t.TempDir()
200- logPath := filepath.Join(tmpDir, "test.log")
201-
202- log, _ := logger.New(logPath, false)
203- defer func() {
204- if err := log.Close(); err != nil {
205- t.Fatalf("failed to close log: %v", err)
206- }
207- }()
208-
209- cfg := config.NewConfig("test")
210- phase := NewBasePhase("test", "test", cfg, log)
211-
212- if phase.CanSkip() {
213- t.Error("CanSkip() = true, want false")
214- }
215-}
216-
217-// TestSendProgress verifies progress updates sent to channel
218-func TestSendProgress(t *testing.T) {
219- tmpDir := t.TempDir()
220- logPath := filepath.Join(tmpDir, "test.log")
221-
222- log, _ := logger.New(logPath, false)
223- defer func() {
224- if err := log.Close(); err != nil {
225- t.Fatalf("failed to close log: %v", err)
226- }
227- }()
228-
229- cfg := config.NewConfig("test")
230- phase := NewBasePhase("test-phase", "test", cfg, log)
231-
232- progressChan := make(chan ProgressUpdate, 5)
233-
234- phase.SendProgress(progressChan, "step-1", 1, 3)
235- phase.SendProgress(progressChan, "step-2", 2, 3)
236-
237- // Verify first update
238- update1 := <-progressChan
239- if update1.PhaseName != "test-phase" {
240- t.Errorf("PhaseName = %q, want %q", update1.PhaseName, "test-phase")
241- }
242- if update1.Step != "step-1" {
243- t.Errorf("Step = %q, want %q", update1.Step, "step-1")
244- }
245- if update1.Current != 1 || update1.Total != 3 {
246- t.Errorf("Progress = %d/%d, want 1/3", update1.Current, update1.Total)
247- }
248- if update1.IsComplete || update1.IsError {
249- t.Error("Flags should be false for regular progress update")
250- }
251-
252- // Verify second update
253- update2 := <-progressChan
254- if update2.Step != "step-2" {
255- t.Errorf("Step = %q, want %q", update2.Step, "step-2")
256- }
257- if update2.Current != 2 {
258- t.Errorf("Current = %d, want 2", update2.Current)
259- }
260-}
261-
262-// TestSendProgressWithNilChannel verifies no panic with nil channel
263-func TestSendProgressWithNilChannel(t *testing.T) {
264- tmpDir := t.TempDir()
265- logPath := filepath.Join(tmpDir, "test.log")
266-
267- log, _ := logger.New(logPath, false)
268- defer func() {
269- if err := log.Close(); err != nil {
270- t.Fatalf("failed to close log: %v", err)
271- }
272- }()
273-
274- cfg := config.NewConfig("test")
275- phase := NewBasePhase("test", "test", cfg, log)
276-
277- // Should not panic with nil channel
278- phase.SendProgress(nil, "step", 1, 2)
279-}
280-
281-// TestSendComplete verifies completion signal
282-func TestSendComplete(t *testing.T) {
283- tmpDir := t.TempDir()
284- logPath := filepath.Join(tmpDir, "test.log")
285-
286- log, _ := logger.New(logPath, false)
287- defer func() {
288- if err := log.Close(); err != nil {
289- t.Fatalf("failed to close log: %v", err)
290- }
291- }()
292-
293- cfg := config.NewConfig("test")
294- phase := NewBasePhase("test-phase", "test", cfg, log)
295-
296- progressChan := make(chan ProgressUpdate, 1)
297-
298- phase.SendComplete(progressChan, "Installation complete")
299-
300- update := <-progressChan
301- if !update.IsComplete {
302- t.Error("IsComplete = false, want true")
303- }
304- if update.IsError {
305- t.Error("IsError = true, want false")
306- }
307- if update.Step != "Installation complete" {
308- t.Errorf("Step = %q, want %q", update.Step, "Installation complete")
309- }
310-}
311-
312-// TestSendError verifies error signal
313-func TestSendError(t *testing.T) {
314- tmpDir := t.TempDir()
315- logPath := filepath.Join(tmpDir, "test.log")
316-
317- log, _ := logger.New(logPath, false)
318- defer func() {
319- if err := log.Close(); err != nil {
320- t.Fatalf("failed to close log: %v", err)
321- }
322- }()
323-
324- cfg := config.NewConfig("test")
325- phase := NewBasePhase("test-phase", "test", cfg, log)
326-
327- progressChan := make(chan ProgressUpdate, 1)
328-
329- testErr := "test error message"
330- err := fmt.Errorf("%s", testErr)
331- phase.SendError(progressChan, err)
332-
333- update := <-progressChan
334- if !update.IsComplete {
335- t.Error("IsComplete = false, want true")
336- }
337- if !update.IsError {
338- t.Error("IsError = false, want true")
339- }
340- if update.ErrorMsg != testErr {
341- t.Errorf("ErrorMsg = %q, want %q", update.ErrorMsg, testErr)
342- }
343-}
344-
345-// TestSendOutput verifies output line sending
346-func TestSendOutput(t *testing.T) {
347- tmpDir := t.TempDir()
348- logPath := filepath.Join(tmpDir, "test.log")
349-
350- log, _ := logger.New(logPath, false)
351- defer func() {
352- if err := log.Close(); err != nil {
353- t.Fatalf("failed to close log: %v", err)
354- }
355- }()
356-
357- cfg := config.NewConfig("test")
358- phase := NewBasePhase("test-phase", "test", cfg, log)
359-
360- progressChan := make(chan ProgressUpdate, 1)
361-
362- outputLine := "output from command"
363- phase.SendOutput(progressChan, outputLine)
364-
365- update := <-progressChan
366- if update.OutputLine != outputLine {
367- t.Errorf("OutputLine = %q, want %q", update.OutputLine, outputLine)
368- }
369- if update.IsComplete || update.IsError {
370- t.Error("Completion/Error flags should be false for output update")
371- }
372-}
D internal/phases/postinstall.go +0 -644
@@ -1,644 +0,0 @@
1-package phases
2-
3-import (
4- "fmt"
5- "io"
6- "net/http"
7- "os"
8- "path/filepath"
9- "regexp"
10- "strings"
11-
12- "github.com/bnema/archup/internal/config"
13- "github.com/bnema/archup/internal/interfaces"
14- "github.com/bnema/archup/internal/logger"
15- "github.com/bnema/archup/internal/system"
16-)
17-
18-// PostInstallPhase handles post-installation tasks
19-type PostInstallPhase struct {
20- *BasePhase
21- fs interfaces.FileSystem
22- http interfaces.HTTPClient
23- sysExec interfaces.SystemExecutor
24- chrExec interfaces.ChrootExecutor
25-}
26-
27-// NewPostInstallPhase creates a new post-install phase
28-func NewPostInstallPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, http interfaces.HTTPClient, sysExec interfaces.SystemExecutor, chrExec interfaces.ChrootExecutor) *PostInstallPhase {
29- return &PostInstallPhase{
30- BasePhase: NewBasePhase("postinstall", "Post-Installation", cfg, log),
31- fs: fs,
32- http: http,
33- sysExec: sysExec,
34- chrExec: chrExec,
35- }
36-}
37-
38-// fetchFile returns the content of a repo-relative path.
39-// It checks /tmp/archup-install/<repoPath> first (populated by push-binary-to-vm.sh --local),
40-// then falls back to downloading from GitHub.
41-func (p *PostInstallPhase) fetchFile(repoPath string) ([]byte, error) {
42- localPath := filepath.Join(config.DefaultInstallDir, repoPath)
43- if content, err := p.fs.ReadFile(localPath); err == nil {
44- return content, nil
45- }
46- url := fmt.Sprintf("%s/%s", p.config.RawURL, repoPath)
47- resp, err := p.http.Get(url)
48- if err != nil {
49- return nil, fmt.Errorf("failed to fetch %s: %w", repoPath, err)
50- }
51- defer resp.Body.Close()
52- if resp.StatusCode != http.StatusOK {
53- return nil, fmt.Errorf("failed to fetch %s: HTTP %d", repoPath, resp.StatusCode)
54- }
55- data, err := io.ReadAll(resp.Body)
56- if err != nil {
57- return nil, fmt.Errorf("failed to read %s: %w", repoPath, err)
58- }
59- return data, nil
60-}
61-
62-// PreCheck validates post-install prerequisites
63-func (p *PostInstallPhase) PreCheck() error {
64- // Verify /mnt is mounted
65- result := p.sysExec.RunSimple("mountpoint", "-q", config.PathMnt)
66- switch {
67- case result.ExitCode != 0:
68- return fmt.Errorf("%s is not mounted", config.PathMnt)
69- }
70-
71- // Verify boot directory exists
72- switch _, err := p.fs.Stat(config.PathMntBoot); {
73- case p.fs.IsNotExist(err):
74- return fmt.Errorf("boot directory not found")
75- }
76-
77- return nil
78-}
79-
80-// Execute runs the post-install phase
81-func (p *PostInstallPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
82- totalSteps := 8
83- currentStep := 0
84-
85- // Step 1: Plymouth
86- currentStep++
87- p.SendProgress(progressChan, "Configuring Plymouth splash screen...", currentStep, totalSteps)
88- switch err := p.configurePlymouth(progressChan); {
89- case err != nil:
90- // Non-fatal, continue
91- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Plymouth failed: %v", err))
92- }
93-
94- // Step 3: Snapper
95- currentStep++
96- p.SendProgress(progressChan, "Configuring btrfs snapshots...", currentStep, totalSteps)
97- switch err := p.configureSnapper(progressChan); {
98- case err != nil:
99- // Non-fatal, continue
100- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Snapper failed: %v", err))
101- }
102-
103- // Step 4: Pacman configuration
104- currentStep++
105- p.SendProgress(progressChan, "Configuring pacman...", currentStep, totalSteps)
106- switch err := p.configurePacman(progressChan); {
107- case err != nil:
108- p.SendError(progressChan, err)
109- return PhaseResult{Success: false, Error: err}
110- }
111-
112- // Step 5: Post-boot setup
113- currentStep++
114- p.SendProgress(progressChan, "Setting up post-boot scripts...", currentStep, totalSteps)
115- switch err := p.setupPostBoot(progressChan); {
116- case err != nil:
117- p.SendError(progressChan, err)
118- return PhaseResult{Success: false, Error: err}
119- }
120-
121- // Step 6: Pacman hooks
122- currentStep++
123- p.SendProgress(progressChan, "Installing pacman hooks...", currentStep, totalSteps)
124- switch err := p.installPacmanHooks(progressChan); {
125- case err != nil:
126- p.SendError(progressChan, err)
127- return PhaseResult{Success: false, Error: err}
128- }
129-
130- // Step 7: Shell configuration
131- currentStep++
132- p.SendProgress(progressChan, "Configuring shell environment...", currentStep, totalSteps)
133- switch err := p.configureShell(progressChan); {
134- case err != nil:
135- p.SendError(progressChan, err)
136- return PhaseResult{Success: false, Error: err}
137- }
138-
139- // Step 8: Verification
140- currentStep++
141- p.SendProgress(progressChan, "Verifying installation...", currentStep, totalSteps)
142- switch err := p.verifyInstallation(progressChan); {
143- case err != nil:
144- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Verification warnings: %v", err))
145- }
146-
147- // Step 9: Unmount
148- currentStep++
149- p.SendProgress(progressChan, "Unmounting filesystems...", currentStep, totalSteps)
150- switch err := p.unmountAll(progressChan); {
151- case err != nil:
152- p.SendError(progressChan, err)
153- return PhaseResult{Success: false, Error: err}
154- }
155-
156- p.SendComplete(progressChan, "Post-installation complete")
157- return PhaseResult{Success: true, Message: "Post-installation complete"}
158-}
159-
160-// configurePlymouth sets up Plymouth splash screen
161-func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate) error {
162- p.SendOutput(progressChan, "Setting up Plymouth theme...")
163-
164- // Create theme directory
165- themeDir := filepath.Join(config.PathMntPlymouthThemes, config.PlymouthThemeName)
166- switch err := p.fs.MkdirAll(themeDir, 0755); {
167- case err != nil:
168- return fmt.Errorf("failed to create theme directory: %w", err)
169- }
170-
171- // Fetch all Plymouth files (local-first, then remote)
172- for _, filename := range config.PlymouthFiles {
173- p.SendOutput(progressChan, fmt.Sprintf("Fetching %s...", filename))
174-
175- data, err := p.fetchFile(filepath.Join("assets", "plymouth", filename))
176- if err != nil {
177- return fmt.Errorf("failed to fetch %s: %w", filename, err)
178- }
179-
180- destPath := filepath.Join(themeDir, filename)
181- if err := p.fs.WriteFile(destPath, data, 0644); err != nil {
182- return fmt.Errorf("failed to write %s: %w", filename, err)
183- }
184- }
185-
186- p.SendOutput(progressChan, "[OK] Plymouth files downloaded")
187-
188- // Set as default theme
189- setThemeCmd := fmt.Sprintf("plymouth-set-default-theme %s", config.PlymouthThemeName)
190- switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, setThemeCmd); {
191- case err != nil:
192- return fmt.Errorf("failed to set Plymouth theme: %w", err)
193- }
194-
195- p.SendOutput(progressChan, "[OK] Plymouth theme set")
196-
197- // Regenerate initramfs
198- p.SendOutput(progressChan, "Regenerating initramfs with Plymouth...")
199- switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "mkinitcpio -P"); {
200- case err != nil:
201- return fmt.Errorf("failed to regenerate initramfs: %w", err)
202- }
203-
204- p.SendOutput(progressChan, "[OK] Plymouth configured")
205- return nil
206-}
207-
208-// configureSnapper sets up btrfs snapshots with Limine integration
209-func (p *PostInstallPhase) configureSnapper(progressChan chan<- ProgressUpdate) error {
210- p.SendOutput(progressChan, "Installing limine-snapper-sync...")
211-
212- // Install limine-snapper-sync
213- switch err := p.chrExec.ChrootPacman(p.logger.LogPath(), config.PathMnt, "-S", "--needed", "limine-snapper-sync"); {
214- case err != nil:
215- return fmt.Errorf("failed to install limine-snapper-sync: %w", err)
216- }
217-
218- p.SendOutput(progressChan, "[OK] Package installed")
219-
220- // Get kernel cmdline from existing Limine config
221- limineConf := config.PathMntBootLimineConf
222- content, err := p.fs.ReadFile(limineConf)
223- switch {
224- case err != nil:
225- return fmt.Errorf("failed to read limine.conf: %w", err)
226- }
227-
228- // Extract cmdline
229- cmdlineRegex := regexp.MustCompile(`(?m)^\s*cmdline:\s*(.+)$`)
230- matches := cmdlineRegex.FindStringSubmatch(string(content))
231- cmdline := ""
232- switch {
233- case len(matches) > 1:
234- cmdline = strings.TrimSpace(matches[1])
235- }
236-
237- // Create Limine snapper config
238- limineDefaultConfig := fmt.Sprintf(`TARGET_OS_NAME="%s"
239-
240-ESP_PATH="/boot"
241-
242-KERNEL_CMDLINE[default]="%s"
243-
244-ENABLE_UKI=no
245-ENABLE_LIMINE_FALLBACK=yes
246-
247-FIND_BOOTLOADERS=yes
248-
249-BOOT_ORDER="*, *fallback, Snapshots"
250-
251-MAX_SNAPSHOT_ENTRIES=5
252-
253-SNAPSHOT_FORMAT_CHOICE=5
254-`, config.LimineBranding, cmdline)
255-
256- switch err := p.fs.WriteFile(config.PathMntEtcDefaultLimine, []byte(limineDefaultConfig), 0644); {
257- case err != nil:
258- return fmt.Errorf("failed to write limine config: %w", err)
259- }
260-
261- p.SendOutput(progressChan, "[OK] Limine snapper config created")
262-
263- // Enable service
264- switch err := p.chrExec.ChrootSystemctl(p.logger.LogPath(), config.PathMnt, "enable", "limine-snapper-sync.service"); {
265- case err != nil:
266- return fmt.Errorf("failed to enable limine-snapper-sync: %w", err)
267- }
268-
269- p.SendOutput(progressChan, "[OK] Btrfs snapshots configured")
270- return nil
271-}
272-
273-// configurePacman configures pacman for better UX
274-func (p *PostInstallPhase) configurePacman(progressChan chan<- ProgressUpdate) error {
275- p.SendOutput(progressChan, "Configuring pacman UX...")
276-
277- content, err := p.fs.ReadFile(config.PathMntEtcPacmanConf)
278- switch {
279- case err != nil:
280- return fmt.Errorf("failed to read pacman.conf: %w", err)
281- }
282-
283- contentStr := string(content)
284-
285- // Enable Color
286- colorRegex := regexp.MustCompile(`(?m)^#Color$`)
287- contentStr = colorRegex.ReplaceAllString(contentStr, "Color")
288-
289- // Enable ParallelDownloads
290- parallelRegex := regexp.MustCompile(`(?m)^#ParallelDownloads = 5$`)
291- contentStr = parallelRegex.ReplaceAllString(contentStr, "ParallelDownloads = 5")
292-
293- // Add ILoveCandy after Color
294- switch {
295- case !strings.Contains(contentStr, "ILoveCandy"):
296- colorLineRegex := regexp.MustCompile(`(?m)^Color$`)
297- contentStr = colorLineRegex.ReplaceAllString(contentStr, "Color\nILoveCandy")
298- }
299-
300- switch err := p.fs.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); {
301- case err != nil:
302- return fmt.Errorf("failed to write pacman.conf: %w", err)
303- }
304-
305- p.SendOutput(progressChan, "[OK] Pacman configured (color, parallel downloads, candy)")
306- return nil
307-}
308-
309-// installPacmanHooks installs pacman hooks for maintenance
310-func (p *PostInstallPhase) installPacmanHooks(progressChan chan<- ProgressUpdate) error {
311- p.SendOutput(progressChan, "Installing pacman hooks...")
312-
313- // Create hooks directory
314- switch err := p.fs.MkdirAll(config.PathMntEtcPacmanDHooks, 0755); {
315- case err != nil:
316- return fmt.Errorf("failed to create hooks directory: %w", err)
317- }
318-
319- // Limine update hook
320- limineHook := `[Trigger]
321-Operation = Install
322-Operation = Upgrade
323-Type = Package
324-Target = limine
325-
326-[Action]
327-Description = Deploying Limine after upgrade...
328-When = PostTransaction
329-Exec = /usr/bin/cp /usr/share/limine/BOOTX64.EFI /boot/EFI/limine/
330-`
331-
332- hookPath := filepath.Join(config.PathMntEtcPacmanDHooks, "99-limine.hook")
333- switch err := p.fs.WriteFile(hookPath, []byte(limineHook), 0644); {
334- case err != nil:
335- return fmt.Errorf("failed to write limine hook: %w", err)
336- }
337-
338- p.SendOutput(progressChan, "[OK] Limine update hook installed")
339- return nil
340-}
341-
342-// configureShell sets up bash shell with ArchUp defaults
343-func (p *PostInstallPhase) configureShell(progressChan chan<- ProgressUpdate) error {
344- p.SendOutput(progressChan, "Configuring shell environment...")
345-
346- // Get username from /mnt/home
347- homeDir := filepath.Join(config.PathMnt, "home")
348- entries, err := os.ReadDir(homeDir)
349- switch {
350- case err != nil:
351- return fmt.Errorf("failed to read home directory: %w", err)
352- case len(entries) == 0:
353- return fmt.Errorf("no user found in /home")
354- }
355-
356- username := entries[0].Name()
357- userHome := filepath.Join(config.PathMnt, "home", username)
358- archupDefault := filepath.Join(userHome, ".local", "share", "archup", "default")
359- archupDefaultBash := filepath.Join(archupDefault, "bash")
360-
361- p.SendOutput(progressChan, fmt.Sprintf("Configuring shell for user: %s", username))
362-
363- // Create directory structure
364- switch err := p.fs.MkdirAll(archupDefaultBash, 0755); {
365- case err != nil:
366- return fmt.Errorf("failed to create shell config directory: %w", err)
367- }
368-
369- // Copy shell configuration files from templates
370- switch err := p.copyShellTemplates(archupDefault, archupDefaultBash); {
371- case err != nil:
372- return fmt.Errorf("failed to copy shell templates: %w", err)
373- }
374-
375- // Create .bashrc
376- bashrcContent, err := p.readTemplate(config.BashrcTemplate)
377- switch {
378- case err != nil:
379- return fmt.Errorf("failed to read bashrc template: %w", err)
380- }
381-
382- bashrcPath := filepath.Join(userHome, ".bashrc")
383- switch err := p.fs.WriteFile(bashrcPath, bashrcContent, 0644); {
384- case err != nil:
385- return fmt.Errorf("failed to write .bashrc: %w", err)
386- }
387-
388- // Set ownership to user
389- switch err := p.setShellOwnership(userHome, username); {
390- case err != nil:
391- return fmt.Errorf("failed to set ownership: %w", err)
392- }
393-
394- // Configure git delta
395- gitCommands := []string{
396- fmt.Sprintf("su - %s -c 'git config --global core.pager delta'", username),
397- fmt.Sprintf("su - %s -c 'git config --global interactive.diffFilter \"delta --color-only\"'", username),
398- fmt.Sprintf("su - %s -c 'git config --global delta.navigate true'", username),
399- fmt.Sprintf("su - %s -c 'git config --global delta.light false'", username),
400- fmt.Sprintf("su - %s -c 'git config --global delta.side-by-side true'", username),
401- fmt.Sprintf("su - %s -c 'git config --global merge.conflictstyle diff3'", username),
402- fmt.Sprintf("su - %s -c 'git config --global diff.colorMoved default'", username),
403- }
404-
405- for _, gitCmd := range gitCommands {
406- switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, gitCmd); {
407- case err != nil:
408- // Non-fatal
409- p.SendOutput(progressChan, "[WARN] Failed to configure git delta")
410- }
411- }
412-
413- p.SendOutput(progressChan, "[OK] Shell environment configured")
414- return nil
415-}
416-
417-// copyShellTemplates copies shell config templates to user directory
418-func (p *PostInstallPhase) copyShellTemplates(archupDefault, archupDefaultBash string) error {
419- templates := map[string]string{
420- config.ShellConfigTemplate: filepath.Join(archupDefaultBash, "shell"),
421- config.ShellInitTemplate: filepath.Join(archupDefaultBash, "init"),
422- config.ShellAliasesTemplate: filepath.Join(archupDefaultBash, "aliases"),
423- config.ShellEnvsTemplate: filepath.Join(archupDefaultBash, "envs"),
424- config.ShellRcTemplate: filepath.Join(archupDefaultBash, "rc"),
425- }
426-
427- for template, dest := range templates {
428- content, err := p.readTemplate(template)
429- switch {
430- case err != nil:
431- return fmt.Errorf("failed to read template %s: %w", template, err)
432- }
433-
434- switch err := p.fs.WriteFile(dest, content, 0644); {
435- case err != nil:
436- return fmt.Errorf("failed to write %s: %w", dest, err)
437- }
438- }
439-
440- return nil
441-}
442-
443-// readTemplate reads a template file from install path
444-func (p *PostInstallPhase) readTemplate(filename string) ([]byte, error) {
445- // Use DefaultInstallDir directly to match where bootstrap downloads files
446- templatePath := filepath.Join(config.DefaultInstallDir, filename)
447- return p.fs.ReadFile(templatePath)
448-}
449-
450-// setShellOwnership sets ownership of shell config files to user
451-func (p *PostInstallPhase) setShellOwnership(userHome, username string) error {
452- // Use chroot to run chown
453- relativeHome := strings.TrimPrefix(userHome, config.PathMnt)
454- chownCmd := fmt.Sprintf("chown -R %s:%s %s/.local %s/.bashrc",
455- username, username, relativeHome, relativeHome)
456- return p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, chownCmd)
457-}
458-
459-// verifyInstallation performs comprehensive verification
460-func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate) error {
461- p.SendOutput(progressChan, "Verifying installation components...")
462-
463- verificationFailed := false
464-
465- // Verify kernel
466- kernelName := p.config.KernelChoice
467- kernelPath := filepath.Join(config.PathMntBoot, fmt.Sprintf("vmlinuz-%s", kernelName))
468- switch _, err := p.fs.Stat(kernelPath); {
469- case p.fs.IsNotExist(err):
470- p.SendOutput(progressChan, fmt.Sprintf("[ERROR] Kernel missing: %s", kernelPath))
471- verificationFailed = true
472- default:
473- p.SendOutput(progressChan, "[OK] Kernel found")
474- }
475-
476- // Verify initramfs
477- initramfsPath := filepath.Join(config.PathMntBoot, fmt.Sprintf("initramfs-%s.img", kernelName))
478- switch _, err := p.fs.Stat(initramfsPath); {
479- case p.fs.IsNotExist(err):
480- p.SendOutput(progressChan, fmt.Sprintf("[ERROR] Initramfs missing: %s", initramfsPath))
481- verificationFailed = true
482- default:
483- p.SendOutput(progressChan, "[OK] Initramfs found")
484- }
485-
486- // Verify bootloader
487- bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
488- switch _, err := p.fs.Stat(bootloaderPath); {
489- case p.fs.IsNotExist(err):
490- p.SendOutput(progressChan, "[ERROR] Bootloader missing")
491- verificationFailed = true
492- default:
493- p.SendOutput(progressChan, "[OK] Bootloader found")
494- }
495-
496- // Verify Limine config
497- limineConfPath := config.PathMntBootLimineConf
498- switch content, err := p.fs.ReadFile(limineConfPath); {
499- case err != nil:
500- p.SendOutput(progressChan, "[ERROR] Limine config missing")
501- verificationFailed = true
502- default:
503- contentStr := string(content)
504- switch {
505- case strings.Contains(contentStr, "protocol:") && strings.Contains(contentStr, "path:"):
506- p.SendOutput(progressChan, "[OK] Limine config validated")
507- default:
508- p.SendOutput(progressChan, "[WARN] Limine config may be incomplete")
509- }
510- }
511-
512- // Verify NetworkManager is enabled
513- result := p.sysExec.RunSimple("arch-chroot", config.PathMnt, "systemctl", "is-enabled", "NetworkManager")
514- switch {
515- case result.ExitCode != 0:
516- p.SendOutput(progressChan, "[ERROR] NetworkManager not enabled")
517- verificationFailed = true
518- default:
519- p.SendOutput(progressChan, "[OK] NetworkManager enabled")
520- }
521-
522- switch {
523- case verificationFailed:
524- p.SendOutput(progressChan, "[WARN] Verification completed with warnings")
525- return fmt.Errorf("verification completed with warnings")
526- default:
527- p.SendOutput(progressChan, "[OK] All critical components verified")
528- }
529-
530- return nil
531-}
532-
533-// unmountAll unmounts all filesystems
534-func (p *PostInstallPhase) unmountAll(progressChan chan<- ProgressUpdate) error {
535- p.SendOutput(progressChan, "Unmounting all partitions...")
536-
537- // Unmount /mnt recursively
538- switch err := system.Unmount(p.logger.LogPath(), config.PathMnt); {
539- case err != nil:
540- p.SendOutput(progressChan, "[WARN] Some partitions may still be mounted")
541- return fmt.Errorf("failed to unmount cleanly: %w", err)
542- }
543-
544- p.SendOutput(progressChan, "[OK] All partitions unmounted")
545-
546- // Close encrypted volume if encryption was used
547- switch p.config.EncryptionType {
548- case config.EncryptionLUKS, config.EncryptionLUKSLVM:
549- p.SendOutput(progressChan, "Closing encrypted volume...")
550- result := p.logger.ExecCommand("cryptsetup", "close", "cryptroot")
551- switch {
552- case result.ExitCode != 0:
553- p.SendOutput(progressChan, "[WARN] Failed to close encrypted volume")
554- default:
555- p.SendOutput(progressChan, "[OK] Encrypted volume closed")
556- }
557- }
558-
559- return nil
560-}
561-
562-// PostCheck validates post-install completion
563-func (p *PostInstallPhase) PostCheck() error {
564- // Verification already done in Execute
565- return p.config.Save()
566-}
567-
568-// Rollback for post-install phase
569-func (p *PostInstallPhase) Rollback() error {
570- // Attempt to unmount if something failed
571- _ = system.Unmount(p.logger.LogPath(), config.PathMnt)
572- return nil
573-}
574-
575-// CanSkip returns false - post-install cannot be skipped
576-func (p *PostInstallPhase) CanSkip() bool {
577- return false
578-}
579-
580-// setupPostBoot downloads and configures first-boot service
581-func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) error {
582- p.SendOutput(progressChan, "Configuring first-boot service...")
583-
584- switch err := p.fs.MkdirAll(config.PathMntPostBoot, 0755); {
585- case err != nil:
586- return fmt.Errorf("failed to create post-boot directory: %w", err)
587- }
588-
589- // Fetch logo.txt (local-first, then remote)
590- p.SendOutput(progressChan, "Fetching logo.txt...")
591- logoData, err := p.fetchFile("logo.txt")
592- if err != nil {
593- return fmt.Errorf("failed to fetch logo.txt: %w", err)
594- }
595- logoPath := filepath.Join(config.PathMntPostBoot, "logo.txt")
596- if err := p.fs.WriteFile(logoPath, logoData, 0644); err != nil {
597- return fmt.Errorf("failed to write logo.txt: %w", err)
598- }
599-
600- // Fetch all post-boot scripts (local-first, then remote)
601- for _, script := range config.PostBootScripts {
602- p.SendOutput(progressChan, fmt.Sprintf("Fetching %s...", script))
603-
604- data, err := p.fetchFile(filepath.Join("install", "mandatory", "post-boot", script))
605- if err != nil {
606- return fmt.Errorf("failed to fetch %s: %w", script, err)
607- }
608-
609- scriptPath := filepath.Join(config.PathMntPostBoot, script)
610- if err := p.fs.WriteFile(scriptPath, data, 0755); err != nil {
611- return fmt.Errorf("failed to write %s: %w", script, err)
612- }
613- }
614-
615- p.SendOutput(progressChan, "[OK] Post-boot scripts ready")
616-
617- // Fetch service template (local-first, then remote)
618- p.SendOutput(progressChan, "Creating systemd service...")
619- templateBytes, err := p.fetchFile(config.PostBootServiceTemplate)
620- if err != nil {
621- return fmt.Errorf("failed to fetch service template: %w", err)
622- }
623-
624- serviceContent := string(templateBytes)
625- serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_EMAIL__", p.config.Email)
626- serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_USERNAME__", p.config.Username)
627-
628- servicePath := filepath.Join(config.PathMntSystemdSystem, config.PostBootServiceName)
629- switch err := p.fs.WriteFile(servicePath, []byte(serviceContent), 0644); {
630- case err != nil:
631- return fmt.Errorf("failed to write service file: %w", err)
632- }
633-
634- p.SendOutput(progressChan, "[OK] Service file created")
635-
636- p.SendOutput(progressChan, "Enabling first-boot service...")
637- switch err := p.chrExec.ChrootSystemctl(p.logger.LogPath(), config.PathMnt, "enable", config.PostBootServiceName); {
638- case err != nil:
639- return fmt.Errorf("failed to enable service: %w", err)
640- }
641-
642- p.SendOutput(progressChan, "[OK] First-boot service enabled")
643- return nil
644-}
D internal/phases/postinstall_test.go +0 -491
@@ -1,491 +0,0 @@
1-package phases
2-
3-import (
4- "fmt"
5- "path/filepath"
6- "testing"
7-
8- "github.com/bnema/archup/internal/config"
9- "github.com/bnema/archup/internal/interfaces/mocks"
10- "github.com/bnema/archup/internal/logger"
11- "github.com/bnema/archup/internal/system"
12- "go.uber.org/mock/gomock"
13-)
14-
15-// TestPostInstallPhasePreCheck tests post-install prerequisites
16-func TestPostInstallPhasePreCheck(t *testing.T) {
17- tests := []struct {
18- name string
19- setupMocks func(*mocks.MockFileSystem, *mocks.MockSystemExecutor)
20- wantErr bool
21- errContains string
22- }{
23- {
24- name: "/mnt mounted and boot directory exists",
25- setupMocks: func(mockFS *mocks.MockFileSystem, mockSysExec *mocks.MockSystemExecutor) {
26- mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1)
27- mockFS.EXPECT().Stat(config.PathMntBoot).Return(mockFileInfo{}, nil).Times(1)
28- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
29- },
30- wantErr: false,
31- },
32- {
33- name: "/mnt not mounted",
34- setupMocks: func(mockFS *mocks.MockFileSystem, mockSysExec *mocks.MockSystemExecutor) {
35- mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 32}).Times(1)
36- },
37- wantErr: true,
38- errContains: "is not mounted",
39- },
40- {
41- name: "boot directory not found",
42- setupMocks: func(mockFS *mocks.MockFileSystem, mockSysExec *mocks.MockSystemExecutor) {
43- mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1)
44- mockFS.EXPECT().Stat(config.PathMntBoot).Return(nil, fmt.Errorf("not found")).Times(1)
45- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1)
46- },
47- wantErr: true,
48- errContains: "boot directory not found",
49- },
50- }
51-
52- for _, tt := range tests {
53- t.Run(tt.name, func(t *testing.T) {
54- ctrl := gomock.NewController(t)
55- defer ctrl.Finish()
56-
57- mockFS := mocks.NewMockFileSystem(ctrl)
58- mockHTTP := mocks.NewMockHTTPClient(ctrl)
59- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
60- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
61-
62- tt.setupMocks(mockFS, mockSysExec)
63-
64- tmpDir := t.TempDir()
65- logPath := filepath.Join(tmpDir, "test.log")
66- log, err := logger.New(logPath, false)
67- if err != nil {
68- t.Fatalf("Failed to create logger: %v", err)
69- }
70- defer func() {
71- if err := log.Close(); err != nil {
72- t.Fatalf("failed to close log: %v", err)
73- }
74- }()
75-
76- cfg := config.NewConfig("test")
77- phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
78-
79- err = phase.PreCheck()
80- if (err != nil) != tt.wantErr {
81- t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr)
82- return
83- }
84- if tt.wantErr && err != nil {
85- if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
86- t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
87- }
88- }
89- })
90- }
91-}
92-
93-// TestPostInstallPhaseShellConfiguration tests shell configuration integration
94-func TestPostInstallPhaseShellConfiguration(t *testing.T) {
95- tests := []struct {
96- name string
97- setupMocks func(*mocks.MockFileSystem, *mocks.MockChrootExecutor)
98- wantErr bool
99- }{
100- {
101- name: "Successfully configure shell",
102- setupMocks: func(mockFS *mocks.MockFileSystem, mockChrExec *mocks.MockChrootExecutor) {
103- mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
104- mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("content"), nil).AnyTimes()
105- mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
106- mockChrExec.EXPECT().ChrootExec(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
107- },
108- wantErr: false,
109- },
110- }
111-
112- for _, tt := range tests {
113- t.Run(tt.name, func(t *testing.T) {
114- ctrl := gomock.NewController(t)
115- defer ctrl.Finish()
116-
117- mockFS := mocks.NewMockFileSystem(ctrl)
118- mockHTTP := mocks.NewMockHTTPClient(ctrl)
119- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
120- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
121-
122- tt.setupMocks(mockFS, mockChrExec)
123-
124- tmpDir := t.TempDir()
125- logPath := filepath.Join(tmpDir, "test.log")
126- log, err := logger.New(logPath, false)
127- if err != nil {
128- t.Fatalf("Failed to create logger: %v", err)
129- }
130- defer func() {
131- if err := log.Close(); err != nil {
132- t.Fatalf("failed to close log: %v", err)
133- }
134- }()
135-
136- cfg := config.NewConfig("test")
137- phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
138-
139- // Verify phase can be created
140- if phase == nil {
141- t.Error("PostInstallPhase is nil")
142- }
143- })
144- }
145-}
146-
147-// TestPostInstallPhasePostCheck tests post-install validation
148-func TestPostInstallPhasePostCheck(t *testing.T) {
149- ctrl := gomock.NewController(t)
150- defer ctrl.Finish()
151-
152- mockFS := mocks.NewMockFileSystem(ctrl)
153- mockHTTP := mocks.NewMockHTTPClient(ctrl)
154- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
155- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
156-
157- tmpDir := t.TempDir()
158- logPath := filepath.Join(tmpDir, "test.log")
159- log, err := logger.New(logPath, false)
160- if err != nil {
161- t.Fatalf("Failed to create logger: %v", err)
162- }
163- defer func() {
164- if err := log.Close(); err != nil {
165- t.Fatalf("failed to close log: %v", err)
166- }
167- }()
168-
169- cfg := config.NewConfig("test")
170- cfg.ConfigPath = filepath.Join(tmpDir, "config")
171-
172- phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
173-
174- err = phase.PostCheck()
175- // PostCheck should succeed as it just saves the config
176- if err != nil {
177- t.Errorf("PostCheck() error = %v, want nil", err)
178- }
179-}
180-
181-// TestPostInstallPhaseRollback tests post-install rollback
182-func TestPostInstallPhaseRollback(t *testing.T) {
183- ctrl := gomock.NewController(t)
184- defer ctrl.Finish()
185-
186- mockFS := mocks.NewMockFileSystem(ctrl)
187- mockHTTP := mocks.NewMockHTTPClient(ctrl)
188- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
189- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
190-
191- tmpDir := t.TempDir()
192- logPath := filepath.Join(tmpDir, "test.log")
193- log, err := logger.New(logPath, false)
194- if err != nil {
195- t.Fatalf("Failed to create logger: %v", err)
196- }
197- defer func() {
198- if err := log.Close(); err != nil {
199- t.Fatalf("failed to close log: %v", err)
200- }
201- }()
202-
203- cfg := config.NewConfig("test")
204- phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
205-
206- // Rollback should always succeed (it's a cleanup operation)
207- err = phase.Rollback()
208- if err != nil {
209- t.Errorf("Rollback() error = %v, want nil", err)
210- }
211-}
212-
213-// TestPostInstallPhaseCanSkip tests the CanSkip method
214-func TestPostInstallPhaseCanSkip(t *testing.T) {
215- ctrl := gomock.NewController(t)
216- defer ctrl.Finish()
217-
218- mockFS := mocks.NewMockFileSystem(ctrl)
219- mockHTTP := mocks.NewMockHTTPClient(ctrl)
220- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
221- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
222-
223- tmpDir := t.TempDir()
224- logPath := filepath.Join(tmpDir, "test.log")
225- log, err := logger.New(logPath, false)
226- if err != nil {
227- t.Fatalf("Failed to create logger: %v", err)
228- }
229- defer func() {
230- if err := log.Close(); err != nil {
231- t.Fatalf("failed to close log: %v", err)
232- }
233- }()
234-
235- cfg := config.NewConfig("test")
236- phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
237-
238- if phase.CanSkip() {
239- t.Error("CanSkip() = true, want false")
240- }
241-}
242-
243-// TestPostInstallPhaseExecute tests the Execute method
244-func TestPostInstallPhaseExecute(t *testing.T) {
245- ctrl := gomock.NewController(t)
246- defer ctrl.Finish()
247-
248- mockFS := mocks.NewMockFileSystem(ctrl)
249- mockHTTP := mocks.NewMockHTTPClient(ctrl)
250- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
251- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
252-
253- // Allow any file system and executor operations
254- mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte{}, nil).AnyTimes()
255- mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
256- mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
257- mockFS.EXPECT().Create(gomock.Any()).Return(&MockWriteCloser{}, nil).AnyTimes()
258- mockFS.EXPECT().Stat(gomock.Any()).Return(mockFileInfo{}, nil).AnyTimes()
259- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).AnyTimes()
260- mockFS.EXPECT().Chmod(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
261- mockHTTP.EXPECT().Get(gomock.Any()).Return(nil, fmt.Errorf("skip http")).AnyTimes()
262- mockChrExec.EXPECT().ChrootExec(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
263- mockChrExec.EXPECT().ChrootPacman(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
264- mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
265- mockSysExec.EXPECT().RunSimple(gomock.Any(), gomock.Any()).Return(system.CommandResult{ExitCode: 0}).AnyTimes()
266-
267- tmpDir := t.TempDir()
268- logPath := filepath.Join(tmpDir, "test.log")
269- log, err := logger.New(logPath, false)
270- if err != nil {
271- t.Fatalf("Failed to create logger: %v", err)
272- }
273- defer func() {
274- if err := log.Close(); err != nil {
275- t.Fatalf("failed to close log: %v", err)
276- }
277- }()
278-
279- cfg := config.NewConfig("test")
280- cfg.KernelChoice = "linux"
281- cfg.RawURL = "https://example.com"
282-
283- phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
284-
285- progressChan := make(chan ProgressUpdate, 100)
286-
287- // Note: Full Execute testing is complex due to multiple async operations
288- result := phase.Execute(progressChan)
289-
290- // Verify result structure is valid
291- _ = result // If we got here, Execute returned a valid result structure
292-}
293-
294-// TestPostInstallPhaseEncryptionHandling tests encryption-aware operations
295-func TestPostInstallPhaseEncryptionHandling(t *testing.T) {
296- tests := []struct {
297- name string
298- encryptionType string
299- expectedClosing bool
300- }{
301- {
302- name: "Unencrypted - no closing needed",
303- encryptionType: config.EncryptionNone,
304- expectedClosing: false,
305- },
306- {
307- name: "LUKS encrypted - closing needed",
308- encryptionType: config.EncryptionLUKS,
309- expectedClosing: true,
310- },
311- {
312- name: "LUKS+LVM - closing needed",
313- encryptionType: config.EncryptionLUKSLVM,
314- expectedClosing: true,
315- },
316- }
317-
318- for _, tt := range tests {
319- t.Run(tt.name, func(t *testing.T) {
320- ctrl := gomock.NewController(t)
321- defer ctrl.Finish()
322-
323- mockFS := mocks.NewMockFileSystem(ctrl)
324- mockHTTP := mocks.NewMockHTTPClient(ctrl)
325- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
326- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
327-
328- tmpDir := t.TempDir()
329- logPath := filepath.Join(tmpDir, "test.log")
330- log, err := logger.New(logPath, false)
331- if err != nil {
332- t.Fatalf("Failed to create logger: %v", err)
333- }
334- defer func() {
335- if err := log.Close(); err != nil {
336- t.Fatalf("failed to close log: %v", err)
337- }
338- }()
339-
340- cfg := config.NewConfig("test")
341- cfg.EncryptionType = tt.encryptionType
342-
343- phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
344-
345- // Verify encryption type is stored correctly
346- if phase.config.EncryptionType != tt.encryptionType {
347- t.Errorf("Encryption type = %q, want %q", phase.config.EncryptionType, tt.encryptionType)
348- }
349-
350- // Verify closing would be needed based on encryption
351- needsClosing := tt.encryptionType == config.EncryptionLUKS || tt.encryptionType == config.EncryptionLUKSLVM
352- if needsClosing != tt.expectedClosing {
353- t.Errorf("Closing needed = %v, want %v", needsClosing, tt.expectedClosing)
354- }
355- })
356- }
357-}
358-
359-// TestPostInstallPhaseFileSystemOperations tests file system interaction patterns
360-func TestPostInstallPhaseFileSystemOperations(t *testing.T) {
361- tests := []struct {
362- name string
363- operation string // "read", "write", "mkdir", "create"
364- }{
365- {
366- name: "Read pacman.conf",
367- operation: "read",
368- },
369- {
370- name: "Write pacman.conf",
371- operation: "write",
372- },
373- {
374- name: "Create hook directory",
375- operation: "mkdir",
376- },
377- {
378- name: "Create hook file",
379- operation: "create",
380- },
381- }
382-
383- for _, tt := range tests {
384- t.Run(tt.name, func(t *testing.T) {
385- ctrl := gomock.NewController(t)
386- defer ctrl.Finish()
387-
388- mockFS := mocks.NewMockFileSystem(ctrl)
389- mockHTTP := mocks.NewMockHTTPClient(ctrl)
390- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
391- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
392-
393- // Setup basic expectations based on operation
394- switch tt.operation {
395- case "read":
396- mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("content"), nil).AnyTimes()
397- case "write":
398- mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
399- case "mkdir":
400- mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
401- case "create":
402- mockFS.EXPECT().Create(gomock.Any()).Return(&MockWriteCloser{}, nil).AnyTimes()
403- }
404-
405- tmpDir := t.TempDir()
406- logPath := filepath.Join(tmpDir, "test.log")
407- log, err := logger.New(logPath, false)
408- if err != nil {
409- t.Fatalf("Failed to create logger: %v", err)
410- }
411- defer func() {
412- if err := log.Close(); err != nil {
413- t.Fatalf("failed to close log: %v", err)
414- }
415- }()
416-
417- cfg := config.NewConfig("test")
418- phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
419-
420- // Verify phase can be created
421- if phase == nil {
422- t.Error("PostInstallPhase is nil")
423- }
424- })
425- }
426-}
427-
428-// TestPostInstallPhaseKernelVerification tests kernel verification logic
429-func TestPostInstallPhaseKernelVerification(t *testing.T) {
430- tests := []struct {
431- name string
432- kernelChoice string
433- expectedFormat string
434- }{
435- {
436- name: "Linux kernel",
437- kernelChoice: "linux",
438- expectedFormat: "vmlinuz-linux",
439- },
440- {
441- name: "Linux LTS kernel",
442- kernelChoice: "linux-lts",
443- expectedFormat: "vmlinuz-linux-lts",
444- },
445- {
446- name: "Linux Zen kernel",
447- kernelChoice: "linux-zen",
448- expectedFormat: "vmlinuz-linux-zen",
449- },
450- }
451-
452- for _, tt := range tests {
453- t.Run(tt.name, func(t *testing.T) {
454- ctrl := gomock.NewController(t)
455- defer ctrl.Finish()
456-
457- mockFS := mocks.NewMockFileSystem(ctrl)
458- mockHTTP := mocks.NewMockHTTPClient(ctrl)
459- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
460- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
461-
462- tmpDir := t.TempDir()
463- logPath := filepath.Join(tmpDir, "test.log")
464- log, err := logger.New(logPath, false)
465- if err != nil {
466- t.Fatalf("Failed to create logger: %v", err)
467- }
468- defer func() {
469- if err := log.Close(); err != nil {
470- t.Fatalf("failed to close log: %v", err)
471- }
472- }()
473-
474- cfg := config.NewConfig("test")
475- cfg.KernelChoice = tt.kernelChoice
476-
477- phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
478-
479- // Verify kernel choice is stored
480- if phase.config.KernelChoice != tt.kernelChoice {
481- t.Errorf("Kernel choice = %q, want %q", phase.config.KernelChoice, tt.kernelChoice)
482- }
483-
484- // Verify expected kernel name format
485- expectedKernelName := fmt.Sprintf("vmlinuz-%s", tt.kernelChoice)
486- if !containsSubstring(expectedKernelName, "vmlinuz") {
487- t.Errorf("Expected kernel name format %q doesn't contain 'vmlinuz'", expectedKernelName)
488- }
489- })
490- }
491-}
D internal/phases/preflight.go +0 -127
@@ -1,127 +0,0 @@
1-package phases
2-
3-import (
4- "fmt"
5- "strings"
6-
7- "github.com/bnema/archup/internal/config"
8- "github.com/bnema/archup/internal/interfaces"
9- "github.com/bnema/archup/internal/logger"
10-)
11-
12-// PreflightPhase handles system validation and initial configuration
13-type PreflightPhase struct {
14- *BasePhase
15- fs interfaces.FileSystem
16- cmdExec interfaces.CommandExecutor
17-}
18-
19-// NewPreflightPhase creates a new preflight phase
20-func NewPreflightPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, cmdExec interfaces.CommandExecutor) *PreflightPhase {
21- return &PreflightPhase{
22- BasePhase: NewBasePhase("preflight", "Preflight Checks", cfg, log),
23- fs: fs,
24- cmdExec: cmdExec,
25- }
26-}
27-
28-// PreCheck validates system requirements
29-func (p *PreflightPhase) PreCheck() error {
30- // Check for Arch Linux
31- if _, err := p.fs.Stat("/etc/arch-release"); p.fs.IsNotExist(err) {
32- return fmt.Errorf("must be running on Arch Linux or Arch ISO")
33- }
34-
35- // Check for derivatives
36- derivatives := map[string]string{
37- "/etc/cachyos-release": "CachyOS",
38- "/etc/eos-release": "EndeavourOS",
39- "/etc/garuda-release": "Garuda",
40- "/etc/manjaro-release": "Manjaro",
41- }
42-
43- for marker, name := range derivatives {
44- if _, err := p.fs.Stat(marker); err == nil {
45- return fmt.Errorf("must be vanilla Arch (detected %s)", name)
46- }
47- }
48-
49- // Check architecture
50- out, err := p.cmdExec.Execute("uname", "-m")
51- arch := strings.TrimSpace(string(out))
52-
53- switch {
54- case err != nil:
55- return fmt.Errorf("failed to detect architecture: %w", err)
56- case arch != "x86_64":
57- return fmt.Errorf("must be x86_64 architecture (detected: %s)", arch)
58- }
59-
60- // Check UEFI
61- if _, err := p.fs.Stat("/sys/firmware/efi/efivars"); p.fs.IsNotExist(err) {
62- return fmt.Errorf("must be UEFI boot mode (legacy BIOS not supported)")
63- }
64-
65- // Check Secure Boot
66- out, err = p.cmdExec.Execute("bootctl", "status")
67- switch {
68- case err == nil && strings.Contains(string(out), "Secure Boot: enabled"):
69- return fmt.Errorf("secure boot must be disabled")
70- }
71-
72- return nil
73-}
74-
75-// Execute runs the preflight phase
76-func (p *PreflightPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
77- p.SendProgress(progressChan, "Running preflight checks...", 1, 2)
78-
79- // Set defaults
80- p.SetDefaults()
81-
82- p.SendProgress(progressChan, "Preflight checks complete", 2, 2)
83- p.SendComplete(progressChan, "Preflight complete")
84-
85- return PhaseResult{
86- Success: true,
87- Message: "Preflight checks passed",
88- }
89-}
90-
91-// SetDefaults sets default configuration values
92-func (p *PreflightPhase) SetDefaults() {
93- if p.config.Hostname == "" {
94- p.config.Hostname = "archup"
95- }
96-
97- if p.config.Timezone == "" {
98- p.config.Timezone = "UTC"
99- }
100-
101- // Hardcoded bootloader
102- p.config.Bootloader = config.BootloaderLimine
103-}
104-
105-// PostCheck validates configuration after form submission
106-func (p *PreflightPhase) PostCheck() error {
107- switch {
108- case p.config.Username == "":
109- return fmt.Errorf("username is required")
110- case p.config.Hostname == "":
111- return fmt.Errorf("hostname is required")
112- case p.config.Timezone == "":
113- return fmt.Errorf("timezone is required")
114- }
115-
116- return p.config.Save()
117-}
118-
119-// Rollback for preflight (no-op as no system changes made)
120-func (p *PreflightPhase) Rollback() error {
121- return nil
122-}
123-
124-// CanSkip returns false - preflight cannot be skipped
125-func (p *PreflightPhase) CanSkip() bool {
126- return false
127-}
D internal/phases/preflight_test.go +0 -477
@@ -1,477 +0,0 @@
1-package phases
2-
3-import (
4- "fmt"
5- "path/filepath"
6- "testing"
7-
8- "github.com/bnema/archup/internal/config"
9- "github.com/bnema/archup/internal/interfaces/mocks"
10- "github.com/bnema/archup/internal/logger"
11- "go.uber.org/mock/gomock"
12-)
13-
14-// TestPreflightPhasePreCheck tests the PreCheck validation
15-func TestPreflightPhasePreCheck(t *testing.T) {
16- tests := []struct {
17- name string
18- setupMocks func(*mocks.MockFileSystem, *mocks.MockCommandExecutor)
19- wantErr bool
20- errContains string
21- }{
22- {
23- name: "Arch Linux detection - file not found",
24- setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
25- mockFS.EXPECT().Stat("/etc/arch-release").Return(nil, fmt.Errorf("not found")).Times(1)
26- mockFS.EXPECT().IsNotExist(gomock.Not(nil)).Return(true).Times(1)
27- },
28- wantErr: true,
29- errContains: "must be running on Arch Linux",
30- },
31- {
32- name: "Arch Linux detected - CachyOS derivative detected",
33- setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
34- mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
35- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
36- // For derivatives, check all 4 since map iteration order isn't guaranteed
37- mockFS.EXPECT().Stat("/etc/cachyos-release").Return(mockFileInfo{}, nil).Times(1)
38- mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).AnyTimes()
39- mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).AnyTimes()
40- mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).AnyTimes()
41- },
42- wantErr: true,
43- errContains: "CachyOS",
44- },
45- {
46- name: "Arch Linux detected - vanilla Arch no derivatives",
47- setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
48- mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
49- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(2)
50- // All derivatives should return not found
51- mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
52- mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
53- mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
54- mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
55- mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1)
56- mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(mockFileInfo{}, nil).Times(1)
57- mockCmd.EXPECT().Execute("bootctl", "status").Return([]byte("Secure Boot: disabled\n"), nil).Times(1)
58- },
59- wantErr: false,
60- },
61- {
62- name: "Vanilla Arch - CPU detection fails",
63- setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
64- mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
65- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
66- // All derivatives should return not found
67- mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
68- mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
69- mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
70- mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
71- mockCmd.EXPECT().Execute("uname", "-m").Return(nil, fmt.Errorf("uname failed")).Times(1)
72- },
73- wantErr: true,
74- errContains: "failed to detect architecture",
75- },
76- {
77- name: "Vanilla Arch - CPU not x86_64",
78- setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
79- mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
80- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
81- // All derivatives should return not found
82- mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
83- mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
84- mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
85- mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
86- mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("armv7l\n"), nil).Times(1)
87- },
88- wantErr: true,
89- errContains: "must be x86_64",
90- },
91- {
92- name: "x86_64 detected - UEFI not found",
93- setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
94- mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
95- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
96- // All derivatives should return not found
97- mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
98- mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
99- mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
100- mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
101- mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1)
102- mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(nil, fmt.Errorf("not found")).Times(1)
103- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1)
104- },
105- wantErr: true,
106- errContains: "must be UEFI boot mode",
107- },
108- {
109- name: "UEFI detected - Secure Boot enabled",
110- setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
111- mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
112- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(2)
113- // All derivatives should return not found
114- mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
115- mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
116- mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
117- mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
118- mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1)
119- mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(mockFileInfo{}, nil).Times(1)
120- mockCmd.EXPECT().Execute("bootctl", "status").Return([]byte("Secure Boot: enabled\n"), nil).Times(1)
121- },
122- wantErr: true,
123- errContains: "secure boot must be disabled",
124- },
125- {
126- name: "Secure Boot disabled - all checks pass",
127- setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
128- mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
129- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(2)
130- // All derivatives should return not found
131- mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
132- mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
133- mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
134- mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
135- mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1)
136- mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(mockFileInfo{}, nil).Times(1)
137- mockCmd.EXPECT().Execute("bootctl", "status").Return([]byte("Secure Boot: disabled\n"), nil).Times(1)
138- },
139- wantErr: false,
140- },
141- {
142- name: "bootctl command fails - precheck should still pass",
143- setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) {
144- mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1)
145- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(2)
146- // All derivatives should return not found
147- mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1)
148- mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1)
149- mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1)
150- mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1)
151- mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1)
152- mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(mockFileInfo{}, nil).Times(1)
153- mockCmd.EXPECT().Execute("bootctl", "status").Return(nil, fmt.Errorf("bootctl not available")).Times(1)
154- },
155- wantErr: false,
156- },
157- }
158-
159- for _, tt := range tests {
160- t.Run(tt.name, func(t *testing.T) {
161- ctrl := gomock.NewController(t)
162- defer ctrl.Finish()
163-
164- mockFS := mocks.NewMockFileSystem(ctrl)
165- mockCmd := mocks.NewMockCommandExecutor(ctrl)
166-
167- tt.setupMocks(mockFS, mockCmd)
168-
169- tmpDir := t.TempDir()
170- logPath := filepath.Join(tmpDir, "test.log")
171- log, err := logger.New(logPath, false)
172- if err != nil {
173- t.Fatalf("Failed to create logger: %v", err)
174- }
175- defer func() {
176- if err := log.Close(); err != nil {
177- t.Fatalf("failed to close log: %v", err)
178- }
179- }()
180-
181- cfg := config.NewConfig("test")
182- phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
183-
184- err = phase.PreCheck()
185- if (err != nil) != tt.wantErr {
186- t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr)
187- return
188- }
189- if tt.wantErr && err != nil {
190- if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
191- t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
192- }
193- }
194- })
195- }
196-}
197-
198-// TestPreflightPhaseSetDefaults tests setting default configuration values
199-func TestPreflightPhaseSetDefaults(t *testing.T) {
200- tests := []struct {
201- name string
202- initialHostname string
203- initialTimezone string
204- expectedHostname string
205- expectedTimezone string
206- expectedBootloader string
207- }{
208- {
209- name: "Empty hostname and timezone - use defaults",
210- initialHostname: "",
211- initialTimezone: "",
212- expectedHostname: "archup",
213- expectedTimezone: "UTC",
214- expectedBootloader: config.BootloaderLimine,
215- },
216- {
217- name: "Custom hostname provided - keep custom",
218- initialHostname: "my-arch",
219- initialTimezone: "",
220- expectedHostname: "my-arch",
221- expectedTimezone: "UTC",
222- expectedBootloader: config.BootloaderLimine,
223- },
224- {
225- name: "Custom timezone provided - keep custom",
226- initialHostname: "",
227- initialTimezone: "Europe/London",
228- expectedHostname: "archup",
229- expectedTimezone: "Europe/London",
230- expectedBootloader: config.BootloaderLimine,
231- },
232- {
233- name: "Both custom - keep both",
234- initialHostname: "arch-desktop",
235- initialTimezone: "America/New_York",
236- expectedHostname: "arch-desktop",
237- expectedTimezone: "America/New_York",
238- expectedBootloader: config.BootloaderLimine,
239- },
240- }
241-
242- for _, tt := range tests {
243- t.Run(tt.name, func(t *testing.T) {
244- ctrl := gomock.NewController(t)
245- defer ctrl.Finish()
246-
247- mockFS := mocks.NewMockFileSystem(ctrl)
248- mockCmd := mocks.NewMockCommandExecutor(ctrl)
249-
250- tmpDir := t.TempDir()
251- logPath := filepath.Join(tmpDir, "test.log")
252- log, err := logger.New(logPath, false)
253- if err != nil {
254- t.Fatalf("Failed to create logger: %v", err)
255- }
256- defer func() {
257- if err := log.Close(); err != nil {
258- t.Fatalf("failed to close log: %v", err)
259- }
260- }()
261-
262- cfg := config.NewConfig("test")
263- cfg.Hostname = tt.initialHostname
264- cfg.Timezone = tt.initialTimezone
265-
266- phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
267- phase.SetDefaults()
268-
269- if cfg.Hostname != tt.expectedHostname {
270- t.Errorf("Hostname = %q, want %q", cfg.Hostname, tt.expectedHostname)
271- }
272- if cfg.Timezone != tt.expectedTimezone {
273- t.Errorf("Timezone = %q, want %q", cfg.Timezone, tt.expectedTimezone)
274- }
275- if cfg.Bootloader != tt.expectedBootloader {
276- t.Errorf("Bootloader = %q, want %q", cfg.Bootloader, tt.expectedBootloader)
277- }
278- })
279- }
280-}
281-
282-// TestPreflightPhasePostCheck tests configuration validation after form submission
283-func TestPreflightPhasePostCheck(t *testing.T) {
284- tests := []struct {
285- name string
286- username string
287- hostname string
288- timezone string
289- wantErr bool
290- errContains string
291- }{
292- {
293- name: "All fields present - validation passes",
294- username: "archuser",
295- hostname: "my-arch",
296- timezone: "UTC",
297- wantErr: false,
298- },
299- {
300- name: "Missing username - validation fails",
301- username: "",
302- hostname: "my-arch",
303- timezone: "UTC",
304- wantErr: true,
305- errContains: "username is required",
306- },
307- {
308- name: "Missing hostname - validation fails",
309- username: "archuser",
310- hostname: "",
311- timezone: "UTC",
312- wantErr: true,
313- errContains: "hostname is required",
314- },
315- {
316- name: "Missing timezone - validation fails",
317- username: "archuser",
318- hostname: "my-arch",
319- timezone: "",
320- wantErr: true,
321- errContains: "timezone is required",
322- },
323- {
324- name: "All fields empty - validation fails on username",
325- username: "",
326- hostname: "",
327- timezone: "",
328- wantErr: true,
329- errContains: "username is required",
330- },
331- }
332-
333- for _, tt := range tests {
334- t.Run(tt.name, func(t *testing.T) {
335- ctrl := gomock.NewController(t)
336- defer ctrl.Finish()
337-
338- mockFS := mocks.NewMockFileSystem(ctrl)
339- mockCmd := mocks.NewMockCommandExecutor(ctrl)
340-
341- tmpDir := t.TempDir()
342- logPath := filepath.Join(tmpDir, "test.log")
343- log, err := logger.New(logPath, false)
344- if err != nil {
345- t.Fatalf("Failed to create logger: %v", err)
346- }
347- defer func() {
348- if err := log.Close(); err != nil {
349- t.Fatalf("failed to close log: %v", err)
350- }
351- }()
352-
353- cfg := config.NewConfig("test")
354- cfg.Username = tt.username
355- cfg.Hostname = tt.hostname
356- cfg.Timezone = tt.timezone
357- cfg.ConfigPath = filepath.Join(tmpDir, "config") // Set config path to temp dir so Save() works
358-
359- phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
360-
361- err = phase.PostCheck()
362- if (err != nil) != tt.wantErr {
363- t.Errorf("PostCheck() error = %v, wantErr %v", err, tt.wantErr)
364- return
365- }
366- if tt.wantErr && err != nil {
367- if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
368- t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains)
369- }
370- }
371- })
372- }
373-}
374-
375-// TestPreflightPhaseExecute tests the Execute method
376-func TestPreflightPhaseExecute(t *testing.T) {
377- ctrl := gomock.NewController(t)
378- defer ctrl.Finish()
379-
380- mockFS := mocks.NewMockFileSystem(ctrl)
381- mockCmd := mocks.NewMockCommandExecutor(ctrl)
382-
383- tmpDir := t.TempDir()
384- logPath := filepath.Join(tmpDir, "test.log")
385- log, err := logger.New(logPath, false)
386- if err != nil {
387- t.Fatalf("Failed to create logger: %v", err)
388- }
389- defer func() {
390- if err := log.Close(); err != nil {
391- t.Fatalf("failed to close log: %v", err)
392- }
393- }()
394-
395- cfg := config.NewConfig("test")
396- phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
397-
398- progressChan := make(chan ProgressUpdate, 10)
399- result := phase.Execute(progressChan)
400-
401- if !result.Success {
402- t.Errorf("Execute() Success = %v, want true", result.Success)
403- }
404-
405- if result.Message != "Preflight checks passed" {
406- t.Errorf("Execute() Message = %q, want 'Preflight checks passed'", result.Message)
407- }
408-
409- // Verify progress updates were sent
410- updates := []ProgressUpdate{}
411- for len(progressChan) > 0 {
412- updates = append(updates, <-progressChan)
413- }
414-
415- if len(updates) < 1 {
416- t.Error("Execute() should send progress updates")
417- }
418-}
419-
420-// TestPreflightPhaseRollback tests the Rollback method
421-func TestPreflightPhaseRollback(t *testing.T) {
422- ctrl := gomock.NewController(t)
423- defer ctrl.Finish()
424-
425- mockFS := mocks.NewMockFileSystem(ctrl)
426- mockCmd := mocks.NewMockCommandExecutor(ctrl)
427-
428- tmpDir := t.TempDir()
429- logPath := filepath.Join(tmpDir, "test.log")
430- log, err := logger.New(logPath, false)
431- if err != nil {
432- t.Fatalf("Failed to create logger: %v", err)
433- }
434- defer func() {
435- if err := log.Close(); err != nil {
436- t.Fatalf("failed to close log: %v", err)
437- }
438- }()
439-
440- cfg := config.NewConfig("test")
441- phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
442-
443- err = phase.Rollback()
444- if err != nil {
445- t.Errorf("Rollback() error = %v, want nil", err)
446- }
447-}
448-
449-// TestPreflightPhaseCanSkip tests the CanSkip method
450-func TestPreflightPhaseCanSkip(t *testing.T) {
451- ctrl := gomock.NewController(t)
452- defer ctrl.Finish()
453-
454- mockFS := mocks.NewMockFileSystem(ctrl)
455- mockCmd := mocks.NewMockCommandExecutor(ctrl)
456-
457- tmpDir := t.TempDir()
458- logPath := filepath.Join(tmpDir, "test.log")
459- log, err := logger.New(logPath, false)
460- if err != nil {
461- t.Fatalf("Failed to create logger: %v", err)
462- }
463- defer func() {
464- if err := log.Close(); err != nil {
465- t.Fatalf("failed to close log: %v", err)
466- }
467- }()
468-
469- cfg := config.NewConfig("test")
470- phase := NewPreflightPhase(cfg, log, mockFS, mockCmd)
471-
472- if phase.CanSkip() {
473- t.Error("CanSkip() = true, want false")
474- }
475-}
476-
477-// Helper functions are defined in helpers_test.go to avoid duplication
D internal/phases/repos.go +0 -572
@@ -1,572 +0,0 @@
1-package phases
2-
3-import (
4- "bufio"
5- "fmt"
6- "path/filepath"
7- "strings"
8-
9- "github.com/bnema/archup/internal/config"
10- "github.com/bnema/archup/internal/interfaces"
11- "github.com/bnema/archup/internal/logger"
12- "github.com/bnema/archup/internal/system"
13-)
14-
15-// Pacman configuration constants
16-const (
17- pacmanMultilibSectionDisabled = "#[multilib]"
18- pacmanMultilibSectionEnabled = "[multilib]"
19- pacmanMultilibIncludeDisabled = "#Include = /etc/pacman.d/mirrorlist"
20- pacmanMultilibIncludeEnabled = "Include = /etc/pacman.d/mirrorlist"
21- pacmanSectionPrefix = "["
22-)
23-
24-// AUR helper constants and validation
25-const (
26- AURHelperParu = "paru"
27- AURHelperYay = "yay"
28-)
29-
30-// ValidAURHelpers contains the list of supported AUR helpers
31-var ValidAURHelpers = []string{AURHelperParu, AURHelperYay}
32-
33-// IsValidAURHelper validates if the given AUR helper is supported
34-func IsValidAURHelper(helper string) bool {
35- if helper == "" {
36- return true // Empty is valid (no AUR helper)
37- }
38- for _, valid := range ValidAURHelpers {
39- if helper == valid {
40- return true
41- }
42- }
43- return false
44-}
45-
46-// ReposPhase handles repository configuration
47-type ReposPhase struct {
48- *BasePhase
49- fs interfaces.FileSystem
50- sysExec interfaces.SystemExecutor
51- chrExec interfaces.ChrootExecutor
52- chaoticConfig map[string]string
53- chaoticAUREnabled bool
54-}
55-
56-// NewReposPhase creates a new repos phase
57-func NewReposPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, sysExec interfaces.SystemExecutor, chrExec interfaces.ChrootExecutor) *ReposPhase {
58- return &ReposPhase{
59- BasePhase: NewBasePhase("repos", "Repository Configuration", cfg, log),
60- fs: fs,
61- sysExec: sysExec,
62- chrExec: chrExec,
63- chaoticConfig: make(map[string]string),
64- }
65-}
66-
67-// PreCheck validates repos prerequisites
68-func (p *ReposPhase) PreCheck() error {
69- // Validate AUR helper first (config validation before system checks)
70- if p.config.AURHelper != "" && !IsValidAURHelper(p.config.AURHelper) {
71- return fmt.Errorf("invalid AUR helper: %s (must be one of: %v)", p.config.AURHelper, ValidAURHelpers)
72- }
73-
74- // Verify /mnt is mounted
75- result := p.sysExec.RunSimple("mountpoint", "-q", config.PathMnt)
76- if result.ExitCode != 0 {
77- return fmt.Errorf("%s is not mounted", config.PathMnt)
78- }
79-
80- // Verify pacman.conf exists
81- if _, err := p.fs.Stat(config.PathMntEtcPacmanConf); p.fs.IsNotExist(err) {
82- return fmt.Errorf("pacman.conf not found")
83- }
84-
85- return nil
86-}
87-
88-// Execute runs the repos phase
89-func (p *ReposPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
90- p.SendProgress(progressChan, "Starting repository configuration...", 1, 5)
91-
92- // Step 1: Enable multilib (optional)
93- switch {
94- case p.config.EnableMultilib:
95- if err := p.enableMultilib(progressChan); err != nil {
96- p.SendError(progressChan, err)
97- return PhaseResult{Success: false, Error: err}
98- }
99- default:
100- p.SendOutput(progressChan, "[SKIP] Multilib disabled")
101- }
102-
103- // Step 2: Sync package databases after enabling multilib
104- p.SendProgress(progressChan, "Syncing package databases...", 2, 5)
105- if err := p.syncDatabases(progressChan); err != nil {
106- p.logger.Warn("Database sync failed", "error", err)
107- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Database sync failed: %v", err))
108- // Don't fail the phase, just warn
109- }
110-
111- p.SendProgress(progressChan, "Installing extra packages...", 3, 5)
112-
113- // Step 3: Install extra packages (always, from official repos)
114- if err := p.installExtraPackages(progressChan); err != nil {
115- // Don't fail the phase, just warn
116- p.logger.Warn("Extra packages installation failed", "error", err)
117- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Some extra packages failed: %v", err))
118- }
119-
120- p.SendProgress(progressChan, "Configuring Chaotic-AUR...", 4, 5)
121-
122- // Step 4: Enable Chaotic-AUR (optional)
123- switch {
124- case p.config.EnableChaotic:
125- if err := p.enableChaoticAUR(progressChan); err != nil {
126- p.SendError(progressChan, err)
127- return PhaseResult{Success: false, Error: err}
128- }
129- default:
130- p.SendOutput(progressChan, "[SKIP] Chaotic-AUR disabled")
131- }
132-
133- p.SendProgress(progressChan, "Installing AUR helper...", 5, 5)
134-
135- // Step 5: Install AUR helper (optional)
136- switch {
137- case p.config.AURHelper != "":
138- if err := p.installAURHelper(progressChan); err != nil {
139- p.SendError(progressChan, err)
140- return PhaseResult{Success: false, Error: err}
141- }
142- default:
143- p.SendOutput(progressChan, "[SKIP] AUR helper disabled")
144- }
145-
146- p.SendProgress(progressChan, "Repository configuration complete", 5, 5)
147- p.SendComplete(progressChan, "Repositories configured successfully")
148-
149- return PhaseResult{Success: true, Message: "Repos configuration complete"}
150-}
151-
152-// syncDatabases syncs pacman package databases
153-func (p *ReposPhase) syncDatabases(progressChan chan<- ProgressUpdate) error {
154- p.SendOutput(progressChan, "Syncing package databases...")
155-
156- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "pacman -Sy --noconfirm"); err != nil {
157- return fmt.Errorf("failed to sync databases: %w", err)
158- }
159-
160- p.SendOutput(progressChan, "[OK] Package databases synced")
161- return nil
162-}
163-
164-// enableMultilib enables multilib repository
165-func (p *ReposPhase) enableMultilib(progressChan chan<- ProgressUpdate) error {
166- p.SendOutput(progressChan, "Enabling multilib repository...")
167-
168- // Read pacman.conf
169- content, err := p.fs.ReadFile(config.PathMntEtcPacmanConf)
170- if err != nil {
171- return fmt.Errorf("failed to read pacman.conf: %w", err)
172- }
173-
174- contentStr := string(content)
175-
176- // Check if already fully enabled (both section and Include line uncommented)
177- hasMultilibSection := strings.Contains(contentStr, "[multilib]") && !strings.Contains(contentStr, "#[multilib]")
178- hasMultilibInclude := strings.Contains(contentStr, pacmanMultilibIncludeEnabled)
179-
180- if hasMultilibSection && hasMultilibInclude {
181- p.SendOutput(progressChan, "[OK] Multilib already enabled")
182- return nil
183- }
184-
185- // Uncomment multilib section and its Include line in context-aware manner
186- // This ensures we only uncomment the Include that's right after [multilib]
187- lines := strings.Split(contentStr, "\n")
188- inMultilib := false
189- for i, line := range lines {
190- trimmedLine := strings.TrimSpace(line)
191-
192- switch {
193- case trimmedLine == pacmanMultilibSectionDisabled:
194- lines[i] = pacmanMultilibSectionEnabled
195- inMultilib = true
196- case trimmedLine == pacmanMultilibSectionEnabled:
197- // Already enabled, track that we're in multilib section
198- inMultilib = true
199- case inMultilib && trimmedLine == pacmanMultilibIncludeDisabled:
200- // Uncomment the Include line, preserving indentation
201- lines[i] = strings.Replace(line, "#Include", "Include", 1)
202- inMultilib = false // Only uncomment the first Include after [multilib]
203- case inMultilib && strings.HasPrefix(trimmedLine, pacmanSectionPrefix) && trimmedLine != pacmanMultilibSectionEnabled:
204- // Exited multilib section without finding Include
205- inMultilib = false
206- }
207- }
208- contentStr = strings.Join(lines, "\n")
209-
210- // Write updated config
211- if err := p.fs.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); err != nil {
212- return fmt.Errorf("failed to write pacman.conf: %w", err)
213- }
214-
215- p.SendOutput(progressChan, "[OK] Multilib enabled")
216-
217- return nil
218-}
219-
220-// loadChaoticConfig loads Chaotic-AUR configuration from file
221-func (p *ReposPhase) loadChaoticConfig() error {
222- configPath := p.getInstallPath(config.ChaoticConfigFile)
223-
224- file, err := p.fs.Open(configPath)
225- if err != nil {
226- return fmt.Errorf("failed to open chaotic config: %w", err)
227- }
228-
229- scanner := bufio.NewScanner(file)
230- for scanner.Scan() {
231- line := strings.TrimSpace(scanner.Text())
232-
233- // Skip comments and empty lines
234- switch {
235- case line == "":
236- continue
237- case strings.HasPrefix(line, "#"):
238- continue
239- }
240-
241- // Parse KEY=VALUE
242- parts := strings.SplitN(line, "=", 2)
243- if len(parts) == 2 {
244- p.chaoticConfig[parts[0]] = parts[1]
245- }
246- }
247-
248- if err := scanner.Err(); err != nil {
249- _ = file.Close()
250- return fmt.Errorf("error reading chaotic config: %w", err)
251- }
252-
253- if err := file.Close(); err != nil {
254- return fmt.Errorf("failed to close chaotic config: %w", err)
255- }
256-
257- return nil
258-}
259-
260-// enableChaoticAUR enables Chaotic-AUR repository
261-func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error {
262- p.SendOutput(progressChan, "Adding Chaotic-AUR repository...")
263-
264- // Load configuration for GPG key settings
265- if err := p.loadChaoticConfig(); err != nil {
266- return fmt.Errorf("failed to load Chaotic config: %w", err)
267- }
268-
269- keyID := p.chaoticConfig["CHAOTIC_KEY_ID"]
270- keyserver := p.chaoticConfig["CHAOTIC_KEYSERVER"]
271- repoName := p.chaoticConfig["CHAOTIC_REPO_NAME"]
272- mirrorlistPath := p.chaoticConfig["CHAOTIC_MIRRORLIST_PATH"]
273-
274- // Import GPG key
275- recvKeyCmd := fmt.Sprintf("pacman-key --recv-key %s --keyserver %s", keyID, keyserver)
276- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, recvKeyCmd); err != nil {
277- p.logger.Warn("Failed to receive Chaotic GPG key, skipping Chaotic-AUR", "error", err)
278- p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (GPG key fetch failed)")
279- return nil // Don't fail, just skip
280- }
281-
282- // Locally sign the key
283- lsignKeyCmd := fmt.Sprintf("pacman-key --lsign-key %s", keyID)
284- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, lsignKeyCmd); err != nil {
285- p.logger.Warn("Failed to sign Chaotic GPG key, skipping Chaotic-AUR", "error", err)
286- p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (GPG key signing failed)")
287- return nil // Don't fail, just skip
288- }
289-
290- p.SendOutput(progressChan, "[OK] Chaotic GPG key imported")
291-
292- // Fetch live mirrorlist from GitLab
293- p.SendOutput(progressChan, "Fetching Chaotic-AUR mirrors...")
294- mirrors, err := system.FetchChaoticMirrorlist()
295- if err != nil {
296- p.logger.Warn("Failed to fetch live Chaotic mirrors from GitLab, skipping Chaotic-AUR", "error", err)
297- p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (mirror fetch failed), skipping")
298- return nil // Don't fail, just skip
299- }
300-
301- // Count enabled vs disabled mirrors
302- enabledCount := 0
303- for _, m := range mirrors {
304- if m.Enabled {
305- enabledCount++
306- }
307- }
308-
309- p.logger.Info("Fetched live Chaotic-AUR mirrors from GitLab",
310- "total", len(mirrors),
311- "enabled", enabledCount,
312- "disabled", len(mirrors)-enabledCount)
313-
314- // Build package URLs from live mirrors
315- const (
316- keyringPkg = "chaotic-keyring.pkg.tar.zst"
317- mirrorlistPkg = "chaotic-mirrorlist.pkg.tar.zst"
318- )
319-
320- keyringURLs := system.BuildPackageURLs(mirrors, keyringPkg, true)
321- mirrorlistURLs := system.BuildPackageURLs(mirrors, mirrorlistPkg, true)
322-
323- if len(keyringURLs) == 0 || len(mirrorlistURLs) == 0 {
324- p.logger.Warn("Failed to build package URLs from mirrors, skipping Chaotic-AUR")
325- p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (no valid mirrors), skipping")
326- return nil // Don't fail, just skip
327- }
328-
329- // Combine all URLs for download
330- var urls []string
331- urls = append(urls, keyringURLs...)
332- urls = append(urls, mirrorlistURLs...)
333-
334- p.logger.Info("Built package URLs from live mirrors", "keyring_urls", len(keyringURLs), "mirrorlist_urls", len(mirrorlistURLs))
335-
336- // Download and install chaotic-keyring and chaotic-mirrorlist
337- p.SendOutput(progressChan, "Downloading Chaotic-AUR packages...")
338-
339- if err := p.chrExec.DownloadAndInstallPackages(p.logger.LogPath(), config.PathMnt, urls...); err != nil {
340- p.logger.Warn("Failed to download/install Chaotic packages, skipping Chaotic-AUR", "error", err)
341- p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (package download failed), skipping")
342- return nil // IMPORTANT: Exit early, don't add to pacman.conf
343- }
344-
345- p.SendOutput(progressChan, "[OK] Chaotic packages installed")
346-
347- // Add Chaotic-AUR to pacman.conf
348- content, err := p.fs.ReadFile(config.PathMntEtcPacmanConf)
349- if err != nil {
350- return fmt.Errorf("failed to read pacman.conf: %w", err)
351- }
352-
353- contentStr := string(content)
354-
355- // Check if already added
356- switch {
357- case strings.Contains(contentStr, fmt.Sprintf("[%s]", repoName)):
358- p.SendOutput(progressChan, "[OK] Chaotic-AUR already in pacman.conf")
359- default:
360- // Append Chaotic-AUR section
361- chaoticSection := fmt.Sprintf("\n# Chaotic-AUR repository\n[%s]\nInclude = %s\n", repoName, mirrorlistPath)
362- contentStr += chaoticSection
363-
364- if err := p.fs.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); err != nil {
365- return fmt.Errorf("failed to write pacman.conf: %w", err)
366- }
367-
368- p.SendOutput(progressChan, "[OK] Added Chaotic-AUR to pacman.conf")
369- }
370-
371- // Update package databases
372- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "pacman -Sy --noconfirm"); err != nil {
373- return fmt.Errorf("failed to update package databases: %w", err)
374- }
375-
376- // Verify Chaotic-AUR is working
377- verifyCmd := fmt.Sprintf("pacman -Sl %s", repoName)
378- result := p.sysExec.RunSimple("arch-chroot", config.PathMnt, "sh", "-c", verifyCmd)
379- if result.ExitCode != 0 {
380- p.SendOutput(progressChan, "[WARN] Chaotic-AUR verification failed")
381- return fmt.Errorf("Chaotic-AUR verification failed")
382- }
383-
384- p.SendOutput(progressChan, "[OK] Chaotic-AUR enabled successfully")
385- p.chaoticAUREnabled = true
386-
387- return nil
388-}
389-
390-// installPackagesIndividually tries to install packages one by one for resilience
391-func (p *ReposPhase) installPackagesIndividually(packages []string, progressChan chan<- ProgressUpdate) (int, []string) {
392- failedPkgs := []string{}
393- successCount := 0
394-
395- for _, pkg := range packages {
396- individualCmd := fmt.Sprintf("pacman -S --noconfirm --needed %s", pkg)
397- switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, individualCmd); {
398- case err != nil:
399- p.logger.Warn("Failed to install package", "package", pkg, "error", err)
400- failedPkgs = append(failedPkgs, pkg)
401- default:
402- successCount++
403- }
404- }
405-
406- return successCount, failedPkgs
407-}
408-
409-// installExtraPackages installs packages from extra.packages file
410-func (p *ReposPhase) installExtraPackages(progressChan chan<- ProgressUpdate) error {
411- p.SendOutput(progressChan, "Installing extra packages...")
412-
413- // Load extra packages
414- packages, err := p.loadExtraPackages()
415- switch {
416- case err != nil:
417- return fmt.Errorf("failed to load extra packages: %w", err)
418- case len(packages) == 0:
419- p.SendOutput(progressChan, "[SKIP] No extra packages to install")
420- return nil
421- }
422-
423- p.logger.Info("Installing extra packages", "count", len(packages), "packages", strings.Join(packages, ", "))
424- p.SendOutput(progressChan, fmt.Sprintf("Installing %d extra packages...", len(packages)))
425-
426- // Try batch installation first
427- installCmd := fmt.Sprintf("pacman -S --noconfirm --needed %s", strings.Join(packages, " "))
428- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, installCmd); err == nil {
429- p.logger.Info("Extra packages installed successfully", "count", len(packages))
430- p.SendOutput(progressChan, fmt.Sprintf("[OK] Installed %d extra packages", len(packages)))
431- return nil
432- }
433-
434- // Batch installation failed, try individual packages for resilience
435- p.logger.Warn("Batch installation failed, trying individual packages", "error", err)
436- p.SendOutput(progressChan, "[WARN] Batch installation failed, trying individual packages...")
437-
438- successCount, failedPkgs := p.installPackagesIndividually(packages, progressChan)
439-
440- // Report results
441- if len(failedPkgs) > 0 {
442- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to install: %s", strings.Join(failedPkgs, ", ")))
443- p.logger.Warn("Some packages failed to install", "failed_count", len(failedPkgs), "packages", failedPkgs)
444- }
445-
446- switch {
447- case successCount > 0:
448- p.SendOutput(progressChan, fmt.Sprintf("[OK] Installed %d/%d packages", successCount, len(packages)))
449- p.logger.Info("Partial package installation successful", "success_count", successCount, "total", len(packages))
450- default:
451- p.SendOutput(progressChan, "[ERROR] Failed to install any packages")
452- return fmt.Errorf("failed to install all %d packages", len(packages))
453- }
454-
455- return nil
456-}
457-
458-// loadExtraPackages reads extra package list from file
459-func (p *ReposPhase) loadExtraPackages() ([]string, error) {
460- packageFile := p.getInstallPath(config.ExtraPackagesFile)
461-
462- file, err := p.fs.Open(packageFile)
463- if err != nil {
464- return nil, fmt.Errorf("failed to open %s: %w", packageFile, err)
465- }
466-
467- var packages []string
468- scanner := bufio.NewScanner(file)
469-
470- for scanner.Scan() {
471- line := strings.TrimSpace(scanner.Text())
472-
473- // Skip comments and empty lines
474- switch {
475- case line == "":
476- continue
477- case strings.HasPrefix(line, "#"):
478- continue
479- }
480-
481- packages = append(packages, line)
482- }
483-
484- if err := scanner.Err(); err != nil {
485- _ = file.Close()
486- return nil, fmt.Errorf("error reading package file: %w", err)
487- }
488-
489- if err := file.Close(); err != nil {
490- return nil, fmt.Errorf("failed to close %s: %w", packageFile, err)
491- }
492-
493- return packages, nil
494-}
495-
496-// installAURHelper installs the selected AUR helper from Chaotic-AUR
497-// Requires Chaotic-AUR to be enabled (no build-from-source fallback)
498-func (p *ReposPhase) installAURHelper(progressChan chan<- ProgressUpdate) error {
499- helper := p.config.AURHelper
500-
501- // Only install if Chaotic-AUR is enabled
502- if !p.chaoticAUREnabled {
503- p.SendOutput(progressChan, "[SKIP] AUR helper installation (Chaotic-AUR required)")
504- p.logger.Info("Skipping AUR helper installation - Chaotic-AUR not enabled", "helper", helper)
505- return nil
506- }
507-
508- // Install from Chaotic-AUR
509- p.SendOutput(progressChan, fmt.Sprintf("Installing %s from Chaotic-AUR...", helper))
510- installCmd := fmt.Sprintf("pacman -S --noconfirm --needed %s", helper)
511- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, installCmd); err != nil {
512- return fmt.Errorf("failed to install %s from Chaotic-AUR: %w", helper, err)
513- }
514-
515- p.SendOutput(progressChan, fmt.Sprintf("[OK] %s installed from Chaotic-AUR", helper))
516- p.logger.Info("AUR helper installed from Chaotic-AUR", "helper", helper)
517-
518- return nil
519-}
520-
521-// getInstallPath constructs full path to install file
522-func (p *ReposPhase) getInstallPath(filename string) string {
523- // Use DefaultInstallDir directly to match where bootstrap downloads files
524- return filepath.Join(config.DefaultInstallDir, filename)
525-}
526-
527-// PostCheck validates repository configuration
528-func (p *ReposPhase) PostCheck() error {
529- // Verify pacman.conf was modified
530- content, err := p.fs.ReadFile(config.PathMntEtcPacmanConf)
531- if err != nil {
532- return fmt.Errorf("failed to read pacman.conf: %w", err)
533- }
534-
535- contentStr := string(content)
536-
537- // Check multilib if enabled
538- switch {
539- case p.config.EnableMultilib:
540- switch {
541- case !strings.Contains(contentStr, "[multilib]"):
542- return fmt.Errorf("multilib was not enabled in pacman.conf")
543- }
544- }
545-
546- // Check Chaotic-AUR if enabled
547- switch {
548- case p.config.EnableChaotic:
549- repoName := p.chaoticConfig["CHAOTIC_REPO_NAME"]
550- if repoName == "" {
551- repoName = "chaotic-aur"
552- }
553-
554- switch {
555- case !strings.Contains(contentStr, fmt.Sprintf("[%s]", repoName)):
556- return fmt.Errorf("Chaotic-AUR was not added to pacman.conf")
557- }
558- }
559-
560- return p.config.Save()
561-}
562-
563-// Rollback for repos phase
564-func (p *ReposPhase) Rollback() error {
565- // Repository changes are in chroot, cleaned up by partitioning rollback
566- return nil
567-}
568-
569-// CanSkip returns false - repos cannot be skipped
570-func (p *ReposPhase) CanSkip() bool {
571- return false
572-}
D internal/phases/repos_test.go +0 -723
@@ -1,723 +0,0 @@
1-package phases
2-
3-import (
4- "path/filepath"
5- "strings"
6- "testing"
7-
8- "github.com/bnema/archup/internal/config"
9- "github.com/bnema/archup/internal/interfaces/mocks"
10- "github.com/bnema/archup/internal/logger"
11- "github.com/bnema/archup/internal/system"
12- "go.uber.org/mock/gomock"
13-)
14-
15-// TestEnableMultilibParsing tests the multilib section and Include line parsing
16-func TestEnableMultilibParsing(t *testing.T) {
17- tests := []struct {
18- name string
19- input string
20- wantMultilib bool
21- wantInclude bool
22- shouldNotHave string
23- }{
24- {
25- name: "Both commented - should uncomment both",
26- input: `[core]
27-#[multilib]
28-#Include = /etc/pacman.d/mirrorlist`,
29- wantMultilib: true,
30- wantInclude: true,
31- shouldNotHave: "#[multilib]",
32- },
33- {
34- name: "Multilib already enabled - Include still commented",
35- input: `[core]
36-[multilib]
37-#Include = /etc/pacman.d/mirrorlist`,
38- wantMultilib: true,
39- wantInclude: true,
40- shouldNotHave: "#Include = /etc/pacman.d/mirrorlist",
41- },
42- {
43- name: "Both already enabled - no changes",
44- input: `[core]
45-[multilib]
46-Include = /etc/pacman.d/mirrorlist`,
47- wantMultilib: true,
48- wantInclude: true,
49- shouldNotHave: "#",
50- },
51- {
52- name: "Only uncomment multilib Include, not other Includes",
53- input: `[core]
54-Include = /etc/pacman.d/mirrorlist
55-#[multilib]
56-#Include = /etc/pacman.d/mirrorlist
57-[community]
58-#Include = /etc/pacman.d/mirrorlist`,
59- wantMultilib: true,
60- wantInclude: true,
61- shouldNotHave: "",
62- },
63- {
64- name: "Preserve indentation in Include line",
65- input: `[core]
66-#[multilib]
67- #Include = /etc/pacman.d/mirrorlist`,
68- wantMultilib: true,
69- wantInclude: true,
70- shouldNotHave: "#Include",
71- },
72- }
73-
74- for _, tt := range tests {
75- t.Run(tt.name, func(t *testing.T) {
76- // Simulate the multilib enabling logic from repos.go
77- contentStr := tt.input
78- lines := strings.Split(contentStr, "\n")
79- inMultilib := false
80-
81- for i, line := range lines {
82- trimmedLine := strings.TrimSpace(line)
83-
84- switch {
85- case trimmedLine == pacmanMultilibSectionDisabled:
86- lines[i] = pacmanMultilibSectionEnabled
87- inMultilib = true
88- case trimmedLine == pacmanMultilibSectionEnabled:
89- // Already enabled, track that we're in multilib section
90- inMultilib = true
91- case inMultilib && trimmedLine == pacmanMultilibIncludeDisabled:
92- // Uncomment the Include line, preserving indentation
93- lines[i] = strings.Replace(line, "#Include", "Include", 1)
94- inMultilib = false // Only uncomment the first Include after [multilib]
95- case inMultilib && strings.HasPrefix(trimmedLine, pacmanSectionPrefix) && trimmedLine != pacmanMultilibSectionEnabled:
96- // Exited multilib section without finding Include
97- inMultilib = false
98- }
99- }
100- contentStr = strings.Join(lines, "\n")
101-
102- // Verify results
103- hasMultilib := strings.Contains(contentStr, "[multilib]") && !strings.Contains(contentStr, "#[multilib]")
104- hasInclude := strings.Contains(contentStr, "Include = /etc/pacman.d/mirrorlist")
105-
106- if tt.wantMultilib && !hasMultilib {
107- t.Errorf("Expected [multilib] to be uncommented, but it's not present or still commented")
108- }
109-
110- if tt.wantInclude && !hasInclude {
111- t.Errorf("Expected Include line to be uncommented under [multilib], but it's not")
112- }
113-
114- if tt.shouldNotHave != "" && strings.Contains(contentStr, tt.shouldNotHave) {
115- t.Errorf("Expected NOT to find '%s', but found it in output", tt.shouldNotHave)
116- }
117- })
118- }
119-}
120-
121-// TestMultilibEdgeCases tests edge cases in multilib parsing
122-func TestMultilibEdgeCases(t *testing.T) {
123- tests := []struct {
124- name string
125- input string
126- expectError bool
127- checkFunc func(string) bool
128- }{
129- {
130- name: "Empty file",
131- input: "",
132- expectError: false,
133- checkFunc: func(s string) bool {
134- return s == ""
135- },
136- },
137- {
138- name: "No multilib section",
139- input: `[core]
140-Include = /etc/pacman.d/mirrorlist
141-[extra]
142-Include = /etc/pacman.d/mirrorlist`,
143- expectError: false,
144- checkFunc: func(s string) bool {
145- return !strings.Contains(s, "[multilib]")
146- },
147- },
148- {
149- name: "Multiple Include lines with different paths",
150- input: `[core]
151-#[multilib]
152-#Include = /etc/pacman.d/mirrorlist
153-#Include = /etc/pacman.d/other`,
154- expectError: false,
155- checkFunc: func(s string) bool {
156- // Should only uncomment the first Include after [multilib]
157- lines := strings.Split(s, "\n")
158- var inMultilib bool
159- var foundInclude int
160- var commentedInclude int
161- for _, line := range lines {
162- trimmed := strings.TrimSpace(line)
163- if trimmed == "[multilib]" {
164- inMultilib = true
165- } else if inMultilib && strings.HasPrefix(trimmed, "Include = ") {
166- foundInclude++
167- } else if inMultilib && strings.HasPrefix(trimmed, "#Include") {
168- // Still commented - that's another Include
169- commentedInclude++
170- }
171- }
172- _ = commentedInclude
173- return foundInclude >= 1
174- },
175- },
176- }
177-
178- for _, tt := range tests {
179- t.Run(tt.name, func(t *testing.T) {
180- contentStr := tt.input
181- lines := strings.Split(contentStr, "\n")
182- inMultilib := false
183-
184- for i, line := range lines {
185- trimmedLine := strings.TrimSpace(line)
186-
187- switch {
188- case trimmedLine == pacmanMultilibSectionDisabled:
189- lines[i] = pacmanMultilibSectionEnabled
190- inMultilib = true
191- case inMultilib && trimmedLine == pacmanMultilibIncludeDisabled:
192- lines[i] = strings.Replace(line, "#Include", "Include", 1)
193- inMultilib = false
194- case inMultilib && strings.HasPrefix(trimmedLine, pacmanSectionPrefix) && trimmedLine != pacmanMultilibSectionEnabled:
195- inMultilib = false
196- }
197- }
198- contentStr = strings.Join(lines, "\n")
199-
200- if !tt.checkFunc(contentStr) {
201- t.Errorf("Check function failed for input:\n%s\n\nOutput:\n%s", tt.input, contentStr)
202- }
203- })
204- }
205-}
206-
207-// TestInstallPackagesIndividuallyLogic tests the fallback package installation logic
208-func TestInstallPackagesIndividuallyLogic(t *testing.T) {
209- // This test verifies the logic of the installPackagesIndividually function
210- // by testing how it would handle a list of packages
211-
212- packages := []string{"package1", "package2", "package3"}
213-
214- // Simulate successful installation for some, failure for others
215- failedPackages := map[string]bool{
216- "package2": true, // This one fails
217- }
218-
219- successCount := 0
220- failedPkgs := []string{}
221-
222- for _, pkg := range packages {
223- if failedPackages[pkg] {
224- failedPkgs = append(failedPkgs, pkg)
225- } else {
226- successCount++
227- }
228- }
229-
230- // Verify results
231- if successCount != 2 {
232- t.Errorf("Expected 2 successful installs, got %d", successCount)
233- }
234-
235- if len(failedPkgs) != 1 {
236- t.Errorf("Expected 1 failed package, got %d", len(failedPkgs))
237- }
238-
239- if len(failedPkgs) > 0 && failedPkgs[0] != "package2" {
240- t.Errorf("Expected 'package2' to fail, got %v", failedPkgs)
241- }
242-}
243-
244-// BenchmarkMultilibParsing benchmarks the multilib parsing performance
245-func BenchmarkMultilibParsing(b *testing.B) {
246- largeConfig := strings.Repeat(`[section]
247-#Include = /etc/pacman.d/mirrorlist
248-`, 100)
249- largeConfig = strings.Replace(largeConfig, "[section]", "[core]\n#[multilib]\n#Include = /etc/pacman.d/mirrorlist", 1)
250-
251- b.ResetTimer()
252- for i := 0; i < b.N; i++ {
253- contentStr := largeConfig
254- lines := strings.Split(contentStr, "\n")
255- inMultilib := false
256-
257- for i, line := range lines {
258- trimmedLine := strings.TrimSpace(line)
259-
260- switch {
261- case trimmedLine == pacmanMultilibSectionDisabled:
262- lines[i] = pacmanMultilibSectionEnabled
263- inMultilib = true
264- case trimmedLine == pacmanMultilibSectionEnabled:
265- // Already enabled, track that we're in multilib section
266- inMultilib = true
267- case inMultilib && trimmedLine == pacmanMultilibIncludeDisabled:
268- lines[i] = strings.Replace(line, "#Include", "Include", 1)
269- inMultilib = false
270- case inMultilib && strings.HasPrefix(trimmedLine, pacmanSectionPrefix) && trimmedLine != pacmanMultilibSectionEnabled:
271- inMultilib = false
272- }
273- }
274- _ = strings.Join(lines, "\n")
275- }
276-}
277-
278-// TestReposPhaseStructure tests that ReposPhase can be properly initialized
279-func TestReposPhaseStructure(t *testing.T) {
280- ctrl := gomock.NewController(t)
281- defer ctrl.Finish()
282-
283- // Create a temporary directory for log file
284- tmpDir := t.TempDir()
285- logPath := filepath.Join(tmpDir, "test.log")
286-
287- // Create logger
288- log, err := logger.New(logPath, false)
289- if err != nil {
290- t.Fatalf("Failed to create logger: %v", err)
291- }
292- defer func() {
293- if err := log.Close(); err != nil {
294- t.Fatalf("failed to close log: %v", err)
295- }
296- }()
297-
298- // Create config
299- cfg := config.NewConfig("test")
300-
301- // Create mocks
302- mockFS := mocks.NewMockFileSystem(ctrl)
303- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
304- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
305-
306- // Create ReposPhase
307- phase := NewReposPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
308-
309- // Verify initialization
310- if phase == nil {
311- t.Fatal("ReposPhase is nil")
312- }
313-
314- if phase.BasePhase == nil {
315- t.Fatal("BasePhase is nil")
316- }
317-
318- if len(phase.chaoticConfig) != 0 {
319- t.Fatal("chaoticConfig should be initialized as empty map")
320- }
321-}
322-
323-// TestPacmanConfigConstants verifies the constants are properly defined
324-func TestPacmanConfigConstants(t *testing.T) {
325- tests := []struct {
326- name string
327- value string
328- expected string
329- }{
330- {"pacmanMultilibSectionDisabled", pacmanMultilibSectionDisabled, "#[multilib]"},
331- {"pacmanMultilibSectionEnabled", pacmanMultilibSectionEnabled, "[multilib]"},
332- {"pacmanMultilibIncludeDisabled", pacmanMultilibIncludeDisabled, "#Include = /etc/pacman.d/mirrorlist"},
333- {"pacmanMultilibIncludeEnabled", pacmanMultilibIncludeEnabled, "Include = /etc/pacman.d/mirrorlist"},
334- {"pacmanSectionPrefix", pacmanSectionPrefix, "["},
335- }
336-
337- for _, tt := range tests {
338- t.Run(tt.name, func(t *testing.T) {
339- if tt.value != tt.expected {
340- t.Errorf("Expected %q, got %q", tt.expected, tt.value)
341- }
342- })
343- }
344-}
345-
346-// TestMultilibParsingRealWorldExample tests with a real pacman.conf structure
347-func TestMultilibParsingRealWorldExample(t *testing.T) {
348- // Simulate a real pacman.conf from Arch Linux
349- realWorldConfig := `#
350-# Arch Linux repository mirrorlist
351-# Generated on 2025-10-28
352-#
353-
354-[core]
355-Include = /etc/pacman.d/mirrorlist
356-
357-[extra]
358-Include = /etc/pacman.d/mirrorlist
359-
360-#[multilib]
361-#Include = /etc/pacman.d/mirrorlist
362-
363-[community]
364-Include = /etc/pacman.d/mirrorlist
365-
366-# An example of a custom package repository. See the pacman manpage for
367-# tips on creating your own repository.
368-#[custom]
369-#SigLevel = Optional TrustAll
370-#Server = file:///home/custompkgs
371-`
372-
373- contentStr := realWorldConfig
374- lines := strings.Split(contentStr, "\n")
375- inMultilib := false
376-
377- for i, line := range lines {
378- trimmedLine := strings.TrimSpace(line)
379-
380- switch {
381- case trimmedLine == pacmanMultilibSectionDisabled:
382- lines[i] = pacmanMultilibSectionEnabled
383- inMultilib = true
384- case inMultilib && trimmedLine == pacmanMultilibIncludeDisabled:
385- lines[i] = strings.Replace(line, "#Include", "Include", 1)
386- inMultilib = false
387- case inMultilib && strings.HasPrefix(trimmedLine, pacmanSectionPrefix) && trimmedLine != pacmanMultilibSectionEnabled:
388- inMultilib = false
389- }
390- }
391- contentStr = strings.Join(lines, "\n")
392-
393- // Verify results
394- if !strings.Contains(contentStr, "[multilib]") {
395- t.Error("Expected [multilib] section to be uncommented")
396- }
397-
398- if strings.Contains(contentStr, "#[multilib]") {
399- t.Error("Expected #[multilib] to be removed")
400- }
401-
402- if !strings.Contains(contentStr, "Include = /etc/pacman.d/mirrorlist") {
403- t.Error("Expected Include line to be present")
404- }
405-
406- // Count Include lines for multilib section
407- lines = strings.Split(contentStr, "\n")
408- var multilibFound bool
409- var multilibIncludeFound bool
410-
411- for i, line := range lines {
412- trimmed := strings.TrimSpace(line)
413- if trimmed == "[multilib]" {
414- multilibFound = true
415- } else if multilibFound {
416- if trimmed == "Include = /etc/pacman.d/mirrorlist" {
417- multilibIncludeFound = true
418- break
419- } else if strings.HasPrefix(trimmed, "[") {
420- // Exited multilib section
421- break
422- }
423- }
424- _ = i
425- }
426-
427- if !multilibIncludeFound {
428- t.Error("Expected Include line directly under [multilib] section")
429- }
430-}
431-
432-// TestIsValidAURHelper tests AUR helper validation
433-func TestIsValidAURHelper(t *testing.T) {
434- tests := []struct {
435- name string
436- helper string
437- expected bool
438- }{
439- {"Empty helper (no AUR helper)", "", true},
440- {"Valid paru", AURHelperParu, true},
441- {"Valid yay", AURHelperYay, true},
442- {"Invalid helper", "invalid-helper", false},
443- {"Invalid pacaur", "pacaur", false},
444- {"Invalid trizen", "trizen", false},
445- {"Case sensitive check", "Paru", false},
446- }
447-
448- for _, tt := range tests {
449- t.Run(tt.name, func(t *testing.T) {
450- result := IsValidAURHelper(tt.helper)
451- if result != tt.expected {
452- t.Errorf("IsValidAURHelper(%q) = %v, want %v", tt.helper, result, tt.expected)
453- }
454- })
455- }
456-}
457-
458-// TestAURHelperConstants verifies AUR helper constants are properly defined
459-func TestAURHelperConstants(t *testing.T) {
460- tests := []struct {
461- name string
462- value string
463- expected string
464- }{
465- {"AURHelperParu", AURHelperParu, "paru"},
466- {"AURHelperYay", AURHelperYay, "yay"},
467- }
468-
469- for _, tt := range tests {
470- t.Run(tt.name, func(t *testing.T) {
471- if tt.value != tt.expected {
472- t.Errorf("Expected %q, got %q", tt.expected, tt.value)
473- }
474- })
475- }
476-}
477-
478-// TestValidAURHelpersList verifies ValidAURHelpers array is correct
479-func TestValidAURHelpersList(t *testing.T) {
480- expected := []string{AURHelperParu, AURHelperYay}
481-
482- if len(ValidAURHelpers) != len(expected) {
483- t.Errorf("ValidAURHelpers length = %d, want %d", len(ValidAURHelpers), len(expected))
484- }
485-
486- for i, helper := range expected {
487- if ValidAURHelpers[i] != helper {
488- t.Errorf("ValidAURHelpers[%d] = %q, want %q", i, ValidAURHelpers[i], helper)
489- }
490- }
491-}
492-
493-// TestReposPhasePreCheckValidation tests PreCheck validation logic
494-func TestReposPhasePreCheckValidation(t *testing.T) {
495- // Create a temporary directory for log file
496- tmpDir := t.TempDir()
497- logPath := filepath.Join(tmpDir, "test.log")
498-
499- // Create logger
500- log, err := logger.New(logPath, false)
501- if err != nil {
502- t.Fatalf("Failed to create logger: %v", err)
503- }
504- defer func() {
505- if err := log.Close(); err != nil {
506- t.Fatalf("failed to close log: %v", err)
507- }
508- }()
509-
510- tests := []struct {
511- name string
512- aurHelper string
513- expectError bool
514- errorMsg string
515- }{
516- {
517- name: "Valid paru",
518- aurHelper: AURHelperParu,
519- expectError: false,
520- },
521- {
522- name: "Valid yay",
523- aurHelper: AURHelperYay,
524- expectError: false,
525- },
526- {
527- name: "Empty AUR helper",
528- aurHelper: "",
529- expectError: false,
530- },
531- {
532- name: "Invalid AUR helper",
533- aurHelper: "invalid-helper",
534- expectError: true,
535- errorMsg: "invalid AUR helper",
536- },
537- }
538-
539- for _, tt := range tests {
540- t.Run(tt.name, func(t *testing.T) {
541- ctrl := gomock.NewController(t)
542- defer ctrl.Finish()
543-
544- // Create config with test AUR helper
545- cfg := config.NewConfig("test")
546- cfg.AURHelper = tt.aurHelper
547-
548- // Create mocks
549- mockFS := mocks.NewMockFileSystem(ctrl)
550- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
551- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
552-
553- // Set up expected mock calls based on whether we'll pass AUR helper validation
554- // AUR helper validation happens first, so if invalid helper, we won't call RunSimple
555- if !tt.expectError || (tt.expectError && tt.errorMsg != "invalid AUR helper") {
556- mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1)
557- mockFS.EXPECT().Stat(config.PathMntEtcPacmanConf).Return(mockFileInfo{}, nil).Times(1)
558- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
559- }
560-
561- // Create ReposPhase
562- phase := NewReposPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
563-
564- // Note: PreCheck validates AUR helper first, then /mnt is mounted, and pacman.conf exists
565- err := phase.PreCheck()
566-
567- if tt.expectError {
568- if err == nil {
569- t.Errorf("Expected error for AUR helper %q, but got nil", tt.aurHelper)
570- } else if !strings.Contains(err.Error(), tt.errorMsg) {
571- t.Errorf("Expected error message to contain %q, got %q", tt.errorMsg, err.Error())
572- }
573- } else if !tt.expectError && err != nil {
574- // Only fail if the error is about AUR helper validation
575- if strings.Contains(err.Error(), "invalid AUR helper") {
576- t.Errorf("Did not expect AUR helper validation error for %q, but got: %v", tt.aurHelper, err)
577- }
578- // Other errors (like /mnt not mounted) are expected in test environment
579- }
580- })
581- }
582-}
583-
584-// TestNoPackageManagerLeakage verifies that the parsing doesn't break other repos
585-func TestNoPackageManagerLeakage(t *testing.T) {
586- pacmanConf := `[core]
587-Include = /etc/pacman.d/mirrorlist
588-
589-[extra]
590-Include = /etc/pacman.d/mirrorlist
591-
592-#[multilib]
593-#Include = /etc/pacman.d/mirrorlist
594-
595-[community]
596-Include = /etc/pacman.d/mirrorlist
597-
598-[options]
599-Server = http://mirror.example.com/arch/$repo/os/$arch
600-#Include = /etc/pacman.d/custom-servers`
601-
602- contentStr := pacmanConf
603- lines := strings.Split(contentStr, "\n")
604- inMultilib := false
605-
606- for i, line := range lines {
607- trimmedLine := strings.TrimSpace(line)
608-
609- switch {
610- case trimmedLine == pacmanMultilibSectionDisabled:
611- lines[i] = pacmanMultilibSectionEnabled
612- inMultilib = true
613- case inMultilib && trimmedLine == pacmanMultilibIncludeDisabled:
614- lines[i] = strings.Replace(line, "#Include", "Include", 1)
615- inMultilib = false
616- case inMultilib && strings.HasPrefix(trimmedLine, pacmanSectionPrefix) && trimmedLine != pacmanMultilibSectionEnabled:
617- inMultilib = false
618- }
619- }
620- contentStr = strings.Join(lines, "\n")
621-
622- // Verify that other repo Includes are not affected
623- coreCount := strings.Count(contentStr, "[core]")
624- extraCount := strings.Count(contentStr, "[extra]")
625- communityCount := strings.Count(contentStr, "[community]")
626-
627- if coreCount != 1 {
628- t.Error("Expected exactly one [core] section")
629- }
630- if extraCount != 1 {
631- t.Error("Expected exactly one [extra] section")
632- }
633- if communityCount != 1 {
634- t.Error("Expected exactly one [community] section")
635- }
636-
637- // Verify [options] section still has commented Include
638- if !strings.Contains(contentStr, "#Include = /etc/pacman.d/custom-servers") {
639- t.Error("Expected custom-servers Include to remain commented")
640- }
641-}
642-
643-// TestPackageFiltering tests the package filtering logic for AUR helper installation
644-func TestPackageFiltering(t *testing.T) {
645- tests := []struct {
646- name string
647- output string
648- wantPackage string
649- wantError bool
650- }{
651- {
652- name: "yay with debug package",
653- output: `/tmp/yay/yay-12.5.2-2-x86_64.pkg.tar.zst
654-/tmp/yay/yay-debug-12.5.2-2-x86_64.pkg.tar.zst`,
655- wantPackage: "/tmp/yay/yay-12.5.2-2-x86_64.pkg.tar.zst",
656- wantError: false,
657- },
658- {
659- name: "paru with debug package",
660- output: `/tmp/paru/paru-2.1.0-1-x86_64.pkg.tar.zst
661-/tmp/paru/paru-debug-2.1.0-1-x86_64.pkg.tar.zst`,
662- wantPackage: "/tmp/paru/paru-2.1.0-1-x86_64.pkg.tar.zst",
663- wantError: false,
664- },
665- {
666- name: "only debug package (should filter all)",
667- output: `/tmp/yay/yay-debug-12.5.2-2-x86_64.pkg.tar.zst`,
668- wantPackage: "",
669- wantError: true,
670- },
671- {
672- name: "empty output",
673- output: "",
674- wantPackage: "",
675- wantError: true,
676- },
677- {
678- name: "single main package without debug",
679- output: `/tmp/yay/yay-12.5.2-2-x86_64.pkg.tar.zst`,
680- wantPackage: "/tmp/yay/yay-12.5.2-2-x86_64.pkg.tar.zst",
681- wantError: false,
682- },
683- {
684- name: "with whitespace and empty lines",
685- output: `
686-/tmp/yay/yay-12.5.2-2-x86_64.pkg.tar.zst
687-
688-/tmp/yay/yay-debug-12.5.2-2-x86_64.pkg.tar.zst
689-`,
690- wantPackage: "/tmp/yay/yay-12.5.2-2-x86_64.pkg.tar.zst",
691- wantError: false,
692- },
693- }
694-
695- for _, tt := range tests {
696- t.Run(tt.name, func(t *testing.T) {
697- // Simulate the filtering logic from buildFromAUR
698- lines := strings.Split(strings.TrimSpace(tt.output), "\n")
699- var mainPackage string
700- for _, line := range lines {
701- line = strings.TrimSpace(line)
702- // Skip debug packages (contain "-debug-" in the filename)
703- if line != "" && !strings.Contains(line, "-debug-") {
704- mainPackage = line
705- break
706- }
707- }
708-
709- if tt.wantError && mainPackage != "" {
710- t.Errorf("Expected no package but got: %s", mainPackage)
711- }
712-
713- if !tt.wantError && mainPackage != tt.wantPackage {
714- t.Errorf("Expected package %q, got %q", tt.wantPackage, mainPackage)
715- }
716-
717- if tt.wantError && mainPackage == "" {
718- // This is expected
719- return
720- }
721- })
722- }
723-}
D internal/phases/systemconfig.go +0 -254
@@ -1,254 +0,0 @@
1-package phases
2-
3-import (
4- "fmt"
5- "path/filepath"
6-
7- "github.com/bnema/archup/internal/config"
8- "github.com/bnema/archup/internal/interfaces"
9- "github.com/bnema/archup/internal/logger"
10-)
11-
12-// ConfigPhase handles system configuration
13-type ConfigPhase struct {
14- *BasePhase
15- fs interfaces.FileSystem
16- sysExec interfaces.SystemExecutor
17- chrExec interfaces.ChrootExecutor
18-}
19-
20-// NewConfigPhase creates a new config phase
21-func NewConfigPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, sysExec interfaces.SystemExecutor, chrExec interfaces.ChrootExecutor) *ConfigPhase {
22- return &ConfigPhase{
23- BasePhase: NewBasePhase("config", "System Configuration", cfg, log),
24- fs: fs,
25- sysExec: sysExec,
26- chrExec: chrExec,
27- }
28-}
29-
30-// PreCheck validates configuration prerequisites
31-func (p *ConfigPhase) PreCheck() error {
32- // Verify /mnt is mounted
33- result := p.sysExec.RunSimple("mountpoint", "-q", config.PathMnt)
34- if result.ExitCode != 0 {
35- return fmt.Errorf("%s is not mounted", config.PathMnt)
36- }
37-
38- // Verify required config values
39- switch {
40- case p.config.Hostname == "":
41- return fmt.Errorf("hostname is required")
42- case p.config.Username == "":
43- return fmt.Errorf("username is required")
44- case p.config.UserPassword == "":
45- return fmt.Errorf("user password is required")
46- case p.config.Timezone == "":
47- return fmt.Errorf("timezone is required")
48- }
49-
50- return nil
51-}
52-
53-// Execute runs the config phase
54-func (p *ConfigPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
55- p.SendProgress(progressChan, "Starting system configuration...", 1, 4)
56-
57- // Step 1: System config (timezone, locale, hostname)
58- if err := p.configureSystem(progressChan); err != nil {
59- p.SendError(progressChan, err)
60- return PhaseResult{Success: false, Error: err}
61- }
62-
63- p.SendProgress(progressChan, "Configuring network...", 2, 4)
64-
65- // Step 2: Network config
66- if err := p.configureNetwork(progressChan); err != nil {
67- p.SendError(progressChan, err)
68- return PhaseResult{Success: false, Error: err}
69- }
70-
71- p.SendProgress(progressChan, "Creating user account...", 3, 4)
72-
73- // Step 3: User creation
74- if err := p.createUser(progressChan); err != nil {
75- p.SendError(progressChan, err)
76- return PhaseResult{Success: false, Error: err}
77- }
78-
79- p.SendProgress(progressChan, "Configuring zram swap...", 4, 4)
80-
81- // Step 4: Configure zram
82- if err := p.configureZram(progressChan); err != nil {
83- p.SendError(progressChan, err)
84- return PhaseResult{Success: false, Error: err}
85- }
86-
87- p.SendProgress(progressChan, "Configuration complete", 4, 4)
88- p.SendComplete(progressChan, "System configured successfully")
89-
90- return PhaseResult{Success: true, Message: "Configuration complete"}
91-}
92-
93-// configureSystem sets timezone, locale, hostname
94-func (p *ConfigPhase) configureSystem(progressChan chan<- ProgressUpdate) error {
95- p.SendOutput(progressChan, "Configuring system settings...")
96-
97- // Set timezone
98- timezonePath := filepath.Join(config.PathUsrShareZoneinfo, p.config.Timezone)
99- tzCmd := fmt.Sprintf("ln -sf %s %s", timezonePath, config.PathEtcLocaltime)
100- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, tzCmd); err != nil {
101- return fmt.Errorf("failed to set timezone: %w", err)
102- }
103-
104- // Set hardware clock
105- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "hwclock --systohc"); err != nil {
106- return fmt.Errorf("failed to set hardware clock: %w", err)
107- }
108-
109- p.SendOutput(progressChan, fmt.Sprintf("[OK] Timezone set to: %s", p.config.Timezone))
110-
111- // Set locale
112- localeGen := p.config.Locale + " UTF-8\n"
113- if err := p.fs.WriteFile(config.PathMntEtcLocaleGen, []byte(localeGen), 0644); err != nil {
114- return fmt.Errorf("failed to write locale.gen: %w", err)
115- }
116-
117- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "locale-gen"); err != nil {
118- return fmt.Errorf("failed to generate locale: %w", err)
119- }
120-
121- localeConf := fmt.Sprintf("LANG=%s\n", p.config.Locale)
122- if err := p.fs.WriteFile(config.PathMntEtcLocaleConf, []byte(localeConf), 0644); err != nil {
123- return fmt.Errorf("failed to write locale.conf: %w", err)
124- }
125-
126- p.SendOutput(progressChan, fmt.Sprintf("[OK] Locale set to: %s", p.config.Locale))
127-
128- // Set console keymap
129- if p.config.Keymap != "" {
130- vconsoleConf := fmt.Sprintf("KEYMAP=%s\n", p.config.Keymap)
131- if err := p.fs.WriteFile(config.PathMntEtcVconsole, []byte(vconsoleConf), 0644); err != nil {
132- return fmt.Errorf("failed to write vconsole.conf: %w", err)
133- }
134- p.SendOutput(progressChan, fmt.Sprintf("[OK] Console keymap set to: %s", p.config.Keymap))
135- }
136-
137- // Set hostname
138- if err := p.fs.WriteFile(config.PathMntEtcHostname, []byte(p.config.Hostname+"\n"), 0644); err != nil {
139- return fmt.Errorf("failed to write hostname: %w", err)
140- }
141-
142- // Configure hosts file
143- hostsContent := fmt.Sprintf(`127.0.0.1 localhost
144-::1 localhost
145-127.0.1.1 %s.localdomain %s
146-`, p.config.Hostname, p.config.Hostname)
147-
148- if err := p.fs.WriteFile(config.PathMntEtcHosts, []byte(hostsContent), 0644); err != nil {
149- return fmt.Errorf("failed to write hosts file: %w", err)
150- }
151-
152- p.SendOutput(progressChan, fmt.Sprintf("[OK] Hostname set to: %s", p.config.Hostname))
153-
154- return nil
155-}
156-
157-// configureNetwork enables NetworkManager
158-func (p *ConfigPhase) configureNetwork(progressChan chan<- ProgressUpdate) error {
159- p.SendOutput(progressChan, "Configuring network...")
160-
161- // Enable NetworkManager service
162- if err := p.chrExec.ChrootSystemctl(p.logger.LogPath(), config.PathMnt, "enable", config.ServiceNetworkManager); err != nil {
163- return fmt.Errorf("failed to enable NetworkManager: %w", err)
164- }
165-
166- p.SendOutput(progressChan, "[OK] Network configured")
167-
168- return nil
169-}
170-
171-// createUser creates user account with sudo access
172-func (p *ConfigPhase) createUser(progressChan chan<- ProgressUpdate) error {
173- p.SendOutput(progressChan, "Creating user account...")
174-
175- // Create user with home directory
176- userAddCmd := fmt.Sprintf("useradd -m -G %s -s %s %s", config.GroupWheel, config.ShellBash, p.config.Username)
177- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, userAddCmd); err != nil {
178- return fmt.Errorf("failed to create user: %w", err)
179- }
180-
181- // Set user password using stdin (secure - not visible in process list)
182- userPassInput := fmt.Sprintf("%s:%s", p.config.Username, p.config.UserPassword)
183- if err := p.chrExec.ChrootExecWithStdin(p.logger.LogPath(), config.PathMnt, "chpasswd", userPassInput); err != nil {
184- return fmt.Errorf("failed to set user password: %w", err)
185- }
186-
187- // Enable sudo for wheel group
188- // Create sudoers.d directory if it doesn't exist
189- sudoersDir := filepath.Dir(config.PathMntEtcSudoersD)
190- if err := p.fs.MkdirAll(sudoersDir, 0755); err != nil {
191- return fmt.Errorf("failed to create sudoers.d directory: %w", err)
192- }
193- if err := p.fs.WriteFile(config.PathMntEtcSudoersD, []byte(config.SudoersWheelContent), config.SudoersWheelPerms); err != nil {
194- return fmt.Errorf("failed to write sudoers config: %w", err)
195- }
196-
197- p.SendOutput(progressChan, fmt.Sprintf("[OK] User created: %s", p.config.Username))
198- p.SendOutput(progressChan, "[OK] Sudo enabled for wheel group")
199-
200- return nil
201-}
202-
203-// configureZram sets up zram compressed swap
204-func (p *ConfigPhase) configureZram(progressChan chan<- ProgressUpdate) error {
205- p.SendOutput(progressChan, "Configuring zram swap...")
206-
207- // Create zram-generator config
208- zramConfigPath := filepath.Join(config.PathMntEtcSystemd, config.FileZramGenerator)
209- if err := p.fs.WriteFile(zramConfigPath, []byte(config.ZramConfigContent), 0644); err != nil {
210- return fmt.Errorf("failed to write zram-generator.conf: %w", err)
211- }
212-
213- // Create sysctl config for zram optimization
214- sysctlConfigPath := filepath.Join(config.PathMntEtcSysctlD, config.FileSysctlZramParams)
215- if err := p.fs.WriteFile(sysctlConfigPath, []byte(config.ZramSysctlContent), 0644); err != nil {
216- return fmt.Errorf("failed to write sysctl zram config: %w", err)
217- }
218-
219- p.SendOutput(progressChan, "[OK] zram swap configured")
220-
221- return nil
222-}
223-
224-// PostCheck validates configuration
225-func (p *ConfigPhase) PostCheck() error {
226- // Verify hostname was set
227- if _, err := p.fs.Stat(config.PathMntEtcHostname); p.fs.IsNotExist(err) {
228- return fmt.Errorf("hostname file was not created")
229- }
230-
231- // Verify locale was set
232- if _, err := p.fs.Stat(config.PathMntEtcLocaleConf); p.fs.IsNotExist(err) {
233- return fmt.Errorf("locale.conf was not created")
234- }
235-
236- // Verify zram config exists
237- zramConfigPath := filepath.Join(config.PathMntEtcSystemd, config.FileZramGenerator)
238- if _, err := p.fs.Stat(zramConfigPath); p.fs.IsNotExist(err) {
239- return fmt.Errorf("zram-generator.conf was not created")
240- }
241-
242- return p.config.Save()
243-}
244-
245-// Rollback for config phase (limited - no-op)
246-func (p *ConfigPhase) Rollback() error {
247- // Configuration changes are in chroot, cleaned up by partitioning rollback
248- return nil
249-}
250-
251-// CanSkip returns false - config cannot be skipped
252-func (p *ConfigPhase) CanSkip() bool {
253- return false
254-}
D internal/phases/systemconfig_test.go +0 -327
@@ -1,327 +0,0 @@
1-package phases
2-
3-import (
4- "fmt"
5- "path/filepath"
6- "testing"
7-
8- "github.com/bnema/archup/internal/config"
9- "github.com/bnema/archup/internal/interfaces/mocks"
10- "github.com/bnema/archup/internal/logger"
11- "github.com/bnema/archup/internal/system"
12- "go.uber.org/mock/gomock"
13-)
14-
15-// TestConfigPhasePreCheck tests configuration prerequisites
16-func TestConfigPhasePreCheck(t *testing.T) {
17- tests := []struct {
18- name string
19- hostname string
20- username string
21- userPass string
22- timezone string
23- mountResult system.CommandResult
24- wantErr bool
25- errContains string
26- }{
27- {
28- name: "All required fields present - /mnt mounted",
29- hostname: "archup-machine",
30- username: "archuser",
31- userPass: "password123",
32- timezone: "UTC",
33- mountResult: system.CommandResult{ExitCode: 0},
34- wantErr: false,
35- },
36- {
37- name: "Missing hostname",
38- hostname: "",
39- username: "archuser",
40- userPass: "password123",
41- timezone: "UTC",
42- mountResult: system.CommandResult{ExitCode: 0},
43- wantErr: true,
44- errContains: "hostname is required",
45- },
46- {
47- name: "Missing username",
48- hostname: "archup-machine",
49- username: "",
50- userPass: "password123",
51- timezone: "UTC",
52- mountResult: system.CommandResult{ExitCode: 0},
53- wantErr: true,
54- errContains: "username is required",
55- },
56- {
57- name: "Missing user password",
58- hostname: "archup-machine",
59- username: "archuser",
60- userPass: "",
61- timezone: "UTC",
62- mountResult: system.CommandResult{ExitCode: 0},
63- wantErr: true,
64- errContains: "user password is required",
65- },
66- {
67- name: "Missing timezone",
68- hostname: "archup-machine",
69- username: "archuser",
70- userPass: "password123",
71- timezone: "",
72- mountResult: system.CommandResult{ExitCode: 0},
73- wantErr: true,
74- errContains: "timezone is required",
75- },
76- {
77- name: "/mnt not mounted",
78- hostname: "archup-machine",
79- username: "archuser",
80- userPass: "password123",
81- timezone: "UTC",
82- mountResult: system.CommandResult{ExitCode: 32},
83- wantErr: true,
84- errContains: "is not mounted",
85- },
86- }
87-
88- for _, tt := range tests {
89- t.Run(tt.name, func(t *testing.T) {
90- ctrl := gomock.NewController(t)
91- defer ctrl.Finish()
92-
93- mockFS := mocks.NewMockFileSystem(ctrl)
94- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
95- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
96-
97- mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(tt.mountResult).Times(1)
98-
99- tmpDir := t.TempDir()
100- logPath := filepath.Join(tmpDir, "test.log")
101- log, err := logger.New(logPath, false)
102- if err != nil {
103- t.Fatalf("Failed to create logger: %v", err)
104- }
105- defer func() {
106- if err := log.Close(); err != nil {
107- t.Fatalf("failed to close log: %v", err)
108- }
109- }()
110-
111- cfg := config.NewConfig("test")
112- cfg.Hostname = tt.hostname
113- cfg.Username = tt.username
114- cfg.UserPassword = tt.userPass
115- cfg.Timezone = tt.timezone
116-
117- phase := NewConfigPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
118-
119- err = phase.PreCheck()
120- if (err != nil) != tt.wantErr {
121- t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr)
122- return
123- }
124- if tt.wantErr && err != nil {
125- if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
126- t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
127- }
128- }
129- })
130- }
131-}
132-
133-// TestConfigPhasePostCheck tests configuration validation
134-func TestConfigPhasePostCheck(t *testing.T) {
135- tests := []struct {
136- name string
137- setupMocks func(*mocks.MockFileSystem)
138- wantErr bool
139- errContains string
140- }{
141- {
142- name: "All configuration files exist - postcheck passes",
143- setupMocks: func(mockFS *mocks.MockFileSystem) {
144- mockFS.EXPECT().Stat(config.PathMntEtcHostname).Return(mockFileInfo{}, nil).Times(1)
145- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
146- mockFS.EXPECT().Stat(config.PathMntEtcLocaleConf).Return(mockFileInfo{}, nil).Times(1)
147- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
148- zramPath := filepath.Join(config.PathMntEtcSystemd, config.FileZramGenerator)
149- mockFS.EXPECT().Stat(zramPath).Return(mockFileInfo{}, nil).Times(1)
150- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
151- },
152- wantErr: false,
153- },
154- {
155- name: "Hostname file missing - postcheck fails",
156- setupMocks: func(mockFS *mocks.MockFileSystem) {
157- mockFS.EXPECT().Stat(config.PathMntEtcHostname).Return(nil, fmt.Errorf("not found")).Times(1)
158- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1)
159- },
160- wantErr: true,
161- errContains: "hostname file was not created",
162- },
163- {
164- name: "Locale.conf file missing - postcheck fails",
165- setupMocks: func(mockFS *mocks.MockFileSystem) {
166- mockFS.EXPECT().Stat(config.PathMntEtcHostname).Return(mockFileInfo{}, nil).Times(1)
167- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
168- mockFS.EXPECT().Stat(config.PathMntEtcLocaleConf).Return(nil, fmt.Errorf("not found")).Times(1)
169- mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1)
170- },
171- wantErr: true,
172- errContains: "locale.conf was not created",
173- },
174- }
175-
176- for _, tt := range tests {
177- t.Run(tt.name, func(t *testing.T) {
178- ctrl := gomock.NewController(t)
179- defer ctrl.Finish()
180-
181- mockFS := mocks.NewMockFileSystem(ctrl)
182- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
183- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
184-
185- tt.setupMocks(mockFS)
186-
187- tmpDir := t.TempDir()
188- logPath := filepath.Join(tmpDir, "test.log")
189- log, err := logger.New(logPath, false)
190- if err != nil {
191- t.Fatalf("Failed to create logger: %v", err)
192- }
193- defer func() {
194- if err := log.Close(); err != nil {
195- t.Fatalf("failed to close log: %v", err)
196- }
197- }()
198-
199- cfg := config.NewConfig("test")
200- cfg.ConfigPath = filepath.Join(tmpDir, "config")
201-
202- phase := NewConfigPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
203-
204- err = phase.PostCheck()
205- if (err != nil) != tt.wantErr {
206- t.Errorf("PostCheck() error = %v, wantErr %v", err, tt.wantErr)
207- return
208- }
209- if tt.wantErr && err != nil {
210- if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
211- t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains)
212- }
213- }
214- })
215- }
216-}
217-
218-// TestConfigPhaseExecute tests the Execute method
219-func TestConfigPhaseExecute(t *testing.T) {
220- ctrl := gomock.NewController(t)
221- defer ctrl.Finish()
222-
223- mockFS := mocks.NewMockFileSystem(ctrl)
224- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
225- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
226-
227- // Setup successful calls for all configuration steps
228- mockChrExec.EXPECT().ChrootExec(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
229- mockChrExec.EXPECT().ChrootExecWithStdin(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
230- mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
231- mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
232- mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
233-
234- tmpDir := t.TempDir()
235- logPath := filepath.Join(tmpDir, "test.log")
236- log, err := logger.New(logPath, false)
237- if err != nil {
238- t.Fatalf("Failed to create logger: %v", err)
239- }
240- defer func() {
241- if err := log.Close(); err != nil {
242- t.Fatalf("failed to close log: %v", err)
243- }
244- }()
245-
246- cfg := config.NewConfig("test")
247- cfg.Hostname = "archup"
248- cfg.Username = "archuser"
249- cfg.UserPassword = "password"
250- cfg.Timezone = "UTC"
251- cfg.Locale = "en_US"
252- cfg.Keymap = "us"
253-
254- phase := NewConfigPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
255-
256- progressChan := make(chan ProgressUpdate, 100)
257- result := phase.Execute(progressChan)
258-
259- if !result.Success {
260- t.Errorf("Execute() Success = %v, want true", result.Success)
261- }
262- if result.Message != "Configuration complete" {
263- t.Errorf("Execute() Message = %q, want 'Configuration complete'", result.Message)
264- }
265-}
266-
267-// TestConfigPhaseRollback tests the Rollback method
268-func TestConfigPhaseRollback(t *testing.T) {
269- ctrl := gomock.NewController(t)
270- defer ctrl.Finish()
271-
272- mockFS := mocks.NewMockFileSystem(ctrl)
273- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
274- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
275-
276- tmpDir := t.TempDir()
277- logPath := filepath.Join(tmpDir, "test.log")
278- log, err := logger.New(logPath, false)
279- if err != nil {
280- t.Fatalf("Failed to create logger: %v", err)
281- }
282- defer func() {
283- if err := log.Close(); err != nil {
284- t.Fatalf("failed to close log: %v", err)
285- }
286- }()
287-
288- cfg := config.NewConfig("test")
289- phase := NewConfigPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
290-
291- // ConfigPhase Rollback should be no-op
292- err = phase.Rollback()
293- if err != nil {
294- t.Errorf("Rollback() error = %v, want nil", err)
295- }
296-}
297-
298-// TestConfigPhaseCanSkip tests the CanSkip method
299-func TestConfigPhaseCanSkip(t *testing.T) {
300- ctrl := gomock.NewController(t)
301- defer ctrl.Finish()
302-
303- mockFS := mocks.NewMockFileSystem(ctrl)
304- mockSysExec := mocks.NewMockSystemExecutor(ctrl)
305- mockChrExec := mocks.NewMockChrootExecutor(ctrl)
306-
307- tmpDir := t.TempDir()
308- logPath := filepath.Join(tmpDir, "test.log")
309- log, err := logger.New(logPath, false)
310- if err != nil {
311- t.Fatalf("Failed to create logger: %v", err)
312- }
313- defer func() {
314- if err := log.Close(); err != nil {
315- t.Fatalf("failed to close log: %v", err)
316- }
317- }()
318-
319- cfg := config.NewConfig("test")
320- phase := NewConfigPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
321-
322- if phase.CanSkip() {
323- t.Error("CanSkip() = true, want false")
324- }
325-}
326-
327-// Helper functions are defined in helpers_test.go to avoid duplication
D internal/ui/DEPRECATED.md +0 -126
@@ -1,126 +0,0 @@
1-# Old TUI Implementation - DEPRECATED
2-
3-## Status: Deprecated as of Phase 6 (2025-10-31)
4-
5-This directory contains the legacy BubbleTea TUI implementation used before the Domain-Driven Design refactoring.
6-
7-## Migration Status
8-
9-**New Implementation:** `internal/interfaces/tui/`
10-
11-The new TUI layer has been completely refactored to:
12-- ✅ Separate concerns (Models → State Only, Views → Rendering, Handlers → Events)
13-- ✅ Use application layer services instead of phase-based logic
14-- ✅ Integrate with progress tracking from ProgressTracker
15-- ✅ Follow the MVC pattern properly
16-
17-## Files in This Directory (Legacy)
18-
19-```
20-internal/ui/
21-├── assets/
22-│ └── assets.go # Asset loading (DEPRECATED)
23-├── components/
24-│ ├── forms.go # Form components (DEPRECATED)
25-│ ├── keybinds.go # Key bindings (DEPRECATED)
26-│ └── output.go # Output formatting (DEPRECATED)
27-├── model/
28-│ └── model.go # Model structure (DEPRECATED)
29-├── styles/
30-│ ├── containers.go # Container styling (DEPRECATED)
31-│ └── theme.go # Theme definition (DEPRECATED)
32-├── views/
33-│ ├── confirmation.go # Confirmation view (DEPRECATED)
34-│ ├── execution.go # Execution view (DEPRECATED)
35-│ ├── forms.go # Forms view (DEPRECATED)
36-│ ├── helpers.go # View helpers (DEPRECATED)
37-│ ├── network.go # Network view (DEPRECATED)
38-│ ├── results.go # Results view (DEPRECATED)
39-│ └── welcome.go # Welcome view (DEPRECATED)
40-├── installer_forms.go # Installer form definitions (DEPRECATED)
41-├── model.go # Main model file (DEPRECATED)
42-└── sections.go # Section definitions (DEPRECATED)
43-```
44-
45-## Why Deprecated?
46-
47-The old UI implementation was tightly coupled with business logic and phases:
48-
49-1. **Mixed Concerns**: Views, models, and business logic were intertwined
50-2. **Phase Coupling**: Direct dependencies on `internal/phases/`
51-3. **Non-testable**: Hard to test views without running business logic
52-4. **Inconsistent Patterns**: Multiple ways to handle state and events
53-
54-## Migration Path
55-
56-The new implementation at `internal/interfaces/tui/` provides:
57-
58-### New Structure
59-```
60-internal/interfaces/tui/
61-├── app.go # Main application coordinator
62-├── models/
63-│ ├── form.go # Form state (inputs, focus) - STATE ONLY
64-│ ├── installation.go # Installation state - STATE ONLY
65-│ └── progress.go # Progress state - STATE ONLY
66-├── views/
67-│ ├── form_view.go # Form rendering - RENDERING ONLY
68-│ ├── progress_view.go # Progress rendering - RENDERING ONLY
69-│ └── summary_view.go # Summary rendering - RENDERING ONLY
70-├── handlers/
71-│ ├── form_handler.go # Form event handling - EVENTS ONLY
72-│ └── install_handler.go # Installation flow handling - EVENTS ONLY
73-├── interfaces.go # Interface definitions
74-├── messages.go # Message types for events
75-└── errors.go # TUI-specific errors
76-```
77-
78-### Key Improvements
79-
80-1. **Clear Separation of Concerns**
81- - Models: Pure state data structures
82- - Views: Pure rendering functions
83- - Handlers: Pure event processing functions
84-
85-2. **Service Integration**
86- - Forms submit to application layer commands
87- - Application layer handlers are invoked
88- - Progress comes from ProgressTracker, not direct phase polling
89-
90-3. **Testability**
91- - View functions can be tested with mock models
92- - Handler functions can be tested with mock commands
93- - Models are simple DTOs, easy to test
94-
95-4. **Maintainability**
96- - Easy to add new views (just create new rendering function)
97- - Easy to add new handlers (just create new event handler)
98- - Easy to modify business logic (doesn't affect views)
99-
100-## Deletion Timeline
101-
102-- **Now (2025-10-31)**: Mark as deprecated
103-- **After Phase 6 Complete**: Keep for reference
104-- **After 4 Weeks (2025-11-28)**: Remove from main branch
105-
106-## What To Do If You Find Bugs
107-
108-If you find bugs in the OLD UI:
109-1. File an issue with "legacy-ui" label
110-2. If it's critical, it may be fixed in the deprecated code
111-3. Prefer fixing in the new UI at `internal/interfaces/tui/`
112-
113-If you find bugs in the NEW UI:
114-1. File an issue with "tui" label
115-2. Create a PR fixing it in `internal/interfaces/tui/`
116-3. This is the primary implementation going forward
117-
118-## References
119-
120-- **New TUI**: `/home/brice/dev/projects/archup/internal/interfaces/tui/`
121-- **DDD Architecture**: `/home/brice/dev/projects/archup/docs/architecture.md`
122-- **Reference Document**: `/home/brice/dev/projects/archup/DDD_REFACTORING_PLAN.md`
123-
124-## Questions?
125-
126-See the architecture documentation or contact the team about the DDD migration.
D internal/ui/components/forms.go +0 -161
@@ -1,161 +0,0 @@
1-package components
2-
3-import (
4- "github.com/bnema/archup/internal/ui/styles"
5- "github.com/charmbracelet/huh"
6- "github.com/charmbracelet/lipgloss"
7-)
8-
9-// FormBuilder provides reusable form components
10-type FormBuilder struct {
11- accessible bool
12- width int
13-}
14-
15-// NewFormBuilder creates a new form builder with specified width
16-func NewFormBuilder(accessible bool, width int) *FormBuilder {
17- return &FormBuilder{
18- accessible: accessible,
19- width: width,
20- }
21-}
22-
23-// TextInput creates a text input field
24-func (fb *FormBuilder) TextInput(title, prompt string, value *string, validator func(string) error) *huh.Input {
25- input := huh.NewInput().
26- Title(title).
27- Prompt(prompt).
28- Value(value)
29-
30- if validator != nil {
31- input.Validate(validator)
32- }
33-
34- return input
35-}
36-
37-// Select creates a single-selection field
38-func (fb *FormBuilder) Select(title string, options []string, value *string) *huh.Select[string] {
39- opts := make([]huh.Option[string], len(options))
40- for i, opt := range options {
41- opts[i] = huh.NewOption(opt, opt)
42- }
43-
44- return huh.NewSelect[string]().
45- Title(title).
46- Options(opts...).
47- Value(value)
48-}
49-
50-// MultiSelect creates a multi-selection field
51-func (fb *FormBuilder) MultiSelect(title string, options []string, value *[]string, limit int) *huh.MultiSelect[string] {
52- opts := make([]huh.Option[string], len(options))
53- for i, opt := range options {
54- opts[i] = huh.NewOption(opt, opt)
55- }
56-
57- ms := huh.NewMultiSelect[string]().
58- Title(title).
59- Options(opts...).
60- Value(value)
61-
62- if limit > 0 {
63- ms.Limit(limit)
64- }
65-
66- return ms
67-}
68-
69-// Confirm creates a yes/no confirmation with left-aligned buttons
70-func (fb *FormBuilder) Confirm(title, affirmative, negative string, value *bool) *huh.Confirm {
71- return huh.NewConfirm().
72- Title(title).
73- Affirmative(affirmative).
74- Negative(negative).
75- Value(value).
76- WithButtonAlignment(lipgloss.Left)
77-}
78-
79-// CreateForm creates a form with accessibility settings and custom theme
80-func (fb *FormBuilder) CreateForm(groups ...*huh.Group) *huh.Form {
81- formWidth := fb.width
82- // Cap to MaxFormWidth from styles
83- if formWidth > styles.MaxFormWidth {
84- formWidth = styles.MaxFormWidth
85- }
86- if formWidth < 40 {
87- formWidth = 40
88- }
89-
90- form := huh.NewForm(groups...).
91- WithTheme(styles.HuhTheme()).
92- WithKeyMap(CreateTTYKeyMap()).
93- WithWidth(formWidth)
94-
95- if fb.accessible {
96- form.WithAccessible(true)
97- }
98- return form
99-}
100-
101-// PasswordInput creates a password input field
102-func (fb *FormBuilder) PasswordInput(title, prompt string, value *string, validator func(string) error) *huh.Input {
103- input := fb.TextInput(title, prompt, value, validator)
104- input.EchoMode(huh.EchoModePassword)
105- return input
106-}
107-
108-// SelectWithOptions creates a select field with option objects (for labels different from values)
109-func (fb *FormBuilder) SelectWithOptions(title string, options []huh.Option[string], value *string) *huh.Select[string] {
110- return huh.NewSelect[string]().
111- Title(title).
112- Options(options...).
113- Value(value)
114-}
115-
116-// CreateOption creates a labeled option for Select fields (DRY helper)
117-func CreateOption(label, description, value string) huh.Option[string] {
118- switch {
119- case description != "":
120- return huh.NewOption(label+" - "+description, value)
121- default:
122- return huh.NewOption(label, value)
123- }
124-}
125-
126-// CreateKernelOptions creates kernel selection options with descriptions
127-func CreateKernelOptions() []huh.Option[string] {
128- return []huh.Option[string]{
129- CreateOption("linux", "Stable mainline kernel (recommended)", "linux"),
130- CreateOption("linux-lts", "Long-term support (maximum stability)", "linux-lts"),
131- CreateOption("linux-zen", "Performance-optimized for general use", "linux-zen"),
132- CreateOption("linux-hardened", "Security-focused kernel", "linux-hardened"),
133- CreateOption("linux-cachyos", "Gaming-optimized", "linux-cachyos"),
134- }
135-}
136-
137-// CreateAMDPStateOptions creates AMD P-State mode options with descriptions
138-func CreateAMDPStateOptions(modes []string) []huh.Option[string] {
139- var options []huh.Option[string]
140-
141- for _, mode := range modes {
142- var description string
143- switch mode {
144- case "active":
145- description = "Best performance (recommended for desktop/gaming)"
146- case "guided":
147- description = "Balanced performance and efficiency (recommended for laptops)"
148- case "passive":
149- description = "Maximum compatibility (older CPUs)"
150- }
151-
152- switch {
153- case description != "":
154- options = append(options, huh.NewOption(mode+" - "+description, mode))
155- default:
156- options = append(options, huh.NewOption(mode, mode))
157- }
158- }
159-
160- return options
161-}
D internal/ui/components/keybinds.go +0 -46
@@ -1,46 +0,0 @@
1-package components
2-
3-import (
4- "github.com/charmbracelet/bubbles/key"
5- "github.com/charmbracelet/huh"
6-)
7-
8-// CreateTTYKeyMap creates a custom keymap with TTY-friendly alternatives to Shift+Tab
9-// This is needed for QEMU console and other limited terminal environments where
10-// Shift+Tab is not properly transmitted.
11-func CreateTTYKeyMap() *huh.KeyMap {
12- km := huh.NewDefaultKeyMap()
13-
14- // Add Ctrl+P as alternative to Shift+Tab for "previous" navigation
15- // These work better in QEMU console and limited TTY environments
16- km.Input.Prev = key.NewBinding(
17- key.WithKeys("shift+tab", "ctrl+p"),
18- key.WithHelp("ctrl+p/shift+tab", "back"),
19- )
20- km.Text.Prev = key.NewBinding(
21- key.WithKeys("shift+tab", "ctrl+p"),
22- key.WithHelp("ctrl+p/shift+tab", "back"),
23- )
24- km.Select.Prev = key.NewBinding(
25- key.WithKeys("shift+tab", "ctrl+p"),
26- key.WithHelp("ctrl+p/shift+tab", "back"),
27- )
28- km.MultiSelect.Prev = key.NewBinding(
29- key.WithKeys("shift+tab", "ctrl+p"),
30- key.WithHelp("ctrl+p/shift+tab", "back"),
31- )
32- km.Note.Prev = key.NewBinding(
33- key.WithKeys("shift+tab", "ctrl+p"),
34- key.WithHelp("ctrl+p/shift+tab", "back"),
35- )
36- km.Confirm.Prev = key.NewBinding(
37- key.WithKeys("shift+tab", "ctrl+p"),
38- key.WithHelp("ctrl+p/shift+tab", "back"),
39- )
40- km.FilePicker.Prev = key.NewBinding(
41- key.WithKeys("shift+tab", "ctrl+p"),
42- key.WithHelp("ctrl+p/shift+tab", "back"),
43- )
44-
45- return km
46-}
D internal/ui/components/output.go +0 -67
@@ -1,67 +0,0 @@
1-package components
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/ui/styles"
7- "github.com/charmbracelet/bubbles/viewport"
8- tea "github.com/charmbracelet/bubbletea"
9-)
10-
11-// OutputViewer displays scrolling command output (last N lines)
12-type OutputViewer struct {
13- viewport viewport.Model
14- lines []string
15- maxLines int
16-}
17-
18-// NewOutputViewer creates a new output viewer
19-func NewOutputViewer(width, height, maxLines int) *OutputViewer {
20- vp := viewport.New(width, height)
21- vp.Style = styles.LogStyle
22-
23- return &OutputViewer{
24- viewport: vp,
25- lines: make([]string, 0, maxLines),
26- maxLines: maxLines,
27- }
28-}
29-
30-// AddLine adds a new line of output (keeps only last N lines)
31-func (ov *OutputViewer) AddLine(line string) {
32- ov.lines = append(ov.lines, line)
33-
34- // Keep only last maxLines
35- if len(ov.lines) > ov.maxLines {
36- ov.lines = ov.lines[len(ov.lines)-ov.maxLines:]
37- }
38-
39- // Update viewport content
40- ov.viewport.SetContent(strings.Join(ov.lines, "\n"))
41- // Auto-scroll to bottom
42- ov.viewport.GotoBottom()
43-}
44-
45-// Clear clears all output
46-func (ov *OutputViewer) Clear() {
47- ov.lines = make([]string, 0, ov.maxLines)
48- ov.viewport.SetContent("")
49-}
50-
51-// Update handles viewport updates
52-func (ov *OutputViewer) Update(msg tea.Msg) tea.Cmd {
53- var cmd tea.Cmd
54- ov.viewport, cmd = ov.viewport.Update(msg)
55- return cmd
56-}
57-
58-// View renders the viewport
59-func (ov *OutputViewer) View() string {
60- return ov.viewport.View()
61-}
62-
63-// SetSize updates the viewport dimensions
64-func (ov *OutputViewer) SetSize(width, height int) {
65- ov.viewport.Width = width
66- ov.viewport.Height = height
67-}
D internal/ui/installer_forms.go +0 -250
@@ -1,250 +0,0 @@
1-package ui
2-
3-import (
4- "fmt"
5-
6- "github.com/bnema/archup/internal/config"
7- "github.com/bnema/archup/internal/phases"
8- "github.com/bnema/archup/internal/system"
9- "github.com/bnema/archup/internal/ui/components"
10- "github.com/bnema/archup/internal/validation"
11- "github.com/charmbracelet/huh"
12-)
13-
14-// CreatePreflightForm creates the initial configuration form with all user identity fields
15-func CreatePreflightForm(cfg *config.Config, fb *components.FormBuilder, sys *system.System, version string) *huh.Form {
16-
17- // Auto-detect timezone from API, fallback to default if detection fails
18- detectedTimezone := sys.DetectTimezone()
19- switch {
20- case detectedTimezone != "":
21- cfg.Timezone = detectedTimezone
22- default:
23- cfg.Timezone = config.TimezoneDefault
24- }
25-
26- // Set default keymap if not already set
27- if cfg.Keymap == "" {
28- cfg.Keymap = config.KeymapDefault
29- }
30-
31- title := "ArchUp Installer"
32- if version != "" {
33- title += " " + version
34- }
35-
36- return fb.CreateForm(
37- // System configuration
38- huh.NewGroup(
39- fb.TextInput("Hostname", "> ", &cfg.Hostname, validation.ValidateHostname),
40- fb.TextInput("Username", "> ", &cfg.Username, validation.ValidateUsername),
41- fb.TextInput("Email (optional)", "> ", &cfg.Email, nil).
42- Description(DescEmail),
43- ).Title(title),
44-
45- // Passwords
46- huh.NewGroup(
47- fb.PasswordInput("User Password", "> ", &cfg.UserPassword, validation.ValidatePassword),
48- fb.PasswordInput("Confirm Password", "> ", &cfg.ConfirmPassword, validation.ValidatePasswordConfirmation(&cfg.UserPassword)),
49-
50- fb.Confirm("Use same password for disk encryption?", "Yes", "No", &cfg.UseSamePasswordForEncryption),
51- ),
52-
53- // Disk Encryption Password (only shown when NOT using same password)
54- huh.NewGroup(
55- fb.PasswordInput("Disk Encryption Password", "> ", &cfg.EncryptPassword, validation.ValidatePassword).
56- Description("Enter a separate password to unlock your encrypted disk at boot"),
57- ).WithHideFunc(func() bool {
58- return cfg.UseSamePasswordForEncryption // Hide when using same password
59- }),
60-
61- // Localization
62- huh.NewGroup(
63- fb.TextInput("Timezone", "> ", &cfg.Timezone, validation.ValidateTimezone).
64- Description(DescTimezone),
65- fb.TextInput("Locale", "> ", &cfg.Locale, validation.ValidateLocale).
66- Description(DescLocale),
67- fb.TextInput("Keymap", "> ", &cfg.Keymap, nil).
68- Description(DescKeymap),
69- ),
70- )
71-}
72-
73-// CreateDiskSelectionForm creates disk selection form with detailed disk information
74-func CreateDiskSelectionForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form {
75-
76- // Detect available disks
77- disks, err := system.ListDisks()
78-
79- var diskOptions []huh.Option[string]
80- switch {
81- case err != nil:
82- // Fallback to empty option if detection fails
83- diskOptions = []huh.Option[string]{
84- huh.NewOption("No disks detected", ""),
85- }
86- default:
87- // Format disk options with detailed info: "/dev/sda (500GB) Samsung SSD [S3Z9NB0K123456]"
88- // Use disk.Path as value and formatted string as display label
89- for _, disk := range disks {
90- label := disk.Path + " (" + disk.Size + ")"
91-
92- // Add model if available
93- if disk.Model != "" {
94- label += " " + disk.Model
95- }
96-
97- // Add serial if available
98- if disk.Serial != "" {
99- label += " [" + disk.Serial + "]"
100- }
101-
102- // Add vendor if available
103- if disk.Vendor != "" && disk.Model == "" {
104- label += " " + disk.Vendor
105- }
106-
107- diskOptions = append(diskOptions, huh.NewOption(label, disk.Path))
108- }
109- }
110-
111- return fb.CreateForm(
112- huh.NewGroup(
113- fb.SelectWithOptions(SectionDiskSelection, diskOptions, &cfg.TargetDisk).
114- Description(WarnDiskErase),
115- ),
116- )
117-}
118-
119-// CreateOptionsForm creates installation options form with CPU detection and descriptions
120-func CreateOptionsForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form {
121-
122- // Auto-detect CPU information
123- cpuInfo, err := system.DetectCPUInfo()
124- if err == nil {
125- cfg.CPUVendor = string(cpuInfo.Vendor)
126- cfg.Microcode = cpuInfo.Microcode
127- }
128-
129- // Page 1: Kernel and Encryption (system base configuration)
130- encryptionOptions := []huh.Option[string]{
131- huh.NewOption("No encryption", config.EncryptionNone),
132- huh.NewOption("LUKS encryption", config.EncryptionLUKS),
133- }
134-
135- systemBaseGroup := huh.NewGroup(
136- fb.SelectWithOptions("Kernel", components.CreateKernelOptions(), &cfg.KernelChoice),
137- fb.SelectWithOptions("Encryption", encryptionOptions, &cfg.EncryptionType).
138- Description(DescEncryption),
139- ).Title(SectionKernel)
140-
141- // Page 2: Repository and package management options
142- aurOptions := []huh.Option[string]{
143- huh.NewOption("None", ""),
144- components.CreateOption("Paru", DescAURHelperParu, phases.AURHelperParu),
145- components.CreateOption("Yay", DescAURHelperYay, phases.AURHelperYay),
146- }
147-
148- packageManagementGroup := huh.NewGroup(
149- fb.Confirm("Enable multilib repository?", "Yes", "No", &cfg.EnableMultilib).
150- Description(DescMultilib),
151- fb.Confirm("Enable Chaotic-AUR?", "Yes", "No", &cfg.EnableChaotic).
152- Description(DescChaoticAUR),
153- )
154-
155- // AUR Helper group (only shown when Chaotic-AUR is enabled)
156- aurHelperGroup := huh.NewGroup(
157- fb.SelectWithOptions("AUR Helper", aurOptions, &cfg.AURHelper).
158- Description(DescAURHelper),
159- ).WithHideFunc(func() bool {
160- return !cfg.EnableChaotic
161- })
162-
163- // Build form groups list
164- groups := []*huh.Group{
165- systemBaseGroup,
166- packageManagementGroup,
167- aurHelperGroup,
168- }
169-
170- // Add AMD P-State tuning group (conditional - only for AMD CPUs)
171- switch {
172- case cpuInfo != nil && cpuInfo.Vendor == system.CPUVendorAMD:
173- switch {
174- case len(cpuInfo.AMDPStateModes) == 1:
175- // Auto-select if only one mode available
176- cfg.AMDPState = string(cpuInfo.AMDPStateModes[0])
177- case len(cpuInfo.AMDPStateModes) > 1:
178- // Show selection if multiple modes available
179- modeStrings := make([]string, len(cpuInfo.AMDPStateModes))
180- for i, mode := range cpuInfo.AMDPStateModes {
181- modeStrings[i] = string(mode)
182- }
183-
184- amdGroup := huh.NewGroup(
185- fb.SelectWithOptions("AMD P-State Mode",
186- components.CreateAMDPStateOptions(modeStrings),
187- &cfg.AMDPState),
188- ).Title(SectionAMDTuning).Description(DescAMDPState)
189-
190- // Insert AMD group after system base group (between kernel/encryption and package management)
191- groups = append(groups[:1], append([]*huh.Group{amdGroup}, groups[1:]...)...)
192- }
193- }
194-
195- return fb.CreateForm(groups...)
196-}
197-
198-// CreateAMDPStateForm creates AMD P-State driver selection form
199-// This form is only shown for AMD CPUs that support P-State
200-func CreateAMDPStateForm(cfg *config.Config, cpuInfo *system.CPUInfo, fb *components.FormBuilder) *huh.Form {
201- // Build P-State mode options based on available modes
202- var modeOptions []huh.Option[string]
203-
204- for _, mode := range cpuInfo.AMDPStateModes {
205- desc := system.GetPStateModeDescription(mode)
206- label := fmt.Sprintf("%s - %s", mode, desc)
207-
208- // Mark recommended mode
209- if mode == cpuInfo.RecommendedPStateMode {
210- label += " (recommended)"
211- }
212-
213- modeOptions = append(modeOptions, huh.NewOption(label, string(mode)))
214- }
215-
216- // Build description text
217- zenLabel := "Unknown"
218- if cpuInfo.AMDZenGen != nil {
219- zenLabel = cpuInfo.AMDZenGen.Label
220- }
221-
222- description := fmt.Sprintf("Detected: %s\nCPU: %s\n\nNote: Requires CPPC enabled in UEFI (AMD CBS > NBIO > SMU > CPPC)",
223- zenLabel, cpuInfo.ModelName)
224-
225- return fb.CreateForm(
226- huh.NewGroup(
227- fb.SelectWithOptions("AMD P-State Mode", modeOptions, &cfg.AMDPState).
228- Description(description),
229- ).Title("AMD P-State Configuration"),
230- )
231-}
232-
233-// FormatDiskOption formats a disk into a user-friendly option string (DRY helper)
234-func FormatDiskOption(disk system.Disk) string {
235- label := fmt.Sprintf("%s (%s)", disk.Path, disk.Size)
236-
237- if disk.Model != "" {
238- label += " " + disk.Model
239- }
240-
241- if disk.Serial != "" {
242- label += " [" + disk.Serial + "]"
243- }
244-
245- if disk.Vendor != "" && disk.Model == "" {
246- label += " " + disk.Vendor
247- }
248-
249- return label
250-}
D internal/ui/model.go +0 -421
@@ -1,421 +0,0 @@
1-package ui
2-
3-import (
4- "fmt"
5- "time"
6-
7- "github.com/bnema/archup/internal/config"
8- "github.com/bnema/archup/internal/logger"
9- "github.com/bnema/archup/internal/phases"
10- "github.com/bnema/archup/internal/system"
11- "github.com/bnema/archup/internal/ui/components"
12- "github.com/bnema/archup/internal/ui/styles"
13- "github.com/bnema/archup/internal/ui/views"
14- "github.com/charmbracelet/bubbles/spinner"
15- tea "github.com/charmbracelet/bubbletea"
16- "github.com/charmbracelet/huh"
17-)
18-
19-// ModelState represents the current UI state
20-type ModelState int
21-
22-const (
23- StateShowLogo ModelState = iota
24- StateNetworkCheck
25- StatePreflightForm
26- StateDiskForm
27- StateOptionsForm
28- StateAMDPStatePrompt
29- StateConfirmation
30- StateExecuting
31- StateComplete
32- StateResultsSuccess
33- StateResultsError
34- StateError
35-)
36-
37-// Model is the main Bubbletea application model
38-type Model struct {
39- state ModelState
40- orchestrator *phases.Orchestrator
41- config *config.Config
42- currentForm *huh.Form
43- formBuilder *components.FormBuilder
44- output *components.OutputViewer
45- logger *logger.Logger
46- system *system.System
47- spinner spinner.Model
48- version string
49- width int
50- height int
51- err error
52- cpuInfo *system.CPUInfo
53- installStartTime time.Time
54- installEndTime time.Duration
55- networkCheckDone bool
56- networkErr error
57-}
58-
59-// NewModel creates a new installer model
60-func NewModel(orchestrator *phases.Orchestrator, cfg *config.Config, log *logger.Logger, version string) *Model {
61- log.Info("UI Model created", "version", version)
62-
63- s := spinner.New()
64- s.Spinner = spinner.Line
65- s.Style = styles.SpinnerStyle
66-
67- return &Model{
68- state: StateShowLogo,
69- orchestrator: orchestrator,
70- config: cfg,
71- formBuilder: components.NewFormBuilder(false, 80), // Initial width, will update on WindowSizeMsg
72- logger: log,
73- system: system.NewSystem(log.Slog()),
74- spinner: s,
75- version: version,
76- width: 80,
77- height: 24,
78- output: components.NewOutputViewer(80, 20, 1000),
79- }
80-}
81-
82-// Init initializes the model
83-func (m *Model) Init() tea.Cmd {
84- m.installStartTime = time.Now()
85- return m.spinner.Tick
86-}
87-
88-// Update handles messages
89-func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
90- switch msg := msg.(type) {
91- case tea.WindowSizeMsg:
92- m.width = msg.Width
93- m.height = msg.Height
94- m.output.SetSize(msg.Width, msg.Height-10)
95- // Update formBuilder width for responsive forms
96- m.formBuilder = components.NewFormBuilder(false, msg.Width)
97-
98- case tea.KeyMsg:
99- m.logger.Info("Key pressed", "key", msg.String(), "state", m.state)
100-
101- switch msg.String() {
102- case "ctrl+c":
103- m.logger.Info("User cancelled installation")
104- return m, tea.Quit
105-
106- case "enter":
107- switch m.state {
108- case StateShowLogo:
109- m.logger.Info("Moving to network check")
110- m.state = StateNetworkCheck
111- return m, m.checkNetwork()
112-
113- case StateNetworkCheck:
114- // Handle retry if network check failed
115- if m.networkErr != nil {
116- m.logger.Info("Retrying network check")
117- return m, m.checkNetwork()
118- }
119- // Network check passed, move to preflight form
120- m.logger.Info("Moving to preflight form")
121- m.state = StatePreflightForm
122- m.currentForm = CreatePreflightForm(m.config, m.formBuilder, m.system, m.version)
123- return m, m.currentForm.Init()
124-
125- case StateConfirmation:
126- m.logger.Info("User confirmed installation - starting execution")
127- m.state = StateExecuting
128- return m, m.executeNextPhase()
129- }
130-
131- case "backspace", "esc":
132- m.logger.Info("User navigating back", "from_state", m.state)
133- switch m.state {
134- case StateDiskForm:
135- m.state = StatePreflightForm
136- m.currentForm = CreatePreflightForm(m.config, m.formBuilder, m.system, m.version)
137- return m, m.currentForm.Init()
138-
139- case StateOptionsForm:
140- m.state = StateDiskForm
141- m.currentForm = CreateDiskSelectionForm(m.config, m.formBuilder)
142- return m, m.currentForm.Init()
143-
144- case StateAMDPStatePrompt:
145- m.state = StateOptionsForm
146- m.currentForm = CreateOptionsForm(m.config, m.formBuilder)
147- return m, m.currentForm.Init()
148-
149- case StateConfirmation:
150- // Go back to AMD P-State prompt if we have AMD CPU, otherwise options
151- if m.cpuInfo != nil && m.cpuInfo.Vendor == system.CPUVendorAMD && len(m.cpuInfo.AMDPStateModes) > 0 {
152- m.state = StateAMDPStatePrompt
153- m.currentForm = CreateAMDPStateForm(m.config, m.cpuInfo, m.formBuilder)
154- } else {
155- m.state = StateOptionsForm
156- m.currentForm = CreateOptionsForm(m.config, m.formBuilder)
157- }
158- return m, m.currentForm.Init()
159- }
160- }
161-
162- case networkCheckMsg:
163- m.networkCheckDone = true
164- m.networkErr = msg.err
165- if msg.err != nil {
166- m.logger.Warn("Network check failed", "error", msg.err)
167- return m, nil
168- }
169- // Network check passed - automatically continue to preflight form
170- m.logger.Info("Network check passed - continuing automatically")
171- m.state = StatePreflightForm
172- m.currentForm = CreatePreflightForm(m.config, m.formBuilder, m.system, m.version)
173- return m, m.currentForm.Init()
174-
175- case phaseCompleteMsg:
176- if msg.err != nil {
177- m.logger.Error("Phase execution failed", "error", msg.err)
178- m.err = msg.err
179- m.installEndTime = time.Since(m.installStartTime)
180- m.state = StateResultsError
181- return m, tea.Quit
182- }
183-
184- // Check if all phases complete
185- switch {
186- case m.orchestrator.IsComplete():
187- m.logger.Info("All phases completed successfully")
188- m.installEndTime = time.Since(m.installStartTime)
189- m.state = StateResultsSuccess
190- return m, tea.Quit
191- default:
192- m.logger.Info("Continuing to next phase")
193- return m, m.executeNextPhase()
194- }
195-
196- case phases.ProgressUpdate:
197- // Handle progress updates from phases
198- switch {
199- case msg.OutputLine != "":
200- m.output.AddLine(msg.OutputLine)
201- case msg.Step != "":
202- m.output.AddLine(fmt.Sprintf("[%s] %s", msg.PhaseName, msg.Step))
203- case msg.IsError:
204- m.output.AddLine(fmt.Sprintf("[ERROR] %s", msg.ErrorMsg))
205- case msg.IsComplete:
206- m.output.AddLine(fmt.Sprintf("[COMPLETE] %s", msg.Step))
207- }
208- return m, waitForProgress(m.orchestrator.ProgressChannel())
209- }
210-
211- // Update current form if showing one
212- switch m.state {
213- case StatePreflightForm, StateDiskForm, StateOptionsForm, StateAMDPStatePrompt:
214- switch {
215- case m.currentForm != nil:
216- // Don't pass WindowSizeMsg to form to preserve our max width
217- if _, isWindowSize := msg.(tea.WindowSizeMsg); isWindowSize {
218- return m, nil
219- }
220-
221- form, cmd := m.currentForm.Update(msg)
222- if f, ok := form.(*huh.Form); ok {
223- m.currentForm = f
224-
225- // Check if form is completed
226- if m.currentForm.State == huh.StateCompleted {
227- return m, m.handleFormComplete()
228- }
229- }
230- return m, cmd
231- }
232- case StateExecuting:
233- // Update spinner and output viewer
234- var cmd tea.Cmd
235- m.spinner, cmd = m.spinner.Update(msg)
236- return m, tea.Batch(cmd, m.output.Update(msg))
237- }
238-
239- return m, nil
240-}
241-
242-// View renders the UI
243-func (m *Model) View() string {
244- switch m.state {
245- case StateShowLogo:
246- return views.RenderWelcome(m)
247-
248- case StateNetworkCheck:
249- return views.RenderNetworkCheck(m)
250-
251- case StatePreflightForm, StateDiskForm, StateOptionsForm, StateAMDPStatePrompt:
252- return views.RenderForm(m)
253-
254- case StateConfirmation:
255- return views.RenderConfirmation(m)
256-
257- case StateExecuting:
258- return views.RenderExecuting(m)
259-
260- case StateResultsSuccess:
261- phaseDurations := m.orchestrator.GetPhaseDurations()
262- return views.RenderSuccess(m, m.installEndTime, phaseDurations)
263-
264- case StateResultsError:
265- phaseName := "unknown"
266- if phase := m.orchestrator.CurrentPhase(); phase != nil {
267- phaseName = phase.Name()
268- }
269- return views.RenderError(m, m.err, phaseName, m.installEndTime)
270-
271- case StateComplete, StateError:
272- if m.err != nil {
273- return styles.RenderError(fmt.Sprintf("Error: %v", m.err))
274- }
275- return styles.RenderSuccess("Installation complete!")
276-
277- default:
278- return "Unknown state"
279- }
280-}
281-
282-// handleFormComplete handles form completion and moves to next state
283-func (m *Model) handleFormComplete() tea.Cmd {
284- m.logger.Info("Form completed", "state", m.state)
285-
286- switch m.state {
287- case StatePreflightForm:
288- m.logger.Info("Preflight form completed",
289- "hostname", m.config.Hostname,
290- "username", m.config.Username,
291- "use_same_password_for_encryption", m.config.UseSamePasswordForEncryption)
292-
293- // Handle encryption password setting based on checkbox
294- if m.config.UseSamePasswordForEncryption {
295- m.config.EncryptPassword = m.config.UserPassword
296- }
297-
298- m.state = StateDiskForm
299- m.currentForm = CreateDiskSelectionForm(m.config, m.formBuilder)
300- return m.currentForm.Init()
301-
302- case StateDiskForm:
303- m.logger.Info("Disk selection completed", "disk", m.config.TargetDisk)
304- m.state = StateOptionsForm
305- m.currentForm = CreateOptionsForm(m.config, m.formBuilder)
306- return m.currentForm.Init()
307-
308- case StateOptionsForm:
309- m.logger.Info("Options form completed",
310- "kernel", m.config.KernelChoice,
311- "encryption", m.config.EncryptionType)
312-
313- // Detect CPU and check if we need to show AMD P-State prompt
314- cpuInfo, err := system.DetectCPUInfo()
315- if err == nil {
316- m.cpuInfo = cpuInfo
317- // Show AMD P-State prompt only for AMD CPUs with P-State support
318- if cpuInfo.Vendor == system.CPUVendorAMD && len(cpuInfo.AMDPStateModes) > 0 {
319- m.logger.Info("AMD CPU detected - showing P-State configuration",
320- "zen_gen", cpuInfo.AMDZenGen.Label,
321- "modes", len(cpuInfo.AMDPStateModes))
322- m.state = StateAMDPStatePrompt
323- m.currentForm = CreateAMDPStateForm(m.config, cpuInfo, m.formBuilder)
324- return m.currentForm.Init()
325- }
326- }
327-
328- // Move directly to confirmation if not AMD or detection failed
329- m.state = StateConfirmation
330- return nil
331-
332- case StateAMDPStatePrompt:
333- m.logger.Info("AMD P-State configuration completed", "mode", m.config.AMDPState)
334- m.state = StateConfirmation
335- return nil
336-
337- default:
338- // Move to confirmation
339- m.state = StateConfirmation
340- return nil
341- }
342-}
343-
344-// Getter methods for views package
345-func (m *Model) Width() int { return m.width }
346-func (m *Model) Version() string { return m.version }
347-func (m *Model) Config() *config.Config { return m.config }
348-func (m *Model) CurrentForm() *huh.Form { return m.currentForm }
349-func (m *Model) Spinner() spinner.Model { return m.spinner }
350-func (m *Model) Output() *components.OutputViewer { return m.output }
351-func (m *Model) RenderPhaseHeader() string { return m.renderPhaseHeader() }
352-func (m *Model) NetworkCheckDone() bool { return m.networkCheckDone }
353-func (m *Model) NetworkErr() error { return m.networkErr }
354-
355-// executeNextPhase executes the next pending phase
356-func (m *Model) executeNextPhase() tea.Cmd {
357- // Execute the phase in the background AND start listening for progress updates
358- return tea.Batch(
359- func() tea.Msg {
360- err := m.orchestrator.ExecuteNext()
361- return phaseCompleteMsg{err: err}
362- },
363- waitForProgress(m.orchestrator.ProgressChannel()),
364- )
365-}
366-
367-// waitForProgress waits for progress updates
368-func waitForProgress(progressChan <-chan phases.ProgressUpdate) tea.Cmd {
369- return func() tea.Msg {
370- return <-progressChan
371- }
372-}
373-
374-// phaseCompleteMsg signals phase execution completion
375-type phaseCompleteMsg struct {
376- err error
377-}
378-
379-// networkCheckMsg signals network check completion
380-type networkCheckMsg struct {
381- err error
382-}
383-
384-// checkNetwork runs network connectivity check in background
385-func (m *Model) checkNetwork() tea.Cmd {
386- return func() tea.Msg {
387- err := system.CheckNetworkConnectivity()
388- return networkCheckMsg{err: err}
389- }
390-}
391-
392-// renderPhaseHeader renders phase progress header
393-func (m *Model) renderPhaseHeader() string {
394- var stepNum, stepName string
395-
396- switch m.state {
397- case StatePreflightForm:
398- stepNum = "1"
399- stepName = "System Configuration"
400- case StateDiskForm:
401- stepNum = "2"
402- stepName = "Disk Selection"
403- case StateOptionsForm:
404- stepNum = "3"
405- stepName = "Installation Options"
406- case StateAMDPStatePrompt:
407- stepNum = "4"
408- stepName = "AMD P-State Configuration"
409- default:
410- return ""
411- }
412-
413- // Total steps depends on whether AMD prompt is shown
414- totalSteps := "3"
415- if m.state == StateAMDPStatePrompt {
416- totalSteps = "4"
417- }
418-
419- header := fmt.Sprintf("ArchUp Install - Step %s/%s: %s", stepNum, totalSteps, stepName)
420- return styles.TitleStyle.Render(header)
421-}
D internal/ui/model/model.go +0 -21
@@ -1,21 +0,0 @@
1-package model
2-
3-import (
4- "github.com/bnema/archup/internal/config"
5- "github.com/bnema/archup/internal/ui/components"
6- "github.com/charmbracelet/bubbles/spinner"
7- "github.com/charmbracelet/huh"
8-)
9-
10-// UI is an interface that views use to access model data
11-type UI interface {
12- Width() int
13- Version() string
14- Config() *config.Config
15- CurrentForm() *huh.Form
16- Spinner() spinner.Model
17- Output() *components.OutputViewer
18- RenderPhaseHeader() string
19- NetworkCheckDone() bool
20- NetworkErr() error
21-}
D internal/ui/sections.go +0 -53
@@ -1,53 +0,0 @@
1-package ui
2-
3-import "github.com/charmbracelet/lipgloss"
4-
5-// CenterText centers text within a given width using lipgloss
6-func CenterText(text string, width int) string {
7- return lipgloss.NewStyle().
8- Width(width).
9- AlignHorizontal(lipgloss.Center).
10- Render(text)
11-}
12-
13-// Form section descriptions (DRY: centralized text)
14-const (
15- // Welcome
16- WelcomeTagline = "A minimal, slightly opinionated Arch Linux installer."
17-
18- // Preflight sections
19- SectionUserIdentity = "Configure your user account and system details"
20- DescEmail = "Email is used for SSH key generation and git configuration"
21- DescTimezone = "e.g. America/New_York, Europe/London, Asia/Tokyo"
22- DescLocale = "e.g. en_US.UTF-8, en_GB.UTF-8, de_DE.UTF-8"
23- DescKeymap = "e.g. us, uk, de, fr"
24-
25- // Disk selection
26- SectionDiskSelection = "Select installation disk (ALL DATA WILL BE ERASED)"
27- WarnDiskErase = "WARNING: This will delete all data on the selected disk!"
28-
29- // Encryption
30- SectionDiskEncryption = "Disk Encryption"
31- DescEncryption = "LUKS encryption protects your data with Argon2id (2000ms iteration)"
32- DescEncryptionPassword = "Leave empty to use the same password as your user account"
33- DescEncryptionSamePass = "Use the same password for disk encryption as your user account?"
34-
35- // Kernel
36- SectionKernel = "Kernel Selection"
37- DescKernelLinux = "Stable mainline kernel (recommended)"
38- DescKernelLTS = "Long-term support (maximum stability)"
39- DescKernelZen = "Performance-optimized for general use"
40- DescKernelHardened = "Security-focused kernel"
41- DescKernelCachyOS = "Gaming-optimized (requires CachyOS repo)"
42-
43- // AMD P-State
44- SectionAMDTuning = "AMD CPU Tuning"
45- DescAMDPState = "Select AMD P-State driver mode for CPU frequency scaling"
46-
47- // Repository options
48- DescMultilib = "Required for 32-bit applications and Wine"
49- DescChaoticAUR = "Community repository with pre-built AUR packages"
50- DescAURHelper = "Tool for installing packages from Arch User Repository"
51- DescAURHelperParu = "Rust-based AUR helper"
52- DescAURHelperYay = "Go-based AUR helper"
53-)
D internal/ui/styles/containers.go +0 -29
@@ -1,29 +0,0 @@
1-package styles
2-
3-import "github.com/charmbracelet/lipgloss"
4-
5-// Layout constants
6-const (
7- MaxFormWidth = 80 // Maximum width for form containers
8- MaxContentWidth = 120 // Maximum width for content (welcome, complete screens)
9-)
10-
11-// Alignment aliases for clarity
12-const (
13- LeftAlign = lipgloss.Left
14- CenterAlign = lipgloss.Center
15-)
16-
17-// Container styles for consistent layout
18-var (
19- // CenteredContainerStyle - Used for welcome, complete, error screens
20- CenteredContainerStyle = lipgloss.NewStyle().
21- MaxWidth(MaxContentWidth).
22- Padding(0, 2).
23- AlignHorizontal(lipgloss.Center)
24-
25- // FormContainerStyle - Used for all forms (content left-aligned)
26- FormContainerStyle = lipgloss.NewStyle().
27- Padding(0, 2).
28- AlignHorizontal(lipgloss.Left)
29-)
D internal/ui/styles/theme.go +0 -324
@@ -1,324 +0,0 @@
1-package styles
2-
3-import (
4- "github.com/charmbracelet/huh"
5- "github.com/charmbracelet/lipgloss"
6-)
7-
8-// Bleu Theme - Based on the bleu-theme color palette
9-// https://github.com/bnema/bleu-theme
10-// Uses CompleteColor with TrueColor > ANSI256 > ANSI16 fallback
11-// Arch ISO supports 256 colors (TERM=xterm-256color)
12-
13-var (
14- // Background Colors
15- ColorDeepNavy = lipgloss.CompleteColor{
16- TrueColor: "#050a14",
17- ANSI256: "16", // Pure black
18- ANSI: "0", // Black
19- }
20-
21- ColorDarkBlue = lipgloss.CompleteColor{
22- TrueColor: "#070c16",
23- ANSI256: "233", // Very dark grey
24- ANSI: "0", // Black
25- }
26-
27- ColorDarkerBlue = lipgloss.CompleteColor{
28- TrueColor: "#0a1018",
29- ANSI256: "234", // Very dark grey
30- ANSI: "8", // Bright black (dark grey)
31- }
32-
33- ColorActiveTabBlue = lipgloss.CompleteColor{
34- TrueColor: "#0f1520",
35- ANSI256: "235", // Dark grey
36- ANSI: "8", // Bright black
37- }
38-
39- ColorOceanBlue = lipgloss.CompleteColor{
40- TrueColor: "#2d4a6b",
41- ANSI256: "24", // Deep blue
42- ANSI: "4", // Blue
43- }
44-
45- // Text Colors
46- ColorPrimaryText = lipgloss.CompleteColor{
47- TrueColor: "#e8f4f8",
48- ANSI256: "255", // Bright white
49- ANSI: "7", // White
50- }
51-
52- ColorPureWhite = lipgloss.CompleteColor{
53- TrueColor: "#fefefe",
54- ANSI256: "231", // Pure white
55- ANSI: "15", // Bright white
56- }
57-
58- ColorDimmedText = lipgloss.CompleteColor{
59- TrueColor: "#708090",
60- ANSI256: "102", // Medium grey
61- ANSI: "8", // Bright black (grey)
62- }
63-
64- // Accent Colors
65- ColorBrightCyan = lipgloss.CompleteColor{
66- TrueColor: "#00d4ff",
67- ANSI256: "45", // Bright cyan
68- ANSI: "14", // Bright cyan
69- }
70-
71- ColorPureBlue = lipgloss.CompleteColor{
72- TrueColor: "#5588cc",
73- ANSI256: "69", // Steel blue
74- ANSI: "12", // Bright blue
75- }
76-
77- ColorLightSkyBlue = lipgloss.CompleteColor{
78- TrueColor: "#87ceeb",
79- ANSI256: "117", // Light sky blue
80- ANSI: "14", // Bright cyan
81- }
82-
83- ColorSkyBlue = lipgloss.CompleteColor{
84- TrueColor: "#4a7ba7",
85- ANSI256: "67", // Steel blue
86- ANSI: "4", // Blue
87- }
88-
89- // Status Colors
90- ColorSuccessGreen = lipgloss.CompleteColor{
91- TrueColor: "#99FFE4",
92- ANSI256: "122", // Aquamarine green
93- ANSI: "10", // Bright green
94- }
95-
96- ColorSoftRed = lipgloss.CompleteColor{
97- TrueColor: "#ff6b8a",
98- ANSI256: "204", // Light red
99- ANSI: "9", // Bright red
100- }
101-
102- ColorWarmOrange = lipgloss.CompleteColor{
103- TrueColor: "#ffb347",
104- ANSI256: "215", // Light orange
105- ANSI: "11", // Bright yellow
106- }
107-)
108-
109-// Styles
110-var (
111- // Base styles
112- BaseStyle = lipgloss.NewStyle().
113- Foreground(ColorPrimaryText)
114-
115- // Title/Header styles
116- TitleStyle = lipgloss.NewStyle().
117- Foreground(ColorBrightCyan).
118- Bold(true).
119- MarginBottom(1)
120-
121- SubtitleStyle = lipgloss.NewStyle().
122- Foreground(ColorPureBlue)
123-
124- // Interactive elements
125- SelectedStyle = lipgloss.NewStyle().
126- Foreground(ColorPureWhite).
127- Background(ColorOceanBlue).
128- Bold(true)
129-
130- UnselectedStyle = lipgloss.NewStyle().
131- Foreground(ColorDimmedText)
132-
133- FocusedStyle = lipgloss.NewStyle().
134- Foreground(ColorBrightCyan).
135- Border(lipgloss.RoundedBorder()).
136- BorderForeground(ColorPureBlue)
137-
138- BlurredStyle = lipgloss.NewStyle().
139- Foreground(ColorDimmedText).
140- Border(lipgloss.RoundedBorder()).
141- BorderForeground(ColorDarkBlue)
142-
143- // Status styles
144- SuccessStyle = lipgloss.NewStyle().
145- Foreground(ColorSuccessGreen).
146- Bold(true)
147-
148- ErrorStyle = lipgloss.NewStyle().
149- Foreground(ColorSoftRed).
150- Bold(true)
151-
152- WarningStyle = lipgloss.NewStyle().
153- Foreground(ColorWarmOrange).
154- Bold(true)
155-
156- InfoStyle = lipgloss.NewStyle().
157- Foreground(ColorLightSkyBlue)
158-
159- // Logo styles (for ASCII art display)
160- LogoCyanStyle = lipgloss.NewStyle().
161- Foreground(ColorBrightCyan)
162-
163- LogoWhiteStyle = lipgloss.NewStyle().
164- Foreground(ColorPureWhite)
165-
166- LogoBlueStyle = lipgloss.NewStyle().
167- Foreground(ColorSkyBlue)
168-
169- // Progress/Spinner styles
170- SpinnerStyle = lipgloss.NewStyle().
171- Foreground(ColorBrightCyan)
172-
173- ProgressBarStyle = lipgloss.NewStyle().
174- Foreground(ColorPureBlue)
175-
176- // Prompt styles
177- PromptStyle = lipgloss.NewStyle().
178- Foreground(ColorBrightCyan).
179- Bold(true)
180-
181- InputStyle = lipgloss.NewStyle().
182- Foreground(ColorPureWhite).
183- Background(ColorActiveTabBlue).
184- Padding(0, 1)
185-
186- PlaceholderStyle = lipgloss.NewStyle().
187- Foreground(ColorDimmedText)
188-
189- // Box/Panel styles
190- PanelStyle = lipgloss.NewStyle().
191- Background(ColorDarkerBlue).
192- Border(lipgloss.RoundedBorder()).
193- BorderForeground(ColorDarkBlue).
194- Padding(1, 2)
195-
196- BorderStyle = lipgloss.NewStyle().
197- BorderStyle(lipgloss.RoundedBorder()).
198- BorderForeground(ColorDarkBlue)
199-
200- // Log/Output styles
201- LogStyle = lipgloss.NewStyle().
202- Foreground(ColorDimmedText).
203- Background(ColorDarkerBlue).
204- Padding(1, 2)
205-
206- // Help/Footer styles
207- HelpStyle = lipgloss.NewStyle().
208- Foreground(ColorDimmedText).
209- MarginTop(1)
210-
211- KeyStyle = lipgloss.NewStyle().
212- Foreground(ColorPureBlue).
213- Bold(true)
214-
215- DescStyle = lipgloss.NewStyle().
216- Foreground(ColorDimmedText)
217-)
218-
219-// Helper functions for common styling patterns
220-
221-// RenderTitle renders a section title
222-func RenderTitle(text string) string {
223- return TitleStyle.Render(text)
224-}
225-
226-// RenderSuccess renders success message
227-func RenderSuccess(text string) string {
228- return SuccessStyle.Render("[OK] " + text)
229-}
230-
231-// RenderError renders error message
232-func RenderError(text string) string {
233- return ErrorStyle.Render("[ERROR] " + text)
234-}
235-
236-// RenderWarning renders warning message
237-func RenderWarning(text string) string {
238- return WarningStyle.Render("[WARN] " + text)
239-}
240-
241-// RenderInfo renders info message
242-func RenderInfo(text string) string {
243- return InfoStyle.Render("[INFO] " + text)
244-}
245-
246-// RenderKeyValue renders a key-value pair
247-func RenderKeyValue(key, value string) string {
248- return KeyStyle.Render(key+": ") + lipgloss.NewStyle().Foreground(ColorPrimaryText).Render(value)
249-}
250-
251-// RenderPanel renders content in a panel
252-func RenderPanel(content string) string {
253- return PanelStyle.Render(content)
254-}
255-
256-// RenderHelp renders help text with key bindings
257-func RenderHelp(bindings map[string]string) string {
258- var help string
259- for key, desc := range bindings {
260- help += KeyStyle.Render(key) + " " + DescStyle.Render(desc) + " "
261- }
262- return HelpStyle.Render(help)
263-}
264-
265-// HuhTheme returns a custom huh.Theme matching the bleu color scheme
266-// Uses ThemeBase16 as foundation for maximum TTY compatibility (QEMU console, etc)
267-func HuhTheme() *huh.Theme {
268- // Start with Base16 theme for limited terminal support
269- theme := huh.ThemeBase16()
270-
271- // Customize with bleu theme colors using ANSI 16 codes
272- // ANSI Color mapping for bleu theme:
273- // 0=black, 4=blue, 6=cyan, 7=white, 8=grey, 10=bright green, 12=bright blue, 14=bright cyan, 15=bright white
274-
275- // Form and Group styles
276- theme.Group.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Bright cyan
277- theme.Group.Description = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) // Bright blue
278-
279- // Focused field styles
280- theme.Focused.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Bright cyan
281- theme.Focused.Description = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) // Bright blue
282- theme.Focused.SelectSelector = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).SetString("> ")
283- theme.Focused.Option = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) // White
284- theme.Focused.MultiSelectSelector = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).SetString("> ")
285- theme.Focused.SelectedOption = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // Bright cyan
286- theme.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).SetString("[OK] ") // Bright green
287- theme.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) // White
288- theme.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).SetString("○ ") // Grey
289-
290- // Button styles with explicit background colors for TTY compatibility
291- // Active/focused button (currently selected - e.g., "Yes" when on Yes)
292- theme.Focused.FocusedButton = lipgloss.NewStyle().
293- Foreground(lipgloss.Color("15")). // Bright white text
294- Background(lipgloss.Color("4")). // Blue background
295- Padding(0, 2).
296- Bold(true)
297-
298- // Inactive/blurred button (not selected - e.g., "No" when on Yes)
299- theme.Focused.BlurredButton = lipgloss.NewStyle().
300- Foreground(lipgloss.Color("7")). // White text
301- Background(lipgloss.Color("0")). // Black background
302- Padding(0, 2)
303-
304- // Blurred field styles (when entire field loses focus)
305- theme.Blurred.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey
306- theme.Blurred.Description = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey
307- theme.Blurred.SelectSelector = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).SetString("> ")
308- theme.Blurred.Option = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey
309-
310- // Override buttons in Blurred state too (inherited from Focused in Base16)
311- theme.Blurred.FocusedButton = theme.Focused.FocusedButton
312- theme.Blurred.BlurredButton = theme.Focused.BlurredButton
313-
314- // Text input styles
315- theme.Focused.TextInput.Cursor = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // Bright cyan
316- theme.Focused.TextInput.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey
317- theme.Focused.TextInput.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Bright cyan
318-
319- // Error styles
320- theme.Focused.ErrorIndicator = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).SetString("[KO] ") // Bright red
321- theme.Focused.ErrorMessage = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true) // Bright red
322-
323- return theme
324-}
D internal/ui/views/confirmation.go +0 -30
@@ -1,30 +0,0 @@
1-package views
2-
3-import (
4- "fmt"
5-
6- "github.com/bnema/archup/internal/ui/model"
7- "github.com/bnema/archup/internal/ui/styles"
8-)
9-
10-// RenderConfirmation renders the confirmation screen (left-aligned)
11-func RenderConfirmation(m model.UI) string {
12- cfg := m.Config()
13-
14- s := styles.TitleStyle.Render("Confirm Installation") + "\n\n"
15- s += fmt.Sprintf("Hostname: %s\n", cfg.Hostname)
16- s += fmt.Sprintf("Username: %s\n", cfg.Username)
17- s += fmt.Sprintf("Disk: %s\n", cfg.TargetDisk)
18- s += fmt.Sprintf("Kernel: %s\n", cfg.KernelChoice)
19- s += fmt.Sprintf("Encryption: %s\n", cfg.EncryptionType)
20-
21- if cfg.AMDPState != "" {
22- s += fmt.Sprintf("AMD P-State: %s\n", cfg.AMDPState)
23- }
24-
25- s += "\n"
26- s += styles.WarningStyle.Render("WARNING: This will erase all data on "+cfg.TargetDisk) + "\n\n"
27- s += styles.HelpStyle.Render("Press ENTER to continue, Ctrl+C to cancel")
28-
29- return styles.FormContainerStyle.Render(s)
30-}
D internal/ui/views/execution.go +0 -12
@@ -1,12 +0,0 @@
1-package views
2-
3-import (
4- "github.com/bnema/archup/internal/ui/model"
5- "github.com/bnema/archup/internal/ui/styles"
6-)
7-
8-// RenderExecuting renders the installation progress view
9-func RenderExecuting(m model.UI) string {
10- title := m.Spinner().View() + " Installation in progress..."
11- return styles.TitleStyle.Render(title) + "\n\n" + m.Output().View()
12-}
D internal/ui/views/forms.go +0 -26
@@ -1,26 +0,0 @@
1-package views
2-
3-import (
4- "github.com/bnema/archup/internal/ui/model"
5- "github.com/bnema/archup/internal/ui/styles"
6- "github.com/charmbracelet/lipgloss"
7-)
8-
9-// RenderForm renders any form view (container centered, content left-aligned)
10-func RenderForm(m model.UI) string {
11- if m.CurrentForm() == nil {
12- return "Loading form..."
13- }
14-
15- header := CenterText(m.RenderPhaseHeader(), m.Width())
16- form := m.CurrentForm().View()
17- content := styles.FormContainerStyle.Render(header + "\n\n" + form)
18-
19- // Center the container horizontally on screen, keep form content left-aligned
20- return lipgloss.PlaceHorizontal(
21- m.Width(),
22- lipgloss.Center,
23- content,
24- lipgloss.WithWhitespaceChars(" "),
25- )
26-}
D internal/ui/views/helpers.go +0 -89
@@ -1,89 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/ui/assets"
7- "github.com/bnema/archup/internal/ui/styles"
8- "github.com/charmbracelet/lipgloss"
9-)
10-
11-const (
12- WelcomeTagline = "A minimal, slightly opinionated Arch Linux installer."
13-)
14-
15-// Logo represents the ArchUp logo display
16-type Logo struct {
17- width int
18- height int
19- version string
20-}
21-
22-// NewLogo creates a new Logo instance
23-func NewLogo(version string) *Logo {
24- lines := strings.Split(assets.LogoASCII, "\n")
25- width := 0
26- for _, line := range lines {
27- if len(line) > width {
28- width = len(line)
29- }
30- }
31-
32- if version == "" {
33- version = "dev"
34- }
35-
36- return &Logo{
37- width: width,
38- height: len(lines),
39- version: version,
40- }
41-}
42-
43-// RenderCentered renders the logo centered using lipgloss alignment
44-func (l *Logo) RenderCentered(termWidth int) string {
45- return l.Render()
46-}
47-
48-// Render renders the logo with character-level styling
49-func (l *Logo) Render() string {
50- lines := strings.Split(assets.LogoASCII, "\n")
51- var rendered strings.Builder
52-
53- for _, line := range lines {
54- if len(line) == 0 {
55- rendered.WriteString("\n")
56- continue
57- }
58-
59- // Apply conditional styling based on character
60- var styledLine strings.Builder
61- for _, ch := range line {
62- if string(ch) == "░" {
63- // Use darker blue for border character
64- styledLine.WriteString(styles.LogoBlueStyle.Render(string(ch)))
65- } else {
66- // Use bright cyan for main logo
67- styledLine.WriteString(styles.LogoCyanStyle.Render(string(ch)))
68- }
69- }
70- rendered.WriteString(styledLine.String())
71- rendered.WriteString("\n")
72- }
73-
74- // Add version below logo
75- rendered.WriteString("\n")
76- versionLine := " " + l.version
77- rendered.WriteString(styles.HelpStyle.Render(versionLine))
78- rendered.WriteString("\n")
79-
80- return rendered.String()
81-}
82-
83-// CenterText centers text within a given width using lipgloss
84-func CenterText(text string, width int) string {
85- return lipgloss.NewStyle().
86- Width(width).
87- AlignHorizontal(lipgloss.Center).
88- Render(text)
89-}
D internal/ui/views/network.go +0 -34
@@ -1,34 +0,0 @@
1-package views
2-
3-import (
4- "fmt"
5-
6- "github.com/bnema/archup/internal/ui/model"
7- "github.com/bnema/archup/internal/ui/styles"
8-)
9-
10-// RenderNetworkCheck renders the network connectivity check screen
11-func RenderNetworkCheck(m model.UI) string {
12- spinner := m.Spinner()
13- spinnerView := spinner.View()
14-
15- var content string
16-
17- // If network check is still running
18- if !m.NetworkCheckDone() {
19- content = CenterText(spinnerView+" Checking network connectivity...", m.Width()) + "\n\n" +
20- CenterText(styles.HelpStyle.Render("Please wait..."), m.Width())
21- } else if m.NetworkErr() != nil {
22- // Network check failed
23- errorMsg := fmt.Sprintf("Network error: %v", m.NetworkErr())
24- content = CenterText(styles.ErrorStyle.Render("✗ Network Check Failed"), m.Width()) + "\n\n" +
25- CenterText(errorMsg, m.Width()) + "\n\n" +
26- CenterText(styles.HelpStyle.Render("Press ENTER to retry or CTRL+C to exit"), m.Width())
27- } else {
28- // Network check passed
29- content = CenterText(styles.SuccessStyle.Render("✓ Network Check Passed"), m.Width()) + "\n\n" +
30- CenterText(styles.HelpStyle.Render("Press ENTER to continue"), m.Width())
31- }
32-
33- return styles.CenteredContainerStyle.Render(content)
34-}
D internal/ui/views/results.go +0 -53
@@ -1,53 +0,0 @@
1-package views
2-
3-import (
4- "fmt"
5- "time"
6-
7- "github.com/bnema/archup/internal/ui/model"
8- "github.com/bnema/archup/internal/ui/styles"
9-)
10-
11-// RenderSuccess renders the success screen with timing breakdown (centered)
12-func RenderSuccess(m model.UI, duration time.Duration, phaseDurations map[string]time.Duration) string {
13- cfg := m.Config()
14-
15- s := styles.SuccessStyle.Render("[OK] Installation Complete!") + "\n\n"
16- s += "Installation Summary:\n"
17- s += fmt.Sprintf(" Hostname: %s\n", cfg.Hostname)
18- s += fmt.Sprintf(" Disk: %s\n", cfg.TargetDisk)
19- s += fmt.Sprintf(" Kernel: %s\n", cfg.KernelChoice)
20- s += fmt.Sprintf(" Total Time: %s\n\n", formatDuration(duration))
21-
22- s += "Next Steps:\n"
23- s += " 1. Remove installation media\n"
24- s += " 2. Reboot the system\n"
25- s += " 3. First-boot setup will run automatically\n\n"
26- s += styles.HelpStyle.Render("Press ENTER to exit")
27-
28- return styles.CenteredContainerStyle.Render(s)
29-}
30-
31-// RenderError renders the error screen with log tail (centered)
32-func RenderError(m model.UI, err error, phaseName string, duration time.Duration) string {
33- s := styles.ErrorStyle.Render("[KO] Installation Failed") + "\n\n"
34- s += fmt.Sprintf("Phase: %s\n", phaseName)
35- s += fmt.Sprintf("Error: %v\n", err)
36- s += fmt.Sprintf("Duration before failure: %s\n\n", formatDuration(duration))
37-
38- s += "Recent log entries:\n"
39- // TODO: Add log tail here once LogViewer is implemented
40-
41- s += "\nLog file: " + m.Config().LogPath + "\n"
42- s += "Report issues: https://github.com/bnema/archup/issues\n\n"
43- s += styles.HelpStyle.Render("Press ENTER to exit")
44-
45- return styles.CenteredContainerStyle.Render(s)
46-}
47-
48-func formatDuration(d time.Duration) string {
49- if d < time.Minute {
50- return fmt.Sprintf("%ds", int(d.Seconds()))
51- }
52- return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
53-}
D internal/ui/views/welcome.go +0 -16
@@ -1,16 +0,0 @@
1-package views
2-
3-import (
4- "github.com/bnema/archup/internal/ui/model"
5- "github.com/bnema/archup/internal/ui/styles"
6-)
7-
8-// RenderWelcome renders the welcome logo screen (centered)
9-func RenderWelcome(m model.UI) string {
10- logo := NewLogo(m.Version())
11- content := logo.RenderCentered(m.Width()) + "\n\n\n" +
12- CenterText(WelcomeTagline, m.Width()) + "\n\n" +
13- CenterText(styles.HelpStyle.Render("Press ENTER to start installation"), m.Width())
14-
15- return styles.CenteredContainerStyle.Render(content)
16-}

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