diff --git a/core/README.md b/core/README.md index f955ad43..fed5bfad 100644 --- a/core/README.md +++ b/core/README.md @@ -10,7 +10,7 @@ Go-based backend for DankMaterialShell providing system integration, IPC, and in Command-line interface and daemon for shell management and system control. **dankinstall** -Distribution-aware installer with TUI for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo. +Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo. Supports both an interactive TUI and a headless (unattended) mode via CLI flags. ## System Integration @@ -147,10 +147,50 @@ go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \ ## Installation via dankinstall +**Interactive (TUI):** + ```bash curl -fsSL https://install.danklinux.com | sh ``` +**Headless (unattended):** + +Headless mode requires cached sudo credentials. Run `sudo -v` first: + +```bash +sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c niri -t ghostty -y +sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c hyprland -t kitty --include-deps dms-greeter -y +``` + +| Flag | Short | Description | +|------|-------|-------------| +| `--compositor ` | `-c` | Compositor/WM to install (required for headless) | +| `--term ` | `-t` | Terminal emulator (required for headless) | +| `--include-deps ` | | Enable optional dependencies (e.g. `dms-greeter`) | +| `--exclude-deps ` | | Skip specific dependencies | +| `--replace-configs ` | | Replace specific configuration files (mutually exclusive with `--replace-configs-all`) | +| `--replace-configs-all` | | Replace all configuration files (mutually exclusive with `--replace-configs`) | +| `--yes` | `-y` | Required for headless mode — confirms installation without interactive prompts | + +Headless mode requires `--yes` to proceed; without it, the installer exits with an error. +Configuration files are not replaced by default unless `--replace-configs` or `--replace-configs-all` is specified. +`dms-greeter` is disabled by default; use `--include-deps dms-greeter` to enable it. + +When no flags are provided, `dankinstall` launches the interactive TUI. + +### Headless mode validation rules + +Headless mode activates when `--compositor` or `--term` is provided. + +- Both `--compositor` and `--term` are required; providing only one results in an error. +- Headless-only flags (`--include-deps`, `--exclude-deps`, `--replace-configs`, `--replace-configs-all`, `--yes`) are rejected in TUI mode. +- Positional arguments are not accepted. + +### Log file location + +`dankinstall` writes logs to `/tmp` by default. +Set the `DANKINSTALL_LOG_DIR` environment variable to override the log directory. + ## Supported Distributions Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives) diff --git a/core/cmd/dankinstall/main.go b/core/cmd/dankinstall/main.go index 3c72553a..042506ea 100644 --- a/core/cmd/dankinstall/main.go +++ b/core/cmd/dankinstall/main.go @@ -3,20 +3,152 @@ package main import ( "fmt" "os" + "strings" + "github.com/AvengeMedia/DankMaterialShell/core/internal/headless" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/tui" tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" ) var Version = "dev" +// Flag variables bound via pflag +var ( + compositor string + term string + includeDeps []string + excludeDeps []string + replaceConfigs []string + replaceConfigsAll bool + yes bool +) + +var rootCmd = &cobra.Command{ + Use: "dankinstall", + Short: "Install DankMaterialShell and its dependencies", + Long: `dankinstall sets up DankMaterialShell with your chosen compositor and terminal. + +Without flags, it launches an interactive TUI. Providing either --compositor +or --term activates headless (unattended) mode, which requires both flags. + +Headless mode requires cached sudo credentials. Run 'sudo -v' beforehand, or +configure passwordless sudo for your user.`, + Args: cobra.NoArgs, + RunE: runDankinstall, + SilenceErrors: true, + SilenceUsage: true, +} + +func init() { + rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri or hyprland (enables headless mode)") + rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)") + rootCmd.Flags().StringSliceVar(&includeDeps, "include-deps", []string{}, "Optional deps to enable (e.g. dms-greeter)") + rootCmd.Flags().StringSliceVar(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation") + rootCmd.Flags().StringSliceVar(&replaceConfigs, "replace-configs", []string{}, "Deploy only named configs (e.g. niri,ghostty)") + rootCmd.Flags().BoolVar(&replaceConfigsAll, "replace-configs-all", false, "Deploy and replace all configurations") + rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts") +} + func main() { if os.Getuid() == 0 { fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root") os.Exit(1) } + if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func runDankinstall(cmd *cobra.Command, args []string) error { + headlessMode := compositor != "" || term != "" + + if !headlessMode { + // Reject headless-only flags when running in TUI mode. + headlessOnly := []string{ + "include-deps", + "exclude-deps", + "replace-configs", + "replace-configs-all", + "yes", + } + var set []string + for _, name := range headlessOnly { + if cmd.Flags().Changed(name) { + set = append(set, "--"+name) + } + } + if len(set) > 0 { + return fmt.Errorf("flags %s are only valid in headless mode (requires both --compositor and --term)", strings.Join(set, ", ")) + } + } + + if headlessMode { + return runHeadless() + } + return runTUI() +} + +func runHeadless() error { + // Validate required flags + if compositor == "" { + return fmt.Errorf("--compositor is required for headless mode (niri or hyprland)") + } + if term == "" { + return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)") + } + + cfg := headless.Config{ + Compositor: compositor, + Terminal: term, + IncludeDeps: includeDeps, + ExcludeDeps: excludeDeps, + ReplaceConfigs: replaceConfigs, + ReplaceConfigsAll: replaceConfigsAll, + Yes: yes, + } + + runner := headless.NewRunner(cfg) + + // Set up file logging + fileLogger, err := log.NewFileLogger() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to create log file: %v\n", err) + } + + if fileLogger != nil { + fmt.Printf("Logging to: %s\n", fileLogger.GetLogPath()) + fileLogger.StartListening(runner.GetLogChan()) + defer func() { + if err := fileLogger.Close(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to close log file: %v\n", err) + } + }() + } else { + // Drain the log channel to prevent blocking sends from deadlocking + // downstream components (distros, config deployer) that write to it. + // Use an explicit stop signal because this code does not own the + // runner log channel and cannot assume it will be closed. + defer drainLogChan(runner.GetLogChan())() + } + + if err := runner.Run(); err != nil { + if fileLogger != nil { + fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", fileLogger.GetLogPath()) + } + return err + } + + if fileLogger != nil { + fmt.Printf("\nFull logs are available at: %s\n", fileLogger.GetLogPath()) + } + return nil +} + +func runTUI() error { fileLogger, err := log.NewFileLogger() if err != nil { fmt.Printf("Warning: Failed to create log file: %v\n", err) @@ -38,18 +170,50 @@ func main() { if fileLogger != nil { fileLogger.StartListening(model.GetLogChan()) + } else { + // Drain the log channel to prevent blocking sends from deadlocking + // downstream components (distros, config deployer) that write to it. + // Use an explicit stop signal because this code does not own the + // model log channel and cannot assume it will be closed. + defer drainLogChan(model.GetLogChan())() } p := tea.NewProgram(model, tea.WithAltScreen()) if _, err := p.Run(); err != nil { - fmt.Printf("Error running program: %v\n", err) if logFilePath != "" { - fmt.Printf("\nFull logs are available at: %s\n", logFilePath) + fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", logFilePath) } - os.Exit(1) + return fmt.Errorf("error running program: %w", err) } if logFilePath != "" { fmt.Printf("\nFull logs are available at: %s\n", logFilePath) } + return nil +} + +// drainLogChan starts a goroutine that discards all messages from logCh, +// preventing blocking sends from deadlocking downstream components. It returns +// a cleanup function that signals the goroutine to stop and waits for it to +// exit. Callers should defer the returned function. +func drainLogChan(logCh <-chan string) func() { + drainStop := make(chan struct{}) + drainDone := make(chan struct{}) + go func() { + defer close(drainDone) + for { + select { + case <-drainStop: + return + case _, ok := <-logCh: + if !ok { + return + } + } + } + }() + return func() { + close(drainStop) + <-drainDone + } } diff --git a/core/internal/config/deployer.go b/core/internal/config/deployer.go index 417aab20..7cc513f2 100644 --- a/core/internal/config/deployer.go +++ b/core/internal/config/deployer.go @@ -62,12 +62,31 @@ func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx contex func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) { var results []DeploymentResult + // Primary config file paths used to detect fresh installs. + configPrimaryPaths := map[string]string{ + "Niri": filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"), + "Hyprland": filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"), + "Ghostty": filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"), + "Kitty": filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"), + "Alacritty": filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"), + } + shouldReplaceConfig := func(configType string) bool { if replaceConfigs == nil { return true } replace, exists := replaceConfigs[configType] - return !exists || replace + if !exists || replace { + return true + } + // Config is explicitly set to "don't replace" — but still deploy + // if the config file doesn't exist yet (fresh install scenario). + if primaryPath, ok := configPrimaryPaths[configType]; ok { + if _, err := os.Stat(primaryPath); os.IsNotExist(err) { + return true + } + } + return false } switch wm { diff --git a/core/internal/config/deployer_test.go b/core/internal/config/deployer_test.go index b0a8048e..37266340 100644 --- a/core/internal/config/deployer_test.go +++ b/core/internal/config/deployer_test.go @@ -1,6 +1,7 @@ package config import ( + "context" "os" "path/filepath" "testing" @@ -624,3 +625,168 @@ func TestAlacrittyConfigDeployment(t *testing.T) { assert.Contains(t, string(newContent), "decorations = \"None\"") }) } + +func TestShouldReplaceConfigDeployIfMissing(t *testing.T) { + allFalse := map[string]bool{ + "Niri": false, + "Hyprland": false, + "Ghostty": false, + "Kitty": false, + "Alacritty": false, + } + + t.Run("replaceConfigs nil deploys config", func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "dankinstall-replace-nil-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + logChan := make(chan string, 100) + cd := NewConfigDeployer(logChan) + + results, err := cd.DeployConfigurationsSelectiveWithReinstalls( + context.Background(), + deps.WindowManagerNiri, + deps.TerminalGhostty, + nil, // installedDeps + nil, // replaceConfigs + nil, // reinstallItems + ) + require.NoError(t, err) + + // With replaceConfigs=nil, all configs should be deployed + hasDeployed := false + for _, r := range results { + if r.Deployed { + hasDeployed = true + break + } + } + assert.True(t, hasDeployed, "expected at least one config to be deployed when replaceConfigs is nil") + }) + + t.Run("replaceConfigs all false and config missing deploys config", func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "dankinstall-replace-missing-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + logChan := make(chan string, 100) + cd := NewConfigDeployer(logChan) + + results, err := cd.DeployConfigurationsSelectiveWithReinstalls( + context.Background(), + deps.WindowManagerNiri, + deps.TerminalGhostty, + nil, // installedDeps + allFalse, // replaceConfigs — all false + nil, // reinstallItems + ) + require.NoError(t, err) + + // Config files don't exist on disk, so they should still be deployed + hasDeployed := false + for _, r := range results { + if r.Deployed { + hasDeployed = true + break + } + } + assert.True(t, hasDeployed, "expected configs to be deployed when files are missing, even with replaceConfigs all false") + }) + + t.Run("replaceConfigs false and config exists skips config", func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "dankinstall-replace-exists-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + // Create the Ghostty primary config file so shouldReplaceConfig returns false + ghosttyPath := filepath.Join(tempDir, ".config", "ghostty", "config") + err = os.MkdirAll(filepath.Dir(ghosttyPath), 0o755) + require.NoError(t, err) + err = os.WriteFile(ghosttyPath, []byte("# existing ghostty config\n"), 0o644) + require.NoError(t, err) + + // Also create the Niri primary config file + niriPath := filepath.Join(tempDir, ".config", "niri", "config.kdl") + err = os.MkdirAll(filepath.Dir(niriPath), 0o755) + require.NoError(t, err) + err = os.WriteFile(niriPath, []byte("// existing niri config\n"), 0o644) + require.NoError(t, err) + + logChan := make(chan string, 100) + cd := NewConfigDeployer(logChan) + + results, err := cd.DeployConfigurationsSelectiveWithReinstalls( + context.Background(), + deps.WindowManagerNiri, + deps.TerminalGhostty, + nil, // installedDeps + allFalse, // replaceConfigs — all false + nil, // reinstallItems + ) + require.NoError(t, err) + + // Both Niri and Ghostty config files exist, so with all false they should be skipped + for _, r := range results { + assert.Fail(t, "expected no configs to be deployed", "got deployed config: %s", r.ConfigType) + } + }) + + t.Run("replaceConfigs true and config exists deploys config", func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "dankinstall-replace-true-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + // Create the Ghostty primary config file + ghosttyPath := filepath.Join(tempDir, ".config", "ghostty", "config") + err = os.MkdirAll(filepath.Dir(ghosttyPath), 0o755) + require.NoError(t, err) + err = os.WriteFile(ghosttyPath, []byte("# existing ghostty config\n"), 0o644) + require.NoError(t, err) + + logChan := make(chan string, 100) + cd := NewConfigDeployer(logChan) + + replaceConfigs := map[string]bool{ + "Niri": false, + "Hyprland": false, + "Ghostty": true, // explicitly true + "Kitty": false, + "Alacritty": false, + } + + results, err := cd.DeployConfigurationsSelectiveWithReinstalls( + context.Background(), + deps.WindowManagerNiri, + deps.TerminalGhostty, + nil, // installedDeps + replaceConfigs, // Ghostty=true, rest=false + nil, // reinstallItems + ) + require.NoError(t, err) + + // Ghostty should be deployed because replaceConfigs["Ghostty"]=true + foundGhostty := false + for _, r := range results { + if r.ConfigType == "Ghostty" && r.Deployed { + foundGhostty = true + } + } + assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true") + }) +} diff --git a/core/internal/headless/runner.go b/core/internal/headless/runner.go new file mode 100644 index 00000000..7dae9fcb --- /dev/null +++ b/core/internal/headless/runner.go @@ -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 +} diff --git a/core/internal/headless/runner_test.go b/core/internal/headless/runner_test.go new file mode 100644 index 00000000..79260ae3 --- /dev/null +++ b/core/internal/headless/runner_test.go @@ -0,0 +1,459 @@ +package headless + +import ( + "strings" + "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" +) + +func TestParseWindowManager(t *testing.T) { + tests := []struct { + name string + input string + want deps.WindowManager + wantErr bool + }{ + {"niri lowercase", "niri", deps.WindowManagerNiri, false}, + {"niri mixed case", "Niri", deps.WindowManagerNiri, false}, + {"hyprland lowercase", "hyprland", deps.WindowManagerHyprland, false}, + {"hyprland mixed case", "Hyprland", deps.WindowManagerHyprland, false}, + {"invalid", "sway", 0, true}, + {"empty", "", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRunner(Config{Compositor: tt.input}) + got, err := r.parseWindowManager() + if (err != nil) != tt.wantErr { + t.Errorf("parseWindowManager() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != tt.want { + t.Errorf("parseWindowManager() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseTerminal(t *testing.T) { + tests := []struct { + name string + input string + want deps.Terminal + wantErr bool + }{ + {"ghostty lowercase", "ghostty", deps.TerminalGhostty, false}, + {"ghostty mixed case", "Ghostty", deps.TerminalGhostty, false}, + {"kitty lowercase", "kitty", deps.TerminalKitty, false}, + {"alacritty lowercase", "alacritty", deps.TerminalAlacritty, false}, + {"alacritty uppercase", "ALACRITTY", deps.TerminalAlacritty, false}, + {"invalid", "wezterm", 0, true}, + {"empty", "", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRunner(Config{Terminal: tt.input}) + got, err := r.parseTerminal() + if (err != nil) != tt.wantErr { + t.Errorf("parseTerminal() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != tt.want { + t.Errorf("parseTerminal() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDepExists(t *testing.T) { + dependencies := []deps.Dependency{ + {Name: "niri", Status: deps.StatusInstalled}, + {Name: "ghostty", Status: deps.StatusMissing}, + {Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled}, + {Name: "dms-greeter", Status: deps.StatusMissing}, + } + + tests := []struct { + name string + dep string + want bool + }{ + {"existing dep", "niri", true}, + {"existing dep with special chars", "dms (DankMaterialShell)", true}, + {"existing optional dep", "dms-greeter", true}, + {"non-existing dep", "firefox", false}, + {"empty name", "", false}, + } + + r := NewRunner(Config{}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := r.depExists(dependencies, tt.dep); got != tt.want { + t.Errorf("depExists(%q) = %v, want %v", tt.dep, got, tt.want) + } + }) + } +} + +func TestNewRunner(t *testing.T) { + cfg := Config{ + Compositor: "niri", + Terminal: "ghostty", + IncludeDeps: []string{"dms-greeter"}, + ExcludeDeps: []string{"some-pkg"}, + Yes: true, + } + r := NewRunner(cfg) + + if r == nil { + t.Fatal("NewRunner returned nil") + } + if r.cfg.Compositor != "niri" { + t.Errorf("cfg.Compositor = %q, want %q", r.cfg.Compositor, "niri") + } + if r.cfg.Terminal != "ghostty" { + t.Errorf("cfg.Terminal = %q, want %q", r.cfg.Terminal, "ghostty") + } + if !r.cfg.Yes { + t.Error("cfg.Yes = false, want true") + } + if r.logChan == nil { + t.Error("logChan is nil") + } +} + +func TestGetLogChan(t *testing.T) { + r := NewRunner(Config{}) + ch := r.GetLogChan() + if ch == nil { + t.Fatal("GetLogChan returned nil") + } + + // Verify the channel is readable by sending a message + go func() { + r.logChan <- "test message" + }() + msg := <-ch + if msg != "test message" { + t.Errorf("received %q, want %q", msg, "test message") + } +} + +func TestLog(t *testing.T) { + r := NewRunner(Config{}) + + // log should not block even if channel is full + for i := 0; i < 1100; i++ { + r.log("message") + } + // If we reach here without hanging, the non-blocking send works +} + +func TestRunRequiresYes(t *testing.T) { + // Verify that ErrConfirmationRequired is a distinct sentinel error + if ErrConfirmationRequired == nil { + t.Fatal("ErrConfirmationRequired should not be nil") + } + expected := "confirmation required: pass --yes to proceed" + if ErrConfirmationRequired.Error() != expected { + t.Errorf("ErrConfirmationRequired = %q, want %q", ErrConfirmationRequired.Error(), expected) + } +} + +func TestConfigYesStoredCorrectly(t *testing.T) { + // Yes=false (default) should be stored + rNo := NewRunner(Config{Compositor: "niri", Terminal: "ghostty", Yes: false}) + if rNo.cfg.Yes { + t.Error("cfg.Yes = true, want false") + } + + // Yes=true should be stored + rYes := NewRunner(Config{Compositor: "niri", Terminal: "ghostty", Yes: true}) + if !rYes.cfg.Yes { + t.Error("cfg.Yes = false, want true") + } +} + +func TestValidConfigNamesCompleteness(t *testing.T) { + // orderedConfigNames and validConfigNames must stay in sync. + if len(orderedConfigNames) != len(validConfigNames) { + t.Fatalf("orderedConfigNames has %d entries but validConfigNames has %d", + len(orderedConfigNames), len(validConfigNames)) + } + + // Every entry in orderedConfigNames must exist in validConfigNames. + for _, name := range orderedConfigNames { + if _, ok := validConfigNames[name]; !ok { + t.Errorf("orderedConfigNames contains %q which is missing from validConfigNames", name) + } + } + + // validConfigNames must have no extra keys not in orderedConfigNames. + ordered := make(map[string]bool, len(orderedConfigNames)) + for _, name := range orderedConfigNames { + ordered[name] = true + } + for key := range validConfigNames { + if !ordered[key] { + t.Errorf("validConfigNames contains %q which is missing from orderedConfigNames", key) + } + } +} + +func TestBuildReplaceConfigs(t *testing.T) { + allDeployerKeys := []string{"Niri", "Hyprland", "Ghostty", "Kitty", "Alacritty"} + + tests := []struct { + name string + replaceConfigs []string + replaceAll bool + wantNil bool // expect nil (replace all) + wantEnabled []string // deployer keys that should be true + wantErr bool + }{ + { + name: "neither flag set", + wantNil: false, + wantEnabled: nil, // all should be false + }, + { + name: "replace-configs-all", + replaceAll: true, + wantNil: true, + }, + { + name: "specific configs", + replaceConfigs: []string{"niri", "ghostty"}, + wantNil: false, + wantEnabled: []string{"Niri", "Ghostty"}, + }, + { + name: "both flags set", + replaceConfigs: []string{"niri"}, + replaceAll: true, + wantErr: true, + }, + { + name: "invalid config name", + replaceConfigs: []string{"foo"}, + wantErr: true, + }, + { + name: "case insensitive", + replaceConfigs: []string{"NIRI", "Ghostty"}, + wantNil: false, + wantEnabled: []string{"Niri", "Ghostty"}, + }, + { + name: "single config", + replaceConfigs: []string{"kitty"}, + wantNil: false, + wantEnabled: []string{"Kitty"}, + }, + { + name: "whitespace entry", + replaceConfigs: []string{" ", "niri"}, + wantNil: false, + wantEnabled: []string{"Niri"}, + }, + { + name: "duplicate entry", + replaceConfigs: []string{"niri", "niri"}, + wantNil: false, + wantEnabled: []string{"Niri"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRunner(Config{ + ReplaceConfigs: tt.replaceConfigs, + ReplaceConfigsAll: tt.replaceAll, + }) + got, err := r.buildReplaceConfigs() + if (err != nil) != tt.wantErr { + t.Fatalf("buildReplaceConfigs() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if tt.wantNil { + if got != nil { + t.Fatalf("buildReplaceConfigs() = %v, want nil", got) + } + return + } + if got == nil { + t.Fatal("buildReplaceConfigs() = nil, want non-nil map") + } + + // All known deployer keys must be present + for _, key := range allDeployerKeys { + if _, exists := got[key]; !exists { + t.Errorf("missing deployer key %q in result map", key) + } + } + + // Build enabled set for easy lookup + enabledSet := make(map[string]bool) + for _, k := range tt.wantEnabled { + enabledSet[k] = true + } + + for _, key := range allDeployerKeys { + want := enabledSet[key] + if got[key] != want { + t.Errorf("replaceConfigs[%q] = %v, want %v", key, got[key], want) + } + } + }) + } +} + +func TestConfigReplaceConfigsStoredCorrectly(t *testing.T) { + r := NewRunner(Config{ + Compositor: "niri", + Terminal: "ghostty", + ReplaceConfigs: []string{"niri", "ghostty"}, + ReplaceConfigsAll: false, + }) + if len(r.cfg.ReplaceConfigs) != 2 { + t.Errorf("len(ReplaceConfigs) = %d, want 2", len(r.cfg.ReplaceConfigs)) + } + if r.cfg.ReplaceConfigsAll { + t.Error("ReplaceConfigsAll = true, want false") + } + + r2 := NewRunner(Config{ + Compositor: "niri", + Terminal: "ghostty", + ReplaceConfigsAll: true, + }) + if !r2.cfg.ReplaceConfigsAll { + t.Error("ReplaceConfigsAll = false, want true") + } + if len(r2.cfg.ReplaceConfigs) != 0 { + t.Errorf("len(ReplaceConfigs) = %d, want 0", len(r2.cfg.ReplaceConfigs)) + } +} + +func TestBuildDisabledItems(t *testing.T) { + dependencies := []deps.Dependency{ + {Name: "niri", Status: deps.StatusInstalled}, + {Name: "ghostty", Status: deps.StatusMissing}, + {Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled}, + {Name: "dms-greeter", Status: deps.StatusMissing}, + {Name: "waybar", Status: deps.StatusMissing}, + } + + tests := []struct { + name string + includeDeps []string + excludeDeps []string + deps []deps.Dependency // nil means use the shared fixture + wantErr bool + errContains string // substring expected in error message + wantDisabled []string // dep names that should be in disabledItems + wantEnabled []string // dep names that should NOT be in disabledItems (extra check) + }{ + { + name: "no flags set, dms-greeter disabled by default", + wantDisabled: []string{"dms-greeter"}, + wantEnabled: []string{"niri", "ghostty", "waybar"}, + }, + { + name: "include dms-greeter enables it", + includeDeps: []string{"dms-greeter"}, + wantEnabled: []string{"dms-greeter"}, + }, + { + name: "exclude a regular dep", + excludeDeps: []string{"waybar"}, + wantDisabled: []string{"dms-greeter", "waybar"}, + }, + { + name: "include unknown dep returns error", + includeDeps: []string{"nonexistent"}, + wantErr: true, + errContains: "--include-deps", + }, + { + name: "exclude unknown dep returns error", + excludeDeps: []string{"nonexistent"}, + wantErr: true, + errContains: "--exclude-deps", + }, + { + name: "exclude DMS itself is forbidden", + excludeDeps: []string{"dms (DankMaterialShell)"}, + wantErr: true, + errContains: "cannot exclude required package", + }, + { + name: "include and exclude same dep", + includeDeps: []string{"dms-greeter"}, + excludeDeps: []string{"dms-greeter"}, + wantDisabled: []string{"dms-greeter"}, + }, + { + name: "whitespace entries are skipped", + includeDeps: []string{" ", "dms-greeter"}, + wantEnabled: []string{"dms-greeter"}, + }, + { + name: "no dms-greeter in deps, nothing disabled by default", + deps: []deps.Dependency{ + {Name: "niri", Status: deps.StatusInstalled}, + }, + wantEnabled: []string{"niri"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRunner(Config{ + IncludeDeps: tt.includeDeps, + ExcludeDeps: tt.excludeDeps, + }) + d := tt.deps + if d == nil { + d = dependencies + } + got, err := r.buildDisabledItems(d) + if (err != nil) != tt.wantErr { + t.Fatalf("buildDisabledItems() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("error %q does not contain %q", err.Error(), tt.errContains) + } + return + } + if got == nil { + t.Fatal("buildDisabledItems() returned nil map, want non-nil") + } + + // Check expected disabled items + for _, name := range tt.wantDisabled { + if !got[name] { + t.Errorf("expected %q to be disabled, but it is not", name) + } + } + + // Check expected enabled items (should not be in the map or be false) + for _, name := range tt.wantEnabled { + if got[name] { + t.Errorf("expected %q to NOT be disabled, but it is", name) + } + } + + // If wantDisabled is empty, the map should have length 0 + if len(tt.wantDisabled) == 0 && len(got) != 0 { + t.Errorf("expected empty disabledItems map, got %v", got) + } + }) + } +} diff --git a/core/internal/log/filelogger.go b/core/internal/log/filelogger.go index 389d38a1..e3ff42b5 100644 --- a/core/internal/log/filelogger.go +++ b/core/internal/log/filelogger.go @@ -4,6 +4,7 @@ import ( "bufio" "fmt" "os" + "path/filepath" "regexp" "sync" "time" @@ -21,7 +22,16 @@ type FileLogger struct { func NewFileLogger() (*FileLogger, error) { timestamp := time.Now().Unix() - logPath := fmt.Sprintf("/tmp/dankinstall-%d.log", timestamp) + + // Use DANKINSTALL_LOG_DIR if set, otherwise fall back to /tmp. + logDir := os.Getenv("DANKINSTALL_LOG_DIR") + if logDir == "" { + logDir = "/tmp" + } + if err := os.MkdirAll(logDir, 0o755); err != nil { + return nil, fmt.Errorf("failed to create log directory: %w", err) + } + logPath := filepath.Join(logDir, fmt.Sprintf("dankinstall-%d.log", timestamp)) file, err := os.Create(logPath) if err != nil {