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]