dankinstall: add non-interactive install script (#3063)

fixes #3043
This commit is contained in:
JDKamalakar authored and GitHub committed 2026-08-28 15:23:46 -04:00
1 parent 2f19ccf707
commit f0867798e6
6 files changed
+327 -24

No files matched your search

+14 -6
View File
@@ -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 <niri|hyprland>` | `-c` | Compositor/WM to install (required for headless) |
| `--term <ghostty|kitty|alacritty>` | `-t` | Terminal emulator (required for headless) |
| `--compositor <niri\|hyprland\|mango>` | `-c` | Compositor/WM to install (required for headless) |
| `--term <ghostty\|kitty\|alacritty>` | `-t` | Terminal emulator (required for headless) |
| `--privesc <sudo\|doas\|run0>` | | Privilege escalation tool (default: autodetect) |
| `--git` | | Install the git version of every dependency that has one |
| `--git-deps <name,...>` | | 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 <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`) |
@@ -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
+22 -2
View File
@@ -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)
+2 -7
View File
@@ -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 "$@"
+1
View File
@@ -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
+110 -9
View File
@@ -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 {
+178
View File
@@ -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())
}
}