diff --git a/CHANGELOG.md b/CHANGELOG.md index f8b4fed..8df6cfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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.3] - 2026-01-03 + +### Changed +- Improved installation process for a smoother user experience +- Added --install top-level flag to perform all install activities + + ## [1.1.2] - 2025-12-26 ### Added diff --git a/README.md b/README.md index 84f171b..bbf6a8c 100644 --- a/README.md +++ b/README.md @@ -105,14 +105,35 @@ make install ## Configure your PROMPT -After installing dashlights, run the installer once. It detects bash, zsh, fish, and Powerlevel10k automatically. +After downloading dashlights, run the unified installer to set up everything at once: ```bash -dashlights --installprompt +./dashlights --install +``` + +This will: +1. Install the binary to a sensible location in your PATH +2. Configure your shell prompt (bash, zsh, fish, or Powerlevel10k) +3. Set up AI agent hooks if Claude Code or Cursor are detected + +### Binary Installation Location + +The installer selects a binary location using this priority: +1. **Existing location** - If dashlights is already in your PATH, it updates that location +2. **User-writable PATH directory** - First writable directory in PATH (excluding system dirs and non-preferred homebrew subdirectories) +3. **Fallback** - `~/.local/bin` (created and added to PATH if needed) + +### Install Options + +```bash +dashlights --install # Full installation (binary + prompt + detected agents) +dashlights --installprompt # Shell prompt only +dashlights --installagent claude # Claude Code agent hooks only +dashlights --installagent cursor # Cursor agent hooks only ``` Tips: -- Use `--yes` for non-interactive installs. +- Use `--yes` or `-y` 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. diff --git a/scripts/dockerized-install-test.sh b/scripts/dockerized-install-test.sh index 745211c..021856f 100644 --- a/scripts/dockerized-install-test.sh +++ b/scripts/dockerized-install-test.sh @@ -4,7 +4,8 @@ set -euo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" IMAGE="${IMAGE:-golang:1.25-rc-bullseye}" -docker run --rm -it \ +TTY_FLAG="${TTY_FLAG:--it}" +docker run --rm $TTY_FLAG \ -v "${REPO_DIR}:/work:ro" \ -w /work \ "${IMAGE}" \ @@ -18,8 +19,10 @@ 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" +mkdir -p /opt/dashlights-build +go build -o /opt/dashlights-build/dashlights ./src +export PATH="/opt/dashlights-build:$PATH" +DASHLIGHTS_BIN="/opt/dashlights-build/dashlights" fail() { echo "FAIL: $*" >&2 @@ -93,6 +96,196 @@ output="$(expect_success "version check" dashlights --version)" assert_contains "$output" "dashlights" end_test +# ============================================================ +# Binary Installation Tests +# ============================================================ + +begin_test "binary install to writable PATH dir" +reset_home +export SHELL="/bin/bash" +mkdir -p "$HOME/bin" +export PATH="$HOME/bin:/usr/bin:/bin" +echo 'export BASH_TEST=1' > "$HOME/.bashrc" +output="$(expect_success "binary install" "$DASHLIGHTS_BIN" --install -y)" +test -f "$HOME/bin/dashlights" || fail "Expected binary to be installed to ~/bin" +test -x "$HOME/bin/dashlights" || fail "Expected binary to be executable" +end_test + +begin_test "binary install respects existing PATH location" +reset_home +export SHELL="/bin/bash" +mkdir -p "$HOME/bin" "$HOME/other-bin" +echo '#!/bin/sh' > "$HOME/other-bin/dashlights" +echo 'echo old' >> "$HOME/other-bin/dashlights" +chmod +x "$HOME/other-bin/dashlights" +export PATH="$HOME/bin:$HOME/other-bin:/usr/bin:/bin" +echo 'export BASH_TEST=1' > "$HOME/.bashrc" +output="$(expect_success "respect existing" "$DASHLIGHTS_BIN" --install -y)" +test -x "$HOME/other-bin/dashlights" || fail "Expected binary to be updated in existing location" +"$HOME/other-bin/dashlights" --version >/dev/null || fail "Updated binary should work" +end_test + +begin_test "binary install skips homebrew subdirs" +reset_home +export SHELL="/bin/bash" +mkdir -p "$HOME/preferred-bin" +mkdir -p "/opt/homebrew/lib/ruby/gems/3.3.0/bin" +chmod 777 "/opt/homebrew/lib/ruby/gems/3.3.0/bin" +export PATH="/opt/homebrew/lib/ruby/gems/3.3.0/bin:$HOME/preferred-bin:/usr/bin:/bin" +echo 'export BASH_TEST=1' > "$HOME/.bashrc" +output="$(expect_success "skip homebrew subdirs" "$DASHLIGHTS_BIN" --install -y)" +test -f "$HOME/preferred-bin/dashlights" || fail "Expected binary in preferred-bin" +test ! -f "/opt/homebrew/lib/ruby/gems/3.3.0/bin/dashlights" || fail "Should NOT install to homebrew subdir" +end_test + +begin_test "binary install allows /opt/homebrew/bin" +reset_home +export SHELL="/bin/bash" +mkdir -p "/opt/homebrew/bin" +chmod 777 "/opt/homebrew/bin" +export PATH="/opt/homebrew/bin:/usr/bin:/bin" +echo 'export BASH_TEST=1' > "$HOME/.bashrc" +output="$(expect_success "allow homebrew bin" "$DASHLIGHTS_BIN" --install -y)" +test -f "/opt/homebrew/bin/dashlights" || fail "Expected binary in /opt/homebrew/bin" +end_test + +begin_test "binary install fallback to .local/bin" +reset_home +export SHELL="/bin/bash" +export PATH="/usr/bin:/bin:/usr/local/bin" +echo 'export BASH_TEST=1' > "$HOME/.bashrc" +output="$(expect_success "binary fallback" "$DASHLIGHTS_BIN" --install -y)" +test -f "$HOME/.local/bin/dashlights" || fail "Expected binary at ~/.local/bin/dashlights" +assert_file_contains "$HOME/.bashrc" "# BEGIN dashlights-path" +assert_file_contains "$HOME/.bashrc" ".local/bin" +end_test + +begin_test "binary install idempotency" +reset_home +export SHELL="/bin/bash" +mkdir -p "$HOME/bin" +export PATH="$HOME/bin:/usr/bin:/bin" +echo 'export BASH_TEST=1' > "$HOME/.bashrc" +"$DASHLIGHTS_BIN" --install -y >/dev/null +first_hash="$(sha256sum "$HOME/bin/dashlights" | awk '{print $1}')" +output="$(expect_success "binary idempotency" "$DASHLIGHTS_BIN" --install -y)" +second_hash="$(sha256sum "$HOME/bin/dashlights" | awk '{print $1}')" +test "$first_hash" = "$second_hash" || fail "Binary should not change on idempotent install" +end_test + +begin_test "binary update older version" +reset_home +export SHELL="/bin/bash" +mkdir -p "$HOME/bin" +export PATH="$HOME/bin:/usr/bin:/bin" +echo 'export BASH_TEST=1' > "$HOME/.bashrc" +echo '#!/bin/sh' > "$HOME/bin/dashlights" +echo 'echo old-version' >> "$HOME/bin/dashlights" +chmod +x "$HOME/bin/dashlights" +old_hash="$(sha256sum "$HOME/bin/dashlights" | awk '{print $1}')" +output="$(expect_success "binary update" "$DASHLIGHTS_BIN" --install -y)" +assert_contains "$output" "Updated binary" || assert_contains "$output" "Installed" +new_hash="$(sha256sum "$HOME/bin/dashlights" | awk '{print $1}')" +test "$old_hash" != "$new_hash" || fail "Binary should have been updated" +test -f "$HOME/bin/dashlights.dashlights-backup" || fail "Expected backup of old binary" +"$HOME/bin/dashlights" --version >/dev/null || fail "Updated binary should be functional" +end_test + +begin_test "PATH export idempotency" +reset_home +export SHELL="/bin/bash" +export PATH="/usr/bin:/bin" +echo 'export BASH_TEST=1' > "$HOME/.bashrc" +"$DASHLIGHTS_BIN" --install -y >/dev/null +count1="$(grep -c "BEGIN dashlights-path" "$HOME/.bashrc")" +"$DASHLIGHTS_BIN" --install -y >/dev/null +count2="$(grep -c "BEGIN dashlights-path" "$HOME/.bashrc")" +test "$count1" = "$count2" || fail "PATH export should not be duplicated" +test "$count1" = "1" || fail "Expected exactly one PATH export block" +end_test + +# ============================================================ +# Unified --install Tests +# ============================================================ + +begin_test "unified install with no agents" +reset_home +export SHELL="/bin/bash" +mkdir -p "$HOME/bin" +export PATH="$HOME/bin:/usr/bin:/bin" +echo 'export BASH_TEST=1' > "$HOME/.bashrc" +output="$(expect_success "unified install no agents" "$DASHLIGHTS_BIN" --install -y)" +assert_contains "$output" "Binary:" +assert_contains "$output" "Shell Prompt:" +test -f "$HOME/bin/dashlights" || fail "Expected binary installed" +assert_file_contains "$HOME/.bashrc" "# BEGIN dashlights" +end_test + +begin_test "unified install with claude" +reset_home +export SHELL="/bin/bash" +mkdir -p "$HOME/bin" "$HOME/.claude" +export PATH="$HOME/bin:/usr/bin:/bin" +echo 'export BASH_TEST=1' > "$HOME/.bashrc" +output="$(expect_success "unified install with claude" "$DASHLIGHTS_BIN" --install -y)" +assert_contains "$output" "Claude Code:" +assert_file_contains "$HOME/.claude/settings.json" "dashlights --agentic" +end_test + +begin_test "unified install with cursor" +reset_home +export SHELL="/bin/bash" +mkdir -p "$HOME/bin" "$HOME/.cursor" +export PATH="$HOME/bin:/usr/bin:/bin" +echo 'export BASH_TEST=1' > "$HOME/.bashrc" +output="$(expect_success "unified install with cursor" "$DASHLIGHTS_BIN" --install -y)" +assert_contains "$output" "Cursor:" +assert_file_contains "$HOME/.cursor/hooks.json" "dashlights --agentic" +end_test + +begin_test "unified install with both agents" +reset_home +export SHELL="/bin/bash" +mkdir -p "$HOME/bin" "$HOME/.claude" "$HOME/.cursor" +export PATH="$HOME/bin:/usr/bin:/bin" +echo 'export BASH_TEST=1' > "$HOME/.bashrc" +output="$(expect_success "unified install both agents" "$DASHLIGHTS_BIN" --install -y)" +assert_contains "$output" "Claude Code:" +assert_contains "$output" "Cursor:" +assert_file_contains "$HOME/.claude/settings.json" "dashlights --agentic" +assert_file_contains "$HOME/.cursor/hooks.json" "dashlights --agentic" +end_test + +begin_test "unified install dry run" +reset_home +export SHELL="/bin/bash" +mkdir -p "$HOME/bin" "$HOME/.claude" +export PATH="$HOME/bin:/usr/bin:/bin" +echo 'export BASH_TEST=1' > "$HOME/.bashrc" +output="$(expect_success "unified dry run" "$DASHLIGHTS_BIN" --install --dry-run)" +assert_contains "$output" "DRY RUN" +test ! -f "$HOME/bin/dashlights" || fail "Dry run should not install binary" +test ! -f "$HOME/.claude/settings.json" || fail "Dry run should not create agent config" +end_test + +begin_test "unified install idempotency" +reset_home +export SHELL="/bin/bash" +mkdir -p "$HOME/bin" "$HOME/.claude" +export PATH="$HOME/bin:/usr/bin:/bin" +echo 'export BASH_TEST=1' > "$HOME/.bashrc" +"$DASHLIGHTS_BIN" --install -y >/dev/null +output="$(expect_success "unified idempotency" "$DASHLIGHTS_BIN" --install -y)" +assert_contains "$output" "already" +end_test + +# ============================================================ +# Shell Prompt Installation Tests +# ============================================================ + +# Restore PATH to include the build directory for prompt tests +export PATH="/opt/dashlights-build:$PATH" + begin_test "bash install" reset_home export SHELL="/bin/bash" diff --git a/src/install/binary.go b/src/install/binary.go new file mode 100644 index 0000000..8f0aa0e --- /dev/null +++ b/src/install/binary.go @@ -0,0 +1,497 @@ +package install + +import ( + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "strings" +) + +// BinaryInstallState represents the state of the binary installation. +type BinaryInstallState int + +const ( + BinaryNotInstalled BinaryInstallState = iota + BinaryInstalled // Same version already installed + BinaryOutdated // Different version exists + BinaryIsSymlink // Target is a symlink (warn, don't overwrite) +) + +// BinaryConfig contains information about binary installation. +type BinaryConfig struct { + SourcePath string // Path to currently running binary + TargetDir string // Directory to install to + TargetPath string // Full path to target binary + PathNeedsExport bool // Whether ~/.local/bin needs to be added to PATH + ShellConfigPath string // Shell config file to modify for PATH export + Shell ShellType +} + +// Sentinel markers for PATH export. +const ( + PathSentinelBegin = "# BEGIN dashlights-path" + PathSentinelEnd = "# END dashlights-path" +) + +// Default fallback directory for binary installation. +const DefaultBinDir = ".local/bin" + +// System directories that should be skipped for user installs. +var systemDirs = []string{"/usr/bin", "/usr/local/bin", "/bin", "/sbin", "/usr/sbin"} + +// homebrewBinDir is the acceptable homebrew bin directory. +const homebrewBinDir = "/opt/homebrew/bin" + +// homebrewPrefix is the prefix for homebrew directories that should be filtered out. +const homebrewPrefix = "/opt/homebrew/" + +// BinaryInstaller handles installation of the dashlights binary to PATH. +type BinaryInstaller struct { + fs Filesystem + backup *BackupManager +} + +// NewBinaryInstaller creates a new BinaryInstaller. +func NewBinaryInstaller(fs Filesystem) *BinaryInstaller { + return &BinaryInstaller{ + fs: fs, + backup: NewBackupManager(fs), + } +} + +// GetBinaryConfig determines the source and target paths for binary installation. +func (b *BinaryInstaller) GetBinaryConfig(shellConfig *ShellConfig) (*BinaryConfig, error) { + // Get the path of the running binary + sourcePath, err := b.fs.Executable() + if err != nil { + return nil, fmt.Errorf("cannot determine running binary path: %w", err) + } + + // Resolve symlinks to get the real path + realPath, err := b.resolveSymlinks(sourcePath) + if err != nil { + return nil, fmt.Errorf("cannot resolve binary path: %w", err) + } + + // Verify the binary exists and is readable + if _, err := b.fs.Stat(realPath); err != nil { + return nil, fmt.Errorf("running binary not accessible at %s: %w", realPath, err) + } + + // Find installation directory + targetDir, needsExport, err := b.FindInstallDir() + if err != nil { + return nil, fmt.Errorf("cannot find installation directory: %w", err) + } + + config := &BinaryConfig{ + SourcePath: realPath, + TargetDir: targetDir, + TargetPath: filepath.Join(targetDir, "dashlights"), + PathNeedsExport: needsExport, + } + + // If PATH export is needed, determine shell config + if needsExport && shellConfig != nil { + config.ShellConfigPath = shellConfig.ConfigPath + config.Shell = shellConfig.Shell + } + + return config, nil +} + +// resolveSymlinks resolves symlinks to get the real binary path. +func (b *BinaryInstaller) resolveSymlinks(path string) (string, error) { + // For mock filesystem, just return the path as-is + // In real use, filepath.EvalSymlinks handles this + info, err := b.fs.Lstat(path) + if err != nil { + return "", err + } + if info.Mode()&0o120000 == 0o120000 { // symlink + // For real filesystem, we'd follow the link + // For mock, we just return the path + return path, nil + } + return path, nil +} + +// findExistingDashlightsInPath checks if dashlights binary already exists somewhere in PATH. +// Returns the directory path if found, empty string otherwise. +func (b *BinaryInstaller) findExistingDashlightsInPath() string { + pathDirs := b.fs.SplitPath() + for _, dir := range pathDirs { + if dir == "" { + continue + } + binaryPath := filepath.Join(dir, "dashlights") + if b.fs.Exists(binaryPath) { + return dir + } + } + return "" +} + +// FindInstallDir finds the best directory to install the binary to. +// Priority: +// 1. If dashlights already exists somewhere in PATH, use that location +// 2. First user-writable directory in PATH (excluding system dirs and non-preferred homebrew subdirs) +// 3. Fallback to ~/.local/bin +func (b *BinaryInstaller) FindInstallDir() (string, bool, error) { + pathDirs := b.fs.SplitPath() + + homeDir, err := b.fs.UserHomeDir() + if err != nil { + return "", false, fmt.Errorf("cannot determine home directory: %w", err) + } + localBin := filepath.Join(homeDir, DefaultBinDir) + + // Priority 1: If dashlights already exists in PATH, use that location + // (even if it requires sudo - we respect user's prior choice) + existingDir := b.findExistingDashlightsInPath() + if existingDir != "" { + return existingDir, false, nil + } + + // Priority 2: First user-writable directory in PATH + // Skip system directories and non-preferred homebrew subdirectories + for _, dir := range pathDirs { + if dir == "" { + continue + } + + // Skip system directories + if isSystemDir(dir) { + continue + } + + // Skip non-preferred homebrew subdirectories (but allow /opt/homebrew/bin) + if isNonPreferredHomebrewDir(dir) { + continue + } + + // Check if writable + if b.fs.IsWritable(dir) { + return dir, false, nil + } + } + + // Priority 3: Fall back to ~/.local/bin + // Check if ~/.local/bin is already in PATH + for _, dir := range pathDirs { + if dir == localBin { + // In PATH but may not exist or not writable - create it + return localBin, false, nil + } + } + + // ~/.local/bin not in PATH - need to add export + return localBin, true, nil +} + +// isSystemDir checks if a directory is a system directory. +func isSystemDir(dir string) bool { + for _, sys := range systemDirs { + if dir == sys { + return true + } + } + return false +} + +// isNonPreferredHomebrewDir checks if a directory is a homebrew subdirectory +// that should be filtered out (e.g., /opt/homebrew/lib/ruby/gems/3.3.0/bin). +// Returns false for /opt/homebrew/bin which is acceptable. +func isNonPreferredHomebrewDir(dir string) bool { + if dir == homebrewBinDir { + return false // /opt/homebrew/bin is acceptable + } + return strings.HasPrefix(dir, homebrewPrefix) +} + +// CheckBinaryState checks if the binary is already installed and its state. +func (b *BinaryInstaller) CheckBinaryState(config *BinaryConfig) (BinaryInstallState, error) { + // Check if target exists + if !b.fs.Exists(config.TargetPath) { + return BinaryNotInstalled, nil + } + + // Check if target is a symlink + info, err := b.fs.Lstat(config.TargetPath) + if err != nil { + return BinaryNotInstalled, err + } + if info.Mode()&os.ModeSymlink != 0 { + return BinaryIsSymlink, nil + } + + // Compare versions + same, err := b.CompareVersions(config.SourcePath, config.TargetPath) + if err != nil { + return BinaryNotInstalled, err + } + if same { + return BinaryInstalled, nil + } + return BinaryOutdated, nil +} + +// CompareVersions compares the running binary against the installed one. +// Returns true if they are the same (by size and checksum). +func (b *BinaryInstaller) CompareVersions(sourcePath, targetPath string) (bool, error) { + // First compare file sizes (fast check) + srcInfo, err := b.fs.Stat(sourcePath) + if err != nil { + return false, err + } + dstInfo, err := b.fs.Stat(targetPath) + if err != nil { + return false, err + } + if srcInfo.Size() != dstInfo.Size() { + return false, nil // Different sizes = different versions + } + + // If sizes match, compare SHA256 checksums + srcHash, err := b.hashFile(sourcePath) + if err != nil { + return false, err + } + dstHash, err := b.hashFile(targetPath) + if err != nil { + return false, err + } + + return srcHash == dstHash, nil +} + +// hashFile computes the SHA256 hash of a file. +func (b *BinaryInstaller) hashFile(path string) (string, error) { + content, err := b.fs.ReadFile(path) + if err != nil { + return "", err + } + hash := sha256.Sum256(content) + return fmt.Sprintf("%x", hash), nil +} + +// InstallBinary copies the binary to the target location. +func (b *BinaryInstaller) InstallBinary(config *BinaryConfig, dryRun bool) (*InstallResult, error) { + // Check current state + state, err := b.CheckBinaryState(config) + if err != nil { + return nil, fmt.Errorf("failed to check binary state: %w", err) + } + + switch state { + case BinaryInstalled: + return &InstallResult{ + ExitCode: ExitSuccess, + Message: fmt.Sprintf("Binary already installed at %s (up to date)", config.TargetPath), + }, nil + + case BinaryIsSymlink: + return &InstallResult{ + ExitCode: ExitSuccess, + Message: fmt.Sprintf("Warning: %s is a symlink, not overwriting", config.TargetPath), + }, nil + + case BinaryOutdated: + if dryRun { + return &InstallResult{ + ExitCode: ExitSuccess, + Message: fmt.Sprintf("[DRY RUN] Would update binary at %s", config.TargetPath), + WhatChanged: "update", + }, nil + } + // Create backup before updating + backupResult, err := b.backup.CreateBackup(config.TargetPath) + if err != nil { + return nil, fmt.Errorf("failed to backup existing binary: %w", err) + } + // Copy the new binary + if err := b.fs.CopyFile(config.SourcePath, config.TargetPath); err != nil { + return nil, fmt.Errorf("failed to copy binary: %w", err) + } + // Ensure executable permissions + if err := b.fs.Chmod(config.TargetPath, 0755); err != nil { + return nil, fmt.Errorf("failed to set permissions: %w", err) + } + return &InstallResult{ + ExitCode: ExitSuccess, + Message: fmt.Sprintf("Updated binary at %s", config.TargetPath), + BackupPath: backupResult.BackupPath, + WhatChanged: "update", + }, nil + + case BinaryNotInstalled: + if dryRun { + return &InstallResult{ + ExitCode: ExitSuccess, + Message: fmt.Sprintf("[DRY RUN] Would install binary to %s", config.TargetPath), + WhatChanged: "install", + }, nil + } + // Ensure target directory exists + if err := b.fs.MkdirAll(config.TargetDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create directory %s: %w", config.TargetDir, err) + } + // Copy the binary + if err := b.fs.CopyFile(config.SourcePath, config.TargetPath); err != nil { + return nil, fmt.Errorf("failed to copy binary: %w", err) + } + // Ensure executable permissions + if err := b.fs.Chmod(config.TargetPath, 0755); err != nil { + return nil, fmt.Errorf("failed to set permissions: %w", err) + } + return &InstallResult{ + ExitCode: ExitSuccess, + Message: fmt.Sprintf("Installed binary to %s", config.TargetPath), + ConfigPath: config.TargetPath, + WhatChanged: "install", + }, nil + } + + return nil, fmt.Errorf("unknown binary state") +} + +// CheckPathExportState checks if PATH export is already configured in shell config. +func (b *BinaryInstaller) CheckPathExportState(shellConfigPath string) (InstallState, error) { + if shellConfigPath == "" { + return NotInstalled, nil + } + + content, err := b.fs.ReadFile(shellConfigPath) + if err != nil { + // File doesn't exist - not installed + return NotInstalled, nil + } + + contentStr := string(content) + hasBegin := strings.Contains(contentStr, PathSentinelBegin) + hasEnd := strings.Contains(contentStr, PathSentinelEnd) + + if hasBegin && hasEnd { + return FullyInstalled, nil + } + if hasBegin || hasEnd { + return PartialInstall, nil + } + return NotInstalled, nil +} + +// AddPathExport adds ~/.local/bin to PATH in shell config file. +func (b *BinaryInstaller) AddPathExport(shellConfig *ShellConfig, dryRun bool) (*InstallResult, error) { + if shellConfig == nil || shellConfig.ConfigPath == "" { + return &InstallResult{ + ExitCode: ExitSuccess, + Message: "No shell config to update", + }, nil + } + + // Check if already configured + state, err := b.CheckPathExportState(shellConfig.ConfigPath) + if err != nil { + return nil, err + } + + switch state { + case FullyInstalled: + return &InstallResult{ + ExitCode: ExitSuccess, + Message: "PATH export already configured", + }, nil + + case PartialInstall: + return &InstallResult{ + ExitCode: ExitError, + Message: fmt.Sprintf("Error: Found partial PATH export in %s. Please remove the dashlights-path section manually and try again.", shellConfig.ConfigPath), + }, nil + } + + // Get the template for this shell + template := GetPathExportTemplate(shellConfig.Shell) + if template == "" { + return &InstallResult{ + ExitCode: ExitSuccess, + Message: fmt.Sprintf("No PATH template for shell %s", shellConfig.Shell), + }, nil + } + + if dryRun { + return &InstallResult{ + ExitCode: ExitSuccess, + Message: fmt.Sprintf("[DRY RUN] Would add PATH export to %s", shellConfig.ConfigPath), + WhatChanged: "path_export", + }, nil + } + + // Read existing content (may not exist - that's OK) + existingContent, readErr := b.fs.ReadFile(shellConfig.ConfigPath) + if readErr != nil { + existingContent = nil // File doesn't exist, start fresh + } + + // Backup if file exists + var backupPath string + if len(existingContent) > 0 { + backupResult, backupErr := b.backup.CreateBackup(shellConfig.ConfigPath) + if backupErr != nil { + return nil, fmt.Errorf("failed to backup %s: %w", shellConfig.ConfigPath, backupErr) + } + backupPath = backupResult.BackupPath + } + + // Append the PATH export + newContent := string(existingContent) + if len(newContent) > 0 && !strings.HasSuffix(newContent, "\n") { + newContent += "\n" + } + newContent += "\n" + template + + if err := b.fs.WriteFile(shellConfig.ConfigPath, []byte(newContent), 0644); err != nil { + return nil, fmt.Errorf("failed to write %s: %w", shellConfig.ConfigPath, err) + } + + return &InstallResult{ + ExitCode: ExitSuccess, + Message: fmt.Sprintf("Added PATH export to %s", shellConfig.ConfigPath), + BackupPath: backupPath, + ConfigPath: shellConfig.ConfigPath, + WhatChanged: "path_export", + }, nil +} + +// EnsureBinaryInstalled installs the binary and adds PATH export if needed. +// This is a convenience method for use by prompt and agent installers. +func (b *BinaryInstaller) EnsureBinaryInstalled(shellConfig *ShellConfig, dryRun bool) (*InstallResult, error) { + config, err := b.GetBinaryConfig(shellConfig) + if err != nil { + return &InstallResult{ + ExitCode: ExitError, + Message: fmt.Sprintf("Warning: %v", err), + }, nil + } + + // Install binary + result, err := b.InstallBinary(config, dryRun) + if err != nil { + return &InstallResult{ + ExitCode: ExitError, + Message: fmt.Sprintf("Warning: %v", err), + }, nil + } + + // Add PATH export if needed + if config.PathNeedsExport && shellConfig != nil { + pathResult, err := b.AddPathExport(shellConfig, dryRun) + if err != nil { + result.Message += fmt.Sprintf("; PATH export failed: %v", err) + } else if pathResult.WhatChanged != "" { + result.Message += "; " + pathResult.Message + } + } + + return result, nil +} diff --git a/src/install/binary_test.go b/src/install/binary_test.go new file mode 100644 index 0000000..253f226 --- /dev/null +++ b/src/install/binary_test.go @@ -0,0 +1,696 @@ +package install + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestIsNonPreferredHomebrewDir(t *testing.T) { + tests := []struct { + dir string + expected bool + }{ + {"/opt/homebrew/bin", false}, // acceptable + {"/opt/homebrew/lib/ruby/gems/3.3.0/bin", true}, // non-preferred + {"/opt/homebrew/opt/openjdk@21/bin", true}, // non-preferred + {"/opt/homebrew/Cellar/python@3.11/bin", true}, // non-preferred + {"/usr/local/bin", false}, // not homebrew + {"/home/user/bin", false}, // not homebrew + {"/opt/homebrew", false}, // just prefix, not a subdir + } + + for _, tt := range tests { + t.Run(tt.dir, func(t *testing.T) { + result := isNonPreferredHomebrewDir(tt.dir) + if result != tt.expected { + t.Errorf("isNonPreferredHomebrewDir(%q) = %v, want %v", tt.dir, result, tt.expected) + } + }) + } +} + +func TestBinaryInstaller_FindExistingDashlightsInPath(t *testing.T) { + fs := NewMockFilesystem() + fs.PathEnv = "/usr/bin:/home/testuser/bin:/usr/local/bin" + // Simulate dashlights existing in /home/testuser/bin + fs.Files["/home/testuser/bin/dashlights"] = []byte("binary") + + bi := NewBinaryInstaller(fs) + existingDir := bi.findExistingDashlightsInPath() + + if existingDir != "/home/testuser/bin" { + t.Errorf("expected /home/testuser/bin, got %s", existingDir) + } +} + +func TestBinaryInstaller_FindExistingDashlightsInPath_NotFound(t *testing.T) { + fs := NewMockFilesystem() + fs.PathEnv = "/usr/bin:/home/testuser/bin:/usr/local/bin" + // No dashlights binary exists + + bi := NewBinaryInstaller(fs) + existingDir := bi.findExistingDashlightsInPath() + + if existingDir != "" { + t.Errorf("expected empty string, got %s", existingDir) + } +} + +func TestBinaryInstaller_FindInstallDir_RespectsExistingLocation(t *testing.T) { + fs := NewMockFilesystem() + // Dashlights already exists in /usr/local/bin (even though it's a system dir) + fs.PathEnv = "/home/testuser/bin:/usr/local/bin:/usr/bin" + fs.Files["/usr/local/bin/dashlights"] = []byte("existing binary") + fs.WritableDirs["/home/testuser/bin"] = true + + bi := NewBinaryInstaller(fs) + dir, needsExport, err := bi.FindInstallDir() + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should use existing location even though /home/testuser/bin is writable + if dir != "/usr/local/bin" { + t.Errorf("expected /usr/local/bin (existing location), got %s", dir) + } + if needsExport { + t.Error("expected needsExport=false for existing location") + } +} + +func TestBinaryInstaller_FindInstallDir_SkipsHomebrewSubdirs(t *testing.T) { + fs := NewMockFilesystem() + // PATH has homebrew subdirs before the preferred dir + fs.PathEnv = "/opt/homebrew/lib/ruby/gems/3.3.0/bin:/opt/homebrew/opt/openjdk@21/bin:/home/testuser/bin" + fs.WritableDirs["/opt/homebrew/lib/ruby/gems/3.3.0/bin"] = true + fs.WritableDirs["/opt/homebrew/opt/openjdk@21/bin"] = true + fs.WritableDirs["/home/testuser/bin"] = true + + bi := NewBinaryInstaller(fs) + dir, needsExport, err := bi.FindInstallDir() + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should skip homebrew subdirs and use /home/testuser/bin + if dir != "/home/testuser/bin" { + t.Errorf("expected /home/testuser/bin (skipping homebrew subdirs), got %s", dir) + } + if needsExport { + t.Error("expected needsExport=false") + } +} + +func TestBinaryInstaller_FindInstallDir_AllowsHomebrewBin(t *testing.T) { + fs := NewMockFilesystem() + // PATH has /opt/homebrew/bin which should be allowed + fs.PathEnv = "/opt/homebrew/bin:/usr/bin" + fs.WritableDirs["/opt/homebrew/bin"] = true + + bi := NewBinaryInstaller(fs) + dir, needsExport, err := bi.FindInstallDir() + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should use /opt/homebrew/bin (it's acceptable) + if dir != "/opt/homebrew/bin" { + t.Errorf("expected /opt/homebrew/bin, got %s", dir) + } + if needsExport { + t.Error("expected needsExport=false") + } +} + +func TestBinaryInstaller_FindInstallDir_WritableInPath(t *testing.T) { + fs := NewMockFilesystem() + fs.PathEnv = "/usr/bin:/home/testuser/bin:/usr/local/bin" + fs.WritableDirs["/home/testuser/bin"] = true + + bi := NewBinaryInstaller(fs) + dir, needsExport, err := bi.FindInstallDir() + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dir != "/home/testuser/bin" { + t.Errorf("expected /home/testuser/bin, got %s", dir) + } + if needsExport { + t.Error("expected needsExport=false for writable dir in PATH") + } +} + +func TestBinaryInstaller_FindInstallDir_FallbackToLocalBin(t *testing.T) { + fs := NewMockFilesystem() + fs.PathEnv = "/usr/bin:/usr/local/bin" + // No writable dirs + + bi := NewBinaryInstaller(fs) + dir, needsExport, err := bi.FindInstallDir() + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := filepath.Join(fs.HomeDir, ".local/bin") + if dir != expected { + t.Errorf("expected %s, got %s", expected, dir) + } + if !needsExport { + t.Error("expected needsExport=true when falling back to ~/.local/bin") + } +} + +func TestBinaryInstaller_FindInstallDir_LocalBinAlreadyInPath(t *testing.T) { + fs := NewMockFilesystem() + localBin := filepath.Join(fs.HomeDir, ".local/bin") + fs.PathEnv = "/usr/bin:" + localBin + ":/usr/local/bin" + // ~/.local/bin is in PATH but not writable (doesn't exist) + + bi := NewBinaryInstaller(fs) + dir, needsExport, err := bi.FindInstallDir() + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dir != localBin { + t.Errorf("expected %s, got %s", localBin, dir) + } + if needsExport { + t.Error("expected needsExport=false when ~/.local/bin already in PATH") + } +} + +func TestBinaryInstaller_FindInstallDir_SkipsSystemDirs(t *testing.T) { + fs := NewMockFilesystem() + fs.PathEnv = "/usr/bin:/bin:/usr/local/bin:/home/testuser/bin" + // Make system dirs writable - they should still be skipped + fs.WritableDirs["/usr/bin"] = true + fs.WritableDirs["/bin"] = true + fs.WritableDirs["/usr/local/bin"] = true + fs.WritableDirs["/home/testuser/bin"] = true + + bi := NewBinaryInstaller(fs) + dir, _, err := bi.FindInstallDir() + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should skip system dirs and use the user's bin + if dir != "/home/testuser/bin" { + t.Errorf("expected /home/testuser/bin (skipping system dirs), got %s", dir) + } +} + +func TestBinaryInstaller_CheckBinaryState_NotInstalled(t *testing.T) { + fs := NewMockFilesystem() + bi := NewBinaryInstaller(fs) + + config := &BinaryConfig{ + SourcePath: "/tmp/dashlights", + TargetPath: "/home/testuser/.local/bin/dashlights", + } + + state, err := bi.CheckBinaryState(config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if state != BinaryNotInstalled { + t.Errorf("expected BinaryNotInstalled, got %d", state) + } +} + +func TestBinaryInstaller_CheckBinaryState_Installed(t *testing.T) { + fs := NewMockFilesystem() + // Same content = same version + fs.Files["/tmp/dashlights"] = []byte("binary content v1") + fs.Files["/home/testuser/.local/bin/dashlights"] = []byte("binary content v1") + + bi := NewBinaryInstaller(fs) + config := &BinaryConfig{ + SourcePath: "/tmp/dashlights", + TargetPath: "/home/testuser/.local/bin/dashlights", + } + + state, err := bi.CheckBinaryState(config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if state != BinaryInstalled { + t.Errorf("expected BinaryInstalled, got %d", state) + } +} + +func TestBinaryInstaller_CheckBinaryState_Outdated(t *testing.T) { + fs := NewMockFilesystem() + // Different content = different version + fs.Files["/tmp/dashlights"] = []byte("binary content v2") + fs.Files["/home/testuser/.local/bin/dashlights"] = []byte("binary content v1") + + bi := NewBinaryInstaller(fs) + config := &BinaryConfig{ + SourcePath: "/tmp/dashlights", + TargetPath: "/home/testuser/.local/bin/dashlights", + } + + state, err := bi.CheckBinaryState(config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if state != BinaryOutdated { + t.Errorf("expected BinaryOutdated, got %d", state) + } +} + +func TestBinaryInstaller_CheckBinaryState_Symlink(t *testing.T) { + fs := NewMockFilesystem() + fs.Files["/tmp/dashlights"] = []byte("binary content") + // Target is a symlink + fs.Symlinks["/home/testuser/.local/bin/dashlights"] = "/opt/dashlights/bin/dashlights" + // Also add to Files so Exists returns true + fs.Files["/home/testuser/.local/bin/dashlights"] = []byte("symlink target") + + bi := NewBinaryInstaller(fs) + config := &BinaryConfig{ + SourcePath: "/tmp/dashlights", + TargetPath: "/home/testuser/.local/bin/dashlights", + } + + state, err := bi.CheckBinaryState(config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if state != BinaryIsSymlink { + t.Errorf("expected BinaryIsSymlink, got %d", state) + } +} + +func TestBinaryInstaller_CompareVersions_Same(t *testing.T) { + fs := NewMockFilesystem() + fs.Files["/src/binary"] = []byte("identical content") + fs.Files["/dst/binary"] = []byte("identical content") + + bi := NewBinaryInstaller(fs) + same, err := bi.CompareVersions("/src/binary", "/dst/binary") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !same { + t.Error("expected files to be the same") + } +} + +func TestBinaryInstaller_CompareVersions_DifferentSize(t *testing.T) { + fs := NewMockFilesystem() + fs.Files["/src/binary"] = []byte("short") + fs.Files["/dst/binary"] = []byte("this is longer content") + + bi := NewBinaryInstaller(fs) + same, err := bi.CompareVersions("/src/binary", "/dst/binary") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if same { + t.Error("expected files to be different (different sizes)") + } +} + +func TestBinaryInstaller_CompareVersions_SameSizeDifferentContent(t *testing.T) { + fs := NewMockFilesystem() + fs.Files["/src/binary"] = []byte("content A") + fs.Files["/dst/binary"] = []byte("content B") + + bi := NewBinaryInstaller(fs) + same, err := bi.CompareVersions("/src/binary", "/dst/binary") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if same { + t.Error("expected files to be different (same size, different content)") + } +} + +func TestBinaryInstaller_InstallBinary_Success(t *testing.T) { + fs := NewMockFilesystem() + fs.Files["/tmp/dashlights"] = []byte("binary content") + fs.Modes["/tmp/dashlights"] = 0755 + + bi := NewBinaryInstaller(fs) + config := &BinaryConfig{ + SourcePath: "/tmp/dashlights", + TargetDir: "/home/testuser/.local/bin", + TargetPath: "/home/testuser/.local/bin/dashlights", + } + + result, err := bi.InstallBinary(config, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.ExitCode != ExitSuccess { + t.Errorf("expected ExitSuccess, got %d", result.ExitCode) + } + if result.WhatChanged != "install" { + t.Errorf("expected WhatChanged=install, got %s", result.WhatChanged) + } + + // Verify binary was copied + if _, ok := fs.Files["/home/testuser/.local/bin/dashlights"]; !ok { + t.Error("binary was not copied to target") + } +} + +func TestBinaryInstaller_InstallBinary_AlreadyInstalled(t *testing.T) { + fs := NewMockFilesystem() + fs.Files["/tmp/dashlights"] = []byte("binary content") + fs.Files["/home/testuser/.local/bin/dashlights"] = []byte("binary content") + + bi := NewBinaryInstaller(fs) + config := &BinaryConfig{ + SourcePath: "/tmp/dashlights", + TargetDir: "/home/testuser/.local/bin", + TargetPath: "/home/testuser/.local/bin/dashlights", + } + + result, err := bi.InstallBinary(config, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.ExitCode != ExitSuccess { + t.Errorf("expected ExitSuccess, got %d", result.ExitCode) + } + if result.WhatChanged != "" { + t.Errorf("expected no change, got WhatChanged=%s", result.WhatChanged) + } +} + +func TestBinaryInstaller_InstallBinary_Update(t *testing.T) { + fs := NewMockFilesystem() + fs.Files["/tmp/dashlights"] = []byte("new binary v2") + fs.Files["/home/testuser/.local/bin/dashlights"] = []byte("old binary v1") + fs.Modes["/tmp/dashlights"] = 0755 + + bi := NewBinaryInstaller(fs) + config := &BinaryConfig{ + SourcePath: "/tmp/dashlights", + TargetDir: "/home/testuser/.local/bin", + TargetPath: "/home/testuser/.local/bin/dashlights", + } + + result, err := bi.InstallBinary(config, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.ExitCode != ExitSuccess { + t.Errorf("expected ExitSuccess, got %d", result.ExitCode) + } + if result.WhatChanged != "update" { + t.Errorf("expected WhatChanged=update, got %s", result.WhatChanged) + } + if result.BackupPath == "" { + t.Error("expected backup to be created") + } + + // Verify binary was updated + content := string(fs.Files["/home/testuser/.local/bin/dashlights"]) + if content != "new binary v2" { + t.Errorf("binary was not updated, got content: %s", content) + } +} + +func TestBinaryInstaller_InstallBinary_DryRun(t *testing.T) { + fs := NewMockFilesystem() + fs.Files["/tmp/dashlights"] = []byte("binary content") + + bi := NewBinaryInstaller(fs) + config := &BinaryConfig{ + SourcePath: "/tmp/dashlights", + TargetDir: "/home/testuser/.local/bin", + TargetPath: "/home/testuser/.local/bin/dashlights", + } + + result, err := bi.InstallBinary(config, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.ExitCode != ExitSuccess { + t.Errorf("expected ExitSuccess, got %d", result.ExitCode) + } + if result.WhatChanged != "install" { + t.Errorf("expected WhatChanged=install for dry run, got %s", result.WhatChanged) + } + + // Verify binary was NOT copied + if _, ok := fs.Files["/home/testuser/.local/bin/dashlights"]; ok { + t.Error("binary should not be copied in dry run mode") + } +} + +func TestBinaryInstaller_InstallBinary_SymlinkWarning(t *testing.T) { + fs := NewMockFilesystem() + fs.Files["/tmp/dashlights"] = []byte("binary content") + fs.Files["/home/testuser/.local/bin/dashlights"] = []byte("symlink placeholder") + fs.Symlinks["/home/testuser/.local/bin/dashlights"] = "/opt/dashlights" + + bi := NewBinaryInstaller(fs) + config := &BinaryConfig{ + SourcePath: "/tmp/dashlights", + TargetDir: "/home/testuser/.local/bin", + TargetPath: "/home/testuser/.local/bin/dashlights", + } + + result, err := bi.InstallBinary(config, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.ExitCode != ExitSuccess { + t.Errorf("expected ExitSuccess, got %d", result.ExitCode) + } + // Should not have changed anything + if result.WhatChanged != "" { + t.Errorf("expected no change for symlink, got WhatChanged=%s", result.WhatChanged) + } +} + +func TestBinaryInstaller_AddPathExport_Success(t *testing.T) { + fs := NewMockFilesystem() + fs.Files["/home/testuser/.bashrc"] = []byte("# existing content\n") + + bi := NewBinaryInstaller(fs) + shellConfig := &ShellConfig{ + Shell: ShellBash, + ConfigPath: "/home/testuser/.bashrc", + } + + result, err := bi.AddPathExport(shellConfig, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.ExitCode != ExitSuccess { + t.Errorf("expected ExitSuccess, got %d", result.ExitCode) + } + if result.WhatChanged != "path_export" { + t.Errorf("expected WhatChanged=path_export, got %s", result.WhatChanged) + } + + // Verify PATH export was added + content := string(fs.Files["/home/testuser/.bashrc"]) + if !strings.Contains(content, PathSentinelBegin) { + t.Error("PATH export sentinel not found in config") + } + if !strings.Contains(content, "export PATH=") { + t.Error("PATH export statement not found in config") + } +} + +func TestBinaryInstaller_AddPathExport_AlreadyPresent(t *testing.T) { + fs := NewMockFilesystem() + fs.Files["/home/testuser/.bashrc"] = []byte("# existing\n" + PathSentinelBegin + "\nexport PATH\n" + PathSentinelEnd + "\n") + + bi := NewBinaryInstaller(fs) + shellConfig := &ShellConfig{ + Shell: ShellBash, + ConfigPath: "/home/testuser/.bashrc", + } + + result, err := bi.AddPathExport(shellConfig, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.ExitCode != ExitSuccess { + t.Errorf("expected ExitSuccess, got %d", result.ExitCode) + } + // Should not have changed anything + if result.WhatChanged != "" { + t.Errorf("expected no change when already present, got WhatChanged=%s", result.WhatChanged) + } +} + +func TestBinaryInstaller_AddPathExport_DryRun(t *testing.T) { + fs := NewMockFilesystem() + originalContent := "# existing content\n" + fs.Files["/home/testuser/.bashrc"] = []byte(originalContent) + + bi := NewBinaryInstaller(fs) + shellConfig := &ShellConfig{ + Shell: ShellBash, + ConfigPath: "/home/testuser/.bashrc", + } + + result, err := bi.AddPathExport(shellConfig, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.WhatChanged != "path_export" { + t.Errorf("expected WhatChanged=path_export for dry run, got %s", result.WhatChanged) + } + + // Verify config was NOT modified + content := string(fs.Files["/home/testuser/.bashrc"]) + if content != originalContent { + t.Error("config should not be modified in dry run mode") + } +} + +func TestBinaryInstaller_CheckPathExportState_NotInstalled(t *testing.T) { + fs := NewMockFilesystem() + fs.Files["/home/testuser/.bashrc"] = []byte("# normal config\n") + + bi := NewBinaryInstaller(fs) + state, err := bi.CheckPathExportState("/home/testuser/.bashrc") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if state != NotInstalled { + t.Errorf("expected NotInstalled, got %d", state) + } +} + +func TestBinaryInstaller_CheckPathExportState_FullyInstalled(t *testing.T) { + fs := NewMockFilesystem() + fs.Files["/home/testuser/.bashrc"] = []byte(PathSentinelBegin + "\n" + PathSentinelEnd + "\n") + + bi := NewBinaryInstaller(fs) + state, err := bi.CheckPathExportState("/home/testuser/.bashrc") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if state != FullyInstalled { + t.Errorf("expected FullyInstalled, got %d", state) + } +} + +func TestBinaryInstaller_CheckPathExportState_PartialInstall(t *testing.T) { + fs := NewMockFilesystem() + // Only begin marker, no end + fs.Files["/home/testuser/.bashrc"] = []byte(PathSentinelBegin + "\nexport PATH\n") + + bi := NewBinaryInstaller(fs) + state, err := bi.CheckPathExportState("/home/testuser/.bashrc") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if state != PartialInstall { + t.Errorf("expected PartialInstall, got %d", state) + } +} + +func TestBinaryInstaller_GetBinaryConfig(t *testing.T) { + fs := NewMockFilesystem() + fs.ExecutablePath = "/tmp/downloaded/dashlights" + fs.Files["/tmp/downloaded/dashlights"] = []byte("binary") + fs.PathEnv = "/usr/bin:/home/testuser/bin" + fs.WritableDirs["/home/testuser/bin"] = true + + bi := NewBinaryInstaller(fs) + shellConfig := &ShellConfig{ + Shell: ShellBash, + ConfigPath: "/home/testuser/.bashrc", + } + + config, err := bi.GetBinaryConfig(shellConfig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if config.SourcePath != "/tmp/downloaded/dashlights" { + t.Errorf("expected source path /tmp/downloaded/dashlights, got %s", config.SourcePath) + } + if config.TargetDir != "/home/testuser/bin" { + t.Errorf("expected target dir /home/testuser/bin, got %s", config.TargetDir) + } + if config.PathNeedsExport { + t.Error("expected PathNeedsExport=false for writable dir in PATH") + } +} + +func TestBinaryInstaller_GetBinaryConfig_ExecutableError(t *testing.T) { + fs := NewMockFilesystem() + fs.ExecutableErr = os.ErrNotExist + + bi := NewBinaryInstaller(fs) + _, err := bi.GetBinaryConfig(nil) + + if err == nil { + t.Error("expected error when executable path cannot be determined") + } +} + +func TestBinaryInstaller_EnsureBinaryInstalled(t *testing.T) { + fs := NewMockFilesystem() + fs.ExecutablePath = "/tmp/dashlights" + fs.Files["/tmp/dashlights"] = []byte("binary") + fs.PathEnv = "/home/testuser/bin" + fs.WritableDirs["/home/testuser/bin"] = true + + bi := NewBinaryInstaller(fs) + shellConfig := &ShellConfig{ + Shell: ShellBash, + ConfigPath: "/home/testuser/.bashrc", + } + + result, err := bi.EnsureBinaryInstalled(shellConfig, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.ExitCode != ExitSuccess { + t.Errorf("expected ExitSuccess, got %d", result.ExitCode) + } + + // Verify binary was copied + if _, ok := fs.Files["/home/testuser/bin/dashlights"]; !ok { + t.Error("binary was not installed") + } +} + +func TestGetPathExportTemplate(t *testing.T) { + tests := []struct { + shell ShellType + contains string + }{ + {ShellBash, "export PATH="}, + {ShellZsh, "export PATH="}, + {ShellFish, "fish_add_path"}, + } + + for _, tt := range tests { + t.Run(string(tt.shell), func(t *testing.T) { + template := GetPathExportTemplate(tt.shell) + if !strings.Contains(template, tt.contains) { + t.Errorf("template for %s should contain %q", tt.shell, tt.contains) + } + if !strings.Contains(template, PathSentinelBegin) { + t.Error("template should contain begin sentinel") + } + if !strings.Contains(template, PathSentinelEnd) { + t.Error("template should contain end sentinel") + } + }) + } +} diff --git a/src/install/filesystem.go b/src/install/filesystem.go index fd956c2..cdd305c 100644 --- a/src/install/filesystem.go +++ b/src/install/filesystem.go @@ -3,9 +3,11 @@ package install import ( + "io" "io/fs" "os" "path/filepath" + "strings" "time" ) @@ -14,11 +16,17 @@ type Filesystem interface { ReadFile(path string) ([]byte, error) WriteFile(path string, data []byte, perm os.FileMode) error Stat(path string) (os.FileInfo, error) + Lstat(path string) (os.FileInfo, error) // for symlink detection Exists(path string) bool MkdirAll(path string, perm os.FileMode) error Rename(src, dst string) error UserHomeDir() (string, error) Getenv(key string) string + Executable() (string, error) // returns path of running binary + CopyFile(src, dst string) error // copy file preserving permissions + Chmod(path string, mode os.FileMode) error + SplitPath() []string // splits $PATH by os.PathListSeparator + IsWritable(path string) bool // checks if directory is writable } // OSFilesystem implements Filesystem using real OS operations. @@ -65,6 +73,81 @@ func (f *OSFilesystem) Getenv(key string) string { return os.Getenv(key) } +// Lstat returns file info without following symlinks. +func (f *OSFilesystem) Lstat(path string) (os.FileInfo, error) { + return os.Lstat(path) +} + +// Executable returns the path of the running binary. +func (f *OSFilesystem) Executable() (string, error) { + return os.Executable() +} + +// CopyFile copies a file from src to dst, preserving permissions. +func (f *OSFilesystem) CopyFile(src, dst string) error { + srcFile, err := os.Open(filepath.Clean(src)) + if err != nil { + return err + } + defer srcFile.Close() + + srcInfo, err := srcFile.Stat() + if err != nil { + return err + } + + dstFile, err := os.OpenFile(filepath.Clean(dst), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode()) + if err != nil { + return err + } + + _, copyErr := io.Copy(dstFile, srcFile) + closeErr := dstFile.Close() + if copyErr != nil { + return copyErr + } + return closeErr +} + +// Chmod changes the mode of a file. +func (f *OSFilesystem) Chmod(path string, mode os.FileMode) error { + return os.Chmod(path, mode) +} + +// SplitPath returns the directories in $PATH. +func (f *OSFilesystem) SplitPath() []string { + pathEnv := os.Getenv("PATH") + if pathEnv == "" { + return nil + } + return filepath.SplitList(pathEnv) +} + +// IsWritable checks if a directory is writable by the current user. +func (f *OSFilesystem) IsWritable(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + if !info.IsDir() { + return false + } + // Try to create a temp file to test writability + testFile := filepath.Join(filepath.Clean(path), ".dashlights-write-test") + file, err := os.Create(testFile) + if err != nil { + return false + } + if err := file.Close(); err != nil { + return false + } + if err := os.Remove(testFile); err != nil { + // File was created but couldn't be removed - still writable + return true + } + return true +} + // MockFilesystem implements Filesystem for testing. type MockFilesystem struct { Files map[string][]byte @@ -72,22 +155,35 @@ type MockFilesystem struct { EnvVars map[string]string HomeDir string + // Binary installation support + ExecutablePath string // path returned by Executable() + Symlinks map[string]string // path -> target (for symlink detection) + WritableDirs map[string]bool // dir path -> writable + PathEnv string // value for PATH (overrides EnvVars["PATH"]) + // Error simulation - ReadFileErr error - WriteFileErr error - StatErr error - MkdirAllErr error - RenameErr error - HomeDirErr error + ReadFileErr error + WriteFileErr error + StatErr error + MkdirAllErr error + RenameErr error + HomeDirErr error + ExecutableErr error + CopyFileErr error + ChmodErr error + LstatErr 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", + Files: make(map[string][]byte), + Modes: make(map[string]os.FileMode), + EnvVars: make(map[string]string), + HomeDir: "/home/testuser", + Symlinks: make(map[string]string), + WritableDirs: make(map[string]bool), + ExecutablePath: "/usr/local/bin/dashlights", } } @@ -178,6 +274,80 @@ func (f *MockFilesystem) Getenv(key string) string { return f.EnvVars[key] } +// Lstat returns mock file info, detecting symlinks. +func (f *MockFilesystem) Lstat(path string) (os.FileInfo, error) { + if f.LstatErr != nil { + return nil, f.LstatErr + } + // Check if it's a symlink + if target, isSymlink := f.Symlinks[path]; isSymlink { + return &mockFileInfo{ + name: filepath.Base(path), + size: int64(len(target)), + mode: os.ModeSymlink | 0777, + }, nil + } + // Fall back to regular Stat behavior + return f.Stat(path) +} + +// Executable returns the mock executable path. +func (f *MockFilesystem) Executable() (string, error) { + if f.ExecutableErr != nil { + return "", f.ExecutableErr + } + return f.ExecutablePath, nil +} + +// CopyFile copies a file in the mock filesystem. +func (f *MockFilesystem) CopyFile(src, dst string) error { + if f.CopyFileErr != nil { + return f.CopyFileErr + } + content, ok := f.Files[src] + if !ok { + return os.ErrNotExist + } + f.Files[dst] = make([]byte, len(content)) + copy(f.Files[dst], content) + if mode, ok := f.Modes[src]; ok { + f.Modes[dst] = mode + } else { + f.Modes[dst] = 0755 // default for binary + } + return nil +} + +// Chmod changes the mode of a file in the mock filesystem. +func (f *MockFilesystem) Chmod(path string, mode os.FileMode) error { + if f.ChmodErr != nil { + return f.ChmodErr + } + if _, ok := f.Files[path]; !ok { + return os.ErrNotExist + } + f.Modes[path] = mode + return nil +} + +// SplitPath returns the directories in the mock PATH. +func (f *MockFilesystem) SplitPath() []string { + pathEnv := f.PathEnv + if pathEnv == "" { + pathEnv = f.EnvVars["PATH"] + } + if pathEnv == "" { + return nil + } + return strings.Split(pathEnv, string(os.PathListSeparator)) +} + +// IsWritable checks if a directory is writable in the mock filesystem. +func (f *MockFilesystem) IsWritable(path string) bool { + writable, ok := f.WritableDirs[path] + return ok && writable +} + // mockFileInfo implements os.FileInfo for testing. type mockFileInfo struct { name string diff --git a/src/install/install.go b/src/install/install.go index 367c7e9..492e849 100644 --- a/src/install/install.go +++ b/src/install/install.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" ) @@ -18,6 +19,7 @@ const ( // InstallOptions contains options for installation. type InstallOptions struct { + InstallAll bool // Unified install (binary, prompt, agents) InstallPrompt bool InstallAgent string // Agent name (claude, cursor) ConfigPathOverride string // If set, overrides auto-detected config path @@ -36,36 +38,39 @@ type InstallResult struct { // 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 + fs Filesystem + shellInstall *ShellInstaller + agentInstall *AgentInstaller + binaryInstall *BinaryInstaller + 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, + fs: fs, + shellInstall: NewShellInstaller(fs), + agentInstall: NewAgentInstaller(fs), + binaryInstall: NewBinaryInstaller(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, + fs: fs, + shellInstall: NewShellInstaller(fs), + agentInstall: NewAgentInstaller(fs), + binaryInstall: NewBinaryInstaller(fs), + stdin: os.Stdin, + stdout: os.Stdout, + stderr: os.Stderr, } } @@ -84,6 +89,11 @@ func (i *Installer) Run(opts InstallOptions) ExitCode { return ExitError } + // Unified install handles everything + if opts.InstallAll { + return i.runUnifiedInstall(opts) + } + if opts.InstallPrompt { return i.runPromptInstall(opts) } @@ -247,3 +257,253 @@ func (i *Installer) confirm(prompt string) bool { response = strings.TrimSpace(strings.ToLower(response)) return response == "y" || response == "yes" } + +// installComponent represents a component to install during unified installation. +type installComponent struct { + name string + action string // Description of what will be done + targetPath string + execute func() (*InstallResult, error) +} + +// runUnifiedInstall handles the unified --install flag. +func (i *Installer) runUnifiedInstall(opts InstallOptions) ExitCode { + var components []installComponent + var warnings []string + + // Get shell configuration first (needed for binary PATH export) + shellConfig, shellErr := i.shellInstall.GetShellConfig("") + if shellErr != nil { + warnings = append(warnings, fmt.Sprintf("Shell detection failed: %v", shellErr)) + } + + // 1. Binary installation (always first) + binaryConfig, binaryErr := i.binaryInstall.GetBinaryConfig(shellConfig) + if binaryErr != nil { + warnings = append(warnings, fmt.Sprintf("Binary config failed: %v (skipping binary installation)", binaryErr)) + } else { + action := "Install binary" + state, stateErr := i.binaryInstall.CheckBinaryState(binaryConfig) + if stateErr != nil { + warnings = append(warnings, fmt.Sprintf("Binary state check failed: %v", stateErr)) + } + switch state { + case BinaryInstalled: + action = "Binary already installed (up to date)" + case BinaryOutdated: + action = "Update binary" + case BinaryIsSymlink: + action = "Binary is symlink (will skip)" + } + + pathAction := "" + if binaryConfig.PathNeedsExport { + pathAction = "; add ~/.local/bin to PATH" + } + + components = append(components, installComponent{ + name: "Binary", + action: action + pathAction, + targetPath: binaryConfig.TargetPath, + execute: func() (*InstallResult, error) { + return i.binaryInstall.EnsureBinaryInstalled(shellConfig, opts.DryRun) + }, + }) + } + + // 2. Shell prompt (always, if shell detected) + if shellConfig != nil { + promptState, promptErr := i.shellInstall.CheckInstallState(shellConfig) + if promptErr != nil { + warnings = append(warnings, fmt.Sprintf("Prompt state check failed: %v", promptErr)) + } + action := "Add dashlights prompt function" + if promptState == FullyInstalled { + action = "Already installed" + } + + components = append(components, installComponent{ + name: "Shell Prompt", + action: action, + targetPath: shellConfig.ConfigPath, + execute: func() (*InstallResult, error) { + return i.shellInstall.Install(shellConfig, opts.DryRun) + }, + }) + } + + // 3. Detect and add supported agents + homeDir, homeErr := i.fs.UserHomeDir() + if homeErr != nil { + warnings = append(warnings, fmt.Sprintf("Cannot determine home directory: %v", homeErr)) + } else { + // Claude Code (if ~/.claude exists) + claudeDir := filepath.Join(homeDir, ".claude") + if i.fs.Exists(claudeDir) { + claudeConfig, err := i.agentInstall.GetAgentConfig(AgentClaude) + if err != nil { + warnings = append(warnings, fmt.Sprintf("Claude config error: %v", err)) + } else { + action := "Add PreToolUse hook" + if installed, installErr := i.agentInstall.IsInstalled(claudeConfig); installErr != nil { + warnings = append(warnings, fmt.Sprintf("Claude install check failed: %v", installErr)) + } else if installed { + action = "Already installed" + } + + components = append(components, installComponent{ + name: "Claude Code", + action: action, + targetPath: claudeConfig.ConfigPath, + execute: func() (*InstallResult, error) { + return i.agentInstall.Install(claudeConfig, opts.DryRun, opts.NonInteractive) + }, + }) + } + } + + // Cursor (if ~/.cursor exists) + cursorDir := filepath.Join(homeDir, ".cursor") + if i.fs.Exists(cursorDir) { + cursorConfig, err := i.agentInstall.GetAgentConfig(AgentCursor) + if err != nil { + warnings = append(warnings, fmt.Sprintf("Cursor config error: %v", err)) + } else { + action := "Add beforeShellExecution hook" + if installed, installErr := i.agentInstall.IsInstalled(cursorConfig); installErr != nil { + warnings = append(warnings, fmt.Sprintf("Cursor install check failed: %v", installErr)) + } else if installed { + action = "Already installed" + } else { + // Check for conflict + existingCmd, hasConflict, conflictErr := i.agentInstall.CheckCursorConflict(cursorConfig) + if conflictErr != nil { + warnings = append(warnings, fmt.Sprintf("Cursor conflict check failed: %v", conflictErr)) + } else if hasConflict { + action = fmt.Sprintf("Replace existing hook (%s)", existingCmd) + } + } + + components = append(components, installComponent{ + name: "Cursor", + action: action, + targetPath: cursorConfig.ConfigPath, + execute: func() (*InstallResult, error) { + return i.agentInstall.Install(cursorConfig, opts.DryRun, opts.NonInteractive) + }, + }) + } + } + } + + // Nothing to install? + if len(components) == 0 { + fmt.Fprintln(i.stderr, "Error: No components to install") + for _, w := range warnings { + fmt.Fprintf(i.stderr, " - %s\n", w) + } + return ExitError + } + + // Interactive confirmation + if !opts.NonInteractive && !opts.DryRun { + if !i.confirmUnifiedInstall(components, warnings) { + fmt.Fprintln(i.stdout, "Installation cancelled.") + return ExitError + } + } + + // Execute installations + return i.executeUnifiedInstall(components, warnings, opts.DryRun, shellConfig) +} + +// confirmUnifiedInstall shows the unified install preview and asks for confirmation. +func (i *Installer) confirmUnifiedInstall(components []installComponent, warnings []string) bool { + fmt.Fprintln(i.stdout, "Dashlights Unified Installation") + fmt.Fprintln(i.stdout, "================================") + fmt.Fprintln(i.stdout, "") + fmt.Fprintln(i.stdout, "The following components will be installed:") + fmt.Fprintln(i.stdout, "") + + for _, c := range components { + fmt.Fprintf(i.stdout, " %s:\n", c.name) + fmt.Fprintf(i.stdout, " Action: %s\n", c.action) + if c.targetPath != "" { + fmt.Fprintf(i.stdout, " Target: %s\n", c.targetPath) + } + fmt.Fprintln(i.stdout, "") + } + + if len(warnings) > 0 { + fmt.Fprintln(i.stdout, "Warnings:") + for _, w := range warnings { + fmt.Fprintf(i.stdout, " - %s\n", w) + } + fmt.Fprintln(i.stdout, "") + } + + return i.confirm("Proceed?") +} + +// executeUnifiedInstall runs all installation components and reports results. +func (i *Installer) executeUnifiedInstall(components []installComponent, warnings []string, dryRun bool, shellConfig *ShellConfig) ExitCode { + fmt.Fprintln(i.stdout, "Installing dashlights...") + fmt.Fprintln(i.stdout, "") + + var results []string + var errors []string + hasChanges := false + + for idx, c := range components { + result, err := c.execute() + if err != nil { + errors = append(errors, fmt.Sprintf("%s: %v", c.name, err)) + results = append(results, fmt.Sprintf(" [%d/%d] %s: Failed - %v", idx+1, len(components), c.name, err)) + } else { + results = append(results, fmt.Sprintf(" [%d/%d] %s: %s", idx+1, len(components), c.name, result.Message)) + if result.WhatChanged != "" { + hasChanges = true + } + } + } + + // Print results + for _, r := range results { + fmt.Fprintln(i.stdout, r) + } + fmt.Fprintln(i.stdout, "") + + // Print warnings + if len(warnings) > 0 || len(errors) > 0 { + if len(errors) > 0 { + fmt.Fprintln(i.stdout, "Errors:") + for _, e := range errors { + fmt.Fprintf(i.stdout, " - %s\n", e) + } + } + if len(warnings) > 0 { + fmt.Fprintln(i.stdout, "Warnings:") + for _, w := range warnings { + fmt.Fprintf(i.stdout, " - %s\n", w) + } + } + fmt.Fprintln(i.stdout, "") + } + + // Show next steps + if hasChanges && !dryRun { + fmt.Fprintln(i.stdout, "Next steps:") + if shellConfig != nil { + fmt.Fprintf(i.stdout, " Restart your shell or run: source %s\n", shellConfig.ConfigPath) + } + fmt.Fprintln(i.stdout, " Restart any AI coding assistants to apply hook changes") + } + + if len(errors) > 0 { + fmt.Fprintln(i.stdout, "Installation completed with errors.") + return ExitError + } + + fmt.Fprintln(i.stdout, "Installation complete!") + return ExitSuccess +} diff --git a/src/install/templates.go b/src/install/templates.go index 8b1ee3f..7cdba10 100644 --- a/src/install/templates.go +++ b/src/install/templates.go @@ -75,6 +75,44 @@ func GetShellTemplate(templateType TemplateType) string { } } +// PATH export templates for adding ~/.local/bin to PATH. +const ( + // BashPathExport adds ~/.local/bin to PATH in bash. + BashPathExport = `# BEGIN dashlights-path +# Added by dashlights installer +export PATH="$HOME/.local/bin:$PATH" +# END dashlights-path +` + + // ZshPathExport adds ~/.local/bin to PATH in zsh. + ZshPathExport = `# BEGIN dashlights-path +# Added by dashlights installer +export PATH="$HOME/.local/bin:$PATH" +# END dashlights-path +` + + // FishPathExport adds ~/.local/bin to PATH in fish. + FishPathExport = `# BEGIN dashlights-path +# Added by dashlights installer +fish_add_path $HOME/.local/bin +# END dashlights-path +` +) + +// GetPathExportTemplate returns the PATH export template for a shell type. +func GetPathExportTemplate(shell ShellType) string { + switch shell { + case ShellBash: + return BashPathExport + case ShellZsh: + return ZshPathExport + case ShellFish: + return FishPathExport + default: + return "" + } +} + // Agent configuration templates. const ( // ClaudeHookJSON is the hook configuration to add to Claude's settings. diff --git a/src/main.go b/src/main.go index 601b34d..cff127f 100644 --- a/src/main.go +++ b/src/main.go @@ -48,6 +48,7 @@ type cliArgs struct { 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)."` + Install bool `arg:"--install,help:Install dashlights (binary + prompt + detected AI agents)."` 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)."` @@ -87,8 +88,8 @@ func main() { } } - // Install mode: handle --installprompt or --installagent - if args.InstallPrompt || args.InstallAgent != "" { + // Install mode: handle --install, --installprompt, or --installagent + if args.Install || args.InstallPrompt || args.InstallAgent != "" { os.Exit(int(runInstallMode())) } @@ -711,11 +712,12 @@ func displayDebugInfo(w io.Writer, envStart, envEnd, sigStart, sigEnd time.Time, flexPrintln(w, "\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") } -// runInstallMode handles the --installprompt and --installagent flags. +// runInstallMode handles the --install, --installprompt, and --installagent flags. func runInstallMode() install.ExitCode { installer := install.NewInstaller() opts := install.InstallOptions{ + InstallAll: args.Install, InstallPrompt: args.InstallPrompt, InstallAgent: args.InstallAgent, ConfigPathOverride: args.ConfigPath,