chore(cleanup): remove dead code from pre-DDD refactor
Delete packages entirely superseded by the domain layer (postboot, shell, validation, verify, defaults) and trim internal/system/ down to only what the TUI still calls: ListDisks, DetectCPUInfo, DetectTimezoneSimple, ChrootExec*, Unmount, BeginSession, DownloadAndInstallPackages. Also drop internal/interfaces/cli/.gitkeep (empty placeholder).
- commit
- 9a253ed3e0ba76e10d32c88d0f03c43ef29c886a
- author
- brice · · 2026-03-03 03:25 UTC
- parent
- 59b3181
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
22 files · +15 -1248
D internal/defaults/defaults.go +0 -32
| @@ -1,32 +0,0 @@ | |||
| 1 | - | package defaults | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "embed" | |
| 5 | - | "fmt" | |
| 6 | - | "strings" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | //go:embed packages/*.packages | |
| 10 | - | var packageLists embed.FS | |
| 11 | - | ||
| 12 | - | // ReadPackageList loads a named package list. | |
| 13 | - | func ReadPackageList(name string) ([]string, error) { | |
| 14 | - | path := fmt.Sprintf("packages/%s.packages", name) | |
| 15 | - | data, err := packageLists.ReadFile(path) | |
| 16 | - | if err != nil { | |
| 17 | - | return nil, fmt.Errorf("read package list %s: %w", name, err) | |
| 18 | - | } | |
| 19 | - | return splitPackages(string(data)), nil | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | func splitPackages(contents string) []string { | |
| 23 | - | packages := []string{} | |
| 24 | - | for _, line := range strings.Split(contents, "\n") { | |
| 25 | - | trimmed := strings.TrimSpace(line) | |
| 26 | - | if trimmed == "" || strings.HasPrefix(trimmed, "#") { | |
| 27 | - | continue | |
| 28 | - | } | |
| 29 | - | packages = append(packages, strings.Fields(trimmed)...) | |
| 30 | - | } | |
| 31 | - | return packages | |
| 32 | - | } | |
| @@ -1,32 +0,0 @@ | |||||
| 1 | - | package defaults | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "embed" | |||
| 5 | - | "fmt" | |||
| 6 | - | "strings" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | //go:embed packages/*.packages | |||
| 10 | - | var packageLists embed.FS | |||
| 11 | - | ||||
| 12 | - | // ReadPackageList loads a named package list. | |||
| 13 | - | func ReadPackageList(name string) ([]string, error) { | |||
| 14 | - | path := fmt.Sprintf("packages/%s.packages", name) | |||
| 15 | - | data, err := packageLists.ReadFile(path) | |||
| 16 | - | if err != nil { | |||
| 17 | - | return nil, fmt.Errorf("read package list %s: %w", name, err) | |||
| 18 | - | } | |||
| 19 | - | return splitPackages(string(data)), nil | |||
| 20 | - | } | |||
| 21 | - | ||||
| 22 | - | func splitPackages(contents string) []string { | |||
| 23 | - | packages := []string{} | |||
| 24 | - | for _, line := range strings.Split(contents, "\n") { | |||
| 25 | - | trimmed := strings.TrimSpace(line) | |||
| 26 | - | if trimmed == "" || strings.HasPrefix(trimmed, "#") { | |||
| 27 | - | continue | |||
| 28 | - | } | |||
| 29 | - | packages = append(packages, strings.Fields(trimmed)...) | |||
| 30 | - | } | |||
| 31 | - | return packages | |||
| 32 | - | } | |||
D internal/defaults/packages/compositor-hyprland.packages +0 -1
| @@ -1,1 +0,0 @@ | |||
| 1 | - | hyprland xdg-desktop-portal-hyprland | |
| @@ -1,1 +0,0 @@ | |||||
| 1 | - | hyprland xdg-desktop-portal-hyprland | |||
D internal/defaults/packages/compositor-niri.packages +0 -1
| @@ -1,1 +0,0 @@ | |||
| 1 | - | niri xdg-desktop-portal-gnome | |
| @@ -1,1 +0,0 @@ | |||||
| 1 | - | niri xdg-desktop-portal-gnome | |||
D internal/defaults/packages/desktop-core.packages +0 -2
| @@ -1,2 +0,0 @@ | |||
| 1 | - | foot waybar fuzzel hypridle hyprlock swww mako nautilus | |
| 2 | - | xdg-desktop-portal polkit-kde-agent wl-clipboard grim slurp wlr-randr | |
| @@ -1,2 +0,0 @@ | |||||
| 1 | - | foot waybar fuzzel hypridle hyprlock swww mako nautilus | |||
| 2 | - | xdg-desktop-portal polkit-kde-agent wl-clipboard grim slurp wlr-randr | |||
D internal/defaults/packages/gtk.packages +0 -1
| @@ -1,1 +0,0 @@ | |||
| 1 | - | gtk4 libadwaita adw-gtk3 | |
| @@ -1,1 +0,0 @@ | |||||
| 1 | - | gtk4 libadwaita adw-gtk3 | |||
D internal/defaults/packages/sddm.packages +0 -1
| @@ -1,1 +0,0 @@ | |||
| 1 | - | sddm qt6-svg qt6-virtualkeyboard | |
| @@ -1,1 +0,0 @@ | |||||
| 1 | - | sddm qt6-svg qt6-virtualkeyboard | |||
D internal/defaults/packages/video-accel.packages +0 -4
| @@ -1,4 +0,0 @@ | |||
| 1 | - | libva libva-utils nvtop vulkan-icd-loader vulkan-tools | |
| 2 | - | gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad | |
| 3 | - | gst-plugins-ugly gst-plugin-va gst-libav ffmpeg | |
| 4 | - | pipewire pipewire-pulse pipewire-alsa wireplumber | |
| @@ -1,4 +0,0 @@ | |||||
| 1 | - | libva libva-utils nvtop vulkan-icd-loader vulkan-tools | |||
| 2 | - | gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad | |||
| 3 | - | gst-plugins-ugly gst-plugin-va gst-libav ffmpeg | |||
| 4 | - | pipewire pipewire-pulse pipewire-alsa wireplumber | |||
D internal/interfaces/cli/.gitkeep +0 -0
no textual change.
D internal/postboot/setup.go +0 -131
| @@ -1,131 +0,0 @@ | |||
| 1 | - | package postboot | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "io" | |
| 6 | - | "net/http" | |
| 7 | - | "path/filepath" | |
| 8 | - | "strings" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/archup/internal/config" | |
| 11 | - | "github.com/bnema/archup/internal/interfaces" | |
| 12 | - | "github.com/bnema/archup/internal/logger" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | // fetchFile returns the content of a repo-relative path. | |
| 16 | - | // It checks /tmp/archup-install/<repoPath> first (populated by push-binary-to-vm.sh --local), | |
| 17 | - | // then falls back to downloading from GitHub. | |
| 18 | - | func (s *Setup) fetchFile(repoPath string) ([]byte, error) { | |
| 19 | - | localPath := filepath.Join(config.DefaultInstallDir, repoPath) | |
| 20 | - | if content, err := s.fs.ReadFile(localPath); err == nil { | |
| 21 | - | return content, nil | |
| 22 | - | } | |
| 23 | - | url := fmt.Sprintf("%s/%s", s.config.RawURL, repoPath) | |
| 24 | - | resp, err := s.http.Get(url) | |
| 25 | - | if err != nil { | |
| 26 | - | return nil, fmt.Errorf("failed to fetch %s: %w", repoPath, err) | |
| 27 | - | } | |
| 28 | - | defer resp.Body.Close() | |
| 29 | - | if resp.StatusCode != http.StatusOK { | |
| 30 | - | return nil, fmt.Errorf("failed to fetch %s: HTTP %d", repoPath, resp.StatusCode) | |
| 31 | - | } | |
| 32 | - | data, err := io.ReadAll(resp.Body) | |
| 33 | - | if err != nil { | |
| 34 | - | return nil, fmt.Errorf("failed to read %s: %w", repoPath, err) | |
| 35 | - | } | |
| 36 | - | return data, nil | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | // SetupResult holds the result of post-boot setup | |
| 40 | - | type SetupResult struct { | |
| 41 | - | ScriptsDownloaded int | |
| 42 | - | ServiceEnabled bool | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | // Setup handles first-boot service configuration | |
| 46 | - | type Setup struct { | |
| 47 | - | fs interfaces.FileSystem | |
| 48 | - | http interfaces.HTTPClient | |
| 49 | - | chrExec interfaces.ChrootExecutor | |
| 50 | - | config *config.Config | |
| 51 | - | logger *logger.Logger | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // NewSetup creates a new post-boot setup handler | |
| 55 | - | func NewSetup( | |
| 56 | - | fs interfaces.FileSystem, | |
| 57 | - | http interfaces.HTTPClient, | |
| 58 | - | chrExec interfaces.ChrootExecutor, | |
| 59 | - | cfg *config.Config, | |
| 60 | - | log *logger.Logger, | |
| 61 | - | ) *Setup { | |
| 62 | - | return &Setup{ | |
| 63 | - | fs: fs, | |
| 64 | - | http: http, | |
| 65 | - | chrExec: chrExec, | |
| 66 | - | config: cfg, | |
| 67 | - | logger: log, | |
| 68 | - | } | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | // Configure downloads and configures first-boot service | |
| 72 | - | func (s *Setup) Configure() (SetupResult, error) { | |
| 73 | - | result := SetupResult{ | |
| 74 | - | ScriptsDownloaded: 0, | |
| 75 | - | ServiceEnabled: false, | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | if err := s.fs.MkdirAll(config.PathMntPostBoot, 0755); err != nil { | |
| 79 | - | return result, fmt.Errorf("failed to create post-boot directory: %w", err) | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | // Fetch logo.txt (local-first, then remote) | |
| 83 | - | logoData, err := s.fetchFile("logo.txt") | |
| 84 | - | if err != nil { | |
| 85 | - | return result, fmt.Errorf("failed to fetch logo.txt: %w", err) | |
| 86 | - | } | |
| 87 | - | logoPath := filepath.Join(config.PathMntPostBoot, "logo.txt") | |
| 88 | - | if err := s.fs.WriteFile(logoPath, logoData, 0644); err != nil { | |
| 89 | - | return result, fmt.Errorf("failed to write logo.txt: %w", err) | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | // Fetch all post-boot scripts (local-first, then remote) | |
| 93 | - | for _, script := range config.PostBootScripts { | |
| 94 | - | data, err := s.fetchFile(filepath.Join("install", "mandatory", "post-boot", script)) | |
| 95 | - | if err != nil { | |
| 96 | - | return result, fmt.Errorf("failed to fetch %s: %w", script, err) | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | scriptPath := filepath.Join(config.PathMntPostBoot, script) | |
| 100 | - | if err := s.fs.WriteFile(scriptPath, data, 0755); err != nil { | |
| 101 | - | return result, fmt.Errorf("failed to write %s: %w", script, err) | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | if err := s.fs.Chmod(scriptPath, 0755); err != nil { | |
| 105 | - | return result, fmt.Errorf("failed to set permissions on %s: %w", script, err) | |
| 106 | - | } | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | // Fetch service template (local-first, then remote) | |
| 110 | - | templateBytes, err := s.fetchFile(config.PostBootServiceTemplate) | |
| 111 | - | if err != nil { | |
| 112 | - | return result, fmt.Errorf("failed to fetch service template: %w", err) | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | serviceContent := string(templateBytes) | |
| 116 | - | serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_EMAIL__", s.config.Email) | |
| 117 | - | serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_USERNAME__", s.config.Username) | |
| 118 | - | ||
| 119 | - | servicePath := filepath.Join(config.PathMntSystemdSystem, config.PostBootServiceName) | |
| 120 | - | if err := s.fs.WriteFile(servicePath, []byte(serviceContent), 0644); err != nil { | |
| 121 | - | return result, fmt.Errorf("failed to write service file: %w", err) | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | if err := s.chrExec.ChrootSystemctl(s.logger.LogPath(), config.PathMnt, "enable", config.PostBootServiceName); err != nil { | |
| 125 | - | return result, fmt.Errorf("failed to enable service: %w", err) | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | result.ServiceEnabled = true | |
| 129 | - | result.ScriptsDownloaded = len(config.PostBootScripts) | |
| 130 | - | return result, nil | |
| 131 | - | } | |
| @@ -1,131 +0,0 @@ | |||||
| 1 | - | package postboot | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "io" | |||
| 6 | - | "net/http" | |||
| 7 | - | "path/filepath" | |||
| 8 | - | "strings" | |||
| 9 | - | ||||
| 10 | - | "github.com/bnema/archup/internal/config" | |||
| 11 | - | "github.com/bnema/archup/internal/interfaces" | |||
| 12 | - | "github.com/bnema/archup/internal/logger" | |||
| 13 | - | ) | |||
| 14 | - | ||||
| 15 | - | // fetchFile returns the content of a repo-relative path. | |||
| 16 | - | // It checks /tmp/archup-install/<repoPath> first (populated by push-binary-to-vm.sh --local), | |||
| 17 | - | // then falls back to downloading from GitHub. | |||
| 18 | - | func (s *Setup) fetchFile(repoPath string) ([]byte, error) { | |||
| 19 | - | localPath := filepath.Join(config.DefaultInstallDir, repoPath) | |||
| 20 | - | if content, err := s.fs.ReadFile(localPath); err == nil { | |||
| 21 | - | return content, nil | |||
| 22 | - | } | |||
| 23 | - | url := fmt.Sprintf("%s/%s", s.config.RawURL, repoPath) | |||
| 24 | - | resp, err := s.http.Get(url) | |||
| 25 | - | if err != nil { | |||
| 26 | - | return nil, fmt.Errorf("failed to fetch %s: %w", repoPath, err) | |||
| 27 | - | } | |||
| 28 | - | defer resp.Body.Close() | |||
| 29 | - | if resp.StatusCode != http.StatusOK { | |||
| 30 | - | return nil, fmt.Errorf("failed to fetch %s: HTTP %d", repoPath, resp.StatusCode) | |||
| 31 | - | } | |||
| 32 | - | data, err := io.ReadAll(resp.Body) | |||
| 33 | - | if err != nil { | |||
| 34 | - | return nil, fmt.Errorf("failed to read %s: %w", repoPath, err) | |||
| 35 | - | } | |||
| 36 | - | return data, nil | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | // SetupResult holds the result of post-boot setup | |||
| 40 | - | type SetupResult struct { | |||
| 41 | - | ScriptsDownloaded int | |||
| 42 | - | ServiceEnabled bool | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | // Setup handles first-boot service configuration | |||
| 46 | - | type Setup struct { | |||
| 47 | - | fs interfaces.FileSystem | |||
| 48 | - | http interfaces.HTTPClient | |||
| 49 | - | chrExec interfaces.ChrootExecutor | |||
| 50 | - | config *config.Config | |||
| 51 | - | logger *logger.Logger | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | - | // NewSetup creates a new post-boot setup handler | |||
| 55 | - | func NewSetup( | |||
| 56 | - | fs interfaces.FileSystem, | |||
| 57 | - | http interfaces.HTTPClient, | |||
| 58 | - | chrExec interfaces.ChrootExecutor, | |||
| 59 | - | cfg *config.Config, | |||
| 60 | - | log *logger.Logger, | |||
| 61 | - | ) *Setup { | |||
| 62 | - | return &Setup{ | |||
| 63 | - | fs: fs, | |||
| 64 | - | http: http, | |||
| 65 | - | chrExec: chrExec, | |||
| 66 | - | config: cfg, | |||
| 67 | - | logger: log, | |||
| 68 | - | } | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | // Configure downloads and configures first-boot service | |||
| 72 | - | func (s *Setup) Configure() (SetupResult, error) { | |||
| 73 | - | result := SetupResult{ | |||
| 74 | - | ScriptsDownloaded: 0, | |||
| 75 | - | ServiceEnabled: false, | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | if err := s.fs.MkdirAll(config.PathMntPostBoot, 0755); err != nil { | |||
| 79 | - | return result, fmt.Errorf("failed to create post-boot directory: %w", err) | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | - | // Fetch logo.txt (local-first, then remote) | |||
| 83 | - | logoData, err := s.fetchFile("logo.txt") | |||
| 84 | - | if err != nil { | |||
| 85 | - | return result, fmt.Errorf("failed to fetch logo.txt: %w", err) | |||
| 86 | - | } | |||
| 87 | - | logoPath := filepath.Join(config.PathMntPostBoot, "logo.txt") | |||
| 88 | - | if err := s.fs.WriteFile(logoPath, logoData, 0644); err != nil { | |||
| 89 | - | return result, fmt.Errorf("failed to write logo.txt: %w", err) | |||
| 90 | - | } | |||
| 91 | - | ||||
| 92 | - | // Fetch all post-boot scripts (local-first, then remote) | |||
| 93 | - | for _, script := range config.PostBootScripts { | |||
| 94 | - | data, err := s.fetchFile(filepath.Join("install", "mandatory", "post-boot", script)) | |||
| 95 | - | if err != nil { | |||
| 96 | - | return result, fmt.Errorf("failed to fetch %s: %w", script, err) | |||
| 97 | - | } | |||
| 98 | - | ||||
| 99 | - | scriptPath := filepath.Join(config.PathMntPostBoot, script) | |||
| 100 | - | if err := s.fs.WriteFile(scriptPath, data, 0755); err != nil { | |||
| 101 | - | return result, fmt.Errorf("failed to write %s: %w", script, err) | |||
| 102 | - | } | |||
| 103 | - | ||||
| 104 | - | if err := s.fs.Chmod(scriptPath, 0755); err != nil { | |||
| 105 | - | return result, fmt.Errorf("failed to set permissions on %s: %w", script, err) | |||
| 106 | - | } | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | // Fetch service template (local-first, then remote) | |||
| 110 | - | templateBytes, err := s.fetchFile(config.PostBootServiceTemplate) | |||
| 111 | - | if err != nil { | |||
| 112 | - | return result, fmt.Errorf("failed to fetch service template: %w", err) | |||
| 113 | - | } | |||
| 114 | - | ||||
| 115 | - | serviceContent := string(templateBytes) | |||
| 116 | - | serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_EMAIL__", s.config.Email) | |||
| 117 | - | serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_USERNAME__", s.config.Username) | |||
| 118 | - | ||||
| 119 | - | servicePath := filepath.Join(config.PathMntSystemdSystem, config.PostBootServiceName) | |||
| 120 | - | if err := s.fs.WriteFile(servicePath, []byte(serviceContent), 0644); err != nil { | |||
| 121 | - | return result, fmt.Errorf("failed to write service file: %w", err) | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | - | if err := s.chrExec.ChrootSystemctl(s.logger.LogPath(), config.PathMnt, "enable", config.PostBootServiceName); err != nil { | |||
| 125 | - | return result, fmt.Errorf("failed to enable service: %w", err) | |||
| 126 | - | } | |||
| 127 | - | ||||
| 128 | - | result.ServiceEnabled = true | |||
| 129 | - | result.ScriptsDownloaded = len(config.PostBootScripts) | |||
| 130 | - | return result, nil | |||
| 131 | - | } | |||
D internal/shell/configurator.go +0 -25
| @@ -1,25 +0,0 @@ | |||
| 1 | - | package shell | |
| 2 | - | ||
| 3 | - | // ConfigResult holds the result of shell configuration | |
| 4 | - | type ConfigResult struct { | |
| 5 | - | ThemesApplied int | |
| 6 | - | ThemesFailed []string | |
| 7 | - | Warnings []string | |
| 8 | - | } | |
| 9 | - | ||
| 10 | - | // Configurator handles shell and bash configuration setup | |
| 11 | - | type Configurator struct{} | |
| 12 | - | ||
| 13 | - | // NewConfigurator creates a new shell configurator | |
| 14 | - | func NewConfigurator() *Configurator { | |
| 15 | - | return &Configurator{} | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | // Configure is a no-op. Shell configuration is handled by cli-tools.sh at first boot. | |
| 19 | - | func (c *Configurator) Configure(username, userHome string) (ConfigResult, error) { | |
| 20 | - | return ConfigResult{ | |
| 21 | - | ThemesApplied: 0, | |
| 22 | - | ThemesFailed: []string{}, | |
| 23 | - | Warnings: []string{"Shell configuration deferred to cli-tools.sh on first boot"}, | |
| 24 | - | }, nil | |
| 25 | - | } | |
| @@ -1,25 +0,0 @@ | |||||
| 1 | - | package shell | |||
| 2 | - | ||||
| 3 | - | // ConfigResult holds the result of shell configuration | |||
| 4 | - | type ConfigResult struct { | |||
| 5 | - | ThemesApplied int | |||
| 6 | - | ThemesFailed []string | |||
| 7 | - | Warnings []string | |||
| 8 | - | } | |||
| 9 | - | ||||
| 10 | - | // Configurator handles shell and bash configuration setup | |||
| 11 | - | type Configurator struct{} | |||
| 12 | - | ||||
| 13 | - | // NewConfigurator creates a new shell configurator | |||
| 14 | - | func NewConfigurator() *Configurator { | |||
| 15 | - | return &Configurator{} | |||
| 16 | - | } | |||
| 17 | - | ||||
| 18 | - | // Configure is a no-op. Shell configuration is handled by cli-tools.sh at first boot. | |||
| 19 | - | func (c *Configurator) Configure(username, userHome string) (ConfigResult, error) { | |||
| 20 | - | return ConfigResult{ | |||
| 21 | - | ThemesApplied: 0, | |||
| 22 | - | ThemesFailed: []string{}, | |||
| 23 | - | Warnings: []string{"Shell configuration deferred to cli-tools.sh on first boot"}, | |||
| 24 | - | }, nil | |||
| 25 | - | } | |||
M internal/system/chroot.go +0 -26
| @@ -179,20 +179,6 @@ func ChrootSystemctl(logPath, mountPoint, action, service string) error { | |||
| 179 | 179 | return nil | |
| 180 | 180 | } | |
| 181 | 181 | ||
| 182 | - | // Mount mounts a filesystem | |
| 183 | - | func Mount(logPath, device, mountPoint string, options ...string) error { | |
| 184 | - | args := []string{device, mountPoint} | |
| 185 | - | if len(options) > 0 { | |
| 186 | - | args = append([]string{"-o", strings.Join(options, ",")}, args...) | |
| 187 | - | } | |
| 188 | - | ||
| 189 | - | result := RunLogged(logPath, "mount", args...) | |
| 190 | - | if result.Error != nil { | |
| 191 | - | return fmt.Errorf("failed to mount %s: %w", device, result.Error) | |
| 192 | - | } | |
| 193 | - | return nil | |
| 194 | - | } | |
| 195 | - | ||
| 196 | 182 | // Unmount unmounts a filesystem | |
| 197 | 183 | func Unmount(logPath, mountPoint string) error { | |
| 198 | 184 | result := RunLogged(logPath, "umount", "-R", mountPoint) | |
| @@ -202,18 +188,6 @@ func Unmount(logPath, mountPoint string) error { | |||
| 202 | 188 | return nil | |
| 203 | 189 | } | |
| 204 | 190 | ||
| 205 | - | // Genfstab generates fstab for the installed system | |
| 206 | - | func Genfstab(logPath, mountPoint, fstabPath string) error { | |
| 207 | - | result := RunLogged(logPath, "genfstab", "-U", mountPoint) | |
| 208 | - | if result.Error != nil { | |
| 209 | - | return fmt.Errorf("failed to generate fstab: %w", result.Error) | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | // TODO: Write result.Output to fstabPath | |
| 213 | - | // This should be done using file operations | |
| 214 | - | return nil | |
| 215 | - | } | |
| 216 | - | ||
| 217 | 191 | // DefaultChrootSession represents a persistent chroot session | |
| 218 | 192 | type DefaultChrootSession struct { | |
| 219 | 193 | cmd *exec.Cmd | |
| @@ -179,20 +179,6 @@ func ChrootSystemctl(logPath, mountPoint, action, service string) error { | |||||
| 179 | return nil | 179 | return nil | ||
| 180 | } | 180 | } | ||
| 181 | 181 | ||||
| 182 | - | // Mount mounts a filesystem | |||
| 183 | - | func Mount(logPath, device, mountPoint string, options ...string) error { | |||
| 184 | - | args := []string{device, mountPoint} | |||
| 185 | - | if len(options) > 0 { | |||
| 186 | - | args = append([]string{"-o", strings.Join(options, ",")}, args...) | |||
| 187 | - | } | |||
| 188 | - | ||||
| 189 | - | result := RunLogged(logPath, "mount", args...) | |||
| 190 | - | if result.Error != nil { | |||
| 191 | - | return fmt.Errorf("failed to mount %s: %w", device, result.Error) | |||
| 192 | - | } | |||
| 193 | - | return nil | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | // Unmount unmounts a filesystem | 182 | // Unmount unmounts a filesystem | ||
| 197 | func Unmount(logPath, mountPoint string) error { | 183 | func Unmount(logPath, mountPoint string) error { | ||
| 198 | result := RunLogged(logPath, "umount", "-R", mountPoint) | 184 | result := RunLogged(logPath, "umount", "-R", mountPoint) | ||
| @@ -202,18 +188,6 @@ func Unmount(logPath, mountPoint string) error { | |||||
| 202 | return nil | 188 | return nil | ||
| 203 | } | 189 | } | ||
| 204 | 190 | ||||
| 205 | - | // Genfstab generates fstab for the installed system | |||
| 206 | - | func Genfstab(logPath, mountPoint, fstabPath string) error { | |||
| 207 | - | result := RunLogged(logPath, "genfstab", "-U", mountPoint) | |||
| 208 | - | if result.Error != nil { | |||
| 209 | - | return fmt.Errorf("failed to generate fstab: %w", result.Error) | |||
| 210 | - | } | |||
| 211 | - | ||||
| 212 | - | // TODO: Write result.Output to fstabPath | |||
| 213 | - | // This should be done using file operations | |||
| 214 | - | return nil | |||
| 215 | - | } | |||
| 216 | - | ||||
| 217 | // DefaultChrootSession represents a persistent chroot session | 191 | // DefaultChrootSession represents a persistent chroot session | ||
| 218 | type DefaultChrootSession struct { | 192 | type DefaultChrootSession struct { | ||
| 219 | cmd *exec.Cmd | 193 | cmd *exec.Cmd | ||
M internal/system/disk.go +6 -85
| @@ -3,7 +3,6 @@ package system | |||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/json" | |
| 5 | 5 | "fmt" | |
| 6 | - | "strings" | |
| 7 | 6 | ) | |
| 8 | 7 | ||
| 9 | 8 | // Disk represents a storage device | |
| @@ -17,28 +16,15 @@ type Disk struct { | |||
| 17 | 16 | Path string `json:"path"` | |
| 18 | 17 | } | |
| 19 | 18 | ||
| 20 | - | // Partition represents a disk partition | |
| 21 | - | type Partition struct { | |
| 22 | - | Name string `json:"name"` | |
| 23 | - | Size string `json:"size"` | |
| 24 | - | Type string `json:"type"` | |
| 25 | - | Mountpoint string `json:"mountpoint"` | |
| 26 | - | UUID string `json:"uuid"` | |
| 27 | - | Label string `json:"label"` | |
| 28 | - | } | |
| 29 | - | ||
| 30 | 19 | // lsblkOutput represents the JSON output from lsblk | |
| 31 | 20 | type lsblkOutput struct { | |
| 32 | 21 | BlockDevices []struct { | |
| 33 | - | Name string `json:"name"` | |
| 34 | - | Size string `json:"size"` | |
| 35 | - | Type string `json:"type"` | |
| 36 | - | Model string `json:"model"` | |
| 37 | - | Serial string `json:"serial"` | |
| 38 | - | Vendor string `json:"vendor"` | |
| 39 | - | Mountpoint string `json:"mountpoint"` | |
| 40 | - | UUID string `json:"uuid"` | |
| 41 | - | Label string `json:"label"` | |
| 22 | + | Name string `json:"name"` | |
| 23 | + | Size string `json:"size"` | |
| 24 | + | Type string `json:"type"` | |
| 25 | + | Model string `json:"model"` | |
| 26 | + | Serial string `json:"serial"` | |
| 27 | + | Vendor string `json:"vendor"` | |
| 42 | 28 | } `json:"blockdevices"` | |
| 43 | 29 | } | |
| 44 | 30 | ||
| @@ -71,68 +57,3 @@ func ListDisks() ([]Disk, error) { | |||
| 71 | 57 | ||
| 72 | 58 | return disks, nil | |
| 73 | 59 | } | |
| 74 | - | ||
| 75 | - | // ListPartitions returns a list of partitions for a given disk | |
| 76 | - | func ListPartitions(diskPath string) ([]Partition, error) { | |
| 77 | - | diskName := strings.TrimPrefix(diskPath, "/dev/") | |
| 78 | - | ||
| 79 | - | result := RunSimple("lsblk", "-J", "-o", "NAME,SIZE,TYPE,MOUNTPOINT,UUID,LABEL", diskName) | |
| 80 | - | if result.Error != nil { | |
| 81 | - | return nil, fmt.Errorf("failed to list partitions: %w", result.Error) | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | var output lsblkOutput | |
| 85 | - | if err := json.Unmarshal([]byte(result.Output), &output); err != nil { | |
| 86 | - | return nil, fmt.Errorf("failed to parse lsblk output: %w", err) | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | var partitions []Partition | |
| 90 | - | for _, dev := range output.BlockDevices { | |
| 91 | - | if dev.Type == "part" { | |
| 92 | - | partitions = append(partitions, Partition{ | |
| 93 | - | Name: dev.Name, | |
| 94 | - | Size: dev.Size, | |
| 95 | - | Type: dev.Type, | |
| 96 | - | Mountpoint: dev.Mountpoint, | |
| 97 | - | UUID: dev.UUID, | |
| 98 | - | Label: dev.Label, | |
| 99 | - | }) | |
| 100 | - | } | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | return partitions, nil | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | // IsDiskMounted checks if any partition on a disk is mounted | |
| 107 | - | func IsDiskMounted(diskPath string) (bool, error) { | |
| 108 | - | partitions, err := ListPartitions(diskPath) | |
| 109 | - | if err != nil { | |
| 110 | - | return false, err | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | for _, part := range partitions { | |
| 114 | - | if part.Mountpoint != "" { | |
| 115 | - | return true, nil | |
| 116 | - | } | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | return false, nil | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | // WipeDisk wipes the partition table of a disk | |
| 123 | - | func WipeDisk(diskPath string) error { | |
| 124 | - | result := RunSimple("wipefs", "-a", diskPath) | |
| 125 | - | if result.Error != nil { | |
| 126 | - | return fmt.Errorf("failed to wipe disk: %w", result.Error) | |
| 127 | - | } | |
| 128 | - | return nil | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | // CreateGPTPartitionTable creates a new GPT partition table | |
| 132 | - | func CreateGPTPartitionTable(diskPath string) error { | |
| 133 | - | result := RunSimple("parted", "-s", diskPath, "mklabel", "gpt") | |
| 134 | - | if result.Error != nil { | |
| 135 | - | return fmt.Errorf("failed to create GPT partition table: %w", result.Error) | |
| 136 | - | } | |
| 137 | - | return nil | |
| 138 | - | } | |
| @@ -3,7 +3,6 @@ package system | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "encoding/json" | 4 | "encoding/json" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | - | "strings" | |||
| 7 | ) | 6 | ) | ||
| 8 | 7 | ||||
| 9 | // Disk represents a storage device | 8 | // Disk represents a storage device | ||
| @@ -17,28 +16,15 @@ type Disk struct { | |||||
| 17 | Path string `json:"path"` | 16 | Path string `json:"path"` | ||
| 18 | } | 17 | } | ||
| 19 | 18 | ||||
| 20 | - | // Partition represents a disk partition | |||
| 21 | - | type Partition struct { | |||
| 22 | - | Name string `json:"name"` | |||
| 23 | - | Size string `json:"size"` | |||
| 24 | - | Type string `json:"type"` | |||
| 25 | - | Mountpoint string `json:"mountpoint"` | |||
| 26 | - | UUID string `json:"uuid"` | |||
| 27 | - | Label string `json:"label"` | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | // lsblkOutput represents the JSON output from lsblk | 19 | // lsblkOutput represents the JSON output from lsblk | ||
| 31 | type lsblkOutput struct { | 20 | type lsblkOutput struct { | ||
| 32 | BlockDevices []struct { | 21 | BlockDevices []struct { | ||
| 33 | - | Name string `json:"name"` | 22 | + | Name string `json:"name"` |
| 34 | - | Size string `json:"size"` | 23 | + | Size string `json:"size"` |
| 35 | - | Type string `json:"type"` | 24 | + | Type string `json:"type"` |
| 36 | - | Model string `json:"model"` | 25 | + | Model string `json:"model"` |
| 37 | - | Serial string `json:"serial"` | 26 | + | Serial string `json:"serial"` |
| 38 | - | Vendor string `json:"vendor"` | 27 | + | Vendor string `json:"vendor"` |
| 39 | - | Mountpoint string `json:"mountpoint"` | |||
| 40 | - | UUID string `json:"uuid"` | |||
| 41 | - | Label string `json:"label"` | |||
| 42 | } `json:"blockdevices"` | 28 | } `json:"blockdevices"` | ||
| 43 | } | 29 | } | ||
| 44 | 30 | ||||
| @@ -71,68 +57,3 @@ func ListDisks() ([]Disk, error) { | |||||
| 71 | 57 | ||||
| 72 | return disks, nil | 58 | return disks, nil | ||
| 73 | } | 59 | } | ||
| 74 | - | ||||
| 75 | - | // ListPartitions returns a list of partitions for a given disk | |||
| 76 | - | func ListPartitions(diskPath string) ([]Partition, error) { | |||
| 77 | - | diskName := strings.TrimPrefix(diskPath, "/dev/") | |||
| 78 | - | ||||
| 79 | - | result := RunSimple("lsblk", "-J", "-o", "NAME,SIZE,TYPE,MOUNTPOINT,UUID,LABEL", diskName) | |||
| 80 | - | if result.Error != nil { | |||
| 81 | - | return nil, fmt.Errorf("failed to list partitions: %w", result.Error) | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | - | var output lsblkOutput | |||
| 85 | - | if err := json.Unmarshal([]byte(result.Output), &output); err != nil { | |||
| 86 | - | return nil, fmt.Errorf("failed to parse lsblk output: %w", err) | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | var partitions []Partition | |||
| 90 | - | for _, dev := range output.BlockDevices { | |||
| 91 | - | if dev.Type == "part" { | |||
| 92 | - | partitions = append(partitions, Partition{ | |||
| 93 | - | Name: dev.Name, | |||
| 94 | - | Size: dev.Size, | |||
| 95 | - | Type: dev.Type, | |||
| 96 | - | Mountpoint: dev.Mountpoint, | |||
| 97 | - | UUID: dev.UUID, | |||
| 98 | - | Label: dev.Label, | |||
| 99 | - | }) | |||
| 100 | - | } | |||
| 101 | - | } | |||
| 102 | - | ||||
| 103 | - | return partitions, nil | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | - | // IsDiskMounted checks if any partition on a disk is mounted | |||
| 107 | - | func IsDiskMounted(diskPath string) (bool, error) { | |||
| 108 | - | partitions, err := ListPartitions(diskPath) | |||
| 109 | - | if err != nil { | |||
| 110 | - | return false, err | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | - | for _, part := range partitions { | |||
| 114 | - | if part.Mountpoint != "" { | |||
| 115 | - | return true, nil | |||
| 116 | - | } | |||
| 117 | - | } | |||
| 118 | - | ||||
| 119 | - | return false, nil | |||
| 120 | - | } | |||
| 121 | - | ||||
| 122 | - | // WipeDisk wipes the partition table of a disk | |||
| 123 | - | func WipeDisk(diskPath string) error { | |||
| 124 | - | result := RunSimple("wipefs", "-a", diskPath) | |||
| 125 | - | if result.Error != nil { | |||
| 126 | - | return fmt.Errorf("failed to wipe disk: %w", result.Error) | |||
| 127 | - | } | |||
| 128 | - | return nil | |||
| 129 | - | } | |||
| 130 | - | ||||
| 131 | - | // CreateGPTPartitionTable creates a new GPT partition table | |||
| 132 | - | func CreateGPTPartitionTable(diskPath string) error { | |||
| 133 | - | result := RunSimple("parted", "-s", diskPath, "mklabel", "gpt") | |||
| 134 | - | if result.Error != nil { | |||
| 135 | - | return fmt.Errorf("failed to create GPT partition table: %w", result.Error) | |||
| 136 | - | } | |||
| 137 | - | return nil | |||
| 138 | - | } | |||
M internal/system/exec.go +0 -46
| @@ -4,7 +4,6 @@ package system | |||
| 4 | 4 | import ( | |
| 5 | 5 | "bufio" | |
| 6 | 6 | "fmt" | |
| 7 | - | "io" | |
| 8 | 7 | "os" | |
| 9 | 8 | "os/exec" | |
| 10 | 9 | "strings" | |
| @@ -157,48 +156,3 @@ func RunLogged(logPath, command string, args ...string) CommandResult { | |||
| 157 | 156 | LogPath: logPath, | |
| 158 | 157 | }) | |
| 159 | 158 | } | |
| 160 | - | ||
| 161 | - | // RunWithOutput runs a command and captures output in real-time via a callback | |
| 162 | - | func RunWithOutput(command string, args []string, callback func(line string)) error { | |
| 163 | - | cmd := exec.Command(command, args...) | |
| 164 | - | ||
| 165 | - | stdout, err := cmd.StdoutPipe() | |
| 166 | - | if err != nil { | |
| 167 | - | return fmt.Errorf("failed to create stdout pipe: %w", err) | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | stderr, err := cmd.StderrPipe() | |
| 171 | - | if err != nil { | |
| 172 | - | return fmt.Errorf("failed to create stderr pipe: %w", err) | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | if err := cmd.Start(); err != nil { | |
| 176 | - | return fmt.Errorf("failed to start command: %w", err) | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | var wg sync.WaitGroup | |
| 180 | - | ||
| 181 | - | // Process stdout | |
| 182 | - | wg.Add(1) | |
| 183 | - | go func() { | |
| 184 | - | defer wg.Done() | |
| 185 | - | processOutput(stdout, callback) | |
| 186 | - | }() | |
| 187 | - | ||
| 188 | - | // Process stderr | |
| 189 | - | wg.Add(1) | |
| 190 | - | go func() { | |
| 191 | - | defer wg.Done() | |
| 192 | - | processOutput(stderr, callback) | |
| 193 | - | }() | |
| 194 | - | ||
| 195 | - | wg.Wait() | |
| 196 | - | return cmd.Wait() | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | func processOutput(reader io.Reader, callback func(string)) { | |
| 200 | - | scanner := bufio.NewScanner(reader) | |
| 201 | - | for scanner.Scan() { | |
| 202 | - | callback(scanner.Text()) | |
| 203 | - | } | |
| 204 | - | } | |
| @@ -4,7 +4,6 @@ package system | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "bufio" | 5 | "bufio" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | - | "io" | |||
| 8 | "os" | 7 | "os" | ||
| 9 | "os/exec" | 8 | "os/exec" | ||
| 10 | "strings" | 9 | "strings" | ||
| @@ -157,48 +156,3 @@ func RunLogged(logPath, command string, args ...string) CommandResult { | |||||
| 157 | LogPath: logPath, | 156 | LogPath: logPath, | ||
| 158 | }) | 157 | }) | ||
| 159 | } | 158 | } | ||
| 160 | - | ||||
| 161 | - | // RunWithOutput runs a command and captures output in real-time via a callback | |||
| 162 | - | func RunWithOutput(command string, args []string, callback func(line string)) error { | |||
| 163 | - | cmd := exec.Command(command, args...) | |||
| 164 | - | ||||
| 165 | - | stdout, err := cmd.StdoutPipe() | |||
| 166 | - | if err != nil { | |||
| 167 | - | return fmt.Errorf("failed to create stdout pipe: %w", err) | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | stderr, err := cmd.StderrPipe() | |||
| 171 | - | if err != nil { | |||
| 172 | - | return fmt.Errorf("failed to create stderr pipe: %w", err) | |||
| 173 | - | } | |||
| 174 | - | ||||
| 175 | - | if err := cmd.Start(); err != nil { | |||
| 176 | - | return fmt.Errorf("failed to start command: %w", err) | |||
| 177 | - | } | |||
| 178 | - | ||||
| 179 | - | var wg sync.WaitGroup | |||
| 180 | - | ||||
| 181 | - | // Process stdout | |||
| 182 | - | wg.Add(1) | |||
| 183 | - | go func() { | |||
| 184 | - | defer wg.Done() | |||
| 185 | - | processOutput(stdout, callback) | |||
| 186 | - | }() | |||
| 187 | - | ||||
| 188 | - | // Process stderr | |||
| 189 | - | wg.Add(1) | |||
| 190 | - | go func() { | |||
| 191 | - | defer wg.Done() | |||
| 192 | - | processOutput(stderr, callback) | |||
| 193 | - | }() | |||
| 194 | - | ||||
| 195 | - | wg.Wait() | |||
| 196 | - | return cmd.Wait() | |||
| 197 | - | } | |||
| 198 | - | ||||
| 199 | - | func processOutput(reader io.Reader, callback func(string)) { | |||
| 200 | - | scanner := bufio.NewScanner(reader) | |||
| 201 | - | for scanner.Scan() { | |||
| 202 | - | callback(scanner.Text()) | |||
| 203 | - | } | |||
| 204 | - | } | |||
D internal/system/mirrorlist.go +0 -146
| @@ -1,146 +0,0 @@ | |||
| 1 | - | package system | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bufio" | |
| 5 | - | "fmt" | |
| 6 | - | "io" | |
| 7 | - | "net/http" | |
| 8 | - | "strings" | |
| 9 | - | "time" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | const ( | |
| 13 | - | ChaoticMirrorlistURL = "https://gitlab.com/chaotic-aur/pkgbuilds/-/raw/main/chaotic-mirrorlist/mirrorlist" | |
| 14 | - | HTTPTimeout = 10 * time.Second | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | // MirrorlistEntry represents a parsed mirror entry | |
| 18 | - | type MirrorlistEntry struct { | |
| 19 | - | URL string | |
| 20 | - | Enabled bool | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | // FetchChaoticMirrorlist fetches the official Chaotic-AUR mirrorlist from GitLab | |
| 24 | - | // Returns a list of mirrors with their enabled/disabled status | |
| 25 | - | func FetchChaoticMirrorlist() ([]MirrorlistEntry, error) { | |
| 26 | - | client := &http.Client{Timeout: HTTPTimeout} | |
| 27 | - | ||
| 28 | - | // Fetch mirrorlist from GitLab | |
| 29 | - | resp, err := client.Get(ChaoticMirrorlistURL) | |
| 30 | - | if err != nil { | |
| 31 | - | return nil, fmt.Errorf("network request failed to %s (timeout: %v): %w", ChaoticMirrorlistURL, HTTPTimeout, err) | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | // Check HTTP response status | |
| 35 | - | if resp.StatusCode != http.StatusOK { | |
| 36 | - | if closeErr := resp.Body.Close(); closeErr != nil { | |
| 37 | - | return nil, fmt.Errorf("failed to close mirrorlist response: %w", closeErr) | |
| 38 | - | } | |
| 39 | - | return nil, fmt.Errorf("failed to fetch mirrorlist from %s: HTTP %d %s", ChaoticMirrorlistURL, resp.StatusCode, http.StatusText(resp.StatusCode)) | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | // Parse the mirrorlist content | |
| 43 | - | mirrors, err := ParseMirrorlist(resp.Body) | |
| 44 | - | if err != nil { | |
| 45 | - | if closeErr := resp.Body.Close(); closeErr != nil { | |
| 46 | - | return nil, fmt.Errorf("failed to close mirrorlist response: %w", closeErr) | |
| 47 | - | } | |
| 48 | - | return nil, fmt.Errorf("failed to parse mirrorlist from %s: %w", ChaoticMirrorlistURL, err) | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | if err := resp.Body.Close(); err != nil { | |
| 52 | - | return nil, fmt.Errorf("failed to close mirrorlist response: %w", err) | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | return mirrors, nil | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | // ParseMirrorlist parses a pacman mirrorlist format | |
| 59 | - | // Handles lines like: | |
| 60 | - | // | |
| 61 | - | // Server = https://mirror.example.com/$repo/$arch | |
| 62 | - | // #Server = https://disabled.mirror.com/$repo/$arch | |
| 63 | - | func ParseMirrorlist(r io.Reader) ([]MirrorlistEntry, error) { | |
| 64 | - | var mirrors []MirrorlistEntry | |
| 65 | - | scanner := bufio.NewScanner(r) | |
| 66 | - | ||
| 67 | - | for scanner.Scan() { | |
| 68 | - | line := strings.TrimSpace(scanner.Text()) | |
| 69 | - | ||
| 70 | - | // Skip empty lines and non-Server lines | |
| 71 | - | if line == "" || (!strings.HasPrefix(line, "Server") && !strings.HasPrefix(line, "#Server")) { | |
| 72 | - | continue | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | enabled := !strings.HasPrefix(line, "#") | |
| 76 | - | ||
| 77 | - | // Remove comment prefix if present | |
| 78 | - | if !enabled { | |
| 79 | - | line = strings.TrimPrefix(line, "#") | |
| 80 | - | line = strings.TrimSpace(line) | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | // Parse "Server = URL" format | |
| 84 | - | parts := strings.SplitN(line, "=", 2) | |
| 85 | - | if len(parts) != 2 { | |
| 86 | - | continue | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | url := strings.TrimSpace(parts[1]) | |
| 90 | - | if url == "" { | |
| 91 | - | continue | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | mirrors = append(mirrors, MirrorlistEntry{ | |
| 95 | - | URL: url, | |
| 96 | - | Enabled: enabled, | |
| 97 | - | }) | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | if err := scanner.Err(); err != nil { | |
| 101 | - | return nil, fmt.Errorf("error parsing mirrorlist: %w", err) | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | if len(mirrors) == 0 { | |
| 105 | - | return nil, fmt.Errorf("no mirrors found in mirrorlist") | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | return mirrors, nil | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | // BuildPackageURLs converts mirror templates to actual package URLs | |
| 112 | - | // Replaces $repo and $arch with actual values, appends package filename | |
| 113 | - | func BuildPackageURLs(mirrors []MirrorlistEntry, packageName string, preferEnabled bool) []string { | |
| 114 | - | const ( | |
| 115 | - | repo = "chaotic-aur" | |
| 116 | - | arch = "x86_64" | |
| 117 | - | ) | |
| 118 | - | ||
| 119 | - | var urls []string | |
| 120 | - | ||
| 121 | - | // First pass: enabled mirrors (if requested) | |
| 122 | - | if preferEnabled { | |
| 123 | - | for _, mirror := range mirrors { | |
| 124 | - | if !mirror.Enabled { | |
| 125 | - | continue | |
| 126 | - | } | |
| 127 | - | url := strings.ReplaceAll(mirror.URL, "$repo", repo) | |
| 128 | - | url = strings.ReplaceAll(url, "$arch", arch) | |
| 129 | - | url = strings.TrimSuffix(url, "/") + "/" + packageName | |
| 130 | - | urls = append(urls, url) | |
| 131 | - | } | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | // Second pass: all mirrors (fallback) | |
| 135 | - | for _, mirror := range mirrors { | |
| 136 | - | if preferEnabled && mirror.Enabled { | |
| 137 | - | continue // Already added in first pass | |
| 138 | - | } | |
| 139 | - | url := strings.ReplaceAll(mirror.URL, "$repo", repo) | |
| 140 | - | url = strings.ReplaceAll(url, "$arch", arch) | |
| 141 | - | url = strings.TrimSuffix(url, "/") + "/" + packageName | |
| 142 | - | urls = append(urls, url) | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | return urls | |
| 146 | - | } | |
| @@ -1,146 +0,0 @@ | |||||
| 1 | - | package system | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bufio" | |||
| 5 | - | "fmt" | |||
| 6 | - | "io" | |||
| 7 | - | "net/http" | |||
| 8 | - | "strings" | |||
| 9 | - | "time" | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | const ( | |||
| 13 | - | ChaoticMirrorlistURL = "https://gitlab.com/chaotic-aur/pkgbuilds/-/raw/main/chaotic-mirrorlist/mirrorlist" | |||
| 14 | - | HTTPTimeout = 10 * time.Second | |||
| 15 | - | ) | |||
| 16 | - | ||||
| 17 | - | // MirrorlistEntry represents a parsed mirror entry | |||
| 18 | - | type MirrorlistEntry struct { | |||
| 19 | - | URL string | |||
| 20 | - | Enabled bool | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | // FetchChaoticMirrorlist fetches the official Chaotic-AUR mirrorlist from GitLab | |||
| 24 | - | // Returns a list of mirrors with their enabled/disabled status | |||
| 25 | - | func FetchChaoticMirrorlist() ([]MirrorlistEntry, error) { | |||
| 26 | - | client := &http.Client{Timeout: HTTPTimeout} | |||
| 27 | - | ||||
| 28 | - | // Fetch mirrorlist from GitLab | |||
| 29 | - | resp, err := client.Get(ChaoticMirrorlistURL) | |||
| 30 | - | if err != nil { | |||
| 31 | - | return nil, fmt.Errorf("network request failed to %s (timeout: %v): %w", ChaoticMirrorlistURL, HTTPTimeout, err) | |||
| 32 | - | } | |||
| 33 | - | ||||
| 34 | - | // Check HTTP response status | |||
| 35 | - | if resp.StatusCode != http.StatusOK { | |||
| 36 | - | if closeErr := resp.Body.Close(); closeErr != nil { | |||
| 37 | - | return nil, fmt.Errorf("failed to close mirrorlist response: %w", closeErr) | |||
| 38 | - | } | |||
| 39 | - | return nil, fmt.Errorf("failed to fetch mirrorlist from %s: HTTP %d %s", ChaoticMirrorlistURL, resp.StatusCode, http.StatusText(resp.StatusCode)) | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | // Parse the mirrorlist content | |||
| 43 | - | mirrors, err := ParseMirrorlist(resp.Body) | |||
| 44 | - | if err != nil { | |||
| 45 | - | if closeErr := resp.Body.Close(); closeErr != nil { | |||
| 46 | - | return nil, fmt.Errorf("failed to close mirrorlist response: %w", closeErr) | |||
| 47 | - | } | |||
| 48 | - | return nil, fmt.Errorf("failed to parse mirrorlist from %s: %w", ChaoticMirrorlistURL, err) | |||
| 49 | - | } | |||
| 50 | - | ||||
| 51 | - | if err := resp.Body.Close(); err != nil { | |||
| 52 | - | return nil, fmt.Errorf("failed to close mirrorlist response: %w", err) | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | return mirrors, nil | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | // ParseMirrorlist parses a pacman mirrorlist format | |||
| 59 | - | // Handles lines like: | |||
| 60 | - | // | |||
| 61 | - | // Server = https://mirror.example.com/$repo/$arch | |||
| 62 | - | // #Server = https://disabled.mirror.com/$repo/$arch | |||
| 63 | - | func ParseMirrorlist(r io.Reader) ([]MirrorlistEntry, error) { | |||
| 64 | - | var mirrors []MirrorlistEntry | |||
| 65 | - | scanner := bufio.NewScanner(r) | |||
| 66 | - | ||||
| 67 | - | for scanner.Scan() { | |||
| 68 | - | line := strings.TrimSpace(scanner.Text()) | |||
| 69 | - | ||||
| 70 | - | // Skip empty lines and non-Server lines | |||
| 71 | - | if line == "" || (!strings.HasPrefix(line, "Server") && !strings.HasPrefix(line, "#Server")) { | |||
| 72 | - | continue | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | enabled := !strings.HasPrefix(line, "#") | |||
| 76 | - | ||||
| 77 | - | // Remove comment prefix if present | |||
| 78 | - | if !enabled { | |||
| 79 | - | line = strings.TrimPrefix(line, "#") | |||
| 80 | - | line = strings.TrimSpace(line) | |||
| 81 | - | } | |||
| 82 | - | ||||
| 83 | - | // Parse "Server = URL" format | |||
| 84 | - | parts := strings.SplitN(line, "=", 2) | |||
| 85 | - | if len(parts) != 2 { | |||
| 86 | - | continue | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | url := strings.TrimSpace(parts[1]) | |||
| 90 | - | if url == "" { | |||
| 91 | - | continue | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | mirrors = append(mirrors, MirrorlistEntry{ | |||
| 95 | - | URL: url, | |||
| 96 | - | Enabled: enabled, | |||
| 97 | - | }) | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | if err := scanner.Err(); err != nil { | |||
| 101 | - | return nil, fmt.Errorf("error parsing mirrorlist: %w", err) | |||
| 102 | - | } | |||
| 103 | - | ||||
| 104 | - | if len(mirrors) == 0 { | |||
| 105 | - | return nil, fmt.Errorf("no mirrors found in mirrorlist") | |||
| 106 | - | } | |||
| 107 | - | ||||
| 108 | - | return mirrors, nil | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | // BuildPackageURLs converts mirror templates to actual package URLs | |||
| 112 | - | // Replaces $repo and $arch with actual values, appends package filename | |||
| 113 | - | func BuildPackageURLs(mirrors []MirrorlistEntry, packageName string, preferEnabled bool) []string { | |||
| 114 | - | const ( | |||
| 115 | - | repo = "chaotic-aur" | |||
| 116 | - | arch = "x86_64" | |||
| 117 | - | ) | |||
| 118 | - | ||||
| 119 | - | var urls []string | |||
| 120 | - | ||||
| 121 | - | // First pass: enabled mirrors (if requested) | |||
| 122 | - | if preferEnabled { | |||
| 123 | - | for _, mirror := range mirrors { | |||
| 124 | - | if !mirror.Enabled { | |||
| 125 | - | continue | |||
| 126 | - | } | |||
| 127 | - | url := strings.ReplaceAll(mirror.URL, "$repo", repo) | |||
| 128 | - | url = strings.ReplaceAll(url, "$arch", arch) | |||
| 129 | - | url = strings.TrimSuffix(url, "/") + "/" + packageName | |||
| 130 | - | urls = append(urls, url) | |||
| 131 | - | } | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | - | // Second pass: all mirrors (fallback) | |||
| 135 | - | for _, mirror := range mirrors { | |||
| 136 | - | if preferEnabled && mirror.Enabled { | |||
| 137 | - | continue // Already added in first pass | |||
| 138 | - | } | |||
| 139 | - | url := strings.ReplaceAll(mirror.URL, "$repo", repo) | |||
| 140 | - | url = strings.ReplaceAll(url, "$arch", arch) | |||
| 141 | - | url = strings.TrimSuffix(url, "/") + "/" + packageName | |||
| 142 | - | urls = append(urls, url) | |||
| 143 | - | } | |||
| 144 | - | ||||
| 145 | - | return urls | |||
| 146 | - | } | |||
D internal/system/mirrorlist_test.go +0 -287
| @@ -1,287 +0,0 @@ | |||
| 1 | - | package system | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "strings" | |
| 5 | - | "testing" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | func TestParseMirrorlist(t *testing.T) { | |
| 9 | - | tests := []struct { | |
| 10 | - | name string | |
| 11 | - | input string | |
| 12 | - | wantCount int | |
| 13 | - | wantErr bool | |
| 14 | - | wantFirst MirrorlistEntry | |
| 15 | - | }{ | |
| 16 | - | { | |
| 17 | - | name: "Standard mirrorlist with enabled and disabled mirrors", | |
| 18 | - | input: `# Chaotic-AUR mirrorlist | |
| 19 | - | Server = https://cdn-mirror.chaotic.cx/$repo/$arch | |
| 20 | - | #Server = https://backup.mirror.com/$repo/$arch | |
| 21 | - | Server = https://another.mirror.com/$repo/$arch`, | |
| 22 | - | wantCount: 3, | |
| 23 | - | wantErr: false, | |
| 24 | - | wantFirst: MirrorlistEntry{URL: "https://cdn-mirror.chaotic.cx/$repo/$arch", Enabled: true}, | |
| 25 | - | }, | |
| 26 | - | { | |
| 27 | - | name: "Empty mirrorlist", | |
| 28 | - | input: `# Just comments | |
| 29 | - | # No servers here`, | |
| 30 | - | wantCount: 0, | |
| 31 | - | wantErr: true, | |
| 32 | - | }, | |
| 33 | - | { | |
| 34 | - | name: "Mirrorlist with empty lines and whitespace", | |
| 35 | - | input: `# Header | |
| 36 | - | Server = https://mirror1.com/$repo/$arch | |
| 37 | - | ||
| 38 | - | Server = https://mirror2.com/$repo/$arch | |
| 39 | - | ||
| 40 | - | #Server = https://mirror3.com/$repo/$arch`, | |
| 41 | - | wantCount: 3, | |
| 42 | - | wantErr: false, | |
| 43 | - | wantFirst: MirrorlistEntry{URL: "https://mirror1.com/$repo/$arch", Enabled: true}, | |
| 44 | - | }, | |
| 45 | - | { | |
| 46 | - | name: "All disabled mirrors", | |
| 47 | - | input: `#Server = https://disabled1.com/$repo/$arch | |
| 48 | - | #Server = https://disabled2.com/$repo/$arch`, | |
| 49 | - | wantCount: 2, | |
| 50 | - | wantErr: false, | |
| 51 | - | wantFirst: MirrorlistEntry{URL: "https://disabled1.com/$repo/$arch", Enabled: false}, | |
| 52 | - | }, | |
| 53 | - | { | |
| 54 | - | name: "Malformed Server lines (should be skipped)", | |
| 55 | - | input: `Server https://no-equals.com/$repo/$arch | |
| 56 | - | Server = | |
| 57 | - | = https://no-server.com/$repo/$arch | |
| 58 | - | Server = https://valid.com/$repo/$arch`, | |
| 59 | - | wantCount: 1, | |
| 60 | - | wantErr: false, | |
| 61 | - | wantFirst: MirrorlistEntry{URL: "https://valid.com/$repo/$arch", Enabled: true}, | |
| 62 | - | }, | |
| 63 | - | { | |
| 64 | - | name: "Mirrors with various whitespace", | |
| 65 | - | input: `Server = https://spaces.com/$repo/$arch | |
| 66 | - | Server=https://nospace.com/$repo/$arch | |
| 67 | - | Server = https://tabs.com/$repo/$arch `, | |
| 68 | - | wantCount: 3, | |
| 69 | - | wantErr: false, | |
| 70 | - | wantFirst: MirrorlistEntry{URL: "https://spaces.com/$repo/$arch", Enabled: true}, | |
| 71 | - | }, | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | for _, tt := range tests { | |
| 75 | - | t.Run(tt.name, func(t *testing.T) { | |
| 76 | - | mirrors, err := ParseMirrorlist(strings.NewReader(tt.input)) | |
| 77 | - | ||
| 78 | - | if (err != nil) != tt.wantErr { | |
| 79 | - | t.Errorf("ParseMirrorlist() error = %v, wantErr %v", err, tt.wantErr) | |
| 80 | - | return | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | if !tt.wantErr && len(mirrors) != tt.wantCount { | |
| 84 | - | t.Errorf("ParseMirrorlist() got %d mirrors, want %d", len(mirrors), tt.wantCount) | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | if !tt.wantErr && len(mirrors) > 0 { | |
| 88 | - | if mirrors[0] != tt.wantFirst { | |
| 89 | - | t.Errorf("ParseMirrorlist() first mirror = %+v, want %+v", mirrors[0], tt.wantFirst) | |
| 90 | - | } | |
| 91 | - | } | |
| 92 | - | }) | |
| 93 | - | } | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | func TestBuildPackageURLs(t *testing.T) { | |
| 97 | - | tests := []struct { | |
| 98 | - | name string | |
| 99 | - | mirrors []MirrorlistEntry | |
| 100 | - | packageName string | |
| 101 | - | preferEnabled bool | |
| 102 | - | wantCount int | |
| 103 | - | wantURLContains []string | |
| 104 | - | shouldStartWith string | |
| 105 | - | }{ | |
| 106 | - | { | |
| 107 | - | name: "Basic URL building with enabled mirrors preferred", | |
| 108 | - | mirrors: []MirrorlistEntry{ | |
| 109 | - | {URL: "https://mirror1.com/$repo/$arch", Enabled: true}, | |
| 110 | - | {URL: "https://mirror2.com/$repo/$arch", Enabled: false}, | |
| 111 | - | {URL: "https://mirror3.com/$repo/$arch", Enabled: true}, | |
| 112 | - | }, | |
| 113 | - | packageName: "test.pkg.tar.zst", | |
| 114 | - | preferEnabled: true, | |
| 115 | - | wantCount: 3, | |
| 116 | - | wantURLContains: []string{ | |
| 117 | - | "https://mirror1.com/chaotic-aur/x86_64/test.pkg.tar.zst", | |
| 118 | - | "https://mirror3.com/chaotic-aur/x86_64/test.pkg.tar.zst", | |
| 119 | - | "https://mirror2.com/chaotic-aur/x86_64/test.pkg.tar.zst", | |
| 120 | - | }, | |
| 121 | - | }, | |
| 122 | - | { | |
| 123 | - | name: "Enabled mirrors should come first", | |
| 124 | - | mirrors: []MirrorlistEntry{ | |
| 125 | - | {URL: "https://disabled.com/$repo/$arch", Enabled: false}, | |
| 126 | - | {URL: "https://enabled1.com/$repo/$arch", Enabled: true}, | |
| 127 | - | {URL: "https://enabled2.com/$repo/$arch", Enabled: true}, | |
| 128 | - | }, | |
| 129 | - | packageName: "pkg.tar.zst", | |
| 130 | - | preferEnabled: true, | |
| 131 | - | wantCount: 3, | |
| 132 | - | shouldStartWith: "https://enabled1.com/chaotic-aur/x86_64/pkg.tar.zst", | |
| 133 | - | }, | |
| 134 | - | { | |
| 135 | - | name: "All mirrors when preferEnabled is false", | |
| 136 | - | mirrors: []MirrorlistEntry{ | |
| 137 | - | {URL: "https://mirror1.com/$repo/$arch", Enabled: true}, | |
| 138 | - | {URL: "https://mirror2.com/$repo/$arch", Enabled: false}, | |
| 139 | - | }, | |
| 140 | - | packageName: "test.pkg.tar.zst", | |
| 141 | - | preferEnabled: false, | |
| 142 | - | wantCount: 2, | |
| 143 | - | }, | |
| 144 | - | { | |
| 145 | - | name: "Empty mirrors list", | |
| 146 | - | mirrors: []MirrorlistEntry{}, | |
| 147 | - | packageName: "test.pkg.tar.zst", | |
| 148 | - | preferEnabled: true, | |
| 149 | - | wantCount: 0, | |
| 150 | - | }, | |
| 151 | - | { | |
| 152 | - | name: "Template variable substitution", | |
| 153 | - | mirrors: []MirrorlistEntry{ | |
| 154 | - | {URL: "https://example.com/$repo/$arch", Enabled: true}, | |
| 155 | - | }, | |
| 156 | - | packageName: "chaotic-keyring.pkg.tar.zst", | |
| 157 | - | preferEnabled: true, | |
| 158 | - | wantCount: 1, | |
| 159 | - | wantURLContains: []string{ | |
| 160 | - | "https://example.com/chaotic-aur/x86_64/chaotic-keyring.pkg.tar.zst", | |
| 161 | - | }, | |
| 162 | - | }, | |
| 163 | - | { | |
| 164 | - | name: "URL with trailing slash handling", | |
| 165 | - | mirrors: []MirrorlistEntry{ | |
| 166 | - | {URL: "https://example.com/$repo/$arch/", Enabled: true}, | |
| 167 | - | }, | |
| 168 | - | packageName: "test.pkg.tar.zst", | |
| 169 | - | preferEnabled: true, | |
| 170 | - | wantCount: 1, | |
| 171 | - | wantURLContains: []string{ | |
| 172 | - | "https://example.com/chaotic-aur/x86_64/test.pkg.tar.zst", | |
| 173 | - | }, | |
| 174 | - | }, | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | for _, tt := range tests { | |
| 178 | - | t.Run(tt.name, func(t *testing.T) { | |
| 179 | - | urls := BuildPackageURLs(tt.mirrors, tt.packageName, tt.preferEnabled) | |
| 180 | - | ||
| 181 | - | if len(urls) != tt.wantCount { | |
| 182 | - | t.Errorf("BuildPackageURLs() got %d URLs, want %d", len(urls), tt.wantCount) | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | for _, wantURL := range tt.wantURLContains { | |
| 186 | - | found := false | |
| 187 | - | for _, url := range urls { | |
| 188 | - | if url == wantURL { | |
| 189 | - | found = true | |
| 190 | - | break | |
| 191 | - | } | |
| 192 | - | } | |
| 193 | - | if !found { | |
| 194 | - | t.Errorf("BuildPackageURLs() URL %q not found in result", wantURL) | |
| 195 | - | t.Logf("Got URLs: %v", urls) | |
| 196 | - | } | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | if tt.shouldStartWith != "" && len(urls) > 0 { | |
| 200 | - | if urls[0] != tt.shouldStartWith { | |
| 201 | - | t.Errorf("BuildPackageURLs() first URL = %q, want %q", urls[0], tt.shouldStartWith) | |
| 202 | - | } | |
| 203 | - | } | |
| 204 | - | }) | |
| 205 | - | } | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | func TestBuildPackageURLs_EnabledOrderingGuarantee(t *testing.T) { | |
| 209 | - | // Verify that enabled mirrors always come before disabled ones | |
| 210 | - | mirrors := []MirrorlistEntry{ | |
| 211 | - | {URL: "https://d1.com/$repo/$arch", Enabled: false}, | |
| 212 | - | {URL: "https://e1.com/$repo/$arch", Enabled: true}, | |
| 213 | - | {URL: "https://d2.com/$repo/$arch", Enabled: false}, | |
| 214 | - | {URL: "https://e2.com/$repo/$arch", Enabled: true}, | |
| 215 | - | {URL: "https://d3.com/$repo/$arch", Enabled: false}, | |
| 216 | - | } | |
| 217 | - | ||
| 218 | - | urls := BuildPackageURLs(mirrors, "test.pkg.tar.zst", true) | |
| 219 | - | ||
| 220 | - | if len(urls) != 5 { | |
| 221 | - | t.Fatalf("Expected 5 URLs, got %d", len(urls)) | |
| 222 | - | } | |
| 223 | - | ||
| 224 | - | // First two should be from enabled mirrors | |
| 225 | - | if !strings.Contains(urls[0], "e1.com") && !strings.Contains(urls[0], "e2.com") { | |
| 226 | - | t.Errorf("First URL should be from enabled mirror, got %s", urls[0]) | |
| 227 | - | } | |
| 228 | - | if !strings.Contains(urls[1], "e1.com") && !strings.Contains(urls[1], "e2.com") { | |
| 229 | - | t.Errorf("Second URL should be from enabled mirror, got %s", urls[1]) | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | // Last three should be from disabled mirrors | |
| 233 | - | for i := 2; i < 5; i++ { | |
| 234 | - | if !strings.Contains(urls[i], ".d") && !strings.Contains(urls[i], "d1") && | |
| 235 | - | !strings.Contains(urls[i], "d2") && !strings.Contains(urls[i], "d3") { | |
| 236 | - | // Check if it contains any disabled mirror domain | |
| 237 | - | isDisabled := false | |
| 238 | - | for _, m := range mirrors { | |
| 239 | - | if !m.Enabled && strings.Contains(urls[i], strings.Split(m.URL, "/")[2]) { | |
| 240 | - | isDisabled = true | |
| 241 | - | break | |
| 242 | - | } | |
| 243 | - | } | |
| 244 | - | if !isDisabled { | |
| 245 | - | t.Logf("URL at index %d: %s might not be from disabled mirror", i, urls[i]) | |
| 246 | - | } | |
| 247 | - | } | |
| 248 | - | } | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | func TestParseMirrorlist_SpecialCharacters(t *testing.T) { | |
| 252 | - | // Test with various URL formats that might appear in real mirrorlist | |
| 253 | - | input := ` | |
| 254 | - | Server = https://mirror.example.com:8080/$repo/$arch | |
| 255 | - | Server = http://mirror.example.com/$repo/$arch?token=xyz | |
| 256 | - | Server = https://mirror-example.com/$repo/$arch | |
| 257 | - | Server = https://mirror_example.com/$repo/$arch | |
| 258 | - | Server = https://123.456.789.012/$repo/$arch | |
| 259 | - | ` | |
| 260 | - | ||
| 261 | - | mirrors, err := ParseMirrorlist(strings.NewReader(input)) | |
| 262 | - | if err != nil { | |
| 263 | - | t.Fatalf("ParseMirrorlist() error = %v", err) | |
| 264 | - | } | |
| 265 | - | ||
| 266 | - | if len(mirrors) != 5 { | |
| 267 | - | t.Errorf("ParseMirrorlist() got %d mirrors, want 5", len(mirrors)) | |
| 268 | - | } | |
| 269 | - | ||
| 270 | - | // Verify all URLs are preserved correctly | |
| 271 | - | urls := []string{ | |
| 272 | - | "https://mirror.example.com:8080/$repo/$arch", | |
| 273 | - | "http://mirror.example.com/$repo/$arch?token=xyz", | |
| 274 | - | "https://mirror-example.com/$repo/$arch", | |
| 275 | - | "https://mirror_example.com/$repo/$arch", | |
| 276 | - | "https://123.456.789.012/$repo/$arch", | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | for i, wantURL := range urls { | |
| 280 | - | if i >= len(mirrors) { | |
| 281 | - | t.Fatalf("Expected mirror at index %d, but only got %d mirrors", i, len(mirrors)) | |
| 282 | - | } | |
| 283 | - | if mirrors[i].URL != wantURL { | |
| 284 | - | t.Errorf("Mirror %d: got %q, want %q", i, mirrors[i].URL, wantURL) | |
| 285 | - | } | |
| 286 | - | } | |
| 287 | - | } | |
| @@ -1,287 +0,0 @@ | |||||
| 1 | - | package system | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "strings" | |||
| 5 | - | "testing" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | func TestParseMirrorlist(t *testing.T) { | |||
| 9 | - | tests := []struct { | |||
| 10 | - | name string | |||
| 11 | - | input string | |||
| 12 | - | wantCount int | |||
| 13 | - | wantErr bool | |||
| 14 | - | wantFirst MirrorlistEntry | |||
| 15 | - | }{ | |||
| 16 | - | { | |||
| 17 | - | name: "Standard mirrorlist with enabled and disabled mirrors", | |||
| 18 | - | input: `# Chaotic-AUR mirrorlist | |||
| 19 | - | Server = https://cdn-mirror.chaotic.cx/$repo/$arch | |||
| 20 | - | #Server = https://backup.mirror.com/$repo/$arch | |||
| 21 | - | Server = https://another.mirror.com/$repo/$arch`, | |||
| 22 | - | wantCount: 3, | |||
| 23 | - | wantErr: false, | |||
| 24 | - | wantFirst: MirrorlistEntry{URL: "https://cdn-mirror.chaotic.cx/$repo/$arch", Enabled: true}, | |||
| 25 | - | }, | |||
| 26 | - | { | |||
| 27 | - | name: "Empty mirrorlist", | |||
| 28 | - | input: `# Just comments | |||
| 29 | - | # No servers here`, | |||
| 30 | - | wantCount: 0, | |||
| 31 | - | wantErr: true, | |||
| 32 | - | }, | |||
| 33 | - | { | |||
| 34 | - | name: "Mirrorlist with empty lines and whitespace", | |||
| 35 | - | input: `# Header | |||
| 36 | - | Server = https://mirror1.com/$repo/$arch | |||
| 37 | - | ||||
| 38 | - | Server = https://mirror2.com/$repo/$arch | |||
| 39 | - | ||||
| 40 | - | #Server = https://mirror3.com/$repo/$arch`, | |||
| 41 | - | wantCount: 3, | |||
| 42 | - | wantErr: false, | |||
| 43 | - | wantFirst: MirrorlistEntry{URL: "https://mirror1.com/$repo/$arch", Enabled: true}, | |||
| 44 | - | }, | |||
| 45 | - | { | |||
| 46 | - | name: "All disabled mirrors", | |||
| 47 | - | input: `#Server = https://disabled1.com/$repo/$arch | |||
| 48 | - | #Server = https://disabled2.com/$repo/$arch`, | |||
| 49 | - | wantCount: 2, | |||
| 50 | - | wantErr: false, | |||
| 51 | - | wantFirst: MirrorlistEntry{URL: "https://disabled1.com/$repo/$arch", Enabled: false}, | |||
| 52 | - | }, | |||
| 53 | - | { | |||
| 54 | - | name: "Malformed Server lines (should be skipped)", | |||
| 55 | - | input: `Server https://no-equals.com/$repo/$arch | |||
| 56 | - | Server = | |||
| 57 | - | = https://no-server.com/$repo/$arch | |||
| 58 | - | Server = https://valid.com/$repo/$arch`, | |||
| 59 | - | wantCount: 1, | |||
| 60 | - | wantErr: false, | |||
| 61 | - | wantFirst: MirrorlistEntry{URL: "https://valid.com/$repo/$arch", Enabled: true}, | |||
| 62 | - | }, | |||
| 63 | - | { | |||
| 64 | - | name: "Mirrors with various whitespace", | |||
| 65 | - | input: `Server = https://spaces.com/$repo/$arch | |||
| 66 | - | Server=https://nospace.com/$repo/$arch | |||
| 67 | - | Server = https://tabs.com/$repo/$arch `, | |||
| 68 | - | wantCount: 3, | |||
| 69 | - | wantErr: false, | |||
| 70 | - | wantFirst: MirrorlistEntry{URL: "https://spaces.com/$repo/$arch", Enabled: true}, | |||
| 71 | - | }, | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | for _, tt := range tests { | |||
| 75 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 76 | - | mirrors, err := ParseMirrorlist(strings.NewReader(tt.input)) | |||
| 77 | - | ||||
| 78 | - | if (err != nil) != tt.wantErr { | |||
| 79 | - | t.Errorf("ParseMirrorlist() error = %v, wantErr %v", err, tt.wantErr) | |||
| 80 | - | return | |||
| 81 | - | } | |||
| 82 | - | ||||
| 83 | - | if !tt.wantErr && len(mirrors) != tt.wantCount { | |||
| 84 | - | t.Errorf("ParseMirrorlist() got %d mirrors, want %d", len(mirrors), tt.wantCount) | |||
| 85 | - | } | |||
| 86 | - | ||||
| 87 | - | if !tt.wantErr && len(mirrors) > 0 { | |||
| 88 | - | if mirrors[0] != tt.wantFirst { | |||
| 89 | - | t.Errorf("ParseMirrorlist() first mirror = %+v, want %+v", mirrors[0], tt.wantFirst) | |||
| 90 | - | } | |||
| 91 | - | } | |||
| 92 | - | }) | |||
| 93 | - | } | |||
| 94 | - | } | |||
| 95 | - | ||||
| 96 | - | func TestBuildPackageURLs(t *testing.T) { | |||
| 97 | - | tests := []struct { | |||
| 98 | - | name string | |||
| 99 | - | mirrors []MirrorlistEntry | |||
| 100 | - | packageName string | |||
| 101 | - | preferEnabled bool | |||
| 102 | - | wantCount int | |||
| 103 | - | wantURLContains []string | |||
| 104 | - | shouldStartWith string | |||
| 105 | - | }{ | |||
| 106 | - | { | |||
| 107 | - | name: "Basic URL building with enabled mirrors preferred", | |||
| 108 | - | mirrors: []MirrorlistEntry{ | |||
| 109 | - | {URL: "https://mirror1.com/$repo/$arch", Enabled: true}, | |||
| 110 | - | {URL: "https://mirror2.com/$repo/$arch", Enabled: false}, | |||
| 111 | - | {URL: "https://mirror3.com/$repo/$arch", Enabled: true}, | |||
| 112 | - | }, | |||
| 113 | - | packageName: "test.pkg.tar.zst", | |||
| 114 | - | preferEnabled: true, | |||
| 115 | - | wantCount: 3, | |||
| 116 | - | wantURLContains: []string{ | |||
| 117 | - | "https://mirror1.com/chaotic-aur/x86_64/test.pkg.tar.zst", | |||
| 118 | - | "https://mirror3.com/chaotic-aur/x86_64/test.pkg.tar.zst", | |||
| 119 | - | "https://mirror2.com/chaotic-aur/x86_64/test.pkg.tar.zst", | |||
| 120 | - | }, | |||
| 121 | - | }, | |||
| 122 | - | { | |||
| 123 | - | name: "Enabled mirrors should come first", | |||
| 124 | - | mirrors: []MirrorlistEntry{ | |||
| 125 | - | {URL: "https://disabled.com/$repo/$arch", Enabled: false}, | |||
| 126 | - | {URL: "https://enabled1.com/$repo/$arch", Enabled: true}, | |||
| 127 | - | {URL: "https://enabled2.com/$repo/$arch", Enabled: true}, | |||
| 128 | - | }, | |||
| 129 | - | packageName: "pkg.tar.zst", | |||
| 130 | - | preferEnabled: true, | |||
| 131 | - | wantCount: 3, | |||
| 132 | - | shouldStartWith: "https://enabled1.com/chaotic-aur/x86_64/pkg.tar.zst", | |||
| 133 | - | }, | |||
| 134 | - | { | |||
| 135 | - | name: "All mirrors when preferEnabled is false", | |||
| 136 | - | mirrors: []MirrorlistEntry{ | |||
| 137 | - | {URL: "https://mirror1.com/$repo/$arch", Enabled: true}, | |||
| 138 | - | {URL: "https://mirror2.com/$repo/$arch", Enabled: false}, | |||
| 139 | - | }, | |||
| 140 | - | packageName: "test.pkg.tar.zst", | |||
| 141 | - | preferEnabled: false, | |||
| 142 | - | wantCount: 2, | |||
| 143 | - | }, | |||
| 144 | - | { | |||
| 145 | - | name: "Empty mirrors list", | |||
| 146 | - | mirrors: []MirrorlistEntry{}, | |||
| 147 | - | packageName: "test.pkg.tar.zst", | |||
| 148 | - | preferEnabled: true, | |||
| 149 | - | wantCount: 0, | |||
| 150 | - | }, | |||
| 151 | - | { | |||
| 152 | - | name: "Template variable substitution", | |||
| 153 | - | mirrors: []MirrorlistEntry{ | |||
| 154 | - | {URL: "https://example.com/$repo/$arch", Enabled: true}, | |||
| 155 | - | }, | |||
| 156 | - | packageName: "chaotic-keyring.pkg.tar.zst", | |||
| 157 | - | preferEnabled: true, | |||
| 158 | - | wantCount: 1, | |||
| 159 | - | wantURLContains: []string{ | |||
| 160 | - | "https://example.com/chaotic-aur/x86_64/chaotic-keyring.pkg.tar.zst", | |||
| 161 | - | }, | |||
| 162 | - | }, | |||
| 163 | - | { | |||
| 164 | - | name: "URL with trailing slash handling", | |||
| 165 | - | mirrors: []MirrorlistEntry{ | |||
| 166 | - | {URL: "https://example.com/$repo/$arch/", Enabled: true}, | |||
| 167 | - | }, | |||
| 168 | - | packageName: "test.pkg.tar.zst", | |||
| 169 | - | preferEnabled: true, | |||
| 170 | - | wantCount: 1, | |||
| 171 | - | wantURLContains: []string{ | |||
| 172 | - | "https://example.com/chaotic-aur/x86_64/test.pkg.tar.zst", | |||
| 173 | - | }, | |||
| 174 | - | }, | |||
| 175 | - | } | |||
| 176 | - | ||||
| 177 | - | for _, tt := range tests { | |||
| 178 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 179 | - | urls := BuildPackageURLs(tt.mirrors, tt.packageName, tt.preferEnabled) | |||
| 180 | - | ||||
| 181 | - | if len(urls) != tt.wantCount { | |||
| 182 | - | t.Errorf("BuildPackageURLs() got %d URLs, want %d", len(urls), tt.wantCount) | |||
| 183 | - | } | |||
| 184 | - | ||||
| 185 | - | for _, wantURL := range tt.wantURLContains { | |||
| 186 | - | found := false | |||
| 187 | - | for _, url := range urls { | |||
| 188 | - | if url == wantURL { | |||
| 189 | - | found = true | |||
| 190 | - | break | |||
| 191 | - | } | |||
| 192 | - | } | |||
| 193 | - | if !found { | |||
| 194 | - | t.Errorf("BuildPackageURLs() URL %q not found in result", wantURL) | |||
| 195 | - | t.Logf("Got URLs: %v", urls) | |||
| 196 | - | } | |||
| 197 | - | } | |||
| 198 | - | ||||
| 199 | - | if tt.shouldStartWith != "" && len(urls) > 0 { | |||
| 200 | - | if urls[0] != tt.shouldStartWith { | |||
| 201 | - | t.Errorf("BuildPackageURLs() first URL = %q, want %q", urls[0], tt.shouldStartWith) | |||
| 202 | - | } | |||
| 203 | - | } | |||
| 204 | - | }) | |||
| 205 | - | } | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | func TestBuildPackageURLs_EnabledOrderingGuarantee(t *testing.T) { | |||
| 209 | - | // Verify that enabled mirrors always come before disabled ones | |||
| 210 | - | mirrors := []MirrorlistEntry{ | |||
| 211 | - | {URL: "https://d1.com/$repo/$arch", Enabled: false}, | |||
| 212 | - | {URL: "https://e1.com/$repo/$arch", Enabled: true}, | |||
| 213 | - | {URL: "https://d2.com/$repo/$arch", Enabled: false}, | |||
| 214 | - | {URL: "https://e2.com/$repo/$arch", Enabled: true}, | |||
| 215 | - | {URL: "https://d3.com/$repo/$arch", Enabled: false}, | |||
| 216 | - | } | |||
| 217 | - | ||||
| 218 | - | urls := BuildPackageURLs(mirrors, "test.pkg.tar.zst", true) | |||
| 219 | - | ||||
| 220 | - | if len(urls) != 5 { | |||
| 221 | - | t.Fatalf("Expected 5 URLs, got %d", len(urls)) | |||
| 222 | - | } | |||
| 223 | - | ||||
| 224 | - | // First two should be from enabled mirrors | |||
| 225 | - | if !strings.Contains(urls[0], "e1.com") && !strings.Contains(urls[0], "e2.com") { | |||
| 226 | - | t.Errorf("First URL should be from enabled mirror, got %s", urls[0]) | |||
| 227 | - | } | |||
| 228 | - | if !strings.Contains(urls[1], "e1.com") && !strings.Contains(urls[1], "e2.com") { | |||
| 229 | - | t.Errorf("Second URL should be from enabled mirror, got %s", urls[1]) | |||
| 230 | - | } | |||
| 231 | - | ||||
| 232 | - | // Last three should be from disabled mirrors | |||
| 233 | - | for i := 2; i < 5; i++ { | |||
| 234 | - | if !strings.Contains(urls[i], ".d") && !strings.Contains(urls[i], "d1") && | |||
| 235 | - | !strings.Contains(urls[i], "d2") && !strings.Contains(urls[i], "d3") { | |||
| 236 | - | // Check if it contains any disabled mirror domain | |||
| 237 | - | isDisabled := false | |||
| 238 | - | for _, m := range mirrors { | |||
| 239 | - | if !m.Enabled && strings.Contains(urls[i], strings.Split(m.URL, "/")[2]) { | |||
| 240 | - | isDisabled = true | |||
| 241 | - | break | |||
| 242 | - | } | |||
| 243 | - | } | |||
| 244 | - | if !isDisabled { | |||
| 245 | - | t.Logf("URL at index %d: %s might not be from disabled mirror", i, urls[i]) | |||
| 246 | - | } | |||
| 247 | - | } | |||
| 248 | - | } | |||
| 249 | - | } | |||
| 250 | - | ||||
| 251 | - | func TestParseMirrorlist_SpecialCharacters(t *testing.T) { | |||
| 252 | - | // Test with various URL formats that might appear in real mirrorlist | |||
| 253 | - | input := ` | |||
| 254 | - | Server = https://mirror.example.com:8080/$repo/$arch | |||
| 255 | - | Server = http://mirror.example.com/$repo/$arch?token=xyz | |||
| 256 | - | Server = https://mirror-example.com/$repo/$arch | |||
| 257 | - | Server = https://mirror_example.com/$repo/$arch | |||
| 258 | - | Server = https://123.456.789.012/$repo/$arch | |||
| 259 | - | ` | |||
| 260 | - | ||||
| 261 | - | mirrors, err := ParseMirrorlist(strings.NewReader(input)) | |||
| 262 | - | if err != nil { | |||
| 263 | - | t.Fatalf("ParseMirrorlist() error = %v", err) | |||
| 264 | - | } | |||
| 265 | - | ||||
| 266 | - | if len(mirrors) != 5 { | |||
| 267 | - | t.Errorf("ParseMirrorlist() got %d mirrors, want 5", len(mirrors)) | |||
| 268 | - | } | |||
| 269 | - | ||||
| 270 | - | // Verify all URLs are preserved correctly | |||
| 271 | - | urls := []string{ | |||
| 272 | - | "https://mirror.example.com:8080/$repo/$arch", | |||
| 273 | - | "http://mirror.example.com/$repo/$arch?token=xyz", | |||
| 274 | - | "https://mirror-example.com/$repo/$arch", | |||
| 275 | - | "https://mirror_example.com/$repo/$arch", | |||
| 276 | - | "https://123.456.789.012/$repo/$arch", | |||
| 277 | - | } | |||
| 278 | - | ||||
| 279 | - | for i, wantURL := range urls { | |||
| 280 | - | if i >= len(mirrors) { | |||
| 281 | - | t.Fatalf("Expected mirror at index %d, but only got %d mirrors", i, len(mirrors)) | |||
| 282 | - | } | |||
| 283 | - | if mirrors[i].URL != wantURL { | |||
| 284 | - | t.Errorf("Mirror %d: got %q, want %q", i, mirrors[i].URL, wantURL) | |||
| 285 | - | } | |||
| 286 | - | } | |||
| 287 | - | } | |||
D internal/system/network.go +0 -36
| @@ -1,36 +0,0 @@ | |||
| 1 | - | package system | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "net/http" | |
| 6 | - | "time" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | const ( | |
| 10 | - | NetworkCheckURL = "https://raw.githubusercontent.com" | |
| 11 | - | NetworkCheckTimeout = 5 * time.Second | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | // CheckNetworkConnectivity verifies internet connectivity | |
| 15 | - | // Returns nil if network is available, error otherwise | |
| 16 | - | func CheckNetworkConnectivity() error { | |
| 17 | - | client := &http.Client{Timeout: NetworkCheckTimeout} | |
| 18 | - | ||
| 19 | - | resp, err := client.Get(NetworkCheckURL) | |
| 20 | - | if err != nil { | |
| 21 | - | return fmt.Errorf("network connectivity check failed: %w", err) | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | if resp.StatusCode != http.StatusOK { | |
| 25 | - | if err := resp.Body.Close(); err != nil { | |
| 26 | - | return fmt.Errorf("failed to close network response: %w", err) | |
| 27 | - | } | |
| 28 | - | return fmt.Errorf("network check returned HTTP %d", resp.StatusCode) | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | if err := resp.Body.Close(); err != nil { | |
| 32 | - | return fmt.Errorf("failed to close network response: %w", err) | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | return nil | |
| 36 | - | } | |
| @@ -1,36 +0,0 @@ | |||||
| 1 | - | package system | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "net/http" | |||
| 6 | - | "time" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | const ( | |||
| 10 | - | NetworkCheckURL = "https://raw.githubusercontent.com" | |||
| 11 | - | NetworkCheckTimeout = 5 * time.Second | |||
| 12 | - | ) | |||
| 13 | - | ||||
| 14 | - | // CheckNetworkConnectivity verifies internet connectivity | |||
| 15 | - | // Returns nil if network is available, error otherwise | |||
| 16 | - | func CheckNetworkConnectivity() error { | |||
| 17 | - | client := &http.Client{Timeout: NetworkCheckTimeout} | |||
| 18 | - | ||||
| 19 | - | resp, err := client.Get(NetworkCheckURL) | |||
| 20 | - | if err != nil { | |||
| 21 | - | return fmt.Errorf("network connectivity check failed: %w", err) | |||
| 22 | - | } | |||
| 23 | - | ||||
| 24 | - | if resp.StatusCode != http.StatusOK { | |||
| 25 | - | if err := resp.Body.Close(); err != nil { | |||
| 26 | - | return fmt.Errorf("failed to close network response: %w", err) | |||
| 27 | - | } | |||
| 28 | - | return fmt.Errorf("network check returned HTTP %d", resp.StatusCode) | |||
| 29 | - | } | |||
| 30 | - | ||||
| 31 | - | if err := resp.Body.Close(); err != nil { | |||
| 32 | - | return fmt.Errorf("failed to close network response: %w", err) | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | return nil | |||
| 36 | - | } | |||
M internal/system/pacman.go +5 -50
| @@ -1,60 +1,17 @@ | |||
| 1 | 1 | package system | |
| 2 | 2 | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | ) | |
| 3 | + | import "fmt" | |
| 6 | 4 | ||
| 7 | - | // PacmanSync syncs the package database | |
| 8 | - | func PacmanSync(logPath string) error { | |
| 9 | - | result := RunLogged(logPath, "pacman", "-Sy", "--noconfirm") | |
| 10 | - | if result.Error != nil { | |
| 11 | - | return fmt.Errorf("failed to sync pacman database: %w", result.Error) | |
| 12 | - | } | |
| 13 | - | return nil | |
| 14 | - | } | |
| 15 | - | ||
| 16 | - | // PacmanInstall installs packages | |
| 17 | - | func PacmanInstall(logPath string, packages ...string) error { | |
| 18 | - | args := append([]string{"-S", "--needed", "--noconfirm"}, packages...) | |
| 19 | - | result := RunLogged(logPath, "pacman", args...) | |
| 20 | - | if result.Error != nil { | |
| 21 | - | return fmt.Errorf("failed to install packages: %w", result.Error) | |
| 22 | - | } | |
| 23 | - | return nil | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | // Pacstrap installs the base system to a mount point | |
| 27 | - | func Pacstrap(logPath, mountPoint string, packages ...string) error { | |
| 28 | - | args := append([]string{mountPoint}, packages...) | |
| 29 | - | result := RunLogged(logPath, "pacstrap", args...) | |
| 30 | - | if result.Error != nil { | |
| 31 | - | return fmt.Errorf("failed to run pacstrap: %w", result.Error) | |
| 32 | - | } | |
| 33 | - | return nil | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // PacmanRemove removes packages | |
| 37 | - | func PacmanRemove(logPath string, packages ...string) error { | |
| 38 | - | args := append([]string{"-R", "--noconfirm"}, packages...) | |
| 39 | - | result := RunLogged(logPath, "pacman", args...) | |
| 40 | - | if result.Error != nil { | |
| 41 | - | return fmt.Errorf("failed to remove packages: %w", result.Error) | |
| 42 | - | } | |
| 43 | - | return nil | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | // DownloadAndInstallPackages downloads packages from URLs using curl and installs them in chroot | |
| 47 | - | // Supports mirror fallbacks: URLs with the same package name but different mirrors | |
| 5 | + | // DownloadAndInstallPackages downloads packages from URLs using curl and installs them in chroot. | |
| 6 | + | // Supports mirror fallbacks: URLs with the same package name but different mirrors. | |
| 48 | 7 | func DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error { | |
| 49 | 8 | if len(urls) == 0 { | |
| 50 | 9 | return fmt.Errorf("no URLs provided") | |
| 51 | 10 | } | |
| 52 | 11 | ||
| 53 | 12 | // Group URLs by package (same filename = same package, different mirrors) | |
| 54 | - | // This allows fallback to alternative mirrors if primary fails | |
| 55 | 13 | packageMirrors := make(map[string][]string) | |
| 56 | 14 | for _, url := range urls { | |
| 57 | - | // Extract package name from URL | |
| 58 | 15 | var pkgName string | |
| 59 | 16 | for i := len(url) - 1; i >= 0; i-- { | |
| 60 | 17 | if url[i] == '/' { | |
| @@ -65,11 +22,10 @@ func DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) erro | |||
| 65 | 22 | packageMirrors[pkgName] = append(packageMirrors[pkgName], url) | |
| 66 | 23 | } | |
| 67 | 24 | ||
| 68 | - | // Download with fallback: try each mirror for each package | |
| 25 | + | // Build command with mirror fallback per package | |
| 69 | 26 | cmd := "mkdir -p /tmp && " | |
| 70 | 27 | for i, pkgName := range getSortedKeys(packageMirrors) { | |
| 71 | 28 | mirrors := packageMirrors[pkgName] | |
| 72 | - | // Try primary mirror first, fallback to others if it fails | |
| 73 | 29 | cmd += fmt.Sprintf("(curl -L -f -o /tmp/pkg%d.tar.zst '%s'", i, mirrors[0]) | |
| 74 | 30 | for _, mirror := range mirrors[1:] { | |
| 75 | 31 | cmd += fmt.Sprintf(" || curl -L -f -o /tmp/pkg%d.tar.zst '%s'", i, mirror) | |
| @@ -89,13 +45,12 @@ func DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) erro | |||
| 89 | 45 | return nil | |
| 90 | 46 | } | |
| 91 | 47 | ||
| 92 | - | // getSortedKeys returns map keys in a stable order (important for reproducibility) | |
| 48 | + | // getSortedKeys returns map keys in stable alphabetical order | |
| 93 | 49 | func getSortedKeys(m map[string][]string) []string { | |
| 94 | 50 | keys := make([]string, 0, len(m)) | |
| 95 | 51 | for k := range m { | |
| 96 | 52 | keys = append(keys, k) | |
| 97 | 53 | } | |
| 98 | - | // Simple stable sort (not alphabetical, but consistent) | |
| 99 | 54 | for i := 0; i < len(keys); i++ { | |
| 100 | 55 | for j := i + 1; j < len(keys); j++ { | |
| 101 | 56 | if keys[i] > keys[j] { | |
| @@ -1,60 +1,17 @@ | |||||
| 1 | package system | 1 | package system | ||
| 2 | 2 | ||||
| 3 | - | import ( | 3 | + | import "fmt" |
| 4 | - | "fmt" | |||
| 5 | - | ) | |||
| 6 | 4 | ||||
| 7 | - | // PacmanSync syncs the package database | 5 | + | // DownloadAndInstallPackages downloads packages from URLs using curl and installs them in chroot. |
| 8 | - | func PacmanSync(logPath string) error { | 6 | + | // Supports mirror fallbacks: URLs with the same package name but different mirrors. |
| 9 | - | result := RunLogged(logPath, "pacman", "-Sy", "--noconfirm") | |||
| 10 | - | if result.Error != nil { | |||
| 11 | - | return fmt.Errorf("failed to sync pacman database: %w", result.Error) | |||
| 12 | - | } | |||
| 13 | - | return nil | |||
| 14 | - | } | |||
| 15 | - | ||||
| 16 | - | // PacmanInstall installs packages | |||
| 17 | - | func PacmanInstall(logPath string, packages ...string) error { | |||
| 18 | - | args := append([]string{"-S", "--needed", "--noconfirm"}, packages...) | |||
| 19 | - | result := RunLogged(logPath, "pacman", args...) | |||
| 20 | - | if result.Error != nil { | |||
| 21 | - | return fmt.Errorf("failed to install packages: %w", result.Error) | |||
| 22 | - | } | |||
| 23 | - | return nil | |||
| 24 | - | } | |||
| 25 | - | ||||
| 26 | - | // Pacstrap installs the base system to a mount point | |||
| 27 | - | func Pacstrap(logPath, mountPoint string, packages ...string) error { | |||
| 28 | - | args := append([]string{mountPoint}, packages...) | |||
| 29 | - | result := RunLogged(logPath, "pacstrap", args...) | |||
| 30 | - | if result.Error != nil { | |||
| 31 | - | return fmt.Errorf("failed to run pacstrap: %w", result.Error) | |||
| 32 | - | } | |||
| 33 | - | return nil | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | // PacmanRemove removes packages | |||
| 37 | - | func PacmanRemove(logPath string, packages ...string) error { | |||
| 38 | - | args := append([]string{"-R", "--noconfirm"}, packages...) | |||
| 39 | - | result := RunLogged(logPath, "pacman", args...) | |||
| 40 | - | if result.Error != nil { | |||
| 41 | - | return fmt.Errorf("failed to remove packages: %w", result.Error) | |||
| 42 | - | } | |||
| 43 | - | return nil | |||
| 44 | - | } | |||
| 45 | - | ||||
| 46 | - | // DownloadAndInstallPackages downloads packages from URLs using curl and installs them in chroot | |||
| 47 | - | // Supports mirror fallbacks: URLs with the same package name but different mirrors | |||
| 48 | func DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error { | 7 | func DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error { | ||
| 49 | if len(urls) == 0 { | 8 | if len(urls) == 0 { | ||
| 50 | return fmt.Errorf("no URLs provided") | 9 | return fmt.Errorf("no URLs provided") | ||
| 51 | } | 10 | } | ||
| 52 | 11 | ||||
| 53 | // Group URLs by package (same filename = same package, different mirrors) | 12 | // Group URLs by package (same filename = same package, different mirrors) | ||
| 54 | - | // This allows fallback to alternative mirrors if primary fails | |||
| 55 | packageMirrors := make(map[string][]string) | 13 | packageMirrors := make(map[string][]string) | ||
| 56 | for _, url := range urls { | 14 | for _, url := range urls { | ||
| 57 | - | // Extract package name from URL | |||
| 58 | var pkgName string | 15 | var pkgName string | ||
| 59 | for i := len(url) - 1; i >= 0; i-- { | 16 | for i := len(url) - 1; i >= 0; i-- { | ||
| 60 | if url[i] == '/' { | 17 | if url[i] == '/' { | ||
| @@ -65,11 +22,10 @@ func DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) erro | |||||
| 65 | packageMirrors[pkgName] = append(packageMirrors[pkgName], url) | 22 | packageMirrors[pkgName] = append(packageMirrors[pkgName], url) | ||
| 66 | } | 23 | } | ||
| 67 | 24 | ||||
| 68 | - | // Download with fallback: try each mirror for each package | 25 | + | // Build command with mirror fallback per package |
| 69 | cmd := "mkdir -p /tmp && " | 26 | cmd := "mkdir -p /tmp && " | ||
| 70 | for i, pkgName := range getSortedKeys(packageMirrors) { | 27 | for i, pkgName := range getSortedKeys(packageMirrors) { | ||
| 71 | mirrors := packageMirrors[pkgName] | 28 | mirrors := packageMirrors[pkgName] | ||
| 72 | - | // Try primary mirror first, fallback to others if it fails | |||
| 73 | cmd += fmt.Sprintf("(curl -L -f -o /tmp/pkg%d.tar.zst '%s'", i, mirrors[0]) | 29 | cmd += fmt.Sprintf("(curl -L -f -o /tmp/pkg%d.tar.zst '%s'", i, mirrors[0]) | ||
| 74 | for _, mirror := range mirrors[1:] { | 30 | for _, mirror := range mirrors[1:] { | ||
| 75 | cmd += fmt.Sprintf(" || curl -L -f -o /tmp/pkg%d.tar.zst '%s'", i, mirror) | 31 | cmd += fmt.Sprintf(" || curl -L -f -o /tmp/pkg%d.tar.zst '%s'", i, mirror) | ||
| @@ -89,13 +45,12 @@ func DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) erro | |||||
| 89 | return nil | 45 | return nil | ||
| 90 | } | 46 | } | ||
| 91 | 47 | ||||
| 92 | - | // getSortedKeys returns map keys in a stable order (important for reproducibility) | 48 | + | // getSortedKeys returns map keys in stable alphabetical order |
| 93 | func getSortedKeys(m map[string][]string) []string { | 49 | func getSortedKeys(m map[string][]string) []string { | ||
| 94 | keys := make([]string, 0, len(m)) | 50 | keys := make([]string, 0, len(m)) | ||
| 95 | for k := range m { | 51 | for k := range m { | ||
| 96 | keys = append(keys, k) | 52 | keys = append(keys, k) | ||
| 97 | } | 53 | } | ||
| 98 | - | // Simple stable sort (not alphabetical, but consistent) | |||
| 99 | for i := 0; i < len(keys); i++ { | 54 | for i := 0; i < len(keys); i++ { | ||
| 100 | for j := i + 1; j < len(keys); j++ { | 55 | for j := i + 1; j < len(keys); j++ { | ||
| 101 | if keys[i] > keys[j] { | 56 | if keys[i] > keys[j] { | ||
D internal/system/system.go +0 -17
| @@ -1,17 +0,0 @@ | |||
| 1 | - | package system | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "log/slog" | |
| 5 | - | ) | |
| 6 | - | ||
| 7 | - | // System provides system-level operations with logging support | |
| 8 | - | type System struct { | |
| 9 | - | log *slog.Logger | |
| 10 | - | } | |
| 11 | - | ||
| 12 | - | // NewSystem creates a new System instance with the provided logger | |
| 13 | - | func NewSystem(log *slog.Logger) *System { | |
| 14 | - | return &System{ | |
| 15 | - | log: log, | |
| 16 | - | } | |
| 17 | - | } | |
| @@ -1,17 +0,0 @@ | |||||
| 1 | - | package system | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "log/slog" | |||
| 5 | - | ) | |||
| 6 | - | ||||
| 7 | - | // System provides system-level operations with logging support | |||
| 8 | - | type System struct { | |||
| 9 | - | log *slog.Logger | |||
| 10 | - | } | |||
| 11 | - | ||||
| 12 | - | // NewSystem creates a new System instance with the provided logger | |||
| 13 | - | func NewSystem(log *slog.Logger) *System { | |||
| 14 | - | return &System{ | |||
| 15 | - | log: log, | |||
| 16 | - | } | |||
| 17 | - | } | |||
M internal/system/timezone.go +4 -114
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package system | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "fmt" | |
| 5 | 4 | "io" | |
| 6 | 5 | "net/http" | |
| 7 | 6 | "strings" | |
| @@ -9,121 +8,12 @@ import ( | |||
| 9 | 8 | ) | |
| 10 | 9 | ||
| 11 | 10 | const ( | |
| 12 | - | timezoneAPIURL = "https://ipapi.co/timezone/" | |
| 13 | - | timezoneAPITimeout = 5 * time.Second | |
| 14 | - | timezoneRetryAttempt = 3 | |
| 15 | - | timezoneRetryDelay = 1 * time.Second | |
| 11 | + | timezoneAPIURL = "https://ipapi.co/timezone/" | |
| 12 | + | timezoneAPITimeout = 5 * time.Second | |
| 16 | 13 | ) | |
| 17 | 14 | ||
| 18 | - | // DetectTimezone fetches timezone from ipapi.co API with retry logic | |
| 19 | - | // Attempts up to 3 times with exponential backoff before falling back | |
| 20 | - | // Returns detected timezone string or empty string on failure | |
| 21 | - | // Logs details to the system logger | |
| 22 | - | func (s *System) DetectTimezone() string { | |
| 23 | - | var lastErr error | |
| 24 | - | ||
| 25 | - | for attempt := 1; attempt <= timezoneRetryAttempt; attempt++ { | |
| 26 | - | if attempt > 1 { | |
| 27 | - | // Log retry attempt | |
| 28 | - | s.log.Debug("Timezone detection retry", | |
| 29 | - | "attempt", attempt, | |
| 30 | - | "max_attempts", timezoneRetryAttempt, | |
| 31 | - | "delay", timezoneRetryDelay.String()) | |
| 32 | - | time.Sleep(timezoneRetryDelay) | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | client := &http.Client{ | |
| 36 | - | Timeout: timezoneAPITimeout, | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | resp, err := client.Get(timezoneAPIURL) | |
| 40 | - | if err != nil { | |
| 41 | - | lastErr = fmt.Errorf("http request failed: %w", err) | |
| 42 | - | s.log.Debug("Timezone detection failed", | |
| 43 | - | "attempt", attempt, | |
| 44 | - | "error", lastErr) | |
| 45 | - | continue | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | // Check HTTP status code | |
| 49 | - | if resp.StatusCode != http.StatusOK { | |
| 50 | - | if closeErr := resp.Body.Close(); closeErr != nil { | |
| 51 | - | lastErr = fmt.Errorf("failed to close response: %w", closeErr) | |
| 52 | - | s.log.Debug("Timezone detection failed", | |
| 53 | - | "attempt", attempt, | |
| 54 | - | "error", lastErr) | |
| 55 | - | continue | |
| 56 | - | } | |
| 57 | - | lastErr = fmt.Errorf("API returned status %d", resp.StatusCode) | |
| 58 | - | s.log.Debug("Timezone detection failed", | |
| 59 | - | "attempt", attempt, | |
| 60 | - | "error", lastErr) | |
| 61 | - | continue | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | // Read response body | |
| 65 | - | body, err := io.ReadAll(resp.Body) | |
| 66 | - | if err != nil { | |
| 67 | - | if closeErr := resp.Body.Close(); closeErr != nil { | |
| 68 | - | lastErr = fmt.Errorf("failed to close response: %w", closeErr) | |
| 69 | - | s.log.Debug("Timezone detection failed", | |
| 70 | - | "attempt", attempt, | |
| 71 | - | "error", lastErr) | |
| 72 | - | continue | |
| 73 | - | } | |
| 74 | - | lastErr = fmt.Errorf("failed to read response: %w", err) | |
| 75 | - | s.log.Debug("Timezone detection failed", | |
| 76 | - | "attempt", attempt, | |
| 77 | - | "error", lastErr) | |
| 78 | - | continue | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | if err := resp.Body.Close(); err != nil { | |
| 82 | - | lastErr = fmt.Errorf("failed to close response: %w", err) | |
| 83 | - | s.log.Debug("Timezone detection failed", | |
| 84 | - | "attempt", attempt, | |
| 85 | - | "error", lastErr) | |
| 86 | - | continue | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | timezone := strings.TrimSpace(string(body)) | |
| 90 | - | ||
| 91 | - | // Validate timezone format (should be like "America/New_York") | |
| 92 | - | if !strings.Contains(timezone, "/") { | |
| 93 | - | lastErr = fmt.Errorf("invalid timezone format: %q (expected format like America/New_York)", timezone) | |
| 94 | - | s.log.Debug("Timezone detection failed", | |
| 95 | - | "attempt", attempt, | |
| 96 | - | "error", lastErr) | |
| 97 | - | continue | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | // Success - log and return | |
| 101 | - | s.log.Info("Timezone detected successfully", "timezone", timezone) | |
| 102 | - | return timezone | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | // All retries exhausted | |
| 106 | - | s.log.Warn("Timezone detection failed after all retries", | |
| 107 | - | "attempts", timezoneRetryAttempt, | |
| 108 | - | "last_error", lastErr) | |
| 109 | - | return "" | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | // ValidateTimezone checks if a timezone string is valid | |
| 113 | - | // This is a basic check - full validation would require checking against zoneinfo | |
| 114 | - | func ValidateTimezone(tz string) bool { | |
| 115 | - | switch { | |
| 116 | - | case tz == "": | |
| 117 | - | return false | |
| 118 | - | case !strings.Contains(tz, "/"): | |
| 119 | - | return false | |
| 120 | - | default: | |
| 121 | - | return true | |
| 122 | - | } | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | // DetectTimezoneSimple fetches timezone from ipapi.co API without logging | |
| 126 | - | // Returns detected timezone string or empty string on failure | |
| 15 | + | // DetectTimezoneSimple fetches timezone from ipapi.co API without logging. | |
| 16 | + | // Returns detected timezone string or empty string on failure. | |
| 127 | 17 | func DetectTimezoneSimple() string { | |
| 128 | 18 | client := &http.Client{ | |
| 129 | 19 | Timeout: timezoneAPITimeout, | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package system | 1 | package system | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "fmt" | |||
| 5 | "io" | 4 | "io" | ||
| 6 | "net/http" | 5 | "net/http" | ||
| 7 | "strings" | 6 | "strings" | ||
| @@ -9,121 +8,12 @@ import ( | |||||
| 9 | ) | 8 | ) | ||
| 10 | 9 | ||||
| 11 | const ( | 10 | const ( | ||
| 12 | - | timezoneAPIURL = "https://ipapi.co/timezone/" | 11 | + | timezoneAPIURL = "https://ipapi.co/timezone/" |
| 13 | - | timezoneAPITimeout = 5 * time.Second | 12 | + | timezoneAPITimeout = 5 * time.Second |
| 14 | - | timezoneRetryAttempt = 3 | |||
| 15 | - | timezoneRetryDelay = 1 * time.Second | |||
| 16 | ) | 13 | ) | ||
| 17 | 14 | ||||
| 18 | - | // DetectTimezone fetches timezone from ipapi.co API with retry logic | 15 | + | // DetectTimezoneSimple fetches timezone from ipapi.co API without logging. |
| 19 | - | // Attempts up to 3 times with exponential backoff before falling back | 16 | + | // Returns detected timezone string or empty string on failure. |
| 20 | - | // Returns detected timezone string or empty string on failure | |||
| 21 | - | // Logs details to the system logger | |||
| 22 | - | func (s *System) DetectTimezone() string { | |||
| 23 | - | var lastErr error | |||
| 24 | - | ||||
| 25 | - | for attempt := 1; attempt <= timezoneRetryAttempt; attempt++ { | |||
| 26 | - | if attempt > 1 { | |||
| 27 | - | // Log retry attempt | |||
| 28 | - | s.log.Debug("Timezone detection retry", | |||
| 29 | - | "attempt", attempt, | |||
| 30 | - | "max_attempts", timezoneRetryAttempt, | |||
| 31 | - | "delay", timezoneRetryDelay.String()) | |||
| 32 | - | time.Sleep(timezoneRetryDelay) | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | client := &http.Client{ | |||
| 36 | - | Timeout: timezoneAPITimeout, | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | resp, err := client.Get(timezoneAPIURL) | |||
| 40 | - | if err != nil { | |||
| 41 | - | lastErr = fmt.Errorf("http request failed: %w", err) | |||
| 42 | - | s.log.Debug("Timezone detection failed", | |||
| 43 | - | "attempt", attempt, | |||
| 44 | - | "error", lastErr) | |||
| 45 | - | continue | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | // Check HTTP status code | |||
| 49 | - | if resp.StatusCode != http.StatusOK { | |||
| 50 | - | if closeErr := resp.Body.Close(); closeErr != nil { | |||
| 51 | - | lastErr = fmt.Errorf("failed to close response: %w", closeErr) | |||
| 52 | - | s.log.Debug("Timezone detection failed", | |||
| 53 | - | "attempt", attempt, | |||
| 54 | - | "error", lastErr) | |||
| 55 | - | continue | |||
| 56 | - | } | |||
| 57 | - | lastErr = fmt.Errorf("API returned status %d", resp.StatusCode) | |||
| 58 | - | s.log.Debug("Timezone detection failed", | |||
| 59 | - | "attempt", attempt, | |||
| 60 | - | "error", lastErr) | |||
| 61 | - | continue | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | // Read response body | |||
| 65 | - | body, err := io.ReadAll(resp.Body) | |||
| 66 | - | if err != nil { | |||
| 67 | - | if closeErr := resp.Body.Close(); closeErr != nil { | |||
| 68 | - | lastErr = fmt.Errorf("failed to close response: %w", closeErr) | |||
| 69 | - | s.log.Debug("Timezone detection failed", | |||
| 70 | - | "attempt", attempt, | |||
| 71 | - | "error", lastErr) | |||
| 72 | - | continue | |||
| 73 | - | } | |||
| 74 | - | lastErr = fmt.Errorf("failed to read response: %w", err) | |||
| 75 | - | s.log.Debug("Timezone detection failed", | |||
| 76 | - | "attempt", attempt, | |||
| 77 | - | "error", lastErr) | |||
| 78 | - | continue | |||
| 79 | - | } | |||
| 80 | - | ||||
| 81 | - | if err := resp.Body.Close(); err != nil { | |||
| 82 | - | lastErr = fmt.Errorf("failed to close response: %w", err) | |||
| 83 | - | s.log.Debug("Timezone detection failed", | |||
| 84 | - | "attempt", attempt, | |||
| 85 | - | "error", lastErr) | |||
| 86 | - | continue | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | timezone := strings.TrimSpace(string(body)) | |||
| 90 | - | ||||
| 91 | - | // Validate timezone format (should be like "America/New_York") | |||
| 92 | - | if !strings.Contains(timezone, "/") { | |||
| 93 | - | lastErr = fmt.Errorf("invalid timezone format: %q (expected format like America/New_York)", timezone) | |||
| 94 | - | s.log.Debug("Timezone detection failed", | |||
| 95 | - | "attempt", attempt, | |||
| 96 | - | "error", lastErr) | |||
| 97 | - | continue | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | // Success - log and return | |||
| 101 | - | s.log.Info("Timezone detected successfully", "timezone", timezone) | |||
| 102 | - | return timezone | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | // All retries exhausted | |||
| 106 | - | s.log.Warn("Timezone detection failed after all retries", | |||
| 107 | - | "attempts", timezoneRetryAttempt, | |||
| 108 | - | "last_error", lastErr) | |||
| 109 | - | return "" | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | // ValidateTimezone checks if a timezone string is valid | |||
| 113 | - | // This is a basic check - full validation would require checking against zoneinfo | |||
| 114 | - | func ValidateTimezone(tz string) bool { | |||
| 115 | - | switch { | |||
| 116 | - | case tz == "": | |||
| 117 | - | return false | |||
| 118 | - | case !strings.Contains(tz, "/"): | |||
| 119 | - | return false | |||
| 120 | - | default: | |||
| 121 | - | return true | |||
| 122 | - | } | |||
| 123 | - | } | |||
| 124 | - | ||||
| 125 | - | // DetectTimezoneSimple fetches timezone from ipapi.co API without logging | |||
| 126 | - | // Returns detected timezone string or empty string on failure | |||
| 127 | func DetectTimezoneSimple() string { | 17 | func DetectTimezoneSimple() string { | ||
| 128 | client := &http.Client{ | 18 | client := &http.Client{ | ||
| 129 | Timeout: timezoneAPITimeout, | 19 | Timeout: timezoneAPITimeout, | ||
D internal/system/timezone_test.go +0 -37
| @@ -1,37 +0,0 @@ | |||
| 1 | - | package system | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "testing" | |
| 5 | - | ) | |
| 6 | - | ||
| 7 | - | // TestDetectTimezoneSuccess tests successful timezone detection | |
| 8 | - | func TestDetectTimezoneSuccess(t *testing.T) { | |
| 9 | - | // Skip this test as it requires network and a System instance with logger | |
| 10 | - | // A proper unit test would require refactoring DetectTimezone to accept a base URL | |
| 11 | - | t.Skip("Skipping timezone detection test - requires System instance and network access") | |
| 12 | - | } | |
| 13 | - | ||
| 14 | - | // TestValidateTimezone tests timezone validation | |
| 15 | - | func TestValidateTimezone(t *testing.T) { | |
| 16 | - | tests := []struct { | |
| 17 | - | name string | |
| 18 | - | timezone string | |
| 19 | - | want bool | |
| 20 | - | }{ | |
| 21 | - | {"Valid Europe timezone", "Europe/Paris", true}, | |
| 22 | - | {"Valid US timezone", "America/New_York", true}, | |
| 23 | - | {"Valid Asia timezone", "Asia/Tokyo", true}, | |
| 24 | - | {"Invalid - no slash", "UTC", false}, | |
| 25 | - | {"Invalid - empty string", "", false}, | |
| 26 | - | {"Valid - just slash", "/", true}, | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | for _, tt := range tests { | |
| 30 | - | t.Run(tt.name, func(t *testing.T) { | |
| 31 | - | got := ValidateTimezone(tt.timezone) | |
| 32 | - | if got != tt.want { | |
| 33 | - | t.Errorf("ValidateTimezone(%q) = %v, want %v", tt.timezone, got, tt.want) | |
| 34 | - | } | |
| 35 | - | }) | |
| 36 | - | } | |
| 37 | - | } | |
| @@ -1,37 +0,0 @@ | |||||
| 1 | - | package system | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ) | |||
| 6 | - | ||||
| 7 | - | // TestDetectTimezoneSuccess tests successful timezone detection | |||
| 8 | - | func TestDetectTimezoneSuccess(t *testing.T) { | |||
| 9 | - | // Skip this test as it requires network and a System instance with logger | |||
| 10 | - | // A proper unit test would require refactoring DetectTimezone to accept a base URL | |||
| 11 | - | t.Skip("Skipping timezone detection test - requires System instance and network access") | |||
| 12 | - | } | |||
| 13 | - | ||||
| 14 | - | // TestValidateTimezone tests timezone validation | |||
| 15 | - | func TestValidateTimezone(t *testing.T) { | |||
| 16 | - | tests := []struct { | |||
| 17 | - | name string | |||
| 18 | - | timezone string | |||
| 19 | - | want bool | |||
| 20 | - | }{ | |||
| 21 | - | {"Valid Europe timezone", "Europe/Paris", true}, | |||
| 22 | - | {"Valid US timezone", "America/New_York", true}, | |||
| 23 | - | {"Valid Asia timezone", "Asia/Tokyo", true}, | |||
| 24 | - | {"Invalid - no slash", "UTC", false}, | |||
| 25 | - | {"Invalid - empty string", "", false}, | |||
| 26 | - | {"Valid - just slash", "/", true}, | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | for _, tt := range tests { | |||
| 30 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 31 | - | got := ValidateTimezone(tt.timezone) | |||
| 32 | - | if got != tt.want { | |||
| 33 | - | t.Errorf("ValidateTimezone(%q) = %v, want %v", tt.timezone, got, tt.want) | |||
| 34 | - | } | |||
| 35 | - | }) | |||
| 36 | - | } | |||
| 37 | - | } | |||
D internal/validation/validators.go +0 -119
| @@ -1,119 +0,0 @@ | |||
| 1 | - | package validation | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "fmt" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | // ValidateNotEmpty validates that a string is not empty | |
| 9 | - | func ValidateNotEmpty(field string) func(string) error { | |
| 10 | - | return func(s string) error { | |
| 11 | - | if s == "" { | |
| 12 | - | return fmt.Errorf("%s cannot be empty", field) | |
| 13 | - | } | |
| 14 | - | return nil | |
| 15 | - | } | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | // ValidateHostname validates a hostname | |
| 19 | - | func ValidateHostname(s string) error { | |
| 20 | - | if s == "" { | |
| 21 | - | return errors.New("hostname cannot be empty") | |
| 22 | - | } | |
| 23 | - | if len(s) > 63 { | |
| 24 | - | return errors.New("hostname must be 63 characters or less") | |
| 25 | - | } | |
| 26 | - | // Basic validation | |
| 27 | - | for _, c := range s { | |
| 28 | - | if !isHostnameChar(c) { | |
| 29 | - | return errors.New("hostname can only contain letters, numbers, and hyphens") | |
| 30 | - | } | |
| 31 | - | } | |
| 32 | - | return nil | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | // ValidateUsername validates a username | |
| 36 | - | func ValidateUsername(s string) error { | |
| 37 | - | if s == "" { | |
| 38 | - | return errors.New("username cannot be empty") | |
| 39 | - | } | |
| 40 | - | if len(s) > 32 { | |
| 41 | - | return errors.New("username must be 32 characters or less") | |
| 42 | - | } | |
| 43 | - | // Must start with lowercase letter | |
| 44 | - | if s[0] < 'a' || s[0] > 'z' { | |
| 45 | - | return errors.New("username must start with a lowercase letter") | |
| 46 | - | } | |
| 47 | - | // Can only contain lowercase letters, numbers, underscore, hyphen | |
| 48 | - | for _, c := range s { | |
| 49 | - | if !isUsernameChar(c) { | |
| 50 | - | return errors.New("username can only contain lowercase letters, numbers, underscore, and hyphen") | |
| 51 | - | } | |
| 52 | - | } | |
| 53 | - | return nil | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | // ValidatePassword validates a password (minimum length) | |
| 57 | - | func ValidatePassword(s string) error { | |
| 58 | - | if len(s) < 3 { | |
| 59 | - | return errors.New("password must be at least 3 characters") | |
| 60 | - | } | |
| 61 | - | return nil | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | // ValidateTimezone validates a timezone (basic check) | |
| 65 | - | func ValidateTimezone(s string) error { | |
| 66 | - | if s == "" { | |
| 67 | - | return errors.New("timezone cannot be empty") | |
| 68 | - | } | |
| 69 | - | // Basic validation - should contain / | |
| 70 | - | // More thorough validation would check against zoneinfo database | |
| 71 | - | return nil | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | // ValidateLocale validates a locale (basic check) | |
| 75 | - | func ValidateLocale(s string) error { | |
| 76 | - | if s == "" { | |
| 77 | - | return errors.New("locale cannot be empty") | |
| 78 | - | } | |
| 79 | - | // Basic validation - should match pattern like en_US.UTF-8 | |
| 80 | - | return nil | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | func isHostnameChar(c rune) bool { | |
| 84 | - | switch { | |
| 85 | - | case c >= 'a' && c <= 'z': | |
| 86 | - | return true | |
| 87 | - | case c >= 'A' && c <= 'Z': | |
| 88 | - | return true | |
| 89 | - | case c >= '0' && c <= '9': | |
| 90 | - | return true | |
| 91 | - | case c == '-': | |
| 92 | - | return true | |
| 93 | - | default: | |
| 94 | - | return false | |
| 95 | - | } | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | func isUsernameChar(c rune) bool { | |
| 99 | - | switch { | |
| 100 | - | case c >= 'a' && c <= 'z': | |
| 101 | - | return true | |
| 102 | - | case c >= '0' && c <= '9': | |
| 103 | - | return true | |
| 104 | - | case c == '_' || c == '-': | |
| 105 | - | return true | |
| 106 | - | default: | |
| 107 | - | return false | |
| 108 | - | } | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | // ValidatePasswordConfirmation validates that password and confirmation match | |
| 112 | - | func ValidatePasswordConfirmation(password *string) func(string) error { | |
| 113 | - | return func(confirm string) error { | |
| 114 | - | if confirm != *password { | |
| 115 | - | return errors.New("passwords do not match") | |
| 116 | - | } | |
| 117 | - | return nil | |
| 118 | - | } | |
| 119 | - | } | |
| @@ -1,119 +0,0 @@ | |||||
| 1 | - | package validation | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "errors" | |||
| 5 | - | "fmt" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | // ValidateNotEmpty validates that a string is not empty | |||
| 9 | - | func ValidateNotEmpty(field string) func(string) error { | |||
| 10 | - | return func(s string) error { | |||
| 11 | - | if s == "" { | |||
| 12 | - | return fmt.Errorf("%s cannot be empty", field) | |||
| 13 | - | } | |||
| 14 | - | return nil | |||
| 15 | - | } | |||
| 16 | - | } | |||
| 17 | - | ||||
| 18 | - | // ValidateHostname validates a hostname | |||
| 19 | - | func ValidateHostname(s string) error { | |||
| 20 | - | if s == "" { | |||
| 21 | - | return errors.New("hostname cannot be empty") | |||
| 22 | - | } | |||
| 23 | - | if len(s) > 63 { | |||
| 24 | - | return errors.New("hostname must be 63 characters or less") | |||
| 25 | - | } | |||
| 26 | - | // Basic validation | |||
| 27 | - | for _, c := range s { | |||
| 28 | - | if !isHostnameChar(c) { | |||
| 29 | - | return errors.New("hostname can only contain letters, numbers, and hyphens") | |||
| 30 | - | } | |||
| 31 | - | } | |||
| 32 | - | return nil | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | // ValidateUsername validates a username | |||
| 36 | - | func ValidateUsername(s string) error { | |||
| 37 | - | if s == "" { | |||
| 38 | - | return errors.New("username cannot be empty") | |||
| 39 | - | } | |||
| 40 | - | if len(s) > 32 { | |||
| 41 | - | return errors.New("username must be 32 characters or less") | |||
| 42 | - | } | |||
| 43 | - | // Must start with lowercase letter | |||
| 44 | - | if s[0] < 'a' || s[0] > 'z' { | |||
| 45 | - | return errors.New("username must start with a lowercase letter") | |||
| 46 | - | } | |||
| 47 | - | // Can only contain lowercase letters, numbers, underscore, hyphen | |||
| 48 | - | for _, c := range s { | |||
| 49 | - | if !isUsernameChar(c) { | |||
| 50 | - | return errors.New("username can only contain lowercase letters, numbers, underscore, and hyphen") | |||
| 51 | - | } | |||
| 52 | - | } | |||
| 53 | - | return nil | |||
| 54 | - | } | |||
| 55 | - | ||||
| 56 | - | // ValidatePassword validates a password (minimum length) | |||
| 57 | - | func ValidatePassword(s string) error { | |||
| 58 | - | if len(s) < 3 { | |||
| 59 | - | return errors.New("password must be at least 3 characters") | |||
| 60 | - | } | |||
| 61 | - | return nil | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | // ValidateTimezone validates a timezone (basic check) | |||
| 65 | - | func ValidateTimezone(s string) error { | |||
| 66 | - | if s == "" { | |||
| 67 | - | return errors.New("timezone cannot be empty") | |||
| 68 | - | } | |||
| 69 | - | // Basic validation - should contain / | |||
| 70 | - | // More thorough validation would check against zoneinfo database | |||
| 71 | - | return nil | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | // ValidateLocale validates a locale (basic check) | |||
| 75 | - | func ValidateLocale(s string) error { | |||
| 76 | - | if s == "" { | |||
| 77 | - | return errors.New("locale cannot be empty") | |||
| 78 | - | } | |||
| 79 | - | // Basic validation - should match pattern like en_US.UTF-8 | |||
| 80 | - | return nil | |||
| 81 | - | } | |||
| 82 | - | ||||
| 83 | - | func isHostnameChar(c rune) bool { | |||
| 84 | - | switch { | |||
| 85 | - | case c >= 'a' && c <= 'z': | |||
| 86 | - | return true | |||
| 87 | - | case c >= 'A' && c <= 'Z': | |||
| 88 | - | return true | |||
| 89 | - | case c >= '0' && c <= '9': | |||
| 90 | - | return true | |||
| 91 | - | case c == '-': | |||
| 92 | - | return true | |||
| 93 | - | default: | |||
| 94 | - | return false | |||
| 95 | - | } | |||
| 96 | - | } | |||
| 97 | - | ||||
| 98 | - | func isUsernameChar(c rune) bool { | |||
| 99 | - | switch { | |||
| 100 | - | case c >= 'a' && c <= 'z': | |||
| 101 | - | return true | |||
| 102 | - | case c >= '0' && c <= '9': | |||
| 103 | - | return true | |||
| 104 | - | case c == '_' || c == '-': | |||
| 105 | - | return true | |||
| 106 | - | default: | |||
| 107 | - | return false | |||
| 108 | - | } | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | // ValidatePasswordConfirmation validates that password and confirmation match | |||
| 112 | - | func ValidatePasswordConfirmation(password *string) func(string) error { | |||
| 113 | - | return func(confirm string) error { | |||
| 114 | - | if confirm != *password { | |||
| 115 | - | return errors.New("passwords do not match") | |||
| 116 | - | } | |||
| 117 | - | return nil | |||
| 118 | - | } | |||
| 119 | - | } | |||
D internal/verify/shell.go +0 -87
| @@ -1,87 +0,0 @@ | |||
| 1 | - | package verify | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "path/filepath" | |
| 6 | - | "strings" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/archup/internal/config" | |
| 9 | - | "github.com/bnema/archup/internal/interfaces" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // ShellConfig holds verification results for shell configuration | |
| 13 | - | type ShellConfig struct { | |
| 14 | - | BashrcExists bool | |
| 15 | - | ShellFilesOK bool | |
| 16 | - | BashrcSyntaxOK bool | |
| 17 | - | Warnings []string | |
| 18 | - | Errors []string | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | // ValidateShellConfigs performs comprehensive shell configuration verification | |
| 22 | - | func ValidateShellConfigs( | |
| 23 | - | fs interfaces.FileSystem, | |
| 24 | - | chrExec interfaces.ChrootExecutor, | |
| 25 | - | logPath string, | |
| 26 | - | userHome string, | |
| 27 | - | ) ShellConfig { | |
| 28 | - | result := ShellConfig{ | |
| 29 | - | Warnings: []string{}, | |
| 30 | - | Errors: []string{}, | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | // Check .bashrc exists | |
| 34 | - | bashrcPath := filepath.Join(userHome, ".bashrc") | |
| 35 | - | _, err := fs.Stat(bashrcPath) | |
| 36 | - | if fs.IsNotExist(err) { | |
| 37 | - | result.BashrcExists = false | |
| 38 | - | result.Warnings = append(result.Warnings, ".bashrc not found") | |
| 39 | - | } else if err != nil { | |
| 40 | - | result.Warnings = append(result.Warnings, fmt.Sprintf(".bashrc stat error: %v", err)) | |
| 41 | - | } else { | |
| 42 | - | result.BashrcExists = true | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | // Check shell config directory exists | |
| 46 | - | archupDefaultBash := filepath.Join(userHome, ".local", "share", "archup", "default", "bash") | |
| 47 | - | _, err = fs.Stat(archupDefaultBash) | |
| 48 | - | if fs.IsNotExist(err) { | |
| 49 | - | result.Warnings = append(result.Warnings, "shell config directory missing") | |
| 50 | - | } else if err == nil { | |
| 51 | - | result.ShellFilesOK = true | |
| 52 | - | } else { | |
| 53 | - | result.Warnings = append(result.Warnings, fmt.Sprintf("shell config directory stat error: %v", err)) | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | return result | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | // ValidateBashSyntax tests bash configuration syntax by loading bash in chroot | |
| 60 | - | func ValidateBashSyntax( | |
| 61 | - | chrExec interfaces.ChrootExecutor, | |
| 62 | - | logPath string, | |
| 63 | - | ) error { | |
| 64 | - | // Test bash syntax: bash -n ~/.bashrc in chroot | |
| 65 | - | cmd := "bash -n /root/.bashrc" | |
| 66 | - | if err := chrExec.ChrootExec(logPath, config.PathMnt, cmd); err != nil { | |
| 67 | - | return fmt.Errorf("bash syntax check failed: %w", err) | |
| 68 | - | } | |
| 69 | - | return nil | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | // GetShellConfigWarnings returns formatted warning messages | |
| 73 | - | func GetShellConfigWarnings(result ShellConfig) string { | |
| 74 | - | if len(result.Warnings) == 0 && len(result.Errors) == 0 { | |
| 75 | - | return "" | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | var msgs []string | |
| 79 | - | for _, w := range result.Warnings { | |
| 80 | - | msgs = append(msgs, fmt.Sprintf("[WARN] %s", w)) | |
| 81 | - | } | |
| 82 | - | for _, e := range result.Errors { | |
| 83 | - | msgs = append(msgs, fmt.Sprintf("[ERROR] %s", e)) | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | return strings.Join(msgs, "\n") | |
| 87 | - | } | |
| @@ -1,87 +0,0 @@ | |||||
| 1 | - | package verify | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "path/filepath" | |||
| 6 | - | "strings" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/archup/internal/config" | |||
| 9 | - | "github.com/bnema/archup/internal/interfaces" | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | // ShellConfig holds verification results for shell configuration | |||
| 13 | - | type ShellConfig struct { | |||
| 14 | - | BashrcExists bool | |||
| 15 | - | ShellFilesOK bool | |||
| 16 | - | BashrcSyntaxOK bool | |||
| 17 | - | Warnings []string | |||
| 18 | - | Errors []string | |||
| 19 | - | } | |||
| 20 | - | ||||
| 21 | - | // ValidateShellConfigs performs comprehensive shell configuration verification | |||
| 22 | - | func ValidateShellConfigs( | |||
| 23 | - | fs interfaces.FileSystem, | |||
| 24 | - | chrExec interfaces.ChrootExecutor, | |||
| 25 | - | logPath string, | |||
| 26 | - | userHome string, | |||
| 27 | - | ) ShellConfig { | |||
| 28 | - | result := ShellConfig{ | |||
| 29 | - | Warnings: []string{}, | |||
| 30 | - | Errors: []string{}, | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | // Check .bashrc exists | |||
| 34 | - | bashrcPath := filepath.Join(userHome, ".bashrc") | |||
| 35 | - | _, err := fs.Stat(bashrcPath) | |||
| 36 | - | if fs.IsNotExist(err) { | |||
| 37 | - | result.BashrcExists = false | |||
| 38 | - | result.Warnings = append(result.Warnings, ".bashrc not found") | |||
| 39 | - | } else if err != nil { | |||
| 40 | - | result.Warnings = append(result.Warnings, fmt.Sprintf(".bashrc stat error: %v", err)) | |||
| 41 | - | } else { | |||
| 42 | - | result.BashrcExists = true | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | // Check shell config directory exists | |||
| 46 | - | archupDefaultBash := filepath.Join(userHome, ".local", "share", "archup", "default", "bash") | |||
| 47 | - | _, err = fs.Stat(archupDefaultBash) | |||
| 48 | - | if fs.IsNotExist(err) { | |||
| 49 | - | result.Warnings = append(result.Warnings, "shell config directory missing") | |||
| 50 | - | } else if err == nil { | |||
| 51 | - | result.ShellFilesOK = true | |||
| 52 | - | } else { | |||
| 53 | - | result.Warnings = append(result.Warnings, fmt.Sprintf("shell config directory stat error: %v", err)) | |||
| 54 | - | } | |||
| 55 | - | ||||
| 56 | - | return result | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | - | // ValidateBashSyntax tests bash configuration syntax by loading bash in chroot | |||
| 60 | - | func ValidateBashSyntax( | |||
| 61 | - | chrExec interfaces.ChrootExecutor, | |||
| 62 | - | logPath string, | |||
| 63 | - | ) error { | |||
| 64 | - | // Test bash syntax: bash -n ~/.bashrc in chroot | |||
| 65 | - | cmd := "bash -n /root/.bashrc" | |||
| 66 | - | if err := chrExec.ChrootExec(logPath, config.PathMnt, cmd); err != nil { | |||
| 67 | - | return fmt.Errorf("bash syntax check failed: %w", err) | |||
| 68 | - | } | |||
| 69 | - | return nil | |||
| 70 | - | } | |||
| 71 | - | ||||
| 72 | - | // GetShellConfigWarnings returns formatted warning messages | |||
| 73 | - | func GetShellConfigWarnings(result ShellConfig) string { | |||
| 74 | - | if len(result.Warnings) == 0 && len(result.Errors) == 0 { | |||
| 75 | - | return "" | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | var msgs []string | |||
| 79 | - | for _, w := range result.Warnings { | |||
| 80 | - | msgs = append(msgs, fmt.Sprintf("[WARN] %s", w)) | |||
| 81 | - | } | |||
| 82 | - | for _, e := range result.Errors { | |||
| 83 | - | msgs = append(msgs, fmt.Sprintf("[ERROR] %s", e)) | |||
| 84 | - | } | |||
| 85 | - | ||||
| 86 | - | return strings.Join(msgs, "\n") | |||
| 87 | - | } | |||