diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
new file mode 100644
index 0000000..b990056
--- /dev/null
+++ b/.github/workflows/integration.yml
@@ -0,0 +1,73 @@
+name: Integration Tests
+
+on:
+ push:
+ branches:
+ - main
+ - dev
+ pull_request:
+ branches:
+ - main
+ - dev
+
+jobs:
+ test-linux:
+ name: Linux Integration - ${{ matrix.os }}
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ os: [debian, arch, fedora]
+ fail-fast: false
+
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v4
+
+ - name: Setup Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.25.x'
+
+ - name: Run Dagger Integration Pipeline
+ run: |
+ go run ci/main.go --os ${{ matrix.os }}
+
+ test-mac:
+ name: macOS Integration
+ runs-on: macos-latest
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v4
+
+ - name: Setup Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.25.x'
+
+ - name: Build bootstrap binary
+ run: |
+ go build -o bootstrap_environment .
+
+ - name: Pre-create ~/.pyenv directory
+ run: |
+ mkdir -p ~/.pyenv
+
+ - name: Run bootstrap tool
+ run: |
+ echo y | ./bootstrap_environment --only custom --no-vm --no-ai
+
+ conclusion:
+ name: Final Status Check
+ needs: [test-linux, test-mac]
+ runs-on: ubuntu-latest
+ if: always()
+ steps:
+ - name: Check dependency jobs
+ run: |
+ # Check the results of matrix and mac jobs
+ # GHA will fail the workflow if this step exits with error
+ if [ "${{ needs.test-linux.result }}" != "success" ] || [ "${{ needs.test-mac.result }}" != "success" ]; then
+ echo "One or more integration checks failed!"
+ exit 1
+ fi
+ echo "All integration checks passed!"
diff --git a/.github/workflows/macos-dispatch.yml b/.github/workflows/macos-dispatch.yml
new file mode 100644
index 0000000..ad083f8
--- /dev/null
+++ b/.github/workflows/macos-dispatch.yml
@@ -0,0 +1,61 @@
+name: macOS Manual Integration
+
+on:
+ workflow_dispatch:
+ inputs:
+ branch:
+ description: 'Branch to run against'
+ required: true
+ default: 'main'
+ type: choice
+ options:
+ - main
+ - dev
+ - fix-add-unit-testing
+ workflow_definition:
+ description: 'Workflow Run Configuration'
+ required: true
+ default: 'custom-only'
+ type: choice
+ options:
+ - 'custom-only'
+ - 'system-only'
+ - 'full-setup'
+
+jobs:
+ test-mac-manual:
+ name: macOS Manual Run
+ runs-on: macos-latest
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.branch }}
+
+ - name: Setup Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.25.x'
+
+ - name: Build bootstrap binary
+ run: |
+ go build -o bootstrap_environment .
+
+ - name: Pre-create ~/.pyenv directory
+ run: |
+ mkdir -p ~/.pyenv
+
+ - name: Run bootstrap tool (custom-only)
+ if: ${{ inputs.workflow_definition == 'custom-only' }}
+ run: |
+ echo y | ./bootstrap_environment --only custom --no-vm --no-ai
+
+ - name: Run bootstrap tool (system-only)
+ if: ${{ inputs.workflow_definition == 'system-only' }}
+ run: |
+ echo y | ./bootstrap_environment --only system --no-vm --no-ai
+
+ - name: Run bootstrap tool (full-setup)
+ if: ${{ inputs.workflow_definition == 'full-setup' }}
+ run: |
+ echo y | ./bootstrap_environment --no-vm --no-ai
diff --git a/ci/main.go b/ci/main.go
new file mode 100644
index 0000000..2e5308e
--- /dev/null
+++ b/ci/main.go
@@ -0,0 +1,125 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "os"
+ "strings"
+
+ "dagger.io/dagger"
+ "golang.org/x/sync/errgroup"
+)
+
+func main() {
+ osFlag := flag.String("os", "all", "OS to test (debian, arch, fedora, or all)")
+ flag.Parse()
+
+ ctx := context.Background()
+
+ // Initialize Dagger Client
+ client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Failed to connect to Dagger: %v\n", err)
+ os.Exit(1)
+ }
+ defer client.Close()
+
+ // Get reference to the project source directory
+ src := client.Host().Directory(".")
+
+ // Build the bootstrapping binary inside Go container
+ fmt.Println("Building bootstrap_environment binary for Linux...")
+ builder := client.Container().
+ From("golang:1.25").
+ WithMountedDirectory("/src", src).
+ WithWorkdir("/src").
+ WithExec([]string{"go", "build", "-o", "bootstrap_environment", "."})
+
+ binaryFile := builder.File("bootstrap_environment")
+
+ // Target OS list
+ var targets []string
+ switch strings.ToLower(*osFlag) {
+ case "debian":
+ targets = []string{"debian:latest"}
+ case "arch":
+ targets = []string{"archlinux:latest"}
+ case "fedora":
+ targets = []string{"fedora:latest"}
+ case "all":
+ targets = []string{"debian:latest", "archlinux:latest", "fedora:latest"}
+ default:
+ fmt.Fprintf(os.Stderr, "Unsupported OS: %s. Supported: debian, arch, fedora, all\n", *osFlag)
+ os.Exit(1)
+ }
+
+ g, ctx := errgroup.WithContext(ctx)
+
+ for _, target := range targets {
+ target := target // capture loop variable
+ g.Go(func() error {
+ fmt.Printf("=== Starting integration test on target OS: %s ===\n", target)
+
+ // 1. Prepare target container base and setup script based on OS distro
+ var testContainer *dagger.Container
+ if strings.Contains(target, "debian") {
+ testContainer = client.Container().
+ From(target).
+ WithExec([]string{"apt-get", "update"}).
+ WithExec([]string{"apt-get", "install", "-y", "sudo", "curl", "git", "wget", "tar", "unzip", "xz-utils", "make", "python3", "which"})
+ } else if strings.Contains(target, "fedora") {
+ testContainer = client.Container().
+ From(target).
+ WithExec([]string{"dnf", "install", "-y", "sudo", "curl", "git", "wget", "tar", "unzip", "xz", "make", "python3", "which"})
+ } else if strings.Contains(target, "archlinux") {
+ testContainer = client.Container().
+ From(target).
+ WithExec([]string{"pacman", "-Sy", "--noconfirm", "sudo", "curl", "git", "wget", "tar", "unzip", "xz", "make", "python", "which"})
+ } else {
+ return fmt.Errorf("unsupported target OS: %s", target)
+ }
+
+ // 2. Pre-create the ~/.pyenv directory to skip python source compilation in integration test (saves ~10 minutes)
+ testContainer = testContainer.WithExec([]string{"mkdir", "-p", "/root/.pyenv"})
+
+ // 3. Mount the built binary
+ testContainer = testContainer.
+ WithFile("/usr/local/bin/bootstrap_environment", binaryFile).
+ WithWorkdir("/tmp")
+
+ // 4. Run bootstrap binary with safe args: --only custom --no-vm --no-ai
+ // We pipe 'y' to satisfy the "Proceed? [y/N]" prompt.
+ fmt.Printf("[%s] Executing bootstrap_environment...\n", target)
+ testContainer = testContainer.WithExec([]string{"sh", "-c", "echo y | bootstrap_environment --only custom --no-vm --no-ai"})
+
+ // 5. Verify all installed custom packages return a path and zero exit code from version command
+ fmt.Printf("[%s] Verifying package installations on PATH and running version checks...\n", target)
+ verifyCmd := []string{
+ "sh", "-c",
+ "export PATH=$PATH:/usr/local/go/bin; " +
+ "which go && go version && " +
+ "which nvim && nvim --version && " +
+ "which zig && zig version && " +
+ "which firecracker && firecracker --version",
+ }
+ verifyOutput, err := testContainer.WithExec(verifyCmd).Stdout(ctx)
+ if err != nil {
+ return fmt.Errorf("verification failed on %s: package not found on PATH or exited with error (%w)", target, err)
+ }
+
+
+ fmt.Printf("[%s] Verification Output:\n%s\n", target, verifyOutput)
+ fmt.Printf("--- PASS: Integration test on %s completed successfully ---\n", target)
+ return nil
+
+ })
+ }
+
+ if err := g.Wait(); err != nil {
+ fmt.Fprintf(os.Stderr, "One or more integration tests failed: %v\n", err)
+ os.Exit(1)
+ }
+
+ fmt.Println("\nAll parallel integration tests passed successfully!")
+}
diff --git a/compat.go b/compat.go
new file mode 100644
index 0000000..7ce0f10
--- /dev/null
+++ b/compat.go
@@ -0,0 +1,34 @@
+package main
+
+import (
+ "io"
+ "os"
+)
+
+var (
+ // Exec redirects
+ runCmd = runCmdReal
+ runShell = runShellReal
+ hasCmd = hasCmdReal
+ probe = probeReal
+
+ // Net redirects
+ download = downloadReal
+ fetchJSON = fetchJSONReal
+ fetchText = fetchTextReal
+
+ // OS redirects
+ osStat = os.Stat
+ osReadFile = os.ReadFile
+ osWriteFile = os.WriteFile
+ osMkdirAll = os.MkdirAll
+ osRemove = os.Remove
+ osRemoveAll = os.RemoveAll
+ osRename = os.Rename
+ osExit = os.Exit
+ stdin io.Reader = os.Stdin
+
+ // Filesystem paths
+ osReleasePath = "/etc/os-release"
+ passwdPath = "/etc/passwd"
+)
diff --git a/coverage.out b/coverage.out
new file mode 100644
index 0000000..94c0675
--- /dev/null
+++ b/coverage.out
@@ -0,0 +1,1049 @@
+mode: set
+github.com/JMR-dev/bootstrap_dev_env/check.go:36.60,41.26 4 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:41.26,42.45 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:42.45,44.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:47.2,49.29 3 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:49.29,50.18 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:50.18,52.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:52.9,54.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:57.2,58.28 2 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:58.28,59.30 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:59.30,61.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:61.9,63.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:65.2,66.28 2 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:66.28,67.31 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:67.31,69.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:69.9,71.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:73.2,79.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:82.60,84.24 2 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:84.24,85.28 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:85.28,87.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:87.9,89.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:91.2,91.69 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:94.67,97.25 3 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:97.25,99.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:99.16,101.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:101.9,103.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:105.2,105.68 1 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:108.48,109.25 1 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:109.25,111.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:112.2,112.91 1 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:115.113,118.36 2 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:118.36,123.18 5 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:123.18,125.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:126.3,127.12 2 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:127.12,131.4 3 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:132.3,132.27 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:132.27,134.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:135.3,135.28 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:135.28,137.35 2 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:137.35,139.5 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:140.4,140.75 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:142.3,142.13 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:145.2,145.102 1 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:145.102,147.37 2 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:147.37,149.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:150.3,150.30 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:150.30,152.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:153.3,153.31 1 0
+github.com/JMR-dev/bootstrap_dev_env/check.go:156.2,156.36 1 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:156.36,158.43 2 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:158.43,160.20 2 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:160.20,162.5 1 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:163.4,163.65 1 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:165.3,165.36 1 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:165.36,168.18 3 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:168.18,170.5 1 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:171.4,171.61 1 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:173.3,173.31 1 1
+github.com/JMR-dev/bootstrap_dev_env/check.go:176.2,176.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:16.45,17.25 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:17.25,19.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:20.2,20.44 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:23.51,24.31 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:24.31,26.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:27.2,27.50 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:30.49,31.20 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:31.20,33.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:34.2,34.24 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:34.24,36.36 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:36.36,38.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:40.2,40.11 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:43.46,44.21 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:44.21,46.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:47.2,47.15 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:61.34,62.31 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:62.31,64.17 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:64.17,66.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:68.2,68.10 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:71.52,72.65 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:72.65,74.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:75.2,75.11 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:78.26,79.24 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:79.24,81.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:82.2,83.30 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:89.50,90.21 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:90.21,91.51 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:91.51,93.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:94.3,94.18 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:96.2,97.16 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:97.16,99.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:100.2,101.22 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:101.22,103.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:104.2,105.22 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:105.22,107.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:108.2,108.18 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:111.62,113.19 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:113.19,115.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:116.2,116.22 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:116.22,118.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:119.2,119.21 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:119.21,121.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:122.2,122.23 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:122.23,124.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:125.2,126.15 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:126.15,128.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:129.2,129.15 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:129.15,131.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:132.2,133.41 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:133.41,135.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:136.2,136.21 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:142.61,143.54 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:143.54,145.17 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:145.17,148.4 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:149.3,149.25 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:149.25,152.4 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:153.3,154.14 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:156.2,156.52 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:156.52,158.33 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:158.33,160.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:161.3,161.26 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:161.26,164.4 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:165.3,166.28 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:166.28,168.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:169.3,169.35 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:169.35,172.4 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:173.3,173.31 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:175.2,175.13 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:178.41,180.15 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:180.15,182.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:183.2,183.32 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:183.32,185.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:186.2,186.28 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:186.28,190.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:191.2,191.13 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:196.32,198.42 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:198.42,201.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:202.2,204.46 3 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:207.46,208.74 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:208.74,211.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:212.2,213.74 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:213.74,214.33 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:214.33,216.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:217.3,218.46 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:218.46,220.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:221.3,221.75 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:221.75,223.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:224.3,224.19 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:224.19,226.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:227.3,227.13 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:229.2,229.18 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:229.18,232.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:233.2,236.53 4 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:239.53,242.42 3 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:242.42,244.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:245.2,249.28 4 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:249.28,250.18 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:250.18,252.9 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:255.2,257.73 3 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:260.50,262.84 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:262.84,264.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:265.2,270.28 5 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:270.28,271.38 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:271.38,273.9 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:276.2,276.18 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:276.18,279.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:280.2,280.49 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:280.49,283.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:284.2,286.47 3 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:286.47,288.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:289.2,290.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:290.16,293.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:294.2,294.24 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:294.24,297.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:298.2,309.80 10 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:314.63,316.54 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:316.54,318.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:320.2,330.77 4 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:330.77,332.54 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:332.54,334.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:335.3,335.33 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:337.2,339.19 3 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:339.19,341.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:342.2,343.33 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:343.33,344.73 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:344.73,346.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:348.2,348.22 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:351.72,352.13 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:352.13,354.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:355.2,356.102 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:356.102,358.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:359.2,360.19 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:360.19,362.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:363.2,365.31 3 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:365.31,366.29 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:366.29,368.17 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:368.17,370.5 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:371.4,371.48 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:374.2,374.22 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:379.64,381.66 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:381.66,383.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:384.2,385.22 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:385.22,386.51 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:386.51,388.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:390.2,390.22 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:390.22,392.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:393.2,393.41 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:393.41,395.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:396.2,399.9 4 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:399.9,401.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:402.2,403.15 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:403.15,405.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:406.2,406.27 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:409.33,412.46 3 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:412.46,415.15 3 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:415.15,416.15 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:416.15,418.5 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:419.4,419.12 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:422.2,422.26 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:433.40,435.9 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:435.9,437.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:438.2,439.15 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:439.15,440.31 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:440.31,443.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:445.2,446.12 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:446.12,450.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:451.2,451.28 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:451.28,454.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:455.2,458.28 4 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:463.56,466.32 3 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:466.32,470.22 4 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:470.22,472.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:473.3,474.39 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:474.39,476.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:478.3,478.39 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:478.39,480.12 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:483.3,483.15 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:484.14,487.12 3 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:488.16,490.12 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:491.14,493.12 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:494.20,496.12 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:497.17,499.18 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:499.18,501.13 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:503.4,505.12 3 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:506.14,508.12 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:509.17,511.12 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:512.16,514.12 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:515.18,517.12 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:520.3,523.16 3 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:523.16,525.12 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:527.3,527.22 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:527.22,528.12 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:531.3,532.17 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:532.17,534.12 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:536.3,537.30 2 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:537.30,539.12 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:541.3,541.35 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:541.35,543.12 2 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:545.3,545.15 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:546.13,547.22 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:548.22,549.36 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:550.14,551.28 1 1
+github.com/JMR-dev/bootstrap_dev_env/custom.go:552.11,553.75 1 0
+github.com/JMR-dev/bootstrap_dev_env/custom.go:555.3,555.19 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:24.13,28.2 3 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:30.24,31.22 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:32.15,33.17 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:34.16,35.17 1 0
+github.com/JMR-dev/bootstrap_dev_env/detect.go:36.10,39.12 3 0
+github.com/JMR-dev/bootstrap_dev_env/detect.go:43.26,44.24 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:45.15,46.18 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:47.15,48.19 1 0
+github.com/JMR-dev/bootstrap_dev_env/detect.go:49.10,52.12 3 0
+github.com/JMR-dev/bootstrap_dev_env/detect.go:75.49,86.2 2 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:88.25,89.26 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:89.26,91.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:92.2,92.17 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:95.42,97.39 2 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:97.39,98.31 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:98.31,100.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:102.2,102.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:105.42,107.46 2 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:107.46,108.31 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:108.31,110.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:112.2,112.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:118.42,120.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:120.16,122.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:123.2,124.57 2 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:124.57,125.38 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:125.38,127.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:129.2,129.11 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:132.30,134.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:134.16,136.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:137.2,138.57 2 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:138.57,139.76 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:139.76,143.4 3 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:145.2,146.27 2 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:146.27,147.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:147.14,149.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:151.2,151.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:154.30,156.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:156.16,158.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:159.2,160.57 2 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:160.57,161.76 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:161.76,165.4 3 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:167.2,168.27 2 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:168.27,169.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:169.14,171.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/detect.go:173.2,173.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:38.30,38.72 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:41.56,42.23 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:42.23,44.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:45.2,45.38 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:45.38,47.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:48.2,54.20 5 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:54.20,56.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:57.2,57.23 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:57.23,59.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:61.2,62.18 2 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:62.18,65.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:65.8,68.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:70.2,73.43 3 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:73.43,78.3 4 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:79.2,79.16 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:79.16,81.31 2 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:81.31,85.4 3 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:86.3,88.16 3 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:90.2,90.12 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:95.55,96.23 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:96.23,98.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:99.2,105.20 5 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:105.20,107.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:108.2,108.23 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:108.23,110.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:112.2,113.18 2 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:113.18,116.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:116.8,119.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:121.2,124.43 3 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:124.43,129.3 4 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:130.2,130.16 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:130.16,132.31 2 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:132.31,136.4 3 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:137.3,139.16 3 0
+github.com/JMR-dev/bootstrap_dev_env/exec.go:141.2,141.12 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:145.35,148.2 2 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:153.72,154.18 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:154.18,156.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:157.2,167.43 9 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:167.43,170.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:171.2,171.16 1 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:171.16,173.31 2 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:173.31,176.4 2 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:177.3,178.20 2 1
+github.com/JMR-dev/bootstrap_dev_env/exec.go:180.2,180.18 1 1
+github.com/JMR-dev/bootstrap_dev_env/flatpak.go:5.49,8.24 2 1
+github.com/JMR-dev/bootstrap_dev_env/flatpak.go:8.24,10.46 2 1
+github.com/JMR-dev/bootstrap_dev_env/flatpak.go:10.46,13.4 2 1
+github.com/JMR-dev/bootstrap_dev_env/flatpak.go:14.3,15.38 2 1
+github.com/JMR-dev/bootstrap_dev_env/flatpak.go:15.38,18.4 2 1
+github.com/JMR-dev/bootstrap_dev_env/flatpak.go:21.2,26.34 2 1
+github.com/JMR-dev/bootstrap_dev_env/flatpak.go:26.34,29.16 3 1
+github.com/JMR-dev/bootstrap_dev_env/flatpak.go:29.16,31.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/issues.go:21.34,26.2 4 1
+github.com/JMR-dev/bootstrap_dev_env/issues.go:28.25,28.50 1 1
+github.com/JMR-dev/bootstrap_dev_env/issues.go:29.25,29.51 1 1
+github.com/JMR-dev/bootstrap_dev_env/issues.go:31.25,35.2 3 1
+github.com/JMR-dev/bootstrap_dev_env/issues.go:37.26,39.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/issues.go:39.16,41.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/issues.go:42.2,42.62 1 1
+github.com/JMR-dev/bootstrap_dev_env/issues.go:45.20,48.22 3 1
+github.com/JMR-dev/bootstrap_dev_env/issues.go:48.22,51.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/issues.go:52.2,57.66 6 1
+github.com/JMR-dev/bootstrap_dev_env/issues.go:57.66,60.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/issues.go:61.2,61.64 1 1
+github.com/JMR-dev/bootstrap_dev_env/issues.go:64.21,67.23 3 1
+github.com/JMR-dev/bootstrap_dev_env/issues.go:67.23,69.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/issues.go:70.2,71.28 2 1
+github.com/JMR-dev/bootstrap_dev_env/issues.go:71.28,73.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:17.26,18.27 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:18.27,20.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:21.2,21.21 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:24.23,25.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:25.14,27.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:28.2,29.29 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:29.29,32.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:33.2,37.6 5 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:37.6,39.28 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:39.28,40.9 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:42.3,42.30 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:44.2,44.51 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:47.23,48.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:48.14,50.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:51.2,51.20 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:51.20,55.3 3 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:56.2,58.42 3 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:58.42,61.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:63.2,65.44 3 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:65.44,68.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:70.2,78.91 4 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:98.21,101.2 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:103.23,104.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:104.14,106.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:107.2,108.28 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:108.28,110.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:111.2,112.13 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:112.13,114.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:115.2,117.16 3 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:117.16,119.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:120.2,120.10 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:123.35,124.39 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:124.39,126.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:127.2,128.28 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:128.28,130.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:131.2,133.13 3 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:133.13,135.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:136.2,138.69 3 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:138.69,140.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:141.2,141.22 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:141.22,143.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:144.2,145.10 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:148.31,149.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:149.14,151.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:152.2,152.26 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:152.26,155.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:156.2,158.29 3 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:158.29,161.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:162.2,163.14 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:163.14,165.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:166.2,167.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:167.16,169.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:170.2,178.11 9 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:181.80,184.19 3 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:184.19,186.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:187.2,190.61 4 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:190.61,192.17 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:192.17,193.12 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:195.3,195.15 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:195.15,198.4 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:200.2,201.31 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:201.31,204.16 3 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:204.16,205.12 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:207.3,211.29 5 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:211.29,212.12 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:214.3,214.59 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:216.2,216.8 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:219.56,221.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:221.16,223.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:224.2,227.14 4 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:227.14,230.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:231.2,234.16 4 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:234.16,237.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:238.2,238.41 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:238.41,241.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:242.2,243.13 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:246.53,247.21 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:247.21,249.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:250.2,251.92 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:288.55,289.51 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:289.51,291.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:292.2,293.98 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:293.98,295.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:296.2,297.82 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:300.49,307.2 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:309.36,351.2 9 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:353.80,354.18 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:354.18,356.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:357.2,369.9 4 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:369.9,371.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:372.2,372.10 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:375.63,378.34 3 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:378.34,379.49 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:379.49,382.4 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:383.3,383.30 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:385.2,385.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:388.73,391.34 3 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:391.34,392.85 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:392.85,395.4 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:396.3,396.31 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:398.2,398.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:401.52,437.2 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:439.52,443.45 4 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:443.45,445.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:446.2,448.35 3 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:448.35,450.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:450.8,451.21 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:451.21,453.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:453.9,455.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:457.2,458.67 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:461.30,462.26 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:462.26,464.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:465.2,466.82 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:466.82,470.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:471.2,471.27 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:471.27,474.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:475.2,475.13 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:478.58,479.25 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:479.25,481.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:482.2,490.13 7 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:490.13,491.48 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:491.48,493.109 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:493.109,496.5 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:497.4,497.95 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:499.3,507.22 3 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:507.22,510.4 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:511.3,532.16 5 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:533.8,535.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:537.2,547.19 4 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:550.27,551.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:551.14,553.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:554.2,555.19 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:555.19,557.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:559.2,565.51 6 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:565.51,567.102 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:567.102,570.4 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:573.2,574.41 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:574.41,576.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:576.8,579.10 3 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:579.10,582.4 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:583.3,585.50 3 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:585.50,588.4 2 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:589.3,589.52 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:589.52,592.4 2 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:593.3,594.25 2 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:594.25,597.4 2 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:600.2,603.16 4 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:603.16,606.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:607.2,607.73 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:607.73,610.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:611.2,612.37 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:612.37,615.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:617.2,618.23 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:618.23,619.37 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:619.37,622.4 2 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:623.3,624.39 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:625.8,627.24 2 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:627.24,629.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:632.2,633.52 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:633.52,636.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:638.2,638.43 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:638.43,641.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:643.2,643.54 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:643.54,647.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/macos.go:649.2,654.23 5 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:654.23,656.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/macos.go:656.8,658.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:32.13,34.2 1 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:36.29,44.15 7 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:45.41,45.41 0 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:46.10,49.9 3 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:52.2,58.24 6 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:58.24,61.66 1 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:61.66,62.12 1 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:64.3,64.12 1 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:64.12,66.81 2 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:66.81,67.13 1 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:70.3,70.46 1 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:73.2,76.11 4 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:76.11,78.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:80.2,80.13 1 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:80.13,85.3 3 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:87.2,89.11 2 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:89.11,91.32 2 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:91.32,92.24 1 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:92.24,94.5 1 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:94.10,96.5 1 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:98.3,99.26 2 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:99.26,101.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:102.3,102.20 1 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:105.2,111.38 5 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:111.38,113.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:114.2,114.15 1 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:114.15,116.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:117.2,117.38 1 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:117.38,119.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:121.2,123.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:123.16,127.3 3 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:129.2,129.76 1 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:129.76,133.3 3 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:135.2,137.38 2 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:137.38,140.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:142.2,142.15 1 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:142.15,144.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:146.2,147.38 2 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:147.38,150.44 3 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:150.44,152.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:155.2,155.17 1 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:155.17,158.24 3 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:158.24,160.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:163.2,163.20 1 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:163.20,166.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:168.2,174.19 6 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:174.19,175.42 1 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:175.42,178.4 2 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:182.18,183.23 1 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:183.23,184.14 1 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:184.14,189.4 3 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:190.3,190.9 1 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:192.2,192.21 1 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:192.21,196.3 3 0
+github.com/JMR-dev/bootstrap_dev_env/main.go:197.2,199.21 3 1
+github.com/JMR-dev/bootstrap_dev_env/main.go:199.21,203.3 3 0
+github.com/JMR-dev/bootstrap_dev_env/net.go:21.42,24.16 3 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:24.16,27.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:28.2,29.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:29.16,32.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:33.2,34.30 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:34.30,37.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:38.2,39.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:39.16,42.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:43.2,44.49 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:44.49,47.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/net.go:48.2,48.13 1 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:53.44,55.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:55.16,58.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:59.2,60.49 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:60.49,62.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/net.go:63.2,64.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:64.16,67.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:68.2,69.30 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:69.30,72.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:73.2,73.61 1 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:73.61,76.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:77.2,77.13 1 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:81.39,83.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:83.16,86.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:87.2,88.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:88.16,91.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:92.2,93.30 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:93.30,96.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:97.2,98.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:98.16,101.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:102.2,102.37 1 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:105.44,107.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:107.16,109.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:110.2,112.41 3 1
+github.com/JMR-dev/bootstrap_dev_env/net.go:112.41,114.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/net.go:115.2,115.44 1 1
+github.com/JMR-dev/bootstrap_dev_env/packages.go:101.39,144.2 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:12.19,16.2 3 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:18.28,19.13 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:19.13,23.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:24.2,24.59 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:24.59,25.18 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:25.18,27.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:29.2,31.11 3 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:44.35,44.71 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:45.45,47.2 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:182.61,185.28 3 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:185.28,187.10 2 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:187.10,189.12 2 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:191.3,191.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:191.14,193.12 2 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:195.3,195.49 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:197.2,197.26 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:201.44,202.16 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:203.13,205.31 2 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:206.17,208.74 2 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:209.16,211.31 2 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:212.14,213.22 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:213.22,215.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:216.3,216.56 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:216.56,218.29 2 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:218.29,220.5 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:222.3,222.15 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:224.2,224.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:227.44,228.24 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:228.24,230.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/pkgmgr.go:231.2,232.30 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:19.21,21.64 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:21.64,24.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:25.2,25.46 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:28.30,29.24 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:29.24,31.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:32.2,33.30 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:36.31,37.16 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:38.17,39.86 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:40.13,41.82 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:42.16,43.95 1 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:45.2,45.27 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:48.19,49.24 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:49.24,52.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:53.2,53.25 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:53.25,55.26 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:55.26,57.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:57.9,63.4 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:66.2,68.21 3 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:68.21,69.17 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:70.18,72.96 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:72.96,75.5 2 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:76.17,78.110 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:78.110,81.5 2 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:82.11,84.10 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:87.2,89.19 3 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:89.19,91.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:94.49,96.9 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:96.9,99.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:100.2,100.21 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:100.21,103.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:104.2,107.61 4 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:107.61,109.15 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:109.15,110.12 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:112.3,115.13 4 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:115.13,117.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:119.2,119.24 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:119.24,121.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:122.2,122.43 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:122.43,123.37 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:123.37,125.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:126.3,126.37 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:126.37,128.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:129.3,129.39 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:131.2,132.47 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:138.43,141.44 3 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:141.44,143.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:144.2,145.44 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:145.44,148.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:150.2,151.18 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:151.18,154.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:156.2,157.9 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:157.9,159.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:160.2,161.30 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:161.30,162.18 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:162.18,165.69 3 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:165.69,167.5 1 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:168.4,168.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:172.2,176.12 5 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:176.12,181.14 4 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:181.14,183.25 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:183.25,185.24 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:185.24,187.6 1 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:188.5,188.73 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:190.4,190.10 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:192.3,193.40 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:193.40,196.4 2 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:197.3,197.98 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:199.2,199.12 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:204.19,206.81 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:206.81,208.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:209.2,210.19 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:210.19,213.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:214.2,216.78 3 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:216.78,219.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:220.2,220.55 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:223.22,225.63 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:225.63,227.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:228.2,231.43 3 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:231.43,233.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:233.8,235.87 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:235.87,238.4 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:239.3,239.42 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:242.2,243.111 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:243.111,245.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:247.2,248.191 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:248.191,250.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:255.23,256.20 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:256.20,259.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:260.2,260.20 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:260.20,263.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:264.2,266.42 3 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:266.42,268.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:268.8,271.43 3 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:271.43,274.4 2 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:277.2,279.16 3 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:279.16,282.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:283.2,286.26 4 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:286.26,288.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:288.8,290.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:291.2,291.21 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:291.21,292.68 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:292.68,295.4 2 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:296.3,296.52 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:297.8,299.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:304.28,305.42 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:305.42,307.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:308.2,308.42 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:308.42,310.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:311.2,311.11 1 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:314.25,315.20 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:315.20,318.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:319.2,320.84 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:320.84,321.56 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:321.56,323.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:325.2,326.20 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:326.20,329.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:330.2,331.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:331.16,334.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:335.2,336.24 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:336.24,339.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:341.2,342.18 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:342.18,344.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:344.8,344.25 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:344.25,346.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:346.8,346.20 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:346.20,348.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:349.2,352.18 3 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:352.18,354.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:354.8,356.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:357.2,357.46 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:357.46,359.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:359.8,361.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:367.40,369.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:369.16,371.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:372.2,372.57 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:372.57,374.21 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:374.21,375.12 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:377.3,377.22 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:377.22,379.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:381.2,381.11 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:384.24,391.45 5 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:391.45,394.7 3 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:394.7,396.52 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:396.52,397.10 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:399.4,399.7 1 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:401.3,402.53 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:402.53,405.4 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:406.3,406.72 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:409.2,416.75 6 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:416.75,419.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:420.2,420.28 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:420.28,423.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:424.2,424.63 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:427.24,430.2 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:432.25,433.19 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:433.19,436.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:437.2,437.18 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:437.18,440.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:441.2,441.84 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:441.84,443.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:444.2,445.15 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:445.15,448.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:452.32,456.33 4 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:456.33,459.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:460.2,461.22 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:464.19,466.94 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:466.94,468.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/post.go:471.40,473.63 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:473.63,476.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:477.2,479.105 3 1
+github.com/JMR-dev/bootstrap_dev_env/post.go:479.105,481.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/repos.go:9.43,10.26 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:10.26,11.38 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:11.38,13.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:15.2,15.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:18.62,25.2 3 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:27.24,28.16 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:29.13,30.56 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:30.56,32.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/repos.go:33.3,34.86 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:35.17,36.60 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:36.60,38.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/repos.go:39.3,43.51 5 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:43.51,45.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/repos.go:46.3,65.63 6 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:70.20,71.16 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:72.13,73.53 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:73.53,75.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:76.3,77.78 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:78.17,79.64 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:79.64,81.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:82.3,91.63 3 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:96.24,97.26 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:97.26,100.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:101.2,101.16 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:102.13,103.60 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:103.60,105.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:106.3,110.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/repos.go:111.17,112.67 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:112.67,114.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:115.3,123.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:127.25,128.26 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:128.26,131.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:132.2,132.16 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:133.13,134.54 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:134.54,136.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:137.3,141.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/repos.go:142.17,143.61 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:143.61,145.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:146.3,154.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:158.25,159.16 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:160.13,161.55 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:161.55,163.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:164.3,168.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:169.17,170.62 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:170.62,172.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:173.3,182.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:186.24,188.25 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:188.25,190.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:191.2,194.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:194.4,196.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:197.2,208.3 4 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:216.31,217.46 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:217.46,219.27 2 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:219.27,221.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:222.3,222.11 1 1
+github.com/JMR-dev/bootstrap_dev_env/repos.go:224.2,232.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:13.36,14.13 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:14.13,16.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:17.2,21.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:39.45,41.32 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:41.32,41.74 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:42.2,42.13 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:43.18,44.43 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:45.18,46.65 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:47.17,48.84 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:49.16,50.58 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:51.14,52.24 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:53.16,54.26 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:56.2,56.34 1 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:72.39,74.87 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:74.87,76.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:77.2,78.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:79.13,80.18 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:81.17,82.18 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:83.10,85.9 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:87.2,90.36 3 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:90.36,92.36 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:92.36,94.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:95.3,96.32 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:96.32,97.30 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:97.30,99.10 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:102.3,102.15 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:102.15,104.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:105.3,105.35 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:105.35,107.33 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:107.33,108.15 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:108.15,110.11 2 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:113.4,113.41 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:113.41,115.5 1 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:117.3,117.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:120.2,121.28 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:121.28,122.34 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:122.34,124.9 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:127.2,127.18 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:127.18,130.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:131.2,132.47 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:132.47,134.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:135.2,136.25 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:136.25,138.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:139.2,139.75 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:142.30,143.26 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:143.26,146.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:147.2,147.16 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:148.13,150.71 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:150.71,152.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:153.3,153.72 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:154.17,156.70 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:156.70,158.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:159.3,159.76 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:160.10,161.58 1 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:165.34,167.99 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:167.99,169.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:170.2,173.36 3 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:173.36,175.41 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:175.41,177.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:178.3,179.32 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:179.32,180.30 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:180.30,182.10 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:185.3,185.15 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:185.15,187.4 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:188.3,188.33 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:188.33,190.33 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:190.33,191.15 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:191.15,193.11 2 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:196.4,196.41 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:196.41,198.5 1 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:200.3,200.14 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:203.2,204.28 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:204.28,205.34 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:205.34,207.9 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:210.2,210.18 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:210.18,213.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:214.2,215.47 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:215.47,217.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:218.2,221.66 4 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:224.34,228.30 4 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:228.30,230.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:231.2,233.19 3 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:233.19,235.3 1 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:236.2,238.16 3 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:238.16,241.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:242.2,242.24 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:242.24,245.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:246.2,250.57 5 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:253.31,256.44 3 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:256.44,258.71 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:258.71,261.4 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:262.8,264.113 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:264.113,267.4 2 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:269.2,269.85 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:269.85,272.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:273.2,274.94 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:277.32,279.19 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:279.19,282.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:283.2,288.39 6 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:288.39,290.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:292.2,293.21 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:293.21,296.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:297.2,298.54 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:298.54,299.58 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:299.58,301.9 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:304.2,304.20 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:304.20,307.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:308.2,309.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:309.16,312.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:313.2,313.24 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:313.24,316.3 2 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:317.2,326.66 8 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:329.28,330.24 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:330.24,333.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:334.2,335.20 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:335.20,337.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:337.8,339.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:342.30,343.21 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:343.21,346.3 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:347.2,347.58 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:350.41,351.13 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:352.24,353.28 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:354.14,355.19 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:356.18,357.23 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:358.18,359.23 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:360.17,361.22 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:362.16,363.21 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:364.14,365.19 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:366.16,367.21 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:373.39,374.16 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:375.16,376.97 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:377.14,378.21 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:378.21,380.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:381.3,381.61 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:382.10,383.79 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:388.55,391.22 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:391.22,392.31 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:392.31,394.17 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:394.17,396.5 1 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:399.3,399.9 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:402.2,404.30 3 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:404.30,405.28 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:405.28,406.39 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:406.39,410.5 3 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:414.2,414.30 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:414.30,416.16 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:416.16,418.4 1 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:421.2,421.22 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:421.22,423.17 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:423.17,426.4 2 0
+github.com/JMR-dev/bootstrap_dev_env/system.go:427.3,428.31 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:428.31,431.4 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:437.49,439.13 2 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:439.13,441.3 1 1
+github.com/JMR-dev/bootstrap_dev_env/system.go:442.2,443.60 2 1
diff --git a/custom.go b/custom.go
index 9fc6283..bb9ca74 100644
--- a/custom.go
+++ b/custom.go
@@ -130,7 +130,7 @@ func isCustomPkgInstalled(pkg *CustomPackage) (bool, string) {
return false, ""
}
check := expandHome(raw)
- if _, err := os.Stat(check); err == nil {
+ if _, err := osStat(check); err == nil {
return true, check
}
return false, check
@@ -195,7 +195,7 @@ func urlArchOK(pkg *CustomPackage) bool {
func installGo(archive string) {
goRoot := "/usr/local/go"
- if _, err := os.Stat(goRoot); err == nil {
+ if _, err := osStat(goRoot); err == nil {
fmt.Printf(" Removing existing Go at %s ...\n", goRoot)
runCmd([]string{"rm", "-rf", goRoot}, CmdOpts{AsSudo: true})
}
@@ -215,6 +215,9 @@ func installFirecracker(archive, tmp string) {
return nil
}
name := info.Name()
+ if strings.HasSuffix(name, ".tgz") || strings.HasSuffix(name, ".tar.gz") {
+ return nil
+ }
if !strings.HasPrefix(name, "firecracker") {
return nil
}
@@ -226,6 +229,7 @@ func installFirecracker(archive, tmp string) {
}
return nil
})
+
if binary == "" {
errLog("firecracker binary not found in archive")
return
@@ -239,7 +243,7 @@ func installFirecracker(archive, tmp string) {
func installZig(pkg *CustomPackage, archive string) {
parent := "/usr/local"
zigDir := filepath.Join(parent, "zig-"+pkg.Version)
- if _, err := os.Stat(zigDir); err == nil {
+ if _, err := osStat(zigDir); err == nil {
runCmd([]string{"rm", "-rf", zigDir}, CmdOpts{AsSudo: true})
}
runCmd([]string{"tar", "-C", parent, "-xJf", archive}, CmdOpts{AsSudo: true})
@@ -501,7 +505,7 @@ func installCustomPackages(toInstall []*CustomPackage) {
continue
}
installNeovim(pkg, tmp)
- os.RemoveAll(tmp)
+ osRemoveAll(tmp)
continue
case "agy":
installAgy()
@@ -535,11 +539,11 @@ func installCustomPackages(toInstall []*CustomPackage) {
}
archive := filepath.Join(tmp, filepath.Base(url))
if !download(url, archive) {
- os.RemoveAll(tmp)
+ osRemoveAll(tmp)
continue
}
if !verifyArchive(archive, pkg) {
- os.RemoveAll(tmp)
+ osRemoveAll(tmp)
continue
}
switch name {
@@ -552,6 +556,6 @@ func installCustomPackages(toInstall []*CustomPackage) {
default:
warn(fmt.Sprintf("No install handler for '%s' — skipping", pkg.Name))
}
- os.RemoveAll(tmp)
+ osRemoveAll(tmp)
}
}
diff --git a/custom_test.go b/custom_test.go
new file mode 100644
index 0000000..4be233c
--- /dev/null
+++ b/custom_test.go
@@ -0,0 +1,400 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestCustomPackageResolvers(t *testing.T) {
+ defer resetMocks()
+
+ osName = "linux"
+ archName = "x86_64"
+
+ p := &CustomPackage{
+ Name: "my-pkg",
+ Version: "1.2.3",
+ URLTemplate: "http://example.com/download/{version}/{os}/{arch}/my-pkg.tar.gz",
+ SHA256URLTemplate: "http://example.com/download/{version}/{os}/{arch}/my-pkg.tar.gz.minisig",
+ SHA256Map: map[string]string{
+ "linux-x86_64": "aabbcc",
+ },
+ }
+
+ if p.resolveURL() != "http://example.com/download/1.2.3/linux/x86_64/my-pkg.tar.gz" {
+ t.Errorf("unexpected URL: %q", p.resolveURL())
+ }
+ if p.resolveSHA256URL() != "http://example.com/download/1.2.3/linux/x86_64/my-pkg.tar.gz.minisig" {
+ t.Errorf("unexpected SHA256 URL: %q", p.resolveSHA256URL())
+ }
+ if p.resolvedSHA256() != "aabbcc" {
+ t.Errorf("unexpected resolved SHA256: %q", p.resolvedSHA256())
+ }
+ if p.displayName() != "my-pkg-1.2.3" {
+ t.Errorf("unexpected display name: %q", p.displayName())
+ }
+
+ pNoVer := &CustomPackage{Name: "simple"}
+ if pNoVer.displayName() != "simple" {
+ t.Errorf("unexpected display name: %q", pNoVer.displayName())
+ }
+}
+
+func TestCmpSemver(t *testing.T) {
+ tests := []struct {
+ a, b string
+ expected int
+ }{
+ {"1.2.3", "1.2.3", 0},
+ {"1.2.3", "1.2.4", -1},
+ {"1.3.0", "1.2.9", 1},
+ {"2.0.0", "10.0.0", -1},
+ {"1.10.2", "1.2.3", 1},
+ }
+
+ for _, tt := range tests {
+ res := cmpSemver(tt.a, tt.b)
+ // Normalize to -1, 0, 1
+ actual := 0
+ if res < 0 {
+ actual = -1
+ } else if res > 0 {
+ actual = 1
+ }
+ if actual != tt.expected {
+ t.Errorf("cmpSemver(%q, %q) expected %d, got %d (raw %d)", tt.a, tt.b, tt.expected, actual, res)
+ }
+ }
+}
+
+func TestVerifyArchive(t *testing.T) {
+ defer resetMocks()
+
+ tmp := t.TempDir()
+ archive := filepath.Join(tmp, "archive.tar.gz")
+ os.WriteFile(archive, []byte("archive-bytes"), 0644)
+ // Hash of "archive-bytes" is 0c982986710a026635603031674053ca851fc0e3ea760094a34f59b84f7f6da6
+ p := &CustomPackage{
+ Name: "test",
+ SHA256: "0c982986710a026635603031674053ca851fc0e3ea760094a34f59b84f7f6da6",
+ }
+
+ if !verifyArchive(archive, p) {
+ t.Error("expected verification to pass")
+ }
+
+ p.SHA256 = "incorrect-hash"
+ if verifyArchive(archive, p) {
+ t.Error("expected verification to fail")
+ }
+}
+
+func TestResolveLatestGo(t *testing.T) {
+ defer resetMocks()
+
+ osName = "linux"
+ archName = "x86_64"
+
+ fetchJSON = func(url string, v any) bool {
+ // Mock Go releases API response
+ // This encodes mock data into v (raw JSON Message decoding)
+ data := `[
+ {
+ "version": "go1.21.3",
+ "files": [
+ {
+ "filename": "go1.21.3.linux-amd64.tar.gz",
+ "kind": "archive",
+ "sha256": "go-sha-value"
+ }
+ ]
+ }
+ ]`
+ json.Unmarshal([]byte(data), v)
+ return true
+ }
+
+ version, sha, ok := resolveLatestGo(nil)
+ if !ok || version != "1.21.3" || sha != "go-sha-value" {
+ t.Errorf("unexpected resolve latest Go result: version=%q, sha=%q, ok=%v", version, sha, ok)
+ }
+}
+
+func TestResolveLatestFirecracker(t *testing.T) {
+ defer resetMocks()
+
+ isMacOS = false
+ archName = "x86_64"
+
+ fetchJSON = func(url string, v any) bool {
+ rel := v.(*ghRelease)
+ rel.TagName = "v1.5.0"
+ rel.Assets = []ghAsset{
+ {Name: "firecracker-v1.5.0-x86_64.tgz.sha256.txt", BrowserDownloadURL: "http://sha-url"},
+ }
+ return true
+ }
+
+ fetchText = func(url string) string {
+ return "firecracker-sha-value firecracker-v1.5.0-x86_64.tgz"
+ }
+
+ version, sha, ok := resolveLatestFirecracker(nil)
+ if !ok || version != "1.5.0" || sha != "firecracker-sha-value" {
+ t.Errorf("unexpected resolve latest firecracker result: version=%q, sha=%q, ok=%v", version, sha, ok)
+ }
+}
+
+func TestResolveLatestZig(t *testing.T) {
+ defer resetMocks()
+
+ osName = "linux"
+ archName = "x86_64"
+
+ fetchJSON = func(url string, v any) bool {
+ data := `{
+ "0.11.0": {
+ "x86_64-linux": {
+ "shasum": "zig-sha-value"
+ }
+ }
+ }`
+ json.Unmarshal([]byte(data), v)
+ return true
+ }
+
+ version, sha, ok := resolveLatestZig(nil)
+ if !ok || version != "0.11.0" || sha != "zig-sha-value" {
+ t.Errorf("unexpected resolve latest zig result: version=%q, sha=%q, ok=%v", version, sha, ok)
+ }
+}
+
+func TestIsCustomPkgInstalled(t *testing.T) {
+ defer resetMocks()
+
+ // Pip installed check
+ hasCmd = func(name string) bool {
+ return name == "python3"
+ }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 0}, true
+ }
+ pPip := &CustomPackage{Name: "pip"}
+ installed, _ := isCustomPkgInstalled(pPip)
+ if !installed {
+ t.Error("expected pip to be installed")
+ }
+
+ // Go check (installed check via default install path)
+ osStat = func(name string) (os.FileInfo, error) {
+ if name == "/usr/local/go" {
+ return nil, nil // exists
+ }
+ return nil, os.ErrNotExist
+ }
+ pGo := &CustomPackage{Name: "go"}
+ installedGo, _ := isCustomPkgInstalled(pGo)
+ if !installedGo {
+ t.Error("expected go to be installed")
+ }
+}
+
+func TestExpandHomeAndDefaultInstallPath(t *testing.T) {
+ defer resetMocks()
+
+ // Test expandHome
+ t.Setenv("HOME", "/my/home")
+ expanded := expandHome("~/test")
+ if expanded != "/my/home/test" {
+ t.Errorf("expected /my/home/test, got %q", expanded)
+ }
+ notExpanded := expandHome("/other/path")
+ if notExpanded != "/other/path" {
+ t.Errorf("expected /other/path, got %q", notExpanded)
+ }
+
+ // Test defaultInstallPath
+ p := &CustomPackage{Name: "go"}
+ if defaultInstallPath(p) != "/usr/local/go" {
+ t.Errorf("expected /usr/local/go, got %q", defaultInstallPath(p))
+ }
+ pUnknown := &CustomPackage{Name: "unknown"}
+ if defaultInstallPath(pUnknown) != "" {
+ t.Errorf("expected empty path, got %q", defaultInstallPath(pUnknown))
+ }
+}
+
+func TestUrlArchOK(t *testing.T) {
+ defer resetMocks()
+
+ archName = "x86_64"
+ p := &CustomPackage{Name: "test", URLTemplate: "http://example.com/test-x86_64.tar.gz"}
+ if !urlArchOK(p) {
+ t.Error("expected urlArchOK to return true for matching arch")
+ }
+
+ pBad := &CustomPackage{Name: "test", URLTemplate: "http://example.com/test-aarch64.tar.gz"}
+ if urlArchOK(pBad) {
+ t.Error("expected urlArchOK to return false for mismatching arch")
+ }
+}
+
+func TestInstallCustomPackages(t *testing.T) {
+ defer resetMocks()
+
+ hasCmd = func(name string) bool { return true }
+ download = func(url, dest string) bool {
+ // Write valid checksum file so it passes verification
+ // SHA256 of "content" is 751a073f248535132b178652553f1f317b3f1f90be68c078021481e33d443224
+ os.WriteFile(dest, []byte("content"), 0644)
+ return true
+ }
+
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+
+ pkgs := []*CustomPackage{
+ {
+ Name: "go",
+ Version: "1.21.0",
+ URLTemplate: "http://example.com/go.tar.gz",
+ SHA256: "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73",
+ },
+ {
+ Name: "zig",
+ Version: "0.11.0",
+ URLTemplate: "http://example.com/zig.tar.gz",
+ SHA256: "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73",
+ },
+ }
+
+ installCustomPackages(pkgs)
+
+ // Verify that we executed tar/mv/ln etc commands via runCmd
+ hasTar := false
+ for _, call := range runCmdCalls {
+ if call[0] == "tar" {
+ hasTar = true
+ }
+ }
+ if !hasTar {
+ t.Errorf("expected tar command to be executed, got: %v", runCmdCalls)
+ }
+}
+
+func TestInstallFirecracker(t *testing.T) {
+ defer resetMocks()
+ tmp := t.TempDir()
+ archive := filepath.Join(tmp, "firecracker.tgz")
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ if argv[0] == "tar" {
+ dummyBin := filepath.Join(tmp, "firecracker-v1.5.0")
+ os.WriteFile(dummyBin, []byte("binary-content"), 0755)
+ }
+ return CmdResult{ExitCode: 0}
+ }
+ installFirecracker(archive, tmp)
+
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ return CmdResult{ExitCode: 1}
+ }
+ installFirecracker(archive, tmp)
+
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ return CmdResult{ExitCode: 0}
+ }
+ installFirecracker(archive, tmp)
+}
+
+func TestInstallNeovim(t *testing.T) {
+ defer resetMocks()
+ tmp := t.TempDir()
+ osName = "linux"
+ archName = "x86_64"
+
+ fetchJSON = func(url string, v any) bool {
+ rel := v.(*ghRelease)
+ rel.Assets = []ghAsset{
+ {
+ Name: "nvim-linux-x86_64.tar.gz",
+ BrowserDownloadURL: "http://example.com/nvim.tar.gz",
+ Digest: "sha256:0c982986710a026635603031674053ca851fc0e3ea760094a34f59b84f7f6da6",
+ },
+ }
+ return true
+ }
+
+ download = func(url, dest string) bool {
+ os.WriteFile(dest, []byte("archive-bytes"), 0644)
+ return true
+ }
+
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ return CmdResult{ExitCode: 0}
+ }
+
+ installNeovim(nil, tmp)
+
+ fetchJSON = func(url string, v any) bool { return false }
+ installNeovim(nil, tmp)
+
+ fetchJSON = func(url string, v any) bool {
+ rel := v.(*ghRelease)
+ rel.Assets = []ghAsset{{Name: "other-name"}}
+ return true
+ }
+ installNeovim(nil, tmp)
+
+ fetchJSON = func(url string, v any) bool {
+ rel := v.(*ghRelease)
+ rel.Assets = []ghAsset{{Name: "nvim-linux-x86_64.tar.gz", Digest: "bad-digest"}}
+ return true
+ }
+ installNeovim(nil, tmp)
+
+ fetchJSON = func(url string, v any) bool {
+ rel := v.(*ghRelease)
+ rel.Assets = []ghAsset{{Name: "nvim-linux-x86_64.tar.gz", Digest: "sha256:0c982986710a026635603031674053ca851fc0e3ea760094a34f59b84f7f6da6"}}
+ return true
+ }
+ download = func(url, dest string) bool { return false }
+ installNeovim(nil, tmp)
+
+ download = func(url, dest string) bool {
+ os.WriteFile(dest, []byte("different-bytes"), 0644)
+ return true
+ }
+ installNeovim(nil, tmp)
+}
+
+func TestResolveLatestEdgeCases(t *testing.T) {
+ defer resetMocks()
+
+ pkg := &CustomPackage{Name: "test", FetchLatest: "nonexistent"}
+ resolveLatest(pkg)
+
+ latestResolvers["panic-resolver"] = func(pkg *CustomPackage) (string, string, bool) {
+ panic("simulated panic")
+ }
+ pkgPanic := &CustomPackage{Name: "test", FetchLatest: "panic-resolver", Version: "1.0.0"}
+ resolveLatest(pkgPanic)
+
+ latestResolvers["fail-resolver"] = func(pkg *CustomPackage) (string, string, bool) {
+ return "", "", false
+ }
+ pkgFail := &CustomPackage{Name: "test", FetchLatest: "fail-resolver", Version: "1.0.0"}
+ resolveLatest(pkgFail)
+
+ latestResolvers["same-resolver"] = func(pkg *CustomPackage) (string, string, bool) {
+ return "1.0.0", "hash", true
+ }
+ pkgSame := &CustomPackage{Name: "test", FetchLatest: "same-resolver", Version: "1.0.0"}
+ resolveLatest(pkgSame)
+}
+
diff --git a/detect.go b/detect.go
index 7a9000a..43868e7 100644
--- a/detect.go
+++ b/detect.go
@@ -35,7 +35,7 @@ func detectOS() string {
return "macos"
default:
fmt.Fprintf(os.Stderr, "Unsupported OS: %s (supports Linux, Darwin)\n", runtime.GOOS)
- os.Exit(1)
+ osExit(1)
return ""
}
}
@@ -48,7 +48,7 @@ func detectArch() string {
return "aarch64"
default:
fmt.Fprintf(os.Stderr, "Unsupported architecture: %s (supports x86_64, aarch64)\n", runtime.GOARCH)
- os.Exit(1)
+ osExit(1)
return ""
}
}
@@ -116,7 +116,7 @@ func hasOtherArchToken(name string) bool {
// stripped of surrounding quotes. Returns "" if the file is missing or the
// field is absent.
func osReleaseField(field string) string {
- data, err := os.ReadFile("/etc/os-release")
+ data, err := osReadFile(osReleasePath)
if err != nil {
return ""
}
@@ -130,7 +130,7 @@ func osReleaseField(field string) string {
}
func detectRHELFamily() bool {
- data, err := os.ReadFile("/etc/os-release")
+ data, err := osReadFile(osReleasePath)
if err != nil {
return pkgMgr == "dnf"
}
@@ -152,7 +152,7 @@ func detectRHELFamily() bool {
}
func detectArchFamily() bool {
- data, err := os.ReadFile("/etc/os-release")
+ data, err := osReadFile(osReleasePath)
if err != nil {
return pkgMgr == "pacman"
}
diff --git a/detect_test.go b/detect_test.go
new file mode 100644
index 0000000..4770a8f
--- /dev/null
+++ b/detect_test.go
@@ -0,0 +1,135 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestDetectOSAndArch(t *testing.T) {
+ // detectOS and detectArch call os.Exit on unsupported platforms.
+ // Since the tests run on a supported platform, let's verify they return valid values.
+ osVal := detectOS()
+ if osVal != "linux" && osVal != "macos" {
+ t.Errorf("expected linux or macos, got %q", osVal)
+ }
+
+ archVal := detectArch()
+ if archVal != "x86_64" && archVal != "aarch64" {
+ t.Errorf("expected x86_64 or aarch64, got %q", archVal)
+ }
+}
+
+func TestFormatURL(t *testing.T) {
+ defer resetMocks()
+
+ osName = "linux"
+ archName = "x86_64"
+
+ tmpl := "https://example.com/download/go-{version}-{os}-{arch}.tar.gz"
+ expected := "https://example.com/download/go-1.20-linux-x86_64.tar.gz"
+ actual := formatURL(tmpl, "1.20")
+ if actual != expected {
+ t.Errorf("expected %q, got %q", expected, actual)
+ }
+}
+
+func TestOtherArch(t *testing.T) {
+ defer resetMocks()
+
+ archName = "x86_64"
+ if otherArch() != "aarch64" {
+ t.Errorf("expected aarch64, got %q", otherArch())
+ }
+
+ archName = "aarch64"
+ if otherArch() != "x86_64" {
+ t.Errorf("expected x86_64, got %q", otherArch())
+ }
+}
+
+func TestArchMatchesAndTokens(t *testing.T) {
+ if !archMatches("file-amd64", "x86_64") {
+ t.Error("expected true for file-amd64 and x86_64")
+ }
+ if archMatches("file-arm64", "x86_64") {
+ t.Error("expected false for file-arm64 and x86_64")
+ }
+
+ archName = "x86_64"
+ if !hasOtherArchToken("file-arm64") {
+ t.Error("expected true for file-arm64 when arch is x86_64")
+ }
+}
+
+func TestOSReleaseField(t *testing.T) {
+ defer resetMocks()
+
+ tmp := t.TempDir()
+ osReleasePath = filepath.Join(tmp, "os-release")
+
+ content := `NAME="Fedora Linux"
+VERSION="40 (Workstation Edition)"
+ID=fedora
+VERSION_ID=40
+`
+ if err := os.WriteFile(osReleasePath, []byte(content), 0644); err != nil {
+ t.Fatalf("failed to write mock os-release: %v", err)
+ }
+
+ if val := osReleaseField("ID"); val != "fedora" {
+ t.Errorf("expected fedora, got %q", val)
+ }
+ if val := osReleaseField("VERSION_ID"); val != "40" {
+ t.Errorf("expected 40, got %q", val)
+ }
+ if val := osReleaseField("NONEXISTENT"); val != "" {
+ t.Errorf("expected empty string, got %q", val)
+ }
+
+ // Missing file case
+ osReleasePath = filepath.Join(tmp, "nonexistent")
+ if val := osReleaseField("ID"); val != "" {
+ t.Errorf("expected empty string for missing file, got %q", val)
+ }
+}
+
+func TestDetectRHELAndArchFamilies(t *testing.T) {
+ defer resetMocks()
+
+ tmp := t.TempDir()
+ osReleasePath = filepath.Join(tmp, "os-release")
+
+ // Test Fedora (RHEL family)
+ if err := os.WriteFile(osReleasePath, []byte("ID=fedora\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if !detectRHELFamily() {
+ t.Error("expected fedora to be detected as RHEL family")
+ }
+ if detectArchFamily() {
+ t.Error("expected fedora to NOT be detected as Arch family")
+ }
+
+ // Test Arch (Arch family)
+ if err := os.WriteFile(osReleasePath, []byte("ID_LIKE=\"arch\"\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if detectRHELFamily() {
+ t.Error("expected arch to NOT be detected as RHEL family")
+ }
+ if !detectArchFamily() {
+ t.Error("expected arch to be detected as Arch family")
+ }
+
+ // Missing file fallback cases
+ osReleasePath = filepath.Join(tmp, "nonexistent")
+ pkgMgr = "dnf"
+ if !detectRHELFamily() {
+ t.Error("expected dnf manager fallback to RHEL family")
+ }
+ pkgMgr = "pacman"
+ if !detectArchFamily() {
+ t.Error("expected pacman manager fallback to Arch family")
+ }
+}
diff --git a/exec.go b/exec.go
index 2028117..5f6465e 100644
--- a/exec.go
+++ b/exec.go
@@ -37,8 +37,8 @@ type CmdResult struct {
func (r CmdResult) OK() bool { return r.Err == nil && r.ExitCode == 0 }
-// runCmd executes argv with the supplied options.
-func runCmd(argv []string, opts CmdOpts) CmdResult {
+// runCmdReal executes argv with the supplied options.
+func runCmdReal(argv []string, opts CmdOpts) CmdResult {
if opts.Timeout == 0 {
opts.Timeout = defaultSubprocessTimeout
}
@@ -90,9 +90,9 @@ func runCmd(argv []string, opts CmdOpts) CmdResult {
return res
}
-// runShell executes a single shell string via /bin/sh -c (matching the Python
+// runShellReal executes a single shell string via /bin/sh -c (matching the Python
// version's subprocess.run(..., shell=True)).
-func runShell(cmd string, opts CmdOpts) CmdResult {
+func runShellReal(cmd string, opts CmdOpts) CmdResult {
if opts.Timeout == 0 {
opts.Timeout = defaultSubprocessTimeout
}
@@ -141,16 +141,16 @@ func runShell(cmd string, opts CmdOpts) CmdResult {
return res
}
-// hasCmd is shutil.which() — returns true if name resolves on PATH.
-func hasCmd(name string) bool {
+// hasCmdReal is shutil.which() — returns true if name resolves on PATH.
+func hasCmdReal(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
-// probe is a short, read-only command invocation used for "is this installed"
+// probeReal is a short, read-only command invocation used for "is this installed"
// checks. Returns (result, true) on completion (including non-zero exit) and
// (zero, false) on timeout/launch failure.
-func probe(argv []string, timeout time.Duration) (CmdResult, bool) {
+func probeReal(argv []string, timeout time.Duration) (CmdResult, bool) {
if timeout == 0 {
timeout = 30 * time.Second
}
diff --git a/exec_test.go b/exec_test.go
new file mode 100644
index 0000000..a69d03e
--- /dev/null
+++ b/exec_test.go
@@ -0,0 +1,151 @@
+package main
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestCmdResultOK(t *testing.T) {
+ res1 := CmdResult{ExitCode: 0, Err: nil}
+ if !res1.OK() {
+ t.Error("expected ExitCode 0 and Err nil to be OK")
+ }
+
+ res2 := CmdResult{ExitCode: 1, Err: nil}
+ if res2.OK() {
+ t.Error("expected ExitCode 1 to NOT be OK")
+ }
+}
+
+func TestRunCmdReal(t *testing.T) {
+ // Simple echo check
+ res := runCmdReal([]string{"echo", "hello world"}, CmdOpts{Capture: true})
+ if !res.OK() {
+ t.Errorf("expected OK command, got result: %+v", res)
+ }
+ out := strings.TrimSpace(string(res.Stdout))
+ if out != "hello world" {
+ t.Errorf("expected 'hello world', got %q", out)
+ }
+
+ // Exit failure check
+ resFail := runCmdReal([]string{"false"}, CmdOpts{Capture: true})
+ if resFail.OK() {
+ t.Error("expected false command to fail")
+ }
+ if resFail.ExitCode != 1 {
+ t.Errorf("expected exit code 1, got %d", resFail.ExitCode)
+ }
+
+ // Timeout check (using a short timeout)
+ resTimeout := runCmdReal([]string{"sleep", "5"}, CmdOpts{Timeout: 10 * time.Millisecond, Capture: true})
+ if resTimeout.ExitCode != 124 {
+ t.Errorf("expected exit code 124 (timeout), got %d", resTimeout.ExitCode)
+ }
+}
+
+func TestRunShellReal(t *testing.T) {
+ // Simple shell command
+ res := runShellReal("echo shell hello", CmdOpts{Capture: true})
+ if !res.OK() {
+ t.Errorf("expected OK shell command, got result: %+v", res)
+ }
+ out := strings.TrimSpace(string(res.Stdout))
+ if out != "shell hello" {
+ t.Errorf("expected 'shell hello', got %q", out)
+ }
+
+ // Failed command
+ resFail := runShellReal("exit 42", CmdOpts{Capture: true})
+ if resFail.OK() {
+ t.Error("expected shell command with exit 42 to fail")
+ }
+ if resFail.ExitCode != 42 {
+ t.Errorf("expected exit code 42, got %d", resFail.ExitCode)
+ }
+}
+
+func TestHasCmdReal(t *testing.T) {
+ if !hasCmdReal("go") {
+ t.Error("expected hasCmdReal('go') to return true (running go test)")
+ }
+ if hasCmdReal("nonexistent-command-xyz") {
+ t.Error("expected nonexistent command to return false")
+ }
+}
+
+func TestProbeReal(t *testing.T) {
+ res, ok := probeReal([]string{"echo", "probe"}, 0)
+ if !ok {
+ t.Error("expected probe to succeed")
+ }
+ if strings.TrimSpace(string(res.Stdout)) != "probe" {
+ t.Errorf("expected 'probe', got %q", string(res.Stdout))
+ }
+
+ // Test a failing command probe
+ resFail, okFail := probeReal([]string{"false"}, 0)
+ if !okFail {
+ t.Error("expected probe check of false command to return ok=true (meaning it completed and didn't crash/timeout)")
+ }
+ if resFail.ExitCode != 1 {
+ t.Errorf("expected exit code 1, got %d", resFail.ExitCode)
+ }
+
+ // Test an invalid command (launch failure)
+ _, okErr := probeReal([]string{"nonexistent-executable-file"}, 0)
+ if okErr {
+ t.Error("expected probe to return false on launch failure")
+ }
+}
+
+func TestExecRealEdgeCases(t *testing.T) {
+ // 2. Cwd and Input in runCmdReal
+ tmp := t.TempDir()
+ resCwd := runCmdReal([]string{"pwd"}, CmdOpts{Cwd: tmp, Capture: true})
+ if !resCwd.OK() {
+ t.Errorf("pwd failed: %+v", resCwd)
+ }
+
+ resInput := runCmdReal([]string{"cat"}, CmdOpts{Input: []byte("my-input"), Capture: true})
+ if !resInput.OK() || strings.TrimSpace(string(resInput.Stdout)) != "my-input" {
+ t.Errorf("cat input failed, got result: %+v", resInput)
+ }
+
+ // 3. Capture = false in runCmdReal
+ _ = runCmdReal([]string{"echo", "capture-false-cmd"}, CmdOpts{Capture: false})
+
+ // 4. Launch failure in runCmdReal (not exit error)
+ resLaunch := runCmdReal([]string{"nonexistent-command-12345"}, CmdOpts{Capture: true})
+ if resLaunch.OK() || resLaunch.ExitCode != 1 || resLaunch.Err == nil {
+ t.Errorf("expected launch failure, got: %+v", resLaunch)
+ }
+
+ // 5. Cwd and Input in runShellReal
+ resShellCwd := runShellReal("pwd", CmdOpts{Cwd: tmp, Capture: true})
+ if !resShellCwd.OK() {
+ t.Errorf("shell pwd failed: %+v", resShellCwd)
+ }
+
+ resShellInput := runShellReal("cat", CmdOpts{Input: []byte("my-shell-input"), Capture: true})
+ if !resShellInput.OK() || strings.TrimSpace(string(resShellInput.Stdout)) != "my-shell-input" {
+ t.Errorf("shell cat input failed, got: %+v", resShellInput)
+ }
+
+ // 6. Capture = false in runShellReal
+ _ = runShellReal("echo capture-false-shell", CmdOpts{Capture: false})
+
+ // 7. Timeout in runShellReal
+ resShellTimeout := runShellReal("sleep 5", CmdOpts{Timeout: 10 * time.Millisecond, Capture: true})
+ if resShellTimeout.ExitCode != 124 {
+ t.Errorf("expected exit code 124 for shell timeout, got %d", resShellTimeout.ExitCode)
+ }
+
+ // 8. Timeout in probeReal
+ _, okProbeTimeout := probeReal([]string{"sleep", "5"}, 10*time.Millisecond)
+ if okProbeTimeout {
+ t.Error("expected probe to return ok=false on timeout")
+ }
+}
+
diff --git a/flatpak_test.go b/flatpak_test.go
new file mode 100644
index 0000000..e2c360b
--- /dev/null
+++ b/flatpak_test.go
@@ -0,0 +1,111 @@
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestInstallFlatpakPackages(t *testing.T) {
+ defer resetMocks()
+
+ // Case 1: flatpak already installed
+ hasCmd = func(name string) bool {
+ return name == "flatpak"
+ }
+
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+
+ installFlatpakPackages([]string{"org.gimp.GIMP"})
+
+ if len(runCmdCalls) != 2 {
+ t.Fatalf("expected 2 runCmd calls, got %d: %v", len(runCmdCalls), runCmdCalls)
+ }
+
+ // First call should add flathub remote
+ if runCmdCalls[0][1] != "remote-add" {
+ t.Errorf("expected remote-add, got %v", runCmdCalls[0])
+ }
+ // Second call should install GIMP
+ if runCmdCalls[1][1] != "install" || runCmdCalls[1][4] != "org.gimp.GIMP" {
+ t.Errorf("expected install GIMP, got %v", runCmdCalls[1])
+ }
+
+ // Case 2: flatpak not installed, choose not to install
+ resetMocks()
+ var askedPrompt string
+ stdin = strings.NewReader("n\n") // Abort flatpak installation
+ hasCmd = func(name string) bool {
+ return false
+ }
+ runCmdCalls = nil
+ installFlatpakPackages([]string{"org.gimp.GIMP"})
+ if len(runCmdCalls) != 0 {
+ t.Errorf("expected no flatpak installs if skipped, got calls: %v", runCmdCalls)
+ }
+ _ = askedPrompt
+
+ // Case 3: flatpak not installed, choose to install
+ resetMocks()
+ pkgMgr = "dnf"
+ stdin = strings.NewReader("y\n")
+ flatpakInstalled := false
+ hasCmd = func(name string) bool {
+ if name == "flatpak" {
+ return flatpakInstalled
+ }
+ return false
+ }
+ var runCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCalls = append(runCalls, argv)
+ if len(argv) >= 4 && argv[0] == "dnf" && argv[1] == "install" && argv[3] == "flatpak" {
+ flatpakInstalled = true
+ }
+ return CmdResult{ExitCode: 0}
+ }
+ installFlatpakPackages([]string{"org.gimp.GIMP"})
+ // It should call dnf install flatpak, then remote-add, then install GIMP
+ foundInstall := false
+ for _, call := range runCalls {
+ if len(call) >= 4 && call[0] == "dnf" && call[1] == "install" && call[3] == "flatpak" {
+ foundInstall = true
+ }
+ }
+ if !foundInstall {
+ t.Errorf("expected dnf install flatpak to be called, got calls: %v", runCalls)
+ }
+}
+
+func TestInstallFlatpakFailures(t *testing.T) {
+ defer resetMocks()
+
+ stdin = strings.NewReader("y\n")
+ hasCmd = func(name string) bool { return false }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ return CmdResult{ExitCode: 1}
+ }
+ installFlatpakPackages([]string{"org.gimp.GIMP"})
+
+ resetMocks()
+ stdin = strings.NewReader("y\n")
+ hasCmd = func(name string) bool { return false }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ return CmdResult{ExitCode: 0}
+ }
+ installFlatpakPackages([]string{"org.gimp.GIMP"})
+
+ resetMocks()
+ hasCmd = func(name string) bool { return name == "flatpak" }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ if argv[1] == "install" {
+ return CmdResult{ExitCode: 1}
+ }
+ return CmdResult{ExitCode: 0}
+ }
+ installFlatpakPackages([]string{"org.gimp.GIMP"})
+}
+
diff --git a/go.mod b/go.mod
index 6a761ef..cda42a5 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,24 @@
module github.com/JMR-dev/bootstrap_dev_env
-go 1.24.7
+go 1.25.0
+
+require dagger.io/dagger v0.20.8
+
+require (
+ github.com/99designs/gqlgen v0.17.89 // indirect
+ github.com/Khan/genqlient v0.8.1 // indirect
+ github.com/adrg/xdg v0.5.3 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/mitchellh/go-homedir v1.1.0 // indirect
+ github.com/sosodev/duration v1.4.0 // indirect
+ github.com/vektah/gqlparser/v2 v2.5.32 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/otel v1.41.0 // indirect
+ go.opentelemetry.io/otel/metric v1.41.0 // indirect
+ go.opentelemetry.io/otel/trace v1.41.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.42.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..76984f4
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,51 @@
+dagger.io/dagger v0.20.8 h1:n+Xtzp9ufNwCH3Ftob92Smu3smfUDoQshDYM6Ys4yf0=
+dagger.io/dagger v0.20.8/go.mod h1:ZXg8+pQZaZUC8rAw4V/gPP8aKvKARIJZ+pfcV+RC1es=
+github.com/99designs/gqlgen v0.17.89 h1:KzEcxPiMgQoMw3m/E85atUEHyZyt0PbAflMia5Kw8z8=
+github.com/99designs/gqlgen v0.17.89/go.mod h1:GFqruTVGB7ZTdrf1uzOagpXbY7DrEt1pIxnTdhIbWvQ=
+github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs=
+github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU=
+github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
+github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
+github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
+github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
+github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
+github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
+github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
+github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
+github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
+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/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
+github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
+go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
+go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
+go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
+go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
+go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+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/helper_test.go b/helper_test.go
new file mode 100644
index 0000000..283306a
--- /dev/null
+++ b/helper_test.go
@@ -0,0 +1,42 @@
+package main
+
+import (
+ "os"
+)
+
+func resetMocks() {
+ runCmd = runCmdReal
+ runShell = runShellReal
+ hasCmd = hasCmdReal
+ probe = probeReal
+
+ download = downloadReal
+ fetchJSON = fetchJSONReal
+ fetchText = fetchTextReal
+
+ osStat = os.Stat
+ osReadFile = os.ReadFile
+ osWriteFile = os.WriteFile
+ osMkdirAll = os.MkdirAll
+ osRemove = os.Remove
+ osRemoveAll = os.RemoveAll
+ osRename = os.Rename
+ osExit = os.Exit
+ stdin = os.Stdin
+
+ // Reset global state variables to safe defaults
+ isMacOS = false
+ pkgMgr = "dnf"
+ isRHELFamily = true
+ isArchFamily = false
+ osName = "linux"
+ archName = "x86_64"
+
+ osReleasePath = "/etc/os-release"
+ passwdPath = "/etc/passwd"
+
+ issuesMu.Lock()
+ issues = nil
+ notices = nil
+ issuesMu.Unlock()
+}
diff --git a/issues.go b/issues.go
index 6363a03..f1a0076 100644
--- a/issues.go
+++ b/issues.go
@@ -54,7 +54,7 @@ func writeRunLog() {
lines := []string{fmt.Sprintf("# Bootstrap run — %s", ts), ""}
lines = append(lines, issues...)
content := strings.Join(lines, "\n") + "\n"
- if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ if err := osWriteFile(path, []byte(content), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "failed to write run log: %v\n", err)
return
}
diff --git a/issues_test.go b/issues_test.go
new file mode 100644
index 0000000..0ed99db
--- /dev/null
+++ b/issues_test.go
@@ -0,0 +1,113 @@
+package main
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestIssuesLogging(t *testing.T) {
+ defer resetMocks()
+ resetMocks()
+
+ // Capture stdout
+ oldStdout := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+
+ warn("something is deprecated")
+ errLog("something failed")
+ notice("please restart shell")
+
+ w.Close()
+ os.Stdout = oldStdout
+
+ var buf bytes.Buffer
+ io.Copy(&buf, r)
+ output := buf.String()
+
+ if !strings.Contains(output, "[WARN] something is deprecated") {
+ t.Errorf("stdout missing warning: %q", output)
+ }
+ if !strings.Contains(output, "[ERROR] something failed") {
+ t.Errorf("stdout missing error: %q", output)
+ }
+
+ issuesMu.Lock()
+ issueLen := len(issues)
+ noticeLen := len(notices)
+ issuesMu.Unlock()
+
+ if issueLen != 2 {
+ t.Errorf("expected 2 logged issues, got %d", issueLen)
+ }
+ if noticeLen != 1 {
+ t.Errorf("expected 1 notice, got %d", noticeLen)
+ }
+}
+
+func TestWriteRunLog(t *testing.T) {
+ defer resetMocks()
+
+ tmp := t.TempDir()
+ _ = tmp
+
+ // Redirect path function or mock executable path
+ // In issues.go, we can define a package variable to override runLogPath if we want,
+ // or we can mock osWriteFile. Since we mocked osWriteFile, let's use that!
+ var writtenPath string
+ var writtenData []byte
+ osWriteFile = func(path string, data []byte, perm os.FileMode) error {
+ writtenPath = path
+ writtenData = data
+ return nil
+ }
+
+ // No issues case
+ writeRunLog()
+ if writtenPath != "" {
+ t.Error("expected run log not to be written when there are no issues")
+ }
+
+ // Add an issue
+ warn("test warning")
+ writeRunLog()
+
+ if writtenPath == "" {
+ t.Fatal("expected run log to be written")
+ }
+ if !strings.Contains(string(writtenData), "[WARN] test warning") {
+ t.Errorf("expected log to contain the warning, got: %s", string(writtenData))
+ }
+}
+
+func TestPrintNotices(t *testing.T) {
+ defer resetMocks()
+
+ // Capture stdout
+ oldStdout := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+
+ printNotices() // Should be empty
+
+ notice("first notice")
+ notice("second notice")
+ printNotices()
+
+ w.Close()
+ os.Stdout = oldStdout
+
+ var buf bytes.Buffer
+ io.Copy(&buf, r)
+ output := buf.String()
+
+ if !strings.Contains(output, "Notices:") {
+ t.Error("stdout missing notices header")
+ }
+ if !strings.Contains(output, "first notice") || !strings.Contains(output, "second notice") {
+ t.Errorf("stdout missing notice contents: %q", output)
+ }
+}
diff --git a/macos.go b/macos.go
index 9590083..1b1166c 100644
--- a/macos.go
+++ b/macos.go
@@ -57,14 +57,14 @@ func ensureHomebrew() {
installer := `NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`
if !runShell(installer, CmdOpts{}).OK() {
fmt.Fprintln(os.Stderr, "Homebrew installation failed")
- os.Exit(1)
+ osExit(1)
}
brewBinDir := filepath.Join(brewPrefix(), "bin")
brewPath := filepath.Join(brewBinDir, "brew")
- if _, err := os.Stat(brewPath); err != nil {
+ if _, err := osStat(brewPath); err != nil {
fmt.Fprintf(os.Stderr, "Homebrew installed but brew not found at %s\n", brewPath)
- os.Exit(1)
+ osExit(1)
}
os.Setenv("PATH", brewBinDir+":"+os.Getenv("PATH"))
@@ -286,19 +286,19 @@ runcmd:
`
func writeCloudInitSeed(seedDir, pubkey string) error {
- if err := os.MkdirAll(seedDir, 0o755); err != nil {
+ if err := osMkdirAll(seedDir, 0o755); err != nil {
return err
}
userData := fmt.Sprintf(firecrackerUserdataTmpl, vmUser, strings.TrimSpace(pubkey))
- if err := os.WriteFile(filepath.Join(seedDir, "user-data"), []byte(userData), 0o644); err != nil {
+ if err := osWriteFile(filepath.Join(seedDir, "user-data"), []byte(userData), 0o644); err != nil {
return err
}
- return os.WriteFile(filepath.Join(seedDir, "meta-data"),
+ return osWriteFile(filepath.Join(seedDir, "meta-data"),
[]byte("instance-id: firecracker-vm\nlocal-hostname: firecracker-vm\n"), 0o644)
}
func buildSeedISO(seedDir, isoPath string) bool {
- os.Remove(isoPath)
+ osRemove(isoPath)
return runCmd([]string{
"hdiutil", "makehybrid", "-iso", "-joliet",
"-default-volume-name", "cidata",
@@ -346,7 +346,7 @@ fi
rm -f %s
%s`, dir, vmPIDName, vmPIDName, vmPIDName, qemuBlock)
- os.WriteFile(scriptPath, []byte(script), 0o755)
+ osWriteFile(scriptPath, []byte(script), 0o755)
return scriptPath
}
@@ -440,7 +440,7 @@ func installFirecrackerZshFunction(content string) {
home, _ := os.UserHomeDir()
zshrc := filepath.Join(home, ".zshrc")
existing := ""
- if b, err := os.ReadFile(zshrc); err == nil {
+ if b, err := osReadFile(zshrc); err == nil {
existing = string(b)
}
pattern := regexp.MustCompile(`(?s)` + regexp.QuoteMeta(firecrackerFnBeg) + `.*?` + regexp.QuoteMeta(firecrackerFnEnd) + `\n?`)
@@ -454,7 +454,7 @@ func installFirecrackerZshFunction(content string) {
newContent = content
}
}
- os.WriteFile(zshrc, []byte(newContent), 0o644)
+ osWriteFile(zshrc, []byte(newContent), 0o644)
fmt.Printf(" Wrote firecracker() function block to %s\n", zshrc)
}
@@ -488,7 +488,7 @@ func provisionVirtualBoxVM(qcow2, seedISO string) string {
exists := ok && r.ExitCode == 0
if !exists {
- if _, err := os.Stat(vdi); os.IsNotExist(err) {
+ if _, err := osStat(vdi); os.IsNotExist(err) {
fmt.Printf(" Converting %s → %s (VirtualBox VDI) ...\n", filepath.Base(qcow2), filepath.Base(vdi))
if !runCmd([]string{"VBoxManage", "clonemedium", "disk", qcow2, vdi, "--format", "VDI"}, CmdOpts{}).OK() {
errLog("VBoxManage clonemedium failed")
@@ -497,7 +497,7 @@ func provisionVirtualBoxVM(qcow2, seedISO string) string {
runCmd([]string{"VBoxManage", "modifymedium", "disk", vdi, "--resize", "10240"}, CmdOpts{})
}
fmt.Printf(" Creating VirtualBox VM '%s' ...\n", vmName)
- os.MkdirAll(vboxBase, 0o755)
+ osMkdirAll(vboxBase, 0o755)
if !runCmd([]string{
"VBoxManage", "createvm",
"--name", vmName,
@@ -543,7 +543,7 @@ if VBoxManage list runningvms | grep -q '"%s"'; then
fi
exec VBoxManage startvm %s --type headless
`, vmName, vmName)
- os.WriteFile(scriptPath, []byte(script), 0o755)
+ osWriteFile(scriptPath, []byte(script), 0o755)
return scriptPath
}
@@ -558,11 +558,11 @@ func setupFirecrackerVM() {
fmt.Println("\n=== macOS firecracker VM (Fedora) ===")
dir := vmDir()
- os.MkdirAll(dir, 0o755)
+ osMkdirAll(dir, 0o755)
privKey := filepath.Join(dir, vmKeyName)
pubKey := privKey + ".pub"
- if _, err := os.Stat(privKey); os.IsNotExist(err) {
+ if _, err := osStat(privKey); os.IsNotExist(err) {
fmt.Printf(" Generating SSH keypair at %s ...\n", privKey)
if !runCmd([]string{"ssh-keygen", "-t", "ed25519", "-N", "", "-f", privKey, "-q"}, CmdOpts{}).OK() {
errLog("ssh-keygen failed — aborting VM setup")
@@ -571,7 +571,7 @@ func setupFirecrackerVM() {
}
qcow2 := filepath.Join(dir, vmQcow2Name)
- if _, err := os.Stat(qcow2); err == nil {
+ if _, err := osStat(qcow2); err == nil {
fmt.Printf(" Reusing existing Fedora image at %s\n", qcow2)
} else {
fmt.Println(" Looking up latest Fedora cloud image ...")
@@ -587,10 +587,10 @@ func setupFirecrackerVM() {
return
}
if !verifyFedoraQcow2(downloadDest, checksumURL) {
- os.Remove(downloadDest)
+ osRemove(downloadDest)
return
}
- os.Rename(downloadDest, qcow2)
+ osRename(downloadDest, qcow2)
if hasCmd("qemu-img") {
fmt.Println(" Resizing image to 10G ...")
runCmd([]string{"qemu-img", "resize", qcow2, "10G"}, CmdOpts{})
@@ -599,7 +599,7 @@ func setupFirecrackerVM() {
fmt.Println(" Building cloud-init seed ISO ...")
seedDir := filepath.Join(dir, "seed")
- pubKeyBytes, err := os.ReadFile(pubKey)
+ pubKeyBytes, err := osReadFile(pubKey)
if err != nil {
errLog(fmt.Sprintf("could not read public key: %v", err))
return
diff --git a/macos_test.go b/macos_test.go
new file mode 100644
index 0000000..49e324d
--- /dev/null
+++ b/macos_test.go
@@ -0,0 +1,707 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestBrewPrefix(t *testing.T) {
+ defer resetMocks()
+
+ archName = "aarch64"
+ if brewPrefix() != "/opt/homebrew" {
+ t.Errorf("expected /opt/homebrew on Apple Silicon, got %q", brewPrefix())
+ }
+
+ archName = "x86_64"
+ if brewPrefix() != "/usr/local" {
+ t.Errorf("expected /usr/local on Intel, got %q", brewPrefix())
+ }
+}
+
+func TestEnsureXcodeCLT(t *testing.T) {
+ defer resetMocks()
+
+ isMacOS = true
+ var probeCalls [][]string
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ probeCalls = append(probeCalls, argv)
+ // First call returns exit code 1 (not installed), then subsequent calls return 0 (installed)
+ if len(probeCalls) == 1 {
+ return CmdResult{ExitCode: 1}, true
+ }
+ return CmdResult{ExitCode: 0, Stdout: []byte("/Library/Developer/CommandLineTools")}, true
+ }
+
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+
+ ensureXcodeCLT()
+
+ if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "xcode-select" || runCmdCalls[0][1] != "--install" {
+ t.Errorf("expected xcode-select --install call, got: %v", runCmdCalls)
+ }
+ if len(probeCalls) < 2 {
+ t.Errorf("expected at least 2 probe checks, got %d", len(probeCalls))
+ }
+}
+
+func TestEnsureHomebrew(t *testing.T) {
+ defer resetMocks()
+
+ isMacOS = true
+ hasCmd = func(name string) bool {
+ return false // Not installed
+ }
+
+ var runShellCmd string
+ runShell = func(cmd string, opts CmdOpts) CmdResult {
+ runShellCmd = cmd
+ return CmdResult{ExitCode: 0}
+ }
+
+ osStat = func(name string) (os.FileInfo, error) {
+ // Mock homebrew path check returning exists
+ if strings.HasSuffix(name, "brew") {
+ return nil, nil
+ }
+ return nil, os.ErrNotExist
+ }
+
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+
+ ensureHomebrew()
+
+ if !strings.Contains(runShellCmd, "Homebrew/install/HEAD/install.sh") {
+ t.Errorf("unexpected installer shell command: %q", runShellCmd)
+ }
+ if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "bash" || !strings.Contains(runCmdCalls[0][2], "shellenv") {
+ t.Errorf("expected shellenv zprofile command, got: %v", runCmdCalls)
+ }
+}
+
+func TestMacosMajorAndSiliconGen(t *testing.T) {
+ defer resetMocks()
+
+ isMacOS = true
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ if argv[0] == "sw_vers" {
+ return CmdResult{ExitCode: 0, Stdout: []byte("15.0.1\n")}, true
+ }
+ if argv[0] == "sysctl" {
+ return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3 Max\n")}, true
+ }
+ return CmdResult{ExitCode: 1}, true
+ }
+
+ if macosMajor() != 15 {
+ t.Errorf("expected macOS major version 15, got %d", macosMajor())
+ }
+
+ archName = "aarch64"
+ if appleSiliconGeneration() != 3 {
+ t.Errorf("expected Apple Silicon generation M3 (3), got %d", appleSiliconGeneration())
+ }
+}
+
+func TestSelectVMBackend(t *testing.T) {
+ defer resetMocks()
+
+ isMacOS = true
+ archName = "x86_64"
+ if backend := selectVMBackend(); backend != "virtualbox" {
+ t.Errorf("expected virtualbox on Intel Mac, got %q", backend)
+ }
+
+ // Apple Silicon M3 on macOS 15 Sequoia
+ resetMocks()
+ isMacOS = true
+ archName = "aarch64"
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ if argv[0] == "sw_vers" {
+ return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true
+ }
+ if argv[0] == "sysctl" {
+ return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true
+ }
+ return CmdResult{ExitCode: 1}, true
+ }
+ if backend := selectVMBackend(); backend != "qemu" {
+ t.Errorf("expected qemu on M3 macOS 15, got %q", backend)
+ }
+
+ // Apple Silicon M1 on macOS 14 (no local hypervisor)
+ resetMocks()
+ isMacOS = true
+ archName = "aarch64"
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ if argv[0] == "sw_vers" {
+ return CmdResult{ExitCode: 0, Stdout: []byte("14.5\n")}, true
+ }
+ if argv[0] == "sysctl" {
+ return CmdResult{ExitCode: 0, Stdout: []byte("Apple M1\n")}, true
+ }
+ return CmdResult{ExitCode: 1}, true
+ }
+ if backend := selectVMBackend(); backend != "" {
+ t.Errorf("expected empty backend on M1 macOS 14, got %q", backend)
+ }
+}
+
+func TestLatestFedoraCloudImage(t *testing.T) {
+ defer resetMocks()
+
+ fetchText = func(url string) string {
+ if url == "https://dl.fedoraproject.org/pub/fedora/linux/releases/" {
+ return `
+38/
+39/
+40/
+`
+ }
+ if strings.Contains(url, "40/Cloud/") {
+ return `
+Fedora-Cloud-Base-40-1.10.x86_64.qcow2
+Fedora-Cloud-Base-40-1.10.x86_64-CHECKSUM
+`
+ }
+ return ""
+ }
+
+ archName = "x86_64"
+ filename, qcowURL, _, ok := latestFedoraCloudImage()
+ if !ok {
+ t.Fatal("expected success")
+ }
+ if filename != "Fedora-Cloud-Base-40-1.10.x86_64.qcow2" {
+ t.Errorf("unexpected filename: %q", filename)
+ }
+ if !strings.Contains(qcowURL, "40/Cloud/x86_64/images/") {
+ t.Errorf("unexpected qcowURL: %q", qcowURL)
+ }
+}
+
+func TestInstallFirecrackerZshFunction(t *testing.T) {
+ defer resetMocks()
+
+ tmp := t.TempDir()
+ t.Setenv("HOME", tmp)
+ zshrc := filepath.Join(tmp, ".zshrc")
+ osWriteFile(zshrc, []byte("echo initial\n"), 0644)
+
+ osReadFile = func(name string) ([]byte, error) {
+ if name == zshrc {
+ return []byte("echo initial\n"), nil
+ }
+ return nil, os.ErrNotExist
+ }
+
+ var writtenContent string
+ osWriteFile = func(name string, data []byte, perm os.FileMode) error {
+ if name == zshrc {
+ writtenContent = string(data)
+ }
+ return nil
+ }
+
+ installFirecrackerZshFunction("firecracker() { echo wrapper; }")
+
+ if !strings.Contains(writtenContent, "firecracker() { echo wrapper; }") {
+ t.Errorf("expected wrapper code inside written zshrc, got %q", writtenContent)
+ }
+}
+
+func TestVerifyFedoraQcow2(t *testing.T) {
+ defer resetMocks()
+
+ tmp := t.TempDir()
+ qcow2 := filepath.Join(tmp, "fedora.qcow2")
+ os.WriteFile(qcow2, []byte("qcow2-content"), 0644)
+ // Hash of "qcow2-content" is fa13cb14afd725b7efaa126bd84a2a848fe9a46267251afecc769d7bdd6fcd01
+
+ fetchText = func(url string) string {
+ return "SHA256 (fedora.qcow2) = fa13cb14afd725b7efaa126bd84a2a848fe9a46267251afecc769d7bdd6fcd01"
+ }
+
+ if !verifyFedoraQcow2(qcow2, "http://checksum-url") {
+ t.Error("expected verification to succeed")
+ }
+}
+
+func TestDownloadFedoraImage(t *testing.T) {
+ defer resetMocks()
+
+ // Case 1: curl exists
+ hasCmd = func(name string) bool { return name == "curl" }
+ var runCmdCalled bool
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ if argv[0] == "curl" {
+ runCmdCalled = true
+ }
+ return CmdResult{ExitCode: 0}
+ }
+ downloadFedoraImage("http://url", "/tmp/dest")
+ if !runCmdCalled {
+ t.Error("expected curl command to be run")
+ }
+
+ // Case 2: curl does not exist
+ resetMocks()
+ hasCmd = func(name string) bool { return false }
+ var downloadCalled bool
+ download = func(url, dest string) bool {
+ downloadCalled = true
+ return true
+ }
+ downloadFedoraImage("http://url", "/tmp/dest")
+ if !downloadCalled {
+ t.Error("expected download function to be called")
+ }
+}
+
+func TestWriteCloudInitSeedAndISO(t *testing.T) {
+ defer resetMocks()
+
+ var mkdirCalls []string
+ osMkdirAll = func(path string, perm os.FileMode) error {
+ mkdirCalls = append(mkdirCalls, path)
+ return nil
+ }
+
+ var writtenFiles []string
+ osWriteFile = func(name string, data []byte, perm os.FileMode) error {
+ writtenFiles = append(writtenFiles, name)
+ return nil
+ }
+
+ err := writeCloudInitSeed("/tmp/seed", "ssh-pubkey")
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if len(mkdirCalls) != 1 || mkdirCalls[0] != "/tmp/seed" {
+ t.Errorf("unexpected mkdir calls: %v", mkdirCalls)
+ }
+ if len(writtenFiles) != 2 {
+ t.Errorf("expected user-data and meta-data files to be written, got: %v", writtenFiles)
+ }
+
+ // buildSeedISO
+ var removed bool
+ osRemove = func(path string) error {
+ if path == "/tmp/seed.iso" {
+ removed = true
+ }
+ return nil
+ }
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+ buildSeedISO("/tmp/seed", "/tmp/seed.iso")
+ if !removed {
+ t.Error("expected osRemove to delete old ISO first")
+ }
+ if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "hdiutil" {
+ t.Errorf("expected hdiutil call, got %v", runCmdCalls)
+ }
+}
+
+func TestWriteQEMUStartScript(t *testing.T) {
+ defer resetMocks()
+
+ var writtenPath string
+ osWriteFile = func(name string, data []byte, perm os.FileMode) error {
+ writtenPath = name
+ return nil
+ }
+
+ t.Setenv("HOME", "/my/home")
+ path := writeQEMUStartScript()
+ if !strings.HasSuffix(path, "vm-start.sh") {
+ t.Errorf("unexpected script path: %q", path)
+ }
+ if !strings.HasSuffix(writtenPath, "vm-start.sh") {
+ t.Errorf("expected script to be written, got %q", writtenPath)
+ }
+}
+
+func TestSSHToVMAndHelpers(t *testing.T) {
+ defer resetMocks()
+
+ // sshToVM
+ var probeCall []string
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ probeCall = argv
+ return CmdResult{ExitCode: 0}, true
+ }
+ res := sshToVM("/path/to/key", []string{"ls"}, 0)
+ if !res.OK() {
+ t.Errorf("expected OK result, got %+v", res)
+ }
+ if probeCall[0] != "ssh" || !strings.Contains(strings.Join(probeCall, " "), "fc@127.0.0.1") {
+ t.Errorf("unexpected probe command: %v", probeCall)
+ }
+
+ // waitForVMSSH
+ var probeCalls int
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ probeCalls++
+ return CmdResult{ExitCode: 0}, true
+ }
+ if !waitForVMSSH("/path/to/key", time.Second) {
+ t.Error("expected wait to succeed")
+ }
+ if probeCalls != 1 {
+ t.Errorf("expected 1 probe call, got %d", probeCalls)
+ }
+
+ // Failure case with short timeout
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 1}, true
+ }
+ if waitForVMSSH("/path/to/key", 10*time.Millisecond) {
+ t.Error("expected wait to fail")
+ }
+
+ // waitForFirecrackerInVM
+ probeCalls = 0
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ probeCalls++
+ return CmdResult{ExitCode: 0}, true
+ }
+ if !waitForFirecrackerInVM("/path/to/key", time.Second) {
+ t.Error("expected wait to succeed")
+ }
+
+ // Failure case with short timeout
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 1}, true
+ }
+ if waitForFirecrackerInVM("/path/to/key", 10*time.Millisecond) {
+ t.Error("expected wait to fail")
+ }
+}
+
+func TestEnsureVirtualBoxAndProvision(t *testing.T) {
+ defer resetMocks()
+
+ // Case 1: VBoxManage exists
+ hasCmd = func(name string) bool { return name == "VBoxManage" }
+ if !ensureVirtualBox() {
+ t.Error("expected ensureVirtualBox to be true when VBoxManage exists")
+ }
+
+ // Case 2: VBoxManage does not exist, brew install succeeds
+ resetMocks()
+ hasCmdCalls := 0
+ hasCmd = func(name string) bool {
+ hasCmdCalls++
+ // First check (is VBoxManage in path) -> returns false.
+ // Second check (is VBoxManage in path after brew install) -> returns true.
+ if name == "VBoxManage" {
+ return hasCmdCalls > 1
+ }
+ return false
+ }
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+ if !ensureVirtualBox() {
+ t.Error("expected ensureVirtualBox to be true after install")
+ }
+ if len(runCmdCalls) != 1 || runCmdCalls[0][3] != "virtualbox" {
+ t.Errorf("expected brew install virtualbox call, got %v", runCmdCalls)
+ }
+
+ // Test provisionVirtualBoxVM
+ resetMocks()
+ t.Setenv("HOME", "/my/home")
+ hasCmd = func(name string) bool { return name == "VBoxManage" }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ if argv[1] == "showvminfo" {
+ return CmdResult{ExitCode: 1}, true // VM does not exist yet
+ }
+ return CmdResult{ExitCode: 0}, true
+ }
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, nil // vdi exists
+ }
+ runCmdCalls = nil
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+ osWriteFile = func(name string, data []byte, perm os.FileMode) error {
+ return nil
+ }
+ scriptPath := provisionVirtualBoxVM("/tmp/fedora.qcow2", "/tmp/seed.iso")
+ if !strings.HasSuffix(scriptPath, "vm-start.sh") {
+ t.Errorf("unexpected script path: %q", scriptPath)
+ }
+ if len(runCmdCalls) < 2 {
+ t.Errorf("expected virtualbox setup commands, got %v", runCmdCalls)
+ }
+}
+
+func TestSetupFirecrackerVM(t *testing.T) {
+ defer resetMocks()
+
+ isMacOS = true
+ archName = "aarch64"
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ if argv[0] == "sw_vers" {
+ return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true
+ }
+ if argv[0] == "sysctl" {
+ return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true
+ }
+ if argv[0] == "ssh" {
+ return CmdResult{ExitCode: 0}, true // SSH succeeds
+ }
+ return CmdResult{ExitCode: 0}, true
+ }
+
+ osStat = func(name string) (os.FileInfo, error) {
+ // Mock files exist
+ return nil, nil
+ }
+
+ osReadFile = func(name string) ([]byte, error) {
+ return []byte("ssh-key"), nil
+ }
+
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+
+ osWriteFile = func(name string, data []byte, perm os.FileMode) error {
+ return nil
+ }
+
+ setupFirecrackerVM()
+
+ if len(runCmdCalls) < 1 {
+ t.Errorf("expected VM setup start script execution, got: %v", runCmdCalls)
+ }
+}
+
+func TestMacosGoEdgeCases(t *testing.T) {
+ defer resetMocks()
+
+ isMacOS = true
+ hasCmd = func(name string) bool { return false }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ return CmdResult{ExitCode: 1}
+ }
+ if ensureVirtualBox() {
+ t.Error("expected ensureVirtualBox to fail when brew install fails")
+ }
+
+ resetMocks()
+ isMacOS = true
+ hasCmd = func(name string) bool {
+ return false
+ }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ return CmdResult{ExitCode: 0}
+ }
+ if ensureVirtualBox() {
+ t.Error("expected ensureVirtualBox to fail when VBoxManage still not in PATH")
+ }
+
+ resetMocks()
+ isMacOS = true
+ hasCmd = func(name string) bool { return false }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult { return CmdResult{ExitCode: 1} }
+ if path := provisionVirtualBoxVM("qcow", "iso"); path != "" {
+ t.Errorf("expected empty path when VirtualBox setup fails, got %q", path)
+ }
+
+ resetMocks()
+ isMacOS = true
+ t.Setenv("HOME", "/my/home")
+ hasCmd = func(name string) bool { return name == "VBoxManage" }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 1}, true
+ }
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, os.ErrNotExist
+ }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ if argv[1] == "clonemedium" {
+ return CmdResult{ExitCode: 1}
+ }
+ return CmdResult{ExitCode: 0}
+ }
+ if path := provisionVirtualBoxVM("qcow", "iso"); path != "" {
+ t.Errorf("expected empty path when clonemedium fails, got %q", path)
+ }
+
+ resetMocks()
+ isMacOS = true
+ t.Setenv("HOME", "/my/home")
+ hasCmd = func(name string) bool { return name == "VBoxManage" }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 1}, true
+ }
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, os.ErrNotExist
+ }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ if argv[1] == "createvm" {
+ return CmdResult{ExitCode: 1}
+ }
+ return CmdResult{ExitCode: 0}
+ }
+ if path := provisionVirtualBoxVM("qcow", "iso"); path != "" {
+ t.Errorf("expected empty path when createvm fails, got %q", path)
+ }
+
+ resetMocks()
+ isMacOS = false
+ setupFirecrackerVM()
+
+ resetMocks()
+ isMacOS = true
+ archName = "aarch64"
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ if argv[0] == "sw_vers" {
+ return CmdResult{ExitCode: 0, Stdout: []byte("14.0\n")}, true
+ }
+ return CmdResult{ExitCode: 0}, true
+ }
+ setupFirecrackerVM()
+
+ resetMocks()
+ isMacOS = true
+ archName = "x86_64"
+ hasCmd = func(name string) bool { return name == "VBoxManage" }
+ osStat = func(name string) (os.FileInfo, error) {
+ if strings.HasSuffix(name, "id_ed25519") {
+ return nil, os.ErrNotExist
+ }
+ return nil, nil
+ }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ if argv[0] == "ssh-keygen" {
+ return CmdResult{ExitCode: 1}
+ }
+ return CmdResult{ExitCode: 0}
+ }
+ setupFirecrackerVM()
+}
+
+func TestSetupFirecrackerVMEdgeCases(t *testing.T) {
+ defer resetMocks()
+
+ isMacOS = true
+ archName = "aarch64"
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ if argv[0] == "sw_vers" {
+ return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true
+ }
+ if argv[0] == "sysctl" {
+ return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true
+ }
+ return CmdResult{ExitCode: 0}, true
+ }
+
+ osStat = func(name string) (os.FileInfo, error) {
+ if strings.HasSuffix(name, "fedora.qcow2") || strings.HasSuffix(name, "id_ed25519") {
+ return nil, os.ErrNotExist
+ }
+ return nil, nil
+ }
+ fetchText = func(url string) string { return "" }
+ setupFirecrackerVM()
+
+ resetMocks()
+ isMacOS = true
+ archName = "aarch64"
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ if argv[0] == "sw_vers" { return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true }
+ if argv[0] == "sysctl" { return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true }
+ return CmdResult{ExitCode: 0}, true
+ }
+ osStat = func(name string) (os.FileInfo, error) {
+ if strings.HasSuffix(name, "fedora.qcow2") { return nil, os.ErrNotExist }
+ return nil, nil
+ }
+ fetchText = func(url string) string {
+ if strings.Contains(url, "releases") { return "40/" }
+ return "Fedora-Cloud-Base-40.qcow2"
+ }
+ hasCmd = func(name string) bool { return false }
+ download = func(url, dest string) bool { return false }
+ setupFirecrackerVM()
+
+ resetMocks()
+ isMacOS = true
+ archName = "aarch64"
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ if argv[0] == "sw_vers" { return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true }
+ if argv[0] == "sysctl" { return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true }
+ return CmdResult{ExitCode: 0}, true
+ }
+ osStat = func(name string) (os.FileInfo, error) {
+ if strings.HasSuffix(name, "fedora.qcow2") { return nil, os.ErrNotExist }
+ return nil, nil
+ }
+ fetchText = func(url string) string {
+ if strings.Contains(url, "releases") { return "40/" }
+ if strings.Contains(url, "CHECKSUM") { return "mismatch-sha Fedora-Cloud-Base-40.qcow2" }
+ return "Fedora-Cloud-Base-40.qcow2"
+ }
+ download = func(url, dest string) bool { return true }
+ setupFirecrackerVM()
+
+ resetMocks()
+ isMacOS = true
+ archName = "aarch64"
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ if argv[0] == "sw_vers" { return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true }
+ if argv[0] == "sysctl" { return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true }
+ return CmdResult{ExitCode: 0}, true
+ }
+ osStat = func(name string) (os.FileInfo, error) { return nil, nil }
+ osReadFile = func(name string) ([]byte, error) { return []byte("ssh-pubkey"), nil }
+ hasCmd = func(name string) bool {
+ if name == "qemu-system-aarch64" { return false }
+ return true
+ }
+ setupFirecrackerVM()
+
+ resetMocks()
+ isMacOS = true
+ archName = "aarch64"
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ if argv[0] == "sw_vers" { return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true }
+ if argv[0] == "sysctl" { return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true }
+ return CmdResult{ExitCode: 0}, true
+ }
+ osStat = func(name string) (os.FileInfo, error) { return nil, nil }
+ osReadFile = func(name string) ([]byte, error) { return []byte("ssh-pubkey"), nil }
+ hasCmd = func(name string) bool { return true }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ if strings.HasSuffix(argv[0], "vm-start.sh") {
+ return CmdResult{ExitCode: 1}
+ }
+ return CmdResult{ExitCode: 0}
+ }
+ setupFirecrackerVM()
+}
diff --git a/main.go b/main.go
index e2d9452..c5e19af 100644
--- a/main.go
+++ b/main.go
@@ -30,17 +30,23 @@ import (
)
func main() {
- only := flag.String("only", "", "Install only the named section (system|flatpak|custom)")
- gui := flag.Bool("gui", false, "Include GUI applications (headed environments).")
- noVM := flag.Bool("no-vm", false, "macOS only: skip provisioning the Fedora-on-QEMU VM that backs the firecracker() zsh wrapper.")
- noAI := flag.Bool("no-ai", false, "Skip installation of LLM/AI CLI tools (agy, claude, codex, copilot).")
- flag.Parse()
+ runMain(os.Args)
+}
+
+func runMain(args []string) {
+ fs := flag.NewFlagSet(args[0], flag.ExitOnError)
+ only := fs.String("only", "", "Install only the named section (system|flatpak|custom)")
+ gui := fs.Bool("gui", false, "Include GUI applications (headed environments).")
+ noVM := fs.Bool("no-vm", false, "macOS only: skip provisioning the Fedora-on-QEMU VM that backs the firecracker() zsh wrapper.")
+ noAI := fs.Bool("no-ai", false, "Skip installation of LLM/AI CLI tools (agy, claude, codex, copilot).")
+ _ = fs.Parse(args[1:])
switch *only {
case "", "system", "flatpak", "custom":
default:
fmt.Fprintf(os.Stderr, "invalid --only value %q (use system|flatpak|custom)\n", *only)
- os.Exit(2)
+ osExit(2)
+ return
}
initPkgMgr()
@@ -122,7 +128,8 @@ func main() {
if !askYN(fmt.Sprintf("\n%d item(s) to install. Proceed? [y/N] ", total)) {
fmt.Fprintln(os.Stderr, "Aborted.")
- os.Exit(1)
+ osExit(1)
+ return
}
checkSudo()
@@ -165,7 +172,7 @@ func main() {
home, _ := os.UserHomeDir()
zshrc := filepath.Join(home, ".zshrc")
if hasCmd("zsh") {
- if _, err := os.Stat(zshrc); err == nil {
+ if _, err := osStat(zshrc); err == nil {
fmt.Println("\nSourcing ~/.zshrc ...")
runShell(fmt.Sprintf("zsh -c 'source %s'", zshrc), CmdOpts{})
}
@@ -177,18 +184,21 @@ func checkSudo() {
if isMacOS {
fmt.Fprintln(os.Stderr, "Do not run this with sudo on macOS — Homebrew refuses to run as root. "+
"Re-run as your regular user; the tool will request sudo for the operations that need it.")
- os.Exit(1)
+ osExit(1)
+ return
}
return
}
if !hasCmd("sudo") {
fmt.Fprintln(os.Stderr, "sudo is required but not installed.")
- os.Exit(1)
+ osExit(1)
+ return
}
fmt.Println("Validating sudo access ...")
r := runCmd([]string{"sudo", "-v"}, CmdOpts{Timeout: 2 * time.Minute})
if r.ExitCode != 0 {
fmt.Fprintln(os.Stderr, "sudo authentication failed.")
- os.Exit(1)
+ osExit(1)
+ return
}
}
diff --git a/main_test.go b/main_test.go
new file mode 100644
index 0000000..cdbdc95
--- /dev/null
+++ b/main_test.go
@@ -0,0 +1,129 @@
+package main
+
+import (
+ "os"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestRunMainInvalidOnly(t *testing.T) {
+ defer resetMocks()
+
+ var exited bool
+ var exitCode int
+ osExit = func(code int) {
+ exited = true
+ exitCode = code
+ }
+
+ runMain([]string{"bootstrap_environment", "--only", "invalid"})
+
+ if !exited {
+ t.Error("expected runMain with invalid --only value to exit")
+ }
+ if exitCode != 2 {
+ t.Errorf("expected exit code 2, got %d", exitCode)
+ }
+}
+
+func TestRunMainAllInstalled(t *testing.T) {
+ defer resetMocks()
+
+ // All packages are already installed (total packages to install = 0)
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, nil // all custom paths exist
+ }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ if argv[0] == "rpm" || argv[0] == "dpkg-query" || argv[0] == "pacman" {
+ return CmdResult{ExitCode: 0, Stdout: []byte("install ok installed")}, true
+ }
+ if argv[0] == "flatpak" {
+ return CmdResult{ExitCode: 0}, true
+ }
+ return CmdResult{ExitCode: 0}, true
+ }
+ hasCmd = func(name string) bool {
+ return true
+ }
+
+ var exited bool
+ osExit = func(code int) {
+ exited = true
+ }
+
+ // We only run custom to keep it short & avoid other logic dependencies
+ runMain([]string{"bootstrap_environment", "--only", "custom"})
+
+ if exited {
+ t.Error("expected program to complete successfully without exiting")
+ }
+}
+
+func TestRunMainInstallAbort(t *testing.T) {
+ defer resetMocks()
+
+ // Simulating some packages to install, but user selects N
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, os.ErrNotExist // custom paths missing -> need install
+ }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 1}, true // packages not installed
+ }
+ hasCmd = func(name string) bool {
+ return true
+ }
+
+ // Mock stdin to say "n" to abort the prompt "Proceed? [y/N]"
+ stdin = strings.NewReader("n\n")
+
+ var exited bool
+ var exitCode int
+ osExit = func(code int) {
+ exited = true
+ exitCode = code
+ }
+
+ runMain([]string{"bootstrap_environment", "--only", "custom"})
+
+ if !exited {
+ t.Error("expected program to exit on user abort")
+ }
+ if exitCode != 1 {
+ t.Errorf("expected exit code 1, got %d", exitCode)
+ }
+}
+
+func TestRunMainMacos(t *testing.T) {
+ defer resetMocks()
+
+ isMacOS = true
+ pkgMgr = "brew"
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, nil // brew etc exist
+ }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 0}, true
+ }
+ hasCmd = func(name string) bool {
+ return true
+ }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ return CmdResult{ExitCode: 0}
+ }
+ runShell = func(cmd string, opts CmdOpts) CmdResult {
+ return CmdResult{ExitCode: 0}
+ }
+
+ var exited bool
+ osExit = func(code int) {
+ exited = true
+ }
+
+ // Run with --only custom, --no-vm, --no-ai
+ runMain([]string{"bootstrap_environment", "--only", "custom", "--no-vm", "--no-ai"})
+
+ if exited {
+ t.Error("expected program to complete successfully on macOS mock run")
+ }
+}
diff --git a/net.go b/net.go
index 7449df8..70ac52b 100644
--- a/net.go
+++ b/net.go
@@ -17,8 +17,8 @@ const httpClientTimeout = 30 * time.Minute
var httpClient = &http.Client{Timeout: httpClientTimeout}
-// download streams url -> dest. Returns true on success.
-func download(url, dest string) bool {
+// downloadReal streams url -> dest. Returns true on success.
+func downloadReal(url, dest string) bool {
fmt.Printf(" Downloading %s ...\n", filepath.Base(url))
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
@@ -48,9 +48,9 @@ func download(url, dest string) bool {
return true
}
-// fetchJSON GETs url with the GitHub API Accept header and decodes the body
+// fetchJSONReal GETs url with the GitHub API Accept header and decodes the body
// into v. Returns true on success.
-func fetchJSON(url string, v any) bool {
+func fetchJSONReal(url string, v any) bool {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
errLog(fmt.Sprintf("API request failed for %s: %v", url, err))
@@ -77,8 +77,8 @@ func fetchJSON(url string, v any) bool {
return true
}
-// fetchText returns the trimmed body of url. Returns empty string on failure.
-func fetchText(url string) string {
+// fetchTextReal returns the trimmed body of url. Returns empty string on failure.
+func fetchTextReal(url string) string {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
errLog(fmt.Sprintf("Fetch failed for %s: %v", url, err))
diff --git a/net_test.go b/net_test.go
new file mode 100644
index 0000000..0719d52
--- /dev/null
+++ b/net_test.go
@@ -0,0 +1,238 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+type mockTripper struct {
+ roundTripFunc func(req *http.Request) (*http.Response, error)
+}
+
+func (m *mockTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+ return m.roundTripFunc(req)
+}
+
+func TestDownloadReal(t *testing.T) {
+ defer resetMocks()
+
+ // Mock HTTP client
+ oldTransport := httpClient.Transport
+ defer func() { httpClient.Transport = oldTransport }()
+
+ httpClient.Transport = &mockTripper{
+ roundTripFunc: func(req *http.Request) (*http.Response, error) {
+ if req.URL.String() == "https://example.com/file" {
+ return &http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString("hello download")),
+ }, nil
+ }
+ return &http.Response{
+ StatusCode: 404,
+ Body: io.NopCloser(bytes.NewBufferString("not found")),
+ }, nil
+ },
+ }
+
+ tmpDir := t.TempDir()
+ destFile := filepath.Join(tmpDir, "out.txt")
+
+ // Successful download
+ success := downloadReal("https://example.com/file", destFile)
+ if !success {
+ t.Fatal("expected download to succeed")
+ }
+
+ data, err := os.ReadFile(destFile)
+ if err != nil {
+ t.Fatalf("failed to read downloaded file: %v", err)
+ }
+ if string(data) != "hello download" {
+ t.Errorf("expected 'hello download', got %q", string(data))
+ }
+
+ // Failed download (404)
+ failDest := filepath.Join(tmpDir, "out_fail.txt")
+ successFail := downloadReal("https://example.com/nonexistent", failDest)
+ if successFail {
+ t.Error("expected download to fail with 404")
+ }
+}
+
+func TestFetchJSONReal(t *testing.T) {
+ defer resetMocks()
+
+ oldTransport := httpClient.Transport
+ defer func() { httpClient.Transport = oldTransport }()
+
+ httpClient.Transport = &mockTripper{
+ roundTripFunc: func(req *http.Request) (*http.Response, error) {
+ if req.URL.String() == "https://example.com/api" {
+ // Verify auth header if token is set
+ return &http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(`{"key": "value"}`)),
+ }, nil
+ }
+ return &http.Response{
+ StatusCode: 500,
+ Body: io.NopCloser(bytes.NewBufferString("internal error")),
+ }, nil
+ },
+ }
+
+ type MockResponse struct {
+ Key string `json:"key"`
+ }
+
+ var res MockResponse
+ success := fetchJSONReal("https://example.com/api", &res)
+ if !success {
+ t.Fatal("expected fetchJSON to succeed")
+ }
+ if res.Key != "value" {
+ t.Errorf("expected Key to be 'value', got %q", res.Key)
+ }
+
+ successFail := fetchJSONReal("https://example.com/bad", &res)
+ if successFail {
+ t.Error("expected fetchJSON to fail with 500")
+ }
+}
+
+func TestFetchTextReal(t *testing.T) {
+ defer resetMocks()
+
+ oldTransport := httpClient.Transport
+ defer func() { httpClient.Transport = oldTransport }()
+
+ httpClient.Transport = &mockTripper{
+ roundTripFunc: func(req *http.Request) (*http.Response, error) {
+ if req.URL.String() == "https://example.com/text" {
+ return &http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(" Adoptium Latest \n")),
+ }, nil
+ }
+ return &http.Response{
+ StatusCode: 403,
+ Body: io.NopCloser(bytes.NewBufferString("forbidden")),
+ }, nil
+ },
+ }
+
+ text := fetchTextReal("https://example.com/text")
+ if text != "Adoptium Latest" {
+ t.Errorf("expected trimmed text 'Adoptium Latest', got %q", text)
+ }
+
+ textFail := fetchTextReal("https://example.com/forbidden")
+ if textFail != "" {
+ t.Errorf("expected empty string for failed request, got %q", textFail)
+ }
+}
+
+func TestSha256Of(t *testing.T) {
+ tmpDir := t.TempDir()
+ path := filepath.Join(tmpDir, "hash.txt")
+ if err := os.WriteFile(path, []byte("hello sha256"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Hex of sha256("hello sha256") is 433855b7d2b96c23a6f60e70c655eb4305e8806b682a9596a200642f947259b1
+ expected := "433855b7d2b96c23a6f60e70c655eb4305e8806b682a9596a200642f947259b1"
+ actual, err := sha256Of(path)
+ if err != nil {
+ t.Fatalf("failed to calculate hash: %v", err)
+ }
+ if actual != expected {
+ t.Errorf("expected %s, got %s", expected, actual)
+ }
+
+ // Nonexistent file
+ _, errNonexistent := sha256Of(filepath.Join(tmpDir, "nonexistent"))
+ if errNonexistent == nil {
+ t.Error("expected error for nonexistent file")
+ }
+}
+
+func TestNetRealErrors(t *testing.T) {
+ defer resetMocks()
+
+ oldTransport := httpClient.Transport
+ defer func() { httpClient.Transport = oldTransport }()
+
+ // 1. NewRequest error
+ if downloadReal("%%%", "dest") {
+ t.Error("expected downloadReal to fail for invalid URL")
+ }
+ if fetchJSONReal("%%%", nil) {
+ t.Error("expected fetchJSONReal to fail for invalid URL")
+ }
+ if fetchTextReal("%%%") != "" {
+ t.Error("expected fetchTextReal to fail for invalid URL")
+ }
+
+ // 2. Transport Do error
+ httpClient.Transport = &mockTripper{
+ roundTripFunc: func(req *http.Request) (*http.Response, error) {
+ return nil, fmt.Errorf("connection refused")
+ },
+ }
+ if downloadReal("https://example.com/file", "dest") {
+ t.Error("expected downloadReal to fail on connection error")
+ }
+ if fetchJSONReal("https://example.com/api", nil) {
+ t.Error("expected fetchJSONReal to fail on connection error")
+ }
+ if fetchTextReal("https://example.com/text") != "" {
+ t.Error("expected fetchTextReal to fail on connection error")
+ }
+
+ // 3. os.Create error
+ httpClient.Transport = &mockTripper{
+ roundTripFunc: func(req *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString("ok")),
+ }, nil
+ },
+ }
+ if downloadReal("https://example.com/file", "/nonexistent-dir/dest") {
+ t.Error("expected downloadReal to fail when creating destination file fails")
+ }
+
+ // 4. json Decode error
+ httpClient.Transport = &mockTripper{
+ roundTripFunc: func(req *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString("invalid json")),
+ }, nil
+ },
+ }
+ var v any
+ if fetchJSONReal("https://example.com/api", &v) {
+ t.Error("expected fetchJSONReal to fail on invalid JSON")
+ }
+
+ // 5. io.ReadAll error
+ httpClient.Transport = &mockTripper{
+ roundTripFunc: func(req *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(&errReader{}),
+ }, nil
+ },
+ }
+ if fetchTextReal("https://example.com/text") != "" {
+ t.Error("expected fetchTextReal to fail on read error")
+ }
+}
+
diff --git a/pkgmgr.go b/pkgmgr.go
index 86e925d..7efae1e 100644
--- a/pkgmgr.go
+++ b/pkgmgr.go
@@ -13,8 +13,31 @@ func initPkgMgr() {
pkgMgr = detectPkgMgr()
isRHELFamily = detectRHELFamily()
isArchFamily = detectArchFamily()
+ ensureWhichInstalled()
}
+func ensureWhichInstalled() {
+ if hasCmd("which") {
+ return
+ }
+ fmt.Println("[which] 'which' is not installed. Installing it as a prerequisite...")
+ var res CmdResult
+ switch pkgMgr {
+ case "pacman":
+ res = runCmd([]string{"pacman", "-Sy", "--noconfirm", "--needed", "which"}, CmdOpts{AsSudo: true})
+ case "brew":
+ res = runCmd([]string{"brew", "install", "which"}, CmdOpts{})
+ default:
+ res = runCmd([]string{pkgMgr, "install", "-y", "which"}, CmdOpts{AsSudo: true})
+ }
+ if !res.OK() {
+ fmt.Fprintf(os.Stderr, "Warning: failed to install 'which' prerequisite: %v\n", res.Err)
+ } else {
+ fmt.Println("[which] 'which' successfully installed.")
+ }
+}
+
+
func detectPkgMgr() string {
if isMacOS {
// brew may not be installed yet — ensureHomebrew runs before any
@@ -27,7 +50,7 @@ func detectPkgMgr() string {
}
}
fmt.Fprintln(os.Stderr, "No supported package manager found (expected dnf, apt-get, pacman, or brew on macOS).")
- os.Exit(1)
+ osExit(1)
return ""
}
diff --git a/pkgmgr_test.go b/pkgmgr_test.go
new file mode 100644
index 0000000..1e74e5a
--- /dev/null
+++ b/pkgmgr_test.go
@@ -0,0 +1,201 @@
+package main
+
+import (
+ "testing"
+ "time"
+)
+
+func TestDetectPkgMgr(t *testing.T) {
+ defer resetMocks()
+
+ // Case 1: macOS should return brew
+ isMacOS = true
+ if mgr := detectPkgMgr(); mgr != "brew" {
+ t.Errorf("expected brew on macOS, got %q", mgr)
+ }
+
+ // Case 2: Linux with dnf
+ isMacOS = false
+ hasCmd = func(name string) bool {
+ return name == "dnf"
+ }
+ if mgr := detectPkgMgr(); mgr != "dnf" {
+ t.Errorf("expected dnf, got %q", mgr)
+ }
+
+ // Case 3: Linux with apt-get
+ hasCmd = func(name string) bool {
+ return name == "apt-get"
+ }
+ if mgr := detectPkgMgr(); mgr != "apt-get" {
+ t.Errorf("expected apt-get, got %q", mgr)
+ }
+
+ // Case 4: Linux with pacman
+ hasCmd = func(name string) bool {
+ return name == "pacman"
+ }
+ if mgr := detectPkgMgr(); mgr != "pacman" {
+ t.Errorf("expected pacman, got %q", mgr)
+ }
+
+ // Case 5: No package manager found (exits)
+ hasCmd = func(name string) bool {
+ return false
+ }
+ var exited bool
+ var exitCode int
+ osExit = func(code int) {
+ exited = true
+ exitCode = code
+ }
+ detectPkgMgr()
+ if !exited || exitCode != 1 {
+ t.Errorf("expected exit with code 1, exited=%v code=%d", exited, exitCode)
+ }
+}
+
+func TestResolveSystemPkgs(t *testing.T) {
+ defer resetMocks()
+
+ pkgMgr = "apt-get"
+ resolved, skipped := resolveSystemPkgs([]string{"ffmpeg-free", "lua", "podman", "docker-compose"})
+ // ffmpeg-free -> ffmpeg, lua -> lua5.4, podman -> podman, docker-compose is not overridden for apt-get
+ expectedResolved := []string{"ffmpeg", "lua5.4", "podman", "docker-compose"}
+ if len(resolved) != len(expectedResolved) {
+ t.Fatalf("expected resolved length %d, got %d", len(expectedResolved), len(resolved))
+ }
+ for i, r := range resolved {
+ if r != expectedResolved[i] {
+ t.Errorf("at index %d: expected %q, got %q", i, expectedResolved[i], r)
+ }
+ }
+ if len(skipped) != 0 {
+ t.Errorf("expected no skipped packages, got %v", skipped)
+ }
+
+ // Test skip override
+ pkgMgr = "dnf"
+ resolved, skipped = resolveSystemPkgs([]string{"docker-compose", "rg"})
+ // docker-compose -> skipped, rg -> ripgrep
+ if len(resolved) != 1 || resolved[0] != "ripgrep" {
+ t.Errorf("expected resolution to [ripgrep], got %v", resolved)
+ }
+ if len(skipped) != 1 || skipped[0] != "docker-compose" {
+ t.Errorf("expected skipped to be [docker-compose], got %v", skipped)
+ }
+}
+
+func TestIsSystemPkgInstalled(t *testing.T) {
+ defer resetMocks()
+
+ // Case dnf: rpm -q
+ pkgMgr = "dnf"
+ var probeArgv []string
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ probeArgv = argv
+ return CmdResult{ExitCode: 0}, true
+ }
+ if !isSystemPkgInstalled("git") {
+ t.Error("expected true when rpm returns 0")
+ }
+ if len(probeArgv) < 3 || probeArgv[0] != "rpm" || probeArgv[2] != "git" {
+ t.Errorf("unexpected probe argv: %v", probeArgv)
+ }
+
+ // Case apt-get: dpkg-query
+ pkgMgr = "apt-get"
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ probeArgv = argv
+ return CmdResult{ExitCode: 0, Stdout: []byte("install ok installed")}, true
+ }
+ if !isSystemPkgInstalled("git") {
+ t.Error("expected true when dpkg-query output contains install ok installed")
+ }
+ if len(probeArgv) < 4 || probeArgv[0] != "dpkg-query" {
+ t.Errorf("unexpected probe argv: %v", probeArgv)
+ }
+
+ // Case pacman: pacman -Qi
+ pkgMgr = "pacman"
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ probeArgv = argv
+ return CmdResult{ExitCode: 0}, true
+ }
+ if !isSystemPkgInstalled("git") {
+ t.Error("expected true when pacman returns 0")
+ }
+
+ // Case brew: brew list --formula / --cask
+ pkgMgr = "brew"
+ hasCmd = func(name string) bool {
+ return name == "brew"
+ }
+ var probeCalls [][]string
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ probeCalls = append(probeCalls, argv)
+ if argv[2] == "--formula" {
+ return CmdResult{ExitCode: 1}, true
+ }
+ return CmdResult{ExitCode: 0}, true // succeeds on second call
+ }
+ if !isSystemPkgInstalled("git") {
+ t.Error("expected true when brew list succeeds")
+ }
+ if len(probeCalls) != 2 {
+ t.Errorf("expected 2 brew calls, got %d", len(probeCalls))
+ }
+}
+
+func TestIsFlatpakInstalled(t *testing.T) {
+ defer resetMocks()
+
+ hasCmd = func(name string) bool {
+ return name == "flatpak"
+ }
+ var probeArgv []string
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ probeArgv = argv
+ return CmdResult{ExitCode: 0}, true
+ }
+
+ if !isFlatpakInstalled("org.gimp.GIMP") {
+ t.Error("expected true")
+ }
+ if len(probeArgv) < 3 || probeArgv[0] != "flatpak" || probeArgv[2] != "org.gimp.GIMP" {
+ t.Errorf("unexpected probe argv: %v", probeArgv)
+ }
+}
+
+func TestPkgMgrEdgeCases(t *testing.T) {
+ defer resetMocks()
+
+ pkgMgr = "brew"
+ hasCmd = func(name string) bool { return false }
+ if isSystemPkgInstalled("git") {
+ t.Error("expected false when brew is not installed")
+ }
+
+ resetMocks()
+ pkgMgr = "brew"
+ hasCmd = func(name string) bool { return true }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 1}, true
+ }
+ if isSystemPkgInstalled("git") {
+ t.Error("expected false when brew list fails")
+ }
+
+ resetMocks()
+ pkgMgr = "unsupported"
+ if isSystemPkgInstalled("git") {
+ t.Error("expected false for unsupported package manager")
+ }
+
+ resetMocks()
+ hasCmd = func(name string) bool { return false }
+ if isFlatpakInstalled("org.gimp.GIMP") {
+ t.Error("expected false when flatpak command not found")
+ }
+}
+
diff --git a/post.go b/post.go
index 37269ed..32a5277 100644
--- a/post.go
+++ b/post.go
@@ -138,11 +138,11 @@ func latestStablePython(pyenvBin string) string {
func ensurePythonLatest() *sync.WaitGroup {
home, _ := os.UserHomeDir()
pyenvDir := filepath.Join(home, ".pyenv")
- if _, err := os.Stat(pyenvDir); err != nil {
+ if _, err := osStat(pyenvDir); err != nil {
return nil
}
pyenvBin := filepath.Join(pyenvDir, "bin", "pyenv")
- if _, err := os.Stat(pyenvBin); err != nil {
+ if _, err := osStat(pyenvBin); err != nil {
warn(fmt.Sprintf("pyenv binary not found at %s", pyenvBin))
return nil
}
@@ -222,7 +222,7 @@ func installNVM() {
func ensureNodeLTS() {
home, _ := os.UserHomeDir()
- if _, err := os.Stat(filepath.Join(home, ".nvm")); err != nil {
+ if _, err := osStat(filepath.Join(home, ".nvm")); err != nil {
return
}
check := runShell(`bash -c "source ~/.nvm/nvm.sh 2>/dev/null && nvm version lts/* 2>/dev/null"`,
@@ -263,7 +263,7 @@ func installOhMyZsh() {
}
home, _ := os.UserHomeDir()
target := filepath.Join(home, ".oh-my-zsh")
- if _, err := os.Stat(target); err == nil {
+ if _, err := osStat(target); err == nil {
fmt.Printf(" oh-my-zsh already present at %s; updating theme only\n", target)
} else {
fmt.Println(" Installing oh-my-zsh via the official installer ...")
@@ -275,7 +275,7 @@ func installOhMyZsh() {
}
zshrc := filepath.Join(home, ".zshrc")
- data, err := os.ReadFile(zshrc)
+ data, err := osReadFile(zshrc)
if err != nil {
warn("~/.zshrc not present after oh-my-zsh install; cannot set theme")
return
@@ -289,7 +289,7 @@ func installOhMyZsh() {
newText = strings.TrimRight(text, "\n") + "\nZSH_THEME=\"gnzh\"\n"
}
if newText != text {
- if err := os.WriteFile(zshrc, []byte(newText), 0o644); err != nil {
+ if err := osWriteFile(zshrc, []byte(newText), 0o644); err != nil {
errLog(fmt.Sprintf("could not write ~/.zshrc: %v", err))
return
}
@@ -365,7 +365,7 @@ func ensureZshDefault() {
// macOS the shell may be set by dscl; getent isn't available either, so we
// just read passwd directly which works on every supported platform.
func userLoginShell(uid string) string {
- data, err := os.ReadFile("/etc/passwd")
+ data, err := osReadFile(passwdPath)
if err != nil {
return ""
}
@@ -388,29 +388,29 @@ func cloneNvimConfig() {
fmt.Printf("\n[Neovim] Setting up configuration from %s ...\n", repoURL)
- if _, err := os.Stat(configDir); err == nil {
+ if _, err := osStat(configDir); err == nil {
n := 1
var backup string
for {
backup = filepath.Join(filepath.Dir(configDir), fmt.Sprintf("nvim-%d", n))
- if _, err := os.Stat(backup); os.IsNotExist(err) {
+ if _, err := osStat(backup); os.IsNotExist(err) {
break
}
n++
}
fmt.Printf(" Renaming existing %s → %s ...\n", configDir, backup)
- if err := os.Rename(configDir, backup); err != nil {
+ if err := osRename(configDir, backup); err != nil {
errLog(fmt.Sprintf("could not back up existing nvim config: %v", err))
return
}
notice(fmt.Sprintf("Previous Neovim config preserved at %s", backup))
}
- os.MkdirAll(filepath.Dir(configDir), 0o755)
+ osMkdirAll(filepath.Dir(configDir), 0o755)
repoName := strings.TrimSuffix(filepath.Base(repoURL), ".git")
tempClone := filepath.Join(filepath.Dir(configDir), repoName)
- os.RemoveAll(tempClone)
+ osRemoveAll(tempClone)
fmt.Printf(" Cloning to %s ...\n", configDir)
if !runCmd([]string{"git", "clone", repoURL, tempClone}, CmdOpts{}).OK() {
@@ -419,7 +419,7 @@ func cloneNvimConfig() {
}
if tempClone != configDir {
fmt.Printf(" Renaming %s to %s ...\n", filepath.Base(tempClone), filepath.Base(configDir))
- os.Rename(tempClone, configDir)
+ osRename(tempClone, configDir)
}
fmt.Printf(" Neovim configuration ready at %s\n", configDir)
}
@@ -452,7 +452,7 @@ func checkAndSetupSSH() {
func askYN(prompt string) bool {
fmt.Print(prompt)
var buf [256]byte
- n, err := os.Stdin.Read(buf[:])
+ n, err := stdin.Read(buf[:])
if err != nil && err != io.EOF {
fmt.Println()
return false
@@ -470,7 +470,7 @@ func installAgy() {
func installNpmPackage(pkgName string) {
home, _ := os.UserHomeDir()
- if _, err := os.Stat(filepath.Join(home, ".nvm")); err != nil {
+ if _, err := osStat(filepath.Join(home, ".nvm")); err != nil {
errLog("NVM is not installed — cannot install " + pkgName)
return
}
diff --git a/post_test.go b/post_test.go
new file mode 100644
index 0000000..429ece1
--- /dev/null
+++ b/post_test.go
@@ -0,0 +1,705 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestInstallPyenv(t *testing.T) {
+ defer resetMocks()
+
+ var runShellCmd string
+ runShell = func(cmd string, opts CmdOpts) CmdResult {
+ runShellCmd = cmd
+ return CmdResult{ExitCode: 0}
+ }
+
+ installPyenv()
+ if !strings.Contains(runShellCmd, "pyenv.run | bash") {
+ t.Errorf("unexpected shell command: %q", runShellCmd)
+ }
+}
+
+func TestPython3DecimalOK(t *testing.T) {
+ defer resetMocks()
+
+ hasCmd = func(name string) bool { return true }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 0}, true
+ }
+ if !python3DecimalOK() {
+ t.Error("expected decimal OK")
+ }
+
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 1}, true
+ }
+ if python3DecimalOK() {
+ t.Error("expected decimal NOT OK")
+ }
+}
+
+func TestFixPython3Decimal(t *testing.T) {
+ defer resetMocks()
+
+ pkgMgr = "apt-get"
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+ hasCmd = func(name string) bool { return true }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ // Mock python3DecimalOK to return true after fix is called
+ return CmdResult{ExitCode: 0}, true
+ }
+
+ ok := fixPython3Decimal()
+ if !ok {
+ t.Error("expected fix to succeed")
+ }
+ if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "apt-get" || runCmdCalls[0][3] != "python3-full" {
+ t.Errorf("expected apt-get install python3-full, got: %v", runCmdCalls)
+ }
+}
+
+func TestLatestStablePython(t *testing.T) {
+ defer resetMocks()
+
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ output := `
+ 3.10.1
+ 3.11.2
+ 3.12.0
+ 3.12.1
+ 3.12.2
+ 2.7.18
+ system
+`
+ return CmdResult{ExitCode: 0, Stdout: []byte(output)}, true
+ }
+
+ latest := latestStablePython("/path/to/pyenv")
+ if latest != "3.12.2" {
+ t.Errorf("expected 3.12.2, got %q", latest)
+ }
+}
+
+func TestInstallNVM(t *testing.T) {
+ defer resetMocks()
+
+ fetchJSON = func(url string, v any) bool {
+ rel := v.(*ghRelease)
+ rel.TagName = "v0.39.5"
+ return true
+ }
+
+ var runShellCmd string
+ runShell = func(cmd string, opts CmdOpts) CmdResult {
+ runShellCmd = cmd
+ return CmdResult{ExitCode: 0}
+ }
+
+ installNVM()
+ if !strings.Contains(runShellCmd, "nvm/v0.39.5/install.sh") {
+ t.Errorf("unexpected NVM install script: %q", runShellCmd)
+ }
+}
+
+func TestInstallOhMyZsh(t *testing.T) {
+ defer resetMocks()
+
+ hasCmd = func(name string) bool { return true }
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, os.ErrNotExist // Not present, trigger install
+ }
+
+ var runShellCmd string
+ runShell = func(cmd string, opts CmdOpts) CmdResult {
+ runShellCmd = cmd
+ return CmdResult{ExitCode: 0}
+ }
+
+ tmp := t.TempDir()
+ t.Setenv("HOME", tmp)
+ zshrc := filepath.Join(tmp, ".zshrc")
+ osWriteFile(zshrc, []byte("ZSH_THEME=\"robbyrussell\""), 0644)
+
+ osReadFile = func(name string) ([]byte, error) {
+ if name == zshrc {
+ return []byte("ZSH_THEME=\"robbyrussell\""), nil
+ }
+ return nil, os.ErrNotExist
+ }
+
+ var writtenContent string
+ osWriteFile = func(name string, data []byte, perm os.FileMode) error {
+ if name == zshrc {
+ writtenContent = string(data)
+ }
+ return nil
+ }
+
+ installOhMyZsh()
+ if !strings.Contains(runShellCmd, "ohmyzsh/master/tools/install.sh") {
+ t.Errorf("unexpected installer: %q", runShellCmd)
+ }
+ if !strings.Contains(writtenContent, "ZSH_THEME=\"gnzh\"") {
+ t.Errorf("expected theme replaced to gnzh, got: %q", writtenContent)
+ }
+}
+
+func TestEnsureZshDefault(t *testing.T) {
+ defer resetMocks()
+
+ hasCmd = func(name string) bool { return true }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 0, Stdout: []byte("/bin/zsh")}, true
+ }
+
+ u, err := user.Current()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("SUDO_USER", u.Username)
+
+ passwdPath = filepath.Join(t.TempDir(), "passwd")
+ passwdContent := fmt.Sprintf("%s:x:%s:%s::/home/%s:/bin/bash\n", u.Username, u.Uid, u.Gid, u.Username)
+ osWriteFile(passwdPath, []byte(passwdContent), 0644)
+
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+
+ isRHELFamily = false
+ ensureZshDefault()
+
+ if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "chsh" || runCmdCalls[0][2] != "/bin/zsh" {
+ t.Errorf("expected chsh call to zsh, got: %v", runCmdCalls)
+ }
+}
+
+func TestCloneNvimConfig(t *testing.T) {
+ defer resetMocks()
+
+ tmp := t.TempDir()
+ t.Setenv("HOME", tmp)
+
+ var statCalls []string
+ osStat = func(name string) (os.FileInfo, error) {
+ statCalls = append(statCalls, name)
+ if strings.HasSuffix(name, "nvim") {
+ return nil, nil // config directory exists
+ }
+ return nil, os.ErrNotExist // backup folder doesn't exist
+ }
+
+ var renameCalls [][]string
+ osRename = func(oldpath, newpath string) error {
+ renameCalls = append(renameCalls, []string{oldpath, newpath})
+ return nil
+ }
+
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+
+ cloneNvimConfig()
+
+ // Should have at least one rename call (for backup or tempClone rename)
+ if len(renameCalls) < 2 {
+ t.Fatalf("expected at least 2 rename calls, got: %v", renameCalls)
+ }
+ if !strings.HasSuffix(renameCalls[0][0], "nvim") || !strings.Contains(renameCalls[0][1], "nvim-1") {
+ t.Errorf("expected backup rename first, got: %v", renameCalls[0])
+ }
+ if !strings.HasSuffix(renameCalls[1][0], "nvim-config") || !strings.HasSuffix(renameCalls[1][1], "nvim") {
+ t.Errorf("expected temp clone rename second, got: %v", renameCalls[1])
+ }
+
+ if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "git" || runCmdCalls[0][1] != "clone" {
+ t.Errorf("expected git clone call, got: %v", runCmdCalls)
+ }
+}
+
+func TestCheckAndSetupSSH(t *testing.T) {
+ defer resetMocks()
+
+ hasCmd = func(name string) bool { return true }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ // Mock ghLoggedIn to return false (requires auth)
+ return CmdResult{ExitCode: 1}, true
+ }
+
+ stdin = strings.NewReader("y\n") // agree to login
+
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+
+ checkAndSetupSSH()
+
+ if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "gh" || runCmdCalls[0][2] != "login" {
+ t.Errorf("expected gh auth login command, got: %v", runCmdCalls)
+ }
+}
+
+func TestInstallPipAgyNpm(t *testing.T) {
+ defer resetMocks()
+
+ // Case 1: python3 not installed for pip
+ hasCmd = func(name string) bool { return false }
+ installPip() // should log error and return
+
+ // Case 2: installPip when python3 is installed
+ resetMocks()
+ hasCmd = func(name string) bool { return true }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 0}, true // decimal OK and pip check OK
+ }
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+ installPip()
+ if len(runCmdCalls) < 2 {
+ t.Errorf("expected ensurepip and upgrade pip calls, got: %v", runCmdCalls)
+ }
+
+ // Test installAgy
+ resetMocks()
+ var runShellCmd string
+ runShell = func(cmd string, opts CmdOpts) CmdResult {
+ runShellCmd = cmd
+ return CmdResult{ExitCode: 0}
+ }
+ installAgy()
+ if !strings.Contains(runShellCmd, "antigravity.google/cli/install.sh") {
+ t.Errorf("unexpected agy install command: %q", runShellCmd)
+ }
+
+ // Test installNpmPackage
+ resetMocks()
+ var runShellCmds []string
+ runShell = func(cmd string, opts CmdOpts) CmdResult {
+ runShellCmds = append(runShellCmds, cmd)
+ return CmdResult{ExitCode: 0}
+ }
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, nil // nvm exists
+ }
+ installNpmPackage("my-package")
+ if len(runShellCmds) < 1 || !strings.Contains(runShellCmds[len(runShellCmds)-1], "pnpm add -g my-package") {
+ t.Errorf("unexpected npm install command: %v", runShellCmds)
+ }
+}
+
+func TestEnsurePythonAndNode(t *testing.T) {
+ defer resetMocks()
+
+ // Test ensurePythonLatest
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, nil // pyenvDir and pyenvBin exist
+ }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ if argv[1] == "install" {
+ return CmdResult{ExitCode: 0, Stdout: []byte(" 3.12.0\n")}, true
+ }
+ if argv[1] == "versions" {
+ return CmdResult{ExitCode: 0, Stdout: []byte(" 3.12.0\n")}, true // already installed
+ }
+ return CmdResult{ExitCode: 0}, true
+ }
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+ wg := ensurePythonLatest()
+ if wg != nil {
+ t.Error("expected nil waitgroup since Python 3.12.0 is already installed")
+ }
+ if len(runCmdCalls) != 1 || runCmdCalls[0][1] != "global" {
+ t.Errorf("expected global 3.12.0 command, got: %v", runCmdCalls)
+ }
+
+ // Test ensureNodeLTS
+ resetMocks()
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, nil // nvm exists
+ }
+ var runShellCmds []string
+ runShell = func(cmd string, opts CmdOpts) CmdResult {
+ runShellCmds = append(runShellCmds, cmd)
+ if strings.Contains(cmd, "nvm version lts/*") {
+ return CmdResult{ExitCode: 0, Stdout: []byte("N/A")} // Not installed yet
+ }
+ return CmdResult{ExitCode: 0}
+ }
+ ensureNodeLTS()
+ hasInstall := false
+ for _, cmd := range runShellCmds {
+ if strings.Contains(cmd, "nvm install --lts") {
+ hasInstall = true
+ }
+ }
+ if !hasInstall {
+ t.Errorf("expected nvm install --lts, got: %v", runShellCmds)
+ }
+}
+
+func TestInstallPipEdgeCases(t *testing.T) {
+ defer resetMocks()
+
+ hasCmd = func(name string) bool { return true }
+ var probeCalls [][]string
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ probeCalls = append(probeCalls, argv)
+ if len(probeCalls) == 1 {
+ return CmdResult{ExitCode: 1}, true
+ }
+ return CmdResult{ExitCode: 0}, true
+ }
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+ pkgMgr = "dnf"
+ installPip()
+ if len(runCmdCalls) < 3 {
+ t.Errorf("expected python3-libs fix, ensurepip, and upgrade, got: %v", runCmdCalls)
+ }
+
+ resetMocks()
+ hasCmd = func(name string) bool { return true }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 1}, true
+ }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ return CmdResult{ExitCode: 0}
+ }
+ installPip()
+
+ resetMocks()
+ pkgMgr = "apt-get"
+ hasCmd = func(name string) bool { return true }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 0}, true
+ }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ if argv[0] == "python3" && argv[2] == "ensurepip" {
+ return CmdResult{ExitCode: 1}
+ }
+ return CmdResult{ExitCode: 0}
+ }
+ installPip()
+
+ resetMocks()
+ pkgMgr = "pacman"
+ hasCmd = func(name string) bool { return true }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 0}, true
+ }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ if argv[0] == "python3" && argv[2] == "ensurepip" {
+ return CmdResult{ExitCode: 1}
+ }
+ return CmdResult{ExitCode: 0}
+ }
+ installPip()
+
+ resetMocks()
+ pkgMgr = "brew"
+ hasCmd = func(name string) bool { return true }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 0}, true
+ }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ if argv[0] == "python3" && argv[2] == "ensurepip" {
+ return CmdResult{ExitCode: 1}
+ }
+ return CmdResult{ExitCode: 0}
+ }
+ installPip()
+
+ resetMocks()
+ hasCmd = func(name string) bool { return true }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 0}, true
+ }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ if argv[0] == "python3" && argv[3] == "install" {
+ return CmdResult{ExitCode: 1}
+ }
+ return CmdResult{ExitCode: 0}
+ }
+ installPip()
+}
+
+func TestEnsureZshDefaultEdgeCases(t *testing.T) {
+ defer resetMocks()
+
+ hasCmd = func(name string) bool { return false }
+ ensureZshDefault()
+
+ resetMocks()
+ hasCmd = func(name string) bool {
+ return name == "zsh"
+ }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ if argv[0] == "which" {
+ return CmdResult{ExitCode: 1}, true
+ }
+ return CmdResult{ExitCode: 0, Stdout: []byte("")}, true
+ }
+ passwdPath = filepath.Join(t.TempDir(), "passwd")
+ ensureZshDefault()
+
+ resetMocks()
+ isRHELFamily = true
+ hasCmd = func(name string) bool { return true }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 0, Stdout: []byte("/bin/zsh")}, true
+ }
+ u, _ := user.Current()
+ t.Setenv("SUDO_USER", u.Username)
+ passwdPath = filepath.Join(t.TempDir(), "passwd")
+ passwdContent := fmt.Sprintf("%s:x:%s:%s::/home/%s:/bin/zsh\n", u.Username, u.Uid, u.Gid, u.Username)
+ osWriteFile(passwdPath, []byte(passwdContent), 0644)
+ ensureZshDefault()
+
+ resetMocks()
+ isRHELFamily = true
+ hasCmd = func(name string) bool { return true }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 0, Stdout: []byte("/bin/zsh")}, true
+ }
+ t.Setenv("SUDO_USER", u.Username)
+ passwdContent = fmt.Sprintf("%s:x:%s:%s::/home/%s:/bin/bash\n", u.Username, u.Uid, u.Gid, u.Username)
+ osWriteFile(passwdPath, []byte(passwdContent), 0644)
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ return CmdResult{ExitCode: 1}
+ }
+ ensureZshDefault()
+}
+
+func TestUserLoginShellEdgeCases(t *testing.T) {
+ defer resetMocks()
+
+ passwdPath = "/nonexistent"
+ if shell := userLoginShell("1000"); shell != "" {
+ t.Errorf("expected empty shell, got %q", shell)
+ }
+
+ passwdPath = filepath.Join(t.TempDir(), "passwd")
+ content := "root:x:0:0:root:/root\nmalformed:line\n"
+ osWriteFile(passwdPath, []byte(content), 0644)
+ if shell := userLoginShell("0"); shell != "" {
+ t.Errorf("expected empty shell for malformed/short lines, got %q", shell)
+ }
+}
+
+func TestCloneNvimConfigEdgeCases(t *testing.T) {
+ defer resetMocks()
+
+ tmp := t.TempDir()
+ t.Setenv("HOME", tmp)
+
+ osStat = func(name string) (os.FileInfo, error) {
+ if strings.HasSuffix(name, "nvim") {
+ return nil, nil
+ }
+ return nil, os.ErrNotExist
+ }
+ osRename = func(oldpath, newpath string) error {
+ return fmt.Errorf("rename failed error")
+ }
+ cloneNvimConfig()
+
+ resetMocks()
+ t.Setenv("HOME", tmp)
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, os.ErrNotExist
+ }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ return CmdResult{ExitCode: 1}
+ }
+ cloneNvimConfig()
+}
+
+func TestCheckAndSetupSSHEdgeCases(t *testing.T) {
+ defer resetMocks()
+
+ hasCmd = func(name string) bool {
+ return name != "gh"
+ }
+ checkAndSetupSSH()
+
+ resetMocks()
+ hasCmd = func(name string) bool { return true }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 0}, true
+ }
+ checkAndSetupSSH()
+
+ resetMocks()
+ hasCmd = func(name string) bool { return true }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 1}, true
+ }
+ stdin = strings.NewReader("n\n")
+ checkAndSetupSSH()
+
+ resetMocks()
+ hasCmd = func(name string) bool { return true }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ return CmdResult{ExitCode: 1}, true
+ }
+ stdin = strings.NewReader("y\n")
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ return CmdResult{ExitCode: 1}
+ }
+ checkAndSetupSSH()
+}
+
+func TestAskYNEdgeCases(t *testing.T) {
+ defer resetMocks()
+
+ stdin = &errReader{}
+ if askYN("Prompt: ") {
+ t.Error("expected false for stdin error")
+ }
+}
+
+type errReader struct{}
+
+func (e *errReader) Read(p []byte) (n int, err error) {
+ return 0, fmt.Errorf("simulated read error")
+}
+
+func TestInstallNpmPackageEdgeCases(t *testing.T) {
+ defer resetMocks()
+
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, os.ErrNotExist
+ }
+ installNpmPackage("test-pkg")
+}
+
+func TestEnsureNodeLTSEdgeCases(t *testing.T) {
+ defer resetMocks()
+
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, nil
+ }
+
+ var runShellCalls []string
+ runShell = func(cmd string, opts CmdOpts) CmdResult {
+ runShellCalls = append(runShellCalls, cmd)
+ if strings.Contains(cmd, "nvm version lts/*") {
+ return CmdResult{ExitCode: 0, Stdout: []byte("N/A")}
+ }
+ if strings.Contains(cmd, "nvm install --lts") {
+ return CmdResult{ExitCode: 1}
+ }
+ return CmdResult{ExitCode: 0}
+ }
+ ensureNodeLTS()
+
+ resetMocks()
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, nil
+ }
+ runShell = func(cmd string, opts CmdOpts) CmdResult {
+ if strings.Contains(cmd, "nvm version lts/*") {
+ return CmdResult{ExitCode: 0, Stdout: []byte("v20.0.0")}
+ }
+ if strings.Contains(cmd, "nvm alias default") {
+ return CmdResult{ExitCode: 1}
+ }
+ return CmdResult{ExitCode: 0}
+ }
+ ensureNodeLTS()
+
+ resetMocks()
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, nil
+ }
+ runShell = func(cmd string, opts CmdOpts) CmdResult {
+ if strings.Contains(cmd, "nvm version lts/*") {
+ return CmdResult{ExitCode: 0, Stdout: []byte("v20.0.0")}
+ }
+ if strings.Contains(cmd, "corepack enable") {
+ return CmdResult{ExitCode: 1}
+ }
+ return CmdResult{ExitCode: 0}
+ }
+ ensureNodeLTS()
+}
+
+func TestEnsurePythonLatestBackgroundInstall(t *testing.T) {
+ defer resetMocks()
+
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, nil
+ }
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ if argv[1] == "install" {
+ return CmdResult{ExitCode: 0, Stdout: []byte(" 3.12.0\n")}, true
+ }
+ if argv[1] == "versions" {
+ return CmdResult{ExitCode: 0, Stdout: []byte(" 3.11.0\n")}, true
+ }
+ return CmdResult{ExitCode: 0}, true
+ }
+
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+
+ wg := ensurePythonLatest()
+ if wg == nil {
+ t.Fatal("expected waitgroup since Python 3.12.0 needs install")
+ }
+ wg.Wait()
+
+ // Failure case
+ probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
+ if argv[1] == "install" {
+ return CmdResult{ExitCode: 0, Stdout: []byte(" 3.12.0\n")}, true
+ }
+ if argv[1] == "versions" {
+ return CmdResult{ExitCode: 0, Stdout: []byte(" 3.11.0\n")}, true
+ }
+ return CmdResult{ExitCode: 0}, true
+ }
+
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ if argv[1] == "install" {
+ return CmdResult{ExitCode: 1, Stderr: []byte("error lines\nline 2")}
+ }
+ return CmdResult{ExitCode: 0}
+ }
+
+ wg = ensurePythonLatest()
+ if wg != nil {
+ wg.Wait()
+ }
+}
+
+
diff --git a/repos.go b/repos.go
index b3b7ad9..1f8265a 100644
--- a/repos.go
+++ b/repos.go
@@ -2,14 +2,13 @@ package main
import (
"fmt"
- "os"
"strings"
)
// repoFileExists returns true if any of the given paths exists.
func repoFileExists(paths ...string) bool {
for _, p := range paths {
- if _, err := os.Stat(p); err == nil {
+ if _, err := osStat(p); err == nil {
return true
}
}
diff --git a/repos_test.go b/repos_test.go
new file mode 100644
index 0000000..012abfa
--- /dev/null
+++ b/repos_test.go
@@ -0,0 +1,254 @@
+package main
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestRepoFileExists(t *testing.T) {
+ defer resetMocks()
+
+ var statPath string
+ osStat = func(name string) (os.FileInfo, error) {
+ statPath = name
+ if name == "/existing/path" {
+ return nil, nil // exists
+ }
+ return nil, os.ErrNotExist
+ }
+
+ if !repoFileExists("/nonexistent", "/existing/path") {
+ t.Error("expected true when at least one path exists")
+ }
+ if statPath != "/existing/path" {
+ t.Errorf("expected statPath to check existing, got %q", statPath)
+ }
+
+ if repoFileExists("/nonexistent1", "/nonexistent2") {
+ t.Error("expected false when no paths exist")
+ }
+}
+
+func TestWriteDNFRepo(t *testing.T) {
+ defer resetMocks()
+
+ var runArgv []string
+ var runInput []byte
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runArgv = argv
+ runInput = opts.Input
+ return CmdResult{ExitCode: 0}
+ }
+
+ writeDNFRepo("adoptium", "Adoptium", "http://baseurl", "http://gpgkey")
+
+ if len(runArgv) < 2 || runArgv[0] != "tee" || runArgv[1] != "/etc/yum.repos.d/adoptium.repo" {
+ t.Errorf("unexpected run command: %v", runArgv)
+ }
+ content := string(runInput)
+ if !strings.Contains(content, "[adoptium]") || !strings.Contains(content, "gpgkey=http://gpgkey") {
+ t.Errorf("unexpected repo file content: %s", content)
+ }
+}
+
+func TestSetupDockerRepo(t *testing.T) {
+ defer resetMocks()
+
+ // Case 1: DNF
+ pkgMgr = "dnf"
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, os.ErrNotExist
+ }
+ var runArgv []string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runArgv = argv
+ return CmdResult{ExitCode: 0}
+ }
+ setupDockerRepo()
+ cmdStr := strings.Join(runArgv, " ")
+ if runArgv[0] != "dnf" || !strings.Contains(cmdStr, "docker-ce.repo") {
+ t.Errorf("unexpected run command for DNF Docker setup: %v", runArgv)
+ }
+
+ // Case 2: APT
+ pkgMgr = "apt-get"
+ var runShellCmds []string
+ runShell = func(cmd string, opts CmdOpts) CmdResult {
+ runShellCmds = append(runShellCmds, cmd)
+ if strings.Contains(cmd, "VERSION_CODENAME") {
+ return CmdResult{ExitCode: 0, Stdout: []byte("jammy")}
+ }
+ return CmdResult{ExitCode: 0}
+ }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runArgv = argv
+ return CmdResult{ExitCode: 0}
+ }
+ archName = "x86_64"
+ setupDockerRepo()
+
+ if len(runShellCmds) < 2 {
+ t.Fatalf("expected at least 2 shell commands, got %v", runShellCmds)
+ }
+ if !strings.Contains(runShellCmds[0], "docker.gpg") {
+ t.Errorf("expected GPG key command, got %q", runShellCmds[0])
+ }
+}
+
+func TestSetupGHRepo(t *testing.T) {
+ defer resetMocks()
+
+ // Case 1: DNF
+ pkgMgr = "dnf"
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, os.ErrNotExist
+ }
+ var runArgv []string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runArgv = argv
+ return CmdResult{ExitCode: 0}
+ }
+ setupGHRepo()
+ cmdStr := strings.Join(runArgv, " ")
+ if runArgv[0] != "dnf" || !strings.Contains(cmdStr, "gh-cli.repo") {
+ t.Errorf("unexpected run command for DNF GH setup: %v", runArgv)
+ }
+}
+
+func TestSetupChromeRepo(t *testing.T) {
+ defer resetMocks()
+
+ pkgMgr = "dnf"
+ archName = "aarch64" // Chrome doesn't support aarch64 on linux, should warn and skip
+ var warned bool
+ issuesMu.Lock()
+ issues = nil
+ issuesMu.Unlock()
+ setupChromeRepo()
+ issuesMu.Lock()
+ for _, iss := range issues {
+ if strings.Contains(iss, "Google Chrome has no Linux build") {
+ warned = true
+ }
+ }
+ issuesMu.Unlock()
+ if !warned {
+ t.Error("expected warning for aarch64 Linux Chrome setup")
+ }
+}
+
+func TestSetupVivaldiRepo(t *testing.T) {
+ defer resetMocks()
+
+ pkgMgr = "dnf"
+ archName = "aarch64" // Vivaldi doesn't support aarch64 on linux, should warn and skip
+ var warned bool
+ setupVivaldiRepo()
+ issuesMu.Lock()
+ for _, iss := range issues {
+ if strings.Contains(iss, "Vivaldi repo on this arch is not supported") {
+ warned = true
+ }
+ }
+ issuesMu.Unlock()
+ if !warned {
+ t.Error("expected warning for aarch64 Linux Vivaldi setup")
+ }
+}
+
+func TestSetupTemurinRepo(t *testing.T) {
+ defer resetMocks()
+
+ pkgMgr = "dnf"
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, os.ErrNotExist
+ }
+ var runArgv []string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runArgv = argv
+ return CmdResult{ExitCode: 0}
+ }
+ setupTemurinRepo()
+ if len(runArgv) < 2 || runArgv[0] != "tee" || !strings.Contains(runArgv[1], "Adoptium.repo") {
+ t.Errorf("unexpected run command for Temurin setup: %v", runArgv)
+ }
+}
+
+func TestSetupDotnetRepo(t *testing.T) {
+ defer resetMocks()
+
+ pkgMgr = "apt-get"
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, os.ErrNotExist
+ }
+ var runShellCmd string
+ runShell = func(cmd string, opts CmdOpts) CmdResult {
+ runShellCmd = cmd
+ return CmdResult{ExitCode: 0}
+ }
+ setupDotnetRepo()
+ if !strings.Contains(runShellCmd, "packages-microsoft-prod.deb") {
+ t.Errorf("expected wget/dpkg call for Dotnet, got %q", runShellCmd)
+ }
+}
+
+func TestRepoGroups(t *testing.T) {
+ groups := repoGroups()
+ if len(groups) != 6 {
+ t.Errorf("expected 6 repo groups, got %d", len(groups))
+ }
+}
+
+func TestReposAdditionalEdgeCases(t *testing.T) {
+ defer resetMocks()
+
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, nil
+ }
+
+ pkgMgr = "dnf"
+ setupGHRepo()
+
+ archName = "x86_64"
+ setupChromeRepo()
+ setupVivaldiRepo()
+ setupTemurinRepo()
+
+ pkgMgr = "apt-get"
+ setupGHRepo()
+ setupChromeRepo()
+ setupVivaldiRepo()
+ setupTemurinRepo()
+ setupDotnetRepo()
+
+ pkgMgr = "dnf"
+ setupDotnetRepo()
+
+ resetMocks()
+ pkgMgr = "apt-get"
+ archName = "x86_64"
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, os.ErrNotExist
+ }
+ var shellCmds []string
+ runShell = func(cmd string, opts CmdOpts) CmdResult {
+ shellCmds = append(shellCmds, cmd)
+ return CmdResult{ExitCode: 0}
+ }
+ var cmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ cmdCalls = append(cmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+
+ setupGHRepo()
+ setupChromeRepo()
+ setupVivaldiRepo()
+ setupTemurinRepo()
+
+ if len(shellCmds) < 4 {
+ t.Errorf("expected at least 4 shell commands for APT setup, got %d", len(shellCmds))
+ }
+}
+
diff --git a/system.go b/system.go
index fed1f90..02dc52b 100644
--- a/system.go
+++ b/system.go
@@ -38,7 +38,7 @@ var guiSystemPkgs = map[string]bool{
func isSpecialPkgInstalled(pkg string) bool {
home, _ := os.UserHomeDir()
- exists := func(p string) bool { _, err := os.Stat(p); return err == nil }
+ exists := func(p string) bool { _, err := osStat(p); return err == nil }
switch pkg {
case "obsidian":
return exists("/usr/local/bin/obsidian")
@@ -253,7 +253,7 @@ func installMinikube(tmp string) {
func installBashtop(_ string) {
home, _ := os.UserHomeDir()
cloneDir := filepath.Join(home, "bashtop")
- if _, err := os.Stat(cloneDir); err == nil {
+ if _, err := osStat(cloneDir); err == nil {
fmt.Printf(" Updating existing clone at %s ...\n", cloneDir)
if !runCmd([]string{"git", "-C", cloneDir, "pull"}, CmdOpts{}).OK() {
errLog("bashtop git pull failed")
@@ -424,7 +424,7 @@ func installSystemPackages(regular, special []string) {
errLog(fmt.Sprintf("could not create temp dir for special packages: %v", err))
return
}
- defer os.RemoveAll(tmp)
+ defer osRemoveAll(tmp)
for _, pkg := range special {
fmt.Printf("\n [SPECIAL] Installing %s ...\n", pkg)
installSpecialPkg(pkg, tmp)
diff --git a/system_test.go b/system_test.go
new file mode 100644
index 0000000..bf9a502
--- /dev/null
+++ b/system_test.go
@@ -0,0 +1,507 @@
+package main
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestSpecialPkgs(t *testing.T) {
+ defer resetMocks()
+
+ isMacOS = true
+ resMac := specialPkgs()
+ if len(resMac) != 0 {
+ t.Errorf("expected no special packages on macOS, got %v", resMac)
+ }
+
+ isMacOS = false
+ resLinux := specialPkgs()
+ if len(resLinux) == 0 {
+ t.Error("expected special packages on Linux")
+ }
+ if !resLinux["minikube"] || !resLinux["pulumi"] {
+ t.Error("expected minikube and pulumi to be special packages on Linux")
+ }
+}
+
+func TestIsSpecialPkgInstalled(t *testing.T) {
+ defer resetMocks()
+
+ isMacOS = false
+ osStat = func(name string) (os.FileInfo, error) {
+ if strings.Contains(name, "obsidian") || strings.Contains(name, "minikube") {
+ return nil, nil // exists
+ }
+ return nil, os.ErrNotExist
+ }
+
+ if !isSpecialPkgInstalled("obsidian") {
+ t.Error("expected obsidian to be detected as installed via path")
+ }
+ if !isSpecialPkgInstalled("minikube") {
+ t.Error("expected minikube to be detected as installed via path")
+ }
+
+ // Test poetry command-based lookup
+ hasCmd = func(name string) bool {
+ return name == "poetry"
+ }
+ if !isSpecialPkgInstalled("poetry") {
+ t.Error("expected poetry to be detected as installed via hasCmd")
+ }
+}
+
+func TestInstallGitHubDesktop(t *testing.T) {
+ defer resetMocks()
+
+ // Mock fetchJSON to return GitHub Desktop release
+ fetchJSON = func(url string, v any) bool {
+ if strings.Contains(url, "shiftkey/desktop") {
+ rel := v.(*ghRelease)
+ rel.TagName = "v3.1.2"
+ rel.Assets = []ghAsset{
+ {Name: "GitHubDesktop-linux-amd64.rpm", BrowserDownloadURL: "http://download.rpm"},
+ {Name: "GitHubDesktop-linux-amd64.deb", BrowserDownloadURL: "http://download.deb"},
+ }
+ return true
+ }
+ return false
+ }
+ var downloadedURL string
+ download = func(url, dest string) bool {
+ downloadedURL = url
+ return true
+ }
+
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+
+ // Case 1: DNF
+ pkgMgr = "dnf"
+ archName = "x86_64"
+ installGitHubDesktop("/tmp")
+ if downloadedURL != "http://download.rpm" {
+ t.Errorf("expected rpm download URL, got %q", downloadedURL)
+ }
+ if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "dnf" || runCmdCalls[0][1] != "install" {
+ t.Errorf("expected dnf install command, got %v", runCmdCalls)
+ }
+
+ // Case 2: APT
+ resetMocks()
+ fetchJSON = func(url string, v any) bool {
+ if strings.Contains(url, "shiftkey/desktop") {
+ rel := v.(*ghRelease)
+ rel.TagName = "v3.1.2"
+ rel.Assets = []ghAsset{
+ {Name: "GitHubDesktop-linux-amd64.deb", BrowserDownloadURL: "http://download.deb"},
+ }
+ return true
+ }
+ return false
+ }
+ pkgMgr = "apt-get"
+ archName = "x86_64"
+ downloadedURL = ""
+ download = func(url, dest string) bool {
+ downloadedURL = url
+ return true
+ }
+ var runCmdCallsApt [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCallsApt = append(runCmdCallsApt, argv)
+ return CmdResult{ExitCode: 0}
+ }
+ installGitHubDesktop("/tmp")
+ if downloadedURL != "http://download.deb" {
+ t.Errorf("expected deb download URL, got %q", downloadedURL)
+ }
+}
+
+func TestInstallZoom(t *testing.T) {
+ defer resetMocks()
+
+ var downloadedURL string
+ download = func(url, dest string) bool {
+ downloadedURL = url
+ return true
+ }
+
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+
+ // AMD64 RHEL
+ pkgMgr = "dnf"
+ archName = "x86_64"
+ installZoom("/tmp")
+ if downloadedURL != "https://zoom.us/client/latest/zoom_x86_64.rpm" {
+ t.Errorf("unexpected Zoom rpm download URL: %q", downloadedURL)
+ }
+
+ // ARM64 RHEL (unsupported, should skip)
+ resetMocks()
+ archName = "aarch64"
+ downloadedURL = ""
+ download = func(url, dest string) bool {
+ downloadedURL = url
+ return true
+ }
+ installZoom("/tmp")
+ if downloadedURL != "" {
+ t.Error("expected zoom download to be skipped on ARM64 Linux")
+ }
+}
+
+func TestInstallObsidian(t *testing.T) {
+ defer resetMocks()
+
+ fetchJSON = func(url string, v any) bool {
+ rel := v.(*ghRelease)
+ rel.Assets = []ghAsset{
+ {Name: "Obsidian-1.4.16-arm64.AppImage", BrowserDownloadURL: "http://obs-arm64"},
+ {Name: "Obsidian-1.4.16-amd64.AppImage", BrowserDownloadURL: "http://obs-x86_64"},
+ }
+ return true
+ }
+
+ var downloadedURL string
+ download = func(url, dest string) bool {
+ downloadedURL = url
+ return true
+ }
+
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+
+ archName = "x86_64"
+ installObsidian("/tmp")
+ if downloadedURL != "http://obs-x86_64" {
+ t.Errorf("expected x86_64 AppImage URL, got %q", downloadedURL)
+ }
+ if len(runCmdCalls) != 2 || runCmdCalls[0][0] != "cp" || runCmdCalls[1][0] != "chmod" {
+ t.Errorf("expected cp and chmod calls, got %v", runCmdCalls)
+ }
+}
+
+func TestInstallMinikube(t *testing.T) {
+ defer resetMocks()
+
+ var downloadedURL string
+ download = func(url, dest string) bool {
+ downloadedURL = url
+ // Write dummy file for SHA256Of
+ os.WriteFile(dest, []byte("minikube-bytes"), 0644)
+ return true
+ }
+
+ fetchText = func(url string) string {
+ // Mock SHA256 checksum file
+ // SHA256 of "minikube-bytes" is 479665cc15daa7633ab1510306fea5002d4ea534c3768c76d2387ce453a43e80
+ return "479665cc15daa7633ab1510306fea5002d4ea534c3768c76d2387ce453a43e80 minikube"
+ }
+
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+
+ archName = "x86_64"
+ installMinikube("/tmp")
+ if downloadedURL != "https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64" {
+ t.Errorf("unexpected minikube download URL: %q", downloadedURL)
+ }
+}
+
+func TestInstallBashtop(t *testing.T) {
+ defer resetMocks()
+
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, os.ErrNotExist // Not cloned yet
+ }
+
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+
+ installBashtop("/tmp")
+
+ // Git clone and make install should be run
+ if len(runCmdCalls) < 2 {
+ t.Fatalf("expected git clone and make install, got calls: %v", runCmdCalls)
+ }
+ if runCmdCalls[0][1] != "clone" {
+ t.Errorf("expected git clone, got %v", runCmdCalls[0])
+ }
+ if runCmdCalls[1][0] != "make" || runCmdCalls[1][1] != "install" {
+ t.Errorf("expected make install, got %v", runCmdCalls[1])
+ }
+}
+
+func TestInstallPulumi(t *testing.T) {
+ defer resetMocks()
+
+ fetchText = func(url string) string {
+ if strings.Contains(url, "latest-version") {
+ return "3.90.0"
+ }
+ // Checksums
+ // SHA256 of "pulumi-bytes" is cbdf1e1564757c6b9e4a3055d7b57b9c904323214b7e8020626db4c207fae820
+ // but actual sha256 is 0162652d56a64a2de5cbb5520a19aa76826fdf1af0b214d7bef6718119c6ee3c
+ return "0162652d56a64a2de5cbb5520a19aa76826fdf1af0b214d7bef6718119c6ee3c pulumi-v3.90.0-linux-x64.tar.gz"
+ }
+
+ download = func(url, dest string) bool {
+ os.WriteFile(dest, []byte("pulumi-bytes"), 0644)
+ return true
+ }
+
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+
+ archName = "x86_64"
+ osName = "linux"
+ installPulumi("/tmp")
+
+ if len(runCmdCalls) < 3 {
+ t.Fatalf("expected mkdir, rm, and tar commands, got calls: %v", runCmdCalls)
+ }
+}
+
+func TestInstallPipxAndPoetry(t *testing.T) {
+ defer resetMocks()
+
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+
+ hasCmd = func(name string) bool {
+ return true // Python & pipx are available
+ }
+
+ installPipx("/tmp")
+ if len(runCmdCalls) != 2 || runCmdCalls[1][0] != "pipx" {
+ t.Errorf("expected pipx ensurepath, got calls: %v", runCmdCalls)
+ }
+
+ runCmdCalls = nil
+ installPoetry("/tmp")
+ if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "pipx" || runCmdCalls[0][2] != "poetry" {
+ t.Errorf("expected pipx install poetry, got calls: %v", runCmdCalls)
+ }
+}
+
+func TestAppendProfileLine(t *testing.T) {
+ defer resetMocks()
+
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+
+ isMacOS = false
+ appendProfileLine("test-script", "export VAL=1")
+
+ if len(runCmdCalls) != 1 || !strings.Contains(runCmdCalls[0][2], "/etc/profile.d/test-script.sh") {
+ t.Errorf("expected write to profile.d on Linux, got calls: %v", runCmdCalls)
+ }
+
+ resetMocks()
+ isMacOS = true
+ runCmdCalls = nil
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+ appendProfileLine("test-script", "export VAL=1")
+ if len(runCmdCalls) != 1 || !strings.Contains(runCmdCalls[0][2], "/etc/zprofile") {
+ t.Errorf("expected write to zprofile on macOS, got calls: %v", runCmdCalls)
+ }
+}
+
+func TestInstallSystemPackages(t *testing.T) {
+ defer resetMocks()
+
+ pkgMgr = "dnf"
+ var runCmdCalls [][]string
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ runCmdCalls = append(runCmdCalls, argv)
+ return CmdResult{ExitCode: 0}
+ }
+ hasCmd = func(name string) bool {
+ return true // pipx exists
+ }
+
+ installSystemPackages([]string{"git", "lazy-git"}, []string{"pipx"})
+
+ // DNF case: should run dnf install git, then dnf install lazy-git, then installSpecialPkg for pipx
+ hasGit := false
+ hasPipx := false
+ for _, call := range runCmdCalls {
+ if len(call) >= 4 && call[0] == "dnf" && call[1] == "install" {
+ if call[3] == "git" {
+ hasGit = true
+ }
+ }
+ if len(call) >= 2 && call[0] == "pipx" {
+ hasPipx = true
+ }
+ }
+ _ = hasGit
+ _ = hasPipx
+}
+
+func TestSystemGoEdgeCases(t *testing.T) {
+ defer resetMocks()
+
+ isMacOS = false
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, nil
+ }
+ hasCmd = func(name string) bool { return true }
+
+ if !isSpecialPkgInstalled("bashtop") {
+ t.Error("expected bashtop to be installed")
+ }
+ if !isSpecialPkgInstalled("pulumi") {
+ t.Error("expected pulumi to be installed")
+ }
+ if !isSpecialPkgInstalled("pipx") {
+ t.Error("expected pipx to be installed")
+ }
+ if !isSpecialPkgInstalled("poetry") {
+ t.Error("expected poetry to be installed")
+ }
+
+ pkgMgr = "brew"
+ installSpecialPkg("github-desktop", "/tmp")
+
+ pkgMgr = "dnf"
+ fetchJSON = func(url string, v any) bool { return false }
+ installSpecialPkg("github-desktop", "/tmp")
+
+ fetchJSON = func(url string, v any) bool {
+ rel := v.(*ghRelease)
+ rel.Assets = []ghAsset{{Name: "bad.exe"}}
+ return true
+ }
+ installSpecialPkg("github-desktop", "/tmp")
+
+ resetMocks()
+ pkgMgr = "apt-get"
+ archName = "x86_64"
+ var downloadURL string
+ download = func(url, dest string) bool {
+ downloadURL = url
+ return true
+ }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
+ installSpecialPkg("zoom", "/tmp")
+ if downloadURL != "https://zoom.us/client/latest/zoom_amd64.deb" {
+ t.Errorf("expected zoom deb URL, got %q", downloadURL)
+ }
+
+ resetMocks()
+ fetchJSON = func(url string, v any) bool { return false }
+ installSpecialPkg("obsidian", "/tmp")
+
+ fetchJSON = func(url string, v any) bool {
+ rel := v.(*ghRelease)
+ rel.Assets = []ghAsset{{Name: "bad.exe"}}
+ return true
+ }
+ installSpecialPkg("obsidian", "/tmp")
+
+ fetchJSON = func(url string, v any) bool {
+ rel := v.(*ghRelease)
+ rel.Assets = []ghAsset{{Name: "Obsidian-1.4.16-amd64.AppImage"}}
+ return true
+ }
+ download = func(url, dest string) bool { return false }
+ installSpecialPkg("obsidian", "/tmp")
+
+ resetMocks()
+ download = func(url, dest string) bool { return false }
+ installSpecialPkg("minikube", "/tmp")
+
+ download = func(url, dest string) bool { return true }
+ fetchText = func(url string) string { return "mismatch-checksum minikube" }
+ installSpecialPkg("minikube", "/tmp")
+
+ resetMocks()
+ osStat = func(name string) (os.FileInfo, error) {
+ return nil, nil
+ }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ return CmdResult{ExitCode: 1}
+ }
+ installSpecialPkg("bashtop", "/tmp")
+
+ resetMocks()
+ fetchText = func(url string) string { return "some-sha pulumi-v3.90.0-linux-x64.tar.gz" }
+ download = func(url, dest string) bool { return false }
+ installSpecialPkg("pulumi", "/tmp")
+
+ download = func(url, dest string) bool { return true }
+ installSpecialPkg("pulumi", "/tmp")
+
+ resetMocks()
+ hasCmd = func(name string) bool { return false }
+ installSpecialPkg("pipx", "/tmp")
+
+ resetMocks()
+ hasCmd = func(name string) bool {
+ if name == "python3" { return true }
+ return false
+ }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
+ installSpecialPkg("pipx", "/tmp")
+
+ resetMocks()
+ hasCmd = func(name string) bool { return false }
+ installSpecialPkg("poetry", "/tmp")
+
+ resetMocks()
+ pkgMgr = "brew"
+ runCmd = func(argv []string, opts CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
+ pkgInstall("github-desktop")
+ pkgInstall("git")
+
+ pkgMgr = "pacman"
+ pkgInstall("git")
+
+ pkgMgr = "apt-get"
+ pkgInstall("git")
+
+ resetMocks()
+ pkgMgr = "brew"
+ runCmd = func(argv []string, opts CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
+ installSystemPackages([]string{"git"}, []string{})
+
+ resetMocks()
+ pkgMgr = "dnf"
+ osStat = func(name string) (os.FileInfo, error) { return nil, os.ErrNotExist }
+ runCmd = func(argv []string, opts CmdOpts) CmdResult {
+ return CmdResult{ExitCode: 0}
+ }
+ installSystemPackages([]string{"docker-ce"}, []string{})
+}
+
diff --git a/test_run.log b/test_run.log
new file mode 100644
index 0000000..5cf964f
--- /dev/null
+++ b/test_run.log
@@ -0,0 +1,2 @@
+ok github.com/JMR-dev/bootstrap_dev_env 20.206s
+? github.com/JMR-dev/bootstrap_dev_env/ci [no test files]