From 6d00fd729547967bb14acbbbdfce862f37192767 Mon Sep 17 00:00:00 2001 From: purian23 Date: Sun, 25 Oct 2026 03:19:41 -0400 Subject: [PATCH] feat(duxinstall): config support for immutable image-based hosts - detect ostree/bootc/transactional hosts - official dms doctor & dms command support - updated installation instructions on dux labs docs Port 2.0 (cherry picked from commit 696be97018d3d7bfc5eb8c6839f4989484e52e12) --- core/cmd/dms/assets/cli-policy.default.json | 11 +- core/cmd/dms/commands_doctor.go | 12 +- core/cmd/dms/commands_features.go | 4 + core/cmd/dms/immutable_policy.go | 90 +------- core/cmd/dms/immutable_policy_test.go | 14 +- core/internal/distros/fedora.go | 98 ++++++++- core/internal/distros/immutable.go | 127 ++++++++++++ core/internal/distros/immutable_test.go | 218 ++++++++++++++++++++ core/internal/distros/osinfo.go | 93 ++++++--- core/internal/headless/runner.go | 153 +++++++++----- core/internal/tui/app.go | 5 + core/internal/tui/resume.go | 90 ++++++++ core/internal/tui/states.go | 1 + core/internal/tui/views_config.go | 12 +- core/internal/tui/views_dependencies.go | 36 +++- core/internal/tui/views_install.go | 28 ++- core/internal/tui/views_layer.go | 110 ++++++++++ core/internal/tui/views_layer_test.go | 141 +++++++++++++ core/internal/tui/views_selection.go | 3 + core/internal/tui/views_welcome.go | 82 +++++++- core/internal/utils/greeter_setup.go | 15 +- 21 files changed, 1121 insertions(+), 222 deletions(-) create mode 100644 core/internal/distros/immutable.go create mode 100644 core/internal/distros/immutable_test.go create mode 100644 core/internal/tui/resume.go create mode 100644 core/internal/tui/views_layer.go create mode 100644 core/internal/tui/views_layer_test.go diff --git a/core/cmd/dms/assets/cli-policy.default.json b/core/cmd/dms/assets/cli-policy.default.json index de9b8ad8f..3bd9d5f44 100644 --- a/core/cmd/dms/assets/cli-policy.default.json +++ b/core/cmd/dms/assets/cli-policy.default.json @@ -1,10 +1 @@ -{ - "policy_version": 1, - "blocked_commands": [ - "greeter install", - "greeter enable", - "greeter uninstall", - "setup" - ], - "message": "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes." -} +{"policy_version":1,"blocked_commands":[],"message":"This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes."} diff --git a/core/cmd/dms/commands_doctor.go b/core/cmd/dms/commands_doctor.go index 99be2e6d4..2a64a0aa0 100644 --- a/core/cmd/dms/commands_doctor.go +++ b/core/cmd/dms/commands_doctor.go @@ -285,15 +285,15 @@ func checkSystemInfo() []checkResult { if message == "" { message = fmt.Sprintf("%s %s", osInfo.Distribution.ID, osInfo.VersionID) } - if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) { + details := fmt.Sprintf("ID: %s, Version: %s, Arch: %s", osInfo.ID, osInfo.VersionID, osInfo.Architecture) + switch { + case osInfo.Immutable: + details += "; image-based (" + osInfo.ImmutableReason + "), dankinstall deploys configuration only" + case distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID): status = statusWarn message += " (version may not be fully supported)" } - results = append(results, checkResult{ - catSystem, "Operating System", status, message, - fmt.Sprintf("ID: %s, Version: %s, Arch: %s", osInfo.Distribution.ID, osInfo.VersionID, osInfo.Architecture), - doctorDocsURL + "#operating-system", - }) + results = append(results, checkResult{catSystem, "Operating System", status, message, details, doctorDocsURL + "#operating-system"}) } arch := runtime.GOARCH diff --git a/core/cmd/dms/commands_features.go b/core/cmd/dms/commands_features.go index 01d0eff8d..ce5ee83da 100644 --- a/core/cmd/dms/commands_features.go +++ b/core/cmd/dms/commands_features.go @@ -83,6 +83,10 @@ func runUpdate() { log.Fatalf("Error detecting OS: %v", err) } + if osInfo.Immutable { + log.Fatalf("%s is an image-based system (%s). dms lives in the read-only image; update it with 'dms system update' or your image tooling (rpm-ostree, bootc, transactional-update).", osInfo.PrettyName, osInfo.ImmutableReason) + } + config, exists := distros.Registry[osInfo.Distribution.ID] if !exists { log.Fatalf("Unsupported distribution: %s", osInfo.Distribution.ID) diff --git a/core/cmd/dms/immutable_policy.go b/core/cmd/dms/immutable_policy.go index 37c032328..83bd25a33 100644 --- a/core/cmd/dms/immutable_policy.go +++ b/core/cmd/dms/immutable_policy.go @@ -1,7 +1,6 @@ package main import ( - "bufio" _ "embed" "encoding/json" "fmt" @@ -10,6 +9,7 @@ import ( "sync" "github.com/duxlabs-dev/dux-shell/core/internal/clipolicy" + "github.com/duxlabs-dev/dux-shell/core/internal/distros" "github.com/duxlabs-dev/dux-shell/core/internal/privesc" "github.com/spf13/afero" "github.com/spf13/cobra" @@ -97,97 +97,13 @@ func mergePolicyFile(base *immutableCommandPolicy, path string) error { return nil } -func readOSReleaseMap(path string) map[string]string { - values := make(map[string]string) - - file, err := os.Open(path) - if err != nil { - return values - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue - } - key := strings.ToUpper(strings.TrimSpace(parts[0])) - value := strings.Trim(strings.TrimSpace(parts[1]), "\"") - values[key] = strings.ToLower(value) - } - - return values -} - -func hasAnyToken(text string, tokens ...string) bool { - if text == "" { - return false - } - for _, token := range tokens { - if strings.Contains(text, token) { - return true - } - } - return false -} - -func detectImmutableSystem() (bool, string) { - if _, err := os.Stat("/run/ostree-booted"); err == nil { - return true, "/run/ostree-booted is present" - } - - osRelease := readOSReleaseMap("/etc/os-release") - if len(osRelease) == 0 { - return false, "" - } - - id := osRelease["ID"] - idLike := osRelease["ID_LIKE"] - variantID := osRelease["VARIANT_ID"] - name := osRelease["NAME"] - prettyName := osRelease["PRETTY_NAME"] - - immutableIDs := map[string]bool{ - "bluefin": true, - "bazzite": true, - "silverblue": true, - "kinoite": true, - "sericea": true, - "onyx": true, - "aurora": true, - "fedora-iot": true, - "fedora-coreos": true, - } - if immutableIDs[id] { - return true, "os-release ID=" + id - } - - markers := []string{"silverblue", "kinoite", "sericea", "onyx", "bazzite", "bluefin", "aurora", "ostree", "atomic"} - if hasAnyToken(variantID, markers...) { - return true, "os-release VARIANT_ID=" + variantID - } - if hasAnyToken(idLike, "ostree", "rpm-ostree") { - return true, "os-release ID_LIKE=" + idLike - } - if hasAnyToken(name, markers...) || hasAnyToken(prettyName, markers...) { - return true, "os-release identifies an atomic/ostree variant" - } - - return false, "" -} - func getImmutablePolicy() (*immutableCommandPolicy, error) { immutablePolicyOnce.Do(func() { - detectedImmutable, reason := detectImmutableSystem() + detectedImmutable, reason := distros.DetectImmutable() immutablePolicy = immutableCommandPolicy{ ImmutableSystem: detectedImmutable, ImmutableReason: reason, - BlockedCommands: []string{"greeter install", "greeter enable", "setup"}, + BlockedCommands: nil, Message: "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes.", } diff --git a/core/cmd/dms/immutable_policy_test.go b/core/cmd/dms/immutable_policy_test.go index 4c1d27013..70d24c7de 100644 --- a/core/cmd/dms/immutable_policy_test.go +++ b/core/cmd/dms/immutable_policy_test.go @@ -7,7 +7,9 @@ import ( "github.com/duxlabs-dev/dux-shell/core/internal/clipolicy" ) -func TestDefaultImmutablePolicyAllowsSyncButBlocksEnable(t *testing.T) { +// The greeter commands forward to dms-greeter, which applies its own policy; +// a packaged /usr/share/dms/cli-policy.json can still block them here. +func TestDefaultImmutablePolicyBlocksNothing(t *testing.T) { var policyFile clipolicy.File if err := json.Unmarshal(defaultCLIPolicyJSON, &policyFile); err != nil { t.Fatalf("failed to parse embedded CLI policy: %v", err) @@ -17,10 +19,12 @@ func TestDefaultImmutablePolicyAllowsSyncButBlocksEnable(t *testing.T) { } blocked := normalizeBlockedCommands(*policyFile.BlockedCommands) - if !commandBlockedByPolicy("greeter enable", blocked) { - t.Fatal("expected greeter enable to remain blocked on immutable/image-based systems") + for _, command := range []string{"greeter install", "greeter enable", "greeter uninstall", "greeter sync"} { + if commandBlockedByPolicy(command, blocked) { + t.Fatalf("%s must be available by default on image-based systems", command) + } } - if commandBlockedByPolicy("greeter sync", blocked) { - t.Fatal("expected greeter sync to remain available on immutable/image-based systems") + if commandBlockedByPolicy("setup", blocked) { + t.Fatal("setup only writes to the home directory and must stay available on image-based systems") } } diff --git a/core/internal/distros/fedora.go b/core/internal/distros/fedora.go index 4622709e4..2d90466b3 100644 --- a/core/internal/distros/fedora.go +++ b/core/internal/distros/fedora.go @@ -3,6 +3,7 @@ package distros import ( "context" "fmt" + "maps" "os/exec" "strings" @@ -23,9 +24,6 @@ func init() { Register("fedora-asahi-remix", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution { return NewFedoraDistribution(config, logChan) }) - Register("bluefin", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution { - return NewFedoraDistribution(config, logChan) - }) Register("ultramarine", "#00078b", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution { return NewFedoraDistribution(config, logChan) }) @@ -54,6 +52,8 @@ type FedoraDistribution struct { *BaseDistribution *ManualPackageInstaller config DistroConfig + + rpmInstalled func(pkg string) bool } func NewFedoraDistribution(config DistroConfig, logChan chan<- string) *FedoraDistribution { @@ -138,6 +138,9 @@ func (f *FedoraDistribution) detectXDGPortal() deps.Dependency { } func (f *FedoraDistribution) packageInstalled(pkg string) bool { + if f.rpmInstalled != nil { + return f.rpmInstalled(pkg) + } cmd := exec.Command("rpm", "-q", pkg) err := cmd.Run() return err == nil @@ -150,21 +153,33 @@ func (f *FedoraDistribution) InstalledVariant(stable, git PackageMapping) (deps. if !f.packageInstalled(stable.Name) { return deps.VariantStable, false } - if git.Repository == RepoTypeCOPR && fedoraInstalledFromCOPR(stable.Name, git.RepoURL) { + if git.Repository == RepoTypeCOPR && installedFromCOPR(stable.Name, git.RepoURL) { return deps.VariantGit, true } return deps.VariantStable, true } +var installedFromCOPR = fedoraInstalledFromCOPR + // fedoraInstalledFromCOPR tells same-named variants apart (dms, niri), which only differ by COPR. func fedoraInstalledFromCOPR(pkg, coprRepo string) bool { out, err := exec.Command("dnf", "repoquery", "--installed", "--qf", "%{from_repo}", pkg).Output() if err != nil { - return false + return rpmLooksLikeGitBuild(pkg) } return strings.HasSuffix(strings.TrimSpace(string(out)), ":"+strings.ReplaceAll(coprRepo, "/", ":")) } +// niri-git carries "git" in its version; dms-git only differs by its Epoch of 2. +func rpmLooksLikeGitBuild(pkg string) bool { + out, err := exec.Command("rpm", "-q", "--qf", "%{EPOCH} %{VERSION}-%{RELEASE}", pkg).Output() + if err != nil { + return false + } + epoch, versionRelease, _ := strings.Cut(strings.TrimSpace(string(out)), " ") + return strings.Contains(versionRelease, "git") || (pkg == "dms" && epoch == "2") +} + func (f *FedoraDistribution) SwapVariants(ctx context.Context, swaps []VariantSwap, sudoPassword string, progressChan chan<- InstallProgressMsg) error { if err := f.enableCOPRRepos(ctx, swapTargets(swaps, RepoTypeCOPR), sudoPassword, progressChan); err != nil { return err @@ -744,3 +759,76 @@ func (f *FedoraDistribution) installDNFGroups(ctx context.Context, packages []st } return nil } + +// Image-based Fedora has rpm-ostree but no dnf, so the .repo file is fetched directly. +func coprRepoFilePath(repo string) string { + owner, project, _ := strings.Cut(repo, "/") + return fmt.Sprintf("/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:%s:%s.repo", owner, project) +} + +func (f *FedoraDistribution) coprRepoFileCommand(repo string) string { + owner, project, _ := strings.Cut(repo, "/") + chroot := "fedora-$(rpm -E %fedora)" + if f.isEL() { + chroot = "epel-$(rpm -E %rhel)" + } + return fmt.Sprintf("sudo curl -fsSLo %s https://copr.fedorainfracloud.org/coprs/%s/%s/repo/%s/%s-%s-%s.repo", + coprRepoFilePath(repo), owner, project, chroot, owner, project, chroot) +} + +// Shown to the user, never run. Terra-only packages have no rpm-ostree path. +func (f *FedoraDistribution) LayerCommands(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags, disabledFlags map[string]bool) []string { + var commands []string + var uninstall []string + skip := maps.Clone(disabledFlags) + if skip == nil { + skip = map[string]bool{} + } + // rpm-ostree refuses to install what is already layered, so a reinstall mark only means a variant switch. + for _, dep := range dependencies { + if !reinstallFlags[dep.Name] || skip[dep.Name] || dep.Status != deps.StatusInstalled { + continue + } + swap, status := PlanVariantSwap(f, wm, dep.Name, dep.Variant) + if status != SwapNeeded { + skip[dep.Name] = true + continue + } + uninstall = append(uninstall, swap.From.Name) + if swap.From.Name == swap.To.Name && swap.From.Repository == RepoTypeCOPR { + commands = append(commands, "sudo rm -f "+coprRepoFilePath(swap.From.RepoURL)) + } + } + dnfPkgs, coprPkgs, manualPkgs, _ := f.categorizePackages(dependencies, wm, reinstallFlags, skip) + + seen := map[string]bool{} + pkgs := append([]string{}, dnfPkgs...) + for _, pkg := range coprPkgs { + pkgs = append(pkgs, pkg.Name) + if pkg.RepoURL == "" || seen[pkg.RepoURL] { + continue + } + seen[pkg.RepoURL] = true + commands = append(commands, f.coprRepoFileCommand(pkg.RepoURL)) + if pkg.RepoURL == "yalter/niri-git" { + commands = append(commands, "echo priority=1 | sudo tee -a /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:yalter:niri-git.repo") + } + } + for _, dep := range dependencies { + if dep.Status == deps.StatusNeedsUpdate && !disabledFlags[dep.Name] { + commands = append(commands, "sudo rpm-ostree upgrade") + break + } + } + if len(pkgs) > 0 { + install := "sudo rpm-ostree install " + strings.Join(pkgs, " ") + if len(uninstall) > 0 { + install += " --uninstall " + strings.Join(uninstall, " ") + } + commands = append(commands, install) + } + for _, name := range manualPkgs { + commands = append(commands, fmt.Sprintf("# %s has no package for image-based Fedora", name)) + } + return append(commands, "systemctl reboot") +} diff --git a/core/internal/distros/immutable.go b/core/internal/distros/immutable.go new file mode 100644 index 000000000..8e194c9ea --- /dev/null +++ b/core/internal/distros/immutable.go @@ -0,0 +1,127 @@ +package distros + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/duxlabs-dev/dux-shell/core/internal/deps" + "github.com/duxlabs-dev/dux-shell/core/internal/site" + "github.com/spf13/afero" +) + +// Markers only reached when /run/ostree-booted is absent, such as a chroot. +var immutableIDs = map[string]bool{ + "bluefin": true, + "bazzite": true, + "aurora": true, + "secureblue": true, + "zirconium": true, + "fedora-iot": true, + "fedora-coreos": true, + "opensuse-microos": true, + "opensuse-aeon": true, + "steamos": true, + "org.gnome.os": true, + "blendos": true, + "arkane": true, +} + +var immutableVariantMarkers = []string{"silverblue", "kinoite", "sericea", "onyx", "atomic", "coreos", "iot"} + +var immutableMarkerFiles = []struct { + path string + reason string +}{ + {"/run/ostree-booted", "ostree-based system"}, + {"/usr/sbin/transactional-update", "transactional-update system"}, + {"/usr/bin/abroot", "ABRoot system"}, +} + +const ZirconiumSwitchCommand = "sudo bootc switch ghcr.io/zirconium-dev/zirconium:latest" + +// The commands are shown to the user, never run. +type PackageLayerer interface { + LayerCommands(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags, disabledFlags map[string]bool) []string +} + +func DetectImmutable() (bool, string) { + fs := afero.NewOsFs() + return detectImmutable(fs, readOSRelease(fs)) +} + +func detectImmutable(fs afero.Fs, osRelease map[string]string) (bool, string) { + for _, marker := range immutableMarkerFiles { + if exists, _ := afero.Exists(fs, marker.path); exists { + return true, fmt.Sprintf("%s (%s)", marker.reason, marker.path) + } + } + + id := strings.ToLower(osRelease["ID"]) + if immutableIDs[id] { + return true, "os-release ID=" + id + } + + variantID := strings.ToLower(osRelease["VARIANT_ID"]) + for _, marker := range immutableVariantMarkers { + if strings.Contains(variantID, marker) { + return true, "os-release VARIANT_ID=" + variantID + } + } + + for _, like := range strings.Fields(strings.ToLower(osRelease["ID_LIKE"])) { + if like == "microos" || like == "ostree" { + return true, "os-release ID_LIKE=" + like + } + } + + return false, "" +} + +func ImmutableGuidance(info *OSInfo) string { + var b strings.Builder + fmt.Fprintf(&b, "%s is an image-based system: %s.\n\n", info.PrettyName, info.ImmutableReason) + b.WriteString("dankinstall has no package mapping for this image, so it cannot tell what is installed or deploy configuration for it. Nothing was changed on this system.\n\n") + b.WriteString("See " + site.Docs + "/getting-started for other options.") + return b.String() +} + +func PendingDependencies(dependencies []deps.Dependency, reinstallFlags, disabledFlags map[string]bool) []deps.Dependency { + var pending []deps.Dependency + for _, dep := range dependencies { + if disabledFlags[dep.Name] { + continue + } + if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] { + continue + } + pending = append(pending, dep) + } + return pending +} + +// detectDMS keys off the clone directory; packaged hosts only have /usr/bin/dms. +func ApplyImmutableDetection(dependencies []deps.Dependency) []deps.Dependency { + if _, err := exec.LookPath("dms"); err != nil { + return dependencies + } + for i := range dependencies { + if dependencies[i].Name != "dms (Dux Shell)" || dependencies[i].Status != deps.StatusMissing { + continue + } + dependencies[i].Status = deps.StatusInstalled + // "dms (git) v2.0.0-5333" or "dms v2.0.0" + out, err := exec.Command("dms", "version").Output() + if err != nil { + continue + } + fields := strings.Fields(string(out)) + if len(fields) > 0 { + dependencies[i].Version = fields[len(fields)-1] + } + if strings.Contains(string(out), "(git)") { + dependencies[i].Variant = deps.VariantGit + } + } + return dependencies +} diff --git a/core/internal/distros/immutable_test.go b/core/internal/distros/immutable_test.go new file mode 100644 index 000000000..efa2cb8a7 --- /dev/null +++ b/core/internal/distros/immutable_test.go @@ -0,0 +1,218 @@ +package distros + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/duxlabs-dev/dux-shell/core/internal/deps" + "github.com/spf13/afero" +) + +func writeOSRelease(t *testing.T, fs afero.Fs, body string) { + t.Helper() + if err := afero.WriteFile(fs, "/etc/os-release", []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestDetectImmutable(t *testing.T) { + cases := []struct { + name string + files []string + osRelease map[string]string + want bool + }{ + {"ostree marker beats plain fedora id", []string{"/run/ostree-booted"}, map[string]string{"ID": "fedora"}, true}, + {"silverblue variant without marker", nil, map[string]string{"ID": "fedora", "VARIANT_ID": "silverblue"}, true}, + {"sway atomic variant", nil, map[string]string{"ID": "fedora", "VARIANT_ID": "sway-atomic"}, true}, + {"workstation fedora", nil, map[string]string{"ID": "fedora", "VARIANT_ID": "workstation"}, false}, + {"bazzite id", nil, map[string]string{"ID": "bazzite", "ID_LIKE": "fedora"}, true}, + {"microos", []string{"/usr/sbin/transactional-update"}, map[string]string{"ID": "opensuse-microos"}, true}, + {"aeon by id_like", nil, map[string]string{"ID": "opensuse-aeon-rc", "ID_LIKE": "suse opensuse microos"}, true}, + {"tumbleweed", nil, map[string]string{"ID": "opensuse-tumbleweed", "ID_LIKE": "suse opensuse"}, false}, + {"vanilla by abroot", []string{"/usr/bin/abroot"}, map[string]string{"ID": "ubuntu", "ID_LIKE": "debian"}, true}, + {"steamos", nil, map[string]string{"ID": "steamos", "ID_LIKE": "arch"}, true}, + {"arch", nil, map[string]string{"ID": "arch"}, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + fs := afero.NewMemMapFs() + for _, f := range c.files { + if err := afero.WriteFile(fs, f, nil, 0o644); err != nil { + t.Fatal(err) + } + } + got, reason := detectImmutable(fs, c.osRelease) + if got != c.want { + t.Fatalf("detectImmutable = %v (%q), want %v", got, reason, c.want) + } + if got && reason == "" { + t.Fatal("immutable without a reason") + } + }) + } +} + +func TestGetOSInfoImmutable(t *testing.T) { + t.Run("unregistered image id is accepted when immutable", func(t *testing.T) { + fs := afero.NewMemMapFs() + writeOSRelease(t, fs, "ID=\"zirconium\"\nID_LIKE=\"zirconium\"\nPRETTY_NAME=\"Zirconium\"\nVERSION_ID=44\n") + if err := afero.WriteFile(fs, "/run/ostree-booted", nil, 0o644); err != nil { + t.Fatal(err) + } + info, err := getOSInfo(fs, "amd64") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !info.Immutable || info.Distribution.ID != "zirconium" || info.VersionID != "44" { + t.Fatalf("got %+v", info) + } + }) + + t.Run("fedora derivative resolves through id_like", func(t *testing.T) { + fs := afero.NewMemMapFs() + writeOSRelease(t, fs, "ID=bazzite\nID_LIKE=fedora\nPRETTY_NAME=Bazzite\nVERSION_ID=44\n") + info, err := getOSInfo(fs, "amd64") + if err != nil { + t.Fatal(err) + } + if !info.Immutable || info.ID != "bazzite" || info.Distribution.ID != "fedora" { + t.Fatalf("got %+v", info) + } + }) + + t.Run("unregistered mutable id still errors", func(t *testing.T) { + fs := afero.NewMemMapFs() + writeOSRelease(t, fs, "ID=slackware\nPRETTY_NAME=Slackware\n") + if _, err := getOSInfo(fs, "amd64"); err == nil { + t.Fatal("expected unsupported distribution error") + } + }) + + t.Run("silverblue resolves to fedora but is flagged", func(t *testing.T) { + fs := afero.NewMemMapFs() + writeOSRelease(t, fs, "ID=fedora\nVARIANT_ID=silverblue\nPRETTY_NAME=\"Fedora Linux 44 (Silverblue)\"\nVERSION_ID=44\n") + info, err := getOSInfo(fs, "amd64") + if err != nil { + t.Fatal(err) + } + if !info.Immutable || info.Distribution.ID != "fedora" || info.VariantID != "silverblue" { + t.Fatalf("got %+v", info) + } + }) + + t.Run("bluefin is no longer a mutable fedora", func(t *testing.T) { + if IsDistroSupported("bluefin") { + t.Fatal("bluefin must not be registered as an installable distribution") + } + }) +} + +func TestPendingDependencies(t *testing.T) { + all := []deps.Dependency{ + {Name: "dms (Dux Shell)", Status: deps.StatusMissing}, + {Name: "niri", Status: deps.StatusInstalled}, + {Name: "quickshell", Status: deps.StatusInstalled}, + {Name: "danksearch", Status: deps.StatusMissing}, + } + got := PendingDependencies(all, map[string]bool{"quickshell": true}, map[string]bool{"danksearch": true}) + if len(got) != 2 || got[0].Name != "dms (Dux Shell)" || got[1].Name != "quickshell" { + t.Fatalf("got %+v", got) + } +} + +func TestApplyImmutableDetection(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "dms"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + got := ApplyImmutableDetection([]deps.Dependency{{Name: "dms (Dux Shell)", Status: deps.StatusMissing}, {Name: "niri", Status: deps.StatusMissing}}) + if got[0].Status != deps.StatusInstalled || got[1].Status != deps.StatusMissing { + t.Fatalf("got %+v", got) + } +} + +func TestFedoraLayerCommands(t *testing.T) { + f := NewFedoraDistribution(Registry["fedora"], nil) + dependencies := []deps.Dependency{ + {Name: "dms (Dux Shell)", Status: deps.StatusMissing}, + {Name: "niri", Status: deps.StatusMissing, Variant: deps.VariantGit}, + {Name: "ghostty", Status: deps.StatusInstalled}, + {Name: "matugen", Status: deps.StatusMissing}, + {Name: "danksearch", Status: deps.StatusMissing}, + {Name: "xwayland-satellite", Status: deps.StatusMissing}, + } + got := strings.Join(f.LayerCommands(dependencies, deps.WindowManagerNiri, nil, map[string]bool{"danksearch": true}), "\n") + + for _, want := range []string{ + "_copr:copr.fedorainfracloud.org:avengemedia:dms.repo https://copr.fedorainfracloud.org/coprs/avengemedia/dms/repo/fedora-$(rpm -E %fedora)/avengemedia-dms-fedora-$(rpm -E %fedora).repo", + "_copr:copr.fedorainfracloud.org:yalter:niri-git.repo", + "echo priority=1 | sudo tee -a", + "sudo rpm-ostree install xwayland-satellite dms niri matugen", + "systemctl reboot", + } { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in:\n%s", want, got) + } + } + for _, unwanted := range []string{"ghostty", "danksearch", "rpm-ostree upgrade", "dnf "} { + if strings.Contains(got, unwanted) { + t.Fatalf("unexpected %q in:\n%s", unwanted, got) + } + } + if strings.Count(got, "avengemedia:danklinux.repo https") != 1 { + t.Fatalf("danklinux repo must be fetched exactly once:\n%s", got) + } +} + +func TestImmutableGuidance(t *testing.T) { + steam := &OSInfo{PrettyName: "SteamOS", Distribution: DistroInfo{ID: "steamos"}, ImmutableReason: "os-release ID=steamos"} + got := ImmutableGuidance(steam) + if !strings.Contains(got, "SteamOS is an image-based system") || !strings.Contains(got, "Nothing was changed") { + t.Fatalf("got %q", got) + } +} + +func TestFedoraLayerCommandsSwapVariants(t *testing.T) { + f := NewFedoraDistribution(Registry["fedora"], nil) + installed := map[string]bool{"dms-greeter": true, "dms": true} + f.rpmInstalled = func(pkg string) bool { return installed[pkg] } + orig := installedFromCOPR + installedFromCOPR = func(pkg, repo string) bool { return pkg == "dms" && repo == "avengemedia/dms-git" } + t.Cleanup(func() { installedFromCOPR = orig }) + + dependencies := []deps.Dependency{ + {Name: "dms (Dux Shell)", Status: deps.StatusInstalled, Variant: deps.VariantStable, CanToggle: true}, + {Name: "dms-greeter", Status: deps.StatusInstalled, Variant: deps.VariantGit, CanToggle: true}, + {Name: "danksearch", Status: deps.StatusInstalled}, + } + reinstall := map[string]bool{"dms (Dux Shell)": true, "dms-greeter": true, "danksearch": true} + got := strings.Join(f.LayerCommands(dependencies, deps.WindowManagerNiri, reinstall, nil), "\n") + + for _, want := range []string{ + "sudo rm -f /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:avengemedia:dms-git.repo", + "sudo rpm-ostree install dms dms-greeter-git --uninstall dms dms-greeter", + } { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in:\n%s", want, got) + } + } + if strings.Contains(got, "danksearch") { + t.Fatalf("reinstall mark without a variant change must not be layered:\n%s", got) + } +} + +func TestApplyImmutableDetectionReadsVariant(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "dms"), []byte("#!/bin/sh\necho 'dms (git) v2.0.0-5333'\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + got := ApplyImmutableDetection([]deps.Dependency{{Name: "dms (Dux Shell)", Status: deps.StatusMissing}}) + if got[0].Variant != deps.VariantGit || got[0].Version != "v2.0.0-5333" { + t.Fatalf("got %+v", got[0]) + } +} diff --git a/core/internal/distros/osinfo.go b/core/internal/distros/osinfo.go index 97f54134e..cfe4e2328 100644 --- a/core/internal/distros/osinfo.go +++ b/core/internal/distros/osinfo.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/duxlabs-dev/dux-shell/core/internal/errdefs" + "github.com/spf13/afero" ) // DistroInfo contains basic information about a distribution @@ -19,12 +20,18 @@ type DistroInfo struct { // OSInfo contains complete OS information type OSInfo struct { + // Raw os-release ID; Distribution.ID is the registered distro it resolves to via ID_LIKE (bazzite -> fedora). + ID string Distribution DistroInfo Version string VersionID string VersionCodename string PrettyName string Architecture string + IDLike []string + VariantID string + Immutable bool + ImmutableReason string } // GetOSInfo detects the current OS and returns information about it @@ -37,50 +44,74 @@ func GetOSInfo() (*OSInfo, error) { return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidArchitecture, fmt.Sprintf("Only amd64 and arm64 are supported, but I found %s", runtime.GOARCH)) } - info := &OSInfo{ - Architecture: runtime.GOARCH, + return getOSInfo(afero.NewOsFs(), runtime.GOARCH) +} + +// An unregistered ID is only an error on a mutable system; immutable hosts +// stop at the guidance screen and need no Distribution implementation. +func getOSInfo(fs afero.Fs, arch string) (*OSInfo, error) { + values := readOSRelease(fs) + if len(values) == 0 { + return nil, fmt.Errorf("cannot read /etc/os-release") } - file, err := os.Open("/etc/os-release") + info := &OSInfo{ + Architecture: arch, + Version: values["VERSION"], + VersionID: values["VERSION_ID"], + VersionCodename: values["VERSION_CODENAME"], + PrettyName: values["PRETTY_NAME"], + IDLike: strings.Fields(values["ID_LIKE"]), + VariantID: values["VARIANT_ID"], + } + if info.VersionID == "" { + info.VersionID = values["BUILD_ID"] + } + info.Immutable, info.ImmutableReason = detectImmutable(fs, values) + + info.ID = values["ID"] + id := info.ID + config, exists := Registry[id] + if !exists && !info.Immutable { + return nil, errdefs.NewCustomError(errdefs.ErrTypeUnsupportedDistribution, fmt.Sprintf("Unsupported distribution: %s", id)) + } + if !exists { + for _, like := range info.IDLike { + if likeConfig, ok := Registry[like]; ok { + id, config = like, likeConfig + break + } + } + } + info.Distribution = DistroInfo{ID: id, HexColorCode: config.ColorHex} + + return info, nil +} + +func readOSRelease(fs afero.Fs) map[string]string { + values := make(map[string]string) + + file, err := fs.Open("/etc/os-release") if err != nil { - return nil, err + return values } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { - line := scanner.Text() - parts := strings.SplitN(line, "=", 2) + parts := strings.SplitN(scanner.Text(), "=", 2) if len(parts) != 2 { continue } - - key := parts[0] - value := strings.Trim(parts[1], "\"'") - - switch key { - case "ID": - config, exists := Registry[value] - if !exists { - return nil, errdefs.NewCustomError(errdefs.ErrTypeUnsupportedDistribution, fmt.Sprintf("Unsupported distribution: %s", value)) - } - - info.Distribution = DistroInfo{ - ID: value, // Use the actual ID from os-release - HexColorCode: config.ColorHex, - } - case "VERSION_ID", "BUILD_ID": - info.VersionID = value - case "VERSION": - info.Version = value - case "VERSION_CODENAME": - info.VersionCodename = value - case "PRETTY_NAME": - info.PrettyName = value - } + values[strings.TrimSpace(parts[0])] = strings.Trim(strings.TrimSpace(parts[1]), "\"'") } - return info, scanner.Err() + return values +} + +func HasSystemd() bool { + _, err := os.Stat("/run/systemd/system") + return err == nil } // IsUnsupportedDistro checks if a distribution/version combination is supported diff --git a/core/internal/headless/runner.go b/core/internal/headless/runner.go index abdd176fc..9b3b0f2f3 100644 --- a/core/internal/headless/runner.go +++ b/core/internal/headless/runner.go @@ -2,6 +2,7 @@ package headless import ( "context" + "errors" "fmt" "os" "strings" @@ -17,6 +18,8 @@ import ( // must explicitly confirm the operation. var ErrConfirmationRequired = fmt.Errorf("confirmation required: pass --yes to proceed") +var ErrPackagesNotLayered = fmt.Errorf("packages must be layered into the image before configuration can be deployed") + // validConfigNames maps lowercase CLI input to the deployer key used in // replaceConfigs. Keep in sync with the config types checked by // shouldReplaceConfig in deployer.go. @@ -101,12 +104,18 @@ func (r *Runner) Run() error { return fmt.Errorf("OS detection failed: %w", err) } - if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) { + switch { + case osInfo.Immutable && !distros.IsDistroSupported(osInfo.Distribution.ID): + return errors.New(distros.ImmutableGuidance(osInfo)) + case osInfo.Immutable: + fmt.Fprintf(os.Stdout, "Detected: %s (%s), image-based: %s\n", osInfo.PrettyName, osInfo.Architecture, osInfo.ImmutableReason) + fmt.Fprintln(os.Stdout, "No packages will be installed; only configuration is deployed.") + case distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID): return fmt.Errorf("unsupported distribution: %s %s", osInfo.PrettyName, osInfo.VersionID) + default: + fmt.Fprintf(os.Stdout, "Detected: %s (%s)\n", osInfo.PrettyName, osInfo.Architecture) } - fmt.Fprintf(os.Stdout, "Detected: %s (%s)\n", osInfo.PrettyName, osInfo.Architecture) - // 4. Create distribution instance distro, err := distros.NewDistribution(osInfo.Distribution.ID, r.logChan) if err != nil { @@ -120,6 +129,9 @@ func (r *Runner) Run() error { if err != nil { return fmt.Errorf("dependency detection failed: %w", err) } + if osInfo.Immutable { + dependencies = distros.ApplyImmutableDetection(dependencies) + } reinstallItems, err := r.applyGitVariants(dependencies, gitVariantChecker(distro, wm, dependencies)) if err != nil { @@ -158,14 +170,25 @@ func (r *Runner) Run() error { marker = " RE " status = "(will reinstall)" } + if osInfo.Immutable && dep.Status != deps.StatusInstalled { + status = "(not installed)" + } } fmt.Fprintf(os.Stdout, "%s%-30s %s\n", marker, dep.Name, status) } fmt.Fprintln(os.Stdout) + if osInfo.Immutable { + if err := r.requireLayeredPackages(distro, dependencies, wm, reinstallItems, disabledItems); err != nil { + return err + } + } + // 6b. Require explicit confirmation unless --yes is set if !r.cfg.Yes { - if replaceConfigs == nil { + if osInfo.Immutable { + fmt.Fprintln(os.Stdout, "All packages are present. Only configurations will be deployed.") + } else if replaceConfigs == nil { // --replace-configs-all fmt.Fprintln(os.Stdout, "Packages will be installed and all configurations will be replaced.") fmt.Fprintln(os.Stdout, "Existing config files will be backed up before replacement.") @@ -186,57 +209,21 @@ func (r *Runner) Run() error { return ErrConfirmationRequired } - // 7. Authenticate sudo - sudoPassword, err := r.resolveSudoPassword() - if err != nil { - return err - } - - // 8. Install packages - fmt.Fprintln(os.Stdout, "Installing packages...") - r.log("Starting package installation") - - progressChan := make(chan distros.InstallProgressMsg, 100) - - installErr := make(chan error, 1) - go func() { - defer close(progressChan) - installErr <- distro.InstallPackages( - context.Background(), - dependencies, - wm, - sudoPassword, - reinstallItems, - disabledItems, - false, // skipGlobalUseFlags - progressChan, - ) - }() - - // Consume progress messages and print them - for msg := range progressChan { - if msg.Error != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error) - } else if msg.Step != "" { - fmt.Fprintf(os.Stdout, " [%3.0f%%] %s\n", msg.Progress*100, msg.Step) - } - if msg.LogOutput != "" { - r.log(msg.LogOutput) - fmt.Fprintf(os.Stdout, " %s\n", msg.LogOutput) + sudoPassword := "" + if !osInfo.Immutable { + sudoPassword, err = r.installPackages(distro, dependencies, wm, reinstallItems, disabledItems) + if err != nil { + return err } } - if err := <-installErr; err != nil { - return fmt.Errorf("package installation failed: %w", err) - } + useSystemd := distros.HasSystemd() - useSystemd := true - if distroConfig, exists := distros.Registry[osInfo.Distribution.ID]; exists && distroConfig.Family == distros.FamilyVoid { - useSystemd = false + // 9. Greeter setup. On images dms-greeter escalates itself, so it is left to the user. + if osInfo.Immutable && !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") { + fmt.Fprintln(os.Stdout, "Greeter: run 'dms-greeter install --yes' to configure greetd.") } - - // 9. Greeter setup (if dms-greeter was included) - if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") { + if !osInfo.Immutable && !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") { fmt.Fprintln(os.Stdout, "Configuring DMS greeter...") logFunc := func(line string) { r.log(line) @@ -569,3 +556,69 @@ func (r *Runner) depExists(dependencies []deps.Dependency, name string) bool { } return false } + +func (r *Runner) requireLayeredPackages(distro distros.Distribution, dependencies []deps.Dependency, wm deps.WindowManager, reinstallItems, disabledItems map[string]bool) error { + pending := distros.PendingDependencies(dependencies, reinstallItems, disabledItems) + if len(pending) == 0 { + return nil + } + + layerer, ok := distro.(distros.PackageLayerer) + if !ok { + return fmt.Errorf("this image has no known way to add packages; install them with the image's own tooling, reboot, and run dankinstall again") + } + fmt.Fprintln(os.Stdout, "Packages must be added to the image first. Run these, reboot, then run dankinstall again:") + fmt.Fprintln(os.Stdout) + for _, cmd := range layerer.LayerCommands(dependencies, wm, reinstallItems, disabledItems) { + fmt.Fprintf(os.Stdout, " %s\n", cmd) + } + fmt.Fprintln(os.Stdout) + r.log("Aborted: packages not layered on image-based host") + return ErrPackagesNotLayered +} + +func (r *Runner) installPackages(distro distros.Distribution, dependencies []deps.Dependency, wm deps.WindowManager, reinstallItems, disabledItems map[string]bool) (string, error) { + // 7. Authenticate sudo + sudoPassword, err := r.resolveSudoPassword() + if err != nil { + return "", err + } + + // 8. Install packages + fmt.Fprintln(os.Stdout, "Installing packages...") + r.log("Starting package installation") + + progressChan := make(chan distros.InstallProgressMsg, 100) + + installErr := make(chan error, 1) + go func() { + defer close(progressChan) + installErr <- distro.InstallPackages( + context.Background(), + dependencies, + wm, + sudoPassword, + reinstallItems, + disabledItems, + false, // skipGlobalUseFlags + progressChan, + ) + }() + + for msg := range progressChan { + if msg.Error != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error) + } else if msg.Step != "" { + fmt.Fprintf(os.Stdout, " [%3.0f%%] %s\n", msg.Progress*100, msg.Step) + } + if msg.LogOutput != "" { + r.log(msg.LogOutput) + fmt.Fprintf(os.Stdout, " %s\n", msg.LogOutput) + } + } + + if err := <-installErr; err != nil { + return "", fmt.Errorf("package installation failed: %w", err) + } + return sudoPassword, nil +} diff --git a/core/internal/tui/app.go b/core/internal/tui/app.go index b63898e6a..8c5322daf 100644 --- a/core/internal/tui/app.go +++ b/core/internal/tui/app.go @@ -14,6 +14,7 @@ type Model struct { state ApplicationState osInfo *distros.OSInfo + resume *layerResume dependencies []deps.Dependency err error @@ -137,6 +138,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateDetectingDepsState(msg) case StateDependencyReview: return m.updateDependencyReviewState(msg) + case StateLayerPackages: + return m.updateLayerPackagesState(msg) case StateGentooUseFlags: return m.updateGentooUseFlagsState(msg) case StateGentooGCCCheck: @@ -180,6 +183,8 @@ func (m Model) View() string { return m.viewDetectingDeps() case StateDependencyReview: return m.viewDependencyReview() + case StateLayerPackages: + return m.viewLayerPackages() case StateGentooUseFlags: return m.viewGentooUseFlags() case StateGentooGCCCheck: diff --git a/core/internal/tui/resume.go b/core/internal/tui/resume.go new file mode 100644 index 000000000..a6a38b048 --- /dev/null +++ b/core/internal/tui/resume.go @@ -0,0 +1,90 @@ +package tui + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +// Carries the first run's choices across the reboot that layering needs. +type layerResume struct { + WindowManager string `json:"window_manager"` + Terminal string `json:"terminal"` + Selected []string `json:"selected"` +} + +func resumePath() string { + dir := os.Getenv("XDG_STATE_HOME") + if dir == "" { + dir = filepath.Join(os.Getenv("HOME"), ".local", "state") + } + return filepath.Join(dir, "dankinstall", "resume.json") +} + +func saveLayerResume(r layerResume) error { + path := resumePath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.Marshal(r) + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +func loadLayerResume() *layerResume { + data, err := os.ReadFile(resumePath()) + if err != nil { + return nil + } + var r layerResume + if json.Unmarshal(data, &r) != nil || r.WindowManager == "" || r.Terminal == "" { + return nil + } + return &r +} + +func clearLayerResume() { + os.Remove(resumePath()) +} + +func (m Model) currentLayerResume() layerResume { + r := layerResume{} + if wms := m.windowManagerChoices(); m.selectedWM >= 0 && m.selectedWM < len(wms) { + r.WindowManager = strings.ToLower(wms[m.selectedWM].label) + } + if terms := m.terminalChoices(); m.selectedTerminal >= 0 && m.selectedTerminal < len(terms) { + r.Terminal = strings.ToLower(terms[m.selectedTerminal].label) + } + for _, dep := range m.dependencies { + if !dep.Required && !m.disabledItems[dep.Name] { + r.Selected = append(r.Selected, dep.Name) + } + } + return r +} + +// false: the saved choices no longer fit this host. +func (m *Model) applyLayerResume() bool { + if m.resume == nil { + return false + } + wm, term := -1, -1 + for i, c := range m.windowManagerChoices() { + if strings.ToLower(c.label) == m.resume.WindowManager { + wm = i + } + } + for i, c := range m.terminalChoices() { + if strings.ToLower(c.label) == m.resume.Terminal { + term = i + } + } + if wm < 0 || term < 0 { + return false + } + m.selectedWM, m.selectedTerminal = wm, term + return true +} diff --git a/core/internal/tui/states.go b/core/internal/tui/states.go index 07411f71f..cf26f3cd6 100644 --- a/core/internal/tui/states.go +++ b/core/internal/tui/states.go @@ -8,6 +8,7 @@ const ( StateSelectTerminal StateDetectingDeps StateDependencyReview + StateLayerPackages StateGentooUseFlags StateGentooGCCCheck StateSelectPrivesc diff --git a/core/internal/tui/views_config.go b/core/internal/tui/views_config.go index 1f8792f5d..7ca9060c0 100644 --- a/core/internal/tui/views_config.go +++ b/core/internal/tui/views_config.go @@ -73,6 +73,9 @@ func (m Model) updateDeployingConfigsState(msg tea.Msg) (tea.Model, tea.Cmd) { m.installationLogs = append(m.installationLogs, logLine) } + if m.isImmutable() { + clearLayerResume() + } m.state = StateInstallComplete m.isLoading = false return m, nil @@ -101,14 +104,7 @@ func (m Model) optionalDepSelected(name string) bool { } func (m Model) useSystemdConfig() bool { - if m.osInfo == nil { - return true - } - distroConfig, exists := distros.Registry[m.osInfo.Distribution.ID] - if !exists { - return true - } - return distroConfig.Family != distros.FamilyVoid + return distros.HasSystemd() } func (m Model) viewConfigConfirmation() string { diff --git a/core/internal/tui/views_dependencies.go b/core/internal/tui/views_dependencies.go index 5fea758b8..07a0b0e26 100644 --- a/core/internal/tui/views_dependencies.go +++ b/core/internal/tui/views_dependencies.go @@ -89,13 +89,20 @@ func (m Model) dependencyStatus(dep deps.Dependency) (marker, status string) { switch { case m.disabledItems[dep.Name]: return "✗ ", m.styles.Subtle.Render("Will skip") + case m.variantMarks[dep.Name]: + return "🔄 ", m.styles.Warning.Render("Will switch to " + variantLabel(dep.Variant)) case m.reinstallItems[dep.Name]: return "🔄 ", m.styles.Warning.Render("Will upgrade") + case m.isImmutable() && !dep.Required && dep.Status == deps.StatusInstalled: + return "✓ ", m.styles.Success.Render("✓ Selected (installed)") case dep.Name == dmsDepName: switch dep.Status { case deps.StatusInstalled: return "⚡ ", m.styles.Success.Render("✓ Required (installed)") case deps.StatusMissing: + if m.isImmutable() { + return "⚡ ", m.styles.Warning.Render("○ Required (not installed)") + } return "⚡ ", m.styles.Warning.Render("○ Required (will install)") case deps.StatusNeedsUpdate: return "⚡ ", m.styles.Warning.Render("△ Required (needs update)") @@ -105,19 +112,30 @@ func (m Model) dependencyStatus(dep deps.Dependency) (marker, status string) { return "⚡ ", "" } + verb := "Will install" + if m.isImmutable() { + verb = "Not installed" + } switch dep.Status { case deps.StatusInstalled: return "", m.styles.Subtle.Render("✓ Already installed") case deps.StatusMissing: - return "", m.styles.Warning.Render("○ Will install") + return "", m.styles.Warning.Render("○ " + verb) case deps.StatusNeedsUpdate: - return "", m.styles.Warning.Render("△ Will install") + return "", m.styles.Warning.Render("△ " + verb) case deps.StatusNeedsReinstall: - return "", m.styles.Error.Render("! Will install") + return "", m.styles.Error.Render("! " + verb) } return "", "" } +func variantLabel(variant deps.PackageVariant) string { + if variant == deps.VariantGit { + return "git" + } + return "stable" +} + func (m Model) dependencyNote(name string) string { switch name { case "dms-greeter": @@ -144,6 +162,9 @@ func (m Model) updateDetectingDepsState(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.listenForLogs() } + if m.isImmutable() { + depsMsg.deps = distros.ApplyImmutableDetection(depsMsg.deps) + } m.dependencies = partitionOptionalLast(depsMsg.deps) for _, dep := range m.dependencies { m.detectedVariants[dep.Name] = dep.Variant @@ -152,6 +173,9 @@ func (m Model) updateDetectingDepsState(msg tea.Msg) (tea.Model, tea.Cmd) { } m.disabledItems[dep.Name] = true } + if m.resume != nil { + return m.resumeAfterLayering() + } m.state = StateDependencyReview return m, m.listenForLogs() } @@ -172,6 +196,9 @@ func (m Model) updateDependencyReviewState(msg tea.Msg) (tea.Model, tea.Cmd) { case "g", "G": m.toggleSelectedVariant() case "enter": + if m.isImmutable() { + return m.continueOnImmutable() + } if m.osInfo != nil { if config, ok := distros.Registry[m.osInfo.Distribution.ID]; ok && config.Family == distros.FamilyGentoo { m.state = StateGentooUseFlags @@ -197,7 +224,8 @@ func (m Model) toggleSelectedDependency() { } installed := dep.Status == deps.StatusInstalled || dep.Status == deps.StatusNeedsReinstall - if installed { + // Nothing to reinstall on an image; enabling only drives the user-space setup. + if installed && !m.isImmutable() { m.reinstallItems[dep.Name] = !m.reinstallItems[dep.Name] m.disabledItems[dep.Name] = false return diff --git a/core/internal/tui/views_install.go b/core/internal/tui/views_install.go index 5f5a0eb46..c4244af50 100644 --- a/core/internal/tui/views_install.go +++ b/core/internal/tui/views_install.go @@ -136,6 +136,16 @@ func dmsPackageName(distroID string, dependencies []deps.Dependency) string { } } +func (m Model) uninstallHint() string { + if m.isImmutable() { + if distros.Registry[m.osInfo.Distribution.ID].Family == distros.FamilyFedora { + return "sudo rpm-ostree uninstall " + dmsPackageName(m.osInfo.Distribution.ID, m.dependencies) + } + return "" + } + return uninstallCommand(m.osInfo.Distribution.ID, m.dependencies) +} + func uninstallCommand(distroID string, dependencies []deps.Dependency) string { config, ok := distros.Registry[distroID] if !ok { @@ -218,15 +228,20 @@ func (m Model) viewInstallComplete() string { b.WriteByte('\n') b.WriteString(m.styles.Success.Render("Setup Complete!")) b.WriteString("\n\n") - b.WriteString(m.styles.Success.Render("✓ All packages installed and configurations deployed.")) - b.WriteString("\n\n") - + summary := "✓ All packages installed and configurations deployed." accomplishments := []string{ "• Window manager and dependencies installed", "• Terminal and development tools configured", "• Configuration files deployed with backups", "• System optimized for Dux Shell", } + if m.isImmutable() { + summary = "✓ Configurations deployed. No packages were changed on this image." + accomplishments = accomplishments[1:] + } + b.WriteString(m.styles.Success.Render(summary)) + b.WriteString("\n\n") + for _, item := range accomplishments { b.WriteString(m.styles.Subtle.Render(item)) b.WriteByte('\n') @@ -240,12 +255,17 @@ func (m Model) viewInstallComplete() string { cmd := m.styles.Accent autostart, logs := m.troubleshootingHints() + if m.isImmutable() && m.optionalDepSelected("dms-greeter") { + b.WriteString(label.Render("Greeter:") + "\n") + b.WriteString(label.Render(" Configure greetd: ") + cmd.Render("dms-greeter install --yes") + "\n\n") + } + b.WriteString(label.Render("Troubleshooting:") + "\n") b.WriteString(label.Render(" Disable autostart: ") + cmd.Render(autostart) + "\n") b.WriteString(label.Render(" View logs: ") + cmd.Render(logs) + "\n") if m.osInfo != nil { - if uninstall := uninstallCommand(m.osInfo.Distribution.ID, m.dependencies); uninstall != "" { + if uninstall := m.uninstallHint(); uninstall != "" { b.WriteString(label.Render(" Uninstall: ") + cmd.Render(uninstall) + "\n") } } diff --git a/core/internal/tui/views_layer.go b/core/internal/tui/views_layer.go new file mode 100644 index 000000000..c016b57ee --- /dev/null +++ b/core/internal/tui/views_layer.go @@ -0,0 +1,110 @@ +package tui + +import ( + "context" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/duxlabs-dev/dux-shell/core/internal/distros" +) + +// Images skip the privileged phases: packages are present, or the user layers them and comes back after a reboot. +func (m Model) continueOnImmutable() (tea.Model, tea.Cmd) { + if len(distros.PendingDependencies(m.dependencies, m.reinstallItems, m.disabledItems)) > 0 { + if err := saveLayerResume(m.currentLayerResume()); err != nil { + m.logChan <- "could not save resume state: " + err.Error() + } + m.state = StateLayerPackages + return m, nil + } + m.installationLogs = nil + m.state = StateConfigConfirmation + m.isLoading = true + return m, tea.Batch(m.spinner.Tick, m.setupUserServices(), m.checkExistingConfigurations()) +} + +func (m Model) resumeAfterLayering() (tea.Model, tea.Cmd) { + for _, name := range m.resume.Selected { + if _, ok := m.disabledItems[name]; ok { + m.disabledItems[name] = false + } + } + return m.continueOnImmutable() +} + +func (m Model) setupUserServices() tea.Cmd { + return func() tea.Msg { + if !m.useSystemdConfig() || !m.optionalDepSelected("danksearch") { + return nil + } + if err := distros.SetupDsearchService(context.Background(), func(line string) { m.logChan <- line }); err != nil { + m.logChan <- "danksearch service setup warning (non-fatal): " + err.Error() + } + return nil + } +} + +func (m Model) layerCommands() []string { + if m.osInfo == nil { + return nil + } + distro, err := distros.NewDistribution(m.osInfo.Distribution.ID, m.logChan) + if err != nil { + return nil + } + layerer, ok := distro.(distros.PackageLayerer) + if !ok { + return nil + } + return layerer.LayerCommands(m.dependencies, m.chosenWindowManager(), m.reinstallItems, m.disabledItems) +} + +func (m Model) viewLayerPackages() string { + var b strings.Builder + b.WriteString(m.renderBanner()) + b.WriteByte('\n') + b.WriteString(m.styles.Title.Render("Packages To Layer")) + b.WriteString("\n\n") + + for _, dep := range distros.PendingDependencies(m.dependencies, m.reinstallItems, m.disabledItems) { + line := " ○ " + dep.Name + if m.variantMarks[dep.Name] { + line = " ⇄ " + dep.Name + " → " + variantLabel(dep.Variant) + } + b.WriteString(m.styles.Warning.Render(line)) + b.WriteByte('\n') + } + b.WriteByte('\n') + + commands := m.layerCommands() + if len(commands) == 0 { + b.WriteString(m.styles.Normal.Render(wrapText("This image has no known way to add these packages. Install them from the image's own tooling, reboot, and run dankinstall again.", 70))) + b.WriteString("\n\n") + } else { + b.WriteString(m.styles.Normal.Render("Run these, reboot, then run dankinstall again. It picks up from here and deploys configuration:")) + b.WriteString("\n\n") + for _, cmd := range commands { + b.WriteString(" " + m.styles.Accent.Render(cmd)) + b.WriteByte('\n') + } + b.WriteByte('\n') + } + + if m.osInfo != nil && distros.Registry[m.osInfo.Distribution.ID].Family == distros.FamilyFedora { + b.WriteString(m.styles.Subtle.Render("Prefer a ready-made image? Zirconium ships niri and Dux Shell:")) + b.WriteByte('\n') + b.WriteString(" " + m.styles.Subtle.Render(distros.ZirconiumSwitchCommand)) + b.WriteString("\n\n") + } + + b.WriteString(m.styles.Subtle.Render("Esc: Back, Ctrl+C: Quit")) + return b.String() +} + +func (m Model) updateLayerPackagesState(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "esc" { + m.state = StateDependencyReview + return m, nil + } + return m, m.listenForLogs() +} diff --git a/core/internal/tui/views_layer_test.go b/core/internal/tui/views_layer_test.go new file mode 100644 index 000000000..677c15aba --- /dev/null +++ b/core/internal/tui/views_layer_test.go @@ -0,0 +1,141 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/duxlabs-dev/dux-shell/core/internal/deps" + "github.com/duxlabs-dev/dux-shell/core/internal/distros" +) + +func immutableFedoraModel(dependencies []deps.Dependency) Model { + m := NewModel("test", "") + m.osInfo = &distros.OSInfo{PrettyName: "Fedora Linux 44 (Silverblue)", Distribution: distros.DistroInfo{ID: "fedora"}, Immutable: true, ImmutableReason: "ostree-based system"} + m.dependencies = dependencies + return m +} + +func TestImmutableHostStopsAtLayerScreen(t *testing.T) { + m := immutableFedoraModel([]deps.Dependency{ + {Name: dmsDepName, Status: deps.StatusMissing, Required: true}, + {Name: "niri", Status: deps.StatusInstalled, Required: true}, + }) + next, _ := m.continueOnImmutable() + if got := next.(Model).state; got != StateLayerPackages { + t.Fatalf("state = %v, want StateLayerPackages", got) + } + view := next.(Model).viewLayerPackages() + if !strings.Contains(view, "sudo rpm-ostree install dms") || !strings.Contains(view, "bootc switch") { + t.Fatalf("layer screen missing commands:\n%s", view) + } +} + +func TestImmutableHostWithPackagesSkipsToConfigs(t *testing.T) { + m := immutableFedoraModel([]deps.Dependency{ + {Name: dmsDepName, Status: deps.StatusInstalled, Required: true}, + {Name: "niri", Status: deps.StatusInstalled, Required: true}, + }) + next, _ := m.continueOnImmutable() + if got := next.(Model).state; got != StateConfigConfirmation { + t.Fatalf("state = %v, want StateConfigConfirmation", got) + } + if m.cannotContinue() { + t.Fatal("registered immutable host must pass the welcome screen") + } +} + +func TestImmutableSecondRunResumesPastReview(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + + first := immutableFedoraModel([]deps.Dependency{ + {Name: dmsDepName, Status: deps.StatusMissing, Required: true}, + {Name: "danksearch", Status: deps.StatusMissing}, + {Name: "dankcalendar", Status: deps.StatusMissing}, + }) + first.selectedWM, first.selectedTerminal = 0, 1 // niri, kitty + first.disabledItems["danksearch"] = false + first.disabledItems["dankcalendar"] = true + if next, _ := first.continueOnImmutable(); next.(Model).state != StateLayerPackages { + t.Fatal("first run should stop at the layer screen") + } + + second := immutableFedoraModel(nil) + second.isLoading = false + second.resume = loadLayerResume() + if second.resume == nil { + t.Fatal("layer screen must save a resume file") + } + second = pressKey(t, second, enterKey) + if second.state != StateDetectingDeps || second.selectedTerminal != 1 { + t.Fatalf("state = %v, terminal = %d; want detection with the saved choices", second.state, second.selectedTerminal) + } + + updated, _ := second.Update(depsDetectedMsg{deps: []deps.Dependency{ + {Name: dmsDepName, Status: deps.StatusInstalled, Required: true}, + {Name: "danksearch", Status: deps.StatusInstalled}, + {Name: "dankcalendar", Status: deps.StatusInstalled}, + }}) + second = updated.(Model) + if second.state != StateConfigConfirmation { + t.Fatalf("state = %v, want StateConfigConfirmation without a review stop", second.state) + } + if second.disabledItems["danksearch"] || !second.disabledItems["dankcalendar"] { + t.Fatalf("resume must restore the optional selection: %v", second.disabledItems) + } +} + +func TestImmutableStartOverClearsResume(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + if err := saveLayerResume(layerResume{WindowManager: "niri", Terminal: "ghostty"}); err != nil { + t.Fatal(err) + } + m := immutableFedoraModel(nil) + m.isLoading = false + m.resume = loadLayerResume() + m = pressKey(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")}) + if m.resume != nil || loadLayerResume() != nil { + t.Fatal("S must drop the saved run") + } + m = pressKey(t, m, enterKey) + if m.state != StateSelectWindowManager { + t.Fatalf("state = %v, want StateSelectWindowManager", m.state) + } +} + +func TestImmutableEnableInstalledOptionalIsNotPending(t *testing.T) { + m := immutableFedoraModel([]deps.Dependency{ + {Name: dmsDepName, Status: deps.StatusInstalled, Required: true}, + {Name: "dms-greeter", Status: deps.StatusInstalled}, + }) + m.selectedDep = 1 + m.disabledItems["dms-greeter"] = true + m.toggleSelectedDependency() + if m.disabledItems["dms-greeter"] || m.reinstallItems["dms-greeter"] { + t.Fatalf("enable on an image must not mark a reinstall: disabled=%v reinstall=%v", m.disabledItems, m.reinstallItems) + } + if pending := distros.PendingDependencies(m.dependencies, m.reinstallItems, m.disabledItems); len(pending) != 0 { + t.Fatalf("installed package listed for layering: %v", pending) + } + if line := m.dependencyLine(m.dependencies[1], false); !strings.Contains(line, "Selected (installed)") { + t.Fatalf("status line = %q", line) + } +} + +func TestImmutableVariantToggleMarksSwitch(t *testing.T) { + m := immutableFedoraModel([]deps.Dependency{ + {Name: dmsDepName, Status: deps.StatusInstalled, Required: true, CanToggle: true, Variant: deps.VariantGit}, + }) + m.detectedVariants[dmsDepName] = deps.VariantGit + m.toggleSelectedVariant() + if m.dependencies[0].Variant != deps.VariantStable || !m.reinstallItems[dmsDepName] || !m.variantMarks[dmsDepName] { + t.Fatalf("G on an image must mark a switch: %+v reinstall=%v marks=%v", m.dependencies[0], m.reinstallItems, m.variantMarks) + } + if line := m.dependencyLine(m.dependencies[0], false); !strings.Contains(line, "Will switch to stable") { + t.Fatalf("status line = %q", line) + } + m.toggleSelectedVariant() + if m.reinstallItems[dmsDepName] || m.variantMarks[dmsDepName] { + t.Fatal("toggling back must clear the switch mark") + } +} diff --git a/core/internal/tui/views_selection.go b/core/internal/tui/views_selection.go index ff77d564a..60c884e5b 100644 --- a/core/internal/tui/views_selection.go +++ b/core/internal/tui/views_selection.go @@ -39,6 +39,9 @@ func (m Model) windowManagerChoices() []wmChoice { if m.osInfo == nil || m.osInfo.Distribution.ID != "debian" { choices = append(choices, wmChoice{deps.WindowManagerHyprland, "Hyprland", "Dynamic tiling Wayland compositor."}) } + if m.isImmutable() { + return choices + } if m.osInfo != nil { // No mango package mapping exists for these families; offering it would deploy a config with no compositor. switch distros.Registry[m.osInfo.Distribution.ID].Family { diff --git a/core/internal/tui/views_welcome.go b/core/internal/tui/views_welcome.go index 4c69f69c2..48b473e59 100644 --- a/core/internal/tui/views_welcome.go +++ b/core/internal/tui/views_welcome.go @@ -26,6 +26,14 @@ func (m Model) viewWelcome() string { switch { case m.osInfo == nil && m.isLoading: fmt.Fprintf(&b, "%s %s\n\n", m.spinner.View(), m.styles.Normal.Render("Detecting system...")) + case m.osInfo != nil && m.osInfo.Immutable && !m.distroSupported(): + m.writeImmutableSystem(&b) + case m.osInfo != nil && m.osInfo.Immutable && m.resume != nil: + m.writeSystemSummary(&b) + m.writeResumeNotice(&b) + case m.osInfo != nil && m.osInfo.Immutable: + m.writeSystemSummary(&b) + m.writeImmutableNotice(&b) case m.osInfo != nil && distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID): m.writeUnsupportedDistro(&b) case m.osInfo != nil: @@ -46,6 +54,49 @@ func (m Model) writeUnsupportedDistro(b *strings.Builder) { b.WriteString("\n\n") } +func (m Model) writeImmutableSystem(b *strings.Builder) { + title := m.styles.Warning.Bold(true).Render("⚠ IMAGE-BASED SYSTEM") + var paragraphs []string + for _, p := range strings.Split(distros.ImmutableGuidance(m.osInfo), "\n\n") { + paragraphs = append(paragraphs, wrapText(p, 70)) + } + msg := m.styles.Normal.Render(strings.Join(paragraphs, "\n\n")) + b.WriteString(m.styles.ErrorBox.Render(title + "\n\n" + msg)) + b.WriteString("\n\n") +} + +func (m Model) writeImmutableNotice(b *strings.Builder) { + title := m.styles.Warning.Bold(true).Render("Image-based system: " + m.osInfo.ImmutableReason) + msg := wrapText("dankinstall will not install packages or ask for a password here. It checks what is installed, lists anything missing with the command to layer it, and deploys configuration into your home directory.", 70) + b.WriteString(m.styles.InfoBox.Render(title + "\n" + m.styles.Normal.Render(msg))) + b.WriteString("\n\n") +} + +func (m Model) writeResumeNotice(b *strings.Builder) { + title := m.styles.Success.Bold(true).Render(fmt.Sprintf("▶ Resuming setup: %s with %s", m.resume.WindowManager, m.resume.Terminal)) + msg := wrapText("Press Enter to finish. dankinstall checks that the layered packages are installed and deploys configuration into your home directory. No packages are installed and no password is needed.", 70) + b.WriteString(m.styles.InfoBox.Render(title + "\n" + m.styles.Normal.Render(msg))) + b.WriteString("\n\n") +} + +func (m Model) isImmutable() bool { + return m.osInfo != nil && m.osInfo.Immutable +} + +func (m Model) distroSupported() bool { + return m.osInfo != nil && distros.IsDistroSupported(m.osInfo.Distribution.ID) +} + +func (m Model) cannotContinue() bool { + if m.osInfo == nil { + return true + } + if m.osInfo.Immutable { + return !m.distroSupported() + } + return distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID) +} + func unsupportedDistroMessage(info *distros.OSInfo) string { switch info.Distribution.ID { case "ubuntu": @@ -114,12 +165,17 @@ func (m Model) writeWelcomeFooter(b *strings.Builder) { } ctrlKey := m.styles.Highlight.Render("Ctrl+C") - if distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID) { + if m.cannotContinue() { b.WriteString(m.styles.Subtle.Render("Press ") + ctrlKey + m.styles.Subtle.Render(" to quit")) return } enterKey := m.styles.Highlight.Render("Enter") + if m.resume != nil { + b.WriteString(m.styles.Subtle.Render("Press ") + enterKey + m.styles.Subtle.Render(" to resume setup, ") + + m.styles.Highlight.Render("S") + m.styles.Subtle.Render(" to start over, ") + ctrlKey + m.styles.Subtle.Render(" to quit")) + return + } b.WriteString(m.styles.Subtle.Render("Press ") + enterKey + m.styles.Subtle.Render(" to choose window manager, ") + ctrlKey + m.styles.Subtle.Render(" to quit")) } @@ -134,15 +190,31 @@ func (m Model) updateWelcomeState(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.listenForLogs() } m.osInfo = msg.info + if m.isImmutable() && m.distroSupported() { + m.resume = loadLayerResume() + } case tea.KeyMsg: - if msg.String() != "enter" { + if m.cannotContinue() { break } - if m.osInfo == nil || distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID) { - break + switch msg.String() { + case "s", "S": + if m.resume == nil { + break + } + clearLayerResume() + m.resume = nil + case "enter": + if m.applyLayerResume() { + m.state = StateDetectingDeps + m.isLoading = true + return m, tea.Batch(m.spinner.Tick, m.detectDependencies()) + } + clearLayerResume() + m.resume = nil + m.state = StateSelectWindowManager } - m.state = StateSelectWindowManager } return m, m.listenForLogs() } diff --git a/core/internal/utils/greeter_setup.go b/core/internal/utils/greeter_setup.go index ddaa763a5..499b0f8c8 100644 --- a/core/internal/utils/greeter_setup.go +++ b/core/internal/utils/greeter_setup.go @@ -2,9 +2,11 @@ package utils import ( "bufio" + "context" "fmt" "os/exec" - "strings" + + "github.com/duxlabs-dev/dux-shell/core/internal/privesc" ) // RunDmsGreeterInstall delegates greeter setup to the standalone dms-greeter @@ -15,12 +17,11 @@ func RunDmsGreeterInstall(sudoPassword string, logFunc func(string)) error { return fmt.Errorf("dms-greeter binary not found; install the dms-greeter package and run 'dms-greeter install'") } - var cmd *exec.Cmd - if sudoPassword == "" { - cmd = exec.Command(binary, "install", "--yes") - } else { - cmd = exec.Command("sudo", "-S", "-p", "", binary, "install", "--yes") - cmd.Stdin = strings.NewReader(sudoPassword + "\n") + // Without a password dms-greeter escalates on its own. + argv := []string{binary, "install", "--yes"} + cmd := exec.Command(argv[0], argv[1:]...) + if sudoPassword != "" { + cmd = privesc.ExecCommand(context.Background(), sudoPassword, privesc.QuoteArgsForShell(argv)) } stdout, err := cmd.StdoutPipe()