diff --git a/README.md b/README.md index e0a2165..85e7964 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,6 @@ # hepctl -`hepctl` is a terminal app to install HEP packages from one place. - -Current scope: -- macOS: ROOT install -- Ubuntu: planned +`hepctl` is a terminal app to install HEP (High Energy Physics) packages from one place. ## Quick start @@ -13,11 +9,22 @@ go run ./cmd/hepctl ``` ```bash -go run ./cmd/hepctl install root +go run ./cmd/hepct ``` ## Commands -- `install root` +- `install packagename` - `help` - `quit` + +## Package checklist + +| Package | Ubuntu | macOS | +| ------- | ------ | ----- | +| ROOT | [ ] | [x] | +| PYTHIA | [ ] | [ ] | +| AMPT | [ ] | [ ] | +| HIJING | [ ] | [ ] | +| RIVET | [ ] | [ ] | +| EPOS4 | [ ] | [ ] | diff --git a/internal/install/root.go b/internal/install/root.go index 4f31c99..1d68ffe 100644 --- a/internal/install/root.go +++ b/internal/install/root.go @@ -9,6 +9,8 @@ import ( "os/exec" "runtime" "strings" + + "hepctl/internal/platform" ) const homebrewInstallCommand = `$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)` @@ -97,15 +99,62 @@ func (i *RootInstaller) Install(ctx context.Context) error { case "darwin": return i.installOnMacOS(ctx) case "linux": - if isUbuntu() { - return errors.New("ubuntu support is planned but not implemented yet") - } - return errors.New("linux detected, but only ubuntu is in scope for the next step") + return i.installOnLinux(ctx) default: return fmt.Errorf("unsupported OS: %s", runtime.GOOS) } } +// linuxManagerInfo maps a package manager binary to the install arguments for ROOT. +type linuxManagerInfo struct { + bin string // binary name (e.g. "pacman") + args []string // arguments after "sudo " (e.g. ["-S", "root"]) + pkgName string // human-friendly label for logging +} + +// ErrNeedsVersionSelection signals the caller (the TUI) that this distro +// requires the user to pick a ROOT version before installation can proceed. +var ErrNeedsVersionSelection = errors.New("version selection required") + +// distrosNeedingVersionSelect lists distros where ROOT is not in the system repos. +var distrosNeedingVersionSelect = []string{"ubuntu", "debian", "linuxmint"} + +// DistrosNeedingVersionSelect returns the list of distros that require +// manual ROOT version selection. +func DistrosNeedingVersionSelect() []string { + return distrosNeedingVersionSelect +} + +var linuxManagers = []linuxManagerInfo{ + {bin: "pacman", args: []string{"-S", "--noconfirm", "root"}, pkgName: "pacman"}, + {bin: "dnf", args: []string{"install", "-y", "root"}, pkgName: "dnf"}, + {bin: "yum", args: []string{"install", "-y", "root"}, pkgName: "yum"}, + {bin: "zypper", args: []string{"install", "-y", "root"}, pkgName: "zypper"}, + {bin: "eopkg", args: []string{"install", "-y", "root"}, pkgName: "eopkg"}, +} + +func (i *RootInstaller) installOnLinux(ctx context.Context) error { + // Check if this distro needs manual version selection (ROOT not in repos). + for _, d := range distrosNeedingVersionSelect { + if platform.IsDistro(d) { + return ErrNeedsVersionSelection + } + } + + mgr := platform.PackageManager() + + for _, m := range linuxManagers { + if m.bin == mgr { + cmdArgs := append([]string{m.bin}, m.args...) + fmt.Fprintf(i.out, "Detected %s. Installing ROOT with: sudo %s\n", m.pkgName, strings.Join(cmdArgs, " ")) + return i.runner.Run(ctx, "sudo", cmdArgs...) + } + } + + distro := platform.DistroName() + return fmt.Errorf("no supported package manager found on %s; detected manager: %s", distro, mgr) +} + func (i *RootInstaller) installOnMacOS(ctx context.Context) error { detected := i.DetectMacManagers() brewExists := detected.BrewExists @@ -192,13 +241,3 @@ func (i *RootInstaller) resolveBrewPath() (string, error) { return "", errors.New("homebrew installed but `brew` was not found in PATH; rerun your shell and try again") } - -func isUbuntu() bool { - data, err := os.ReadFile("/etc/os-release") - if err != nil { - return false - } - - lower := strings.ToLower(string(data)) - return strings.Contains(lower, "id=ubuntu") || strings.Contains(lower, "id_like=ubuntu") -} diff --git a/internal/install/versions.go b/internal/install/versions.go new file mode 100644 index 0000000..3891c55 --- /dev/null +++ b/internal/install/versions.go @@ -0,0 +1,320 @@ +package install + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const ( + // GitHub API endpoint for ROOT releases (paginated, newest first). + ghReleasesURL = "https://api.github.com/repos/root-project/root/releases?per_page=100" + // GitHub API endpoint for the release tagged "latest". + ghLatestURL = "https://api.github.com/repos/root-project/root/releases/latest" +) + +// ROOTVersion represents a single ROOT release. +type ROOTVersion struct { + Version string // e.g. "6.36.08" + Tag string // e.g. "v6-36-08" (GitHub tag) + Date string // e.g. "06 Feb 2026" + IsLatest bool +} + +// String returns a display label for the version. +func (v ROOTVersion) String() string { + s := v.Version + if v.Date != "" { + s += " (" + v.Date + ")" + } + if v.IsLatest { + s = s + " [latest] (recommended)" + } + return s +} + +// ghRelease is the subset of fields we need from the GitHub API response. +type ghRelease struct { + TagName string `json:"tag_name"` + PublishedAt string `json:"published_at"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + Assets []ghAsset `json:"assets"` +} + +// ghAsset represents a release asset from the GitHub API. +type ghAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Size int64 `json:"size"` +} + +// FetchROOTVersions fetches available ROOT versions from the GitHub releases +// API. Returns stable versions released within 8 months of the latest, newest +// first. The version marked "latest" on GitHub is flagged with IsLatest. +func FetchROOTVersions() ([]ROOTVersion, error) { + client := &http.Client{Timeout: 15 * time.Second} + + // 1. Get the tag marked as "latest" on GitHub. + latestTag, err := fetchLatestTag(client) + if err != nil { + return nil, err + } + + // 2. Get all releases. + releases, err := fetchAllReleases(client) + if err != nil { + return nil, err + } + + // 3. Convert to ROOTVersion, applying filters. + latestVer := tagToVersion(latestTag) + var versions []ROOTVersion + var newest time.Time + + for _, r := range releases { + if r.Draft || r.Prerelease { + continue + } + ver := tagToVersion(r.TagName) + if ver == "" { + continue + } + // Skip release candidates (tag might not set prerelease flag). + if strings.Contains(strings.ToLower(r.TagName), "rc") { + continue + } + + pubDate, _ := time.Parse(time.RFC3339, r.PublishedAt) + if pubDate.After(newest) { + newest = pubDate + } + + versions = append(versions, ROOTVersion{ + Version: ver, + Tag: r.TagName, + Date: formatDate(pubDate), + IsLatest: ver == latestVer, + }) + } + + if len(versions) == 0 { + return nil, fmt.Errorf("no stable ROOT releases found") + } + + // 4. Apply 8-month cutoff from the newest release. + if !newest.IsZero() { + cutoff := newest.AddDate(0, -8, 0) + var filtered []ROOTVersion + for _, v := range versions { + t, ok := parseDisplayDate(v.Date) + if !ok || !t.Before(cutoff) { + filtered = append(filtered, v) + } + } + versions = filtered + } + + return versions, nil +} + +// fetchLatestTag returns the tag_name of the release flagged as "latest". +func fetchLatestTag(client *http.Client) (string, error) { + req, _ := http.NewRequest("GET", ghLatestURL, nil) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("fetching latest release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub latest release returned status %d", resp.StatusCode) + } + + var r ghRelease + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return "", fmt.Errorf("decoding latest release: %w", err) + } + return r.TagName, nil +} + +// fetchAllReleases returns all releases from the GitHub API (up to 100). +func fetchAllReleases(client *http.Client) ([]ghRelease, error) { + req, _ := http.NewRequest("GET", ghReleasesURL, nil) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching releases: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub releases returned status %d", resp.StatusCode) + } + + var releases []ghRelease + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return nil, fmt.Errorf("decoding releases: %w", err) + } + return releases, nil +} + +// tagToVersion converts a GitHub tag like "v6-36-08" to "6.36.08". +// Returns "" for tags that don't match the expected pattern. +func tagToVersion(tag string) string { + t := strings.TrimPrefix(tag, "v") + if t == tag { + return "" // no "v" prefix, not a version tag + } + return strings.ReplaceAll(t, "-", ".") +} + +// formatDate formats a time.Time to "02 Jan 2006" for display. +func formatDate(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("02 Jan 2006") +} + +// parseDisplayDate parses a date string formatted by formatDate. +func parseDisplayDate(s string) (time.Time, bool) { + t, err := time.Parse("02 Jan 2006", s) + return t, err == nil +} + +// FindDistroAsset searches the GitHub release assets for the given tag and +// returns the download URL and filename matching the given distro and version. +// For example, distro="ubuntu", distroVer="24.04" would match +// "root_v6.36.08.Linux-ubuntu24.04-x86_64-gcc13.3.tar.gz". +func FindDistroAsset(tag, distro, distroVer string) (downloadURL, filename string, err error) { + client := &http.Client{Timeout: 15 * time.Second} + + url := fmt.Sprintf("https://api.github.com/repos/root-project/root/releases/tags/%s", tag) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := client.Do(req) + if err != nil { + return "", "", fmt.Errorf("fetching release %s: %w", tag, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("release %s returned status %d", tag, resp.StatusCode) + } + + var release ghRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", "", fmt.Errorf("decoding release %s: %w", tag, err) + } + + // Build the search pattern: e.g. "ubuntu24.04" + pattern := strings.ToLower(distro + distroVer) + + for _, asset := range release.Assets { + name := strings.ToLower(asset.Name) + if strings.Contains(name, pattern) && strings.HasSuffix(name, ".tar.gz") { + return asset.BrowserDownloadURL, asset.Name, nil + } + } + + return "", "", fmt.Errorf("no matching asset found for %s %s in release %s", distro, distroVer, tag) +} + +// DownloadROOTAsset downloads a file from the given URL to destPath. +// It creates the parent directory if it doesn't exist. +// The progress callback is called periodically with bytes downloaded so far +// and total bytes (-1 if unknown). +func DownloadROOTAsset(downloadURL, destPath string, progress func(downloaded, total int64)) error { + // Ensure destination directory exists. + destDir := filepath.Dir(destPath) + if err := os.MkdirAll(destDir, 0o755); err != nil { + return fmt.Errorf("creating directory %s: %w", destDir, err) + } + + // Start the download. + resp, err := http.Get(downloadURL) + if err != nil { + return fmt.Errorf("downloading %s: %w", filepath.Base(destPath), err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download returned status %d", resp.StatusCode) + } + + out, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("creating file %s: %w", destPath, err) + } + defer out.Close() + + total := resp.ContentLength + var downloaded int64 + buf := make([]byte, 32*1024) + + for { + n, readErr := resp.Body.Read(buf) + if n > 0 { + _, writeErr := out.Write(buf[:n]) + if writeErr != nil { + return fmt.Errorf("writing to %s: %w", destPath, writeErr) + } + downloaded += int64(n) + if progress != nil { + progress(downloaded, total) + } + } + if readErr != nil { + if readErr == io.EOF { + break + } + return fmt.Errorf("reading response: %w", readErr) + } + } + + return nil +} + +// ExtractROOT extracts the given tarball into destParent (e.g. $HOME/.local). +// It first removes any existing "ROOT" or "root" directories in destParent +// to ensure a clean install. After extraction, it ensures the directory is +// named "ROOT". +func ExtractROOT(tarballPath, destParent string) error { + // 1. Clean up old installation. + rootUpper := filepath.Join(destParent, "ROOT") + rootLower := filepath.Join(destParent, "root") + + if err := os.RemoveAll(rootUpper); err != nil { + return fmt.Errorf("removing old %s: %w", rootUpper, err) + } + if err := os.RemoveAll(rootLower); err != nil { + return fmt.Errorf("removing old %s: %w", rootLower, err) + } + + // 2. Extract tarball. + cmd := exec.Command("tar", "-xzf", tarballPath, "-C", destParent) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("tar extraction failed: %s: %w", string(out), err) + } + + // 3. Rename "root" to "ROOT" if needed. + // Standard ROOT tarballs extract to "root". + if _, err := os.Stat(rootLower); err == nil { + if err := os.Rename(rootLower, rootUpper); err != nil { + return fmt.Errorf("renaming %s to %s: %w", rootLower, rootUpper, err) + } + } + + return nil +} diff --git a/internal/install/versions_test.go b/internal/install/versions_test.go new file mode 100644 index 0000000..8f13e18 --- /dev/null +++ b/internal/install/versions_test.go @@ -0,0 +1,144 @@ +package install + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestTagToVersion(t *testing.T) { + tests := []struct { + tag string + want string + }{ + {"v6-36-08", "6.36.08"}, + {"v6-32-22", "6.32.22"}, + {"v6-30-02", "6.30.02"}, + {"no-v-prefix", ""}, + {"v6-38-00-rc1", "6.38.00.rc1"}, + } + for _, tt := range tests { + got := tagToVersion(tt.tag) + if got != tt.want { + t.Errorf("tagToVersion(%q) = %q, want %q", tt.tag, got, tt.want) + } + } +} + +func TestFormatDate(t *testing.T) { + tests := []struct { + rfc3339 string + want string + }{ + {"2026-02-05T20:00:19Z", "05 Feb 2026"}, + {"2025-11-27T10:00:00Z", "27 Nov 2025"}, + } + for _, tt := range tests { + parsed, err := time.Parse(time.RFC3339, tt.rfc3339) + if err != nil { + t.Fatalf("failed to parse %q: %v", tt.rfc3339, err) + } + got := formatDate(parsed) + if got != tt.want { + t.Errorf("formatDate(%q) = %q, want %q", tt.rfc3339, got, tt.want) + } + } +} + +func TestParseDisplayDate(t *testing.T) { + tests := []struct { + input string + ok bool + }{ + {"05 Feb 2026", true}, + {"27 Nov 2025", true}, + {"garbage", false}, + } + for _, tt := range tests { + _, ok := parseDisplayDate(tt.input) + if ok != tt.ok { + t.Errorf("parseDisplayDate(%q): got ok=%v, want %v", tt.input, ok, tt.ok) + } + } +} + +func TestROOTVersionString(t *testing.T) { + v := ROOTVersion{Version: "6.36.08", Date: "05 Feb 2026", IsLatest: true} + s := v.String() + if s != "6.36.08 (05 Feb 2026) [latest] (recommended)" { + t.Fatalf("unexpected string: %q", s) + } + + v2 := ROOTVersion{Version: "6.34.10", Date: "27 Jun 2025"} + s2 := v2.String() + if s2 != "6.34.10 (27 Jun 2025)" { + t.Fatalf("unexpected string: %q", s2) + } +} + +func TestExtractROOT(t *testing.T) { + // Create a temporary directory for the test. + tmpDir, err := os.MkdirTemp("", "extract-test") + if err != nil { + t.Fatalf("creating temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create a dummy "root" directory with some content. + srcDir := filepath.Join(tmpDir, "src") + rootDir := filepath.Join(srcDir, "root") + if err := os.MkdirAll(rootDir, 0o755); err != nil { + t.Fatalf("creating root dir: %v", err) + } + if err := os.WriteFile(filepath.Join(rootDir, "this_is_root"), []byte("content"), 0o644); err != nil { + t.Fatalf("writing content file: %v", err) + } + + // Create a tarball of the "root" directory. + tarballPath := filepath.Join(tmpDir, "root.tar.gz") + cmd := exec.Command("tar", "-czf", tarballPath, "-C", srcDir, "root") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("creating tarball failed: %s: %v", out, err) + } + + // Prepare destination directory. + destParent := filepath.Join(tmpDir, "dest") + if err := os.MkdirAll(destParent, 0o755); err != nil { + t.Fatalf("creating dest dir: %v", err) + } + + // Create a "ROOT" directory to simulate an existing installation (should be removed). + existingROOT := filepath.Join(destParent, "ROOT") + if err := os.Mkdir(existingROOT, 0o755); err != nil { + t.Fatalf("creating existing ROOT: %v", err) + } + if err := os.WriteFile(filepath.Join(existingROOT, "old_file"), []byte("old"), 0o644); err != nil { + t.Fatalf("writing old file: %v", err) + } + + // Run ExtractROOT. + if err := ExtractROOT(tarballPath, destParent); err != nil { + t.Fatalf("ExtractROOT failed: %v", err) + } + + // Verify "ROOT" exists and contains the new content. + newROOT := filepath.Join(destParent, "ROOT") + if _, err := os.Stat(newROOT); os.IsNotExist(err) { + t.Fatalf("expected ROOT directory to exist") + } + if _, err := os.Stat(filepath.Join(newROOT, "this_is_root")); os.IsNotExist(err) { + t.Fatalf("expected content file to exist in ROOT") + } + + // Verify "root" directory does not exist (renamed). + if _, err := os.Stat(filepath.Join(destParent, "root")); !os.IsNotExist(err) { + t.Fatalf("expected root directory to be gone (renamed)") + } + + // Verify old content is gone. + if _, err := os.Stat(filepath.Join(newROOT, "old_file")); !os.IsNotExist(err) { + t.Fatalf("expected old file to be removed") + } +} diff --git a/internal/platform/platform.go b/internal/platform/platform.go new file mode 100644 index 0000000..9bf80f1 --- /dev/null +++ b/internal/platform/platform.go @@ -0,0 +1,141 @@ +package platform + +import ( + "os" + "os/exec" + "strings" +) + +// DistroName returns the Linux distribution name by parsing /etc/os-release. +// Falls back to "Linux" if the file is missing or the NAME field is absent. +func DistroName() string { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return "Linux" + } + return parseDistroName(string(data)) +} + +// DistroID returns the ID from /etc/os-release (e.g. "ubuntu", "fedora"). +// Returns "linux" if unavailable. +func DistroID() string { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return "linux" + } + return parseDistroID(string(data)) +} + +func parseDistroID(content string) string { + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "ID=") { + val := strings.TrimPrefix(line, "ID=") + val = strings.Trim(val, `"`) + return strings.TrimSpace(val) + } + } + return "linux" +} + +// parseDistroName extracts the NAME field from os-release content. +func parseDistroName(content string) string { + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "NAME=") { + val := strings.TrimPrefix(line, "NAME=") + val = strings.Trim(val, `"`) + val = strings.TrimSpace(val) + if val != "" { + return val + } + } + } + return "Linux" +} + +// DistroVersion returns the VERSION_ID from /etc/os-release (e.g. "24.04"). +// Returns "" if unavailable. +func DistroVersion() string { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return "" + } + return parseDistroVersion(string(data)) +} + +func parseDistroVersion(content string) string { + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "VERSION_ID=") { + val := strings.TrimPrefix(line, "VERSION_ID=") + val = strings.Trim(val, `"`) + return strings.TrimSpace(val) + } + } + return "" +} + +// knownManagers is the ordered list of package managers to probe for. +var knownManagers = []string{ + "apt", + "dnf", + "yum", + "pacman", + "zypper", + "apk", + "emerge", + "xbps-install", + "nix-env", + "eopkg", +} + +// PackageManager returns the name of the first detected package manager, +// or "unknown" if none is found. +func PackageManager() string { + return detectPackageManager(exec.LookPath) +} + +func detectPackageManager(lookPath func(string) (string, error)) string { + for _, mgr := range knownManagers { + if path, err := lookPath(mgr); err == nil && path != "" { + return mgr + } + } + return "unknown" +} + +// IsDistro reports whether the running Linux distribution matches the given +// name (case-insensitive). It reads /etc/os-release and checks both ID and +// ID_LIKE fields. +func IsDistro(name string) bool { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return false + } + return isDistroFromContent(string(data), name) +} + +func isDistroFromContent(content, name string) bool { + target := strings.ToLower(name) + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "ID=") { + val := strings.TrimPrefix(line, "ID=") + val = strings.Trim(val, `"`) + if strings.ToLower(val) == target { + return true + } + } + if strings.HasPrefix(line, "ID_LIKE=") { + val := strings.TrimPrefix(line, "ID_LIKE=") + val = strings.Trim(val, `"`) + for _, part := range strings.Fields(val) { + if strings.ToLower(part) == target { + return true + } + } + } + } + return false +} diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go new file mode 100644 index 0000000..b7fcd27 --- /dev/null +++ b/internal/platform/platform_test.go @@ -0,0 +1,190 @@ +package platform + +import ( + "errors" + "testing" +) + +func TestParseDistroNameUbuntu(t *testing.T) { + content := `NAME="Ubuntu" +VERSION="22.04.3 LTS (Jammy Jellyfish)" +ID=ubuntu +ID_LIKE=debian +` + got := parseDistroName(content) + if got != "Ubuntu" { + t.Fatalf("expected Ubuntu, got %q", got) + } +} + +func TestParseDistroNameFedora(t *testing.T) { + content := `NAME="Fedora Linux" +VERSION="39 (Workstation Edition)" +ID=fedora +` + got := parseDistroName(content) + if got != "Fedora Linux" { + t.Fatalf("expected Fedora Linux, got %q", got) + } +} + +func TestParseDistroNameArch(t *testing.T) { + content := `NAME="Arch Linux" +ID=arch +` + got := parseDistroName(content) + if got != "Arch Linux" { + t.Fatalf("expected Arch Linux, got %q", got) + } +} + +func TestParseDistroNameEmpty(t *testing.T) { + got := parseDistroName("") + if got != "Linux" { + t.Fatalf("expected Linux fallback, got %q", got) + } +} + +func TestParseDistroNameMalformed(t *testing.T) { + content := `garbage=value +something else +` + got := parseDistroName(content) + if got != "Linux" { + t.Fatalf("expected Linux fallback, got %q", got) + } +} + +func TestParseDistroNameNoQuotes(t *testing.T) { + content := `NAME=Solus +ID=solus +` + got := parseDistroName(content) + if got != "Solus" { + t.Fatalf("expected Solus, got %q", got) + } +} + +func TestDetectPackageManagerApt(t *testing.T) { + lookup := func(name string) (string, error) { + if name == "apt" { + return "/usr/bin/apt", nil + } + return "", errors.New("not found") + } + got := detectPackageManager(lookup) + if got != "apt" { + t.Fatalf("expected apt, got %q", got) + } +} + +func TestDetectPackageManagerPacman(t *testing.T) { + lookup := func(name string) (string, error) { + if name == "pacman" { + return "/usr/bin/pacman", nil + } + return "", errors.New("not found") + } + got := detectPackageManager(lookup) + if got != "pacman" { + t.Fatalf("expected pacman, got %q", got) + } +} + +func TestDetectPackageManagerNone(t *testing.T) { + lookup := func(_ string) (string, error) { + return "", errors.New("not found") + } + got := detectPackageManager(lookup) + if got != "unknown" { + t.Fatalf("expected unknown, got %q", got) + } +} + +func TestDetectPackageManagerPriority(t *testing.T) { + // When both apt and dnf exist, apt should win (earlier in list). + lookup := func(name string) (string, error) { + if name == "apt" { + return "/usr/bin/apt", nil + } + if name == "dnf" { + return "/usr/bin/dnf", nil + } + return "", errors.New("not found") + } + got := detectPackageManager(lookup) + if got != "apt" { + t.Fatalf("expected apt (higher priority), got %q", got) + } +} + +func TestIsDistroFromContentUbuntu(t *testing.T) { + content := `NAME="Ubuntu" +ID=ubuntu +ID_LIKE=debian +` + if !isDistroFromContent(content, "ubuntu") { + t.Fatal("expected ubuntu to match") + } + if !isDistroFromContent(content, "debian") { + t.Fatal("expected debian to match via ID_LIKE") + } + if isDistroFromContent(content, "fedora") { + t.Fatal("expected fedora not to match") + } +} + +func TestIsDistroFromContentCaseInsensitive(t *testing.T) { + content := `NAME="Ubuntu" +ID=ubuntu +` + if !isDistroFromContent(content, "Ubuntu") { + t.Fatal("expected case-insensitive match") + } +} + +func TestIsDistroFromContentIDLikeMultiple(t *testing.T) { + content := `NAME="Linux Mint" +ID=linuxmint +ID_LIKE="ubuntu debian" +` + if !isDistroFromContent(content, "ubuntu") { + t.Fatal("expected ubuntu match via ID_LIKE") + } + if !isDistroFromContent(content, "debian") { + t.Fatal("expected debian match via ID_LIKE") + } +} + +func TestParseDistroID(t *testing.T) { + content := `NAME="Ubuntu" +VERSION="22.04.3 LTS (Jammy Jellyfish)" +ID=ubuntu +ID_LIKE=debian +` + if got := parseDistroID(content); got != "ubuntu" { + t.Errorf("expected ubuntu, got %q", got) + } +} + +func TestParseDistroIDFallback(t *testing.T) { + if got := parseDistroID(""); got != "linux" { + t.Errorf("expected linux fallback, got %q", got) + } +} + +func TestParseDistroVersion(t *testing.T) { + content := `NAME="Ubuntu" +VERSION_ID="22.04" +` + if got := parseDistroVersion(content); got != "22.04" { + t.Errorf("expected 22.04, got %q", got) + } +} + +func TestParseDistroVersionQuotes(t *testing.T) { + content := `VERSION_ID="39"` + if got := parseDistroVersion(content); got != "39" { + t.Errorf("expected 39, got %q", got) + } +} diff --git a/internal/platform/shell.go b/internal/platform/shell.go new file mode 100644 index 0000000..3c2dc99 --- /dev/null +++ b/internal/platform/shell.go @@ -0,0 +1,87 @@ +package platform + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// DetectShell returns the user's shell name (e.g. "bash", "zsh", "fish"). +// Falls back to "bash" if detection fails. +func DetectShell() string { + shell := os.Getenv("SHELL") + if shell == "" { + return "bash" + } + return filepath.Base(shell) +} + +// userHomeDirFunc is a variable to allow mocking in tests. +var userHomeDirFunc = os.UserHomeDir + +// ConfigureShell appends the ROOT source command to the user's shell configuration file. +// It returns the path to the modified RC file, or an error if the shell is unsupported. +func ConfigureShell(shell string) (string, error) { + home, err := userHomeDirFunc() + if err != nil { + return "", fmt.Errorf("getting home dir: %w", err) + } + + var rcFile string + var sourceCommand string + + switch shell { + case "fish": + // fish config is usually in ~/.config/fish/config.fish + rcFile = filepath.Join(home, ".config", "fish", "config.fish") + sourceCommand = fmt.Sprintf("source %s/.local/ROOT/bin/thisroot.fish", home) + case "csh", "tcsh": + // csh/tcsh usually use ~/.cshrc or ~/.tcshrc + // simpler to target .cshrc as it's often sourced by both or represents base config + rcFile = filepath.Join(home, ".cshrc") + sourceCommand = fmt.Sprintf("source %s/.local/ROOT/bin/thisroot.csh", home) + case "zsh": + rcFile = filepath.Join(home, ".zshrc") + sourceCommand = fmt.Sprintf("source %s/.local/ROOT/bin/thisroot.sh", home) + case "bash": + rcFile = filepath.Join(home, ".bashrc") + sourceCommand = fmt.Sprintf("source %s/.local/ROOT/bin/thisroot.sh", home) + case "sh": + rcFile = filepath.Join(home, ".profile") + sourceCommand = fmt.Sprintf("source %s/.local/ROOT/bin/thisroot.sh", home) + default: + return "", fmt.Errorf("unsupported shell: %s. Please manually source thisroot.sh", shell) + } + + // Ensure directory exists for config files (e.g. ~/.config/fish) + if err := os.MkdirAll(filepath.Dir(rcFile), 0755); err != nil { + return "", fmt.Errorf("creating config dir %s: %w", filepath.Dir(rcFile), err) + } + + // Read existing content to check if already configured + content, err := os.ReadFile(rcFile) + if err != nil && !os.IsNotExist(err) { + return "", fmt.Errorf("reading %s: %w", rcFile, err) + } + + // Check if source command is already present + // We check for the script name to be safe (e.g. just "thisroot.sh") + scriptName := filepath.Base(strings.Fields(sourceCommand)[1]) + if strings.Contains(string(content), scriptName) { + return rcFile, nil // Already configured + } + + // Append configuration + f, err := os.OpenFile(rcFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return "", fmt.Errorf("opening %s: %w", rcFile, err) + } + defer f.Close() + + if _, err := f.WriteString(fmt.Sprintf("\n# ROOT environment configuration\n%s\n", sourceCommand)); err != nil { + return "", fmt.Errorf("writing to %s: %w", rcFile, err) + } + + return rcFile, nil +} diff --git a/internal/platform/shell_test.go b/internal/platform/shell_test.go new file mode 100644 index 0000000..aa97f54 --- /dev/null +++ b/internal/platform/shell_test.go @@ -0,0 +1,87 @@ +package platform + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDetectShell(t *testing.T) { + // Backup and restore environment + originalShell := os.Getenv("SHELL") + defer os.Setenv("SHELL", originalShell) + + os.Setenv("SHELL", "/bin/zsh") + if got := DetectShell(); got != "zsh" { + t.Errorf("expected zsh, got %q", got) + } + + os.Setenv("SHELL", "/usr/local/bin/fish") + if got := DetectShell(); got != "fish" { + t.Errorf("expected fish, got %q", got) + } + + os.Unsetenv("SHELL") + if got := DetectShell(); got != "bash" { + t.Errorf("expected fallback bash, got %q", got) + } +} + +func TestConfigureShell(t *testing.T) { + // Mock userHomeDirFunc + tmpHome, err := os.MkdirTemp("", "shell-test") + if err != nil { + t.Fatalf("creating temp home: %v", err) + } + defer os.RemoveAll(tmpHome) + + oldHomeFunc := userHomeDirFunc + userHomeDirFunc = func() (string, error) { + return tmpHome, nil + } + defer func() { userHomeDirFunc = oldHomeFunc }() + + tests := []struct { + shell string + wantRC string + wantSource string + }{ + {"bash", ".bashrc", "thisroot.sh"}, + {"zsh", ".zshrc", "thisroot.sh"}, + {"fish", ".config/fish/config.fish", "thisroot.fish"}, + {"csh", ".cshrc", "thisroot.csh"}, + {"sh", ".profile", "thisroot.sh"}, + } + + for _, tt := range tests { + t.Run(tt.shell, func(t *testing.T) { + rcPath, err := ConfigureShell(tt.shell) + if err != nil { + t.Fatalf("ConfigureShell failed: %v", err) + } + + // Verify correct file path + expectedPath := filepath.Join(tmpHome, tt.wantRC) + if rcPath != expectedPath { + t.Errorf("expected RC path %q, got %q", expectedPath, rcPath) + } + + // Verify file content + content, err := os.ReadFile(rcPath) + if err != nil { + t.Fatalf("reading RC file: %v", err) + } + if !strings.Contains(string(content), tt.wantSource) { + t.Errorf("expected source command for %q in %s", tt.wantSource, rcPath) + } + }) + } +} + +func TestConfigureShellUnsupported(t *testing.T) { + _, err := ConfigureShell("unknownshell") + if err == nil { + t.Error("expected error for unsupported shell, got nil") + } +} diff --git a/internal/ui/dashboard.go b/internal/ui/dashboard.go index 46577ed..d27aac5 100644 --- a/internal/ui/dashboard.go +++ b/internal/ui/dashboard.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "errors" + "fmt" "io" "os" "os/exec" @@ -16,6 +17,7 @@ import ( "unicode" "hepctl/internal/install" + "hepctl/internal/platform" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -41,6 +43,35 @@ type installEvent struct { type installEventMsg installEvent +// installResultMsg indicates the result of an extraction/install operation. +type installResultMsg struct { + err error +} + +type sudoResultMsg struct { + err error +} + +type versionsFetchedMsg struct { + versions []install.ROOTVersion + err error +} + +type assetFoundMsg struct { + url string + filename string +} + +type downloadEvent struct { + filename string + downloaded int64 + total int64 + done bool + err error +} + +type downloadEventMsg downloadEvent + type tickMsg struct{} type dashboardModel struct { @@ -65,6 +96,19 @@ type dashboardModel struct { awaitingManager bool selectedManager install.ManagerChoice + passwordMode bool + passwordInput []rune + + versionSelectMode bool + availableVersions []install.ROOTVersion + versionCursor int + versionFetching bool + selectedROOTVersion string + + downloading bool + downloadProgress float64 + downloadEvents chan downloadEvent + running bool spin int events chan installEvent @@ -121,6 +165,72 @@ func (m dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.spin = (m.spin + 1) % 4 return m, tickCmd() } + case sudoResultMsg: + if typed.err != nil { + m.status = "Authentication failed. Try again with `install root`." + m.statusError = true + return m, nil + } + m.status = "Authenticated. Starting ROOT installation..." + m.statusError = false + m.logs = append(m.logs, "[ok] sudo credentials cached") + return m, m.startInstallRoot() + case versionsFetchedMsg: + m.versionFetching = false + if typed.err != nil { + m.status = "Failed to fetch versions: " + typed.err.Error() + m.statusError = true + m.logs = append(m.logs, "[error] "+typed.err.Error()) + return m, nil + } + if len(typed.versions) == 0 { + m.status = "No ROOT versions found." + m.statusError = true + return m, nil + } + m.availableVersions = typed.versions + m.versionCursor = 0 + m.versionSelectMode = true + m.status = "Select a ROOT version." + m.statusError = false + m.logs = append(m.logs, "[ok] fetched "+fmt.Sprintf("%d", len(typed.versions))+" versions") + return m, nil + case assetFoundMsg: + m.status = "Downloading " + typed.filename + "..." + m.statusError = false + m.logs = append(m.logs, "[ok] found asset: "+typed.filename) + m.downloading = true + m.downloadEvents = make(chan downloadEvent, 256) + return m, startDownload(typed.url, typed.filename, m.downloadEvents) + case downloadEventMsg: + ev := downloadEvent(typed) + if ev.done { + m.downloading = false + if ev.err != nil { + m.status = "Download failed: " + ev.err.Error() + m.statusError = true + m.logs = append(m.logs, "[error] download failed: "+ev.err.Error()) + m.downloadEvents = nil + return m, nil + } + m.status = "Download complete. Extracting to ~/.local/..." + m.statusError = false + m.logs = append(m.logs, "[ok] downloaded to "+ev.filename) + + // Trigger extraction. + home, _ := os.UserHomeDir() // unlikely to fail if download succeeded + tarballPath := filepath.Join(os.TempDir(), ev.filename) + destParent := filepath.Join(home, ".local") + + return m, extractCmd(tarballPath, destParent) + } + if ev.total > 0 { + m.downloadProgress = float64(ev.downloaded) / float64(ev.total) + m.status = fmt.Sprintf("Downloading %s... %.0f%%", ev.filename, m.downloadProgress*100) + } + if m.downloadEvents != nil { + return m, waitForDownloadEvent(m.downloadEvents) + } case installEventMsg: ev := installEvent(typed) if ev.line != "" { @@ -144,7 +254,93 @@ func (m dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.events != nil { return m, waitForInstallEvent(m.events) } + case installResultMsg: + if typed.err != nil { + m.status = "Extraction failed: " + typed.err.Error() + m.statusError = true + m.logs = append(m.logs, "[error] extraction failed: "+typed.err.Error()) + } else { + m.logs = append(m.logs, "[ok] extracted ROOT to ~/.local/ROOT") + + // Configure shell environment. + shell := platform.DetectShell() + rcFile, err := platform.ConfigureShell(shell) + if err != nil { + m.status = fmt.Sprintf("ROOT installed, but shell config failed: %s", err.Error()) + m.statusError = true // Warning, not fatal for install + m.logs = append(m.logs, "[warn] shell config failed: "+err.Error()) + } else { + m.status = fmt.Sprintf("ROOT installed! Source added to %s", rcFile) + m.statusError = false + m.logs = append(m.logs, "[ok] configured "+shell+" in "+rcFile) + } + } + return m, nil case tea.KeyMsg: + // Version selection mode: intercept keys. + if m.versionSelectMode { + switch typed.String() { + case "up", "k": + if m.versionCursor > 0 { + m.versionCursor-- + } + case "down", "j": + if m.versionCursor < len(m.availableVersions)-1 { + m.versionCursor++ + } + case "enter": + selected := m.availableVersions[m.versionCursor] + m.versionSelectMode = false + m.selectedROOTVersion = selected.Version + m.availableVersions = nil + m.status = "Finding compatible ROOT asset..." + m.statusError = false + m.logs = append(m.logs, "[ok] selected ROOT "+selected.Version) + return m, findAssetCmd(selected.Tag) + case "esc", "ctrl+c": + m.versionSelectMode = false + m.availableVersions = nil + m.status = "Version selection canceled." + m.statusError = false + } + return m, nil + } + + // Password mode: handle input separately. + if m.passwordMode { + switch typed.String() { + case "enter": + password := string(m.passwordInput) + // Clear password from model immediately. + for i := range m.passwordInput { + m.passwordInput[i] = 0 + } + m.passwordInput = nil + m.passwordMode = false + m.status = "Authenticating..." + m.statusError = false + return m, trySudoAuth(password) + case "esc", "ctrl+c": + for i := range m.passwordInput { + m.passwordInput[i] = 0 + } + m.passwordInput = nil + m.passwordMode = false + m.status = "Password entry canceled." + m.statusError = false + return m, nil + case "backspace": + if len(m.passwordInput) > 0 { + m.passwordInput = m.passwordInput[:len(m.passwordInput)-1] + } + default: + if len(typed.Runes) > 0 { + m.passwordInput = append(m.passwordInput, typed.Runes...) + } + } + return m, nil + } + suggestions := m.installSuggestions() switch typed.String() { case "left": @@ -298,6 +494,25 @@ func (m *dashboardModel) handleCommand(raw string) tea.Cmd { return nil } } + // On Ubuntu/Debian, ROOT isn't in the repos — offer version selection. + if runtime.GOOS == "linux" && needsVersionSelection() { + m.versionFetching = true + m.status = "Fetching available ROOT versions..." + m.statusError = false + m.logs = append(m.logs, "$ fetching root.cern releases...") + return fetchVersionsCmd() + } + // On Linux, install commands use sudo. Pre-authenticate if needed. + if runtime.GOOS == "linux" { + if needsSudoAuth() { + m.selectedManager = install.ManagerAuto + m.passwordMode = true + m.passwordInput = nil + m.status = "Enter sudo password to continue." + m.statusError = false + return nil + } + } m.selectedManager = install.ManagerAuto m.status = "Starting ROOT installation..." m.statusError = false @@ -429,7 +644,17 @@ func (m dashboardModel) View() string { hint := muted.Render("try: ") + lipgloss.NewStyle().Foreground(lipgloss.Color("#B6C3F2")).Render("install root") + muted.Render(" | help | quit") - if m.awaitingManager { + if m.versionSelectMode { + hint = lipgloss.NewStyle().Foreground(lipgloss.Color("#B6C3F2")).Render("↑/↓ or j/k") + + muted.Render(" to move, ") + + lipgloss.NewStyle().Foreground(lipgloss.Color("#B6C3F2")).Render("Enter") + + muted.Render(" to select, ") + + lipgloss.NewStyle().Foreground(lipgloss.Color("#B6C3F2")).Render("Esc") + + muted.Render(" to cancel") + } else if m.passwordMode { + hint = lipgloss.NewStyle().Foreground(lipgloss.Color("#F2C85B")).Render("🔒 ") + + muted.Render("Enter to submit, Esc to cancel") + } else if m.awaitingManager { hint = muted.Render("choose manager: ") + lipgloss.NewStyle().Foreground(lipgloss.Color("#B6C3F2")).Render("brew") + muted.Render(" | ") + @@ -490,6 +715,11 @@ func (m dashboardModel) View() string { } func (m dashboardModel) renderActivity(width int) string { + // Version selection mode: render the version picker instead of logs. + if m.versionSelectMode && len(m.availableVersions) > 0 { + return m.renderVersionPicker(width) + } + head := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8E1FF")).Bold(true).Render("activity") lines := []string{head} @@ -505,8 +735,56 @@ func (m dashboardModel) renderActivity(width int) string { lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("#5F6C95")).Render("No activity yet.")) } else { logStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#9EA9CE")).MaxWidth(width) + okStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")).MaxWidth(width) + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")).MaxWidth(width) for _, line := range m.logs { - lines = append(lines, logStyle.Render(line)) + switch { + case strings.HasPrefix(line, "[ok]"): + lines = append(lines, okStyle.Render(line)) + case strings.HasPrefix(line, "[error]"): + lines = append(lines, errStyle.Render(line)) + default: + lines = append(lines, logStyle.Render(line)) + } + } + } + + return strings.Join(lines, "\n") +} + +func (m dashboardModel) renderVersionPicker(_ int) string { + head := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8E1FF")).Bold(true).Render("Select ROOT version") + lines := []string{head, ""} + + normal := lipgloss.NewStyle().Foreground(lipgloss.Color("#9EA9CE")) + active := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#0B1020")). + Background(lipgloss.Color("#B6C3F2")). + Bold(true). + Padding(0, 1) + latestBadge := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#0B1020")). + Background(lipgloss.Color("#7DCEA0")). + Bold(true). + Padding(0, 1) + dateMuted := lipgloss.NewStyle().Foreground(lipgloss.Color("#66739A")) + + for idx, ver := range m.availableVersions { + label := ver.Version + dateStr := "" + if ver.Date != "" { + dateStr = dateMuted.Render(" " + ver.Date) + } + badge := "" + if ver.IsLatest { + recommended := lipgloss.NewStyle().Foreground(lipgloss.Color("#7DCEA0")).Render(" (recommended)") + badge = " " + latestBadge.Render("latest") + recommended + } + + if idx == m.versionCursor { + lines = append(lines, " "+active.Render("> "+label)+badge+dateStr) + } else { + lines = append(lines, " "+normal.Render(" "+label)+badge+dateStr) } } @@ -514,6 +792,14 @@ func (m dashboardModel) renderActivity(width int) string { } func (m dashboardModel) renderInputLine() string { + if m.versionSelectMode { + prefix := lipgloss.NewStyle().Foreground(lipgloss.Color("#8FA1D6")).Render("> ") + placeholder := lipgloss.NewStyle().Foreground(lipgloss.Color("#5F6C95")).Render("select a ROOT version above...") + return prefix + placeholder + } + if m.passwordMode { + return m.renderPasswordLine() + } if m.running { prefix := lipgloss.NewStyle().Foreground(lipgloss.Color("#8FA1D6")).Render("> ") placeholder := lipgloss.NewStyle().Foreground(lipgloss.Color("#5F6C95")).Render("installation in progress...") @@ -551,6 +837,37 @@ func (m dashboardModel) renderInputLine() string { return prefix + left + cursor + right } +func (m dashboardModel) renderPasswordLine() string { + lockIcon := lipgloss.NewStyle().Foreground(lipgloss.Color("#F2C85B")).Render("🔒 ") + label := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8E1FF")).Bold(true).Render("sudo password: ") + + masked := strings.Repeat("•", len(m.passwordInput)) + maskedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#B6C3F2")).Render(masked) + + cursor := lipgloss.NewStyle(). + Background(lipgloss.Color("#D8E1FF")). + Foreground(lipgloss.Color("#0B1020")). + Render(" ") + + return lockIcon + label + maskedStyle + cursor +} + +// trySudoAuth validates the password using "sudo -S -v" (reads password from stdin). +// Returns a tea.Cmd that runs asynchronously and produces a sudoResultMsg. +func trySudoAuth(password string) tea.Cmd { + return func() tea.Msg { + cmd := exec.Command("sudo", "-S", "-v") + cmd.Stdin = strings.NewReader(password + "\n") + err := cmd.Run() + // Zero out the password string's backing bytes (best-effort). + pw := []byte(password) + for i := range pw { + pw[i] = 0 + } + return sudoResultMsg{err: err} + } +} + func insertAt(base []rune, pos int, chunk []rune) []rune { if pos < 0 { pos = 0 @@ -662,28 +979,61 @@ func platformLabel() string { case "darwin": return "macOS" case "linux": - return "Linux" + distro := platform.DistroName() + return distro + " (Linux)" default: return runtime.GOOS } } +// needsSudoAuth reports whether sudo credentials need to be (re-)authenticated. +// It runs "sudo -n true" which succeeds silently if credentials are cached. +func needsSudoAuth() bool { + return exec.Command("sudo", "-n", "true").Run() != nil +} + +// needsVersionSelection reports whether the current Linux distro requires +// manual ROOT version selection (i.e. ROOT is not in the distro's repos). +func needsVersionSelection() bool { + for _, d := range install.DistrosNeedingVersionSelect() { + if platform.IsDistro(d) { + return true + } + } + return false +} + +// fetchVersionsCmd returns a tea.Cmd that fetches ROOT versions in the background. +func fetchVersionsCmd() tea.Cmd { + return func() tea.Msg { + versions, err := install.FetchROOTVersions() + return versionsFetchedMsg{versions: versions, err: err} + } +} + func detectManagerState() string { - if runtime.GOOS != "darwin" { - return "ubuntu pending" - } - - probe := install.NewRootInstaller(install.DefaultRunner{}, nil, io.Discard) - detected := probe.DetectMacManagers() - switch { - case detected.BrewExists && detected.PortExists: - return "brew + port" - case detected.BrewExists: - return "brew" - case detected.PortExists: - return "port" + switch runtime.GOOS { + case "linux": + mgr := platform.PackageManager() + if mgr == "unknown" { + return "none detected" + } + return mgr + case "darwin": + probe := install.NewRootInstaller(install.DefaultRunner{}, nil, io.Discard) + detected := probe.DetectMacManagers() + switch { + case detected.BrewExists && detected.PortExists: + return "brew + port" + case detected.BrewExists: + return "brew" + case detected.PortExists: + return "port" + default: + return "none (will install brew)" + } default: - return "none (will install brew)" + return "unsupported" } } @@ -776,3 +1126,64 @@ func (w *eventWriter) Flush() { } w.buf.Reset() } + +// findAssetCmd searches for a compatible ROOT asset for the current distro. +func findAssetCmd(tag string) tea.Cmd { + return func() tea.Msg { + distro := platform.DistroID() + ver := platform.DistroVersion() + url, filename, err := install.FindDistroAsset(tag, distro, ver) + if err != nil { + return downloadEventMsg{ + done: true, + err: fmt.Errorf("finding asset for %s %s: %w", distro, ver, err), + } + } + return assetFoundMsg{url: url, filename: filename} + } +} + +func startDownload(url, filename string, events chan downloadEvent) tea.Cmd { + go runDownload(url, filename, events) + return waitForDownloadEvent(events) +} + +func runDownload(url, filename string, events chan<- downloadEvent) { + // Send initial event + events <- downloadEvent{filename: filename, total: -1} + + // Download to system temp directory (e.g. /tmp/root_v6....tar.gz). + destPath := filepath.Join(os.TempDir(), filename) + + err := install.DownloadROOTAsset(url, destPath, func(current, total int64) { + events <- downloadEvent{ + filename: filename, + downloaded: current, + total: total, + } + }) + + events <- downloadEvent{ + filename: filename, + done: true, + err: err, + } +} + +func waitForDownloadEvent(events chan downloadEvent) tea.Cmd { + if events == nil { + return nil + } + return func() tea.Msg { + return downloadEventMsg(<-events) + } +} + +func extractCmd(tarballPath, destParent string) tea.Cmd { + return func() tea.Msg { + err := install.ExtractROOT(tarballPath, destParent) + // Cleanup tarball regardless of success/failure + _ = os.Remove(tarballPath) + return installResultMsg{err: err} + } +}