mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-25 20:47:48 -04:00
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 696be97018)
This commit is contained in:
1 parent
1c54262b9f
commit
6d00fd7295
21 files changed
+1121
-222
No files matched your search
@@ -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."}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -8,6 +8,7 @@ const (
|
||||
StateSelectTerminal
|
||||
StateDetectingDeps
|
||||
StateDependencyReview
|
||||
StateLayerPackages
|
||||
StateGentooUseFlags
|
||||
StateGentooGCCCheck
|
||||
StateSelectPrivesc
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user