diff --git a/.github/INSTALLER_CI_README.md b/.github/INSTALLER_CI_README.md new file mode 100644 index 0000000..c0b5b44 --- /dev/null +++ b/.github/INSTALLER_CI_README.md @@ -0,0 +1,97 @@ +# Installer CI Documentation + +This directory contains GitHub Actions workflows for the dotfiles installer project located in the `installer/` subdirectory. + +## Workflow Overview + +### πŸ”§ installer-ci.yml +**Purpose**: Build and test the installer +**Trigger**: Push to main or PRs affecting `installer/` directory + +**Jobs (in order)**: +1. **Build**: Uses GoReleaser to create cross-platform binaries +2. **Test**: Runs Go test suite with race detection +3. **E2E Tests**: Tests installer on multiple platforms in real environments + +**Platforms Tested**: +- Ubuntu (latest) +- Debian (bookworm container) +- macOS (latest) + +## Build Process + +The CI uses GoReleaser in **snapshot mode** to: +- Build cross-platform binaries (Linux/macOS, AMD64/ARM64) +- Generate consistent build artifacts +- **No releases** - just builds for testing + +## E2E Testing + +The pipeline includes end-to-end testing that: + +1. **Downloads** the built installer binary +2. **Tests** on multiple OS distributions +3. **Verifies** compatibility detection works +4. **Runs** installer in non-interactive mode +5. **Validates** graceful behavior in CI environments + +### Test Configuration + +E2E tests use these flags for CI compatibility: +- `--non-interactive`: Skips all user prompts +- `--plain`: Disables progress indicators for cleaner logs +- `--install-brew=false`: Skips Homebrew for faster testing +- `--install-prerequisites=false`: Skips prerequisite installation +- `--git-clone-protocol=https`: Uses HTTPS instead of SSH + +### Test Environment + +- **Isolated**: Uses `/tmp/test-home` as HOME directory +- **Timeout**: 300 seconds (5 minutes) to prevent hangs +- **Graceful Failures**: Expected in CI since we don't have full system setup + +## Local Testing + +To test the workflow locally: + +```bash +cd installer/ +task build +./bin/dotfiles-installer install --non-interactive --plain --install-brew=false +``` + +## Workflow Structure + +``` +.github/ +└── workflows/ + └── installer-ci.yml # Main CI pipeline +``` + +## Troubleshooting + +### Common Issues + +1. **Build Fails**: Check Go version in `installer/go.mod` +2. **E2E Test Fails**: Review platform-specific requirements +3. **Test Timeout**: E2E tests timeout after 5 minutes + +### Debugging E2E Tests + +E2E tests are designed to handle CI environment limitations: +- Allow expected failures (exit code 1) in restricted environments +- Create isolated test directories +- Skip complex system modifications + +To debug locally: +```bash +export HOME="/tmp/test-home" +mkdir -p "$HOME" +./installer/bin/dotfiles-installer install --non-interactive --plain +``` + +## Future Enhancements + +- **Linting**: Will integrate golangci-lint later +- **Security Scanning**: Can add Trivy scans if needed +- **Release Automation**: Will add when ready for releases \ No newline at end of file diff --git a/.github/instructions/go-code-style.instructions.md b/.github/instructions/go-code-style.instructions.md new file mode 100644 index 0000000..49cbf29 --- /dev/null +++ b/.github/instructions/go-code-style.instructions.md @@ -0,0 +1,38 @@ +--- +applyTo: "**/*.go" +--- + +# Go Coding Style + +## General Guidelines + +- Use the Go standard library whenever possible. Only use third-party libraries when necessary. +- Limit line length to 120 characters. +- Write code that is easy to test: + - Use interfaces to decouple components and improve testability. + - Use dependency injection to pass dependencies into functions and methods. + - Wrap even basic operations (such as OS functions and file operations) in interfaces to make them easier to mock and test. +- After each struct definition, verify interface implementation by adding: + `var _ InterfaceName = (*StructName)(nil)` +- Provide a constructor function for each struct, named `NewStructName`. + - Place this function immediately after the struct definition and the interface assertion line (if present). +- Format code for readability: + - Vertically align function arguments when there are multiple arguments. + - Insert blank lines between logical sections of code. + - Do not separate error unwrapping from related code with a blank line; treat it as part of the same section. +- End all type and function comments with a period, following Go conventions. +- Pre-allocate collections (such as slices and maps) to their expected size when possible to reduce memory allocations and improve performance. + +## Main Tech Stack + +- [lipgloss]: Go library for creating visually appealing command-line applications. Used for the installer's CLI. +- [cobra]: Go library for building command-line applications. Used for the installer's CLI. +- [viper]: Go library for reading configuration files. Used for the installer's configuration management. +- [goreleaser]: Go tool for building and releasing Go applications. Used for building and releasing the installer. +- [gh-actions] (GitHub Actions): CI/CD tool for automating build and release processes. Used for building and releasing the installer. + +[lipgloss]: https://github.com/charmbracelet/lipgloss +[cobra]: https://github.com/spf13/cobra +[viper]: https://github.com/spf13/viper +[goreleaser]: https://github.com/goreleaser/goreleaser +[gh-actions]: https://github.com/features/actions diff --git a/.github/instructions/go-test-style.instructions.md b/.github/instructions/go-test-style.instructions.md new file mode 100644 index 0000000..be155c9 --- /dev/null +++ b/.github/instructions/go-test-style.instructions.md @@ -0,0 +1,51 @@ +--- +applyTo: "**/*.go" +--- + +# Go Test Style + +## General Guidelines + +- Place tests in the test package of the package being tested. + - For example, if the package is `lib`, the test package should be named `lib_test`. +- Use the `testify` library for all testing in Go. + - Always use the `require` package from `testify` when checking the `error` type. +- Each test should verify a single behavior or property. Do not test multiple behaviors in a single test. +- Name tests based on their behavior, using descriptive and natural language. + - Example: `Test_CompatibilityConfigCanBeLoadedFromFile` checks if the `CompatibilityConfig` struct can be loaded from a file. + - Test names should describe what the test does and what it verifies, not implementation details. + - Use the `Test_` prefix for test functions. + - If a condition is crucial to the test, include it in the test name. + - Example: `Test_CompatibilityConfigCanBeLoadedFromFile_WhenFileExists` indicates that the test checks loading from an existing file. + - Separate different conditions with underscores. +- Use table-driven tests when appropriate. Table-driven tests define a set of inputs and expected outputs in a table, and iterate over the table to run the tests. This pattern makes it easy to add new test cases and keeps the code clean and maintainable. + +## Types of Tests + +### Unit Tests + +- Unit tests verify a single function or method in isolation. +- Use mocks to isolate the function or method being tested. + - Use the [moq](https://github.com/matryer/moq) package to generate mocks. + +### Integration Tests + +- Integration tests verify the interaction between multiple functions or methods. +- Integration tests also cover OS-dependent interactions (anything beyond CPU and memory). +- For every integration test, allow opting out by using `testing.Short()`. + - This is useful for running only unit tests in CI/CD pipelines. + +## Testing Tech Stack + +- [testify]: A Go library for writing tests and assertions. +- [moq]: A Go library for generating mocks for testing. +- [mockery]: A Go library for generating mock objects. It is used to generate mock objects in the project. + +## Using Mocks + +- Use [mockery] to generate mocks for interfaces. Run the command `mockery` (with no arguments) in the root directory of the Go module (for example, the `go-port` directory). +- In test code, use the generated mocks to test your code. The generated mocks are compatible with the `moq` library, so you can use `moq` features in your tests. + +[testify]: https://github.com/stretchr/testify +[moq]: https://github.com/matryer/moq +[mockery]: https://github.com/vektra/mockery diff --git a/.github/prompts/installer-context.prompt.md b/.github/prompts/installer-context.prompt.md new file mode 100644 index 0000000..5cd8832 --- /dev/null +++ b/.github/prompts/installer-context.prompt.md @@ -0,0 +1,51 @@ +# Prompt for working on the installer + +## General Instructions + +- You're an expert in Operating Systems, UNIX shells, and Go. +- You're passionate about dotfiles. +- You specialize in configuration management and automation. + +## Context + +### What is this repository? + +This repository is my personal dotfiles repository. It uses [chezmoi][chezmoi] to manage dotfiles +and configurations across multiple machines. The goal is to have a consistent and easily maintainable setup. +It also contains an "installer" to bootstrap the dotfiles on a new machine, as [chezmoi][chezmoi] alone is +not enough to set up a new machine. + +### What is the installer? + +Currently, there are two types of installers: + +1. **Shell Installer**: A shell script that installs the necessary dependencies and sets up the environment. +2. **Go Installer**: A Go program that does the same thing as the shell installer but is written in Go. + +This is temporary as the goal is to move the shell installer to Go. The shell installer is +currently the main installer, but the Go installer is being developed to replace it. + +### How does the shell installer works? + +The shell installer is a 2-step process: + +1. Bootstrap script: This script is run first as it is written in POSIX shell and can be run on any + platform. It installs the absolute minimum dependencies required to run the main installer, + and checks for compatibility. It is located at [`install.sh`](../../install.sh). +2. Main installer: This is the main installer that installs the necessary dependencies and sets up + the environment. It is located at [`install-impl.sh`](../../install-impl.sh). + It is written in bash 4. + +### What is the goal of the installer? + +The goal of the installer is to set up a new machine with the necessary dependencies and configurations +to run the dotfiles. This includes installing [chezmoi][chezmoi], setting up the environment, and +configuring the shell. The installer should be easy to use and should work on multiple platforms (Linux, macOS, etc.). +The installer should also be able to detect the platform and install the necessary dependencies accordingly. + +## What should you do? + +Analyze the current state of the installer to understand how much of it has been ported to Go. +Then, ask me what I want to do next. + +[chezmoi]: https://chezmoi.io/ diff --git a/.github/workflows/installer-ci.yml b/.github/workflows/installer-ci.yml new file mode 100644 index 0000000..4bf3c1a --- /dev/null +++ b/.github/workflows/installer-ci.yml @@ -0,0 +1,264 @@ +name: Installer CI + +on: + pull_request: + paths: + - "installer/**" + - ".github/workflows/installer-ci.yml" + push: + branches: + - main + paths: + - "installer/**" + - ".github/workflows/installer-ci.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + build: + name: Build + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: installer/go.mod + + - name: Cache Go Dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('installer/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download Dependencies + run: go mod download + working-directory: installer + + - name: Run GoReleaser Build + uses: goreleaser/goreleaser-action@v6 + with: + version: latest + args: build --clean --snapshot + workdir: installer + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: Upload Build Artifacts + uses: actions/upload-artifact@v4 + with: + name: build-artifacts + path: installer/dist/ + if-no-files-found: error # We expect build artifacts to be generated + retention-days: 1 + compression-level: 0 # Binaries are hard to compress + overwrite: true + + test: + name: Test + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: installer/go.mod + + - name: Cache Go Dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('installer/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download Dependencies + run: go mod download + working-directory: installer + + - name: Run Tests + run: go test -race -v ./... + working-directory: installer + + e2e-test: + name: E2E Tests + needs: build + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: ubuntu + - os: ubuntu-latest + platform: debian + container: debian:bookworm + - os: macos-latest + platform: macos + + runs-on: ${{ matrix.os }} + container: ${{ matrix.container }} + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Download Build Artifacts + uses: actions/download-artifact@v4 + with: + name: build-artifacts + path: installer/dist/ + + - name: Set up Prerequisites (macOS) + if: matrix.platform == 'macos' + run: | + # Install coreutils for `timeout` command + brew install coreutils + + - name: Make Binary Executable + run: | + # Find the correct binary for the current platform + if [ "${{ matrix.platform }}" = "macos" ]; then + if [ "$(uname -m)" = "arm64" ]; then + BINARY_PATH="installer/dist/dotfiles_installer_darwin_arm64_v8.0/dotfiles-installer" + else + BINARY_PATH="installer/dist/dotfiles_installer_darwin_amd64_v1/dotfiles-installer" + fi + else + # Linux platforms + if [ "$(uname -m)" = "aarch64" ]; then + BINARY_PATH="installer/dist/dotfiles_installer_linux_arm64_v8.0/dotfiles-installer" + else + BINARY_PATH="installer/dist/dotfiles_installer_linux_amd64_v1/dotfiles-installer" + fi + fi + + echo "Using binary: $BINARY_PATH" + chmod +x "$BINARY_PATH" + cp "$BINARY_PATH" ./dotfiles-installer + + - name: Test Binary Help + run: ./dotfiles-installer --help + + - name: Test Install Command Help + run: ./dotfiles-installer install --help + + - name: Run Compatibility Check + run: | + # Create temporary file to capture output while showing it in real-time + temp_output=$(mktemp) + + echo "Running compatibility check..." + + # Use tee to both display output and capture it, handle exit code properly + set +e # Don't exit on command failure + ./dotfiles-installer check-compatibility --non-interactive --plain 2>&1 | tee "$temp_output" + exit_code=$? + set -e # Re-enable exit on error + + echo "Exit code: $exit_code" + + # Read the captured output + output=$(cat "$temp_output") + rm -f "$temp_output" + + # Check if output contains "missing prerequisites" - this is expected and should pass + if echo "$output" | grep -i "missing prerequisites" >/dev/null 2>&1; then + echo "βœ… Found 'missing prerequisites' in output - this is expected behavior" + exit 0 + fi + + # If exit code is 0, that's also a pass + if [ $exit_code -eq 0 ]; then + echo "βœ… Compatibility check passed with exit code 0" + exit 0 + fi + + # Any other scenario is unexpected + echo "❌ Unexpected compatibility check result - exit code: $exit_code" + exit $exit_code + + - name: Test Non-Interactive Installation + run: | + echo "Testing installer in non-interactive mode..." + + # Set up test environment + export HOME="/tmp/test-home" + mkdir -p "$HOME" + + # Create a fake GPG directory to simulate existing setup + mkdir -p "$HOME/.gnupg" + chmod 700 "$HOME/.gnupg" + + # Run the installer with minimal configuration that shouldn't require interaction + # We use https as the git clone protocol to avoid SSH key prompts + timeout 300 ./dotfiles-installer install \ + --non-interactive \ + --plain \ + --extra-verbose \ + --install-prerequisites=true \ + --git-clone-protocol=https \ + --work-env=false \ + --multi-user-system=false + + - name: Install expect tool + run: | + if [ "${{ matrix.platform }}" = "macos" ]; then + brew install expect + else + # For Ubuntu/Debian - check if sudo exists, otherwise run directly (containers) + if command -v sudo >/dev/null 2>&1; then + sudo apt-get update && sudo apt-get install -y expect + else + apt-get update && apt-get install -y expect + fi + fi + + - name: Test Interactive GPG Installation + run: | + echo "Testing installer in interactive mode with expect automation..." + + # Set up separate test environment for interactive test + export HOME="/tmp/test-interactive-home" + mkdir -p "$HOME" + + # Create a fake GPG directory to simulate existing setup + mkdir -p "$HOME/.gnupg" + chmod 700 "$HOME/.gnupg" + + # Run the expect script with test parameters + installer/test-interactive-gpg.exp \ + "./dotfiles-installer" \ + "test-user@example.com" \ + "Test CI User" \ + "test-ci-passphrase" + + - name: Verify Installation Artifacts (if created) + run: | + echo "Checking for any artifacts created during installation..." + ls -la /tmp/test-home/ || echo "No test home directory created" + ls -la /tmp/test-interactive-home/ || echo "No interactive test home directory created" + + # Check if any dotfiles manager was initialized + ls -la /tmp/test-home/.local/share/chezmoi/ 2>/dev/null || echo "No chezmoi directory found (expected in CI)" + ls -la /tmp/test-interactive-home/.local/share/chezmoi/ 2>/dev/null || echo "No interactive chezmoi directory found (expected in CI)" diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6586e4c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "go.testFlags": [ + "-v", + "-count=1" + ], +} diff --git a/README.md b/README.md index aa5cf24..889591a 100644 --- a/README.md +++ b/README.md @@ -2,78 +2,124 @@ ## Motivation -Like any other dotfiles project, I'm looking to create myself -a templated solution that will help me apply it on new environments, mostly Unix ones. -I'm using a dotfiles manager alongside some custom shell scripts to achieve this, -managing both home and office/work environments. +Like any other dotfiles project, I'm looking to create myself a templated solution that will help me +apply it on new environments, mostly Unix ones. +I'm using a dotfiles manager alongside a custom installer binary to achieve this, +managing both home and office/work environments. ## Installation -To install, simply run one of the following commands -in the environment you'd like to install in: +The dotfiles are installed using a dedicated Go binary that handles system setup and configuration. -| Tool | Command | -| ---- | ---------------------------------------------------------------------------------------------- | -| Curl | `bash -c "$(curl -fsSL https://raw.githubusercontent.com/MrPointer/dotfiles/main/install.sh)"` | -| Wget | `bash -c "$(wget -O- https://raw.githubusercontent.com/MrPointer/dotfiles/main/install.sh)"` | +### Build from Source -The bootstrap script will take you through a configuration process -that would query some required info, and then will install the dotfiles manager and apply it. +Clone the repository and build the installer: + +```bash +git clone https://github.com/MrPointer/dotfiles.git +cd dotfiles/installer +go build -o dotfiles-installer . +./dotfiles-installer install +``` + +### Using Go Install + +If you have Go installed: + +```bash +go install github.com/MrPointer/dotfiles/installer@main +dotfiles-installer install +``` + +### Pre-built Releases + +Pre-built binaries will be available in GitHub releases once the first version is tagged. +Until then, use the build-from-source method above. + +## Usage + +The installer provides several commands and options: + +### Basic Commands + +- `dotfiles-installer install` - Install dotfiles on the current system +- `dotfiles-installer check-compatibility` - Check system compatibility +- `dotfiles-installer --help` - Show all available commands and options ### Installation Options -The following options can be passed to the command above to customize the installation: - -| Option | Description | -| ----------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `-v` or `--verbose` | Enable verbose output | -| `--ref=[git-ref]` | Reference the given git-ref for installation (can be any git ref - commit, branch, tag). Defaults to `main` | -| `--local` | Use local installation instead of remote. This is useful for testing purposes. | -| `--work-env` | Treat this installation as a work environment | -| `--work-name` | Use the given work-name as the work environment. Defaults to `sedg` (current workplace) | -| `--work-email=[email]` | Use given email address as work's email address. Defaults to `timor.gruber@solaredge.com` | -| `--shell=[shell]` | Install given shell if required and set it as user's default. Defaults to `zsh`. | -| `--no-brew` | Don't install `brew` (Homebrew) | -| `--brew-shell` | Install shell using `brew`. By default it's installed with system's package manager | -| `--prefer-package-manager` | Prefer installing tools with system's package manager rather than brew (Doesn't apply for Mac) | -| `--package-manager=[manager]` | Package manager to use for installing prerequisites | -| `--multi-user-system` | Take into account that the system is used by multiple users | -| `--git-via-https` | Use HTTPS for git operations instead of SSH | -| `--git-via-ssh` | Use SSH for git operations instead of HTTPS (default) | - -To add options to the install command above, append it after the last closing parentheses `)`, like so: -`bash -c "$(curl -fsSL https://raw.githubusercontent.com/MrPointer/dotfiles/main/install.sh) --verbose"` +The following options can be passed to the `install` command: + +| Option | Description | Default | +| --------------------------- | --------------------------------------------- | ---------------------------- | +| `--work-env` | Treat this installation as a work environment | `false` | +| `--work-name` | Work environment name | `sedg` | +| `--work-email` | Work email address | `timor.gruber@solaredge.com` | +| `--shell` | Shell to install and set as default | `zsh` | +| `--install-brew` | Install Homebrew if not present | `true` | +| `--install-shell-with-brew` | Install shell using Homebrew | `true` | +| `--multi-user-system` | Configure for multi-user system | `false` | +| `--git-clone-protocol` | Git protocol for operations | `https` | +| `--install-prerequisites` | Automatically install missing prerequisites | `false` | + +### Global Options + +These options work with any command: + +| Option | Description | +| ------------------- | --------------------------------------------------- | +| `-v, --verbose` | Enable verbose output (use `-vv` for extra verbose) | +| `--plain` | Show plain text instead of progress indicators | +| `--non-interactive` | Disable interactive prompts | +| `--extra-verbose` | Enable maximum verbosity | + +### Example Usage + +**Basic installation:** + +```bash +./dotfiles-installer install +``` + +**Work environment setup:** + +```bash +./dotfiles-installer install --work-env --work-email your.email@company.com +``` + +**Non-interactive installation with prerequisites:** + +```bash +./dotfiles-installer install --non-interactive --install-prerequisites --git-clone-protocol=https +``` + +**Check system compatibility first:** + +```bash +./dotfiles-installer check-compatibility +./dotfiles-installer install +``` ## Overview ### Dotfiles Manager -I'm using a dedicated dotfiles manager, [chezmoi][chezmoi-url], which provides templating abilities, -per-machine differences, and a lot more. -Other managers might be considered in the future, especially if [chezmoi][chezmoi-url] becomes stale. +I'm using [chezmoi] as the dotfiles manager, which provides templating abilities, +per-machine differences, and much more. The installer sets up chezmoi and populates it with +the necessary configuration. ### Installation Process -#### Bootstrap - -To create a single-click installation experience, I'm bootstrapping the process -by making sure everything is available, and only then proceed with the actual installation. - -The main installation driver script is written in "Pure" shell, -guaranteed to work on almost all systems, even the strangest ones. -It checks whether `bash` is available, trying to install it if not. -The installation utilizes the guessed system package manager, e.g. `apt` for `Debian` systems. -If the installation fails for some reason, then the user is prompted to manually install `bash`. -After `bash` is properly installed, it's used to execute the actual installation script, written in `bash`. +The Go installer handles the complete setup process: -#### Actual Installation +1. **System Compatibility Check** - Verifies the system can run the dotfiles +2. **Prerequisites Installation** - Installs required tools and dependencies +3. **Homebrew Setup** - Installs Homebrew on macOS (optional on Linux) +4. **Shell Installation** - Installs and configures the specified shell +5. **GPG Setup** - Configures GPG keys for secure operations +6. **Dotfiles Manager Setup** - Installs and configures chezmoi +7. **Template Application** - Applies the dotfiles with user-specific configuration -The actual installation script installs the dotfiles manager in some way, preferably standalone, -creates a config file for it, prompting the user for some required info such as name and email, -and then *"applies"* the template. -The dotfiles manager can also install some additional packages on its own, depending on the configuration, -target machine, and the manager itself. -At last, the script tries to reinstall the dotfiles manager in a way that will get it updated, -but only if the user has configured it previously and have the correct package managers installed. +The installer provides real-time progress indicators and detailed logging to track the installation process. -[chezmoi-url]: https://www.chezmoi.io/ +[chezmoi]: https://www.chezmoi.io/ diff --git a/cspell.json b/cspell.json new file mode 100644 index 0000000..4ac51ec --- /dev/null +++ b/cspell.json @@ -0,0 +1,10 @@ +{ + "words": [ + "pkgmanager", + "httpclient", + "osmanager", + "NONINTERACTIVE", + "linuxbrew", + "chezmoi" + ] +} \ No newline at end of file diff --git a/docs/ai-sessions/race-condition-fix.md b/docs/ai-sessions/race-condition-fix.md new file mode 100644 index 0000000..cc7c6dc --- /dev/null +++ b/docs/ai-sessions/race-condition-fix.md @@ -0,0 +1,127 @@ +# Race Condition Fix: Progress Display Thread Safety + +## Problem Description + +The progress display implementation was experiencing race conditions when running tests with Go's race detector (`go test -race`). The race occurred between: + +1. **Writer goroutines**: `huh` spinner library (via `bubbletea`) writing terminal control sequences and content to the output buffer +2. **Reader goroutines**: Test code reading buffer content using `buffer.String()` to verify output + +## Root Cause Analysis + +The core issue was that `bytes.Buffer` is **not thread-safe** for concurrent reads and writes. Even though writes were happening through the spinner library, tests were directly reading from the same buffer concurrently, causing data races at the memory level. + +### Race Condition Details + +``` +WARNING: DATA RACE +Read at 0x... by goroutine 7: + bytes.(*Buffer).String() +Previous write at 0x... by goroutine 8: + bytes.(*Buffer).Write() +``` + +This occurred because: +- Multiple goroutines in `bubbletea`'s renderer were writing to the buffer +- Test goroutines were reading via `buffer.String()` simultaneously +- No synchronization existed between these operations + +## Solution Implementation + +### 1. Thread-Safe Buffer Wrapper + +Created `safeBytesBuffer` that wraps `*bytes.Buffer` with `sync.RWMutex`: + +```go +type safeBytesBuffer struct { + buf *bytes.Buffer + mutex sync.RWMutex +} + +func (sbb *safeBytesBuffer) Write(p []byte) (n int, err error) { + sbb.mutex.Lock() + defer sbb.mutex.Unlock() + return sbb.buf.Write(p) +} + +func (sbb *safeBytesBuffer) SafeString() string { + sbb.mutex.RLock() + defer sbb.mutex.RUnlock() + return sbb.buf.String() +} +``` + +### 2. Smart Output Writer Detection + +Modified `NewProgressDisplay` to automatically detect `*bytes.Buffer` and wrap it: + +```go +// Special handling for *bytes.Buffer to ensure thread safety +if buf, ok := output.(*bytes.Buffer); ok { + safeBuffer = &safeBytesBuffer{buf: buf} + outputWriter = safeBuffer +} else { + outputWriter = &synchronizedWriter{writer: output} +} +``` + +### 3. Safe Testing Interface + +Added `GetOutputSafely()` method for tests to safely read buffer content: + +```go +func (p *ProgressDisplay) GetOutputSafely() string { + if p.safeBuffer != nil { + return p.safeBuffer.SafeString() + } + return "" +} +``` + +## Testing Best Practices + +### ❌ Unsafe (Race Condition) +```go +func TestExample(t *testing.T) { + var output bytes.Buffer + display := NewProgressDisplay(&output) + + display.Start("Test") + // Race condition: concurrent read while spinner writes + content := output.String() +} +``` + +### βœ… Safe (Thread-Safe) +```go +func TestExample(t *testing.T) { + var output bytes.Buffer + display := NewProgressDisplay(&output) + + display.Start("Test") + // Thread-safe read using synchronized access + content := display.GetOutputSafely() +} +``` + +## Key Insights + +1. **Library Assumption**: The issue wasn't in the `huh` or `bubbletea` libraries themselves, but in **our usage pattern** where we exposed the same buffer to both the library (for writes) and tests (for reads) without proper synchronization. + +2. **Buffer Thread Safety**: `bytes.Buffer` is designed for single-threaded use. Concurrent access requires explicit synchronization. + +3. **Testing vs Production**: The race only manifested in tests because production code typically doesn't read from the output buffer while the progress display is running. + +## Resolution Verification + +- βœ… All tests pass with race detection enabled (`go test -race`) +- βœ… All tests pass in normal mode (`go test`) +- βœ… No performance degradation in production usage +- βœ… Backward compatibility maintained for existing code + +## Lessons Learned + +- Always consider concurrent access patterns when sharing mutable state between goroutines +- Race conditions can be subtle and may only appear in testing scenarios +- Thread-safe wrappers should protect both reads and writes, not just writes +- Go's race detector is invaluable for catching these issues early \ No newline at end of file diff --git a/install-impl.sh b/install-impl.sh deleted file mode 100755 index e26d07e..0000000 --- a/install-impl.sh +++ /dev/null @@ -1,919 +0,0 @@ -#!/usr/bin/env bash - -# saner programming env: these switches turn some bugs into errors -set -o pipefail -o nounset - -function show_usage { - cat <&2 -} -function info { - cecho "$BLUE_COLOR" "$@" -} -function success { - cecho "$GREEN_COLOR" "$@" -} - -### -# Join strings, just as in Python's str.join(). -# Arguments: -# $1 - String to join with (e.g. ',') -# $2..$N - Variable number of strings to join -# Output (stdout): -# Single string representing the joined string -### -function join_by { - local d=${1-} f=${2-} - if shift 2; then printf %s "$f" "${@/#/$d}"; fi -} - -### -# Retrieves the path to the given shell's user profile. -# Arguments: -# $1 - Name of the shell to retrieve for -# Output (stdout): -# Path to the given shell's user profile -# Returns: -# 0 on success, 1 if an unknown/unsupported shell has been specified -### -function get_shell_user_profile { - local shell_name="${1:-}" - - case "$shell_name" in - bash) - echo "${HOME}/.profile" - ;; - zsh) - echo "${HOME}/.zprofile" - ;; - *) - return 1 - ;; - esac -} - -### -# Checks whether the current user is root. -# Returns: -# 0 if the user is root, 1 otherwise. -### -function root_user { - local current_uid - current_uid=$(id -u) - - ((current_uid == 0)) -} - -function brew { - if [[ "$MULTI_USER_SYSTEM" == "false" ]]; then - command brew "$@" - return $? - else - sudo -Hu "$BREW_USER_ON_MULTI_USER_SYSTEM" "$DEFAULT_BREW_PATH" "$@" - return $? - fi -} - -function _install_packages_with_brew { - brew install "$@" -} - -function _install_packages_with_package_manager { - local packages=("$@") - - if [[ -z "$PACKAGE_MANAGER" ]]; then - error "Package manager not set, something went wrong. Please install packages manually." - return 1 - fi - - local install_package_cmd=() - if [[ "$ROOT_USER" == false ]]; then - install_package_cmd=(sudo) - fi - install_package_cmd+=("$PACKAGE_MANAGER" install -y "${packages[@]}") - - "${install_package_cmd[@]}" -} - -### -# Install given package(s) using either system's package manager or homebrew, depending on the passed options. -# Arguments: -# $1..$N - Variable number of packages to install -# Returns: -# Install tool's result, zero on success. -### -function install_packages { - if [[ "$PREFER_BREW_FOR_ALL_TOOLS" == true ]]; then - ! _install_packages_with_brew "$@" && return 1 - else - ! _install_packages_with_package_manager "$@" && return 1 - fi - return 0 -} - -### -# Reload target shell's user profile, to activate changes. -### -function _reload_shell_user_profile { - source "$SHELL_USER_PROFILE" -} - -function _reinstall_chezmoi_as_package { - [[ "$INSTALL_BREW" == false || "$BREW_INSTALLED_DOTFILES_MANAGER" == true ]] && return 0 - - if ! command -v brew &>/dev/null; then - warning "Brew is not available, deferring chezmoi installation as a brew package" - return 0 - fi - - local brew_chezmoi_installed=false - - if brew list | grep -q "$DOTFILES_MANAGER"; then - brew_chezmoi_installed=true - fi - - if [[ "$brew_chezmoi_installed" == false ]]; then - [ "$VERBOSE" == true ] && info "Installing $DOTFILES_MANAGER using brew" - - if ! _install_packages_with_brew "$DOTFILES_MANAGER"; then - error "Failed installing $DOTFILES_MANAGER using brew, will keep existing binary at $DOTFILES_MANAGER_STANDALONE_BINARY_PATH" - return 1 - fi - success "Successfully installed $DOTFILES_MANAGER as a brew package" - fi - - [ "$VERBOSE" == true ] && info "Removing standalone $DOTFILES_MANAGER binary" - if ! rm -f "$DOTFILES_MANAGER_STANDALONE_BINARY_PATH"; then - warning "Failed removing standalone chezmoi binary (downloaded at first) at $DOTFILES_MANAGER_STANDALONE_BINARY_PATH" - else - success "Successfully removed standalone chezmoi binary" - fi - - return 0 -} - -### -# Finalize installation by executing post-install commands. -### -function post_install { - if ! _reinstall_chezmoi_as_package; then - error "Failed reinstalling chezmoi as an updatable package" - # It's not a fatal error, we can proceed - fi - - if [[ "$SHELL_TO_INSTALL" == "bash" ]]; then - if ! _reload_shell_user_profile; then - warning "Failed reloading shell profile, please attempt a manual re-login" - fi - else - warning "You've installed a new shell, please re-login to apply changes" - fi - - return 0 -} - -### -# Apply dotfiles, optionally by using a dotfiles manager. -### -function apply_dotfiles { - # Always remove old dotfiles, if any, just in case - rm -rf "$DOTFILES_CLONE_PATH" || return 1 - - if [[ "$DEBUG" == true ]]; then - APPLY_DOTFILES_CMD+=("--verbose") - fi - - APPLY_DOTFILES_CMD+=(init --apply) - - if [[ "$DOTFILES_CLONING_PROTOCOL" == "ssh" ]]; then - APPLY_DOTFILES_CMD+=(--ssh) - fi - - APPLY_DOTFILES_CMD+=("$GITHUB_USERNAME") - - "${APPLY_DOTFILES_CMD[@]}" -} - -### -# Prepare dotfiles environment before applying dotfiles. -# This might be a useful step for some dotfiles managers. -### -function prepare_dotfiles_environment { - if ! mkdir -p "$ENVIRONMENT_TEMPLATE_CONFIG_DIR" &>/dev/null; then - error "Couldn't create environment's dotfiles config directory" - return 1 - fi - - # The first print zeroes the template file if it already has content - if ! printf "%s\n" "[data]" >"$ENVIRONMENT_TEMPLATE_FILE_PATH"; then - error "Failed initializing environment template file!" - return 2 - fi - - { - printf "%s\n" "[data.personal]" - printf "\t%s\n" "full_name = \"$FULL_NAME\"" - printf "\t%s\n" "email = \"$ACTIVE_EMAIL\"" - printf "\t%s\n" "signing_key = \"$ACTIVE_GPG_SIGNING_KEY\"" - printf "\t%s\n" "work_env = $WORK_ENVIRONMENT" - } >>"$ENVIRONMENT_TEMPLATE_FILE_PATH" - - if [[ "$WORK_ENVIRONMENT" == true ]]; then - printf "\t%s\n" "work_name = \"$WORK_NAME\"" >>"$ENVIRONMENT_TEMPLATE_FILE_PATH" - fi - - { - printf "%s\n" "[data.system]" - printf "\t%s\n" "shell = \"$SHELL_TO_INSTALL\"" - printf "\t%s\n" "user = \"$CURRENT_USER_NAME\"" - printf "\t%s\n" "multi_user_system = $MULTI_USER_SYSTEM" - printf "\t%s\n" "brew_multi_user = \"$BREW_USER_ON_MULTI_USER_SYSTEM\"" - } >>"$ENVIRONMENT_TEMPLATE_FILE_PATH" - - if [[ "$WORK_ENVIRONMENT" == true ]]; then - { - printf "\t%s\n" "work_generic_dotfiles_dir = \"${WORK_GENERIC_DOTFILES_DIR}\"" - printf "\t%s\n" "work_specific_dotfiles_dir = \"${WORK_SPECIFIC_DOTFILES_DIR}\"" - printf "\t%s\n" "work_generic_dotfiles_profile = \"${WORK_GENERIC_DOTFILES_PROFILE}\"" - printf "\t%s\n" "work_specific_dotfiles_profile = \"${WORK_SPECIFIC_DOTFILES_PROFILE}\"" - } >>"$ENVIRONMENT_TEMPLATE_FILE_PATH" - fi - - { - printf "%s\n" "[data.tools_preferences]" - printf "\t%s\n" "prefer_brew = $PREFER_BREW_FOR_ALL_TOOLS" - } >>"$ENVIRONMENT_TEMPLATE_FILE_PATH" - - for mandatory_dir in "${MANDATORY_DIRECTORIES_BEFORE_APPLYING_DOTFILES[@]}"; do - if ! mkdir -p "${mandatory_dir}"; then - error "Failed creating directory ${mandatory_dir} - It must exist before applying dotfiles!" - return 3 - fi - done - - return 0 -} - -function _create_new_gpg_key { - declare -n created_key="${1:?}" - - gpg --expert --full-gen-key || return 1 - created_key="$(gpg --list-secret-keys --keyid-format LONG | tr -s " " | awk -F"[ /]" '/^sec/ { print $3 }' | tail -n1)" || return 2 - return 0 -} - -function _verify_gpg_client_installation { - ! command -v gpg &>/dev/null && return 1 - ! command -v gpg-agent &>/dev/null && return 1 - - local gpg_version - gpg_version="$(gpg --version | head -n1 | cut -d' ' -f3)" - if ! grep -q "^2\.[^0-1]\." <<<"$gpg_version"; then - # gpg 2.2 or higher is NOT installed - warning "Installed gpg version ($gpg_version) is less than 2.2" - return 2 - fi - - return 0 -} - -function _install_gpg_client { - local rc - _verify_gpg_client_installation - rc=$? - - ((rc == 0)) && return 0 - - # Version is too low, nothing we can do for now - ((rc == 2)) && return 1 - - info "Installing gpg" - - if [[ "$SYSTEM_TYPE" == "darwin" ]]; then - if ! brew install gnupg; then - error "Failed installing gpg tools using brew" - return 2 - fi - else - sudo apt-get update - if ! sudo apt-get install -y --no-install-recommends gpg gpg-agent; then - error "Failed installing gpg tools using apt" - return 2 - fi - fi - - return 0 -} - -### -# Ensures a GPG key exist in order to be able to sign git commits in the future (and maybe do other stuff). -# If a key is not already available, a new one is created instead and will be used in all managed dotfiles. -# Otherwise, the user is asked whether to reuse an existing key, and if so which one. -# The user can also decide to create a new one nevertheless. -# The script requires some interactivity. -### -function ensure_gpg_key_exist { - info "Installing gpg client (if required)" - if ! _install_gpg_client; then - error "Failed installing gpg client" - return 1 - fi - success "Successfully installed gpg client" - - if gpg --list-secret-keys --keyid-format LONG | grep -q "sec"; then - info "GPG keys already available" - - info "Would you like to reuse one of the available keys?" - local answer - select answer in "Yes" "No"; do - case $answer in - [Yy]*) - local available_keys - mapfile -t available_keys < <(gpg --list-secret-keys --keyid-format LONG | tr -s " " | awk -F"[ /]" '/^sec/ { print $3 }') - - info "Select the key to reuse:" - local selected_key - select selected_key in "${available_keys[@]}"; do - warning "Using $selected_key as the GPG key" - ACTIVE_GPG_SIGNING_KEY="$selected_key" - return 0 - done - ;; - [Nn]*) - warning "Creating a new GPG key" - break - ;; - esac - done - fi - - local new_gpg_key - if ! _create_new_gpg_key new_gpg_key; then - error "Failed creating a new GPG key" - return 2 - fi - success "Successfully created a new GPG key" - - ACTIVE_GPG_SIGNING_KEY="$new_gpg_key" - return 0 -} - -### -# Install selected shell using either system's package manager or homebrew, depending on the passed options. -# If selected shell is already installed, do nothing. -# Otherwise, also configure it as user's default shell. -### -function install_shell { - if command -v "$SHELL_TO_INSTALL" &>/dev/null; then - return 0 - fi - - # First install our shell - if [[ "$INSTALL_SHELL_WITH_BREW" == true ]]; then - # User has insisted on installing it with brew, so we follow along - ! _install_packages_with_brew "$SHELL_TO_INSTALL" && return 1 - else - # Otherwise, we always use the system's package-manager, even if other tools are installed via brew - ! _install_packages_with_package_manager "$SHELL_TO_INSTALL" && return 2 - fi - - # Find installed shell's location - local shell_path - shell_path="$(which "$SHELL_TO_INSTALL")" - - # Then configure it as user's default shell - sudo chsh -s "$shell_path" "$CURRENT_USER_NAME" -} - -### -# Install Homebrew using their official standalone script. -# The script requires some interactivity. -### -function install_brew { - if [[ "$MULTI_USER_SYSTEM" == true ]]; then - if [[ "$SYSTEM_TYPE" == "darwin" ]]; then - error "We don't support multi-user systems on MacOS, please install brew manually" - return 1 - fi - - if ! id "$BREW_USER_ON_MULTI_USER_SYSTEM" &>/dev/null; then - info "Creating user '$BREW_USER_ON_MULTI_USER_SYSTEM' for brew" - local create_brew_user_cmd=(useradd -m -p "" "$BREW_USER_ON_MULTI_USER_SYSTEM") - if [[ "$ROOT_USER" == false ]]; then - create_brew_user_cmd=(sudo "${create_brew_user_cmd[@]}") - if ! "${create_brew_user_cmd[@]}"; then - error "Failed creating user '$BREW_USER_ON_MULTI_USER_SYSTEM' for brew" - return 1 - fi - if ! sudo usermod -aG sudo "$BREW_USER_ON_MULTI_USER_SYSTEM"; then - error "Failed adding user '$BREW_USER_ON_MULTI_USER_SYSTEM' to sudo group" - return 2 - fi - if ! echo "$BREW_USER_ON_MULTI_USER_SYSTEM ALL=(ALL) NOPASSWD:ALL" | sudo tee -a /etc/sudoers >/dev/null; then - error "Failed adding user '$BREW_USER_ON_MULTI_USER_SYSTEM' to passwordless-sudoers" - return 3 - fi - else - if ! "${create_brew_user_cmd[@]}"; then - error "Failed creating user '$BREW_USER_ON_MULTI_USER_SYSTEM' for brew" - return 1 - fi - fi - fi - - local brew_user_home_dir="/home/linuxbrew" - if [[ "$ROOT_USER" == false ]]; then - if ! sudo chown -R "$BREW_USER_ON_MULTI_USER_SYSTEM:$BREW_USER_ON_MULTI_USER_SYSTEM" "$brew_user_home_dir"; then - error "Failed changing ownership of $brew_user_home_dir to $BREW_USER_ON_MULTI_USER_SYSTEM" - return 4 - fi - else - if ! chown -R "$BREW_USER_ON_MULTI_USER_SYSTEM:$BREW_USER_ON_MULTI_USER_SYSTEM" "$brew_user_home_dir"; then - error "Failed changing ownership of $brew_user_home_dir to $BREW_USER_ON_MULTI_USER_SYSTEM" - return 4 - fi - fi - - if ! sudo -Hu "$BREW_USER_ON_MULTI_USER_SYSTEM" \ - bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"; then - return 5 - fi - - local brew_user_profile_file="/home/$BREW_USER_ON_MULTI_USER_SYSTEM/.profile" - { - # Load (home)brew - eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" - } | sudo -Hu "$BREW_USER_ON_MULTI_USER_SYSTEM" tee -a "$brew_user_profile_file" - else - if ! bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"; then - return 5 - fi - fi - - # Eval brew for current session to be able to use it later, if needed - eval "$($BREW_LOCATION_RESOLVING_CMD)" -} - -### -# Install chezmoi, our dotfiles manager. -# To avoid any errors and complicated checks, just install the latest binary at this stage. -### -function install_dotfiles_manager { - local dotfiles_manager_bin="" - if dotfiles_manager_bin="$(command -v "$DOTFILES_MANAGER" 2>/dev/null)"; then - info "$DOTFILES_MANAGER already installed at '$dotfiles_manager_bin', skipping" - elif [[ "$BREW_AVAILABLE" == true && -e "$DOTFILES_MANAGER_BREW_BINARY_PATH" ]]; then - info "$DOTFILES_MANAGER already installed with brew, skipping" - BREW_INSTALLED_DOTFILES_MANAGER=true - dotfiles_manager_bin="$DOTFILES_MANAGER_BREW_BINARY_PATH/bin/${DOTFILES_MANAGER}" - elif [[ -f "$DOTFILES_MANAGER_STANDALONE_BINARY_PATH" && -x "$DOTFILES_MANAGER_STANDALONE_BINARY_PATH" ]]; then - info "$DOTFILES_MANAGER already installed at '$DOTFILES_MANAGER_STANDALONE_BINARY_PATH', skipping" - dotfiles_manager_bin="$DOTFILES_MANAGER_STANDALONE_BINARY_PATH" - else - info "$DOTFILES_MANAGER not found on the system, installing it via the standalone installer" - dotfiles_manager_bin="" - fi - - if [[ -n "$dotfiles_manager_bin" ]]; then - APPLY_DOTFILES_CMD=("$dotfiles_manager_bin") - return 0 - else - APPLY_DOTFILES_CMD=("$DOTFILES_MANAGER_STANDALONE_BINARY_PATH") - fi - - local installation_failed=false - - local install_dir - if ! install_dir="$(dirname "$DOTFILES_MANAGER_STANDALONE_BINARY_PATH")"; then - error "Failed determining installation directory for $DOTFILES_MANAGER" - return 1 - fi - - if [[ "$DOWNLOAD_TOOL" == "curl" ]]; then - info "Installing $DOTFILES_MANAGER using curl" - if ! sh -c "$(curl -fsLS get.chezmoi.io)" -- -b "$install_dir"; then - error "Failed installing $DOTFILES_MANAGER using curl" - installation_failed=true - fi - elif [[ "$DOWNLOAD_TOOL" == "wget" ]]; then - info "Installing $DOTFILES_MANAGER using wget" - if ! sh -c "$(wget -qO- get.chezmoi.io)" -- -b "$install_dir"; then - error "Failed installing $DOTFILES_MANAGER using wget" - installation_failed=true - fi - else - error "Download tool not set, something went wrong, aborting" - return 1 - fi - - if [[ "$installation_failed" == true ]]; then - return 1 - fi - return 0 -} - -### -# Install dotfiles. This is the main "driver" function. -### -function install_dotfiles { - if [[ "$INSTALL_BREW" == true && "$BREW_AVAILABLE" == false ]]; then - info "Installing brew" - if ! install_brew; then - error "Failed installing brew" - return 2 - fi - success "Successfully installed brew" - fi - - info "Installing shell" - if ! install_shell; then - error "Failed installing shell" - return 2 - fi - success "Successfully installed $SHELL_TO_INSTALL" - - info "Ensuring a GPG key exists" - if ! ensure_gpg_key_exist; then - error "Failed ensuring a GPG key exists" - return 3 - fi - success "Successfully ensured a GPG key exists" - - info "Installing dotfiles manager ($DOTFILES_MANAGER)" - if ! install_dotfiles_manager; then - error "Failed installing dotfiles manager ($DOTFILES_MANAGER)" - return 1 - fi - success "Successfully installed dotfiles manager, $DOTFILES_MANAGER" - - info "Preparing dotfiles environment" - if ! prepare_dotfiles_environment; then - error "Failed preparing dotfiles environment" - return 4 - fi - success "Successfully prepared dotfiles environment" - - info "Applying dotfiles" - if ! apply_dotfiles; then - error "Failed applying dotfiles" - return 5 - fi - success "Successfully applied dotfiles" - - info "Finalizing installation" - if ! post_install; then - error "Failed finalizing installation" - return 6 - fi - success "Successfully finalized installation" - - return 0 -} - -function brew_available { - # Brew is installed at a different location on MacOS and Linux, we need to account for that - if [[ "$SYSTEM_TYPE" == "darwin" ]]; then - if [[ "$(uname -m)" == "arm64" ]]; then - DEFAULT_BREW_PATH="/opt/homebrew/bin/brew" - else - DEFAULT_BREW_PATH="/usr/local/bin/brew" - fi - fi - - if [[ "$MULTI_USER_SYSTEM" == true ]]; then - stat -c "%U" "$DEFAULT_BREW_PATH" | grep -q "$BREW_USER_ON_MULTI_USER_SYSTEM" || return 1 - return 0 - else - [[ -f "$DEFAULT_BREW_PATH" ]] && return 0 - fi -} - -### -# Checks which download tool is locally available from a preset list -# and outputs the first that has been found. -### -function get_download_tool { - local optional_download_tools=( - curl - wget - ) - - for download_tool in "${optional_download_tools[@]}"; do - if command -v "${download_tool}" &>/dev/null; then - echo "${download_tool}" - return 0 - fi - done - - echo "" - return 1 -} - -### -# Set global variables -### -function set_globals { - if [[ -n "$INSTALL_REF" ]]; then - APPLY_DOTFILES_CMD+=(--branch "$INSTALL_REF") - fi - - # Can't prefer to install with brew if brew should not even be installed - if [[ "$INSTALL_BREW" == false ]]; then - PREFER_BREW_FOR_ALL_TOOLS=false - else - brew_available && BREW_AVAILABLE=true - fi - - if ! DOWNLOAD_TOOL="$(get_download_tool)"; then - error "Couldn't determine download tool, aborting" - return 1 - else - info "$DOWNLOAD_TOOL available, using it for downloading files" - fi - - CURRENT_USER_NAME="$(id -u -n)" - - if root_user; then - ROOT_USER=true - fi - - if ! SHELL_USER_PROFILE="$(get_shell_user_profile "$SHELL_TO_INSTALL")"; then - error "Failed determining shell's user profile" - return 2 - fi - - if [[ "$WORK_ENVIRONMENT" == true ]]; then - ACTIVE_EMAIL="$WORK_EMAIL" - WORK_SPECIFIC_DOTFILES_DIR="${WORK_GENERIC_DOTFILES_DIR}/${WORK_NAME}" - WORK_SPECIFIC_DOTFILES_PROFILE="${WORK_SPECIFIC_DOTFILES_DIR}/profile" - else - ACTIVE_EMAIL="$PERSONAL_EMAIL" - fi -} - -### -# Parse arguments/options using getopt, the almighty C-based parser. -### -function parse_arguments { - getopt --test >/dev/null - if (($? != 4)); then - error "I'm sorry, 'getopt --test' failed in this environment." - return 1 - fi - - local short_options=hvd - local long_options=help,verbose,debug - long_options+=,ref:,local - long_options+=,work-env,work-name:,work-email: - long_options+=,shell:,brew-shell - long_options+=,no-brew,prefer-package-manager,package-manager: - long_options+=,system: - long_options+=,multi-user-system - long_options+=,git-via-https,git-via-ssh - - # -temporarily store output to be able to check for errors - # -activate quoting/enhanced mode (e.g. by writing out β€œ--options”) - # -pass arguments only via -- "$@" to separate them correctly - if ! PARSED=$( - getopt --options="$short_options" --longoptions="$long_options" \ - --name "Dotfiles installer" -- "$@" - ); then - # getopt has complained about wrong arguments to stdout - error "Wrong arguments to Dotfiles installer" && return 2 - fi - - # read getopt’s output this way to handle the quoting right: - eval set -- "$PARSED" - - while true; do - case $1 in - -h | --help) - show_usage - exit 0 - ;; - -v | --verbose) - VERBOSE=true - shift - ;; - -d | --debug) - DEBUG=true - shift - ;; - --ref) - INSTALL_REF="${2:-main}" - shift 2 - ;; - --local) - # This is a wrapper script option, not used here - shift - ;; - --work-env) - WORK_ENVIRONMENT=true - shift - ;; - --work-name) - WORK_ENVIRONMENT=true - [ -n "$2" ] && WORK_NAME="${2}" - shift 2 - ;; - --work-email) - [ -n "$2" ] && WORK_EMAIL="${2}" - WORK_ENVIRONMENT=true - shift 2 - ;; - --multi-user-system) - MULTI_USER_SYSTEM=true - shift - ;; - --shell) - SHELL_TO_INSTALL="${2:-}" - shift 2 - ;; - --brew-shell) - INSTALL_SHELL_WITH_BREW=true - shift - ;; - --no-brew) - INSTALL_BREW=false - shift - ;; - --prefer-package-manager) - PREFER_BREW_FOR_ALL_TOOLS=false - shift - ;; - --package-manager) - PACKAGE_MANAGER="${2:-}" - shift 2 - ;; - --system) - SYSTEM_TYPE="${2:-}" - shift 2 - ;; - --git-via-https) - DOTFILES_CLONING_PROTOCOL="https" - shift - ;; - --git-via-ssh) - DOTFILES_CLONING_PROTOCOL="ssh" - shift - ;; - --) - shift - break - ;; - *) - error "Programming error" - return 3 - ;; - esac - done - - return 0 -} - -function _set_work_info_defaults { - WORK_NAME="sedg" - WORK_GENERIC_DOTFILES_DIR="${HOME}/.work" - WORK_GENERIC_DOTFILES_PROFILE="${WORK_GENERIC_DOTFILES_DIR}/profile" -} - -function _set_package_management_defaults { - PACKAGE_MANAGER="" - - INSTALL_BREW=true - PREFER_BREW_FOR_ALL_TOOLS=true - DEFAULT_BREW_PATH="/home/linuxbrew/.linuxbrew/bin/brew" - BREW_LOCATION_RESOLVING_CMD="$DEFAULT_BREW_PATH shellenv" - BREW_AVAILABLE=false - BREW_INSTALLED_DOTFILES_MANAGER=false - BREW_USER_ON_MULTI_USER_SYSTEM="linuxbrew-manager" -} - -function _set_shell_defaults { - INSTALL_SHELL_WITH_BREW=false - SHELL_TO_INSTALL=zsh - SHELL_USER_PROFILE="" - SYSTEM_TYPE="" -} - -function _set_dotfiles_manager_defaults { - DOTFILES_MANAGER=chezmoi - DOTFILES_MANAGER_STANDALONE_BINARY_PATH="${HOME}/bin/${DOTFILES_MANAGER}" - DOTFILES_MANAGER_BREW_BINARY_PATH="/home/linuxbrew/.linuxbrew/opt/${DOTFILES_MANAGER}" - - DOTFILES_CLONING_PROTOCOL="ssh" - - DOTFILES_CLONE_PATH="${HOME}/.local/share/${DOTFILES_MANAGER}" - ENVIRONMENT_TEMPLATE_CONFIG_DIR="$HOME/.config/${DOTFILES_MANAGER}" - ENVIRONMENT_TEMPLATE_FILE_PATH="${ENVIRONMENT_TEMPLATE_CONFIG_DIR}/${DOTFILES_MANAGER}.toml" - - MANDATORY_DIRECTORIES_BEFORE_APPLYING_DOTFILES=( - "${HOME}/.oh-my-zsh/cache/" - ) -} - -function _set_personal_info_defaults { - GITHUB_USERNAME="MrPointer" - FULL_NAME="Timor Gruber" - PERSONAL_EMAIL="timor.gruber@gmail.com" - WORK_EMAIL="timor.gruber@solaredge.com" -} - -### -# Set script default values for later show_usage. -### -function set_defaults { - VERBOSE=false - DEBUG=false - INSTALL_REF=main - WORK_ENVIRONMENT=false - ROOT_USER=false - MULTI_USER_SYSTEM=false - - _set_personal_info_defaults - _set_dotfiles_manager_defaults - _set_shell_defaults - _set_package_management_defaults - _set_work_info_defaults -} - -### -# This is the script's entry point, just like in any other programming language. -### -function main { - if ! set_defaults; then - error "Failed setting default values, aborting" - return 1 - fi - - if ! parse_arguments "$@"; then - error "Couldn't parse arguments, aborting" - return 2 - fi - - if ! set_globals; then - error "Failed setting global variables, aborting" - return 1 - fi - - info "Installing dotfiles" - if ! install_dotfiles; then - error "Failed installing dotfiles" - return 3 - fi - - success "Successfully installed dotfiles!" - return 0 -} - -# Call main and don't do anything else -# It will pass the correct exit code to the OS -main "$@" diff --git a/install.sh b/install.sh deleted file mode 100755 index 1e47e4e..0000000 --- a/install.sh +++ /dev/null @@ -1,414 +0,0 @@ -#!/usr/bin/env sh - -### -# Set default color codes for colorful prints -### -RED_COLOR="\033[0;31m" -GREEN_COLOR="\033[0;32m" -YELLOW_COLOR="\033[1;33m" -BLUE_COLOR="\033[0;34m" -NEUTRAL_COLOR="\033[0m" - -error() { - printf "${RED_COLOR}%s${NEUTRAL_COLOR}\n" "$@" -} - -warning() { - printf "${YELLOW_COLOR}%s${NEUTRAL_COLOR}\n" "$@" -} - -info() { - printf "${BLUE_COLOR}%s${NEUTRAL_COLOR}\n" "$@" -} - -success() { - printf "${GREEN_COLOR}%s${NEUTRAL_COLOR}\n" "$@" -} - -get_download_tool() { - if command -v curl >/dev/null 2>&1; then - echo "curl" - elif command -v wget >/dev/null 2>&1; then - echo "wget" - else - echo "" - fi -} - -invoke_actual_installation() { - if [ "$INVOKE_LOCAL_INSTALL" = true ]; then - # Get the path to the local implementation script - v_script_base_dir="$(dirname "$(readlink -f "$0")")" - v_local_install_script="${v_script_base_dir}/install-impl.sh" - - # Check if the file exists - if [ ! -f "$v_local_install_script" ]; then - error "Failed to find local implementation script!" - unset v_local_install_script v_script_base_dir - return 1 - fi - # Check if the file is executable - if [ ! -x "$v_local_install_script" ]; then - error "Local implementation script is not executable!" - unset v_local_install_script v_script_base_dir - return 2 - fi - - TMP_IMPL_INSTALL_PATH="$v_local_install_script" - unset v_local_install_script v_script_base_dir - else - # Create temporary executable file to hold the contents - # of the downloaded implementation script - TMP_IMPL_INSTALL_PATH="$(mktemp)" - - # Execute manually for every type of download tool to get exit code, it's impossible otherwise... - # Shell commands executed with "-c" must be in single-quotes to catch their exit codes correctly - IMPL_DOWNLOAD_RESULT=0 - case "$v_DOWNLOAD_TOOL" in - curl) - curl -fsSL -o "$TMP_IMPL_INSTALL_PATH" "https://raw.githubusercontent.com/MrPointer/dotfiles/$INSTALL_REF/install-impl.sh" - IMPL_DOWNLOAD_RESULT=$? - ;; - wget) - wget -q -O "$TMP_IMPL_INSTALL_PATH" "https://raw.githubusercontent.com/MrPointer/dotfiles/$INSTALL_REF/install-impl.sh" - IMPL_DOWNLOAD_RESULT=$? - ;; - esac - - if [ $IMPL_DOWNLOAD_RESULT -ne 0 ] || [ ! -s "$TMP_IMPL_INSTALL_PATH" ]; then - error "Failed downloading implementation script!" - return 2 - fi - - chmod +x "$TMP_IMPL_INSTALL_PATH" - fi - - # For macOS, find GNU getopt path - if [ "$SYSTEM_TYPE" = "darwin" ]; then - # Determine where GNU getopt is installed - v_apple_silicon_path="/opt/homebrew/opt/gnu-getopt/bin" - v_intel_path="/usr/local/opt/gnu-getopt/bin" - - if [ -d "$v_apple_silicon_path" ]; then - # Apple Silicon Mac - v_getopt_path="$v_apple_silicon_path" - elif [ -d "$v_intel_path" ]; then - # Intel Mac - v_getopt_path="$v_intel_path" - else - error "GNU getopt not found on macOS, please install it manually OR open a new shell and run the script again" - unset v_apple_silicon_path v_intel_path - return 4 - fi - - # Execute with modified PATH environment - info "Executing: env PATH=\"$v_getopt_path:\$PATH\" $TMP_IMPL_INSTALL_PATH --package-manager $PKG_MANAGER --system $SYSTEM_TYPE $*" - env PATH="$v_getopt_path:$PATH" "$TMP_IMPL_INSTALL_PATH" --package-manager "$PKG_MANAGER" --system "$SYSTEM_TYPE" "$@" - v_result=$? - else - # Execute normally on other systems - info "Executing: $TMP_IMPL_INSTALL_PATH --package-manager $PKG_MANAGER --system $SYSTEM_TYPE $*" - "$TMP_IMPL_INSTALL_PATH" --package-manager "$PKG_MANAGER" --system "$SYSTEM_TYPE" "$@" - v_result=$? - fi - - if [ $v_result -ne 0 ]; then - error "Real installer failed, sorry..." - return 3 - fi - - unset v_local_install_script - return 0 -} - -install_getopt() { - v_distro="$1" - v_pkg_manager="$2" - - if [ "$v_distro" = "mac" ] && [ "$v_pkg_manager" = "brew" ]; then - if brew list | grep -q gnu-getopt; then - info "gnu-getopt already installed" - return 0 - fi - - brew install gnu-getopt || return 1 - unset v_distro v_pkg_manager - return 0 - fi - - unset v_distro v_pkg_manager - return 0 -} - -verify_bash_version() { - v_bash_version="$(bash --version | head -n 1 | grep -o 'version [0-9]\.[0-9]\.[0-9]' | cut -d ' ' -f 2)" - if [ -z "$v_bash_version" ]; then - error "Failed detecting bash version" - unset v_bash_version - return 2 - fi - - # Check if bash version is at least 4.4.0 - v_expected_bash_version="4.4.0" - if [ "$(printf '%s\n' "$v_bash_version" "$v_expected_bash_version" | sort -V | head -n1)" = "$v_expected_bash_version" ]; then - info "Bash exists!" - unset v_bash_version - return 0 - fi - - unset v_bash_version - return 1 -} - -install_bash_with_package_manager() { - case "$1" in - apt) - sudo apt install -y bash - ;; - dnf) - sudo dnf install -y bash - ;; - brew) - brew install bash - ;; - *) ;; - - esac -} - -install_bash() { - if command -v bash >/dev/null 2>&1; then - verify_bash_version - v_exit_code=$? - if [ $v_exit_code -eq 0 ]; then - return 0 - fi - if [ $v_exit_code -eq 1 ]; then - warning "Bash version is too old, trying to install a newer version using detected package manager" - fi - if [ $v_exit_code -eq 2 ]; then - return 1 - fi - unset v_exit_code - else - warning "bash does not exist, trying to install it" - fi - - if ! install_bash_with_package_manager "$1"; then - error "Failed installing bash using $1" - return 5 - fi - - # Verify version again to ensure it's installed correctly - verify_bash_version - v_exit_code=$? - if [ $v_exit_code -eq 1 ]; then - warning "Bash version is still too old, please install a newer version manually OR open a new shell and run the script again" - unset v_exit_code - return 6 - fi - - unset v_exit_code - return 0 -} - -### -# Parse special arguments/options, and "swallow" the rest as they're intended for the implementation script. -### -parse_arguments() { - while [ "$#" -gt 0 ]; do - case $1 in - --ref) - [ -n "$2" ] && INSTALL_REF="${2}" - shift 2 - ;; - --ref=*) - INSTALL_REF="$(echo "$1" | cut -d '=' -f 2)" - shift - ;; - --local) - INVOKE_LOCAL_INSTALL=true - shift - ;; - *) - # Probably options to the real installer (implementation), simply shift past them - shift - ;; - esac - done -} - -supported_system() { - v_system="${1:?}" - v_distro="${2:?}" - v_pkg_manager="${3:?}" - - v_supported_distros_file="$(mktemp)" || return 1 - echo "$SUPPORTED_DISTROS" >"$v_supported_distros_file" - - if ! grep -q "$v_distro" "$v_supported_distros_file"; then - error "$v_distro is not yet supported, currently supported are: $SUPPORTED_DISTROS" - unset v_supported_distros_file v_system v_distro v_pkg_manager - return 3 - fi - - unset v_supported_distros_file v_system v_distro v_pkg_manager - return 0 -} - -_get_default_system_package_manager() { - case "$1" in - mac | darwin) - echo "brew" - ;; - ubuntu | debian | suse) - echo "apt" - ;; - fedora | centos | redhat) - echo "dnf" - ;; - *) - echo "" - ;; - esac -} - -_get_linux_distro_name() { - v_distro="" - - if [ -f /etc/os-release ]; then - # freedesktop.org and systemd - . /etc/os-release - v_distro=$NAME - elif [ -f /etc/lsb-release ]; then - # For some versions of Debian/Ubuntu without lsb_release command - . /etc/lsb-release - v_distro=$DISTRIB_ID - elif [ -f /etc/debian_version ]; then - # Older Debian/Ubuntu/etc. - v_distro=Debian - elif [ -f /etc/SuSe-release ]; then - # Older SuSE/etc. - v_distro=SuSE - elif [ -f /etc/redhat-release ]; then - # Older Red Hat, CentOS, etc. - v_distro=RedHat - else - # Fall back to uname, e.g. "Linux ", also works for BSD, etc. - v_distro="$(uname -s)" - fi - - echo "$v_distro" | tr '[:upper:]' '[:lower:]' - unset v_distro - return 0 -} - -_get_system_type() { - case "$(uname -s)" in - Darwin) - echo "darwin" - ;; - Linux) - echo "linux" - ;; - *) - echo "unsupported" - ;; - esac -} - -detect_system() { - SYSTEM_TYPE="$(_get_system_type)" - - case "$SYSTEM_TYPE" in - linux) - DISTRO_NAME="$(_get_linux_distro_name)" - if [ -z "$DISTRO_NAME" ]; then - error "Failed detecting linux distribution" - return 2 - fi - ;; - darwin) - DISTRO_NAME="mac" - ;; - *) - error "Unsupported system type: $SYSTEM_TYPE" - return 3 - ;; - esac - - PKG_MANAGER="$(_get_default_system_package_manager "$DISTRO_NAME")" - if [ -z "$PKG_MANAGER" ]; then - error "Failed determining package manager for distro: $DISTRO_NAME" - return 2 - fi - if ! command -v "$PKG_MANAGER" >/dev/null 2>&1; then - error "Detected '$PKG_MANAGER' as package-manager for '$DISTRO_NAME' but it's not available, maybe you need to install it manually first?" - return 4 - fi - - printf "\n" # Print an empty line - info "Detected system:" - info "----------------" - info "Type: $SYSTEM_TYPE" - info "Distro: $DISTRO_NAME" - info "Package manager: $PKG_MANAGER" - info "----------------" - printf "\n" # Print an empty line -} - -set_defaults() { - INSTALL_REF="main" - INVOKE_LOCAL_INSTALL=false - SUPPORTED_DISTROS="ubuntu debian mac" -} - -main() { - info "Installing dotfiles, but first some bootstrapping" - - set_defaults # Should never fail - - if ! detect_system; then - error "Detected system is not supported, sorry" - return 1 - fi - - if ! supported_system "$SYSTEM_TYPE" "$DISTRO_NAME" "$PKG_MANAGER"; then - error "Detected system is not supported, sorry" - return 1 - fi - - if ! parse_arguments "$@"; then - error "Failed parsing arguments, aborting" - return 2 - fi - - info "Installing bash (if required)" - if ! install_bash "$PKG_MANAGER"; then - error "Failed installing bash!" - return 3 - fi - - info "Installing getopt (if required)" - if ! install_getopt "$DISTRO_NAME" "$PKG_MANAGER"; then - error "Failed installing getopt!" - return 3 - fi - - v_DOWNLOAD_TOOL="$(get_download_tool)" - if [ -z "$v_DOWNLOAD_TOOL" ]; then - error "Neither 'curl' nor 'wget' are available, please install one of them manually" - return 4 - fi - - info "Running real bootstrap installation (bash script)" - if ! invoke_actual_installation "$@"; then - error "Failed installing dotfiles [from bootstrap]" - return 5 - fi - - success "Successfully completed dotfiles installation [from bootstrap]" - return 0 -} - -main "$@" diff --git a/installer/.copier-answers.yml b/installer/.copier-answers.yml new file mode 100644 index 0000000..7412064 --- /dev/null +++ b/installer/.copier-answers.yml @@ -0,0 +1,12 @@ +# Changes here will be overwritten by Copier +_commit: v0.44.0 +_src_path: gh:FollowTheProcess/go_copier +author_email: timor.gruber@gmail.com +author_name: Timor Gruber +description: My personal dotfiles installer +github_url: https://github.com/MrPointer/dotfiles +github_username: MrPointer +license: MIT License +project_name: dotfiles-installer +project_slug: dotfiles_installer +project_type: binary diff --git a/installer/.gitattributes b/installer/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/installer/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/installer/.gitignore b/installer/.gitignore new file mode 100755 index 0000000..f521a6d --- /dev/null +++ b/installer/.gitignore @@ -0,0 +1,51 @@ +# Allowlisting gitignore template for GO projects prevents us +# from adding various unwanted local files, such as generated +# files, developer configurations or IDE-specific files etc. + +# Ignore everything +* + +# Except these... + +# Git/GitHub +!/.gitignore +!/.gitattributes +!/.github/**/* + +# Go +!*.go +!go.sum +!go.mod +!.goreleaser.yaml +!.golangci.yml + +# CodeCov +!codecov.yml + +# General +!README.md +!LICENSE +!img/**/* +!docs/img/**/* + +# Typos +!.typos.toml + +# Task +!Taskfile.yml + +# Copier +!*.jinja + +# ...even if they are in subdirectories +!*/ + +# Embedded config files +!internal/config/*.yaml +!internal/config/*.yml + +# Custom Docker files +!docker/**/* + +# Expect scripts +!test-interactive-gpg.exp diff --git a/installer/.golangci.yml b/installer/.golangci.yml new file mode 100644 index 0000000..e907fd6 --- /dev/null +++ b/installer/.golangci.yml @@ -0,0 +1,167 @@ +version: "2" + +formatters: + enable: + - gofumpt + - goimports + - golines + + settings: + gofumpt: + extra-rules: true + + golines: + max-len: 120 + +linters: + default: all + disable: + - decorder # Don't care about this + - dupl # Basically every table driven test ever triggers this + - dupword # Messes with test cases more often than not + - err113 # Out of date + - exhaustruct # No + - forbidigo # Nothing to forbid + - funlen # Bad metric for complexity + - ginkgolinter # I don't use whatever this is + - gochecknoglobals # Globals are fine sometimes, use common sense + - gocyclo # cyclop does this instead + - godox # "todo" and "fixme" comments are allowed + - goheader # No need + - gosmopolitan # No need + - grouper # Imports take care of themselves, rest is common sense + - ireturn # This is just not necessary or practical in a real codebase + - lll # Auto formatters do this and what they can't do I don't care about + - maintidx # This is just the inverse of complexity... which is cyclop + - nestif # cyclop does this + - nlreturn # Similar to wsl, I think best left to judgement + - nonamedreturns # Named returns are often helpful, it's naked returns that are the issue + - paralleltest # I've never had Go tests take longer than a few seconds, it's fine + - unparam # gopls is better and more subtle + - varnamelen # Lots of false positives of things that are fine + - wrapcheck # Not every error must be wrapped + - wsl # Very aggressive, some of this I like but tend to do anyway + + exclusions: + presets: + # See https://golangci-lint.run/usage/false-positives/#exclusion-presets + - comments # Revive in particular has lots of false positives + - std-error-handling + - common-false-positives + rules: + - path: _test\.go + linters: + - prealloc # These kinds of optimisations will make no difference to test code + - gosec # Tests don't need security stuff + + settings: + cyclop: + max-complexity: 20 + + depguard: + rules: + main: + deny: + - pkg: io/ioutil + desc: io/ioutil is deprecated, use io instead + + - pkg: "math/rand$" + desc: use math/rand/v2 instead + + errcheck: + check-type-assertions: true + check-blank: true + + exhaustive: + check: + - switch + - map + default-signifies-exhaustive: true + + staticcheck: + checks: + - all + + gosec: + excludes: + - G104 # Errors not checked, handled by errcheck + + govet: + enable-all: true + + nakedret: + max-func-lines: 0 # Disallow any naked returns + + nolintlint: + allow-unused: false + require-explanation: true + require-specific: true + + usetesting: + context-background: true + context-todo: true + os-chdir: true + os-mkdir-temp: true + os-setenv: true + os-create-temp: true + os-temp-dir: true + + revive: + max-open-files: 256 + enable-all-rules: true + rules: + - name: add-constant + disabled: true # goconst does this + + - name: argument-limit + arguments: + - 5 + + - name: cognitive-complexity + disabled: true # gocognit does this + + - name: comment-spacings + arguments: + - "nolint:" + + - name: cyclomatic + disabled: true # cyclop does this + + - name: exported + arguments: + - checkPrivateReceivers + - checkPublicInterface + + - name: function-length + disabled: true # Bad proxy for complexity + + - name: function-result-limit + arguments: + - 3 + + - name: import-shadowing + disabled: true # predeclared does this + + - name: line-length-limit + disabled: true # gofmt/golines handles this well enough + + - name: max-public-structs + disabled: true # This is a dumb rule + + - name: redefines-builtin-id + disabled: true # predeclared does this + + - name: unhandled-error + arguments: + - fmt\.(Fp|P)rint(ln|f)? + - strings.Builder.Write(String|Byte)? + - bytes.Buffer.Write(String|Byte)? + + - name: flag-parameter + disabled: true # As far as I can work out this just doesn't like bools + + - name: unused-parameter + disabled: true # The gopls unused analyzer covers this better + + - name: unused-receiver + disabled: true # As above diff --git a/installer/.goreleaser.yaml b/installer/.goreleaser.yaml new file mode 100644 index 0000000..0cdc1d6 --- /dev/null +++ b/installer/.goreleaser.yaml @@ -0,0 +1,74 @@ +version: 2 + +project_name: dotfiles-installer + +before: + hooks: + - go mod tidy + +builds: + - id: dotfiles_installer + dir: "." + main: "." + binary: dotfiles-installer + flags: + - -trimpath + ldflags: + - -s -w + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + +brews: + - repository: + owner: MrPointer + name: homebrew-tap + token: "{{.Env.HOMEBREW_TAP_TOKEN}}" + directory: Formula + commit_author: + name: Timor Gruber + email: timor.gruber@gmail.com + homepage: https://github.com/MrPointer/dotfiles + description: The installer of my dotfiles, used to bootstrap the system + license: MIT License + install: | + bin.install "dotfiles-installer" + test: | + "#{bin}/dotfiles-installer --version" + +archives: + - id: dotfiles_installer + name_template: >- + {{ .ProjectName }}- + {{- .Version }}- + {{- .Os }}- + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + +sboms: + - id: dotfiles_installer + artifacts: archive + documents: + - >- + {{ .ProjectName }}- + {{- .Version }}- + {{- .Os }}- + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }}.sbom + +checksum: + name_template: checksums.txt + +snapshot: + version_template: "{{ .Tag }}-dev{{ .ShortCommit }}" + +changelog: + # The changelog is handled by release drafter + disable: true diff --git a/installer/.mockery.yml b/installer/.mockery.yml new file mode 100644 index 0000000..bd9a0c2 --- /dev/null +++ b/installer/.mockery.yml @@ -0,0 +1,19 @@ +all: true +dir: "{{.InterfaceDir}}" +filename: "{{.InterfaceName}}_mock.go" +force-file-write: true +formatter: goimports +log-level: info +structname: "Moq{{.InterfaceName}}" +pkgname: "{{.SrcPackageName}}" +recursive: true +require-template-schema-exists: true +template: matryer +template-schema: "{{.Template}}.schema.json" +packages: + github.com/MrPointer/dotfiles/installer: + config: + all: true + github.com/MrPointer/dotfiles/installer/cli: + config: + all: false diff --git a/installer/.typos.toml b/installer/.typos.toml new file mode 100644 index 0000000..cfa8f03 --- /dev/null +++ b/installer/.typos.toml @@ -0,0 +1,4 @@ + +[default.extend-words] +byt = "byt" # Useful name as byte is a keyword in go +decorder = "decorder" # Name of a Go linter diff --git a/installer/LICENSE b/installer/LICENSE new file mode 100644 index 0000000..5bcb754 --- /dev/null +++ b/installer/LICENSE @@ -0,0 +1,22 @@ + +MIT License + +Copyright (c) 2025, Timor Gruber + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/installer/README.md b/installer/README.md new file mode 100644 index 0000000..9d0b318 --- /dev/null +++ b/installer/README.md @@ -0,0 +1,43 @@ +# Dotfiles Installer + +[![License](https://img.shields.io/github/license/MrPointer/dotfiles_installer)](https://github.com/MrPointer/dotfiles_installer) +[![Go Report Card](https://goreportcard.com/badge/github.com/MrPointer/dotfiles_installer)](https://goreportcard.com/report/github.com/MrPointer/dotfiles_installer) +[![GitHub](https://img.shields.io/github/v/release/MrPointer/dotfiles_installer?logo=github&sort=semver)](https://github.com/MrPointer/dotfiles_installer) +[![CI](https://github.com/MrPointer/dotfiles_installer/workflows/CI/badge.svg)](https://github.com/MrPointer/dotfiles_installer/actions?query=workflow%3ACI) +[![codecov](https://codecov.io/gh/MrPointer/dotfiles_installer/branch/main/graph/badge.svg)](https://codecov.io/gh/MrPointer/dotfiles_installer) + +The installer of my dotfiles, used to bootstrap the system + +> [!WARNING] +> **Dotfiles Installer is in early development and is not yet ready for use** + +![caution](./img/caution.png) + +## Project Description + +## Features + +- **Hierarchical Progress Display**: Shows npm-style progress indicators with spinners and timing information +- **Automatic Cursor Cleanup**: Ensures terminal cursor is always visible after program exit, even on interruption +- **Signal Handling**: Gracefully handles Ctrl+C and other termination signals with proper cleanup +- **Verbosity Control**: Multiple verbosity levels from minimal to extra-verbose output +- **Non-Interactive Mode**: Supports automated installations without user prompts + +## Installation + +Compiled binaries for all supported platforms can be found in the [GitHub release]. There is also a [homebrew] tap: + +```shell +brew install MrPointer/tap/dotfiles_installer +``` + +## Quickstart + +### Credits + +This package was created with [copier] and the [FollowTheProcess/go_copier] project template. + +[copier]: https://copier.readthedocs.io/en/stable/ +[FollowTheProcess/go_copier]: https://github.com/FollowTheProcess/go_copier +[GitHub release]: https://github.com/MrPointer/dotfiles_installer/releases +[homebrew]: https://brew.sh diff --git a/installer/Taskfile.yml b/installer/Taskfile.yml new file mode 100644 index 0000000..3b81968 --- /dev/null +++ b/installer/Taskfile.yml @@ -0,0 +1,115 @@ +# https://taskfile.dev + +version: "3" + +vars: + COV_DATA: coverage.out + +tasks: + default: + desc: List all available tasks + silent: true + cmds: + - task --list + + tidy: + desc: Tidy dependencies in go.mod and go.sum + sources: + - "**/*.go" + - go.mod + - go.sum + cmds: + - go mod tidy + + fmt: + desc: Run go fmt on all source files + sources: + - "**/*.go" + - .golangci.yml + cmds: + - golangci-lint fmt + + test: + desc: Run the test suite + cmds: + - go test -race ./... {{ .CLI_ARGS }} + + build: + desc: Compile the project binary + sources: + - "**/*.go" + - go.mod + - go.sum + - .goreleaser.yml + generates: + - bin + - dist + cmds: + - mkdir -p ./bin + - goreleaser build --single-target --skip before --snapshot --clean --output ./bin/dotfiles-installer + + bench: + desc: Run all project benchmarks + sources: + - "**/*.go" + cmds: + - go test ./... -run None -benchmem -bench . {{ .CLI_ARGS }} + + lint: + desc: Run the linters and auto-fix if possible + sources: + - "**/*.go" + - .golangci.yml + preconditions: + - sh: command -v golangci-lint + msg: golangci-lint not installed, see https://golangci-lint.run/usage/install/#local-installation + + - sh: command -v typos + msg: requires typos-cli, run `brew install typos-cli` + cmds: + - golangci-lint run --fix + - typos + + cov: + desc: Calculate test coverage and render the html + generates: + - "{{ .COV_DATA }}" + cmds: + - go test -race -cover -covermode atomic -coverprofile {{ .COV_DATA }} ./... + - go tool cover -html {{ .COV_DATA }} + + check: + desc: Run tests and linting in one + cmds: + - task: test + - task: lint + + sloc: + desc: Print lines of code + cmds: + - fd . -e go | xargs wc -l | sort -nr | head + + clean: + desc: Remove build artifacts and other clutter + cmds: + - go clean ./... + - rm -rf {{ .COV_DATA }} + + install: + desc: Install the project on your machine + deps: + - uninstall + - build + cmds: + - cp ./bin/dotfiles-installer $GOBIN/dotfiles-installer + + uninstall: + desc: Uninstall the project from your machine + cmds: + - rm -rf $GOBIN/dotfiles-installer + + update: + desc: Updates dependencies in go.mod and go.sum + cmds: + - go get -u ./... + - go mod tidy diff --git a/installer/cli/gpg_selector.go b/installer/cli/gpg_selector.go new file mode 100644 index 0000000..ef6aee7 --- /dev/null +++ b/installer/cli/gpg_selector.go @@ -0,0 +1,46 @@ +package cli + +import ( + "fmt" +) + +// GpgKeySelector provides GPG-specific key selection functionality. +type GpgKeySelector struct { + selector Selector[string] +} + +// NewGpgKeySelector constructs a GpgKeySelector with the given generic selector. +func NewGpgKeySelector(selector Selector[string]) *GpgKeySelector { + return &GpgKeySelector{ + selector: selector, + } +} + +// NewDefaultGpgKeySelector constructs a GpgKeySelector with the default HuhSelector. +func NewDefaultGpgKeySelector() *GpgKeySelector { + return &GpgKeySelector{ + selector: NewHuhSelector[string](), + } +} + +// SelectKey prompts the user to select a GPG key from the available keys. +// It provides GPG-specific context and formatting for better user experience. +func (s *GpgKeySelector) SelectKey(availableKeys []string) (string, error) { + if len(availableKeys) == 0 { + return "", fmt.Errorf("no GPG keys available for selection") + } + + if len(availableKeys) == 1 { + return availableKeys[0], nil + } + + title := fmt.Sprintf("Select a GPG key to use (%d available):", len(availableKeys)) + + // Format keys with additional context for better display + labels := make([]string, len(availableKeys)) + for i, key := range availableKeys { + labels[i] = fmt.Sprintf("Key ID: %s", key) + } + + return s.selector.SelectWithLabels(title, availableKeys, labels) +} diff --git a/installer/cli/multiselect_selector.go b/installer/cli/multiselect_selector.go new file mode 100644 index 0000000..6b8ae51 --- /dev/null +++ b/installer/cli/multiselect_selector.go @@ -0,0 +1,66 @@ +package cli + +import ( + "errors" + + "github.com/charmbracelet/huh" +) + +// MultiSelectItem represents an item that can be selected in a multi-select interface. +type MultiSelectItem[T comparable] struct { + Value T // The actual value to be returned. + Label string // The display label for the item. + Description string // Optional description for additional context. +} + +// MultiSelectSelector defines the interface for selecting multiple items from a list. +type MultiSelectSelector[T comparable] interface { + // SelectMultiple prompts the user to select multiple items from the provided list. + SelectMultiple(title string, items []MultiSelectItem[T]) ([]T, error) +} + +var _ MultiSelectSelector[string] = (*HuhMultiSelectSelector[string])(nil) + +// HuhMultiSelectSelector implements MultiSelectSelector using the huh library. +type HuhMultiSelectSelector[T comparable] struct{} + +// NewHuhMultiSelectSelector constructs a HuhMultiSelectSelector. +func NewHuhMultiSelectSelector[T comparable]() *HuhMultiSelectSelector[T] { + return &HuhMultiSelectSelector[T]{} +} + +// SelectMultiple implements MultiSelectSelector. +func (s *HuhMultiSelectSelector[T]) SelectMultiple(title string, items []MultiSelectItem[T]) ([]T, error) { + if len(items) == 0 { + return nil, errors.New("no items available for selection") + } + + // Create options for the multi-select widget + options := make([]huh.Option[T], len(items)) + for i, item := range items { + // Combine label and description for display + displayText := item.Label + if item.Description != "" { + displayText = item.Label + " - " + item.Description + } + options[i] = huh.NewOption(displayText, item.Value) + } + + var selectedItems []T + form := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[T](). + Title(title). + Description("Use space to select/deselect, enter to confirm"). + Options(options...). + Value(&selectedItems), + ), + ) + + err := form.Run() + if err != nil { + return nil, err + } + + return selectedItems, nil +} diff --git a/installer/cli/prerequisite_selector.go b/installer/cli/prerequisite_selector.go new file mode 100644 index 0000000..1f0126a --- /dev/null +++ b/installer/cli/prerequisite_selector.go @@ -0,0 +1,67 @@ +package cli + +import ( + "errors" +) + +// PrerequisiteSelector provides prerequisite-specific selection functionality. +type PrerequisiteSelector struct { + selector MultiSelectSelector[string] +} + +// NewPrerequisiteSelector constructs a PrerequisiteSelector with the given multi-select selector. +func NewPrerequisiteSelector(selector MultiSelectSelector[string]) *PrerequisiteSelector { + return &PrerequisiteSelector{ + selector: selector, + } +} + +// NewDefaultPrerequisiteSelector constructs a PrerequisiteSelector with the default HuhMultiSelectSelector. +func NewDefaultPrerequisiteSelector() *PrerequisiteSelector { + return &PrerequisiteSelector{ + selector: NewHuhMultiSelectSelector[string](), + } +} + +// SelectPrerequisites prompts the user to select prerequisites to install from the available missing ones. +// It provides prerequisite-specific context and formatting for better user experience. +func (s *PrerequisiteSelector) SelectPrerequisites(missingPrerequisites []string, + prerequisiteDetails map[string]PrerequisiteDetail) ([]string, error) { + if len(missingPrerequisites) == 0 { + return nil, errors.New("no missing prerequisites available for selection") + } + + // Create items with descriptions for better user experience + items := make([]MultiSelectItem[string], len(missingPrerequisites)) + for i, prerequisite := range missingPrerequisites { + item := MultiSelectItem[string]{ + Value: prerequisite, + Label: prerequisite, + } + + if detail, exists := prerequisiteDetails[prerequisite]; exists { + item.Description = detail.Description + if detail.InstallHint != "" { + item.Description += " (" + detail.InstallHint + ")" + } + } + + items[i] = item + } + + title := "Select prerequisites to install:" + if len(missingPrerequisites) == 1 { + title = "Install the missing prerequisite?" + } + + return s.selector.SelectMultiple(title, items) +} + +// PrerequisiteDetail represents the details of a prerequisite (matching the compatibility package structure). +type PrerequisiteDetail struct { + Name string // Name of the prerequisite. + Available bool // Whether the prerequisite is available. + Command string // Command used to check availability. + Description string // Human-readable description. + InstallHint string // Hint for installing the prerequisite. +} diff --git a/installer/cli/selector.go b/installer/cli/selector.go new file mode 100644 index 0000000..dc6b0a2 --- /dev/null +++ b/installer/cli/selector.go @@ -0,0 +1,92 @@ +package cli + +import ( + "errors" + + "github.com/charmbracelet/huh" +) + +// Selector defines the interface for selecting items from a list. +type Selector[T comparable] interface { + // Select prompts the user to select an item from the provided list. + Select(title string, items []T) (T, error) + // SelectWithLabels prompts the user to select an item with custom labels. + SelectWithLabels(title string, items []T, labels []string) (T, error) +} + +var _ Selector[string] = (*HuhSelector[string])(nil) + +// HuhSelector implements Selector using the huh library. +type HuhSelector[T comparable] struct{} + +// NewHuhSelector constructs a HuhSelector. +func NewHuhSelector[T comparable]() *HuhSelector[T] { + return &HuhSelector[T]{} +} + +// Select implements Selector. +func (s *HuhSelector[T]) Select(title string, items []T) (T, error) { + var zero T + if len(items) == 0 { + return zero, errors.New("no items available for selection") + } + + if len(items) == 1 { + return items[0], nil + } + + var selectedItem T + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[T](). + Title(title). + Options(huh.NewOptions(items...)...). + Value(&selectedItem), + ), + ) + + err := form.Run() + if err != nil { + return zero, err + } + + return selectedItem, nil +} + +// SelectWithLabels implements Selector. +func (s *HuhSelector[T]) SelectWithLabels(title string, items []T, labels []string) (T, error) { + var zero T + if len(items) == 0 { + return zero, errors.New("no items available for selection") + } + + if len(items) != len(labels) { + return zero, errors.New("items and labels must have the same length") + } + + if len(items) == 1 { + return items[0], nil + } + + options := make([]huh.Option[T], len(items)) + for i, item := range items { + options[i] = huh.NewOption(labels[i], item) + } + + var selectedItem T + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[T](). + Title(title). + Options(options...). + Value(&selectedItem), + ), + ) + + err := form.Run() + if err != nil { + return zero, err + } + + return selectedItem, nil +} diff --git a/installer/cmd/checkCompatibility.go b/installer/cmd/checkCompatibility.go new file mode 100644 index 0000000..aa2ca7a --- /dev/null +++ b/installer/cmd/checkCompatibility.go @@ -0,0 +1,51 @@ +/* +Copyright Β© 2025 NAME HERE +*/ +package cmd + +import ( + "fmt" + + "github.com/MrPointer/dotfiles/installer/lib/compatibility" + "github.com/spf13/cobra" +) + +// checkCompatibilityCmd represents the check-compatibility command. +var checkCompatibilityCmd = &cobra.Command{ + Use: "check-compatibility", + Short: "Check compatibility of your dotfiles with the current system", + Long: `Checks whether the current system is compatible with the dotfiles, +as they have some distribution-specific configurations. This command will +provide a report on the compatibility status. + +It's recommended to run this command before attempting to install the dotfiles.`, + Run: func(cmd *cobra.Command, args []string) { + // Get the globally loaded compatibility config. + config := GetCompatibilityConfig() + + // Check system compatibility. + sysInfo, err := compatibility.CheckCompatibility(config, globalOsManager) + if err != nil { + HandleCompatibilityError(err, sysInfo, cliLogger) + } + + // Print the success symbol and message. + fmt.Print("βœ”οΈŽ ") + cliLogger.Success("Your system is compatible with these dotfiles!") + + // Print detected system information if verbose flag is set. + if verbose { + fmt.Println() // Add an empty line for better spacing. + cliLogger.Info("Detected system information:") + cliLogger.Info("OS: %s\n", sysInfo.OSName) + cliLogger.Info("Distribution: %s\n", sysInfo.DistroName) + cliLogger.Info("Architecture: %s\n", sysInfo.Arch) + } + }, +} + +//nolint:gochecknoinits // Cobra requires an init function to set up the command structure. +func init() { + rootCmd.AddCommand(checkCompatibilityCmd) + // No need for additional flags here, as we use the global compatibility config. +} diff --git a/installer/cmd/install.go b/installer/cmd/install.go new file mode 100644 index 0000000..b8e34b6 --- /dev/null +++ b/installer/cmd/install.go @@ -0,0 +1,536 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path" + + "github.com/MrPointer/dotfiles/installer/cli" + "github.com/MrPointer/dotfiles/installer/lib/apt" + "github.com/MrPointer/dotfiles/installer/lib/brew" + "github.com/MrPointer/dotfiles/installer/lib/compatibility" + "github.com/MrPointer/dotfiles/installer/lib/dotfilesmanager" + "github.com/MrPointer/dotfiles/installer/lib/dotfilesmanager/chezmoi" + "github.com/MrPointer/dotfiles/installer/lib/gpg" + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/lib/shell" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/privilege" + "github.com/samber/mo" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// Options and flags for the install command. +var ( + workEnvironment bool + workName string + workEmail string + shellName string + installBrew bool + installShellWithBrew bool + multiUserSystem bool + gitCloneProtocol string + verbose bool + installPrerequisites bool +) + +// global variables for the command execution context. +var ( + globalPackageManager pkgmanager.PackageManager = nil // set later based on passed flags +) + +// output variables stored in global context +var ( + selectedGpgKey string +) + +// installCmd represents the install command. +var installCmd = &cobra.Command{ + Use: "install", + Short: "Install dotfiles", + Long: `Install dotfiles on the current system. +This command will set up the necessary configurations and +install essential packages and tools that I use on a daily basis. +It automates the process of setting up the dotfiles, +making it easier to get started with a new system.`, + Run: func(cmd *cobra.Command, args []string) { + // Use the global logger (already configured with proper progress/verbosity settings) + installLogger := cliLogger + + // Get basic system info first to determine if we need to install Homebrew early + osDetector := compatibility.NewDefaultOSDetector() + basicSysInfo, err := osDetector.DetectSystem() + if err != nil { + installLogger.Error("Failed to get basic system information: %v", err) + os.Exit(1) + } + + // For macOS, install Homebrew BEFORE checking prerequisites if requested + // This solves the chicken-and-egg problem where we need Homebrew to install prerequisites + if basicSysInfo.OSName == "darwin" && installBrew { + brewPath, err := installHomebrew(basicSysInfo, installLogger) + if err != nil { + installLogger.Error("Failed to install Homebrew: %v", err) + os.Exit(1) + } + globalPackageManager = brew.NewBrewPackageManager(installLogger, globalCommander, globalOsManager, brewPath, GetDisplayMode()) + } + + // Check system compatibility and get system info. + config := GetCompatibilityConfig() + + // Now check system compatibility and get full system info including prerequisites + sysInfo, err := compatibility.CheckCompatibilityWithDetector(config, osDetector, globalOsManager) + if err != nil { + if handlePrerequisiteInstallation(sysInfo, installLogger) { + // Prerequisites were installed, re-check compatibility + sysInfo, err = compatibility.CheckCompatibility(config, globalOsManager) + if err != nil { + HandleCompatibilityError(err, sysInfo, installLogger) + } + } else { + HandleCompatibilityError(err, sysInfo, installLogger) + } + } + installLogger.Success("System compatibility check passed") + + // Install Homebrew for non-macOS systems or if not already installed + if installBrew && (basicSysInfo.OSName != "darwin" || globalPackageManager == nil) { + brewPath, err := installHomebrew(sysInfo, installLogger) + if err != nil { + installLogger.Error("Failed to install Homebrew: %v", err) + os.Exit(1) + } + globalPackageManager = brew.NewBrewPackageManager(installLogger, globalCommander, globalOsManager, brewPath, GetDisplayMode()) + } + + if err := installShell(installLogger); err != nil { + installLogger.Error("Failed to install shell: %v", err) + os.Exit(1) + } + + if err := setupGpgKeys(installLogger); err != nil { + installLogger.Error("Failed to setup GPG keys: %v", err) + os.Exit(1) + } + + if err := setupDotfilesManager(installLogger); err != nil { + installLogger.Error("Failed to setup dotfiles manager: %v", err) + os.Exit(1) + } + + installLogger.Success("Installation completed successfully") + }, +} + +// createPackageManagerForSystem creates the appropriate package manager for the current system. +func createPackageManagerForSystem(sysInfo *compatibility.SystemInfo) pkgmanager.PackageManager { + // If we already have a global package manager set up (e.g., Homebrew installed early for macOS), use it + if globalPackageManager != nil { + return globalPackageManager + } + + switch sysInfo.OSName { + case "linux": + switch sysInfo.DistroName { + case "ubuntu", "debian": + return apt.NewAptPackageManager(cliLogger, globalCommander, globalOsManager, privilege.NewDefaultEscalator(cliLogger, globalCommander, globalOsManager), GetDisplayMode()) + default: + cliLogger.Warning("Unsupported Linux distribution for automatic package installation: %s", sysInfo.DistroName) + return nil + } + case "darwin": + brewPath, err := brew.DetectBrewPath(sysInfo, "") + if err != nil { + cliLogger.Error("Failed to detect Homebrew path: %v", err) + return nil + } + return brew.NewBrewPackageManager(cliLogger, globalCommander, globalOsManager, brewPath, GetDisplayMode()) + default: + cliLogger.Warning("Unsupported operating system for automatic package installation: %s", sysInfo.OSName) + return nil + } +} + +// handlePrerequisiteInstallation handles automatic installation of missing prerequisites. +// Returns true if prerequisites were installed and compatibility should be re-checked. +func handlePrerequisiteInstallation(sysInfo compatibility.SystemInfo, log logger.Logger) bool { + // Only attempt installation if we have missing prerequisites and the flag is set + if len(sysInfo.Prerequisites.Missing) == 0 { + return false + } + + // Create package manager for this system + packageManager := createPackageManagerForSystem(&sysInfo) + if packageManager == nil { + log.Warning("Cannot install prerequisites automatically on this system") + return false + } + + var prerequisitesToInstall []string + + // In non-interactive mode, or if explicitly requested, install all missing prerequisites automatically + if IsNonInteractive() || installPrerequisites { + prerequisitesToInstall = sysInfo.Prerequisites.Missing + log.StartProgress("Installing missing prerequisites automatically") + } else { + // In interactive mode, let user select which prerequisites to install + prerequisiteSelector := cli.NewDefaultPrerequisiteSelector() + + // Convert compatibility.PrerequisiteDetail to cli.PrerequisiteDetail + cliDetails := make(map[string]cli.PrerequisiteDetail) + for name, detail := range sysInfo.Prerequisites.Details { + cliDetails[name] = cli.PrerequisiteDetail{ + Name: detail.Name, + Available: detail.Available, + Command: detail.Command, + Description: detail.Description, + InstallHint: detail.InstallHint, + } + } + + selectedPrerequisites, err := prerequisiteSelector.SelectPrerequisites( + sysInfo.Prerequisites.Missing, + cliDetails, + ) + if err != nil { + log.Error("Failed to select prerequisites: %v", err) + return false + } + + if len(selectedPrerequisites) == 0 { + log.Info("No prerequisites selected for installation") + return false + } + + prerequisitesToInstall = selectedPrerequisites + log.StartProgress(fmt.Sprintf("Installing %d selected prerequisites", len(selectedPrerequisites))) + } + + // Install each selected prerequisite + installed := false + for _, name := range prerequisitesToInstall { + if detail, exists := sysInfo.Prerequisites.Details[name]; exists { + log.StartProgress(fmt.Sprintf("Installing %s", detail.Description)) + + // Use the prerequisite name directly as the package name + packageInfo := pkgmanager.NewRequestedPackageInfo(name, nil) + + err := packageManager.InstallPackage(packageInfo) + if err != nil { + log.FailProgress(fmt.Sprintf("Failed to install %s", detail.Description), err) + return false + } + + log.FinishProgress(fmt.Sprintf("%s installed successfully", detail.Description)) + installed = true + } + } + + if installed { + log.FinishProgress("Prerequisites installation completed") + return true + } + + return false +} + +// installHomebrew installs Homebrew if not already installed. +func installHomebrew(sysInfo compatibility.SystemInfo, log logger.Logger) (string, error) { + log.StartProgress("Setting up Homebrew") + + // Create BrewInstaller using the new API. + installer := brew.NewBrewInstaller(brew.Options{ + SystemInfo: &sysInfo, + Logger: cliLogger, + Commander: globalCommander, + HTTPClient: globalHttpClient, + OsManager: globalOsManager, + Fs: globalFilesystem, + MultiUserSystem: multiUserSystem, + DisplayMode: GetDisplayMode(), + }) + + log.StartProgress("Checking Homebrew availability") + isAvailable, err := installer.IsAvailable() + if err != nil { + log.FailProgress("Failed to check Homebrew availability", err) + return "", err + } + + if isAvailable { + log.FinishProgress("Homebrew is already available") + + log.Debug("Detecting Homebrew path") + brewPath, err := brew.DetectBrewPath(&sysInfo, "") + if err != nil { + log.FailProgress("Failed to detect Homebrew path", err) + return "", err + } + log.Debug("Homebrew path detected: %s", brewPath) + + log.Debug("Updating PATH environment variable with Homebrew binaries") + // Although Homebrew is already installed, we still need to update the PATH environment variable, + // because it may not be set correctly. + err = brew.UpdatePathWithBrewBinaries(brewPath) + if err != nil { + log.FailProgress("Failed to update PATH with Homebrew binaries", err) + return "", err + } + log.Debug("PATH updated with Homebrew binaries") + + log.FinishProgress("Homebrew is ready") + return brewPath, nil + } + log.FinishProgress("Homebrew not found") + + log.StartProgress("Installing Homebrew") + if err := installer.Install(); err != nil { + log.FailProgress("Failed to install Homebrew", err) + return "", err + } + log.FinishProgress("Homebrew installation completed") + + log.Debug("Detecting Homebrew path after installation") + brewPath, err := brew.DetectBrewPath(&sysInfo, "") + if err != nil { + log.FailProgress("Failed to detect Homebrew path after installation", err) + return "", err + } + log.Debug("Homebrew path detected: %s", brewPath) + + log.FinishProgress("Homebrew setup completed") + return brewPath, nil +} + +func installShell(log logger.Logger) error { + log.StartProgress(fmt.Sprintf("Setting up %s shell", shellName)) + + shellInstaller := shell.NewDefaultShellInstaller(shellName, globalOsManager, globalPackageManager, log) + + log.StartProgress(fmt.Sprintf("Checking %s shell availability", shellName)) + isAvailable, err := shellInstaller.IsAvailable() + if err != nil { + log.FailProgress(fmt.Sprintf("Failed to check %s shell availability", shellName), err) + return err + } + + if isAvailable { + log.FinishProgress(fmt.Sprintf("%s shell is already available", shellName)) + log.FinishProgress(fmt.Sprintf("%s shell is ready", shellName)) + return nil + } + log.FinishProgress(fmt.Sprintf("%s shell not found", shellName)) + + log.StartProgress(fmt.Sprintf("Installing %s shell", shellName)) + if err := shellInstaller.Install(context.TODO()); err != nil { + log.FailProgress(fmt.Sprintf("Failed to install %s shell", shellName), err) + return err + } + log.FinishProgress(fmt.Sprintf("%s shell installed successfully", shellName)) + + log.FinishProgress(fmt.Sprintf("%s shell setup completed", shellName)) + return nil +} + +func setupGpgKeys(log logger.Logger) error { + err := installGpgClient(log) + if err != nil { + return err + } + + if IsNonInteractive() { + log.Warning("Skipping GPG key setup in non-interactive mode - You will need to set them up manually") + return nil + } + + log.StartProgress("Setting up GPG keys") + + gpgClient := gpg.NewDefaultGpgClient( + globalOsManager, + globalFilesystem, + globalCommander, + cliLogger, + ) + + log.StartProgress("Checking for existing GPG keys") + existingKeys, err := gpgClient.ListAvailableKeys() + if err != nil { + log.FailProgress("Failed to list available GPG keys", err) + return err + } + log.FinishProgress("GPG keys check completed") + + if len(existingKeys) == 0 { + log.StartInteractiveProgress("Creating new GPG key pair") + keyId, err := gpgClient.CreateKeyPair() + if err != nil { + log.FailInteractiveProgress("Failed to create GPG key pair", err) + return err + } + selectedGpgKey = keyId + log.FinishInteractiveProgress("GPG key pair created successfully") + } else { + log.StartInteractiveProgress("Selecting GPG key from existing keys") + gpgSelector := cli.NewDefaultGpgKeySelector() + selectedKey, err := gpgSelector.SelectKey(existingKeys) + if err != nil { + log.FailInteractiveProgress("Failed to select GPG key", err) + return err + } + selectedGpgKey = selectedKey + log.FinishInteractiveProgress("GPG key selected successfully") + } + + log.FinishProgress("GPG keys set up successfully") + return nil +} + +// installGpgClient installs the GPG client if not already available. +func installGpgClient(log logger.Logger) error { + log.StartProgress("Setting up GPG client") + + // Create GpgClientInstaller using the new API. + installer := gpg.NewGpgInstaller( + log, + globalCommander, + globalOsManager, + globalPackageManager, + ) + + log.StartProgress("Checking GPG client availability") + isAvailable, err := installer.IsAvailable() + if err != nil { + log.FailProgress("Failed to check GPG client availability", err) + return err + } + + if isAvailable { + log.FinishProgress("GPG client is already available") + log.FinishProgress("GPG client is ready") + return nil + } + log.FinishProgress("GPG client not found") + + log.StartProgress("Installing GPG client") + if err := installer.Install(context.TODO()); err != nil { + log.FailProgress("Failed to install GPG client", err) + return err + } + log.FinishProgress("GPG client installed successfully") + + log.FinishProgress("GPG client setup completed") + return nil +} + +func setupDotfilesManager(log logger.Logger) error { + log.StartProgress("Setting up dotfiles manager") + + dm, err := chezmoi.TryStandardChezmoiManager(log, globalFilesystem, globalOsManager, globalCommander, globalPackageManager, globalHttpClient, GetDisplayMode(), chezmoi.DefaultGitHubUsername, gitCloneProtocol == "ssh") + if err != nil { + log.FailProgress("Failed to create dotfiles manager", err) + return err + } + + log.StartProgress("Installing dotfiles manager") + err = dm.Install() + if err != nil { + log.FailProgress("Failed to install dotfiles manager", err) + return err + } + log.FinishProgress("Dotfiles manager installed successfully") + + log.StartProgress("Initializing dotfiles manager data") + if err := initDotfilesManagerData(dm); err != nil { + log.FailProgress("Failed to initialize dotfiles manager data", err) + return err + } + log.FinishProgress("Dotfiles manager data initialized successfully") + + log.StartProgress("Applying dotfiles configuration") + if err := dm.Apply(); err != nil { + log.FailProgress("Failed to apply dotfiles configuration", err) + return err + } + log.FinishProgress("Dotfiles configuration applied successfully") + + log.FinishProgress("Dotfiles manager setup completed successfully") + return nil +} + +func initDotfilesManagerData(dm dotfilesmanager.DotfilesManager) error { + dotfiles_data := dotfilesmanager.DotfilesData{ + FirstName: "Timor", + LastName: "Gruber", + Email: "timor.gruber@gmail.com", + SystemData: mo.Some(dotfilesmanager.DotfilesSystemData{ + Shell: shellName, + MultiUserSystem: multiUserSystem, + BrewMultiUser: "linuxbrew-manager", + }), + } + + if workEnvironment { + work_data := dotfilesmanager.DotfilesWorkEnvData{ + WorkName: workName, + WorkEmail: workEmail, + } + dotfiles_data.WorkEnv = mo.Some(work_data) + + userHomeDir, err := os.UserHomeDir() + if err != nil { + return err + } + generic_work_dotfiles_dir := path.Join(userHomeDir, ".work") + + dotfiles_data.SystemData = dotfiles_data.SystemData.Map(func(value dotfilesmanager.DotfilesSystemData) (dotfilesmanager.DotfilesSystemData, bool) { + value.GenericWorkProfile = mo.Some(path.Join(generic_work_dotfiles_dir, "profile")) + value.SpecificWorkProfile = mo.Some(path.Join(generic_work_dotfiles_dir, workName, "profile")) + return value, true + }) + } + + if selectedGpgKey != "" { + dotfiles_data.GpgSigningKey = mo.Some(selectedGpgKey) + } + + return dm.Initialize(dotfiles_data) +} + +//nolint:gochecknoinits // Cobra requires an init function to set up the command structure. +func init() { + rootCmd.AddCommand(installCmd) + + installCmd.Flags().BoolVar(&workEnvironment, "work-env", false, + "Treat this installation as a work environment (affects some dotfiles)") + installCmd.Flags().StringVar(&workName, "work-name", "sedg", + "Use the given name as the work's name") + installCmd.Flags().StringVar(&workEmail, "work-email", "timor.gruber@solaredge.com", + "Use the given email address as work's email address") + installCmd.Flags().StringVar(&shellName, "shell", "zsh", + "Install given shell if required and set it as user's default") + installCmd.Flags().BoolVar(&installBrew, "install-brew", true, + "Install brew if not already installed") + installCmd.Flags().BoolVar(&installShellWithBrew, "install-shell-with-brew", true, + "Install shell with brew if not already installed") + installCmd.Flags().BoolVar(&multiUserSystem, "multi-user-system", false, + "Treat this system as a multi-user system (affects some dotfiles)") + installCmd.Flags().StringVar(&gitCloneProtocol, "git-clone-protocol", "https", + "Use the given git clone protocol (ssh or https) for git operations") + + installCmd.Flags().BoolVar(&installPrerequisites, "install-prerequisites", false, + "Automatically install missing prerequisites") + + viper.BindPFlag("work-env", installCmd.Flags().Lookup("work-env")) + viper.BindPFlag("work-name", installCmd.Flags().Lookup("work-name")) + viper.BindPFlag("work-email", installCmd.Flags().Lookup("work-email")) + viper.BindPFlag("shell", installCmd.Flags().Lookup("shell")) + viper.BindPFlag("install-brew", installCmd.Flags().Lookup("install-brew")) + viper.BindPFlag("install-shell-with-brew", installCmd.Flags().Lookup("install-shell-with-brew")) + viper.BindPFlag("multi-user-system", installCmd.Flags().Lookup("multi-user-system")) + viper.BindPFlag("git-clone-protocol", installCmd.Flags().Lookup("git-clone-protocol")) + + viper.BindPFlag("install-prerequisites", installCmd.Flags().Lookup("install-prerequisites")) +} diff --git a/installer/cmd/root.go b/installer/cmd/root.go new file mode 100644 index 0000000..4940039 --- /dev/null +++ b/installer/cmd/root.go @@ -0,0 +1,277 @@ +package cmd + +import ( + "fmt" + "os" + "os/signal" + "runtime" + "syscall" + + "github.com/MrPointer/dotfiles/installer/lib/compatibility" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/httpclient" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + cfgFile string + compatibilityConfigFile string + globalCompatibilityConfig *compatibility.CompatibilityConfig + globalVerbosity logger.VerbosityLevel + verboseCount int + extraVerbose bool + plainFlag bool + nonInteractive bool + + cliLogger logger.Logger = nil // Will be initialized before any command is executed + globalCommander utils.Commander = nil // Will be initialized before any command is executed + globalHttpClient = httpclient.NewDefaultHTTPClient() + globalFilesystem = utils.NewDefaultFileSystem() + globalOsManager osmanager.OsManager = nil // Will be initialized before any command is executed +) + +// HandleCompatibilityError displays compatibility error with install hints and exits. +func HandleCompatibilityError(err error, sysInfo compatibility.SystemInfo, log logger.Logger) { + // Print the error symbol and message. + fmt.Fprint(os.Stderr, "✘ ") + log.Error("Your system isn't compatible with these dotfiles: %v", err) + + // Show install hints for missing prerequisites + if len(sysInfo.Prerequisites.Missing) > 0 { + fmt.Println() + log.Info("Missing prerequisites and how to install them:") + for _, name := range sysInfo.Prerequisites.Missing { + if detail, exists := sysInfo.Prerequisites.Details[name]; exists { + if detail.InstallHint != "" { + fmt.Printf(" β€’ %s: %s\n", detail.Description, detail.InstallHint) + } else { + fmt.Printf(" β€’ %s\n", detail.Description) + } + } else { + fmt.Printf(" β€’ %s: (no install hint available)\n", name) + } + } + } + os.Exit(1) +} + +// rootCmd represents the base command when called without any subcommands. +var rootCmd = &cobra.Command{ + Use: "dotfiles-installer", + Short: "A tool to install (bootstrap) my dotfiles on any system", + Long: `dotfiles-installer is a command-line tool that helps installing +my personal dotfiles on any system. It automates the process of setting up +necessary configurations, mostly for chezmoi to work properly. +It also installs essential packages and tools that I use on a daily basis. + +By default, the installer shows hierarchical progress indicators with spinners +and timing information (similar to npm). Use --plain for simple text output.`, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + // Ensure cleanup happens after all commands complete successfully + if cliLogger != nil { + cliLogger.Close() + } + }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + err := rootCmd.Execute() + if err != nil { + cleanupAndExit(1) + } + // Cleanup is handled by PersistentPostRun for successful completion +} + +// cleanupAndExit performs cleanup and exits with the given code. +func cleanupAndExit(code int) { + if cliLogger != nil { + cliLogger.Close() + } + os.Exit(code) +} + +// setupCleanup sets up signal handlers and cleanup after logger is initialized. +func setupCleanup() { + // Set up signal handling to ensure cleanup on interrupt + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + + go func() { + <-c + if cliLogger != nil { + cliLogger.Info("Interrupt received, cleaning up...") + } + cleanupAndExit(1) + }() +} + +//nolint:gochecknoinits // Cobra requires an init function to set up the command structure. +func init() { + cobra.OnInitialize(initConfig, initCompatibilityConfig, initLogger, setupCleanup, initCommander, initOsManager) + + // Here you will define your flags and configuration settings. + // Cobra supports persistent flags, which, if defined here, + // will be global for your application. + + rootCmd.PersistentFlags(). + StringVar(&cfgFile, "config", "", "config file (default is $HOME/.dotfiles-installer.yaml)") + + // Add compatibility config flag to root command so it's available globally. + rootCmd.PersistentFlags().StringVar(&compatibilityConfigFile, "compat-config", "", + "compatibility configuration file (uses embedded config by default)") + + // Verbosity flags: supports multiple levels + // - No flags: Normal verbosity with progress indicators (default) + // - -v or --verbose: Verbose level (adds Debug messages, progress disabled by default) + // - -vv or --extra-verbose: Extra verbose level (adds Trace messages, progress disabled by default) + // Note: Progress can be explicitly enabled with --progress even when using verbosity flags + rootCmd.PersistentFlags().CountVarP(&verboseCount, "verbose", "v", + "Enable verbose output (use -vv for extra verbose)") + + rootCmd.PersistentFlags().BoolVar(&extraVerbose, "extra-verbose", false, + "Enable extra verbose output (equivalent to -vv)") + + // Plain flag: controls whether to show plain text logs instead of progress indicators + // - Default behavior: show hierarchical progress indicators (npm-style with spinners and timing) + // - Explicit --plain: disables progress and shows regular log messages instead + // - Progress always disabled in non-interactive mode regardless of other flags + rootCmd.PersistentFlags().BoolVar(&plainFlag, "plain", false, + "Show plain text logs instead of hierarchical progress indicators") + + // Interactive flag: controls whether to allow user interaction + // - When enabled, disables progress indicators and skips any prompts + // - Affects all commands that might need user input + rootCmd.PersistentFlags().BoolVar(&nonInteractive, "non-interactive", false, + "Disable interactive mode (also disables progress indicators)") + + viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose")) + viper.BindPFlag("extra-verbose", rootCmd.PersistentFlags().Lookup("extra-verbose")) + viper.BindPFlag("plain", rootCmd.PersistentFlags().Lookup("plain")) + viper.BindPFlag("non-interactive", rootCmd.PersistentFlags().Lookup("non-interactive")) +} + +// initConfig reads in config file and ENV variables if set. +func initConfig() { + if cfgFile != "" { + // Use config file from the flag. + viper.SetConfigFile(cfgFile) + } else { + // Find home directory. + home, err := os.UserHomeDir() + cobra.CheckErr(err) + + // Search config in home directory with name ".dotfiles" (without extension). + viper.AddConfigPath(home) + viper.SetConfigType("yaml") + viper.SetConfigName(".dotfiles-installer") + } + + viper.AutomaticEnv() // Read in environment variables that match. + + // If a config file is found, read it in. + if err := viper.ReadInConfig(); err == nil { + fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) + } +} + +func initCompatibilityConfig() { + // Initialize compatibility configuration. + compatibilityConfig, err := compatibility.LoadCompatibilityConfig(viper.New(), compatibilityConfigFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading compatibility config: %v\n", err) + os.Exit(1) + } + globalCompatibilityConfig = compatibilityConfig +} + +// GetCompatibilityConfig returns the loaded compatibility configuration. +func GetCompatibilityConfig() *compatibility.CompatibilityConfig { + return globalCompatibilityConfig +} + +func initOsManager() { + if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { + globalOsManager = osmanager.NewUnixOsManager(cliLogger, globalCommander, osmanager.IsRoot()) + } else { + cliLogger.Error("The system may be compatible, but we haven't implemented an OS manager for it yet. Please open an issue on GitHub to request support for this OS.") + os.Exit(1) + } +} + +func initCommander() { + globalCommander = utils.NewDefaultCommander(cliLogger) +} + +func initLogger() { + // Determine verbosity level based on flags + if extraVerbose || verboseCount >= 2 { + globalVerbosity = logger.ExtraVerbose + } else if verboseCount >= 1 { + globalVerbosity = logger.Verbose + } else { + globalVerbosity = logger.Normal + } + + // Create logger with or without progress based on ShouldShowProgress() + if ShouldShowProgress() { + cliLogger = logger.NewProgressCliLogger(globalVerbosity) + } else { + cliLogger = logger.NewCliLogger(globalVerbosity) + } +} + +// ShouldShowProgress determines if hierarchical progress indicators should be shown. +// The logic is: +// 1. If --non-interactive is set, never show progress +// 2. If --plain was explicitly set, don't show progress (use regular logging) +// 3. Otherwise, default to showing progress (hierarchical indicators) +func ShouldShowProgress() bool { + // If non-interactive mode is enabled, never show progress + if nonInteractive { + return false + } + + // Don't show progress if plain output was explicitly requested + if plainFlag { + return false + } + + // Default to showing progress + return true +} + +// GetVerbosity returns the current global verbosity level. +func GetVerbosity() logger.VerbosityLevel { + return globalVerbosity +} + +// IsNonInteractive returns whether non-interactive mode is enabled. +func IsNonInteractive() bool { + return nonInteractive +} + +// GetDisplayMode determines the appropriate display mode for external tool output. +func GetDisplayMode() utils.DisplayMode { + // If non-interactive mode is enabled, show all command output + if nonInteractive { + return utils.DisplayModePassthrough + } + + // If verbose mode is enabled (any level), show all command output + if globalVerbosity >= logger.Verbose { + return utils.DisplayModePassthrough + } + + // If plain flag is set, use plain mode (simple progress messages, hide output) + if plainFlag { + return utils.DisplayModePlain + } + + // Default: use progress mode (spinners, hide output) + return utils.DisplayModeProgress +} diff --git a/installer/cmd/verbosity_test.go b/installer/cmd/verbosity_test.go new file mode 100644 index 0000000..5e76c3c --- /dev/null +++ b/installer/cmd/verbosity_test.go @@ -0,0 +1,231 @@ +package cmd + +import ( + "testing" + + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/stretchr/testify/require" +) + +func Test_VerbosityLevelDeterminationLogic(t *testing.T) { + tests := []struct { + name string + verboseCount int + extraVerbose bool + expected logger.VerbosityLevel + }{ + { + name: "Normal_WhenNoVerbosityFlags", + verboseCount: 0, + extraVerbose: false, + expected: logger.Normal, + }, + { + name: "Verbose_WhenSingleVerboseFlag", + verboseCount: 1, + extraVerbose: false, + expected: logger.Verbose, + }, + { + name: "ExtraVerbose_WhenDoubleVerboseFlag", + verboseCount: 2, + extraVerbose: false, + expected: logger.ExtraVerbose, + }, + { + name: "ExtraVerbose_WhenTripleVerboseFlag", + verboseCount: 3, + extraVerbose: false, + expected: logger.ExtraVerbose, + }, + { + name: "ExtraVerbose_WhenExtraVerboseFlagSet", + verboseCount: 0, + extraVerbose: true, + expected: logger.ExtraVerbose, + }, + { + name: "ExtraVerbose_WhenBothExtraVerboseAndVerboseFlags", + verboseCount: 1, + extraVerbose: true, + expected: logger.ExtraVerbose, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset global state + verboseCount = tt.verboseCount + extraVerbose = tt.extraVerbose + plainFlag = false + globalVerbosity = logger.Normal + + // Call the verbosity determination logic + initLogger() + + // Verify the result + actual := GetVerbosity() + require.Equal(t, tt.expected, actual) + }) + } +} + +func Test_CliLoggerCreationWithDifferentVerbosityLevels(t *testing.T) { + tests := []struct { + name string + verbosity logger.VerbosityLevel + }{ + { + name: "Normal_VerbosityLevel", + verbosity: logger.Normal, + }, + { + name: "Verbose_VerbosityLevel", + verbosity: logger.Verbose, + }, + { + name: "ExtraVerbose_VerbosityLevel", + verbosity: logger.ExtraVerbose, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create logger with specific verbosity level + testLogger := logger.NewCliLogger(tt.verbosity) + + // Verify logger was created successfully + require.NotNil(t, testLogger) + }) + } +} + +func Test_ShouldShowProgress_Logic(t *testing.T) { + tests := []struct { + name string + plainFlag bool + nonInteractive bool + expected bool + }{ + { + name: "Progress_WhenNoFlags", + plainFlag: false, + nonInteractive: false, + expected: true, + }, + { + name: "NoProgress_WhenPlainFlagSet", + plainFlag: true, + nonInteractive: false, + expected: false, + }, + { + name: "NoProgress_WhenNonInteractiveSet", + plainFlag: false, + nonInteractive: true, + expected: false, + }, + { + name: "NoProgress_WhenBothPlainAndNonInteractiveSet", + plainFlag: true, + nonInteractive: true, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset global state + plainFlag = tt.plainFlag + nonInteractive = tt.nonInteractive + + // Verify the result + actual := ShouldShowProgress() + require.Equal(t, tt.expected, actual) + }) + } +} + +func Test_GetDisplayMode_Logic(t *testing.T) { + tests := []struct { + name string + nonInteractiveFlag bool + plainFlag bool + verbosity logger.VerbosityLevel + expectedDisplayMode utils.DisplayMode + }{ + { + name: "Progress_WhenDefaultSettings", + nonInteractiveFlag: false, + plainFlag: false, + verbosity: logger.Normal, + expectedDisplayMode: utils.DisplayModeProgress, + }, + { + name: "Plain_WhenPlainFlagSet", + nonInteractiveFlag: false, + plainFlag: true, + verbosity: logger.Normal, + expectedDisplayMode: utils.DisplayModePlain, + }, + { + name: "Passthrough_WhenNonInteractiveSet", + nonInteractiveFlag: true, + plainFlag: false, + verbosity: logger.Normal, + expectedDisplayMode: utils.DisplayModePassthrough, + }, + { + name: "Passthrough_WhenVerboseSet", + nonInteractiveFlag: false, + plainFlag: false, + verbosity: logger.Verbose, + expectedDisplayMode: utils.DisplayModePassthrough, + }, + { + name: "Passthrough_WhenExtraVerboseSet", + nonInteractiveFlag: false, + plainFlag: false, + verbosity: logger.ExtraVerbose, + expectedDisplayMode: utils.DisplayModePassthrough, + }, + { + name: "Passthrough_WhenNonInteractiveAndPlainBothSet", + nonInteractiveFlag: true, + plainFlag: true, + verbosity: logger.Normal, + expectedDisplayMode: utils.DisplayModePassthrough, + }, + { + name: "Passthrough_WhenNonInteractiveAndVerboseSet", + nonInteractiveFlag: true, + plainFlag: false, + verbosity: logger.Verbose, + expectedDisplayMode: utils.DisplayModePassthrough, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Store original values + origNonInteractive := nonInteractive + origPlainFlag := plainFlag + origGlobalVerbosity := globalVerbosity + + // Set test values + nonInteractive = tt.nonInteractiveFlag + plainFlag = tt.plainFlag + globalVerbosity = tt.verbosity + + // Test the function + result := GetDisplayMode() + require.Equal(t, tt.expectedDisplayMode, result) + + // Restore original values + nonInteractive = origNonInteractive + plainFlag = origPlainFlag + globalVerbosity = origGlobalVerbosity + }) + } +} diff --git a/installer/demo/Taskfile.yml b/installer/demo/Taskfile.yml new file mode 100644 index 0000000..2627cb2 --- /dev/null +++ b/installer/demo/Taskfile.yml @@ -0,0 +1,318 @@ +# Taskfile for Spinner Demo Application +# Consolidates build, test, and development tasks for the logger spinner demo + +version: "3" + +vars: + DEMO_BINARY: spinner-demo + DEFAULT_COUNT: 3 + DEFAULT_FAIL_RATE: 25 + +tasks: + default: + desc: Show available tasks + cmds: + - task --list-all + + build: + desc: Build the demo application + cmds: + - echo "πŸ”¨ Building spinner demo..." + - go build -o {{.DEMO_BINARY}} ./ + - echo "βœ… Build complete" + generates: + - "{{.DEMO_BINARY}}" + + clean: + desc: Clean build artifacts + cmds: + - echo "🧹 Cleaning build artifacts..." + - rm -f {{.DEMO_BINARY}} + - echo "βœ… Clean complete" + + run: + desc: Run demo with default settings + deps: [build] + cmds: + - echo "🎯 Running default demo..." + - ./{{.DEMO_BINARY}} + + # Individual demo types + simple: + desc: Run simple sequential demo + deps: [build] + cmds: + - echo "1️⃣ Running simple demo..." + - ./{{.DEMO_BINARY}} --type simple --count {{.DEFAULT_COUNT}} + + nested: + desc: Run hierarchical nested demo + deps: [build] + cmds: + - echo "2️⃣ Running nested demo..." + - ./{{.DEMO_BINARY}} --type nested --count {{.DEFAULT_COUNT}} + + mixed: + desc: Run mixed success/failure demo + deps: [build] + cmds: + - echo "3️⃣ Running mixed demo..." + - ./{{.DEMO_BINARY}} --type mixed --count {{.DEFAULT_COUNT}} --fail-rate {{.DEFAULT_FAIL_RATE}} + + concurrent: + desc: Run concurrent operations demo + deps: [build] + cmds: + - echo "4️⃣ Running concurrent demo..." + - ./{{.DEMO_BINARY}} --type concurrent --count 5 + + long: + desc: Run long-running installation demo + deps: [build] + cmds: + - echo "5️⃣ Running long demo..." + - ./{{.DEMO_BINARY}} --type long + + persistent: + desc: Run persistent progress demo (like cargo/npm) + deps: [build] + cmds: + - echo "6️⃣ Running persistent demo..." + - ./{{.DEMO_BINARY}} --type persistent + + interactive: + desc: Run interactive demo (tests spinner pause/resume) + deps: [build] + cmds: + - echo "7️⃣ Running interactive demo..." + - ./{{.DEMO_BINARY}} --type interactive + + interactive-stress: + desc: Run stress test demo (rapid pause/resume cycles) + deps: [build] + cmds: + - echo "πŸ”₯ Running stress test demo..." + - ./{{.DEMO_BINARY}} --type stress + + # Comparison and testing + compare: + desc: Compare progress vs plain output modes + deps: [build] + cmds: + - echo "πŸ“Š Comparing output modes..." + - echo "" + - echo "πŸŽͺ With Progress Display:" + - ./{{.DEMO_BINARY}} --type simple --count 2 --progress + - echo "" + - echo "πŸ“ With Plain Text Output:" + - ./{{.DEMO_BINARY}} --type simple --count 2 --progress=false + + all-demos: + desc: Run all demo types interactively + deps: [build] + cmds: + - | + echo "🎭 Running all demo types..." + echo "" + echo "Press Enter after each demo to continue..." + echo "" + - task: simple + - | + if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + read -n1 -r -p "Press any key to continue..." + else + read -p "Press Enter to continue..." + fi + - task: nested + - | + if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + read -n1 -r -p "Press any key to continue..." + else + read -p "Press Enter to continue..." + fi + - task: mixed + - | + if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + read -n1 -r -p "Press any key to continue..." + else + read -p "Press Enter to continue..." + fi + - task: concurrent + - | + if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + read -n1 -r -p "Press any key to continue..." + else + read -p "Press Enter to continue..." + fi + - task: long + - | + if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + read -n1 -r -p "Press any key to continue..." + else + read -p "Press Enter to continue..." + fi + - task: interactive + - | + if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + read -n1 -r -p "Press any key to continue..." + else + read -p "Press Enter to continue..." + fi + - task: stress + - echo "" + - echo "πŸŽ‰ All demos completed!" + + # Development and testing tasks + quick-test: + desc: Quick test of all demo types (non-interactive) + deps: [build] + cmds: + - echo "⚑ Quick test of all demo types..." + - ./{{.DEMO_BINARY}} --type simple --count 2 + - ./{{.DEMO_BINARY}} --type nested --count 2 + - ./{{.DEMO_BINARY}} --type mixed --count 2 --fail-rate {{.DEFAULT_FAIL_RATE}} + - ./{{.DEMO_BINARY}} --type concurrent --count 3 + - ./{{.DEMO_BINARY}} --type long + - ./{{.DEMO_BINARY}} --type persistent + - ./{{.DEMO_BINARY}} --type interactive + - ./{{.DEMO_BINARY}} --type stress + + verbose: + desc: Run with verbose logging for debugging + deps: [build] + cmds: + - echo "πŸ” Running with verbose output..." + - ./{{.DEMO_BINARY}} --type nested --verbosity verbose --count 2 + + stress: + desc: Stress test with many concurrent operations + deps: [build] + cmds: + - echo "⚑ Stress testing with concurrent operations..." + - ./{{.DEMO_BINARY}} --type concurrent --count 15 --fail-rate 20 + + failures: + desc: Test with high failure rate + deps: [build] + cmds: + - echo "πŸ’₯ Testing with high failure rate..." + - ./{{.DEMO_BINARY}} --type mixed --count 5 --fail-rate 60 + + # Development workflow tasks + dev-test: + desc: Development testing workflow + deps: [build] + cmds: + - echo "πŸ”§ Running development test workflow..." + - task: verbose + - task: compare + - task: failures + + # Verification and validation + check: + desc: Verify demo setup and functionality + cmds: + - echo "πŸ” Checking demo setup..." + - | + if [ ! -f "../go.mod" ]; then + echo "❌ Error: Run from installer directory" + exit 1 + fi + - go build -o {{.DEMO_BINARY}} ./ + - echo "βœ… Build successful" + - ./{{.DEMO_BINARY}} --type simple --count 1 + - echo "βœ… Runtime successful" + - rm -f {{.DEMO_BINARY}} + - echo "βœ… Demo check complete" + + # Custom scenarios + custom: + desc: Run custom demo scenario (interactive) + deps: [build] + interactive: true + cmds: + - | + echo "🎨 Custom Demo Configuration" + echo "Available types: simple, nested, mixed, concurrent, long" + echo "Enter demo configuration or press Enter for defaults:" + + read -p "Type (simple): " TYPE + TYPE=${TYPE:-simple} + + read -p "Count (3): " COUNT + COUNT=${COUNT:-3} + + read -p "Fail rate % (0): " FAIL_RATE + FAIL_RATE=${FAIL_RATE:-0} + + read -p "Verbosity (normal): " VERBOSITY + VERBOSITY=${VERBOSITY:-normal} + + read -p "Progress display [Y/n]: " PROGRESS + if [[ "$PROGRESS" =~ ^[Nn] ]]; then + PROGRESS_FLAG="--progress=false" + else + PROGRESS_FLAG="--progress" + fi + + echo "" + echo "Running: ./{{.DEMO_BINARY}} --type $TYPE --count $COUNT --fail-rate $FAIL_RATE --verbosity $VERBOSITY $PROGRESS_FLAG" + ./{{.DEMO_BINARY}} --type $TYPE --count $COUNT --fail-rate $FAIL_RATE --verbosity $VERBOSITY $PROGRESS_FLAG + + # Performance and benchmarking + benchmark: + desc: Run performance benchmarks + deps: [build] + cmds: + - echo "πŸ“ˆ Running performance benchmarks..." + - echo "Timing concurrent operations:" + - time ./{{.DEMO_BINARY}} --type concurrent --count 50 + - echo "" + - echo "Memory usage for long operations:" + - | + if command -v /usr/bin/time > /dev/null; then + /usr/bin/time -v ./{{.DEMO_BINARY}} --type long 2>&1 | grep -E "(Maximum|User|System)" || true + else + echo "Memory profiling not available on this system" + fi + + # Help and documentation + help: + desc: Show detailed help and examples + cmds: + - | + cat << 'EOF' + πŸŽͺ Spinner Demo Taskfile Help + ============================ + + πŸ“‹ Common Tasks: + task run - Run default demo + task all-demos - Run all demos interactively + task quick-test - Run all demos quickly + task compare - Compare progress vs plain output + task custom - Interactive custom configuration + + 🎯 Individual Demo Types: + task simple - Sequential operations + task nested - Hierarchical operations + task mixed - Success/failure mix + task concurrent - Thread-safe operations + task long - Installation simulation + task persistent - Persistent progress (like cargo/npm) + + πŸ”§ Development Tasks: + task dev-test - Development workflow + task verbose - Debug with verbose output + task stress - Stress test concurrency + task failures - Test error handling + task benchmark - Performance testing + + πŸ“š Usage Examples: + task simple # Basic demo + task nested # Hierarchical demo + task mixed # With some failures + task custom # Interactive config + + πŸ’‘ Direct Usage: + task build && ./spinner-demo --type nested --count 5 --fail-rate 30 + EOF diff --git a/installer/demo/main.go b/installer/demo/main.go new file mode 100644 index 0000000..e8d216e --- /dev/null +++ b/installer/demo/main.go @@ -0,0 +1,441 @@ +package main + +import ( + "errors" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/spf13/cobra" +) + +var ( + verbosity string + withProgress bool + operationType string + operationCount int + failureRate int +) + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +var rootCmd = &cobra.Command{ + Use: "spinner-demo", + Short: "Demo application for testing spinner capabilities", + Long: `A demo application that showcases the hierarchical progress display +capabilities of the logger package. Use this to test and refine spinner +behavior without running the full installer.`, + RunE: runDemo, +} + +func init() { + rootCmd.Flags().StringVarP(&verbosity, "verbosity", "v", "normal", + "Log verbosity level (minimal, normal, verbose, extra-verbose)") + rootCmd.Flags().BoolVarP(&withProgress, "progress", "p", true, + "Enable progress display with spinners") + rootCmd.Flags().StringVarP(&operationType, "type", "t", "simple", + "Type of demo to run (simple, nested, mixed, concurrent, long, persistent, interactive, stress)") + rootCmd.Flags().IntVarP(&operationCount, "count", "c", 3, + "Number of operations to run") + rootCmd.Flags().IntVar(&failureRate, "fail-rate", 0, + "Percentage of operations that should fail (0-100)") +} + +func runDemo(cmd *cobra.Command, args []string) error { + // Parse verbosity level + var verbosityLevel logger.VerbosityLevel + switch verbosity { + case "minimal": + verbosityLevel = logger.Minimal + case "normal": + verbosityLevel = logger.Normal + case "verbose": + verbosityLevel = logger.Verbose + case "extra-verbose": + verbosityLevel = logger.ExtraVerbose + default: + return fmt.Errorf("invalid verbosity level: %s", verbosity) + } + + // Create logger + var log logger.Logger + if withProgress { + log = logger.NewProgressCliLogger(verbosityLevel) + fmt.Println("🎯 Running spinner demo with progress display enabled") + } else { + log = logger.NewCliLogger(verbosityLevel) + fmt.Println("πŸ“ Running spinner demo with plain text output") + } + + // Set up signal handling to ensure cleanup on interrupt + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + + go func() { + <-c + log.Info("Interrupt received, cleaning up...") + log.Close() + os.Exit(1) + }() + + // Defer cleanup to ensure it runs even if program exits normally + defer log.Close() + + fmt.Printf("πŸ“Š Configuration: type=%s, count=%d, fail-rate=%d%%\n\n", + operationType, operationCount, failureRate) + + // Run the appropriate demo + switch operationType { + case "simple": + return runSimpleDemo(log) + case "nested": + return runNestedDemo(log) + case "mixed": + return runMixedDemo(log) + case "concurrent": + return runConcurrentDemo(log) + case "long": + return runLongDemo(log) + case "persistent": + return runPersistentDemo(log) + case "interactive": + return runInteractiveDemo(log) + case "stress": + return runStressTestDemo(log) + default: + return fmt.Errorf("invalid operation type: %s", operationType) + } +} + +func runSimpleDemo(log logger.Logger) error { + log.Info("πŸš€ Starting simple demo with %d operations", operationCount) + + for i := 1; i <= operationCount; i++ { + operationName := fmt.Sprintf("Simple operation %d", i) + + log.StartProgress(operationName) + + // Simulate work + duration := time.Duration(200+i*100) * time.Millisecond + time.Sleep(duration) + + // Randomly fail based on failure rate + if shouldFail() { + log.FailProgress(operationName, errors.New("simulated failure")) + } else { + log.FinishProgress(fmt.Sprintf("Completed %s", operationName)) + } + } + + log.Success("βœ… Simple demo completed") + return nil +} + +func runNestedDemo(log logger.Logger) error { + log.Info("πŸ—οΈ Starting nested demo") + + log.StartProgress("Parent operation") + time.Sleep(100 * time.Millisecond) + + for i := 1; i <= operationCount; i++ { + childOp := fmt.Sprintf("Child operation %d", i) + + log.StartProgress(childOp) + time.Sleep(150 * time.Millisecond) + + // Add some grandchild operations + if i%2 == 0 { + grandchildOp := fmt.Sprintf("Grandchild of operation %d", i) + log.StartProgress(grandchildOp) + time.Sleep(100 * time.Millisecond) + + if shouldFail() { + log.FailProgress(grandchildOp, errors.New("grandchild failed")) + } else { + log.FinishProgress(fmt.Sprintf("Completed %s", grandchildOp)) + } + } + + if shouldFail() { + log.FailProgress(childOp, errors.New("child operation failed")) + } else { + log.FinishProgress(fmt.Sprintf("Completed %s", childOp)) + } + } + + log.FinishProgress("Parent operation completed") + log.Success("βœ… Nested demo completed") + return nil +} + +func runMixedDemo(log logger.Logger) error { + log.Info("🎭 Starting mixed demo (success/failure mix)") + + for i := 1; i <= operationCount; i++ { + parentOp := fmt.Sprintf("Mixed parent %d", i) + + log.StartProgress(parentOp) + time.Sleep(50 * time.Millisecond) + + // First child - usually succeeds + child1 := fmt.Sprintf("Child %d-A (success)", i) + log.StartProgress(child1) + time.Sleep(100 * time.Millisecond) + log.FinishProgress(fmt.Sprintf("Completed %s", child1)) + + // Second child - might fail + child2 := fmt.Sprintf("Child %d-B (risky)", i) + log.StartProgress(child2) + time.Sleep(100 * time.Millisecond) + + if shouldFail() { + log.FailProgress(child2, errors.New("risky operation failed")) + } else { + log.FinishProgress(fmt.Sprintf("Completed %s", child2)) + } + + // Third child with updates + child3 := fmt.Sprintf("Child %d-C (with updates)", i) + log.StartProgress(child3) + time.Sleep(50 * time.Millisecond) + + log.UpdateProgress(fmt.Sprintf("%s (25%% complete)", child3)) + time.Sleep(50 * time.Millisecond) + + log.UpdateProgress(fmt.Sprintf("%s (75%% complete)", child3)) + time.Sleep(50 * time.Millisecond) + + log.FinishProgress(fmt.Sprintf("Completed %s", child3)) + + log.FinishProgress(fmt.Sprintf("Completed %s", parentOp)) + } + + log.Success("βœ… Mixed demo completed") + return nil +} + +func runConcurrentDemo(log logger.Logger) error { + log.Info("⚑ Starting concurrent demo") + + done := make(chan bool, operationCount) + + for i := 1; i <= operationCount; i++ { + go func(id int) { + defer func() { done <- true }() + + opName := fmt.Sprintf("Concurrent operation %d", id) + + log.StartProgress(opName) + + // Simulate varying work durations + duration := time.Duration(100+id*50) * time.Millisecond + time.Sleep(duration) + + if shouldFail() { + log.FailProgress(opName, fmt.Errorf("concurrent operation %d failed", id)) + } else { + log.FinishProgress(fmt.Sprintf("Completed %s", opName)) + } + }(i) + + // Stagger the start times slightly + time.Sleep(25 * time.Millisecond) + } + + // Wait for all operations to complete + for i := 0; i < operationCount; i++ { + <-done + } + + log.Success("βœ… Concurrent demo completed") + return nil +} + +func runLongDemo(log logger.Logger) error { + log.Info("⏳ Starting long-running demo") + + log.StartProgress("Long-running installation process") + time.Sleep(100 * time.Millisecond) + + phases := []string{ + "Downloading packages", + "Extracting archives", + "Installing dependencies", + "Configuring settings", + "Running post-install scripts", + "Cleaning up temporary files", + } + + for i, phase := range phases { + log.StartProgress(phase) + + // Simulate progress updates + steps := 4 + for step := 1; step <= steps; step++ { + time.Sleep(200 * time.Millisecond) + percentage := (step * 100) / steps + log.UpdateProgress(fmt.Sprintf("%s (%d%%)", phase, percentage)) + } + + // Occasionally fail a phase + if i == 3 && shouldFail() { // Fail configuration sometimes + log.FailProgress(phase, errors.New("configuration validation failed")) + log.FailProgress("Long-running installation process", + errors.New("installation aborted due to configuration error")) + return nil + } + + log.FinishProgress(fmt.Sprintf("Completed %s", phase)) + } + + log.FinishProgress("Installation process completed successfully") + log.Success("βœ… Long-running demo completed") + return nil +} + +func shouldFail() bool { + if failureRate <= 0 { + return false + } + if failureRate >= 100 { + return true + } + + // Simple pseudo-random failure based on current time + return int(time.Now().UnixNano()%100) < failureRate +} + +func runPersistentDemo(log logger.Logger) error { + log.Info("πŸ“¦ Starting persistent progress demo (like cargo/npm)") + + // Demo 1: Installing system packages + log.StartPersistentProgress("Installing system packages") + time.Sleep(200 * time.Millisecond) + + packages := []string{"brew", "git v2.39.0", "zsh v5.8.1", "fzf v0.35.1", "tmux v3.3a"} + for _, pkg := range packages { + time.Sleep(300 * time.Millisecond) + if shouldFail() { + log.FailPersistentProgress("Failed to install system packages", fmt.Errorf("could not install %s", pkg)) + return nil + } + log.LogAccomplishment(fmt.Sprintf("Installed %s", pkg)) + } + log.FinishPersistentProgress(fmt.Sprintf("System packages installed (%d packages)", len(packages))) + + time.Sleep(500 * time.Millisecond) + + // Demo 2: Configuring shell environment + log.StartPersistentProgress("Configuring shell environment") + time.Sleep(150 * time.Millisecond) + + configs := []string{ + "~/.zshrc", + "~/.zsh/aliases.zsh", + "~/.zsh/exports.zsh", + "~/.zsh/functions.zsh", + } + for _, config := range configs { + time.Sleep(200 * time.Millisecond) + log.LogAccomplishment(fmt.Sprintf("Created %s", config)) + } + + time.Sleep(300 * time.Millisecond) + log.LogAccomplishment("Installed oh-my-zsh plugins (5 plugins)") + log.FinishPersistentProgress("Shell environment configured") + + time.Sleep(500 * time.Millisecond) + + // Demo 3: Linking dotfiles + log.StartPersistentProgress("Linking dotfiles") + time.Sleep(100 * time.Millisecond) + + dotfiles := []struct { + target, source string + }{ + {"~/.vimrc", "~/dotfiles/vim/vimrc"}, + {"~/.tmux.conf", "~/dotfiles/tmux/tmux.conf"}, + {"~/.gitconfig", "~/dotfiles/git/gitconfig"}, + {"~/.ssh/config", "~/dotfiles/ssh/config"}, + } + + for _, df := range dotfiles { + time.Sleep(150 * time.Millisecond) + log.LogAccomplishment(fmt.Sprintf("Linked %s β†’ %s", df.target, df.source)) + } + log.FinishPersistentProgress(fmt.Sprintf("Dotfiles linked (%d files)", len(dotfiles))) + + log.Success("βœ… Persistent demo completed - notice how accomplishments stay visible!") + return nil +} + +func runStressTestDemo(log logger.Logger) error { + log.Info("πŸ”₯ Starting stress test demo - rapid pause/resume cycles") + + // Start background operations + log.StartProgress("Background operation 1") + time.Sleep(100 * time.Millisecond) + + log.StartProgress("Background operation 2") + time.Sleep(100 * time.Millisecond) + + // Perform rapid pause/resume cycles to test race conditions + for i := 1; i <= 5; i++ { + log.StartInteractiveProgress(fmt.Sprintf("Interactive cycle %d (rapid test)", i)) + + // Very short interaction to stress test the synchronization + time.Sleep(50 * time.Millisecond) + + log.FinishInteractiveProgress(fmt.Sprintf("Interactive cycle %d completed", i)) + + // Brief pause between cycles + time.Sleep(50 * time.Millisecond) + } + + // Finish background operations + log.FinishProgress("Background operation 2 completed") + log.FinishProgress("Background operation 1 completed") + + log.Success("πŸ”₯ Stress test completed - no race conditions detected!") + return nil +} + +func runInteractiveDemo(log logger.Logger) error { + log.Info("🎯 Starting interactive demo to test spinner pause/resume") + + // Start a regular progress operation + log.StartProgress("Background operation running") + + // Simulate some background work + time.Sleep(500 * time.Millisecond) + + // Start an interactive operation that should pause the spinner + log.StartInteractiveProgress("Interactive operation (spinner should pause)") + + // Simulate an interactive command (like GPG key creation) + fmt.Print("Please enter some text (this simulates GPG prompts): ") + var userInput string + fmt.Scanln(&userInput) + + // Finish the interactive operation (should resume spinner) + log.FinishInteractiveProgress("Interactive operation completed") + + // Continue with background work + time.Sleep(500 * time.Millisecond) + + // Finish the background operation + log.FinishProgress("Background operation finished") + + log.Success("πŸŽ‰ Interactive demo completed successfully!") + log.Info("User input was: %s", userInput) + + return nil +} diff --git a/installer/docker/.dockerignore b/installer/docker/.dockerignore new file mode 100644 index 0000000..6c2128d --- /dev/null +++ b/installer/docker/.dockerignore @@ -0,0 +1,59 @@ +# Git directories and files +.git +.gitignore +.gitattributes + +# Go build artifacts +/bin/ +/dist/ +*.exe +*.dll +*.so +*.dylib +*.test +*.out +go.work + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# OS generated files +Thumbs.db +ehthumbs.db + +# Task runner +.task/ + +# Documentation (not needed for builds) +README.md +*.md + +# GitHub workflows +.github/ + +# Demo and test files +/demo/ +*.test + +# License and config files +LICENSE +.copier-answers.yml +.golangci.yml +.goreleaser.yaml +.mockery.yml +.typos.toml +brew.test + +# Other Docker files in parent directories +**/Dockerfile +**/docker-compose.yml +**/docker/ + +# Temporary files +*.tmp +*.temp diff --git a/installer/docker/README.md b/installer/docker/README.md new file mode 100644 index 0000000..e2111b2 --- /dev/null +++ b/installer/docker/README.md @@ -0,0 +1,288 @@ +# Multi-OS Docker Testing Environment + +This directory contains Docker configurations for creating consistent testing environments across different operating systems with all required prerequisites pre-installed. + +## Supported Operating Systems + +The Docker environment supports the following operating systems based on `../internal/config/compatibility.yaml`: + +- **Ubuntu Latest** - All prerequisites via `apt-get` +- **Debian Latest** - All prerequisites via `apt-get` +- **Fedora Latest** - All prerequisites via `dnf` + +Each OS includes: +- Build tools (gcc, make, development packages) +- Process utilities (ps, procps) +- Network tools (curl) +- File utilities (file command) +- Version control (git) +- SSL certificates (ca-certificates) + +## Quick Start + +### List Available Operating Systems +```bash +task list +``` + +### Ubuntu Environment +```bash +# All-in-one development command +task ubuntu:dev + +# Or step by step: +task ubuntu:start # Start Ubuntu environment +task ubuntu:shell # Enter the container shell +task ubuntu:stop # Stop when done +``` + +### Debian Environment +```bash +task debian:dev # Start Debian and enter shell +task debian:validate # Run validation tests +task debian:stop # Stop environment +``` + +### Fedora Environment +```bash +task fedora:dev # Start Fedora and enter shell +task fedora:validate # Run validation tests +task fedora:stop # Stop environment +``` + +## Available Tasks + +Run `task --list` to see all available tasks. Here are the main patterns: + +### Per-OS Tasks +Each OS (`ubuntu`, `debian`, `fedora`) supports: +- `task :start` - Build and start the environment +- `task :stop` - Stop the environment +- `task :shell` - Enter the container shell +- `task :dev` - Start environment and enter shell (recommended) +- `task :validate` - Run validation tests +- `task :rebuild` - Rebuild the Docker image from scratch +- `task :clean` - Clean up containers and images + +### Multi-OS Tasks +- `task status` - Show status of all environments +- `task stop-all` - Stop all running environments +- `task clean-all` - Clean up all environments +- `task validate-all` - Run validation on all environments + +## Directory Structure + +``` +docker/ +β”œβ”€β”€ README.md # This file +β”œβ”€β”€ Taskfile.yml # Multi-OS task definitions +β”œβ”€β”€ validate.sh # OS-aware validation script +β”œβ”€β”€ ubuntu/ # Ubuntu-specific files +β”‚ β”œβ”€β”€ Dockerfile +β”‚ └── docker-compose.yml +β”œβ”€β”€ debian/ # Debian-specific files +β”‚ β”œβ”€β”€ Dockerfile +β”‚ └── docker-compose.yml +└── fedora/ # Fedora-specific files + β”œβ”€β”€ Dockerfile + └── docker-compose.yml +``` + +## Features + +- **Multiple OS support** - Test across Ubuntu, Debian, and Fedora +- **OS-specific prerequisites** - Each image has the correct packages for that OS +- **Non-root user setup** - All containers run as `testuser` with sudo privileges +- **Volume mounting** - Your installer code is mounted at `/workspace/installer` +- **Persistent caches** - Separate Docker volumes for each OS build artifacts +- **Clean environments** - Fresh OS installation every time +- **Comprehensive validation** - OS-aware validation script +- **Task automation** - Streamlined workflow with OS-namespaced tasks + +## Usage Examples + +### Development Workflow +```bash +# Start your preferred OS environment +task ubuntu:dev # Ubuntu development +# or +task debian:dev # Debian development +# or +task fedora:dev # Fedora development + +# The container will start and you'll be dropped into a shell +# Your installer code is available at /workspace/installer +``` + +### Testing Across Multiple OS +```bash +# Validate all environments work +task validate-all + +# Check status of all environments +task status + +# Clean up everything when done +task clean-all +``` + +### Validation Results +```bash +task ubuntu:validate +# Example output: +# πŸ§ͺ Running Ubuntu validation tests... +# [PASS] Running as testuser +# [PASS] Sudo access without password works +# [PASS] GCC (build-essential) is available +# [PASS] All prerequisites installed +# βœ… All tests passed! Ubuntu environment is ready for development. +``` + +### OS-Specific Testing +```bash +# Test Ubuntu-specific behavior +task ubuntu:start +task ubuntu:shell +# ... do Ubuntu-specific testing ... + +# Switch to Fedora for RPM-based testing +task fedora:start +task fedora:shell +# ... test dnf/rpm behavior ... +``` + +## Troubleshooting + +### Permission Issues +All containers run as `testuser` (UID 1000) by default. The mounted installer directory inherits permissions from your host filesystem. + +### Rebuilding After Changes +If you modify any Dockerfile: +```bash +task :rebuild # Rebuild specific OS +# or +task clean-all # Clean everything and rebuild as needed +``` + +### Container Conflicts +Each OS uses separate containers and volumes: +- Ubuntu: `installer-test-ubuntu-env` +- Debian: `installer-test-debian-env` +- Fedora: `installer-test-fedora-env` + +### Cleaning Up +```bash +# Clean specific OS +task ubuntu:clean + +# Clean everything +task clean-all + +# Nuclear option - remove all Docker artifacts +docker system prune -a +``` + +## Interactive GPG Testing + +⚠️ **SAFETY WARNING**: When testing locally, the scripts use isolated test environments (`/tmp/test-*-home`) to prevent any damage to your real system. Never run the installer directly on your system during development! + +This directory also contains tools for testing the dotfiles installer's interactive GPG setup functionality using automated expect scripts. + +### Prerequisites for Interactive Testing + +**Install expect:** + +macOS: +```bash +brew install expect +``` + +Ubuntu/Debian: +```bash +sudo apt-get install expect +``` + +### Interactive Testing Script + +The `./test-interactive-gpg.exp` script automates GPG key setup during interactive installation using the `expect` tool. + +**Basic Usage:** +```bash +# Use defaults (from installer directory) +./test-interactive-gpg.exp + +# Specify custom parameters +./test-interactive-gpg.exp [installer_path] [email] [name] [passphrase] +``` + +**Examples:** +```bash +# Test with default values +./test-interactive-gpg.exp + +# Test with custom GPG info +./test-interactive-gpg.exp \ + "./dotfiles-installer" \ + "your@email.com" \ + "Your Full Name" \ + "your-secure-passphrase" + +# Test with built binary in Docker environment +./test-interactive-gpg.exp \ + "./dist/dotfiles_installer_linux_amd64_v1/dotfiles-installer" \ + "test@example.com" \ + "Test User" \ + "test-passphrase" +``` + +### GPG Prompts Handled + +The expect script handles these GPG-specific interactive prompts: + +- βœ… GPG email address input +- βœ… GPG full name input +- βœ… GPG passphrase input +- βœ… GPG "okay" confirmation (sends "O" as required by GPG) +- βœ… GPG key generation parameters (size, expiration) +- βœ… GPG key type selection +- βœ… GPG comment field + +### Interactive Testing Workflow + +1. **Build the installer:** + ```bash + cd installer + goreleaser build --skip before --snapshot --clean + ``` +2. **Start Container** (e.g. Ubuntu) + ```bash + task ubuntu:dev + ``` +3. **Run interactive test:** + ```bash + ./test-interactive-gpg.exp + ``` + +### Troubleshooting Interactive Tests + +**Script hangs or times out:** +- Enable debug mode by editing the script and setting `exp_internal 1` +- Run manually and observe the exact GPG prompt text + +**GPG errors in containers:** +- This is expected in containerized environments +- The script handles these gracefully for CI testing + +**Custom GPG prompts not handled:** +- Add new patterns to the `expect` block in the script +- Use case-insensitive regex patterns: `(?i).*your_pattern` + +## Development Tips + +- Use `task status` to see which environments are running +- Each OS has its own cache volume for faster rebuilds +- The validation script automatically detects the OS and runs appropriate tests +- You can run multiple OS environments simultaneously for comparison testing +- All environments mount the same installer code, so changes are immediately available +- Interactive GPG testing works in both Docker environments and locally +- The expect script uses a 5-minute timeout to prevent hanging in CI diff --git a/installer/docker/Taskfile.yml b/installer/docker/Taskfile.yml new file mode 100644 index 0000000..f5bbd8e --- /dev/null +++ b/installer/docker/Taskfile.yml @@ -0,0 +1,379 @@ +# https://taskfile.dev +# Multi-OS Docker development environment management + +version: "3" +silent: true + +vars: + SUPPORTED_OS: ubuntu,debian,fedora + DEFAULT_OS: ubuntu + +tasks: + default: + desc: List all available Docker environment tasks + cmds: + - task --list + + check: + desc: Check if Docker and Docker Compose are available + cmds: + - | + if ! command -v docker &> /dev/null; then + echo "❌ Docker is not installed or not in PATH" + exit 1 + fi + if ! command -v docker-compose &> /dev/null; then + echo "❌ Docker Compose is not installed or not in PATH" + exit 1 + fi + if ! docker info &> /dev/null; then + echo "❌ Docker daemon is not running" + exit 1 + fi + echo "βœ… Docker environment is ready" + + list: + desc: List all supported operating systems + cmds: + - | + echo "🐧 Supported operating systems:" + echo " β€’ ubuntu - Ubuntu Latest" + echo " β€’ debian - Debian Latest" + echo " β€’ fedora - Fedora Latest" + echo "" + echo "Usage examples:" + echo " task ubuntu:start # Start Ubuntu environment" + echo " task debian:dev # Start Debian environment and enter shell" + echo " task fedora:validate # Validate Fedora environment" + + # Ubuntu tasks + ubuntu:build: + desc: Build Ubuntu Docker image + deps: [check] + dir: ubuntu + cmds: + - echo "πŸ”¨ Building Ubuntu Docker image..." + - docker-compose build + - echo "βœ… Ubuntu image built successfully" + + ubuntu:start: + desc: Build and start Ubuntu testing environment + deps: [check] + dir: ubuntu + cmds: + - | + if docker-compose ps | grep -q "installer-test-ubuntu-env.*Up"; then + echo "⚠️ Ubuntu environment is already running" + else + echo "πŸš€ Starting Ubuntu testing environment..." + docker-compose up -d + echo "βœ… Ubuntu environment started successfully" + echo "πŸ’‘ Use 'task ubuntu:shell' to enter the container" + fi + + ubuntu:stop: + desc: Stop Ubuntu testing environment + deps: [check] + dir: ubuntu + cmds: + - echo "⏹️ Stopping Ubuntu testing environment..." + - docker-compose down + - echo "βœ… Ubuntu environment stopped" + + ubuntu:shell: + desc: Enter Ubuntu container shell + deps: [check] + dir: ubuntu + cmds: + - | + if ! docker-compose ps | grep -q "installer-test-ubuntu-env.*Up"; then + echo "⚠️ Ubuntu environment is not running. Starting it now..." + task ubuntu:start + sleep 2 + fi + echo "🐚 Entering Ubuntu container shell..." + docker-compose exec --user testuser installer-test-ubuntu bash + + ubuntu:dev: + desc: Start Ubuntu environment and enter shell + deps: [check] + cmds: + - task: ubuntu:start + - task: ubuntu:shell + + ubuntu:validate: + desc: Run validation tests in Ubuntu container + deps: [check] + dir: ubuntu + cmds: + - | + if ! docker-compose ps | grep -q "installer-test-ubuntu-env.*Up"; then + echo "⚠️ Ubuntu environment is not running. Starting it now..." + task ubuntu:start + sleep 2 + fi + echo "πŸ§ͺ Running Ubuntu validation tests..." + docker-compose exec -T --user testuser installer-test-ubuntu /workspace/installer/docker/validate.sh + + ubuntu:rebuild: + desc: Rebuild Ubuntu Docker image from scratch + deps: [check] + dir: ubuntu + cmds: + - echo "πŸ”„ Rebuilding Ubuntu environment from scratch..." + - docker-compose down + - docker-compose build --no-cache + - echo "βœ… Ubuntu environment rebuilt successfully" + + ubuntu:clean: + desc: Stop Ubuntu containers and remove images + deps: [check] + dir: ubuntu + cmds: + - echo "🧹 Cleaning up Ubuntu environment..." + - docker-compose down + - | + if docker images | grep -q "ubuntu_installer-test-ubuntu"; then + docker rmi ubuntu_installer-test-ubuntu 2>/dev/null || true + fi + - echo "βœ… Ubuntu environment cleaned up" + + # Debian tasks + debian:build: + desc: Build Debian Docker image + deps: [check] + dir: debian + cmds: + - echo "πŸ”¨ Building Debian Docker image..." + - docker-compose build + - echo "βœ… Debian image built successfully" + + debian:start: + desc: Build and start Debian testing environment + deps: [check] + dir: debian + cmds: + - | + if docker-compose ps | grep -q "installer-test-debian-env.*Up"; then + echo "⚠️ Debian environment is already running" + else + echo "πŸš€ Starting Debian testing environment..." + docker-compose up -d + echo "βœ… Debian environment started successfully" + echo "πŸ’‘ Use 'task debian:shell' to enter the container" + fi + + debian:stop: + desc: Stop Debian testing environment + deps: [check] + dir: debian + cmds: + - echo "⏹️ Stopping Debian testing environment..." + - docker-compose down + - echo "βœ… Debian environment stopped" + + debian:shell: + desc: Enter Debian container shell + deps: [check] + dir: debian + cmds: + - | + if ! docker-compose ps | grep -q "installer-test-debian-env.*Up"; then + echo "⚠️ Debian environment is not running. Starting it now..." + task debian:start + sleep 2 + fi + echo "🐚 Entering Debian container shell..." + docker-compose exec --user testuser installer-test-debian bash + + debian:dev: + desc: Start Debian environment and enter shell + deps: [check] + cmds: + - task: debian:start + - task: debian:shell + + debian:validate: + desc: Run validation tests in Debian container + deps: [check] + dir: debian + cmds: + - | + if ! docker-compose ps | grep -q "installer-test-debian-env.*Up"; then + echo "⚠️ Debian environment is not running. Starting it now..." + task debian:start + sleep 2 + fi + echo "πŸ§ͺ Running Debian validation tests..." + docker-compose exec -T --user testuser installer-test-debian /workspace/installer/docker/validate.sh + + debian:rebuild: + desc: Rebuild Debian Docker image from scratch + deps: [check] + dir: debian + cmds: + - echo "πŸ”„ Rebuilding Debian environment from scratch..." + - docker-compose down + - docker-compose build --no-cache + - echo "βœ… Debian environment rebuilt successfully" + + debian:clean: + desc: Stop Debian containers and remove images + deps: [check] + dir: debian + cmds: + - echo "🧹 Cleaning up Debian environment..." + - docker-compose down + - | + if docker images | grep -q "debian_installer-test-debian"; then + docker rmi debian_installer-test-debian 2>/dev/null || true + fi + - echo "βœ… Debian environment cleaned up" + + # Fedora tasks + fedora:build: + desc: Build Fedora Docker image + deps: [check] + dir: fedora + cmds: + - echo "πŸ”¨ Building Fedora Docker image..." + - docker-compose build + - echo "βœ… Fedora image built successfully" + + fedora:start: + desc: Build and start Fedora testing environment + deps: [check] + dir: fedora + cmds: + - | + if docker-compose ps | grep -q "installer-test-fedora-env.*Up"; then + echo "⚠️ Fedora environment is already running" + else + echo "πŸš€ Starting Fedora testing environment..." + docker-compose up -d + echo "βœ… Fedora environment started successfully" + echo "πŸ’‘ Use 'task fedora:shell' to enter the container" + fi + + fedora:stop: + desc: Stop Fedora testing environment + deps: [check] + dir: fedora + cmds: + - echo "⏹️ Stopping Fedora testing environment..." + - docker-compose down + - echo "βœ… Fedora environment stopped" + + fedora:shell: + desc: Enter Fedora container shell + deps: [check] + dir: fedora + cmds: + - | + if ! docker-compose ps | grep -q "installer-test-fedora-env.*Up"; then + echo "⚠️ Fedora environment is not running. Starting it now..." + task fedora:start + sleep 2 + fi + echo "🐚 Entering Fedora container shell..." + docker-compose exec --user testuser installer-test-fedora bash + + fedora:dev: + desc: Start Fedora environment and enter shell + deps: [check] + cmds: + - task: fedora:start + - task: fedora:shell + + fedora:validate: + desc: Run validation tests in Fedora container + deps: [check] + dir: fedora + cmds: + - | + if ! docker-compose ps | grep -q "installer-test-fedora-env.*Up"; then + echo "⚠️ Fedora environment is not running. Starting it now..." + task fedora:start + sleep 2 + fi + echo "πŸ§ͺ Running Fedora validation tests..." + docker-compose exec -T --user testuser installer-test-fedora /workspace/installer/docker/validate.sh + + fedora:rebuild: + desc: Rebuild Fedora Docker image from scratch + deps: [check] + dir: fedora + cmds: + - echo "πŸ”„ Rebuilding Fedora environment from scratch..." + - docker-compose down + - docker-compose build --no-cache + - echo "βœ… Fedora environment rebuilt successfully" + + fedora:clean: + desc: Stop Fedora containers and remove images + deps: [check] + dir: fedora + cmds: + - echo "🧹 Cleaning up Fedora environment..." + - docker-compose down + - | + if docker images | grep -q "fedora_installer-test-fedora"; then + docker rmi fedora_installer-test-fedora 2>/dev/null || true + fi + - echo "βœ… Fedora environment cleaned up" + + # Utility tasks + status: + desc: Show status of all Docker environments + deps: [check] + cmds: + - | + echo "πŸ“Š Docker Environment Status" + echo "=============================" + echo "" + echo "🐧 Ubuntu:" + if docker ps | grep -q "installer-test-ubuntu-env"; then + echo " βœ… Running" + else + echo " ❌ Stopped" + fi + echo "" + echo "🐧 Debian:" + if docker ps | grep -q "installer-test-debian-env"; then + echo " βœ… Running" + else + echo " ❌ Stopped" + fi + echo "" + echo "🐧 Fedora:" + if docker ps | grep -q "installer-test-fedora-env"; then + echo " βœ… Running" + else + echo " ❌ Stopped" + fi + + stop-all: + desc: Stop all Docker environments + deps: [check] + cmds: + - task: ubuntu:stop + - task: debian:stop + - task: fedora:stop + + clean-all: + desc: Clean up all Docker environments + deps: [check] + cmds: + - task: ubuntu:clean + - task: debian:clean + - task: fedora:clean + + validate-all: + desc: Run validation tests on all environments + deps: [check] + cmds: + - echo "πŸ§ͺ Running validation on all environments..." + - task: ubuntu:validate + - task: debian:validate + - task: fedora:validate + - echo "βœ… All validations completed" diff --git a/installer/docker/debian/Dockerfile b/installer/docker/debian/Dockerfile new file mode 100644 index 0000000..4f7a803 --- /dev/null +++ b/installer/docker/debian/Dockerfile @@ -0,0 +1,44 @@ +# Debian-based Docker image with all prerequisites for installer testing +FROM debian:latest + +# Set environment variables to avoid interactive prompts during package installation +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC + +# Update package list and install all required prerequisites +RUN apt-get update && apt-get install -y \ + build-essential \ + procps \ + curl \ + file \ + git \ + ca-certificates \ + sudo \ + && rm -rf /var/lib/apt/lists/* + +# Create a non-root user for testing +RUN useradd -m -s /bin/bash testuser && \ + usermod -aG sudo testuser && \ + echo "testuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/testuser && \ + chmod 0440 /etc/sudoers.d/testuser + +# Copy entrypoint script +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Set working directory +WORKDIR /workspace + +# Set default shell to bash +SHELL ["/bin/bash", "-c"] + +# Add helpful aliases and environment setup +RUN echo 'alias ll="ls -la"' >> ~/.bashrc && \ + echo 'alias la="ls -A"' >> ~/.bashrc && \ + echo 'export PS1="\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ "' >> ~/.bashrc + +# Use entrypoint script to handle permission fixes at runtime +ENTRYPOINT ["/entrypoint.sh"] + +# Default command +CMD ["/bin/bash"] diff --git a/installer/docker/debian/docker-compose.yml b/installer/docker/debian/docker-compose.yml new file mode 100644 index 0000000..53c318d --- /dev/null +++ b/installer/docker/debian/docker-compose.yml @@ -0,0 +1,19 @@ +services: + installer-test-debian: + build: + context: . + dockerfile: Dockerfile + container_name: installer-test-debian-env + volumes: + - ../../:/workspace/installer + - installer-cache-debian:/home/testuser/.cache + working_dir: /workspace/installer + environment: + - TERM=xterm-256color + stdin_open: true + tty: true + command: /bin/bash + +volumes: + installer-cache-debian: + driver: local diff --git a/installer/docker/debian/entrypoint.sh b/installer/docker/debian/entrypoint.sh new file mode 100755 index 0000000..06510b1 --- /dev/null +++ b/installer/docker/debian/entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Entrypoint script to ensure proper ownership of mounted volumes +# This script runs as root at container startup to fix permissions before switching to testuser + +set -e + +# Fix ownership of the cache directory if it exists and is owned by root +if [[ -d "/home/testuser/.cache" ]]; then + # Check if the directory is owned by root (which happens with Docker volume mounts) + if [[ "$(stat -c %U /home/testuser/.cache)" == "root" ]]; then + echo "πŸ”§ Fixing ownership of /home/testuser/.cache directory..." + chown -R testuser:testuser /home/testuser/.cache + fi +fi + +# Ensure the entire home directory has correct ownership +if [[ "$(stat -c %U /home/testuser)" == "root" ]]; then + echo "πŸ”§ Fixing ownership of /home/testuser directory..." + chown testuser:testuser /home/testuser +fi + +# If no command is provided, start bash as testuser +if [[ $# -eq 0 ]]; then + exec su - testuser -c "/bin/bash" +else + # Execute the provided command as testuser + exec su - testuser -c "$*" +fi diff --git a/installer/docker/entrypoint.sh b/installer/docker/entrypoint.sh new file mode 100755 index 0000000..163367c --- /dev/null +++ b/installer/docker/entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Entrypoint script to ensure proper ownership of mounted volumes +# This script runs as root at container startup to fix permissions before switching to testuser + +set -e + +# Fix ownership of the cache directory if it exists and is owned by root +if [[ -d "/home/testuser/.cache" ]]; then + # Check if the directory is owned by root (which happens with Docker volume mounts) + if [[ "$(stat -c %U /home/testuser/.cache)" == "root" ]]; then + echo "πŸ”§ Fixing ownership of /home/testuser/.cache directory..." + chown -R testuser:testuser /home/testuser/.cache + fi +fi + +# Ensure the entire home directory has correct ownership +if [[ "$(stat -c %U /home/testuser)" == "root" ]]; then + echo "πŸ”§ Fixing ownership of /home/testuser directory..." + chown testuser:testuser /home/testuser +fi + +# If no command is provided, start bash as testuser +if [[ $# -eq 0 ]]; then + exec su - testuser +else + # Execute the provided command as testuser + exec su - testuser -c "$*" +fi diff --git a/installer/docker/fedora/Dockerfile b/installer/docker/fedora/Dockerfile new file mode 100644 index 0000000..90afc15 --- /dev/null +++ b/installer/docker/fedora/Dockerfile @@ -0,0 +1,44 @@ +# Fedora-based Docker image with all prerequisites for installer testing +FROM fedora:latest + +# Set environment variables to avoid interactive prompts during package installation +ENV TZ=UTC + +# Update package list and install all required prerequisites +# Note: Fedora uses dnf and different package names than Debian/Ubuntu +RUN dnf update -y && dnf install -y \ + @development-tools \ + procps-ng \ + curl \ + file \ + git \ + ca-certificates \ + sudo \ + && dnf clean all + +# Create a non-root user for testing +RUN useradd -m -s /bin/bash testuser && \ + usermod -aG wheel testuser && \ + echo "testuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/testuser && \ + chmod 0440 /etc/sudoers.d/testuser + +# Copy entrypoint script +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Set working directory +WORKDIR /workspace + +# Set default shell to bash +SHELL ["/bin/bash", "-c"] + +# Add helpful aliases and environment setup +RUN echo 'alias ll="ls -la"' >> ~/.bashrc && \ + echo 'alias la="ls -A"' >> ~/.bashrc && \ + echo 'export PS1="\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ "' >> ~/.bashrc + +# Use entrypoint script to handle permission fixes at runtime +ENTRYPOINT ["/entrypoint.sh"] + +# Default command +CMD ["/bin/bash"] diff --git a/installer/docker/fedora/docker-compose.yml b/installer/docker/fedora/docker-compose.yml new file mode 100644 index 0000000..339048d --- /dev/null +++ b/installer/docker/fedora/docker-compose.yml @@ -0,0 +1,19 @@ +services: + installer-test-fedora: + build: + context: . + dockerfile: Dockerfile + container_name: installer-test-fedora-env + volumes: + - ../../:/workspace/installer + - installer-cache-fedora:/home/testuser/.cache + working_dir: /workspace/installer + environment: + - TERM=xterm-256color + stdin_open: true + tty: true + command: /bin/bash + +volumes: + installer-cache-fedora: + driver: local diff --git a/installer/docker/fedora/entrypoint.sh b/installer/docker/fedora/entrypoint.sh new file mode 100755 index 0000000..06510b1 --- /dev/null +++ b/installer/docker/fedora/entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Entrypoint script to ensure proper ownership of mounted volumes +# This script runs as root at container startup to fix permissions before switching to testuser + +set -e + +# Fix ownership of the cache directory if it exists and is owned by root +if [[ -d "/home/testuser/.cache" ]]; then + # Check if the directory is owned by root (which happens with Docker volume mounts) + if [[ "$(stat -c %U /home/testuser/.cache)" == "root" ]]; then + echo "πŸ”§ Fixing ownership of /home/testuser/.cache directory..." + chown -R testuser:testuser /home/testuser/.cache + fi +fi + +# Ensure the entire home directory has correct ownership +if [[ "$(stat -c %U /home/testuser)" == "root" ]]; then + echo "πŸ”§ Fixing ownership of /home/testuser directory..." + chown testuser:testuser /home/testuser +fi + +# If no command is provided, start bash as testuser +if [[ $# -eq 0 ]]; then + exec su - testuser -c "/bin/bash" +else + # Execute the provided command as testuser + exec su - testuser -c "$*" +fi diff --git a/installer/docker/ubuntu/Dockerfile b/installer/docker/ubuntu/Dockerfile new file mode 100644 index 0000000..72b0b4c --- /dev/null +++ b/installer/docker/ubuntu/Dockerfile @@ -0,0 +1,44 @@ +# Ubuntu-based Docker image with all prerequisites for installer testing +FROM ubuntu:latest + +# Set environment variables to avoid interactive prompts during package installation +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC + +# Update package list and install all required prerequisites +RUN apt-get update && apt-get install -y \ + build-essential \ + procps \ + curl \ + file \ + git \ + ca-certificates \ + sudo \ + && rm -rf /var/lib/apt/lists/* + +# Create a non-root user for testing +RUN useradd -m -s /bin/bash testuser && \ + usermod -aG sudo testuser && \ + echo "testuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/testuser && \ + chmod 0440 /etc/sudoers.d/testuser + +# Copy entrypoint script +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Set working directory +WORKDIR /workspace + +# Set default shell to bash +SHELL ["/bin/bash", "-c"] + +# Add helpful aliases and environment setup +RUN echo 'alias ll="ls -la"' >> ~/.bashrc && \ + echo 'alias la="ls -A"' >> ~/.bashrc && \ + echo 'export PS1="\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ "' >> ~/.bashrc + +# Use entrypoint script to handle permission fixes at runtime +ENTRYPOINT ["/entrypoint.sh"] + +# Default command +CMD ["/bin/bash"] diff --git a/installer/docker/ubuntu/docker-compose.yml b/installer/docker/ubuntu/docker-compose.yml new file mode 100644 index 0000000..946b43f --- /dev/null +++ b/installer/docker/ubuntu/docker-compose.yml @@ -0,0 +1,19 @@ +services: + installer-test-ubuntu: + build: + context: . + dockerfile: Dockerfile + container_name: installer-test-ubuntu-env + volumes: + - ../../:/workspace/installer + - installer-cache-ubuntu:/home/testuser/.cache + working_dir: /workspace/installer + environment: + - TERM=xterm-256color + stdin_open: true + tty: true + command: /bin/bash + +volumes: + installer-cache-ubuntu: + driver: local diff --git a/installer/docker/ubuntu/entrypoint.sh b/installer/docker/ubuntu/entrypoint.sh new file mode 100755 index 0000000..06510b1 --- /dev/null +++ b/installer/docker/ubuntu/entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Entrypoint script to ensure proper ownership of mounted volumes +# This script runs as root at container startup to fix permissions before switching to testuser + +set -e + +# Fix ownership of the cache directory if it exists and is owned by root +if [[ -d "/home/testuser/.cache" ]]; then + # Check if the directory is owned by root (which happens with Docker volume mounts) + if [[ "$(stat -c %U /home/testuser/.cache)" == "root" ]]; then + echo "πŸ”§ Fixing ownership of /home/testuser/.cache directory..." + chown -R testuser:testuser /home/testuser/.cache + fi +fi + +# Ensure the entire home directory has correct ownership +if [[ "$(stat -c %U /home/testuser)" == "root" ]]; then + echo "πŸ”§ Fixing ownership of /home/testuser directory..." + chown testuser:testuser /home/testuser +fi + +# If no command is provided, start bash as testuser +if [[ $# -eq 0 ]]; then + exec su - testuser -c "/bin/bash" +else + # Execute the provided command as testuser + exec su - testuser -c "$*" +fi diff --git a/installer/docker/validate.sh b/installer/docker/validate.sh new file mode 100755 index 0000000..718780e --- /dev/null +++ b/installer/docker/validate.sh @@ -0,0 +1,288 @@ +#!/bin/bash +# Validation script to test Docker environment has all required prerequisites +# This script runs inside the container to verify everything is properly installed +# Supports Ubuntu, Debian, and Fedora + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +print_header() { + echo -e "${BLUE}=================================================${NC}" + echo -e "${BLUE} Docker Environment Validation${NC}" + echo -e "${BLUE}=================================================${NC}" + echo "" +} + +log_test() { + echo -e "${BLUE}[TEST]${NC} $1" +} + +log_pass() { + echo -e "${GREEN}[PASS]${NC} $1" +} + +log_fail() { + echo -e "${RED}[FAIL]${NC} $1" +} + +log_info() { + echo -e "${YELLOW}[INFO]${NC} $1" +} + +test_command() { + local cmd=$1 + local name=$2 + local version_flag=${3:-"--version"} + + log_test "Testing $name command availability" + + if command -v "$cmd" &> /dev/null; then + log_pass "$name is available" + if [[ "$version_flag" != "none" ]]; then + local version_output + version_output=$($cmd $version_flag 2>&1 | head -n1) + log_info "Version: $version_output" + fi + return 0 + else + log_fail "$name is not available" + return 1 + fi +} + +test_package() { + local package=$1 + local test_cmd=$2 + + log_test "Testing $package package" + + if $test_cmd &> /dev/null; then + log_pass "$package is properly installed" + return 0 + else + log_fail "$package is not properly installed" + return 1 + fi +} + +test_user_setup() { + log_test "Testing user setup" + + local current_user=$(whoami) + if [[ "$current_user" == "testuser" ]]; then + log_pass "Running as testuser" + else + log_fail "Expected to run as testuser, but running as $current_user" + return 1 + fi + + log_test "Testing sudo access" + if sudo -n true 2>/dev/null; then + log_pass "Sudo access without password works" + elif sudo -l 2>/dev/null | grep -q NOPASSWD; then + log_pass "Sudo access configured (NOPASSWD found)" + else + log_info "Sudo may not be fully configured, but this is non-critical for testing" + log_info "Most installer functionality will still work" + fi + + return 0 +} + +test_filesystem() { + log_test "Testing filesystem setup" + + if [[ -d "/workspace" ]]; then + log_pass "Workspace directory exists" + else + log_fail "Workspace directory not found" + return 1 + fi + + # Check the mounted installer directory instead of workspace root + if [[ -d "/workspace/installer" ]]; then + log_pass "Installer directory is mounted" + else + log_fail "Installer directory not found" + return 1 + fi + + # Test write access to the mounted directory + if touch "/workspace/installer/.test_write" 2>/dev/null && rm -f "/workspace/installer/.test_write" 2>/dev/null; then + log_pass "Mounted directory is writable" + else + log_info "Mounted directory may not be writable, but this is expected in some setups" + fi + + return 0 +} + +detect_os() { + if [[ -f /etc/os-release ]]; then + source /etc/os-release + echo "$ID" + else + echo "unknown" + fi +} + +test_fedora_prerequisites() { + local failed_tests=0 + + # Test development tools group + if rpm -q --whatprovides gcc &>/dev/null; then + log_pass "Development tools (gcc) available" + else + log_fail "Development tools (gcc) not found" + ((failed_tests++)) + fi + + # Test make + if ! test_command "make" "Make (development tools)"; then + ((failed_tests++)) + fi + + # procps-ng (test ps) + if ! test_command "ps" "Process utilities (procps-ng)" "none"; then + ((failed_tests++)) + fi + + # curl + if ! test_command "curl" "cURL"; then + ((failed_tests++)) + fi + + # file + if ! test_command "file" "File utility"; then + ((failed_tests++)) + fi + + # git + if ! test_command "git" "Git"; then + ((failed_tests++)) + fi + + return $failed_tests +} + +test_debian_ubuntu_prerequisites() { + local failed_tests=0 + + # build-essential (test gcc) + if ! test_command "gcc" "GCC (build-essential)"; then + ((failed_tests++)) + fi + + # Make sure we have make + if ! test_command "make" "Make (build-essential)"; then + ((failed_tests++)) + fi + + # procps (test ps) + if ! test_command "ps" "Process utilities (procps)" "none"; then + ((failed_tests++)) + fi + + # curl + if ! test_command "curl" "cURL"; then + ((failed_tests++)) + fi + + # file + if ! test_command "file" "File utility"; then + ((failed_tests++)) + fi + + # git + if ! test_command "git" "Git"; then + ((failed_tests++)) + fi + + return $failed_tests +} + +run_validation() { + local failed_tests=0 + local os_type=$(detect_os) + + print_header + + # Test basic system info + log_info "OS: $(cat /etc/os-release | grep PRETTY_NAME | cut -d'"' -f2)" + log_info "Detected OS Type: $os_type" + log_info "Kernel: $(uname -r)" + log_info "Architecture: $(uname -m)" + echo "" + + # Test user setup + if ! test_user_setup; then + ((failed_tests++)) + fi + echo "" + + # Test filesystem + if ! test_filesystem; then + ((failed_tests++)) + fi + echo "" + + # Test OS-specific prerequisites from compatibility.yaml + case "$os_type" in + "ubuntu"|"debian") + log_info "Testing Debian/Ubuntu prerequisites..." + if ! test_debian_ubuntu_prerequisites; then + ((failed_tests += $?)) + fi + ;; + "fedora") + log_info "Testing Fedora prerequisites..." + if ! test_fedora_prerequisites; then + ((failed_tests += $?)) + fi + ;; + *) + log_fail "Unsupported OS: $os_type" + ((failed_tests++)) + ;; + esac + + echo "" + + # Test some additional functionality + log_test "Testing build capability" + if echo 'int main(){return 0;}' | gcc -x c - -o /tmp/test_build 2>/dev/null; then + log_pass "C compilation works" + rm -f /tmp/test_build + else + log_fail "C compilation failed" + ((failed_tests++)) + fi + + log_test "Testing network connectivity" + if curl -s --connect-timeout 5 https://httpbin.org/ip > /dev/null; then + log_pass "Network connectivity works" + else + log_fail "Network connectivity issues" + ((failed_tests++)) + fi + + echo "" + echo -e "${BLUE}=================================================${NC}" + + if [[ $failed_tests -eq 0 ]]; then + echo -e "${GREEN}βœ“ All tests passed! ${os_type^} environment is ready for development.${NC}" + exit 0 + else + echo -e "${RED}βœ— $failed_tests test(s) failed. ${os_type^} environment needs attention.${NC}" + exit 1 + fi +} + +# Main execution +run_validation diff --git a/installer/go.mod b/installer/go.mod new file mode 100644 index 0000000..06c6421 --- /dev/null +++ b/installer/go.mod @@ -0,0 +1,58 @@ +module github.com/MrPointer/dotfiles/installer + +go 1.24.0 + +require ( + github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/samber/mo v1.16.0 + github.com/spf13/cobra v1.10.1 + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20251016201629-54e687c87a08 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect +) + +require ( + github.com/Masterminds/semver v1.5.0 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/huh/spinner v0.0.0-20251005153135-a01a1e304532 + github.com/charmbracelet/x/ansi v0.10.2 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/installer/go.sum b/installer/go.sum new file mode 100644 index 0000000..2720e0a --- /dev/null +++ b/installer/go.sum @@ -0,0 +1,128 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/huh/spinner v0.0.0-20251005153135-a01a1e304532 h1:iw1MJ8sBSnXQD50VwYaTM8I2P+kWw+X2RLPH5HHd1v0= +github.com/charmbracelet/huh/spinner v0.0.0-20251005153135-a01a1e304532/go.mod h1:OMqKat/mm9a/qOnpuNOPyYO9bPzRNnmzLnRZT5KYltg= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= +github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20251016201629-54e687c87a08 h1:7YnDqaTgWPSO9FnYOU8oB6RSlUudqOpE+p+a6WWRZNA= +github.com/charmbracelet/x/exp/strings v0.0.0-20251016201629-54e687c87a08/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/samber/mo v1.16.0 h1:qpEPCI63ou6wXlsNDMLE0IIN8A+devbGX/K1xdgr4b4= +github.com/samber/mo v1.16.0/go.mod h1:DlgzJ4SYhOh41nP1L9kh9rDNERuf8IqWSAs+gj2Vxag= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/installer/img/caution.png b/installer/img/caution.png new file mode 100644 index 0000000..cdb9faa Binary files /dev/null and b/installer/img/caution.png differ diff --git a/installer/internal/config/compatibility.yaml b/installer/internal/config/compatibility.yaml new file mode 100644 index 0000000..ecd13e3 --- /dev/null +++ b/installer/internal/config/compatibility.yaml @@ -0,0 +1,155 @@ +# Compatibility configuration for the installer +# This file defines which operating systems and distributions are supported, +# as well as required prerequisites for installation + +# Operating system compatibility matrix +operatingSystems: + linux: + supported: true + distributions: + ubuntu: + supported: true + version_constraint: ">= 20.04" + notes: "Ubuntu 20.04 LTS or newer" + prerequisites: + - name: "build-essential" + command: "gcc" + description: "Essential build tools including gcc, make, and libc-dev" + install_hint: "sudo apt-get install build-essential" + - name: "procps" + command: "ps" + description: "Process utilities" + install_hint: "sudo apt-get install procps" + - name: "curl" + command: "curl" + description: "Command line tool for transferring data" + install_hint: "sudo apt-get install curl" + - name: "file" + command: "file" + description: "File type identification utility" + install_hint: "sudo apt-get install file" + - name: "git" + command: "git" + description: "Git version control system" + install_hint: "sudo apt-get install git" + debian: + supported: true + version_constraint: ">= 10" + notes: "Debian 10 (Buster) or newer" + prerequisites: + - name: "build-essential" + command: "gcc" + description: "Essential build tools including gcc, make, and libc-dev" + install_hint: "sudo apt-get install build-essential" + - name: "procps" + command: "ps" + description: "Process utilities" + install_hint: "sudo apt-get install procps" + - name: "curl" + command: "curl" + description: "Command line tool for transferring data" + install_hint: "sudo apt-get install curl" + - name: "file" + command: "file" + description: "File type identification utility" + install_hint: "sudo apt-get install file" + - name: "git" + command: "git" + description: "Git version control system" + install_hint: "sudo apt-get install git" + fedora: + supported: false + notes: "Support planned for future release" + prerequisites: + - name: "development-tools" + command: "gcc" + description: "Development tools group including gcc, make, and other build tools" + install_hint: "sudo dnf group install 'Development Tools'" + - name: "procps-ng" + command: "ps" + description: "Process utilities" + install_hint: "sudo dnf install procps-ng" + - name: "curl" + command: "curl" + description: "Command line tool for transferring data" + install_hint: "sudo dnf install curl" + - name: "file" + command: "file" + description: "File type identification utility" + install_hint: "sudo dnf install file" + - name: "git" + command: "git" + description: "Git version control system" + install_hint: "sudo dnf install git" + centos: + supported: false + notes: "Support planned for future release" + prerequisites: + - name: "development-tools" + command: "gcc" + description: "Development tools group including gcc, make, and other build tools" + install_hint: "sudo dnf group install 'Development Tools'" + - name: "procps-ng" + command: "ps" + description: "Process utilities" + install_hint: "sudo dnf install procps-ng" + - name: "curl" + command: "curl" + description: "Command line tool for transferring data" + install_hint: "sudo dnf install curl" + - name: "file" + command: "file" + description: "File type identification utility" + install_hint: "sudo dnf install file" + - name: "git" + command: "git" + description: "Git version control system" + install_hint: "sudo dnf install git" + redhat: + supported: false + notes: "Support planned for future release" + prerequisites: + - name: "development-tools" + command: "gcc" + description: "Development tools group including gcc, make, and other build tools" + install_hint: "sudo dnf group install 'Development Tools'" + - name: "procps-ng" + command: "ps" + description: "Process utilities" + install_hint: "sudo dnf install procps-ng" + - name: "curl" + command: "curl" + description: "Command line tool for transferring data" + install_hint: "sudo dnf install curl" + - name: "file" + command: "file" + description: "File type identification utility" + install_hint: "sudo dnf install file" + - name: "git" + command: "git" + description: "Git version control system" + install_hint: "sudo dnf install git" + suse: + supported: false + notes: "No current plans for support" + # Add more Linux distributions here as needed + darwin: + supported: true + notes: "macOS 10.15 or newer recommended" + prerequisites: + - name: "xcode-command-line-tools" + command: "xcode-select" + description: "Command Line Tools for Xcode" + install_hint: "xcode-select --install" + - name: "bash" + command: "bash" + description: "Bash shell" + install_hint: "Bash should be available by default on macOS" + - name: "git" + command: "git" + description: "Git version control system (included with Command Line Tools)" + install_hint: "Install Command Line Tools: xcode-select --install" + windows: + supported: false + notes: "Windows is not supported" + # Add more operating systems here as needed diff --git a/installer/internal/config/embed.go b/installer/internal/config/embed.go new file mode 100644 index 0000000..1308d85 --- /dev/null +++ b/installer/internal/config/embed.go @@ -0,0 +1,29 @@ +package config + +import ( + "embed" + "fmt" +) + +//go:embed compatibility.yaml packagemap.yaml +var configFS embed.FS + +// GetRawEmbeddedCompatibilityConfig returns the raw content of the embedded compatibility configuration file. +// This is useful for testing purposes. +func GetRawEmbeddedCompatibilityConfig() ([]byte, error) { + data, err := configFS.ReadFile("compatibility.yaml") + if err != nil { + return nil, fmt.Errorf("failed to read embedded compatibility config: %w", err) + } + return data, nil +} + +// GetRawEmbeddedPackageMapConfig returns the raw content of the embedded package map configuration file. +// This is useful for loading the default configuration or for testing purposes. +func GetRawEmbeddedPackageMapConfig() ([]byte, error) { + data, err := configFS.ReadFile("packagemap.yaml") + if err != nil { + return nil, fmt.Errorf("failed to read embedded package map config: %w", err) + } + return data, nil +} diff --git a/installer/internal/config/embed_test.go b/installer/internal/config/embed_test.go new file mode 100644 index 0000000..ec84638 --- /dev/null +++ b/installer/internal/config/embed_test.go @@ -0,0 +1,31 @@ +package config_test + +import ( + "testing" + + "github.com/MrPointer/dotfiles/installer/internal/config" +) + +func TestEmbeddedCompatibilityConfigCanBeLoaded(t *testing.T) { + // Test basic loading functionality + config, err := config.GetRawEmbeddedCompatibilityConfig() + if err != nil { + t.Fatalf("Expected no error when loading embedded config, got: %v", err) + } + + if config == nil { + t.Fatal("Expected config to be non-nil, got nil") + } +} + +func TestEmbeddedPackageMapConfigCanBeLoaded(t *testing.T) { + // Test basic loading functionality + config, err := config.GetRawEmbeddedPackageMapConfig() + if err != nil { + t.Fatalf("Expected no error when loading embedded package map config, got: %v", err) + } + + if config == nil { + t.Fatal("Expected package map config to be non-nil, got nil") + } +} diff --git a/installer/internal/config/packagemap.yaml b/installer/internal/config/packagemap.yaml new file mode 100644 index 0000000..ec2bbb1 --- /dev/null +++ b/installer/internal/config/packagemap.yaml @@ -0,0 +1,24 @@ +# Package mappings for the installer +packages: + git: + apt: + name: git + brew: + name: git + gpg: + apt: + name: gnupg2 # Debian/Ubuntu often use gnupg2 + brew: + name: gnupg + dnf: + name: gnupg2 + neovim: + apt: + name: neovim + brew: + name: neovim + zsh: + apt: + name: zsh + brew: + name: zsh diff --git a/installer/lib/apt/apt.go b/installer/lib/apt/apt.go new file mode 100644 index 0000000..93e007d --- /dev/null +++ b/installer/lib/apt/apt.go @@ -0,0 +1,200 @@ +package apt + +import ( + "fmt" + "strings" + + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" + "github.com/MrPointer/dotfiles/installer/utils/privilege" +) + +type AptPackageManager struct { + logger logger.Logger + commander utils.Commander + programQuery osmanager.ProgramQuery + escalator privilege.Escalator + displayMode utils.DisplayMode +} + +var _ pkgmanager.PackageManager = (*AptPackageManager)(nil) + +// NewAptPackageManager creates a new AptPackageManager instance. +func NewAptPackageManager(logger logger.Logger, commander utils.Commander, programQuery osmanager.ProgramQuery, escalator privilege.Escalator, displayMode utils.DisplayMode) *AptPackageManager { + return &AptPackageManager{ + logger: logger, + commander: commander, + programQuery: programQuery, + escalator: escalator, + displayMode: displayMode, + } +} + +// GetInfo retrieves information about the APT package manager. +func (a *AptPackageManager) GetInfo() (pkgmanager.PackageManagerInfo, error) { + a.logger.Debug("Getting info about apt") + + aptVersion, err := a.programQuery.GetProgramVersion("apt", func(version string) (string, error) { + if version == "" { + return "", nil + } + + // APT version output typically contains "apt 2.4.8 (amd64)" format. + // Extract just the version number. + parts := strings.Fields(version) + if len(parts) >= 2 { + return parts[1], nil + } + + return version, nil + }) + if err != nil { + return pkgmanager.DefaultPackageManagerInfo(), fmt.Errorf("failed to get APT version: %w", err) + } + + return pkgmanager.PackageManagerInfo{ + Name: "apt", + Version: aptVersion, + }, nil +} + +// GetPackageVersion retrieves the version of an installed package. +func (a *AptPackageManager) GetPackageVersion(packageName string) (string, error) { + packages, err := a.ListInstalledPackages() + if err != nil { + return "", fmt.Errorf("failed to list installed packages: %w", err) + } + + for _, pkg := range packages { + if pkg.Name == packageName { + return pkg.Version, nil + } + } + + return "", fmt.Errorf("package %s is not installed", packageName) +} + +// InstallPackage installs a package using APT. +func (a *AptPackageManager) InstallPackage(requestedPackageInfo pkgmanager.RequestedPackageInfo) error { + a.logger.Debug("Installing package %s with apt", requestedPackageInfo.Name) + + if requestedPackageInfo.VersionConstraints != nil { + a.logger.Debug("APT doesn't support version constraints, installing the latest version of package %s", requestedPackageInfo.Name) + } + + // Update package list first to ensure we have the latest package information. + escalatedUpdate, err := a.escalator.EscalateCommand("apt", []string{"update"}) + if err != nil { + return fmt.Errorf("failed to determine privilege escalation for apt update: %w", err) + } + + var discardOutputOption utils.Option = utils.EmptyOption() + if a.displayMode.ShouldDiscardOutput() { + discardOutputOption = utils.WithDiscardOutput() + } + + _, err = a.commander.RunCommand(escalatedUpdate.Command, escalatedUpdate.Args, discardOutputOption) + if err != nil { + return fmt.Errorf("failed to update package list: %w", err) + } + + // Install the package with automatic yes confirmation. + escalatedInstall, err := a.escalator.EscalateCommand("apt", []string{"install", "-y", requestedPackageInfo.Name}) + if err != nil { + return fmt.Errorf("failed to determine privilege escalation for apt install: %w", err) + } + + discardOutputOption = utils.EmptyOption() + if a.displayMode.ShouldDiscardOutput() { + discardOutputOption = utils.WithDiscardOutput() + } + + _, err = a.commander.RunCommand(escalatedInstall.Command, escalatedInstall.Args, discardOutputOption) + if err != nil { + return fmt.Errorf("failed to install package %s: %w", requestedPackageInfo.Name, err) + } + + a.logger.Debug("Package %s installed successfully with apt", requestedPackageInfo.Name) + return nil +} + +// IsPackageInstalled checks if a package is installed. +func (a *AptPackageManager) IsPackageInstalled(packageInfo pkgmanager.PackageInfo) (bool, error) { + a.logger.Debug("Checking if package %s is installed with apt", packageInfo.Name) + + packages, err := a.ListInstalledPackages() + if err != nil { + return false, fmt.Errorf("failed to list installed packages: %w", err) + } + + for _, pkg := range packages { + if pkg.Name == packageInfo.Name { + a.logger.Debug("Package %s is installed with apt", packageInfo.Name) + return true, nil + } + } + + a.logger.Debug("Package %s is not installed with apt", packageInfo.Name) + return false, nil +} + +// ListInstalledPackages returns a list of all installed packages. +func (a *AptPackageManager) ListInstalledPackages() ([]pkgmanager.PackageInfo, error) { + a.logger.Debug("Listing packages installed with apt") + + // Use dpkg-query to get installed packages with versions. + output, err := a.commander.RunCommand("dpkg-query", []string{"-W", "-f=${Package} ${Version}\n"}, utils.WithCaptureOutput()) + if err != nil { + return nil, fmt.Errorf("failed to list installed packages: %w", err) + } + + trimmedOutput := strings.TrimSpace(string(output.Stdout)) + if trimmedOutput == "" { + return []pkgmanager.PackageInfo{}, nil + } + + a.logger.Trace("Raw output from dpkg-query: %s", trimmedOutput) + + lines := strings.Split(trimmedOutput, "\n") + packages := make([]pkgmanager.PackageInfo, 0, len(lines)) + + for _, line := range lines { + if line == "" { + continue + } + + parts := strings.Fields(line) + if len(parts) >= 2 { + name := parts[0] + version := parts[1] + packages = append(packages, pkgmanager.NewPackageInfo(name, version)) + } + } + + return packages, nil +} + +// UninstallPackage uninstalls a package using APT. +func (a *AptPackageManager) UninstallPackage(packageInfo pkgmanager.PackageInfo) error { + a.logger.Debug("Uninstalling package %s with apt", packageInfo.Name) + + removeResult, err := a.escalator.EscalateCommand("apt", []string{"remove", "-y", packageInfo.Name}) + if err != nil { + return fmt.Errorf("failed to determine privilege escalation for apt remove: %w", err) + } + + var discardOutputOption utils.Option = utils.EmptyOption() + if a.displayMode.ShouldDiscardOutput() { + discardOutputOption = utils.WithDiscardOutput() + } + + _, err = a.commander.RunCommand(removeResult.Command, removeResult.Args, discardOutputOption) + if err != nil { + return fmt.Errorf("failed to uninstall package %s: %w", packageInfo.Name, err) + } + + a.logger.Debug("Package %s uninstalled successfully with apt", packageInfo.Name) + return nil +} diff --git a/installer/lib/apt/apt_test.go b/installer/lib/apt/apt_test.go new file mode 100644 index 0000000..8878c36 --- /dev/null +++ b/installer/lib/apt/apt_test.go @@ -0,0 +1,431 @@ +package apt_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/MrPointer/dotfiles/installer/lib/apt" + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" + "github.com/MrPointer/dotfiles/installer/utils/privilege" +) + +func Test_AptPackageManager_ImplementsPackageManagerInterface(t *testing.T) { + mockCommander := &utils.MoqCommander{} + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{} + + aptManager := apt.NewAptPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + require.Implements(t, (*pkgmanager.PackageManager)(nil), aptManager) +} + +func Test_NewAptPackageManager_ReturnsValidInstance(t *testing.T) { + mockCommander := &utils.MoqCommander{} + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{} + + aptManager := apt.NewAptPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + require.NotNil(t, aptManager) +} + +func Test_GetInfo_ReturnsAptManagerInfo(t *testing.T) { + mockCommander := &utils.MoqCommander{} + mockProgramQuery := &osmanager.MoqProgramQuery{ + GetProgramVersionFunc: func(program string, versionExtractor osmanager.VersionExtractor, queryArgs ...string) (string, error) { + if program == "apt" { + return versionExtractor("apt 2.4.8 (amd64)") + } + return "", nil + }, + } + mockEscalator := &privilege.MoqEscalator{} + + aptManager := apt.NewAptPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + info, err := aptManager.GetInfo() + + require.NoError(t, err) + require.Equal(t, "apt", info.Name) + require.Equal(t, "2.4.8", info.Version) +} + +func Test_InstallPackage_CallsAptInstallCommand_AsRoot(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{ + EscalateCommandFunc: func(baseCmd string, baseArgs []string) (privilege.EscalationResult, error) { + return privilege.EscalationResult{ + Method: privilege.EscalationNone, + Command: baseCmd, + Args: baseArgs, + NeedsEscalation: false, + }, nil + }, + } + + aptManager := apt.NewAptPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + packageInfo := pkgmanager.NewRequestedPackageInfo("git", nil) + + err := aptManager.InstallPackage(packageInfo) + + require.NoError(t, err) + + calls := mockCommander.RunCommandCalls() + require.GreaterOrEqual(t, len(calls), 2) + + // Find apt calls - should be direct (no sudo) since running as root + var updateCall, installCall *struct { + Name string + Args []string + Opts []utils.Option + } + + for _, call := range calls { + if call.Name == "apt" && len(call.Args) >= 1 && call.Args[0] == "update" { + updateCall = &call + } + if call.Name == "apt" && len(call.Args) >= 3 && call.Args[0] == "install" && call.Args[1] == "-y" { + installCall = &call + } + } + + require.NotNil(t, updateCall, "apt update call not found") + require.NotNil(t, installCall, "apt install call not found") +} + +func Test_InstallPackage_CallsAptInstallCommand_WithSudo(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{ + EscalateCommandFunc: func(baseCmd string, baseArgs []string) (privilege.EscalationResult, error) { + return privilege.EscalationResult{ + Method: privilege.EscalationSudo, + Command: "sudo", + Args: append([]string{baseCmd}, baseArgs...), + NeedsEscalation: true, + }, nil + }, + } + + aptManager := apt.NewAptPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + packageInfo := pkgmanager.NewRequestedPackageInfo("git", nil) + + err := aptManager.InstallPackage(packageInfo) + + require.NoError(t, err) + + calls := mockCommander.RunCommandCalls() + require.GreaterOrEqual(t, len(calls), 2) + + // Find sudo apt calls + var updateCall, installCall *struct { + Name string + Args []string + Opts []utils.Option + } + + for _, call := range calls { + if call.Name == "sudo" && len(call.Args) >= 2 && call.Args[0] == "apt" && call.Args[1] == "update" { + updateCall = &call + } + if call.Name == "sudo" && len(call.Args) >= 4 && call.Args[0] == "apt" && call.Args[1] == "install" && call.Args[2] == "-y" { + installCall = &call + } + } + + require.NotNil(t, updateCall, "sudo apt update call not found") + require.NotNil(t, installCall, "sudo apt install call not found") +} + +func Test_InstallPackage_CallsAptInstallCommand_WithoutPrivilegeEscalation(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{ + EscalateCommandFunc: func(baseCmd string, baseArgs []string) (privilege.EscalationResult, error) { + return privilege.EscalationResult{ + Method: privilege.EscalationDirect, + Command: baseCmd, + Args: baseArgs, + NeedsEscalation: false, + }, nil + }, + } + + aptManager := apt.NewAptPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + packageInfo := pkgmanager.NewRequestedPackageInfo("git", nil) + + err := aptManager.InstallPackage(packageInfo) + + require.NoError(t, err) + + calls := mockCommander.RunCommandCalls() + require.GreaterOrEqual(t, len(calls), 2) + + // Find direct apt calls (no privilege escalation) + var updateCall, installCall *struct { + Name string + Args []string + Opts []utils.Option + } + + for _, call := range calls { + if call.Name == "apt" && len(call.Args) >= 1 && call.Args[0] == "update" { + updateCall = &call + } + if call.Name == "apt" && len(call.Args) >= 3 && call.Args[0] == "install" && call.Args[1] == "-y" { + installCall = &call + } + } + + require.NotNil(t, updateCall, "apt update call not found") + require.NotNil(t, installCall, "apt install call not found") +} + +func Test_IsPackageInstalled_ChecksInstalledPackages(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "dpkg-query" { + return &utils.Result{ + Stdout: []byte("git 1:2.34.1-1ubuntu1.9\ncurl 7.81.0-1ubuntu1.10\n"), + }, nil + } + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{} + + aptManager := apt.NewAptPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + packageInfo := pkgmanager.NewPackageInfo("git", "") + + isInstalled, err := aptManager.IsPackageInstalled(packageInfo) + + require.NoError(t, err) + require.True(t, isInstalled) +} + +func Test_IsPackageInstalled_ReturnsFalse_WhenPackageNotInstalled(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "dpkg-query" { + return &utils.Result{ + Stdout: []byte("curl 7.81.0-1ubuntu1.10\n"), + }, nil + } + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{} + + aptManager := apt.NewAptPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + packageInfo := pkgmanager.NewPackageInfo("git", "") + + isInstalled, err := aptManager.IsPackageInstalled(packageInfo) + + require.NoError(t, err) + require.False(t, isInstalled) +} + +func Test_ListInstalledPackages_ParsesDpkgQueryOutput(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "dpkg-query" { + return &utils.Result{ + Stdout: []byte("git 1:2.34.1-1ubuntu1.9\ncurl 7.81.0-1ubuntu1.10\nbuild-essential 12.9ubuntu3\n"), + }, nil + } + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{} + + aptManager := apt.NewAptPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + packages, err := aptManager.ListInstalledPackages() + + require.NoError(t, err) + require.Len(t, packages, 3) + require.Equal(t, "git", packages[0].Name) + require.Equal(t, "1:2.34.1-1ubuntu1.9", packages[0].Version) + require.Equal(t, "curl", packages[1].Name) + require.Equal(t, "7.81.0-1ubuntu1.10", packages[1].Version) + require.Equal(t, "build-essential", packages[2].Name) + require.Equal(t, "12.9ubuntu3", packages[2].Version) +} + +func Test_UninstallPackage_CallsAptRemoveCommand_AsRoot(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{ + EscalateCommandFunc: func(baseCmd string, baseArgs []string) (privilege.EscalationResult, error) { + return privilege.EscalationResult{ + Method: privilege.EscalationNone, + Command: baseCmd, + Args: baseArgs, + NeedsEscalation: false, + }, nil + }, + } + + aptManager := apt.NewAptPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + packageInfo := pkgmanager.NewPackageInfo("git", "1.0.0") + + err := aptManager.UninstallPackage(packageInfo) + + require.NoError(t, err) + + calls := mockCommander.RunCommandCalls() + require.NotEmpty(t, calls) + + // Find the direct apt remove call (no sudo since running as root) + var removeCall *struct { + Name string + Args []string + Opts []utils.Option + } + + for _, call := range calls { + if call.Name == "apt" && len(call.Args) >= 3 && call.Args[0] == "remove" && call.Args[1] == "-y" { + removeCall = &call + } + } + + require.NotNil(t, removeCall, "apt remove call not found") +} + +func Test_GetPackageVersion_ReturnsVersion_WhenPackageIsInstalled(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "dpkg-query" { + return &utils.Result{ + Stdout: []byte("git 1:2.34.1-1ubuntu1.9\ncurl 7.81.0-1ubuntu1.10\n"), + }, nil + } + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{} + + aptManager := apt.NewAptPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + version, err := aptManager.GetPackageVersion("git") + + require.NoError(t, err) + require.Equal(t, "1:2.34.1-1ubuntu1.9", version) +} + +func Test_GetPackageVersion_ReturnsError_WhenPackageNotInstalled(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "dpkg-query" { + return &utils.Result{ + Stdout: []byte("curl 7.81.0-1ubuntu1.10\n"), + }, nil + } + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{} + + aptManager := apt.NewAptPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + version, err := aptManager.GetPackageVersion("git") + + require.Error(t, err) + require.Contains(t, err.Error(), "package git is not installed") + require.Empty(t, version) +} + +func Test_InstallPackage_DiscardsOutput_WhenDisplayModeIsNotPassthrough(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + cmdOptions := utils.Options{ + Stdout: os.Stdout, + Stderr: os.Stderr, + } + + // Apply all provided options + for _, opt := range options { + opt(&cmdOptions) + } + + // Verify that output was discarded (stdout/stderr should be different from original) + require.NotEqual(t, os.Stdout, cmdOptions.Stdout) + require.NotEqual(t, os.Stderr, cmdOptions.Stderr) + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{ + EscalateCommandFunc: func(command string, args []string) (privilege.EscalationResult, error) { + return privilege.EscalationResult{Command: command, Args: args}, nil + }, + } + + aptManager := apt.NewAptPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + packageInfo := pkgmanager.NewRequestedPackageInfo("git", nil) + + err := aptManager.InstallPackage(packageInfo) + + require.NoError(t, err) +} + +func Test_InstallPackage_DoesNotDiscardOutput_WhenDisplayModeIsPassthrough(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + cmdOptions := utils.Options{ + Stdout: os.Stdout, + Stderr: os.Stderr, + } + + // Apply all provided options + for _, opt := range options { + opt(&cmdOptions) + } + + // Verify that output was not discarded (stdout/stderr should remain unchanged) + require.Equal(t, os.Stdout, cmdOptions.Stdout) + require.Equal(t, os.Stderr, cmdOptions.Stderr) + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{ + EscalateCommandFunc: func(command string, args []string) (privilege.EscalationResult, error) { + return privilege.EscalationResult{Command: command, Args: args}, nil + }, + } + + aptManager := apt.NewAptPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModePassthrough) + packageInfo := pkgmanager.NewRequestedPackageInfo("git", nil) + + err := aptManager.InstallPackage(packageInfo) + + require.NoError(t, err) +} diff --git a/installer/lib/apt/integration_test.go b/installer/lib/apt/integration_test.go new file mode 100644 index 0000000..2cf7c04 --- /dev/null +++ b/installer/lib/apt/integration_test.go @@ -0,0 +1,145 @@ +package apt_test + +import ( + "testing" + + "github.com/MrPointer/dotfiles/installer/lib/apt" + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" + "github.com/MrPointer/dotfiles/installer/utils/privilege" + "github.com/stretchr/testify/require" +) + +func Test_AptPackageManager_CanCheckIfPackageExists_Integration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + // This test only runs on systems where apt is available + defaultCommander := utils.NewDefaultCommander(logger.DefaultLogger) + defaultOsManager := osmanager.NewUnixOsManager(logger.DefaultLogger, defaultCommander, false) + + // Check if apt is available on this system + aptExists, err := defaultOsManager.ProgramExists("apt") + if err != nil || !aptExists { + t.Skip("apt not available on this system") + } + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, defaultCommander, defaultOsManager) + aptManager := apt.NewAptPackageManager(logger.DefaultLogger, defaultCommander, defaultOsManager, escalator, utils.DisplayModeProgress) + + // Test with a commonly available package that should exist + packageInfo := pkgmanager.NewPackageInfo("libc6", "") + isInstalled, err := aptManager.IsPackageInstalled(packageInfo) + + require.NoError(t, err) + // libc6 should be installed on any Debian/Ubuntu system + require.True(t, isInstalled) +} + +func Test_AptPackageManager_CanListInstalledPackages_Integration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + // This test only runs on systems where apt is available + defaultCommander := utils.NewDefaultCommander(logger.DefaultLogger) + defaultOsManager := osmanager.NewUnixOsManager(logger.DefaultLogger, defaultCommander, false) + + // Check if apt is available on this system + aptExists, err := defaultOsManager.ProgramExists("apt") + if err != nil || !aptExists { + t.Skip("apt not available on this system") + } + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, defaultCommander, defaultOsManager) + aptManager := apt.NewAptPackageManager(logger.DefaultLogger, defaultCommander, defaultOsManager, escalator, utils.DisplayModeProgress) + + packages, err := aptManager.ListInstalledPackages() + + require.NoError(t, err) + require.NotEmpty(t, packages) + + // Verify structure of returned packages + for _, pkg := range packages { + require.NotEmpty(t, pkg.Name) + require.NotEmpty(t, pkg.Version) + } +} + +func Test_AptPackageManager_CanGetManagerInfo_Integration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + // This test only runs on systems where apt is available + defaultCommander := utils.NewDefaultCommander(logger.DefaultLogger) + defaultOsManager := osmanager.NewUnixOsManager(logger.DefaultLogger, defaultCommander, false) + + // Check if apt is available on this system + aptExists, err := defaultOsManager.ProgramExists("apt") + if err != nil || !aptExists { + t.Skip("apt not available on this system") + } + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, defaultCommander, defaultOsManager) + aptManager := apt.NewAptPackageManager(logger.DefaultLogger, defaultCommander, defaultOsManager, escalator, utils.DisplayModeProgress) + + info, err := aptManager.GetInfo() + + require.NoError(t, err) + require.Equal(t, "apt", info.Name) + require.NotEmpty(t, info.Version) +} + +func Test_AptPackageManager_PrerequisiteInstallationWorkflow_Integration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + // This test only runs on systems where apt is available + defaultCommander := utils.NewDefaultCommander(logger.DefaultLogger) + defaultOsManager := osmanager.NewUnixOsManager(logger.DefaultLogger, defaultCommander, false) + + // Check if apt is available on this system + aptExists, err := defaultOsManager.ProgramExists("apt") + if err != nil || !aptExists { + t.Skip("apt not available on this system") + } + + // Check if we're running as root or can use sudo + _, err = defaultCommander.RunCommand("sudo", []string{"-n", "true"}, utils.WithCaptureOutput()) + if err != nil { + t.Skip("sudo not available or requires password - skipping package installation test") + } + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, defaultCommander, defaultOsManager) + aptManager := apt.NewAptPackageManager(logger.DefaultLogger, defaultCommander, defaultOsManager, escalator, utils.DisplayModeProgress) + + // Test with a lightweight package that's commonly available but might not be installed + testPackage := "file" + packageInfo := pkgmanager.NewRequestedPackageInfo(testPackage, nil) + + // Check initial state + initiallyInstalled, err := aptManager.IsPackageInstalled(pkgmanager.NewPackageInfo(testPackage, "")) + require.NoError(t, err) + + if !initiallyInstalled { + // Install the package + err = aptManager.InstallPackage(packageInfo) + require.NoError(t, err) + + // Verify it's now installed + isInstalled, err := aptManager.IsPackageInstalled(pkgmanager.NewPackageInfo(testPackage, "")) + require.NoError(t, err) + require.True(t, isInstalled) + + // Clean up by removing it + err = aptManager.UninstallPackage(pkgmanager.NewPackageInfo(testPackage, "")) + require.NoError(t, err) + } else { + t.Log("Package already installed, skipping installation test") + } +} diff --git a/installer/lib/brew/BrewInstaller_mock.go b/installer/lib/brew/BrewInstaller_mock.go new file mode 100644 index 0000000..eb7593e --- /dev/null +++ b/installer/lib/brew/BrewInstaller_mock.go @@ -0,0 +1,105 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package brew + +import ( + "sync" +) + +// Ensure that MoqBrewInstaller does implement BrewInstaller. +// If this is not the case, regenerate this file with mockery. +var _ BrewInstaller = &MoqBrewInstaller{} + +// MoqBrewInstaller is a mock implementation of BrewInstaller. +// +// func TestSomethingThatUsesBrewInstaller(t *testing.T) { +// +// // make and configure a mocked BrewInstaller +// mockedBrewInstaller := &MoqBrewInstaller{ +// InstallFunc: func() error { +// panic("mock out the Install method") +// }, +// IsAvailableFunc: func() (bool, error) { +// panic("mock out the IsAvailable method") +// }, +// } +// +// // use mockedBrewInstaller in code that requires BrewInstaller +// // and then make assertions. +// +// } +type MoqBrewInstaller struct { + // InstallFunc mocks the Install method. + InstallFunc func() error + + // IsAvailableFunc mocks the IsAvailable method. + IsAvailableFunc func() (bool, error) + + // calls tracks calls to the methods. + calls struct { + // Install holds details about calls to the Install method. + Install []struct { + } + // IsAvailable holds details about calls to the IsAvailable method. + IsAvailable []struct { + } + } + lockInstall sync.RWMutex + lockIsAvailable sync.RWMutex +} + +// Install calls InstallFunc. +func (mock *MoqBrewInstaller) Install() error { + if mock.InstallFunc == nil { + panic("MoqBrewInstaller.InstallFunc: method is nil but BrewInstaller.Install was just called") + } + callInfo := struct { + }{} + mock.lockInstall.Lock() + mock.calls.Install = append(mock.calls.Install, callInfo) + mock.lockInstall.Unlock() + return mock.InstallFunc() +} + +// InstallCalls gets all the calls that were made to Install. +// Check the length with: +// +// len(mockedBrewInstaller.InstallCalls()) +func (mock *MoqBrewInstaller) InstallCalls() []struct { +} { + var calls []struct { + } + mock.lockInstall.RLock() + calls = mock.calls.Install + mock.lockInstall.RUnlock() + return calls +} + +// IsAvailable calls IsAvailableFunc. +func (mock *MoqBrewInstaller) IsAvailable() (bool, error) { + if mock.IsAvailableFunc == nil { + panic("MoqBrewInstaller.IsAvailableFunc: method is nil but BrewInstaller.IsAvailable was just called") + } + callInfo := struct { + }{} + mock.lockIsAvailable.Lock() + mock.calls.IsAvailable = append(mock.calls.IsAvailable, callInfo) + mock.lockIsAvailable.Unlock() + return mock.IsAvailableFunc() +} + +// IsAvailableCalls gets all the calls that were made to IsAvailable. +// Check the length with: +// +// len(mockedBrewInstaller.IsAvailableCalls()) +func (mock *MoqBrewInstaller) IsAvailableCalls() []struct { +} { + var calls []struct { + } + mock.lockIsAvailable.RLock() + calls = mock.calls.IsAvailable + mock.lockIsAvailable.RUnlock() + return calls +} diff --git a/installer/lib/brew/brew.go b/installer/lib/brew/brew.go new file mode 100644 index 0000000..f1e64ef --- /dev/null +++ b/installer/lib/brew/brew.go @@ -0,0 +1,172 @@ +package brew + +import ( + "errors" + "fmt" + "strings" + + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" +) + +type BrewPackageManager struct { + logger logger.Logger + commander utils.Commander + programQuery osmanager.ProgramQuery + brewPath string + displayMode utils.DisplayMode +} + +var _ pkgmanager.PackageManager = &BrewPackageManager{} + +// NewBrewPackageManager creates a new BrewPackageManager instance. +func NewBrewPackageManager(logger logger.Logger, commander utils.Commander, programQuery osmanager.ProgramQuery, brewPath string, displayMode utils.DisplayMode) *BrewPackageManager { + return &BrewPackageManager{ + logger: logger, + commander: commander, + programQuery: programQuery, + brewPath: brewPath, + displayMode: displayMode, + } +} + +func (b *BrewPackageManager) GetInfo() (pkgmanager.PackageManagerInfo, error) { + b.logger.Debug("Getting info about Homebrew") + + brewVersion, err := b.programQuery.GetProgramVersion(b.brewPath, func(version string) (string, error) { + if version == "" { + return "", nil + } + + // Brew's version is typically in the format "Homebrew 3.4.0", so we extract the version number. + parts := strings.Split(version, " ") + if len(parts) < 2 { + return "", errors.New("unexpected version format: " + version) + } + + return parts[1], nil + }) + if err != nil { + return pkgmanager.DefaultPackageManagerInfo(), errors.New("failed to get Homebrew version: " + err.Error()) + } + + return pkgmanager.PackageManagerInfo{ + Name: "brew", + Version: brewVersion, + }, nil +} + +// GetPackageVersion implements pkgmanager.PackageManager. +func (b *BrewPackageManager) GetPackageVersion(packageName string) (string, error) { + b.logger.Debug("Getting version of package %s with Homebrew", packageName) + + // Get list of installed packages with versions, then find the requested package. + packages, err := b.ListInstalledPackages() + if err != nil { + return "", errors.New("failed to list installed packages with Homebrew: " + err.Error()) + } + + for _, pkg := range packages { + if pkg.Name == packageName { + b.logger.Debug("Found package %s with version %s", packageName, pkg.Version) + return pkg.Version, nil + } + } + + b.logger.Debug("Package %s not found with Homebrew", packageName) + return "", fmt.Errorf("package %s is not installed with Homebrew", packageName) +} + +// InstallPackage implements pkgmanager.PackageManager. +func (b *BrewPackageManager) InstallPackage(requestedPackageInfo pkgmanager.RequestedPackageInfo) error { + b.logger.Debug("Installing package %s with Homebrew", requestedPackageInfo.Name) + + if requestedPackageInfo.VersionConstraints != nil { + b.logger.Debug("Homebrew doesn't support version constraints, installing the latest version of package %s", requestedPackageInfo.Name) + } + + var discardOutputOption utils.Option = utils.EmptyOption() + if b.displayMode.ShouldDiscardOutput() { + discardOutputOption = utils.WithDiscardOutput() + } + + _, err := b.commander.RunCommand(b.brewPath, []string{"install", requestedPackageInfo.Name}, discardOutputOption) + if err != nil { + return fmt.Errorf("failed to install package %s with Homebrew: %v", requestedPackageInfo.Name, err) + } + + b.logger.Debug("Package %s installed successfully with Homebrew", requestedPackageInfo.Name) + return nil +} + +// IsPackageInstalled implements pkgmanager.PackageManager. +func (b *BrewPackageManager) IsPackageInstalled(packageInfo pkgmanager.PackageInfo) (bool, error) { + b.logger.Debug("Checking if package %s is installed with Homebrew", packageInfo.Name) + + // Check if the package is installed by listing all installed packages and checking for the package name. + packages, err := b.ListInstalledPackages() + if err != nil { + return false, errors.New("failed to list installed packages with Homebrew: " + err.Error()) + } + + for _, pkg := range packages { + if pkg.Name == packageInfo.Name { + b.logger.Debug("Package %s is installed with Homebrew", packageInfo.Name) + return true, nil + } + } + + b.logger.Debug("Package %s is not installed with Homebrew", packageInfo.Name) + return false, nil +} + +// ListInstalledPackages implements pkgmanager.PackageManager. +func (b *BrewPackageManager) ListInstalledPackages() ([]pkgmanager.PackageInfo, error) { + b.logger.Debug("Listing packages installed by Homebrew") + + // Run `brew list` to get the list of installed packages. + output, err := b.commander.RunCommand(b.brewPath, []string{"list", "--versions"}, utils.WithCaptureOutput()) + if err != nil { + return nil, errors.New("failed to list installed packages with Homebrew: " + err.Error()) + } + + // Split the output into lines and create PackageInfo for each package. + trimmedOutput := strings.TrimSpace(output.String()) + if trimmedOutput == "" { + return []pkgmanager.PackageInfo{}, nil + } + + b.logger.Trace("Raw list of installed packages: %s", trimmedOutput) + + rawPackages := strings.Split(trimmedOutput, "\n") + var packages []pkgmanager.PackageInfo + for _, pkg := range rawPackages { + name, version, found := strings.Cut(pkg, " ") + if !found { + return nil, errors.New("failed to parse package line: " + pkg) + } + packages = append(packages, pkgmanager.NewPackageInfo(name, version)) + } + + return packages, nil +} + +// UninstallPackage implements pkgmanager.PackageManager. +func (b *BrewPackageManager) UninstallPackage(packageInfo pkgmanager.PackageInfo) error { + b.logger.Debug("Uninstalling package %s with Homebrew", packageInfo.Name) + + var discardOutputOption utils.Option = utils.EmptyOption() + if b.displayMode.ShouldDiscardOutput() { + discardOutputOption = utils.WithDiscardOutput() + } + + _, err := b.commander.RunCommand(b.brewPath, []string{"uninstall", packageInfo.Name}, discardOutputOption) + if err != nil { + return fmt.Errorf("failed to uninstall package %s with Homebrew: %v", packageInfo.Name, err) + } + + b.logger.Debug("Package %s uninstalled successfully", packageInfo.Name) + return nil +} diff --git a/installer/lib/brew/brew_test.go b/installer/lib/brew/brew_test.go new file mode 100644 index 0000000..85b99b3 --- /dev/null +++ b/installer/lib/brew/brew_test.go @@ -0,0 +1,534 @@ +package brew_test + +import ( + "errors" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/MrPointer/dotfiles/installer/lib/brew" + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" +) + +func Test_NewBrewPackageManager_ReturnsValidInstance(t *testing.T) { + mockCommander := &utils.MoqCommander{} + mockProgramQuery := &osmanager.MoqProgramQuery{} + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + + require.NotNil(t, packageManager) +} + +func Test_GetInfo_ReturnsCorrectInfo_WhenBrewVersionIsRetrieved(t *testing.T) { + mockCommander := &utils.MoqCommander{} + mockProgramQuery := &osmanager.MoqProgramQuery{ + GetProgramVersionFunc: func(program string, versionExtractor osmanager.VersionExtractor, queryArgs ...string) (string, error) { + if program == "/usr/local/bin/brew" { + return "3.6.5", nil + } + return "", errors.New("unexpected program") + }, + } + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + + info, err := packageManager.GetInfo() + + require.NoError(t, err) + require.Equal(t, "brew", info.Name) + require.Equal(t, "3.6.5", info.Version) +} + +func Test_GetInfo_HandlesBrewVersionExtractionCorrectly(t *testing.T) { + tests := []struct { + name string + rawVersion string + expectedVersion string + expectedError bool + }{ + { + name: "Standard brew version format", + rawVersion: "Homebrew 3.6.5", + expectedVersion: "3.6.5", + expectedError: false, + }, + { + name: "Empty version string", + rawVersion: "", + expectedVersion: "", + expectedError: false, + }, + { + name: "Invalid version format", + rawVersion: "InvalidFormat", + expectedVersion: "", + expectedError: true, + }, + { + name: "Version with additional components", + rawVersion: "Homebrew 3.6.5-beta (extra info)", + expectedVersion: "3.6.5-beta", + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockCommander := &utils.MoqCommander{} + mockProgramQuery := &osmanager.MoqProgramQuery{ + GetProgramVersionFunc: func(program string, versionExtractor osmanager.VersionExtractor, queryArgs ...string) (string, error) { + return versionExtractor(tt.rawVersion) + }, + } + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + + info, err := packageManager.GetInfo() + + if tt.expectedError { + require.Error(t, err) + require.Contains(t, err.Error(), "Homebrew version") + } else { + require.NoError(t, err) + require.Equal(t, "brew", info.Name) + require.Equal(t, tt.expectedVersion, info.Version) + } + }) + } +} + +func Test_GetInfo_ReturnsError_WhenProgramQueryFails(t *testing.T) { + mockCommander := &utils.MoqCommander{} + mockProgramQuery := &osmanager.MoqProgramQuery{ + GetProgramVersionFunc: func(program string, versionExtractor osmanager.VersionExtractor, queryArgs ...string) (string, error) { + return "", errors.New("program not found") + }, + } + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + + info, err := packageManager.GetInfo() + + require.Error(t, err) + require.Contains(t, err.Error(), "Homebrew version") + require.Equal(t, pkgmanager.DefaultPackageManagerInfo(), info) +} + +func Test_GetPackageVersion_ReturnsVersion_WhenPackageIsInstalled(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "/usr/local/bin/brew" && len(args) == 2 && args[0] == "list" && args[1] == "--versions" { + output := "git 2.39.0\nnode 18.12.1\nvim 9.0.0500" + return &utils.Result{ + Stdout: []byte(output), + }, nil + } + return nil, errors.New("unexpected command") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + + version, err := packageManager.GetPackageVersion("node") + + require.NoError(t, err) + require.Equal(t, "18.12.1", version) +} + +func Test_GetPackageVersion_ReturnsError_WhenPackageIsNotInstalled(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "/usr/local/bin/brew" && len(args) == 2 && args[0] == "list" && args[1] == "--versions" { + output := "git 2.39.0\nvim 9.0.0500" + return &utils.Result{ + Stdout: []byte(output), + }, nil + } + return nil, errors.New("unexpected command") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + + version, err := packageManager.GetPackageVersion("nonexistent") + + require.Error(t, err) + require.Contains(t, err.Error(), "not installed") + require.Empty(t, version) +} + +func Test_GetPackageVersion_ReturnsError_WhenListInstalledPackagesFails(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return nil, errors.New("command failed") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + + version, err := packageManager.GetPackageVersion("git") + + require.Error(t, err) + require.Contains(t, err.Error(), "list installed packages") + require.Empty(t, version) +} + +func Test_InstallPackage_InstallsPackageSuccessfully(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "/usr/local/bin/brew" && len(args) == 2 && args[0] == "install" && args[1] == "git" { + return &utils.Result{}, nil + } + return nil, errors.New("unexpected command") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + requestedPackage := pkgmanager.RequestedPackageInfo{Name: "git"} + + err := packageManager.InstallPackage(requestedPackage) + + require.NoError(t, err) +} + +func Test_InstallPackage_ReturnsError_WhenInstallationFails(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return nil, errors.New("installation failed") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + requestedPackage := pkgmanager.RequestedPackageInfo{Name: "git"} + + err := packageManager.InstallPackage(requestedPackage) + + require.Error(t, err) + require.Contains(t, err.Error(), "install package") +} + +func Test_IsPackageInstalled_ReturnsTrue_WhenPackageIsInstalled(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "/usr/local/bin/brew" && len(args) == 2 && args[0] == "list" && args[1] == "--versions" { + output := "git 2.39.0\nnode 18.12.1\nvim 9.0.0500" + return &utils.Result{ + Stdout: []byte(output), + }, nil + } + return nil, errors.New("unexpected command") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + packageInfo := pkgmanager.NewPackageInfo("git", "2.39.0") + + isInstalled, err := packageManager.IsPackageInstalled(packageInfo) + + require.NoError(t, err) + require.True(t, isInstalled) +} + +func Test_IsPackageInstalled_ReturnsFalse_WhenPackageIsNotInstalled(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "/usr/local/bin/brew" && len(args) == 2 && args[0] == "list" && args[1] == "--versions" { + output := "git 2.39.0\nvim 9.0.0500" + return &utils.Result{ + Stdout: []byte(output), + }, nil + } + return nil, errors.New("unexpected command") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + packageInfo := pkgmanager.NewPackageInfo("nonexistent", "1.0.0") + + isInstalled, err := packageManager.IsPackageInstalled(packageInfo) + + require.NoError(t, err) + require.False(t, isInstalled) +} + +func Test_IsPackageInstalled_ReturnsError_WhenListInstalledPackagesFails(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return nil, errors.New("command failed") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + packageInfo := pkgmanager.NewPackageInfo("git", "2.39.0") + + isInstalled, err := packageManager.IsPackageInstalled(packageInfo) + + require.Error(t, err) + require.Contains(t, err.Error(), "list installed packages") + require.False(t, isInstalled) +} + +func Test_ListInstalledPackages_ReturnsPackageList_WhenCommandSucceeds(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "/usr/local/bin/brew" && len(args) == 2 && args[0] == "list" && args[1] == "--versions" { + output := "git 2.39.0\nnode 18.12.1\nvim 9.0.0500" + return &utils.Result{ + Stdout: []byte(output), + }, nil + } + return nil, errors.New("unexpected command") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + + packages, err := packageManager.ListInstalledPackages() + + require.NoError(t, err) + require.Len(t, packages, 3) + require.Equal(t, "git", packages[0].Name) + require.Equal(t, "2.39.0", packages[0].Version) + require.Equal(t, "node", packages[1].Name) + require.Equal(t, "18.12.1", packages[1].Version) + require.Equal(t, "vim", packages[2].Name) + require.Equal(t, "9.0.0500", packages[2].Version) +} + +func Test_ListInstalledPackages_ReturnsError_WhenCommandFails(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return nil, errors.New("command failed") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + + packages, err := packageManager.ListInstalledPackages() + + require.Error(t, err) + require.Contains(t, err.Error(), "list installed packages") + require.Nil(t, packages) +} + +func Test_ListInstalledPackages_ReturnsError_WhenOutputFormatIsInvalid(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "/usr/local/bin/brew" && len(args) == 2 && args[0] == "list" && args[1] == "--versions" { + output := "git\ninvalid-line-without-version\nvim 9.0.0500" + return &utils.Result{ + Stdout: []byte(output), + }, nil + } + return nil, errors.New("unexpected command") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + + packages, err := packageManager.ListInstalledPackages() + + require.Error(t, err) + require.Contains(t, err.Error(), "parse package") + require.Nil(t, packages) +} + +func Test_ListInstalledPackages_HandlesEmptyOutput(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "/usr/local/bin/brew" && len(args) == 2 && args[0] == "list" && args[1] == "--versions" { + return &utils.Result{ + Stdout: []byte(""), + }, nil + } + return nil, errors.New("unexpected command") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + + packages, err := packageManager.ListInstalledPackages() + + require.NoError(t, err) + require.Empty(t, packages) +} + +func Test_UninstallPackage_UninstallsPackageSuccessfully(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "/usr/local/bin/brew" && len(args) == 2 && args[0] == "uninstall" && args[1] == "git" { + return &utils.Result{}, nil + } + return nil, errors.New("unexpected command") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + packageInfo := pkgmanager.NewPackageInfo("git", "2.39.0") + + err := packageManager.UninstallPackage(packageInfo) + + require.NoError(t, err) +} + +func Test_InstallPackage_DiscardsOutput_WhenDisplayModeIsNotPassthrough(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + cmdOptions := utils.Options{ + Stdout: os.Stdout, + Stderr: os.Stderr, + } + + // Apply all provided options + for _, opt := range options { + opt(&cmdOptions) + } + + // Verify that output was discarded (stdout/stderr should be different from original) + require.NotEqual(t, os.Stdout, cmdOptions.Stdout) + require.NotEqual(t, os.Stderr, cmdOptions.Stderr) + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + requestedPackage := pkgmanager.RequestedPackageInfo{Name: "git"} + + err := packageManager.InstallPackage(requestedPackage) + + require.NoError(t, err) +} + +func Test_InstallPackage_DoesNotDiscardOutput_WhenDisplayModeIsPassthrough(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + cmdOptions := utils.Options{ + Stdout: os.Stdout, + Stderr: os.Stderr, + } + + // Apply all provided options + for _, opt := range options { + opt(&cmdOptions) + } + + // Verify that output was not discarded (stdout/stderr should remain unchanged) + require.Equal(t, os.Stdout, cmdOptions.Stdout) + require.Equal(t, os.Stderr, cmdOptions.Stderr) + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModePassthrough) + requestedPackage := pkgmanager.RequestedPackageInfo{Name: "git"} + + err := packageManager.InstallPackage(requestedPackage) + + require.NoError(t, err) +} + +func Test_UninstallPackage_ReturnsError_WhenUninstallationFails(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return nil, errors.New("uninstallation failed") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + packageInfo := pkgmanager.NewPackageInfo("git", "2.39.0") + + err := packageManager.UninstallPackage(packageInfo) + + require.Error(t, err) + require.Contains(t, err.Error(), "uninstall package") +} + +func Test_ListInstalledPackages_HandlesPackageNamesWithVersionsInMultipleFormats(t *testing.T) { + tests := []struct { + name string + brewOutput string + expectedCount int + expectedFirst pkgmanager.PackageInfo + expectError bool + }{ + { + name: "Standard package format", + brewOutput: "git 2.39.0\nnode 18.12.1", + expectedCount: 2, + expectedFirst: pkgmanager.NewPackageInfo("git", "2.39.0"), + expectError: false, + }, + { + name: "Package with complex version", + brewOutput: "postgresql@14 14.6_1\nredis 7.0.5", + expectedCount: 2, + expectedFirst: pkgmanager.NewPackageInfo("postgresql@14", "14.6_1"), + expectError: false, + }, + { + name: "Single package", + brewOutput: "vim 9.0.0500", + expectedCount: 1, + expectedFirst: pkgmanager.NewPackageInfo("vim", "9.0.0500"), + expectError: false, + }, + { + name: "Package with whitespace-containing version", + brewOutput: "package-name 1.0.0 2.0.0", + expectedCount: 1, + expectedFirst: pkgmanager.NewPackageInfo("package-name", "1.0.0 2.0.0"), + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "/usr/local/bin/brew" && len(args) == 2 && args[0] == "list" && args[1] == "--versions" { + return &utils.Result{ + Stdout: []byte(tt.brewOutput), + }, nil + } + return nil, errors.New("unexpected command") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + + packageManager := brew.NewBrewPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, "/usr/local/bin/brew", utils.DisplayModeProgress) + + packages, err := packageManager.ListInstalledPackages() + + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Len(t, packages, tt.expectedCount) + if tt.expectedCount > 0 { + require.Equal(t, tt.expectedFirst.Name, packages[0].Name) + require.Equal(t, tt.expectedFirst.Version, packages[0].Version) + } + } + }) + } +} diff --git a/installer/lib/brew/installer.go b/installer/lib/brew/installer.go new file mode 100644 index 0000000..c1f5ed6 --- /dev/null +++ b/installer/lib/brew/installer.go @@ -0,0 +1,535 @@ +package brew + +import ( + "fmt" + "net/http" // Keep for http.StatusOK and potentially other http constants if needed by other functions + "os" + "path/filepath" + "runtime" + "strings" + + "slices" + + "github.com/MrPointer/dotfiles/installer/lib/compatibility" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/httpclient" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" +) + +const ( + // BrewUserOnMultiUserSystem is the username used for Homebrew on multi-user systems. + BrewUserOnMultiUserSystem = "linuxbrew-manager" + + // LinuxBrewPath is the default installation path for Homebrew on Linux. + LinuxBrewPath = "/home/linuxbrew/.linuxbrew/bin/brew" + + // MacOSIntelBrewPath is the default installation path for Homebrew on Intel macOS. + MacOSIntelBrewPath = "/usr/local/bin/brew" + + // MacOSArmBrewPath is the default installation path for Homebrew on ARM macOS. + MacOSArmBrewPath = "/opt/homebrew/bin/brew" +) + +// DetectBrewPath returns the appropriate brew binary path based on the system information. +func DetectBrewPath(systemInfo *compatibility.SystemInfo, pathOverride string) (string, error) { + if pathOverride != "" { + return pathOverride, nil + } + + if systemInfo != nil { + switch systemInfo.OSName { + case "darwin": + if systemInfo.Arch == "arm64" { + return MacOSArmBrewPath, nil + } + + return MacOSIntelBrewPath, nil + + case "linux": + return LinuxBrewPath, nil + + default: + return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } + } + + return "", fmt.Errorf("system information is not provided") +} + +// UpdatePathWithBrewBinaries updates the current process's PATH to include brew's binary directories. +// This function is primarily used internally by BrewInstaller but can be called directly if needed. +func UpdatePathWithBrewBinaries(brewPath string) error { + // Extract the bin directory from the brew path + // e.g., /opt/homebrew/bin/brew -> /opt/homebrew/bin + brewBinDir := filepath.Dir(brewPath) + + // Get current PATH + currentPath := os.Getenv("PATH") + + // Check if already in PATH to avoid duplicates + pathDirs := strings.Split(currentPath, string(os.PathListSeparator)) + if slices.Contains(pathDirs, brewBinDir) { + return nil // Already in PATH + } + + // Prepend brew bin directory to PATH + newPath := brewBinDir + string(os.PathListSeparator) + currentPath + return os.Setenv("PATH", newPath) +} + +// BrewInstaller defines the interface for Homebrew operations. +// (moq is used for generating mocks in tests.) +type BrewInstaller interface { + IsAvailable() (bool, error) + Install() error +} + +// brewInstaller implements BrewInstaller for single-user systems. +// Holds configuration options for Homebrew operations. +type brewInstaller struct { + logger logger.Logger + systemInfo *compatibility.SystemInfo + commander utils.Commander + httpClient httpclient.HTTPClient + osManager osmanager.OsManager + fs utils.FileSystem + brewPathOverride string // for testing only + displayMode utils.DisplayMode +} + +var _ BrewInstaller = (*brewInstaller)(nil) + +// MultiUserBrewInstaller implements BrewInstaller for multi-user systems. +// It composes a regular brewInstaller and adds multi-user logic. +type MultiUserBrewInstaller struct { + *brewInstaller + brewUser string +} + +var _ BrewInstaller = (*MultiUserBrewInstaller)(nil) + +// NewBrewInstaller creates a new BrewInstaller with the given options. +func NewBrewInstaller(opts Options) BrewInstaller { + base := &brewInstaller{ + logger: opts.Logger, + systemInfo: opts.SystemInfo, + commander: opts.Commander, + httpClient: opts.HTTPClient, + osManager: opts.OsManager, + fs: opts.Fs, + brewPathOverride: opts.BrewPathOverride, + displayMode: opts.DisplayMode, + } + + if opts.MultiUserSystem { + return &MultiUserBrewInstaller{ + brewInstaller: base, + brewUser: BrewUserOnMultiUserSystem, + } + } + + return base +} + +// DetectBrewPath returns the appropriate brew binary path based on the system information. +func (b *brewInstaller) DetectBrewPath() (string, error) { + return DetectBrewPath(b.systemInfo, b.brewPathOverride) +} + +// IsAvailable checks if Homebrew is already installed and available (single-user). +func (b *brewInstaller) IsAvailable() (bool, error) { + b.logger.Debug("Checking if Homebrew is available") + + brewPath, err := b.DetectBrewPath() + if err != nil { + return false, err + } + + exists, err := b.fs.PathExists(brewPath) + if err != nil { + return false, err + } + + return exists, nil +} + +// Install installs Homebrew if not already installed (single-user). +func (b *brewInstaller) Install() error { + isAvailable, err := b.IsAvailable() + if err != nil { + return fmt.Errorf("failed checking Homebrew availability: %w", err) + } + + if isAvailable { + b.logger.Debug("Homebrew is already installed") + + // Update PATH to include brew binaries so installed tools can be found + brewPath, err := b.DetectBrewPath() + if err != nil { + return fmt.Errorf("failed to detect brew path for PATH update: %w", err) + } + if err := UpdatePathWithBrewBinaries(brewPath); err != nil { + b.logger.Warning("Failed to update PATH with brew binaries: %v", err) + } + + return nil + } + + b.logger.Debug("Installing Homebrew") + err = b.installHomebrew("") + if err != nil { + return err + } + + // Self-validation: check that brew is available and works + if err := b.validateInstall(); err != nil { + return fmt.Errorf("brew self-validation failed: %w", err) + } + + // Update PATH to include brew binaries so installed tools can be found + brewPath, err := b.DetectBrewPath() + if err != nil { + return fmt.Errorf("failed to detect brew path for PATH update: %w", err) + } + if err := UpdatePathWithBrewBinaries(brewPath); err != nil { + b.logger.Warning("Failed to update PATH with brew binaries: %v", err) + } + + return nil +} + +// validateInstall checks that the brew binary exists and is functional. +func (b *brewInstaller) validateInstall() error { + brewPath, err := b.DetectBrewPath() + if err != nil { + return fmt.Errorf("could not detect brew path: %w", err) + } + + exists, err := b.fs.PathExists(brewPath) + if err != nil { + return fmt.Errorf("failed to check if brew binary exists: %w", err) + } + if !exists { + return fmt.Errorf("brew binary not found at %s", brewPath) + } + + // Try running 'brew --version' to verify it works + if b.commander != nil { + var discardOutputOption utils.Option = utils.EmptyOption() + if b.displayMode != utils.DisplayModePassthrough { + discardOutputOption = utils.WithDiscardOutput() + } + + _, err = b.commander.RunCommand(brewPath, []string{"--version"}, discardOutputOption) + if err != nil { + return fmt.Errorf("brew --version failed: %w", err) + } + } + + return nil +} + +// Install installs Homebrew if not already installed (multi-user). +func (m *MultiUserBrewInstaller) Install() error { + isAvailable, err := m.IsAvailable() + if err != nil { + return fmt.Errorf("failed checking Homebrew availability: %w", err) + } + + if isAvailable { + m.logger.Debug("Homebrew is already installed (multi-user)") + + // Update PATH to include brew binaries so installed tools can be found + brewPath, err := m.DetectBrewPath() + if err != nil { + return fmt.Errorf("failed to detect brew path for PATH update: %w", err) + } + if err := UpdatePathWithBrewBinaries(brewPath); err != nil { + m.logger.Warning("Failed to update PATH with brew binaries: %v", err) + } + + return nil + } + + m.logger.Debug("Installing Homebrew (multi-user)") + if m.systemInfo.OSName == "darwin" { + return fmt.Errorf("multi-user Homebrew installation is not supported on macOS, please install manually") + } + + err = m.installMultiUserLinux() + if err != nil { + return err + } + + // Self-validation: check that brew is available and works + if err := m.validateInstall(); err != nil { + return fmt.Errorf("brew self-validation failed: %w", err) + } + + // Update PATH to include brew binaries so installed tools can be found + brewPath, err := m.DetectBrewPath() + if err != nil { + return fmt.Errorf("failed to detect brew path for PATH update: %w", err) + } + if err := UpdatePathWithBrewBinaries(brewPath); err != nil { + m.logger.Warning("Failed to update PATH with brew binaries: %v", err) + } + + return nil +} + +// Multi-user overrides. +// IsAvailable checks if Homebrew is already installed and available (multi-user). +func (m *MultiUserBrewInstaller) IsAvailable() (bool, error) { + m.logger.Debug("Checking if Homebrew is available") + + brewPath, err := m.DetectBrewPath() + if err != nil { + return false, err + } + + exists, err := m.fs.PathExists(brewPath) + if err != nil { + return false, err + } + if !exists { + return false, nil + } + + // For Linux multi-user systems, check file ownership + if m.systemInfo.OSName == "linux" { + brewPathOwner, err := m.osManager.GetFileOwner(brewPath) + if err != nil { + return false, fmt.Errorf("failed to get owner of brew binary: %w", err) + } + + return brewPathOwner == m.brewUser, nil + } + + return true, nil +} + +// installMultiUserLinux installs Homebrew in a multi-user configuration on Linux. +func (m *MultiUserBrewInstaller) installMultiUserLinux() error { + brewUser := BrewUserOnMultiUserSystem + brewHome := "/home/linuxbrew" + + // 1. Check if user exists and create if needed + exists, err := m.osManager.UserExists(brewUser) + if err != nil { + return fmt.Errorf("error checking if user '%s' exists: %w", brewUser, err) + } + + if !exists { + if err := m.osManager.AddUser(brewUser); err != nil { + return fmt.Errorf("error creating user '%s': %w", brewUser, err) + } + } + + // 2. Add user to sudo group + if err := m.osManager.AddUserToGroup(brewUser, "sudo"); err != nil { + m.logger.Debug("Note: Failed to add user to sudo group, continuing anyway") + } + + // 3. Add passwordless sudo for brew user + if err := m.osManager.AddSudoAccess(brewUser); err != nil { + return fmt.Errorf("failed to add sudo access for user '%s': %w", brewUser, err) + } + + // 4. Set ownership of homebrew directory + if err := m.osManager.SetOwnership(brewHome, brewUser); err != nil { + return fmt.Errorf("failed to set ownership of '%s' to '%s': %w", brewHome, brewUser, err) + } + + // 5. Install Homebrew as the brew user + return m.installHomebrew(brewUser) +} + +// installHomebrew handles both regular and multi-user Homebrew installations. +func (b *brewInstaller) installHomebrew(asUser string) error { + // Download and prepare the installation script + installScriptPath, cleanup, err := b.downloadAndPrepareInstallScript() + if err != nil { + return err + } + defer cleanup() // Ensure the temporary script is removed after execution + + b.logger.Debug("Downloading Homebrew install script") + exists, err := b.fs.PathExists(installScriptPath) + if err != nil { + return fmt.Errorf("failed checking if install script exists: %w", err) + } + if !exists { + return fmt.Errorf("install script does not exist at %s", installScriptPath) + } + b.logger.Debug("Successfully downloaded Homebrew install script") + b.logger.Trace("Homebrew install script downloaded to %s", installScriptPath) + + // Execute the downloaded install script, optionally as a different user + if asUser != "" { + b.logger.Debug("Running Homebrew install script as %s", asUser) + + var discardOutputOption utils.Option = utils.EmptyOption() + if b.displayMode != utils.DisplayModePassthrough { + discardOutputOption = utils.WithDiscardOutput() + } + + _, err := b.commander.RunCommand("sudo", []string{"-Hu", asUser, "bash", installScriptPath}, discardOutputOption) + if err != nil { + return fmt.Errorf("failed running Homebrew install script as %s: %w", asUser, err) + } + + b.logger.Debug("Successfully installed Homebrew for user %s", asUser) + } else { + b.logger.Debug("Running Homebrew install script") + + var discardOutputOption utils.Option = utils.EmptyOption() + if b.displayMode != utils.DisplayModePassthrough { + discardOutputOption = utils.WithDiscardOutput() + } + + _, err := b.commander.RunCommand("/bin/bash", []string{installScriptPath}, discardOutputOption, utils.WithEnv(map[string]string{"NONINTERACTIVE": "1"})) + if err != nil { + return err + } + + b.logger.Debug("Homebrew installed successfully") + } + + return nil +} + +// downloadAndPrepareInstallScript downloads the Homebrew installation script and prepares it for execution. +func (b *brewInstaller) downloadAndPrepareInstallScript() (string, func(), error) { + installScriptURL := "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" + + resp, err := b.httpClient.Get(installScriptURL) // Changed to use httpClient + if err != nil { + return "", nil, fmt.Errorf("failed to download Homebrew install script: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", nil, fmt.Errorf("failed to download Homebrew install script: HTTP status %d", resp.StatusCode) + } + + // Create a temporary file for the install script + b.logger.Trace("Creating temporary file for Homebrew install script") + tempFilePath, err := b.fs.CreateTemporaryFile("", "brew-install-*.sh") + if err != nil { + return "", nil, fmt.Errorf("failed to create temporary file for Homebrew install script: %w", err) + } + + // Create a cleanup function to remove the temp file + cleanup := func() { + b.logger.Trace("Cleaning up temporary file for Homebrew install script") + err := b.fs.RemovePath(tempFilePath) + if err != nil { + b.logger.Trace("Failed to remove temporary file: %w", err) + } + } + + // Copy the script to the temp file + b.logger.Trace("Writing Homebrew install script to temporary file") + bytesWritten, err := b.fs.WriteFile(tempFilePath, resp.Body) + if err != nil { + cleanup() + return "", nil, fmt.Errorf("failed to write Homebrew install script to temporary file: %w", err) + } + if bytesWritten == 0 { + cleanup() + return "", nil, fmt.Errorf("failed to write Homebrew install script: no bytes written") + } + + // Make the script executable. + const permissions = 0o755 // Standard executable permissions + b.logger.Trace("Making Homebrew install script executable") + if err = b.osManager.SetPermissions(tempFilePath, permissions); err != nil { + cleanup() + return "", nil, fmt.Errorf("failed to make Homebrew install script executable: %w", err) + } + + return tempFilePath, cleanup, nil +} + +// Options holds configuration options for Homebrew operations. +type Options struct { + Logger logger.Logger + MultiUserSystem bool + SystemInfo *compatibility.SystemInfo + Commander utils.Commander + HTTPClient httpclient.HTTPClient + OsManager osmanager.OsManager + Fs utils.FileSystem + BrewPathOverride string + DisplayMode utils.DisplayMode +} + +// DefaultOptions returns the default options. +func DefaultOptions() *Options { + return &Options{ + MultiUserSystem: false, + Logger: logger.DefaultLogger, + SystemInfo: nil, + Commander: utils.NewDefaultCommander(logger.DefaultLogger), + HTTPClient: httpclient.NewDefaultHTTPClient(), + OsManager: osmanager.NewUnixOsManager(logger.DefaultLogger, utils.NewDefaultCommander(logger.DefaultLogger), osmanager.IsRoot()), + Fs: utils.NewDefaultFileSystem(), + BrewPathOverride: "", + DisplayMode: utils.DisplayModeProgress, + } +} + +func (o *Options) WithBrewPathOverride(path string) *Options { + o.BrewPathOverride = path + return o +} + +// WithLogger sets a custom logger for the brew operations. +func (o *Options) WithLogger(log logger.Logger) *Options { + o.Logger = log + return o +} + +// WithMultiUserSystem configures for multi-user system operation. +func (o *Options) WithMultiUserSystem(multiUser bool) *Options { + o.MultiUserSystem = multiUser + return o +} + +// WithSystemInfo sets system information for brew operations. +func (o *Options) WithSystemInfo(sysInfo *compatibility.SystemInfo) *Options { + o.SystemInfo = sysInfo + return o +} + +// WithCommander sets a custom Commander for Homebrew operations. +func (o *Options) WithCommander(cmdr utils.Commander) *Options { + o.Commander = cmdr + return o +} + +// WithHTTPClient sets a custom HTTP client for Homebrew operations. +func (o *Options) WithHTTPClient(client httpclient.HTTPClient) *Options { + o.HTTPClient = client + return o +} + +// WithOsManager sets a custom OS manager for Homebrew operations. +func (o *Options) WithOsManager(osMgr osmanager.OsManager) *Options { + o.OsManager = osMgr + return o +} + +// WithFileSystem sets a custom FileSystem for Homebrew operations. +func (o *Options) WithFileSystem(fs utils.FileSystem) *Options { + o.Fs = fs + return o +} + +// WithDisplayMode sets the display mode for external tool output. +func (o *Options) WithDisplayMode(mode utils.DisplayMode) *Options { + o.DisplayMode = mode + return o +} diff --git a/installer/lib/brew/installer_test.go b/installer/lib/brew/installer_test.go new file mode 100644 index 0000000..45c0b52 --- /dev/null +++ b/installer/lib/brew/installer_test.go @@ -0,0 +1,1533 @@ +package brew_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/MrPointer/dotfiles/installer/lib/brew" + "github.com/MrPointer/dotfiles/installer/lib/compatibility" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/httpclient" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" +) + +/* ------------------------------------------------------------------------------------------------------------------ */ +/* Path Detection Tests */ +/* ------------------------------------------------------------------------------------------------------------------ */ + +func Test_DetectBrewPath_ReturnsExpectedPath_WhenCompatible(t *testing.T) { + tests := []struct { + name string + sysInfo *compatibility.SystemInfo + expectedPath string + errorExpected bool + }{ + {"darwin/arm64", &compatibility.SystemInfo{OSName: "darwin", Arch: "arm64"}, brew.MacOSArmBrewPath, false}, + {"darwin/amd64", &compatibility.SystemInfo{OSName: "darwin", Arch: "amd64"}, brew.MacOSIntelBrewPath, false}, + {"linux/amd64", &compatibility.SystemInfo{OSName: "linux", Arch: "amd64"}, brew.LinuxBrewPath, false}, + {"unsupported", &compatibility.SystemInfo{OSName: "plan9", Arch: "amd64"}, "", true}, + {"no sysinfo", nil, "", true}, + } + + for _, tt := range tests { + current := tt + t.Run(current.name, func(t *testing.T) { + got, err := brew.DetectBrewPath(current.sysInfo, "") + if (err != nil) != current.errorExpected { + t.Fatalf("DetectBrewPath() error = %v, wantError %v", err, current.errorExpected) + } + if got != current.expectedPath { + t.Errorf("DetectBrewPath() = %q, want %q", got, current.expectedPath) + } + }) + } +} + +func Test_DetectBrewPath_UsesOverride_WhenProvided(t *testing.T) { + overridePath := "/custom/brew/path" + sysInfo := &compatibility.SystemInfo{OSName: "darwin", Arch: "arm64"} + + got, err := brew.DetectBrewPath(sysInfo, overridePath) + + require.NoError(t, err) + require.Equal(t, overridePath, got) +} + +/* ------------------------------------------------------------------------------------------------------------------ */ +/* Installer Constructor Tests */ +/* ------------------------------------------------------------------------------------------------------------------ */ + +func Test_NewBrewInstaller_CreatesMultiUserImplementation_WhenOptionIsEnabled(t *testing.T) { + opts := brew.Options{ + MultiUserSystem: true, + Logger: logger.DefaultLogger, + SystemInfo: &compatibility.SystemInfo{OSName: "linux", Arch: "amd64"}, + Commander: nil, + } + installer := brew.NewBrewInstaller(opts) + require.NotNil(t, installer) + + _, isMultiUser := installer.(*brew.MultiUserBrewInstaller) + require.True(t, isMultiUser) +} + +func Test_NewBrewInstaller_CreatesSingleUserImplementation_ByDefault(t *testing.T) { + opts := brew.Options{ + MultiUserSystem: false, + Logger: logger.DefaultLogger, + SystemInfo: &compatibility.SystemInfo{OSName: "linux", Arch: "amd64"}, + Commander: nil, + } + installer := brew.NewBrewInstaller(opts) + require.NotNil(t, installer) + + _, isMultiUser := installer.(*brew.MultiUserBrewInstaller) + require.False(t, isMultiUser) +} + +/* ------------------------------------------------------------------------------------------------------------------ */ +/* Single-User Installer Tests */ +/* ------------------------------------------------------------------------------------------------------------------ */ + +func Test_SingleUserBrew_ReportsAvailable_WhenPathExists(t *testing.T) { + expectedBrewPath := "/opt/homebrew/bin/brew" + + // Create mock dependencies + mockLogger := &logger.NoopLogger{} + mockCommander := &utils.MoqCommander{} + mockFS := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + if path == expectedBrewPath { + return true, nil // Simulate that brew exists + } + return false, nil // Other paths do not exist + }, + } + mockHTTP := &httpclient.MoqHTTPClient{} + mockOsManager := &osmanager.MoqOsManager{} + + opts := brew.Options{ + Logger: mockLogger, + SystemInfo: &compatibility.SystemInfo{OSName: "darwin", Arch: "arm64"}, + Commander: mockCommander, + HTTPClient: mockHTTP, + OsManager: mockOsManager, + Fs: mockFS, + MultiUserSystem: false, + BrewPathOverride: expectedBrewPath, + } + + installer := brew.NewBrewInstaller(opts) + available, err := installer.IsAvailable() + + require.NoError(t, err) + require.True(t, available) +} + +func Test_SingleUserBrew_ReportsUnavailable_WhenPathDoesNotExist(t *testing.T) { + expectedBrewPath := "/nonexistent/brew" + + // Create mock dependencies + mockLogger := &logger.NoopLogger{} + mockCommander := &utils.MoqCommander{} + mockFS := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + if path == expectedBrewPath { + return false, nil // Simulate that brew does not exist + } + return true, nil // Other paths exist + }, + } + mockHTTP := &httpclient.MoqHTTPClient{} + mockOsManager := &osmanager.MoqOsManager{} + + opts := brew.Options{ + Logger: mockLogger, + SystemInfo: &compatibility.SystemInfo{OSName: "darwin", Arch: "arm64"}, + Commander: mockCommander, + HTTPClient: mockHTTP, + OsManager: mockOsManager, + Fs: mockFS, + MultiUserSystem: false, + BrewPathOverride: expectedBrewPath, + } + + installer := brew.NewBrewInstaller(opts) + available, err := installer.IsAvailable() + + require.NoError(t, err) + require.False(t, available) +} + +func Test_SingleUserBrew_IsNotReinstalled_WhenAvailable(t *testing.T) { + expectedBrewPath := "/opt/homebrew/bin/brew" + + // Create mock dependencies + mockLogger := &logger.NoopLogger{} + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == expectedBrewPath && len(args) == 1 && args[0] == "--version" { + return &utils.Result{ExitCode: 0}, nil // Validation successful + } + return nil, fmt.Errorf("unexpected command: %s %v", name, args) + }, + } + mockFS := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + if path == expectedBrewPath { + return true, nil // Simulate that brew exists + } + return false, nil // Other paths do not exist + }, + } + mockHTTP := &httpclient.MoqHTTPClient{} + mockOsManager := &osmanager.MoqOsManager{} + + opts := brew.Options{ + Logger: mockLogger, + SystemInfo: &compatibility.SystemInfo{OSName: "darwin", Arch: "arm64"}, + Commander: mockCommander, + HTTPClient: mockHTTP, + OsManager: mockOsManager, + Fs: mockFS, + MultiUserSystem: false, + BrewPathOverride: expectedBrewPath, + } + + installer := brew.NewBrewInstaller(opts) + err := installer.Install() + + require.NoError(t, err) + + // Verify no HTTP requests were made (no download should happen) + require.Empty(t, mockHTTP.GetCalls()) +} + +func Test_SingleUserBrew_InstallsSuccessfully_WhenNotAvailable(t *testing.T) { + // Create mock dependencies + mockLogger := &logger.NoopLogger{} + tempScriptPath := "/tmp/brew-install-12345.sh" + installScript := "#!/bin/bash\necho 'Installing Homebrew...'" + + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "/bin/bash" && len(args) == 1 && args[0] == tempScriptPath { + // Check if NONINTERACTIVE env is set via options + for range opts { + // We can't easily inspect options, so assume env is correct + } + return &utils.Result{ExitCode: 0}, nil // Installation successful + } + if name == "/opt/homebrew/bin/brew" && len(args) == 1 && args[0] == "--version" { + return &utils.Result{ExitCode: 0}, nil // Validation successful + } + return nil, fmt.Errorf("unexpected command: %s %v", name, args) + }, + } + + expectedBrewPath := "/opt/homebrew/bin/brew" + + pathExistsCalls := 0 + mockFS := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + pathExistsCalls++ + if path == expectedBrewPath { + // First call: brew doesn't exist, second call: brew exists after install + return pathExistsCalls > 1, nil + } + if path == tempScriptPath { + return true, nil // Script exists after download + } + return false, nil + }, + CreateTemporaryFileFunc: func(dir, pattern string) (string, error) { + return tempScriptPath, nil + }, + WriteFileFunc: func(path string, reader io.Reader) (int64, error) { + if path == tempScriptPath { + return int64(len(installScript)), nil + } + return 0, fmt.Errorf("unexpected path: %s", path) + }, + RemovePathFunc: func(path string) error { + return nil // Cleanup successful + }, + } + + mockHTTP := &httpclient.MoqHTTPClient{ + GetFunc: func(url string) (*http.Response, error) { + if url == "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(installScript)), + }, nil + } + return nil, fmt.Errorf("unexpected URL: %s", url) + }, + } + + mockOsManager := &osmanager.MoqOsManager{ + SetPermissionsFunc: func(path string, perms os.FileMode) error { + if path == tempScriptPath && perms == 0o755 { + return nil + } + return fmt.Errorf("unexpected permission call: %s %o", path, perms) + }, + } + + opts := brew.Options{ + Logger: mockLogger, + SystemInfo: &compatibility.SystemInfo{OSName: "darwin", Arch: "arm64"}, + Commander: mockCommander, + HTTPClient: mockHTTP, + OsManager: mockOsManager, + Fs: mockFS, + MultiUserSystem: false, + BrewPathOverride: expectedBrewPath, + } + + installer := brew.NewBrewInstaller(opts) + err := installer.Install() + + require.NoError(t, err) + + // Verify the correct sequence of operations + require.Len(t, mockHTTP.GetCalls(), 1) + require.Len(t, mockFS.CreateTemporaryFileCalls(), 1) + require.Len(t, mockFS.WriteFileCalls(), 1) + require.Len(t, mockOsManager.SetPermissionsCalls(), 1) + require.Len(t, mockCommander.RunCommandCalls(), 2) // Installation and validation calls + require.Len(t, mockFS.RemovePathCalls(), 1) // Cleanup call +} + +/* ------------------------------------------------------------------------------------------------------------------ */ +/* Error handling tests */ +/* ------------------------------------------------------------------------------------------------------------------ */ + +func Test_SingleUserBrew_FailsInstallation_WhenDownloadingInstallationScriptFails(t *testing.T) { + tests := []struct { + name string + setupMockHTTP func() *httpclient.MoqHTTPClient + expectedErrText string + }{ + { + name: "HTTP error status", + setupMockHTTP: func() *httpclient.MoqHTTPClient { + return &httpclient.MoqHTTPClient{ + GetFunc: func(url string) (*http.Response, error) { + if url == "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewBufferString("")), + }, nil + } + return nil, fmt.Errorf("unexpected URL: %s", url) + }, + } + }, + expectedErrText: "HTTP status 404", + }, + { + name: "Network error", + setupMockHTTP: func() *httpclient.MoqHTTPClient { + return &httpclient.MoqHTTPClient{ + GetFunc: func(url string) (*http.Response, error) { + return nil, fmt.Errorf("network error: connection timeout") + }, + } + }, + expectedErrText: "network error: connection timeout", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock dependencies + mockLogger := &logger.NoopLogger{} + mockCommander := &utils.MoqCommander{} + mockFS := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + return false, nil // Brew doesn't exist + }, + } + mockHTTP := tt.setupMockHTTP() + mockOsManager := &osmanager.MoqOsManager{} + + opts := brew.Options{ + Logger: mockLogger, + SystemInfo: &compatibility.SystemInfo{OSName: "darwin", Arch: "arm64"}, + Commander: mockCommander, + HTTPClient: mockHTTP, + OsManager: mockOsManager, + Fs: mockFS, + MultiUserSystem: false, + BrewPathOverride: "/opt/homebrew/bin/brew", + } + + installer := brew.NewBrewInstaller(opts) + err := installer.Install() + + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedErrText) + require.Len(t, mockHTTP.GetCalls(), 1) // HTTP call should have been made + }) + } +} + +func Test_SingleUserBrew_FailsInstallation_WhenFailingToCreateTempFileHoldingDownloadedScript(t *testing.T) { + // Create mock dependencies + mockLogger := &logger.NoopLogger{} + mockCommander := &utils.MoqCommander{} + mockFS := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + return false, nil // Brew doesn't exist + }, + CreateTemporaryFileFunc: func(dir, pattern string) (string, error) { + return "", fmt.Errorf("disk space full") + }, + } + mockHTTP := &httpclient.MoqHTTPClient{ + GetFunc: func(url string) (*http.Response, error) { + if url == "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString("#!/bin/bash\necho 'test'")), + }, nil + } + return nil, fmt.Errorf("unexpected URL: %s", url) + }, + } + mockOsManager := &osmanager.MoqOsManager{} + + opts := brew.Options{ + Logger: mockLogger, + SystemInfo: &compatibility.SystemInfo{OSName: "darwin", Arch: "arm64"}, + Commander: mockCommander, + HTTPClient: mockHTTP, + OsManager: mockOsManager, + Fs: mockFS, + MultiUserSystem: false, + BrewPathOverride: "/opt/homebrew/bin/brew", + } + + installer := brew.NewBrewInstaller(opts) + err := installer.Install() + + require.Error(t, err) + require.Contains(t, err.Error(), "disk space full") +} + +func Test_SingleUserBrew_FailsInstallation_WhenFailingToCopyDownloadedScriptFromHttpBodyToTempFile(t *testing.T) { + tests := []struct { + name string + setupMockFS func(tempScriptPath, expectedBrewPath, installScript string) *utils.MoqFileSystem + expectedErrText string + }{ + { + name: "Write permissions error", + setupMockFS: func(tempScriptPath, expectedBrewPath, installScript string) *utils.MoqFileSystem { + return &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + // For this specific test case, brew is assumed to not exist initially to trigger download. + return false, nil + }, + CreateTemporaryFileFunc: func(dir, pattern string) (string, error) { + return tempScriptPath, nil + }, + WriteFileFunc: func(path string, reader io.Reader) (int64, error) { + return 0, fmt.Errorf("permission denied") + }, + RemovePathFunc: func(path string) error { + return nil // Cleanup should still work + }, + } + }, + expectedErrText: "permission denied", + }, + { + name: "Zero bytes written", + setupMockFS: func(tempScriptPath, expectedBrewPath, installScript string) *utils.MoqFileSystem { + return &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + // Brew should not exist to trigger download and write attempt. + return false, nil + }, + CreateTemporaryFileFunc: func(dir, pattern string) (string, error) { + return tempScriptPath, nil + }, + WriteFileFunc: func(path string, reader io.Reader) (int64, error) { + return 0, nil // Zero bytes written + }, + RemovePathFunc: func(path string) error { + return nil // Cleanup should still work + }, + } + }, + expectedErrText: "no bytes written", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expectedBrewPath := "/opt/homebrew/bin/brew" + mockLogger := &logger.NoopLogger{} + tempScriptPath := "/tmp/brew-install-12345.sh" + installScript := "#!/bin/bash\necho 'Installing Homebrew...'" + + mockCommander := &utils.MoqCommander{} + mockFS := tt.setupMockFS(tempScriptPath, expectedBrewPath, installScript) + mockHTTP := &httpclient.MoqHTTPClient{ + GetFunc: func(url string) (*http.Response, error) { + if url == "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(installScript)), + }, nil + } + return nil, fmt.Errorf("unexpected URL: %s", url) + }, + } + mockOsManager := &osmanager.MoqOsManager{} + + opts := brew.Options{ + Logger: mockLogger, + SystemInfo: &compatibility.SystemInfo{OSName: "darwin", Arch: "arm64"}, + Commander: mockCommander, + HTTPClient: mockHTTP, + OsManager: mockOsManager, + Fs: mockFS, + MultiUserSystem: false, + BrewPathOverride: expectedBrewPath, + } + + installer := brew.NewBrewInstaller(opts) + err := installer.Install() + + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedErrText) + require.Len(t, mockFS.RemovePathCalls(), 1) // Cleanup should be called + }) + } +} + +/* ------------------------------------------------------------------------------------------------------------------ */ +/* Options builder pattern tests */ +/* ------------------------------------------------------------------------------------------------------------------ */ + +func Test_DefaultOptions_CreatesNonEmptyConfiguration(t *testing.T) { + opts := brew.DefaultOptions() + + require.NotNil(t, opts.Logger) + require.NotNil(t, opts.Commander) + require.NotNil(t, opts.HTTPClient) + require.NotNil(t, opts.OsManager) + require.NotNil(t, opts.Fs) + require.False(t, opts.MultiUserSystem) + require.Nil(t, opts.SystemInfo) + require.Empty(t, opts.BrewPathOverride) +} + +func Test_OptionsBuilderPattern_ConfiguresAllOptions(t *testing.T) { + customLogger := &logger.NoopLogger{} + customSystemInfo := &compatibility.SystemInfo{OSName: "linux", Arch: "amd64"} + customCommander := &utils.MoqCommander{} + customHTTP := &httpclient.MoqHTTPClient{} + customOsManager := &osmanager.MoqOsManager{} + customFS := &utils.MoqFileSystem{} + + opts := brew.DefaultOptions(). + WithLogger(customLogger). + WithMultiUserSystem(true). + WithSystemInfo(customSystemInfo). + WithCommander(customCommander). + WithHTTPClient(customHTTP). + WithOsManager(customOsManager). + WithFileSystem(customFS) + + require.Equal(t, customLogger, opts.Logger) + require.True(t, opts.MultiUserSystem) + require.Equal(t, customSystemInfo, opts.SystemInfo) + require.Equal(t, customCommander, opts.Commander) + require.Equal(t, customHTTP, opts.HTTPClient) + require.Equal(t, customOsManager, opts.OsManager) + require.Equal(t, customFS, opts.Fs) +} + +/* ------------------------------------------------------------------------------------------------------------------ */ +/* Edge case and boundary tests */ +/* ------------------------------------------------------------------------------------------------------------------ */ + +func Test_SingleUserBrew_HandlesEmptyInstallScript(t *testing.T) { + // Create mock dependencies + mockLogger := &logger.NoopLogger{} + tempScriptPath := "/tmp/brew-install-12345.sh" + emptyScript := "" + + mockCommander := &utils.MoqCommander{} + + mockFS := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + return false, nil // Brew doesn't exist + }, + CreateTemporaryFileFunc: func(dir, pattern string) (string, error) { + return tempScriptPath, nil + }, + WriteFileFunc: func(path string, reader io.Reader) (int64, error) { + if path == tempScriptPath { + return int64(len(emptyScript)), nil // Zero bytes for empty script + } + return 0, fmt.Errorf("unexpected path: %s", path) + }, + RemovePathFunc: func(path string) error { + return nil // Cleanup should still work + }, + } + + mockHTTP := &httpclient.MoqHTTPClient{ + GetFunc: func(url string) (*http.Response, error) { + if url == "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(emptyScript)), + }, nil + } + return nil, fmt.Errorf("unexpected URL: %s", url) + }, + } + + mockOsManager := &osmanager.MoqOsManager{} + + opts := brew.Options{ + Logger: mockLogger, + SystemInfo: &compatibility.SystemInfo{OSName: "darwin", Arch: "arm64"}, + Commander: mockCommander, + HTTPClient: mockHTTP, + OsManager: mockOsManager, + Fs: mockFS, + MultiUserSystem: false, + BrewPathOverride: "/opt/homebrew/bin/brew", + } + + installer := brew.NewBrewInstaller(opts) + err := installer.Install() + + require.Error(t, err) + require.Contains(t, err.Error(), "no bytes written") + require.Len(t, mockFS.RemovePathCalls(), 1) // Cleanup should be called +} + +func Test_SingleUserBrew_CanHandleLargeInstallScript(t *testing.T) { + // Create mock dependencies with a large script + mockLogger := &logger.NoopLogger{} + tempScriptPath := "/tmp/brew-install-12345.sh" + // Create a large script (1MB) + largeScript := "#!/bin/bash\n" + string(bytes.Repeat([]byte("echo 'large script content'\n"), 50000)[:]) + + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "/bin/bash" && len(args) == 1 && args[0] == tempScriptPath { + return &utils.Result{ExitCode: 0}, nil // Installation successful + } + if name == "/opt/homebrew/bin/brew" && len(args) == 1 && args[0] == "--version" { + return &utils.Result{ExitCode: 0}, nil // Validation successful + } + return nil, fmt.Errorf("unexpected command: %s %v", name, args) + }, + } + + pathExistsCalls := 0 + mockFS := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + pathExistsCalls++ + if path == "/opt/homebrew/bin/brew" { + // First call: brew doesn't exist, second call: brew exists after install + return pathExistsCalls > 1, nil + } + if path == tempScriptPath { + return true, nil // Script exists after download + } + return false, nil + }, + CreateTemporaryFileFunc: func(dir, pattern string) (string, error) { + return tempScriptPath, nil + }, + WriteFileFunc: func(path string, reader io.Reader) (int64, error) { + if path == tempScriptPath { + return int64(len(largeScript)), nil + } + return 0, fmt.Errorf("unexpected path: %s", path) + }, + RemovePathFunc: func(path string) error { + return nil // Cleanup successful + }, + } + + mockHTTP := &httpclient.MoqHTTPClient{ + GetFunc: func(url string) (*http.Response, error) { + if url == "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(largeScript)), + }, nil + } + return nil, fmt.Errorf("unexpected URL: %s", url) + }, + } + + mockOsManager := &osmanager.MoqOsManager{ + SetPermissionsFunc: func(path string, perms os.FileMode) error { + if path == tempScriptPath && perms == 0o755 { + return nil + } + return fmt.Errorf("unexpected permission call: %s %o", path, perms) + }, + } + + opts := brew.Options{ + Logger: mockLogger, + SystemInfo: &compatibility.SystemInfo{OSName: "darwin", Arch: "arm64"}, + Commander: mockCommander, + HTTPClient: mockHTTP, + OsManager: mockOsManager, + Fs: mockFS, + MultiUserSystem: false, + BrewPathOverride: "/opt/homebrew/bin/brew", + } + + installer := brew.NewBrewInstaller(opts) + err := installer.Install() + + require.NoError(t, err) + + // Verify that large scripts are handled correctly + writeCalls := mockFS.WriteFileCalls() + require.Len(t, writeCalls, 1) + // Note: We can't easily verify the exact bytes written in this mock setup, + // but the test ensures the system can handle large scripts without errors +} + +func Test_SingleUserBrew_Install_UpdatesPath_AfterSuccessfulInstallation(t *testing.T) { + // Save original PATH to restore later + originalPath := os.Getenv("PATH") + defer func() { + os.Setenv("PATH", originalPath) + }() + + // Set a test PATH that doesn't include brew bin + testPath := "/usr/bin:/bin" + os.Setenv("PATH", testPath) + + brewPath := "/opt/homebrew/bin/brew" + expectedBrewBinDir := "/opt/homebrew/bin" + + // Mock dependencies + mockLogger := &logger.MoqLogger{ + InfoFunc: func(format string, args ...any) {}, + SuccessFunc: func(format string, args ...any) {}, + DebugFunc: func(format string, args ...any) {}, + WarningFunc: func(format string, args ...any) {}, + } + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return &utils.Result{ExitCode: 0}, nil + }, + } + mockFS := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + // Return true for the temporary install script and brew binary (after installation) + if path == "/tmp/brew-install.sh" || path == brewPath { + return true, nil + } + return false, nil + }, + CreateTemporaryFileFunc: func(dir, pattern string) (string, error) { + return "/tmp/brew-install.sh", nil + }, + WriteFileFunc: func(path string, reader io.Reader) (int64, error) { + return 100, nil + }, + RemovePathFunc: func(path string) error { + return nil + }, + } + mockHTTPClient := &httpclient.MoqHTTPClient{ + GetFunc: func(url string) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader("#!/bin/bash\necho 'installing homebrew'")), + }, nil + }, + } + mockOsManager := &osmanager.MoqOsManager{ + SetPermissionsFunc: func(path string, mode os.FileMode) error { + return nil + }, + } + + sysInfo := &compatibility.SystemInfo{OSName: "darwin", Arch: "arm64"} + opts := brew.Options{ + Logger: mockLogger, + SystemInfo: sysInfo, + Commander: mockCommander, + HTTPClient: mockHTTPClient, + OsManager: mockOsManager, + Fs: mockFS, + MultiUserSystem: false, + BrewPathOverride: brewPath, + } + + installer := brew.NewBrewInstaller(opts) + + err := installer.Install() + + require.NoError(t, err) + + // Verify PATH was updated + newPath := os.Getenv("PATH") + require.True(t, strings.HasPrefix(newPath, expectedBrewBinDir+string(os.PathListSeparator))) + require.Contains(t, newPath, testPath) +} + +func Test_DetectBrewPath_ReturnsError_WhenSystemInfoIsNil(t *testing.T) { + // Test behavior when SystemInfo is nil + _, err := brew.DetectBrewPath(nil, "") + + require.Error(t, err) + require.Contains(t, err.Error(), "system information is not provided") +} + +func Test_UpdatePathWithBrewBinaries_UpdatesPath_WhenBrewBinNotInPath(t *testing.T) { + // Save original PATH to restore later + originalPath := os.Getenv("PATH") + defer func() { + os.Setenv("PATH", originalPath) + }() + + // Set a test PATH that doesn't include brew bin + testPath := "/usr/bin:/bin" + os.Setenv("PATH", testPath) + + brewPath := "/opt/homebrew/bin/brew" + expectedBrewBinDir := "/opt/homebrew/bin" + + err := brew.UpdatePathWithBrewBinaries(brewPath) + + require.NoError(t, err) + + newPath := os.Getenv("PATH") + require.True(t, strings.HasPrefix(newPath, expectedBrewBinDir+string(os.PathListSeparator))) + require.Contains(t, newPath, testPath) +} + +func Test_UpdatePathWithBrewBinaries_DoesNotDuplicate_WhenBrewBinAlreadyInPath(t *testing.T) { + // Save original PATH to restore later + originalPath := os.Getenv("PATH") + defer func() { + os.Setenv("PATH", originalPath) + }() + + brewPath := "/opt/homebrew/bin/brew" + brewBinDir := "/opt/homebrew/bin" + + // Set PATH that already includes brew bin directory + testPath := brewBinDir + string(os.PathListSeparator) + "/usr/bin:/bin" + os.Setenv("PATH", testPath) + + err := brew.UpdatePathWithBrewBinaries(brewPath) + + require.NoError(t, err) + + newPath := os.Getenv("PATH") + require.Equal(t, testPath, newPath) // Should remain unchanged +} + +func Test_UpdatePathWithBrewBinaries_HandlesEmptyPath(t *testing.T) { + // Save original PATH to restore later + originalPath := os.Getenv("PATH") + defer func() { + os.Setenv("PATH", originalPath) + }() + + // Set empty PATH + os.Setenv("PATH", "") + + brewPath := "/usr/local/bin/brew" + expectedBrewBinDir := "/usr/local/bin" + + err := brew.UpdatePathWithBrewBinaries(brewPath) + + require.NoError(t, err) + + newPath := os.Getenv("PATH") + require.Equal(t, expectedBrewBinDir+string(os.PathListSeparator), newPath) +} + +func Test_UpdatePathWithBrewBinaries_UsesPlatformPathSeparator(t *testing.T) { + // Save original PATH to restore later + originalPath := os.Getenv("PATH") + defer func() { + os.Setenv("PATH", originalPath) + }() + + // Set a test PATH + testPath := "/usr/bin:/bin" + os.Setenv("PATH", testPath) + + brewPath := "/opt/homebrew/bin/brew" + expectedBrewBinDir := "/opt/homebrew/bin" + + err := brew.UpdatePathWithBrewBinaries(brewPath) + + require.NoError(t, err) + + newPath := os.Getenv("PATH") + expectedPath := expectedBrewBinDir + string(os.PathListSeparator) + testPath + require.Equal(t, expectedPath, newPath) +} + +/* ------------------------------------------------------------------------------------------------------------------ */ +/* Multi-User Brew Installer Tests */ +/* ------------------------------------------------------------------------------------------------------------------ */ + +func Test_MultiUserBrew_ReportsAvailable_WhenBrewExistsForBrewUser(t *testing.T) { + expectedBrewPath := "/home/linuxbrew/.linuxbrew/bin/brew" + + // Create mock dependencies + mockLogger := &logger.NoopLogger{} + mockCommander := &utils.MoqCommander{} + mockFS := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + if path == expectedBrewPath { + return true, nil // Simulate that brew exists for the brew user + } + return false, nil // Other paths do not exist + }, + } + mockHTTP := &httpclient.MoqHTTPClient{} + mockOsManager := &osmanager.MoqOsManager{ + GetFileOwnerFunc: func(path string) (string, error) { + if path == expectedBrewPath { + return brew.BrewUserOnMultiUserSystem, nil // Simulate that the brew user owns the brew binary + } + return "", fmt.Errorf("unexpected path: %s", path) + }, + } + + opts := brew.Options{ + Logger: mockLogger, + SystemInfo: &compatibility.SystemInfo{OSName: "linux", Arch: "amd64"}, + Commander: mockCommander, + HTTPClient: mockHTTP, + OsManager: mockOsManager, + Fs: mockFS, + MultiUserSystem: true, + BrewPathOverride: expectedBrewPath, + } + + installer := brew.NewBrewInstaller(opts) + available, err := installer.IsAvailable() + + require.NoError(t, err) + require.True(t, available) +} + +func Test_MultiUserBrew_ReportsUnavailable_WhenBrewDoesNotExistForBrewUser(t *testing.T) { + expectedBrewPath := "/home/linuxbrew/.linuxbrew/bin/brew" + + // Create mock dependencies + mockLogger := &logger.NoopLogger{} + mockCommander := &utils.MoqCommander{} + mockFS := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + if path == expectedBrewPath { + return true, nil // Simulate that brew path exists + } + return true, nil // Other paths exist + }, + } + mockHTTP := &httpclient.MoqHTTPClient{} + mockOsManager := &osmanager.MoqOsManager{ + GetFileOwnerFunc: func(path string) (string, error) { + if path == expectedBrewPath { + return "someotheruser", nil // Simulate that the brew binary is owned by a different user + } + return "", fmt.Errorf("unexpected path: %s", path) + }, + } + + opts := brew.Options{ + Logger: mockLogger, + SystemInfo: &compatibility.SystemInfo{OSName: "linux", Arch: "amd64"}, + Commander: mockCommander, + HTTPClient: mockHTTP, + OsManager: mockOsManager, + Fs: mockFS, + MultiUserSystem: true, + BrewPathOverride: expectedBrewPath, + } + + installer := brew.NewBrewInstaller(opts) + available, err := installer.IsAvailable() + + require.NoError(t, err) + require.False(t, available) +} + +func Test_MultiUserBrew_ReportedUnavailable_WhenBrewPathDoesNotExist(t *testing.T) { + expectedBrewPath := "/nonexistent/brew" + + // Create mock dependencies + mockLogger := &logger.NoopLogger{} + mockCommander := &utils.MoqCommander{} + mockFS := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + if path == expectedBrewPath { + return false, nil // Simulate that brew does not exist + } + return true, nil // Other paths exist + }, + } + mockHTTP := &httpclient.MoqHTTPClient{} + mockOsManager := &osmanager.MoqOsManager{} + + opts := brew.Options{ + Logger: mockLogger, + SystemInfo: &compatibility.SystemInfo{OSName: "linux", Arch: "amd64"}, + Commander: mockCommander, + HTTPClient: mockHTTP, + OsManager: mockOsManager, + Fs: mockFS, + MultiUserSystem: true, + BrewPathOverride: expectedBrewPath, + } + + installer := brew.NewBrewInstaller(opts) + available, err := installer.IsAvailable() + + require.NoError(t, err) // Multi-user installation should error on non-existent path + require.False(t, available) // Brew should be reported as unavailable +} + +func Test_MultiUserBrew_DoesNotReinstall_WhenAlreadyAvailable(t *testing.T) { + expectedBrewPath := "/home/linuxbrew/.linuxbrew/bin/brew" + + // Create mock dependencies + mockLogger := &logger.NoopLogger{} + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == expectedBrewPath && len(args) == 1 && args[0] == "--version" { + return &utils.Result{ExitCode: 0}, nil // Validation successful + } + return nil, fmt.Errorf("unexpected command: %s %v", name, args) + }, + } + mockFS := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + if path == expectedBrewPath { + return true, nil // Simulate that brew exists for the brew user + } + return false, nil // Other paths do not exist + }, + } + mockHTTP := &httpclient.MoqHTTPClient{} + mockOsManager := &osmanager.MoqOsManager{ + AddUserFunc: func(username string) error { + if username == brew.BrewUserOnMultiUserSystem { + return nil // Simulate successful user addition + } + return fmt.Errorf("unexpected user: %s", username) + }, + UserExistsFunc: func(username string) (bool, error) { + if username == brew.BrewUserOnMultiUserSystem { + return true, nil // Simulate that the brew user exists + } + return false, nil // Other users do not exist + }, + GetFileOwnerFunc: func(path string) (string, error) { + if path == expectedBrewPath { + return brew.BrewUserOnMultiUserSystem, nil // Simulate that the brew user owns the brew binary + } + return "", fmt.Errorf("unexpected path: %s", path) + }, + } + + opts := brew.Options{ + Logger: mockLogger, + SystemInfo: &compatibility.SystemInfo{OSName: "linux", Arch: "amd64"}, + Commander: mockCommander, + HTTPClient: mockHTTP, + OsManager: mockOsManager, + Fs: mockFS, + MultiUserSystem: true, + BrewPathOverride: expectedBrewPath, + } + + installer := brew.NewBrewInstaller(opts) + err := installer.Install() + + require.NoError(t, err) + + // Verify no HTTP requests were made (no download should happen) + require.Empty(t, mockHTTP.GetCalls()) + // Verify no user management operations were performed + require.Empty(t, mockOsManager.UserExistsCalls()) + require.Empty(t, mockOsManager.AddUserCalls()) +} + +//gocognit:ignore +//nolint:cyclop // This test is complex due to the multi-user setup and various checks involved. +func Test_MultiUserBrew_InstallsFromScratch_WhenUserDoesNotExist(t *testing.T) { + expectedBrewPath := "/home/linuxbrew/.linuxbrew/bin/brew" + tempScriptPath := "/tmp/brew-install-12345.sh" + installScript := "#!/bin/bash\necho 'Installing Homebrew...'" + + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "sudo" && len(args) == 4 && args[0] == "-Hu" && + args[1] == brew.BrewUserOnMultiUserSystem && + args[2] == "bash" && args[3] == tempScriptPath { + return &utils.Result{ExitCode: 0}, nil + } + if name == expectedBrewPath && len(args) == 1 && args[0] == "--version" { + return &utils.Result{ExitCode: 0}, nil + } + return nil, fmt.Errorf("unexpected command: %s %v", name, args) + }, + } + + pathExistsCalls := 0 + mockFS := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + pathExistsCalls++ + if path == expectedBrewPath { + return pathExistsCalls > 1, nil + } + if path == tempScriptPath { + return true, nil + } + return false, nil + }, + CreateTemporaryFileFunc: func(dir, pattern string) (string, error) { + return tempScriptPath, nil + }, + WriteFileFunc: func(path string, reader io.Reader) (int64, error) { + if path == tempScriptPath { + return int64(len(installScript)), nil + } + return 0, fmt.Errorf("unexpected path: %s", path) + }, + RemovePathFunc: func(path string) error { + return nil + }, + } + + mockHTTP := &httpclient.MoqHTTPClient{ + GetFunc: func(url string) (*http.Response, error) { + if url == "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(installScript)), + }, nil + } + return nil, fmt.Errorf("unexpected URL: %s", url) + }, + } + + mockOsManager := &osmanager.MoqOsManager{ + UserExistsFunc: func(username string) (bool, error) { + if username == brew.BrewUserOnMultiUserSystem { + return false, nil // User does not exist + } + return false, fmt.Errorf("unexpected user check: %s", username) + }, + AddUserFunc: func(username string) error { + if username == brew.BrewUserOnMultiUserSystem { + return nil + } + return fmt.Errorf("unexpected user add: %s", username) + }, + AddUserToGroupFunc: func(username, group string) error { + if username == brew.BrewUserOnMultiUserSystem && group == "sudo" { + return nil + } + return fmt.Errorf("unexpected user/group add: %s/%s", username, group) + }, + AddSudoAccessFunc: func(username string) error { + if username == brew.BrewUserOnMultiUserSystem { + return nil + } + return fmt.Errorf("unexpected sudo access for: %s", username) + }, + SetOwnershipFunc: func(path, username string) error { + if path == "/home/linuxbrew" && username == brew.BrewUserOnMultiUserSystem { + return nil + } + return fmt.Errorf("unexpected ownership set: %s for %s", path, username) + }, + SetPermissionsFunc: func(path string, perms os.FileMode) error { + if path == tempScriptPath && perms == 0o755 { + return nil + } + return fmt.Errorf("unexpected permission call: %s %o", path, perms) + }, + GetFileOwnerFunc: func(path string) (string, error) { + if path == expectedBrewPath { + return brew.BrewUserOnMultiUserSystem, nil + } + return "", fmt.Errorf("unexpected get owner for: %s", path) + }, + } + + opts := brew.Options{ + Logger: &logger.NoopLogger{}, + SystemInfo: &compatibility.SystemInfo{OSName: "linux", Arch: "amd64"}, + Commander: mockCommander, + HTTPClient: mockHTTP, + OsManager: mockOsManager, + Fs: mockFS, + MultiUserSystem: true, + BrewPathOverride: expectedBrewPath, + } + + installer := brew.NewBrewInstaller(opts) + err := installer.Install() + + require.NoError(t, err) + require.Len(t, mockOsManager.UserExistsCalls(), 1) + require.Len(t, mockOsManager.AddUserCalls(), 1) // User should be added + require.Len(t, mockOsManager.AddUserToGroupCalls(), 1) + require.Len(t, mockOsManager.AddSudoAccessCalls(), 1) + require.Len(t, mockOsManager.SetOwnershipCalls(), 1) + require.Len(t, mockHTTP.GetCalls(), 1) + require.Len(t, mockFS.CreateTemporaryFileCalls(), 1) + require.Len(t, mockFS.WriteFileCalls(), 1) + require.Len(t, mockOsManager.SetPermissionsCalls(), 1) + require.Len(t, mockCommander.RunCommandCalls(), 2) + require.Len(t, mockFS.RemovePathCalls(), 1) +} + +//nolint:cyclop // This test is complex due to the multi-user setup and various checks involved. +func Test_MultiUserBrew_InstallsFromScratch_WhenUserAlreadyExists(t *testing.T) { + expectedBrewPath := "/home/linuxbrew/.linuxbrew/bin/brew" + tempScriptPath := "/tmp/brew-install-12345.sh" + installScript := "#!/bin/bash\necho 'Installing Homebrew...'" + + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "sudo" && len(args) == 4 && args[0] == "-Hu" && + args[1] == brew.BrewUserOnMultiUserSystem && + args[2] == "bash" && args[3] == tempScriptPath { + return &utils.Result{ExitCode: 0}, nil + } + if name == expectedBrewPath && len(args) == 1 && args[0] == "--version" { + return &utils.Result{ExitCode: 0}, nil + } + return nil, fmt.Errorf("unexpected command: %s %v", name, args) + }, + } + + pathExistsCalls := 0 + mockFS := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + pathExistsCalls++ + if path == expectedBrewPath { + return pathExistsCalls > 1, nil + } + if path == tempScriptPath { + return true, nil + } + return false, nil + }, + CreateTemporaryFileFunc: func(dir, pattern string) (string, error) { + return tempScriptPath, nil + }, + WriteFileFunc: func(path string, reader io.Reader) (int64, error) { + if path == tempScriptPath { + return int64(len(installScript)), nil + } + return 0, fmt.Errorf("unexpected path: %s", path) + }, + RemovePathFunc: func(path string) error { + return nil + }, + } + + mockHTTP := &httpclient.MoqHTTPClient{ + GetFunc: func(url string) (*http.Response, error) { + if url == "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(installScript)), + }, nil + } + return nil, fmt.Errorf("unexpected URL: %s", url) + }, + } + + mockOsManager := &osmanager.MoqOsManager{ + UserExistsFunc: func(username string) (bool, error) { + if username == brew.BrewUserOnMultiUserSystem { + return true, nil // User already exists + } + return false, fmt.Errorf("unexpected user check: %s", username) + }, + AddUserFunc: func(username string) error { + return fmt.Errorf("unexpected user add: %s", username) // Should not be called + }, + AddUserToGroupFunc: func(username, group string) error { + if username == brew.BrewUserOnMultiUserSystem && group == "sudo" { + return nil + } + return fmt.Errorf("unexpected user/group add: %s/%s", username, group) + }, + AddSudoAccessFunc: func(username string) error { + if username == brew.BrewUserOnMultiUserSystem { + return nil + } + return fmt.Errorf("unexpected sudo access for: %s", username) + }, + SetOwnershipFunc: func(path, username string) error { + if path == "/home/linuxbrew" && username == brew.BrewUserOnMultiUserSystem { + return nil + } + return fmt.Errorf("unexpected ownership set: %s for %s", path, username) + }, + SetPermissionsFunc: func(path string, perms os.FileMode) error { + if path == tempScriptPath && perms == 0o755 { + return nil + } + return fmt.Errorf("unexpected permission call: %s %o", path, perms) + }, + GetFileOwnerFunc: func(path string) (string, error) { + if path == expectedBrewPath { + return brew.BrewUserOnMultiUserSystem, nil + } + return "", fmt.Errorf("unexpected get owner for: %s", path) + }, + } + + opts := brew.Options{ + Logger: &logger.NoopLogger{}, + SystemInfo: &compatibility.SystemInfo{OSName: "linux", Arch: "amd64"}, + Commander: mockCommander, + HTTPClient: mockHTTP, + OsManager: mockOsManager, + Fs: mockFS, + MultiUserSystem: true, + BrewPathOverride: expectedBrewPath, + } + + installer := brew.NewBrewInstaller(opts) + err := installer.Install() + + require.NoError(t, err) + require.Len(t, mockOsManager.UserExistsCalls(), 1) + require.Empty(t, mockOsManager.AddUserCalls()) // User should not be added + require.Len(t, mockOsManager.AddUserToGroupCalls(), 1) + require.Len(t, mockOsManager.AddSudoAccessCalls(), 1) + require.Len(t, mockOsManager.SetOwnershipCalls(), 1) + require.Len(t, mockHTTP.GetCalls(), 1) + require.Len(t, mockFS.CreateTemporaryFileCalls(), 1) + require.Len(t, mockFS.WriteFileCalls(), 1) + require.Len(t, mockOsManager.SetPermissionsCalls(), 1) + require.Len(t, mockCommander.RunCommandCalls(), 2) + require.Len(t, mockFS.RemovePathCalls(), 1) +} + +func Test_SingleUserBrew_Install_DiscardsOutput_WhenDisplayModeIsNotPassthrough(t *testing.T) { + mockLogger := &logger.NoopLogger{} + tempScriptPath := "/tmp/brew-install-12345.sh" + installScript := "#!/bin/bash\necho 'Installing Homebrew...'" + + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + cmdOptions := utils.Options{ + Stdout: os.Stdout, + Stderr: os.Stderr, + } + + // Apply all provided options + for _, opt := range opts { + opt(&cmdOptions) + } + + if name == "/bin/bash" && len(args) == 1 && args[0] == tempScriptPath { + // Verify that output was discarded (stdout/stderr should be different from original) + require.NotEqual(t, os.Stdout, cmdOptions.Stdout) + require.NotEqual(t, os.Stderr, cmdOptions.Stderr) + return &utils.Result{ExitCode: 0}, nil + } + if strings.Contains(name, "brew") && len(args) == 1 && args[0] == "--version" { + // Verify that validation command also discards output + require.NotEqual(t, os.Stdout, cmdOptions.Stdout) + require.NotEqual(t, os.Stderr, cmdOptions.Stderr) + return &utils.Result{ExitCode: 0}, nil + } + return nil, fmt.Errorf("unexpected command: %s %v", name, args) + }, + } + + expectedBrewPath := "/opt/homebrew/bin/brew" + + pathExistsCalls := 0 + mockFS := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + pathExistsCalls++ + if path == expectedBrewPath { + // First call: brew doesn't exist, second call: brew exists after install + return pathExistsCalls > 1, nil + } + if path == tempScriptPath { + return true, nil // Script exists + } + return false, nil + }, + CreateTemporaryFileFunc: func(dir, pattern string) (string, error) { + return tempScriptPath, nil + }, + WriteFileFunc: func(path string, reader io.Reader) (int64, error) { + require.Equal(t, tempScriptPath, path) + return int64(len(installScript)), nil + }, + RemovePathFunc: func(path string) error { + require.Equal(t, tempScriptPath, path) + return nil + }, + } + + mockHTTPClient := &httpclient.MoqHTTPClient{ + GetFunc: func(url string) (*http.Response, error) { + resp := &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(installScript)), + } + return resp, nil + }, + } + + mockOsManager := &osmanager.MoqOsManager{ + SetPermissionsFunc: func(path string, mode os.FileMode) error { + require.Equal(t, tempScriptPath, path) + return nil + }, + } + + opts := brew.Options{ + Logger: mockLogger, + SystemInfo: &compatibility.SystemInfo{OSName: "darwin", Arch: "arm64"}, + Commander: mockCommander, + HTTPClient: mockHTTPClient, + OsManager: mockOsManager, + Fs: mockFS, + MultiUserSystem: false, + DisplayMode: utils.DisplayModeProgress, // Non-passthrough mode + } + + installer := brew.NewBrewInstaller(opts) + err := installer.Install() + + require.NoError(t, err) +} + +func Test_SingleUserBrew_Install_DoesNotDiscardOutput_WhenDisplayModeIsPassthrough(t *testing.T) { + mockLogger := &logger.NoopLogger{} + tempScriptPath := "/tmp/brew-install-12345.sh" + installScript := "#!/bin/bash\necho 'Installing Homebrew...'" + + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + cmdOptions := utils.Options{ + Stdout: os.Stdout, + Stderr: os.Stderr, + } + + // Apply all provided options + for _, opt := range opts { + opt(&cmdOptions) + } + + if name == "/bin/bash" && len(args) == 1 && args[0] == tempScriptPath { + // Verify that output was not discarded (stdout/stderr should remain unchanged) + require.Equal(t, os.Stdout, cmdOptions.Stdout) + require.Equal(t, os.Stderr, cmdOptions.Stderr) + return &utils.Result{ExitCode: 0}, nil + } + if strings.Contains(name, "brew") && len(args) == 1 && args[0] == "--version" { + // Verify that validation command also preserves output + require.Equal(t, os.Stdout, cmdOptions.Stdout) + require.Equal(t, os.Stderr, cmdOptions.Stderr) + return &utils.Result{ExitCode: 0}, nil + } + return nil, fmt.Errorf("unexpected command: %s %v", name, args) + }, + } + + expectedBrewPath := "/opt/homebrew/bin/brew" + + pathExistsCalls := 0 + mockFS := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + pathExistsCalls++ + if path == expectedBrewPath { + // First call: brew doesn't exist, second call: brew exists after install + return pathExistsCalls > 1, nil + } + if path == tempScriptPath { + return true, nil // Script exists + } + return false, nil + }, + CreateTemporaryFileFunc: func(dir, pattern string) (string, error) { + return tempScriptPath, nil + }, + WriteFileFunc: func(path string, reader io.Reader) (int64, error) { + require.Equal(t, tempScriptPath, path) + return int64(len(installScript)), nil + }, + RemovePathFunc: func(path string) error { + require.Equal(t, tempScriptPath, path) + return nil + }, + } + + mockHTTPClient := &httpclient.MoqHTTPClient{ + GetFunc: func(url string) (*http.Response, error) { + resp := &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(installScript)), + } + return resp, nil + }, + } + + mockOsManager := &osmanager.MoqOsManager{ + SetPermissionsFunc: func(path string, mode os.FileMode) error { + require.Equal(t, tempScriptPath, path) + return nil + }, + } + + opts := brew.Options{ + Logger: mockLogger, + SystemInfo: &compatibility.SystemInfo{OSName: "darwin", Arch: "arm64"}, + Commander: mockCommander, + HTTPClient: mockHTTPClient, + OsManager: mockOsManager, + Fs: mockFS, + MultiUserSystem: false, + DisplayMode: utils.DisplayModePassthrough, // Passthrough mode + } + + installer := brew.NewBrewInstaller(opts) + err := installer.Install() + + require.NoError(t, err) +} diff --git a/installer/lib/brew/integration_test.go b/installer/lib/brew/integration_test.go new file mode 100644 index 0000000..00f75a4 --- /dev/null +++ b/installer/lib/brew/integration_test.go @@ -0,0 +1,61 @@ +package brew_test + +import ( + "os" + "runtime" + "testing" + + "github.com/MrPointer/dotfiles/installer/lib/brew" + "github.com/MrPointer/dotfiles/installer/lib/compatibility" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_BrewReportedAsUnavailable_WhenNotInstalled(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + // Arrange + opts := brew.DefaultOptions(). + WithLogger(logger.DefaultLogger). + WithSystemInfo(&compatibility.SystemInfo{OSName: runtime.GOOS, Arch: runtime.GOARCH}). + WithBrewPathOverride("/tmp/nonexistent-brew-binary") + + installer := brew.NewBrewInstaller(*opts) + + // Act + available, err := installer.IsAvailable() + + // Assert + require.NoError(t, err) + assert.False(t, available, "expected brew to be unavailable when not installed") +} + +func Test_BrewReportedAsAvailable_WhenInstalled(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + // Arrange + tempFile, err := os.CreateTemp("", "brew-binary-*") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer os.Remove(tempFile.Name()) + + opts := brew.DefaultOptions(). + WithLogger(logger.DefaultLogger). + WithSystemInfo(&compatibility.SystemInfo{OSName: runtime.GOOS, Arch: runtime.GOARCH}). + WithBrewPathOverride(tempFile.Name()) + + installer := brew.NewBrewInstaller(*opts) + + // Act + available, err := installer.IsAvailable() + + // Assert + require.NoError(t, err) + assert.True(t, available, "expected brew to be available when installed") +} diff --git a/installer/lib/compatibility/OSDetector_mock.go b/installer/lib/compatibility/OSDetector_mock.go new file mode 100644 index 0000000..60aa322 --- /dev/null +++ b/installer/lib/compatibility/OSDetector_mock.go @@ -0,0 +1,142 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package compatibility + +import ( + "sync" +) + +// Ensure that MoqOSDetector does implement OSDetector. +// If this is not the case, regenerate this file with mockery. +var _ OSDetector = &MoqOSDetector{} + +// MoqOSDetector is a mock implementation of OSDetector. +// +// func TestSomethingThatUsesOSDetector(t *testing.T) { +// +// // make and configure a mocked OSDetector +// mockedOSDetector := &MoqOSDetector{ +// DetectSystemFunc: func() (SystemInfo, error) { +// panic("mock out the DetectSystem method") +// }, +// GetDistroNameFunc: func() string { +// panic("mock out the GetDistroName method") +// }, +// GetOSNameFunc: func() string { +// panic("mock out the GetOSName method") +// }, +// } +// +// // use mockedOSDetector in code that requires OSDetector +// // and then make assertions. +// +// } +type MoqOSDetector struct { + // DetectSystemFunc mocks the DetectSystem method. + DetectSystemFunc func() (SystemInfo, error) + + // GetDistroNameFunc mocks the GetDistroName method. + GetDistroNameFunc func() string + + // GetOSNameFunc mocks the GetOSName method. + GetOSNameFunc func() string + + // calls tracks calls to the methods. + calls struct { + // DetectSystem holds details about calls to the DetectSystem method. + DetectSystem []struct { + } + // GetDistroName holds details about calls to the GetDistroName method. + GetDistroName []struct { + } + // GetOSName holds details about calls to the GetOSName method. + GetOSName []struct { + } + } + lockDetectSystem sync.RWMutex + lockGetDistroName sync.RWMutex + lockGetOSName sync.RWMutex +} + +// DetectSystem calls DetectSystemFunc. +func (mock *MoqOSDetector) DetectSystem() (SystemInfo, error) { + if mock.DetectSystemFunc == nil { + panic("MoqOSDetector.DetectSystemFunc: method is nil but OSDetector.DetectSystem was just called") + } + callInfo := struct { + }{} + mock.lockDetectSystem.Lock() + mock.calls.DetectSystem = append(mock.calls.DetectSystem, callInfo) + mock.lockDetectSystem.Unlock() + return mock.DetectSystemFunc() +} + +// DetectSystemCalls gets all the calls that were made to DetectSystem. +// Check the length with: +// +// len(mockedOSDetector.DetectSystemCalls()) +func (mock *MoqOSDetector) DetectSystemCalls() []struct { +} { + var calls []struct { + } + mock.lockDetectSystem.RLock() + calls = mock.calls.DetectSystem + mock.lockDetectSystem.RUnlock() + return calls +} + +// GetDistroName calls GetDistroNameFunc. +func (mock *MoqOSDetector) GetDistroName() string { + if mock.GetDistroNameFunc == nil { + panic("MoqOSDetector.GetDistroNameFunc: method is nil but OSDetector.GetDistroName was just called") + } + callInfo := struct { + }{} + mock.lockGetDistroName.Lock() + mock.calls.GetDistroName = append(mock.calls.GetDistroName, callInfo) + mock.lockGetDistroName.Unlock() + return mock.GetDistroNameFunc() +} + +// GetDistroNameCalls gets all the calls that were made to GetDistroName. +// Check the length with: +// +// len(mockedOSDetector.GetDistroNameCalls()) +func (mock *MoqOSDetector) GetDistroNameCalls() []struct { +} { + var calls []struct { + } + mock.lockGetDistroName.RLock() + calls = mock.calls.GetDistroName + mock.lockGetDistroName.RUnlock() + return calls +} + +// GetOSName calls GetOSNameFunc. +func (mock *MoqOSDetector) GetOSName() string { + if mock.GetOSNameFunc == nil { + panic("MoqOSDetector.GetOSNameFunc: method is nil but OSDetector.GetOSName was just called") + } + callInfo := struct { + }{} + mock.lockGetOSName.Lock() + mock.calls.GetOSName = append(mock.calls.GetOSName, callInfo) + mock.lockGetOSName.Unlock() + return mock.GetOSNameFunc() +} + +// GetOSNameCalls gets all the calls that were made to GetOSName. +// Check the length with: +// +// len(mockedOSDetector.GetOSNameCalls()) +func (mock *MoqOSDetector) GetOSNameCalls() []struct { +} { + var calls []struct { + } + mock.lockGetOSName.RLock() + calls = mock.calls.GetOSName + mock.lockGetOSName.RUnlock() + return calls +} diff --git a/installer/lib/compatibility/PrerequisiteChecker_mock.go b/installer/lib/compatibility/PrerequisiteChecker_mock.go new file mode 100644 index 0000000..5556ead --- /dev/null +++ b/installer/lib/compatibility/PrerequisiteChecker_mock.go @@ -0,0 +1,75 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package compatibility + +import ( + "sync" +) + +// Ensure that MoqPrerequisiteChecker does implement PrerequisiteChecker. +// If this is not the case, regenerate this file with mockery. +var _ PrerequisiteChecker = &MoqPrerequisiteChecker{} + +// MoqPrerequisiteChecker is a mock implementation of PrerequisiteChecker. +// +// func TestSomethingThatUsesPrerequisiteChecker(t *testing.T) { +// +// // make and configure a mocked PrerequisiteChecker +// mockedPrerequisiteChecker := &MoqPrerequisiteChecker{ +// CheckPrerequisitesFunc: func(config map[string]PrerequisiteConfig) (PrerequisiteStatus, error) { +// panic("mock out the CheckPrerequisites method") +// }, +// } +// +// // use mockedPrerequisiteChecker in code that requires PrerequisiteChecker +// // and then make assertions. +// +// } +type MoqPrerequisiteChecker struct { + // CheckPrerequisitesFunc mocks the CheckPrerequisites method. + CheckPrerequisitesFunc func(config map[string]PrerequisiteConfig) (PrerequisiteStatus, error) + + // calls tracks calls to the methods. + calls struct { + // CheckPrerequisites holds details about calls to the CheckPrerequisites method. + CheckPrerequisites []struct { + // Config is the config argument value. + Config map[string]PrerequisiteConfig + } + } + lockCheckPrerequisites sync.RWMutex +} + +// CheckPrerequisites calls CheckPrerequisitesFunc. +func (mock *MoqPrerequisiteChecker) CheckPrerequisites(config map[string]PrerequisiteConfig) (PrerequisiteStatus, error) { + if mock.CheckPrerequisitesFunc == nil { + panic("MoqPrerequisiteChecker.CheckPrerequisitesFunc: method is nil but PrerequisiteChecker.CheckPrerequisites was just called") + } + callInfo := struct { + Config map[string]PrerequisiteConfig + }{ + Config: config, + } + mock.lockCheckPrerequisites.Lock() + mock.calls.CheckPrerequisites = append(mock.calls.CheckPrerequisites, callInfo) + mock.lockCheckPrerequisites.Unlock() + return mock.CheckPrerequisitesFunc(config) +} + +// CheckPrerequisitesCalls gets all the calls that were made to CheckPrerequisites. +// Check the length with: +// +// len(mockedPrerequisiteChecker.CheckPrerequisitesCalls()) +func (mock *MoqPrerequisiteChecker) CheckPrerequisitesCalls() []struct { + Config map[string]PrerequisiteConfig +} { + var calls []struct { + Config map[string]PrerequisiteConfig + } + mock.lockCheckPrerequisites.RLock() + calls = mock.calls.CheckPrerequisites + mock.lockCheckPrerequisites.RUnlock() + return calls +} diff --git a/installer/lib/compatibility/check.go b/installer/lib/compatibility/check.go new file mode 100644 index 0000000..855a251 --- /dev/null +++ b/installer/lib/compatibility/check.go @@ -0,0 +1,320 @@ +package compatibility + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strings" + + "github.com/MrPointer/dotfiles/installer/utils/osmanager" +) + +// SystemInfo contains information about the detected system. +type SystemInfo struct { + OSName string // Operating system name (e.g., "linux", "darwin"). + DistroName string // Linux distribution name (e.g., "ubuntu", "debian"). + Arch string // Architecture (e.g., "amd64", "arm64"). + Prerequisites PrerequisiteStatus // Status of system prerequisites. +} + +// PrerequisiteStatus contains the status of system prerequisites. +type PrerequisiteStatus struct { + Available []string // List of available prerequisites. + Missing []string // List of missing prerequisites. + Details map[string]PrerequisiteDetail // Detailed status for each prerequisite. +} + +// PrerequisiteDetail contains detailed information about a prerequisite. +type PrerequisiteDetail struct { + Name string // Name of the prerequisite. + Available bool // Whether the prerequisite is available. + Command string // Command used to check availability. + Description string // Human-readable description. + InstallHint string // Hint for installing the prerequisite. +} + +// OSDetector provides operating system detection capabilities. +type OSDetector interface { + GetOSName() string + GetDistroName() string + DetectSystem() (SystemInfo, error) +} + +// DefaultOSDetector uses runtime and file system to detect OS information. +type DefaultOSDetector struct{} + +// Ensure that DefaultOSDetector implements OSDetector. +var _ OSDetector = (*DefaultOSDetector)(nil) + +// NewDefaultOSDetector creates a new DefaultOSDetector. +func NewDefaultOSDetector() *DefaultOSDetector { + return &DefaultOSDetector{} +} + +// GetOSName returns the current operating system name. +func (d *DefaultOSDetector) GetOSName() string { + return runtime.GOOS +} + +// GetDistroName returns the current Linux distribution name. +func (d *DefaultOSDetector) GetDistroName() string { + return getLinuxDistro() +} + +// DetectSystem detects the current system information. +func (d *DefaultOSDetector) DetectSystem() (SystemInfo, error) { + osName := d.GetOSName() + var distroName string + if osName == "linux" { + distroName = d.GetDistroName() + } else if osName == "darwin" { + distroName = "mac" + } + + return SystemInfo{ + OSName: osName, + DistroName: distroName, + Arch: runtime.GOARCH, + Prerequisites: PrerequisiteStatus{}, // Will be populated by compatibility check + }, nil +} + +// PrerequisiteChecker provides prerequisite checking capabilities. +type PrerequisiteChecker interface { + CheckPrerequisites(config map[string]PrerequisiteConfig) (PrerequisiteStatus, error) +} + +// DefaultPrerequisiteChecker uses ProgramQuery to check for prerequisites. +type DefaultPrerequisiteChecker struct { + programQuery osmanager.ProgramQuery +} + +// Ensure that DefaultPrerequisiteChecker implements PrerequisiteChecker. +var _ PrerequisiteChecker = (*DefaultPrerequisiteChecker)(nil) + +// NewDefaultPrerequisiteChecker creates a new DefaultPrerequisiteChecker with the provided ProgramQuery. +func NewDefaultPrerequisiteChecker(programQuery osmanager.ProgramQuery) *DefaultPrerequisiteChecker { + return &DefaultPrerequisiteChecker{ + programQuery: programQuery, + } +} + +// CheckPrerequisites checks if required prerequisites are available on the system. +func (d *DefaultPrerequisiteChecker) CheckPrerequisites(config map[string]PrerequisiteConfig) (PrerequisiteStatus, error) { + status := PrerequisiteStatus{ + Available: make([]string, 0), + Missing: make([]string, 0), + Details: make(map[string]PrerequisiteDetail), + } + + for name, prereqConfig := range config { + detail := PrerequisiteDetail{ + Name: name, + Command: prereqConfig.Command, + Description: prereqConfig.Description, + InstallHint: prereqConfig.InstallHint, + } + + // Check if the command is available + exists, err := d.programQuery.ProgramExists(prereqConfig.Command) + if err != nil { + detail.Available = false + status.Missing = append(status.Missing, name) + } else if exists { + detail.Available = true + status.Available = append(status.Available, name) + } else { + detail.Available = false + status.Missing = append(status.Missing, name) + } + + status.Details[name] = detail + } + + // Return error if prerequisites are missing + if len(status.Missing) > 0 { + return status, fmt.Errorf("missing prerequisites: %v", status.Missing) + } + + return status, nil +} + +// CompatibilityConfig represents the structure of the compatibility.yaml file. +type CompatibilityConfig struct { + OperatingSystems map[string]OSConfig `mapstructure:"operatingSystems"` +} + +// PrerequisiteConfig represents configuration for a system prerequisite. +type PrerequisiteConfig struct { + Name string `mapstructure:"name"` + Command string `mapstructure:"command"` + Description string `mapstructure:"description"` + InstallHint string `mapstructure:"install_hint"` +} + +// OSConfig represents configuration for an operating system. +type OSConfig struct { + Supported bool `mapstructure:"supported"` + Notes string `mapstructure:"notes,omitempty"` + Prerequisites []PrerequisiteConfig `mapstructure:"prerequisites,omitempty"` + Distributions map[string]DistroConfig `mapstructure:"distributions,omitempty"` +} + +// DistroConfig represents configuration for a Linux distribution. +type DistroConfig struct { + Supported bool `mapstructure:"supported"` + VersionConstraint string `mapstructure:"version_constraint,omitempty"` + Notes string `mapstructure:"notes,omitempty"` + Prerequisites []PrerequisiteConfig `mapstructure:"prerequisites,omitempty"` +} + +// CheckCompatibility checks if the current system is compatible. +func CheckCompatibility(config *CompatibilityConfig, programQuery osmanager.ProgramQuery) (SystemInfo, error) { + if config == nil { + return SystemInfo{}, fmt.Errorf("compatibility configuration is nil") + } + + detector := NewDefaultOSDetector() + prereqChecker := NewDefaultPrerequisiteChecker(programQuery) + return CheckCompatibilityWithDetectors(config, detector, prereqChecker) +} + +// CheckCompatibilityWithDetector checks compatibility using the provided detector. +// Deprecated: Use CheckCompatibilityWithDetectors instead. +func CheckCompatibilityWithDetector(config *CompatibilityConfig, detector OSDetector, programQuery osmanager.ProgramQuery) (SystemInfo, error) { + prereqChecker := NewDefaultPrerequisiteChecker(programQuery) + return CheckCompatibilityWithDetectors(config, detector, prereqChecker) +} + +// CheckCompatibilityWithDetectors checks compatibility using the provided detectors. +func CheckCompatibilityWithDetectors(config *CompatibilityConfig, + detector OSDetector, + prereqChecker PrerequisiteChecker) (SystemInfo, error) { + if config == nil { + return SystemInfo{}, fmt.Errorf("compatibility configuration is nil") + } + + // Detect system information + sysInfo, err := detector.DetectSystem() + if err != nil { + return SystemInfo{}, fmt.Errorf("failed to detect system: %w", err) + } + + // Check prerequisites based on OS and distribution + var prerequisites []PrerequisiteConfig + if osConfig, exists := config.OperatingSystems[sysInfo.OSName]; exists { + // Add OS-level prerequisites + prerequisites = append(prerequisites, osConfig.Prerequisites...) + + // Add distribution-specific prerequisites for Linux + if sysInfo.OSName == "linux" { + if distroConfig, exists := osConfig.Distributions[sysInfo.DistroName]; exists { + prerequisites = append(prerequisites, distroConfig.Prerequisites...) + } + } + } + + // Convert to map format for checker + prereqMap := make(map[string]PrerequisiteConfig) + for _, prereq := range prerequisites { + prereqMap[prereq.Name] = prereq + } + + prereqStatus, err := prereqChecker.CheckPrerequisites(prereqMap) + if err != nil { + sysInfo.Prerequisites = prereqStatus + return sysInfo, fmt.Errorf("prerequisite check failed: %w", err) + } + sysInfo.Prerequisites = prereqStatus + + // Check if the operating system is supported + osConfig, exists := config.OperatingSystems[sysInfo.OSName] + if !exists { + return sysInfo, fmt.Errorf("unsupported operating system: %s", sysInfo.OSName) + } + + if !osConfig.Supported { + return sysInfo, fmt.Errorf("unsupported operating system: %s - %s", sysInfo.OSName, osConfig.Notes) + } + + // If Linux, check distribution compatibility + if sysInfo.OSName == "linux" { + distroConfig, exists := osConfig.Distributions[sysInfo.DistroName] + + if !exists { + return sysInfo, fmt.Errorf("unsupported Linux distribution: %s", sysInfo.DistroName) + } + + if !distroConfig.Supported { + return sysInfo, fmt.Errorf( + "unsupported Linux distribution: %s - %s", + sysInfo.DistroName, + distroConfig.Notes, + ) + } + + // TODO: If needed, add version constraint checking here + } + + // System is compatible + return sysInfo, nil +} + +func getLinuxDistro() string { + // Check for /etc/os-release (freedesktop.org and systemd). + if _, err := os.Stat("/etc/os-release"); err == nil { + content, err := os.ReadFile("/etc/os-release") + if err == nil { + lines := strings.Split(string(content), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "ID=") { + id := strings.TrimPrefix(line, "ID=") + // Remove quotes if present. + id = strings.Trim(id, "\"") + return strings.ToLower(id) + } + } + } + } + + // Check for /etc/lsb-release. + if _, err := os.Stat("/etc/lsb-release"); err == nil { + content, err := os.ReadFile("/etc/lsb-release") + if err == nil { + lines := strings.Split(string(content), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "DISTRIB_ID=") { + id := strings.TrimPrefix(line, "DISTRIB_ID=") + // Remove quotes if present. + id = strings.Trim(id, "\"") + return strings.ToLower(id) + } + } + } + } + + // Check for /etc/debian_version. + if _, err := os.Stat("/etc/debian_version"); err == nil { + return "debian" + } + + // Check for /etc/SuSe-release. + if _, err := os.Stat("/etc/SuSe-release"); err == nil { + return "suse" + } + + // Check for /etc/redhat-release. + if _, err := os.Stat("/etc/redhat-release"); err == nil { + return "redhat" + } + + // Fallback: use uname. + cmd := exec.Command("uname", "-s") + output, err := cmd.Output() + if err == nil { + return strings.ToLower(strings.TrimSpace(string(output))) + } + + return "unknown" +} diff --git a/installer/lib/compatibility/check_test.go b/installer/lib/compatibility/check_test.go new file mode 100644 index 0000000..d542923 --- /dev/null +++ b/installer/lib/compatibility/check_test.go @@ -0,0 +1,438 @@ +package compatibility_test + +import ( + "errors" + "testing" + + "github.com/MrPointer/dotfiles/installer/lib/compatibility" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +// MockOSDetector implements OSDetector for testing. +type MockOSDetector struct { + osName string + distroName string +} + +// GetOSName returns the mocked OS name. +func (m *MockOSDetector) GetOSName() string { + return m.osName +} + +// GetDistroName returns the mocked distro name. +func (m *MockOSDetector) GetDistroName() string { + return m.distroName +} + +// DetectSystem implements the OSDetector interface for the mock. +func (m *MockOSDetector) DetectSystem() (compatibility.SystemInfo, error) { + return compatibility.SystemInfo{ + OSName: m.osName, + DistroName: m.distroName, + Arch: "amd64", // Mock architecture + Prerequisites: compatibility.PrerequisiteStatus{}, // Will be populated by compatibility check + }, nil +} + +// createMockConfig creates a mock compatibility configuration for testing. +func createMockConfig() *compatibility.CompatibilityConfig { + return &compatibility.CompatibilityConfig{ + OperatingSystems: map[string]compatibility.OSConfig{ + "linux": { + Supported: true, + Notes: "Supported Linux", + Distributions: map[string]compatibility.DistroConfig{ + "ubuntu": { + Supported: true, + VersionConstraint: ">= 20.04", + Notes: "Ubuntu is supported", + Prerequisites: []compatibility.PrerequisiteConfig{ + { + Name: "git", + Command: "git", + Description: "Git version control system", + InstallHint: "sudo apt-get install git", + }, + { + Name: "curl", + Command: "curl", + Description: "Command line tool for transferring data", + InstallHint: "sudo apt-get install curl", + }, + }, + }, + "debian": { + Supported: true, + VersionConstraint: ">= 10", + Notes: "Debian is supported", + Prerequisites: []compatibility.PrerequisiteConfig{ + { + Name: "git", + Command: "git", + Description: "Git version control system", + InstallHint: "sudo apt-get install git", + }, + }, + }, + "fedora": { + Supported: false, + Notes: "Fedora is not supported", + }, + }, + }, + "darwin": { + Supported: true, + Notes: "macOS is supported", + Prerequisites: []compatibility.PrerequisiteConfig{ + { + Name: "git", + Command: "git", + Description: "Git version control system", + InstallHint: "Install Command Line Tools: xcode-select --install", + }, + }, + }, + "windows": { + Supported: false, + Notes: "Windows is not supported", + }, + }, + } +} + +//gocognit:ignore +func TestCompatibilityCanBeCheckedWithMockDetectorAndMockConfig(t *testing.T) { + tests := []struct { + name string + osName string + distroName string + expectError bool + errorMsg string + }{ + { + name: "Supported OS Darwin", + osName: "darwin", + distroName: "", + expectError: false, + }, + { + name: "Unsupported OS Windows", + osName: "windows", + distroName: "", + expectError: true, + errorMsg: "unsupported operating system: windows - Windows is not supported", + }, + { + name: "Supported Linux Ubuntu", + osName: "linux", + distroName: "ubuntu", + expectError: false, + }, + { + name: "Supported Linux Debian", + osName: "linux", + distroName: "debian", + expectError: false, + }, + { + name: "Unsupported Linux Fedora", + osName: "linux", + distroName: "fedora", + expectError: true, + errorMsg: "unsupported Linux distribution: fedora - Fedora is not supported", + }, + { + name: "Unknown Linux Distro", + osName: "linux", + distroName: "arch", + expectError: true, + errorMsg: "unsupported Linux distribution: arch", + }, + { + name: "Unknown OS", + osName: "solaris", + distroName: "", + expectError: true, + errorMsg: "unsupported operating system: solaris", + }, + } + + config := createMockConfig() + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + detector := &MockOSDetector{ + osName: tc.osName, + distroName: tc.distroName, + } + + // Create a mock program query that returns true for all programs + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return true, nil + }, + } + sysInfo, err := compatibility.CheckCompatibilityWithDetector(config, detector, mockProgramQuery) + + if tc.expectError { + if err == nil { + t.Fatalf("Expected error but got nil") + } + if err.Error() != tc.errorMsg { + t.Fatalf("Expected error message '%s', got '%s'", tc.errorMsg, err.Error()) + } + + // Even when there's an error, sysInfo should contain the detected system information + if tc.osName != sysInfo.OSName { + t.Fatalf("Expected OS name '%s', got '%s'", tc.osName, sysInfo.OSName) + } + if tc.distroName != sysInfo.DistroName && tc.osName == "linux" { + t.Fatalf("Expected distro name '%s', got '%s'", tc.distroName, sysInfo.DistroName) + } + } else { + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // For successful compatibility checks, verify the system info is correct + if tc.osName != sysInfo.OSName { + t.Fatalf("Expected OS name '%s', got '%s'", tc.osName, sysInfo.OSName) + } + if tc.osName == "linux" && tc.distroName != sysInfo.DistroName { + t.Fatalf("Expected distro name '%s', got '%s'", tc.distroName, sysInfo.DistroName) + } + if sysInfo.Arch == "" { + t.Fatalf("Expected non-empty architecture") + } + } + }) + } +} + +func TestCompatibilityCanBeCheckedWithMockDetectorAndEmbeddedConfig(t *testing.T) { + detector := &MockOSDetector{osName: "linux", distroName: "ubuntu"} + + compatibilityConfig, err := compatibility.LoadCompatibilityConfig(viper.New(), "") + if err != nil { + t.Fatalf("Expected no error when loading embedded compatibility config, got: %v", err) + } + + // Create a mock program query that returns true for all programs + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return true, nil + }, + } + sysInfo, err := compatibility.CheckCompatibilityWithDetector(compatibilityConfig, detector, mockProgramQuery) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Verify the system info is correct + if sysInfo.OSName != "linux" { + t.Fatalf("Expected OS name 'linux', got '%s'", sysInfo.OSName) + } + if sysInfo.DistroName != "ubuntu" { + t.Fatalf("Expected distro name 'ubuntu', got '%s'", sysInfo.DistroName) + } +} + +func TestCompatibilityCheckRejectsNilConfig(t *testing.T) { + detector := &MockOSDetector{osName: "linux", distroName: "ubuntu"} + // Create a mock program query + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return true, nil + }, + } + sysInfo, err := compatibility.CheckCompatibilityWithDetector(nil, detector, mockProgramQuery) + + if err == nil { + t.Fatal("Expected error with nil config, got nil") + } + + expectedMsg := "compatibility configuration is nil" + if err.Error() != expectedMsg { + t.Fatalf("Expected error message '%s', got '%s'", expectedMsg, err.Error()) + } + + // When config is nil, sysInfo should be empty + if sysInfo.OSName != "" || sysInfo.DistroName != "" || sysInfo.Arch != "" { + t.Fatalf("Expected empty system info, got %+v", sysInfo) + } +} + +func Test_DefaultPrerequisiteChecker_CheckPrerequisites_WhenAllPrerequisitesAvailable(t *testing.T) { + // Arrange + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return true, nil + }, + } + checker := compatibility.NewDefaultPrerequisiteChecker(mockProgramQuery) + + prereqConfig := map[string]compatibility.PrerequisiteConfig{ + "git": { + Name: "git", + Command: "git", + Description: "Git version control system", + InstallHint: "Install git", + }, + "curl": { + Name: "curl", + Command: "curl", + Description: "HTTP client", + InstallHint: "Install curl", + }, + } + + // Act + status, err := checker.CheckPrerequisites(prereqConfig) + + // Assert + require.NoError(t, err) + require.Len(t, status.Available, 2) + require.Len(t, status.Missing, 0) + require.Contains(t, status.Available, "git") + require.Contains(t, status.Available, "curl") + require.Len(t, status.Details, 2) + + gitDetail := status.Details["git"] + require.True(t, gitDetail.Available) + require.Equal(t, "git", gitDetail.Name) + require.Equal(t, "Git version control system", gitDetail.Description) +} + +func Test_DefaultPrerequisiteChecker_CheckPrerequisites_WhenSomePrerequisitesMissing(t *testing.T) { + // Arrange + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + // Only git is available, curl is missing + return program == "git", nil + }, + } + checker := compatibility.NewDefaultPrerequisiteChecker(mockProgramQuery) + + prereqConfig := map[string]compatibility.PrerequisiteConfig{ + "git": { + Name: "git", + Command: "git", + Description: "Git version control system", + InstallHint: "Install git", + }, + "curl": { + Name: "curl", + Command: "curl", + Description: "HTTP client", + InstallHint: "Install curl", + }, + } + + // Act + status, err := checker.CheckPrerequisites(prereqConfig) + + // Assert + require.Error(t, err) + require.Contains(t, err.Error(), "missing prerequisites") + require.Len(t, status.Available, 1) + require.Len(t, status.Missing, 1) + require.Contains(t, status.Available, "git") + require.Contains(t, status.Missing, "curl") + + gitDetail := status.Details["git"] + require.True(t, gitDetail.Available) + + curlDetail := status.Details["curl"] + require.False(t, curlDetail.Available) +} + +func Test_DefaultPrerequisiteChecker_CheckPrerequisites_WhenAllPrerequisitesMissing(t *testing.T) { + // Arrange + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return false, nil + }, + } + checker := compatibility.NewDefaultPrerequisiteChecker(mockProgramQuery) + + prereqConfig := map[string]compatibility.PrerequisiteConfig{ + "git": { + Name: "git", + Command: "git", + Description: "Git version control system", + InstallHint: "Install git", + }, + "make": { + Name: "make", + Command: "make", + Description: "Build tool", + InstallHint: "Install make", + }, + } + + // Act + status, err := checker.CheckPrerequisites(prereqConfig) + + // Assert + require.Error(t, err) + require.Contains(t, err.Error(), "missing prerequisites") + require.Len(t, status.Available, 0) + require.Len(t, status.Missing, 2) + require.Contains(t, status.Missing, "git") + require.Contains(t, status.Missing, "make") +} + +func Test_DefaultPrerequisiteChecker_CheckPrerequisites_WhenProgramQueryReturnsError(t *testing.T) { + // Arrange + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return false, errors.New("program query failed") + }, + } + checker := compatibility.NewDefaultPrerequisiteChecker(mockProgramQuery) + + prereqConfig := map[string]compatibility.PrerequisiteConfig{ + "git": { + Name: "git", + Command: "git", + Description: "Git version control system", + InstallHint: "Install git", + }, + } + + // Act + status, err := checker.CheckPrerequisites(prereqConfig) + + // Assert + require.Error(t, err) + require.Contains(t, err.Error(), "missing prerequisites") + require.Len(t, status.Available, 0) + require.Len(t, status.Missing, 1) + require.Contains(t, status.Missing, "git") + + gitDetail := status.Details["git"] + require.False(t, gitDetail.Available) +} + +func Test_DefaultPrerequisiteChecker_CheckPrerequisites_WhenNoPrerequisitesProvided(t *testing.T) { + // Arrange + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return true, nil + }, + } + checker := compatibility.NewDefaultPrerequisiteChecker(mockProgramQuery) + + prereqConfig := map[string]compatibility.PrerequisiteConfig{} + + // Act + status, err := checker.CheckPrerequisites(prereqConfig) + + // Assert + require.NoError(t, err) + require.Len(t, status.Available, 0) + require.Len(t, status.Missing, 0) + require.Len(t, status.Details, 0) +} diff --git a/installer/lib/compatibility/load.go b/installer/lib/compatibility/load.go new file mode 100644 index 0000000..bac9319 --- /dev/null +++ b/installer/lib/compatibility/load.go @@ -0,0 +1,48 @@ +package compatibility + +import ( + "bytes" + "fmt" + + "github.com/MrPointer/dotfiles/installer/internal/config" + "github.com/spf13/viper" +) + +// LoadCompatibilityConfig loads compatibility config from file or embedded source. +func LoadCompatibilityConfig(v *viper.Viper, compatibilityConfigFile string) (*CompatibilityConfig, error) { + // Create a separate viper instance for compatibility config + var compatibilityConfig CompatibilityConfig + + // If compatibility config file is specified, load from there + if compatibilityConfigFile != "" { + v.SetConfigFile(compatibilityConfigFile) + + if err := v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("error reading compatibility config file: %v", err) + } + + fmt.Println("Using compatibility config file:", v.ConfigFileUsed()) + + if err := v.Unmarshal(&compatibilityConfig); err != nil { + return nil, fmt.Errorf("error parsing compatibility config: %v", err) + } + } else { + // If no file is specified, use the embedded compatibility config + v.SetConfigType("yaml") + + embedded_config, err := config.GetRawEmbeddedCompatibilityConfig() + if err != nil { + return nil, fmt.Errorf("error loading embedded compatibility config: %v", err) + } + + if err := v.ReadConfig(bytes.NewBuffer(embedded_config)); err != nil { + return nil, fmt.Errorf("error reading embedded compatibility config: %v", err) + } + + if err := v.Unmarshal(&compatibilityConfig); err != nil { + return nil, fmt.Errorf("error parsing embedded compatibility config: %v", err) + } + } + + return &compatibilityConfig, nil +} diff --git a/installer/lib/compatibility/load_test.go b/installer/lib/compatibility/load_test.go new file mode 100644 index 0000000..f769cfc --- /dev/null +++ b/installer/lib/compatibility/load_test.go @@ -0,0 +1,50 @@ +package compatibility_test + +import ( + "path/filepath" + "testing" + + "github.com/MrPointer/dotfiles/installer/lib/compatibility" + "github.com/MrPointer/dotfiles/installer/utils/files/current" + "github.com/spf13/viper" +) + +func TestCompatibilityConfigCanBeLoadedFromEmbeddedSource(t *testing.T) { + // Create a new Viper instance + v := viper.New() + + // Load the embedded compatibility config + compatibilityConfig, err := compatibility.LoadCompatibilityConfig(v, "") + if err != nil { + t.Fatalf("Expected no error when loading embedded compatibility config, got: %v", err) + } + + if compatibilityConfig == nil { + t.Fatal("Expected compatibility config to be non-nil, got nil") + } +} + +func TestCompatibilityConfigCanBeLoadedFromFile(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test in short mode") + } + + rootDir, err := current.RootDirectory() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + + // Test loading from a file + compatibilityConfigFile := filepath.Join(rootDir, "internal", "config", "compatibility.yaml") + v := viper.New() + v.SetConfigFile(compatibilityConfigFile) + + compatibilityConfig, err := compatibility.LoadCompatibilityConfig(v, compatibilityConfigFile) + if err != nil { + t.Fatalf("Expected no error when loading compatibility config from file, got: %v", err) + } + + if compatibilityConfig == nil { + t.Fatal("Expected compatibility config to be non-nil, got nil") + } +} diff --git a/installer/lib/dotfilesmanager/DotfilesApplier_mock.go b/installer/lib/dotfilesmanager/DotfilesApplier_mock.go new file mode 100644 index 0000000..2166ca8 --- /dev/null +++ b/installer/lib/dotfilesmanager/DotfilesApplier_mock.go @@ -0,0 +1,68 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package dotfilesmanager + +import ( + "sync" +) + +// Ensure that MoqDotfilesApplier does implement DotfilesApplier. +// If this is not the case, regenerate this file with mockery. +var _ DotfilesApplier = &MoqDotfilesApplier{} + +// MoqDotfilesApplier is a mock implementation of DotfilesApplier. +// +// func TestSomethingThatUsesDotfilesApplier(t *testing.T) { +// +// // make and configure a mocked DotfilesApplier +// mockedDotfilesApplier := &MoqDotfilesApplier{ +// ApplyFunc: func() error { +// panic("mock out the Apply method") +// }, +// } +// +// // use mockedDotfilesApplier in code that requires DotfilesApplier +// // and then make assertions. +// +// } +type MoqDotfilesApplier struct { + // ApplyFunc mocks the Apply method. + ApplyFunc func() error + + // calls tracks calls to the methods. + calls struct { + // Apply holds details about calls to the Apply method. + Apply []struct { + } + } + lockApply sync.RWMutex +} + +// Apply calls ApplyFunc. +func (mock *MoqDotfilesApplier) Apply() error { + if mock.ApplyFunc == nil { + panic("MoqDotfilesApplier.ApplyFunc: method is nil but DotfilesApplier.Apply was just called") + } + callInfo := struct { + }{} + mock.lockApply.Lock() + mock.calls.Apply = append(mock.calls.Apply, callInfo) + mock.lockApply.Unlock() + return mock.ApplyFunc() +} + +// ApplyCalls gets all the calls that were made to Apply. +// Check the length with: +// +// len(mockedDotfilesApplier.ApplyCalls()) +func (mock *MoqDotfilesApplier) ApplyCalls() []struct { +} { + var calls []struct { + } + mock.lockApply.RLock() + calls = mock.calls.Apply + mock.lockApply.RUnlock() + return calls +} diff --git a/installer/lib/dotfilesmanager/DotfilesDataInitializer_mock.go b/installer/lib/dotfilesmanager/DotfilesDataInitializer_mock.go new file mode 100644 index 0000000..fd69014 --- /dev/null +++ b/installer/lib/dotfilesmanager/DotfilesDataInitializer_mock.go @@ -0,0 +1,75 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package dotfilesmanager + +import ( + "sync" +) + +// Ensure that MoqDotfilesDataInitializer does implement DotfilesDataInitializer. +// If this is not the case, regenerate this file with mockery. +var _ DotfilesDataInitializer = &MoqDotfilesDataInitializer{} + +// MoqDotfilesDataInitializer is a mock implementation of DotfilesDataInitializer. +// +// func TestSomethingThatUsesDotfilesDataInitializer(t *testing.T) { +// +// // make and configure a mocked DotfilesDataInitializer +// mockedDotfilesDataInitializer := &MoqDotfilesDataInitializer{ +// InitializeFunc: func(data DotfilesData) error { +// panic("mock out the Initialize method") +// }, +// } +// +// // use mockedDotfilesDataInitializer in code that requires DotfilesDataInitializer +// // and then make assertions. +// +// } +type MoqDotfilesDataInitializer struct { + // InitializeFunc mocks the Initialize method. + InitializeFunc func(data DotfilesData) error + + // calls tracks calls to the methods. + calls struct { + // Initialize holds details about calls to the Initialize method. + Initialize []struct { + // Data is the data argument value. + Data DotfilesData + } + } + lockInitialize sync.RWMutex +} + +// Initialize calls InitializeFunc. +func (mock *MoqDotfilesDataInitializer) Initialize(data DotfilesData) error { + if mock.InitializeFunc == nil { + panic("MoqDotfilesDataInitializer.InitializeFunc: method is nil but DotfilesDataInitializer.Initialize was just called") + } + callInfo := struct { + Data DotfilesData + }{ + Data: data, + } + mock.lockInitialize.Lock() + mock.calls.Initialize = append(mock.calls.Initialize, callInfo) + mock.lockInitialize.Unlock() + return mock.InitializeFunc(data) +} + +// InitializeCalls gets all the calls that were made to Initialize. +// Check the length with: +// +// len(mockedDotfilesDataInitializer.InitializeCalls()) +func (mock *MoqDotfilesDataInitializer) InitializeCalls() []struct { + Data DotfilesData +} { + var calls []struct { + Data DotfilesData + } + mock.lockInitialize.RLock() + calls = mock.calls.Initialize + mock.lockInitialize.RUnlock() + return calls +} diff --git a/installer/lib/dotfilesmanager/DotfilesInstaller_mock.go b/installer/lib/dotfilesmanager/DotfilesInstaller_mock.go new file mode 100644 index 0000000..2188209 --- /dev/null +++ b/installer/lib/dotfilesmanager/DotfilesInstaller_mock.go @@ -0,0 +1,68 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package dotfilesmanager + +import ( + "sync" +) + +// Ensure that MoqDotfilesInstaller does implement DotfilesInstaller. +// If this is not the case, regenerate this file with mockery. +var _ DotfilesInstaller = &MoqDotfilesInstaller{} + +// MoqDotfilesInstaller is a mock implementation of DotfilesInstaller. +// +// func TestSomethingThatUsesDotfilesInstaller(t *testing.T) { +// +// // make and configure a mocked DotfilesInstaller +// mockedDotfilesInstaller := &MoqDotfilesInstaller{ +// InstallFunc: func() error { +// panic("mock out the Install method") +// }, +// } +// +// // use mockedDotfilesInstaller in code that requires DotfilesInstaller +// // and then make assertions. +// +// } +type MoqDotfilesInstaller struct { + // InstallFunc mocks the Install method. + InstallFunc func() error + + // calls tracks calls to the methods. + calls struct { + // Install holds details about calls to the Install method. + Install []struct { + } + } + lockInstall sync.RWMutex +} + +// Install calls InstallFunc. +func (mock *MoqDotfilesInstaller) Install() error { + if mock.InstallFunc == nil { + panic("MoqDotfilesInstaller.InstallFunc: method is nil but DotfilesInstaller.Install was just called") + } + callInfo := struct { + }{} + mock.lockInstall.Lock() + mock.calls.Install = append(mock.calls.Install, callInfo) + mock.lockInstall.Unlock() + return mock.InstallFunc() +} + +// InstallCalls gets all the calls that were made to Install. +// Check the length with: +// +// len(mockedDotfilesInstaller.InstallCalls()) +func (mock *MoqDotfilesInstaller) InstallCalls() []struct { +} { + var calls []struct { + } + mock.lockInstall.RLock() + calls = mock.calls.Install + mock.lockInstall.RUnlock() + return calls +} diff --git a/installer/lib/dotfilesmanager/DotfilesManager_mock.go b/installer/lib/dotfilesmanager/DotfilesManager_mock.go new file mode 100644 index 0000000..2b86b9b --- /dev/null +++ b/installer/lib/dotfilesmanager/DotfilesManager_mock.go @@ -0,0 +1,149 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package dotfilesmanager + +import ( + "sync" +) + +// Ensure that MoqDotfilesManager does implement DotfilesManager. +// If this is not the case, regenerate this file with mockery. +var _ DotfilesManager = &MoqDotfilesManager{} + +// MoqDotfilesManager is a mock implementation of DotfilesManager. +// +// func TestSomethingThatUsesDotfilesManager(t *testing.T) { +// +// // make and configure a mocked DotfilesManager +// mockedDotfilesManager := &MoqDotfilesManager{ +// ApplyFunc: func() error { +// panic("mock out the Apply method") +// }, +// InitializeFunc: func(data DotfilesData) error { +// panic("mock out the Initialize method") +// }, +// InstallFunc: func() error { +// panic("mock out the Install method") +// }, +// } +// +// // use mockedDotfilesManager in code that requires DotfilesManager +// // and then make assertions. +// +// } +type MoqDotfilesManager struct { + // ApplyFunc mocks the Apply method. + ApplyFunc func() error + + // InitializeFunc mocks the Initialize method. + InitializeFunc func(data DotfilesData) error + + // InstallFunc mocks the Install method. + InstallFunc func() error + + // calls tracks calls to the methods. + calls struct { + // Apply holds details about calls to the Apply method. + Apply []struct { + } + // Initialize holds details about calls to the Initialize method. + Initialize []struct { + // Data is the data argument value. + Data DotfilesData + } + // Install holds details about calls to the Install method. + Install []struct { + } + } + lockApply sync.RWMutex + lockInitialize sync.RWMutex + lockInstall sync.RWMutex +} + +// Apply calls ApplyFunc. +func (mock *MoqDotfilesManager) Apply() error { + if mock.ApplyFunc == nil { + panic("MoqDotfilesManager.ApplyFunc: method is nil but DotfilesManager.Apply was just called") + } + callInfo := struct { + }{} + mock.lockApply.Lock() + mock.calls.Apply = append(mock.calls.Apply, callInfo) + mock.lockApply.Unlock() + return mock.ApplyFunc() +} + +// ApplyCalls gets all the calls that were made to Apply. +// Check the length with: +// +// len(mockedDotfilesManager.ApplyCalls()) +func (mock *MoqDotfilesManager) ApplyCalls() []struct { +} { + var calls []struct { + } + mock.lockApply.RLock() + calls = mock.calls.Apply + mock.lockApply.RUnlock() + return calls +} + +// Initialize calls InitializeFunc. +func (mock *MoqDotfilesManager) Initialize(data DotfilesData) error { + if mock.InitializeFunc == nil { + panic("MoqDotfilesManager.InitializeFunc: method is nil but DotfilesManager.Initialize was just called") + } + callInfo := struct { + Data DotfilesData + }{ + Data: data, + } + mock.lockInitialize.Lock() + mock.calls.Initialize = append(mock.calls.Initialize, callInfo) + mock.lockInitialize.Unlock() + return mock.InitializeFunc(data) +} + +// InitializeCalls gets all the calls that were made to Initialize. +// Check the length with: +// +// len(mockedDotfilesManager.InitializeCalls()) +func (mock *MoqDotfilesManager) InitializeCalls() []struct { + Data DotfilesData +} { + var calls []struct { + Data DotfilesData + } + mock.lockInitialize.RLock() + calls = mock.calls.Initialize + mock.lockInitialize.RUnlock() + return calls +} + +// Install calls InstallFunc. +func (mock *MoqDotfilesManager) Install() error { + if mock.InstallFunc == nil { + panic("MoqDotfilesManager.InstallFunc: method is nil but DotfilesManager.Install was just called") + } + callInfo := struct { + }{} + mock.lockInstall.Lock() + mock.calls.Install = append(mock.calls.Install, callInfo) + mock.lockInstall.Unlock() + return mock.InstallFunc() +} + +// InstallCalls gets all the calls that were made to Install. +// Check the length with: +// +// len(mockedDotfilesManager.InstallCalls()) +func (mock *MoqDotfilesManager) InstallCalls() []struct { +} { + var calls []struct { + } + mock.lockInstall.RLock() + calls = mock.calls.Install + mock.lockInstall.RUnlock() + return calls +} diff --git a/installer/lib/dotfilesmanager/chezmoi/apply.go b/installer/lib/dotfilesmanager/chezmoi/apply.go new file mode 100644 index 0000000..0e5b6ab --- /dev/null +++ b/installer/lib/dotfilesmanager/chezmoi/apply.go @@ -0,0 +1,46 @@ +package chezmoi + +import ( + "fmt" + + "github.com/MrPointer/dotfiles/installer/utils" +) + +func (c *ChezmoiManager) Apply() error { + c.logger.Debug("Applying chezmoi") + + // Always remove existing chezmoi clone first, just in case + err := c.filesystem.RemovePath(c.chezmoiConfig.chezmoiCloneDir) + if err != nil { + return err + } + + c.logger.Trace("Building chezmoi apply command") + chezmoiApplyCmdArgs := []string{"init", "--apply"} + if c.chezmoiConfig.chezmoiCloneDir != "" { + chezmoiApplyCmdArgs = append(chezmoiApplyCmdArgs, "--source", c.chezmoiConfig.chezmoiCloneDir) + } + if c.chezmoiConfig.cloneViaSSH { + chezmoiApplyCmdArgs = append(chezmoiApplyCmdArgs, "--ssh") + } + chezmoiApplyCmdArgs = append(chezmoiApplyCmdArgs, c.chezmoiConfig.githubUsername) + + var discardOutputOption utils.Option = utils.EmptyOption() + if c.displayMode != utils.DisplayModePassthrough { + discardOutputOption = utils.WithDiscardOutput() + } + + // Add explicit config flag to chezmoi command to bypass path resolution issues + chezmoiApplyCmdArgs = append(chezmoiApplyCmdArgs, "--config", c.chezmoiConfig.chezmoiConfigFilePath) + + result, err := c.commander.RunCommand("chezmoi", chezmoiApplyCmdArgs, discardOutputOption) + if err != nil { + return err + } + if result.ExitCode != 0 { + return fmt.Errorf("chezmoi init failed with exit code %d: %s", result.ExitCode, result.StderrString()) + } + + c.logger.Debug("Chezmoi has been applied successfully") + return nil +} diff --git a/installer/lib/dotfilesmanager/chezmoi/apply_test.go b/installer/lib/dotfilesmanager/chezmoi/apply_test.go new file mode 100644 index 0000000..48c9fe8 --- /dev/null +++ b/installer/lib/dotfilesmanager/chezmoi/apply_test.go @@ -0,0 +1,371 @@ +package chezmoi_test + +import ( + "errors" + "io" + "os" + "testing" + + "github.com/MrPointer/dotfiles/installer/lib/dotfilesmanager/chezmoi" + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/httpclient" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" + "github.com/stretchr/testify/require" +) + +func Test_Apply_RemovesExistingCloneDirectory(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockFileSystem.RemovePathFunc = func(path string) error { + require.Equal(t, "/home/user/.local/share/chezmoi", path) + return nil + } + + mockUserManager := &osmanager.MoqUserManager{} + + mockCommander := &utils.MoqCommander{} + mockCommander.RunCommandFunc = func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return &utils.Result{ExitCode: 0}, nil + } + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "/home/user/.local/share/chezmoi") + + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + err := manager.Apply() + + require.NoError(t, err) + require.Len(t, mockFileSystem.RemovePathCalls(), 1) +} + +func Test_Apply_ReturnsError_WhenRemovePathFails(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockFileSystem.RemovePathFunc = func(path string) error { + return errors.New("permission denied") + } + + mockUserManager := &osmanager.MoqUserManager{} + mockCommander := &utils.MoqCommander{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "/home/user/.local/share/chezmoi") + + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + err := manager.Apply() + + require.Error(t, err) + require.Contains(t, err.Error(), "permission denied") + require.Len(t, mockFileSystem.RemovePathCalls(), 1) +} + +func Test_Apply_RunsChezmoiInitApplyCommand_WithBasicArgs(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockFileSystem.RemovePathFunc = func(path string) error { + return nil + } + + mockUserManager := &osmanager.MoqUserManager{} + + mockCommander := &utils.MoqCommander{} + mockCommander.RunCommandFunc = func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + require.Equal(t, "chezmoi", name) + require.Equal(t, []string{"init", "--apply", "MrPointer", "--config", "/home/user/.config/chezmoi/chezmoi.toml"}, args) + return &utils.Result{ExitCode: 0}, nil + } + + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "") + + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + err := manager.Apply() + + require.NoError(t, err) + require.Len(t, mockCommander.RunCommandCalls(), 1) +} + +func Test_Apply_RunsChezmoiInitApplyCommand_WithSourceDir(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockFileSystem.RemovePathFunc = func(path string) error { + return nil + } + + mockUserManager := &osmanager.MoqUserManager{} + + mockCommander := &utils.MoqCommander{} + mockCommander.RunCommandFunc = func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + require.Equal(t, "chezmoi", name) + expectedArgs := []string{"init", "--apply", "--source", "/home/user/.local/share/chezmoi", "MrPointer", "--config", "/home/user/.config/chezmoi/chezmoi.toml"} + require.Equal(t, expectedArgs, args) + return &utils.Result{ExitCode: 0}, nil + } + + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "/home/user/.local/share/chezmoi") + + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + err := manager.Apply() + + require.NoError(t, err) + require.Len(t, mockCommander.RunCommandCalls(), 1) +} + +func Test_Apply_RunsChezmoiInitApplyCommand_WithSSHCloningPreference(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockFileSystem.RemovePathFunc = func(path string) error { + return nil + } + + mockUserManager := &osmanager.MoqUserManager{} + + mockCommander := &utils.MoqCommander{} + mockCommander.RunCommandFunc = func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + require.Equal(t, "chezmoi", name) + expectedArgs := []string{"init", "--apply", "--source", "/home/user/.local/share/chezmoi", "--ssh", "testuser", "--config", "/home/user/.config/chezmoi/chezmoi.toml"} + require.Equal(t, expectedArgs, args) + return &utils.Result{ExitCode: 0}, nil + } + + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.NewChezmoiConfig( + "/home/user/.config/chezmoi", + "/home/user/.config/chezmoi/chezmoi.toml", + "/home/user/.local/share/chezmoi", + "testuser", + true, + ) + + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + err := manager.Apply() + + require.NoError(t, err) + require.Len(t, mockCommander.RunCommandCalls(), 1) +} + +func Test_Apply_RunsChezmoiInitApplyCommand_WithCustomUsername(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockFileSystem.RemovePathFunc = func(path string) error { + return nil + } + + mockUserManager := &osmanager.MoqUserManager{} + + mockCommander := &utils.MoqCommander{} + mockCommander.RunCommandFunc = func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + require.Equal(t, "chezmoi", name) + require.Equal(t, []string{"init", "--apply", "customuser", "--config", "/home/user/.config/chezmoi/chezmoi.toml"}, args) + return &utils.Result{ExitCode: 0}, nil + } + + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.NewChezmoiConfig( + "/home/user/.config/chezmoi", + "/home/user/.config/chezmoi/chezmoi.toml", + "", + "customuser", + false, + ) + + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + err := manager.Apply() + + require.NoError(t, err) + require.Len(t, mockCommander.RunCommandCalls(), 1) +} + +func Test_Apply_ReturnsError_WhenCommandExecutionFails(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockFileSystem.RemovePathFunc = func(path string) error { + return nil + } + + mockUserManager := &osmanager.MoqUserManager{} + + mockCommander := &utils.MoqCommander{} + mockCommander.RunCommandFunc = func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return nil, errors.New("command not found") + } + + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "/home/user/.local/share/chezmoi") + + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + err := manager.Apply() + + require.Error(t, err) + require.Contains(t, err.Error(), "command not found") +} + +func Test_Apply_ReturnsError_WhenCommandExitsWithNonZeroCode(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockFileSystem.RemovePathFunc = func(path string) error { + return nil + } + + mockUserManager := &osmanager.MoqUserManager{} + + mockCommander := &utils.MoqCommander{} + mockCommander.RunCommandFunc = func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return &utils.Result{ + ExitCode: 1, + Stderr: []byte("initialization failed"), + }, nil + } + + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "/home/user/.local/share/chezmoi") + + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + err := manager.Apply() + + require.Error(t, err) + require.Contains(t, err.Error(), "chezmoi init failed with exit code 1") + require.Contains(t, err.Error(), "initialization failed") +} + +func Test_Apply_SucceedsWithAllParametersCombined(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockFileSystem.RemovePathFunc = func(path string) error { + require.Equal(t, "/custom/clone/dir", path) + return nil + } + + mockUserManager := &osmanager.MoqUserManager{} + + mockCommander := &utils.MoqCommander{} + mockCommander.RunCommandFunc = func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + require.Equal(t, "chezmoi", name) + expectedArgs := []string{"init", "--apply", "--source", "/custom/clone/dir", "--ssh", "testuser123", "--config", "/home/user/.config/chezmoi/chezmoi.toml"} + require.Equal(t, expectedArgs, args) + return &utils.Result{ExitCode: 0}, nil + } + + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.NewChezmoiConfig( + "/home/user/.config/chezmoi", + "/home/user/.config/chezmoi/chezmoi.toml", + "/custom/clone/dir", + "testuser123", + true, + ) + + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + err := manager.Apply() + + require.NoError(t, err) + require.Len(t, mockFileSystem.RemovePathCalls(), 1) + require.Len(t, mockCommander.RunCommandCalls(), 1) +} + +func Test_Apply_DiscardsOutput_WhenDisplayModeIsNotPassthrough(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{ + RemovePathFunc: func(path string) error { + require.Equal(t, "/custom/clone/dir", path) + return nil + }, + } + + mockUserManager := &osmanager.MoqUserManager{} + + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + cmdOptions := utils.Options{} + + // Apply all provided options + for _, opt := range options { + opt(&cmdOptions) + } + + require.Equal(t, io.Discard, cmdOptions.Stdout) + require.Equal(t, io.Discard, cmdOptions.Stderr) + return &utils.Result{}, nil + }, + } + + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.NewChezmoiConfig( + "/home/user/.config/chezmoi", + "/home/user/.config/chezmoi/chezmoi.toml", + "/custom/clone/dir", + "testuser123", + true, + ) + + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + err := manager.Apply() + + require.NoError(t, err) +} + +func Test_Apply_DoesNotDiscardOutput_WhenDisplayModeIsPassthrough(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{ + RemovePathFunc: func(path string) error { + require.Equal(t, "/custom/clone/dir", path) + return nil + }, + } + mockUserManager := &osmanager.MoqUserManager{} + + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + cmdOptions := utils.Options{ + Stdout: os.Stdout, + Stderr: os.Stderr, + } + + // Apply all provided options + for _, opt := range options { + opt(&cmdOptions) + } + + require.Equal(t, os.Stdout, cmdOptions.Stdout) + require.Equal(t, os.Stderr, cmdOptions.Stderr) + return &utils.Result{}, nil + }, + } + + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.NewChezmoiConfig( + "/home/user/.config/chezmoi", + "/home/user/.config/chezmoi/chezmoi.toml", + "/custom/clone/dir", + "testuser123", + true, + ) + + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModePassthrough, chezmoiConfig) + + err := manager.Apply() + + require.NoError(t, err) +} diff --git a/installer/lib/dotfilesmanager/chezmoi/data.go b/installer/lib/dotfilesmanager/chezmoi/data.go new file mode 100644 index 0000000..71a2088 --- /dev/null +++ b/installer/lib/dotfilesmanager/chezmoi/data.go @@ -0,0 +1,73 @@ +package chezmoi + +import ( + "errors" + "fmt" + "os" + + "github.com/MrPointer/dotfiles/installer/lib/dotfilesmanager" + "github.com/spf13/viper" +) + +func (c *ChezmoiManager) Initialize(data dotfilesmanager.DotfilesData) error { + c.logger.Debug("Initializing chezmoi data") + + c.logger.Trace("Creating chezmoi config directory") + configDirExists, err := c.filesystem.PathExists(c.chezmoiConfig.chezmoiConfigDir) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to check if chezmoi config directory exists: %w", err) + } + if !configDirExists { + if err := c.filesystem.CreateDirectory(c.chezmoiConfig.chezmoiConfigDir); err != nil { + return fmt.Errorf("failed to create chezmoi config directory: %w", err) + } + } + c.logger.Trace("Chezmoi config directory created") + + c.logger.Trace("Creating chezmoi config file") + _, err = c.filesystem.CreateFile(c.chezmoiConfig.chezmoiConfigFilePath) + if err != nil { + return fmt.Errorf("failed to create chezmoi config file: %w", err) + } + c.logger.Trace("Chezmoi config file created") + + c.logger.Trace("Building viper object to contain chezmoi data") + viperObject := viper.New() + viperObject.SetConfigFile(c.chezmoiConfig.chezmoiConfigFilePath) + + viperObject.Set("data.personal.email", data.Email) + viperObject.Set("data.personal.full_name", fmt.Sprintf("%s %s", data.FirstName, data.LastName)) + + data.GpgSigningKey.MapValue(func(value string) string { + viperObject.Set("data.gpg.signing_key", value) + return value + }) + + data.WorkEnv.Match(func(value dotfilesmanager.DotfilesWorkEnvData) (dotfilesmanager.DotfilesWorkEnvData, bool) { + viperObject.Set("data.personal.work_env", true) + viperObject.Set("data.personal.work_name", value.WorkName) + viperObject.Set("data.personal.work_email", value.WorkEmail) + return value, true + }, func() (dotfilesmanager.DotfilesWorkEnvData, bool) { + viperObject.Set("data.personal.work_env", false) + return dotfilesmanager.DotfilesWorkEnvData{}, false + }) + + data.SystemData.MapValue(func(value dotfilesmanager.DotfilesSystemData) dotfilesmanager.DotfilesSystemData { + viperObject.Set("data.system.shell", value.Shell) + viperObject.Set("data.system.multi_user_system", value.MultiUserSystem) + viperObject.Set("data.system.brew_multi_user", value.BrewMultiUser) + + if value.GenericWorkProfile.IsPresent() { + viperObject.Set("data.system.work_generic_dotfiles_profile", value.GenericWorkProfile) + if value.SpecificWorkProfile.IsPresent() { + viperObject.Set("data.system.work_specific_dotfiles_profile", value.SpecificWorkProfile) + } + } + + return value + }) + + c.logger.Trace("Writing viper object to chezmoi config file") + return viperObject.WriteConfig() +} diff --git a/installer/lib/dotfilesmanager/chezmoi/data_test.go b/installer/lib/dotfilesmanager/chezmoi/data_test.go new file mode 100644 index 0000000..04e0497 --- /dev/null +++ b/installer/lib/dotfilesmanager/chezmoi/data_test.go @@ -0,0 +1,371 @@ +package chezmoi_test + +import ( + "errors" + "os" + "path" + "path/filepath" + "testing" + + "github.com/samber/mo" + "github.com/stretchr/testify/require" + + "github.com/MrPointer/dotfiles/installer/lib/dotfilesmanager" + "github.com/MrPointer/dotfiles/installer/lib/dotfilesmanager/chezmoi" + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/httpclient" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" +) + +func Test_Initialize_CreatesConfigDirectory_WhenDirectoryDoesNotExist(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + tempDir := t.TempDir() + configDir := filepath.Join(tempDir, "config") + configFilePath := filepath.Join(configDir, "chezmoi.toml") + cloneDir := filepath.Join(tempDir, "clone") + + fileSystem := utils.NewDefaultFileSystem() + mockUserManager := &osmanager.MoqUserManager{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + mockCommander := &utils.MoqCommander{} + + config := chezmoi.DefaultChezmoiConfig(configFilePath, cloneDir) + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, fileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, config) + + data := dotfilesmanager.DotfilesData{ + Email: "test@example.com", + FirstName: "John", + LastName: "Doe", + GpgSigningKey: mo.None[string](), + WorkEnv: mo.None[dotfilesmanager.DotfilesWorkEnvData](), + SystemData: mo.None[dotfilesmanager.DotfilesSystemData](), + } + + err := manager.Initialize(data) + + require.NoError(t, err) + require.DirExists(t, configDir) + require.FileExists(t, configFilePath) +} + +func Test_Initialize_DoesNotCreateDirectory_WhenDirectoryExists(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + tempDir := t.TempDir() + configDir := filepath.Join(tempDir, "config") + configFilePath := filepath.Join(configDir, "chezmoi.toml") + cloneDir := filepath.Join(tempDir, "clone") + + err := os.MkdirAll(configDir, 0755) + require.NoError(t, err) + + fileSystem := utils.NewDefaultFileSystem() + mockUserManager := &osmanager.MoqUserManager{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + mockCommander := &utils.MoqCommander{} + + config := chezmoi.DefaultChezmoiConfig(configFilePath, cloneDir) + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, fileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, config) + + data := dotfilesmanager.DotfilesData{ + Email: "test@example.com", + FirstName: "John", + LastName: "Doe", + GpgSigningKey: mo.None[string](), + WorkEnv: mo.None[dotfilesmanager.DotfilesWorkEnvData](), + SystemData: mo.None[dotfilesmanager.DotfilesSystemData](), + } + + err = manager.Initialize(data) + + require.NoError(t, err) + require.FileExists(t, configFilePath) +} + +func Test_Initialize_ReturnsError_WhenDirectoryCreationFails(t *testing.T) { + expectedError := errors.New("permission denied") + mockFileSystem := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + return false, os.ErrNotExist + }, + CreateDirectoryFunc: func(path string) error { + return expectedError + }, + } + mockUserManager := &osmanager.MoqUserManager{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + mockCommander := &utils.MoqCommander{} + + configFilePath := "/home/user/.config/chezmoi.toml" + cloneDir := "/home/user/.local/share/chezmoi" + config := chezmoi.DefaultChezmoiConfig(configFilePath, cloneDir) + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, config) + + data := dotfilesmanager.DotfilesData{ + Email: "test@example.com", + FirstName: "John", + LastName: "Doe", + } + + err := manager.Initialize(data) + + require.Error(t, err) + require.Contains(t, err.Error(), "failed to create chezmoi config directory") + require.Contains(t, err.Error(), expectedError.Error()) +} + +func Test_Initialize_WritesBasicPersonalData_WhenOnlyRequiredFieldsProvided(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + tempDir := t.TempDir() + configDir := filepath.Join(tempDir, "config") + configFilePath := filepath.Join(configDir, "chezmoi.toml") + cloneDir := filepath.Join(tempDir, "clone") + + err := os.MkdirAll(configDir, 0755) + require.NoError(t, err) + + fileSystem := utils.NewDefaultFileSystem() + mockUserManager := &osmanager.MoqUserManager{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + mockCommander := &utils.MoqCommander{} + + config := chezmoi.DefaultChezmoiConfig(configFilePath, cloneDir) + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, fileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, config) + + data := dotfilesmanager.DotfilesData{ + Email: "test@example.com", + FirstName: "John", + LastName: "Doe", + GpgSigningKey: mo.None[string](), + WorkEnv: mo.None[dotfilesmanager.DotfilesWorkEnvData](), + SystemData: mo.None[dotfilesmanager.DotfilesSystemData](), + } + + err = manager.Initialize(data) + + require.NoError(t, err) + require.FileExists(t, configFilePath) + + configContent, err := os.ReadFile(configFilePath) + require.NoError(t, err) + configStr := string(configContent) + require.Contains(t, configStr, "test@example.com") + require.Contains(t, configStr, "John Doe") + require.Contains(t, configStr, "work_env = false") +} + +func Test_Initialize_WritesGpgSigningKey_WhenProvided(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + tempDir := t.TempDir() + configDir := filepath.Join(tempDir, "config") + configFilePath := filepath.Join(configDir, "chezmoi.toml") + cloneDir := filepath.Join(tempDir, "clone") + + err := os.MkdirAll(configDir, 0755) + require.NoError(t, err) + + fileSystem := utils.NewDefaultFileSystem() + mockUserManager := &osmanager.MoqUserManager{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + mockCommander := &utils.MoqCommander{} + + config := chezmoi.DefaultChezmoiConfig(configFilePath, cloneDir) + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, fileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, config) + + data := dotfilesmanager.DotfilesData{ + Email: "test@example.com", + FirstName: "John", + LastName: "Doe", + GpgSigningKey: mo.Some("ABC123DEF456"), + WorkEnv: mo.None[dotfilesmanager.DotfilesWorkEnvData](), + SystemData: mo.None[dotfilesmanager.DotfilesSystemData](), + } + + err = manager.Initialize(data) + + require.NoError(t, err) + require.FileExists(t, configFilePath) + + configContent, err := os.ReadFile(configFilePath) + require.NoError(t, err) + configStr := string(configContent) + require.Contains(t, configStr, "ABC123DEF456") +} + +func Test_Initialize_WritesWorkEnvironmentData_WhenProvided(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + tempDir := t.TempDir() + configDir := filepath.Join(tempDir, "config") + configFilePath := filepath.Join(configDir, "chezmoi.toml") + cloneDir := filepath.Join(tempDir, "clone") + + err := os.MkdirAll(configDir, 0755) + require.NoError(t, err) + + fileSystem := utils.NewDefaultFileSystem() + mockUserManager := &osmanager.MoqUserManager{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + mockCommander := &utils.MoqCommander{} + + config := chezmoi.DefaultChezmoiConfig(configFilePath, cloneDir) + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, fileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, config) + + workEnvData := dotfilesmanager.DotfilesWorkEnvData{ + WorkName: "Acme Corp", + WorkEmail: "john.doe@acme.com", + } + data := dotfilesmanager.DotfilesData{ + Email: "test@example.com", + FirstName: "John", + LastName: "Doe", + GpgSigningKey: mo.None[string](), + WorkEnv: mo.Some(workEnvData), + SystemData: mo.None[dotfilesmanager.DotfilesSystemData](), + } + + err = manager.Initialize(data) + + require.NoError(t, err) + require.FileExists(t, configFilePath) + + configContent, err := os.ReadFile(configFilePath) + require.NoError(t, err) + configStr := string(configContent) + require.Contains(t, configStr, "work_env = true") + require.Contains(t, configStr, "Acme Corp") + require.Contains(t, configStr, "john.doe@acme.com") +} + +func Test_Initialize_WritesSystemData_WhenProvided(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + tempDir := t.TempDir() + configDir := filepath.Join(tempDir, "config") + configFilePath := filepath.Join(configDir, "chezmoi.toml") + cloneDir := filepath.Join(tempDir, "clone") + + err := os.MkdirAll(configDir, 0755) + require.NoError(t, err) + + fileSystem := utils.NewDefaultFileSystem() + mockUserManager := &osmanager.MoqUserManager{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + mockCommander := &utils.MoqCommander{} + + config := chezmoi.DefaultChezmoiConfig(configFilePath, cloneDir) + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, fileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, config) + + systemData := dotfilesmanager.DotfilesSystemData{ + Shell: "/bin/zsh", + MultiUserSystem: true, + BrewMultiUser: "multifoo", + } + data := dotfilesmanager.DotfilesData{ + Email: "test@example.com", + FirstName: "John", + LastName: "Doe", + GpgSigningKey: mo.None[string](), + WorkEnv: mo.None[dotfilesmanager.DotfilesWorkEnvData](), + SystemData: mo.Some(systemData), + } + + err = manager.Initialize(data) + + require.NoError(t, err) + require.FileExists(t, configFilePath) + + configContent, err := os.ReadFile(configFilePath) + require.NoError(t, err) + configStr := string(configContent) + require.Contains(t, configStr, "/bin/zsh") + require.Contains(t, configStr, "multi_user_system = true") + require.Contains(t, configStr, "brew_multi_user") + require.Contains(t, configStr, "multifoo") +} + +func Test_Initialize_WritesCompleteData_WhenAllFieldsProvided(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + tempDir := t.TempDir() + configDir := filepath.Join(tempDir, "config") + configFilePath := filepath.Join(configDir, "chezmoi.toml") + cloneDir := filepath.Join(tempDir, "clone") + + err := os.MkdirAll(configDir, 0755) + require.NoError(t, err) + + fileSystem := utils.NewDefaultFileSystem() + mockUserManager := &osmanager.MoqUserManager{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + mockCommander := &utils.MoqCommander{} + + config := chezmoi.DefaultChezmoiConfig(configFilePath, cloneDir) + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, fileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, config) + + workEnvData := dotfilesmanager.DotfilesWorkEnvData{ + WorkName: "Acme Corp", + WorkEmail: "john.doe@acme.com", + } + systemData := dotfilesmanager.DotfilesSystemData{ + Shell: "/bin/zsh", + MultiUserSystem: true, + BrewMultiUser: "multifoo", + GenericWorkProfile: mo.Some(path.Join("/home", "user", ".work", "profile")), + SpecificWorkProfile: mo.Some(path.Join("/home", "user", ".work", "foobar", "profile")), + } + data := dotfilesmanager.DotfilesData{ + Email: "test@example.com", + FirstName: "John", + LastName: "Doe", + GpgSigningKey: mo.Some("ABC123DEF456"), + WorkEnv: mo.Some(workEnvData), + SystemData: mo.Some(systemData), + } + + err = manager.Initialize(data) + + require.NoError(t, err) + require.FileExists(t, configFilePath) + + configContent, err := os.ReadFile(configFilePath) + require.NoError(t, err) + configStr := string(configContent) + require.Contains(t, configStr, "test@example.com") + require.Contains(t, configStr, "John Doe") + require.Contains(t, configStr, "ABC123DEF456") + require.Contains(t, configStr, "work_env = true") + require.Contains(t, configStr, "Acme Corp") + require.Contains(t, configStr, "john.doe@acme.com") + require.Contains(t, configStr, "/bin/zsh") + require.Contains(t, configStr, "multi_user_system = true") + require.Contains(t, configStr, "brew_multi_user") + require.Contains(t, configStr, "multifoo") +} diff --git a/installer/lib/dotfilesmanager/chezmoi/installer.go b/installer/lib/dotfilesmanager/chezmoi/installer.go new file mode 100644 index 0000000..b2fdfb8 --- /dev/null +++ b/installer/lib/dotfilesmanager/chezmoi/installer.go @@ -0,0 +1,89 @@ +package chezmoi + +import ( + "fmt" + "io" + "net/http" + + "github.com/Masterminds/semver" + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/utils" +) + +func (c *ChezmoiManager) Install() error { + chezmoiInstalled, err := c.pkgManager.IsPackageInstalled(pkgmanager.NewPackageInfo("chezmoi", "")) + if err != nil { + return err + } + if chezmoiInstalled { + return nil + } + + c.logger.Debug("Trying to install chezmoi using package manager") + err = c.tryPackageManagerInstall() + if err == nil { + c.logger.Debug("chezmoi installed successfully using package manager") + return nil + } + c.logger.Debug("Failed to install chezmoi using package manager") + + c.logger.Debug("Trying to install chezmoi manually") + return c.tryManualInstall() +} + +// tryPackageManagerInstall attempts to install chezmoi using the system package manager. +// Returns nil if successful, otherwise returns the package manager error. +func (c *ChezmoiManager) tryPackageManagerInstall() error { + chezmoiVersionConstraint, err := semver.NewConstraint(">=2.60.0") + if err != nil { + return err + } + return c.pkgManager.InstallPackage(pkgmanager.NewRequestedPackageInfo("chezmoi", chezmoiVersionConstraint)) +} + +// tryManualInstall attempts to install chezmoi manually by downloading and executing +// the installation script from get.chezmoi.io. +// Returns nil if successful, otherwise returns an error describing the failure. +func (c *ChezmoiManager) tryManualInstall() error { + c.logger.Debug("Downloading chezmoi binary from official website (get.chezmoi.io)") + resp, err := c.httpClient.Get("get.chezmoi.io") + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download chezmoi binary: %s", resp.Status) + } + + c.logger.Trace("Reading HTTP response body") + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + userHomeDir, err := c.usermanager.GetHomeDir() + if err != nil { + return err + } + + manualInstallDir := fmt.Sprintf("%s/.local/bin", userHomeDir) + + c.logger.Trace("Executing downloaded binary through the shell") + + var discardOutputOption utils.Option = utils.EmptyOption() + if c.displayMode.ShouldDiscardOutput() { + discardOutputOption = utils.WithDiscardOutput() + } + + result, err := c.commander.RunCommand("sh", []string{"-c", string(body), "--", "-b", manualInstallDir}, discardOutputOption) + if err != nil { + return err + } + if result.ExitCode != 0 { + return fmt.Errorf("failed to install chezmoi manually: %s", result.Stderr) + } + + c.logger.Debug("Chezmoi installed manually successfully") + return nil +} diff --git a/installer/lib/dotfilesmanager/chezmoi/installer_test.go b/installer/lib/dotfilesmanager/chezmoi/installer_test.go new file mode 100644 index 0000000..e3102c3 --- /dev/null +++ b/installer/lib/dotfilesmanager/chezmoi/installer_test.go @@ -0,0 +1,541 @@ +package chezmoi_test + +import ( + "bytes" + "errors" + "io" + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/MrPointer/dotfiles/installer/lib/dotfilesmanager/chezmoi" + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/httpclient" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" +) + +func Test_Install_ReturnsEarly_WhenChezmoiAlreadyInstalled(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockUserManager := &osmanager.MoqUserManager{} + mockCommander := &utils.MoqCommander{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "/home/user/.local/share/chezmoi") + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + mockPackageManager.IsPackageInstalledFunc = func(packageInfo pkgmanager.PackageInfo) (bool, error) { + require.Equal(t, "chezmoi", packageInfo.Name) + return true, nil + } + + err := manager.Install() + + require.NoError(t, err) + require.Len(t, mockPackageManager.IsPackageInstalledCalls(), 1) + require.Len(t, mockPackageManager.InstallPackageCalls(), 0) + require.Len(t, mockHTTPClient.GetCalls(), 0) + require.Len(t, mockCommander.RunCommandCalls(), 0) +} + +func Test_Install_ReturnsError_WhenPackageInstalledCheckFails(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockUserManager := &osmanager.MoqUserManager{} + mockCommander := &utils.MoqCommander{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "/home/user/.local/share/chezmoi") + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + mockPackageManager.IsPackageInstalledFunc = func(packageInfo pkgmanager.PackageInfo) (bool, error) { + return false, errors.New("package manager unavailable") + } + + err := manager.Install() + + require.Error(t, err) + require.Contains(t, err.Error(), "package manager unavailable") + require.Len(t, mockPackageManager.IsPackageInstalledCalls(), 1) +} + +func Test_Install_SucceedsWithPackageManager_WhenChezmoiNotInstalledAndPackageManagerWorks(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockUserManager := &osmanager.MoqUserManager{} + mockCommander := &utils.MoqCommander{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "/home/user/.local/share/chezmoi") + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + mockPackageManager.IsPackageInstalledFunc = func(packageInfo pkgmanager.PackageInfo) (bool, error) { + return false, nil + } + + mockPackageManager.InstallPackageFunc = func(packageInfo pkgmanager.RequestedPackageInfo) error { + require.Equal(t, "chezmoi", packageInfo.Name) + require.NotNil(t, packageInfo.VersionConstraints) + return nil + } + + err := manager.Install() + + require.NoError(t, err) + require.Len(t, mockPackageManager.IsPackageInstalledCalls(), 1) + require.Len(t, mockPackageManager.InstallPackageCalls(), 1) + require.Len(t, mockHTTPClient.GetCalls(), 0) + require.Len(t, mockCommander.RunCommandCalls(), 0) +} + +func Test_Install_FallsBackToManualInstall_WhenPackageManagerFails(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockUserManager := &osmanager.MoqUserManager{} + mockCommander := &utils.MoqCommander{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "/home/user/.local/share/chezmoi") + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + mockPackageManager.IsPackageInstalledFunc = func(packageInfo pkgmanager.PackageInfo) (bool, error) { + return false, nil + } + + mockPackageManager.InstallPackageFunc = func(packageInfo pkgmanager.RequestedPackageInfo) error { + return errors.New("package manager failed") + } + + mockHTTPClient.GetFunc = func(url string) (*http.Response, error) { + require.Equal(t, "get.chezmoi.io", url) + response := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString("#!/bin/sh\necho 'Installing chezmoi...'")), + } + return response, nil + } + + mockUserManager.GetHomeDirFunc = func() (string, error) { + return "/home/user", nil + } + + mockCommander.RunCommandFunc = func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + require.Equal(t, "sh", name) + require.Equal(t, []string{"-c", "#!/bin/sh\necho 'Installing chezmoi...'", "--", "-b", "/home/user/.local/bin"}, args) + return &utils.Result{ExitCode: 0}, nil + } + + err := manager.Install() + + require.NoError(t, err) + require.Len(t, mockPackageManager.IsPackageInstalledCalls(), 1) + require.Len(t, mockPackageManager.InstallPackageCalls(), 1) + require.Len(t, mockHTTPClient.GetCalls(), 1) + require.Len(t, mockUserManager.GetHomeDirCalls(), 1) + require.Len(t, mockCommander.RunCommandCalls(), 1) +} + +func Test_Install_ReturnsError_WhenHTTPRequestFails(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockUserManager := &osmanager.MoqUserManager{} + mockCommander := &utils.MoqCommander{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "/home/user/.local/share/chezmoi") + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + mockPackageManager.IsPackageInstalledFunc = func(packageInfo pkgmanager.PackageInfo) (bool, error) { + return false, nil + } + + mockPackageManager.InstallPackageFunc = func(packageInfo pkgmanager.RequestedPackageInfo) error { + return errors.New("package manager failed") + } + + mockHTTPClient.GetFunc = func(url string) (*http.Response, error) { + return nil, errors.New("network unavailable") + } + + err := manager.Install() + + require.Error(t, err) + require.Contains(t, err.Error(), "network unavailable") + require.Len(t, mockPackageManager.IsPackageInstalledCalls(), 1) + require.Len(t, mockPackageManager.InstallPackageCalls(), 1) + require.Len(t, mockHTTPClient.GetCalls(), 1) +} + +func Test_Install_ReturnsError_WhenHTTPResponseIsNotOK(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockUserManager := &osmanager.MoqUserManager{} + mockCommander := &utils.MoqCommander{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "/home/user/.local/share/chezmoi") + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + mockPackageManager.IsPackageInstalledFunc = func(packageInfo pkgmanager.PackageInfo) (bool, error) { + return false, nil + } + + mockPackageManager.InstallPackageFunc = func(packageInfo pkgmanager.RequestedPackageInfo) error { + return errors.New("package manager failed") + } + + mockHTTPClient.GetFunc = func(url string) (*http.Response, error) { + response := &http.Response{ + StatusCode: http.StatusNotFound, + Status: "404 Not Found", + Body: io.NopCloser(bytes.NewBufferString("")), + } + return response, nil + } + + err := manager.Install() + + require.Error(t, err) + require.Contains(t, err.Error(), "failed to download chezmoi binary") + require.Contains(t, err.Error(), "404 Not Found") +} + +func Test_Install_ReturnsError_WhenResponseBodyReadFails(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockUserManager := &osmanager.MoqUserManager{} + mockCommander := &utils.MoqCommander{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "/home/user/.local/share/chezmoi") + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + mockPackageManager.IsPackageInstalledFunc = func(packageInfo pkgmanager.PackageInfo) (bool, error) { + return false, nil + } + + mockPackageManager.InstallPackageFunc = func(packageInfo pkgmanager.RequestedPackageInfo) error { + return errors.New("package manager failed") + } + + failingReader := &FailingReader{} + mockHTTPClient.GetFunc = func(url string) (*http.Response, error) { + response := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(failingReader), + } + return response, nil + } + + err := manager.Install() + + require.Error(t, err) + require.Contains(t, err.Error(), "read failed") +} + +func Test_Install_ReturnsError_WhenGetHomeDirFails(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockUserManager := &osmanager.MoqUserManager{} + mockCommander := &utils.MoqCommander{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "/home/user/.local/share/chezmoi") + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + mockPackageManager.IsPackageInstalledFunc = func(packageInfo pkgmanager.PackageInfo) (bool, error) { + return false, nil + } + + mockPackageManager.InstallPackageFunc = func(packageInfo pkgmanager.RequestedPackageInfo) error { + return errors.New("package manager failed") + } + + mockHTTPClient.GetFunc = func(url string) (*http.Response, error) { + response := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString("#!/bin/sh\necho 'Installing chezmoi...'")), + } + return response, nil + } + + mockUserManager.GetHomeDirFunc = func() (string, error) { + return "", errors.New("home directory not available") + } + + err := manager.Install() + + require.Error(t, err) + require.Contains(t, err.Error(), "home directory not available") +} + +func Test_Install_ReturnsError_WhenManualInstallCommandFails(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockUserManager := &osmanager.MoqUserManager{} + mockCommander := &utils.MoqCommander{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "/home/user/.local/share/chezmoi") + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + mockPackageManager.IsPackageInstalledFunc = func(packageInfo pkgmanager.PackageInfo) (bool, error) { + return false, nil + } + + mockPackageManager.InstallPackageFunc = func(packageInfo pkgmanager.RequestedPackageInfo) error { + return errors.New("package manager failed") + } + + mockHTTPClient.GetFunc = func(url string) (*http.Response, error) { + response := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString("#!/bin/sh\necho 'Installing chezmoi...'")), + } + return response, nil + } + + mockUserManager.GetHomeDirFunc = func() (string, error) { + return "/home/user", nil + } + + mockCommander.RunCommandFunc = func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return nil, errors.New("command execution failed") + } + + err := manager.Install() + + require.Error(t, err) + require.Contains(t, err.Error(), "command execution failed") +} + +func Test_Install_ReturnsError_WhenManualInstallCommandExitsWithNonZeroCode(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockUserManager := &osmanager.MoqUserManager{} + mockCommander := &utils.MoqCommander{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "/home/user/.local/share/chezmoi") + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + mockPackageManager.IsPackageInstalledFunc = func(packageInfo pkgmanager.PackageInfo) (bool, error) { + return false, nil + } + + mockPackageManager.InstallPackageFunc = func(packageInfo pkgmanager.RequestedPackageInfo) error { + return errors.New("package manager failed") + } + + mockHTTPClient.GetFunc = func(url string) (*http.Response, error) { + response := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString("#!/bin/sh\necho 'Installing chezmoi...'")), + } + return response, nil + } + + mockUserManager.GetHomeDirFunc = func() (string, error) { + return "/home/user", nil + } + + mockCommander.RunCommandFunc = func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return &utils.Result{ + ExitCode: 1, + Stderr: []byte("installation script failed"), + }, nil + } + + err := manager.Install() + + require.Error(t, err) + require.Contains(t, err.Error(), "failed to install chezmoi manually") + require.Contains(t, err.Error(), "installation script failed") +} + +func Test_Install_ClosesResponseBody(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockUserManager := &osmanager.MoqUserManager{} + mockCommander := &utils.MoqCommander{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "/home/user/.local/share/chezmoi") + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + mockPackageManager.IsPackageInstalledFunc = func(packageInfo pkgmanager.PackageInfo) (bool, error) { + return false, nil + } + + mockPackageManager.InstallPackageFunc = func(packageInfo pkgmanager.RequestedPackageInfo) error { + return errors.New("package manager failed") + } + + mockBody := &MockReadCloser{ + Reader: bytes.NewBufferString("#!/bin/sh\necho 'Installing chezmoi...'"), + } + + mockHTTPClient.GetFunc = func(url string) (*http.Response, error) { + response := &http.Response{ + StatusCode: http.StatusOK, + Body: mockBody, + } + return response, nil + } + + mockUserManager.GetHomeDirFunc = func() (string, error) { + return "/home/user", nil + } + + mockCommander.RunCommandFunc = func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return &utils.Result{ExitCode: 0}, nil + } + + err := manager.Install() + + require.NoError(t, err) + require.True(t, mockBody.CloseCalled, "Response body should be closed") +} + +// Helper types for testing +type FailingReader struct{} + +func (fr *FailingReader) Read(p []byte) (n int, err error) { + return 0, errors.New("read failed") +} + +type MockReadCloser struct { + io.Reader + CloseCalled bool +} + +func (mrc *MockReadCloser) Close() error { + mrc.CloseCalled = true + return nil +} + +func Test_Install_ManualInstall_DiscardsOutput_WhenDisplayModeIsNotPassthrough(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockUserManager := &osmanager.MoqUserManager{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + cmdOptions := utils.Options{ + Stdout: os.Stdout, + Stderr: os.Stderr, + } + + // Apply all provided options + for _, opt := range opts { + opt(&cmdOptions) + } + + require.Equal(t, "sh", name) + require.Equal(t, []string{"-c", "#!/bin/sh\necho 'Installing chezmoi...'", "--", "-b", "/home/user/.local/bin"}, args) + + // Verify that output was discarded (stdout/stderr should be different from original) + require.NotEqual(t, os.Stdout, cmdOptions.Stdout) + require.NotEqual(t, os.Stderr, cmdOptions.Stderr) + return &utils.Result{ExitCode: 0}, nil + }, + } + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "/home/user/.local/share/chezmoi") + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress, chezmoiConfig) + + mockPackageManager.IsPackageInstalledFunc = func(packageInfo pkgmanager.PackageInfo) (bool, error) { + return false, nil + } + + mockPackageManager.InstallPackageFunc = func(packageInfo pkgmanager.RequestedPackageInfo) error { + return errors.New("package manager failed") + } + + mockHTTPClient.GetFunc = func(url string) (*http.Response, error) { + response := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString("#!/bin/sh\necho 'Installing chezmoi...'")), + } + return response, nil + } + + mockUserManager.GetHomeDirFunc = func() (string, error) { + return "/home/user", nil + } + + err := manager.Install() + + require.NoError(t, err) + require.Len(t, mockPackageManager.IsPackageInstalledCalls(), 1) + require.Len(t, mockPackageManager.InstallPackageCalls(), 1) + require.Len(t, mockHTTPClient.GetCalls(), 1) + require.Len(t, mockUserManager.GetHomeDirCalls(), 1) + require.Len(t, mockCommander.RunCommandCalls(), 1) +} + +func Test_Install_ManualInstall_DoesNotDiscardOutput_WhenDisplayModeIsPassthrough(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockUserManager := &osmanager.MoqUserManager{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + cmdOptions := utils.Options{ + Stdout: os.Stdout, + Stderr: os.Stderr, + } + + // Apply all provided options + for _, opt := range opts { + opt(&cmdOptions) + } + + require.Equal(t, "sh", name) + require.Equal(t, []string{"-c", "#!/bin/sh\necho 'Installing chezmoi...'", "--", "-b", "/home/user/.local/bin"}, args) + + // Verify that output was not discarded (stdout/stderr should remain unchanged) + require.Equal(t, os.Stdout, cmdOptions.Stdout) + require.Equal(t, os.Stderr, cmdOptions.Stderr) + return &utils.Result{ExitCode: 0}, nil + }, + } + + chezmoiConfig := chezmoi.DefaultChezmoiConfig("/home/user/.config/chezmoi/chezmoi.toml", "/home/user/.local/share/chezmoi") + manager := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModePassthrough, chezmoiConfig) + + mockPackageManager.IsPackageInstalledFunc = func(packageInfo pkgmanager.PackageInfo) (bool, error) { + return false, nil + } + + mockPackageManager.InstallPackageFunc = func(packageInfo pkgmanager.RequestedPackageInfo) error { + return errors.New("package manager failed") + } + + mockHTTPClient.GetFunc = func(url string) (*http.Response, error) { + response := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString("#!/bin/sh\necho 'Installing chezmoi...'")), + } + return response, nil + } + + mockUserManager.GetHomeDirFunc = func() (string, error) { + return "/home/user", nil + } + + err := manager.Install() + + require.NoError(t, err) + require.Len(t, mockPackageManager.IsPackageInstalledCalls(), 1) + require.Len(t, mockPackageManager.InstallPackageCalls(), 1) + require.Len(t, mockHTTPClient.GetCalls(), 1) + require.Len(t, mockUserManager.GetHomeDirCalls(), 1) + require.Len(t, mockCommander.RunCommandCalls(), 1) +} diff --git a/installer/lib/dotfilesmanager/chezmoi/manager.go b/installer/lib/dotfilesmanager/chezmoi/manager.go new file mode 100644 index 0000000..7b4d0c7 --- /dev/null +++ b/installer/lib/dotfilesmanager/chezmoi/manager.go @@ -0,0 +1,106 @@ +package chezmoi + +import ( + "fmt" + "path/filepath" + + "github.com/MrPointer/dotfiles/installer/lib/dotfilesmanager" + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/httpclient" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" +) + +const DefaultGitHubUsername = "MrPointer" + +type ChezmoiConfig struct { + chezmoiConfigDir string + chezmoiConfigFilePath string + chezmoiCloneDir string + githubUsername string + cloneViaSSH bool +} + +func NewChezmoiConfig(configDir, configFilePath, cloneDir, githubUsername string, cloneViaSSH bool) ChezmoiConfig { + return ChezmoiConfig{ + chezmoiConfigDir: configDir, + chezmoiConfigFilePath: configFilePath, + chezmoiCloneDir: cloneDir, + githubUsername: githubUsername, + cloneViaSSH: cloneViaSSH, + } +} + +func DefaultChezmoiConfig(chezmoiConfigFilePath string, chezmoiCloneDir string) ChezmoiConfig { + return ChezmoiConfig{ + chezmoiConfigDir: filepath.Dir(chezmoiConfigFilePath), + chezmoiConfigFilePath: chezmoiConfigFilePath, + chezmoiCloneDir: chezmoiCloneDir, + githubUsername: "MrPointer", + cloneViaSSH: false, + } +} + +type ChezmoiManager struct { + chezmoiConfig ChezmoiConfig + logger logger.Logger + filesystem utils.FileSystem + usermanager osmanager.UserManager + commander utils.Commander + pkgManager pkgmanager.PackageManager + httpClient httpclient.HTTPClient + displayMode utils.DisplayMode +} + +var _ dotfilesmanager.DotfilesDataInitializer = (*ChezmoiManager)(nil) + +func NewChezmoiManager(logger logger.Logger, filesystem utils.FileSystem, userManager osmanager.UserManager, commander utils.Commander, pkgManager pkgmanager.PackageManager, httpClient httpclient.HTTPClient, displayMode utils.DisplayMode, chezmoiConfig ChezmoiConfig) *ChezmoiManager { + return &ChezmoiManager{ + chezmoiConfig: chezmoiConfig, + logger: logger, + filesystem: filesystem, + usermanager: userManager, + commander: commander, + pkgManager: pkgManager, + httpClient: httpClient, + displayMode: displayMode, + } +} + +func TryStandardChezmoiManager(logger logger.Logger, filesystem utils.FileSystem, userManager osmanager.UserManager, commander utils.Commander, pkgManager pkgmanager.PackageManager, httpClient httpclient.HTTPClient, displayMode utils.DisplayMode, githubUsername string, cloneViaSSH bool) (*ChezmoiManager, error) { + chezmoiConfigHome, err := userManager.GetChezmoiConfigHome() + if err != nil { + return nil, fmt.Errorf("failed to get chezmoi config home directory: %w", err) + } + chezmoiConfigDir := fmt.Sprintf("%s/chezmoi", chezmoiConfigHome) + chezmoiConfigFilePath := fmt.Sprintf("%s/chezmoi.toml", chezmoiConfigDir) + + userHomeDir, err := userManager.GetHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get user home directory: %w", err) + } + + chezmoiCloneDir := fmt.Sprintf("%s/.local/share/chezmoi", userHomeDir) + + return NewChezmoiManager( + logger, + filesystem, + userManager, + commander, + pkgManager, + httpClient, + displayMode, + NewChezmoiConfig( + chezmoiConfigDir, + chezmoiConfigFilePath, + chezmoiCloneDir, + githubUsername, + cloneViaSSH, + ), + ), nil +} + +func TryStandardChezmoiManagerWithDefaults(logger logger.Logger, filesystem utils.FileSystem, userManager osmanager.UserManager, commander utils.Commander, pkgManager pkgmanager.PackageManager, httpClient httpclient.HTTPClient, displayMode utils.DisplayMode) (*ChezmoiManager, error) { + return TryStandardChezmoiManager(logger, filesystem, userManager, commander, pkgManager, httpClient, displayMode, DefaultGitHubUsername, false) +} diff --git a/installer/lib/dotfilesmanager/chezmoi/manager_test.go b/installer/lib/dotfilesmanager/chezmoi/manager_test.go new file mode 100644 index 0000000..7b80ce8 --- /dev/null +++ b/installer/lib/dotfilesmanager/chezmoi/manager_test.go @@ -0,0 +1,94 @@ +package chezmoi_test + +import ( + "errors" + "testing" + + "github.com/MrPointer/dotfiles/installer/lib/dotfilesmanager/chezmoi" + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/httpclient" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" + "github.com/stretchr/testify/require" +) + +func Test_NewChezmoiManager_ReturnsValidInstance(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + mockUserManager := &osmanager.MoqUserManager{} + mockCommander := &utils.MoqCommander{} + mockPkgManager := &pkgmanager.MoqPackageManager{} + mockHttpClient := &httpclient.MoqHTTPClient{} + + configFilePath := "/home/user/.config/chezmoi.toml" + + initializer := chezmoi.NewChezmoiManager(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPkgManager, mockHttpClient, utils.DisplayModeProgress, chezmoi.DefaultChezmoiConfig(configFilePath, "")) + + require.NotNil(t, initializer) +} + +func Test_TryNewDefaultChezmoiManager_ReturnsValidInstance_WhenUserConfigDirAndHomeDirAreAvailable(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + + mockUserManager := &osmanager.MoqUserManager{ + GetChezmoiConfigHomeFunc: func() (string, error) { + return "/home/user/.config", nil + }, + GetHomeDirFunc: func() (string, error) { + return "/home/user", nil + }, + } + + mockCommander := &utils.MoqCommander{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + initializer, err := chezmoi.TryStandardChezmoiManagerWithDefaults(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress) + + require.NoError(t, err) + require.NotNil(t, initializer) +} + +func Test_TryNewDefaultChezmoiManager_ReturnsError_WhenUserConfigDirIsUnavailable(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + + mockUserManager := &osmanager.MoqUserManager{ + GetChezmoiConfigHomeFunc: func() (string, error) { + return "", errors.New("failed to get user config directory") + }, + GetHomeDirFunc: func() (string, error) { + return "/home/user", nil + }, + } + + mockCommander := &utils.MoqCommander{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + initializer, err := chezmoi.TryStandardChezmoiManagerWithDefaults(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress) + + require.Error(t, err) + require.Nil(t, initializer) +} + +func Test_TryNewDefaultChezmoiManager_ReturnsError_WhenUserHomeDirIsUnavailable(t *testing.T) { + mockFileSystem := &utils.MoqFileSystem{} + + mockUserManager := &osmanager.MoqUserManager{ + GetChezmoiConfigHomeFunc: func() (string, error) { + return "/home/user/.config", nil + }, + GetHomeDirFunc: func() (string, error) { + return "", errors.New("failed to get user home directory") + }, + } + + mockCommander := &utils.MoqCommander{} + mockPackageManager := &pkgmanager.MoqPackageManager{} + mockHTTPClient := &httpclient.MoqHTTPClient{} + + initializer, err := chezmoi.TryStandardChezmoiManagerWithDefaults(logger.DefaultLogger, mockFileSystem, mockUserManager, mockCommander, mockPackageManager, mockHTTPClient, utils.DisplayModeProgress) + + require.Error(t, err) + require.Nil(t, initializer) +} diff --git a/installer/lib/dotfilesmanager/data.go b/installer/lib/dotfilesmanager/data.go new file mode 100644 index 0000000..4a87c03 --- /dev/null +++ b/installer/lib/dotfilesmanager/data.go @@ -0,0 +1,27 @@ +package dotfilesmanager + +import ( + "github.com/samber/mo" +) + +type DotfilesData struct { + Email string `mapstructure:"email"` + FirstName string `mapstructure:"first_name"` + LastName string `mapstructure:"last_name"` + GpgSigningKey mo.Option[string] `mapstructure:"gpg_signing_key"` + WorkEnv mo.Option[DotfilesWorkEnvData] `mapstructure:"work_env"` + SystemData mo.Option[DotfilesSystemData] `mapstructure:"system_data"` +} + +type DotfilesWorkEnvData struct { + WorkName string `mapstructure:"work_name"` + WorkEmail string `mapstructure:"work_email"` +} + +type DotfilesSystemData struct { + Shell string `mapstructure:"shell"` + MultiUserSystem bool `mapstructure:"multi_user_system"` + BrewMultiUser string `mapstructure:"brew_multi_user"` + GenericWorkProfile mo.Option[string] `mapstructure:"generic_work_profile"` + SpecificWorkProfile mo.Option[string] `mapstructure:"specific_work_profile"` +} diff --git a/installer/lib/dotfilesmanager/manager.go b/installer/lib/dotfilesmanager/manager.go new file mode 100644 index 0000000..561cc8a --- /dev/null +++ b/installer/lib/dotfilesmanager/manager.go @@ -0,0 +1,31 @@ +package dotfilesmanager + +// DotfilesDataInitializer initializes the dotfiles data. +type DotfilesDataInitializer interface { + // Initialize initializes the dotfiles data. + // It takes a DotfilesData object as input and returns an error if any. + // + // data: The DotfilesData object to initialize. + Initialize(data DotfilesData) error +} + +// DotfilesApplier applies the dotfiles. +type DotfilesApplier interface { + // Apply applies the dotfiles. + // It returns an error if any. + Apply() error +} + +// DotfilesInstaller installs the dotfiles. +type DotfilesInstaller interface { + // Install installs the dotfiles. + // It returns an error if any. + Install() error +} + +// DotfilesManager manages the dotfiles, by providing a unified interface for initializing, applying, and installing dotfiles. +type DotfilesManager interface { + DotfilesDataInitializer + DotfilesApplier + DotfilesInstaller +} diff --git a/installer/lib/gpg/GpgClientInstaller_mock.go b/installer/lib/gpg/GpgClientInstaller_mock.go new file mode 100644 index 0000000..10e28dc --- /dev/null +++ b/installer/lib/gpg/GpgClientInstaller_mock.go @@ -0,0 +1,113 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package gpg + +import ( + "context" + "sync" +) + +// Ensure that MoqGpgClientInstaller does implement GpgClientInstaller. +// If this is not the case, regenerate this file with mockery. +var _ GpgClientInstaller = &MoqGpgClientInstaller{} + +// MoqGpgClientInstaller is a mock implementation of GpgClientInstaller. +// +// func TestSomethingThatUsesGpgClientInstaller(t *testing.T) { +// +// // make and configure a mocked GpgClientInstaller +// mockedGpgClientInstaller := &MoqGpgClientInstaller{ +// InstallFunc: func(ctx context.Context) error { +// panic("mock out the Install method") +// }, +// IsAvailableFunc: func() (bool, error) { +// panic("mock out the IsAvailable method") +// }, +// } +// +// // use mockedGpgClientInstaller in code that requires GpgClientInstaller +// // and then make assertions. +// +// } +type MoqGpgClientInstaller struct { + // InstallFunc mocks the Install method. + InstallFunc func(ctx context.Context) error + + // IsAvailableFunc mocks the IsAvailable method. + IsAvailableFunc func() (bool, error) + + // calls tracks calls to the methods. + calls struct { + // Install holds details about calls to the Install method. + Install []struct { + // Ctx is the ctx argument value. + Ctx context.Context + } + // IsAvailable holds details about calls to the IsAvailable method. + IsAvailable []struct { + } + } + lockInstall sync.RWMutex + lockIsAvailable sync.RWMutex +} + +// Install calls InstallFunc. +func (mock *MoqGpgClientInstaller) Install(ctx context.Context) error { + if mock.InstallFunc == nil { + panic("MoqGpgClientInstaller.InstallFunc: method is nil but GpgClientInstaller.Install was just called") + } + callInfo := struct { + Ctx context.Context + }{ + Ctx: ctx, + } + mock.lockInstall.Lock() + mock.calls.Install = append(mock.calls.Install, callInfo) + mock.lockInstall.Unlock() + return mock.InstallFunc(ctx) +} + +// InstallCalls gets all the calls that were made to Install. +// Check the length with: +// +// len(mockedGpgClientInstaller.InstallCalls()) +func (mock *MoqGpgClientInstaller) InstallCalls() []struct { + Ctx context.Context +} { + var calls []struct { + Ctx context.Context + } + mock.lockInstall.RLock() + calls = mock.calls.Install + mock.lockInstall.RUnlock() + return calls +} + +// IsAvailable calls IsAvailableFunc. +func (mock *MoqGpgClientInstaller) IsAvailable() (bool, error) { + if mock.IsAvailableFunc == nil { + panic("MoqGpgClientInstaller.IsAvailableFunc: method is nil but GpgClientInstaller.IsAvailable was just called") + } + callInfo := struct { + }{} + mock.lockIsAvailable.Lock() + mock.calls.IsAvailable = append(mock.calls.IsAvailable, callInfo) + mock.lockIsAvailable.Unlock() + return mock.IsAvailableFunc() +} + +// IsAvailableCalls gets all the calls that were made to IsAvailable. +// Check the length with: +// +// len(mockedGpgClientInstaller.IsAvailableCalls()) +func (mock *MoqGpgClientInstaller) IsAvailableCalls() []struct { +} { + var calls []struct { + } + mock.lockIsAvailable.RLock() + calls = mock.calls.IsAvailable + mock.lockIsAvailable.RUnlock() + return calls +} diff --git a/installer/lib/gpg/GpgClient_mock.go b/installer/lib/gpg/GpgClient_mock.go new file mode 100644 index 0000000..58e780a --- /dev/null +++ b/installer/lib/gpg/GpgClient_mock.go @@ -0,0 +1,142 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package gpg + +import ( + "sync" +) + +// Ensure that MoqGpgClient does implement GpgClient. +// If this is not the case, regenerate this file with mockery. +var _ GpgClient = &MoqGpgClient{} + +// MoqGpgClient is a mock implementation of GpgClient. +// +// func TestSomethingThatUsesGpgClient(t *testing.T) { +// +// // make and configure a mocked GpgClient +// mockedGpgClient := &MoqGpgClient{ +// CreateKeyPairFunc: func() (string, error) { +// panic("mock out the CreateKeyPair method") +// }, +// KeysAvailableFunc: func() (bool, error) { +// panic("mock out the KeysAvailable method") +// }, +// ListAvailableKeysFunc: func() ([]string, error) { +// panic("mock out the ListAvailableKeys method") +// }, +// } +// +// // use mockedGpgClient in code that requires GpgClient +// // and then make assertions. +// +// } +type MoqGpgClient struct { + // CreateKeyPairFunc mocks the CreateKeyPair method. + CreateKeyPairFunc func() (string, error) + + // KeysAvailableFunc mocks the KeysAvailable method. + KeysAvailableFunc func() (bool, error) + + // ListAvailableKeysFunc mocks the ListAvailableKeys method. + ListAvailableKeysFunc func() ([]string, error) + + // calls tracks calls to the methods. + calls struct { + // CreateKeyPair holds details about calls to the CreateKeyPair method. + CreateKeyPair []struct { + } + // KeysAvailable holds details about calls to the KeysAvailable method. + KeysAvailable []struct { + } + // ListAvailableKeys holds details about calls to the ListAvailableKeys method. + ListAvailableKeys []struct { + } + } + lockCreateKeyPair sync.RWMutex + lockKeysAvailable sync.RWMutex + lockListAvailableKeys sync.RWMutex +} + +// CreateKeyPair calls CreateKeyPairFunc. +func (mock *MoqGpgClient) CreateKeyPair() (string, error) { + if mock.CreateKeyPairFunc == nil { + panic("MoqGpgClient.CreateKeyPairFunc: method is nil but GpgClient.CreateKeyPair was just called") + } + callInfo := struct { + }{} + mock.lockCreateKeyPair.Lock() + mock.calls.CreateKeyPair = append(mock.calls.CreateKeyPair, callInfo) + mock.lockCreateKeyPair.Unlock() + return mock.CreateKeyPairFunc() +} + +// CreateKeyPairCalls gets all the calls that were made to CreateKeyPair. +// Check the length with: +// +// len(mockedGpgClient.CreateKeyPairCalls()) +func (mock *MoqGpgClient) CreateKeyPairCalls() []struct { +} { + var calls []struct { + } + mock.lockCreateKeyPair.RLock() + calls = mock.calls.CreateKeyPair + mock.lockCreateKeyPair.RUnlock() + return calls +} + +// KeysAvailable calls KeysAvailableFunc. +func (mock *MoqGpgClient) KeysAvailable() (bool, error) { + if mock.KeysAvailableFunc == nil { + panic("MoqGpgClient.KeysAvailableFunc: method is nil but GpgClient.KeysAvailable was just called") + } + callInfo := struct { + }{} + mock.lockKeysAvailable.Lock() + mock.calls.KeysAvailable = append(mock.calls.KeysAvailable, callInfo) + mock.lockKeysAvailable.Unlock() + return mock.KeysAvailableFunc() +} + +// KeysAvailableCalls gets all the calls that were made to KeysAvailable. +// Check the length with: +// +// len(mockedGpgClient.KeysAvailableCalls()) +func (mock *MoqGpgClient) KeysAvailableCalls() []struct { +} { + var calls []struct { + } + mock.lockKeysAvailable.RLock() + calls = mock.calls.KeysAvailable + mock.lockKeysAvailable.RUnlock() + return calls +} + +// ListAvailableKeys calls ListAvailableKeysFunc. +func (mock *MoqGpgClient) ListAvailableKeys() ([]string, error) { + if mock.ListAvailableKeysFunc == nil { + panic("MoqGpgClient.ListAvailableKeysFunc: method is nil but GpgClient.ListAvailableKeys was just called") + } + callInfo := struct { + }{} + mock.lockListAvailableKeys.Lock() + mock.calls.ListAvailableKeys = append(mock.calls.ListAvailableKeys, callInfo) + mock.lockListAvailableKeys.Unlock() + return mock.ListAvailableKeysFunc() +} + +// ListAvailableKeysCalls gets all the calls that were made to ListAvailableKeys. +// Check the length with: +// +// len(mockedGpgClient.ListAvailableKeysCalls()) +func (mock *MoqGpgClient) ListAvailableKeysCalls() []struct { +} { + var calls []struct { + } + mock.lockListAvailableKeys.RLock() + calls = mock.calls.ListAvailableKeys + mock.lockListAvailableKeys.RUnlock() + return calls +} diff --git a/installer/lib/gpg/client.go b/installer/lib/gpg/client.go new file mode 100644 index 0000000..9f27abf --- /dev/null +++ b/installer/lib/gpg/client.go @@ -0,0 +1,273 @@ +package gpg + +import ( + "errors" + "strings" + + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" +) + +// GpgClient defines the interface for interacting with GPG. +type GpgClient interface { + // CreateKeyPair creates a GPG key pair interactively. + CreateKeyPair() (string, error) + // KeysAvailable returns true if there are secret keys, false otherwise. + KeysAvailable() (bool, error) + // ListAvailableKeys lists all available GPG keys. + ListAvailableKeys() ([]string, error) +} + +var _ GpgClient = (*DefaultGpgClient)(nil) + +// DefaultGpgClient implements GpgClient using OsManager and Commander. +type DefaultGpgClient struct { + osMgr osmanager.OsManager + fs utils.FileSystem + commander utils.Commander + logger logger.Logger +} + +// NewDefaultGpgClient constructs a DefaultGpgClient with the given OsManager, Commander, and Logger. +func NewDefaultGpgClient( + osMgr osmanager.OsManager, + fs utils.FileSystem, + commander utils.Commander, + logger logger.Logger, +) *DefaultGpgClient { + return &DefaultGpgClient{ + osMgr: osMgr, + fs: fs, + commander: commander, + logger: logger, + } +} + +// CreateKeyPair implements GpgClient. +func (c *DefaultGpgClient) CreateKeyPair() (string, error) { + c.logger.Debug("Creating GPG key pair") + + activeTerminal, err := c.detectTTY() + if err != nil { + return "", err + } + + // Run the command interactively while capturing output for parsing + args := []string{"--gen-key", "--pinentry-mode", "loopback", "--default-new-key-algo", "nistp256"} + result, err := c.commander.RunCommand("gpg", args, utils.WithInteractiveCapture(), utils.WithEnv(map[string]string{"GPG_TTY": activeTerminal})) + if err != nil { + return "", err + } else if result.ExitCode != 0 { + return "", errors.New("failed to create GPG key pair: " + result.StderrString()) + } + + // Parse the output to extract the key ID using multiple robust methods + keyID, err := c.extractKeyIDFromGPGOutput(result.String()) + if err != nil { + return "", errors.New("failed to extract GPG key ID from output: " + err.Error()) + } + return keyID, nil +} + +// ListAvailableKeys implements GpgClient. +func (c *DefaultGpgClient) ListAvailableKeys() ([]string, error) { + c.logger.Debug("Listing available GPG keys") + + args := []string{"--list-secret-keys", "--keyid-format", "LONG"} + result, err := c.commander.RunCommand("gpg", args, utils.WithCaptureOutput()) + if err != nil { + return nil, err + } + + // Search for "sec" in output. + trimmedOutput := strings.TrimSpace(result.String()) + outputLines := strings.Split(trimmedOutput, "\n") + + keys := make([]string, 0, len(outputLines)) + for _, line := range outputLines { + if strings.HasPrefix(line, "sec") { + // Extract the key ID from the line. + parts := strings.Fields(line) + if len(parts) > 1 { + fullKeyID := parts[1] + // The key ID is everything after the last slash. + slashIndex := strings.LastIndex(fullKeyID, "/") + if slashIndex != -1 { + keyID := fullKeyID[slashIndex+1:] + keys = append(keys, keyID) + } + } + } + } + + if len(keys) == 0 { + return nil, nil // No keys found + } + + return keys, nil +} + +// KeysAvailable returns true if there are secret keys, false otherwise. +func (c *DefaultGpgClient) KeysAvailable() (bool, error) { + c.logger.Debug("Checking for available GPG keys") + + availableKeys, err := c.ListAvailableKeys() + if err != nil { + return false, err + } + + return len(availableKeys) > 0, nil +} + +// detectTTY attempts to detect the current TTY using multiple fallback methods. +// This is essential for GPG operations that require interactive input, as GPG needs +// to know which terminal to use for user prompts. +// +// The method tries the following approaches in order: +// 1. Check if GPG_TTY environment variable is already set +// 2. Try running the 'tty' command to get the current terminal +// 3. Use /dev/tty as a fallback if it exists +// 4. Check other common TTY environment variables (TTY, TERM_TTY) +// +// Returns the detected TTY path or an error if no TTY can be determined. +func (c *DefaultGpgClient) detectTTY() (string, error) { + c.logger.Trace("Detecting TTY for GPG operations") + + // Method 1: Check if GPG_TTY is already set in the environment + if tty := c.osMgr.Getenv("GPG_TTY"); tty != "" { + c.logger.Trace("Using GPG_TTY from environment: " + tty) + return strings.TrimSpace(tty), nil + } + + // Method 2: Try the tty command + ttyOutput, err := c.commander.RunCommand("tty", []string{}, utils.WithCaptureOutput()) + if err == nil && ttyOutput.ExitCode == 0 && len(ttyOutput.Stdout) > 0 { + tty := strings.TrimSpace(ttyOutput.String()) + if tty != "" && tty != "not a tty" { + c.logger.Trace("Detected TTY using tty command: " + tty) + return tty, nil + } + } + + // Method 3: Try /dev/tty directly + if exists, err := c.fs.PathExists("/dev/tty"); err == nil && exists { + c.logger.Trace("Using /dev/tty as fallback") + return "/dev/tty", nil + } + + // Method 4: Check common TTY environment variables + for _, envVar := range []string{"TTY", "TERM_TTY"} { + if tty := c.osMgr.Getenv(envVar); tty != "" { + c.logger.Trace("Using TTY from " + envVar + ": " + tty) + return strings.TrimSpace(tty), nil + } + } + + return "", errors.New("unable to detect TTY for GPG operations - ensure you're running in an interactive terminal or set GPG_TTY environment variable") +} + +// extractKeyIDFromGPGOutput parses GPG output to extract the key ID using multiple robust methods. +func (c *DefaultGpgClient) extractKeyIDFromGPGOutput(output string) (string, error) { + c.logger.Trace("Extracting key ID from GPG output") + + // Method 1: Look for "key marked as ultimately trusted" pattern + // This is the most reliable pattern across distributions + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.Contains(line, "marked as ultimately trusted") { + // Pattern: "gpg: key ABC123DEF456 marked as ultimately trusted" + parts := strings.Fields(line) + for i, part := range parts { + if part == "key" && i+1 < len(parts) { + keyID := parts[i+1] + c.logger.Trace("Found key ID using 'ultimately trusted' pattern: " + keyID) + return keyID, nil + } + } + } + } + + // Method 2: Look for "gpg: : public key" pattern + for _, line := range lines { + if strings.Contains(line, ": public key") { + // Pattern: "gpg: ABC123DEF456: public key" + parts := strings.Split(line, ":") + if len(parts) >= 2 { + keyID := strings.TrimSpace(parts[1]) + if keyID != "" && keyID != "gpg" { + c.logger.Trace("Found key ID using 'public key' pattern: " + keyID) + return keyID, nil + } + } + } + } + + // Method 3: Look for fingerprint patterns in pub lines + // Parse lines that start with "pub" and extract the key ID + for i, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "pub") { + // The key ID might be on the same line or the next line + // Format: "pub nistp256 2024-01-01 [SC]" followed by " ABC123DEF456" + if i+1 < len(lines) { + nextLine := strings.TrimSpace(lines[i+1]) + // Check if next line looks like a key ID (alphanumeric, reasonable length) + if len(nextLine) >= 8 && len(nextLine) <= 40 && isAlphanumeric(nextLine) { + c.logger.Trace("Found key ID using pub line pattern: " + nextLine) + return nextLine, nil + } + } + + // Also check if key ID is on the same line after the algorithm + parts := strings.Fields(line) + if len(parts) >= 2 { + // Sometimes format is "pub nistp256/ABC123DEF456 2024-01-01 [SC]" + for _, part := range parts { + if strings.Contains(part, "/") { + keyParts := strings.Split(part, "/") + if len(keyParts) == 2 { + keyID := keyParts[1] + if len(keyID) >= 8 && isAlphanumeric(keyID) { + c.logger.Trace("Found key ID using pub/keyid pattern: " + keyID) + return keyID, nil + } + } + } + } + } + } + } + + // Method 4: Look for revocation certificate patterns + for _, line := range lines { + if strings.Contains(line, "revocation certificate stored") { + // Pattern: "gpg: revocation certificate stored as '/path/ABC123DEF456.rev'" + // Extract the filename and get the key ID from it + if strings.Contains(line, ".rev") { + parts := strings.Split(line, "/") + if len(parts) > 0 { + filename := parts[len(parts)-1] + if strings.HasSuffix(filename, ".rev'") || strings.HasSuffix(filename, ".rev\"") { + keyID := strings.TrimSuffix(strings.TrimSuffix(filename, ".rev'"), ".rev\"") + if len(keyID) >= 8 && isAlphanumeric(keyID) { + c.logger.Trace("Found key ID using revocation certificate pattern: " + keyID) + return keyID, nil + } + } + } + } + } + } + + return "", errors.New("could not find key ID in GPG output using any known pattern") +} + +// isAlphanumeric checks if a string contains only alphanumeric characters. +func isAlphanumeric(s string) bool { + for _, r := range s { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) { + return false + } + } + return true +} diff --git a/installer/lib/gpg/client_test.go b/installer/lib/gpg/client_test.go new file mode 100644 index 0000000..303ccb0 --- /dev/null +++ b/installer/lib/gpg/client_test.go @@ -0,0 +1,506 @@ +package gpg_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/MrPointer/dotfiles/installer/lib/gpg" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" +) + +func Test_NewDefaultGpgClient_ReturnsValidInstance(t *testing.T) { + mockOsManager := &osmanager.MoqOsManager{} + mockFilesystem := &utils.MoqFileSystem{} + mockCommander := &utils.MoqCommander{} + + client := gpg.NewDefaultGpgClient(mockOsManager, mockFilesystem, mockCommander, logger.DefaultLogger) + + require.NotNil(t, client) +} + +func Test_CreateKeyPair_ReturnsKeyID_WhenCommandSucceeds(t *testing.T) { + testCases := []struct { + name string + commandOutput string + expectedKeyID string + gpgTTY string + }{ + { + name: "RSA 3072-bit key with GPG_TTY set", + commandOutput: `gpg: key ABC123DEF456 marked as ultimately trusted +gpg: revocation certificate stored as '/home/user/.gnupg/openpgp-revocs.d/ABC123DEF456789.rev' +pub rsa3072 2024-01-01 [SC] + ABC123DEF456789 +uid Test User `, + expectedKeyID: "ABC123DEF456", + gpgTTY: "/dev/pts/0", + }, + { + name: "RSA 4096-bit key with tty command fallback", + commandOutput: `gpg: key XYZ789ABC123 marked as ultimately trusted +gpg: revocation certificate stored as '/home/user/.gnupg/openpgp-revocs.d/XYZ789ABC123456.rev' +pub rsa4096 2024-01-02 [SC] + XYZ789ABC123456 +uid Another User `, + expectedKeyID: "XYZ789ABC123", + gpgTTY: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockOsManager := &osmanager.MoqOsManager{ + GetenvFunc: func(key string) string { + if key == "GPG_TTY" { + return tc.gpgTTY + } + return "" + }, + } + mockFilesystem := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + return path == "/dev/tty", nil + }, + } + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "tty" && tc.gpgTTY == "" { + return &utils.Result{ + Stdout: []byte("/dev/pts/1\n"), + ExitCode: 0, + }, nil + } + if name == "gpg" { + return &utils.Result{ + Stdout: []byte(tc.commandOutput), + ExitCode: 0, + }, nil + } + return &utils.Result{ExitCode: 0}, nil + }, + } + + client := gpg.NewDefaultGpgClient(mockOsManager, mockFilesystem, mockCommander, logger.DefaultLogger) + + keyID, err := client.CreateKeyPair() + + require.NoError(t, err) + require.Equal(t, tc.expectedKeyID, keyID) + + calls := mockCommander.RunCommandCalls() + // Find GPG call + var gpgCall bool + for _, call := range calls { + if call.Name == "gpg" && len(call.Args) > 0 && call.Args[0] == "--gen-key" { + gpgCall = true + require.Equal(t, []string{"--gen-key", "--pinentry-mode", "loopback", "--default-new-key-algo", "nistp256"}, call.Args) + break + } + } + require.True(t, gpgCall, "Expected GPG command call") + }) + } +} + +func Test_CreateKeyPair_ReturnsError_WhenTTYDetectionFails(t *testing.T) { + mockOsManager := &osmanager.MoqOsManager{ + GetenvFunc: func(key string) string { + return "" + }, + } + mockFilesystem := &utils.MoqFileSystem{ + PathExistsFunc: func(path string) (bool, error) { + return path == "", errors.New("path not found") + }, + } + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return &utils.Result{ExitCode: 1}, errors.New("command failed") + }, + } + + client := gpg.NewDefaultGpgClient(mockOsManager, mockFilesystem, mockCommander, logger.DefaultLogger) + + keyID, err := client.CreateKeyPair() + require.Error(t, err) + require.Empty(t, keyID) + require.Contains(t, err.Error(), "unable to detect TTY") +} + +func Test_CreateKeyPair_ExtractsKeyIDFromDifferentOutputFormats(t *testing.T) { + testCases := []struct { + name string + gpgOutput string + expectedKeyID string + }{ + { + name: "ultimately trusted pattern", + gpgOutput: `gpg: directory '/home/user/.gnupg' created +gpg: keybox '/home/user/.gnupg/pubring.kbx' created +gpg: key ABC123DEF456 marked as ultimately trusted +gpg: directory '/home/user/.gnupg/openpgp-revocs.d' created`, + expectedKeyID: "ABC123DEF456", + }, + { + name: "public key pattern", + gpgOutput: `gpg: directory '/home/user/.gnupg' created +gpg: XYZ789ABC123: public key "Test User " imported +gpg: Total number processed: 1`, + expectedKeyID: "XYZ789ABC123", + }, + { + name: "pub line pattern with next line key", + gpgOutput: `gpg: directory '/home/user/.gnupg' created +pub nistp256 2024-01-01 [SC] + DEF456GHI789 +uid Test User `, + expectedKeyID: "DEF456GHI789", + }, + { + name: "revocation certificate pattern", + gpgOutput: `gpg: directory '/home/user/.gnupg' created +gpg: keybox '/home/user/.gnupg/pubring.kbx' created +gpg: revocation certificate stored as '/home/user/.gnupg/openpgp-revocs.d/JKL012MNO345.rev'`, + expectedKeyID: "JKL012MNO345", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockOsManager := &osmanager.MoqOsManager{ + GetenvFunc: func(key string) string { + if key == "GPG_TTY" { + return "/dev/pts/0" + } + return "" + }, + } + mockFileSystem := &utils.MoqFileSystem{} + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "gpg" { + return &utils.Result{ + Stdout: []byte(tc.gpgOutput), + ExitCode: 0, + }, nil + } + return &utils.Result{ExitCode: 0}, nil + }, + } + client := gpg.NewDefaultGpgClient(mockOsManager, mockFileSystem, mockCommander, logger.DefaultLogger) + + keyID, err := client.CreateKeyPair() + require.NoError(t, err) + require.Equal(t, tc.expectedKeyID, keyID) + }) + } +} + +func Test_CreateKeyPair_ReturnsError_WhenKeyIDCannotBeExtractedFromOutput(t *testing.T) { + mockOsManager := &osmanager.MoqOsManager{ + GetenvFunc: func(key string) string { + if key == "GPG_TTY" { + return "/dev/pts/0" + } + return "" + }, + } + mockFileSystem := &utils.MoqFileSystem{} + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "gpg" { + return &utils.Result{ + Stdout: []byte(`gpg: directory '/home/user/.gnupg' created +gpg: keybox '/home/user/.gnupg/pubring.kbx' created +Some random output without key information`), + ExitCode: 0, + }, nil + } + return &utils.Result{ExitCode: 0}, nil + }, + } + client := gpg.NewDefaultGpgClient(mockOsManager, mockFileSystem, mockCommander, logger.DefaultLogger) + + keyID, err := client.CreateKeyPair() + require.Error(t, err) + require.Empty(t, keyID) + require.Contains(t, err.Error(), "could not find key ID in GPG output") +} + +func Test_CreateKeyPair_ReturnsError_WhenGpgCommandExitsWithNonZeroCode(t *testing.T) { + mockOsManager := &osmanager.MoqOsManager{ + GetenvFunc: func(key string) string { + if key == "GPG_TTY" { + return "/dev/pts/0" + } + return "" + }, + } + mockFilesystem := &utils.MoqFileSystem{} + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "gpg" { + return &utils.Result{ + Stderr: []byte("GPG error occurred"), + ExitCode: 1, + }, nil + } + return &utils.Result{ExitCode: 0}, nil + }, + } + + client := gpg.NewDefaultGpgClient(mockOsManager, mockFilesystem, mockCommander, logger.DefaultLogger) + + keyID, err := client.CreateKeyPair() + + require.Error(t, err) + require.Empty(t, keyID) + require.Contains(t, err.Error(), "failed to create GPG key pair") +} + +func Test_CreateKeyPair_ReturnsError_WhenOutputHasInsufficientLines(t *testing.T) { + mockOsManager := &osmanager.MoqOsManager{ + GetenvFunc: func(key string) string { + if key == "GPG_TTY" { + return "/dev/pts/0" + } + return "" + }, + } + mockFilesystem := &utils.MoqFileSystem{} + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "gpg" { + return &utils.Result{ + Stdout: []byte("line1\nline2"), + ExitCode: 0, + }, nil + } + return &utils.Result{ExitCode: 0}, nil + }, + } + + client := gpg.NewDefaultGpgClient(mockOsManager, mockFilesystem, mockCommander, logger.DefaultLogger) + + keyID, err := client.CreateKeyPair() + + require.Error(t, err) + require.Empty(t, keyID) + require.Contains(t, err.Error(), "failed to extract GPG key ID") +} + +func Test_CreateKeyPair_ReturnsError_WhenKeyIDCannotBeExtracted(t *testing.T) { + mockOsManager := &osmanager.MoqOsManager{ + GetenvFunc: func(key string) string { + if key == "GPG_TTY" { + return "/dev/pts/0" + } + return "" + }, + } + mockFilesystem := &utils.MoqFileSystem{} + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "gpg" { + return &utils.Result{ + Stdout: []byte(`line1 +line2 +line3 without pub prefix`), + ExitCode: 0, + }, nil + } + return &utils.Result{ExitCode: 0}, nil + }, + } + + client := gpg.NewDefaultGpgClient(mockOsManager, mockFilesystem, mockCommander, logger.DefaultLogger) + + keyID, err := client.CreateKeyPair() + + require.Error(t, err) + require.Empty(t, keyID) + require.Contains(t, err.Error(), "could not find key ID in GPG output") +} + +func Test_ListAvailableKeys_ReturnsKeys_WhenKeysExist(t *testing.T) { + testCases := []struct { + name string + commandOutput string + expectedKeys []string + }{ + { + name: "single key", + commandOutput: `sec rsa3072/ABC123DEF456 2024-01-01 [SC] +uid [ultimate] Test User +ssb rsa3072/789GHI012JKL 2024-01-01 [E]`, + expectedKeys: []string{"ABC123DEF456"}, + }, + { + name: "multiple keys", + commandOutput: `sec rsa3072/ABC123DEF456 2024-01-01 [SC] +uid [ultimate] Test User +ssb rsa3072/789GHI012JKL 2024-01-01 [E] +sec rsa4096/XYZ789ABC123 2024-01-02 [SC] +uid [ultimate] Another User +ssb rsa4096/456DEF789GHI 2024-01-02 [E]`, + expectedKeys: []string{"ABC123DEF456", "XYZ789ABC123"}, + }, + { + name: "key with different format", + commandOutput: `sec ed25519/FEDCBA987654 2024-01-01 [SC] +uid [ultimate] Ed User +ssb cv25519/321FED654CBA 2024-01-01 [E]`, + expectedKeys: []string{"FEDCBA987654"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockOsManager := &osmanager.MoqOsManager{} + mockFilesystem := &utils.MoqFileSystem{} + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return &utils.Result{ + Stdout: []byte(tc.commandOutput), + ExitCode: 0, + }, nil + }, + } + + client := gpg.NewDefaultGpgClient(mockOsManager, mockFilesystem, mockCommander, logger.DefaultLogger) + + keys, err := client.ListAvailableKeys() + + require.NoError(t, err) + require.Equal(t, tc.expectedKeys, keys) + + calls := mockCommander.RunCommandCalls() + require.Len(t, calls, 1) + require.Equal(t, "gpg", calls[0].Name) + require.Equal(t, []string{"--list-secret-keys", "--keyid-format", "LONG"}, calls[0].Args) + }) + } +} + +func Test_ListAvailableKeys_ReturnsNil_WhenNoKeysExist(t *testing.T) { + mockOsManager := &osmanager.MoqOsManager{} + mockFilesystem := &utils.MoqFileSystem{} + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return &utils.Result{ + Stdout: []byte("gpg: no secret keys found"), + ExitCode: 0, + }, nil + }, + } + + client := gpg.NewDefaultGpgClient(mockOsManager, mockFilesystem, mockCommander, logger.DefaultLogger) + + keys, err := client.ListAvailableKeys() + + require.NoError(t, err) + require.Nil(t, keys) +} + +func Test_ListAvailableKeys_ReturnsError_WhenCommandFails(t *testing.T) { + mockOsManager := &osmanager.MoqOsManager{} + mockFilesystem := &utils.MoqFileSystem{} + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return nil, errors.New("gpg command failed") + }, + } + + client := gpg.NewDefaultGpgClient(mockOsManager, mockFilesystem, mockCommander, logger.DefaultLogger) + + keys, err := client.ListAvailableKeys() + + require.Error(t, err) + require.Nil(t, keys) + require.Contains(t, err.Error(), "gpg command failed") +} + +func Test_ListAvailableKeys_HandlesIncompleteSecLines(t *testing.T) { + mockOsManager := &osmanager.MoqOsManager{} + mockFilesystem := &utils.MoqFileSystem{} + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return &utils.Result{ + Stdout: []byte(`sec +sec incomplete +sec rsa3072/ABC123DEF456 2024-01-01 [SC]`), + ExitCode: 0, + }, nil + }, + } + + client := gpg.NewDefaultGpgClient(mockOsManager, mockFilesystem, mockCommander, logger.DefaultLogger) + + keys, err := client.ListAvailableKeys() + + require.NoError(t, err) + require.Equal(t, []string{"ABC123DEF456"}, keys) +} + +func Test_KeysAvailable_ReturnsTrue_WhenKeysExist(t *testing.T) { + mockOsManager := &osmanager.MoqOsManager{} + mockFilesystem := &utils.MoqFileSystem{} + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return &utils.Result{ + Stdout: []byte(`sec rsa3072/ABC123DEF456 2024-01-01 [SC] +uid [ultimate] Test User `), + ExitCode: 0, + }, nil + }, + } + + client := gpg.NewDefaultGpgClient(mockOsManager, mockFilesystem, mockCommander, logger.DefaultLogger) + + available, err := client.KeysAvailable() + + require.NoError(t, err) + require.True(t, available) +} + +func Test_KeysAvailable_ReturnsFalse_WhenNoKeysExist(t *testing.T) { + mockOsManager := &osmanager.MoqOsManager{} + mockFilesystem := &utils.MoqFileSystem{} + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return &utils.Result{ + Stdout: []byte("gpg: no secret keys found"), + ExitCode: 0, + }, nil + }, + } + + client := gpg.NewDefaultGpgClient(mockOsManager, mockFilesystem, mockCommander, logger.DefaultLogger) + + available, err := client.KeysAvailable() + + require.NoError(t, err) + require.False(t, available) +} + +func Test_KeysAvailable_ReturnsError_WhenListAvailableKeysFails(t *testing.T) { + mockOsManager := &osmanager.MoqOsManager{} + mockFilesystem := &utils.MoqFileSystem{} + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + return nil, errors.New("gpg list command failed") + }, + } + + client := gpg.NewDefaultGpgClient(mockOsManager, mockFilesystem, mockCommander, logger.DefaultLogger) + + available, err := client.KeysAvailable() + + require.Error(t, err) + require.False(t, available) + require.Contains(t, err.Error(), "gpg list command failed") +} diff --git a/installer/lib/gpg/export_test.go b/installer/lib/gpg/export_test.go new file mode 100644 index 0000000..ff106f6 --- /dev/null +++ b/installer/lib/gpg/export_test.go @@ -0,0 +1,4 @@ +package gpg + +// ExportedExtractGpgVersion exposes the extractGpgVersion function for testing. +var ExportedExtractGpgVersion = extractGpgVersion diff --git a/installer/lib/gpg/installer.go b/installer/lib/gpg/installer.go new file mode 100644 index 0000000..96daff7 --- /dev/null +++ b/installer/lib/gpg/installer.go @@ -0,0 +1,140 @@ +package gpg + +import ( + "context" + "errors" + "strings" + + "github.com/Masterminds/semver" + + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" +) + +const supportedGpgVersionConstraintString = ">=2.2.0" + +type GpgClientInstaller interface { + IsAvailable() (bool, error) + Install(ctx context.Context) error +} + +type gpgInstaller struct { + logger logger.Logger + commander utils.Commander + osManager osmanager.OsManager + packageManager pkgmanager.PackageManager +} + +func NewGpgInstaller( + logger logger.Logger, + commander utils.Commander, + osManager osmanager.OsManager, + packageManager pkgmanager.PackageManager, +) GpgClientInstaller { + return &gpgInstaller{ + logger: logger, + commander: commander, + osManager: osManager, + packageManager: packageManager, + } +} + +func (g *gpgInstaller) IsAvailable() (bool, error) { + g.logger.Debug("Checking if GPG is available") + + // Check if gpg is available. + gpgExists, err := g.osManager.ProgramExists("gpg") + if err != nil { + return false, err + } + if !gpgExists { + g.logger.Warning("GPG is not available. Required for GPG operations.") + return false, nil + } + g.logger.Trace("GPG is available") + + g.logger.Trace("Checking if GPG version is compatible") + // gpg is available, now ensure its version is compatible (we required anything above 2.2). + versionMatches, err := gpgVersionMatches(g) + if err != nil { + return false, err + } + if !versionMatches { + g.logger.Warning("GPG version is not compatible. Required version is >=2.2.0") + return false, nil + } + g.logger.Trace("GPG version is compatible") + + g.logger.Trace("Checking if GPG agent is available") + gpgAgentExists, err := g.osManager.ProgramExists("gpg-agent") + if err != nil { + return false, err + } + if !gpgAgentExists { + g.logger.Warning("GPG agent is not available. Required for GPG operations.") + return false, nil + } + g.logger.Trace("GPG agent is available") + + g.logger.Debug("GPG is compatible and agent is available") + return true, nil +} + +func gpgVersionMatches(g *gpgInstaller) (bool, error) { + gpgVersion, err := g.osManager.GetProgramVersion("gpg", extractGpgVersion) + if err != nil { + return false, err + } + + constraints, err := semver.NewConstraint(supportedGpgVersionConstraintString) + if err != nil { + return false, err + } + + version, err := semver.NewVersion(gpgVersion) + if err != nil { + return false, err + } + + if !constraints.Check(version) { + return false, nil + } + + return true, nil +} + +func extractGpgVersion(rawVersion string) (string, error) { + // Extract the version number from the raw version string. + // Take the first row, split by space, and return the 3rd element (the version number). + lines := strings.Split(rawVersion, "\n") + if len(lines) == 1 { + return "", errors.New("line count is 1, meaning there are no newlines in the version string") + } + + const minimumRequiredElements = 3 + parts := strings.Split(lines[0], " ") + if len(parts) < minimumRequiredElements { + return "", errors.New("version string does not contain enough parts to extract version") + } + + return parts[2], nil +} + +func (g *gpgInstaller) Install(ctx context.Context) error { + g.logger.Debug("Installing GPG client") + + versionConstraints, err := semver.NewConstraint(supportedGpgVersionConstraintString) + if err != nil { + return errors.New("failed to create version constraints: " + err.Error()) + } + + err = g.packageManager.InstallPackage(pkgmanager.NewRequestedPackageInfo("gpg", versionConstraints)) + if err != nil { + return errors.New("failed to install GPG client: " + err.Error()) + } + + g.logger.Debug("GPG client installed successfully") + return nil +} diff --git a/installer/lib/gpg/installer_test.go b/installer/lib/gpg/installer_test.go new file mode 100644 index 0000000..b6a45e9 --- /dev/null +++ b/installer/lib/gpg/installer_test.go @@ -0,0 +1,256 @@ +package gpg_test + +import ( + "context" + "testing" + + "github.com/MrPointer/dotfiles/installer/lib/gpg" + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_GpgIsReportedAsUnavailable_WhenGpgIsNotInstalled(t *testing.T) { + // Arrange + commanderMock := &utils.MoqCommander{} + + osManagerMock := &osmanager.MoqOsManager{ + ProgramExistsFunc: func(program string) (bool, error) { + if program == "gpg" { + return false, nil + } + return true, nil + }, + } + + pkgManagerMock := &pkgmanager.MoqPackageManager{} + + installer := gpg.NewGpgInstaller(logger.DefaultLogger, commanderMock, osManagerMock, pkgManagerMock) + + // Act + available, err := installer.IsAvailable() + + // Assert + require.NoError(t, err) + assert.False(t, available) + assert.Len(t, osManagerMock.ProgramExistsCalls(), 1) + assert.Equal(t, "gpg", osManagerMock.ProgramExistsCalls()[0].Program) + // Warning call assertion removed as we're using DefaultLogger +} + +func Test_GpgAvailabilityCheckFails_WhenGpgProgramExistsFails(t *testing.T) { + // Arrange + commanderMock := &utils.MoqCommander{} + pkgManagerMock := &pkgmanager.MoqPackageManager{} + + osManagerMock := &osmanager.MoqOsManager{ + ProgramExistsFunc: func(program string) (bool, error) { + return false, assert.AnError + }, + } + + installer := gpg.NewGpgInstaller(logger.DefaultLogger, commanderMock, osManagerMock, pkgManagerMock) + + // Act + available, err := installer.IsAvailable() + + // Assert + require.Error(t, err) + assert.False(t, available) + assert.Len(t, osManagerMock.ProgramExistsCalls(), 1) + assert.Equal(t, "gpg", osManagerMock.ProgramExistsCalls()[0].Program) +} + +func Test_GpgIsReportedAsUnavailable_WhenGpgVersionIsIncompatible(t *testing.T) { + // Arrange + commanderMock := &utils.MoqCommander{} + + osManagerMock := &osmanager.MoqOsManager{ + ProgramExistsFunc: func(program string) (bool, error) { + return true, nil + }, + GetProgramVersionFunc: func(program string, versionExtractor osmanager.VersionExtractor, queryArgs ...string) (string, error) { + return "2.1.0", nil + }, + } + + pkgManagerMock := &pkgmanager.MoqPackageManager{} + + installer := gpg.NewGpgInstaller(logger.DefaultLogger, commanderMock, osManagerMock, pkgManagerMock) + + // Act + available, err := installer.IsAvailable() + + // Assert + require.NoError(t, err) + assert.False(t, available) + assert.Len(t, osManagerMock.ProgramExistsCalls(), 1) + assert.Len(t, osManagerMock.GetProgramVersionCalls(), 1) + assert.Equal(t, "gpg", osManagerMock.GetProgramVersionCalls()[0].Program) + // Warning call assertion removed as we're using DefaultLogger +} + +func Test_GpgAvailabilityCheckFails_WhenGetProgramVersionFails(t *testing.T) { + // Arrange + commanderMock := &utils.MoqCommander{} + + osManagerMock := &osmanager.MoqOsManager{ + ProgramExistsFunc: func(program string) (bool, error) { + return true, nil + }, + GetProgramVersionFunc: func(program string, versionExtractor osmanager.VersionExtractor, queryArgs ...string) (string, error) { + return "", assert.AnError + }, + } + + pkgManagerMock := &pkgmanager.MoqPackageManager{} + + installer := gpg.NewGpgInstaller(logger.DefaultLogger, commanderMock, osManagerMock, pkgManagerMock) + + // Act + available, err := installer.IsAvailable() + + // Assert + require.Error(t, err) + assert.False(t, available) + assert.Len(t, osManagerMock.ProgramExistsCalls(), 1) + assert.Len(t, osManagerMock.GetProgramVersionCalls(), 1) +} + +func Test_GpgIsReportedAsUnavailable_WhenGpgAgentIsNotInstalled(t *testing.T) { + // Arrange + commanderMock := &utils.MoqCommander{} + + osManagerMock := &osmanager.MoqOsManager{ + ProgramExistsFunc: func(program string) (bool, error) { + if program == "gpg-agent" { + return false, nil + } + return true, nil + }, + GetProgramVersionFunc: func(program string, versionExtractor osmanager.VersionExtractor, queryArgs ...string) (string, error) { + return "2.3.0", nil + }, + } + + pkgManagerMock := &pkgmanager.MoqPackageManager{} + + installer := gpg.NewGpgInstaller(logger.DefaultLogger, commanderMock, osManagerMock, pkgManagerMock) + + // Act + available, err := installer.IsAvailable() + + // Assert + require.NoError(t, err) + assert.False(t, available) + assert.Len(t, osManagerMock.ProgramExistsCalls(), 2) + assert.Equal(t, "gpg-agent", osManagerMock.ProgramExistsCalls()[1].Program) + // Warning call assertion removed as we're using DefaultLogger +} + +func Test_GpgAvailabilityCheckFails_WhenGpgAgentProgramExistsFails(t *testing.T) { + // Arrange + commanderMock := &utils.MoqCommander{} + pkgManagerMock := &pkgmanager.MoqPackageManager{} + + callCount := 0 + osManagerMock := &osmanager.MoqOsManager{ + ProgramExistsFunc: func(program string) (bool, error) { + callCount++ + if callCount == 1 { + return true, nil // First call for gpg + } + return false, assert.AnError // Second call for gpg-agent + }, + GetProgramVersionFunc: func(program string, versionExtractor osmanager.VersionExtractor, queryArgs ...string) (string, error) { + return "2.3.0", nil + }, + } + + installer := gpg.NewGpgInstaller(logger.DefaultLogger, commanderMock, osManagerMock, pkgManagerMock) + + // Act + available, err := installer.IsAvailable() + + // Assert + require.Error(t, err) + assert.False(t, available) + assert.Len(t, osManagerMock.ProgramExistsCalls(), 2) + assert.Equal(t, "gpg", osManagerMock.ProgramExistsCalls()[0].Program) + assert.Equal(t, "gpg-agent", osManagerMock.ProgramExistsCalls()[1].Program) +} + +func Test_GpgIsReportedAsAvailable_WhenAllRequirementsAreMet(t *testing.T) { + // Arrange + commanderMock := &utils.MoqCommander{} + + osManagerMock := &osmanager.MoqOsManager{ + ProgramExistsFunc: func(program string) (bool, error) { + return true, nil + }, + GetProgramVersionFunc: func(program string, versionExtractor osmanager.VersionExtractor, queryArgs ...string) (string, error) { + return "2.4.0", nil + }, + } + + pkgManagerMock := &pkgmanager.MoqPackageManager{} + + installer := gpg.NewGpgInstaller(logger.DefaultLogger, commanderMock, osManagerMock, pkgManagerMock) + + // Act + available, err := installer.IsAvailable() + + // Assert + require.NoError(t, err) + assert.True(t, available) + assert.Len(t, osManagerMock.ProgramExistsCalls(), 2) + assert.Equal(t, "gpg", osManagerMock.ProgramExistsCalls()[0].Program) + assert.Equal(t, "gpg-agent", osManagerMock.ProgramExistsCalls()[1].Program) +} + +func Test_GpgInstallationFails_WhenPackageManagerFails(t *testing.T) { + // Arrange + commanderMock := &utils.MoqCommander{} + osManagerMock := &osmanager.MoqOsManager{} + + pkgManagerMock := &pkgmanager.MoqPackageManager{ + InstallPackageFunc: func(pkg pkgmanager.RequestedPackageInfo) error { + return assert.AnError + }, + } + + installer := gpg.NewGpgInstaller(logger.DefaultLogger, commanderMock, osManagerMock, pkgManagerMock) + + // Act + err := installer.Install(context.Background()) + + // Assert + require.Error(t, err) + assert.Len(t, pkgManagerMock.InstallPackageCalls(), 1) + assert.Equal(t, "gpg", pkgManagerMock.InstallPackageCalls()[0].RequestedPackageInfo.Name) +} + +func Test_GpgIsInstalledSuccessfully_WhenAllRequirementsAreMet(t *testing.T) { + // Arrange + commanderMock := &utils.MoqCommander{} + osManagerMock := &osmanager.MoqOsManager{} + + pkgManagerMock := &pkgmanager.MoqPackageManager{ + InstallPackageFunc: func(pkg pkgmanager.RequestedPackageInfo) error { + return nil + }, + } + + installer := gpg.NewGpgInstaller(logger.DefaultLogger, commanderMock, osManagerMock, pkgManagerMock) + + // Act + err := installer.Install(context.Background()) + + // Assert + require.NoError(t, err) + assert.Len(t, pkgManagerMock.InstallPackageCalls(), 1) + assert.Equal(t, "gpg", pkgManagerMock.InstallPackageCalls()[0].RequestedPackageInfo.Name) +} diff --git a/installer/lib/packageresolver/PackageManagerResolver_mock.go b/installer/lib/packageresolver/PackageManagerResolver_mock.go new file mode 100644 index 0000000..2a933c8 --- /dev/null +++ b/installer/lib/packageresolver/PackageManagerResolver_mock.go @@ -0,0 +1,83 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package packageresolver + +import ( + "sync" + + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" +) + +// Ensure that MoqPackageManagerResolver does implement PackageManagerResolver. +// If this is not the case, regenerate this file with mockery. +var _ PackageManagerResolver = &MoqPackageManagerResolver{} + +// MoqPackageManagerResolver is a mock implementation of PackageManagerResolver. +// +// func TestSomethingThatUsesPackageManagerResolver(t *testing.T) { +// +// // make and configure a mocked PackageManagerResolver +// mockedPackageManagerResolver := &MoqPackageManagerResolver{ +// ResolveFunc: func(genericPackageCode string, versionConstraintString string) (pkgmanager.RequestedPackageInfo, error) { +// panic("mock out the Resolve method") +// }, +// } +// +// // use mockedPackageManagerResolver in code that requires PackageManagerResolver +// // and then make assertions. +// +// } +type MoqPackageManagerResolver struct { + // ResolveFunc mocks the Resolve method. + ResolveFunc func(genericPackageCode string, versionConstraintString string) (pkgmanager.RequestedPackageInfo, error) + + // calls tracks calls to the methods. + calls struct { + // Resolve holds details about calls to the Resolve method. + Resolve []struct { + // GenericPackageCode is the genericPackageCode argument value. + GenericPackageCode string + // VersionConstraintString is the versionConstraintString argument value. + VersionConstraintString string + } + } + lockResolve sync.RWMutex +} + +// Resolve calls ResolveFunc. +func (mock *MoqPackageManagerResolver) Resolve(genericPackageCode string, versionConstraintString string) (pkgmanager.RequestedPackageInfo, error) { + if mock.ResolveFunc == nil { + panic("MoqPackageManagerResolver.ResolveFunc: method is nil but PackageManagerResolver.Resolve was just called") + } + callInfo := struct { + GenericPackageCode string + VersionConstraintString string + }{ + GenericPackageCode: genericPackageCode, + VersionConstraintString: versionConstraintString, + } + mock.lockResolve.Lock() + mock.calls.Resolve = append(mock.calls.Resolve, callInfo) + mock.lockResolve.Unlock() + return mock.ResolveFunc(genericPackageCode, versionConstraintString) +} + +// ResolveCalls gets all the calls that were made to Resolve. +// Check the length with: +// +// len(mockedPackageManagerResolver.ResolveCalls()) +func (mock *MoqPackageManagerResolver) ResolveCalls() []struct { + GenericPackageCode string + VersionConstraintString string +} { + var calls []struct { + GenericPackageCode string + VersionConstraintString string + } + mock.lockResolve.RLock() + calls = mock.calls.Resolve + mock.lockResolve.RUnlock() + return calls +} diff --git a/installer/lib/packageresolver/integration_test.go b/installer/lib/packageresolver/integration_test.go new file mode 100644 index 0000000..812b813 --- /dev/null +++ b/installer/lib/packageresolver/integration_test.go @@ -0,0 +1,159 @@ +package packageresolver_test + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/MrPointer/dotfiles/installer/lib/packageresolver" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func Test_LoadPackageMappings_CanLoadFromActualFile(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + tempDir := t.TempDir() + configFile := filepath.Join(tempDir, "test-config.yaml") + + // Use the exact structure from embedded packagemap.yaml + configContent := `packages: + git: + apt: + name: git + brew: + name: git + gpg: + apt: + name: gnupg2 + brew: + name: gnupg + dnf: + name: gnupg2` + + err := os.WriteFile(configFile, []byte(configContent), 0644) + require.NoError(t, err) + + v := viper.New() + mappings, err := packageresolver.LoadPackageMappings(v, configFile) + + require.NoError(t, err) + require.NotNil(t, mappings) + require.NotNil(t, mappings.Packages) + require.Len(t, mappings.Packages, 2) + + // Verify packages were loaded correctly from file + gitMapping := mappings.Packages["git"] + require.Equal(t, "git", gitMapping["apt"].Name) + require.Equal(t, "git", gitMapping["brew"].Name) + + gpgMapping := mappings.Packages["gpg"] + require.Equal(t, "gnupg2", gpgMapping["apt"].Name) + require.Equal(t, "gnupg", gpgMapping["brew"].Name) + require.Equal(t, "gnupg2", gpgMapping["dnf"].Name) +} + +func Test_LoadPackageMappings_ReturnsError_WhenFileDoesNotExist(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + v := viper.New() + nonExistentFile := "/non/existent/file.yaml" + + mappings, err := packageresolver.LoadPackageMappings(v, nonExistentFile) + + require.Error(t, err) + require.Nil(t, mappings) + require.Contains(t, err.Error(), "error reading package map file") + require.Contains(t, err.Error(), nonExistentFile) +} + +func Test_LoadPackageMappings_ReturnsError_WhenFileHasInvalidYAML(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + tempDir := t.TempDir() + configFile := filepath.Join(tempDir, "invalid.yaml") + + invalidYAML := `packages: + git: + apt: + name: git + invalid_yaml: [unclosed array` + + err := os.WriteFile(configFile, []byte(invalidYAML), 0644) + require.NoError(t, err) + + v := viper.New() + mappings, err := packageresolver.LoadPackageMappings(v, configFile) + + require.Error(t, err) + require.Nil(t, mappings) + require.Contains(t, err.Error(), "error reading package map file") +} + +func Test_LoadPackageMappings_CanLoadFromEmbeddedConfig(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + v := viper.New() + mappings, err := packageresolver.LoadPackageMappings(v, "") + + require.NoError(t, err) + require.NotNil(t, mappings) + require.NotNil(t, mappings.Packages) + + // The embedded config should contain the packages from packagemap.yaml + // We don't assert specific content since it might change, but verify it loads +} + +func Test_LoadPackageMappings_HandlesLargeConfigFile(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + tempDir := t.TempDir() + configFile := filepath.Join(tempDir, "large-config.yaml") + + // Generate a large config with many packages + configContent := "packages:\n" + packageManagers := []string{"apt", "brew", "dnf", "pacman"} + + // Create 50 packages (reasonable size for performance testing) + for i := 0; i < 50; i++ { + pkgName := fmt.Sprintf("package%02d", i) + configContent += " " + pkgName + ":\n" + + for _, pm := range packageManagers { + configContent += " " + pm + ":\n" + configContent += " name: " + pm + "-pkg-" + fmt.Sprintf("%02d", i) + "\n" + } + } + + err := os.WriteFile(configFile, []byte(configContent), 0644) + require.NoError(t, err) + + v := viper.New() + mappings, err := packageresolver.LoadPackageMappings(v, configFile) + + require.NoError(t, err) + require.NotNil(t, mappings) + + require.Len(t, mappings.Packages, 50) + + // Verify a few random packages to ensure structure is correct + pkg00 := mappings.Packages["package00"] + require.Len(t, pkg00, len(packageManagers)) + require.Equal(t, "apt-pkg-00", pkg00["apt"].Name) + require.Equal(t, "brew-pkg-00", pkg00["brew"].Name) + + pkg49 := mappings.Packages["package49"] + require.Len(t, pkg49, len(packageManagers)) + require.Equal(t, "apt-pkg-49", pkg49["apt"].Name) +} diff --git a/installer/lib/packageresolver/loader.go b/installer/lib/packageresolver/loader.go new file mode 100644 index 0000000..0cfd065 --- /dev/null +++ b/installer/lib/packageresolver/loader.go @@ -0,0 +1,47 @@ +package packageresolver + +import ( + "bytes" + "fmt" + + "github.com/MrPointer/dotfiles/installer/internal/config" + "github.com/spf13/viper" +) + +// LoadPackageMappings loads the package mapping configuration. +// It first tries to load from the specified `packageMapFile`. If `packageMapFile` is empty +// or loading fails and fallback is implicitly desired, it loads from the embedded default configuration. +func LoadPackageMappings(v *viper.Viper, packageMapFile string) (*PackageMappingCollection, error) { + var mappingsCfg PackageMappingCollection + + if packageMapFile != "" { + v.SetConfigFile(packageMapFile) + if err := v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("error reading package map file '%s': %w", packageMapFile, err) + } + // Consider adding logging for which config file is used, e.g.: + // fmt.Println("Using package map file:", v.ConfigFileUsed()) + } else { + // Use embedded configuration if no file is specified + v.SetConfigType("yaml") + embeddedData, err := config.GetRawEmbeddedPackageMapConfig() + if err != nil { + return nil, fmt.Errorf("error loading embedded package map config: %w", err) + } + if err := v.ReadConfig(bytes.NewBuffer(embeddedData)); err != nil { + return nil, fmt.Errorf("error reading embedded package map config: %w", err) + } + } + + if err := v.Unmarshal(&mappingsCfg); err != nil { + return nil, fmt.Errorf("error parsing package map configuration: %w", err) + } + + // Ensure Packages map is initialized to prevent nil pointer dereference later. + // This is important if the "packages" key is missing or empty in the config. + if mappingsCfg.Packages == nil { + mappingsCfg.Packages = make(map[string]PackageMapping) + } + + return &mappingsCfg, nil +} diff --git a/installer/lib/packageresolver/loader_test.go b/installer/lib/packageresolver/loader_test.go new file mode 100644 index 0000000..5f02a34 --- /dev/null +++ b/installer/lib/packageresolver/loader_test.go @@ -0,0 +1,349 @@ +package packageresolver_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/MrPointer/dotfiles/installer/lib/packageresolver" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +// Unit tests using in-memory YAML data + +func Test_LoadPackageMappings_CanParseValidYAMLStructure(t *testing.T) { + yamlContent := `packages: + neovim: + apt: + name: neovim + brew: + name: neovim + git: + apt: + name: git + brew: + name: git` + + v := viper.New() + v.SetConfigType("yaml") + err := v.ReadConfig(strings.NewReader(yamlContent)) + require.NoError(t, err) + + var mappingsCfg packageresolver.PackageMappingCollection + err = v.Unmarshal(&mappingsCfg) + require.NoError(t, err) + + // Ensure Packages map is initialized (mimicking loader behavior) + if mappingsCfg.Packages == nil { + mappingsCfg.Packages = make(map[string]packageresolver.PackageMapping) + } + + require.NotNil(t, mappingsCfg.Packages) + require.Len(t, mappingsCfg.Packages, 2) + require.Contains(t, mappingsCfg.Packages, "neovim") + require.Contains(t, mappingsCfg.Packages, "git") + + neovimMapping := mappingsCfg.Packages["neovim"] + require.Len(t, neovimMapping, 2) + require.Equal(t, "neovim", neovimMapping["apt"].Name) + require.Equal(t, "neovim", neovimMapping["brew"].Name) + + gitMapping := mappingsCfg.Packages["git"] + require.Len(t, gitMapping, 2) + require.Equal(t, "git", gitMapping["apt"].Name) + require.Equal(t, "git", gitMapping["brew"].Name) +} + +func Test_LoadPackageMappings_CanLoadFromEmbeddedConfig_WhenNoFileSpecified(t *testing.T) { + v := viper.New() + + mappings, err := packageresolver.LoadPackageMappings(v, "") + + require.NoError(t, err) + require.NotNil(t, mappings) + require.NotNil(t, mappings.Packages) +} + +func Test_LoadPackageMappings_ReturnsError_WhenYAMLIsInvalid(t *testing.T) { + invalidYAML := `packages: + neovim: + apt: + name: neovim + brew: + name: neovim + invalid_yaml: [unclosed array` + + v := viper.New() + v.SetConfigType("yaml") + err := v.ReadConfig(strings.NewReader(invalidYAML)) + + require.Error(t, err) +} + +func Test_LoadPackageMappings_InitializesEmptyPackagesMap_WhenConfigHasNoPackages(t *testing.T) { + yamlContent := `packages: {}` + + v := viper.New() + v.SetConfigType("yaml") + err := v.ReadConfig(strings.NewReader(yamlContent)) + require.NoError(t, err) + + var mappingsCfg packageresolver.PackageMappingCollection + err = v.Unmarshal(&mappingsCfg) + require.NoError(t, err) + + // Ensure Packages map is initialized (mimicking loader behavior) + if mappingsCfg.Packages == nil { + mappingsCfg.Packages = make(map[string]packageresolver.PackageMapping) + } + + require.NotNil(t, mappingsCfg.Packages) + require.Empty(t, mappingsCfg.Packages) +} + +func Test_LoadPackageMappings_InitializesEmptyPackagesMap_WhenPackagesKeyIsMissing(t *testing.T) { + yamlContent := `other_config: + some_value: test` + + v := viper.New() + v.SetConfigType("yaml") + err := v.ReadConfig(strings.NewReader(yamlContent)) + require.NoError(t, err) + + var mappingsCfg packageresolver.PackageMappingCollection + err = v.Unmarshal(&mappingsCfg) + require.NoError(t, err) + + // Ensure Packages map is initialized (mimicking loader behavior) + if mappingsCfg.Packages == nil { + mappingsCfg.Packages = make(map[string]packageresolver.PackageMapping) + } + + require.NotNil(t, mappingsCfg.Packages) + require.Empty(t, mappingsCfg.Packages) +} + +func Test_LoadPackageMappings_CanParseComplexConfiguration(t *testing.T) { + yamlContent := `packages: + nodejs: + apt: + name: nodejs + brew: + name: node + dnf: + name: nodejs + pacman: + name: nodejs + python: + apt: + name: python3 + brew: + name: python@3.11 + docker: + apt: + name: docker.io + brew: + name: docker` + + v := viper.New() + v.SetConfigType("yaml") + err := v.ReadConfig(strings.NewReader(yamlContent)) + require.NoError(t, err) + + var mappingsCfg packageresolver.PackageMappingCollection + err = v.Unmarshal(&mappingsCfg) + require.NoError(t, err) + + // Ensure Packages map is initialized (mimicking loader behavior) + if mappingsCfg.Packages == nil { + mappingsCfg.Packages = make(map[string]packageresolver.PackageMapping) + } + + require.NotNil(t, mappingsCfg.Packages) + require.Len(t, mappingsCfg.Packages, 3) + + // Test nodejs mapping + nodejsMapping := mappingsCfg.Packages["nodejs"] + require.Len(t, nodejsMapping, 4) + require.Equal(t, "nodejs", nodejsMapping["apt"].Name) + require.Equal(t, "node", nodejsMapping["brew"].Name) + require.Equal(t, "nodejs", nodejsMapping["dnf"].Name) + require.Equal(t, "nodejs", nodejsMapping["pacman"].Name) + + // Test python mapping + pythonMapping := mappingsCfg.Packages["python"] + require.Len(t, pythonMapping, 2) + require.Equal(t, "python3", pythonMapping["apt"].Name) + require.Equal(t, "python@3.11", pythonMapping["brew"].Name) + + // Test docker mapping + dockerMapping := mappingsCfg.Packages["docker"] + require.Len(t, dockerMapping, 2) + require.Equal(t, "docker.io", dockerMapping["apt"].Name) + require.Equal(t, "docker", dockerMapping["brew"].Name) +} + +func Test_LoadPackageMappings_ReturnsError_WhenUnmarshalFails(t *testing.T) { + // This YAML is valid but doesn't match our expected structure + yamlContent := `packages: + - this_should_be_a_map + - not_an_array` + + v := viper.New() + v.SetConfigType("yaml") + err := v.ReadConfig(strings.NewReader(yamlContent)) + require.NoError(t, err) + + var mappingsCfg packageresolver.PackageMappingCollection + err = v.Unmarshal(&mappingsCfg) + + require.Error(t, err) +} + +func Test_LoadPackageMappings_CanHandleEmptyManagersMap(t *testing.T) { + yamlContent := `packages: + test-package: {}` + + v := viper.New() + v.SetConfigType("yaml") + err := v.ReadConfig(strings.NewReader(yamlContent)) + require.NoError(t, err) + + var mappingsCfg packageresolver.PackageMappingCollection + err = v.Unmarshal(&mappingsCfg) + require.NoError(t, err) + + // Ensure Packages map is initialized (mimicking loader behavior) + if mappingsCfg.Packages == nil { + mappingsCfg.Packages = make(map[string]packageresolver.PackageMapping) + } + + require.NotNil(t, mappingsCfg.Packages) + // Empty package mappings are not loaded, so we expect 0 packages + require.Empty(t, mappingsCfg.Packages) +} + +func Test_LoadPackageMappings_PreservesViperConfigurationState(t *testing.T) { + yamlContent := `packages: + test: + apt: + name: test-package` + + v := viper.New() + originalValue := "test-value" + v.Set("test-key", originalValue) + + v.SetConfigType("yaml") + err := v.ReadConfig(strings.NewReader(yamlContent)) + require.NoError(t, err) + + var mappingsCfg packageresolver.PackageMappingCollection + err = v.Unmarshal(&mappingsCfg) + require.NoError(t, err) + + // Verify that the viper instance still contains our original value + require.Equal(t, originalValue, v.Get("test-key")) +} + +func Test_LoadPackageMappings_MatchesRealWorldStructure(t *testing.T) { + // This matches the actual structure from packagemap.yaml + yamlContent := `packages: + git: + apt: + name: git + brew: + name: git + gpg: + apt: + name: gnupg2 + brew: + name: gnupg + dnf: + name: gnupg2 + neovim: + apt: + name: neovim + brew: + name: neovim + zsh: + apt: + name: zsh + brew: + name: zsh` + + v := viper.New() + v.SetConfigType("yaml") + err := v.ReadConfig(strings.NewReader(yamlContent)) + require.NoError(t, err) + + var mappingsCfg packageresolver.PackageMappingCollection + err = v.Unmarshal(&mappingsCfg) + require.NoError(t, err) + + // Ensure Packages map is initialized (mimicking loader behavior) + if mappingsCfg.Packages == nil { + mappingsCfg.Packages = make(map[string]packageresolver.PackageMapping) + } + + require.NotNil(t, mappingsCfg.Packages) + require.Len(t, mappingsCfg.Packages, 4) + + // Verify git package + gitMapping := mappingsCfg.Packages["git"] + require.Len(t, gitMapping, 2) + require.Equal(t, "git", gitMapping["apt"].Name) + require.Equal(t, "git", gitMapping["brew"].Name) + + // Verify gpg package with different names per manager + gpgMapping := mappingsCfg.Packages["gpg"] + require.Len(t, gpgMapping, 3) + require.Equal(t, "gnupg2", gpgMapping["apt"].Name) + require.Equal(t, "gnupg", gpgMapping["brew"].Name) + require.Equal(t, "gnupg2", gpgMapping["dnf"].Name) + + // Verify neovim package + neovimMapping := mappingsCfg.Packages["neovim"] + require.Len(t, neovimMapping, 2) + require.Equal(t, "neovim", neovimMapping["apt"].Name) + require.Equal(t, "neovim", neovimMapping["brew"].Name) + + // Verify zsh package + zshMapping := mappingsCfg.Packages["zsh"] + require.Len(t, zshMapping, 2) + require.Equal(t, "zsh", zshMapping["apt"].Name) + require.Equal(t, "zsh", zshMapping["brew"].Name) +} + +func Test_LoadPackageMappings_CanParseYAMLWithBytesBuffer(t *testing.T) { + yamlContent := `packages: + curl: + apt: + name: curl + brew: + name: curl` + + yamlBytes := []byte(yamlContent) + + v := viper.New() + v.SetConfigType("yaml") + err := v.ReadConfig(bytes.NewBuffer(yamlBytes)) + require.NoError(t, err) + + var mappingsCfg packageresolver.PackageMappingCollection + err = v.Unmarshal(&mappingsCfg) + require.NoError(t, err) + + // Ensure Packages map is initialized (mimicking loader behavior) + if mappingsCfg.Packages == nil { + mappingsCfg.Packages = make(map[string]packageresolver.PackageMapping) + } + + require.NotNil(t, mappingsCfg.Packages) + require.Len(t, mappingsCfg.Packages, 1) + + curlMapping := mappingsCfg.Packages["curl"] + require.Len(t, curlMapping, 2) + require.Equal(t, "curl", curlMapping["apt"].Name) + require.Equal(t, "curl", curlMapping["brew"].Name) +} diff --git a/installer/lib/packageresolver/resolver.go b/installer/lib/packageresolver/resolver.go new file mode 100644 index 0000000..fcd5527 --- /dev/null +++ b/installer/lib/packageresolver/resolver.go @@ -0,0 +1,105 @@ +package packageresolver + +import ( + "fmt" + + "github.com/Masterminds/semver" + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" +) + +// Resolver translates generic package codes and version constraints into manager-specific package information. +type Resolver struct { + mappings *PackageMappingCollection + packageManagerName string // Normalized name like "apt", "brew" +} + +var _ PackageManagerResolver = (*Resolver)(nil) + +// NewResolver creates a new package resolver. +// It requires the loaded package mappings and an active PackageManager to determine the current manager. +func NewResolver( + mappings *PackageMappingCollection, + pm pkgmanager.PackageManager, +) (*Resolver, error) { + if mappings == nil { + return nil, fmt.Errorf("package mappings cannot be nil") + } + if pm == nil { + return nil, fmt.Errorf("package manager cannot be nil") + } + + pmInfo, err := pm.GetInfo() + if err != nil { + return nil, fmt.Errorf("failed to get package manager info: %w", err) + } + + return &Resolver{ + mappings: mappings, + packageManagerName: pmInfo.Name, + }, nil +} + +// Resolve takes a generic package code (e.g., "neovim") and a version constraint string +// (e.g., ">=0.5, <0.7 || >0.8.0"). It returns a RequestedPackageInfo struct containing the +// manager-specific package name and the parsed semver constraints. +// If versionConstraintString is empty, VersionConstraints in the result will be nil. +func (r *Resolver) Resolve( + genericPackageCode string, + versionConstraintString string, +) (pkgmanager.RequestedPackageInfo, error) { + if genericPackageCode == "" { + return pkgmanager.RequestedPackageInfo{}, fmt.Errorf("generic package code cannot be empty") + } + + packageMapping, ok := r.mappings.Packages[genericPackageCode] + if !ok { + // If no direct mapping, try to use the generic code as the package name itself. + // This allows users to specify packages not in the map, assuming the name is consistent. + // No version constraints can be applied in this case unless the package manager handles it. + var constraints *semver.Constraints + var err error + if versionConstraintString != "" { + constraints, err = semver.NewConstraint(versionConstraintString) + if err != nil { + return pkgmanager.RequestedPackageInfo{}, fmt.Errorf("invalid version constraint string '%s' for package '%s': %w", versionConstraintString, genericPackageCode, err) + } + } + // Consider logging a warning here that a direct mapping was not found. + return pkgmanager.RequestedPackageInfo{ + Name: genericPackageCode, // Use the code as the name + VersionConstraints: constraints, + }, nil + } + + var specificPackageName string + managerSpecificCfg, managerFound := packageMapping[r.packageManagerName] + + if managerFound && managerSpecificCfg.Name != "" { + specificPackageName = managerSpecificCfg.Name + } else { + // No specific name for this manager, fall back to generic code + specificPackageName = genericPackageCode + // Consider logging a warning here that a specific mapping was not found, + // and the generic code is being used as the package name. + } + + var constraints *semver.Constraints + var err error + if versionConstraintString != "" { + constraints, err = semver.NewConstraint(versionConstraintString) + if err != nil { + return pkgmanager.RequestedPackageInfo{}, fmt.Errorf("invalid version constraint string '%s' for package '%s': %w", versionConstraintString, genericPackageCode, err) + } + } + + return pkgmanager.RequestedPackageInfo{ + Name: specificPackageName, + VersionConstraints: constraints, // This will be nil if versionConstraintString was empty + }, nil +} + +// PackageManagerResolver defines the interface for resolving package information. +// This is added here to allow for the var _ PackageManagerResolver = (*Resolver)(nil) check. +type PackageManagerResolver interface { + Resolve(genericPackageCode string, versionConstraintString string) (pkgmanager.RequestedPackageInfo, error) +} diff --git a/installer/lib/packageresolver/resolver_test.go b/installer/lib/packageresolver/resolver_test.go new file mode 100644 index 0000000..064d85d --- /dev/null +++ b/installer/lib/packageresolver/resolver_test.go @@ -0,0 +1,533 @@ +package packageresolver_test + +import ( + "errors" + "testing" + + "github.com/Masterminds/semver" + "github.com/MrPointer/dotfiles/installer/lib/packageresolver" + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/stretchr/testify/require" +) + +func Test_NewResolver_CanCreateResolver_WithValidInputs(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: make(map[string]packageresolver.PackageMapping), + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "apt"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM) + + require.NoError(t, err) + require.NotNil(t, resolver) +} + +func Test_NewResolver_ReturnsError_WhenMappingsIsNil(t *testing.T) { + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "apt"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(nil, mockPM) + + require.Error(t, err) + require.Nil(t, resolver) + require.Contains(t, err.Error(), "package mappings cannot be nil") +} + +func Test_NewResolver_ReturnsError_WhenPackageManagerIsNil(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: make(map[string]packageresolver.PackageMapping), + } + + resolver, err := packageresolver.NewResolver(mappings, nil) + + require.Error(t, err) + require.Nil(t, resolver) + require.Contains(t, err.Error(), "package manager cannot be nil") +} + +func Test_NewResolver_ReturnsError_WhenPackageManagerGetInfoFails(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: make(map[string]packageresolver.PackageMapping), + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{}, errors.New("failed to get info") + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM) + + require.Error(t, err) + require.Nil(t, resolver) + require.Contains(t, err.Error(), "failed to get package manager info") +} + +func Test_NewResolver_AcceptsAnyPackageManagerName(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: make(map[string]packageresolver.PackageMapping), + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "custom-manager"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM) + + require.NoError(t, err) + require.NotNil(t, resolver) +} + +func Test_NewResolver_UsesPackageManagerNameDirectly(t *testing.T) { + testCases := []struct { + name string + pmName string + }{ + { + name: "uses apt name directly", + pmName: "apt", + }, + { + name: "uses brew name directly", + pmName: "brew", + }, + { + name: "uses dnf name directly", + pmName: "dnf", + }, + { + name: "uses pacman name directly", + pmName: "pacman", + }, + { + name: "uses custom name directly", + pmName: "custom-pm", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: make(map[string]packageresolver.PackageMapping), + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: tc.pmName}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM) + + require.NoError(t, err) + require.NotNil(t, resolver) + }) + } +} + +func Test_Resolve_ReturnsError_WhenGenericPackageCodeIsEmpty(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: make(map[string]packageresolver.PackageMapping), + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "apt"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM) + require.NoError(t, err) + + result, err := resolver.Resolve("", "") + + require.Error(t, err) + require.Equal(t, pkgmanager.RequestedPackageInfo{}, result) + require.Contains(t, err.Error(), "generic package code cannot be empty") +} + +func Test_Resolve_UsesManagerSpecificName_WhenAvailable(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: map[string]packageresolver.PackageMapping{ + "neovim": { + "apt": {Name: "neovim"}, + "brew": {Name: "neovim"}, + }, + }, + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "apt"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM) + require.NoError(t, err) + + result, err := resolver.Resolve("neovim", "") + + require.NoError(t, err) + require.Equal(t, "neovim", result.Name) + require.Nil(t, result.VersionConstraints) +} + +func Test_Resolve_FallsBackToGenericCode_WhenManagerSpecificNameNotFound(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: map[string]packageresolver.PackageMapping{ + "nodejs": { + "brew": {Name: "node"}, + }, + }, + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "apt"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM) + require.NoError(t, err) + + result, err := resolver.Resolve("nodejs", "") + + require.NoError(t, err) + require.Equal(t, "nodejs", result.Name) // Falls back to generic code + require.Nil(t, result.VersionConstraints) +} + +func Test_Resolve_FallsBackToGenericCode_WhenNoMappingFound(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: make(map[string]packageresolver.PackageMapping), + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "apt"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM) + require.NoError(t, err) + + result, err := resolver.Resolve("unknown-package", "") + + require.NoError(t, err) + require.Equal(t, "unknown-package", result.Name) + require.Nil(t, result.VersionConstraints) +} + +func Test_Resolve_FallsBackToGenericCode_WhenManagerSpecificNameIsEmpty(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: map[string]packageresolver.PackageMapping{ + "test-package": { + "apt": {Name: ""}, // Empty name + }, + }, + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "apt"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM) + require.NoError(t, err) + + result, err := resolver.Resolve("test-package", "") + + require.NoError(t, err) + require.Equal(t, "test-package", result.Name) + require.Nil(t, result.VersionConstraints) +} + +func Test_Resolve_ParsesVersionConstraints_WhenProvided(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: map[string]packageresolver.PackageMapping{ + "nodejs": { + "apt": {Name: "nodejs"}, + }, + }, + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "apt"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM) + require.NoError(t, err) + + result, err := resolver.Resolve("nodejs", ">=16.0.0") + + require.NoError(t, err) + require.Equal(t, "nodejs", result.Name) + require.NotNil(t, result.VersionConstraints) + + // Test that the constraint works as expected + version160, _ := semver.NewVersion("16.0.0") + version140, _ := semver.NewVersion("14.0.0") + require.True(t, result.VersionConstraints.Check(version160)) + require.False(t, result.VersionConstraints.Check(version140)) +} + +func Test_Resolve_ReturnsError_WhenVersionConstraintIsInvalid(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: map[string]packageresolver.PackageMapping{ + "nodejs": { + "apt": {Name: "nodejs"}, + }, + }, + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "apt"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM) + require.NoError(t, err) + + result, err := resolver.Resolve("nodejs", "invalid-version-constraint") + + require.Error(t, err) + require.Equal(t, pkgmanager.RequestedPackageInfo{}, result) + require.Contains(t, err.Error(), "invalid version constraint string") + require.Contains(t, err.Error(), "invalid-version-constraint") + require.Contains(t, err.Error(), "nodejs") +} + +func Test_Resolve_ParsesComplexVersionConstraints(t *testing.T) { + testCases := []struct { + name string + constraint string + versionToTest string + expectedSatisfied bool + }{ + { + name: "simple greater than constraint", + constraint: ">1.0.0", + versionToTest: "1.1.0", + expectedSatisfied: true, + }, + { + name: "simple greater than constraint not satisfied", + constraint: ">1.0.0", + versionToTest: "0.9.0", + expectedSatisfied: false, + }, + { + name: "range constraint satisfied", + constraint: ">=1.0.0, <2.0.0", + versionToTest: "1.5.0", + expectedSatisfied: true, + }, + { + name: "range constraint not satisfied", + constraint: ">=1.0.0, <2.0.0", + versionToTest: "2.1.0", + expectedSatisfied: false, + }, + { + name: "OR constraint satisfied by first part", + constraint: "<1.0.0 || >2.0.0", + versionToTest: "0.5.0", + expectedSatisfied: true, + }, + { + name: "OR constraint satisfied by second part", + constraint: "<1.0.0 || >2.0.0", + versionToTest: "3.0.0", + expectedSatisfied: true, + }, + { + name: "OR constraint not satisfied", + constraint: "<1.0.0 || >2.0.0", + versionToTest: "1.5.0", + expectedSatisfied: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: map[string]packageresolver.PackageMapping{ + "test-package": { + "apt": {Name: "test-pkg"}, + }, + }, + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "apt"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM) + require.NoError(t, err) + + result, err := resolver.Resolve("test-package", tc.constraint) + + require.NoError(t, err) + require.NotNil(t, result.VersionConstraints) + + testVersion, err := semver.NewVersion(tc.versionToTest) + require.NoError(t, err) + + satisfied := result.VersionConstraints.Check(testVersion) + require.Equal(t, tc.expectedSatisfied, satisfied) + }) + } +} + +func Test_Resolve_HandlesMultiplePackageManagers(t *testing.T) { + testCases := []struct { + name string + packageManagerName string + expectedPackageName string + }{ + { + name: "resolves for apt", + packageManagerName: "apt", + expectedPackageName: "nodejs", + }, + { + name: "resolves for brew", + packageManagerName: "brew", + expectedPackageName: "node", + }, + { + name: "resolves for dnf", + packageManagerName: "dnf", + expectedPackageName: "nodejs", + }, + { + name: "resolves for pacman", + packageManagerName: "pacman", + expectedPackageName: "nodejs", + }, + } + + mappings := &packageresolver.PackageMappingCollection{ + Packages: map[string]packageresolver.PackageMapping{ + "nodejs": { + "apt": {Name: "nodejs"}, + "brew": {Name: "node"}, + "dnf": {Name: "nodejs"}, + "pacman": {Name: "nodejs"}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: tc.packageManagerName}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM) + require.NoError(t, err) + + result, err := resolver.Resolve("nodejs", "") + + require.NoError(t, err) + require.Equal(t, tc.expectedPackageName, result.Name) + }) + } +} + +func Test_Resolve_WorksWithRealWorldStructure(t *testing.T) { + // This test uses the actual structure from packagemap.yaml + mappings := &packageresolver.PackageMappingCollection{ + Packages: map[string]packageresolver.PackageMapping{ + "git": { + "apt": {Name: "git"}, + "brew": {Name: "git"}, + }, + "gpg": { + "apt": {Name: "gnupg2"}, + "brew": {Name: "gnupg"}, + "dnf": {Name: "gnupg2"}, + }, + "neovim": { + "apt": {Name: "neovim"}, + "brew": {Name: "neovim"}, + }, + "zsh": { + "apt": {Name: "zsh"}, + "brew": {Name: "zsh"}, + }, + }, + } + + testCases := []struct { + packageManagerName string + packageCode string + expectedPackageName string + }{ + {"apt", "git", "git"}, + {"brew", "git", "git"}, + {"apt", "gpg", "gnupg2"}, + {"brew", "gpg", "gnupg"}, + {"dnf", "gpg", "gnupg2"}, + {"apt", "neovim", "neovim"}, + {"brew", "neovim", "neovim"}, + {"apt", "zsh", "zsh"}, + {"brew", "zsh", "zsh"}, + } + + for _, tc := range testCases { + t.Run("resolves "+tc.packageCode+" for "+tc.packageManagerName, func(t *testing.T) { + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: tc.packageManagerName}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM) + require.NoError(t, err) + + result, err := resolver.Resolve(tc.packageCode, "") + + require.NoError(t, err) + require.Equal(t, tc.expectedPackageName, result.Name) + }) + } +} + +func Test_Resolve_HandlesPackageWithVersionConstraints_UsingRealWorldStructure(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: map[string]packageresolver.PackageMapping{ + "git": { + "apt": {Name: "git"}, + "brew": {Name: "git"}, + }, + }, + } + + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "apt"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM) + require.NoError(t, err) + + result, err := resolver.Resolve("git", ">=2.0.0") + + require.NoError(t, err) + require.Equal(t, "git", result.Name) + require.NotNil(t, result.VersionConstraints) + + // Verify constraint works + version200, _ := semver.NewVersion("2.0.0") + version100, _ := semver.NewVersion("1.0.0") + require.True(t, result.VersionConstraints.Check(version200)) + require.False(t, result.VersionConstraints.Check(version100)) +} diff --git a/installer/lib/packageresolver/types.go b/installer/lib/packageresolver/types.go new file mode 100644 index 0000000..d4d1c1a --- /dev/null +++ b/installer/lib/packageresolver/types.go @@ -0,0 +1,17 @@ +package packageresolver + +// PackageMappingCollection holds all package mappings defined in the configuration. +// The top-level key in the configuration YAML (e.g., "packages") maps to this structure. +type PackageMappingCollection struct { + Packages map[string]PackageMapping `mapstructure:"packages"` +} + +// PackageMapping maps package manager names directly to their specific configurations. +// For example: "apt" -> ManagerSpecificMapping{Name: "git"}, "brew" -> ManagerSpecificMapping{Name: "git"} +type PackageMapping map[string]ManagerSpecificMapping + +// ManagerSpecificMapping holds the actual package name for a specific package manager. +type ManagerSpecificMapping struct { + // Name is the package name as recognized by the specific package manager. + Name string `mapstructure:"name"` +} diff --git a/installer/lib/packageresolver/types_test.go b/installer/lib/packageresolver/types_test.go new file mode 100644 index 0000000..c39e806 --- /dev/null +++ b/installer/lib/packageresolver/types_test.go @@ -0,0 +1,154 @@ +package packageresolver_test + +import ( + "testing" + + "github.com/MrPointer/dotfiles/installer/lib/packageresolver" + "github.com/stretchr/testify/require" +) + +func Test_PackageMappingCollection_CanBeInstantiated(t *testing.T) { + collection := packageresolver.PackageMappingCollection{ + Packages: make(map[string]packageresolver.PackageMapping), + } + + require.NotNil(t, collection.Packages) + require.Empty(t, collection.Packages) +} + +func Test_PackageMappingCollection_CanStorePackageMappings(t *testing.T) { + collection := packageresolver.PackageMappingCollection{ + Packages: map[string]packageresolver.PackageMapping{ + "test-package": { + "apt": {Name: "apt-test-package"}, + "brew": {Name: "brew-test-package"}, + }, + }, + } + + require.Len(t, collection.Packages, 1) + require.Contains(t, collection.Packages, "test-package") + + packageMapping := collection.Packages["test-package"] + require.Len(t, packageMapping, 2) + require.Contains(t, packageMapping, "apt") + require.Contains(t, packageMapping, "brew") + require.Equal(t, "apt-test-package", packageMapping["apt"].Name) + require.Equal(t, "brew-test-package", packageMapping["brew"].Name) +} + +func Test_PackageMapping_CanBeInstantiated(t *testing.T) { + mapping := packageresolver.PackageMapping{ + "apt": {Name: "test-package"}, + } + + require.Len(t, mapping, 1) + require.Contains(t, mapping, "apt") + require.Equal(t, "test-package", mapping["apt"].Name) +} + +func Test_PackageMapping_CanBeInstantiated_AsEmptyMap(t *testing.T) { + mapping := packageresolver.PackageMapping{} + + require.Empty(t, mapping) + require.NotNil(t, mapping) +} + +func Test_PackageMapping_CanStoreMultipleManagers(t *testing.T) { + mapping := packageresolver.PackageMapping{ + "apt": {Name: "apt-package"}, + "brew": {Name: "homebrew-package"}, + "dnf": {Name: "dnf-package"}, + "pacman": {Name: "arch-package"}, + } + + require.Len(t, mapping, 4) + + require.Equal(t, "apt-package", mapping["apt"].Name) + require.Equal(t, "homebrew-package", mapping["brew"].Name) + require.Equal(t, "dnf-package", mapping["dnf"].Name) + require.Equal(t, "arch-package", mapping["pacman"].Name) +} + +func Test_PackageMapping_CanAccessNonExistentManager(t *testing.T) { + mapping := packageresolver.PackageMapping{ + "apt": {Name: "test-package"}, + } + + // Accessing non-existent key should return zero value + nonExistent := mapping["non-existent"] + require.Empty(t, nonExistent.Name) + + // Check with ok pattern + value, exists := mapping["non-existent"] + require.False(t, exists) + require.Empty(t, value.Name) +} + +func Test_ManagerSpecificMapping_CanBeInstantiated(t *testing.T) { + mapping := packageresolver.ManagerSpecificMapping{ + Name: "specific-package-name", + } + + require.Equal(t, "specific-package-name", mapping.Name) +} + +func Test_ManagerSpecificMapping_CanBeInstantiated_WithEmptyName(t *testing.T) { + mapping := packageresolver.ManagerSpecificMapping{} + + require.Empty(t, mapping.Name) +} + +func Test_PackageMapping_SupportsRealWorldStructure(t *testing.T) { + // This test mimics the actual structure from packagemap.yaml + mapping := packageresolver.PackageMapping{ + "apt": {Name: "git"}, + "brew": {Name: "git"}, + } + + require.Len(t, mapping, 2) + require.Equal(t, "git", mapping["apt"].Name) + require.Equal(t, "git", mapping["brew"].Name) +} + +func Test_PackageMappingCollection_SupportsRealWorldStructure(t *testing.T) { + // This test mimics the actual structure from packagemap.yaml + collection := packageresolver.PackageMappingCollection{ + Packages: map[string]packageresolver.PackageMapping{ + "git": { + "apt": {Name: "git"}, + "brew": {Name: "git"}, + }, + "gpg": { + "apt": {Name: "gnupg2"}, + "brew": {Name: "gnupg"}, + "dnf": {Name: "gnupg2"}, + }, + "neovim": { + "apt": {Name: "neovim"}, + "brew": {Name: "neovim"}, + }, + }, + } + + require.Len(t, collection.Packages, 3) + + // Test git package + gitMapping := collection.Packages["git"] + require.Len(t, gitMapping, 2) + require.Equal(t, "git", gitMapping["apt"].Name) + require.Equal(t, "git", gitMapping["brew"].Name) + + // Test gpg package with different manager names + gpgMapping := collection.Packages["gpg"] + require.Len(t, gpgMapping, 3) + require.Equal(t, "gnupg2", gpgMapping["apt"].Name) + require.Equal(t, "gnupg", gpgMapping["brew"].Name) + require.Equal(t, "gnupg2", gpgMapping["dnf"].Name) + + // Test neovim package + neovimMapping := collection.Packages["neovim"] + require.Len(t, neovimMapping, 2) + require.Equal(t, "neovim", neovimMapping["apt"].Name) + require.Equal(t, "neovim", neovimMapping["brew"].Name) +} diff --git a/installer/lib/pkgmanager/PackageManager_mock.go b/installer/lib/pkgmanager/PackageManager_mock.go new file mode 100644 index 0000000..d5540cf --- /dev/null +++ b/installer/lib/pkgmanager/PackageManager_mock.go @@ -0,0 +1,281 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package pkgmanager + +import ( + "sync" +) + +// Ensure that MoqPackageManager does implement PackageManager. +// If this is not the case, regenerate this file with mockery. +var _ PackageManager = &MoqPackageManager{} + +// MoqPackageManager is a mock implementation of PackageManager. +// +// func TestSomethingThatUsesPackageManager(t *testing.T) { +// +// // make and configure a mocked PackageManager +// mockedPackageManager := &MoqPackageManager{ +// GetInfoFunc: func() (PackageManagerInfo, error) { +// panic("mock out the GetInfo method") +// }, +// GetPackageVersionFunc: func(packageName string) (string, error) { +// panic("mock out the GetPackageVersion method") +// }, +// InstallPackageFunc: func(requestedPackageInfo RequestedPackageInfo) error { +// panic("mock out the InstallPackage method") +// }, +// IsPackageInstalledFunc: func(packageInfo PackageInfo) (bool, error) { +// panic("mock out the IsPackageInstalled method") +// }, +// ListInstalledPackagesFunc: func() ([]PackageInfo, error) { +// panic("mock out the ListInstalledPackages method") +// }, +// UninstallPackageFunc: func(packageInfo PackageInfo) error { +// panic("mock out the UninstallPackage method") +// }, +// } +// +// // use mockedPackageManager in code that requires PackageManager +// // and then make assertions. +// +// } +type MoqPackageManager struct { + // GetInfoFunc mocks the GetInfo method. + GetInfoFunc func() (PackageManagerInfo, error) + + // GetPackageVersionFunc mocks the GetPackageVersion method. + GetPackageVersionFunc func(packageName string) (string, error) + + // InstallPackageFunc mocks the InstallPackage method. + InstallPackageFunc func(requestedPackageInfo RequestedPackageInfo) error + + // IsPackageInstalledFunc mocks the IsPackageInstalled method. + IsPackageInstalledFunc func(packageInfo PackageInfo) (bool, error) + + // ListInstalledPackagesFunc mocks the ListInstalledPackages method. + ListInstalledPackagesFunc func() ([]PackageInfo, error) + + // UninstallPackageFunc mocks the UninstallPackage method. + UninstallPackageFunc func(packageInfo PackageInfo) error + + // calls tracks calls to the methods. + calls struct { + // GetInfo holds details about calls to the GetInfo method. + GetInfo []struct { + } + // GetPackageVersion holds details about calls to the GetPackageVersion method. + GetPackageVersion []struct { + // PackageName is the packageName argument value. + PackageName string + } + // InstallPackage holds details about calls to the InstallPackage method. + InstallPackage []struct { + // RequestedPackageInfo is the requestedPackageInfo argument value. + RequestedPackageInfo RequestedPackageInfo + } + // IsPackageInstalled holds details about calls to the IsPackageInstalled method. + IsPackageInstalled []struct { + // PackageInfo is the packageInfo argument value. + PackageInfo PackageInfo + } + // ListInstalledPackages holds details about calls to the ListInstalledPackages method. + ListInstalledPackages []struct { + } + // UninstallPackage holds details about calls to the UninstallPackage method. + UninstallPackage []struct { + // PackageInfo is the packageInfo argument value. + PackageInfo PackageInfo + } + } + lockGetInfo sync.RWMutex + lockGetPackageVersion sync.RWMutex + lockInstallPackage sync.RWMutex + lockIsPackageInstalled sync.RWMutex + lockListInstalledPackages sync.RWMutex + lockUninstallPackage sync.RWMutex +} + +// GetInfo calls GetInfoFunc. +func (mock *MoqPackageManager) GetInfo() (PackageManagerInfo, error) { + if mock.GetInfoFunc == nil { + panic("MoqPackageManager.GetInfoFunc: method is nil but PackageManager.GetInfo was just called") + } + callInfo := struct { + }{} + mock.lockGetInfo.Lock() + mock.calls.GetInfo = append(mock.calls.GetInfo, callInfo) + mock.lockGetInfo.Unlock() + return mock.GetInfoFunc() +} + +// GetInfoCalls gets all the calls that were made to GetInfo. +// Check the length with: +// +// len(mockedPackageManager.GetInfoCalls()) +func (mock *MoqPackageManager) GetInfoCalls() []struct { +} { + var calls []struct { + } + mock.lockGetInfo.RLock() + calls = mock.calls.GetInfo + mock.lockGetInfo.RUnlock() + return calls +} + +// GetPackageVersion calls GetPackageVersionFunc. +func (mock *MoqPackageManager) GetPackageVersion(packageName string) (string, error) { + if mock.GetPackageVersionFunc == nil { + panic("MoqPackageManager.GetPackageVersionFunc: method is nil but PackageManager.GetPackageVersion was just called") + } + callInfo := struct { + PackageName string + }{ + PackageName: packageName, + } + mock.lockGetPackageVersion.Lock() + mock.calls.GetPackageVersion = append(mock.calls.GetPackageVersion, callInfo) + mock.lockGetPackageVersion.Unlock() + return mock.GetPackageVersionFunc(packageName) +} + +// GetPackageVersionCalls gets all the calls that were made to GetPackageVersion. +// Check the length with: +// +// len(mockedPackageManager.GetPackageVersionCalls()) +func (mock *MoqPackageManager) GetPackageVersionCalls() []struct { + PackageName string +} { + var calls []struct { + PackageName string + } + mock.lockGetPackageVersion.RLock() + calls = mock.calls.GetPackageVersion + mock.lockGetPackageVersion.RUnlock() + return calls +} + +// InstallPackage calls InstallPackageFunc. +func (mock *MoqPackageManager) InstallPackage(requestedPackageInfo RequestedPackageInfo) error { + if mock.InstallPackageFunc == nil { + panic("MoqPackageManager.InstallPackageFunc: method is nil but PackageManager.InstallPackage was just called") + } + callInfo := struct { + RequestedPackageInfo RequestedPackageInfo + }{ + RequestedPackageInfo: requestedPackageInfo, + } + mock.lockInstallPackage.Lock() + mock.calls.InstallPackage = append(mock.calls.InstallPackage, callInfo) + mock.lockInstallPackage.Unlock() + return mock.InstallPackageFunc(requestedPackageInfo) +} + +// InstallPackageCalls gets all the calls that were made to InstallPackage. +// Check the length with: +// +// len(mockedPackageManager.InstallPackageCalls()) +func (mock *MoqPackageManager) InstallPackageCalls() []struct { + RequestedPackageInfo RequestedPackageInfo +} { + var calls []struct { + RequestedPackageInfo RequestedPackageInfo + } + mock.lockInstallPackage.RLock() + calls = mock.calls.InstallPackage + mock.lockInstallPackage.RUnlock() + return calls +} + +// IsPackageInstalled calls IsPackageInstalledFunc. +func (mock *MoqPackageManager) IsPackageInstalled(packageInfo PackageInfo) (bool, error) { + if mock.IsPackageInstalledFunc == nil { + panic("MoqPackageManager.IsPackageInstalledFunc: method is nil but PackageManager.IsPackageInstalled was just called") + } + callInfo := struct { + PackageInfo PackageInfo + }{ + PackageInfo: packageInfo, + } + mock.lockIsPackageInstalled.Lock() + mock.calls.IsPackageInstalled = append(mock.calls.IsPackageInstalled, callInfo) + mock.lockIsPackageInstalled.Unlock() + return mock.IsPackageInstalledFunc(packageInfo) +} + +// IsPackageInstalledCalls gets all the calls that were made to IsPackageInstalled. +// Check the length with: +// +// len(mockedPackageManager.IsPackageInstalledCalls()) +func (mock *MoqPackageManager) IsPackageInstalledCalls() []struct { + PackageInfo PackageInfo +} { + var calls []struct { + PackageInfo PackageInfo + } + mock.lockIsPackageInstalled.RLock() + calls = mock.calls.IsPackageInstalled + mock.lockIsPackageInstalled.RUnlock() + return calls +} + +// ListInstalledPackages calls ListInstalledPackagesFunc. +func (mock *MoqPackageManager) ListInstalledPackages() ([]PackageInfo, error) { + if mock.ListInstalledPackagesFunc == nil { + panic("MoqPackageManager.ListInstalledPackagesFunc: method is nil but PackageManager.ListInstalledPackages was just called") + } + callInfo := struct { + }{} + mock.lockListInstalledPackages.Lock() + mock.calls.ListInstalledPackages = append(mock.calls.ListInstalledPackages, callInfo) + mock.lockListInstalledPackages.Unlock() + return mock.ListInstalledPackagesFunc() +} + +// ListInstalledPackagesCalls gets all the calls that were made to ListInstalledPackages. +// Check the length with: +// +// len(mockedPackageManager.ListInstalledPackagesCalls()) +func (mock *MoqPackageManager) ListInstalledPackagesCalls() []struct { +} { + var calls []struct { + } + mock.lockListInstalledPackages.RLock() + calls = mock.calls.ListInstalledPackages + mock.lockListInstalledPackages.RUnlock() + return calls +} + +// UninstallPackage calls UninstallPackageFunc. +func (mock *MoqPackageManager) UninstallPackage(packageInfo PackageInfo) error { + if mock.UninstallPackageFunc == nil { + panic("MoqPackageManager.UninstallPackageFunc: method is nil but PackageManager.UninstallPackage was just called") + } + callInfo := struct { + PackageInfo PackageInfo + }{ + PackageInfo: packageInfo, + } + mock.lockUninstallPackage.Lock() + mock.calls.UninstallPackage = append(mock.calls.UninstallPackage, callInfo) + mock.lockUninstallPackage.Unlock() + return mock.UninstallPackageFunc(packageInfo) +} + +// UninstallPackageCalls gets all the calls that were made to UninstallPackage. +// Check the length with: +// +// len(mockedPackageManager.UninstallPackageCalls()) +func (mock *MoqPackageManager) UninstallPackageCalls() []struct { + PackageInfo PackageInfo +} { + var calls []struct { + PackageInfo PackageInfo + } + mock.lockUninstallPackage.RLock() + calls = mock.calls.UninstallPackage + mock.lockUninstallPackage.RUnlock() + return calls +} diff --git a/installer/lib/pkgmanager/info.go b/installer/lib/pkgmanager/info.go new file mode 100644 index 0000000..9b42bde --- /dev/null +++ b/installer/lib/pkgmanager/info.go @@ -0,0 +1,56 @@ +package pkgmanager + +import "github.com/Masterminds/semver" + +type PackageManagerInfo struct { + // Name of the package manager. + Name string `json:"name"` + + // Version of the package manager. + Version string `json:"version"` +} + +func NewPackageManagerInfo(name, version string) PackageManagerInfo { + return PackageManagerInfo{ + Name: name, + Version: version, + } +} + +func DefaultPackageManagerInfo() PackageManagerInfo { + return PackageManagerInfo{ + Name: "Unknown", + Version: "0.0.0", + } +} + +type PackageInfo struct { + // Name of the package. + Name string `json:"name"` + + // Version of the package. + Version string `json:"version"` +} + +func NewPackageInfo(name, version string) PackageInfo { + return PackageInfo{ + Name: name, + Version: version, + } +} + +type RequestedPackageInfo struct { + // Name of the package. + Name string `json:"name"` + + // VersionConstraints defines the semver constraints for the requested package. + // It's a pointer to allow for nil (no constraints). + VersionConstraints *semver.Constraints `json:"version_constraint,omitempty"` +} + +func NewRequestedPackageInfo(name string, versionConstraints *semver.Constraints) RequestedPackageInfo { + return RequestedPackageInfo{ + Name: name, + VersionConstraints: versionConstraints, + } +} diff --git a/installer/lib/pkgmanager/pkgmanager.go b/installer/lib/pkgmanager/pkgmanager.go new file mode 100644 index 0000000..18d9e53 --- /dev/null +++ b/installer/lib/pkgmanager/pkgmanager.go @@ -0,0 +1,21 @@ +package pkgmanager + +type PackageManager interface { + // InstallPackage installs a package by its name. + InstallPackage(requestedPackageInfo RequestedPackageInfo) error + + // UninstallPackage uninstalls a package by its name. + UninstallPackage(packageInfo PackageInfo) error + + // ListInstalledPackages returns a list of installed packages. + ListInstalledPackages() ([]PackageInfo, error) + + // IsPackageInstalled checks if a package is installed by its name. + IsPackageInstalled(packageInfo PackageInfo) (bool, error) + + // GetPackageVersion retrieves the version of a package by its name. + GetPackageVersion(packageName string) (string, error) + + // GetInfo retrieves information about the package manager itself. + GetInfo() (PackageManagerInfo, error) +} diff --git a/installer/lib/shell/ShellInstaller_mock.go b/installer/lib/shell/ShellInstaller_mock.go new file mode 100644 index 0000000..a7a41d3 --- /dev/null +++ b/installer/lib/shell/ShellInstaller_mock.go @@ -0,0 +1,113 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package shell + +import ( + "context" + "sync" +) + +// Ensure that MoqShellInstaller does implement ShellInstaller. +// If this is not the case, regenerate this file with mockery. +var _ ShellInstaller = &MoqShellInstaller{} + +// MoqShellInstaller is a mock implementation of ShellInstaller. +// +// func TestSomethingThatUsesShellInstaller(t *testing.T) { +// +// // make and configure a mocked ShellInstaller +// mockedShellInstaller := &MoqShellInstaller{ +// InstallFunc: func(ctx context.Context) error { +// panic("mock out the Install method") +// }, +// IsAvailableFunc: func() (bool, error) { +// panic("mock out the IsAvailable method") +// }, +// } +// +// // use mockedShellInstaller in code that requires ShellInstaller +// // and then make assertions. +// +// } +type MoqShellInstaller struct { + // InstallFunc mocks the Install method. + InstallFunc func(ctx context.Context) error + + // IsAvailableFunc mocks the IsAvailable method. + IsAvailableFunc func() (bool, error) + + // calls tracks calls to the methods. + calls struct { + // Install holds details about calls to the Install method. + Install []struct { + // Ctx is the ctx argument value. + Ctx context.Context + } + // IsAvailable holds details about calls to the IsAvailable method. + IsAvailable []struct { + } + } + lockInstall sync.RWMutex + lockIsAvailable sync.RWMutex +} + +// Install calls InstallFunc. +func (mock *MoqShellInstaller) Install(ctx context.Context) error { + if mock.InstallFunc == nil { + panic("MoqShellInstaller.InstallFunc: method is nil but ShellInstaller.Install was just called") + } + callInfo := struct { + Ctx context.Context + }{ + Ctx: ctx, + } + mock.lockInstall.Lock() + mock.calls.Install = append(mock.calls.Install, callInfo) + mock.lockInstall.Unlock() + return mock.InstallFunc(ctx) +} + +// InstallCalls gets all the calls that were made to Install. +// Check the length with: +// +// len(mockedShellInstaller.InstallCalls()) +func (mock *MoqShellInstaller) InstallCalls() []struct { + Ctx context.Context +} { + var calls []struct { + Ctx context.Context + } + mock.lockInstall.RLock() + calls = mock.calls.Install + mock.lockInstall.RUnlock() + return calls +} + +// IsAvailable calls IsAvailableFunc. +func (mock *MoqShellInstaller) IsAvailable() (bool, error) { + if mock.IsAvailableFunc == nil { + panic("MoqShellInstaller.IsAvailableFunc: method is nil but ShellInstaller.IsAvailable was just called") + } + callInfo := struct { + }{} + mock.lockIsAvailable.Lock() + mock.calls.IsAvailable = append(mock.calls.IsAvailable, callInfo) + mock.lockIsAvailable.Unlock() + return mock.IsAvailableFunc() +} + +// IsAvailableCalls gets all the calls that were made to IsAvailable. +// Check the length with: +// +// len(mockedShellInstaller.IsAvailableCalls()) +func (mock *MoqShellInstaller) IsAvailableCalls() []struct { +} { + var calls []struct { + } + mock.lockIsAvailable.RLock() + calls = mock.calls.IsAvailable + mock.lockIsAvailable.RUnlock() + return calls +} diff --git a/installer/lib/shell/installer.go b/installer/lib/shell/installer.go new file mode 100644 index 0000000..366f385 --- /dev/null +++ b/installer/lib/shell/installer.go @@ -0,0 +1,58 @@ +package shell + +import ( + "context" + + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" +) + +type ShellInstaller interface { + IsAvailable() (bool, error) + Install(ctx context.Context) error +} + +type DefaultShellInstaller struct { + shellName string + programQuery osmanager.ProgramQuery + pkgManager pkgmanager.PackageManager + logger logger.Logger +} + +var _ ShellInstaller = (*DefaultShellInstaller)(nil) + +func NewDefaultShellInstaller( + shellName string, + programQuery osmanager.ProgramQuery, + pkgManager pkgmanager.PackageManager, + logger logger.Logger, +) *DefaultShellInstaller { + return &DefaultShellInstaller{ + shellName: shellName, + programQuery: programQuery, + pkgManager: pkgManager, + logger: logger, + } +} + +func (d *DefaultShellInstaller) IsAvailable() (bool, error) { + d.logger.Debug("Checking if %s is available", d.shellName) + + shellAvailable, err := d.programQuery.ProgramExists(d.shellName) + if err != nil { + return false, err + } + + if shellAvailable { + d.logger.Debug("%s is available", d.shellName) + } else { + d.logger.Debug("%s is not available", d.shellName) + } + return shellAvailable, nil +} + +func (d *DefaultShellInstaller) Install(ctx context.Context) error { + d.logger.Debug("Installing %s via package manager", d.shellName) + return d.pkgManager.InstallPackage(pkgmanager.NewRequestedPackageInfo(d.shellName, nil)) +} diff --git a/installer/lib/shell/installer_test.go b/installer/lib/shell/installer_test.go new file mode 100644 index 0000000..b33605f --- /dev/null +++ b/installer/lib/shell/installer_test.go @@ -0,0 +1,133 @@ +package shell_test + +import ( + "context" + "testing" + + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/lib/shell" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ShellIsReportedAsAvailable_WhenShellProgramExists(t *testing.T) { + // Arrange + shellName := "zsh" + + programQueryMock := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return true, nil + }, + } + + pkgManagerMock := &pkgmanager.MoqPackageManager{} + + installer := shell.NewDefaultShellInstaller(shellName, programQueryMock, pkgManagerMock, logger.DefaultLogger) + + // Act + available, err := installer.IsAvailable() + + // Assert + require.NoError(t, err) + assert.True(t, available) + assert.Len(t, programQueryMock.ProgramExistsCalls(), 1) + assert.Equal(t, shellName, programQueryMock.ProgramExistsCalls()[0].Program) +} + +func Test_ShellIsReportedAsUnavailable_WhenShellProgramDoesNotExist(t *testing.T) { + // Arrange + shellName := "fish" + + programQueryMock := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return false, nil + }, + } + + pkgManagerMock := &pkgmanager.MoqPackageManager{} + + installer := shell.NewDefaultShellInstaller(shellName, programQueryMock, pkgManagerMock, logger.DefaultLogger) + + // Act + available, err := installer.IsAvailable() + + // Assert + require.NoError(t, err) + assert.False(t, available) + assert.Len(t, programQueryMock.ProgramExistsCalls(), 1) + assert.Equal(t, shellName, programQueryMock.ProgramExistsCalls()[0].Program) +} + +func Test_ShellAvailabilityCheckFails_WhenProgramExistsFails(t *testing.T) { + // Arrange + shellName := "bash" + + programQueryMock := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return false, assert.AnError + }, + } + + pkgManagerMock := &pkgmanager.MoqPackageManager{} + + installer := shell.NewDefaultShellInstaller(shellName, programQueryMock, pkgManagerMock, logger.DefaultLogger) + + // Act + available, err := installer.IsAvailable() + + // Assert + require.Error(t, err) + assert.False(t, available) + assert.Len(t, programQueryMock.ProgramExistsCalls(), 1) + assert.Equal(t, shellName, programQueryMock.ProgramExistsCalls()[0].Program) +} + +func Test_ShellInstallationSucceeds_WhenPackageManagerSucceeds(t *testing.T) { + // Arrange + shellName := "zsh" + + programQueryMock := &osmanager.MoqProgramQuery{} + + pkgManagerMock := &pkgmanager.MoqPackageManager{ + InstallPackageFunc: func(pkg pkgmanager.RequestedPackageInfo) error { + return nil + }, + } + + installer := shell.NewDefaultShellInstaller(shellName, programQueryMock, pkgManagerMock, logger.DefaultLogger) + + // Act + err := installer.Install(context.Background()) + + // Assert + require.NoError(t, err) + assert.Len(t, pkgManagerMock.InstallPackageCalls(), 1) + assert.Equal(t, shellName, pkgManagerMock.InstallPackageCalls()[0].RequestedPackageInfo.Name) + assert.Nil(t, pkgManagerMock.InstallPackageCalls()[0].RequestedPackageInfo.VersionConstraints) +} + +func Test_ShellInstallationFails_WhenPackageManagerFails(t *testing.T) { + // Arrange + shellName := "fish" + + programQueryMock := &osmanager.MoqProgramQuery{} + + pkgManagerMock := &pkgmanager.MoqPackageManager{ + InstallPackageFunc: func(pkg pkgmanager.RequestedPackageInfo) error { + return assert.AnError + }, + } + + installer := shell.NewDefaultShellInstaller(shellName, programQueryMock, pkgManagerMock, logger.DefaultLogger) + + // Act + err := installer.Install(context.Background()) + + // Assert + require.Error(t, err) + assert.Len(t, pkgManagerMock.InstallPackageCalls(), 1) + assert.Equal(t, shellName, pkgManagerMock.InstallPackageCalls()[0].RequestedPackageInfo.Name) + assert.Nil(t, pkgManagerMock.InstallPackageCalls()[0].RequestedPackageInfo.VersionConstraints) +} diff --git a/installer/main.go b/installer/main.go new file mode 100644 index 0000000..12348a3 --- /dev/null +++ b/installer/main.go @@ -0,0 +1,8 @@ +package main + +// Main entry point for the dotfiles installer application. +import "github.com/MrPointer/dotfiles/installer/cmd" + +func main() { + cmd.Execute() +} diff --git a/installer/test-interactive-gpg.exp b/installer/test-interactive-gpg.exp new file mode 100755 index 0000000..e34a49f --- /dev/null +++ b/installer/test-interactive-gpg.exp @@ -0,0 +1,166 @@ +#!/usr/bin/expect -f +# +# Interactive GPG Testing Script for Dotfiles Installer +# Usage: ./test-interactive-gpg.exp [installer_path] [email] [name] [passphrase] +# +# This script automates GPG key setup during interactive installation +# for both CI testing and local development. + +# Set default timeout (in seconds) +set timeout 300 + +# Parse command line arguments with defaults +set installer_path [lindex $argv 0] +set email [lindex $argv 1] +set name [lindex $argv 2] +set passphrase [lindex $argv 3] +set verbosity [lindex $argv 4] + +# Use defaults if not provided +if {$installer_path eq ""} { + set installer_path "./dotfiles-installer" +} +if {$email eq ""} { + set email "test-user@example.com" +} +if {$name eq ""} { + set name "Test CI User" +} +if {$passphrase eq ""} { + set passphrase "test-ci-passphrase" +} +if {$verbosity eq ""} { + set verbosity "" +} + +# Enable debugging output (set to 1 for verbose debugging) +exp_internal 0 + +# Log all output to help debug unmatched prompts +log_user 1 + +# Start the installer +puts "πŸ”‘ Starting interactive GPG test with:" +puts " Installer: $installer_path" +puts " Email: $email" +puts " Name: $name" +puts " Passphrase: [string repeat "*" [string length $passphrase]]" +puts " Verbosity: $verbosity" +puts "" + +spawn $installer_path install --plain --install-prerequisites=true --git-clone-protocol=https "$verbosity" + +# Main interaction loop - Only handle GPG-specific prompts +expect { + # GPG email prompts (various possible formats) + -re "(?i).*(email|e-?mail address)" { + puts "πŸ“§ Entering email address..." + send "$email\r" + exp_continue + } + + # GPG name/full name prompts + -re "(?i).*(full name|name|real name)" { + puts "πŸ‘€ Entering full name..." + send "$name\r" + exp_continue + } + + # GPG passphrase prompts + -re "(?i).*(passphrase|password)" { + puts "πŸ” Entering passphrase..." + send "$passphrase\r" + exp_continue + } + + # GPG "okay" confirmation - exact match for the problematic prompt + -re "Change.*Name.*Email.*kay.*uit" { + puts "βœ… GPG change/okay prompt (sending O)..." + send "O\r" + exp_continue + } + + # GPG "okay" confirmation - matches "(O)kay" pattern + -re "\\(O\\)kay" { + puts "βœ… GPG okay confirmation (sending O)..." + send "O\r" + exp_continue + } + + # Fallback: any prompt containing "(O)" - likely GPG menu + -re "\\(O\\)" { + puts "βœ… GPG menu with O option (sending O)..." + send "O\r" + exp_continue + } + + # GPG key generation prompts + -re "(?i).*(key.*size|rsa.*bits)" { + puts "πŸ”§ Using default key size..." + send "\r" + exp_continue + } + + # Key expiration prompts + -re "(?i).*(key.*expir|expir.*date)" { + puts "⏰ Setting key expiration..." + send "0\r" + exp_continue + } + + # GPG key type selection (default to RSA) + -re "(?i).*(kind of key|key.*type)" { + puts "πŸ”‘ Selecting default key type..." + send "\r" + exp_continue + } + + # GPG comment field (usually optional) + -re "(?i).*comment" { + puts "πŸ’¬ Skipping comment field..." + send "\r" + exp_continue + } + + # Error patterns + -re "(?i).*(error|fail|abort)" { + puts "❌ Error detected in output" + set error_output $expect_out(buffer) + puts "Error details: $error_output" + # Don't exit immediately - some errors might be expected in CI + exp_continue + } + + # Success patterns + -re "(?i).*(success|complete|finished|done)" { + puts "βœ… Installation appears to have completed successfully" + exp_continue + } + + # Timeout handling + timeout { + puts "⏰ Timeout reached after 300 seconds" + exit 1 + } + + # End of output + eof { + puts "πŸ“‹ Process completed" + # Get exit status + catch wait result + set exit_code [lindex $result 3] + puts "Exit code: $exit_code" + + if {$exit_code == 0} { + puts "βœ… Installation completed successfully!" + exit 0 + } else { + puts "❌ Installation failed with exit code: $exit_code" + exit $exit_code + } + } +} + +# This should never be reached, but just in case +puts "πŸ€” Unexpected end of script" +exit 0 diff --git a/installer/utils/FileSystem_mock.go b/installer/utils/FileSystem_mock.go new file mode 100644 index 0000000..c2e01bd --- /dev/null +++ b/installer/utils/FileSystem_mock.go @@ -0,0 +1,453 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package utils + +import ( + "io" + "os" + "sync" +) + +// Ensure that MoqFileSystem does implement FileSystem. +// If this is not the case, regenerate this file with mockery. +var _ FileSystem = &MoqFileSystem{} + +// MoqFileSystem is a mock implementation of FileSystem. +// +// func TestSomethingThatUsesFileSystem(t *testing.T) { +// +// // make and configure a mocked FileSystem +// mockedFileSystem := &MoqFileSystem{ +// CreateDirectoryFunc: func(path string) error { +// panic("mock out the CreateDirectory method") +// }, +// CreateDirectoryWithPermissionsFunc: func(path string, mode os.FileMode) error { +// panic("mock out the CreateDirectoryWithPermissions method") +// }, +// CreateFileFunc: func(path string) (string, error) { +// panic("mock out the CreateFile method") +// }, +// CreateTemporaryDirectoryFunc: func(dir string) (string, error) { +// panic("mock out the CreateTemporaryDirectory method") +// }, +// CreateTemporaryFileFunc: func(dir string, pattern string) (string, error) { +// panic("mock out the CreateTemporaryFile method") +// }, +// PathExistsFunc: func(path string) (bool, error) { +// panic("mock out the PathExists method") +// }, +// ReadFileFunc: func(path string, receiver io.Writer) (int64, error) { +// panic("mock out the ReadFile method") +// }, +// RemovePathFunc: func(path string) error { +// panic("mock out the RemovePath method") +// }, +// WriteFileFunc: func(path string, reader io.Reader) (int64, error) { +// panic("mock out the WriteFile method") +// }, +// } +// +// // use mockedFileSystem in code that requires FileSystem +// // and then make assertions. +// +// } +type MoqFileSystem struct { + // CreateDirectoryFunc mocks the CreateDirectory method. + CreateDirectoryFunc func(path string) error + + // CreateDirectoryWithPermissionsFunc mocks the CreateDirectoryWithPermissions method. + CreateDirectoryWithPermissionsFunc func(path string, mode os.FileMode) error + + // CreateFileFunc mocks the CreateFile method. + CreateFileFunc func(path string) (string, error) + + // CreateTemporaryDirectoryFunc mocks the CreateTemporaryDirectory method. + CreateTemporaryDirectoryFunc func(dir string) (string, error) + + // CreateTemporaryFileFunc mocks the CreateTemporaryFile method. + CreateTemporaryFileFunc func(dir string, pattern string) (string, error) + + // PathExistsFunc mocks the PathExists method. + PathExistsFunc func(path string) (bool, error) + + // ReadFileFunc mocks the ReadFile method. + ReadFileFunc func(path string, receiver io.Writer) (int64, error) + + // RemovePathFunc mocks the RemovePath method. + RemovePathFunc func(path string) error + + // WriteFileFunc mocks the WriteFile method. + WriteFileFunc func(path string, reader io.Reader) (int64, error) + + // calls tracks calls to the methods. + calls struct { + // CreateDirectory holds details about calls to the CreateDirectory method. + CreateDirectory []struct { + // Path is the path argument value. + Path string + } + // CreateDirectoryWithPermissions holds details about calls to the CreateDirectoryWithPermissions method. + CreateDirectoryWithPermissions []struct { + // Path is the path argument value. + Path string + // Mode is the mode argument value. + Mode os.FileMode + } + // CreateFile holds details about calls to the CreateFile method. + CreateFile []struct { + // Path is the path argument value. + Path string + } + // CreateTemporaryDirectory holds details about calls to the CreateTemporaryDirectory method. + CreateTemporaryDirectory []struct { + // Dir is the dir argument value. + Dir string + } + // CreateTemporaryFile holds details about calls to the CreateTemporaryFile method. + CreateTemporaryFile []struct { + // Dir is the dir argument value. + Dir string + // Pattern is the pattern argument value. + Pattern string + } + // PathExists holds details about calls to the PathExists method. + PathExists []struct { + // Path is the path argument value. + Path string + } + // ReadFile holds details about calls to the ReadFile method. + ReadFile []struct { + // Path is the path argument value. + Path string + // Receiver is the receiver argument value. + Receiver io.Writer + } + // RemovePath holds details about calls to the RemovePath method. + RemovePath []struct { + // Path is the path argument value. + Path string + } + // WriteFile holds details about calls to the WriteFile method. + WriteFile []struct { + // Path is the path argument value. + Path string + // Reader is the reader argument value. + Reader io.Reader + } + } + lockCreateDirectory sync.RWMutex + lockCreateDirectoryWithPermissions sync.RWMutex + lockCreateFile sync.RWMutex + lockCreateTemporaryDirectory sync.RWMutex + lockCreateTemporaryFile sync.RWMutex + lockPathExists sync.RWMutex + lockReadFile sync.RWMutex + lockRemovePath sync.RWMutex + lockWriteFile sync.RWMutex +} + +// CreateDirectory calls CreateDirectoryFunc. +func (mock *MoqFileSystem) CreateDirectory(path string) error { + if mock.CreateDirectoryFunc == nil { + panic("MoqFileSystem.CreateDirectoryFunc: method is nil but FileSystem.CreateDirectory was just called") + } + callInfo := struct { + Path string + }{ + Path: path, + } + mock.lockCreateDirectory.Lock() + mock.calls.CreateDirectory = append(mock.calls.CreateDirectory, callInfo) + mock.lockCreateDirectory.Unlock() + return mock.CreateDirectoryFunc(path) +} + +// CreateDirectoryCalls gets all the calls that were made to CreateDirectory. +// Check the length with: +// +// len(mockedFileSystem.CreateDirectoryCalls()) +func (mock *MoqFileSystem) CreateDirectoryCalls() []struct { + Path string +} { + var calls []struct { + Path string + } + mock.lockCreateDirectory.RLock() + calls = mock.calls.CreateDirectory + mock.lockCreateDirectory.RUnlock() + return calls +} + +// CreateDirectoryWithPermissions calls CreateDirectoryWithPermissionsFunc. +func (mock *MoqFileSystem) CreateDirectoryWithPermissions(path string, mode os.FileMode) error { + if mock.CreateDirectoryWithPermissionsFunc == nil { + panic("MoqFileSystem.CreateDirectoryWithPermissionsFunc: method is nil but FileSystem.CreateDirectoryWithPermissions was just called") + } + callInfo := struct { + Path string + Mode os.FileMode + }{ + Path: path, + Mode: mode, + } + mock.lockCreateDirectoryWithPermissions.Lock() + mock.calls.CreateDirectoryWithPermissions = append(mock.calls.CreateDirectoryWithPermissions, callInfo) + mock.lockCreateDirectoryWithPermissions.Unlock() + return mock.CreateDirectoryWithPermissionsFunc(path, mode) +} + +// CreateDirectoryWithPermissionsCalls gets all the calls that were made to CreateDirectoryWithPermissions. +// Check the length with: +// +// len(mockedFileSystem.CreateDirectoryWithPermissionsCalls()) +func (mock *MoqFileSystem) CreateDirectoryWithPermissionsCalls() []struct { + Path string + Mode os.FileMode +} { + var calls []struct { + Path string + Mode os.FileMode + } + mock.lockCreateDirectoryWithPermissions.RLock() + calls = mock.calls.CreateDirectoryWithPermissions + mock.lockCreateDirectoryWithPermissions.RUnlock() + return calls +} + +// CreateFile calls CreateFileFunc. +func (mock *MoqFileSystem) CreateFile(path string) (string, error) { + if mock.CreateFileFunc == nil { + panic("MoqFileSystem.CreateFileFunc: method is nil but FileSystem.CreateFile was just called") + } + callInfo := struct { + Path string + }{ + Path: path, + } + mock.lockCreateFile.Lock() + mock.calls.CreateFile = append(mock.calls.CreateFile, callInfo) + mock.lockCreateFile.Unlock() + return mock.CreateFileFunc(path) +} + +// CreateFileCalls gets all the calls that were made to CreateFile. +// Check the length with: +// +// len(mockedFileSystem.CreateFileCalls()) +func (mock *MoqFileSystem) CreateFileCalls() []struct { + Path string +} { + var calls []struct { + Path string + } + mock.lockCreateFile.RLock() + calls = mock.calls.CreateFile + mock.lockCreateFile.RUnlock() + return calls +} + +// CreateTemporaryDirectory calls CreateTemporaryDirectoryFunc. +func (mock *MoqFileSystem) CreateTemporaryDirectory(dir string) (string, error) { + if mock.CreateTemporaryDirectoryFunc == nil { + panic("MoqFileSystem.CreateTemporaryDirectoryFunc: method is nil but FileSystem.CreateTemporaryDirectory was just called") + } + callInfo := struct { + Dir string + }{ + Dir: dir, + } + mock.lockCreateTemporaryDirectory.Lock() + mock.calls.CreateTemporaryDirectory = append(mock.calls.CreateTemporaryDirectory, callInfo) + mock.lockCreateTemporaryDirectory.Unlock() + return mock.CreateTemporaryDirectoryFunc(dir) +} + +// CreateTemporaryDirectoryCalls gets all the calls that were made to CreateTemporaryDirectory. +// Check the length with: +// +// len(mockedFileSystem.CreateTemporaryDirectoryCalls()) +func (mock *MoqFileSystem) CreateTemporaryDirectoryCalls() []struct { + Dir string +} { + var calls []struct { + Dir string + } + mock.lockCreateTemporaryDirectory.RLock() + calls = mock.calls.CreateTemporaryDirectory + mock.lockCreateTemporaryDirectory.RUnlock() + return calls +} + +// CreateTemporaryFile calls CreateTemporaryFileFunc. +func (mock *MoqFileSystem) CreateTemporaryFile(dir string, pattern string) (string, error) { + if mock.CreateTemporaryFileFunc == nil { + panic("MoqFileSystem.CreateTemporaryFileFunc: method is nil but FileSystem.CreateTemporaryFile was just called") + } + callInfo := struct { + Dir string + Pattern string + }{ + Dir: dir, + Pattern: pattern, + } + mock.lockCreateTemporaryFile.Lock() + mock.calls.CreateTemporaryFile = append(mock.calls.CreateTemporaryFile, callInfo) + mock.lockCreateTemporaryFile.Unlock() + return mock.CreateTemporaryFileFunc(dir, pattern) +} + +// CreateTemporaryFileCalls gets all the calls that were made to CreateTemporaryFile. +// Check the length with: +// +// len(mockedFileSystem.CreateTemporaryFileCalls()) +func (mock *MoqFileSystem) CreateTemporaryFileCalls() []struct { + Dir string + Pattern string +} { + var calls []struct { + Dir string + Pattern string + } + mock.lockCreateTemporaryFile.RLock() + calls = mock.calls.CreateTemporaryFile + mock.lockCreateTemporaryFile.RUnlock() + return calls +} + +// PathExists calls PathExistsFunc. +func (mock *MoqFileSystem) PathExists(path string) (bool, error) { + if mock.PathExistsFunc == nil { + panic("MoqFileSystem.PathExistsFunc: method is nil but FileSystem.PathExists was just called") + } + callInfo := struct { + Path string + }{ + Path: path, + } + mock.lockPathExists.Lock() + mock.calls.PathExists = append(mock.calls.PathExists, callInfo) + mock.lockPathExists.Unlock() + return mock.PathExistsFunc(path) +} + +// PathExistsCalls gets all the calls that were made to PathExists. +// Check the length with: +// +// len(mockedFileSystem.PathExistsCalls()) +func (mock *MoqFileSystem) PathExistsCalls() []struct { + Path string +} { + var calls []struct { + Path string + } + mock.lockPathExists.RLock() + calls = mock.calls.PathExists + mock.lockPathExists.RUnlock() + return calls +} + +// ReadFile calls ReadFileFunc. +func (mock *MoqFileSystem) ReadFile(path string, receiver io.Writer) (int64, error) { + if mock.ReadFileFunc == nil { + panic("MoqFileSystem.ReadFileFunc: method is nil but FileSystem.ReadFile was just called") + } + callInfo := struct { + Path string + Receiver io.Writer + }{ + Path: path, + Receiver: receiver, + } + mock.lockReadFile.Lock() + mock.calls.ReadFile = append(mock.calls.ReadFile, callInfo) + mock.lockReadFile.Unlock() + return mock.ReadFileFunc(path, receiver) +} + +// ReadFileCalls gets all the calls that were made to ReadFile. +// Check the length with: +// +// len(mockedFileSystem.ReadFileCalls()) +func (mock *MoqFileSystem) ReadFileCalls() []struct { + Path string + Receiver io.Writer +} { + var calls []struct { + Path string + Receiver io.Writer + } + mock.lockReadFile.RLock() + calls = mock.calls.ReadFile + mock.lockReadFile.RUnlock() + return calls +} + +// RemovePath calls RemovePathFunc. +func (mock *MoqFileSystem) RemovePath(path string) error { + if mock.RemovePathFunc == nil { + panic("MoqFileSystem.RemovePathFunc: method is nil but FileSystem.RemovePath was just called") + } + callInfo := struct { + Path string + }{ + Path: path, + } + mock.lockRemovePath.Lock() + mock.calls.RemovePath = append(mock.calls.RemovePath, callInfo) + mock.lockRemovePath.Unlock() + return mock.RemovePathFunc(path) +} + +// RemovePathCalls gets all the calls that were made to RemovePath. +// Check the length with: +// +// len(mockedFileSystem.RemovePathCalls()) +func (mock *MoqFileSystem) RemovePathCalls() []struct { + Path string +} { + var calls []struct { + Path string + } + mock.lockRemovePath.RLock() + calls = mock.calls.RemovePath + mock.lockRemovePath.RUnlock() + return calls +} + +// WriteFile calls WriteFileFunc. +func (mock *MoqFileSystem) WriteFile(path string, reader io.Reader) (int64, error) { + if mock.WriteFileFunc == nil { + panic("MoqFileSystem.WriteFileFunc: method is nil but FileSystem.WriteFile was just called") + } + callInfo := struct { + Path string + Reader io.Reader + }{ + Path: path, + Reader: reader, + } + mock.lockWriteFile.Lock() + mock.calls.WriteFile = append(mock.calls.WriteFile, callInfo) + mock.lockWriteFile.Unlock() + return mock.WriteFileFunc(path, reader) +} + +// WriteFileCalls gets all the calls that were made to WriteFile. +// Check the length with: +// +// len(mockedFileSystem.WriteFileCalls()) +func (mock *MoqFileSystem) WriteFileCalls() []struct { + Path string + Reader io.Reader +} { + var calls []struct { + Path string + Reader io.Reader + } + mock.lockWriteFile.RLock() + calls = mock.calls.WriteFile + mock.lockWriteFile.RUnlock() + return calls +} diff --git a/installer/utils/collections/slices.go b/installer/utils/collections/slices.go new file mode 100644 index 0000000..636da8e --- /dev/null +++ b/installer/utils/collections/slices.go @@ -0,0 +1,23 @@ +package collections + +import "errors" + +// Last returns the last element of a slice. If the slice is empty, it returns an error. +// This function is generic and works with any type of slice. +// It is useful for getting the last element of a slice without needing to know the type in advance. +// +// Example usage: +// +// slice := []int{1, 2, 3} +// lastElement, err := Last(slice) +// if err != nil { +// // handle error +// } +// fmt.Println(lastElement) // Output: 3 +func Last[E any](s []E) (E, error) { + if len(s) == 0 { + var zero E + return zero, errors.New("slice is empty") + } + return s[len(s)-1], nil +} diff --git a/installer/utils/collections/slices_test.go b/installer/utils/collections/slices_test.go new file mode 100644 index 0000000..5073546 --- /dev/null +++ b/installer/utils/collections/slices_test.go @@ -0,0 +1,38 @@ +package collections_test + +import ( + "testing" + + "github.com/MrPointer/dotfiles/installer/utils/collections" +) + +func TestLast(t *testing.T) { + tests := []struct { + name string + input []int + expected int + }{ + { + name: "non-empty slice", + input: []int{1, 2, 3}, + expected: 3, + }, + { + name: "empty slice", + input: []int{}, + expected: 0, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := collections.Last(test.input) + if err != nil && len(test.input) > 0 { + t.Errorf("Expected true, got false") + } + if result != test.expected { + t.Errorf("Expected %d, got %d", test.expected, result) + } + }) + } +} diff --git a/installer/utils/commander.go b/installer/utils/commander.go new file mode 100644 index 0000000..c97f3bb --- /dev/null +++ b/installer/utils/commander.go @@ -0,0 +1,295 @@ +package utils + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "github.com/MrPointer/dotfiles/installer/utils/logger" +) + +// Commander defines an interface for running system commands +// This allows us to proxy commands into a container for testing +// or use the real exec.Command in production. +type Commander interface { + // RunCommand executes a command with flexible options + // It returns the result containing output, error information, and exit code + RunCommand(name string, args []string, opts ...Option) (*Result, error) +} + +// Result contains the output and metadata from a command execution +type Result struct { + // Stdout contains the standard output + Stdout []byte + // Stderr contains the standard error output + Stderr []byte + // ExitCode is the exit code of the command + ExitCode int + // Duration is how long the command took to execute + Duration time.Duration +} + +// String returns the stdout as a string +func (r *Result) String() string { + return string(r.Stdout) +} + +// StderrString returns the stderr as a string +func (r *Result) StderrString() string { + return string(r.Stderr) +} + +// Options contains all configurable options for command execution +type Options struct { + // Env contains environment variables to set for the command + Env map[string]string + // Dir is the working directory for the command + Dir string + // Input is data to send to the command's stdin + Input []byte + // CaptureOutput determines whether to capture stdout/stderr or pipe to current process + CaptureOutput bool + // Interactive determines whether this is an interactive command that needs direct terminal access + Interactive bool + // Timeout specifies a timeout for the command execution + Timeout time.Duration + // Stdout specifies where to write stdout (only used when CaptureOutput is false) + Stdout io.Writer + // Stderr specifies where to write stderr (only used when CaptureOutput is false) + Stderr io.Writer +} + +// Option is a function that modifies Options +type Option func(*Options) + +// EmptyOption returns an empty option function +func EmptyOption() Option { + return func(opts *Options) {} +} + +// WithEnv sets environment variables for the command +func WithEnv(env map[string]string) Option { + return func(o *Options) { + if o.Env == nil { + o.Env = make(map[string]string) + } + for k, v := range env { + o.Env[k] = v + } + } +} + +// WithEnvVar sets a single environment variable +func WithEnvVar(key, value string) Option { + return func(o *Options) { + if o.Env == nil { + o.Env = make(map[string]string) + } + o.Env[key] = value + } +} + +// WithDir sets the working directory for the command +func WithDir(dir string) Option { + return func(o *Options) { + o.Dir = dir + } +} + +// WithInput provides input to send to the command's stdin +func WithInput(input []byte) Option { + return func(o *Options) { + o.Input = input + } +} + +// WithInputString provides string input to send to the command's stdin +func WithInputString(input string) Option { + return func(o *Options) { + o.Input = []byte(input) + } +} + +// WithCaptureOutput enables capturing stdout and stderr in the result +func WithCaptureOutput() Option { + return func(o *Options) { + o.CaptureOutput = true + } +} + +// WithDiscardOutput discards stdout and stderr output (sends to io.Discard) +func WithDiscardOutput() Option { + return func(o *Options) { + o.Stdout = io.Discard + o.Stderr = io.Discard + } +} + +// WithInteractive enables interactive mode for commands that need user input +// This ensures stdin/stdout/stderr are connected to the terminal and not captured +func WithInteractive() Option { + return func(o *Options) { + o.Interactive = true + o.CaptureOutput = false // Interactive commands should not capture output + } +} + +// WithInteractiveCapture enables interactive mode while still capturing output +// This allows user interaction but also captures output for parsing +func WithInteractiveCapture() Option { + return func(o *Options) { + o.Interactive = true + o.CaptureOutput = true // Capture output for parsing while allowing interaction + } +} + +// WithTimeout sets a timeout for command execution +func WithTimeout(timeout time.Duration) Option { + return func(o *Options) { + o.Timeout = timeout + } +} + +// WithStdout sets where stdout should be written (when not capturing) +func WithStdout(w io.Writer) Option { + return func(o *Options) { + o.Stdout = w + } +} + +// WithStderr sets where stderr should be written (when not capturing) +func WithStderr(w io.Writer) Option { + return func(o *Options) { + o.Stderr = w + } +} + +// DefaultCommander is the production implementation using os/exec +type DefaultCommander struct { + logger logger.Logger +} + +func NewDefaultCommander(logger logger.Logger) *DefaultCommander { + return &DefaultCommander{ + logger: logger, + } +} + +var _ Commander = (*DefaultCommander)(nil) + +func (c *DefaultCommander) RunCommand(name string, args []string, opts ...Option) (*Result, error) { + c.logger.Trace("Running command: %s %s", name, strings.Join(args, " ")) + + // Apply default options + options := &Options{ + CaptureOutput: false, + Interactive: false, + Stdout: os.Stdout, + Stderr: os.Stderr, + } + + // Apply provided options + for _, opt := range opts { + opt(options) + } + + // Create the command + cmd := exec.Command(name, args...) + + // Set working directory + if options.Dir != "" { + cmd.Dir = options.Dir + } + + // Set environment variables + if len(options.Env) > 0 { + cmd.Env = os.Environ() // Start with current environment + for key, value := range options.Env { + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, value)) + } + } + + // Set up input + if options.Interactive { + // For interactive commands, connect stdin directly to terminal + cmd.Stdin = os.Stdin + } else if len(options.Input) > 0 { + cmd.Stdin = bytes.NewReader(options.Input) + } + + var stdout, stderr bytes.Buffer + var result *Result + + start := time.Now() + + if options.Interactive && !options.CaptureOutput { + // For pure interactive commands, connect directly to terminal + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } else if options.Interactive && options.CaptureOutput { + // For interactive commands that also need output capture, + // use io.MultiWriter to both display and capture + cmd.Stdout = io.MultiWriter(os.Stdout, &stdout) + cmd.Stderr = io.MultiWriter(os.Stderr, &stderr) + } else if options.CaptureOutput { + // Capture output in buffers + cmd.Stdout = &stdout + cmd.Stderr = &stderr + } else { + // Pipe to specified writers + cmd.Stdout = options.Stdout + cmd.Stderr = options.Stderr + } + + // Handle timeout + var err error + if options.Timeout > 0 { + done := make(chan error, 1) + go func() { + done <- cmd.Run() + }() + + select { + case err = <-done: + // Command completed normally + case <-time.After(options.Timeout): + // Timeout occurred + if cmd.Process != nil { + cmd.Process.Kill() + } + err = fmt.Errorf("command timed out after %v", options.Timeout) + } + } else { + err = cmd.Run() + } + + duration := time.Since(start) + + // Create result + result = &Result{ + Duration: duration, + } + + if options.CaptureOutput { + result.Stdout = stdout.Bytes() + result.Stderr = stderr.Bytes() + } + + // Get exit code + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + result.ExitCode = exitError.ExitCode() + } else { + // Non-exit error (e.g., command not found, timeout) + result.ExitCode = -1 + } + } else { + result.ExitCode = 0 + } + + return result, err +} diff --git a/installer/utils/commander_mock.go b/installer/utils/commander_mock.go new file mode 100644 index 0000000..9652c49 --- /dev/null +++ b/installer/utils/commander_mock.go @@ -0,0 +1,87 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package utils + +import ( + "sync" +) + +// Ensure that MoqCommander does implement Commander. +// If this is not the case, regenerate this file with mockery. +var _ Commander = &MoqCommander{} + +// MoqCommander is a mock implementation of Commander. +// +// func TestSomethingThatUsesCommander(t *testing.T) { +// +// // make and configure a mocked Commander +// mockedCommander := &MoqCommander{ +// RunCommandFunc: func(name string, args []string, opts ...Option) (*Result, error) { +// panic("mock out the RunCommand method") +// }, +// } +// +// // use mockedCommander in code that requires Commander +// // and then make assertions. +// +// } +type MoqCommander struct { + // RunCommandFunc mocks the RunCommand method. + RunCommandFunc func(name string, args []string, opts ...Option) (*Result, error) + + // calls tracks calls to the methods. + calls struct { + // RunCommand holds details about calls to the RunCommand method. + RunCommand []struct { + // Name is the name argument value. + Name string + // Args is the args argument value. + Args []string + // Opts is the opts argument value. + Opts []Option + } + } + lockRunCommand sync.RWMutex +} + +// RunCommand calls RunCommandFunc. +func (mock *MoqCommander) RunCommand(name string, args []string, opts ...Option) (*Result, error) { + if mock.RunCommandFunc == nil { + panic("MoqCommander.RunCommandFunc: method is nil but Commander.RunCommand was just called") + } + callInfo := struct { + Name string + Args []string + Opts []Option + }{ + Name: name, + Args: args, + Opts: opts, + } + mock.lockRunCommand.Lock() + mock.calls.RunCommand = append(mock.calls.RunCommand, callInfo) + mock.lockRunCommand.Unlock() + return mock.RunCommandFunc(name, args, opts...) +} + +// RunCommandCalls gets all the calls that were made to RunCommand. +// Check the length with: +// +// len(mockedCommander.RunCommandCalls()) +func (mock *MoqCommander) RunCommandCalls() []struct { + Name string + Args []string + Opts []Option +} { + var calls []struct { + Name string + Args []string + Opts []Option + } + mock.lockRunCommand.RLock() + calls = mock.calls.RunCommand + mock.lockRunCommand.RUnlock() + return calls +} diff --git a/installer/utils/display_mode.go b/installer/utils/display_mode.go new file mode 100644 index 0000000..d20e1d4 --- /dev/null +++ b/installer/utils/display_mode.go @@ -0,0 +1,39 @@ +package utils + +// DisplayMode represents different output display modes for external tool execution. +type DisplayMode int + +const ( + // DisplayModeProgress shows progress indicators and hides command output (default interactive mode). + DisplayModeProgress DisplayMode = iota + // DisplayModePlain shows simple progress messages without spinners, hides command output. + DisplayModePlain + // DisplayModePassthrough shows all command output directly to stdout. + DisplayModePassthrough +) + +// String returns the string representation of the display mode. +func (d DisplayMode) String() string { + switch d { + case DisplayModeProgress: + return "progress" + case DisplayModePlain: + return "plain" + case DisplayModePassthrough: + return "passthrough" + default: + return "unknown" + } +} + +// ShouldDiscardOutput returns true if command output should be discarded/hidden. +func (d DisplayMode) ShouldDiscardOutput() bool { + switch d { + case DisplayModeProgress, DisplayModePlain: + return true + case DisplayModePassthrough: + return false + default: + return true + } +} diff --git a/installer/utils/display_mode_test.go b/installer/utils/display_mode_test.go new file mode 100644 index 0000000..1e3981f --- /dev/null +++ b/installer/utils/display_mode_test.go @@ -0,0 +1,79 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_DisplayMode_String_ReturnsCorrectStringRepresentation(t *testing.T) { + tests := []struct { + name string + displayMode DisplayMode + expectedStr string + }{ + { + name: "Progress_Mode", + displayMode: DisplayModeProgress, + expectedStr: "progress", + }, + { + name: "Plain_Mode", + displayMode: DisplayModePlain, + expectedStr: "plain", + }, + { + name: "Passthrough_Mode", + displayMode: DisplayModePassthrough, + expectedStr: "passthrough", + }, + { + name: "Unknown_Mode", + displayMode: DisplayMode(999), + expectedStr: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.displayMode.String() + require.Equal(t, tt.expectedStr, result) + }) + } +} + +func Test_DisplayMode_ShouldDiscardOutput_ReturnsCorrectValue(t *testing.T) { + tests := []struct { + name string + displayMode DisplayMode + shouldDiscard bool + }{ + { + name: "Progress_ShouldDiscardOutput", + displayMode: DisplayModeProgress, + shouldDiscard: true, + }, + { + name: "Plain_ShouldDiscardOutput", + displayMode: DisplayModePlain, + shouldDiscard: true, + }, + { + name: "Passthrough_ShouldNotDiscardOutput", + displayMode: DisplayModePassthrough, + shouldDiscard: false, + }, + { + name: "Unknown_ShouldDiscardOutput_ByDefault", + displayMode: DisplayMode(999), + shouldDiscard: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.displayMode.ShouldDiscardOutput() + require.Equal(t, tt.shouldDiscard, result) + }) + } +} diff --git a/installer/utils/files/current/current.go b/installer/utils/files/current/current.go new file mode 100644 index 0000000..a75fb17 --- /dev/null +++ b/installer/utils/files/current/current.go @@ -0,0 +1,28 @@ +package current + +import ( + "errors" + "path/filepath" + "runtime" +) + +// Filename returns the name of the current file. +// It uses [runtime.Caller] to get the file name of the caller. +func Filename() (string, error) { + _, filename, _, ok := runtime.Caller(1) + if !ok { + return "", errors.New("unable to get the current filename") + } + return filename, nil +} + +// Dirname returns the directory name of the current file. +// It uses the [Filename] function +// to get the file name and then extracts the directory part. +func Dirname() (string, error) { + filename, err := Filename() + if err != nil { + return "", err + } + return filepath.Dir(filename), nil +} diff --git a/installer/utils/files/current/current_test.go b/installer/utils/files/current/current_test.go new file mode 100644 index 0000000..307fc0b --- /dev/null +++ b/installer/utils/files/current/current_test.go @@ -0,0 +1,51 @@ +package current_test + +import ( + "strings" + "testing" + + "github.com/MrPointer/dotfiles/installer/utils/collections" + "github.com/MrPointer/dotfiles/installer/utils/files/current" +) + +func TestCurrentFileIsSelf(t *testing.T) { + // Get the current file path + currentFile, err := current.Filename() + if err != nil { + t.Fatalf("Failed to get current file: %v", err) + } + + // Get the expected last path element + expectedLastElement := "current_test.go" + + // Check if the current file is the expected file by comparing the last path element + lastPathElement, err := collections.Last(strings.Split(currentFile, "/")) + if err != nil { + t.Fatalf("Failed to get last path element of returned current file: %v", err) + } + + if lastPathElement != expectedLastElement { + t.Errorf("Expected current file to be %s, got %s", expectedLastElement, lastPathElement) + } +} + +func TestCurrentDirIsSelf(t *testing.T) { + // Get the current directory path + currentDir, err := current.Dirname() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + + // Get the expected last path element + expectedLastElement := "current" + + // Check if the current directory is the expected directory by comparing the last path element + lastPathElement, err := collections.Last(strings.Split(currentDir, "/")) + if err != nil { + t.Fatalf("Failed to get last path element of returned current directory: %v", err) + } + + if lastPathElement != expectedLastElement { + t.Errorf("Expected current directory to be %s, got %s", expectedLastElement, lastPathElement) + } +} diff --git a/installer/utils/files/current/root.go b/installer/utils/files/current/root.go new file mode 100644 index 0000000..f23d777 --- /dev/null +++ b/installer/utils/files/current/root.go @@ -0,0 +1,16 @@ +package current + +import "path/filepath" + +// Gets the root directory of the project by navigating three levels up from the current directory. +// This is useful for locating files or directories that are relative to the root of the project. +// +// Note: Should be used mostly in tests. +func RootDirectory() (string, error) { + currentDir, err := Dirname() + if err != nil { + return "", err + } + + return filepath.Join(currentDir, "..", "..", ".."), nil +} diff --git a/installer/utils/files/current/root_test.go b/installer/utils/files/current/root_test.go new file mode 100644 index 0000000..8ef172e --- /dev/null +++ b/installer/utils/files/current/root_test.go @@ -0,0 +1,22 @@ +package current_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/MrPointer/dotfiles/installer/utils/files/current" +) + +func TestRootDirectoryContainsMain(t *testing.T) { + // Test that the root directory contains the main.go file + rootDir, err := current.RootDirectory() + if err != nil { + t.Fatalf("Expected no error when getting root directory, got: %v", err) + } + + mainFilePath := filepath.Join(rootDir, "main.go") + if _, err := os.Stat(mainFilePath); os.IsNotExist(err) { + t.Fatalf("Expected main.go to exist in root directory, but it does not: %v", err) + } +} diff --git a/installer/utils/filesystem.go b/installer/utils/filesystem.go new file mode 100644 index 0000000..5f67791 --- /dev/null +++ b/installer/utils/filesystem.go @@ -0,0 +1,163 @@ +package utils + +import ( + "io" + "os" +) + +type FileSystem interface { + // CreateFile creates a file at the specified path. + // If the file already exists, it will be truncated. + // + // It returns the created file or an error if it fails. + CreateFile(path string) (string, error) + + // CreateDirectory creates a directory at the specified path. + CreateDirectory(path string) error + + // CreateDirectoryWithPermissions creates a directory at the specified path with the specified permissions. + CreateDirectoryWithPermissions(path string, mode os.FileMode) error + + // RemovePath removes a file or directory at the specified path. + // If the path is a directory, it will be removed recursively. + RemovePath(path string) error + + // PathExists checks if a file or directory exists at the specified path. + // It returns true if the path exists, false if it does not, and an error if there was an issue checking the path. + // This function does not distinguish between files and directories. + PathExists(path string) (bool, error) + + // CreateTemporaryFile creates a temporary file in the optional specified directory. + // dir is the directory where the temporary file will be created. + // If dir is nil, the system's default temporary directory will be used. + // + // It returns the created file or an error if it fails. + CreateTemporaryFile(dir, pattern string) (string, error) + + // CreateTemporaryDirectory creates a temporary directory in the optional specified directory. + // dir is the directory where the temporary directory will be created. + // If dir is nil, the system's default temporary directory will be used. + // + // It returns the path of the created temporary directory or an error if it fails. + CreateTemporaryDirectory(dir string) (string, error) + + // WriteFile writes data to a file at the specified path. + // If the file does not exist, it will be created. + // If the file exists, it will be truncated before writing. + // + // It accepts an io.Reader to read data from, which allows for streaming data into the file. + // + // It returns the number of bytes written and an error if the write operation fails. + WriteFile(path string, reader io.Reader) (int64, error) + + // ReadFile reads data from a file at the specified path. + // It writes the data to the provided receiver, which is an io.Writer. + // + // It returns the number of bytes read and an error if the read operation fails. + ReadFile(path string, receiver io.Writer) (int64, error) +} + +// DefaultFileSystem is the default implementation of the FileSystem interface using os package. +type DefaultFileSystem struct{} + +var _ FileSystem = (*DefaultFileSystem)(nil) + +// NewDefaultFileSystem creates a new DefaultFileSystem. +func NewDefaultFileSystem() FileSystem { + return &DefaultFileSystem{} +} + +func (fs *DefaultFileSystem) CreateFile(path string) (string, error) { + file, err := os.Create(path) + if err != nil { + return "", err + } + defer file.Close() + + return file.Name(), nil +} + +func (fs *DefaultFileSystem) CreateDirectory(path string) error { + const defaultPermissions = 0o755 // Default permissions for directories. + return os.MkdirAll(path, defaultPermissions) +} + +func (fs *DefaultFileSystem) CreateDirectoryWithPermissions(path string, permissions os.FileMode) error { + return os.MkdirAll(path, permissions) +} + +func (fs *DefaultFileSystem) RemovePath(path string) error { + return os.RemoveAll(path) +} + +func (fs *DefaultFileSystem) PathExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + + if os.IsNotExist(err) { + return false, nil + } + + return false, err +} + +func (fs *DefaultFileSystem) CreateTemporaryFile(dir, pattern string) (string, error) { + var tempDir string + if dir != "" { + tempDir = dir + } + + computedPattern := "tempfile-*.tmp" + if pattern != "" { + computedPattern = pattern + } + + tempFile, err := os.CreateTemp(tempDir, computedPattern) + if err != nil { + return "", err + } + defer tempFile.Close() + + return tempFile.Name(), nil +} + +func (fs *DefaultFileSystem) CreateTemporaryDirectory(dir string) (string, error) { + var tempDir string + if dir != "" { + tempDir = dir + } + + return os.MkdirTemp(tempDir, "tempdir-*") +} + +func (fs *DefaultFileSystem) WriteFile(path string, reader io.Reader) (int64, error) { + file, err := os.Create(path) + if err != nil { + return 0, err + } + defer file.Close() + + bytesWritten, err := io.Copy(file, reader) + if err != nil { + return 0, err + } + + return bytesWritten, nil +} + +func (fs *DefaultFileSystem) ReadFile(path string, receiver io.Writer) (int64, error) { + file, err := os.Open(path) + if err != nil { + return 0, err + } + defer file.Close() + + bytesRead, err := io.Copy(receiver, file) + if err != nil { + return 0, err + } + + return bytesRead, nil +} diff --git a/installer/utils/httpclient/HTTPClient_mock.go b/installer/utils/httpclient/HTTPClient_mock.go new file mode 100644 index 0000000..38fdd4c --- /dev/null +++ b/installer/utils/httpclient/HTTPClient_mock.go @@ -0,0 +1,133 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package httpclient + +import ( + "io" + "net/http" + "sync" +) + +// Ensure that MoqHTTPClient does implement HTTPClient. +// If this is not the case, regenerate this file with mockery. +var _ HTTPClient = &MoqHTTPClient{} + +// MoqHTTPClient is a mock implementation of HTTPClient. +// +// func TestSomethingThatUsesHTTPClient(t *testing.T) { +// +// // make and configure a mocked HTTPClient +// mockedHTTPClient := &MoqHTTPClient{ +// GetFunc: func(url string) (*http.Response, error) { +// panic("mock out the Get method") +// }, +// PostFunc: func(url string, contentType string, body io.Reader) (*http.Response, error) { +// panic("mock out the Post method") +// }, +// } +// +// // use mockedHTTPClient in code that requires HTTPClient +// // and then make assertions. +// +// } +type MoqHTTPClient struct { + // GetFunc mocks the Get method. + GetFunc func(url string) (*http.Response, error) + + // PostFunc mocks the Post method. + PostFunc func(url string, contentType string, body io.Reader) (*http.Response, error) + + // calls tracks calls to the methods. + calls struct { + // Get holds details about calls to the Get method. + Get []struct { + // URL is the url argument value. + URL string + } + // Post holds details about calls to the Post method. + Post []struct { + // URL is the url argument value. + URL string + // ContentType is the contentType argument value. + ContentType string + // Body is the body argument value. + Body io.Reader + } + } + lockGet sync.RWMutex + lockPost sync.RWMutex +} + +// Get calls GetFunc. +func (mock *MoqHTTPClient) Get(url string) (*http.Response, error) { + if mock.GetFunc == nil { + panic("MoqHTTPClient.GetFunc: method is nil but HTTPClient.Get was just called") + } + callInfo := struct { + URL string + }{ + URL: url, + } + mock.lockGet.Lock() + mock.calls.Get = append(mock.calls.Get, callInfo) + mock.lockGet.Unlock() + return mock.GetFunc(url) +} + +// GetCalls gets all the calls that were made to Get. +// Check the length with: +// +// len(mockedHTTPClient.GetCalls()) +func (mock *MoqHTTPClient) GetCalls() []struct { + URL string +} { + var calls []struct { + URL string + } + mock.lockGet.RLock() + calls = mock.calls.Get + mock.lockGet.RUnlock() + return calls +} + +// Post calls PostFunc. +func (mock *MoqHTTPClient) Post(url string, contentType string, body io.Reader) (*http.Response, error) { + if mock.PostFunc == nil { + panic("MoqHTTPClient.PostFunc: method is nil but HTTPClient.Post was just called") + } + callInfo := struct { + URL string + ContentType string + Body io.Reader + }{ + URL: url, + ContentType: contentType, + Body: body, + } + mock.lockPost.Lock() + mock.calls.Post = append(mock.calls.Post, callInfo) + mock.lockPost.Unlock() + return mock.PostFunc(url, contentType, body) +} + +// PostCalls gets all the calls that were made to Post. +// Check the length with: +// +// len(mockedHTTPClient.PostCalls()) +func (mock *MoqHTTPClient) PostCalls() []struct { + URL string + ContentType string + Body io.Reader +} { + var calls []struct { + URL string + ContentType string + Body io.Reader + } + mock.lockPost.RLock() + calls = mock.calls.Post + mock.lockPost.RUnlock() + return calls +} diff --git a/installer/utils/httpclient/httpclient.go b/installer/utils/httpclient/httpclient.go new file mode 100644 index 0000000..e4af7e0 --- /dev/null +++ b/installer/utils/httpclient/httpclient.go @@ -0,0 +1,38 @@ +package httpclient + +import ( + "io" + "net/http" +) + +// HTTPClient defines the interface for an HTTP client. +// +//go:generate moq -out httpclient_moq.go . HTTPClient +type HTTPClient interface { + Get(url string) (*http.Response, error) + Post(url, contentType string, body io.Reader) (*http.Response, error) +} + +// defaultHTTPClient is the default implementation of HTTPClient using net/http. +type defaultHTTPClient struct { + client *http.Client +} + +var _ HTTPClient = (*defaultHTTPClient)(nil) + +// NewDefaultHTTPClient creates a new HTTPClient with default settings. +func NewDefaultHTTPClient() HTTPClient { + return &defaultHTTPClient{ + client: &http.Client{}, + } +} + +// Get performs a GET request. +func (c *defaultHTTPClient) Get(url string) (*http.Response, error) { + return c.client.Get(url) +} + +// Post performs a POST request. +func (c *defaultHTTPClient) Post(url, contentType string, body io.Reader) (*http.Response, error) { + return c.client.Post(url, contentType, body) +} diff --git a/installer/utils/logger/Logger_mock.go b/installer/utils/logger/Logger_mock.go new file mode 100644 index 0000000..8869877 --- /dev/null +++ b/installer/utils/logger/Logger_mock.go @@ -0,0 +1,870 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package logger + +import ( + "sync" +) + +// Ensure that MoqLogger does implement Logger. +// If this is not the case, regenerate this file with mockery. +var _ Logger = &MoqLogger{} + +// MoqLogger is a mock implementation of Logger. +// +// func TestSomethingThatUsesLogger(t *testing.T) { +// +// // make and configure a mocked Logger +// mockedLogger := &MoqLogger{ +// CloseFunc: func() error { +// panic("mock out the Close method") +// }, +// DebugFunc: func(format string, args ...any) { +// panic("mock out the Debug method") +// }, +// ErrorFunc: func(format string, args ...any) { +// panic("mock out the Error method") +// }, +// FailInteractiveProgressFunc: func(message string, err error) error { +// panic("mock out the FailInteractiveProgress method") +// }, +// FailPersistentProgressFunc: func(message string, err error) error { +// panic("mock out the FailPersistentProgress method") +// }, +// FailProgressFunc: func(message string, err error) error { +// panic("mock out the FailProgress method") +// }, +// FinishInteractiveProgressFunc: func(message string) error { +// panic("mock out the FinishInteractiveProgress method") +// }, +// FinishPersistentProgressFunc: func(message string) error { +// panic("mock out the FinishPersistentProgress method") +// }, +// FinishProgressFunc: func(message string) error { +// panic("mock out the FinishProgress method") +// }, +// InfoFunc: func(format string, args ...any) { +// panic("mock out the Info method") +// }, +// LogAccomplishmentFunc: func(message string) error { +// panic("mock out the LogAccomplishment method") +// }, +// StartInteractiveProgressFunc: func(message string) error { +// panic("mock out the StartInteractiveProgress method") +// }, +// StartPersistentProgressFunc: func(message string) error { +// panic("mock out the StartPersistentProgress method") +// }, +// StartProgressFunc: func(message string) error { +// panic("mock out the StartProgress method") +// }, +// SuccessFunc: func(format string, args ...any) { +// panic("mock out the Success method") +// }, +// TraceFunc: func(format string, args ...any) { +// panic("mock out the Trace method") +// }, +// UpdateProgressFunc: func(message string) error { +// panic("mock out the UpdateProgress method") +// }, +// WarningFunc: func(format string, args ...any) { +// panic("mock out the Warning method") +// }, +// } +// +// // use mockedLogger in code that requires Logger +// // and then make assertions. +// +// } +type MoqLogger struct { + // CloseFunc mocks the Close method. + CloseFunc func() error + + // DebugFunc mocks the Debug method. + DebugFunc func(format string, args ...any) + + // ErrorFunc mocks the Error method. + ErrorFunc func(format string, args ...any) + + // FailInteractiveProgressFunc mocks the FailInteractiveProgress method. + FailInteractiveProgressFunc func(message string, err error) error + + // FailPersistentProgressFunc mocks the FailPersistentProgress method. + FailPersistentProgressFunc func(message string, err error) error + + // FailProgressFunc mocks the FailProgress method. + FailProgressFunc func(message string, err error) error + + // FinishInteractiveProgressFunc mocks the FinishInteractiveProgress method. + FinishInteractiveProgressFunc func(message string) error + + // FinishPersistentProgressFunc mocks the FinishPersistentProgress method. + FinishPersistentProgressFunc func(message string) error + + // FinishProgressFunc mocks the FinishProgress method. + FinishProgressFunc func(message string) error + + // InfoFunc mocks the Info method. + InfoFunc func(format string, args ...any) + + // LogAccomplishmentFunc mocks the LogAccomplishment method. + LogAccomplishmentFunc func(message string) error + + // StartInteractiveProgressFunc mocks the StartInteractiveProgress method. + StartInteractiveProgressFunc func(message string) error + + // StartPersistentProgressFunc mocks the StartPersistentProgress method. + StartPersistentProgressFunc func(message string) error + + // StartProgressFunc mocks the StartProgress method. + StartProgressFunc func(message string) error + + // SuccessFunc mocks the Success method. + SuccessFunc func(format string, args ...any) + + // TraceFunc mocks the Trace method. + TraceFunc func(format string, args ...any) + + // UpdateProgressFunc mocks the UpdateProgress method. + UpdateProgressFunc func(message string) error + + // WarningFunc mocks the Warning method. + WarningFunc func(format string, args ...any) + + // calls tracks calls to the methods. + calls struct { + // Close holds details about calls to the Close method. + Close []struct { + } + // Debug holds details about calls to the Debug method. + Debug []struct { + // Format is the format argument value. + Format string + // Args is the args argument value. + Args []any + } + // Error holds details about calls to the Error method. + Error []struct { + // Format is the format argument value. + Format string + // Args is the args argument value. + Args []any + } + // FailInteractiveProgress holds details about calls to the FailInteractiveProgress method. + FailInteractiveProgress []struct { + // Message is the message argument value. + Message string + // Err is the err argument value. + Err error + } + // FailPersistentProgress holds details about calls to the FailPersistentProgress method. + FailPersistentProgress []struct { + // Message is the message argument value. + Message string + // Err is the err argument value. + Err error + } + // FailProgress holds details about calls to the FailProgress method. + FailProgress []struct { + // Message is the message argument value. + Message string + // Err is the err argument value. + Err error + } + // FinishInteractiveProgress holds details about calls to the FinishInteractiveProgress method. + FinishInteractiveProgress []struct { + // Message is the message argument value. + Message string + } + // FinishPersistentProgress holds details about calls to the FinishPersistentProgress method. + FinishPersistentProgress []struct { + // Message is the message argument value. + Message string + } + // FinishProgress holds details about calls to the FinishProgress method. + FinishProgress []struct { + // Message is the message argument value. + Message string + } + // Info holds details about calls to the Info method. + Info []struct { + // Format is the format argument value. + Format string + // Args is the args argument value. + Args []any + } + // LogAccomplishment holds details about calls to the LogAccomplishment method. + LogAccomplishment []struct { + // Message is the message argument value. + Message string + } + // StartInteractiveProgress holds details about calls to the StartInteractiveProgress method. + StartInteractiveProgress []struct { + // Message is the message argument value. + Message string + } + // StartPersistentProgress holds details about calls to the StartPersistentProgress method. + StartPersistentProgress []struct { + // Message is the message argument value. + Message string + } + // StartProgress holds details about calls to the StartProgress method. + StartProgress []struct { + // Message is the message argument value. + Message string + } + // Success holds details about calls to the Success method. + Success []struct { + // Format is the format argument value. + Format string + // Args is the args argument value. + Args []any + } + // Trace holds details about calls to the Trace method. + Trace []struct { + // Format is the format argument value. + Format string + // Args is the args argument value. + Args []any + } + // UpdateProgress holds details about calls to the UpdateProgress method. + UpdateProgress []struct { + // Message is the message argument value. + Message string + } + // Warning holds details about calls to the Warning method. + Warning []struct { + // Format is the format argument value. + Format string + // Args is the args argument value. + Args []any + } + } + lockClose sync.RWMutex + lockDebug sync.RWMutex + lockError sync.RWMutex + lockFailInteractiveProgress sync.RWMutex + lockFailPersistentProgress sync.RWMutex + lockFailProgress sync.RWMutex + lockFinishInteractiveProgress sync.RWMutex + lockFinishPersistentProgress sync.RWMutex + lockFinishProgress sync.RWMutex + lockInfo sync.RWMutex + lockLogAccomplishment sync.RWMutex + lockStartInteractiveProgress sync.RWMutex + lockStartPersistentProgress sync.RWMutex + lockStartProgress sync.RWMutex + lockSuccess sync.RWMutex + lockTrace sync.RWMutex + lockUpdateProgress sync.RWMutex + lockWarning sync.RWMutex +} + +// Close calls CloseFunc. +func (mock *MoqLogger) Close() error { + if mock.CloseFunc == nil { + panic("MoqLogger.CloseFunc: method is nil but Logger.Close was just called") + } + callInfo := struct { + }{} + mock.lockClose.Lock() + mock.calls.Close = append(mock.calls.Close, callInfo) + mock.lockClose.Unlock() + return mock.CloseFunc() +} + +// CloseCalls gets all the calls that were made to Close. +// Check the length with: +// +// len(mockedLogger.CloseCalls()) +func (mock *MoqLogger) CloseCalls() []struct { +} { + var calls []struct { + } + mock.lockClose.RLock() + calls = mock.calls.Close + mock.lockClose.RUnlock() + return calls +} + +// Debug calls DebugFunc. +func (mock *MoqLogger) Debug(format string, args ...any) { + if mock.DebugFunc == nil { + panic("MoqLogger.DebugFunc: method is nil but Logger.Debug was just called") + } + callInfo := struct { + Format string + Args []any + }{ + Format: format, + Args: args, + } + mock.lockDebug.Lock() + mock.calls.Debug = append(mock.calls.Debug, callInfo) + mock.lockDebug.Unlock() + mock.DebugFunc(format, args...) +} + +// DebugCalls gets all the calls that were made to Debug. +// Check the length with: +// +// len(mockedLogger.DebugCalls()) +func (mock *MoqLogger) DebugCalls() []struct { + Format string + Args []any +} { + var calls []struct { + Format string + Args []any + } + mock.lockDebug.RLock() + calls = mock.calls.Debug + mock.lockDebug.RUnlock() + return calls +} + +// Error calls ErrorFunc. +func (mock *MoqLogger) Error(format string, args ...any) { + if mock.ErrorFunc == nil { + panic("MoqLogger.ErrorFunc: method is nil but Logger.Error was just called") + } + callInfo := struct { + Format string + Args []any + }{ + Format: format, + Args: args, + } + mock.lockError.Lock() + mock.calls.Error = append(mock.calls.Error, callInfo) + mock.lockError.Unlock() + mock.ErrorFunc(format, args...) +} + +// ErrorCalls gets all the calls that were made to Error. +// Check the length with: +// +// len(mockedLogger.ErrorCalls()) +func (mock *MoqLogger) ErrorCalls() []struct { + Format string + Args []any +} { + var calls []struct { + Format string + Args []any + } + mock.lockError.RLock() + calls = mock.calls.Error + mock.lockError.RUnlock() + return calls +} + +// FailInteractiveProgress calls FailInteractiveProgressFunc. +func (mock *MoqLogger) FailInteractiveProgress(message string, err error) error { + if mock.FailInteractiveProgressFunc == nil { + panic("MoqLogger.FailInteractiveProgressFunc: method is nil but Logger.FailInteractiveProgress was just called") + } + callInfo := struct { + Message string + Err error + }{ + Message: message, + Err: err, + } + mock.lockFailInteractiveProgress.Lock() + mock.calls.FailInteractiveProgress = append(mock.calls.FailInteractiveProgress, callInfo) + mock.lockFailInteractiveProgress.Unlock() + return mock.FailInteractiveProgressFunc(message, err) +} + +// FailInteractiveProgressCalls gets all the calls that were made to FailInteractiveProgress. +// Check the length with: +// +// len(mockedLogger.FailInteractiveProgressCalls()) +func (mock *MoqLogger) FailInteractiveProgressCalls() []struct { + Message string + Err error +} { + var calls []struct { + Message string + Err error + } + mock.lockFailInteractiveProgress.RLock() + calls = mock.calls.FailInteractiveProgress + mock.lockFailInteractiveProgress.RUnlock() + return calls +} + +// FailPersistentProgress calls FailPersistentProgressFunc. +func (mock *MoqLogger) FailPersistentProgress(message string, err error) error { + if mock.FailPersistentProgressFunc == nil { + panic("MoqLogger.FailPersistentProgressFunc: method is nil but Logger.FailPersistentProgress was just called") + } + callInfo := struct { + Message string + Err error + }{ + Message: message, + Err: err, + } + mock.lockFailPersistentProgress.Lock() + mock.calls.FailPersistentProgress = append(mock.calls.FailPersistentProgress, callInfo) + mock.lockFailPersistentProgress.Unlock() + return mock.FailPersistentProgressFunc(message, err) +} + +// FailPersistentProgressCalls gets all the calls that were made to FailPersistentProgress. +// Check the length with: +// +// len(mockedLogger.FailPersistentProgressCalls()) +func (mock *MoqLogger) FailPersistentProgressCalls() []struct { + Message string + Err error +} { + var calls []struct { + Message string + Err error + } + mock.lockFailPersistentProgress.RLock() + calls = mock.calls.FailPersistentProgress + mock.lockFailPersistentProgress.RUnlock() + return calls +} + +// FailProgress calls FailProgressFunc. +func (mock *MoqLogger) FailProgress(message string, err error) error { + if mock.FailProgressFunc == nil { + panic("MoqLogger.FailProgressFunc: method is nil but Logger.FailProgress was just called") + } + callInfo := struct { + Message string + Err error + }{ + Message: message, + Err: err, + } + mock.lockFailProgress.Lock() + mock.calls.FailProgress = append(mock.calls.FailProgress, callInfo) + mock.lockFailProgress.Unlock() + return mock.FailProgressFunc(message, err) +} + +// FailProgressCalls gets all the calls that were made to FailProgress. +// Check the length with: +// +// len(mockedLogger.FailProgressCalls()) +func (mock *MoqLogger) FailProgressCalls() []struct { + Message string + Err error +} { + var calls []struct { + Message string + Err error + } + mock.lockFailProgress.RLock() + calls = mock.calls.FailProgress + mock.lockFailProgress.RUnlock() + return calls +} + +// FinishInteractiveProgress calls FinishInteractiveProgressFunc. +func (mock *MoqLogger) FinishInteractiveProgress(message string) error { + if mock.FinishInteractiveProgressFunc == nil { + panic("MoqLogger.FinishInteractiveProgressFunc: method is nil but Logger.FinishInteractiveProgress was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockFinishInteractiveProgress.Lock() + mock.calls.FinishInteractiveProgress = append(mock.calls.FinishInteractiveProgress, callInfo) + mock.lockFinishInteractiveProgress.Unlock() + return mock.FinishInteractiveProgressFunc(message) +} + +// FinishInteractiveProgressCalls gets all the calls that were made to FinishInteractiveProgress. +// Check the length with: +// +// len(mockedLogger.FinishInteractiveProgressCalls()) +func (mock *MoqLogger) FinishInteractiveProgressCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockFinishInteractiveProgress.RLock() + calls = mock.calls.FinishInteractiveProgress + mock.lockFinishInteractiveProgress.RUnlock() + return calls +} + +// FinishPersistentProgress calls FinishPersistentProgressFunc. +func (mock *MoqLogger) FinishPersistentProgress(message string) error { + if mock.FinishPersistentProgressFunc == nil { + panic("MoqLogger.FinishPersistentProgressFunc: method is nil but Logger.FinishPersistentProgress was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockFinishPersistentProgress.Lock() + mock.calls.FinishPersistentProgress = append(mock.calls.FinishPersistentProgress, callInfo) + mock.lockFinishPersistentProgress.Unlock() + return mock.FinishPersistentProgressFunc(message) +} + +// FinishPersistentProgressCalls gets all the calls that were made to FinishPersistentProgress. +// Check the length with: +// +// len(mockedLogger.FinishPersistentProgressCalls()) +func (mock *MoqLogger) FinishPersistentProgressCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockFinishPersistentProgress.RLock() + calls = mock.calls.FinishPersistentProgress + mock.lockFinishPersistentProgress.RUnlock() + return calls +} + +// FinishProgress calls FinishProgressFunc. +func (mock *MoqLogger) FinishProgress(message string) error { + if mock.FinishProgressFunc == nil { + panic("MoqLogger.FinishProgressFunc: method is nil but Logger.FinishProgress was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockFinishProgress.Lock() + mock.calls.FinishProgress = append(mock.calls.FinishProgress, callInfo) + mock.lockFinishProgress.Unlock() + return mock.FinishProgressFunc(message) +} + +// FinishProgressCalls gets all the calls that were made to FinishProgress. +// Check the length with: +// +// len(mockedLogger.FinishProgressCalls()) +func (mock *MoqLogger) FinishProgressCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockFinishProgress.RLock() + calls = mock.calls.FinishProgress + mock.lockFinishProgress.RUnlock() + return calls +} + +// Info calls InfoFunc. +func (mock *MoqLogger) Info(format string, args ...any) { + if mock.InfoFunc == nil { + panic("MoqLogger.InfoFunc: method is nil but Logger.Info was just called") + } + callInfo := struct { + Format string + Args []any + }{ + Format: format, + Args: args, + } + mock.lockInfo.Lock() + mock.calls.Info = append(mock.calls.Info, callInfo) + mock.lockInfo.Unlock() + mock.InfoFunc(format, args...) +} + +// InfoCalls gets all the calls that were made to Info. +// Check the length with: +// +// len(mockedLogger.InfoCalls()) +func (mock *MoqLogger) InfoCalls() []struct { + Format string + Args []any +} { + var calls []struct { + Format string + Args []any + } + mock.lockInfo.RLock() + calls = mock.calls.Info + mock.lockInfo.RUnlock() + return calls +} + +// LogAccomplishment calls LogAccomplishmentFunc. +func (mock *MoqLogger) LogAccomplishment(message string) error { + if mock.LogAccomplishmentFunc == nil { + panic("MoqLogger.LogAccomplishmentFunc: method is nil but Logger.LogAccomplishment was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockLogAccomplishment.Lock() + mock.calls.LogAccomplishment = append(mock.calls.LogAccomplishment, callInfo) + mock.lockLogAccomplishment.Unlock() + return mock.LogAccomplishmentFunc(message) +} + +// LogAccomplishmentCalls gets all the calls that were made to LogAccomplishment. +// Check the length with: +// +// len(mockedLogger.LogAccomplishmentCalls()) +func (mock *MoqLogger) LogAccomplishmentCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockLogAccomplishment.RLock() + calls = mock.calls.LogAccomplishment + mock.lockLogAccomplishment.RUnlock() + return calls +} + +// StartInteractiveProgress calls StartInteractiveProgressFunc. +func (mock *MoqLogger) StartInteractiveProgress(message string) error { + if mock.StartInteractiveProgressFunc == nil { + panic("MoqLogger.StartInteractiveProgressFunc: method is nil but Logger.StartInteractiveProgress was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockStartInteractiveProgress.Lock() + mock.calls.StartInteractiveProgress = append(mock.calls.StartInteractiveProgress, callInfo) + mock.lockStartInteractiveProgress.Unlock() + return mock.StartInteractiveProgressFunc(message) +} + +// StartInteractiveProgressCalls gets all the calls that were made to StartInteractiveProgress. +// Check the length with: +// +// len(mockedLogger.StartInteractiveProgressCalls()) +func (mock *MoqLogger) StartInteractiveProgressCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockStartInteractiveProgress.RLock() + calls = mock.calls.StartInteractiveProgress + mock.lockStartInteractiveProgress.RUnlock() + return calls +} + +// StartPersistentProgress calls StartPersistentProgressFunc. +func (mock *MoqLogger) StartPersistentProgress(message string) error { + if mock.StartPersistentProgressFunc == nil { + panic("MoqLogger.StartPersistentProgressFunc: method is nil but Logger.StartPersistentProgress was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockStartPersistentProgress.Lock() + mock.calls.StartPersistentProgress = append(mock.calls.StartPersistentProgress, callInfo) + mock.lockStartPersistentProgress.Unlock() + return mock.StartPersistentProgressFunc(message) +} + +// StartPersistentProgressCalls gets all the calls that were made to StartPersistentProgress. +// Check the length with: +// +// len(mockedLogger.StartPersistentProgressCalls()) +func (mock *MoqLogger) StartPersistentProgressCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockStartPersistentProgress.RLock() + calls = mock.calls.StartPersistentProgress + mock.lockStartPersistentProgress.RUnlock() + return calls +} + +// StartProgress calls StartProgressFunc. +func (mock *MoqLogger) StartProgress(message string) error { + if mock.StartProgressFunc == nil { + panic("MoqLogger.StartProgressFunc: method is nil but Logger.StartProgress was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockStartProgress.Lock() + mock.calls.StartProgress = append(mock.calls.StartProgress, callInfo) + mock.lockStartProgress.Unlock() + return mock.StartProgressFunc(message) +} + +// StartProgressCalls gets all the calls that were made to StartProgress. +// Check the length with: +// +// len(mockedLogger.StartProgressCalls()) +func (mock *MoqLogger) StartProgressCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockStartProgress.RLock() + calls = mock.calls.StartProgress + mock.lockStartProgress.RUnlock() + return calls +} + +// Success calls SuccessFunc. +func (mock *MoqLogger) Success(format string, args ...any) { + if mock.SuccessFunc == nil { + panic("MoqLogger.SuccessFunc: method is nil but Logger.Success was just called") + } + callInfo := struct { + Format string + Args []any + }{ + Format: format, + Args: args, + } + mock.lockSuccess.Lock() + mock.calls.Success = append(mock.calls.Success, callInfo) + mock.lockSuccess.Unlock() + mock.SuccessFunc(format, args...) +} + +// SuccessCalls gets all the calls that were made to Success. +// Check the length with: +// +// len(mockedLogger.SuccessCalls()) +func (mock *MoqLogger) SuccessCalls() []struct { + Format string + Args []any +} { + var calls []struct { + Format string + Args []any + } + mock.lockSuccess.RLock() + calls = mock.calls.Success + mock.lockSuccess.RUnlock() + return calls +} + +// Trace calls TraceFunc. +func (mock *MoqLogger) Trace(format string, args ...any) { + if mock.TraceFunc == nil { + panic("MoqLogger.TraceFunc: method is nil but Logger.Trace was just called") + } + callInfo := struct { + Format string + Args []any + }{ + Format: format, + Args: args, + } + mock.lockTrace.Lock() + mock.calls.Trace = append(mock.calls.Trace, callInfo) + mock.lockTrace.Unlock() + mock.TraceFunc(format, args...) +} + +// TraceCalls gets all the calls that were made to Trace. +// Check the length with: +// +// len(mockedLogger.TraceCalls()) +func (mock *MoqLogger) TraceCalls() []struct { + Format string + Args []any +} { + var calls []struct { + Format string + Args []any + } + mock.lockTrace.RLock() + calls = mock.calls.Trace + mock.lockTrace.RUnlock() + return calls +} + +// UpdateProgress calls UpdateProgressFunc. +func (mock *MoqLogger) UpdateProgress(message string) error { + if mock.UpdateProgressFunc == nil { + panic("MoqLogger.UpdateProgressFunc: method is nil but Logger.UpdateProgress was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockUpdateProgress.Lock() + mock.calls.UpdateProgress = append(mock.calls.UpdateProgress, callInfo) + mock.lockUpdateProgress.Unlock() + return mock.UpdateProgressFunc(message) +} + +// UpdateProgressCalls gets all the calls that were made to UpdateProgress. +// Check the length with: +// +// len(mockedLogger.UpdateProgressCalls()) +func (mock *MoqLogger) UpdateProgressCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockUpdateProgress.RLock() + calls = mock.calls.UpdateProgress + mock.lockUpdateProgress.RUnlock() + return calls +} + +// Warning calls WarningFunc. +func (mock *MoqLogger) Warning(format string, args ...any) { + if mock.WarningFunc == nil { + panic("MoqLogger.WarningFunc: method is nil but Logger.Warning was just called") + } + callInfo := struct { + Format string + Args []any + }{ + Format: format, + Args: args, + } + mock.lockWarning.Lock() + mock.calls.Warning = append(mock.calls.Warning, callInfo) + mock.lockWarning.Unlock() + mock.WarningFunc(format, args...) +} + +// WarningCalls gets all the calls that were made to Warning. +// Check the length with: +// +// len(mockedLogger.WarningCalls()) +func (mock *MoqLogger) WarningCalls() []struct { + Format string + Args []any +} { + var calls []struct { + Format string + Args []any + } + mock.lockWarning.RLock() + calls = mock.calls.Warning + mock.lockWarning.RUnlock() + return calls +} diff --git a/installer/utils/logger/ProgressReporter_mock.go b/installer/utils/logger/ProgressReporter_mock.go new file mode 100644 index 0000000..2308948 --- /dev/null +++ b/installer/utils/logger/ProgressReporter_mock.go @@ -0,0 +1,617 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package logger + +import ( + "sync" +) + +// Ensure that MoqProgressReporter does implement ProgressReporter. +// If this is not the case, regenerate this file with mockery. +var _ ProgressReporter = &MoqProgressReporter{} + +// MoqProgressReporter is a mock implementation of ProgressReporter. +// +// func TestSomethingThatUsesProgressReporter(t *testing.T) { +// +// // make and configure a mocked ProgressReporter +// mockedProgressReporter := &MoqProgressReporter{ +// ClearFunc: func() error { +// panic("mock out the Clear method") +// }, +// CloseFunc: func() error { +// panic("mock out the Close method") +// }, +// FailFunc: func(message string, err error) error { +// panic("mock out the Fail method") +// }, +// FailPersistentFunc: func(message string, err error) error { +// panic("mock out the FailPersistent method") +// }, +// FinishFunc: func(message string) error { +// panic("mock out the Finish method") +// }, +// FinishPersistentFunc: func(message string) error { +// panic("mock out the FinishPersistent method") +// }, +// IsActiveFunc: func() bool { +// panic("mock out the IsActive method") +// }, +// IsPausedFunc: func() bool { +// panic("mock out the IsPaused method") +// }, +// LogAccomplishmentFunc: func(message string) error { +// panic("mock out the LogAccomplishment method") +// }, +// PauseFunc: func() error { +// panic("mock out the Pause method") +// }, +// ResumeFunc: func() error { +// panic("mock out the Resume method") +// }, +// StartFunc: func(message string) error { +// panic("mock out the Start method") +// }, +// StartPersistentFunc: func(message string) error { +// panic("mock out the StartPersistent method") +// }, +// UpdateFunc: func(message string) error { +// panic("mock out the Update method") +// }, +// } +// +// // use mockedProgressReporter in code that requires ProgressReporter +// // and then make assertions. +// +// } +type MoqProgressReporter struct { + // ClearFunc mocks the Clear method. + ClearFunc func() error + + // CloseFunc mocks the Close method. + CloseFunc func() error + + // FailFunc mocks the Fail method. + FailFunc func(message string, err error) error + + // FailPersistentFunc mocks the FailPersistent method. + FailPersistentFunc func(message string, err error) error + + // FinishFunc mocks the Finish method. + FinishFunc func(message string) error + + // FinishPersistentFunc mocks the FinishPersistent method. + FinishPersistentFunc func(message string) error + + // IsActiveFunc mocks the IsActive method. + IsActiveFunc func() bool + + // IsPausedFunc mocks the IsPaused method. + IsPausedFunc func() bool + + // LogAccomplishmentFunc mocks the LogAccomplishment method. + LogAccomplishmentFunc func(message string) error + + // PauseFunc mocks the Pause method. + PauseFunc func() error + + // ResumeFunc mocks the Resume method. + ResumeFunc func() error + + // StartFunc mocks the Start method. + StartFunc func(message string) error + + // StartPersistentFunc mocks the StartPersistent method. + StartPersistentFunc func(message string) error + + // UpdateFunc mocks the Update method. + UpdateFunc func(message string) error + + // calls tracks calls to the methods. + calls struct { + // Clear holds details about calls to the Clear method. + Clear []struct { + } + // Close holds details about calls to the Close method. + Close []struct { + } + // Fail holds details about calls to the Fail method. + Fail []struct { + // Message is the message argument value. + Message string + // Err is the err argument value. + Err error + } + // FailPersistent holds details about calls to the FailPersistent method. + FailPersistent []struct { + // Message is the message argument value. + Message string + // Err is the err argument value. + Err error + } + // Finish holds details about calls to the Finish method. + Finish []struct { + // Message is the message argument value. + Message string + } + // FinishPersistent holds details about calls to the FinishPersistent method. + FinishPersistent []struct { + // Message is the message argument value. + Message string + } + // IsActive holds details about calls to the IsActive method. + IsActive []struct { + } + // IsPaused holds details about calls to the IsPaused method. + IsPaused []struct { + } + // LogAccomplishment holds details about calls to the LogAccomplishment method. + LogAccomplishment []struct { + // Message is the message argument value. + Message string + } + // Pause holds details about calls to the Pause method. + Pause []struct { + } + // Resume holds details about calls to the Resume method. + Resume []struct { + } + // Start holds details about calls to the Start method. + Start []struct { + // Message is the message argument value. + Message string + } + // StartPersistent holds details about calls to the StartPersistent method. + StartPersistent []struct { + // Message is the message argument value. + Message string + } + // Update holds details about calls to the Update method. + Update []struct { + // Message is the message argument value. + Message string + } + } + lockClear sync.RWMutex + lockClose sync.RWMutex + lockFail sync.RWMutex + lockFailPersistent sync.RWMutex + lockFinish sync.RWMutex + lockFinishPersistent sync.RWMutex + lockIsActive sync.RWMutex + lockIsPaused sync.RWMutex + lockLogAccomplishment sync.RWMutex + lockPause sync.RWMutex + lockResume sync.RWMutex + lockStart sync.RWMutex + lockStartPersistent sync.RWMutex + lockUpdate sync.RWMutex +} + +// Clear calls ClearFunc. +func (mock *MoqProgressReporter) Clear() error { + if mock.ClearFunc == nil { + panic("MoqProgressReporter.ClearFunc: method is nil but ProgressReporter.Clear was just called") + } + callInfo := struct { + }{} + mock.lockClear.Lock() + mock.calls.Clear = append(mock.calls.Clear, callInfo) + mock.lockClear.Unlock() + return mock.ClearFunc() +} + +// ClearCalls gets all the calls that were made to Clear. +// Check the length with: +// +// len(mockedProgressReporter.ClearCalls()) +func (mock *MoqProgressReporter) ClearCalls() []struct { +} { + var calls []struct { + } + mock.lockClear.RLock() + calls = mock.calls.Clear + mock.lockClear.RUnlock() + return calls +} + +// Close calls CloseFunc. +func (mock *MoqProgressReporter) Close() error { + if mock.CloseFunc == nil { + panic("MoqProgressReporter.CloseFunc: method is nil but ProgressReporter.Close was just called") + } + callInfo := struct { + }{} + mock.lockClose.Lock() + mock.calls.Close = append(mock.calls.Close, callInfo) + mock.lockClose.Unlock() + return mock.CloseFunc() +} + +// CloseCalls gets all the calls that were made to Close. +// Check the length with: +// +// len(mockedProgressReporter.CloseCalls()) +func (mock *MoqProgressReporter) CloseCalls() []struct { +} { + var calls []struct { + } + mock.lockClose.RLock() + calls = mock.calls.Close + mock.lockClose.RUnlock() + return calls +} + +// Fail calls FailFunc. +func (mock *MoqProgressReporter) Fail(message string, err error) error { + if mock.FailFunc == nil { + panic("MoqProgressReporter.FailFunc: method is nil but ProgressReporter.Fail was just called") + } + callInfo := struct { + Message string + Err error + }{ + Message: message, + Err: err, + } + mock.lockFail.Lock() + mock.calls.Fail = append(mock.calls.Fail, callInfo) + mock.lockFail.Unlock() + return mock.FailFunc(message, err) +} + +// FailCalls gets all the calls that were made to Fail. +// Check the length with: +// +// len(mockedProgressReporter.FailCalls()) +func (mock *MoqProgressReporter) FailCalls() []struct { + Message string + Err error +} { + var calls []struct { + Message string + Err error + } + mock.lockFail.RLock() + calls = mock.calls.Fail + mock.lockFail.RUnlock() + return calls +} + +// FailPersistent calls FailPersistentFunc. +func (mock *MoqProgressReporter) FailPersistent(message string, err error) error { + if mock.FailPersistentFunc == nil { + panic("MoqProgressReporter.FailPersistentFunc: method is nil but ProgressReporter.FailPersistent was just called") + } + callInfo := struct { + Message string + Err error + }{ + Message: message, + Err: err, + } + mock.lockFailPersistent.Lock() + mock.calls.FailPersistent = append(mock.calls.FailPersistent, callInfo) + mock.lockFailPersistent.Unlock() + return mock.FailPersistentFunc(message, err) +} + +// FailPersistentCalls gets all the calls that were made to FailPersistent. +// Check the length with: +// +// len(mockedProgressReporter.FailPersistentCalls()) +func (mock *MoqProgressReporter) FailPersistentCalls() []struct { + Message string + Err error +} { + var calls []struct { + Message string + Err error + } + mock.lockFailPersistent.RLock() + calls = mock.calls.FailPersistent + mock.lockFailPersistent.RUnlock() + return calls +} + +// Finish calls FinishFunc. +func (mock *MoqProgressReporter) Finish(message string) error { + if mock.FinishFunc == nil { + panic("MoqProgressReporter.FinishFunc: method is nil but ProgressReporter.Finish was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockFinish.Lock() + mock.calls.Finish = append(mock.calls.Finish, callInfo) + mock.lockFinish.Unlock() + return mock.FinishFunc(message) +} + +// FinishCalls gets all the calls that were made to Finish. +// Check the length with: +// +// len(mockedProgressReporter.FinishCalls()) +func (mock *MoqProgressReporter) FinishCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockFinish.RLock() + calls = mock.calls.Finish + mock.lockFinish.RUnlock() + return calls +} + +// FinishPersistent calls FinishPersistentFunc. +func (mock *MoqProgressReporter) FinishPersistent(message string) error { + if mock.FinishPersistentFunc == nil { + panic("MoqProgressReporter.FinishPersistentFunc: method is nil but ProgressReporter.FinishPersistent was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockFinishPersistent.Lock() + mock.calls.FinishPersistent = append(mock.calls.FinishPersistent, callInfo) + mock.lockFinishPersistent.Unlock() + return mock.FinishPersistentFunc(message) +} + +// FinishPersistentCalls gets all the calls that were made to FinishPersistent. +// Check the length with: +// +// len(mockedProgressReporter.FinishPersistentCalls()) +func (mock *MoqProgressReporter) FinishPersistentCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockFinishPersistent.RLock() + calls = mock.calls.FinishPersistent + mock.lockFinishPersistent.RUnlock() + return calls +} + +// IsActive calls IsActiveFunc. +func (mock *MoqProgressReporter) IsActive() bool { + if mock.IsActiveFunc == nil { + panic("MoqProgressReporter.IsActiveFunc: method is nil but ProgressReporter.IsActive was just called") + } + callInfo := struct { + }{} + mock.lockIsActive.Lock() + mock.calls.IsActive = append(mock.calls.IsActive, callInfo) + mock.lockIsActive.Unlock() + return mock.IsActiveFunc() +} + +// IsActiveCalls gets all the calls that were made to IsActive. +// Check the length with: +// +// len(mockedProgressReporter.IsActiveCalls()) +func (mock *MoqProgressReporter) IsActiveCalls() []struct { +} { + var calls []struct { + } + mock.lockIsActive.RLock() + calls = mock.calls.IsActive + mock.lockIsActive.RUnlock() + return calls +} + +// IsPaused calls IsPausedFunc. +func (mock *MoqProgressReporter) IsPaused() bool { + if mock.IsPausedFunc == nil { + panic("MoqProgressReporter.IsPausedFunc: method is nil but ProgressReporter.IsPaused was just called") + } + callInfo := struct { + }{} + mock.lockIsPaused.Lock() + mock.calls.IsPaused = append(mock.calls.IsPaused, callInfo) + mock.lockIsPaused.Unlock() + return mock.IsPausedFunc() +} + +// IsPausedCalls gets all the calls that were made to IsPaused. +// Check the length with: +// +// len(mockedProgressReporter.IsPausedCalls()) +func (mock *MoqProgressReporter) IsPausedCalls() []struct { +} { + var calls []struct { + } + mock.lockIsPaused.RLock() + calls = mock.calls.IsPaused + mock.lockIsPaused.RUnlock() + return calls +} + +// LogAccomplishment calls LogAccomplishmentFunc. +func (mock *MoqProgressReporter) LogAccomplishment(message string) error { + if mock.LogAccomplishmentFunc == nil { + panic("MoqProgressReporter.LogAccomplishmentFunc: method is nil but ProgressReporter.LogAccomplishment was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockLogAccomplishment.Lock() + mock.calls.LogAccomplishment = append(mock.calls.LogAccomplishment, callInfo) + mock.lockLogAccomplishment.Unlock() + return mock.LogAccomplishmentFunc(message) +} + +// LogAccomplishmentCalls gets all the calls that were made to LogAccomplishment. +// Check the length with: +// +// len(mockedProgressReporter.LogAccomplishmentCalls()) +func (mock *MoqProgressReporter) LogAccomplishmentCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockLogAccomplishment.RLock() + calls = mock.calls.LogAccomplishment + mock.lockLogAccomplishment.RUnlock() + return calls +} + +// Pause calls PauseFunc. +func (mock *MoqProgressReporter) Pause() error { + if mock.PauseFunc == nil { + panic("MoqProgressReporter.PauseFunc: method is nil but ProgressReporter.Pause was just called") + } + callInfo := struct { + }{} + mock.lockPause.Lock() + mock.calls.Pause = append(mock.calls.Pause, callInfo) + mock.lockPause.Unlock() + return mock.PauseFunc() +} + +// PauseCalls gets all the calls that were made to Pause. +// Check the length with: +// +// len(mockedProgressReporter.PauseCalls()) +func (mock *MoqProgressReporter) PauseCalls() []struct { +} { + var calls []struct { + } + mock.lockPause.RLock() + calls = mock.calls.Pause + mock.lockPause.RUnlock() + return calls +} + +// Resume calls ResumeFunc. +func (mock *MoqProgressReporter) Resume() error { + if mock.ResumeFunc == nil { + panic("MoqProgressReporter.ResumeFunc: method is nil but ProgressReporter.Resume was just called") + } + callInfo := struct { + }{} + mock.lockResume.Lock() + mock.calls.Resume = append(mock.calls.Resume, callInfo) + mock.lockResume.Unlock() + return mock.ResumeFunc() +} + +// ResumeCalls gets all the calls that were made to Resume. +// Check the length with: +// +// len(mockedProgressReporter.ResumeCalls()) +func (mock *MoqProgressReporter) ResumeCalls() []struct { +} { + var calls []struct { + } + mock.lockResume.RLock() + calls = mock.calls.Resume + mock.lockResume.RUnlock() + return calls +} + +// Start calls StartFunc. +func (mock *MoqProgressReporter) Start(message string) error { + if mock.StartFunc == nil { + panic("MoqProgressReporter.StartFunc: method is nil but ProgressReporter.Start was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockStart.Lock() + mock.calls.Start = append(mock.calls.Start, callInfo) + mock.lockStart.Unlock() + return mock.StartFunc(message) +} + +// StartCalls gets all the calls that were made to Start. +// Check the length with: +// +// len(mockedProgressReporter.StartCalls()) +func (mock *MoqProgressReporter) StartCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockStart.RLock() + calls = mock.calls.Start + mock.lockStart.RUnlock() + return calls +} + +// StartPersistent calls StartPersistentFunc. +func (mock *MoqProgressReporter) StartPersistent(message string) error { + if mock.StartPersistentFunc == nil { + panic("MoqProgressReporter.StartPersistentFunc: method is nil but ProgressReporter.StartPersistent was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockStartPersistent.Lock() + mock.calls.StartPersistent = append(mock.calls.StartPersistent, callInfo) + mock.lockStartPersistent.Unlock() + return mock.StartPersistentFunc(message) +} + +// StartPersistentCalls gets all the calls that were made to StartPersistent. +// Check the length with: +// +// len(mockedProgressReporter.StartPersistentCalls()) +func (mock *MoqProgressReporter) StartPersistentCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockStartPersistent.RLock() + calls = mock.calls.StartPersistent + mock.lockStartPersistent.RUnlock() + return calls +} + +// Update calls UpdateFunc. +func (mock *MoqProgressReporter) Update(message string) error { + if mock.UpdateFunc == nil { + panic("MoqProgressReporter.UpdateFunc: method is nil but ProgressReporter.Update was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockUpdate.Lock() + mock.calls.Update = append(mock.calls.Update, callInfo) + mock.lockUpdate.Unlock() + return mock.UpdateFunc(message) +} + +// UpdateCalls gets all the calls that were made to Update. +// Check the length with: +// +// len(mockedProgressReporter.UpdateCalls()) +func (mock *MoqProgressReporter) UpdateCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockUpdate.RLock() + calls = mock.calls.Update + mock.lockUpdate.RUnlock() + return calls +} diff --git a/installer/utils/logger/cli_logger.go b/installer/utils/logger/cli_logger.go new file mode 100644 index 0000000..33918d2 --- /dev/null +++ b/installer/utils/logger/cli_logger.go @@ -0,0 +1,320 @@ +package logger + +import ( + "fmt" + "io" + "os" + + "github.com/charmbracelet/lipgloss" +) + +// Styles for different types of messages using lipgloss. +var ( + DebugStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7f8c8d")).Bold(true) // Gray + InfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#3498db")).Bold(true) // Blue + SuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#2ecc71")).Bold(true) // Green + WarningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f39c12")).Bold(true) // Yellow/Orange + ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#e74c3c")).Bold(true) // Red +) + +// Backward compatibility aliases for internal use. +var ( + debugStyle = DebugStyle + infoStyle = InfoStyle + successStyle = SuccessStyle + warningStyle = WarningStyle + errorStyle = ErrorStyle +) + +type VerbosityLevel int + +const ( + Minimal VerbosityLevel = iota + Normal + Verbose + ExtraVerbose +) + +// CliLogger implements the Logger interface using lipgloss styling. +type CliLogger struct { + verbosity VerbosityLevel + progress ProgressReporter + output io.Writer +} + +var _ Logger = (*CliLogger)(nil) + +// NewCliLogger creates a new CLI logger that uses lipgloss styling. +func NewCliLogger(verbosity VerbosityLevel) *CliLogger { + return &CliLogger{ + verbosity: verbosity, + progress: NewNoopProgressDisplay(), + output: os.Stdout, + } +} + +// NewProgressCliLogger creates a new CLI logger with progress indicator support. +func NewProgressCliLogger(verbosity VerbosityLevel) *CliLogger { + return &CliLogger{ + verbosity: verbosity, + progress: NewProgressDisplay(os.Stdout), + output: os.Stdout, + } +} + +// NewCliLoggerWithProgress creates a new CLI logger with a custom progress display. +func NewCliLoggerWithProgress(verbosity VerbosityLevel, progress ProgressReporter) *CliLogger { + return &CliLogger{ + verbosity: verbosity, + progress: progress, + output: os.Stdout, + } +} + +// NewCliLoggerWithOutput creates a new CLI logger with a custom output writer. +func NewCliLoggerWithOutput(verbosity VerbosityLevel, output io.Writer, withProgress bool) *CliLogger { + var progress ProgressReporter + if withProgress { + progress = NewProgressDisplay(output) + } else { + progress = NewNoopProgressDisplay() + } + + return &CliLogger{ + verbosity: verbosity, + progress: progress, + output: output, + } +} + +// Trace logs a trace message with gray styling. +func (l *CliLogger) Trace(format string, args ...any) { + if l.verbosity >= ExtraVerbose { + PrintStyled(l.output, debugStyle, format, args...) + } +} + +// Debug logs a debug message with gray styling. +func (l *CliLogger) Debug(format string, args ...any) { + if l.verbosity >= Verbose { + PrintStyled(l.output, debugStyle, format, args...) + } +} + +// Info logs an informational message with blue styling. +func (l *CliLogger) Info(format string, args ...any) { + if l.verbosity >= Normal { + PrintStyled(l.output, infoStyle, format, args...) + } +} + +// Success logs a success message with green styling. +func (l *CliLogger) Success(format string, args ...any) { + if l.verbosity >= Normal { + PrintStyled(l.output, successStyle, format, args...) + } +} + +// Warning logs a warning message with yellow styling. +func (l *CliLogger) Warning(format string, args ...any) { + if l.verbosity >= Normal { + PrintStyled(l.output, warningStyle, format, args...) + } +} + +// Error logs an error message with red styling. +func (l *CliLogger) Error(format string, args ...any) { + if l.verbosity >= Normal { + if l.output == os.Stdout { + PrintStyled(os.Stderr, errorStyle, format, args...) + } else { + PrintStyled(l.output, errorStyle, format, args...) + } + } +} + +// StartProgress starts a progress indicator with the given message. +func (l *CliLogger) StartProgress(message string) error { + // Always try to start progress - the progress display handles whether it's active + if err := l.progress.Start(message); err != nil { + return err + } + + // If no progress operations are active after attempting to start, + // fall back to Info logging (this handles NoopProgressDisplay) + if !l.progress.IsActive() { + l.Info("%s", message) + } + + return nil +} + +// UpdateProgress updates the current progress message. +func (l *CliLogger) UpdateProgress(message string) error { + return l.progress.Update(message) + // If progress is not active, updates are ignored (no fallback needed) +} + +// FinishProgress completes the progress with success. +func (l *CliLogger) FinishProgress(message string) error { + wasActive := l.progress.IsActive() + if err := l.progress.Finish(message); err != nil { + return err + } + + // Fall back to Success logging if no progress was active + if !wasActive { + l.Success("%s", message) + } + + return nil +} + +// FailProgress completes the progress with failure and shows error. +func (l *CliLogger) FailProgress(message string, err error) error { + wasActive := l.progress.IsActive() + if err := l.progress.Fail(message, err); err != nil { + return err + } + + // Fall back to Error logging if no progress was active + if !wasActive { + l.Error("%s: %v", message, err) + } + + return nil +} + +// StartPersistentProgress starts a persistent progress indicator that shows accomplishments. +func (l *CliLogger) StartPersistentProgress(message string) error { + // Always try to start persistent progress - the progress display handles whether it's active + if err := l.progress.StartPersistent(message); err != nil { + return err + } + + // If no progress operations are active after attempting to start, + // fall back to Info logging (this handles NoopProgressDisplay) + if !l.progress.IsActive() { + l.Info("%s", message) + } + + return nil +} + +// LogAccomplishment logs an accomplishment that stays visible. +func (l *CliLogger) LogAccomplishment(message string) error { + if err := l.progress.LogAccomplishment(message); err != nil { + return err + } + + // If progress is not active, fall back to Success logging + if !l.progress.IsActive() { + l.Success("%s", message) + } + + return nil +} + +// FinishPersistentProgress completes the persistent progress with success. +func (l *CliLogger) FinishPersistentProgress(message string) error { + wasActive := l.progress.IsActive() + if err := l.progress.FinishPersistent(message); err != nil { + return err + } + + // Fall back to Success logging if no progress was active + if !wasActive { + l.Success("%s", message) + } + + return nil +} + +// FailPersistentProgress completes the persistent progress with failure and shows error. +func (l *CliLogger) FailPersistentProgress(message string, err error) error { + wasActive := l.progress.IsActive() + if err := l.progress.FailPersistent(message, err); err != nil { + return err + } + + // Fall back to Error logging if no progress was active + if !wasActive { + l.Error("%s: %v", message, err) + } + + return nil +} + +// StartInteractiveProgress starts progress and pauses spinners for interactive commands. +func (l *CliLogger) StartInteractiveProgress(message string) error { + // Pause any active spinners to prevent interference + if err := l.progress.Pause(); err != nil { + return err + } + + // Start progress normally but it won't show spinners while paused + if err := l.progress.Start(message); err != nil { + return err + } + + // If no progress operations are active, fall back to Info logging + if !l.progress.IsActive() { + l.Info("%s", message) + } + + return nil +} + +// FinishInteractiveProgress completes interactive progress and resumes spinners. +func (l *CliLogger) FinishInteractiveProgress(message string) error { + wasActive := l.progress.IsActive() + if err := l.progress.Finish(message); err != nil { + return err + } + + // Resume spinners for any remaining operations + if err := l.progress.Resume(); err != nil { + return err + } + + // Fall back to Success logging if no progress was active + if !wasActive { + l.Success("%s", message) + } + + return nil +} + +// FailInteractiveProgress completes interactive progress with error and resumes spinners. +func (l *CliLogger) FailInteractiveProgress(message string, err error) error { + wasActive := l.progress.IsActive() + if err := l.progress.Fail(message, err); err != nil { + return err + } + + // Resume spinners for any remaining operations + if err := l.progress.Resume(); err != nil { + return err + } + + // Fall back to Error logging if no progress was active + if !wasActive { + l.Error("%s: %v", message, err) + } + + return nil +} + +// Cleanup ensures proper cleanup of terminal state, including cursor restoration. +func (l *CliLogger) Close() error { + return l.progress.Close() +} + +// PrintStyled is a helper function to print styled text to the specified writer. +func PrintStyled(writer io.Writer, style lipgloss.Style, format string, args ...any) { + if file, ok := writer.(*os.File); ok { + fmt.Fprintln(file, style.Render(fmt.Sprintf(format, args...))) + } else { + fmt.Fprintln(writer, style.Render(fmt.Sprintf(format, args...))) + } +} diff --git a/installer/utils/logger/cli_logger_test.go b/installer/utils/logger/cli_logger_test.go new file mode 100644 index 0000000..a10b68c --- /dev/null +++ b/installer/utils/logger/cli_logger_test.go @@ -0,0 +1,671 @@ +package logger_test + +import ( + "bytes" + "errors" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/MrPointer/dotfiles/installer/utils/logger" +) + +func Test_CliLogger_ByDesign_ImplementsLoggerInterface(t *testing.T) { + var _ logger.Logger = (*logger.CliLogger)(nil) +} + +func Test_NewProgressCliLogger_WhenCalled_CreatesValidInstance(t *testing.T) { + log := logger.NewProgressCliLogger(logger.Normal) + require.NotNil(t, log) +} + +func Test_NewCliLogger_WithVerbosityLevels_CreatesValidInstance(t *testing.T) { + tests := []struct { + name string + verbosity logger.VerbosityLevel + }{ + {"Minimal verbosity", logger.Minimal}, + {"Normal verbosity", logger.Normal}, + {"Verbose verbosity", logger.Verbose}, + {"ExtraVerbose verbosity", logger.ExtraVerbose}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + log := logger.NewCliLogger(tt.verbosity) + require.NotNil(t, log) + }) + } +} + +func Test_NewCliLoggerWithOutput_WithProgressEnabled_EnablesProgressFunctionality(t *testing.T) { + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + require.NotNil(t, log) +} + +func Test_MultipleRapidProgressOperations_WithQuickSuccession_Work(t *testing.T) { + + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + + // Rapidly start and finish operations + for range 5 { + log.StartProgress("Rapid operation") + time.Sleep(10 * time.Millisecond) + log.FinishProgress("Completed") + } + + require.NotNil(t, log) +} + +func Test_DeeplyNestedProgressOperations_WithFiveLevels_Complete(t *testing.T) { + + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + + // Create a deep hierarchy + log.StartProgress("Level 1") + log.StartProgress("Level 2") + log.StartProgress("Level 3") + log.StartProgress("Level 4") + log.StartProgress("Level 5") + + time.Sleep(50 * time.Millisecond) + + // Complete in reverse order + log.FinishProgress("Level 5 done") + log.FinishProgress("Level 4 done") + log.FinishProgress("Level 3 done") + log.FinishProgress("Level 2 done") + log.FinishProgress("Level 1 done") + + require.NotNil(t, log) +} + +func Test_MixedSuccessAndFailureProgressOperations_WithNestedStructure_DisplayCorrectly(t *testing.T) { + + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + + log.StartProgress("Parent operation") + + log.StartProgress("Child 1") + time.Sleep(30 * time.Millisecond) + log.FinishProgress("Child 1 success") + + log.StartProgress("Child 2") + time.Sleep(30 * time.Millisecond) + log.FailProgress("Child 2 failed", errors.New("test error")) + + log.StartProgress("Child 3") + time.Sleep(30 * time.Millisecond) + log.FinishProgress("Child 3 success") + + log.FinishProgress("Parent complete") + + require.NotNil(t, log) +} + +func Test_UpdateProgress_WithoutActiveProgress_DoesNothing(t *testing.T) { + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + + // This should not crash + log.UpdateProgress("No active progress") + + require.NotNil(t, log) +} + +func Test_FinishProgress_WithoutActiveProgress_FallsBackToSuccessLogging(t *testing.T) { + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + + // This should fall back to Success logging + log.FinishProgress("Never started") + + require.NotNil(t, log) +} + +func Test_FailProgress_WithoutActiveProgress_FallsBackToErrorLogging(t *testing.T) { + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + + // This should fall back to Error logging + log.FailProgress("Never started", errors.New("test error")) + + require.NotNil(t, log) +} + +func Test_VeryShortDurationProgressOperations_UnderThreshold_DoNotShowTiming(t *testing.T) { + + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + + log.StartProgress("Quick operation") + // No sleep - immediate completion + log.FinishProgress("Done quickly") + + require.NotNil(t, log) +} + +func Test_ProgressOperations_WithMessageUpdates_Work(t *testing.T) { + + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + + log.StartProgress("Downloading files") + time.Sleep(20 * time.Millisecond) + + log.UpdateProgress("Downloading files (25%)") + time.Sleep(20 * time.Millisecond) + + log.UpdateProgress("Downloading files (50%)") + time.Sleep(20 * time.Millisecond) + + log.UpdateProgress("Downloading files (75%)") + time.Sleep(20 * time.Millisecond) + + log.FinishProgress("Download complete") + + require.NotNil(t, log) +} + +func Test_VerbosityLevels_WithDifferentMessages_FilterCorrectly(t *testing.T) { + tests := []struct { + name string + verbosity logger.VerbosityLevel + logFunc func(logger.Logger) + shouldLog bool + }{ + { + name: "Trace messages appear with ExtraVerbose", + verbosity: logger.ExtraVerbose, + logFunc: func(l logger.Logger) { l.Trace("trace message") }, + shouldLog: true, + }, + { + name: "Trace messages hidden with Verbose", + verbosity: logger.Verbose, + logFunc: func(l logger.Logger) { l.Trace("trace message") }, + shouldLog: false, + }, + { + name: "Debug messages appear with Verbose", + verbosity: logger.Verbose, + logFunc: func(l logger.Logger) { l.Debug("debug message") }, + shouldLog: true, + }, + { + name: "Debug messages hidden with Normal", + verbosity: logger.Normal, + logFunc: func(l logger.Logger) { l.Debug("debug message") }, + shouldLog: false, + }, + { + name: "Info messages appear with Normal", + verbosity: logger.Normal, + logFunc: func(l logger.Logger) { l.Info("info message") }, + shouldLog: true, + }, + { + name: "Info messages hidden with Minimal", + verbosity: logger.Minimal, + logFunc: func(l logger.Logger) { l.Info("info message") }, + shouldLog: false, + }, + { + name: "Error messages appear with Normal", + verbosity: logger.Normal, + logFunc: func(l logger.Logger) { l.Error("error message") }, + shouldLog: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + log := logger.NewCliLogger(tt.verbosity) + require.NotNil(t, log) + + // Just verify that calling the function doesn't panic + // The actual output verification would require capturing stdout/stderr + require.NotPanics(t, func() { + tt.logFunc(log) + }) + }) + } +} + +func Test_ProgressMethods_FallBackToRegularLogging_WhenProgressDisabled(t *testing.T) { + // Test that progress methods work correctly when progress is disabled + log := logger.NewCliLogger(logger.Normal) // Not a progress logger + + // These should fall back to regular logging methods + log.StartProgress("Starting operation") + log.UpdateProgress("Updating operation") + log.FinishProgress("Finishing operation") + log.FailProgress("Failing operation", errors.New("test error")) + + require.NotNil(t, log) +} + +func Test_ConcurrentProgressOperations_WithMultipleGoroutines_AreThreadSafe(t *testing.T) { + + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + + // Test concurrent access to ensure thread safety + done := make(chan bool, 2) + + go func() { + log.StartProgress("Concurrent operation 1") + time.Sleep(50 * time.Millisecond) + log.FinishProgress("Concurrent 1 done") + done <- true + }() + + go func() { + time.Sleep(25 * time.Millisecond) + log.StartProgress("Concurrent operation 2") + time.Sleep(50 * time.Millisecond) + log.FinishProgress("Concurrent 2 done") + done <- true + }() + + // Wait for both goroutines to complete + <-done + <-done + + require.NotNil(t, log) +} + +func Test_LongRunningOperation_WithPeriodicUpdates_Works(t *testing.T) { + + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + + log.StartProgress("Long running operation") + + // Simulate a longer operation with periodic updates + for i := 0; i < 5; i++ { + time.Sleep(50 * time.Millisecond) + log.UpdateProgress(fmt.Sprintf("Long running operation (step %d/5)", i+1)) + } + + log.FinishProgress("Long operation completed") + + require.NotNil(t, log) +} + +func Test_HierarchicalProgressReporting_WithNestedOperations_WorksLikeNpm(t *testing.T) { + + // Create a progress-enabled logger + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + + // Simulate a hierarchical installation process + log.StartProgress("Installing dotfiles") + + // Simulate some work + time.Sleep(100 * time.Millisecond) + + // Start nested progress + log.StartProgress("Downloading configuration files") + time.Sleep(200 * time.Millisecond) + + // Update progress message + log.UpdateProgress("Downloading configuration files (50%)") + time.Sleep(150 * time.Millisecond) + + log.FinishProgress("Downloaded configuration files") + + // Another nested operation + log.StartProgress("Setting up shell configuration") + time.Sleep(100 * time.Millisecond) + + // Nested operation within nested operation + log.StartProgress("Installing zsh plugins") + time.Sleep(150 * time.Millisecond) + log.FinishProgress("Installed zsh plugins") + + log.FinishProgress("Set up shell configuration") + + // Simulate a failed operation + log.StartProgress("Configuring git settings") + time.Sleep(100 * time.Millisecond) + log.FailProgress("Failed to configure git", errors.New("permission denied")) + + // Complete the main operation + log.FinishProgress("Installed dotfiles") + + // This test mainly verifies that the code doesn't crash + // and provides a visual demonstration of the hierarchical progress + require.True(t, true, "Hierarchical progress test completed") +} + +func Test_AllVerbosityLevels_WithDifferentMessages_ProduceAppropriateOutput(t *testing.T) { + tests := []struct { + name string + verbosity logger.VerbosityLevel + }{ + {"Minimal verbosity produces minimal output", logger.Minimal}, + {"Normal verbosity produces normal output", logger.Normal}, + {"Verbose verbosity produces verbose output", logger.Verbose}, + {"ExtraVerbose verbosity produces extra verbose output", logger.ExtraVerbose}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + log := logger.NewCliLogger(tt.verbosity) + + // Test all logging levels + log.Trace("This is a trace message") + log.Debug("This is a debug message") + log.Info("This is an info message") + log.Success("This is a success message") + log.Warning("This is a warning message") + log.Error("This is an error message") + + require.NotNil(t, log) + }) + } +} + +func Test_Progress_WithMinimalVerbosity_Works(t *testing.T) { + + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Minimal, &buf, true) + + log.StartProgress("Operation with minimal verbosity") + time.Sleep(50 * time.Millisecond) + log.FinishProgress("Completed operation") + + require.NotNil(t, log) +} + +func Test_VerboseLogging_WithoutProgress_Works(t *testing.T) { + log := logger.NewCliLogger(logger.Verbose) + + log.StartProgress("This should appear as Info message") + log.UpdateProgress("This update should be ignored") + log.FinishProgress("This should appear as Success message") + + require.NotNil(t, log) +} + +func Test_StartPersistentProgress_WithProgressEnabled_Works(t *testing.T) { + + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + + log.StartPersistentProgress("Installing components") + time.Sleep(50 * time.Millisecond) + log.FinishPersistentProgress("Installation complete") + + require.NotNil(t, log) +} + +func Test_StartPersistentProgress_WithoutProgress_FallsBackToInfoLogging(t *testing.T) { + log := logger.NewCliLogger(logger.Normal) + + log.StartPersistentProgress("This should appear as Info message") + log.FinishPersistentProgress("This should appear as Success message") + + require.NotNil(t, log) +} + +func Test_LogAccomplishment_WithProgressEnabled_ShowsPersistentMessage(t *testing.T) { + + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + + log.StartPersistentProgress("Installing packages") + time.Sleep(30 * time.Millisecond) + + log.LogAccomplishment("Downloaded package A") + log.LogAccomplishment("Downloaded package B") + log.LogAccomplishment("Downloaded package C") + + time.Sleep(30 * time.Millisecond) + log.FinishPersistentProgress("All packages installed") + + require.NotNil(t, log) +} + +func Test_LogAccomplishment_WithoutProgress_FallsBackToSuccessLogging(t *testing.T) { + log := logger.NewCliLogger(logger.Normal) + + log.LogAccomplishment("This should appear as Success message") + + require.NotNil(t, log) +} + +func Test_FinishPersistent_ProgressWithoutActiveProgress_FallsBackToSuccessLogging(t *testing.T) { + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + + log.FinishPersistentProgress("Never started persistent progress") + + require.NotNil(t, log) +} + +func Test_FailPersistentProgress_WithoutActiveProgress_FallsBackToErrorLogging(t *testing.T) { + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + + log.FailPersistentProgress("Never started persistent progress", errors.New("test error")) + + require.NotNil(t, log) +} + +func Test_PersistentProgress_WhenFailed_ShowsErrorMessage(t *testing.T) { + + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + + log.StartPersistentProgress("Installing critical component") + log.LogAccomplishment("Downloaded dependencies") + log.LogAccomplishment("Validated checksums") + time.Sleep(50 * time.Millisecond) + log.FailPersistentProgress("Installation failed", errors.New("permission denied")) + + require.NotNil(t, log) +} + +func Test_MixedPersistentAndRegularProgressOperations_WithNestedStructure_Work(t *testing.T) { + + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + + log.StartPersistentProgress("Setting up development environment") + log.LogAccomplishment("Created project directory") + + log.StartProgress("Downloading dependencies") + time.Sleep(50 * time.Millisecond) + log.FinishProgress("Dependencies downloaded") + + log.LogAccomplishment("Installed build tools") + log.LogAccomplishment("Configured IDE settings") + + log.StartProgress("Running initial build") + time.Sleep(50 * time.Millisecond) + log.FinishProgress("Build completed successfully") + + log.FinishPersistentProgress("Development environment ready") + + require.NotNil(t, log) +} + +func Test_PersistentProgress_WithMultipleAccomplishments_DisplaysCorrectly(t *testing.T) { + + var buf bytes.Buffer + log := logger.NewCliLoggerWithOutput(logger.Normal, &buf, true) + + log.StartPersistentProgress("Deploying application") + + accomplishments := []string{ + "Built application binary", + "Created Docker image", + "Pushed to registry", + "Updated deployment configuration", + "Applied Kubernetes manifests", + "Verified health checks", + } + + for _, accomplishment := range accomplishments { + time.Sleep(20 * time.Millisecond) + log.LogAccomplishment(accomplishment) + } + + time.Sleep(30 * time.Millisecond) + log.FinishPersistentProgress("Application deployed successfully") + + require.NotNil(t, log) +} + +func Test_StartPersistentProgress_WithMockReporter_CallsProgressReporterStartPersistent(t *testing.T) { + mockProgress := &logger.MoqProgressReporter{ + StartPersistentFunc: func(message string) error { return nil }, + IsActiveFunc: func() bool { return true }, + } + + log := logger.NewCliLoggerWithProgress(logger.Normal, mockProgress) + + log.StartPersistentProgress("Test message") + + calls := mockProgress.StartPersistentCalls() + require.Len(t, calls, 1) + require.Equal(t, "Test message", calls[0].Message) +} + +func Test_StartPersistentProgress_WithoutProgress_FallsBackToInfo(t *testing.T) { + mockProgress := &logger.MoqProgressReporter{ + StartPersistentFunc: func(message string) error { return nil }, + IsActiveFunc: func() bool { return false }, + } + + log := logger.NewCliLoggerWithProgress(logger.Normal, mockProgress) + + log.StartPersistentProgress("Test message") + + calls := mockProgress.StartPersistentCalls() + require.Len(t, calls, 1) + require.Equal(t, "Test message", calls[0].Message) +} + +func Test_LogAccomplishment_WithMockReporter_CallsProgressReporterLogAccomplishment(t *testing.T) { + mockProgress := &logger.MoqProgressReporter{ + LogAccomplishmentFunc: func(message string) error { return nil }, + IsActiveFunc: func() bool { return true }, + } + + log := logger.NewCliLoggerWithProgress(logger.Normal, mockProgress) + + log.LogAccomplishment("Test accomplishment") + + calls := mockProgress.LogAccomplishmentCalls() + require.Len(t, calls, 1) + require.Equal(t, "Test accomplishment", calls[0].Message) +} + +func Test_LogAccomplishment_FallsBackToSuccess_WhenProgressNotActive(t *testing.T) { + mockProgress := &logger.MoqProgressReporter{ + LogAccomplishmentFunc: func(message string) error { return nil }, + IsActiveFunc: func() bool { return false }, + } + + log := logger.NewCliLoggerWithProgress(logger.Normal, mockProgress) + + log.LogAccomplishment("Test accomplishment") + + calls := mockProgress.LogAccomplishmentCalls() + require.Len(t, calls, 1) + require.Equal(t, "Test accomplishment", calls[0].Message) +} + +func Test_FinishPersistentProgress_WithMockReporter_CallsProgressReporterFinishPersistent(t *testing.T) { + mockProgress := &logger.MoqProgressReporter{ + FinishPersistentFunc: func(message string) error { return nil }, + IsActiveFunc: func() bool { return true }, + } + + log := logger.NewCliLoggerWithProgress(logger.Normal, mockProgress) + + log.FinishPersistentProgress("Test finished") + + calls := mockProgress.FinishPersistentCalls() + require.Len(t, calls, 1) + require.Equal(t, "Test finished", calls[0].Message) +} + +func Test_FinishPersistentProgress_FallsBackToSuccess_WhenProgressNotActive(t *testing.T) { + mockProgress := &logger.MoqProgressReporter{ + FinishPersistentFunc: func(message string) error { return nil }, + IsActiveFunc: func() bool { return false }, + } + + log := logger.NewCliLoggerWithProgress(logger.Normal, mockProgress) + + log.FinishPersistentProgress("Test finished") + + calls := mockProgress.FinishPersistentCalls() + require.Len(t, calls, 1) + require.Equal(t, "Test finished", calls[0].Message) +} + +func Test_FailPersistentProgress_WithMockReporter_CallsProgressReporterFailPersistent(t *testing.T) { + testErr := errors.New("test error") + mockProgress := &logger.MoqProgressReporter{ + FailPersistentFunc: func(message string, err error) error { return nil }, + IsActiveFunc: func() bool { return true }, + } + + log := logger.NewCliLoggerWithProgress(logger.Normal, mockProgress) + + log.FailPersistentProgress("Test failed", testErr) + + calls := mockProgress.FailPersistentCalls() + require.Len(t, calls, 1) + require.Equal(t, "Test failed", calls[0].Message) + require.Equal(t, testErr, calls[0].Err) +} + +func Test_FailPersistentProgress_FallsBackToError_WhenProgressNotActive(t *testing.T) { + testErr := errors.New("test error") + mockProgress := &logger.MoqProgressReporter{ + FailPersistentFunc: func(message string, err error) error { return nil }, + IsActiveFunc: func() bool { return false }, + } + + log := logger.NewCliLoggerWithProgress(logger.Normal, mockProgress) + + log.FailPersistentProgress("Test failed", testErr) + + calls := mockProgress.FailPersistentCalls() + require.Len(t, calls, 1) + require.Equal(t, "Test failed", calls[0].Message) + require.Equal(t, testErr, calls[0].Err) +} + +func Test_Close_WithMockReporter_CallsProgressReporterClose(t *testing.T) { + mockProgress := &logger.MoqProgressReporter{ + CloseFunc: func() error { return nil }, + } + + log := logger.NewCliLoggerWithProgress(logger.Normal, mockProgress) + + log.Close() + + calls := mockProgress.CloseCalls() + require.Len(t, calls, 1) +} + +func Test_Close_WithoutProgress_StillWorks(t *testing.T) { + log := logger.NewCliLogger(logger.Normal) + + require.NotPanics(t, func() { + log.Close() + }) +} diff --git a/installer/utils/logger/logger.go b/installer/utils/logger/logger.go new file mode 100644 index 0000000..2836b2d --- /dev/null +++ b/installer/utils/logger/logger.go @@ -0,0 +1,75 @@ +package logger + +import "io" + +// Logger defines a minimal logging interface that our installer utilities need. +type Logger interface { + io.Closer + + // Trace logs a trace message + Trace(format string, args ...any) + // Debug logs a debug message + Debug(format string, args ...any) + // Info logs an informational message + Info(format string, args ...any) + // Success logs a success message + Success(format string, args ...any) + // Warning logs a warning message + Warning(format string, args ...any) + // Error logs an error message + Error(format string, args ...any) + + // StartProgress starts a progress indicator with the given message + StartProgress(message string) error + // UpdateProgress updates the current progress message + UpdateProgress(message string) error + // FinishProgress completes the progress with success + FinishProgress(message string) error + // FailProgress completes the progress with failure and shows error + FailProgress(message string, err error) error + + // StartPersistentProgress starts a progress indicator that shows accomplishments + StartPersistentProgress(message string) error + // LogAccomplishment logs an accomplishment that stays visible + LogAccomplishment(message string) error + // FinishPersistentProgress completes persistent progress with success + FinishPersistentProgress(message string) error + // FailPersistentProgress completes persistent progress with failure + FailPersistentProgress(message string, err error) error + + // StartInteractiveProgress starts progress and pauses spinners for interactive commands + StartInteractiveProgress(message string) error + // FinishInteractiveProgress completes interactive progress and resumes spinners + FinishInteractiveProgress(message string) error + // FailInteractiveProgress completes interactive progress with error and resumes spinners + FailInteractiveProgress(message string, err error) error +} + +// NoopLogger implements Logger but does nothing. +type NoopLogger struct{} + +var _ Logger = (*NoopLogger)(nil) + +func (l NoopLogger) Trace(format string, args ...any) {} +func (l NoopLogger) Debug(format string, args ...any) {} +func (l NoopLogger) Info(format string, args ...any) {} +func (l NoopLogger) Success(format string, args ...any) {} +func (l NoopLogger) Warning(format string, args ...any) {} +func (l NoopLogger) Error(format string, args ...any) {} + +// Progress methods - no-op implementations +func (l NoopLogger) StartProgress(message string) error { return nil } +func (l NoopLogger) UpdateProgress(message string) error { return nil } +func (l NoopLogger) FinishProgress(message string) error { return nil } +func (l NoopLogger) FailProgress(message string, err error) error { return nil } +func (l NoopLogger) StartPersistentProgress(message string) error { return nil } +func (l NoopLogger) LogAccomplishment(message string) error { return nil } +func (l NoopLogger) FinishPersistentProgress(message string) error { return nil } +func (l NoopLogger) FailPersistentProgress(message string, err error) error { return nil } +func (l NoopLogger) StartInteractiveProgress(message string) error { return nil } +func (l NoopLogger) FinishInteractiveProgress(message string) error { return nil } +func (l NoopLogger) FailInteractiveProgress(message string, err error) error { return nil } +func (l NoopLogger) Close() error { return nil } + +// DefaultLogger is the default logger used if none is provided. +var DefaultLogger Logger = NoopLogger{} diff --git a/installer/utils/logger/progress_display.go b/installer/utils/logger/progress_display.go new file mode 100644 index 0000000..73e9c24 --- /dev/null +++ b/installer/utils/logger/progress_display.go @@ -0,0 +1,680 @@ +package logger + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/charmbracelet/huh/spinner" + "github.com/charmbracelet/lipgloss" +) + +// ANSI escape codes for terminal control +const ( + clearLine = "\033[K" // Clear line from cursor to end + showCursor = "\033[?25h" // Show cursor +) + +// ProgressOperation represents an active progress operation. +type ProgressOperation struct { + Message string + StartTime time.Time + Level int + done atomic.Int32 // atomic + Success bool + Error error + CancelFunc context.CancelFunc +} + +// IsDone returns whether this operation is completed. +func (op *ProgressOperation) IsDone() bool { + return op.done.Load() == 1 +} + +// SetDone marks this operation as completed. +func (op *ProgressOperation) SetDone() { + op.done.Store(1) +} + +// ProgressReporter defines the interface for hierarchical progress reporting. +type ProgressReporter interface { + io.Closer + // Start begins a new progress operation with the given message + Start(message string) error + // Update modifies the message of the current progress operation + Update(message string) error + // Finish completes the current progress operation successfully + Finish(message string) error + // Fail completes the current progress operation with an error + Fail(message string, err error) error + + // StartPersistent begins a persistent progress operation that shows accomplishments + StartPersistent(message string) error + // LogAccomplishment logs an accomplishment that stays visible + LogAccomplishment(message string) error + // FinishPersistent completes persistent progress with success + FinishPersistent(message string) error + // FailPersistent completes persistent progress with failure + FailPersistent(message string, err error) error + + // Clear stops all progress operations without displaying completion messages + Clear() error + // Pause temporarily stops all spinner operations for interactive commands + Pause() error + // Resume restarts spinner operations after interactive commands complete + Resume() error + + // IsActive returns true if there are any active progress operations + IsActive() bool + // IsPaused returns whether the progress display is currently paused + IsPaused() bool +} + +// synchronizedWriter wraps an io.Writer with a mutex to prevent concurrent writes. +type synchronizedWriter struct { + writer io.Writer + mutex sync.Mutex +} + +func (sw *synchronizedWriter) Write(p []byte) (n int, err error) { + sw.mutex.Lock() + defer sw.mutex.Unlock() + return sw.writer.Write(p) +} + +// safeBytesBuffer provides thread-safe access to a bytes.Buffer for both reads and writes. +type safeBytesBuffer struct { + buf *bytes.Buffer + mutex sync.RWMutex +} + +func (sbb *safeBytesBuffer) Write(p []byte) (n int, err error) { + sbb.mutex.Lock() + defer sbb.mutex.Unlock() + return sbb.buf.Write(p) +} + +// SafeString provides thread-safe read access to buffer content +func (sbb *safeBytesBuffer) SafeString() string { + sbb.mutex.RLock() + defer sbb.mutex.RUnlock() + return sbb.buf.String() +} + +// ProgressDisplay provides hierarchical progress reporting with npm-style output. +type ProgressDisplay struct { + output io.Writer + safeBuffer *safeBytesBuffer // for thread-safe buffer access when using bytes.Buffer + rawOutput io.Writer // original output for direct access when needed + progressStack []*ProgressOperation + activeSpinner *ProgressOperation + operationInProgress atomic.Int32 // atomic counter + persistentMode bool // whether we're in persistent mode + cursorHidden atomic.Int32 // atomic flag for cursor state + paused atomic.Int32 // atomic flag for paused state + pauseMutex sync.Mutex // protects pause/resume operations + spinnerWaitGroup sync.WaitGroup // tracks active spinner goroutines + stackMutex sync.RWMutex // protects progressStack and activeSpinner +} + +var _ ProgressReporter = (*ProgressDisplay)(nil) + +// NewProgressDisplay creates a new hierarchical progress display. +func NewProgressDisplay(output io.Writer) *ProgressDisplay { + if output == nil { + output = os.Stdout + } + + var outputWriter io.Writer + var safeBuffer *safeBytesBuffer + + // Special handling for *bytes.Buffer to ensure thread safety + if buf, ok := output.(*bytes.Buffer); ok { + safeBuffer = &safeBytesBuffer{buf: buf} + outputWriter = safeBuffer + } else { + outputWriter = &synchronizedWriter{writer: output} + } + + pd := &ProgressDisplay{ + output: outputWriter, + safeBuffer: safeBuffer, + rawOutput: output, + } + + // Ensure cursor is restored on program exit + pd.setupCleanup() + + return pd +} + +// Start begins a new progress operation with the given message. +func (p *ProgressDisplay) Start(message string) error { + p.stackMutex.Lock() + level := len(p.progressStack) + + // Stop any currently active spinner + if p.activeSpinner != nil && p.activeSpinner.CancelFunc != nil { + p.activeSpinner.CancelFunc() + } + + // Create context for this operation + ctx, cancel := context.WithCancel(context.Background()) + + operation := &ProgressOperation{ + Message: message, + StartTime: time.Now(), + Level: level, + CancelFunc: cancel, + } + p.progressStack = append(p.progressStack, operation) + p.activeSpinner = operation + + // Create contextual message showing hierarchy + displayMessage := p.buildContextualMessage() + p.stackMutex.Unlock() + + // Increment operation counter + p.operationInProgress.Add(1) + + // Start spinner in background + p.spinnerWaitGroup.Add(1) + go p.runSpinner(ctx, operation, displayMessage) + + return nil +} + +// Update modifies the message of the current progress operation. +func (p *ProgressDisplay) Update(message string) error { + p.stackMutex.Lock() + defer p.stackMutex.Unlock() + + if len(p.progressStack) == 0 { + // Updating an inexistent operation is not an error + return nil + } + + // Update the most recent progress operation + currentIndex := len(p.progressStack) - 1 + p.progressStack[currentIndex].Message = message + + return nil +} + +// Finish completes the current progress operation successfully. +func (p *ProgressDisplay) Finish(message string) error { + p.stackMutex.Lock() + if len(p.progressStack) == 0 { + p.stackMutex.Unlock() + return nil + } + + // Pop from progress stack + currentIndex := len(p.progressStack) - 1 + operation := p.progressStack[currentIndex] + p.progressStack = p.progressStack[:currentIndex] + p.stackMutex.Unlock() + + // Stop the spinner for this operation + if operation.CancelFunc != nil { + operation.CancelFunc() + } + operation.SetDone() + operation.Success = true + + // Wait for spinner goroutine to complete cleanup before proceeding + p.spinnerWaitGroup.Wait() + + // Ensure cursor is restored before displaying completion message + if err := p.restoreCursor(); err != nil { + return err + } + + // Decrement operation counter + p.operationInProgress.Add(-1) + + // Display completion message + if err := p.displayCompletion(operation, true, nil); err != nil { + return err + } + + // Resume parent operation if exists + p.stackMutex.Lock() + p.resumeParentOperation() + p.stackMutex.Unlock() + + return nil +} + +// Fail completes the current progress operation with an error. +func (p *ProgressDisplay) Fail(message string, err error) error { + p.stackMutex.Lock() + if len(p.progressStack) == 0 { + p.stackMutex.Unlock() + return nil + } + + // Pop from progress stack + currentIndex := len(p.progressStack) - 1 + operation := p.progressStack[currentIndex] + p.progressStack = p.progressStack[:currentIndex] + p.stackMutex.Unlock() + + // Stop the spinner for this operation + if operation.CancelFunc != nil { + operation.CancelFunc() + } + operation.SetDone() + operation.Success = false + operation.Error = err + + // Wait for spinner goroutine to complete cleanup before proceeding + p.spinnerWaitGroup.Wait() + + // Ensure cursor is restored before displaying completion message + if err := p.restoreCursor(); err != nil { + return err + } + + // Decrement operation counter + p.operationInProgress.Add(-1) + + // Display failure message + if err := p.displayCompletion(operation, false, err); err != nil { + return err + } + + // Resume parent operation if exists + p.stackMutex.Lock() + p.resumeParentOperation() + p.stackMutex.Unlock() + + return nil +} + +// IsActive returns true if there are any active progress operations. +func (p *ProgressDisplay) IsActive() bool { + return p.operationInProgress.Load() > 0 +} + +// Clear stops all progress operations without displaying completion messages. +func (p *ProgressDisplay) Clear() error { + p.stackMutex.Lock() + // Stop all active spinners + for _, operation := range p.progressStack { + if operation.CancelFunc != nil { + operation.CancelFunc() + } + operation.SetDone() + } + + // Clear the stack and reset counter + p.progressStack = nil + p.activeSpinner = nil + p.stackMutex.Unlock() + + // Wait for all spinner goroutines to complete + p.spinnerWaitGroup.Wait() + + p.operationInProgress.Store(0) + p.paused.Store(0) + + // Restore cursor if it was hidden + return p.restoreCursor() +} + +// Pause temporarily stops all spinner operations for interactive commands +func (p *ProgressDisplay) Pause() error { + p.pauseMutex.Lock() + defer p.pauseMutex.Unlock() + + // Set paused state first + p.paused.Store(1) + + // Cancel all active spinners + for _, operation := range p.progressStack { + if operation.CancelFunc != nil { + operation.CancelFunc() + } + } + + // Wait for all spinner goroutines to finish + p.spinnerWaitGroup.Wait() + + // Now it's safe to clean up terminal state + if err := p.restoreCursor(); err != nil { + return err + } + + if file, ok := p.rawOutput.(*os.File); ok { + file.WriteString("\r" + clearLine) + } else { + fmt.Fprint(p.output, "\r"+clearLine) + } + + return nil +} + +// Resume restarts spinner operations after interactive commands complete +func (p *ProgressDisplay) Resume() error { + p.pauseMutex.Lock() + defer p.pauseMutex.Unlock() + + p.paused.Store(0) + + // Resume the most recent operation if there is one + p.stackMutex.Lock() + if len(p.progressStack) > 0 { + currentOperation := p.progressStack[len(p.progressStack)-1] + if !currentOperation.IsDone() { + // Create new context for resumed operation + ctx, cancel := context.WithCancel(context.Background()) + currentOperation.CancelFunc = cancel + + // Resume spinner for current operation + displayMessage := p.buildContextualMessage() + p.stackMutex.Unlock() + p.spinnerWaitGroup.Add(1) + go p.runSpinner(ctx, currentOperation, displayMessage) + } else { + p.stackMutex.Unlock() + } + } else { + p.stackMutex.Unlock() + } + + return nil +} + +// IsPaused returns whether the progress display is currently paused +func (p *ProgressDisplay) IsPaused() bool { + return p.paused.Load() == 1 +} + +// StartPersistent begins a persistent progress operation that shows accomplishments. +func (p *ProgressDisplay) StartPersistent(message string) error { + p.persistentMode = true + return p.Start(message) +} + +// LogAccomplishment logs an accomplishment that stays visible. +func (p *ProgressDisplay) LogAccomplishment(message string) error { + checkmark := lipgloss.NewStyle().Foreground(lipgloss.Color("#2ecc71")).Render("βœ“") + _, err := fmt.Fprintf(p.output, "\r%s %s %s\n", clearLine, checkmark, message) + return err +} + +// FinishPersistent completes persistent progress with success. +func (p *ProgressDisplay) FinishPersistent(message string) error { + p.persistentMode = false + return p.Finish(message) +} + +// FailPersistent completes persistent progress with failure. +func (p *ProgressDisplay) FailPersistent(message string, err error) error { + p.persistentMode = false + return p.Fail(message, err) +} + +// Close ensures proper cleanup of terminal state. +func (p *ProgressDisplay) Close() error { + return p.Clear() +} + +// setupCleanup sets up signal handlers and cleanup mechanisms to ensure cursor is restored. +func (p *ProgressDisplay) setupCleanup() { + // Note: We don't set up signal handlers here to avoid dependencies. + // The cursor restoration will happen when Clear() is called or through defer. +} + +// resumeParentOperation resumes the spinner for the parent operation if one exists. +// Note: This method assumes the caller holds a lock on stackMutex +func (p *ProgressDisplay) resumeParentOperation() { + if len(p.progressStack) == 0 { + p.activeSpinner = nil + return + } + + prevOperation := p.progressStack[len(p.progressStack)-1] + if !prevOperation.IsDone() { + p.activeSpinner = prevOperation + + // Create new context for resumed operation + ctx, cancel := context.WithCancel(context.Background()) + prevOperation.CancelFunc = cancel + + // Resume spinner for previous operation + displayMessage := p.buildContextualMessage() + + p.spinnerWaitGroup.Add(1) + go p.runSpinner(ctx, prevOperation, displayMessage) + } +} + +// displayCompletion shows the completion message for an operation. +func (p *ProgressDisplay) displayCompletion(operation *ProgressOperation, success bool, err error) error { + duration := time.Since(operation.StartTime) + + // In persistent mode, don't show individual completion messages + // unless it's the top-level operation + if p.persistentMode && operation.Level > 0 { + return nil + } + + var displayMessage string + if success { + if duration > 100*time.Millisecond { + displayMessage = fmt.Sprintf("%s (took %v)", operation.Message, duration.Round(10*time.Millisecond)) + } else { + displayMessage = operation.Message + } + + // Print success message without indentation for minimal output + checkmark := lipgloss.NewStyle().Foreground(lipgloss.Color("#2ecc71")).Render("βœ“") + fmt.Fprintf(p.output, "\r%s%s %s\n", clearLine, checkmark, displayMessage) + } else { + if duration > 100*time.Millisecond { + displayMessage = fmt.Sprintf("%s (failed after %v)", operation.Message, duration.Round(10*time.Millisecond)) + } else { + displayMessage = operation.Message + } + + // Print failure message without indentation for minimal output + cross := lipgloss.NewStyle().Foreground(lipgloss.Color("#e74c3c")).Render("βœ—") + errorMsg := fmt.Sprintf("\r%s%s %s", clearLine, cross, displayMessage) + if err != nil { + errorMsg += fmt.Sprintf("\n Error: %v", err) + } + + // Write to stderr for errors, but use the configured output writer + if p.rawOutput == os.Stdout { + _, err := fmt.Fprintf(os.Stderr, "%s\n", errorMsg) + if err != nil { + return err + } + } else { + _, err := fmt.Fprintf(p.output, "%s\n", errorMsg) + if err != nil { + return err + } + } + } + + return nil +} + +// runSpinner runs a spinner for the given operation in the background. +func (p *ProgressDisplay) runSpinner(ctx context.Context, operation *ProgressOperation, displayMessage string) { + // Signal completion when function exits + defer p.spinnerWaitGroup.Done() + + // Don't start spinner if paused + if p.IsPaused() { + return + } + + // Mark cursor as hidden when spinner starts + p.cursorHidden.Store(1) + + // Create spinner with huh + s := spinner.New(). + Title(displayMessage). + Type(spinner.Dots). + Output(p.output). + Accessible(false). + Context(ctx) + + // Run spinner with a simple action that waits for completion + s.ActionWithErr(func(spinnerCtx context.Context) error { + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-spinnerCtx.Done(): + return spinnerCtx.Err() + case <-ticker.C: + // Stop spinner if paused + if p.IsPaused() { + return nil + } + if operation == nil { + return errors.New("no operation in progress") + } + if operation.IsDone() { + return nil + } + } + } + }) + + // Run the spinner + s.Run() +} + +// buildContextualMessage creates a hierarchical message showing the full context +// Note: This method assumes the caller holds a lock on stackMutex +func (p *ProgressDisplay) buildContextualMessage() string { + if len(p.progressStack) == 0 { + return "" + } + + // Build context from all operations in the stack + var parts []string + for _, op := range p.progressStack { + parts = append(parts, op.Message) + } + + // Join with separator to show hierarchy + return strings.Join(parts, ": ") +} + +// restoreCursor ensures the terminal cursor is visible. +func (p *ProgressDisplay) restoreCursor() error { + // Only restore cursor if it was actually hidden + // If CompareAndSwap fails, it means cursor wasn't hidden, which is fine + if !p.cursorHidden.CompareAndSwap(1, 0) { + return nil + } + + if file, ok := p.rawOutput.(*os.File); ok { + _, err := file.WriteString(showCursor) + if err != nil { + return err + } + } else { + _, err := fmt.Fprint(p.rawOutput, showCursor) + if err != nil { + return err + } + } + + return nil +} + +// GetOutputSafely returns buffer content in a thread-safe manner when using bytes.Buffer. +func (p *ProgressDisplay) GetOutputSafely() string { + if p.safeBuffer != nil { + return p.safeBuffer.SafeString() + } + return "" +} + +// NoopProgressDisplay is a progress display that does nothing. +type NoopProgressDisplay struct{} + +var _ ProgressReporter = (*NoopProgressDisplay)(nil) + +// NewNoopProgressDisplay creates a progress display that does nothing. +func NewNoopProgressDisplay() *NoopProgressDisplay { + return &NoopProgressDisplay{} +} + +// Start does nothing. +func (n *NoopProgressDisplay) Start(message string) error { + return nil +} + +// Update does nothing. +func (n *NoopProgressDisplay) Update(message string) error { + return nil +} + +// Finish does nothing. +func (n *NoopProgressDisplay) Finish(message string) error { + return nil +} + +// Fail does nothing. +func (n *NoopProgressDisplay) Fail(message string, err error) error { + return nil +} + +// IsActive always returns false. +func (n *NoopProgressDisplay) IsActive() bool { return false } + +// Clear does nothing. +func (n *NoopProgressDisplay) Clear() error { return nil } + +// Pause does nothing. +func (n *NoopProgressDisplay) Pause() error { + return nil +} + +// Resume does nothing. +func (n *NoopProgressDisplay) Resume() error { + return nil +} + +// IsPaused always returns false. +func (n *NoopProgressDisplay) IsPaused() bool { return false } + +// StartPersistent does nothing. +func (n *NoopProgressDisplay) StartPersistent(message string) error { + return nil +} + +// LogAccomplishment does nothing. +func (n *NoopProgressDisplay) LogAccomplishment(message string) error { + return nil +} + +// FinishPersistent does nothing. +func (n *NoopProgressDisplay) FinishPersistent(message string) error { + return nil +} + +// FailPersistent does nothing. +func (n *NoopProgressDisplay) FailPersistent(message string, err error) error { + return nil +} + +// Close does nothing. +func (n *NoopProgressDisplay) Close() error { return nil } diff --git a/installer/utils/logger/progress_display_test.go b/installer/utils/logger/progress_display_test.go new file mode 100644 index 0000000..f292ef0 --- /dev/null +++ b/installer/utils/logger/progress_display_test.go @@ -0,0 +1,885 @@ +package logger_test + +import ( + "bytes" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/MrPointer/dotfiles/installer/utils/logger" +) + +func Test_NewProgressDisplay_WithBuffer_CreatesValidInstance(t *testing.T) { + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + require.NotNil(t, display) +} + +func Test_NewProgressDisplay_WithNilOutput_UsesStdout(t *testing.T) { + display := logger.NewProgressDisplay(nil) + require.NotNil(t, display) +} + +func Test_SingleProgressOperation_StartedAndFinished_ShowsSuccessMessage(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.Start("Test operation") + require.True(t, display.IsActive()) + + time.Sleep(50 * time.Millisecond) + display.Finish("Test operation") + + require.False(t, display.IsActive()) + + output := buf.String() + require.Contains(t, output, "βœ“") + require.Contains(t, output, "Test operation") +} + +func Test_NestedProgressOperations_WithMultipleLevels_ShowProperHierarchy(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.Start("Parent operation") + require.True(t, display.IsActive()) + + display.Start("Child operation") + require.True(t, display.IsActive()) + + time.Sleep(30 * time.Millisecond) + display.Finish("Child operation") + require.True(t, display.IsActive()) // Parent still active + + time.Sleep(30 * time.Millisecond) + display.Finish("Parent operation") + require.False(t, display.IsActive()) + + output := buf.String() + lines := strings.Split(strings.TrimSpace(output), "\n") + + // Should have contextual messages during progress and clean completion messages + require.Contains(t, output, "Child operation") + require.Contains(t, output, "Parent operation") + require.Contains(t, strings.Join(lines, "\n"), "βœ“") +} + +func Test_ProgressMessage_UpdatedAfterStart_ShowsNewMessage(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.Start("Initial message") + display.Update("Updated message") + time.Sleep(30 * time.Millisecond) + display.Finish("Final message") + + output := buf.String() + require.Contains(t, output, "Updated message") +} + +func Test_ProgressOperation_WhenFailed_ShowsErrorMessage(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.Start("Failing operation") + time.Sleep(30 * time.Millisecond) + display.Fail("Failing operation", errors.New("test error")) + + require.False(t, display.IsActive()) + + output := buf.String() + require.Contains(t, output, "βœ—") + require.Contains(t, output, "Failing operation") + require.Contains(t, output, "test error") +} + +func Test_MixedSuccessAndFailureOperations_WithNestedStructure_DisplayCorrectly(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.Start("Parent") + + display.Start("Success child") + time.Sleep(20 * time.Millisecond) + display.Finish("Success child") + + display.Start("Failing child") + time.Sleep(20 * time.Millisecond) + display.Fail("Failing child", errors.New("child error")) + + display.Finish("Parent") + + output := buf.String() + require.Contains(t, output, "Success child") + require.Contains(t, output, "Failing child") + require.Contains(t, output, "Parent") + require.Contains(t, output, "child error") + require.Contains(t, output, "βœ“") + require.Contains(t, output, "βœ—") +} + +func Test_DeeplyNestedOperations_WithFiveLevels_ShowCorrectIndentation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + // Create deep nesting + display.Start("Level 1") + display.Start("Level 2") + display.Start("Level 3") + display.Start("Level 4") + + time.Sleep(20 * time.Millisecond) + + // Complete in reverse order + display.Finish("Level 4") + display.Finish("Level 3") + display.Finish("Level 2") + display.Finish("Level 1") + + output := buf.String() + + // Check that all levels appear in output (contextual messages during progress) + require.Contains(t, output, "Level 1") + require.Contains(t, output, "Level 2") + require.Contains(t, output, "Level 3") + require.Contains(t, output, "Level 4") +} + +func Test_LongRunningOperations_OverThreshold_ShowTimingInformation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.Start("Long operation") + time.Sleep(150 * time.Millisecond) // Longer than 100ms threshold + display.Finish("Long operation") + + output := buf.String() + require.Contains(t, output, "took") + require.Contains(t, output, "ms") +} + +func Test_ShortOperations_UnderThreshold_DoNotShowTimingInformation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.Start("Quick operation") + // No sleep - immediate completion + display.Finish("Quick operation") + + output := buf.String() + require.NotContains(t, output, "took") +} + +func Test_Clear_WithActiveOperations_StopsAllOperations(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.Start("Operation 1") + display.Start("Operation 2") + require.True(t, display.IsActive()) + + display.Clear() + require.False(t, display.IsActive()) + + // Should not crash when calling methods after clear + display.Update("Should be ignored") + display.Finish("Should be ignored") + display.Fail("Should be ignored", errors.New("test")) +} + +func Test_Update_WithoutActiveProgress_DoesNothing(t *testing.T) { + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.Update("No active operation") + require.False(t, display.IsActive()) +} + +func Test_Finish_WithoutActiveProgress_DoesNothing(t *testing.T) { + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.Finish("No active operation") + require.False(t, display.IsActive()) +} + +func Test_Fail_WithoutActiveProgress_DoesNothing(t *testing.T) { + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.Fail("No active operation", errors.New("test")) + require.False(t, display.IsActive()) +} + +func Test_NoopProgressDisplay_ByDesign_ImplementsProgressReporterInterface(t *testing.T) { + var _ logger.ProgressReporter = (*logger.NoopProgressDisplay)(nil) +} + +func Test_NoopProgressDisplay_AllMethods_DoNothing(t *testing.T) { + display := logger.NewNoopProgressDisplay() + + // All these should not crash and should not do anything + display.Start("Test") + require.False(t, display.IsActive()) + + display.Update("Test") + require.False(t, display.IsActive()) + + display.Finish("Test") + require.False(t, display.IsActive()) + + display.Fail("Test", errors.New("test")) + require.False(t, display.IsActive()) + + display.Clear() + require.False(t, display.IsActive()) +} + +func Test_ConcurrentProgressDisplayOperations_WithMultipleGoroutines_AreThreadSafe(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + done := make(chan bool, 2) + + // Test concurrent access to ensure thread safety + go func() { + display.Start("Concurrent 1") + time.Sleep(50 * time.Millisecond) + display.Finish("Concurrent 1") + done <- true + }() + + go func() { + time.Sleep(25 * time.Millisecond) + display.Start("Concurrent 2") + time.Sleep(50 * time.Millisecond) + display.Finish("Concurrent 2") + done <- true + }() + + // Wait for both goroutines to complete + <-done + <-done + + require.False(t, display.IsActive()) + + output := buf.String() + require.Contains(t, output, "Concurrent 1") + require.Contains(t, output, "Concurrent 2") +} + +func Test_RapidSequentialOperations_WithQuickSuccession_Work(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + // Rapidly start and finish operations + for i := 0; i < 10; i++ { + display.Start("Rapid operation") + time.Sleep(5 * time.Millisecond) + display.Finish("Rapid operation") + } + + require.False(t, display.IsActive()) + + output := buf.String() + // Should have multiple completion messages + checkmarkCount := strings.Count(output, "βœ“") + require.Equal(t, 10, checkmarkCount) +} + +func Test_StartPersistentProgress_WhenCalled_ActivatesPersistentMode(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.StartPersistent("Installing packages") + require.True(t, display.IsActive()) + + time.Sleep(50 * time.Millisecond) + display.FinishPersistent("Installation complete") + require.False(t, display.IsActive()) + + output := buf.String() + require.Contains(t, output, "βœ“") + require.Contains(t, output, "Installing packages") +} + +func Test_LogAccomplishment_WithPersistentProgress_ShowsVisibleAccomplishments(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.StartPersistent("Deploying application") + time.Sleep(30 * time.Millisecond) + + display.LogAccomplishment("Built application") + display.LogAccomplishment("Created container") + display.LogAccomplishment("Pushed to registry") + + time.Sleep(30 * time.Millisecond) + display.FinishPersistent("Deployment complete") + + output := buf.String() + require.Contains(t, output, "Built application") + require.Contains(t, output, "Created container") + require.Contains(t, output, "Pushed to registry") + require.Contains(t, output, "Deploying application") +} + +func Test_ProgressDisplayPersistentProgress_WhenFailed_ShowsErrorMessage(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.StartPersistent("Critical operation") + display.LogAccomplishment("Step 1 completed") + display.LogAccomplishment("Step 2 completed") + time.Sleep(30 * time.Millisecond) + display.FailPersistent("Critical operation failed", errors.New("permission denied")) + + require.False(t, display.IsActive()) + + output := buf.String() + require.Contains(t, output, "Step 1 completed") + require.Contains(t, output, "Step 2 completed") + require.Contains(t, output, "βœ—") + require.Contains(t, output, "Critical operation") + require.Contains(t, output, "permission denied") +} + +func Test_ProgressDisplayMixed_PersistentAndRegularOperations_Work(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.StartPersistent("Setting up environment") + display.LogAccomplishment("Created directories") + + display.Start("Downloading files") + time.Sleep(30 * time.Millisecond) + display.Finish("Files downloaded") + + display.LogAccomplishment("Installed dependencies") + + display.Start("Running tests") + time.Sleep(30 * time.Millisecond) + display.Finish("Tests passed") + + display.FinishPersistent("Environment ready") + + output := buf.String() + require.Contains(t, output, "Created directories") + require.Contains(t, output, "Installed dependencies") + require.Contains(t, output, "Setting up environment") +} + +func Test_LogAccomplishment_WithoutActivePersistentProgress_StillWorks(t *testing.T) { + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.LogAccomplishment("Standalone accomplishment") + + output := buf.String() + require.Contains(t, output, "βœ“") + require.Contains(t, output, "Standalone accomplishment") +} + +func Test_FinishPersistent_WithoutActiveProgress_DoesNothing(t *testing.T) { + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.FinishPersistent("No active progress") + require.False(t, display.IsActive()) +} + +func Test_FailPersistent_WithoutActiveProgress_DoesNothing(t *testing.T) { + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.FailPersistent("No active progress", errors.New("test error")) + require.False(t, display.IsActive()) +} + +func Test_PersistentProgress_WithAccomplishments_ShowsInRealTime(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.StartPersistent("Processing items") + + accomplishments := []string{ + "Processed item 1", + "Processed item 2", + "Processed item 3", + "Processed item 4", + "Processed item 5", + } + + for _, accomplishment := range accomplishments { + time.Sleep(20 * time.Millisecond) + display.LogAccomplishment(accomplishment) + } + + display.FinishPersistent("All items processed") + + output := buf.String() + for _, accomplishment := range accomplishments { + require.Contains(t, output, accomplishment) + } + require.Contains(t, output, "Processing items") +} + +func Test_NoopProgressDisplay_PersistentMethods_DoNothing(t *testing.T) { + display := logger.NewNoopProgressDisplay() + + // All these should not crash and should not do anything + display.StartPersistent("Test") + require.False(t, display.IsActive()) + + display.LogAccomplishment("Test accomplishment") + require.False(t, display.IsActive()) + + display.FinishPersistent("Test") + require.False(t, display.IsActive()) + + display.FailPersistent("Test", errors.New("test")) + require.False(t, display.IsActive()) + + display.Close() + require.False(t, display.IsActive()) +} + +func Test_Close_WithActiveOperations_StopsAllAndRestoresCursor(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.Start("Operation 1") + display.Start("Operation 2") + require.True(t, display.IsActive()) + + display.Close() + require.False(t, display.IsActive()) + + // Should not crash when calling methods after cleanup + display.Update("Should be ignored") + display.Finish("Should be ignored") + display.Fail("Should be ignored", errors.New("test")) +} + +func Test_Close_CalledMultipleTimes_DoesNotCrash(t *testing.T) { + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.Start("Test operation") + require.True(t, display.IsActive()) + + // Multiple cleanups should not crash + display.Close() + display.Close() + display.Close() + + require.False(t, display.IsActive()) +} + +func Test_Close_WithoutActiveOperations_DoesNotCrash(t *testing.T) { + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + require.NotPanics(t, func() { + display.Close() + }) + + require.False(t, display.IsActive()) +} + +func Test_Close_WithHiddenCursor_RestoresCursor(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.Start("Operation with cursor") + require.True(t, display.IsActive()) + + time.Sleep(30 * time.Millisecond) + display.Close() + require.False(t, display.IsActive()) + + // Close should not crash and should handle cursor state properly + require.NotNil(t, display) +} + +func Test_ProgressFailure_WithSynchronization_PreventsHangingCursor(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + display.Start("Operation that will fail") + require.True(t, display.IsActive()) + + // Simulate work that results in failure + time.Sleep(100 * time.Millisecond) + display.Fail("Operation failed", errors.New("simulated error")) + + // After failure, display should be properly cleaned up + require.False(t, display.IsActive()) + + // Verify that output contains failure message and cursor control sequences are handled + output := buf.String() + require.Contains(t, output, "βœ—") + require.Contains(t, output, "Operation that will fail") + require.Contains(t, output, "simulated error") + + // Should be able to start new operations without issues + display.Start("New operation after failure") + require.True(t, display.IsActive()) + + time.Sleep(30 * time.Millisecond) + display.Finish("New operation completed") + require.False(t, display.IsActive()) + + // Verify new operation also completed successfully + require.Contains(t, buf.String(), "New operation after failure") +} + +func Test_RapidFailureAndRecovery_WithQuickOperations_MaintainsProperTerminalState(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + var buf bytes.Buffer + display := logger.NewProgressDisplay(&buf) + + // Test rapid failure and recovery cycles + for i := 0; i < 5; i++ { + display.Start("Rapid operation") + time.Sleep(20 * time.Millisecond) + + if i%2 == 0 { + display.Fail("Operation failed", errors.New("test error")) + } else { + display.Finish("Operation succeeded") + } + + require.False(t, display.IsActive()) + } + + output := buf.String() + // Should have both success and failure indicators + require.Contains(t, output, "βœ“") + require.Contains(t, output, "βœ—") + + // Should not have any hanging operations + require.False(t, display.IsActive()) +} + +func Test_Pause_WithActiveSpinners_StopsAllOperations(t *testing.T) { + var output bytes.Buffer + display := logger.NewProgressDisplay(&output) + + display.Start("Operation 1") + display.Start("Operation 2") + time.Sleep(50 * time.Millisecond) // Allow spinners to start + + require.True(t, display.IsActive()) + require.False(t, display.IsPaused()) + + err := display.Pause() + require.NoError(t, err) + + require.True(t, display.IsPaused()) + require.True(t, display.IsActive()) // Operations still exist, just paused +} + +func Test_Resume_AfterPause_RestartsSpinnerOperations(t *testing.T) { + var output bytes.Buffer + display := logger.NewProgressDisplay(&output) + + display.Start("Operation 1") + display.Start("Operation 2") + time.Sleep(50 * time.Millisecond) // Allow spinners to start + + err := display.Pause() + require.NoError(t, err) + require.True(t, display.IsPaused()) + + err = display.Resume() + require.NoError(t, err) + + require.False(t, display.IsPaused()) + require.True(t, display.IsActive()) +} + +func Test_Pause_WithoutActiveOperations_DoesNotCrash(t *testing.T) { + var output bytes.Buffer + display := logger.NewProgressDisplay(&output) + + require.False(t, display.IsActive()) + require.False(t, display.IsPaused()) + + err := display.Pause() + require.NoError(t, err) + + require.True(t, display.IsPaused()) + require.False(t, display.IsActive()) +} + +func Test_Resume_WithoutActiveOperations_DoesNotCrash(t *testing.T) { + var output bytes.Buffer + display := logger.NewProgressDisplay(&output) + + err := display.Pause() + require.NoError(t, err) + require.True(t, display.IsPaused()) + + err = display.Resume() + require.NoError(t, err) + + require.False(t, display.IsPaused()) + require.False(t, display.IsActive()) +} + +func Test_Pause_CalledMultipleTimes_IsSafe(t *testing.T) { + var output bytes.Buffer + display := logger.NewProgressDisplay(&output) + + display.Start("Test Operation") + time.Sleep(50 * time.Millisecond) // Allow spinner to start + + err := display.Pause() + require.NoError(t, err) + require.True(t, display.IsPaused()) + + err = display.Pause() + require.NoError(t, err) + require.True(t, display.IsPaused()) +} + +func Test_Resume_CalledMultipleTimes_IsSafe(t *testing.T) { + var output bytes.Buffer + display := logger.NewProgressDisplay(&output) + + display.Start("Test Operation") + time.Sleep(50 * time.Millisecond) // Allow spinner to start + display.Pause() + + err := display.Resume() + require.NoError(t, err) + require.False(t, display.IsPaused()) + + err = display.Resume() + require.NoError(t, err) + require.False(t, display.IsPaused()) +} + +func Test_PauseAndResume_WithNestedOperations_WorksCorrectly(t *testing.T) { + var output bytes.Buffer + display := logger.NewProgressDisplay(&output) + + display.Start("Parent Operation") + display.Start("Child Operation 1") + display.Start("Child Operation 2") + time.Sleep(50 * time.Millisecond) // Allow spinners to start + + require.True(t, display.IsActive()) + require.False(t, display.IsPaused()) + + err := display.Pause() + require.NoError(t, err) + require.True(t, display.IsPaused()) + + err = display.Resume() + require.NoError(t, err) + require.False(t, display.IsPaused()) + require.True(t, display.IsActive()) +} + +func Test_Pause_BeforeInteractiveInput_StopsSpinnerAndClearsOutput(t *testing.T) { + var output bytes.Buffer + display := logger.NewProgressDisplay(&output) + + display.Start("Processing files...") + time.Sleep(50 * time.Millisecond) // Let spinner start + + initialOutput := display.GetOutputSafely() + require.NotEmpty(t, initialOutput) + + err := display.Pause() + require.NoError(t, err) + + // Allow some time to ensure spinner has stopped + time.Sleep(100 * time.Millisecond) + outputAfterPause := display.GetOutputSafely() + + // Output should contain clear line sequence after pause + require.Contains(t, outputAfterPause, "\r") + require.True(t, display.IsPaused()) + require.True(t, display.IsActive()) // Operations still exist, just paused +} + +func Test_Resume_WithMultipleOperations_RestartsMostRecent(t *testing.T) { + var output bytes.Buffer + display := logger.NewProgressDisplay(&output) + + display.Start("Operation 1") + display.Start("Operation 2") + display.Start("Operation 3") + time.Sleep(50 * time.Millisecond) // Allow spinners to start + + err := display.Pause() + require.NoError(t, err) + + err = display.Resume() + require.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + output_content := display.GetOutputSafely() + + // Should show the most recent operation (Operation 3) + require.Contains(t, output_content, "Operation 3") +} + +func Test_PausedState_WithNewOperations_StillAllowsOperationCreation(t *testing.T) { + var output bytes.Buffer + display := logger.NewProgressDisplay(&output) + + display.Start("Operation 1") + time.Sleep(50 * time.Millisecond) // Allow spinner to start + + err := display.Pause() + require.NoError(t, err) + require.True(t, display.IsPaused()) + + // Starting new operation while paused should still work + display.Start("Operation 2") + require.True(t, display.IsActive()) + require.True(t, display.IsPaused()) + + err = display.Resume() + require.NoError(t, err) + require.False(t, display.IsPaused()) +} + +func Test_PauseAndResume_WithPersistentProgress_WorksCorrectly(t *testing.T) { + var output bytes.Buffer + display := logger.NewProgressDisplay(&output) + + display.StartPersistent("Installing packages...") + display.LogAccomplishment("Package 1 installed") + + err := display.Pause() + require.NoError(t, err) + require.True(t, display.IsPaused()) + + err = display.Resume() + require.NoError(t, err) + require.False(t, display.IsPaused()) + + display.LogAccomplishment("Package 2 installed") + display.FinishPersistent("All packages installed successfully") +} + +func Test_Close_AfterPause_RestoresTerminalState(t *testing.T) { + var output bytes.Buffer + display := logger.NewProgressDisplay(&output) + + display.Start("Test Operation") + time.Sleep(50 * time.Millisecond) // Allow spinner to start + + err := display.Pause() + require.NoError(t, err) + require.True(t, display.IsPaused()) + + err = display.Close() + require.NoError(t, err) + + // Should be able to close multiple times + err = display.Close() + require.NoError(t, err) +} + +func Test_NoopProgressDisplay_PauseAndResumeMethods_DoNothing(t *testing.T) { + display := logger.NewNoopProgressDisplay() + + require.False(t, display.IsActive()) + require.False(t, display.IsPaused()) + + err := display.Pause() + require.NoError(t, err) + require.False(t, display.IsPaused()) + + err = display.Resume() + require.NoError(t, err) + require.False(t, display.IsPaused()) +} diff --git a/installer/utils/osmanager/EnvironmentManager_mock.go b/installer/utils/osmanager/EnvironmentManager_mock.go new file mode 100644 index 0000000..602100a --- /dev/null +++ b/installer/utils/osmanager/EnvironmentManager_mock.go @@ -0,0 +1,75 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package osmanager + +import ( + "sync" +) + +// Ensure that MoqEnvironmentManager does implement EnvironmentManager. +// If this is not the case, regenerate this file with mockery. +var _ EnvironmentManager = &MoqEnvironmentManager{} + +// MoqEnvironmentManager is a mock implementation of EnvironmentManager. +// +// func TestSomethingThatUsesEnvironmentManager(t *testing.T) { +// +// // make and configure a mocked EnvironmentManager +// mockedEnvironmentManager := &MoqEnvironmentManager{ +// GetenvFunc: func(key string) string { +// panic("mock out the Getenv method") +// }, +// } +// +// // use mockedEnvironmentManager in code that requires EnvironmentManager +// // and then make assertions. +// +// } +type MoqEnvironmentManager struct { + // GetenvFunc mocks the Getenv method. + GetenvFunc func(key string) string + + // calls tracks calls to the methods. + calls struct { + // Getenv holds details about calls to the Getenv method. + Getenv []struct { + // Key is the key argument value. + Key string + } + } + lockGetenv sync.RWMutex +} + +// Getenv calls GetenvFunc. +func (mock *MoqEnvironmentManager) Getenv(key string) string { + if mock.GetenvFunc == nil { + panic("MoqEnvironmentManager.GetenvFunc: method is nil but EnvironmentManager.Getenv was just called") + } + callInfo := struct { + Key string + }{ + Key: key, + } + mock.lockGetenv.Lock() + mock.calls.Getenv = append(mock.calls.Getenv, callInfo) + mock.lockGetenv.Unlock() + return mock.GetenvFunc(key) +} + +// GetenvCalls gets all the calls that were made to Getenv. +// Check the length with: +// +// len(mockedEnvironmentManager.GetenvCalls()) +func (mock *MoqEnvironmentManager) GetenvCalls() []struct { + Key string +} { + var calls []struct { + Key string + } + mock.lockGetenv.RLock() + calls = mock.calls.Getenv + mock.lockGetenv.RUnlock() + return calls +} diff --git a/installer/utils/osmanager/FilePermissionManager_mock.go b/installer/utils/osmanager/FilePermissionManager_mock.go new file mode 100644 index 0000000..c901fec --- /dev/null +++ b/installer/utils/osmanager/FilePermissionManager_mock.go @@ -0,0 +1,176 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package osmanager + +import ( + "os" + "sync" +) + +// Ensure that MoqFilePermissionManager does implement FilePermissionManager. +// If this is not the case, regenerate this file with mockery. +var _ FilePermissionManager = &MoqFilePermissionManager{} + +// MoqFilePermissionManager is a mock implementation of FilePermissionManager. +// +// func TestSomethingThatUsesFilePermissionManager(t *testing.T) { +// +// // make and configure a mocked FilePermissionManager +// mockedFilePermissionManager := &MoqFilePermissionManager{ +// GetFileOwnerFunc: func(path string) (string, error) { +// panic("mock out the GetFileOwner method") +// }, +// SetOwnershipFunc: func(path string, username string) error { +// panic("mock out the SetOwnership method") +// }, +// SetPermissionsFunc: func(path string, mode os.FileMode) error { +// panic("mock out the SetPermissions method") +// }, +// } +// +// // use mockedFilePermissionManager in code that requires FilePermissionManager +// // and then make assertions. +// +// } +type MoqFilePermissionManager struct { + // GetFileOwnerFunc mocks the GetFileOwner method. + GetFileOwnerFunc func(path string) (string, error) + + // SetOwnershipFunc mocks the SetOwnership method. + SetOwnershipFunc func(path string, username string) error + + // SetPermissionsFunc mocks the SetPermissions method. + SetPermissionsFunc func(path string, mode os.FileMode) error + + // calls tracks calls to the methods. + calls struct { + // GetFileOwner holds details about calls to the GetFileOwner method. + GetFileOwner []struct { + // Path is the path argument value. + Path string + } + // SetOwnership holds details about calls to the SetOwnership method. + SetOwnership []struct { + // Path is the path argument value. + Path string + // Username is the username argument value. + Username string + } + // SetPermissions holds details about calls to the SetPermissions method. + SetPermissions []struct { + // Path is the path argument value. + Path string + // Mode is the mode argument value. + Mode os.FileMode + } + } + lockGetFileOwner sync.RWMutex + lockSetOwnership sync.RWMutex + lockSetPermissions sync.RWMutex +} + +// GetFileOwner calls GetFileOwnerFunc. +func (mock *MoqFilePermissionManager) GetFileOwner(path string) (string, error) { + if mock.GetFileOwnerFunc == nil { + panic("MoqFilePermissionManager.GetFileOwnerFunc: method is nil but FilePermissionManager.GetFileOwner was just called") + } + callInfo := struct { + Path string + }{ + Path: path, + } + mock.lockGetFileOwner.Lock() + mock.calls.GetFileOwner = append(mock.calls.GetFileOwner, callInfo) + mock.lockGetFileOwner.Unlock() + return mock.GetFileOwnerFunc(path) +} + +// GetFileOwnerCalls gets all the calls that were made to GetFileOwner. +// Check the length with: +// +// len(mockedFilePermissionManager.GetFileOwnerCalls()) +func (mock *MoqFilePermissionManager) GetFileOwnerCalls() []struct { + Path string +} { + var calls []struct { + Path string + } + mock.lockGetFileOwner.RLock() + calls = mock.calls.GetFileOwner + mock.lockGetFileOwner.RUnlock() + return calls +} + +// SetOwnership calls SetOwnershipFunc. +func (mock *MoqFilePermissionManager) SetOwnership(path string, username string) error { + if mock.SetOwnershipFunc == nil { + panic("MoqFilePermissionManager.SetOwnershipFunc: method is nil but FilePermissionManager.SetOwnership was just called") + } + callInfo := struct { + Path string + Username string + }{ + Path: path, + Username: username, + } + mock.lockSetOwnership.Lock() + mock.calls.SetOwnership = append(mock.calls.SetOwnership, callInfo) + mock.lockSetOwnership.Unlock() + return mock.SetOwnershipFunc(path, username) +} + +// SetOwnershipCalls gets all the calls that were made to SetOwnership. +// Check the length with: +// +// len(mockedFilePermissionManager.SetOwnershipCalls()) +func (mock *MoqFilePermissionManager) SetOwnershipCalls() []struct { + Path string + Username string +} { + var calls []struct { + Path string + Username string + } + mock.lockSetOwnership.RLock() + calls = mock.calls.SetOwnership + mock.lockSetOwnership.RUnlock() + return calls +} + +// SetPermissions calls SetPermissionsFunc. +func (mock *MoqFilePermissionManager) SetPermissions(path string, mode os.FileMode) error { + if mock.SetPermissionsFunc == nil { + panic("MoqFilePermissionManager.SetPermissionsFunc: method is nil but FilePermissionManager.SetPermissions was just called") + } + callInfo := struct { + Path string + Mode os.FileMode + }{ + Path: path, + Mode: mode, + } + mock.lockSetPermissions.Lock() + mock.calls.SetPermissions = append(mock.calls.SetPermissions, callInfo) + mock.lockSetPermissions.Unlock() + return mock.SetPermissionsFunc(path, mode) +} + +// SetPermissionsCalls gets all the calls that were made to SetPermissions. +// Check the length with: +// +// len(mockedFilePermissionManager.SetPermissionsCalls()) +func (mock *MoqFilePermissionManager) SetPermissionsCalls() []struct { + Path string + Mode os.FileMode +} { + var calls []struct { + Path string + Mode os.FileMode + } + mock.lockSetPermissions.RLock() + calls = mock.calls.SetPermissions + mock.lockSetPermissions.RUnlock() + return calls +} diff --git a/installer/utils/osmanager/OsManager_mock.go b/installer/utils/osmanager/OsManager_mock.go new file mode 100644 index 0000000..6528f09 --- /dev/null +++ b/installer/utils/osmanager/OsManager_mock.go @@ -0,0 +1,657 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package osmanager + +import ( + "os" + "sync" +) + +// Ensure that MoqOsManager does implement OsManager. +// If this is not the case, regenerate this file with mockery. +var _ OsManager = &MoqOsManager{} + +// MoqOsManager is a mock implementation of OsManager. +// +// func TestSomethingThatUsesOsManager(t *testing.T) { +// +// // make and configure a mocked OsManager +// mockedOsManager := &MoqOsManager{ +// AddSudoAccessFunc: func(username string) error { +// panic("mock out the AddSudoAccess method") +// }, +// AddUserFunc: func(username string) error { +// panic("mock out the AddUser method") +// }, +// AddUserToGroupFunc: func(username string, group string) error { +// panic("mock out the AddUserToGroup method") +// }, +// GetChezmoiConfigHomeFunc: func() (string, error) { +// panic("mock out the GetChezmoiConfigHome method") +// }, +// GetConfigDirFunc: func() (string, error) { +// panic("mock out the GetConfigDir method") +// }, +// GetFileOwnerFunc: func(path string) (string, error) { +// panic("mock out the GetFileOwner method") +// }, +// GetHomeDirFunc: func() (string, error) { +// panic("mock out the GetHomeDir method") +// }, +// GetProgramPathFunc: func(program string) (string, error) { +// panic("mock out the GetProgramPath method") +// }, +// GetProgramVersionFunc: func(program string, versionExtractor VersionExtractor, queryArgs ...string) (string, error) { +// panic("mock out the GetProgramVersion method") +// }, +// GetenvFunc: func(key string) string { +// panic("mock out the Getenv method") +// }, +// ProgramExistsFunc: func(program string) (bool, error) { +// panic("mock out the ProgramExists method") +// }, +// SetOwnershipFunc: func(path string, username string) error { +// panic("mock out the SetOwnership method") +// }, +// SetPermissionsFunc: func(path string, mode os.FileMode) error { +// panic("mock out the SetPermissions method") +// }, +// UserExistsFunc: func(username string) (bool, error) { +// panic("mock out the UserExists method") +// }, +// } +// +// // use mockedOsManager in code that requires OsManager +// // and then make assertions. +// +// } +type MoqOsManager struct { + // AddSudoAccessFunc mocks the AddSudoAccess method. + AddSudoAccessFunc func(username string) error + + // AddUserFunc mocks the AddUser method. + AddUserFunc func(username string) error + + // AddUserToGroupFunc mocks the AddUserToGroup method. + AddUserToGroupFunc func(username string, group string) error + + // GetChezmoiConfigHomeFunc mocks the GetChezmoiConfigHome method. + GetChezmoiConfigHomeFunc func() (string, error) + + // GetConfigDirFunc mocks the GetConfigDir method. + GetConfigDirFunc func() (string, error) + + // GetFileOwnerFunc mocks the GetFileOwner method. + GetFileOwnerFunc func(path string) (string, error) + + // GetHomeDirFunc mocks the GetHomeDir method. + GetHomeDirFunc func() (string, error) + + // GetProgramPathFunc mocks the GetProgramPath method. + GetProgramPathFunc func(program string) (string, error) + + // GetProgramVersionFunc mocks the GetProgramVersion method. + GetProgramVersionFunc func(program string, versionExtractor VersionExtractor, queryArgs ...string) (string, error) + + // GetenvFunc mocks the Getenv method. + GetenvFunc func(key string) string + + // ProgramExistsFunc mocks the ProgramExists method. + ProgramExistsFunc func(program string) (bool, error) + + // SetOwnershipFunc mocks the SetOwnership method. + SetOwnershipFunc func(path string, username string) error + + // SetPermissionsFunc mocks the SetPermissions method. + SetPermissionsFunc func(path string, mode os.FileMode) error + + // UserExistsFunc mocks the UserExists method. + UserExistsFunc func(username string) (bool, error) + + // calls tracks calls to the methods. + calls struct { + // AddSudoAccess holds details about calls to the AddSudoAccess method. + AddSudoAccess []struct { + // Username is the username argument value. + Username string + } + // AddUser holds details about calls to the AddUser method. + AddUser []struct { + // Username is the username argument value. + Username string + } + // AddUserToGroup holds details about calls to the AddUserToGroup method. + AddUserToGroup []struct { + // Username is the username argument value. + Username string + // Group is the group argument value. + Group string + } + // GetChezmoiConfigHome holds details about calls to the GetChezmoiConfigHome method. + GetChezmoiConfigHome []struct { + } + // GetConfigDir holds details about calls to the GetConfigDir method. + GetConfigDir []struct { + } + // GetFileOwner holds details about calls to the GetFileOwner method. + GetFileOwner []struct { + // Path is the path argument value. + Path string + } + // GetHomeDir holds details about calls to the GetHomeDir method. + GetHomeDir []struct { + } + // GetProgramPath holds details about calls to the GetProgramPath method. + GetProgramPath []struct { + // Program is the program argument value. + Program string + } + // GetProgramVersion holds details about calls to the GetProgramVersion method. + GetProgramVersion []struct { + // Program is the program argument value. + Program string + // VersionExtractor is the versionExtractor argument value. + VersionExtractor VersionExtractor + // QueryArgs is the queryArgs argument value. + QueryArgs []string + } + // Getenv holds details about calls to the Getenv method. + Getenv []struct { + // Key is the key argument value. + Key string + } + // ProgramExists holds details about calls to the ProgramExists method. + ProgramExists []struct { + // Program is the program argument value. + Program string + } + // SetOwnership holds details about calls to the SetOwnership method. + SetOwnership []struct { + // Path is the path argument value. + Path string + // Username is the username argument value. + Username string + } + // SetPermissions holds details about calls to the SetPermissions method. + SetPermissions []struct { + // Path is the path argument value. + Path string + // Mode is the mode argument value. + Mode os.FileMode + } + // UserExists holds details about calls to the UserExists method. + UserExists []struct { + // Username is the username argument value. + Username string + } + } + lockAddSudoAccess sync.RWMutex + lockAddUser sync.RWMutex + lockAddUserToGroup sync.RWMutex + lockGetChezmoiConfigHome sync.RWMutex + lockGetConfigDir sync.RWMutex + lockGetFileOwner sync.RWMutex + lockGetHomeDir sync.RWMutex + lockGetProgramPath sync.RWMutex + lockGetProgramVersion sync.RWMutex + lockGetenv sync.RWMutex + lockProgramExists sync.RWMutex + lockSetOwnership sync.RWMutex + lockSetPermissions sync.RWMutex + lockUserExists sync.RWMutex +} + +// AddSudoAccess calls AddSudoAccessFunc. +func (mock *MoqOsManager) AddSudoAccess(username string) error { + if mock.AddSudoAccessFunc == nil { + panic("MoqOsManager.AddSudoAccessFunc: method is nil but OsManager.AddSudoAccess was just called") + } + callInfo := struct { + Username string + }{ + Username: username, + } + mock.lockAddSudoAccess.Lock() + mock.calls.AddSudoAccess = append(mock.calls.AddSudoAccess, callInfo) + mock.lockAddSudoAccess.Unlock() + return mock.AddSudoAccessFunc(username) +} + +// AddSudoAccessCalls gets all the calls that were made to AddSudoAccess. +// Check the length with: +// +// len(mockedOsManager.AddSudoAccessCalls()) +func (mock *MoqOsManager) AddSudoAccessCalls() []struct { + Username string +} { + var calls []struct { + Username string + } + mock.lockAddSudoAccess.RLock() + calls = mock.calls.AddSudoAccess + mock.lockAddSudoAccess.RUnlock() + return calls +} + +// AddUser calls AddUserFunc. +func (mock *MoqOsManager) AddUser(username string) error { + if mock.AddUserFunc == nil { + panic("MoqOsManager.AddUserFunc: method is nil but OsManager.AddUser was just called") + } + callInfo := struct { + Username string + }{ + Username: username, + } + mock.lockAddUser.Lock() + mock.calls.AddUser = append(mock.calls.AddUser, callInfo) + mock.lockAddUser.Unlock() + return mock.AddUserFunc(username) +} + +// AddUserCalls gets all the calls that were made to AddUser. +// Check the length with: +// +// len(mockedOsManager.AddUserCalls()) +func (mock *MoqOsManager) AddUserCalls() []struct { + Username string +} { + var calls []struct { + Username string + } + mock.lockAddUser.RLock() + calls = mock.calls.AddUser + mock.lockAddUser.RUnlock() + return calls +} + +// AddUserToGroup calls AddUserToGroupFunc. +func (mock *MoqOsManager) AddUserToGroup(username string, group string) error { + if mock.AddUserToGroupFunc == nil { + panic("MoqOsManager.AddUserToGroupFunc: method is nil but OsManager.AddUserToGroup was just called") + } + callInfo := struct { + Username string + Group string + }{ + Username: username, + Group: group, + } + mock.lockAddUserToGroup.Lock() + mock.calls.AddUserToGroup = append(mock.calls.AddUserToGroup, callInfo) + mock.lockAddUserToGroup.Unlock() + return mock.AddUserToGroupFunc(username, group) +} + +// AddUserToGroupCalls gets all the calls that were made to AddUserToGroup. +// Check the length with: +// +// len(mockedOsManager.AddUserToGroupCalls()) +func (mock *MoqOsManager) AddUserToGroupCalls() []struct { + Username string + Group string +} { + var calls []struct { + Username string + Group string + } + mock.lockAddUserToGroup.RLock() + calls = mock.calls.AddUserToGroup + mock.lockAddUserToGroup.RUnlock() + return calls +} + +// GetChezmoiConfigHome calls GetChezmoiConfigHomeFunc. +func (mock *MoqOsManager) GetChezmoiConfigHome() (string, error) { + if mock.GetChezmoiConfigHomeFunc == nil { + panic("MoqOsManager.GetChezmoiConfigHomeFunc: method is nil but OsManager.GetChezmoiConfigHome was just called") + } + callInfo := struct { + }{} + mock.lockGetChezmoiConfigHome.Lock() + mock.calls.GetChezmoiConfigHome = append(mock.calls.GetChezmoiConfigHome, callInfo) + mock.lockGetChezmoiConfigHome.Unlock() + return mock.GetChezmoiConfigHomeFunc() +} + +// GetChezmoiConfigHomeCalls gets all the calls that were made to GetChezmoiConfigHome. +// Check the length with: +// +// len(mockedOsManager.GetChezmoiConfigHomeCalls()) +func (mock *MoqOsManager) GetChezmoiConfigHomeCalls() []struct { +} { + var calls []struct { + } + mock.lockGetChezmoiConfigHome.RLock() + calls = mock.calls.GetChezmoiConfigHome + mock.lockGetChezmoiConfigHome.RUnlock() + return calls +} + +// GetConfigDir calls GetConfigDirFunc. +func (mock *MoqOsManager) GetConfigDir() (string, error) { + if mock.GetConfigDirFunc == nil { + panic("MoqOsManager.GetConfigDirFunc: method is nil but OsManager.GetConfigDir was just called") + } + callInfo := struct { + }{} + mock.lockGetConfigDir.Lock() + mock.calls.GetConfigDir = append(mock.calls.GetConfigDir, callInfo) + mock.lockGetConfigDir.Unlock() + return mock.GetConfigDirFunc() +} + +// GetConfigDirCalls gets all the calls that were made to GetConfigDir. +// Check the length with: +// +// len(mockedOsManager.GetConfigDirCalls()) +func (mock *MoqOsManager) GetConfigDirCalls() []struct { +} { + var calls []struct { + } + mock.lockGetConfigDir.RLock() + calls = mock.calls.GetConfigDir + mock.lockGetConfigDir.RUnlock() + return calls +} + +// GetFileOwner calls GetFileOwnerFunc. +func (mock *MoqOsManager) GetFileOwner(path string) (string, error) { + if mock.GetFileOwnerFunc == nil { + panic("MoqOsManager.GetFileOwnerFunc: method is nil but OsManager.GetFileOwner was just called") + } + callInfo := struct { + Path string + }{ + Path: path, + } + mock.lockGetFileOwner.Lock() + mock.calls.GetFileOwner = append(mock.calls.GetFileOwner, callInfo) + mock.lockGetFileOwner.Unlock() + return mock.GetFileOwnerFunc(path) +} + +// GetFileOwnerCalls gets all the calls that were made to GetFileOwner. +// Check the length with: +// +// len(mockedOsManager.GetFileOwnerCalls()) +func (mock *MoqOsManager) GetFileOwnerCalls() []struct { + Path string +} { + var calls []struct { + Path string + } + mock.lockGetFileOwner.RLock() + calls = mock.calls.GetFileOwner + mock.lockGetFileOwner.RUnlock() + return calls +} + +// GetHomeDir calls GetHomeDirFunc. +func (mock *MoqOsManager) GetHomeDir() (string, error) { + if mock.GetHomeDirFunc == nil { + panic("MoqOsManager.GetHomeDirFunc: method is nil but OsManager.GetHomeDir was just called") + } + callInfo := struct { + }{} + mock.lockGetHomeDir.Lock() + mock.calls.GetHomeDir = append(mock.calls.GetHomeDir, callInfo) + mock.lockGetHomeDir.Unlock() + return mock.GetHomeDirFunc() +} + +// GetHomeDirCalls gets all the calls that were made to GetHomeDir. +// Check the length with: +// +// len(mockedOsManager.GetHomeDirCalls()) +func (mock *MoqOsManager) GetHomeDirCalls() []struct { +} { + var calls []struct { + } + mock.lockGetHomeDir.RLock() + calls = mock.calls.GetHomeDir + mock.lockGetHomeDir.RUnlock() + return calls +} + +// GetProgramPath calls GetProgramPathFunc. +func (mock *MoqOsManager) GetProgramPath(program string) (string, error) { + if mock.GetProgramPathFunc == nil { + panic("MoqOsManager.GetProgramPathFunc: method is nil but OsManager.GetProgramPath was just called") + } + callInfo := struct { + Program string + }{ + Program: program, + } + mock.lockGetProgramPath.Lock() + mock.calls.GetProgramPath = append(mock.calls.GetProgramPath, callInfo) + mock.lockGetProgramPath.Unlock() + return mock.GetProgramPathFunc(program) +} + +// GetProgramPathCalls gets all the calls that were made to GetProgramPath. +// Check the length with: +// +// len(mockedOsManager.GetProgramPathCalls()) +func (mock *MoqOsManager) GetProgramPathCalls() []struct { + Program string +} { + var calls []struct { + Program string + } + mock.lockGetProgramPath.RLock() + calls = mock.calls.GetProgramPath + mock.lockGetProgramPath.RUnlock() + return calls +} + +// GetProgramVersion calls GetProgramVersionFunc. +func (mock *MoqOsManager) GetProgramVersion(program string, versionExtractor VersionExtractor, queryArgs ...string) (string, error) { + if mock.GetProgramVersionFunc == nil { + panic("MoqOsManager.GetProgramVersionFunc: method is nil but OsManager.GetProgramVersion was just called") + } + callInfo := struct { + Program string + VersionExtractor VersionExtractor + QueryArgs []string + }{ + Program: program, + VersionExtractor: versionExtractor, + QueryArgs: queryArgs, + } + mock.lockGetProgramVersion.Lock() + mock.calls.GetProgramVersion = append(mock.calls.GetProgramVersion, callInfo) + mock.lockGetProgramVersion.Unlock() + return mock.GetProgramVersionFunc(program, versionExtractor, queryArgs...) +} + +// GetProgramVersionCalls gets all the calls that were made to GetProgramVersion. +// Check the length with: +// +// len(mockedOsManager.GetProgramVersionCalls()) +func (mock *MoqOsManager) GetProgramVersionCalls() []struct { + Program string + VersionExtractor VersionExtractor + QueryArgs []string +} { + var calls []struct { + Program string + VersionExtractor VersionExtractor + QueryArgs []string + } + mock.lockGetProgramVersion.RLock() + calls = mock.calls.GetProgramVersion + mock.lockGetProgramVersion.RUnlock() + return calls +} + +// Getenv calls GetenvFunc. +func (mock *MoqOsManager) Getenv(key string) string { + if mock.GetenvFunc == nil { + panic("MoqOsManager.GetenvFunc: method is nil but OsManager.Getenv was just called") + } + callInfo := struct { + Key string + }{ + Key: key, + } + mock.lockGetenv.Lock() + mock.calls.Getenv = append(mock.calls.Getenv, callInfo) + mock.lockGetenv.Unlock() + return mock.GetenvFunc(key) +} + +// GetenvCalls gets all the calls that were made to Getenv. +// Check the length with: +// +// len(mockedOsManager.GetenvCalls()) +func (mock *MoqOsManager) GetenvCalls() []struct { + Key string +} { + var calls []struct { + Key string + } + mock.lockGetenv.RLock() + calls = mock.calls.Getenv + mock.lockGetenv.RUnlock() + return calls +} + +// ProgramExists calls ProgramExistsFunc. +func (mock *MoqOsManager) ProgramExists(program string) (bool, error) { + if mock.ProgramExistsFunc == nil { + panic("MoqOsManager.ProgramExistsFunc: method is nil but OsManager.ProgramExists was just called") + } + callInfo := struct { + Program string + }{ + Program: program, + } + mock.lockProgramExists.Lock() + mock.calls.ProgramExists = append(mock.calls.ProgramExists, callInfo) + mock.lockProgramExists.Unlock() + return mock.ProgramExistsFunc(program) +} + +// ProgramExistsCalls gets all the calls that were made to ProgramExists. +// Check the length with: +// +// len(mockedOsManager.ProgramExistsCalls()) +func (mock *MoqOsManager) ProgramExistsCalls() []struct { + Program string +} { + var calls []struct { + Program string + } + mock.lockProgramExists.RLock() + calls = mock.calls.ProgramExists + mock.lockProgramExists.RUnlock() + return calls +} + +// SetOwnership calls SetOwnershipFunc. +func (mock *MoqOsManager) SetOwnership(path string, username string) error { + if mock.SetOwnershipFunc == nil { + panic("MoqOsManager.SetOwnershipFunc: method is nil but OsManager.SetOwnership was just called") + } + callInfo := struct { + Path string + Username string + }{ + Path: path, + Username: username, + } + mock.lockSetOwnership.Lock() + mock.calls.SetOwnership = append(mock.calls.SetOwnership, callInfo) + mock.lockSetOwnership.Unlock() + return mock.SetOwnershipFunc(path, username) +} + +// SetOwnershipCalls gets all the calls that were made to SetOwnership. +// Check the length with: +// +// len(mockedOsManager.SetOwnershipCalls()) +func (mock *MoqOsManager) SetOwnershipCalls() []struct { + Path string + Username string +} { + var calls []struct { + Path string + Username string + } + mock.lockSetOwnership.RLock() + calls = mock.calls.SetOwnership + mock.lockSetOwnership.RUnlock() + return calls +} + +// SetPermissions calls SetPermissionsFunc. +func (mock *MoqOsManager) SetPermissions(path string, mode os.FileMode) error { + if mock.SetPermissionsFunc == nil { + panic("MoqOsManager.SetPermissionsFunc: method is nil but OsManager.SetPermissions was just called") + } + callInfo := struct { + Path string + Mode os.FileMode + }{ + Path: path, + Mode: mode, + } + mock.lockSetPermissions.Lock() + mock.calls.SetPermissions = append(mock.calls.SetPermissions, callInfo) + mock.lockSetPermissions.Unlock() + return mock.SetPermissionsFunc(path, mode) +} + +// SetPermissionsCalls gets all the calls that were made to SetPermissions. +// Check the length with: +// +// len(mockedOsManager.SetPermissionsCalls()) +func (mock *MoqOsManager) SetPermissionsCalls() []struct { + Path string + Mode os.FileMode +} { + var calls []struct { + Path string + Mode os.FileMode + } + mock.lockSetPermissions.RLock() + calls = mock.calls.SetPermissions + mock.lockSetPermissions.RUnlock() + return calls +} + +// UserExists calls UserExistsFunc. +func (mock *MoqOsManager) UserExists(username string) (bool, error) { + if mock.UserExistsFunc == nil { + panic("MoqOsManager.UserExistsFunc: method is nil but OsManager.UserExists was just called") + } + callInfo := struct { + Username string + }{ + Username: username, + } + mock.lockUserExists.Lock() + mock.calls.UserExists = append(mock.calls.UserExists, callInfo) + mock.lockUserExists.Unlock() + return mock.UserExistsFunc(username) +} + +// UserExistsCalls gets all the calls that were made to UserExists. +// Check the length with: +// +// len(mockedOsManager.UserExistsCalls()) +func (mock *MoqOsManager) UserExistsCalls() []struct { + Username string +} { + var calls []struct { + Username string + } + mock.lockUserExists.RLock() + calls = mock.calls.UserExists + mock.lockUserExists.RUnlock() + return calls +} diff --git a/installer/utils/osmanager/ProgramQuery_mock.go b/installer/utils/osmanager/ProgramQuery_mock.go new file mode 100644 index 0000000..572a297 --- /dev/null +++ b/installer/utils/osmanager/ProgramQuery_mock.go @@ -0,0 +1,175 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package osmanager + +import ( + "sync" +) + +// Ensure that MoqProgramQuery does implement ProgramQuery. +// If this is not the case, regenerate this file with mockery. +var _ ProgramQuery = &MoqProgramQuery{} + +// MoqProgramQuery is a mock implementation of ProgramQuery. +// +// func TestSomethingThatUsesProgramQuery(t *testing.T) { +// +// // make and configure a mocked ProgramQuery +// mockedProgramQuery := &MoqProgramQuery{ +// GetProgramPathFunc: func(program string) (string, error) { +// panic("mock out the GetProgramPath method") +// }, +// GetProgramVersionFunc: func(program string, versionExtractor VersionExtractor, queryArgs ...string) (string, error) { +// panic("mock out the GetProgramVersion method") +// }, +// ProgramExistsFunc: func(program string) (bool, error) { +// panic("mock out the ProgramExists method") +// }, +// } +// +// // use mockedProgramQuery in code that requires ProgramQuery +// // and then make assertions. +// +// } +type MoqProgramQuery struct { + // GetProgramPathFunc mocks the GetProgramPath method. + GetProgramPathFunc func(program string) (string, error) + + // GetProgramVersionFunc mocks the GetProgramVersion method. + GetProgramVersionFunc func(program string, versionExtractor VersionExtractor, queryArgs ...string) (string, error) + + // ProgramExistsFunc mocks the ProgramExists method. + ProgramExistsFunc func(program string) (bool, error) + + // calls tracks calls to the methods. + calls struct { + // GetProgramPath holds details about calls to the GetProgramPath method. + GetProgramPath []struct { + // Program is the program argument value. + Program string + } + // GetProgramVersion holds details about calls to the GetProgramVersion method. + GetProgramVersion []struct { + // Program is the program argument value. + Program string + // VersionExtractor is the versionExtractor argument value. + VersionExtractor VersionExtractor + // QueryArgs is the queryArgs argument value. + QueryArgs []string + } + // ProgramExists holds details about calls to the ProgramExists method. + ProgramExists []struct { + // Program is the program argument value. + Program string + } + } + lockGetProgramPath sync.RWMutex + lockGetProgramVersion sync.RWMutex + lockProgramExists sync.RWMutex +} + +// GetProgramPath calls GetProgramPathFunc. +func (mock *MoqProgramQuery) GetProgramPath(program string) (string, error) { + if mock.GetProgramPathFunc == nil { + panic("MoqProgramQuery.GetProgramPathFunc: method is nil but ProgramQuery.GetProgramPath was just called") + } + callInfo := struct { + Program string + }{ + Program: program, + } + mock.lockGetProgramPath.Lock() + mock.calls.GetProgramPath = append(mock.calls.GetProgramPath, callInfo) + mock.lockGetProgramPath.Unlock() + return mock.GetProgramPathFunc(program) +} + +// GetProgramPathCalls gets all the calls that were made to GetProgramPath. +// Check the length with: +// +// len(mockedProgramQuery.GetProgramPathCalls()) +func (mock *MoqProgramQuery) GetProgramPathCalls() []struct { + Program string +} { + var calls []struct { + Program string + } + mock.lockGetProgramPath.RLock() + calls = mock.calls.GetProgramPath + mock.lockGetProgramPath.RUnlock() + return calls +} + +// GetProgramVersion calls GetProgramVersionFunc. +func (mock *MoqProgramQuery) GetProgramVersion(program string, versionExtractor VersionExtractor, queryArgs ...string) (string, error) { + if mock.GetProgramVersionFunc == nil { + panic("MoqProgramQuery.GetProgramVersionFunc: method is nil but ProgramQuery.GetProgramVersion was just called") + } + callInfo := struct { + Program string + VersionExtractor VersionExtractor + QueryArgs []string + }{ + Program: program, + VersionExtractor: versionExtractor, + QueryArgs: queryArgs, + } + mock.lockGetProgramVersion.Lock() + mock.calls.GetProgramVersion = append(mock.calls.GetProgramVersion, callInfo) + mock.lockGetProgramVersion.Unlock() + return mock.GetProgramVersionFunc(program, versionExtractor, queryArgs...) +} + +// GetProgramVersionCalls gets all the calls that were made to GetProgramVersion. +// Check the length with: +// +// len(mockedProgramQuery.GetProgramVersionCalls()) +func (mock *MoqProgramQuery) GetProgramVersionCalls() []struct { + Program string + VersionExtractor VersionExtractor + QueryArgs []string +} { + var calls []struct { + Program string + VersionExtractor VersionExtractor + QueryArgs []string + } + mock.lockGetProgramVersion.RLock() + calls = mock.calls.GetProgramVersion + mock.lockGetProgramVersion.RUnlock() + return calls +} + +// ProgramExists calls ProgramExistsFunc. +func (mock *MoqProgramQuery) ProgramExists(program string) (bool, error) { + if mock.ProgramExistsFunc == nil { + panic("MoqProgramQuery.ProgramExistsFunc: method is nil but ProgramQuery.ProgramExists was just called") + } + callInfo := struct { + Program string + }{ + Program: program, + } + mock.lockProgramExists.Lock() + mock.calls.ProgramExists = append(mock.calls.ProgramExists, callInfo) + mock.lockProgramExists.Unlock() + return mock.ProgramExistsFunc(program) +} + +// ProgramExistsCalls gets all the calls that were made to ProgramExists. +// Check the length with: +// +// len(mockedProgramQuery.ProgramExistsCalls()) +func (mock *MoqProgramQuery) ProgramExistsCalls() []struct { + Program string +} { + var calls []struct { + Program string + } + mock.lockProgramExists.RLock() + calls = mock.calls.ProgramExists + mock.lockProgramExists.RUnlock() + return calls +} diff --git a/installer/utils/osmanager/SudoManager_mock.go b/installer/utils/osmanager/SudoManager_mock.go new file mode 100644 index 0000000..3674453 --- /dev/null +++ b/installer/utils/osmanager/SudoManager_mock.go @@ -0,0 +1,75 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package osmanager + +import ( + "sync" +) + +// Ensure that MoqSudoManager does implement SudoManager. +// If this is not the case, regenerate this file with mockery. +var _ SudoManager = &MoqSudoManager{} + +// MoqSudoManager is a mock implementation of SudoManager. +// +// func TestSomethingThatUsesSudoManager(t *testing.T) { +// +// // make and configure a mocked SudoManager +// mockedSudoManager := &MoqSudoManager{ +// AddSudoAccessFunc: func(username string) error { +// panic("mock out the AddSudoAccess method") +// }, +// } +// +// // use mockedSudoManager in code that requires SudoManager +// // and then make assertions. +// +// } +type MoqSudoManager struct { + // AddSudoAccessFunc mocks the AddSudoAccess method. + AddSudoAccessFunc func(username string) error + + // calls tracks calls to the methods. + calls struct { + // AddSudoAccess holds details about calls to the AddSudoAccess method. + AddSudoAccess []struct { + // Username is the username argument value. + Username string + } + } + lockAddSudoAccess sync.RWMutex +} + +// AddSudoAccess calls AddSudoAccessFunc. +func (mock *MoqSudoManager) AddSudoAccess(username string) error { + if mock.AddSudoAccessFunc == nil { + panic("MoqSudoManager.AddSudoAccessFunc: method is nil but SudoManager.AddSudoAccess was just called") + } + callInfo := struct { + Username string + }{ + Username: username, + } + mock.lockAddSudoAccess.Lock() + mock.calls.AddSudoAccess = append(mock.calls.AddSudoAccess, callInfo) + mock.lockAddSudoAccess.Unlock() + return mock.AddSudoAccessFunc(username) +} + +// AddSudoAccessCalls gets all the calls that were made to AddSudoAccess. +// Check the length with: +// +// len(mockedSudoManager.AddSudoAccessCalls()) +func (mock *MoqSudoManager) AddSudoAccessCalls() []struct { + Username string +} { + var calls []struct { + Username string + } + mock.lockAddSudoAccess.RLock() + calls = mock.calls.AddSudoAccess + mock.lockAddSudoAccess.RUnlock() + return calls +} diff --git a/installer/utils/osmanager/UserManager_mock.go b/installer/utils/osmanager/UserManager_mock.go new file mode 100644 index 0000000..5c42d9b --- /dev/null +++ b/installer/utils/osmanager/UserManager_mock.go @@ -0,0 +1,280 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package osmanager + +import ( + "sync" +) + +// Ensure that MoqUserManager does implement UserManager. +// If this is not the case, regenerate this file with mockery. +var _ UserManager = &MoqUserManager{} + +// MoqUserManager is a mock implementation of UserManager. +// +// func TestSomethingThatUsesUserManager(t *testing.T) { +// +// // make and configure a mocked UserManager +// mockedUserManager := &MoqUserManager{ +// AddUserFunc: func(username string) error { +// panic("mock out the AddUser method") +// }, +// AddUserToGroupFunc: func(username string, group string) error { +// panic("mock out the AddUserToGroup method") +// }, +// GetChezmoiConfigHomeFunc: func() (string, error) { +// panic("mock out the GetChezmoiConfigHome method") +// }, +// GetConfigDirFunc: func() (string, error) { +// panic("mock out the GetConfigDir method") +// }, +// GetHomeDirFunc: func() (string, error) { +// panic("mock out the GetHomeDir method") +// }, +// UserExistsFunc: func(username string) (bool, error) { +// panic("mock out the UserExists method") +// }, +// } +// +// // use mockedUserManager in code that requires UserManager +// // and then make assertions. +// +// } +type MoqUserManager struct { + // AddUserFunc mocks the AddUser method. + AddUserFunc func(username string) error + + // AddUserToGroupFunc mocks the AddUserToGroup method. + AddUserToGroupFunc func(username string, group string) error + + // GetChezmoiConfigHomeFunc mocks the GetChezmoiConfigHome method. + GetChezmoiConfigHomeFunc func() (string, error) + + // GetConfigDirFunc mocks the GetConfigDir method. + GetConfigDirFunc func() (string, error) + + // GetHomeDirFunc mocks the GetHomeDir method. + GetHomeDirFunc func() (string, error) + + // UserExistsFunc mocks the UserExists method. + UserExistsFunc func(username string) (bool, error) + + // calls tracks calls to the methods. + calls struct { + // AddUser holds details about calls to the AddUser method. + AddUser []struct { + // Username is the username argument value. + Username string + } + // AddUserToGroup holds details about calls to the AddUserToGroup method. + AddUserToGroup []struct { + // Username is the username argument value. + Username string + // Group is the group argument value. + Group string + } + // GetChezmoiConfigHome holds details about calls to the GetChezmoiConfigHome method. + GetChezmoiConfigHome []struct { + } + // GetConfigDir holds details about calls to the GetConfigDir method. + GetConfigDir []struct { + } + // GetHomeDir holds details about calls to the GetHomeDir method. + GetHomeDir []struct { + } + // UserExists holds details about calls to the UserExists method. + UserExists []struct { + // Username is the username argument value. + Username string + } + } + lockAddUser sync.RWMutex + lockAddUserToGroup sync.RWMutex + lockGetChezmoiConfigHome sync.RWMutex + lockGetConfigDir sync.RWMutex + lockGetHomeDir sync.RWMutex + lockUserExists sync.RWMutex +} + +// AddUser calls AddUserFunc. +func (mock *MoqUserManager) AddUser(username string) error { + if mock.AddUserFunc == nil { + panic("MoqUserManager.AddUserFunc: method is nil but UserManager.AddUser was just called") + } + callInfo := struct { + Username string + }{ + Username: username, + } + mock.lockAddUser.Lock() + mock.calls.AddUser = append(mock.calls.AddUser, callInfo) + mock.lockAddUser.Unlock() + return mock.AddUserFunc(username) +} + +// AddUserCalls gets all the calls that were made to AddUser. +// Check the length with: +// +// len(mockedUserManager.AddUserCalls()) +func (mock *MoqUserManager) AddUserCalls() []struct { + Username string +} { + var calls []struct { + Username string + } + mock.lockAddUser.RLock() + calls = mock.calls.AddUser + mock.lockAddUser.RUnlock() + return calls +} + +// AddUserToGroup calls AddUserToGroupFunc. +func (mock *MoqUserManager) AddUserToGroup(username string, group string) error { + if mock.AddUserToGroupFunc == nil { + panic("MoqUserManager.AddUserToGroupFunc: method is nil but UserManager.AddUserToGroup was just called") + } + callInfo := struct { + Username string + Group string + }{ + Username: username, + Group: group, + } + mock.lockAddUserToGroup.Lock() + mock.calls.AddUserToGroup = append(mock.calls.AddUserToGroup, callInfo) + mock.lockAddUserToGroup.Unlock() + return mock.AddUserToGroupFunc(username, group) +} + +// AddUserToGroupCalls gets all the calls that were made to AddUserToGroup. +// Check the length with: +// +// len(mockedUserManager.AddUserToGroupCalls()) +func (mock *MoqUserManager) AddUserToGroupCalls() []struct { + Username string + Group string +} { + var calls []struct { + Username string + Group string + } + mock.lockAddUserToGroup.RLock() + calls = mock.calls.AddUserToGroup + mock.lockAddUserToGroup.RUnlock() + return calls +} + +// GetChezmoiConfigHome calls GetChezmoiConfigHomeFunc. +func (mock *MoqUserManager) GetChezmoiConfigHome() (string, error) { + if mock.GetChezmoiConfigHomeFunc == nil { + panic("MoqUserManager.GetChezmoiConfigHomeFunc: method is nil but UserManager.GetChezmoiConfigHome was just called") + } + callInfo := struct { + }{} + mock.lockGetChezmoiConfigHome.Lock() + mock.calls.GetChezmoiConfigHome = append(mock.calls.GetChezmoiConfigHome, callInfo) + mock.lockGetChezmoiConfigHome.Unlock() + return mock.GetChezmoiConfigHomeFunc() +} + +// GetChezmoiConfigHomeCalls gets all the calls that were made to GetChezmoiConfigHome. +// Check the length with: +// +// len(mockedUserManager.GetChezmoiConfigHomeCalls()) +func (mock *MoqUserManager) GetChezmoiConfigHomeCalls() []struct { +} { + var calls []struct { + } + mock.lockGetChezmoiConfigHome.RLock() + calls = mock.calls.GetChezmoiConfigHome + mock.lockGetChezmoiConfigHome.RUnlock() + return calls +} + +// GetConfigDir calls GetConfigDirFunc. +func (mock *MoqUserManager) GetConfigDir() (string, error) { + if mock.GetConfigDirFunc == nil { + panic("MoqUserManager.GetConfigDirFunc: method is nil but UserManager.GetConfigDir was just called") + } + callInfo := struct { + }{} + mock.lockGetConfigDir.Lock() + mock.calls.GetConfigDir = append(mock.calls.GetConfigDir, callInfo) + mock.lockGetConfigDir.Unlock() + return mock.GetConfigDirFunc() +} + +// GetConfigDirCalls gets all the calls that were made to GetConfigDir. +// Check the length with: +// +// len(mockedUserManager.GetConfigDirCalls()) +func (mock *MoqUserManager) GetConfigDirCalls() []struct { +} { + var calls []struct { + } + mock.lockGetConfigDir.RLock() + calls = mock.calls.GetConfigDir + mock.lockGetConfigDir.RUnlock() + return calls +} + +// GetHomeDir calls GetHomeDirFunc. +func (mock *MoqUserManager) GetHomeDir() (string, error) { + if mock.GetHomeDirFunc == nil { + panic("MoqUserManager.GetHomeDirFunc: method is nil but UserManager.GetHomeDir was just called") + } + callInfo := struct { + }{} + mock.lockGetHomeDir.Lock() + mock.calls.GetHomeDir = append(mock.calls.GetHomeDir, callInfo) + mock.lockGetHomeDir.Unlock() + return mock.GetHomeDirFunc() +} + +// GetHomeDirCalls gets all the calls that were made to GetHomeDir. +// Check the length with: +// +// len(mockedUserManager.GetHomeDirCalls()) +func (mock *MoqUserManager) GetHomeDirCalls() []struct { +} { + var calls []struct { + } + mock.lockGetHomeDir.RLock() + calls = mock.calls.GetHomeDir + mock.lockGetHomeDir.RUnlock() + return calls +} + +// UserExists calls UserExistsFunc. +func (mock *MoqUserManager) UserExists(username string) (bool, error) { + if mock.UserExistsFunc == nil { + panic("MoqUserManager.UserExistsFunc: method is nil but UserManager.UserExists was just called") + } + callInfo := struct { + Username string + }{ + Username: username, + } + mock.lockUserExists.Lock() + mock.calls.UserExists = append(mock.calls.UserExists, callInfo) + mock.lockUserExists.Unlock() + return mock.UserExistsFunc(username) +} + +// UserExistsCalls gets all the calls that were made to UserExists. +// Check the length with: +// +// len(mockedUserManager.UserExistsCalls()) +func (mock *MoqUserManager) UserExistsCalls() []struct { + Username string +} { + var calls []struct { + Username string + } + mock.lockUserExists.RLock() + calls = mock.calls.UserExists + mock.lockUserExists.RUnlock() + return calls +} diff --git a/installer/utils/osmanager/osmanager.go b/installer/utils/osmanager/osmanager.go new file mode 100644 index 0000000..e41d780 --- /dev/null +++ b/installer/utils/osmanager/osmanager.go @@ -0,0 +1,286 @@ +package osmanager + +import ( + "errors" + "fmt" + "os" + "os/exec" + "os/user" + "strconv" + "syscall" + + "path/filepath" + + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/logger" +) + +// UserManager defines operations for managing system users. +type UserManager interface { + // AddUser creates a new user in the system. + AddUser(username string) error + + // AddUserToGroup adds a user to a specified group. + AddUserToGroup(username, group string) error + + // UserExists checks if a user exists in the system. + UserExists(username string) (bool, error) + + // GetHomeDirectory returns the home directory of the current user. + GetHomeDir() (string, error) + + // GetConfigDir returns the configuration directory of the current user. + GetConfigDir() (string, error) + + // GetChezmoiConfigHome returns the configuration directory where chezmoi actually looks for its config. + // This is always ~/.config regardless of XDG specification on different platforms. + GetChezmoiConfigHome() (string, error) +} + +// SudoManager defines operations for managing sudo permissions. +type SudoManager interface { + // AddSudoAccess grants password-less sudo access to a user. + AddSudoAccess(username string) error +} + +// FilePermissionManager defines operations for managing filesystem permissions. +type FilePermissionManager interface { + // SetOwnership sets ownership of a directory to a user. + SetOwnership(path, username string) error + + // SetPermissions sets permissions for a file or directory. + SetPermissions(path string, mode os.FileMode) error + + // GetFileOwner returns the username of the file owner. + GetFileOwner(path string) (string, error) +} + +type VersionExtractor func(string) (string, error) + +type ProgramQuery interface { + // GetProgramPath retrieves the full path of a program if it's available in one of the system's PATH directories. + // If the program is not found, it returns an error. + GetProgramPath(program string) (string, error) + + // ProgramExists checks if a program exists in the system's PATH directories. + // It returns true if the program is found, false if not, and an error if there was an issue checking. + ProgramExists(program string) (bool, error) + + // GetProgramVersion retrieves the version of a program by executing it with the provided query arguments. + GetProgramVersion(program string, versionExtractor VersionExtractor, queryArgs ...string) (string, error) +} + +// EnvironmentManager defines operations for managing environment variables. +type EnvironmentManager interface { + // Getenv retrieves the value of the environment variable named by the key. + Getenv(key string) string +} + +// OsManager combines all system operation interfaces. +type OsManager interface { + UserManager + SudoManager + FilePermissionManager + ProgramQuery + EnvironmentManager +} + +// UnixOsManager implements OsManager for Unix-like systems. +type UnixOsManager struct { + logger logger.Logger + commander utils.Commander + isRoot bool +} + +var _ OsManager = (*UnixOsManager)(nil) + +// NewUnixOsManager creates a new UnixOsManager. +func NewUnixOsManager(logger logger.Logger, commander utils.Commander, isRoot bool) *UnixOsManager { + return &UnixOsManager{ + logger: logger, + commander: commander, + isRoot: isRoot, + } +} + +func (u *UnixOsManager) UserExists(username string) (bool, error) { + _, err := user.Lookup(username) + if err != nil { + return false, nil + } + return true, nil +} + +func (u *UnixOsManager) AddUser(username string) error { + u.logger.Debug("User '%s' does not exist, creating...", username) + + // Try useradd, fallback to adduser. + useraddCmd := []string{"useradd", "-m", "-s", "/bin/bash", username} + if !u.isRoot { + useraddCmd = append([]string{"sudo"}, useraddCmd...) + } + + _, err := u.commander.RunCommand(useraddCmd[0], useraddCmd[1:]) + if err != nil { + // Try adduser as fallback. + adduserCmd := []string{"adduser", "--disabled-password", "--gecos", "''", username} + if !u.isRoot { + adduserCmd = append([]string{"sudo"}, adduserCmd...) + } + + _, err = u.commander.RunCommand(adduserCmd[0], adduserCmd[1:]) + if err != nil { + return fmt.Errorf("failed to create user '%s' with useradd/adduser: %w", username, err) + } + } + + return nil +} + +func (u *UnixOsManager) AddUserToGroup(username, group string) error { + u.logger.Debug("Adding '%s' to %s group", username, group) + usermodCmd := []string{"usermod", "-aG", group, username} + if !u.isRoot { + usermodCmd = append([]string{"sudo"}, usermodCmd...) + } + + _, err := u.commander.RunCommand(usermodCmd[0], usermodCmd[1:]) + // Often we don't care if the user is already in the group. + if err != nil { + u.logger.Debug("Note: User might already be in the %s group", group) + } + + return nil +} + +func (u *UnixOsManager) GetHomeDir() (string, error) { + return os.UserHomeDir() +} + +func (u *UnixOsManager) GetConfigDir() (string, error) { + return os.UserConfigDir() +} + +func (u *UnixOsManager) GetChezmoiConfigHome() (string, error) { + homeDir, err := u.GetHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(homeDir, ".config"), nil +} + +func (u *UnixOsManager) AddSudoAccess(username string) error { + sudoersFile := fmt.Sprintf("/etc/sudoers.d/%s", username) + sudoersLine := fmt.Sprintf("%s ALL=(ALL) NOPASSWD:ALL", username) + + var sudoPrefix string + if !u.isRoot { + sudoPrefix = "sudo " + } + + // Use shell to echo and tee the line into the sudoers file. + shCmd := []string{"sh", "-c", fmt.Sprintf("echo '%s' | %stee %s", sudoersLine, sudoPrefix, sudoersFile)} + _, err := u.commander.RunCommand(shCmd[0], shCmd[1:]) + if err != nil { + return fmt.Errorf("failed to add passwordless sudo for '%s': %w", username, err) + } + + return nil +} + +func (u *UnixOsManager) SetOwnership(path, username string) error { + u.logger.Debug("Setting ownership of %s to %s", path, username) + chownCmd := []string{"chown", "-R", fmt.Sprintf("%s:%s", username, username), path} + if !u.isRoot { + chownCmd = append([]string{"sudo"}, chownCmd...) + } + + _, err := u.commander.RunCommand(chownCmd[0], chownCmd[1:]) + if err != nil { + return fmt.Errorf("failed to chown %s: %w", path, err) + } + + return nil +} + +func (u *UnixOsManager) SetPermissions(path string, mode os.FileMode) error { + u.logger.Debug("Setting permissions of %s to %o", path, mode) + chmodCmd := []string{"chmod", fmt.Sprintf("%o", mode), path} + if !u.isRoot { + chmodCmd = append([]string{"sudo"}, chmodCmd...) + } + + _, err := u.commander.RunCommand(chmodCmd[0], chmodCmd[1:]) + if err != nil { + return fmt.Errorf("failed to chmod %s: %w", path, err) + } + + return nil +} + +func (u *UnixOsManager) GetFileOwner(path string) (string, error) { + fileInfo, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("failed to get file info for %s: %w", path, err) + } + + stat, ok := fileInfo.Sys().(*syscall.Stat_t) + if !ok { + return "", fmt.Errorf("failed to get file info") + } + + owner, err := user.LookupId(strconv.FormatUint(uint64(stat.Uid), 10)) + if err != nil { + return "", fmt.Errorf("failed to lookup owner for %s: %w", path, err) + } + + return owner.Username, nil +} + +func (u *UnixOsManager) GetProgramPath(program string) (string, error) { + return exec.LookPath(program) +} + +func (u *UnixOsManager) ProgramExists(program string) (bool, error) { + _, err := u.GetProgramPath(program) + if err != nil { + if errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist) { + return false, nil // Program not found. + } + return false, fmt.Errorf("error checking program existence: %w", err) + } + return true, nil // Program found. +} + +func (u *UnixOsManager) GetProgramVersion( + program string, + versionExtractor VersionExtractor, + queryArgs ...string, +) (string, error) { + args := []string{"--version"} // Default argument for version query. + if len(queryArgs) > 0 { + args = queryArgs + } + + cmd := exec.Command(program, args...) + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get version for %s: %w", program, err) + } + + version, err := versionExtractor(string(output)) + if err != nil { + return "", fmt.Errorf("failed to extract version from output: %w", err) + } + + return version, nil +} + +func (u *UnixOsManager) Getenv(key string) string { + return os.Getenv(key) +} + +// IsRoot returns true if the current user is root. +func IsRoot() bool { + return os.Geteuid() == 0 +} diff --git a/installer/utils/privilege/Escalator_mock.go b/installer/utils/privilege/Escalator_mock.go new file mode 100644 index 0000000..569c23a --- /dev/null +++ b/installer/utils/privilege/Escalator_mock.go @@ -0,0 +1,155 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: matryer + +package privilege + +import ( + "sync" +) + +// Ensure that MoqEscalator does implement Escalator. +// If this is not the case, regenerate this file with mockery. +var _ Escalator = &MoqEscalator{} + +// MoqEscalator is a mock implementation of Escalator. +// +// func TestSomethingThatUsesEscalator(t *testing.T) { +// +// // make and configure a mocked Escalator +// mockedEscalator := &MoqEscalator{ +// EscalateCommandFunc: func(baseCmd string, baseArgs []string) (EscalationResult, error) { +// panic("mock out the EscalateCommand method") +// }, +// GetAvailableEscalationMethodsFunc: func() ([]EscalationMethod, error) { +// panic("mock out the GetAvailableEscalationMethods method") +// }, +// IsRunningAsRootFunc: func() (bool, error) { +// panic("mock out the IsRunningAsRoot method") +// }, +// } +// +// // use mockedEscalator in code that requires Escalator +// // and then make assertions. +// +// } +type MoqEscalator struct { + // EscalateCommandFunc mocks the EscalateCommand method. + EscalateCommandFunc func(baseCmd string, baseArgs []string) (EscalationResult, error) + + // GetAvailableEscalationMethodsFunc mocks the GetAvailableEscalationMethods method. + GetAvailableEscalationMethodsFunc func() ([]EscalationMethod, error) + + // IsRunningAsRootFunc mocks the IsRunningAsRoot method. + IsRunningAsRootFunc func() (bool, error) + + // calls tracks calls to the methods. + calls struct { + // EscalateCommand holds details about calls to the EscalateCommand method. + EscalateCommand []struct { + // BaseCmd is the baseCmd argument value. + BaseCmd string + // BaseArgs is the baseArgs argument value. + BaseArgs []string + } + // GetAvailableEscalationMethods holds details about calls to the GetAvailableEscalationMethods method. + GetAvailableEscalationMethods []struct { + } + // IsRunningAsRoot holds details about calls to the IsRunningAsRoot method. + IsRunningAsRoot []struct { + } + } + lockEscalateCommand sync.RWMutex + lockGetAvailableEscalationMethods sync.RWMutex + lockIsRunningAsRoot sync.RWMutex +} + +// EscalateCommand calls EscalateCommandFunc. +func (mock *MoqEscalator) EscalateCommand(baseCmd string, baseArgs []string) (EscalationResult, error) { + if mock.EscalateCommandFunc == nil { + panic("MoqEscalator.EscalateCommandFunc: method is nil but Escalator.EscalateCommand was just called") + } + callInfo := struct { + BaseCmd string + BaseArgs []string + }{ + BaseCmd: baseCmd, + BaseArgs: baseArgs, + } + mock.lockEscalateCommand.Lock() + mock.calls.EscalateCommand = append(mock.calls.EscalateCommand, callInfo) + mock.lockEscalateCommand.Unlock() + return mock.EscalateCommandFunc(baseCmd, baseArgs) +} + +// EscalateCommandCalls gets all the calls that were made to EscalateCommand. +// Check the length with: +// +// len(mockedEscalator.EscalateCommandCalls()) +func (mock *MoqEscalator) EscalateCommandCalls() []struct { + BaseCmd string + BaseArgs []string +} { + var calls []struct { + BaseCmd string + BaseArgs []string + } + mock.lockEscalateCommand.RLock() + calls = mock.calls.EscalateCommand + mock.lockEscalateCommand.RUnlock() + return calls +} + +// GetAvailableEscalationMethods calls GetAvailableEscalationMethodsFunc. +func (mock *MoqEscalator) GetAvailableEscalationMethods() ([]EscalationMethod, error) { + if mock.GetAvailableEscalationMethodsFunc == nil { + panic("MoqEscalator.GetAvailableEscalationMethodsFunc: method is nil but Escalator.GetAvailableEscalationMethods was just called") + } + callInfo := struct { + }{} + mock.lockGetAvailableEscalationMethods.Lock() + mock.calls.GetAvailableEscalationMethods = append(mock.calls.GetAvailableEscalationMethods, callInfo) + mock.lockGetAvailableEscalationMethods.Unlock() + return mock.GetAvailableEscalationMethodsFunc() +} + +// GetAvailableEscalationMethodsCalls gets all the calls that were made to GetAvailableEscalationMethods. +// Check the length with: +// +// len(mockedEscalator.GetAvailableEscalationMethodsCalls()) +func (mock *MoqEscalator) GetAvailableEscalationMethodsCalls() []struct { +} { + var calls []struct { + } + mock.lockGetAvailableEscalationMethods.RLock() + calls = mock.calls.GetAvailableEscalationMethods + mock.lockGetAvailableEscalationMethods.RUnlock() + return calls +} + +// IsRunningAsRoot calls IsRunningAsRootFunc. +func (mock *MoqEscalator) IsRunningAsRoot() (bool, error) { + if mock.IsRunningAsRootFunc == nil { + panic("MoqEscalator.IsRunningAsRootFunc: method is nil but Escalator.IsRunningAsRoot was just called") + } + callInfo := struct { + }{} + mock.lockIsRunningAsRoot.Lock() + mock.calls.IsRunningAsRoot = append(mock.calls.IsRunningAsRoot, callInfo) + mock.lockIsRunningAsRoot.Unlock() + return mock.IsRunningAsRootFunc() +} + +// IsRunningAsRootCalls gets all the calls that were made to IsRunningAsRoot. +// Check the length with: +// +// len(mockedEscalator.IsRunningAsRootCalls()) +func (mock *MoqEscalator) IsRunningAsRootCalls() []struct { +} { + var calls []struct { + } + mock.lockIsRunningAsRoot.RLock() + calls = mock.calls.IsRunningAsRoot + mock.lockIsRunningAsRoot.RUnlock() + return calls +} diff --git a/installer/utils/privilege/privilege.go b/installer/utils/privilege/privilege.go new file mode 100644 index 0000000..66f88e6 --- /dev/null +++ b/installer/utils/privilege/privilege.go @@ -0,0 +1,233 @@ +package privilege + +import ( + "fmt" + "strings" + + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" +) + +// EscalationMethod represents the type of privilege escalation being used. +type EscalationMethod string + +const ( + // EscalationNone indicates no privilege escalation is needed (running as root). + EscalationNone EscalationMethod = "none" + // EscalationSudo indicates using sudo for privilege escalation. + EscalationSudo EscalationMethod = "sudo" + // EscalationDoas indicates using doas for privilege escalation. + EscalationDoas EscalationMethod = "doas" + // EscalationDirect indicates falling back to direct execution (may fail). + EscalationDirect EscalationMethod = "direct" +) + +// EscalationResult contains information about how a command should be escalated. +type EscalationResult struct { + // Method indicates which escalation method was chosen. + Method EscalationMethod + // Command is the final command to execute. + Command string + // Args are the final arguments to pass to the command. + Args []string + // NeedsEscalation indicates whether privilege escalation is being used. + NeedsEscalation bool +} + +// Escalator provides smart privilege escalation for system commands. +type Escalator interface { + // EscalateCommand takes a base command and arguments and returns the appropriate + // escalated command based on the current system state and available tools. + EscalateCommand(baseCmd string, baseArgs []string) (EscalationResult, error) + + // IsRunningAsRoot checks if the current process has root privileges. + IsRunningAsRoot() (bool, error) + + // GetAvailableEscalationMethods returns the escalation methods available on this system. + GetAvailableEscalationMethods() ([]EscalationMethod, error) +} + +// DefaultEscalator implements smart privilege escalation using various system tools. +type DefaultEscalator struct { + logger logger.Logger + commander utils.Commander + programQuery osmanager.ProgramQuery +} + +var _ Escalator = (*DefaultEscalator)(nil) + +// NewDefaultEscalator creates a new DefaultEscalator instance. +func NewDefaultEscalator(logger logger.Logger, commander utils.Commander, programQuery osmanager.ProgramQuery) *DefaultEscalator { + return &DefaultEscalator{ + logger: logger, + commander: commander, + programQuery: programQuery, + } +} + +// EscalateCommand implements smart privilege escalation strategy. +// It determines the best method for privilege escalation based on: +// +// 1. Root containers/users: Run commands directly (no privilege escalation needed) +// 2. Non-root with sudo: Use sudo for privilege escalation +// 3. Non-root with doas: Use doas as alternative to sudo (OpenBSD, some Linux) +// 4. Bare containers: Fall back to direct execution with warning +// +// This approach handles common scenarios like: +// - Docker containers running as root (no sudo needed) +// - Minimal Ubuntu containers without sudo package +// - Systems with alternative privilege escalation tools +// - Regular user systems with proper sudo setup +func (e *DefaultEscalator) EscalateCommand(baseCmd string, baseArgs []string) (EscalationResult, error) { + e.logger.Debug("Escalating command to run with privileges: %s %s", baseCmd, strings.Join(baseArgs, " ")) + + if baseCmd == "" { + return EscalationResult{}, fmt.Errorf("base command cannot be empty") + } + + // Check if we're already running as root + isRoot, err := e.IsRunningAsRoot() + if err != nil { + e.logger.Warning("Failed to check if running as root, assuming non-root: %v", err) + isRoot = false + } + + if isRoot { + e.logger.Trace("Already running as root") + return EscalationResult{ + Method: EscalationNone, + Command: baseCmd, + Args: baseArgs, + NeedsEscalation: false, + }, nil + } + + e.logger.Trace("Not running as root") + + // Check if sudo is available + if e.isSudoAvailable() { + e.logger.Trace("Sudo is available") + args := make([]string, 0, len(baseArgs)+1) + args = append(args, baseCmd) + args = append(args, baseArgs...) + return EscalationResult{ + Method: EscalationSudo, + Command: "sudo", + Args: args, + NeedsEscalation: true, + }, nil + } + + e.logger.Trace("Sudo is not available") + + // Check if doas is available (alternative to sudo on some systems) + if e.isDoasAvailable() { + e.logger.Trace("Doas is available") + args := make([]string, 0, len(baseArgs)+1) + args = append(args, baseCmd) + args = append(args, baseArgs...) + return EscalationResult{ + Method: EscalationDoas, + Command: "doas", + Args: args, + NeedsEscalation: true, + }, nil + } + + // Fall back to direct execution (might fail, but let's try) + e.logger.Warning("Running as non-root without sudo/doas - command may fail due to insufficient privileges") + return EscalationResult{ + Method: EscalationDirect, + Command: baseCmd, + Args: baseArgs, + NeedsEscalation: false, + }, nil +} + +// IsRunningAsRoot checks if the current process is running as root. +// Uses `id -u` command to get the user ID, where 0 indicates root. +// This is more reliable than checking environment variables or using +// os.Geteuid() which might not work correctly in all container environments. +func (e *DefaultEscalator) IsRunningAsRoot() (bool, error) { + e.logger.Trace("Checking if running as root") + + exists, err := e.programQuery.ProgramExists("id") + if err != nil { + return false, fmt.Errorf("failed to check if 'id' command exists: %w", err) + } + if !exists { + return false, fmt.Errorf("'id' command not available on this system") + } + + result, err := e.commander.RunCommand("id", []string{"-u"}, utils.WithCaptureOutput()) + if err != nil { + return false, fmt.Errorf("failed to execute 'id -u': %w", err) + } + + uid := strings.TrimSpace(string(result.Stdout)) + return uid == "0", nil +} + +// GetAvailableEscalationMethods returns all escalation methods available on this system. +func (e *DefaultEscalator) GetAvailableEscalationMethods() ([]EscalationMethod, error) { + methods := []EscalationMethod{} + + // Check if running as root + isRoot, err := e.IsRunningAsRoot() + if err != nil { + e.logger.Warning("Failed to check root status: %v", err) + } else if isRoot { + methods = append(methods, EscalationNone) + return methods, nil // If root, no other methods needed + } + + // Check available privilege escalation tools + if e.isSudoAvailable() { + methods = append(methods, EscalationSudo) + } + + if e.isDoasAvailable() { + methods = append(methods, EscalationDoas) + } + + // Direct execution is always available as fallback + methods = append(methods, EscalationDirect) + + return methods, nil +} + +// isSudoAvailable checks if sudo is available and usable. +// First checks if the sudo command exists, then tests if it can be used +// without a password prompt using the -n (non-interactive) flag. +// This handles cases where sudo exists but requires password authentication. +func (e *DefaultEscalator) isSudoAvailable() bool { + e.logger.Trace("Checking if sudo is available") + + // First check if sudo command exists + exists, err := e.programQuery.ProgramExists("sudo") + if err != nil || !exists { + return false + } + + // Test if sudo can be used (with -n flag for non-interactive) + _, err = e.commander.RunCommand("sudo", []string{"-n", "true"}, utils.WithCaptureOutput()) + return err == nil +} + +// isDoasAvailable checks if doas is available and usable. +// doas is an alternative to sudo commonly found on OpenBSD and some Linux systems. +// Like sudo, we test both existence and usability with non-interactive flag. +func (e *DefaultEscalator) isDoasAvailable() bool { + e.logger.Trace("Checking if doas is available") + + // First check if doas command exists + exists, err := e.programQuery.ProgramExists("doas") + if err != nil || !exists { + return false + } + + // Test if doas can be used (with -n flag for non-interactive) + _, err = e.commander.RunCommand("doas", []string{"-n", "true"}, utils.WithCaptureOutput()) + return err == nil +} diff --git a/installer/utils/privilege/privilege_test.go b/installer/utils/privilege/privilege_test.go new file mode 100644 index 0000000..83aace9 --- /dev/null +++ b/installer/utils/privilege/privilege_test.go @@ -0,0 +1,385 @@ +package privilege_test + +import ( + "fmt" + "testing" + + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" + "github.com/MrPointer/dotfiles/installer/utils/privilege" + "github.com/stretchr/testify/require" +) + +func Test_DefaultEscalator_ImplementsEscalatorInterface(t *testing.T) { + mockCommander := &utils.MoqCommander{} + mockProgramQuery := &osmanager.MoqProgramQuery{} + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, mockCommander, mockProgramQuery) + + require.Implements(t, (*privilege.Escalator)(nil), escalator) +} + +func Test_NewDefaultEscalator_ReturnsValidInstance(t *testing.T) { + mockCommander := &utils.MoqCommander{} + mockProgramQuery := &osmanager.MoqProgramQuery{} + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, mockCommander, mockProgramQuery) + + require.NotNil(t, escalator) +} + +func Test_EscalateCommand_RunningAsRoot_ReturnsDirectCommand(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "id" && len(args) == 1 && args[0] == "-u" { + return &utils.Result{Stdout: []byte("0")}, nil + } + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return program == "id", nil + }, + } + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, mockCommander, mockProgramQuery) + + result, err := escalator.EscalateCommand("apt", []string{"install", "git"}) + + require.NoError(t, err) + require.Equal(t, privilege.EscalationNone, result.Method) + require.Equal(t, "apt", result.Command) + require.Equal(t, []string{"install", "git"}, result.Args) + require.False(t, result.NeedsEscalation) +} + +func Test_EscalateCommand_NonRootWithSudo_ReturnsSudoCommand(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "id" && len(args) == 1 && args[0] == "-u" { + return &utils.Result{Stdout: []byte("1000")}, nil + } + if name == "sudo" && len(args) == 2 && args[0] == "-n" && args[1] == "true" { + return &utils.Result{}, nil + } + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return program == "id" || program == "sudo", nil + }, + } + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, mockCommander, mockProgramQuery) + + result, err := escalator.EscalateCommand("apt", []string{"install", "git"}) + + require.NoError(t, err) + require.Equal(t, privilege.EscalationSudo, result.Method) + require.Equal(t, "sudo", result.Command) + require.Equal(t, []string{"apt", "install", "git"}, result.Args) + require.True(t, result.NeedsEscalation) +} + +func Test_EscalateCommand_NonRootWithDoas_ReturnsDoasCommand(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "id" && len(args) == 1 && args[0] == "-u" { + return &utils.Result{Stdout: []byte("1000")}, nil + } + if name == "sudo" && len(args) == 2 && args[0] == "-n" && args[1] == "true" { + return &utils.Result{ExitCode: 1}, fmt.Errorf("sudo not available") + } + if name == "doas" && len(args) == 2 && args[0] == "-n" && args[1] == "true" { + return &utils.Result{}, nil + } + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + if program == "sudo" { + return false, nil + } + return program == "id" || program == "doas", nil + }, + } + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, mockCommander, mockProgramQuery) + + result, err := escalator.EscalateCommand("dnf", []string{"install", "vim"}) + + require.NoError(t, err) + require.Equal(t, privilege.EscalationDoas, result.Method) + require.Equal(t, "doas", result.Command) + require.Equal(t, []string{"dnf", "install", "vim"}, result.Args) + require.True(t, result.NeedsEscalation) +} + +func Test_EscalateCommand_NonRootWithoutPrivilegeEscalation_ReturnsDirectCommand(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "id" && len(args) == 1 && args[0] == "-u" { + return &utils.Result{Stdout: []byte("1000")}, nil + } + // Both sudo and doas fail + if (name == "sudo" || name == "doas") && len(args) == 2 && args[0] == "-n" && args[1] == "true" { + return &utils.Result{ExitCode: 1}, fmt.Errorf("privilege escalation not available") + } + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + if program == "sudo" || program == "doas" { + return false, nil + } + return program == "id", nil + }, + } + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, mockCommander, mockProgramQuery) + + result, err := escalator.EscalateCommand("apt", []string{"install", "git"}) + + require.NoError(t, err) + require.Equal(t, privilege.EscalationDirect, result.Method) + require.Equal(t, "apt", result.Command) + require.Equal(t, []string{"install", "git"}, result.Args) + require.False(t, result.NeedsEscalation) +} + +func Test_EscalateCommand_EmptyCommand_ReturnsError(t *testing.T) { + mockCommander := &utils.MoqCommander{} + mockProgramQuery := &osmanager.MoqProgramQuery{} + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, mockCommander, mockProgramQuery) + + result, err := escalator.EscalateCommand("", []string{"install", "git"}) + + require.Error(t, err) + require.Contains(t, err.Error(), "base command cannot be empty") + require.Equal(t, privilege.EscalationResult{}, result) +} + +func Test_IsRunningAsRoot_ReturnsTrue_WhenUidIsZero(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "id" && len(args) == 1 && args[0] == "-u" { + return &utils.Result{Stdout: []byte("0")}, nil + } + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return program == "id", nil + }, + } + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, mockCommander, mockProgramQuery) + + isRoot, err := escalator.IsRunningAsRoot() + + require.NoError(t, err) + require.True(t, isRoot) +} + +func Test_IsRunningAsRoot_ReturnsFalse_WhenUidIsNotZero(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "id" && len(args) == 1 && args[0] == "-u" { + return &utils.Result{Stdout: []byte("1000")}, nil + } + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return program == "id", nil + }, + } + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, mockCommander, mockProgramQuery) + + isRoot, err := escalator.IsRunningAsRoot() + + require.NoError(t, err) + require.False(t, isRoot) +} + +func Test_IsRunningAsRoot_ReturnsError_WhenIdCommandNotAvailable(t *testing.T) { + mockCommander := &utils.MoqCommander{} + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return false, nil + }, + } + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, mockCommander, mockProgramQuery) + + isRoot, err := escalator.IsRunningAsRoot() + + require.Error(t, err) + require.Contains(t, err.Error(), "'id' command not available") + require.False(t, isRoot) +} + +func Test_IsRunningAsRoot_ReturnsError_WhenIdCommandFails(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "id" && len(args) == 1 && args[0] == "-u" { + return &utils.Result{}, fmt.Errorf("command failed") + } + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return program == "id", nil + }, + } + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, mockCommander, mockProgramQuery) + + isRoot, err := escalator.IsRunningAsRoot() + + require.Error(t, err) + require.Contains(t, err.Error(), "failed to execute 'id -u'") + require.False(t, isRoot) +} + +func Test_GetAvailableEscalationMethods_AsRoot_ReturnsNoneOnly(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "id" && len(args) == 1 && args[0] == "-u" { + return &utils.Result{Stdout: []byte("0")}, nil + } + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return program == "id", nil + }, + } + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, mockCommander, mockProgramQuery) + + methods, err := escalator.GetAvailableEscalationMethods() + + require.NoError(t, err) + require.Equal(t, []privilege.EscalationMethod{privilege.EscalationNone}, methods) +} + +func Test_GetAvailableEscalationMethods_AsNonRootWithSudo_ReturnsSudoAndDirect(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "id" && len(args) == 1 && args[0] == "-u" { + return &utils.Result{Stdout: []byte("1000")}, nil + } + if name == "sudo" && len(args) == 2 && args[0] == "-n" && args[1] == "true" { + return &utils.Result{}, nil + } + return &utils.Result{ExitCode: 1}, fmt.Errorf("command failed") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return program == "id" || program == "sudo", nil + }, + } + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, mockCommander, mockProgramQuery) + + methods, err := escalator.GetAvailableEscalationMethods() + + require.NoError(t, err) + require.Contains(t, methods, privilege.EscalationSudo) + require.Contains(t, methods, privilege.EscalationDirect) + require.NotContains(t, methods, privilege.EscalationNone) +} + +func Test_GetAvailableEscalationMethods_AsNonRootWithBothSudoAndDoas_ReturnsAll(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "id" && len(args) == 1 && args[0] == "-u" { + return &utils.Result{Stdout: []byte("1000")}, nil + } + if (name == "sudo" || name == "doas") && len(args) == 2 && args[0] == "-n" && args[1] == "true" { + return &utils.Result{}, nil + } + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return program == "id" || program == "sudo" || program == "doas", nil + }, + } + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, mockCommander, mockProgramQuery) + + methods, err := escalator.GetAvailableEscalationMethods() + + require.NoError(t, err) + require.Contains(t, methods, privilege.EscalationSudo) + require.Contains(t, methods, privilege.EscalationDoas) + require.Contains(t, methods, privilege.EscalationDirect) + require.Len(t, methods, 3) +} + +func Test_GetAvailableEscalationMethods_AsNonRootWithoutPrivilegeEscalation_ReturnsDirectOnly(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "id" && len(args) == 1 && args[0] == "-u" { + return &utils.Result{Stdout: []byte("1000")}, nil + } + return &utils.Result{ExitCode: 1}, fmt.Errorf("command failed") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return program == "id", nil + }, + } + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, mockCommander, mockProgramQuery) + + methods, err := escalator.GetAvailableEscalationMethods() + + require.NoError(t, err) + require.Equal(t, []privilege.EscalationMethod{privilege.EscalationDirect}, methods) +} + +func Test_EscalateCommand_HandlesRootCheckFailure_GracefullyFallsBackToNonRoot(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(name string, args []string, opts ...utils.Option) (*utils.Result, error) { + if name == "id" && len(args) == 1 && args[0] == "-u" { + return &utils.Result{}, fmt.Errorf("id command failed") + } + if name == "sudo" && len(args) == 2 && args[0] == "-n" && args[1] == "true" { + return &utils.Result{}, nil + } + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{ + ProgramExistsFunc: func(program string) (bool, error) { + return program == "id" || program == "sudo", nil + }, + } + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, mockCommander, mockProgramQuery) + + result, err := escalator.EscalateCommand("apt", []string{"install", "git"}) + + require.NoError(t, err) + require.Equal(t, privilege.EscalationSudo, result.Method) + require.Equal(t, "sudo", result.Command) + require.Equal(t, []string{"apt", "install", "git"}, result.Args) + require.True(t, result.NeedsEscalation) +}