From f0867798e6ec1531edc4a16c41d84bcf3fa126a8 Mon Sep 17 00:00:00 2001 From: JDKamalakar <101495154+JDKamalakar@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:53:46 +0530 Subject: [PATCH] dankinstall: add non-interactive install script (#3063) fixes #3043 --- core/README.md | 20 ++- core/cmd/dankinstall/main.go | 24 +++- core/install.sh | 9 +- core/internal/distros/interface.go | 1 + core/internal/headless/runner.go | 119 +++++++++++++++-- core/internal/headless/runner_test.go | 178 ++++++++++++++++++++++++++ 6 files changed, 327 insertions(+), 24 deletions(-) diff --git a/core/README.md b/core/README.md index 4a8edc6a2..c80bbcd50 100644 --- a/core/README.md +++ b/core/README.md @@ -157,17 +157,25 @@ curl -fsSL https://install.danklinux.com | sh **Headless (unattended):** -Headless mode requires cached sudo credentials. Run `sudo -v` first: +Headless mode requires cached credentials or a passwordless rule for your privilege escalation tool (sudo, doas, or run0). With sudo, 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 +sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c hyprland -t kitty --dms-greeter -y +sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c niri -t ghostty --git-deps niri,quickshell --all-features -y ``` | Flag | Short | Description | |------|-------|-------------| -| `--compositor ` | `-c` | Compositor/WM to install (required for headless) | -| `--term ` | `-t` | Terminal emulator (required for headless) | +| `--compositor ` | `-c` | Compositor/WM to install (required for headless) | +| `--term ` | `-t` | Terminal emulator (required for headless) | +| `--privesc ` | | Privilege escalation tool (default: autodetect) | +| `--git` | | Install the git version of every dependency that has one | +| `--git-deps ` | | Install the git version of specific dependencies (e.g. `niri,quickshell`) | +| `--all-features` | | Enable all optional dependencies (`dms-greeter`, `danksearch`, `dankcalendar`) | +| `--dms-greeter` | | Install dms-greeter | +| `--danksearch` | | Install danksearch and enable its user indexing service | +| `--dankcalendar` | | Install dankcalendar | | `--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`) | @@ -176,7 +184,7 @@ sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c hyprland -t ki 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. +Optional packages (`dms-greeter`, `danksearch`, `dankcalendar`) are disabled by default; enable them with their dedicated flags, `--include-deps`, or `--all-features`. When no flags are provided, `dankinstall` launches the interactive TUI. @@ -185,7 +193,7 @@ When no flags are provided, `dankinstall` launches the interactive TUI. 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. +- Headless-only flags (`--privesc`, `--git`, `--git-deps`, `--all-features`, `--include-deps`, `--exclude-deps`, `--replace-configs`, `--replace-configs-all`, `--yes`, `--danksearch`, `--dankcalendar`, `--dms-greeter`) are rejected in TUI mode. - Positional arguments are not accepted. ### Log file location diff --git a/core/cmd/dankinstall/main.go b/core/cmd/dankinstall/main.go index 389992ab1..ab2020b95 100644 --- a/core/cmd/dankinstall/main.go +++ b/core/cmd/dankinstall/main.go @@ -18,6 +18,10 @@ var Version = "dev" var ( compositor string term string + privescTool string + gitAll bool + gitDeps []string + allFeatures bool includeDeps []string excludeDeps []string replaceConfigs []string @@ -25,6 +29,7 @@ var ( yes bool danksearch bool dankcalendar bool + dmsGreeter bool ) var rootCmd = &cobra.Command{ @@ -35,8 +40,8 @@ var rootCmd = &cobra.Command{ 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.`, +Headless mode requires cached credentials or a passwordless rule for your +privilege escalation tool (sudo, doas, or run0).`, Args: cobra.NoArgs, RunE: runDankinstall, SilenceErrors: true, @@ -46,6 +51,10 @@ configure passwordless sudo for your user.`, func init() { rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri, hyprland, or mango (enables headless mode)") rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)") + rootCmd.Flags().StringVar(&privescTool, "privesc", "", "Privilege escalation tool: sudo, doas, or run0 (default: autodetect)") + rootCmd.Flags().BoolVar(&gitAll, "git", false, "Install the git version of every dep that has one") + rootCmd.Flags().StringSliceVar(&gitDeps, "git-deps", []string{}, "Deps to install the git version of (e.g. niri,quickshell)") + rootCmd.Flags().BoolVar(&allFeatures, "all-features", false, "Enable all optional deps (dms-greeter, danksearch, dankcalendar)") 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)") @@ -53,6 +62,7 @@ func init() { rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts") rootCmd.Flags().BoolVar(&danksearch, "danksearch", false, "Install danksearch and enable its user indexing service") rootCmd.Flags().BoolVar(&dankcalendar, "dankcalendar", false, "Install dankcalendar") + rootCmd.Flags().BoolVar(&dmsGreeter, "dms-greeter", false, "Install dms-greeter") } func main() { @@ -73,6 +83,10 @@ func runDankinstall(cmd *cobra.Command, args []string) error { if !headlessMode { // Reject headless-only flags when running in TUI mode. headlessOnly := []string{ + "privesc", + "git", + "git-deps", + "all-features", "include-deps", "exclude-deps", "replace-configs", @@ -80,6 +94,7 @@ func runDankinstall(cmd *cobra.Command, args []string) error { "yes", "danksearch", "dankcalendar", + "dms-greeter", } var set []string for _, name := range headlessOnly { @@ -110,6 +125,10 @@ func runHeadless() error { cfg := headless.Config{ Compositor: compositor, Terminal: term, + PrivescTool: privescTool, + GitAll: gitAll, + GitDeps: gitDeps, + AllFeatures: allFeatures, IncludeDeps: includeDeps, ExcludeDeps: excludeDeps, ReplaceConfigs: replaceConfigs, @@ -117,6 +136,7 @@ func runHeadless() error { Yes: yes, DankSearch: danksearch, DankCalendar: dankcalendar, + DmsGreeter: dmsGreeter, } runner := headless.NewRunner(cfg) diff --git a/core/install.sh b/core/install.sh index baca5881b..297b5b830 100755 --- a/core/install.sh +++ b/core/install.sh @@ -47,6 +47,7 @@ printf "%bInstalling Dankinstall %s for %s...%b\n" "$GREEN" "$LATEST_VERSION" "$ # Download and install TEMP_DIR=$(mktemp -d) +trap 'cd / && rm -rf "$TEMP_DIR"' EXIT cd "$TEMP_DIR" || exit 1 # Download the gzipped binary and its checksum @@ -67,8 +68,6 @@ if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then printf "Expected: %s\n" "$EXPECTED_CHECKSUM" printf "Got: %s\n" "$ACTUAL_CHECKSUM" printf "The downloaded file may be corrupted or tampered with\n" - cd - >/dev/null - rm -rf "$TEMP_DIR" exit 1 fi @@ -79,8 +78,4 @@ chmod +x installer # Execute the installer printf "%bRunning installer...%b\n" "$GREEN" "$NC" -./installer - -# Cleanup -cd - >/dev/null -rm -rf "$TEMP_DIR" +./installer "$@" diff --git a/core/internal/distros/interface.go b/core/internal/distros/interface.go index 2b70f20bf..07fa433ea 100644 --- a/core/internal/distros/interface.go +++ b/core/internal/distros/interface.go @@ -101,6 +101,7 @@ type Distribution interface { // Package Mapping GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping + GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping // Prerequisites InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error diff --git a/core/internal/headless/runner.go b/core/internal/headless/runner.go index e5f664c3e..d25aff087 100644 --- a/core/internal/headless/runner.go +++ b/core/internal/headless/runner.go @@ -34,8 +34,12 @@ var orderedConfigNames = []string{"niri", "hyprland", "ghostty", "kitty", "alacr // Config holds all CLI parameters for unattended installation. type Config struct { - Compositor string // "niri" or "hyprland" - Terminal string // "ghostty", "kitty", or "alacritty" + Compositor string // "niri", "hyprland", or "mango" + Terminal string // "ghostty", "kitty", or "alacritty" + PrivescTool string // "sudo", "doas", or "run0"; empty means autodetect + GitAll bool // git variant for every dep that supports it + GitDeps []string // deps to force the git variant of + AllFeatures bool // enable all optional deps IncludeDeps []string ExcludeDeps []string ReplaceConfigs []string // specific configs to deploy (e.g. "niri", "ghostty") @@ -43,6 +47,7 @@ type Config struct { Yes bool DankSearch bool // install danksearch and enable its user service DankCalendar bool // install dankcalendar + DmsGreeter bool // install dms-greeter } // Runner orchestrates unattended (headless) installation. @@ -68,6 +73,10 @@ func (r *Runner) GetLogChan() <-chan string { func (r *Runner) Run() error { r.log("Starting headless installation") + if err := r.applyPrivescTool(); err != nil { + return err + } + // 1. Parse compositor and terminal selections wm, err := r.parseWindowManager() if err != nil { @@ -112,14 +121,16 @@ func (r *Runner) Run() error { return fmt.Errorf("dependency detection failed: %w", err) } + reinstallItems, err := r.applyGitVariants(dependencies, gitVariantChecker(distro, wm, dependencies)) + if err != nil { + return 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:") @@ -129,6 +140,9 @@ func (r *Runner) Run() error { if disabledItems[dep.Name] { marker = " SKIP " status = "(disabled)" + } else if reinstallItems[dep.Name] { + marker = " RE " + status = "(will reinstall)" } else { switch dep.Status { case deps.StatusInstalled: @@ -283,15 +297,96 @@ func (r *Runner) Run() error { return nil } +func (r *Runner) applyPrivescTool() error { + if r.cfg.PrivescTool == "" { + return nil + } + switch tool := privesc.Tool(strings.ToLower(r.cfg.PrivescTool)); tool { + case privesc.ToolSudo, privesc.ToolDoas, privesc.ToolRun0: + if err := privesc.SetTool(tool); err != nil { + return fmt.Errorf("--privesc: %w", err) + } + return nil + default: + return fmt.Errorf("invalid --privesc value %q: must be 'sudo', 'doas', or 'run0'", r.cfg.PrivescTool) + } +} + +// CanToggle is not enough: some distros discard the variant (hyprland on Arch maps to stable either way) +func gitVariantChecker(distro distros.Distribution, wm deps.WindowManager, dependencies []deps.Dependency) func(name string) bool { + stable := distro.GetPackageMappingWithVariants(wm, map[string]deps.PackageVariant{}) + allGit := make(map[string]deps.PackageVariant, len(dependencies)) + for _, dep := range dependencies { + allGit[dep.Name] = deps.VariantGit + } + git := distro.GetPackageMappingWithVariants(wm, allGit) + return func(name string) bool { + mapping, ok := git[name] + return ok && mapping != stable[name] + } +} + +func (r *Runner) applyGitVariants(dependencies []deps.Dependency, hasGitVariant func(name string) bool) (map[string]bool, error) { + reinstallItems := make(map[string]bool) + + // InstallPackages skips StatusInstalled deps unless marked for reinstall + markGit := func(dep *deps.Dependency) { + if dep.Variant == deps.VariantStable && dep.Status == deps.StatusInstalled { + reinstallItems[dep.Name] = true + } + dep.Variant = deps.VariantGit + } + + for _, name := range r.cfg.GitDeps { + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" { + continue + } + dep := findDepFold(dependencies, name) + if dep == nil { + return nil, fmt.Errorf("--git-deps: unknown dependency %q", name) + } + if !dep.CanToggle || !hasGitVariant(dep.Name) { + return nil, fmt.Errorf("--git-deps: %q does not have a git variant on this distribution", dep.Name) + } + markGit(dep) + } + + if !r.cfg.GitAll { + return reinstallItems, nil + } + + for i := range dependencies { + if dependencies[i].CanToggle && hasGitVariant(dependencies[i].Name) { + markGit(&dependencies[i]) + } + } + return reinstallItems, nil +} + +func findDepFold(dependencies []deps.Dependency, lowerName string) *deps.Dependency { + if lowerName == "dms" { + lowerName = "dms (dankmaterialshell)" + } + for i := range dependencies { + if strings.ToLower(dependencies[i].Name) == lowerName { + return &dependencies[i] + } + } + return nil +} + // buildDisabledItems computes the set of dependencies that should be skipped // during installation. Optional components are opt-in (disabled by default), -// then re-enabled by the dedicated flags and --include-deps. +// then re-enabled by --all-features, the dedicated flags, and --include-deps. func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]bool, error) { disabledItems := make(map[string]bool) - for i := range dependencies { - if !dependencies[i].Required { - disabledItems[dependencies[i].Name] = true + if !r.cfg.AllFeatures { + for i := range dependencies { + if !dependencies[i].Required { + disabledItems[dependencies[i].Name] = true + } } } @@ -308,6 +403,12 @@ func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string] } delete(disabledItems, "dankcalendar") } + if r.cfg.DmsGreeter { + if !r.depExists(dependencies, "dms-greeter") { + return nil, fmt.Errorf("--dms-greeter: not available on this distribution") + } + delete(disabledItems, "dms-greeter") + } // Process --include-deps (enable items that are disabled by default) for _, name := range r.cfg.IncludeDeps { diff --git a/core/internal/headless/runner_test.go b/core/internal/headless/runner_test.go index 7b2825c3c..d4d701efc 100644 --- a/core/internal/headless/runner_test.go +++ b/core/internal/headless/runner_test.go @@ -357,6 +357,8 @@ func TestBuildDisabledItems(t *testing.T) { excludeDeps []string dankSearch bool dankCalendar bool + dmsGreeter bool + allFeatures bool deps []deps.Dependency // nil means use the shared fixture wantErr bool errContains string // substring expected in error message @@ -442,6 +444,31 @@ func TestBuildDisabledItems(t *testing.T) { wantErr: true, errContains: "--dankcalendar", }, + { + name: "dms-greeter flag enables it", + dmsGreeter: true, + wantEnabled: []string{"dms-greeter"}, + wantDisabled: []string{"danksearch", "dankcalendar"}, + }, + { + name: "dms-greeter flag when unavailable errors", + dmsGreeter: true, + deps: []deps.Dependency{{Name: "niri", Status: deps.StatusInstalled, Required: true}}, + wantErr: true, + errContains: "--dms-greeter", + }, + { + name: "all-features enables every optional dep", + allFeatures: true, + wantEnabled: []string{"dms-greeter", "danksearch", "dankcalendar", "niri", "ghostty", "waybar"}, + }, + { + name: "all-features with exclude still disables the excluded dep", + allFeatures: true, + excludeDeps: []string{"dankcalendar"}, + wantEnabled: []string{"dms-greeter", "danksearch"}, + wantDisabled: []string{"dankcalendar"}, + }, } for _, tt := range tests { @@ -451,6 +478,8 @@ func TestBuildDisabledItems(t *testing.T) { ExcludeDeps: tt.excludeDeps, DankSearch: tt.dankSearch, DankCalendar: tt.dankCalendar, + DmsGreeter: tt.dmsGreeter, + AllFeatures: tt.allFeatures, }) d := tt.deps if d == nil { @@ -491,3 +520,152 @@ func TestBuildDisabledItems(t *testing.T) { }) } } + +func TestApplyGitVariants(t *testing.T) { + freshDeps := func() []deps.Dependency { + return []deps.Dependency{ + {Name: "niri", Status: deps.StatusInstalled, Variant: deps.VariantStable, CanToggle: true}, + {Name: "quickshell", Status: deps.StatusMissing, Variant: deps.VariantStable, CanToggle: true}, + {Name: "hyprland", Status: deps.StatusInstalled, Variant: deps.VariantGit, CanToggle: true}, + {Name: "dms (DankMaterialShell)", Status: deps.StatusMissing, Variant: deps.VariantStable, CanToggle: true}, + {Name: "ghostty", Status: deps.StatusInstalled, Variant: deps.VariantStable, CanToggle: false}, + } + } + + tests := []struct { + name string + gitAll bool + gitDeps []string + noGitVariant []string // deps whose git and stable packages resolve identically + wantErr bool + errContains string + wantGit []string + wantStable []string + wantReinstall []string + }{ + { + name: "no git flags leaves variants untouched", + wantGit: []string{"hyprland"}, + wantStable: []string{"niri", "quickshell", "dms (DankMaterialShell)", "ghostty"}, + }, + { + name: "git-deps flips only the named deps", + gitDeps: []string{"niri", "quickshell"}, + wantGit: []string{"niri", "quickshell", "hyprland"}, + wantStable: []string{"dms (DankMaterialShell)", "ghostty"}, + wantReinstall: []string{"niri"}, + }, + { + name: "git-deps matches names case-insensitively and accepts the dms alias", + gitDeps: []string{"NIRI", "dms"}, + wantGit: []string{"niri", "dms (DankMaterialShell)", "hyprland"}, + wantStable: []string{"quickshell", "ghostty"}, + wantReinstall: []string{"niri"}, + }, + { + name: "empty and whitespace entries are skipped", + gitDeps: []string{"", " "}, + wantGit: []string{"hyprland"}, + wantStable: []string{"niri", "quickshell", "dms (DankMaterialShell)", "ghostty"}, + }, + { + name: "unknown dep errors", + gitDeps: []string{"nonexistent"}, + wantErr: true, + errContains: "--git-deps", + }, + { + name: "dep without git variant errors", + gitDeps: []string{"ghostty"}, + wantErr: true, + errContains: "does not have a git variant", + }, + { + name: "git flips every toggleable dep", + gitAll: true, + wantGit: []string{"niri", "quickshell", "hyprland", "dms (DankMaterialShell)"}, + wantStable: []string{"ghostty"}, + wantReinstall: []string{"niri"}, + }, + { + name: "git-deps errors when the distro has no distinct git package", + gitDeps: []string{"niri"}, + noGitVariant: []string{"niri"}, + wantErr: true, + errContains: "does not have a git variant", + }, + { + name: "git skips deps without a distinct git package", + gitAll: true, + noGitVariant: []string{"niri"}, + wantGit: []string{"quickshell", "hyprland", "dms (DankMaterialShell)"}, + wantStable: []string{"niri", "ghostty"}, + wantReinstall: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRunner(Config{GitAll: tt.gitAll, GitDeps: tt.gitDeps}) + d := freshDeps() + + hasGitVariant := func(name string) bool { + for _, n := range tt.noGitVariant { + if n == name { + return false + } + } + return true + } + + reinstall, err := r.applyGitVariants(d, hasGitVariant) + if (err != nil) != tt.wantErr { + t.Fatalf("applyGitVariants() 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 + } + + variants := make(map[string]deps.PackageVariant) + for _, dep := range d { + variants[dep.Name] = dep.Variant + } + for _, name := range tt.wantGit { + if variants[name] != deps.VariantGit { + t.Errorf("expected %q to be VariantGit", name) + } + } + for _, name := range tt.wantStable { + if variants[name] != deps.VariantStable { + t.Errorf("expected %q to stay VariantStable", name) + } + } + + if len(reinstall) != len(tt.wantReinstall) { + t.Errorf("reinstall = %v, want %v", reinstall, tt.wantReinstall) + } + for _, name := range tt.wantReinstall { + if !reinstall[name] { + t.Errorf("expected %q to be marked for reinstall", name) + } + } + }) + } +} + +func TestApplyPrivescTool(t *testing.T) { + if err := NewRunner(Config{}).applyPrivescTool(); err != nil { + t.Errorf("empty PrivescTool should be a no-op, got error: %v", err) + } + + err := NewRunner(Config{PrivescTool: "pkexec"}).applyPrivescTool() + if err == nil { + t.Fatal("expected error for unsupported privesc tool, got nil") + } + if !strings.Contains(err.Error(), "--privesc") { + t.Errorf("error %q does not mention --privesc", err.Error()) + } +}