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
This commit is contained in:
purian23 committed 2026-10-25 03:19:41 -04:00
1 parent 20bcf8559b
commit 696be97018
21 files changed
+1121 -222

No files matched your search

+1 -10
View File
@@ -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."}
+6 -6
View File
@@ -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
+4
View File
@@ -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)
+3 -87
View File
@@ -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.",
}
+9 -5
View File
@@ -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")
}
}
+93 -5
View File
@@ -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")
}
+127
View File
@@ -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
}
+218
View File
@@ -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])
}
}
+62 -31
View File
@@ -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
+103 -50
View File
@@ -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
}
+5
View File
@@ -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:
+90
View File
@@ -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
}
+1
View File
@@ -8,6 +8,7 @@ const (
StateSelectTerminal
StateDetectingDeps
StateDependencyReview
StateLayerPackages
StateGentooUseFlags
StateGentooGCCCheck
StateSelectPrivesc
+4 -8
View File
@@ -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 {
+32 -4
View File
@@ -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
+24 -4
View File
@@ -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")
}
}
+110
View File
@@ -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()
}
+141
View File
@@ -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")
}
}
+3
View File
@@ -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 {
+77 -5
View File
@@ -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()
}
+8 -7
View File
@@ -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()