mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-04-14 17:52: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.
|
Command-line interface and daemon for shell management and system control.
|
||||||
|
|
||||||
**dankinstall**
|
**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
|
## System Integration
|
||||||
|
|
||||||
@@ -147,10 +147,50 @@ go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
|
|||||||
|
|
||||||
## Installation via dankinstall
|
## Installation via dankinstall
|
||||||
|
|
||||||
|
**Interactive (TUI):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://install.danklinux.com | sh
|
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
|
## Supported Distributions
|
||||||
|
|
||||||
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)
|
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)
|
||||||
|
|||||||
@@ -3,20 +3,152 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/headless"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Version = "dev"
|
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() {
|
func main() {
|
||||||
if os.Getuid() == 0 {
|
if os.Getuid() == 0 {
|
||||||
fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root")
|
fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root")
|
||||||
os.Exit(1)
|
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()
|
fileLogger, err := log.NewFileLogger()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Warning: Failed to create log file: %v\n", err)
|
fmt.Printf("Warning: Failed to create log file: %v\n", err)
|
||||||
@@ -38,18 +170,50 @@ func main() {
|
|||||||
|
|
||||||
if fileLogger != nil {
|
if fileLogger != nil {
|
||||||
fileLogger.StartListening(model.GetLogChan())
|
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())
|
p := tea.NewProgram(model, tea.WithAltScreen())
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
fmt.Printf("Error running program: %v\n", err)
|
|
||||||
if logFilePath != "" {
|
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 != "" {
|
if logFilePath != "" {
|
||||||
fmt.Printf("\nFull logs are available at: %s\n", 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) {
|
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
|
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 {
|
shouldReplaceConfig := func(configType string) bool {
|
||||||
if replaceConfigs == nil {
|
if replaceConfigs == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
replace, exists := replaceConfigs[configType]
|
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 {
|
switch wm {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -624,3 +625,168 @@ func TestAlacrittyConfigDeployment(t *testing.T) {
|
|||||||
assert.Contains(t, string(newContent), "decorations = \"None\"")
|
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"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -21,7 +22,16 @@ type FileLogger struct {
|
|||||||
|
|
||||||
func NewFileLogger() (*FileLogger, error) {
|
func NewFileLogger() (*FileLogger, error) {
|
||||||
timestamp := time.Now().Unix()
|
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)
|
file, err := os.Create(logPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user