mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-04-15 02:02:08 -04:00
Add headless mode support with command-line flags (#2182)
* Add support for headless mode. Allow dankinstall run with command-line flags. * Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3056146219 * Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3056146253 * Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3056146271 * Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3056146296 * Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3056146348 * FIx https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3056146328 * Update headless mode instructions * Add log dir config. Use DANKINSTALL_LOG env var, fallback to /var/tmp * Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3056737552 * Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3056737572 * Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3056737592 * Add explanations for headless validating rules and log file location * Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3058087146 and https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3058087234 * Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3058087271 * Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3058310408 * Enhance configuration deployment logic to support missing files and add corresponding unit tests * Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3058310495 * Reworked the log channel handling logic to simplify the code and added the `drainLogChan` function to prevent blocking (https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3058609491) * Added dependency-checking functionality to ensure installation requirements are met, and optimized the pre-installation logic for AUR packages * feat: output log messages to stdout during installation * Revert dependency-checking functionality due to official fix * Revert compositor provider workaround due to upstream fix
This commit is contained in:
418
core/internal/headless/runner.go
Normal file
418
core/internal/headless/runner.go
Normal file
@@ -0,0 +1,418 @@
|
||||
package headless
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||
)
|
||||
|
||||
// ErrConfirmationRequired is returned when --yes is not set and the user
|
||||
// must explicitly confirm the operation.
|
||||
var ErrConfirmationRequired = fmt.Errorf("confirmation required: pass --yes to proceed")
|
||||
|
||||
// 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.
|
||||
var validConfigNames = map[string]string{
|
||||
"niri": "Niri",
|
||||
"hyprland": "Hyprland",
|
||||
"ghostty": "Ghostty",
|
||||
"kitty": "Kitty",
|
||||
"alacritty": "Alacritty",
|
||||
}
|
||||
|
||||
// orderedConfigNames defines the canonical order for config names in output.
|
||||
// Must be kept in sync with validConfigNames.
|
||||
var orderedConfigNames = []string{"niri", "hyprland", "ghostty", "kitty", "alacritty"}
|
||||
|
||||
// Config holds all CLI parameters for unattended installation.
|
||||
type Config struct {
|
||||
Compositor string // "niri" or "hyprland"
|
||||
Terminal string // "ghostty", "kitty", or "alacritty"
|
||||
IncludeDeps []string
|
||||
ExcludeDeps []string
|
||||
ReplaceConfigs []string // specific configs to deploy (e.g. "niri", "ghostty")
|
||||
ReplaceConfigsAll bool // deploy/replace all configurations
|
||||
Yes bool
|
||||
}
|
||||
|
||||
// Runner orchestrates unattended (headless) installation.
|
||||
type Runner struct {
|
||||
cfg Config
|
||||
logChan chan string
|
||||
}
|
||||
|
||||
// NewRunner creates a new headless runner.
|
||||
func NewRunner(cfg Config) *Runner {
|
||||
return &Runner{
|
||||
cfg: cfg,
|
||||
logChan: make(chan string, 1000),
|
||||
}
|
||||
}
|
||||
|
||||
// GetLogChan returns the log channel for file logging.
|
||||
func (r *Runner) GetLogChan() <-chan string {
|
||||
return r.logChan
|
||||
}
|
||||
|
||||
// Run executes the full unattended installation flow.
|
||||
func (r *Runner) Run() error {
|
||||
r.log("Starting headless installation")
|
||||
|
||||
// 1. Parse compositor and terminal selections
|
||||
wm, err := r.parseWindowManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
terminal, err := r.parseTerminal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Build replace-configs map
|
||||
replaceConfigs, err := r.buildReplaceConfigs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Detect OS
|
||||
r.log("Detecting operating system...")
|
||||
osInfo, err := distros.GetOSInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("OS detection failed: %w", err)
|
||||
}
|
||||
|
||||
if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) {
|
||||
return fmt.Errorf("unsupported distribution: %s %s", osInfo.PrettyName, osInfo.VersionID)
|
||||
}
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("failed to initialize distribution: %w", err)
|
||||
}
|
||||
|
||||
// 5. Detect dependencies
|
||||
r.log("Detecting dependencies...")
|
||||
fmt.Fprintln(os.Stdout, "Detecting dependencies...")
|
||||
dependencies, err := distro.DetectDependenciesWithTerminal(context.Background(), wm, terminal)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dependency detection failed: %w", err)
|
||||
}
|
||||
|
||||
// 5. Apply include/exclude filters and build the disabled-items map.
|
||||
// Headless mode does not currently collect any explicit reinstall selections,
|
||||
// so keep reinstallItems nil instead of constructing an always-empty map.
|
||||
disabledItems, err := r.buildDisabledItems(dependencies)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var reinstallItems map[string]bool
|
||||
|
||||
// Print dependency summary
|
||||
fmt.Fprintln(os.Stdout, "\nDependencies:")
|
||||
for _, dep := range dependencies {
|
||||
marker := " "
|
||||
status := ""
|
||||
if disabledItems[dep.Name] {
|
||||
marker = " SKIP "
|
||||
status = "(disabled)"
|
||||
} else {
|
||||
switch dep.Status {
|
||||
case deps.StatusInstalled:
|
||||
marker = " OK "
|
||||
status = "(installed)"
|
||||
case deps.StatusMissing:
|
||||
marker = " NEW "
|
||||
status = "(will install)"
|
||||
case deps.StatusNeedsUpdate:
|
||||
marker = " UPD "
|
||||
status = "(will update)"
|
||||
case deps.StatusNeedsReinstall:
|
||||
marker = " RE "
|
||||
status = "(will reinstall)"
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "%s%-30s %s\n", marker, dep.Name, status)
|
||||
}
|
||||
fmt.Fprintln(os.Stdout)
|
||||
|
||||
// 6b. Require explicit confirmation unless --yes is set
|
||||
if !r.cfg.Yes {
|
||||
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.")
|
||||
} else if r.anyConfigEnabled(replaceConfigs) {
|
||||
var names []string
|
||||
for _, cliName := range orderedConfigNames {
|
||||
deployerKey := validConfigNames[cliName]
|
||||
if replaceConfigs[deployerKey] {
|
||||
names = append(names, deployerKey)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "Packages will be installed. The following configurations will be replaced (with backups): %s\n", strings.Join(names, ", "))
|
||||
} else {
|
||||
fmt.Fprintln(os.Stdout, "Packages will be installed. No configurations will be deployed.")
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "Re-run with --yes (-y) to proceed.")
|
||||
r.log("Aborted: --yes not set")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if err := <-installErr; err != nil {
|
||||
return fmt.Errorf("package installation failed: %w", err)
|
||||
}
|
||||
|
||||
// 9. Greeter setup (if dms-greeter was included)
|
||||
if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
|
||||
compositorName := "niri"
|
||||
if wm == deps.WindowManagerHyprland {
|
||||
compositorName = "Hyprland"
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "Configuring DMS greeter...")
|
||||
logFunc := func(line string) {
|
||||
r.log(line)
|
||||
fmt.Fprintf(os.Stdout, " greeter: %s\n", line)
|
||||
}
|
||||
if err := greeter.AutoSetupGreeter(compositorName, sudoPassword, logFunc); err != nil {
|
||||
// Non-fatal, matching TUI behavior
|
||||
fmt.Fprintf(os.Stderr, "Warning: greeter setup issue (non-fatal): %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Deploy configurations
|
||||
fmt.Fprintln(os.Stdout, "Deploying configurations...")
|
||||
r.log("Starting configuration deployment")
|
||||
|
||||
deployer := config.NewConfigDeployer(r.logChan)
|
||||
results, err := deployer.DeployConfigurationsSelectiveWithReinstalls(
|
||||
context.Background(),
|
||||
wm,
|
||||
terminal,
|
||||
dependencies,
|
||||
replaceConfigs,
|
||||
reinstallItems,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuration deployment failed: %w", err)
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
if result.Deployed {
|
||||
msg := fmt.Sprintf(" Deployed: %s", result.ConfigType)
|
||||
if result.BackupPath != "" {
|
||||
msg += fmt.Sprintf(" (backup: %s)", result.BackupPath)
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, msg)
|
||||
}
|
||||
if result.Error != nil {
|
||||
fmt.Fprintf(os.Stderr, " Error deploying %s: %v\n", result.ConfigType, result.Error)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, "\nInstallation complete!")
|
||||
r.log("Headless installation completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildDisabledItems computes the set of dependencies that should be skipped
|
||||
// during installation, applying the --include-deps and --exclude-deps filters.
|
||||
// dms-greeter is disabled by default (opt-in), matching TUI behavior.
|
||||
func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]bool, error) {
|
||||
disabledItems := make(map[string]bool)
|
||||
|
||||
// dms-greeter is opt-in (disabled by default), matching TUI behavior
|
||||
for i := range dependencies {
|
||||
if dependencies[i].Name == "dms-greeter" {
|
||||
disabledItems["dms-greeter"] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Process --include-deps (enable items that are disabled by default)
|
||||
for _, name := range r.cfg.IncludeDeps {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if !r.depExists(dependencies, name) {
|
||||
return nil, fmt.Errorf("--include-deps: unknown dependency %q", name)
|
||||
}
|
||||
delete(disabledItems, name)
|
||||
}
|
||||
|
||||
// Process --exclude-deps (disable items)
|
||||
for _, name := range r.cfg.ExcludeDeps {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if !r.depExists(dependencies, name) {
|
||||
return nil, fmt.Errorf("--exclude-deps: unknown dependency %q", name)
|
||||
}
|
||||
// Don't allow excluding DMS itself
|
||||
if name == "dms (DankMaterialShell)" {
|
||||
return nil, fmt.Errorf("--exclude-deps: cannot exclude required package %q", name)
|
||||
}
|
||||
disabledItems[name] = true
|
||||
}
|
||||
|
||||
return disabledItems, nil
|
||||
}
|
||||
|
||||
// buildReplaceConfigs converts the --replace-configs / --replace-configs-all
|
||||
// flags into the map[string]bool consumed by the config deployer.
|
||||
//
|
||||
// Returns:
|
||||
// - nil when --replace-configs-all is set (deployer treats nil as "replace all")
|
||||
// - a map with all known configs set to false when neither flag is set (deploy only if config file is missing on disk)
|
||||
// - a map with requested configs true, all others false for --replace-configs
|
||||
// - an error when both flags are set or an invalid config name is given
|
||||
func (r *Runner) buildReplaceConfigs() (map[string]bool, error) {
|
||||
hasSpecific := len(r.cfg.ReplaceConfigs) > 0
|
||||
if hasSpecific && r.cfg.ReplaceConfigsAll {
|
||||
return nil, fmt.Errorf("--replace-configs and --replace-configs-all are mutually exclusive")
|
||||
}
|
||||
|
||||
if r.cfg.ReplaceConfigsAll {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build a map with all known configs explicitly set to false
|
||||
result := make(map[string]bool, len(validConfigNames))
|
||||
for _, cliName := range orderedConfigNames {
|
||||
result[validConfigNames[cliName]] = false
|
||||
}
|
||||
|
||||
// Enable only the requested configs
|
||||
for _, name := range r.cfg.ReplaceConfigs {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
deployerKey, ok := validConfigNames[strings.ToLower(name)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("--replace-configs: unknown config %q; valid values: niri, hyprland, ghostty, kitty, alacritty", name)
|
||||
}
|
||||
result[deployerKey] = true
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Runner) log(message string) {
|
||||
select {
|
||||
case r.logChan <- message:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) parseWindowManager() (deps.WindowManager, error) {
|
||||
switch strings.ToLower(r.cfg.Compositor) {
|
||||
case "niri":
|
||||
return deps.WindowManagerNiri, nil
|
||||
case "hyprland":
|
||||
return deps.WindowManagerHyprland, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri' or 'hyprland'", r.cfg.Compositor)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) parseTerminal() (deps.Terminal, error) {
|
||||
switch strings.ToLower(r.cfg.Terminal) {
|
||||
case "ghostty":
|
||||
return deps.TerminalGhostty, nil
|
||||
case "kitty":
|
||||
return deps.TerminalKitty, nil
|
||||
case "alacritty":
|
||||
return deps.TerminalAlacritty, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid --term value %q: must be 'ghostty', 'kitty', or 'alacritty'", r.cfg.Terminal)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) resolveSudoPassword() (string, error) {
|
||||
// Check if sudo credentials are cached (via sudo -v or NOPASSWD)
|
||||
cmd := exec.Command("sudo", "-n", "true")
|
||||
if err := cmd.Run(); err == nil {
|
||||
r.log("sudo cache is valid, no password needed")
|
||||
fmt.Fprintln(os.Stdout, "sudo: using cached credentials")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf(
|
||||
"sudo authentication required but no cached credentials found\n" +
|
||||
"Options:\n" +
|
||||
" 1. Run 'sudo -v' before dankinstall to cache credentials\n" +
|
||||
" 2. Configure passwordless sudo for your user",
|
||||
)
|
||||
}
|
||||
|
||||
func (r *Runner) anyConfigEnabled(m map[string]bool) bool {
|
||||
for _, v := range m {
|
||||
if v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *Runner) depExists(dependencies []deps.Dependency, name string) bool {
|
||||
for _, dep := range dependencies {
|
||||
if dep.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user