mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-04-14 09:42:10 -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:
@@ -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 <niri|hyprland>` | `-c` | Compositor/WM to install (required for headless) |
|
||||
| `--term <ghostty|kitty|alacritty>` | `-t` | Terminal emulator (required for headless) |
|
||||
| `--include-deps <name,...>` | | Enable optional dependencies (e.g. `dms-greeter`) |
|
||||
| `--exclude-deps <name,...>` | | Skip specific dependencies |
|
||||
| `--replace-configs <name,...>` | | 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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
459
core/internal/headless/runner_test.go
Normal file
459
core/internal/headless/runner_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user