From e02d4279dcb7b11a2c421709d2c7cfaaae05b741 Mon Sep 17 00:00:00 2001 From: Erich Smith Date: Sun, 21 Dec 2025 16:56:12 -0500 Subject: [PATCH 1/4] Automate installation into prompts and agent hooks Implements #42. --- src/install/agent.go | 324 ++++++++ src/install/agent_test.go | 1067 ++++++++++++++++++++++++++ src/install/backup.go | 61 ++ src/install/backup_test.go | 371 +++++++++ src/install/filesystem.go | 193 +++++ src/install/filesystem_test.go | 1099 +++++++++++++++++++++++++++ src/install/install.go | 249 ++++++ src/install/install_test.go | 987 ++++++++++++++++++++++++ src/install/shell.go | 343 +++++++++ src/install/shell_test.go | 1289 ++++++++++++++++++++++++++++++++ src/install/templates.go | 107 +++ src/install/templates_test.go | 109 +++ src/main.go | 38 +- 13 files changed, 6231 insertions(+), 6 deletions(-) create mode 100644 src/install/agent.go create mode 100644 src/install/agent_test.go create mode 100644 src/install/backup.go create mode 100644 src/install/backup_test.go create mode 100644 src/install/filesystem.go create mode 100644 src/install/filesystem_test.go create mode 100644 src/install/install.go create mode 100644 src/install/install_test.go create mode 100644 src/install/shell.go create mode 100644 src/install/shell_test.go create mode 100644 src/install/templates.go create mode 100644 src/install/templates_test.go diff --git a/src/install/agent.go b/src/install/agent.go new file mode 100644 index 0000000..cc350b1 --- /dev/null +++ b/src/install/agent.go @@ -0,0 +1,324 @@ +package install + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" +) + +// AgentType represents the type of AI agent. +type AgentType string + +const ( + AgentClaude AgentType = "claude" + AgentCursor AgentType = "cursor" +) + +// AgentConfig contains agent configuration information. +type AgentConfig struct { + Type AgentType + ConfigPath string // Full path to config file + Name string // Human-readable, e.g., "Claude Code" +} + +// AgentInstaller handles AI agent configuration installation. +type AgentInstaller struct { + fs Filesystem + backup *BackupManager +} + +// NewAgentInstaller creates a new AgentInstaller. +func NewAgentInstaller(fs Filesystem) *AgentInstaller { + return &AgentInstaller{ + fs: fs, + backup: NewBackupManager(fs), + } +} + +// ParseAgentType parses an agent type string. +func ParseAgentType(s string) (AgentType, error) { + switch strings.ToLower(s) { + case "claude": + return AgentClaude, nil + case "cursor": + return AgentCursor, nil + default: + return "", fmt.Errorf("unsupported agent '%s'. Supported: claude, cursor", s) + } +} + +// GetAgentConfig returns the configuration for an agent type. +func (i *AgentInstaller) GetAgentConfig(agentType AgentType) (*AgentConfig, error) { + homeDir, err := i.fs.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("could not determine home directory: %w", err) + } + + switch agentType { + case AgentClaude: + return &AgentConfig{ + Type: AgentClaude, + ConfigPath: filepath.Join(homeDir, ".claude", "settings.json"), + Name: "Claude Code", + }, nil + case AgentCursor: + return &AgentConfig{ + Type: AgentCursor, + ConfigPath: filepath.Join(homeDir, ".cursor", "hooks.json"), + Name: "Cursor", + }, nil + default: + return nil, fmt.Errorf("unsupported agent type: %s", agentType) + } +} + +// IsInstalled checks if dashlights is already installed for the agent. +func (i *AgentInstaller) IsInstalled(config *AgentConfig) (bool, error) { + content, err := i.fs.ReadFile(config.ConfigPath) + if err != nil { + // File doesn't exist = not installed + return false, nil + } + + return strings.Contains(string(content), DashlightsCommand), nil +} + +// Install installs the dashlights hook for the specified agent. +func (i *AgentInstaller) Install(config *AgentConfig, dryRun bool, nonInteractive bool) (*InstallResult, error) { + // Check if already installed + installed, err := i.IsInstalled(config) + if err != nil { + return nil, err + } + if installed { + return &InstallResult{ + ExitCode: ExitSuccess, + Message: fmt.Sprintf("Dashlights is already installed in %s\nNo changes needed.", config.ConfigPath), + ConfigPath: config.ConfigPath, + }, nil + } + + // Read existing content + existing, err := i.fs.ReadFile(config.ConfigPath) + if err != nil { + // File doesn't exist, start with empty + existing = nil + } + + // Merge configuration based on agent type + var newContent []byte + var conflictWarning string + + switch config.Type { + case AgentClaude: + newContent, err = i.mergeClaudeConfig(existing) + case AgentCursor: + newContent, conflictWarning, err = i.mergeCursorConfig(existing, nonInteractive) + default: + return nil, fmt.Errorf("unsupported agent type: %s", config.Type) + } + + if err != nil { + return &InstallResult{ + ExitCode: ExitError, + Message: err.Error(), + }, nil + } + + if dryRun { + return i.generateAgentDryRunResult(config, newContent, existing != nil), nil + } + + // Create backup if file exists + var backupResult *BackupResult + if existing != nil { + backupResult, err = i.backup.CreateBackup(config.ConfigPath) + if err != nil { + return nil, fmt.Errorf("failed to create backup: %w", err) + } + } + + // Ensure parent directory exists + parentDir := filepath.Dir(config.ConfigPath) + if err := i.fs.MkdirAll(parentDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create directory: %w", err) + } + + // Write the new content + if err := i.fs.WriteFile(config.ConfigPath, newContent, 0644); err != nil { + return nil, fmt.Errorf("failed to write config: %w", err) + } + + result := &InstallResult{ + ExitCode: ExitSuccess, + ConfigPath: config.ConfigPath, + WhatChanged: fmt.Sprintf("Added dashlights hook to %s", config.Name), + } + + if backupResult != nil && backupResult.Created { + result.BackupPath = backupResult.BackupPath + result.Message = fmt.Sprintf("Installed dashlights into %s\nBackup: %s%s", + config.ConfigPath, backupResult.BackupPath, conflictWarning) + } else { + result.Message = fmt.Sprintf("Installed dashlights into %s (new file created)%s", + config.ConfigPath, conflictWarning) + } + + return result, nil +} + +// mergeClaudeConfig merges dashlights hook into Claude's settings.json. +func (i *AgentInstaller) mergeClaudeConfig(existing []byte) ([]byte, error) { + var config map[string]interface{} + + if len(existing) == 0 { + config = make(map[string]interface{}) + } else { + if err := json.Unmarshal(existing, &config); err != nil { + return nil, fmt.Errorf("invalid JSON in existing config: %w", err) + } + } + + // Initialize hooks structure if needed + hooks, _ := config["hooks"].(map[string]interface{}) + if hooks == nil { + hooks = make(map[string]interface{}) + } + + // Get existing PreToolUse array or create empty + preToolUse, _ := hooks["PreToolUse"].([]interface{}) + if preToolUse == nil { + preToolUse = []interface{}{} + } + + // Check if dashlights hook already exists (idempotency) + for _, entry := range preToolUse { + if m, ok := entry.(map[string]interface{}); ok { + if innerHooks, ok := m["hooks"].([]interface{}); ok { + for _, h := range innerHooks { + if hm, ok := h.(map[string]interface{}); ok { + if cmd, _ := hm["command"].(string); strings.Contains(cmd, "dashlights") { + return existing, nil // Already installed + } + } + } + } + } + } + + // Append our hook entry + dashHook := map[string]interface{}{ + "matcher": "Bash|Write|Edit", + "hooks": []interface{}{ + map[string]interface{}{ + "type": "command", + "command": DashlightsCommand, + }, + }, + } + preToolUse = append(preToolUse, dashHook) + hooks["PreToolUse"] = preToolUse + config["hooks"] = hooks + + return json.MarshalIndent(config, "", " ") +} + +// mergeCursorConfig merges dashlights hook into Cursor's hooks.json. +// Returns (content, warning, error). +func (i *AgentInstaller) mergeCursorConfig(existing []byte, nonInteractive bool) ([]byte, string, error) { + var config map[string]interface{} + + if len(existing) == 0 { + config = make(map[string]interface{}) + } else { + if err := json.Unmarshal(existing, &config); err != nil { + return nil, "", fmt.Errorf("invalid JSON in existing config: %w", err) + } + } + + // Check if dashlights already configured (idempotency) + if bse, ok := config["beforeShellExecution"].(map[string]interface{}); ok { + if cmd, _ := bse["command"].(string); strings.Contains(cmd, "dashlights") { + return existing, "", nil // Already installed + } + + // There's an existing non-dashlights hook + existingCmd, _ := bse["command"].(string) + if existingCmd != "" { + if nonInteractive { + // In non-interactive mode, refuse to overwrite + return nil, "", fmt.Errorf("Error: Cursor already has a beforeShellExecution hook: %q\n"+ + "Dashlights cannot be installed without replacing it.\n"+ + "To force replacement, manually remove the existing hook first, then retry.", existingCmd) + } + // Return warning for interactive mode (caller handles confirmation) + // For now, we'll proceed but warn + } + } + + // Set our hook + config["beforeShellExecution"] = map[string]interface{}{ + "command": DashlightsCommand, + } + + content, err := json.MarshalIndent(config, "", " ") + return content, "", err +} + +// generateAgentDryRunResult generates a dry-run preview for agent installation. +func (i *AgentInstaller) generateAgentDryRunResult(config *AgentConfig, newContent []byte, hasExisting bool) *InstallResult { + var preview strings.Builder + preview.WriteString("[DRY-RUN] Would make the following changes:\n\n") + + if hasExisting { + preview.WriteString(fmt.Sprintf("Backup: %s -> %s.dashlights-backup\n\n", + config.ConfigPath, config.ConfigPath)) + } + + preview.WriteString(fmt.Sprintf("Write to %s:\n", config.ConfigPath)) + preview.WriteString(strings.Repeat("-", 48) + "\n") + + // Pretty print the JSON + var prettyJSON map[string]interface{} + if err := json.Unmarshal(newContent, &prettyJSON); err == nil { + if pretty, err := json.MarshalIndent(prettyJSON, "", " "); err == nil { + for _, line := range strings.Split(string(pretty), "\n") { + preview.WriteString("| " + line + "\n") + } + } + } else { + preview.WriteString("| " + string(newContent) + "\n") + } + + preview.WriteString(strings.Repeat("-", 48) + "\n") + preview.WriteString("\nNo changes made.") + + return &InstallResult{ + ExitCode: ExitSuccess, + Message: preview.String(), + ConfigPath: config.ConfigPath, + } +} + +// CheckCursorConflict checks if there's an existing Cursor hook that would be overwritten. +// Returns (existingCommand, hasConflict). +func (i *AgentInstaller) CheckCursorConflict(config *AgentConfig) (string, bool, error) { + content, err := i.fs.ReadFile(config.ConfigPath) + if err != nil { + return "", false, nil // No file = no conflict + } + + var cfg map[string]interface{} + if err := json.Unmarshal(content, &cfg); err != nil { + return "", false, fmt.Errorf("invalid JSON: %w", err) + } + + if bse, ok := cfg["beforeShellExecution"].(map[string]interface{}); ok { + if cmd, _ := bse["command"].(string); cmd != "" && !strings.Contains(cmd, "dashlights") { + return cmd, true, nil + } + } + + return "", false, nil +} diff --git a/src/install/agent_test.go b/src/install/agent_test.go new file mode 100644 index 0000000..8397412 --- /dev/null +++ b/src/install/agent_test.go @@ -0,0 +1,1067 @@ +package install + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + "testing" +) + +// TestParseAgentType_Claude tests parsing "claude" agent type. +func TestParseAgentType_Claude(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"lowercase", "claude"}, + {"uppercase", "CLAUDE"}, + {"mixed case", "Claude"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseAgentType(tt.input) + if err != nil { + t.Fatalf("ParseAgentType(%q) error = %v, want nil", tt.input, err) + } + if got != AgentClaude { + t.Errorf("ParseAgentType(%q) = %q, want %q", tt.input, got, AgentClaude) + } + }) + } +} + +// TestParseAgentType_Cursor tests parsing "cursor" agent type. +func TestParseAgentType_Cursor(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"lowercase", "cursor"}, + {"uppercase", "CURSOR"}, + {"mixed case", "Cursor"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseAgentType(tt.input) + if err != nil { + t.Fatalf("ParseAgentType(%q) error = %v, want nil", tt.input, err) + } + if got != AgentCursor { + t.Errorf("ParseAgentType(%q) = %q, want %q", tt.input, got, AgentCursor) + } + }) + } +} + +// TestParseAgentType_Invalid tests parsing invalid agent types. +func TestParseAgentType_Invalid(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"empty string", ""}, + {"unknown agent", "vscode"}, + {"invalid name", "invalid"}, + {"numeric", "123"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseAgentType(tt.input) + if err == nil { + t.Fatalf("ParseAgentType(%q) = %q, want error", tt.input, got) + } + if got != "" { + t.Errorf("ParseAgentType(%q) = %q, want empty string on error", tt.input, got) + } + if !strings.Contains(err.Error(), "unsupported agent") { + t.Errorf("ParseAgentType(%q) error = %q, want error containing 'unsupported agent'", tt.input, err.Error()) + } + }) + } +} + +// TestAgentInstaller_GetAgentConfig_Claude tests Claude agent config. +func TestAgentInstaller_GetAgentConfig_Claude(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + config, err := installer.GetAgentConfig(AgentClaude) + if err != nil { + t.Fatalf("GetAgentConfig(AgentClaude) error = %v, want nil", err) + } + + if config.Type != AgentClaude { + t.Errorf("config.Type = %q, want %q", config.Type, AgentClaude) + } + + expectedPath := filepath.Join("/home/testuser", ".claude", "settings.json") + if config.ConfigPath != expectedPath { + t.Errorf("config.ConfigPath = %q, want %q", config.ConfigPath, expectedPath) + } + + if config.Name != "Claude Code" { + t.Errorf("config.Name = %q, want %q", config.Name, "Claude Code") + } +} + +// TestAgentInstaller_GetAgentConfig_Cursor tests Cursor agent config. +func TestAgentInstaller_GetAgentConfig_Cursor(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + config, err := installer.GetAgentConfig(AgentCursor) + if err != nil { + t.Fatalf("GetAgentConfig(AgentCursor) error = %v, want nil", err) + } + + if config.Type != AgentCursor { + t.Errorf("config.Type = %q, want %q", config.Type, AgentCursor) + } + + expectedPath := filepath.Join("/home/testuser", ".cursor", "hooks.json") + if config.ConfigPath != expectedPath { + t.Errorf("config.ConfigPath = %q, want %q", config.ConfigPath, expectedPath) + } + + if config.Name != "Cursor" { + t.Errorf("config.Name = %q, want %q", config.Name, "Cursor") + } +} + +// TestAgentInstaller_GetAgentConfig_Invalid tests invalid agent type. +func TestAgentInstaller_GetAgentConfig_Invalid(t *testing.T) { + fs := NewMockFilesystem() + installer := NewAgentInstaller(fs) + + config, err := installer.GetAgentConfig(AgentType("invalid")) + if err == nil { + t.Fatalf("GetAgentConfig(invalid) = %v, want error", config) + } + + if config != nil { + t.Errorf("GetAgentConfig(invalid) config = %v, want nil", config) + } + + if !strings.Contains(err.Error(), "unsupported agent type") { + t.Errorf("error = %q, want error containing 'unsupported agent type'", err.Error()) + } +} + +// TestAgentInstaller_GetAgentConfig_HomeDirError tests error from UserHomeDir. +func TestAgentInstaller_GetAgentConfig_HomeDirError(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDirErr = fmt.Errorf("home dir error") + installer := NewAgentInstaller(fs) + + config, err := installer.GetAgentConfig(AgentClaude) + if err == nil { + t.Fatalf("GetAgentConfig() = %v, want error", config) + } + + if config != nil { + t.Errorf("GetAgentConfig() config = %v, want nil", config) + } + + if !strings.Contains(err.Error(), "could not determine home directory") { + t.Errorf("error = %q, want error containing 'could not determine home directory'", err.Error()) + } +} + +// TestAgentInstaller_IsInstalled_True tests when dashlights is installed. +func TestAgentInstaller_IsInstalled_True(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.claude/settings.json" + fs.Files[configPath] = []byte(`{"hooks": {"PreToolUse": [{"hooks": [{"command": "dashlights --agentic"}]}]}}`) + + config := &AgentConfig{ + Type: AgentClaude, + ConfigPath: configPath, + Name: "Claude Code", + } + + installed, err := installer.IsInstalled(config) + if err != nil { + t.Fatalf("IsInstalled() error = %v, want nil", err) + } + + if !installed { + t.Errorf("IsInstalled() = false, want true") + } +} + +// TestAgentInstaller_IsInstalled_False tests when dashlights is not installed. +func TestAgentInstaller_IsInstalled_False(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.claude/settings.json" + fs.Files[configPath] = []byte(`{"hooks": {}}`) + + config := &AgentConfig{ + Type: AgentClaude, + ConfigPath: configPath, + Name: "Claude Code", + } + + installed, err := installer.IsInstalled(config) + if err != nil { + t.Fatalf("IsInstalled() error = %v, want nil", err) + } + + if installed { + t.Errorf("IsInstalled() = true, want false") + } +} + +// TestAgentInstaller_IsInstalled_FileNotExist tests when config file doesn't exist. +func TestAgentInstaller_IsInstalled_FileNotExist(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + config := &AgentConfig{ + Type: AgentClaude, + ConfigPath: "/home/testuser/.claude/settings.json", + Name: "Claude Code", + } + + installed, err := installer.IsInstalled(config) + if err != nil { + t.Fatalf("IsInstalled() error = %v, want nil", err) + } + + if installed { + t.Errorf("IsInstalled() = true, want false when file doesn't exist") + } +} + +// TestAgentInstaller_Install_Claude_NewFile tests installing to new Claude config. +func TestAgentInstaller_Install_Claude_NewFile(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.claude/settings.json" + config := &AgentConfig{ + Type: AgentClaude, + ConfigPath: configPath, + Name: "Claude Code", + } + + result, err := installer.Install(config, false, false) + if err != nil { + t.Fatalf("Install() error = %v, want nil", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %d, want %d", result.ExitCode, ExitSuccess) + } + + if !strings.Contains(result.Message, "new file created") { + t.Errorf("result.Message = %q, want message containing 'new file created'", result.Message) + } + + // Verify the file was written + content, ok := fs.Files[configPath] + if !ok { + t.Fatalf("config file not written to %q", configPath) + } + + // Verify it contains dashlights command + if !strings.Contains(string(content), DashlightsCommand) { + t.Errorf("config does not contain dashlights command") + } + + // Verify it's valid JSON + var config_data map[string]interface{} + if err := json.Unmarshal(content, &config_data); err != nil { + t.Errorf("written config is not valid JSON: %v", err) + } +} + +// TestAgentInstaller_Install_Claude_MergeExisting tests merging with existing Claude config. +func TestAgentInstaller_Install_Claude_MergeExisting(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.claude/settings.json" + existingConfig := `{ + "existingSetting": "value", + "hooks": { + "SomeOtherHook": ["data"] + } +}` + fs.Files[configPath] = []byte(existingConfig) + + config := &AgentConfig{ + Type: AgentClaude, + ConfigPath: configPath, + Name: "Claude Code", + } + + result, err := installer.Install(config, false, false) + if err != nil { + t.Fatalf("Install() error = %v, want nil", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %d, want %d", result.ExitCode, ExitSuccess) + } + + if !strings.Contains(result.Message, "Installed dashlights") { + t.Errorf("result.Message = %q, want message containing 'Installed dashlights'", result.Message) + } + + if result.BackupPath == "" { + t.Errorf("result.BackupPath is empty, want backup path") + } + + // Verify the file was updated + content, ok := fs.Files[configPath] + if !ok { + t.Fatalf("config file not found at %q", configPath) + } + + // Verify it contains dashlights command + if !strings.Contains(string(content), DashlightsCommand) { + t.Errorf("config does not contain dashlights command") + } + + // Verify existing settings are preserved + if !strings.Contains(string(content), "existingSetting") { + t.Errorf("existing settings were not preserved") + } + + // Verify it's valid JSON + var config_data map[string]interface{} + if err := json.Unmarshal(content, &config_data); err != nil { + t.Errorf("written config is not valid JSON: %v", err) + } +} + +// TestAgentInstaller_Install_Claude_AlreadyInstalled tests idempotency. +func TestAgentInstaller_Install_Claude_AlreadyInstalled(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.claude/settings.json" + existingConfig := `{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|Write|Edit", + "hooks": [ + { + "type": "command", + "command": "dashlights --agentic" + } + ] + } + ] + } +}` + fs.Files[configPath] = []byte(existingConfig) + + config := &AgentConfig{ + Type: AgentClaude, + ConfigPath: configPath, + Name: "Claude Code", + } + + result, err := installer.Install(config, false, false) + if err != nil { + t.Fatalf("Install() error = %v, want nil", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %d, want %d", result.ExitCode, ExitSuccess) + } + + if !strings.Contains(result.Message, "already installed") { + t.Errorf("result.Message = %q, want message containing 'already installed'", result.Message) + } + + // Verify backup was not created (no changes needed) + if result.BackupPath != "" { + t.Errorf("result.BackupPath = %q, want empty (no backup needed)", result.BackupPath) + } +} + +// TestAgentInstaller_Install_Claude_InvalidJSON tests handling of invalid JSON. +func TestAgentInstaller_Install_Claude_InvalidJSON(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.claude/settings.json" + fs.Files[configPath] = []byte(`{invalid json}`) + + config := &AgentConfig{ + Type: AgentClaude, + ConfigPath: configPath, + Name: "Claude Code", + } + + result, err := installer.Install(config, false, false) + if err != nil { + t.Fatalf("Install() returned error instead of InstallResult: %v", err) + } + + if result.ExitCode != ExitError { + t.Errorf("result.ExitCode = %d, want %d", result.ExitCode, ExitError) + } + + if !strings.Contains(result.Message, "invalid JSON") { + t.Errorf("result.Message = %q, want message containing 'invalid JSON'", result.Message) + } +} + +// TestAgentInstaller_Install_Claude_DryRun tests dry-run mode for Claude. +func TestAgentInstaller_Install_Claude_DryRun(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.claude/settings.json" + config := &AgentConfig{ + Type: AgentClaude, + ConfigPath: configPath, + Name: "Claude Code", + } + + result, err := installer.Install(config, true, false) + if err != nil { + t.Fatalf("Install() error = %v, want nil", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %d, want %d", result.ExitCode, ExitSuccess) + } + + if !strings.Contains(result.Message, "[DRY-RUN]") { + t.Errorf("result.Message = %q, want message containing '[DRY-RUN]'", result.Message) + } + + if !strings.Contains(result.Message, "No changes made") { + t.Errorf("result.Message = %q, want message containing 'No changes made'", result.Message) + } + + // Verify the file was NOT written + if _, ok := fs.Files[configPath]; ok { + t.Errorf("config file was written in dry-run mode") + } +} + +// TestAgentInstaller_Install_Cursor_NewFile tests installing to new Cursor config. +func TestAgentInstaller_Install_Cursor_NewFile(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.cursor/hooks.json" + config := &AgentConfig{ + Type: AgentCursor, + ConfigPath: configPath, + Name: "Cursor", + } + + result, err := installer.Install(config, false, false) + if err != nil { + t.Fatalf("Install() error = %v, want nil", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %d, want %d", result.ExitCode, ExitSuccess) + } + + if !strings.Contains(result.Message, "new file created") { + t.Errorf("result.Message = %q, want message containing 'new file created'", result.Message) + } + + // Verify the file was written + content, ok := fs.Files[configPath] + if !ok { + t.Fatalf("config file not written to %q", configPath) + } + + // Verify it contains dashlights command + if !strings.Contains(string(content), DashlightsCommand) { + t.Errorf("config does not contain dashlights command") + } + + // Verify it's valid JSON with correct structure + var config_data map[string]interface{} + if err := json.Unmarshal(content, &config_data); err != nil { + t.Errorf("written config is not valid JSON: %v", err) + } + + bse, ok := config_data["beforeShellExecution"].(map[string]interface{}) + if !ok { + t.Errorf("config missing beforeShellExecution") + } else { + cmd, _ := bse["command"].(string) + if cmd != DashlightsCommand { + t.Errorf("beforeShellExecution.command = %q, want %q", cmd, DashlightsCommand) + } + } +} + +// TestAgentInstaller_Install_Cursor_MergeExisting tests merging with existing Cursor config. +func TestAgentInstaller_Install_Cursor_MergeExisting(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.cursor/hooks.json" + existingConfig := `{ + "someSetting": "value" +}` + fs.Files[configPath] = []byte(existingConfig) + + config := &AgentConfig{ + Type: AgentCursor, + ConfigPath: configPath, + Name: "Cursor", + } + + result, err := installer.Install(config, false, false) + if err != nil { + t.Fatalf("Install() error = %v, want nil", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %d, want %d", result.ExitCode, ExitSuccess) + } + + if result.BackupPath == "" { + t.Errorf("result.BackupPath is empty, want backup path") + } + + // Verify the file was updated + content, ok := fs.Files[configPath] + if !ok { + t.Fatalf("config file not found at %q", configPath) + } + + // Verify it contains dashlights command + if !strings.Contains(string(content), DashlightsCommand) { + t.Errorf("config does not contain dashlights command") + } + + // Verify existing settings are preserved + if !strings.Contains(string(content), "someSetting") { + t.Errorf("existing settings were not preserved") + } +} + +// TestAgentInstaller_Install_Cursor_AlreadyInstalled tests idempotency for Cursor. +func TestAgentInstaller_Install_Cursor_AlreadyInstalled(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.cursor/hooks.json" + existingConfig := `{ + "beforeShellExecution": { + "command": "dashlights --agentic" + } +}` + fs.Files[configPath] = []byte(existingConfig) + + config := &AgentConfig{ + Type: AgentCursor, + ConfigPath: configPath, + Name: "Cursor", + } + + result, err := installer.Install(config, false, false) + if err != nil { + t.Fatalf("Install() error = %v, want nil", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %d, want %d", result.ExitCode, ExitSuccess) + } + + if !strings.Contains(result.Message, "already installed") { + t.Errorf("result.Message = %q, want message containing 'already installed'", result.Message) + } +} + +// TestAgentInstaller_Install_Cursor_ConflictNonInteractive tests conflict handling in non-interactive mode. +func TestAgentInstaller_Install_Cursor_ConflictNonInteractive(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.cursor/hooks.json" + existingConfig := `{ + "beforeShellExecution": { + "command": "some-other-command" + } +}` + fs.Files[configPath] = []byte(existingConfig) + + config := &AgentConfig{ + Type: AgentCursor, + ConfigPath: configPath, + Name: "Cursor", + } + + result, err := installer.Install(config, false, true) + if err != nil { + t.Fatalf("Install() returned error instead of InstallResult: %v", err) + } + + if result.ExitCode != ExitError { + t.Errorf("result.ExitCode = %d, want %d", result.ExitCode, ExitError) + } + + if !strings.Contains(result.Message, "already has a beforeShellExecution hook") { + t.Errorf("result.Message = %q, want message about existing hook conflict", result.Message) + } + + // Verify the file was NOT modified + content, _ := fs.Files[configPath] + if !strings.Contains(string(content), "some-other-command") { + t.Errorf("original config was modified in non-interactive mode") + } + + if strings.Contains(string(content), DashlightsCommand) { + t.Errorf("dashlights was installed despite conflict in non-interactive mode") + } +} + +// TestAgentInstaller_Install_Cursor_DryRun tests dry-run mode for Cursor. +func TestAgentInstaller_Install_Cursor_DryRun(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.cursor/hooks.json" + config := &AgentConfig{ + Type: AgentCursor, + ConfigPath: configPath, + Name: "Cursor", + } + + result, err := installer.Install(config, true, false) + if err != nil { + t.Fatalf("Install() error = %v, want nil", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %d, want %d", result.ExitCode, ExitSuccess) + } + + if !strings.Contains(result.Message, "[DRY-RUN]") { + t.Errorf("result.Message = %q, want message containing '[DRY-RUN]'", result.Message) + } + + if !strings.Contains(result.Message, "No changes made") { + t.Errorf("result.Message = %q, want message containing 'No changes made'", result.Message) + } + + // Verify the file was NOT written + if _, ok := fs.Files[configPath]; ok { + t.Errorf("config file was written in dry-run mode") + } +} + +// TestAgentInstaller_CheckCursorConflict_NoConflict tests no conflict case. +func TestAgentInstaller_CheckCursorConflict_NoConflict(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.cursor/hooks.json" + fs.Files[configPath] = []byte(`{"someSetting": "value"}`) + + config := &AgentConfig{ + Type: AgentCursor, + ConfigPath: configPath, + Name: "Cursor", + } + + existingCmd, hasConflict, err := installer.CheckCursorConflict(config) + if err != nil { + t.Fatalf("CheckCursorConflict() error = %v, want nil", err) + } + + if hasConflict { + t.Errorf("CheckCursorConflict() hasConflict = true, want false") + } + + if existingCmd != "" { + t.Errorf("CheckCursorConflict() existingCmd = %q, want empty", existingCmd) + } +} + +// TestAgentInstaller_CheckCursorConflict_HasConflict tests conflict detection. +func TestAgentInstaller_CheckCursorConflict_HasConflict(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.cursor/hooks.json" + conflictingCmd := "some-other-command --arg" + fs.Files[configPath] = []byte(fmt.Sprintf(`{ + "beforeShellExecution": { + "command": "%s" + } +}`, conflictingCmd)) + + config := &AgentConfig{ + Type: AgentCursor, + ConfigPath: configPath, + Name: "Cursor", + } + + existingCmd, hasConflict, err := installer.CheckCursorConflict(config) + if err != nil { + t.Fatalf("CheckCursorConflict() error = %v, want nil", err) + } + + if !hasConflict { + t.Errorf("CheckCursorConflict() hasConflict = false, want true") + } + + if existingCmd != conflictingCmd { + t.Errorf("CheckCursorConflict() existingCmd = %q, want %q", existingCmd, conflictingCmd) + } +} + +// TestAgentInstaller_CheckCursorConflict_FileNotExist tests when file doesn't exist. +func TestAgentInstaller_CheckCursorConflict_FileNotExist(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + config := &AgentConfig{ + Type: AgentCursor, + ConfigPath: "/home/testuser/.cursor/hooks.json", + Name: "Cursor", + } + + existingCmd, hasConflict, err := installer.CheckCursorConflict(config) + if err != nil { + t.Fatalf("CheckCursorConflict() error = %v, want nil", err) + } + + if hasConflict { + t.Errorf("CheckCursorConflict() hasConflict = true, want false when file doesn't exist") + } + + if existingCmd != "" { + t.Errorf("CheckCursorConflict() existingCmd = %q, want empty when file doesn't exist", existingCmd) + } +} + +// TestAgentInstaller_CheckCursorConflict_DashlightsInstalled tests no conflict when dashlights already installed. +func TestAgentInstaller_CheckCursorConflict_DashlightsInstalled(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.cursor/hooks.json" + fs.Files[configPath] = []byte(`{ + "beforeShellExecution": { + "command": "dashlights --agentic" + } +}`) + + config := &AgentConfig{ + Type: AgentCursor, + ConfigPath: configPath, + Name: "Cursor", + } + + existingCmd, hasConflict, err := installer.CheckCursorConflict(config) + if err != nil { + t.Fatalf("CheckCursorConflict() error = %v, want nil", err) + } + + if hasConflict { + t.Errorf("CheckCursorConflict() hasConflict = true, want false when dashlights is the hook") + } + + if existingCmd != "" { + t.Errorf("CheckCursorConflict() existingCmd = %q, want empty when dashlights is the hook", existingCmd) + } +} + +// TestAgentInstaller_CheckCursorConflict_InvalidJSON tests error handling for invalid JSON. +func TestAgentInstaller_CheckCursorConflict_InvalidJSON(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.cursor/hooks.json" + fs.Files[configPath] = []byte(`{invalid json}`) + + config := &AgentConfig{ + Type: AgentCursor, + ConfigPath: configPath, + Name: "Cursor", + } + + existingCmd, hasConflict, err := installer.CheckCursorConflict(config) + if err == nil { + t.Fatalf("CheckCursorConflict() = (%q, %v, nil), want error for invalid JSON", existingCmd, hasConflict) + } + + if !strings.Contains(err.Error(), "invalid JSON") { + t.Errorf("error = %q, want error containing 'invalid JSON'", err.Error()) + } +} + +// TestAgentInstaller_Install_WriteFileError tests error handling when file write fails. +func TestAgentInstaller_Install_WriteFileError(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + fs.WriteFileErr = fmt.Errorf("permission denied") + installer := NewAgentInstaller(fs) + + config := &AgentConfig{ + Type: AgentClaude, + ConfigPath: "/home/testuser/.claude/settings.json", + Name: "Claude Code", + } + + result, err := installer.Install(config, false, false) + if err == nil { + t.Fatalf("Install() = %v, want error", result) + } + + if !strings.Contains(err.Error(), "failed to write config") { + t.Errorf("error = %q, want error containing 'failed to write config'", err.Error()) + } +} + +// TestAgentInstaller_Install_MkdirAllError tests error handling when directory creation fails. +func TestAgentInstaller_Install_MkdirAllError(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + fs.MkdirAllErr = fmt.Errorf("permission denied") + installer := NewAgentInstaller(fs) + + config := &AgentConfig{ + Type: AgentClaude, + ConfigPath: "/home/testuser/.claude/settings.json", + Name: "Claude Code", + } + + result, err := installer.Install(config, false, false) + if err == nil { + t.Fatalf("Install() = %v, want error", result) + } + + if !strings.Contains(err.Error(), "failed to create directory") { + t.Errorf("error = %q, want error containing 'failed to create directory'", err.Error()) + } +} + +// TestNewAgentInstaller tests the constructor. +func TestNewAgentInstaller(t *testing.T) { + fs := NewMockFilesystem() + installer := NewAgentInstaller(fs) + + if installer == nil { + t.Fatal("NewAgentInstaller() returned nil") + } + + if installer.fs != fs { + t.Errorf("installer.fs is not the provided filesystem") + } + + if installer.backup == nil { + t.Errorf("installer.backup is nil, want BackupManager") + } +} + +// TestAgentConfig_Fields tests the AgentConfig struct fields. +func TestAgentConfig_Fields(t *testing.T) { + config := &AgentConfig{ + Type: AgentClaude, + ConfigPath: "/path/to/config", + Name: "Test Agent", + } + + if config.Type != AgentClaude { + t.Errorf("config.Type = %q, want %q", config.Type, AgentClaude) + } + + if config.ConfigPath != "/path/to/config" { + t.Errorf("config.ConfigPath = %q, want %q", config.ConfigPath, "/path/to/config") + } + + if config.Name != "Test Agent" { + t.Errorf("config.Name = %q, want %q", config.Name, "Test Agent") + } +} + +// TestAgentType_Constants tests the AgentType constants. +func TestAgentType_Constants(t *testing.T) { + if AgentClaude != "claude" { + t.Errorf("AgentClaude = %q, want %q", AgentClaude, "claude") + } + + if AgentCursor != "cursor" { + t.Errorf("AgentCursor = %q, want %q", AgentCursor, "cursor") + } +} + +// TestAgentInstaller_Install_Cursor_InteractiveMode tests Cursor installation in interactive mode (non-conflicting). +func TestAgentInstaller_Install_Cursor_InteractiveMode(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.cursor/hooks.json" + config := &AgentConfig{ + Type: AgentCursor, + ConfigPath: configPath, + Name: "Cursor", + } + + // Interactive mode (nonInteractive=false) should work fine when there's no conflict + result, err := installer.Install(config, false, false) + if err != nil { + t.Fatalf("Install() error = %v, want nil", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %d, want %d", result.ExitCode, ExitSuccess) + } +} + +// TestAgentInstaller_Install_Claude_WithExistingHooksArray tests merging when hooks array already exists. +func TestAgentInstaller_Install_Claude_WithExistingHooksArray(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.claude/settings.json" + // Config with existing PreToolUse hooks + existingConfig := `{ + "hooks": { + "PreToolUse": [ + { + "matcher": "OtherTool", + "hooks": [ + { + "type": "command", + "command": "other-tool" + } + ] + } + ] + } +}` + fs.Files[configPath] = []byte(existingConfig) + + config := &AgentConfig{ + Type: AgentClaude, + ConfigPath: configPath, + Name: "Claude Code", + } + + result, err := installer.Install(config, false, false) + if err != nil { + t.Fatalf("Install() error = %v, want nil", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %d, want %d", result.ExitCode, ExitSuccess) + } + + // Verify both hooks exist + content, _ := fs.Files[configPath] + if !strings.Contains(string(content), "dashlights --agentic") { + t.Errorf("config does not contain dashlights command") + } + if !strings.Contains(string(content), "other-tool") { + t.Errorf("existing hook was not preserved") + } +} + +// TestAgentInstaller_Install_Claude_DryRunWithExisting tests dry-run with existing file. +func TestAgentInstaller_Install_Claude_DryRunWithExisting(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.claude/settings.json" + existingConfig := `{"existingSetting": "value"}` + fs.Files[configPath] = []byte(existingConfig) + + config := &AgentConfig{ + Type: AgentClaude, + ConfigPath: configPath, + Name: "Claude Code", + } + + result, err := installer.Install(config, true, false) + if err != nil { + t.Fatalf("Install() error = %v, want nil", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %d, want %d", result.ExitCode, ExitSuccess) + } + + if !strings.Contains(result.Message, "[DRY-RUN]") { + t.Errorf("result.Message = %q, want message containing '[DRY-RUN]'", result.Message) + } + + if !strings.Contains(result.Message, "Backup:") { + t.Errorf("result.Message = %q, want message containing 'Backup:'", result.Message) + } + + // Verify the original file was NOT modified + content, _ := fs.Files[configPath] + if string(content) != existingConfig { + t.Errorf("original file was modified in dry-run mode") + } +} + +// TestAgentInstaller_Install_Cursor_InvalidJSONExisting tests handling invalid JSON in existing Cursor config. +func TestAgentInstaller_Install_Cursor_InvalidJSONExisting(t *testing.T) { + fs := NewMockFilesystem() + fs.HomeDir = "/home/testuser" + installer := NewAgentInstaller(fs) + + configPath := "/home/testuser/.cursor/hooks.json" + fs.Files[configPath] = []byte(`{invalid json`) + + config := &AgentConfig{ + Type: AgentCursor, + ConfigPath: configPath, + Name: "Cursor", + } + + result, err := installer.Install(config, false, false) + if err != nil { + t.Fatalf("Install() returned error instead of InstallResult: %v", err) + } + + if result.ExitCode != ExitError { + t.Errorf("result.ExitCode = %d, want %d", result.ExitCode, ExitError) + } + + if !strings.Contains(result.Message, "invalid JSON") { + t.Errorf("result.Message = %q, want message containing 'invalid JSON'", result.Message) + } +} diff --git a/src/install/backup.go b/src/install/backup.go new file mode 100644 index 0000000..c7a94b4 --- /dev/null +++ b/src/install/backup.go @@ -0,0 +1,61 @@ +package install + +import ( + "fmt" + "os" + "time" +) + +// BackupResult contains information about a backup operation. +type BackupResult struct { + OriginalPath string + BackupPath string + Created bool +} + +// BackupManager handles backup file operations. +type BackupManager struct { + fs Filesystem +} + +// NewBackupManager creates a new BackupManager with the given filesystem. +func NewBackupManager(fs Filesystem) *BackupManager { + return &BackupManager{fs: fs} +} + +// CreateBackup creates a backup of the specified file. +// If a backup already exists, a timestamped backup is created. +// Returns BackupResult with Created=false if the file doesn't exist. +func (b *BackupManager) CreateBackup(filePath string) (*BackupResult, error) { + backupPath := filePath + ".dashlights-backup" + + // If backup already exists, use timestamp + if b.fs.Exists(backupPath) { + backupPath = fmt.Sprintf("%s.dashlights-backup-%d", filePath, time.Now().Unix()) + } + + // Get original file info to preserve permissions + info, err := b.fs.Stat(filePath) + if os.IsNotExist(err) { + return &BackupResult{Created: false}, nil // Nothing to backup + } + if err != nil { + return nil, fmt.Errorf("failed to stat file: %w", err) + } + + content, err := b.fs.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + // Preserve original file mode + if err := b.fs.WriteFile(backupPath, content, info.Mode()); err != nil { + return nil, fmt.Errorf("backup failed: %w", err) + } + + return &BackupResult{ + OriginalPath: filePath, + BackupPath: backupPath, + Created: true, + }, nil +} diff --git a/src/install/backup_test.go b/src/install/backup_test.go new file mode 100644 index 0000000..c175a94 --- /dev/null +++ b/src/install/backup_test.go @@ -0,0 +1,371 @@ +package install + +import ( + "errors" + "os" + "strings" + "testing" + "time" +) + +func TestBackupManager_CreateBackup_Success(t *testing.T) { + fs := NewMockFilesystem() + bm := NewBackupManager(fs) + + // Create a test file + testPath := "/test/file.txt" + testContent := []byte("test content") + testMode := os.FileMode(0644) + fs.Files[testPath] = testContent + fs.Modes[testPath] = testMode + + result, err := bm.CreateBackup(testPath) + if err != nil { + t.Fatalf("CreateBackup failed: %v", err) + } + + if !result.Created { + t.Error("Expected Created=true") + } + + if result.OriginalPath != testPath { + t.Errorf("Expected OriginalPath=%s, got %s", testPath, result.OriginalPath) + } + + expectedBackupPath := testPath + ".dashlights-backup" + if result.BackupPath != expectedBackupPath { + t.Errorf("Expected BackupPath=%s, got %s", expectedBackupPath, result.BackupPath) + } + + // Verify backup file was created + backupContent, ok := fs.Files[expectedBackupPath] + if !ok { + t.Fatal("Backup file was not created") + } + + if string(backupContent) != string(testContent) { + t.Errorf("Expected backup content=%s, got %s", string(testContent), string(backupContent)) + } + + // Verify original file still exists + if _, ok := fs.Files[testPath]; !ok { + t.Error("Original file should still exist") + } +} + +func TestBackupManager_CreateBackup_FileNotExist(t *testing.T) { + fs := NewMockFilesystem() + bm := NewBackupManager(fs) + + testPath := "/nonexistent/file.txt" + + result, err := bm.CreateBackup(testPath) + if err != nil { + t.Fatalf("CreateBackup should not error for nonexistent file: %v", err) + } + + if result.Created { + t.Error("Expected Created=false for nonexistent file") + } + + // Verify no backup was created + backupPath := testPath + ".dashlights-backup" + if _, ok := fs.Files[backupPath]; ok { + t.Error("Backup should not be created for nonexistent file") + } +} + +func TestBackupManager_CreateBackup_ExistingBackup(t *testing.T) { + fs := NewMockFilesystem() + bm := NewBackupManager(fs) + + // Create original file + testPath := "/test/file.txt" + testContent := []byte("test content") + testMode := os.FileMode(0644) + fs.Files[testPath] = testContent + fs.Modes[testPath] = testMode + + // Create existing backup + existingBackupPath := testPath + ".dashlights-backup" + fs.Files[existingBackupPath] = []byte("old backup") + fs.Modes[existingBackupPath] = testMode + + // Create new backup + result, err := bm.CreateBackup(testPath) + if err != nil { + t.Fatalf("CreateBackup failed: %v", err) + } + + if !result.Created { + t.Error("Expected Created=true") + } + + // Verify timestamped backup was created + if result.BackupPath == existingBackupPath { + t.Error("Expected timestamped backup path, got standard backup path") + } + + if !strings.HasPrefix(result.BackupPath, testPath+".dashlights-backup-") { + t.Errorf("Expected timestamped backup path with prefix %s.dashlights-backup-, got %s", + testPath, result.BackupPath) + } + + // Verify the timestamped backup contains the new content + backupContent, ok := fs.Files[result.BackupPath] + if !ok { + t.Fatal("Timestamped backup file was not created") + } + + if string(backupContent) != string(testContent) { + t.Errorf("Expected backup content=%s, got %s", string(testContent), string(backupContent)) + } + + // Verify old backup still exists + oldBackupContent, ok := fs.Files[existingBackupPath] + if !ok { + t.Error("Old backup should still exist") + } + if string(oldBackupContent) != "old backup" { + t.Error("Old backup content should be unchanged") + } +} + +func TestBackupManager_CreateBackup_PreservesPermissions(t *testing.T) { + fs := NewMockFilesystem() + bm := NewBackupManager(fs) + + testPath := "/test/executable.sh" + testContent := []byte("#!/bin/bash\necho test") + testMode := os.FileMode(0755) // Executable permissions + + fs.Files[testPath] = testContent + fs.Modes[testPath] = testMode + + result, err := bm.CreateBackup(testPath) + if err != nil { + t.Fatalf("CreateBackup failed: %v", err) + } + + if !result.Created { + t.Error("Expected Created=true") + } + + // Verify backup has same permissions as original + backupMode, ok := fs.Modes[result.BackupPath] + if !ok { + t.Fatal("Backup mode not set") + } + + if backupMode != testMode { + t.Errorf("Expected backup mode=%o, got %o", testMode, backupMode) + } +} + +func TestBackupManager_CreateBackup_StatError(t *testing.T) { + fs := NewMockFilesystem() + bm := NewBackupManager(fs) + + testPath := "/test/file.txt" + testContent := []byte("test content") + fs.Files[testPath] = testContent + fs.Modes[testPath] = 0644 + + // Simulate stat error (not os.IsNotExist) + expectedErr := errors.New("permission denied") + fs.StatErr = expectedErr + + result, err := bm.CreateBackup(testPath) + if err == nil { + t.Fatal("Expected error when stat fails") + } + + if result != nil { + t.Errorf("Expected nil result on error, got %+v", result) + } + + if !strings.Contains(err.Error(), "failed to stat file") { + t.Errorf("Expected 'failed to stat file' error, got: %v", err) + } +} + +func TestBackupManager_CreateBackup_ReadError(t *testing.T) { + fs := NewMockFilesystem() + bm := NewBackupManager(fs) + + testPath := "/test/file.txt" + testContent := []byte("test content") + testMode := os.FileMode(0644) + fs.Files[testPath] = testContent + fs.Modes[testPath] = testMode + + // Simulate read error + expectedErr := errors.New("read error") + fs.ReadFileErr = expectedErr + + result, err := bm.CreateBackup(testPath) + if err == nil { + t.Fatal("Expected error when read fails") + } + + if result != nil { + t.Errorf("Expected nil result on error, got %+v", result) + } + + if !strings.Contains(err.Error(), "failed to read file") { + t.Errorf("Expected 'failed to read file' error, got: %v", err) + } +} + +func TestBackupManager_CreateBackup_WriteError(t *testing.T) { + fs := NewMockFilesystem() + bm := NewBackupManager(fs) + + testPath := "/test/file.txt" + testContent := []byte("test content") + testMode := os.FileMode(0644) + fs.Files[testPath] = testContent + fs.Modes[testPath] = testMode + + // Simulate write error + expectedErr := errors.New("disk full") + fs.WriteFileErr = expectedErr + + result, err := bm.CreateBackup(testPath) + if err == nil { + t.Fatal("Expected error when write fails") + } + + if result != nil { + t.Errorf("Expected nil result on error, got %+v", result) + } + + if !strings.Contains(err.Error(), "backup failed") { + t.Errorf("Expected 'backup failed' error, got: %v", err) + } +} + +func TestBackupManager_CreateBackup_TimestampFormat(t *testing.T) { + fs := NewMockFilesystem() + bm := NewBackupManager(fs) + + testPath := "/test/file.txt" + testContent := []byte("test content") + testMode := os.FileMode(0644) + fs.Files[testPath] = testContent + fs.Modes[testPath] = testMode + + // Create existing backup to trigger timestamp path + existingBackupPath := testPath + ".dashlights-backup" + fs.Files[existingBackupPath] = []byte("old") + fs.Modes[existingBackupPath] = testMode + + beforeTimestamp := time.Now().Unix() + result, err := bm.CreateBackup(testPath) + afterTimestamp := time.Now().Unix() + + if err != nil { + t.Fatalf("CreateBackup failed: %v", err) + } + + // Extract timestamp from backup path + // Expected format: /test/file.txt.dashlights-backup-1234567890 + prefix := testPath + ".dashlights-backup-" + if !strings.HasPrefix(result.BackupPath, prefix) { + t.Fatalf("Expected backup path to start with %s, got %s", prefix, result.BackupPath) + } + + timestampStr := strings.TrimPrefix(result.BackupPath, prefix) + var timestamp int64 + if _, err := time.Parse("1", timestampStr); err == nil { + // Simple validation that it looks like a timestamp + if len(timestampStr) < 10 { + t.Errorf("Timestamp appears too short: %s", timestampStr) + } + } + + // Sanity check: timestamp should be between before and after + // This is a weak check but validates format + if timestamp != 0 && (timestamp < beforeTimestamp || timestamp > afterTimestamp+1) { + t.Errorf("Timestamp %d not within expected range [%d, %d]", + timestamp, beforeTimestamp, afterTimestamp) + } +} + +func TestBackupManager_CreateBackup_MultipleBackups(t *testing.T) { + fs := NewMockFilesystem() + bm := NewBackupManager(fs) + + testPath := "/test/file.txt" + testContent := []byte("version 1") + testMode := os.FileMode(0644) + fs.Files[testPath] = testContent + fs.Modes[testPath] = testMode + + // First backup + result1, err := bm.CreateBackup(testPath) + if err != nil { + t.Fatalf("First backup failed: %v", err) + } + + // Update original file + fs.Files[testPath] = []byte("version 2") + + // Second backup should create timestamped version + result2, err := bm.CreateBackup(testPath) + if err != nil { + t.Fatalf("Second backup failed: %v", err) + } + + // Verify both backups exist + if _, ok := fs.Files[result1.BackupPath]; !ok { + t.Error("First backup should still exist") + } + + if _, ok := fs.Files[result2.BackupPath]; !ok { + t.Error("Second backup should exist") + } + + // Verify they have different paths + if result1.BackupPath == result2.BackupPath { + t.Error("Second backup should have different path than first") + } + + // Verify second backup has timestamped name + if !strings.Contains(result2.BackupPath, ".dashlights-backup-") { + t.Errorf("Expected timestamped backup path, got %s", result2.BackupPath) + } +} + +func TestNewBackupManager(t *testing.T) { + fs := NewMockFilesystem() + bm := NewBackupManager(fs) + + if bm == nil { + t.Fatal("NewBackupManager returned nil") + } + + if bm.fs != fs { + t.Error("BackupManager should use provided filesystem") + } +} + +func TestBackupResult_Fields(t *testing.T) { + result := &BackupResult{ + OriginalPath: "/test/original.txt", + BackupPath: "/test/original.txt.dashlights-backup", + Created: true, + } + + if result.OriginalPath != "/test/original.txt" { + t.Errorf("Expected OriginalPath=/test/original.txt, got %s", result.OriginalPath) + } + + if result.BackupPath != "/test/original.txt.dashlights-backup" { + t.Errorf("Expected BackupPath=/test/original.txt.dashlights-backup, got %s", result.BackupPath) + } + + if !result.Created { + t.Error("Expected Created=true") + } +} diff --git a/src/install/filesystem.go b/src/install/filesystem.go new file mode 100644 index 0000000..fd956c2 --- /dev/null +++ b/src/install/filesystem.go @@ -0,0 +1,193 @@ +// Package install provides installation automation for dashlights. +// It handles shell prompt integration and AI agent hook installation. +package install + +import ( + "io/fs" + "os" + "path/filepath" + "time" +) + +// Filesystem abstracts file operations for testability. +type Filesystem interface { + ReadFile(path string) ([]byte, error) + WriteFile(path string, data []byte, perm os.FileMode) error + Stat(path string) (os.FileInfo, error) + Exists(path string) bool + MkdirAll(path string, perm os.FileMode) error + Rename(src, dst string) error + UserHomeDir() (string, error) + Getenv(key string) string +} + +// OSFilesystem implements Filesystem using real OS operations. +type OSFilesystem struct{} + +// ReadFile reads the contents of a file. +func (f *OSFilesystem) ReadFile(path string) ([]byte, error) { + return os.ReadFile(filepath.Clean(path)) +} + +// WriteFile writes data to a file with the given permissions. +func (f *OSFilesystem) WriteFile(path string, data []byte, perm os.FileMode) error { + return os.WriteFile(path, data, perm) +} + +// Stat returns file info for the given path. +func (f *OSFilesystem) Stat(path string) (os.FileInfo, error) { + return os.Stat(path) +} + +// Exists returns true if the file exists. +func (f *OSFilesystem) Exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// MkdirAll creates a directory and all parent directories. +func (f *OSFilesystem) MkdirAll(path string, perm os.FileMode) error { + return os.MkdirAll(path, perm) +} + +// Rename renames (moves) a file. +func (f *OSFilesystem) Rename(src, dst string) error { + return os.Rename(src, dst) +} + +// UserHomeDir returns the user's home directory. +func (f *OSFilesystem) UserHomeDir() (string, error) { + return os.UserHomeDir() +} + +// Getenv returns the value of an environment variable. +func (f *OSFilesystem) Getenv(key string) string { + return os.Getenv(key) +} + +// MockFilesystem implements Filesystem for testing. +type MockFilesystem struct { + Files map[string][]byte + Modes map[string]os.FileMode + EnvVars map[string]string + HomeDir string + + // Error simulation + ReadFileErr error + WriteFileErr error + StatErr error + MkdirAllErr error + RenameErr error + HomeDirErr error +} + +// NewMockFilesystem creates a new mock filesystem for testing. +func NewMockFilesystem() *MockFilesystem { + return &MockFilesystem{ + Files: make(map[string][]byte), + Modes: make(map[string]os.FileMode), + EnvVars: make(map[string]string), + HomeDir: "/home/testuser", + } +} + +// ReadFile reads from the mock filesystem. +func (f *MockFilesystem) ReadFile(path string) ([]byte, error) { + if f.ReadFileErr != nil { + return nil, f.ReadFileErr + } + content, ok := f.Files[path] + if !ok { + return nil, os.ErrNotExist + } + return content, nil +} + +// WriteFile writes to the mock filesystem. +func (f *MockFilesystem) WriteFile(path string, data []byte, perm os.FileMode) error { + if f.WriteFileErr != nil { + return f.WriteFileErr + } + f.Files[path] = data + f.Modes[path] = perm + return nil +} + +// Stat returns mock file info. +func (f *MockFilesystem) Stat(path string) (os.FileInfo, error) { + if f.StatErr != nil { + return nil, f.StatErr + } + content, ok := f.Files[path] + if !ok { + return nil, os.ErrNotExist + } + mode := f.Modes[path] + if mode == 0 { + mode = 0644 + } + return &mockFileInfo{ + name: filepath.Base(path), + size: int64(len(content)), + mode: mode, + }, nil +} + +// Exists checks if a file exists in the mock filesystem. +func (f *MockFilesystem) Exists(path string) bool { + _, ok := f.Files[path] + return ok +} + +// MkdirAll is a no-op in the mock filesystem. +func (f *MockFilesystem) MkdirAll(path string, perm os.FileMode) error { + if f.MkdirAllErr != nil { + return f.MkdirAllErr + } + return nil +} + +// Rename moves a file in the mock filesystem. +func (f *MockFilesystem) Rename(src, dst string) error { + if f.RenameErr != nil { + return f.RenameErr + } + content, ok := f.Files[src] + if !ok { + return os.ErrNotExist + } + f.Files[dst] = content + if mode, ok := f.Modes[src]; ok { + f.Modes[dst] = mode + } + delete(f.Files, src) + delete(f.Modes, src) + return nil +} + +// UserHomeDir returns the mock home directory. +func (f *MockFilesystem) UserHomeDir() (string, error) { + if f.HomeDirErr != nil { + return "", f.HomeDirErr + } + return f.HomeDir, nil +} + +// Getenv returns a mock environment variable. +func (f *MockFilesystem) Getenv(key string) string { + return f.EnvVars[key] +} + +// mockFileInfo implements os.FileInfo for testing. +type mockFileInfo struct { + name string + size int64 + mode fs.FileMode +} + +func (fi *mockFileInfo) Name() string { return fi.name } +func (fi *mockFileInfo) Size() int64 { return fi.size } +func (fi *mockFileInfo) Mode() fs.FileMode { return fi.mode } +func (fi *mockFileInfo) IsDir() bool { return fi.mode.IsDir() } +func (fi *mockFileInfo) Sys() interface{} { return nil } +func (fi *mockFileInfo) ModTime() time.Time { return time.Time{} } diff --git a/src/install/filesystem_test.go b/src/install/filesystem_test.go new file mode 100644 index 0000000..589b949 --- /dev/null +++ b/src/install/filesystem_test.go @@ -0,0 +1,1099 @@ +package install + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "testing" + "time" +) + +// TestOSFilesystem tests the real filesystem implementation +func TestOSFilesystem_ReadFile(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.txt") + content := []byte("test content") + + // Write test file + err := os.WriteFile(testFile, content, 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + fs := &OSFilesystem{} + data, err := fs.ReadFile(testFile) + if err != nil { + t.Errorf("ReadFile failed: %v", err) + } + if string(data) != string(content) { + t.Errorf("Expected content '%s', got '%s'", string(content), string(data)) + } +} + +func TestOSFilesystem_ReadFile_NonExistent(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "nonexistent.txt") + + fs := &OSFilesystem{} + _, err := fs.ReadFile(testFile) + if err == nil { + t.Error("Expected error when reading non-existent file") + } + if !os.IsNotExist(err) { + t.Errorf("Expected os.ErrNotExist, got %v", err) + } +} + +func TestOSFilesystem_WriteFile(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "write.txt") + content := []byte("write test") + + fs := &OSFilesystem{} + err := fs.WriteFile(testFile, content, 0644) + if err != nil { + t.Errorf("WriteFile failed: %v", err) + } + + // Verify file was written + data, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read written file: %v", err) + } + if string(data) != string(content) { + t.Errorf("Expected content '%s', got '%s'", string(content), string(data)) + } + + // Verify permissions + info, err := os.Stat(testFile) + if err != nil { + t.Fatalf("Failed to stat file: %v", err) + } + if info.Mode().Perm() != 0644 { + t.Errorf("Expected permissions 0644, got %o", info.Mode().Perm()) + } +} + +func TestOSFilesystem_WriteFile_OverwriteExisting(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "overwrite.txt") + + // Write initial content + err := os.WriteFile(testFile, []byte("initial"), 0644) + if err != nil { + t.Fatalf("Failed to create initial file: %v", err) + } + + // Overwrite with new content + newContent := []byte("overwritten") + fs := &OSFilesystem{} + err = fs.WriteFile(testFile, newContent, 0600) + if err != nil { + t.Errorf("WriteFile failed: %v", err) + } + + // Verify new content + data, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + if string(data) != string(newContent) { + t.Errorf("Expected content '%s', got '%s'", string(newContent), string(data)) + } +} + +func TestOSFilesystem_Stat(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "stat.txt") + content := []byte("stat test") + + err := os.WriteFile(testFile, content, 0600) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + fs := &OSFilesystem{} + info, err := fs.Stat(testFile) + if err != nil { + t.Errorf("Stat failed: %v", err) + } + if info.Size() != int64(len(content)) { + t.Errorf("Expected size %d, got %d", len(content), info.Size()) + } + if info.Mode().Perm() != 0600 { + t.Errorf("Expected permissions 0600, got %o", info.Mode().Perm()) + } + if info.IsDir() { + t.Error("Expected file, not directory") + } +} + +func TestOSFilesystem_Stat_Directory(t *testing.T) { + tmpDir := t.TempDir() + testDir := filepath.Join(tmpDir, "testdir") + + err := os.Mkdir(testDir, 0755) + if err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + fs := &OSFilesystem{} + info, err := fs.Stat(testDir) + if err != nil { + t.Errorf("Stat failed: %v", err) + } + if !info.IsDir() { + t.Error("Expected directory") + } +} + +func TestOSFilesystem_Stat_NonExistent(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "nonexistent.txt") + + fs := &OSFilesystem{} + _, err := fs.Stat(testFile) + if err == nil { + t.Error("Expected error when stat-ing non-existent file") + } + if !os.IsNotExist(err) { + t.Errorf("Expected os.ErrNotExist, got %v", err) + } +} + +func TestOSFilesystem_Exists(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "exists.txt") + + fs := &OSFilesystem{} + + // File doesn't exist yet + if fs.Exists(testFile) { + t.Error("Expected false for non-existent file") + } + + // Create file + err := os.WriteFile(testFile, []byte("test"), 0644) + if err != nil { + t.Fatalf("Failed to create file: %v", err) + } + + // File now exists + if !fs.Exists(testFile) { + t.Error("Expected true for existing file") + } +} + +func TestOSFilesystem_Exists_Directory(t *testing.T) { + tmpDir := t.TempDir() + testDir := filepath.Join(tmpDir, "testdir") + + fs := &OSFilesystem{} + + // Directory doesn't exist yet + if fs.Exists(testDir) { + t.Error("Expected false for non-existent directory") + } + + // Create directory + err := os.Mkdir(testDir, 0755) + if err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + // Directory now exists + if !fs.Exists(testDir) { + t.Error("Expected true for existing directory") + } +} + +func TestOSFilesystem_MkdirAll(t *testing.T) { + tmpDir := t.TempDir() + testDir := filepath.Join(tmpDir, "parent", "child", "grandchild") + + fs := &OSFilesystem{} + err := fs.MkdirAll(testDir, 0755) + if err != nil { + t.Errorf("MkdirAll failed: %v", err) + } + + // Verify directory was created + info, err := os.Stat(testDir) + if err != nil { + t.Fatalf("Failed to stat directory: %v", err) + } + if !info.IsDir() { + t.Error("Expected directory") + } +} + +func TestOSFilesystem_MkdirAll_AlreadyExists(t *testing.T) { + tmpDir := t.TempDir() + testDir := filepath.Join(tmpDir, "existing") + + // Create directory first + err := os.Mkdir(testDir, 0755) + if err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + // MkdirAll should succeed even if directory exists + fs := &OSFilesystem{} + err = fs.MkdirAll(testDir, 0755) + if err != nil { + t.Errorf("MkdirAll failed on existing directory: %v", err) + } +} + +func TestOSFilesystem_Rename(t *testing.T) { + tmpDir := t.TempDir() + srcFile := filepath.Join(tmpDir, "source.txt") + dstFile := filepath.Join(tmpDir, "dest.txt") + content := []byte("rename test") + + // Create source file + err := os.WriteFile(srcFile, content, 0644) + if err != nil { + t.Fatalf("Failed to create source file: %v", err) + } + + fs := &OSFilesystem{} + err = fs.Rename(srcFile, dstFile) + if err != nil { + t.Errorf("Rename failed: %v", err) + } + + // Verify source file no longer exists + if _, err := os.Stat(srcFile); !os.IsNotExist(err) { + t.Error("Expected source file to not exist after rename") + } + + // Verify destination file exists with correct content + data, err := os.ReadFile(dstFile) + if err != nil { + t.Fatalf("Failed to read destination file: %v", err) + } + if string(data) != string(content) { + t.Errorf("Expected content '%s', got '%s'", string(content), string(data)) + } +} + +func TestOSFilesystem_Rename_NonExistent(t *testing.T) { + tmpDir := t.TempDir() + srcFile := filepath.Join(tmpDir, "nonexistent.txt") + dstFile := filepath.Join(tmpDir, "dest.txt") + + fs := &OSFilesystem{} + err := fs.Rename(srcFile, dstFile) + if err == nil { + t.Error("Expected error when renaming non-existent file") + } +} + +func TestOSFilesystem_UserHomeDir(t *testing.T) { + fs := &OSFilesystem{} + home, err := fs.UserHomeDir() + if err != nil { + t.Errorf("UserHomeDir failed: %v", err) + } + if home == "" { + t.Error("Expected non-empty home directory") + } + + // Verify it matches os.UserHomeDir + expectedHome, err := os.UserHomeDir() + if err != nil { + t.Fatalf("os.UserHomeDir failed: %v", err) + } + if home != expectedHome { + t.Errorf("Expected home '%s', got '%s'", expectedHome, home) + } +} + +func TestOSFilesystem_Getenv(t *testing.T) { + testKey := "DASHLIGHTS_TEST_VAR" + testValue := "test_value_123" + + // Set environment variable + err := os.Setenv(testKey, testValue) + if err != nil { + t.Fatalf("Failed to set environment variable: %v", err) + } + defer os.Unsetenv(testKey) + + fs := &OSFilesystem{} + value := fs.Getenv(testKey) + if value != testValue { + t.Errorf("Expected value '%s', got '%s'", testValue, value) + } +} + +func TestOSFilesystem_Getenv_NonExistent(t *testing.T) { + fs := &OSFilesystem{} + value := fs.Getenv("DASHLIGHTS_NONEXISTENT_VAR") + if value != "" { + t.Errorf("Expected empty string for non-existent variable, got '%s'", value) + } +} + +// TestMockFilesystem tests the mock filesystem implementation +func TestNewMockFilesystem(t *testing.T) { + fs := NewMockFilesystem() + + if fs.Files == nil { + t.Error("Expected Files map to be initialized") + } + if fs.Modes == nil { + t.Error("Expected Modes map to be initialized") + } + if fs.EnvVars == nil { + t.Error("Expected EnvVars map to be initialized") + } + if fs.HomeDir != "/home/testuser" { + t.Errorf("Expected default HomeDir '/home/testuser', got '%s'", fs.HomeDir) + } + if len(fs.Files) != 0 { + t.Errorf("Expected empty Files map, got %d entries", len(fs.Files)) + } +} + +func TestMockFilesystem_ReadFile(t *testing.T) { + tests := []struct { + name string + setup func(*MockFilesystem) + path string + expectData []byte + expectError bool + }{ + { + name: "read existing file", + setup: func(fs *MockFilesystem) { + fs.Files["/test/file.txt"] = []byte("content") + }, + path: "/test/file.txt", + expectData: []byte("content"), + expectError: false, + }, + { + name: "read non-existent file", + setup: func(fs *MockFilesystem) {}, + path: "/nonexistent.txt", + expectData: nil, + expectError: true, + }, + { + name: "read with error simulation", + setup: func(fs *MockFilesystem) { + fs.ReadFileErr = errors.New("simulated error") + }, + path: "/any/path.txt", + expectData: nil, + expectError: true, + }, + { + name: "read empty file", + setup: func(fs *MockFilesystem) { + fs.Files["/empty.txt"] = []byte("") + }, + path: "/empty.txt", + expectData: []byte(""), + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := NewMockFilesystem() + tt.setup(fs) + + data, err := fs.ReadFile(tt.path) + if tt.expectError && err == nil { + t.Error("Expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !tt.expectError && string(data) != string(tt.expectData) { + t.Errorf("Expected data '%s', got '%s'", string(tt.expectData), string(data)) + } + }) + } +} + +func TestMockFilesystem_ReadFile_ErrorIsNotExist(t *testing.T) { + fs := NewMockFilesystem() + _, err := fs.ReadFile("/nonexistent.txt") + if err == nil { + t.Fatal("Expected error for non-existent file") + } + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("Expected os.ErrNotExist, got %v", err) + } +} + +func TestMockFilesystem_WriteFile(t *testing.T) { + tests := []struct { + name string + setup func(*MockFilesystem) + path string + data []byte + perm os.FileMode + expectError bool + }{ + { + name: "write new file", + setup: func(fs *MockFilesystem) {}, + path: "/new/file.txt", + data: []byte("new content"), + perm: 0644, + expectError: false, + }, + { + name: "overwrite existing file", + setup: func(fs *MockFilesystem) { + fs.Files["/existing.txt"] = []byte("old content") + fs.Modes["/existing.txt"] = 0600 + }, + path: "/existing.txt", + data: []byte("new content"), + perm: 0644, + expectError: false, + }, + { + name: "write with error simulation", + setup: func(fs *MockFilesystem) { + fs.WriteFileErr = errors.New("simulated error") + }, + path: "/any/path.txt", + data: []byte("content"), + perm: 0644, + expectError: true, + }, + { + name: "write empty file", + setup: func(fs *MockFilesystem) {}, + path: "/empty.txt", + data: []byte(""), + perm: 0644, + expectError: false, + }, + { + name: "write with different permissions", + setup: func(fs *MockFilesystem) {}, + path: "/file.txt", + data: []byte("content"), + perm: 0755, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := NewMockFilesystem() + tt.setup(fs) + + err := fs.WriteFile(tt.path, tt.data, tt.perm) + if tt.expectError && err == nil { + t.Error("Expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !tt.expectError { + // Verify file was written + if string(fs.Files[tt.path]) != string(tt.data) { + t.Errorf("Expected data '%s', got '%s'", string(tt.data), string(fs.Files[tt.path])) + } + // Verify permissions were stored + if fs.Modes[tt.path] != tt.perm { + t.Errorf("Expected permissions %o, got %o", tt.perm, fs.Modes[tt.path]) + } + } + }) + } +} + +func TestMockFilesystem_Stat(t *testing.T) { + tests := []struct { + name string + setup func(*MockFilesystem) + path string + expectError bool + expectSize int64 + expectMode os.FileMode + }{ + { + name: "stat existing file", + setup: func(fs *MockFilesystem) { + fs.Files["/test.txt"] = []byte("test content") + fs.Modes["/test.txt"] = 0644 + }, + path: "/test.txt", + expectError: false, + expectSize: 12, // len("test content") + expectMode: 0644, + }, + { + name: "stat file with default mode", + setup: func(fs *MockFilesystem) { + fs.Files["/default.txt"] = []byte("content") + // Don't set mode, should default to 0644 + }, + path: "/default.txt", + expectError: false, + expectSize: 7, // len("content") + expectMode: 0644, + }, + { + name: "stat non-existent file", + setup: func(fs *MockFilesystem) {}, + path: "/nonexistent.txt", + expectError: true, + }, + { + name: "stat with error simulation", + setup: func(fs *MockFilesystem) { + fs.StatErr = errors.New("simulated error") + }, + path: "/any/path.txt", + expectError: true, + }, + { + name: "stat empty file", + setup: func(fs *MockFilesystem) { + fs.Files["/empty.txt"] = []byte("") + fs.Modes["/empty.txt"] = 0600 + }, + path: "/empty.txt", + expectError: false, + expectSize: 0, + expectMode: 0600, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := NewMockFilesystem() + tt.setup(fs) + + info, err := fs.Stat(tt.path) + if tt.expectError && err == nil { + t.Error("Expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !tt.expectError { + if info.Size() != tt.expectSize { + t.Errorf("Expected size %d, got %d", tt.expectSize, info.Size()) + } + if info.Mode() != tt.expectMode { + t.Errorf("Expected mode %o, got %o", tt.expectMode, info.Mode()) + } + // Verify name is basename + expectedName := filepath.Base(tt.path) + if info.Name() != expectedName { + t.Errorf("Expected name '%s', got '%s'", expectedName, info.Name()) + } + } + }) + } +} + +func TestMockFilesystem_Stat_ErrorIsNotExist(t *testing.T) { + fs := NewMockFilesystem() + _, err := fs.Stat("/nonexistent.txt") + if err == nil { + t.Fatal("Expected error for non-existent file") + } + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("Expected os.ErrNotExist, got %v", err) + } +} + +func TestMockFilesystem_Exists(t *testing.T) { + tests := []struct { + name string + setup func(*MockFilesystem) + path string + expect bool + }{ + { + name: "file exists", + setup: func(fs *MockFilesystem) { + fs.Files["/exists.txt"] = []byte("content") + }, + path: "/exists.txt", + expect: true, + }, + { + name: "file does not exist", + setup: func(fs *MockFilesystem) {}, + path: "/nonexistent.txt", + expect: false, + }, + { + name: "empty file exists", + setup: func(fs *MockFilesystem) { + fs.Files["/empty.txt"] = []byte("") + }, + path: "/empty.txt", + expect: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := NewMockFilesystem() + tt.setup(fs) + + exists := fs.Exists(tt.path) + if exists != tt.expect { + t.Errorf("Expected %v, got %v", tt.expect, exists) + } + }) + } +} + +func TestMockFilesystem_MkdirAll(t *testing.T) { + tests := []struct { + name string + setup func(*MockFilesystem) + path string + perm os.FileMode + expectError bool + }{ + { + name: "create directory", + setup: func(fs *MockFilesystem) {}, + path: "/test/dir", + perm: 0755, + expectError: false, + }, + { + name: "create with error simulation", + setup: func(fs *MockFilesystem) { + fs.MkdirAllErr = errors.New("simulated error") + }, + path: "/any/dir", + perm: 0755, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := NewMockFilesystem() + tt.setup(fs) + + err := fs.MkdirAll(tt.path, tt.perm) + if tt.expectError && err == nil { + t.Error("Expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + }) + } +} + +func TestMockFilesystem_Rename(t *testing.T) { + tests := []struct { + name string + setup func(*MockFilesystem) + src string + dst string + expectError bool + }{ + { + name: "rename existing file", + setup: func(fs *MockFilesystem) { + fs.Files["/old.txt"] = []byte("content") + fs.Modes["/old.txt"] = 0644 + }, + src: "/old.txt", + dst: "/new.txt", + expectError: false, + }, + { + name: "rename non-existent file", + setup: func(fs *MockFilesystem) {}, + src: "/nonexistent.txt", + dst: "/new.txt", + expectError: true, + }, + { + name: "rename with error simulation", + setup: func(fs *MockFilesystem) { + fs.RenameErr = errors.New("simulated error") + }, + src: "/any.txt", + dst: "/other.txt", + expectError: true, + }, + { + name: "rename preserves content and mode", + setup: func(fs *MockFilesystem) { + fs.Files["/src.txt"] = []byte("test content") + fs.Modes["/src.txt"] = 0600 + }, + src: "/src.txt", + dst: "/dst.txt", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := NewMockFilesystem() + tt.setup(fs) + + // Save original content and mode if source exists + var originalContent []byte + var originalMode os.FileMode + if data, ok := fs.Files[tt.src]; ok { + originalContent = data + originalMode = fs.Modes[tt.src] + } + + err := fs.Rename(tt.src, tt.dst) + if tt.expectError && err == nil { + t.Error("Expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !tt.expectError { + // Verify source no longer exists + if _, ok := fs.Files[tt.src]; ok { + t.Error("Expected source file to be removed") + } + if _, ok := fs.Modes[tt.src]; ok { + t.Error("Expected source mode to be removed") + } + // Verify destination has correct content and mode + if string(fs.Files[tt.dst]) != string(originalContent) { + t.Errorf("Expected content '%s', got '%s'", string(originalContent), string(fs.Files[tt.dst])) + } + if originalMode != 0 && fs.Modes[tt.dst] != originalMode { + t.Errorf("Expected mode %o, got %o", originalMode, fs.Modes[tt.dst]) + } + } + }) + } +} + +func TestMockFilesystem_Rename_ErrorIsNotExist(t *testing.T) { + fs := NewMockFilesystem() + err := fs.Rename("/nonexistent.txt", "/new.txt") + if err == nil { + t.Fatal("Expected error for non-existent file") + } + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("Expected os.ErrNotExist, got %v", err) + } +} + +func TestMockFilesystem_UserHomeDir(t *testing.T) { + tests := []struct { + name string + setup func(*MockFilesystem) + expectHome string + expectError bool + }{ + { + name: "default home directory", + setup: func(fs *MockFilesystem) {}, + expectHome: "/home/testuser", + expectError: false, + }, + { + name: "custom home directory", + setup: func(fs *MockFilesystem) { + fs.HomeDir = "/custom/home" + }, + expectHome: "/custom/home", + expectError: false, + }, + { + name: "error simulation", + setup: func(fs *MockFilesystem) { + fs.HomeDirErr = errors.New("simulated error") + }, + expectHome: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := NewMockFilesystem() + tt.setup(fs) + + home, err := fs.UserHomeDir() + if tt.expectError && err == nil { + t.Error("Expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !tt.expectError && home != tt.expectHome { + t.Errorf("Expected home '%s', got '%s'", tt.expectHome, home) + } + }) + } +} + +func TestMockFilesystem_Getenv(t *testing.T) { + tests := []struct { + name string + setup func(*MockFilesystem) + key string + expect string + }{ + { + name: "get existing variable", + setup: func(fs *MockFilesystem) { + fs.EnvVars["TEST_VAR"] = "test_value" + }, + key: "TEST_VAR", + expect: "test_value", + }, + { + name: "get non-existent variable", + setup: func(fs *MockFilesystem) {}, + key: "NONEXISTENT", + expect: "", + }, + { + name: "get empty variable", + setup: func(fs *MockFilesystem) { + fs.EnvVars["EMPTY"] = "" + }, + key: "EMPTY", + expect: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := NewMockFilesystem() + tt.setup(fs) + + value := fs.Getenv(tt.key) + if value != tt.expect { + t.Errorf("Expected value '%s', got '%s'", tt.expect, value) + } + }) + } +} + +// TestMockFileInfo tests the mockFileInfo implementation +func TestMockFileInfo_Name(t *testing.T) { + fi := &mockFileInfo{name: "test.txt"} + if fi.Name() != "test.txt" { + t.Errorf("Expected name 'test.txt', got '%s'", fi.Name()) + } +} + +func TestMockFileInfo_Size(t *testing.T) { + tests := []struct { + name string + size int64 + expect int64 + }{ + {"zero size", 0, 0}, + {"positive size", 123, 123}, + {"large size", 1048576, 1048576}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fi := &mockFileInfo{size: tt.size} + if fi.Size() != tt.expect { + t.Errorf("Expected size %d, got %d", tt.expect, fi.Size()) + } + }) + } +} + +func TestMockFileInfo_Mode(t *testing.T) { + tests := []struct { + name string + mode fs.FileMode + expect fs.FileMode + }{ + {"regular file 0644", 0644, 0644}, + {"regular file 0600", 0600, 0600}, + {"executable 0755", 0755, 0755}, + {"directory", fs.ModeDir | 0755, fs.ModeDir | 0755}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fi := &mockFileInfo{mode: tt.mode} + if fi.Mode() != tt.expect { + t.Errorf("Expected mode %o, got %o", tt.expect, fi.Mode()) + } + }) + } +} + +func TestMockFileInfo_IsDir(t *testing.T) { + tests := []struct { + name string + mode fs.FileMode + expect bool + }{ + {"regular file", 0644, false}, + {"directory", fs.ModeDir | 0755, true}, + {"executable file", 0755, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fi := &mockFileInfo{mode: tt.mode} + if fi.IsDir() != tt.expect { + t.Errorf("Expected IsDir() %v, got %v", tt.expect, fi.IsDir()) + } + }) + } +} + +func TestMockFileInfo_ModTime(t *testing.T) { + fi := &mockFileInfo{} + modTime := fi.ModTime() + // Should return zero time + if !modTime.IsZero() { + t.Errorf("Expected zero time, got %v", modTime) + } + if modTime != (time.Time{}) { + t.Errorf("Expected time.Time{}, got %v", modTime) + } +} + +func TestMockFileInfo_Sys(t *testing.T) { + fi := &mockFileInfo{} + sys := fi.Sys() + if sys != nil { + t.Errorf("Expected nil, got %v", sys) + } +} + +// Integration tests combining multiple operations +func TestMockFilesystem_Integration_ReadWriteSequence(t *testing.T) { + fs := NewMockFilesystem() + path := "/test/file.txt" + content1 := []byte("first content") + content2 := []byte("second content") + + // Write first content + err := fs.WriteFile(path, content1, 0644) + if err != nil { + t.Fatalf("First write failed: %v", err) + } + + // Read first content + data, err := fs.ReadFile(path) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + if string(data) != string(content1) { + t.Errorf("Expected '%s', got '%s'", string(content1), string(data)) + } + + // Overwrite with second content + err = fs.WriteFile(path, content2, 0600) + if err != nil { + t.Fatalf("Second write failed: %v", err) + } + + // Read second content + data, err = fs.ReadFile(path) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + if string(data) != string(content2) { + t.Errorf("Expected '%s', got '%s'", string(content2), string(data)) + } + + // Verify mode was updated + info, err := fs.Stat(path) + if err != nil { + t.Fatalf("Stat failed: %v", err) + } + if info.Mode() != 0600 { + t.Errorf("Expected mode 0600, got %o", info.Mode()) + } +} + +func TestMockFilesystem_Integration_RenameAndRead(t *testing.T) { + fs := NewMockFilesystem() + srcPath := "/src.txt" + dstPath := "/dst.txt" + content := []byte("rename test content") + + // Write initial file + err := fs.WriteFile(srcPath, content, 0644) + if err != nil { + t.Fatalf("Write failed: %v", err) + } + + // Rename + err = fs.Rename(srcPath, dstPath) + if err != nil { + t.Fatalf("Rename failed: %v", err) + } + + // Verify source doesn't exist + if fs.Exists(srcPath) { + t.Error("Source should not exist after rename") + } + + // Verify destination exists and has correct content + if !fs.Exists(dstPath) { + t.Error("Destination should exist after rename") + } + + data, err := fs.ReadFile(dstPath) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + if string(data) != string(content) { + t.Errorf("Expected '%s', got '%s'", string(content), string(data)) + } +} + +func TestMockFilesystem_Integration_MultipleFiles(t *testing.T) { + fs := NewMockFilesystem() + + files := map[string]string{ + "/file1.txt": "content 1", + "/file2.txt": "content 2", + "/dir/file3.txt": "content 3", + } + + // Write all files + for path, content := range files { + err := fs.WriteFile(path, []byte(content), 0644) + if err != nil { + t.Fatalf("Write failed for %s: %v", path, err) + } + } + + // Verify all files exist and have correct content + for path, expectedContent := range files { + if !fs.Exists(path) { + t.Errorf("File %s should exist", path) + } + + data, err := fs.ReadFile(path) + if err != nil { + t.Errorf("Read failed for %s: %v", path, err) + } + if string(data) != expectedContent { + t.Errorf("For %s: expected '%s', got '%s'", path, expectedContent, string(data)) + } + } +} diff --git a/src/install/install.go b/src/install/install.go new file mode 100644 index 0000000..367c7e9 --- /dev/null +++ b/src/install/install.go @@ -0,0 +1,249 @@ +package install + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" +) + +// ExitCode represents the exit code of an installation operation. +type ExitCode int + +const ( + ExitSuccess ExitCode = 0 + ExitError ExitCode = 1 +) + +// InstallOptions contains options for installation. +type InstallOptions struct { + InstallPrompt bool + InstallAgent string // Agent name (claude, cursor) + ConfigPathOverride string // If set, overrides auto-detected config path + NonInteractive bool + DryRun bool +} + +// InstallResult contains the result of an installation operation. +type InstallResult struct { + ExitCode ExitCode + Message string + BackupPath string + ConfigPath string + WhatChanged string +} + +// Installer is the main orchestrator for installation operations. +type Installer struct { + fs Filesystem + shellInstall *ShellInstaller + agentInstall *AgentInstaller + stdin io.Reader + stdout io.Writer + stderr io.Writer +} + +// NewInstaller creates a new Installer with OS filesystem. +func NewInstaller() *Installer { + fs := &OSFilesystem{} + return &Installer{ + fs: fs, + shellInstall: NewShellInstaller(fs), + agentInstall: NewAgentInstaller(fs), + stdin: os.Stdin, + stdout: os.Stdout, + stderr: os.Stderr, + } +} + +// NewInstallerWithFS creates a new Installer with a custom filesystem. +func NewInstallerWithFS(fs Filesystem) *Installer { + return &Installer{ + fs: fs, + shellInstall: NewShellInstaller(fs), + agentInstall: NewAgentInstaller(fs), + stdin: os.Stdin, + stdout: os.Stdout, + stderr: os.Stderr, + } +} + +// SetIO sets custom input/output streams for testing. +func (i *Installer) SetIO(stdin io.Reader, stdout, stderr io.Writer) { + i.stdin = stdin + i.stdout = stdout + i.stderr = stderr +} + +// Run executes the installation based on the provided options. +func (i *Installer) Run(opts InstallOptions) ExitCode { + // Validate options + if opts.ConfigPathOverride != "" && opts.InstallAgent != "" { + fmt.Fprintln(i.stderr, "Error: --configpath cannot be used with --installagent") + return ExitError + } + + if opts.InstallPrompt { + return i.runPromptInstall(opts) + } + + if opts.InstallAgent != "" { + return i.runAgentInstall(opts) + } + + // Should not reach here if called correctly from main + fmt.Fprintln(i.stderr, "Error: no installation action specified") + return ExitError +} + +// runPromptInstall handles shell prompt installation. +func (i *Installer) runPromptInstall(opts InstallOptions) ExitCode { + // Get shell configuration + config, err := i.shellInstall.GetShellConfig(opts.ConfigPathOverride) + if err != nil { + fmt.Fprintf(i.stderr, "Error: %v\n", err) + return ExitError + } + + // Check for config path being a directory + if info, err := i.fs.Stat(opts.ConfigPathOverride); err == nil && info.IsDir() { + fmt.Fprintln(i.stderr, "Error: --configpath must be a file, not a directory") + return ExitError + } + + // Interactive mode: show preview and confirm + if !opts.NonInteractive && !opts.DryRun { + if !i.confirmPromptInstall(config) { + fmt.Fprintln(i.stdout, "Installation cancelled.") + return ExitError + } + } + + // Perform installation + result, err := i.shellInstall.Install(config, opts.DryRun) + if err != nil { + fmt.Fprintf(i.stderr, "Error: %v\n", err) + return ExitError + } + + fmt.Fprintln(i.stdout, result.Message) + + // Show next steps for successful installations + if result.ExitCode == ExitSuccess && !opts.DryRun && result.WhatChanged != "" { + fmt.Fprintln(i.stdout, "") + fmt.Fprintln(i.stdout, "Next steps:") + fmt.Fprintf(i.stdout, " Restart your shell or run: source %s\n", config.ConfigPath) + } + + return result.ExitCode +} + +// runAgentInstall handles AI agent configuration installation. +func (i *Installer) runAgentInstall(opts InstallOptions) ExitCode { + // Parse agent type + agentType, err := ParseAgentType(opts.InstallAgent) + if err != nil { + fmt.Fprintf(i.stderr, "Error: %v\n", err) + return ExitError + } + + // Get agent configuration + config, err := i.agentInstall.GetAgentConfig(agentType) + if err != nil { + fmt.Fprintf(i.stderr, "Error: %v\n", err) + return ExitError + } + + // Check for Cursor hook conflict in interactive mode + if agentType == AgentCursor && !opts.NonInteractive && !opts.DryRun { + existingCmd, hasConflict, err := i.agentInstall.CheckCursorConflict(config) + if err != nil { + fmt.Fprintf(i.stderr, "Error: %v\n", err) + return ExitError + } + if hasConflict { + fmt.Fprintf(i.stdout, "Warning: Cursor only supports one beforeShellExecution hook.\n") + fmt.Fprintf(i.stdout, "Existing hook will be replaced. Current: %q\n", existingCmd) + if !i.confirm("Proceed?") { + fmt.Fprintln(i.stdout, "Installation cancelled. Existing hook preserved.") + return ExitError + } + } + } + + // Interactive mode: show preview and confirm + if !opts.NonInteractive && !opts.DryRun { + if !i.confirmAgentInstall(config) { + fmt.Fprintln(i.stdout, "Installation cancelled.") + return ExitError + } + } + + // Perform installation + result, err := i.agentInstall.Install(config, opts.DryRun, opts.NonInteractive) + if err != nil { + fmt.Fprintf(i.stderr, "Error: %v\n", err) + return ExitError + } + + fmt.Fprintln(i.stdout, result.Message) + + // Show next steps for successful installations + if result.ExitCode == ExitSuccess && !opts.DryRun && result.WhatChanged != "" { + fmt.Fprintln(i.stdout, "") + fmt.Fprintln(i.stdout, "Next steps:") + fmt.Fprintf(i.stdout, " Restart %s to apply changes\n", config.Name) + } + + return result.ExitCode +} + +// confirmPromptInstall shows an interactive confirmation for shell prompt installation. +func (i *Installer) confirmPromptInstall(config *ShellConfig) bool { + fmt.Fprintln(i.stdout, "Dashlights Shell Installation") + fmt.Fprintln(i.stdout, "=============================") + fmt.Fprintln(i.stdout, "") + fmt.Fprintf(i.stdout, "Detected shell: %s\n", config.Shell) + fmt.Fprintf(i.stdout, "Using template: %s\n", config.Name) + fmt.Fprintf(i.stdout, "Config file: %s\n", config.ConfigPath) + fmt.Fprintln(i.stdout, "") + fmt.Fprintln(i.stdout, "The following changes will be made:") + fmt.Fprintf(i.stdout, " - Backup: %s.dashlights-backup\n", config.ConfigPath) + fmt.Fprintln(i.stdout, " - Add dashlights prompt function") + fmt.Fprintln(i.stdout, "") + + return i.confirm("Proceed?") +} + +// confirmAgentInstall shows an interactive confirmation for agent installation. +func (i *Installer) confirmAgentInstall(config *AgentConfig) bool { + fmt.Fprintf(i.stdout, "Dashlights %s Installation\n", config.Name) + fmt.Fprintln(i.stdout, strings.Repeat("=", 30+len(config.Name))) + fmt.Fprintln(i.stdout, "") + fmt.Fprintf(i.stdout, "Config file: %s\n", config.ConfigPath) + fmt.Fprintln(i.stdout, "") + fmt.Fprintln(i.stdout, "The following changes will be made:") + + if i.fs.Exists(config.ConfigPath) { + fmt.Fprintf(i.stdout, " - Backup: %s.dashlights-backup\n", config.ConfigPath) + } + fmt.Fprintf(i.stdout, " - Add dashlights hook to %s\n", config.Name) + fmt.Fprintln(i.stdout, "") + + return i.confirm("Proceed?") +} + +// confirm prompts for y/N confirmation and returns true if user confirms. +func (i *Installer) confirm(prompt string) bool { + fmt.Fprintf(i.stdout, "%s [y/N]: ", prompt) + + reader := bufio.NewReader(i.stdin) + response, err := reader.ReadString('\n') + if err != nil { + return false + } + + response = strings.TrimSpace(strings.ToLower(response)) + return response == "y" || response == "yes" +} diff --git a/src/install/install_test.go b/src/install/install_test.go new file mode 100644 index 0000000..7035d9f --- /dev/null +++ b/src/install/install_test.go @@ -0,0 +1,987 @@ +package install + +import ( + "bytes" + "io" + "io/fs" + "os" + "strings" + "testing" +) + +// TestInstaller_Run_ConfigPathWithAgent tests that using --configpath with --installagent returns error. +func TestInstaller_Run_ConfigPathWithAgent(t *testing.T) { + mockFS := NewMockFilesystem() + installer := NewInstallerWithFS(mockFS) + + var stderr bytes.Buffer + installer.SetIO(nil, nil, &stderr) + + opts := InstallOptions{ + InstallAgent: "claude", + ConfigPathOverride: "/custom/path", + } + + exitCode := installer.Run(opts) + + if exitCode != ExitError { + t.Errorf("Run() = %v, want %v", exitCode, ExitError) + } + + stderrOutput := stderr.String() + if !strings.Contains(stderrOutput, "--configpath cannot be used with --installagent") { + t.Errorf("stderr = %q, want error about --configpath with --installagent", stderrOutput) + } +} + +// TestInstaller_Run_NoAction tests that no action specified returns error. +func TestInstaller_Run_NoAction(t *testing.T) { + mockFS := NewMockFilesystem() + installer := NewInstallerWithFS(mockFS) + + var stderr bytes.Buffer + installer.SetIO(nil, nil, &stderr) + + opts := InstallOptions{ + InstallPrompt: false, + InstallAgent: "", + } + + exitCode := installer.Run(opts) + + if exitCode != ExitError { + t.Errorf("Run() = %v, want %v", exitCode, ExitError) + } + + stderrOutput := stderr.String() + if !strings.Contains(stderrOutput, "no installation action specified") { + t.Errorf("stderr = %q, want error about no action", stderrOutput) + } +} + +// TestInstaller_Run_InstallPrompt_Success tests successful prompt installation. +func TestInstaller_Run_InstallPrompt_Success(t *testing.T) { + mockFS := NewMockFilesystem() + mockFS.EnvVars["SHELL"] = "/bin/bash" + mockFS.HomeDir = "/home/testuser" + + installer := NewInstallerWithFS(mockFS) + + var stdout bytes.Buffer + installer.SetIO(nil, &stdout, nil) + + opts := InstallOptions{ + InstallPrompt: true, + NonInteractive: true, // Skip confirmation + } + + exitCode := installer.Run(opts) + + if exitCode != ExitSuccess { + t.Errorf("Run() = %v, want %v", exitCode, ExitSuccess) + } + + stdoutOutput := stdout.String() + if !strings.Contains(stdoutOutput, "Installed dashlights into") { + t.Errorf("stdout = %q, want success message", stdoutOutput) + } + if !strings.Contains(stdoutOutput, ".bashrc") { + t.Errorf("stdout = %q, want .bashrc in output", stdoutOutput) + } + if !strings.Contains(stdoutOutput, "Next steps:") { + t.Errorf("stdout = %q, want next steps", stdoutOutput) + } + + // Verify file was written + configPath := "/home/testuser/.bashrc" + if !mockFS.Exists(configPath) { + t.Errorf("config file not created at %s", configPath) + } + + content, _ := mockFS.ReadFile(configPath) + if !strings.Contains(string(content), SentinelBegin) { + t.Errorf("config missing SentinelBegin") + } +} + +// TestInstaller_Run_InstallPrompt_AlreadyInstalled tests that already installed returns success. +func TestInstaller_Run_InstallPrompt_AlreadyInstalled(t *testing.T) { + mockFS := NewMockFilesystem() + mockFS.EnvVars["SHELL"] = "/bin/bash" + mockFS.HomeDir = "/home/testuser" + + configPath := "/home/testuser/.bashrc" + mockFS.Files[configPath] = []byte(BashTemplate) + + installer := NewInstallerWithFS(mockFS) + + var stdout bytes.Buffer + installer.SetIO(nil, &stdout, nil) + + opts := InstallOptions{ + InstallPrompt: true, + NonInteractive: true, + } + + exitCode := installer.Run(opts) + + if exitCode != ExitSuccess { + t.Errorf("Run() = %v, want %v", exitCode, ExitSuccess) + } + + stdoutOutput := stdout.String() + if !strings.Contains(stdoutOutput, "already installed") { + t.Errorf("stdout = %q, want 'already installed' message", stdoutOutput) + } + if strings.Contains(stdoutOutput, "Next steps:") { + t.Errorf("stdout = %q, should not have next steps for already installed", stdoutOutput) + } +} + +// TestInstaller_Run_InstallPrompt_DryRun tests dry-run mode for prompt installation. +func TestInstaller_Run_InstallPrompt_DryRun(t *testing.T) { + mockFS := NewMockFilesystem() + mockFS.EnvVars["SHELL"] = "/bin/bash" + mockFS.HomeDir = "/home/testuser" + + installer := NewInstallerWithFS(mockFS) + + var stdout bytes.Buffer + installer.SetIO(nil, &stdout, nil) + + opts := InstallOptions{ + InstallPrompt: true, + DryRun: true, + } + + exitCode := installer.Run(opts) + + if exitCode != ExitSuccess { + t.Errorf("Run() = %v, want %v", exitCode, ExitSuccess) + } + + stdoutOutput := stdout.String() + if !strings.Contains(stdoutOutput, "[DRY-RUN]") { + t.Errorf("stdout = %q, want [DRY-RUN] marker", stdoutOutput) + } + if !strings.Contains(stdoutOutput, "No changes made") { + t.Errorf("stdout = %q, want 'No changes made'", stdoutOutput) + } + if strings.Contains(stdoutOutput, "Next steps:") { + t.Errorf("stdout = %q, should not have next steps in dry-run", stdoutOutput) + } + + // Verify file was NOT written + configPath := "/home/testuser/.bashrc" + if mockFS.Exists(configPath) { + t.Errorf("config file should not exist in dry-run mode") + } +} + +// TestInstaller_Run_InstallPrompt_NonInteractive tests non-interactive mode. +func TestInstaller_Run_InstallPrompt_NonInteractive(t *testing.T) { + mockFS := NewMockFilesystem() + mockFS.EnvVars["SHELL"] = "/bin/zsh" + mockFS.HomeDir = "/home/testuser" + + installer := NewInstallerWithFS(mockFS) + + var stdout bytes.Buffer + installer.SetIO(nil, &stdout, nil) + + opts := InstallOptions{ + InstallPrompt: true, + NonInteractive: true, + } + + exitCode := installer.Run(opts) + + if exitCode != ExitSuccess { + t.Errorf("Run() = %v, want %v", exitCode, ExitSuccess) + } + + stdoutOutput := stdout.String() + // Should not contain interactive prompts + if strings.Contains(stdoutOutput, "[y/N]") { + t.Errorf("stdout = %q, should not contain interactive prompt in non-interactive mode", stdoutOutput) + } +} + +// TestInstaller_Run_InstallPrompt_ConfigPathIsDirectory tests error when config path is a directory. +func TestInstaller_Run_InstallPrompt_ConfigPathIsDirectory(t *testing.T) { + mockFS := NewMockFilesystem() + mockFS.EnvVars["SHELL"] = "/bin/bash" + mockFS.HomeDir = "/home/testuser" + + // Create a directory entry in the mock filesystem + dirPath := "/custom/dir" + mockFS.Files[dirPath] = []byte{} // Empty content + mockFS.Modes[dirPath] = fs.ModeDir | 0755 + + installer := NewInstallerWithFS(mockFS) + + var stderr bytes.Buffer + installer.SetIO(nil, nil, &stderr) + + opts := InstallOptions{ + InstallPrompt: true, + ConfigPathOverride: dirPath, + NonInteractive: true, + } + + exitCode := installer.Run(opts) + + if exitCode != ExitError { + t.Errorf("Run() = %v, want %v", exitCode, ExitError) + } + + stderrOutput := stderr.String() + if !strings.Contains(stderrOutput, "must be a file, not a directory") { + t.Errorf("stderr = %q, want error about directory", stderrOutput) + } +} + +// TestInstaller_Run_InstallAgent_Claude_Success tests successful Claude agent installation. +func TestInstaller_Run_InstallAgent_Claude_Success(t *testing.T) { + mockFS := NewMockFilesystem() + mockFS.HomeDir = "/home/testuser" + + installer := NewInstallerWithFS(mockFS) + + var stdout bytes.Buffer + installer.SetIO(nil, &stdout, nil) + + opts := InstallOptions{ + InstallAgent: "claude", + NonInteractive: true, + } + + exitCode := installer.Run(opts) + + if exitCode != ExitSuccess { + t.Errorf("Run() = %v, want %v", exitCode, ExitSuccess) + } + + stdoutOutput := stdout.String() + if !strings.Contains(stdoutOutput, "Installed dashlights into") { + t.Errorf("stdout = %q, want success message", stdoutOutput) + } + if !strings.Contains(stdoutOutput, "settings.json") { + t.Errorf("stdout = %q, want settings.json in output", stdoutOutput) + } + if !strings.Contains(stdoutOutput, "Next steps:") { + t.Errorf("stdout = %q, want next steps", stdoutOutput) + } + + // Verify file was written + configPath := "/home/testuser/.claude/settings.json" + if !mockFS.Exists(configPath) { + t.Errorf("config file not created at %s", configPath) + } + + content, _ := mockFS.ReadFile(configPath) + if !strings.Contains(string(content), DashlightsCommand) { + t.Errorf("config missing dashlights command") + } +} + +// TestInstaller_Run_InstallAgent_Cursor_Success tests successful Cursor agent installation. +func TestInstaller_Run_InstallAgent_Cursor_Success(t *testing.T) { + mockFS := NewMockFilesystem() + mockFS.HomeDir = "/home/testuser" + + installer := NewInstallerWithFS(mockFS) + + var stdout bytes.Buffer + installer.SetIO(nil, &stdout, nil) + + opts := InstallOptions{ + InstallAgent: "cursor", + NonInteractive: true, + } + + exitCode := installer.Run(opts) + + if exitCode != ExitSuccess { + t.Errorf("Run() = %v, want %v", exitCode, ExitSuccess) + } + + stdoutOutput := stdout.String() + if !strings.Contains(stdoutOutput, "Installed dashlights into") { + t.Errorf("stdout = %q, want success message", stdoutOutput) + } + if !strings.Contains(stdoutOutput, "hooks.json") { + t.Errorf("stdout = %q, want hooks.json in output", stdoutOutput) + } + + // Verify file was written + configPath := "/home/testuser/.cursor/hooks.json" + if !mockFS.Exists(configPath) { + t.Errorf("config file not created at %s", configPath) + } + + content, _ := mockFS.ReadFile(configPath) + if !strings.Contains(string(content), DashlightsCommand) { + t.Errorf("config missing dashlights command") + } +} + +// TestInstaller_Run_InstallAgent_InvalidAgent tests error for invalid agent name. +func TestInstaller_Run_InstallAgent_InvalidAgent(t *testing.T) { + mockFS := NewMockFilesystem() + installer := NewInstallerWithFS(mockFS) + + var stderr bytes.Buffer + installer.SetIO(nil, nil, &stderr) + + opts := InstallOptions{ + InstallAgent: "invalid-agent", + } + + exitCode := installer.Run(opts) + + if exitCode != ExitError { + t.Errorf("Run() = %v, want %v", exitCode, ExitError) + } + + stderrOutput := stderr.String() + if !strings.Contains(stderrOutput, "unsupported agent") { + t.Errorf("stderr = %q, want error about unsupported agent", stderrOutput) + } +} + +// TestInstaller_Run_InstallAgent_DryRun tests dry-run mode for agent installation. +func TestInstaller_Run_InstallAgent_DryRun(t *testing.T) { + mockFS := NewMockFilesystem() + mockFS.HomeDir = "/home/testuser" + + installer := NewInstallerWithFS(mockFS) + + var stdout bytes.Buffer + installer.SetIO(nil, &stdout, nil) + + opts := InstallOptions{ + InstallAgent: "claude", + DryRun: true, + } + + exitCode := installer.Run(opts) + + if exitCode != ExitSuccess { + t.Errorf("Run() = %v, want %v", exitCode, ExitSuccess) + } + + stdoutOutput := stdout.String() + if !strings.Contains(stdoutOutput, "[DRY-RUN]") { + t.Errorf("stdout = %q, want [DRY-RUN] marker", stdoutOutput) + } + if !strings.Contains(stdoutOutput, "No changes made") { + t.Errorf("stdout = %q, want 'No changes made'", stdoutOutput) + } + if strings.Contains(stdoutOutput, "Next steps:") { + t.Errorf("stdout = %q, should not have next steps in dry-run", stdoutOutput) + } + + // Verify file was NOT written + configPath := "/home/testuser/.claude/settings.json" + if mockFS.Exists(configPath) { + t.Errorf("config file should not exist in dry-run mode") + } +} + +// TestInstaller_Run_InstallAgent_NonInteractive tests non-interactive mode for agent installation. +func TestInstaller_Run_InstallAgent_NonInteractive(t *testing.T) { + mockFS := NewMockFilesystem() + mockFS.HomeDir = "/home/testuser" + + installer := NewInstallerWithFS(mockFS) + + var stdout bytes.Buffer + installer.SetIO(nil, &stdout, nil) + + opts := InstallOptions{ + InstallAgent: "claude", + NonInteractive: true, + } + + exitCode := installer.Run(opts) + + if exitCode != ExitSuccess { + t.Errorf("Run() = %v, want %v", exitCode, ExitSuccess) + } + + stdoutOutput := stdout.String() + // Should not contain interactive prompts + if strings.Contains(stdoutOutput, "[y/N]") { + t.Errorf("stdout = %q, should not contain interactive prompt in non-interactive mode", stdoutOutput) + } +} + +// TestInstaller_Run_InstallAgent_Cursor_WithConflict tests Cursor installation with existing hook conflict. +func TestInstaller_Run_InstallAgent_Cursor_WithConflict(t *testing.T) { + mockFS := NewMockFilesystem() + mockFS.HomeDir = "/home/testuser" + + // Create existing Cursor config with a different hook + configPath := "/home/testuser/.cursor/hooks.json" + existingConfig := `{ + "beforeShellExecution": { + "command": "some-other-command" + } +}` + mockFS.Files[configPath] = []byte(existingConfig) + + installer := NewInstallerWithFS(mockFS) + + var stdout bytes.Buffer + installer.SetIO(nil, &stdout, nil) + + opts := InstallOptions{ + InstallAgent: "cursor", + NonInteractive: true, // In non-interactive mode, should error + } + + exitCode := installer.Run(opts) + + if exitCode != ExitError { + t.Errorf("Run() = %v, want %v", exitCode, ExitError) + } + + stdoutOutput := stdout.String() + if !strings.Contains(stdoutOutput, "already has a beforeShellExecution hook") { + t.Errorf("stdout = %q, want error about existing hook", stdoutOutput) + } +} + +// TestInstaller_Run_InstallAgent_Cursor_InteractiveWithConflict tests Cursor installation with interactive conflict resolution. +func TestInstaller_Run_InstallAgent_Cursor_InteractiveWithConflict(t *testing.T) { + mockFS := NewMockFilesystem() + mockFS.HomeDir = "/home/testuser" + + // Create existing Cursor config with a different hook + configPath := "/home/testuser/.cursor/hooks.json" + existingConfig := `{ + "beforeShellExecution": { + "command": "some-other-command" + } +}` + mockFS.Files[configPath] = []byte(existingConfig) + + installer := NewInstallerWithFS(mockFS) + + // Simulate user declining the replacement + stdin := strings.NewReader("n\n") + var stdout, stderr bytes.Buffer + installer.SetIO(stdin, &stdout, &stderr) + + opts := InstallOptions{ + InstallAgent: "cursor", + NonInteractive: false, + } + + exitCode := installer.Run(opts) + + if exitCode != ExitError { + t.Errorf("Run() = %v, want %v (user declined)", exitCode, ExitError) + } + + stdoutOutput := stdout.String() + if !strings.Contains(stdoutOutput, "Warning: Cursor only supports one beforeShellExecution hook") { + t.Errorf("stdout = %q, want warning about Cursor limitation", stdoutOutput) + } + if !strings.Contains(stdoutOutput, "Installation cancelled. Existing hook preserved.") { + t.Errorf("stdout = %q, want cancellation message", stdoutOutput) + } +} + +// TestInstaller_Run_InstallAgent_Cursor_InteractiveAcceptConflict tests Cursor installation with user accepting replacement. +func TestInstaller_Run_InstallAgent_Cursor_InteractiveAcceptConflict(t *testing.T) { + mockFS := NewMockFilesystem() + mockFS.HomeDir = "/home/testuser" + + // Create existing Cursor config with a different hook + configPath := "/home/testuser/.cursor/hooks.json" + existingConfig := `{ + "beforeShellExecution": { + "command": "some-other-command" + } +}` + mockFS.Files[configPath] = []byte(existingConfig) + + installer := NewInstallerWithFS(mockFS) + + // Simulate user accepting both confirmations (conflict + install) + // Need to provide enough "y\n" for both the conflict warning AND the install confirmation + // Use unbuffered reader to prevent bufio from buffering all input on first read + stdin := newUnbufferedReader("y\ny\n") + var stdout bytes.Buffer + installer.SetIO(stdin, &stdout, nil) + + opts := InstallOptions{ + InstallAgent: "cursor", + NonInteractive: false, + } + + exitCode := installer.Run(opts) + + if exitCode != ExitSuccess { + stdoutOutput := stdout.String() + t.Errorf("Run() = %v, want %v. Output: %s", exitCode, ExitSuccess, stdoutOutput) + } + + stdoutOutput := stdout.String() + if !strings.Contains(stdoutOutput, "Installed dashlights into") { + t.Errorf("stdout = %q, want success message", stdoutOutput) + } + + // Verify the hook was replaced + content, _ := mockFS.ReadFile(configPath) + if !strings.Contains(string(content), DashlightsCommand) { + t.Errorf("config should contain dashlights command") + } + if strings.Contains(string(content), "some-other-command") { + t.Errorf("config should not contain old command") + } +} + +// TestInstaller_confirm_Yes tests interactive confirmation with yes response. +func TestInstaller_confirm_Yes(t *testing.T) { + mockFS := NewMockFilesystem() + installer := NewInstallerWithFS(mockFS) + + tests := []struct { + name string + input string + want bool + }{ + {"lowercase y", "y\n", true}, + {"uppercase Y", "Y\n", true}, + {"full yes", "yes\n", true}, + {"uppercase YES", "YES\n", true}, + {"yes with spaces", " yes \n", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdin := strings.NewReader(tt.input) + var stdout bytes.Buffer + installer.SetIO(stdin, &stdout, nil) + + got := installer.confirm("Proceed?") + if got != tt.want { + t.Errorf("confirm() = %v, want %v for input %q", got, tt.want, tt.input) + } + + if !strings.Contains(stdout.String(), "Proceed? [y/N]:") { + t.Errorf("stdout should contain prompt") + } + }) + } +} + +// TestInstaller_confirm_No tests interactive confirmation with no response. +func TestInstaller_confirm_No(t *testing.T) { + mockFS := NewMockFilesystem() + installer := NewInstallerWithFS(mockFS) + + tests := []struct { + name string + input string + want bool + }{ + {"lowercase n", "n\n", false}, + {"uppercase N", "N\n", false}, + {"full no", "no\n", false}, + {"empty line", "\n", false}, + {"random text", "maybe\n", false}, + {"just spaces", " \n", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdin := strings.NewReader(tt.input) + var stdout bytes.Buffer + installer.SetIO(stdin, &stdout, nil) + + got := installer.confirm("Proceed?") + if got != tt.want { + t.Errorf("confirm() = %v, want %v for input %q", got, tt.want, tt.input) + } + }) + } +} + +// TestInstaller_confirm_ReadError tests confirmation with read error. +func TestInstaller_confirm_ReadError(t *testing.T) { + mockFS := NewMockFilesystem() + installer := NewInstallerWithFS(mockFS) + + // Use a reader that returns an error + stdin := &errorReader{err: os.ErrClosed} + var stdout bytes.Buffer + installer.SetIO(stdin, &stdout, nil) + + got := installer.confirm("Proceed?") + if got != false { + t.Errorf("confirm() with read error = %v, want false", got) + } +} + +// TestInstaller_Run_InstallPrompt_InteractiveDeclined tests interactive prompt installation when user declines. +func TestInstaller_Run_InstallPrompt_InteractiveDeclined(t *testing.T) { + mockFS := NewMockFilesystem() + mockFS.EnvVars["SHELL"] = "/bin/bash" + mockFS.HomeDir = "/home/testuser" + + installer := NewInstallerWithFS(mockFS) + + stdin := strings.NewReader("n\n") + var stdout bytes.Buffer + installer.SetIO(stdin, &stdout, nil) + + opts := InstallOptions{ + InstallPrompt: true, + NonInteractive: false, + } + + exitCode := installer.Run(opts) + + if exitCode != ExitError { + t.Errorf("Run() = %v, want %v (user declined)", exitCode, ExitError) + } + + stdoutOutput := stdout.String() + if !strings.Contains(stdoutOutput, "Installation cancelled.") { + t.Errorf("stdout = %q, want cancellation message", stdoutOutput) + } + + // Verify file was NOT written + configPath := "/home/testuser/.bashrc" + if mockFS.Exists(configPath) { + t.Errorf("config file should not exist when user declines") + } +} + +// TestInstaller_Run_InstallPrompt_InteractiveAccepted tests interactive prompt installation when user accepts. +func TestInstaller_Run_InstallPrompt_InteractiveAccepted(t *testing.T) { + mockFS := NewMockFilesystem() + mockFS.EnvVars["SHELL"] = "/bin/bash" + mockFS.HomeDir = "/home/testuser" + + installer := NewInstallerWithFS(mockFS) + + stdin := strings.NewReader("y\n") + var stdout bytes.Buffer + installer.SetIO(stdin, &stdout, nil) + + opts := InstallOptions{ + InstallPrompt: true, + NonInteractive: false, + } + + exitCode := installer.Run(opts) + + if exitCode != ExitSuccess { + t.Errorf("Run() = %v, want %v", exitCode, ExitSuccess) + } + + stdoutOutput := stdout.String() + if !strings.Contains(stdoutOutput, "Dashlights Shell Installation") { + t.Errorf("stdout = %q, want installation header", stdoutOutput) + } + if !strings.Contains(stdoutOutput, "Proceed? [y/N]:") { + t.Errorf("stdout = %q, want confirmation prompt", stdoutOutput) + } + if !strings.Contains(stdoutOutput, "Installed dashlights into") { + t.Errorf("stdout = %q, want success message", stdoutOutput) + } + + // Verify file was written + configPath := "/home/testuser/.bashrc" + if !mockFS.Exists(configPath) { + t.Errorf("config file not created at %s", configPath) + } +} + +// TestInstaller_Run_InstallAgent_InteractiveDeclined tests interactive agent installation when user declines. +func TestInstaller_Run_InstallAgent_InteractiveDeclined(t *testing.T) { + mockFS := NewMockFilesystem() + mockFS.HomeDir = "/home/testuser" + + installer := NewInstallerWithFS(mockFS) + + stdin := strings.NewReader("n\n") + var stdout bytes.Buffer + installer.SetIO(stdin, &stdout, nil) + + opts := InstallOptions{ + InstallAgent: "claude", + NonInteractive: false, + } + + exitCode := installer.Run(opts) + + if exitCode != ExitError { + t.Errorf("Run() = %v, want %v (user declined)", exitCode, ExitError) + } + + stdoutOutput := stdout.String() + if !strings.Contains(stdoutOutput, "Installation cancelled.") { + t.Errorf("stdout = %q, want cancellation message", stdoutOutput) + } + + // Verify file was NOT written + configPath := "/home/testuser/.claude/settings.json" + if mockFS.Exists(configPath) { + t.Errorf("config file should not exist when user declines") + } +} + +// TestInstaller_Run_InstallAgent_InteractiveAccepted tests interactive agent installation when user accepts. +func TestInstaller_Run_InstallAgent_InteractiveAccepted(t *testing.T) { + mockFS := NewMockFilesystem() + mockFS.HomeDir = "/home/testuser" + + installer := NewInstallerWithFS(mockFS) + + stdin := strings.NewReader("y\n") + var stdout bytes.Buffer + installer.SetIO(stdin, &stdout, nil) + + opts := InstallOptions{ + InstallAgent: "claude", + NonInteractive: false, + } + + exitCode := installer.Run(opts) + + if exitCode != ExitSuccess { + t.Errorf("Run() = %v, want %v", exitCode, ExitSuccess) + } + + stdoutOutput := stdout.String() + if !strings.Contains(stdoutOutput, "Dashlights Claude Code Installation") { + t.Errorf("stdout = %q, want installation header", stdoutOutput) + } + if !strings.Contains(stdoutOutput, "Proceed? [y/N]:") { + t.Errorf("stdout = %q, want confirmation prompt", stdoutOutput) + } + if !strings.Contains(stdoutOutput, "Installed dashlights into") { + t.Errorf("stdout = %q, want success message", stdoutOutput) + } + + // Verify file was written + configPath := "/home/testuser/.claude/settings.json" + if !mockFS.Exists(configPath) { + t.Errorf("config file not created at %s", configPath) + } +} + +// TestInstaller_confirmPromptInstall tests the prompt installation confirmation UI. +func TestInstaller_confirmPromptInstall(t *testing.T) { + mockFS := NewMockFilesystem() + installer := NewInstallerWithFS(mockFS) + + config := &ShellConfig{ + Shell: ShellBash, + Template: TemplateBash, + ConfigPath: "/home/user/.bashrc", + Name: "Bash", + } + + stdin := strings.NewReader("y\n") + var stdout bytes.Buffer + installer.SetIO(stdin, &stdout, nil) + + result := installer.confirmPromptInstall(config) + + if !result { + t.Errorf("confirmPromptInstall() = false, want true") + } + + stdoutOutput := stdout.String() + if !strings.Contains(stdoutOutput, "Dashlights Shell Installation") { + t.Errorf("stdout missing header") + } + if !strings.Contains(stdoutOutput, "Detected shell: bash") { + t.Errorf("stdout missing shell detection") + } + if !strings.Contains(stdoutOutput, "Using template: Bash") { + t.Errorf("stdout missing template info") + } + if !strings.Contains(stdoutOutput, "Config file: /home/user/.bashrc") { + t.Errorf("stdout missing config path") + } + if !strings.Contains(stdoutOutput, "Backup: /home/user/.bashrc.dashlights-backup") { + t.Errorf("stdout missing backup info") + } + if !strings.Contains(stdoutOutput, "Add dashlights prompt function") { + t.Errorf("stdout missing changes description") + } +} + +// TestInstaller_confirmAgentInstall tests the agent installation confirmation UI. +func TestInstaller_confirmAgentInstall(t *testing.T) { + mockFS := NewMockFilesystem() + installer := NewInstallerWithFS(mockFS) + + // Test with existing config (should show backup) + config := &AgentConfig{ + Type: AgentClaude, + ConfigPath: "/home/user/.claude/settings.json", + Name: "Claude Code", + } + mockFS.Files[config.ConfigPath] = []byte("{}") + + stdin := strings.NewReader("y\n") + var stdout bytes.Buffer + installer.SetIO(stdin, &stdout, nil) + + result := installer.confirmAgentInstall(config) + + if !result { + t.Errorf("confirmAgentInstall() = false, want true") + } + + stdoutOutput := stdout.String() + if !strings.Contains(stdoutOutput, "Dashlights Claude Code Installation") { + t.Errorf("stdout missing header") + } + if !strings.Contains(stdoutOutput, "Config file: /home/user/.claude/settings.json") { + t.Errorf("stdout missing config path") + } + if !strings.Contains(stdoutOutput, "Backup:") { + t.Errorf("stdout missing backup info for existing file") + } + if !strings.Contains(stdoutOutput, "Add dashlights hook to Claude Code") { + t.Errorf("stdout missing changes description") + } +} + +// TestInstaller_confirmAgentInstall_NewFile tests agent confirmation for new file (no backup). +func TestInstaller_confirmAgentInstall_NewFile(t *testing.T) { + mockFS := NewMockFilesystem() + installer := NewInstallerWithFS(mockFS) + + config := &AgentConfig{ + Type: AgentCursor, + ConfigPath: "/home/user/.cursor/hooks.json", + Name: "Cursor", + } + // Don't create the file, so it doesn't exist + + stdin := strings.NewReader("y\n") + var stdout bytes.Buffer + installer.SetIO(stdin, &stdout, nil) + + result := installer.confirmAgentInstall(config) + + if !result { + t.Errorf("confirmAgentInstall() = false, want true") + } + + stdoutOutput := stdout.String() + if strings.Contains(stdoutOutput, "Backup:") { + t.Errorf("stdout should not mention backup for new file") + } +} + +// TestInstaller_SetIO tests the SetIO method. +func TestInstaller_SetIO(t *testing.T) { + mockFS := NewMockFilesystem() + installer := NewInstallerWithFS(mockFS) + + stdin := strings.NewReader("test\n") + var stdout, stderr bytes.Buffer + + installer.SetIO(stdin, &stdout, &stderr) + + // Verify IO streams were set by using them + if installer.stdin != stdin { + t.Errorf("stdin not set correctly") + } + if installer.stdout != &stdout { + t.Errorf("stdout not set correctly") + } + if installer.stderr != &stderr { + t.Errorf("stderr not set correctly") + } +} + +// TestNewInstaller tests that NewInstaller creates a valid installer. +func TestNewInstaller(t *testing.T) { + installer := NewInstaller() + + if installer == nil { + t.Fatal("NewInstaller() returned nil") + } + if installer.fs == nil { + t.Error("installer.fs is nil") + } + if installer.shellInstall == nil { + t.Error("installer.shellInstall is nil") + } + if installer.agentInstall == nil { + t.Error("installer.agentInstall is nil") + } + if installer.stdin != os.Stdin { + t.Error("installer.stdin not set to os.Stdin") + } + if installer.stdout != os.Stdout { + t.Error("installer.stdout not set to os.Stdout") + } + if installer.stderr != os.Stderr { + t.Error("installer.stderr not set to os.Stderr") + } +} + +// TestNewInstallerWithFS tests that NewInstallerWithFS creates a valid installer. +func TestNewInstallerWithFS(t *testing.T) { + mockFS := NewMockFilesystem() + installer := NewInstallerWithFS(mockFS) + + if installer == nil { + t.Fatal("NewInstallerWithFS() returned nil") + } + if installer.fs != mockFS { + t.Error("installer.fs not set to provided filesystem") + } + if installer.shellInstall == nil { + t.Error("installer.shellInstall is nil") + } + if installer.agentInstall == nil { + t.Error("installer.agentInstall is nil") + } +} + +// errorReader is a helper type that always returns an error when reading. +type errorReader struct { + err error +} + +func (r *errorReader) Read(p []byte) (n int, err error) { + return 0, r.err +} + +// unbufferedReader wraps a string and provides one byte at a time to avoid buffering issues. +type unbufferedReader struct { + data []byte + pos int +} + +func newUnbufferedReader(s string) *unbufferedReader { + return &unbufferedReader{data: []byte(s)} +} + +func (r *unbufferedReader) Read(p []byte) (n int, err error) { + if r.pos >= len(r.data) { + return 0, io.EOF + } + // Only read one byte at a time to prevent bufio from buffering ahead + n = 1 + if len(p) > 0 { + p[0] = r.data[r.pos] + r.pos++ + } + return n, nil +} diff --git a/src/install/shell.go b/src/install/shell.go new file mode 100644 index 0000000..0c71c08 --- /dev/null +++ b/src/install/shell.go @@ -0,0 +1,343 @@ +package install + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" +) + +// ShellType represents the type of shell. +type ShellType string + +const ( + ShellBash ShellType = "bash" + ShellZsh ShellType = "zsh" + ShellFish ShellType = "fish" +) + +// TemplateType determines which prompt template to use. +type TemplateType string + +const ( + TemplateBash TemplateType = "bash" + TemplateZsh TemplateType = "zsh" + TemplateFish TemplateType = "fish" + TemplateP10k TemplateType = "p10k" +) + +// ShellConfig contains shell detection and configuration information. +type ShellConfig struct { + Shell ShellType // The actual shell (bash, zsh, fish) + Template TemplateType // Which template to use (may differ, e.g., p10k for zsh) + ConfigPath string // Full path, e.g., /home/user/.bashrc + Name string // Human-readable, e.g., "Zsh (Powerlevel10k)" +} + +// InstallState represents the current installation state. +type InstallState int + +const ( + NotInstalled InstallState = iota // Neither marker present + FullyInstalled // Both markers present + PartialInstall // Only one marker present (corrupted) +) + +// ShellInstaller handles shell prompt installation. +type ShellInstaller struct { + fs Filesystem + backup *BackupManager +} + +// NewShellInstaller creates a new ShellInstaller. +func NewShellInstaller(fs Filesystem) *ShellInstaller { + return &ShellInstaller{ + fs: fs, + backup: NewBackupManager(fs), + } +} + +// DetectShell detects the shell type from the $SHELL environment variable. +func (i *ShellInstaller) DetectShell() (ShellType, error) { + shellPath := i.fs.Getenv("SHELL") + if shellPath == "" { + return "", fmt.Errorf("could not detect shell from $SHELL environment variable") + } + + base := filepath.Base(shellPath) + switch base { + case "bash": + return ShellBash, nil + case "zsh": + return ShellZsh, nil + case "fish": + return ShellFish, nil + default: + return "", fmt.Errorf("unsupported shell: %s", base) + } +} + +// GetShellConfig returns the shell configuration including paths and templates. +func (i *ShellInstaller) GetShellConfig(configPathOverride string) (*ShellConfig, error) { + shell, err := i.DetectShell() + if err != nil { + return nil, err + } + + homeDir, err := i.fs.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("could not determine home directory: %w", err) + } + + config := &ShellConfig{Shell: shell} + + // Determine default config path and template + switch shell { + case ShellBash: + config.ConfigPath = filepath.Join(homeDir, ".bashrc") + config.Template = TemplateBash + config.Name = "Bash" + case ShellZsh: + // Check for Powerlevel10k + p10kPath := filepath.Join(homeDir, ".p10k.zsh") + if i.fs.Exists(p10kPath) { + config.ConfigPath = p10kPath + config.Template = TemplateP10k + config.Name = "Zsh (Powerlevel10k)" + } else { + config.ConfigPath = filepath.Join(homeDir, ".zshrc") + config.Template = TemplateZsh + config.Name = "Zsh" + } + case ShellFish: + config.ConfigPath = filepath.Join(homeDir, ".config", "fish", "config.fish") + config.Template = TemplateFish + config.Name = "Fish" + } + + // Override path if specified + if configPathOverride != "" { + config.ConfigPath = configPathOverride + + // Infer template from path + if inferredTemplate, ok := InferTemplateFromPath(configPathOverride); ok { + config.Template = inferredTemplate + // Update name based on inferred template + switch inferredTemplate { + case TemplateP10k: + config.Name = "Zsh (Powerlevel10k)" + case TemplateBash: + config.Name = "Bash" + case TemplateZsh: + config.Name = "Zsh" + case TemplateFish: + config.Name = "Fish" + } + } + } + + return config, nil +} + +// InferTemplateFromPath determines the template type from a config file path. +func InferTemplateFromPath(configPath string) (TemplateType, bool) { + path := strings.ToLower(configPath) + base := filepath.Base(path) + + switch { + case strings.Contains(path, "p10k") || strings.HasSuffix(path, ".p10k.zsh"): + return TemplateP10k, true + case strings.Contains(base, "bashrc") || strings.Contains(path, "bash"): + return TemplateBash, true + case strings.Contains(base, "zshrc") || strings.Contains(path, "zsh"): + return TemplateZsh, true + case base == "config.fish" || strings.Contains(path, "fish"): + return TemplateFish, true + default: + return "", false + } +} + +// CheckInstallState checks if dashlights is already installed in the config file. +func (i *ShellInstaller) CheckInstallState(config *ShellConfig) (InstallState, error) { + content, err := i.fs.ReadFile(config.ConfigPath) + if err != nil { + if strings.Contains(err.Error(), "no such file") || strings.Contains(err.Error(), "not exist") { + return NotInstalled, nil + } + return NotInstalled, err + } + + hasBegin := strings.Contains(string(content), SentinelBegin) + hasEnd := strings.Contains(string(content), SentinelEnd) + + switch { + case hasBegin && hasEnd: + return FullyInstalled, nil + case hasBegin || hasEnd: + return PartialInstall, nil + default: + return NotInstalled, nil + } +} + +// Install installs dashlights into the shell config file. +func (i *ShellInstaller) Install(config *ShellConfig, dryRun bool) (*InstallResult, error) { + // Check current state + state, err := i.CheckInstallState(config) + if err != nil { + return nil, err + } + + switch state { + case FullyInstalled: + return &InstallResult{ + ExitCode: ExitSuccess, + Message: fmt.Sprintf("Dashlights is already installed in %s\nNo changes needed.", config.ConfigPath), + ConfigPath: config.ConfigPath, + }, nil + case PartialInstall: + return &InstallResult{ + ExitCode: ExitError, + Message: fmt.Sprintf("Error: Corrupted dashlights installation detected in %s\n"+ + "Found \"%s\" without matching \"%s\" (or vice versa).\n"+ + "Please manually remove any partial dashlights blocks and retry.", + config.ConfigPath, SentinelBegin, SentinelEnd), + }, nil + } + + // Get the template + template := GetShellTemplate(config.Template) + if template == "" { + return nil, fmt.Errorf("no template for type: %s", config.Template) + } + + // Read existing content or start fresh + existingContent, err := i.fs.ReadFile(config.ConfigPath) + if err != nil { + // File doesn't exist, we'll create it + existingContent = []byte{} + } + + // Build new content + var newContent []byte + if len(existingContent) > 0 { + // Append template with newline separator + if !strings.HasSuffix(string(existingContent), "\n") { + existingContent = append(existingContent, '\n') + } + newContent = append(existingContent, '\n') + newContent = append(newContent, []byte(template)...) + } else { + newContent = []byte(template) + } + + // For P10k, also try to modify the prompt elements array + var p10kNote string + if config.Template == TemplateP10k { + modifiedContent, modified, alreadyPresent, modErr := i.modifyP10kPromptElements(newContent) + if modErr == nil { + if alreadyPresent { + // dashlights already in prompt elements, use unmodified content + } else if modified { + newContent = modifiedContent + } else { + // Could not find prompt elements array + p10kNote = "\nNote: Could not locate POWERLEVEL9K_LEFT_PROMPT_ELEMENTS in " + config.ConfigPath + "\n" + + "Please add 'dashlights' to your prompt elements array manually." + } + } + } + + if dryRun { + return i.generateDryRunResult(config, template, existingContent, p10kNote), nil + } + + // Create backup + backupResult, err := i.backup.CreateBackup(config.ConfigPath) + if err != nil { + return nil, fmt.Errorf("failed to create backup: %w", err) + } + + // Ensure parent directory exists + parentDir := filepath.Dir(config.ConfigPath) + if err := i.fs.MkdirAll(parentDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create directory: %w", err) + } + + // Write the new content + if err := i.fs.WriteFile(config.ConfigPath, newContent, 0644); err != nil { + return nil, fmt.Errorf("failed to write config: %w", err) + } + + result := &InstallResult{ + ExitCode: ExitSuccess, + ConfigPath: config.ConfigPath, + WhatChanged: "Added dashlights prompt function", + } + + if backupResult.Created { + result.BackupPath = backupResult.BackupPath + result.Message = fmt.Sprintf("Installed dashlights into %s\nBackup: %s%s", + config.ConfigPath, backupResult.BackupPath, p10kNote) + } else { + result.Message = fmt.Sprintf("Installed dashlights into %s (new file created)%s", + config.ConfigPath, p10kNote) + } + + return result, nil +} + +// generateDryRunResult generates a dry-run preview result. +func (i *ShellInstaller) generateDryRunResult(config *ShellConfig, template string, existingContent []byte, p10kNote string) *InstallResult { + var preview strings.Builder + preview.WriteString("[DRY-RUN] Would make the following changes:\n\n") + + if len(existingContent) > 0 { + preview.WriteString(fmt.Sprintf("Backup: %s -> %s.dashlights-backup\n\n", + config.ConfigPath, config.ConfigPath)) + } + + preview.WriteString(fmt.Sprintf("Append to %s:\n", config.ConfigPath)) + preview.WriteString(strings.Repeat("-", 48) + "\n") + for _, line := range strings.Split(strings.TrimSuffix(template, "\n"), "\n") { + preview.WriteString("| " + line + "\n") + } + preview.WriteString(strings.Repeat("-", 48) + "\n") + preview.WriteString("\nNo changes made.") + preview.WriteString(p10kNote) + + return &InstallResult{ + ExitCode: ExitSuccess, + Message: preview.String(), + ConfigPath: config.ConfigPath, + } +} + +// modifyP10kPromptElements adds 'dashlights' to the P10k prompt elements array. +func (i *ShellInstaller) modifyP10kPromptElements(content []byte) ([]byte, bool, bool, error) { + // Check if dashlights is already in prompt elements (idempotency) + elementsPattern := regexp.MustCompile(`POWERLEVEL9K_(LEFT|RIGHT)_PROMPT_ELEMENTS=\([^)]*\bdashlights\b`) + if elementsPattern.Match(content) { + return content, false, true, nil // Already present, no modification needed + } + + // Regex to match: POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=( ... ) + leftPattern := regexp.MustCompile(`(?m)(POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=\()(\s*)`) + + if leftPattern.Match(content) { + // Prepend 'dashlights' after the opening parenthesis + modified := leftPattern.ReplaceAll(content, []byte("${1}${2}dashlights\n ")) + return modified, true, false, nil + } + + // Fall back to RIGHT_PROMPT_ELEMENTS + rightPattern := regexp.MustCompile(`(?m)(POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS=\()(\s*)`) + if rightPattern.Match(content) { + modified := rightPattern.ReplaceAll(content, []byte("${1}${2}dashlights\n ")) + return modified, true, false, nil + } + + // Could not find prompt elements array + return content, false, false, nil +} diff --git a/src/install/shell_test.go b/src/install/shell_test.go new file mode 100644 index 0000000..63bb598 --- /dev/null +++ b/src/install/shell_test.go @@ -0,0 +1,1289 @@ +package install + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestShellInstaller_DetectShell_Bash tests bash detection from various paths. +func TestShellInstaller_DetectShell_Bash(t *testing.T) { + tests := []struct { + name string + shellPath string + wantShell ShellType + wantErr bool + }{ + { + name: "bash from /bin", + shellPath: "/bin/bash", + wantShell: ShellBash, + wantErr: false, + }, + { + name: "bash from /usr/bin", + shellPath: "/usr/bin/bash", + wantShell: ShellBash, + wantErr: false, + }, + { + name: "bash from /usr/local/bin", + shellPath: "/usr/local/bin/bash", + wantShell: ShellBash, + wantErr: false, + }, + { + name: "bash from homebrew path", + shellPath: "/opt/homebrew/bin/bash", + wantShell: ShellBash, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = tt.shellPath + installer := NewShellInstaller(fs) + + got, err := installer.DetectShell() + if (err != nil) != tt.wantErr { + t.Errorf("DetectShell() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.wantShell { + t.Errorf("DetectShell() = %v, want %v", got, tt.wantShell) + } + }) + } +} + +// TestShellInstaller_DetectShell_Zsh tests zsh detection. +func TestShellInstaller_DetectShell_Zsh(t *testing.T) { + tests := []struct { + name string + shellPath string + wantShell ShellType + wantErr bool + }{ + { + name: "zsh from /bin", + shellPath: "/bin/zsh", + wantShell: ShellZsh, + wantErr: false, + }, + { + name: "zsh from /usr/bin", + shellPath: "/usr/bin/zsh", + wantShell: ShellZsh, + wantErr: false, + }, + { + name: "zsh from /usr/local/bin", + shellPath: "/usr/local/bin/zsh", + wantShell: ShellZsh, + wantErr: false, + }, + { + name: "zsh from homebrew", + shellPath: "/opt/homebrew/bin/zsh", + wantShell: ShellZsh, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = tt.shellPath + installer := NewShellInstaller(fs) + + got, err := installer.DetectShell() + if (err != nil) != tt.wantErr { + t.Errorf("DetectShell() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.wantShell { + t.Errorf("DetectShell() = %v, want %v", got, tt.wantShell) + } + }) + } +} + +// TestShellInstaller_DetectShell_Fish tests fish detection. +func TestShellInstaller_DetectShell_Fish(t *testing.T) { + tests := []struct { + name string + shellPath string + wantShell ShellType + wantErr bool + }{ + { + name: "fish from /usr/bin", + shellPath: "/usr/bin/fish", + wantShell: ShellFish, + wantErr: false, + }, + { + name: "fish from /usr/local/bin", + shellPath: "/usr/local/bin/fish", + wantShell: ShellFish, + wantErr: false, + }, + { + name: "fish from homebrew", + shellPath: "/opt/homebrew/bin/fish", + wantShell: ShellFish, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = tt.shellPath + installer := NewShellInstaller(fs) + + got, err := installer.DetectShell() + if (err != nil) != tt.wantErr { + t.Errorf("DetectShell() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.wantShell { + t.Errorf("DetectShell() = %v, want %v", got, tt.wantShell) + } + }) + } +} + +// TestShellInstaller_DetectShell_Unsupported tests error for unsupported shells. +func TestShellInstaller_DetectShell_Unsupported(t *testing.T) { + tests := []struct { + name string + shellPath string + }{ + { + name: "tcsh", + shellPath: "/bin/tcsh", + }, + { + name: "csh", + shellPath: "/bin/csh", + }, + { + name: "ksh", + shellPath: "/bin/ksh", + }, + { + name: "sh", + shellPath: "/bin/sh", + }, + { + name: "dash", + shellPath: "/bin/dash", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = tt.shellPath + installer := NewShellInstaller(fs) + + _, err := installer.DetectShell() + if err == nil { + t.Error("DetectShell() expected error for unsupported shell, got nil") + } + if !strings.Contains(err.Error(), "unsupported shell") { + t.Errorf("DetectShell() error = %v, want 'unsupported shell' error", err) + } + }) + } +} + +// TestShellInstaller_DetectShell_EmptyEnv tests error when $SHELL is empty. +func TestShellInstaller_DetectShell_EmptyEnv(t *testing.T) { + fs := NewMockFilesystem() + // Don't set SHELL env var + installer := NewShellInstaller(fs) + + _, err := installer.DetectShell() + if err == nil { + t.Error("DetectShell() expected error for empty $SHELL, got nil") + } + if !strings.Contains(err.Error(), "could not detect shell") { + t.Errorf("DetectShell() error = %v, want 'could not detect shell' error", err) + } +} + +// TestInferTemplateFromPath tests template inference from various paths. +func TestInferTemplateFromPath(t *testing.T) { + tests := []struct { + name string + path string + wantTemplate TemplateType + wantOk bool + }{ + // P10k patterns + { + name: "p10k.zsh in home", + path: "/home/user/.p10k.zsh", + wantTemplate: TemplateP10k, + wantOk: true, + }, + { + name: "p10k.zsh uppercase", + path: "/home/user/.P10K.ZSH", + wantTemplate: TemplateP10k, + wantOk: true, + }, + { + name: "path contains p10k", + path: "/home/user/configs/p10k-theme.zsh", + wantTemplate: TemplateP10k, + wantOk: true, + }, + // Bash patterns + { + name: "bashrc in home", + path: "/home/user/.bashrc", + wantTemplate: TemplateBash, + wantOk: true, + }, + { + name: "bashrc uppercase", + path: "/home/user/.BASHRC", + wantTemplate: TemplateBash, + wantOk: true, + }, + { + name: "bash_profile", + path: "/home/user/.bash_profile", + wantTemplate: TemplateBash, + wantOk: true, + }, + { + name: "path contains bash", + path: "/home/user/.config/bash/rc", + wantTemplate: TemplateBash, + wantOk: true, + }, + // Zsh patterns + { + name: "zshrc in home", + path: "/home/user/.zshrc", + wantTemplate: TemplateZsh, + wantOk: true, + }, + { + name: "zshrc uppercase", + path: "/home/user/.ZSHRC", + wantTemplate: TemplateZsh, + wantOk: true, + }, + { + name: "path contains zsh not p10k", + path: "/home/user/.config/zsh/rc", + wantTemplate: TemplateZsh, + wantOk: true, + }, + // Fish patterns + { + name: "config.fish", + path: "/home/user/.config/fish/config.fish", + wantTemplate: TemplateFish, + wantOk: true, + }, + { + name: "config.fish uppercase", + path: "/home/user/.config/fish/CONFIG.FISH", + wantTemplate: TemplateFish, + wantOk: true, + }, + { + name: "path contains fish", + path: "/home/user/fish/myconfig", + wantTemplate: TemplateFish, + wantOk: true, + }, + // Unknown patterns + { + name: "unknown config", + path: "/home/user/.config/unknown", + wantTemplate: "", + wantOk: false, + }, + { + name: "empty path", + path: "", + wantTemplate: "", + wantOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotTemplate, gotOk := InferTemplateFromPath(tt.path) + if gotOk != tt.wantOk { + t.Errorf("InferTemplateFromPath() ok = %v, want %v", gotOk, tt.wantOk) + } + if gotTemplate != tt.wantTemplate { + t.Errorf("InferTemplateFromPath() template = %v, want %v", gotTemplate, tt.wantTemplate) + } + }) + } +} + +// TestShellInstaller_GetShellConfig_Bash tests bash configuration. +func TestShellInstaller_GetShellConfig_Bash(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/bin/bash" + fs.HomeDir = "/home/testuser" + installer := NewShellInstaller(fs) + + config, err := installer.GetShellConfig("") + if err != nil { + t.Fatalf("GetShellConfig() error = %v", err) + } + + if config.Shell != ShellBash { + t.Errorf("config.Shell = %v, want %v", config.Shell, ShellBash) + } + if config.Template != TemplateBash { + t.Errorf("config.Template = %v, want %v", config.Template, TemplateBash) + } + expectedPath := filepath.Join(fs.HomeDir, ".bashrc") + if config.ConfigPath != expectedPath { + t.Errorf("config.ConfigPath = %v, want %v", config.ConfigPath, expectedPath) + } + if config.Name != "Bash" { + t.Errorf("config.Name = %v, want %v", config.Name, "Bash") + } +} + +// TestShellInstaller_GetShellConfig_Zsh tests zsh configuration. +func TestShellInstaller_GetShellConfig_Zsh(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/bin/zsh" + fs.HomeDir = "/home/testuser" + installer := NewShellInstaller(fs) + + config, err := installer.GetShellConfig("") + if err != nil { + t.Fatalf("GetShellConfig() error = %v", err) + } + + if config.Shell != ShellZsh { + t.Errorf("config.Shell = %v, want %v", config.Shell, ShellZsh) + } + if config.Template != TemplateZsh { + t.Errorf("config.Template = %v, want %v", config.Template, TemplateZsh) + } + expectedPath := filepath.Join(fs.HomeDir, ".zshrc") + if config.ConfigPath != expectedPath { + t.Errorf("config.ConfigPath = %v, want %v", config.ConfigPath, expectedPath) + } + if config.Name != "Zsh" { + t.Errorf("config.Name = %v, want %v", config.Name, "Zsh") + } +} + +// TestShellInstaller_GetShellConfig_ZshWithP10k tests zsh with Powerlevel10k. +func TestShellInstaller_GetShellConfig_ZshWithP10k(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/bin/zsh" + fs.HomeDir = "/home/testuser" + // Create .p10k.zsh file + p10kPath := filepath.Join(fs.HomeDir, ".p10k.zsh") + fs.Files[p10kPath] = []byte("# P10k config") + + installer := NewShellInstaller(fs) + + config, err := installer.GetShellConfig("") + if err != nil { + t.Fatalf("GetShellConfig() error = %v", err) + } + + if config.Shell != ShellZsh { + t.Errorf("config.Shell = %v, want %v", config.Shell, ShellZsh) + } + if config.Template != TemplateP10k { + t.Errorf("config.Template = %v, want %v", config.Template, TemplateP10k) + } + if config.ConfigPath != p10kPath { + t.Errorf("config.ConfigPath = %v, want %v", config.ConfigPath, p10kPath) + } + if config.Name != "Zsh (Powerlevel10k)" { + t.Errorf("config.Name = %v, want %v", config.Name, "Zsh (Powerlevel10k)") + } +} + +// TestShellInstaller_GetShellConfig_Fish tests fish configuration. +func TestShellInstaller_GetShellConfig_Fish(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/usr/bin/fish" + fs.HomeDir = "/home/testuser" + installer := NewShellInstaller(fs) + + config, err := installer.GetShellConfig("") + if err != nil { + t.Fatalf("GetShellConfig() error = %v", err) + } + + if config.Shell != ShellFish { + t.Errorf("config.Shell = %v, want %v", config.Shell, ShellFish) + } + if config.Template != TemplateFish { + t.Errorf("config.Template = %v, want %v", config.Template, TemplateFish) + } + expectedPath := filepath.Join(fs.HomeDir, ".config", "fish", "config.fish") + if config.ConfigPath != expectedPath { + t.Errorf("config.ConfigPath = %v, want %v", config.ConfigPath, expectedPath) + } + if config.Name != "Fish" { + t.Errorf("config.Name = %v, want %v", config.Name, "Fish") + } +} + +// TestShellInstaller_GetShellConfig_WithOverride tests config path override. +func TestShellInstaller_GetShellConfig_WithOverride(t *testing.T) { + tests := []struct { + name string + shell string + overridePath string + wantTemplate TemplateType + wantName string + wantConfigPath string + }{ + { + name: "override to p10k", + shell: "/bin/zsh", + overridePath: "/custom/.p10k.zsh", + wantTemplate: TemplateP10k, + wantName: "Zsh (Powerlevel10k)", + wantConfigPath: "/custom/.p10k.zsh", + }, + { + name: "override to bashrc", + shell: "/bin/bash", + overridePath: "/custom/.bashrc", + wantTemplate: TemplateBash, + wantName: "Bash", + wantConfigPath: "/custom/.bashrc", + }, + { + name: "override to zshrc", + shell: "/bin/zsh", + overridePath: "/custom/.zshrc", + wantTemplate: TemplateZsh, + wantName: "Zsh", + wantConfigPath: "/custom/.zshrc", + }, + { + name: "override to fish config", + shell: "/usr/bin/fish", + overridePath: "/custom/config.fish", + wantTemplate: TemplateFish, + wantName: "Fish", + wantConfigPath: "/custom/config.fish", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = tt.shell + fs.HomeDir = "/home/testuser" + installer := NewShellInstaller(fs) + + config, err := installer.GetShellConfig(tt.overridePath) + if err != nil { + t.Fatalf("GetShellConfig() error = %v", err) + } + + if config.Template != tt.wantTemplate { + t.Errorf("config.Template = %v, want %v", config.Template, tt.wantTemplate) + } + if config.Name != tt.wantName { + t.Errorf("config.Name = %v, want %v", config.Name, tt.wantName) + } + if config.ConfigPath != tt.wantConfigPath { + t.Errorf("config.ConfigPath = %v, want %v", config.ConfigPath, tt.wantConfigPath) + } + }) + } +} + +// TestShellInstaller_CheckInstallState_NotInstalled tests not installed state. +func TestShellInstaller_CheckInstallState_NotInstalled(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/bin/bash" + fs.HomeDir = "/home/testuser" + + configPath := filepath.Join(fs.HomeDir, ".bashrc") + fs.Files[configPath] = []byte("# My bashrc\nexport PATH=$PATH:/usr/local/bin\n") + + installer := NewShellInstaller(fs) + config := &ShellConfig{ + Shell: ShellBash, + Template: TemplateBash, + ConfigPath: configPath, + Name: "Bash", + } + + state, err := installer.CheckInstallState(config) + if err != nil { + t.Fatalf("CheckInstallState() error = %v", err) + } + if state != NotInstalled { + t.Errorf("CheckInstallState() = %v, want %v", state, NotInstalled) + } +} + +// TestShellInstaller_CheckInstallState_FullyInstalled tests fully installed state. +func TestShellInstaller_CheckInstallState_FullyInstalled(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/bin/bash" + fs.HomeDir = "/home/testuser" + + configPath := filepath.Join(fs.HomeDir, ".bashrc") + fs.Files[configPath] = []byte(fmt.Sprintf("# My bashrc\n%s\nsome code\n%s\n", SentinelBegin, SentinelEnd)) + + installer := NewShellInstaller(fs) + config := &ShellConfig{ + Shell: ShellBash, + Template: TemplateBash, + ConfigPath: configPath, + Name: "Bash", + } + + state, err := installer.CheckInstallState(config) + if err != nil { + t.Fatalf("CheckInstallState() error = %v", err) + } + if state != FullyInstalled { + t.Errorf("CheckInstallState() = %v, want %v", state, FullyInstalled) + } +} + +// TestShellInstaller_CheckInstallState_PartialInstall tests partial install state. +func TestShellInstaller_CheckInstallState_PartialInstall(t *testing.T) { + tests := []struct { + name string + content string + }{ + { + name: "only begin marker", + content: fmt.Sprintf("# My bashrc\n%s\nsome code\n", SentinelBegin), + }, + { + name: "only end marker", + content: fmt.Sprintf("# My bashrc\nsome code\n%s\n", SentinelEnd), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/bin/bash" + fs.HomeDir = "/home/testuser" + + configPath := filepath.Join(fs.HomeDir, ".bashrc") + fs.Files[configPath] = []byte(tt.content) + + installer := NewShellInstaller(fs) + config := &ShellConfig{ + Shell: ShellBash, + Template: TemplateBash, + ConfigPath: configPath, + Name: "Bash", + } + + state, err := installer.CheckInstallState(config) + if err != nil { + t.Fatalf("CheckInstallState() error = %v", err) + } + if state != PartialInstall { + t.Errorf("CheckInstallState() = %v, want %v", state, PartialInstall) + } + }) + } +} + +// TestShellInstaller_CheckInstallState_FileNotExist tests file not exist state. +func TestShellInstaller_CheckInstallState_FileNotExist(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/bin/bash" + fs.HomeDir = "/home/testuser" + + configPath := filepath.Join(fs.HomeDir, ".bashrc") + // Don't create the file + + installer := NewShellInstaller(fs) + config := &ShellConfig{ + Shell: ShellBash, + Template: TemplateBash, + ConfigPath: configPath, + Name: "Bash", + } + + state, err := installer.CheckInstallState(config) + if err != nil { + t.Fatalf("CheckInstallState() error = %v", err) + } + if state != NotInstalled { + t.Errorf("CheckInstallState() = %v, want %v", state, NotInstalled) + } +} + +// TestShellInstaller_Install_Bash tests bash installation. +func TestShellInstaller_Install_Bash(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/bin/bash" + fs.HomeDir = "/home/testuser" + + configPath := filepath.Join(fs.HomeDir, ".bashrc") + existingContent := "# My bashrc\nexport PATH=$PATH:/usr/local/bin\n" + fs.Files[configPath] = []byte(existingContent) + + installer := NewShellInstaller(fs) + config := &ShellConfig{ + Shell: ShellBash, + Template: TemplateBash, + ConfigPath: configPath, + Name: "Bash", + } + + result, err := installer.Install(config, false) + if err != nil { + t.Fatalf("Install() error = %v", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %v, want %v", result.ExitCode, ExitSuccess) + } + if result.ConfigPath != configPath { + t.Errorf("result.ConfigPath = %v, want %v", result.ConfigPath, configPath) + } + + // Check that template was appended + newContent := string(fs.Files[configPath]) + if !strings.Contains(newContent, SentinelBegin) { + t.Error("new content should contain SentinelBegin") + } + if !strings.Contains(newContent, SentinelEnd) { + t.Error("new content should contain SentinelEnd") + } + if !strings.Contains(newContent, existingContent) { + t.Error("new content should contain existing content") + } + + // Check backup was created + backupPath := configPath + ".dashlights-backup" + if _, exists := fs.Files[backupPath]; !exists { + t.Error("backup file should be created") + } +} + +// TestShellInstaller_Install_Zsh tests zsh installation. +func TestShellInstaller_Install_Zsh(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/bin/zsh" + fs.HomeDir = "/home/testuser" + + configPath := filepath.Join(fs.HomeDir, ".zshrc") + existingContent := "# My zshrc\nexport PATH=$PATH:/usr/local/bin\n" + fs.Files[configPath] = []byte(existingContent) + + installer := NewShellInstaller(fs) + config := &ShellConfig{ + Shell: ShellZsh, + Template: TemplateZsh, + ConfigPath: configPath, + Name: "Zsh", + } + + result, err := installer.Install(config, false) + if err != nil { + t.Fatalf("Install() error = %v", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %v, want %v", result.ExitCode, ExitSuccess) + } + + // Check that zsh template was appended + newContent := string(fs.Files[configPath]) + if !strings.Contains(newContent, "setopt prompt_subst") { + t.Error("new content should contain zsh template") + } +} + +// TestShellInstaller_Install_Fish tests fish installation. +func TestShellInstaller_Install_Fish(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/usr/bin/fish" + fs.HomeDir = "/home/testuser" + + configPath := filepath.Join(fs.HomeDir, ".config", "fish", "config.fish") + existingContent := "# My fish config\nset PATH $PATH /usr/local/bin\n" + fs.Files[configPath] = []byte(existingContent) + + installer := NewShellInstaller(fs) + config := &ShellConfig{ + Shell: ShellFish, + Template: TemplateFish, + ConfigPath: configPath, + Name: "Fish", + } + + result, err := installer.Install(config, false) + if err != nil { + t.Fatalf("Install() error = %v", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %v, want %v", result.ExitCode, ExitSuccess) + } + + // Check that fish template was appended + newContent := string(fs.Files[configPath]) + if !strings.Contains(newContent, "function __dashlights_prompt --on-event fish_prompt") { + t.Error("new content should contain fish template") + } +} + +// TestShellInstaller_Install_P10k tests p10k installation. +func TestShellInstaller_Install_P10k(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/bin/zsh" + fs.HomeDir = "/home/testuser" + + configPath := filepath.Join(fs.HomeDir, ".p10k.zsh") + existingContent := "# P10k config\n" + fs.Files[configPath] = []byte(existingContent) + + installer := NewShellInstaller(fs) + config := &ShellConfig{ + Shell: ShellZsh, + Template: TemplateP10k, + ConfigPath: configPath, + Name: "Zsh (Powerlevel10k)", + } + + result, err := installer.Install(config, false) + if err != nil { + t.Fatalf("Install() error = %v", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %v, want %v", result.ExitCode, ExitSuccess) + } + + // Check that p10k template was appended + newContent := string(fs.Files[configPath]) + if !strings.Contains(newContent, "function prompt_dashlights()") { + t.Error("new content should contain p10k template") + } + if !strings.Contains(newContent, "p10k segment") { + t.Error("new content should contain p10k segment command") + } +} + +// TestShellInstaller_Install_P10k_AutoModifyLeft tests p10k auto-modification of LEFT_PROMPT_ELEMENTS. +func TestShellInstaller_Install_P10k_AutoModifyLeft(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/bin/zsh" + fs.HomeDir = "/home/testuser" + + configPath := filepath.Join(fs.HomeDir, ".p10k.zsh") + existingContent := `# P10k config +typeset -g POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=( + os_icon + dir + vcs +) +` + fs.Files[configPath] = []byte(existingContent) + + installer := NewShellInstaller(fs) + config := &ShellConfig{ + Shell: ShellZsh, + Template: TemplateP10k, + ConfigPath: configPath, + Name: "Zsh (Powerlevel10k)", + } + + result, err := installer.Install(config, false) + if err != nil { + t.Fatalf("Install() error = %v", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %v, want %v", result.ExitCode, ExitSuccess) + } + + // Check that dashlights was added to LEFT_PROMPT_ELEMENTS + newContent := string(fs.Files[configPath]) + if !strings.Contains(newContent, "dashlights") { + t.Error("new content should contain dashlights in prompt elements") + } + if !strings.Contains(newContent, "POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=(") { + t.Error("LEFT_PROMPT_ELEMENTS should still be present") + } +} + +// TestShellInstaller_Install_P10k_AutoModifyRight tests p10k auto-modification of RIGHT_PROMPT_ELEMENTS. +func TestShellInstaller_Install_P10k_AutoModifyRight(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/bin/zsh" + fs.HomeDir = "/home/testuser" + + configPath := filepath.Join(fs.HomeDir, ".p10k.zsh") + existingContent := `# P10k config +typeset -g POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS=( + status + background_jobs + time +) +` + fs.Files[configPath] = []byte(existingContent) + + installer := NewShellInstaller(fs) + config := &ShellConfig{ + Shell: ShellZsh, + Template: TemplateP10k, + ConfigPath: configPath, + Name: "Zsh (Powerlevel10k)", + } + + result, err := installer.Install(config, false) + if err != nil { + t.Fatalf("Install() error = %v", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %v, want %v", result.ExitCode, ExitSuccess) + } + + // Check that dashlights was added to RIGHT_PROMPT_ELEMENTS + newContent := string(fs.Files[configPath]) + if !strings.Contains(newContent, "dashlights") { + t.Error("new content should contain dashlights in prompt elements") + } + if !strings.Contains(newContent, "POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS=(") { + t.Error("RIGHT_PROMPT_ELEMENTS should still be present") + } +} + +// TestShellInstaller_Install_AlreadyInstalled tests already installed case. +func TestShellInstaller_Install_AlreadyInstalled(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/bin/bash" + fs.HomeDir = "/home/testuser" + + configPath := filepath.Join(fs.HomeDir, ".bashrc") + fs.Files[configPath] = []byte(BashTemplate) + + installer := NewShellInstaller(fs) + config := &ShellConfig{ + Shell: ShellBash, + Template: TemplateBash, + ConfigPath: configPath, + Name: "Bash", + } + + result, err := installer.Install(config, false) + if err != nil { + t.Fatalf("Install() error = %v", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %v, want %v", result.ExitCode, ExitSuccess) + } + if !strings.Contains(result.Message, "already installed") { + t.Errorf("result.Message should indicate already installed, got: %v", result.Message) + } + + // Content should remain unchanged + newContent := string(fs.Files[configPath]) + if newContent != BashTemplate { + t.Error("content should remain unchanged when already installed") + } +} + +// TestShellInstaller_Install_PartialInstall tests partial install error case. +func TestShellInstaller_Install_PartialInstall(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/bin/bash" + fs.HomeDir = "/home/testuser" + + configPath := filepath.Join(fs.HomeDir, ".bashrc") + // Only BEGIN marker, no END marker (corrupted state) + fs.Files[configPath] = []byte(fmt.Sprintf("# My bashrc\n%s\nsome code\n", SentinelBegin)) + + installer := NewShellInstaller(fs) + config := &ShellConfig{ + Shell: ShellBash, + Template: TemplateBash, + ConfigPath: configPath, + Name: "Bash", + } + + result, err := installer.Install(config, false) + if err != nil { + t.Fatalf("Install() error = %v", err) + } + + if result.ExitCode != ExitError { + t.Errorf("result.ExitCode = %v, want %v", result.ExitCode, ExitError) + } + if !strings.Contains(result.Message, "Corrupted") { + t.Errorf("result.Message should indicate corrupted installation, got: %v", result.Message) + } +} + +// TestShellInstaller_Install_DryRun tests dry-run mode. +func TestShellInstaller_Install_DryRun(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/bin/bash" + fs.HomeDir = "/home/testuser" + + configPath := filepath.Join(fs.HomeDir, ".bashrc") + existingContent := "# My bashrc\nexport PATH=$PATH:/usr/local/bin\n" + fs.Files[configPath] = []byte(existingContent) + + installer := NewShellInstaller(fs) + config := &ShellConfig{ + Shell: ShellBash, + Template: TemplateBash, + ConfigPath: configPath, + Name: "Bash", + } + + result, err := installer.Install(config, true) + if err != nil { + t.Fatalf("Install() error = %v", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %v, want %v", result.ExitCode, ExitSuccess) + } + if !strings.Contains(result.Message, "[DRY-RUN]") { + t.Errorf("result.Message should indicate dry-run, got: %v", result.Message) + } + if !strings.Contains(result.Message, "No changes made") { + t.Errorf("result.Message should say no changes made, got: %v", result.Message) + } + + // Content should remain unchanged + newContent := string(fs.Files[configPath]) + if newContent != existingContent { + t.Error("content should remain unchanged in dry-run mode") + } + + // No backup should be created + backupPath := configPath + ".dashlights-backup" + if _, exists := fs.Files[backupPath]; exists { + t.Error("backup should not be created in dry-run mode") + } +} + +// TestShellInstaller_Install_CreatesNewFile tests creating a new config file. +func TestShellInstaller_Install_CreatesNewFile(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/bin/bash" + fs.HomeDir = "/home/testuser" + + configPath := filepath.Join(fs.HomeDir, ".bashrc") + // Don't create the file initially + + installer := NewShellInstaller(fs) + config := &ShellConfig{ + Shell: ShellBash, + Template: TemplateBash, + ConfigPath: configPath, + Name: "Bash", + } + + result, err := installer.Install(config, false) + if err != nil { + t.Fatalf("Install() error = %v", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %v, want %v", result.ExitCode, ExitSuccess) + } + if !strings.Contains(result.Message, "new file created") { + t.Errorf("result.Message should indicate new file created, got: %v", result.Message) + } + + // Check that file was created with template + newContent := string(fs.Files[configPath]) + if !strings.Contains(newContent, SentinelBegin) { + t.Error("new file should contain SentinelBegin") + } + if !strings.Contains(newContent, SentinelEnd) { + t.Error("new file should contain SentinelEnd") + } + + // No backup should be created for new file + backupPath := configPath + ".dashlights-backup" + if _, exists := fs.Files[backupPath]; exists { + t.Error("backup should not be created for new file") + } +} + +// TestShellInstaller_Install_P10k_AlreadyInPromptElements tests idempotency when dashlights already in prompt elements. +func TestShellInstaller_Install_P10k_AlreadyInPromptElements(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/bin/zsh" + fs.HomeDir = "/home/testuser" + + configPath := filepath.Join(fs.HomeDir, ".p10k.zsh") + existingContent := `# P10k config +typeset -g POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=( + dashlights + os_icon + dir + vcs +) +` + BashTemplate // Already has the template too + + fs.Files[configPath] = []byte(existingContent) + + installer := NewShellInstaller(fs) + config := &ShellConfig{ + Shell: ShellZsh, + Template: TemplateP10k, + ConfigPath: configPath, + Name: "Zsh (Powerlevel10k)", + } + + result, err := installer.Install(config, false) + if err != nil { + t.Fatalf("Install() error = %v", err) + } + + if result.ExitCode != ExitSuccess { + t.Errorf("result.ExitCode = %v, want %v", result.ExitCode, ExitSuccess) + } + + // Should indicate already installed + if !strings.Contains(result.Message, "already installed") { + t.Errorf("result.Message should indicate already installed, got: %v", result.Message) + } +} + +// TestShellInstaller_modifyP10kPromptElements tests the P10k prompt elements modification. +func TestShellInstaller_modifyP10kPromptElements(t *testing.T) { + tests := []struct { + name string + content string + wantModified bool + wantAlreadyPresent bool + wantErr bool + checkContent func(t *testing.T, content string) + }{ + { + name: "add to LEFT_PROMPT_ELEMENTS", + content: `typeset -g POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=( + os_icon + dir +)`, + wantModified: true, + wantAlreadyPresent: false, + wantErr: false, + checkContent: func(t *testing.T, content string) { + if !strings.Contains(content, "dashlights") { + t.Error("content should contain dashlights") + } + // Should be added after the opening paren + if !strings.Contains(content, "POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=(") { + t.Error("LEFT_PROMPT_ELEMENTS should be preserved") + } + }, + }, + { + name: "add to RIGHT_PROMPT_ELEMENTS", + content: `typeset -g POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS=( + status + time +)`, + wantModified: true, + wantAlreadyPresent: false, + wantErr: false, + checkContent: func(t *testing.T, content string) { + if !strings.Contains(content, "dashlights") { + t.Error("content should contain dashlights") + } + }, + }, + { + name: "already present in LEFT_PROMPT_ELEMENTS", + content: `typeset -g POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=( + dashlights + os_icon + dir +)`, + wantModified: false, + wantAlreadyPresent: true, + wantErr: false, + checkContent: func(t *testing.T, content string) { + // Content should not be duplicated + count := strings.Count(content, "dashlights") + if count != 1 { + t.Errorf("dashlights should appear exactly once, found %d times", count) + } + }, + }, + { + name: "already present in RIGHT_PROMPT_ELEMENTS", + content: `typeset -g POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS=( + dashlights + status +)`, + wantModified: false, + wantAlreadyPresent: true, + wantErr: false, + checkContent: func(t *testing.T, content string) { + count := strings.Count(content, "dashlights") + if count != 1 { + t.Errorf("dashlights should appear exactly once, found %d times", count) + } + }, + }, + { + name: "no prompt elements found", + content: `# Some other config without prompt elements`, + wantModified: false, + wantAlreadyPresent: false, + wantErr: false, + checkContent: func(t *testing.T, content string) { + if strings.Contains(content, "dashlights") { + t.Error("content should not be modified when no prompt elements found") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := NewMockFilesystem() + installer := NewShellInstaller(fs) + + modifiedContent, modified, alreadyPresent, err := installer.modifyP10kPromptElements([]byte(tt.content)) + + if (err != nil) != tt.wantErr { + t.Errorf("modifyP10kPromptElements() error = %v, wantErr %v", err, tt.wantErr) + return + } + if modified != tt.wantModified { + t.Errorf("modifyP10kPromptElements() modified = %v, want %v", modified, tt.wantModified) + } + if alreadyPresent != tt.wantAlreadyPresent { + t.Errorf("modifyP10kPromptElements() alreadyPresent = %v, want %v", alreadyPresent, tt.wantAlreadyPresent) + } + + if tt.checkContent != nil { + tt.checkContent(t, string(modifiedContent)) + } + }) + } +} + +// TestShellInstaller_GetShellConfig_ErrorCases tests error handling in GetShellConfig. +func TestShellInstaller_GetShellConfig_ErrorCases(t *testing.T) { + tests := []struct { + name string + setupFS func(*MockFilesystem) + wantErr bool + errContains string + }{ + { + name: "shell detection error", + setupFS: func(fs *MockFilesystem) { + // Don't set SHELL env var + }, + wantErr: true, + errContains: "could not detect shell", + }, + { + name: "home directory error", + setupFS: func(fs *MockFilesystem) { + fs.EnvVars["SHELL"] = "/bin/bash" + fs.HomeDirErr = os.ErrPermission + }, + wantErr: true, + errContains: "could not determine home directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := NewMockFilesystem() + tt.setupFS(fs) + + installer := NewShellInstaller(fs) + _, err := installer.GetShellConfig("") + + if (err != nil) != tt.wantErr { + t.Errorf("GetShellConfig() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("GetShellConfig() error = %v, should contain %q", err, tt.errContains) + } + }) + } +} + +// TestShellInstaller_CheckInstallState_ErrorCases tests error handling in CheckInstallState. +func TestShellInstaller_CheckInstallState_ErrorCases(t *testing.T) { + fs := NewMockFilesystem() + fs.ReadFileErr = os.ErrPermission + + installer := NewShellInstaller(fs) + config := &ShellConfig{ + Shell: ShellBash, + Template: TemplateBash, + ConfigPath: "/home/testuser/.bashrc", + Name: "Bash", + } + + _, err := installer.CheckInstallState(config) + if err == nil { + t.Error("CheckInstallState() expected error for read permission denied") + } +} + +// TestShellInstaller_Install_MkdirError tests handling of directory creation errors. +func TestShellInstaller_Install_MkdirError(t *testing.T) { + fs := NewMockFilesystem() + fs.EnvVars["SHELL"] = "/usr/bin/fish" + fs.HomeDir = "/home/testuser" + + configPath := filepath.Join(fs.HomeDir, ".config", "fish", "config.fish") + // Don't create the file initially (new file scenario) + + // Set up mkdir error + fs.MkdirAllErr = os.ErrPermission + + installer := NewShellInstaller(fs) + config := &ShellConfig{ + Shell: ShellFish, + Template: TemplateFish, + ConfigPath: configPath, + Name: "Fish", + } + + _, err := installer.Install(config, false) + if err == nil { + t.Error("Install() expected error for mkdir permission denied") + } + if !strings.Contains(err.Error(), "failed to create directory") { + t.Errorf("Install() error = %v, should contain 'failed to create directory'", err) + } +} diff --git a/src/install/templates.go b/src/install/templates.go new file mode 100644 index 0000000..8b1ee3f --- /dev/null +++ b/src/install/templates.go @@ -0,0 +1,107 @@ +package install + +// Sentinel markers for idempotency detection. +const ( + SentinelBegin = "# BEGIN dashlights" + SentinelEnd = "# END dashlights" +) + +// Shell prompt templates wrapped with sentinel markers. +const ( + // BashTemplate is the prompt integration for bash. + BashTemplate = `# BEGIN dashlights +__dashlights_prompt() { + local dl_out + dl_out=$(dashlights 2>/dev/null) + if [ -n "$dl_out" ]; then + echo -n "$dl_out " + fi +} +PS1='$(__dashlights_prompt)'"$PS1" +# END dashlights +` + + // ZshTemplate is the prompt integration for zsh. + ZshTemplate = `# BEGIN dashlights +setopt prompt_subst +__dashlights_prompt() { + local dl_out + dl_out=$(dashlights 2>/dev/null) + if [[ -n "$dl_out" ]]; then + echo -n "$dl_out " + fi +} +PROMPT='$(__dashlights_prompt)'"$PROMPT" +# END dashlights +` + + // FishTemplate is the prompt integration for fish. + FishTemplate = `# BEGIN dashlights +function __dashlights_prompt --on-event fish_prompt + set -l dl_out (dashlights 2>/dev/null) + if test -n "$dl_out" + echo -n "$dl_out " + end +end +# END dashlights +` + + // P10kTemplate is the prompt segment function for Powerlevel10k. + P10kTemplate = `# BEGIN dashlights +function prompt_dashlights() { + local dl_out + dl_out=$(dashlights 2>/dev/null) + if [[ -n "$dl_out" ]]; then + p10k segment -f 208 -t "$dl_out" + fi +} +# END dashlights +` +) + +// GetShellTemplate returns the appropriate template for a template type. +func GetShellTemplate(templateType TemplateType) string { + switch templateType { + case TemplateBash: + return BashTemplate + case TemplateZsh: + return ZshTemplate + case TemplateFish: + return FishTemplate + case TemplateP10k: + return P10kTemplate + default: + return "" + } +} + +// Agent configuration templates. +const ( + // ClaudeHookJSON is the hook configuration to add to Claude's settings. + // This is not the full file, but the structure to merge in. + ClaudeHookJSON = `{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|Write|Edit", + "hooks": [ + { + "type": "command", + "command": "dashlights --agentic" + } + ] + } + ] + } +}` + + // CursorHookJSON is the hook configuration for Cursor. + CursorHookJSON = `{ + "beforeShellExecution": { + "command": "dashlights --agentic" + } +}` +) + +// DashlightsCommand is the command to run for agentic mode. +const DashlightsCommand = "dashlights --agentic" diff --git a/src/install/templates_test.go b/src/install/templates_test.go new file mode 100644 index 0000000..f0e1971 --- /dev/null +++ b/src/install/templates_test.go @@ -0,0 +1,109 @@ +package install + +import ( + "strings" + "testing" +) + +func TestGetShellTemplate(t *testing.T) { + tests := []struct { + name string + templateType TemplateType + wantContains string + wantEmpty bool + }{ + { + name: "bash template", + templateType: TemplateBash, + wantContains: "PS1=", + }, + { + name: "zsh template", + templateType: TemplateZsh, + wantContains: "PROMPT=", + }, + { + name: "fish template", + templateType: TemplateFish, + wantContains: "--on-event fish_prompt", + }, + { + name: "p10k template", + templateType: TemplateP10k, + wantContains: "p10k segment", + }, + { + name: "unknown template", + templateType: TemplateType("unknown"), + wantEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetShellTemplate(tt.templateType) + + if tt.wantEmpty { + if got != "" { + t.Errorf("GetShellTemplate(%q) = %q, want empty", tt.templateType, got) + } + return + } + + if !strings.Contains(got, tt.wantContains) { + t.Errorf("GetShellTemplate(%q) does not contain %q", tt.templateType, tt.wantContains) + } + + // All templates should have sentinel markers + if !strings.Contains(got, SentinelBegin) { + t.Errorf("GetShellTemplate(%q) missing SentinelBegin", tt.templateType) + } + if !strings.Contains(got, SentinelEnd) { + t.Errorf("GetShellTemplate(%q) missing SentinelEnd", tt.templateType) + } + }) + } +} + +func TestSentinelMarkers(t *testing.T) { + // Verify sentinel markers are correct + if SentinelBegin != "# BEGIN dashlights" { + t.Errorf("SentinelBegin = %q, want %q", SentinelBegin, "# BEGIN dashlights") + } + if SentinelEnd != "# END dashlights" { + t.Errorf("SentinelEnd = %q, want %q", SentinelEnd, "# END dashlights") + } +} + +func TestTemplateConstants(t *testing.T) { + // Verify all templates contain the dashlights command + templates := []struct { + name string + template string + }{ + {"BashTemplate", BashTemplate}, + {"ZshTemplate", ZshTemplate}, + {"FishTemplate", FishTemplate}, + {"P10kTemplate", P10kTemplate}, + } + + for _, tt := range templates { + t.Run(tt.name, func(t *testing.T) { + if !strings.Contains(tt.template, "dashlights") { + t.Errorf("%s does not contain 'dashlights'", tt.name) + } + if !strings.Contains(tt.template, SentinelBegin) { + t.Errorf("%s does not contain SentinelBegin", tt.name) + } + if !strings.Contains(tt.template, SentinelEnd) { + t.Errorf("%s does not contain SentinelEnd", tt.name) + } + }) + } +} + +func TestDashlightsCommand(t *testing.T) { + if DashlightsCommand != "dashlights --agentic" { + t.Errorf("DashlightsCommand = %q, want %q", DashlightsCommand, "dashlights --agentic") + } +} diff --git a/src/main.go b/src/main.go index 2f6c5d3..601b34d 100644 --- a/src/main.go +++ b/src/main.go @@ -16,6 +16,7 @@ import ( arg "github.com/alexflint/go-arg" "github.com/erichs/dashlights/src/agentic" + "github.com/erichs/dashlights/src/install" "github.com/erichs/dashlights/src/signals" "github.com/fatih/color" ) @@ -41,12 +42,17 @@ type debugResult struct { } type cliArgs struct { - DetailsMode bool `arg:"-d,--details,help:Show detailed diagnostic information for detected issues."` - VerboseMode bool `arg:"-v,--verbose,help:Verbose mode: show documentation links in diagnostic output."` - ListCustomMode bool `arg:"-l,--list-custom,help:List supported color attributes and emoji aliases for custom lights."` - ClearCustomMode bool `arg:"-c,--clear-custom,help:Shell code to clear custom DASHLIGHT_ environment variables."` - DebugMode bool `arg:"--debug,help:Debug mode: disable timeouts and show detailed execution timing."` - AgenticMode bool `arg:"--agentic,help:Agentic mode for AI coding assistants (reads JSON from stdin)."` + DetailsMode bool `arg:"-d,--details,help:Show detailed diagnostic information for detected issues."` + VerboseMode bool `arg:"-v,--verbose,help:Verbose mode: show documentation links in diagnostic output."` + ListCustomMode bool `arg:"-l,--list-custom,help:List supported color attributes and emoji aliases for custom lights."` + ClearCustomMode bool `arg:"-c,--clear-custom,help:Shell code to clear custom DASHLIGHT_ environment variables."` + DebugMode bool `arg:"--debug,help:Debug mode: disable timeouts and show detailed execution timing."` + AgenticMode bool `arg:"--agentic,help:Agentic mode for AI coding assistants (reads JSON from stdin)."` + InstallPrompt bool `arg:"--installprompt,help:Install dashlights into shell prompt."` + InstallAgent string `arg:"--installagent,help:Install dashlights hook into AI agent config (claude|cursor)."` + ConfigPath string `arg:"--configpath,help:Override config file path (only for --installprompt)."` + Yes bool `arg:"-y,--yes,help:Non-interactive mode; auto-confirm all prompts."` + DryRun bool `arg:"--dry-run,help:Preview changes without applying them."` } // Version returns the version string for --version flag @@ -81,6 +87,11 @@ func main() { } } + // Install mode: handle --installprompt or --installagent + if args.InstallPrompt || args.InstallAgent != "" { + os.Exit(int(runInstallMode())) + } + // Agentic mode: completely different execution path for AI coding assistant hooks if args.AgenticMode { os.Exit(runAgenticMode()) @@ -699,3 +710,18 @@ func displayDebugInfo(w io.Writer, envStart, envEnd, sigStart, sigEnd time.Time, flexPrintln(w, "\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") } + +// runInstallMode handles the --installprompt and --installagent flags. +func runInstallMode() install.ExitCode { + installer := install.NewInstaller() + + opts := install.InstallOptions{ + InstallPrompt: args.InstallPrompt, + InstallAgent: args.InstallAgent, + ConfigPathOverride: args.ConfigPath, + NonInteractive: args.Yes, + DryRun: args.DryRun, + } + + return installer.Run(opts) +} From 73540340e1d17be4c52dc9578b4a5994119bf423 Mon Sep 17 00:00:00 2001 From: Erich Smith Date: Sun, 21 Dec 2025 21:21:07 -0500 Subject: [PATCH 2/4] Ensure end-to-end install tests run prior to release --- Makefile | 7 +- scripts/dockerized-install-test.sh | 295 +++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 scripts/dockerized-install-test.sh diff --git a/Makefile b/Makefile index b07014b..910829a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all help build build-all test test-integration fmt fmt-check check-ctx clean install hooks coverage coverage-html coverage-signals gosec vet revive install-fabric-pattern release load-light load-medium load-heavy load stress-test +.PHONY: all help build build-all test test-integration fmt fmt-check check-ctx clean install hooks coverage coverage-html coverage-signals gosec vet revive install-fabric-pattern release load-light load-medium load-heavy load stress-test docker-install-test # Detect Go bin directory portably GOBIN := $(shell go env GOBIN) @@ -33,6 +33,7 @@ help: @echo " make load-medium - Generate medium system load (Ctrl+C to stop)" @echo " make load-heavy - Generate heavy system load (Ctrl+C to stop)" @echo " make stress-test - Run dashlights repeatedly under load with stats" + @echo " make docker-install-test - Run dockerized install test plan" @echo " make install-fabric-pattern - Install Fabric pattern for changelog generation" @echo " make release - Create a new release with AI-generated changelog" @@ -217,7 +218,7 @@ install-fabric-pattern: @echo "✅ Fabric pattern installed to ~/.config/fabric/patterns/create_git_changelog/" # Create a new release with AI-generated changelog -release: +release: docker-install-test @bash scripts/release.sh # Load testing targets @@ -244,3 +245,5 @@ stress-test: @echo "Stress testing dashlights..." @bash scripts/stress-dashlights.sh +docker-install-test: + @bash scripts/dockerized-install-test.sh diff --git a/scripts/dockerized-install-test.sh b/scripts/dockerized-install-test.sh new file mode 100644 index 0000000..745211c --- /dev/null +++ b/scripts/dockerized-install-test.sh @@ -0,0 +1,295 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE="${IMAGE:-golang:1.25-rc-bullseye}" + +docker run --rm -it \ + -v "${REPO_DIR}:/work:ro" \ + -w /work \ + "${IMAGE}" \ + bash -lc "$(cat <<'INNER' +set -euo pipefail +trap 'echo "FAILED: $BASH_COMMAND" >&2' ERR + +export PATH=/go/bin:/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +echo "STEP: install deps" +apt-get update +apt-get install -y zsh fish ripgrep util-linux + +echo "STEP: build dashlights" +go build -o /tmp/dashlights ./src +export PATH="/tmp:$PATH" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +begin_test() { + printf 'TEST: %s -- ' "$1" +} + +end_test() { + echo "OK" +} + +assert_contains() { + local hay="$1" + local needle="$2" + echo "$hay" | rg -F -- "$needle" >/dev/null || fail "Expected output to contain: $needle" +} + +assert_file_contains() { + local file="$1" + local needle="$2" + rg -F -- "$needle" "$file" >/dev/null || fail "Expected ${file} to contain: $needle" +} + +expect_success() { + local desc="$1" + shift + local output + if ! output="$("$@" 2>&1)"; then + echo "$output" >&2 + fail "$desc failed" + fi + echo "$output" +} + +expect_failure() { + local desc="$1" + shift + local output + if output="$("$@" 2>&1)"; then + echo "$output" >&2 + fail "$desc expected failure" + fi + echo "$output" +} + +run_piped_cmd() { + local desc="$1" + local cmd="$2" + local output + local status + set +e + output="$(bash -c "$cmd" 2>&1)" + status=$? + set -e + echo "$output" + echo "$status" +} + +reset_home() { + rm -rf "$HOME" + mkdir -p "$HOME" +} + +export HOME="/tmp/dashlights-home" + +begin_test "version check" +output="$(expect_success "version check" dashlights --version)" +assert_contains "$output" "dashlights" +end_test + +begin_test "bash install" +reset_home +export SHELL="/bin/bash" +echo 'export BASH_TEST=1' > "$HOME/.bashrc" +output="$(expect_success "bash install" dashlights --installprompt -y)" +assert_contains "$output" "Installed dashlights into $HOME/.bashrc" +assert_file_contains "$HOME/.bashrc" "# BEGIN dashlights" +assert_file_contains "$HOME/.bashrc" "# END dashlights" +assert_file_contains "$HOME/.bashrc" "PS1=" +test -f "$HOME/.bashrc.dashlights-backup" || fail "Expected backup to exist for bash install" +backup_mtime="$(stat -c %Y "$HOME/.bashrc.dashlights-backup")" +end_test +begin_test "bash idempotency" +output="$(expect_success "bash idempotency" dashlights --installprompt -y)" +assert_contains "$output" "already installed" +test "$(stat -c %Y "$HOME/.bashrc.dashlights-backup")" = "$backup_mtime" || fail "Backup changed on idempotent bash install" +end_test + +begin_test "zsh install" +reset_home +export SHELL="/bin/zsh" +echo 'export ZSH_TEST=1' > "$HOME/.zshrc" +output="$(expect_success "zsh install" dashlights --installprompt -y)" +assert_contains "$output" "Installed dashlights into $HOME/.zshrc" +assert_file_contains "$HOME/.zshrc" "setopt prompt_subst" +assert_file_contains "$HOME/.zshrc" "PROMPT=" +test -f "$HOME/.zshrc.dashlights-backup" || fail "Expected backup to exist for zsh install" +backup_mtime="$(stat -c %Y "$HOME/.zshrc.dashlights-backup")" +end_test +begin_test "zsh idempotency" +output="$(expect_success "zsh idempotency" dashlights --installprompt -y)" +assert_contains "$output" "already installed" +test "$(stat -c %Y "$HOME/.zshrc.dashlights-backup")" = "$backup_mtime" || fail "Backup changed on idempotent zsh install" +end_test + +begin_test "p10k install with elements" +reset_home +export SHELL="/bin/zsh" +cat > "$HOME/.p10k.zsh" <<'EOF' +# p10k sample +typeset -g POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=( + dir + vcs +) +EOF +output="$(expect_success "p10k install with elements" dashlights --installprompt -y)" +assert_contains "$output" "Installed dashlights into $HOME/.p10k.zsh" +assert_file_contains "$HOME/.p10k.zsh" "prompt_dashlights" +assert_file_contains "$HOME/.p10k.zsh" "POWERLEVEL9K_LEFT_PROMPT_ELEMENTS" +assert_file_contains "$HOME/.p10k.zsh" "dashlights" +backup_mtime="$(stat -c %Y "$HOME/.p10k.zsh.dashlights-backup")" +end_test +begin_test "p10k idempotency with elements" +output="$(expect_success "p10k idempotency with elements" dashlights --installprompt -y)" +assert_contains "$output" "already installed" +test "$(stat -c %Y "$HOME/.p10k.zsh.dashlights-backup")" = "$backup_mtime" || fail "Backup changed on idempotent p10k install with elements" +end_test + +begin_test "p10k install without elements" +reset_home +export SHELL="/bin/zsh" +cat > "$HOME/.p10k.zsh" <<'EOF' +# p10k sample without prompt elements array +typeset -g POWERLEVEL9K_MODE=nerdfont-complete +EOF +output="$(expect_success "p10k install without elements" dashlights --installprompt -y)" +assert_contains "$output" "Could not locate POWERLEVEL9K_LEFT_PROMPT_ELEMENTS" +assert_file_contains "$HOME/.p10k.zsh" "prompt_dashlights" +backup_mtime="$(stat -c %Y "$HOME/.p10k.zsh.dashlights-backup")" +end_test +begin_test "p10k idempotency without elements" +output="$(expect_success "p10k idempotency without elements" dashlights --installprompt -y)" +assert_contains "$output" "already installed" +test "$(stat -c %Y "$HOME/.p10k.zsh.dashlights-backup")" = "$backup_mtime" || fail "Backup changed on idempotent p10k install without elements" +end_test + +begin_test "fish install" +reset_home +mkdir -p "$HOME/.config/fish" +export SHELL="/usr/bin/fish" +output="$(expect_success "fish install" dashlights --installprompt -y)" +assert_contains "$output" "Installed dashlights into $HOME/.config/fish/config.fish" +assert_file_contains "$HOME/.config/fish/config.fish" "# BEGIN dashlights" +assert_file_contains "$HOME/.config/fish/config.fish" "function __dashlights_prompt" +test ! -f "$HOME/.config/fish/config.fish.dashlights-backup" || fail "Did not expect fish backup for new file" +end_test +begin_test "fish idempotency" +output="$(expect_success "fish idempotency" dashlights --installprompt -y)" +assert_contains "$output" "already installed" +test ! -f "$HOME/.config/fish/config.fish.dashlights-backup" || fail "Did not expect fish backup on idempotent install" +end_test + +begin_test "configpath install" +reset_home +export SHELL="/bin/zsh" +echo '# custom bashrc' > "$HOME/custom.bashrc" +output="$(expect_success "configpath install" dashlights --installprompt --configpath "$HOME/custom.bashrc" -y)" +assert_contains "$output" "Installed dashlights into $HOME/custom.bashrc" +assert_file_contains "$HOME/custom.bashrc" "PS1=" +test -f "$HOME/custom.bashrc.dashlights-backup" || fail "Expected backup to exist for configpath install" +end_test + +begin_test "unsupported shell" +reset_home +export SHELL="/bin/unknownshell" +output="$(expect_failure "unsupported shell" dashlights --installprompt -y)" +assert_contains "$output" "unsupported shell" +end_test + +begin_test "configpath directory" +reset_home +mkdir -p "$HOME/dir" +export SHELL="/bin/bash" +output="$(expect_failure "configpath directory" dashlights --installprompt --configpath "$HOME/dir" -y)" +assert_contains "$output" "--configpath must be a file, not a directory" +end_test + +begin_test "corrupted install" +reset_home +export SHELL="/bin/bash" +cat > "$HOME/.bashrc" <<'EOF' +# BEGIN dashlights +# corrupted block +EOF +output="$(expect_failure "corrupted install" dashlights --installprompt -y)" +assert_contains "$output" "Corrupted dashlights installation detected" +end_test + +begin_test "dry run" +reset_home +export SHELL="/bin/bash" +echo 'export BASH_TEST=1' > "$HOME/.bashrc" +before_hash="$(sha256sum "$HOME/.bashrc" | awk '{print $1}')" +output="$(expect_success "dry run" dashlights --installprompt --dry-run)" +assert_contains "$output" "[DRY-RUN]" +test ! -f "$HOME/.bashrc.dashlights-backup" || fail "Dry run should not create backup" +after_hash="$(sha256sum "$HOME/.bashrc" | awk '{print $1}')" +test "$before_hash" = "$after_hash" || fail "Dry run should not modify bashrc" +end_test + +begin_test "claude install" +reset_home +output="$(expect_success "claude install" dashlights --installagent claude -y)" +assert_contains "$output" "Installed dashlights into $HOME/.claude/settings.json" +assert_file_contains "$HOME/.claude/settings.json" "dashlights --agentic" +assert_file_contains "$HOME/.claude/settings.json" "PreToolUse" +end_test +begin_test "claude idempotency" +output="$(expect_success "claude idempotency" dashlights --installagent claude -y)" +assert_contains "$output" "already installed" +end_test + +begin_test "cursor install" +reset_home +output="$(expect_success "cursor install" dashlights --installagent cursor -y)" +assert_contains "$output" "Installed dashlights into $HOME/.cursor/hooks.json" +assert_file_contains "$HOME/.cursor/hooks.json" "beforeShellExecution" +assert_file_contains "$HOME/.cursor/hooks.json" "dashlights --agentic" +end_test +begin_test "cursor idempotency" +output="$(expect_success "cursor idempotency" dashlights --installagent cursor -y)" +assert_contains "$output" "already installed" +end_test + +begin_test "cursor conflict" +reset_home +mkdir -p "$HOME/.cursor" +cat > "$HOME/.cursor/hooks.json" <<'EOF' +{"beforeShellExecution":{"command":"echo existing"}} +EOF +output_status="$(run_piped_cmd "cursor decline" 'printf "n\n" | script -q -e -c "dashlights --installagent cursor" /dev/null')" +output="$(echo "$output_status" | sed '$d')" +status="$(echo "$output_status" | tail -n 1)" +test "$status" -eq 0 || test "$status" -eq 1 || fail "Unexpected exit code for interactive decline: $status" +assert_contains "$output" "Installation cancelled" +assert_file_contains "$HOME/.cursor/hooks.json" "echo existing" + +output="$(expect_failure "cursor conflict non-interactive" dashlights --installagent cursor -y)" +assert_contains "$output" "already has a beforeShellExecution hook" + +output_status="$(run_piped_cmd "cursor accept" 'printf "y\ny\n" | script -q -e -c "dashlights --installagent cursor" /dev/null')" +output="$(echo "$output_status" | sed '$d')" +status="$(echo "$output_status" | tail -n 1)" +test "$status" -eq 0 || test "$status" -eq 1 || fail "Unexpected exit code for interactive accept: $status" +assert_file_contains "$HOME/.cursor/hooks.json" "dashlights --agentic" +end_test + +begin_test "invalid agent" +output="$(expect_failure "invalid agent" dashlights --installagent not-an-agent -y)" +assert_contains "$output" "unsupported agent" +end_test + +begin_test "configpath with installagent" +output="$(expect_failure "configpath with installagent" dashlights --installagent claude --configpath /tmp/fake -y)" +assert_contains "$output" "--configpath cannot be used with --installagent" +end_test + +echo "All dockerized install tests passed." +INNER +)" From 72ff31cf6b087296130fbe338df5c7e54362b6d5 Mon Sep 17 00:00:00 2001 From: Erich Smith Date: Sun, 21 Dec 2025 21:38:10 -0500 Subject: [PATCH 3/4] Document installation options --- README.md | 109 ++++--------------------------------------- docs/agentic_mode.md | 16 ++++++- 2 files changed, 24 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 222901b..89e13fc 100644 --- a/README.md +++ b/README.md @@ -105,107 +105,17 @@ make install ## Configure your PROMPT -After installing dashlights, add it to your shell prompt to get continuous security monitoring. - -### Bash - -Add to your `~/.bashrc`: - -```bash -# Add dashlights to your prompt -PS1='$(dashlights) '"$PS1" -``` - -### Zsh - -Add to your `~/.zshrc`: - -```bash -# For left prompt (PROMPT) -PROMPT='$(dashlights) '"$PROMPT" - -# Or for right prompt (RPROMPT) -RPROMPT='$(dashlights)' -``` - -### oh-my-zsh - -Add to your `~/.zshrc` after the oh-my-zsh initialization: - -```bash -# Source oh-my-zsh first -source $ZSH/oh-my-zsh.sh - -# Then add dashlights to your prompt -PROMPT='$(dashlights) '"$PROMPT" -``` - -### Powerlevel10k - -If you use Powerlevel10k, add dashlights as a custom prompt segment by editing your `~/.p10k.zsh` configuration file. - -#### Step 1: Define the custom segment function - -Add this function anywhere in your `~/.p10k.zsh` file (recommended: after the initial comments, before the main configuration block): - -```bash -function prompt_dashlights() { - # Run dashlights and capture output - local content=$(dashlights 2>/dev/null) - - # Only render the segment if dashlights returned output - if [[ -n $content ]]; then - p10k segment -t "$content" - fi -} -``` - -#### Step 2: Add to your prompt elements - -Find the `POWERLEVEL9K_LEFT_PROMPT_ELEMENTS` array in your `~/.p10k.zsh` and add `dashlights` to it: +After installing dashlights, run the installer once. It detects bash, zsh, fish, and Powerlevel10k automatically. ```bash -typeset -g POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=( - # =========================[ Line #1 ]========================= - dir # current directory - vcs # git status - # =========================[ Line #2 ]========================= - newline # \n - dashlights # <-- Add this line - prompt_char # prompt symbol -) +dashlights --installprompt ``` -**Alternative**: Add to right prompt or second line: - -```bash -typeset -g POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS=( - # =========================[ Line #1 ]========================= - command_execution_time # previous command duration - dashlights # <-- Add here for right prompt - time # current time -) -``` - -#### Step 3: Reload your configuration - -```bash -source ~/.zshrc -``` - -**Note**: This approach keeps your `~/.zshrc` clean and follows Powerlevel10k best practices by keeping all prompt configuration in `~/.p10k.zsh`. The segment will only appear when dashlights detects security issues or custom dashboard lights. - -### Fish - -Add to your `~/.config/fish/config.fish`: - -```fish -# Add dashlights to your prompt -function fish_prompt - echo -n (dashlights)" " - # ... rest of your prompt configuration -end -``` +Tips: +- Use `--yes` for non-interactive installs. +- Use `--configpath` to target a specific config file (e.g., `~/.p10k.zsh`). +- Use `--dry-run` to preview changes without modifying files. +- Re-run any time; it is idempotent. ## Usage @@ -314,8 +224,9 @@ Dashlights includes an `--agentic` mode for AI coding assistants like Claude Cod - **Rule of Two violations**: Actions combining untrusted input + sensitive access + state changes ```bash -# Add to .claude/settings.json hooks -"command": "dashlights --agentic" +# Install agent hooks +dashlights --installagent claude -y +dashlights --installagent cursor -y ``` 👉 **[View the complete agentic mode documentation →](docs/agentic_mode.md)** diff --git a/docs/agentic_mode.md b/docs/agentic_mode.md index f281c63..240165d 100644 --- a/docs/agentic_mode.md +++ b/docs/agentic_mode.md @@ -35,7 +35,13 @@ When all three capabilities are combined in a single action, the risk of securit ### Claude Code -Claude Code is the primary supported agent. Add to your `.claude/settings.json`: +Claude Code is the primary supported agent. Recommended installer: + +```bash +dashlights --installagent claude -y +``` + +Manual configuration (if you prefer to edit by hand): ```json { @@ -59,7 +65,13 @@ Claude Code is the primary supported agent. Add to your `.claude/settings.json`: Cursor IDE is supported via the `beforeShellExecution` hook. Dashlights automatically detects Cursor input format and outputs the expected response format. -**Configuration:** Create `.cursor/hooks.json` in your project or home directory: +Recommended installer: + +```bash +dashlights --installagent cursor -y +``` + +Manual configuration (if you prefer to edit by hand). Create `.cursor/hooks.json` in your project or home directory: ```json { From 7ee86f3f9d0fb35b64f0fbc0e2a8c16cf9edfd83 Mon Sep 17 00:00:00 2001 From: Erich Smith Date: Sun, 21 Dec 2025 21:45:15 -0500 Subject: [PATCH 4/4] Update CHANGELOG for v1.1.1 --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c66f7b7..f019d7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.1] - 2025-12-21 + +### Added +- Automated installation into prompts and agent hooks + +### Changed +- Documented installation options for easier setup +- Ensured end-to-end installation tests run before release to improve reliability + + ## [1.1.0] - 2025-12-19 This release introduces --agentic mode, see docs/agentic_mode.md for