From 79615dd529d6f627df9e2a390dab5f601041716c Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Thu, 11 Jun 2026 08:19:31 -0500 Subject: [PATCH 1/5] ensure zsh is installed first --- compat.go | 3 + helper_test.go | 4 +- main.go | 160 +++++++++++++++++++++++++++++++- post.go | 22 +++++ progress_test.go | 232 +++++++++++++++++++++++++++++++++++++++++++++++ testmain_test.go | 1 + 6 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 progress_test.go diff --git a/compat.go b/compat.go index 7ce0f10..7474cf4 100644 --- a/compat.go +++ b/compat.go @@ -31,4 +31,7 @@ var ( // Filesystem paths osReleasePath = "/etc/os-release" passwdPath = "/etc/passwd" + + // Testing override + disableProgressTracking = false ) diff --git a/helper_test.go b/helper_test.go index 857f22f..5478f0b 100644 --- a/helper_test.go +++ b/helper_test.go @@ -23,13 +23,13 @@ func resetMocks() { osRemove = os.Remove osRemoveAll = os.RemoveAll osExit = os.Exit + progressConfigPath = progressConfigPathReal // Default to an empty reader so un-mocked tests don't hang waiting for user input. stdin = strings.NewReader("") readPassword = func() ([]byte, error) { return nil, errors.New("terminal blocked in test") } - // Reset global state variables to safe defaults isMacOS = false pkgMgr = "dnf" isRHELFamily = true @@ -37,6 +37,8 @@ func resetMocks() { osName = "linux" archName = "x86_64" + disableProgressTracking = true + osReleasePath = "/etc/os-release" passwdPath = "/etc/passwd" diff --git a/main.go b/main.go index 94e7ea3..d14f44e 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "time" @@ -106,6 +107,55 @@ func runMain(args []string) { doFlatpak := (*only == "" || *only == "flatpak") && *gui && !isMacOS + step := 0 + if !disableProgressTracking { + step = readProgressStep() + if step >= 2 { + fmt.Println("\nBootstrap is already completed according to progress.config.") + fmt.Println("If you want to re-run, delete or reset progress.config.") + return + } + } + + originalSystemPkgs := systemPkgs + originalCustomPtrs := customPtrs + originalDoFlatpak := doFlatpak + originalFlatpakPkgs := flatpakPkgs + + if !disableProgressTracking && step == 0 { + // Minimum required system packages: zsh, git, curl + step1Sys := map[string]bool{"zsh": true, "git": true, "curl": true} + var keptSys []string + for _, p := range systemPkgs { + if step1Sys[p] { + keptSys = append(keptSys, p) + } + } + systemPkgs = keptSys + + // Minimum required custom packages: oh-my-zsh + var keptCust []*CustomPackage + for _, p := range customPtrs { + if strings.ToLower(p.Name) == "oh-my-zsh" { + keptCust = append(keptCust, p) + } + } + customPtrs = keptCust + + // No flatpaks in Step 1 + flatpakPkgs = nil + doFlatpak = false + } else if !disableProgressTracking && step == 1 { + // Exclude oh-my-zsh in Step 2 + var keptCust []*CustomPackage + for _, p := range customPtrs { + if strings.ToLower(p.Name) != "oh-my-zsh" { + keptCust = append(keptCust, p) + } + } + customPtrs = keptCust + } + sysCheck, flatCheck, custCheck := checkAllInParallel( *only == "" || *only == "system", systemPkgs, doFlatpak, flatpakPkgs, @@ -114,7 +164,52 @@ func runMain(args []string) { total := printCheckSummary(sysCheck, flatCheck, custCheck, *only) + if !disableProgressTracking && step == 0 { + // If we didn't need to install anything for step 1, and default shell is already zsh, + // we can skip step 1 and proceed to step 2 in the same run. + if total == 0 && isZshDefault() { + fmt.Println("\n[zsh/oh-my-zsh] Zsh and oh-my-zsh already installed and default shell is zsh. Proceeding to Step 2...") + if err := writeProgressStep(1); err != nil { + fmt.Fprintf(os.Stderr, "Failed to write progress: %v\n", err) + } + step = 1 + // Restore original packages for Step 2 + systemPkgs = originalSystemPkgs + flatpakPkgs = originalFlatpakPkgs + doFlatpak = originalDoFlatpak + + // Exclude oh-my-zsh + var keptCust []*CustomPackage + for _, p := range originalCustomPtrs { + if strings.ToLower(p.Name) != "oh-my-zsh" { + keptCust = append(keptCust, p) + } + } + customPtrs = keptCust + + // Re-run checks for Step 2 + sysCheck, flatCheck, custCheck = checkAllInParallel( + *only == "" || *only == "system", systemPkgs, + doFlatpak, flatpakPkgs, + *only == "" || *only == "custom", customPtrs, + ) + total = printCheckSummary(sysCheck, flatCheck, custCheck, *only) + } + } + if total == 0 { + if !disableProgressTracking && step == 0 { + // Zsh, git, curl and oh-my-zsh are installed, but default shell is not zsh. + ensureZshDefault() + if err := writeProgressStep(1); err != nil { + fmt.Fprintf(os.Stderr, "Failed to write progress: %v\n", err) + } + fmt.Println("\n[zsh] Default shell has been updated to zsh.") + fmt.Println("IMPORTANT: Please log out of your current session and log back in (or restart your terminal) for the shell change to take effect.") + fmt.Println("Once logged back in, please re-run the bootstrapper to complete the rest of the installation.") + osExit(0) + return + } fmt.Println("\nAll packages already installed.") writeRunLog() return @@ -129,9 +224,30 @@ func runMain(args []string) { checkSudo() promptGitHubToken() + if !disableProgressTracking && step == 0 { + // Run only Step 1: minimum required system packages, default shell, minimum custom packages (oh-my-zsh) + if *only == "" || *only == "system" { + installSystemPackages(sysCheck.toInstallRegular, sysCheck.toInstallSpecial) + ensureZshDefault() + } + if *only == "" || *only == "custom" { + installCustomPackages(custCheck.toInstall) + } + if err := writeProgressStep(1); err != nil { + fmt.Fprintf(os.Stderr, "Failed to write progress: %v\n", err) + } + fmt.Println("\n[zsh/oh-my-zsh] Step 1 of bootstrap completed successfully.") + fmt.Println("IMPORTANT: Please log out of your current session and log back in (or restart your terminal) so that zsh becomes your active shell.") + fmt.Println("Once logged back in, please re-run the bootstrapper to complete the rest of the installation.") + osExit(0) + return + } + if *only == "" || *only == "system" { installSystemPackages(sysCheck.toInstallRegular, sysCheck.toInstallSpecial) - ensureZshDefault() + if disableProgressTracking { + ensureZshDefault() + } } if doFlatpak { @@ -161,6 +277,12 @@ func runMain(args []string) { pyenvWG.Wait() } + if !disableProgressTracking { + if err := writeProgressStep(2); err != nil { + fmt.Fprintf(os.Stderr, "Failed to write progress: %v\n", err) + } + } + writeRunLog() printNotices() fmt.Println("\nDone.") @@ -240,3 +362,39 @@ func checkSudo() { return } } + +var progressConfigPathReal = func() string { + exe, err := os.Executable() + if err != nil { + return "progress.config" + } + return filepath.Join(filepath.Dir(exe), "progress.config") +} + +var progressConfigPath = progressConfigPathReal + +func readProgressStep() int { + path := progressConfigPath() + data, err := osReadFile(path) + if err != nil { + return 0 + } + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "step=") { + stepStr := strings.TrimPrefix(line, "step=") + if step, err := strconv.Atoi(stepStr); err == nil { + return step + } + } + } + return 0 +} + +func writeProgressStep(step int) error { + path := progressConfigPath() + content := fmt.Sprintf("step=%d\n", step) + return osWriteFile(path, []byte(content), 0o644) +} + diff --git a/post.go b/post.go index c9593e3..e80426a 100644 --- a/post.go +++ b/post.go @@ -387,6 +387,28 @@ func userLoginShell(uid string) string { return "" } +func isZshDefault() bool { + if !hasCmd("zsh") { + return false + } + zshPath := "/bin/zsh" + if r, ok := probe([]string{"which", "zsh"}, 5*time.Second); ok && r.ExitCode == 0 { + if p := strings.TrimSpace(string(r.Stdout)); p != "" { + zshPath = p + } + } + username := invokingUser() + if username == "" { + return false + } + u, err := user.Lookup(username) + if err != nil { + return false + } + current := userLoginShell(u.Uid) + return current == zshPath +} + func cloneNvimConfig() { home, _ := os.UserHomeDir() configDir := filepath.Join(home, ".config", "nvim") diff --git a/progress_test.go b/progress_test.go new file mode 100644 index 0000000..9bc4e87 --- /dev/null +++ b/progress_test.go @@ -0,0 +1,232 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestReadWriteProgressStep(t *testing.T) { + defer resetMocks() + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "progress.config") + + // Set up mock file operations + var fileContents []byte + osReadFile = func(name string) ([]byte, error) { + if name == configPath { + if fileContents == nil { + return nil, os.ErrNotExist + } + return fileContents, nil + } + return nil, os.ErrNotExist + } + osWriteFile = func(name string, data []byte, perm os.FileMode) error { + if name == configPath { + fileContents = data + return nil + } + return fmt.Errorf("unexpected file write to %s", name) + } + + // Override progressConfigPath + originalPathFunc := progressConfigPath + progressConfigPath = func() string { + return configPath + } + defer func() { + progressConfigPath = originalPathFunc + }() + + // 1. Initial state (no file) + disableProgressTracking = false + step := readProgressStep() + if step != 0 { + t.Errorf("expected step 0 initially, got %d", step) + } + + // 2. Write step 1 + err := writeProgressStep(1) + if err != nil { + t.Fatalf("failed to write step 1: %v", err) + } + step = readProgressStep() + if step != 1 { + t.Errorf("expected step 1 after writing, got %d", step) + } + + // 3. Write step 2 + err = writeProgressStep(2) + if err != nil { + t.Fatalf("failed to write step 2: %v", err) + } + step = readProgressStep() + if step != 2 { + t.Errorf("expected step 2 after writing, got %d", step) + } +} + +func TestRunMainStep2Exit(t *testing.T) { + defer resetMocks() + + // Mock file read to return step=2 + osReadFile = func(name string) ([]byte, error) { + if strings.HasSuffix(name, "progress.config") { + return []byte("step=2\n"), nil + } + return nil, os.ErrNotExist + } + + disableProgressTracking = false + + var exited bool + osExit = func(code int) { + exited = true + } + + runMain([]string{"bootstrap_environment"}) + + if exited { + t.Error("expected runMain to return normally rather than exit when step=2") + } +} + +func TestRunMainStep0OnlyZshGitCurl(t *testing.T) { + defer resetMocks() + + // Mock file read to return nothing (step=0) + var writtenData []byte + osReadFile = func(name string) ([]byte, error) { + if strings.HasSuffix(name, "progress.config") { + return nil, os.ErrNotExist + } + if strings.HasSuffix(name, "/etc/passwd") { + return []byte(fmt.Sprintf("%s:x:1000:1000::/home/user:/bin/bash\n", os.Getenv("USER"))), nil + } + return nil, os.ErrNotExist + } + osWriteFile = func(name string, data []byte, perm os.FileMode) error { + if strings.HasSuffix(name, "progress.config") { + writtenData = data + return nil + } + return nil + } + + hasCmd = func(name string) bool { + return name == "zsh" || name == "git" || name == "curl" + } + probe = func(argv []string, timeout time.Duration) (CmdResult, bool) { + if argv[0] == "which" && argv[1] == "zsh" { + return CmdResult{ExitCode: 0, Stdout: []byte("/bin/zsh\n")}, true + } + return CmdResult{ExitCode: 1}, true + } + + disableProgressTracking = false + + // Mock user selection: Accept + stdin = strings.NewReader("y\n") + + var runCmdCalls [][]string + runCmd = func(argv []string, opts CmdOpts) CmdResult { + runCmdCalls = append(runCmdCalls, argv) + return CmdResult{ExitCode: 0} + } + + var runShellCalls []string + runShell = func(cmd string, opts CmdOpts) CmdResult { + runShellCalls = append(runShellCalls, cmd) + return CmdResult{ExitCode: 0} + } + + var exited bool + osExit = func(code int) { + exited = true + } + + runMain([]string{"bootstrap_environment"}) + + if !exited { + t.Error("expected runMain to exit at step 1") + } + + // Verify step 1 was written to progress.config + if !strings.Contains(string(writtenData), "step=1") { + t.Errorf("expected step=1 written to progress.config, got: %q", string(writtenData)) + } +} + +func TestRunMainStep0TransitionToStep2(t *testing.T) { + defer resetMocks() + + // Zsh default is true, oh-my-zsh and zsh/git/curl already installed + // Under step=0, we should transition directly to step=1 and then execute step 2 in the same run. + var writtenData []byte + osReadFile = func(name string) ([]byte, error) { + if strings.HasSuffix(name, "progress.config") { + return nil, os.ErrNotExist + } + if strings.HasSuffix(name, "/etc/passwd") { + // passwd already says zsh is default shell + return []byte(fmt.Sprintf("%s:x:1000:1000::/home/user:/bin/zsh\n", os.Getenv("USER"))), nil + } + return nil, os.ErrNotExist + } + osWriteFile = func(name string, data []byte, perm os.FileMode) error { + if strings.HasSuffix(name, "progress.config") { + writtenData = data + return nil + } + return nil + } + + hasCmd = func(name string) bool { + return true // all installed + } + probe = func(argv []string, timeout time.Duration) (CmdResult, bool) { + if argv[0] == "which" && argv[1] == "zsh" { + return CmdResult{ExitCode: 0, Stdout: []byte("/bin/zsh\n")}, true + } + if len(argv) >= 3 && argv[1] == "install" && argv[2] == "--list" { + return CmdResult{ExitCode: 0, Stdout: []byte(" 3.10.0\n 3.11.0\n")}, true + } + return CmdResult{ExitCode: 0}, true + } + + disableProgressTracking = false + + // Mock user selection: Accept + stdin = strings.NewReader("y\n") + + 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 + } + + runMain([]string{"bootstrap_environment", "--only", "custom"}) + + // Since they are already installed, it will continue. + // Since we mock all installed, total items to install for Step 2 is 0. + // It should exit normally or print all packages installed. + if exited { + t.Errorf("did not expect exit during transition path. Issues: %v", issues) + } + + // Should have written step 1, then eventually step 2 + if !strings.Contains(string(writtenData), "step=2") { + t.Errorf("expected step=2 written to progress.config, got: %q", string(writtenData)) + } +} diff --git a/testmain_test.go b/testmain_test.go index 4145616..879daf3 100644 --- a/testmain_test.go +++ b/testmain_test.go @@ -12,5 +12,6 @@ import ( // (GitHub Actions auto-annotates "[ERROR]" lines as workflow errors). func TestMain(m *testing.M) { issueLogWriter = io.Discard + disableProgressTracking = true os.Exit(m.Run()) } From d19d7efefca123a1e4cfbbdda9bf5342a5cb0e3a Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Thu, 11 Jun 2026 11:17:56 -0500 Subject: [PATCH 2/5] fixed install behavior and added local ai configuration pathway --- .github/workflows/integration.yml | 1 + .github/workflows/macos-dispatch.yml | 1 + ci/main.go | 72 +++++++++--- main.go | 44 ++++++- main_test.go | 40 +++++++ repos.go | 39 +++++++ system.go | 168 +++++++++++++++++++++++++++ system_test.go | 66 +++++++++++ 8 files changed, 411 insertions(+), 20 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index c2c4007..44f37db 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -2,6 +2,7 @@ name: Integration Tests env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + BOOTSTRAP_CI: 'true' on: push: diff --git a/.github/workflows/macos-dispatch.yml b/.github/workflows/macos-dispatch.yml index 2e51ffe..ef46c9a 100644 --- a/.github/workflows/macos-dispatch.yml +++ b/.github/workflows/macos-dispatch.yml @@ -2,6 +2,7 @@ name: macOS Manual Integration env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + BOOTSTRAP_CI: 'true' on: workflow_dispatch: diff --git a/ci/main.go b/ci/main.go index 9bf9961..13e74e0 100644 --- a/ci/main.go +++ b/ci/main.go @@ -47,10 +47,12 @@ func main() { targets = []string{"archlinux:latest"} case "fedora": targets = []string{"fedora:latest"} + case "ubuntu": + targets = []string{"ubuntu:latest"} case "all": - targets = []string{"debian:latest", "archlinux:latest", "fedora:latest"} + targets = []string{"debian:latest", "archlinux:latest", "fedora:latest", "ubuntu:latest"} default: - fmt.Fprintf(os.Stderr, "Unsupported OS: %s. Supported: debian, arch, fedora, all\n", *osFlag) + fmt.Fprintf(os.Stderr, "Unsupported OS: %s. Supported: debian, arch, fedora, ubuntu, all\n", *osFlag) os.Exit(1) } @@ -63,23 +65,28 @@ func main() { // 1. Prepare target container base and setup script based on OS distro var testContainer *dagger.Container - if strings.Contains(target, "debian") { + if strings.Contains(target, "debian") || strings.Contains(target, "ubuntu") { 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"}) + WithExec([]string{"apt-get", "install", "-y", "sudo", "curl", "git", "wget", "tar", "unzip", "xz-utils", "make", "python3", "which", "zstd"}) } 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"}) + WithExec([]string{"dnf", "install", "-y", "sudo", "curl", "git", "wget", "tar", "unzip", "xz", "make", "python3", "which", "zstd"}) } 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"}) + WithExec([]string{"pacman", "-Sy", "--noconfirm", "sudo", "curl", "git", "wget", "tar", "unzip", "xz", "make", "python", "which", "zstd"}) } else { return fmt.Errorf("unsupported target OS: %s", target) } + // Mock sync to prevent hangs during package updates/update-shells on overlayfs/FUSE + testContainer = testContainer. + WithExec([]string{"ln", "-sf", "/bin/true", "/bin/sync"}). + WithExec([]string{"ln", "-sf", "/bin/true", "/usr/bin/sync"}) + // 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"}) @@ -88,6 +95,10 @@ func main() { WithFile("/usr/local/bin/bootstrap_environment", binaryFile). WithWorkdir("/tmp") + if val := os.Getenv("BOOTSTRAP_CI"); val != "" { + testContainer = testContainer.WithEnvVariable("BOOTSTRAP_CI", val) + } + // Forward GitHub token so API calls are authenticated (avoids 403 rate-limits) if tok := os.Getenv("GITHUB_TOKEN"); tok != "" { secret := client.SetSecret("github-token", tok) @@ -96,21 +107,47 @@ func main() { WithSecretVariable("GH_TOKEN", secret) } - // 4. Run bootstrap binary against the full package set (no scope flags). + // 4. Run bootstrap binary. + // We run it twice: + // Run 1: Step 1 (zsh, git, curl, oh-my-zsh) which configures the default shell and exits. + // Run 2: Step 2 (the rest of the packages) once the shell configuration is complete. // 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 --gui"}) + var bootstrapCmd []string + if strings.Contains(target, "ubuntu") { + bootstrapCmd = []string{"sh", "-c", "echo y | bootstrap_environment --local-ai"} + } else { + bootstrapCmd = []string{"sh", "-c", "echo y | bootstrap_environment --gui"} + } + + fmt.Printf("[%s] Executing bootstrap_environment (Run 1: ZSH/Step 1)...\n", target) + testContainer = testContainer.WithExec(bootstrapCmd) + + fmt.Printf("[%s] Executing bootstrap_environment (Run 2: Step 2)...\n", target) + testContainer = testContainer.WithExec(bootstrapCmd) // 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", - "set -e -x; " + - "export PATH=$PATH:/usr/local/go/bin:/usr/local/bin; " + - "which go && go version && " + - "which nvim && nvim --version && " + - "which zig && zig version && " + - "which firecracker && firecracker --version", + var verifyCmd []string + if strings.Contains(target, "ubuntu") { + verifyCmd = []string{ + "sh", "-c", + "set -e -x; " + + "export PATH=$PATH:/usr/local/bin:/root/.local/bin; " + + "which nvim && nvim --version && " + + "which firecracker && firecracker --version && " + + "which ollama && ollama --version && " + + "which hf && hf --help", + } + } else { + verifyCmd = []string{ + "sh", "-c", + "set -e -x; " + + "export PATH=$PATH:/usr/local/go/bin:/usr/local/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 { @@ -118,7 +155,6 @@ func main() { return fmt.Errorf("verification failed on %s: %v", 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 diff --git a/main.go b/main.go index d14f44e..2489c9d 100644 --- a/main.go +++ b/main.go @@ -42,6 +42,7 @@ func runMain(args []string) { 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).") + localAI := fs.Bool("local-ai", false, "Ubuntu only: Install local ML inference environment (Nvidia drivers, CUDA, Nvidia Container Toolkit, Ollama, Hugging Face CLI).") _ = fs.Parse(args[1:]) switch *only { @@ -54,22 +55,61 @@ func runMain(args []string) { initPkgMgr() + if *localAI { + if isMacOS || pkgMgr != "apt-get" { + fmt.Fprintln(os.Stderr, "Error: --local-ai flag is only supported on Ubuntu/Debian (apt-get package manager)") + osExit(1) + return + } + } + systemPkgs := append([]string(nil), SystemPackages...) flatpakPkgs := append([]string(nil), FlatpakPackages...) + + if *localAI { + var filteredSys []string + excludeSys := map[string]bool{ + "ansible": true, "ansible-core": true, "buildah": true, + "dotnet-sdk-10.0": true, "helm": true, "kubectl": true, + "minikube": true, "pulumi": true, "sops": true, + "vagrant": true, "virt-manager": true, "wireshark": true, + "zoom": true, "obs-studio": true, "webcamoid": true, + "obsidian": true, + } + for _, p := range systemPkgs { + if !excludeSys[p] { + filteredSys = append(filteredSys, p) + } + } + filteredSys = append(filteredSys, "nvidia-drivers", "cuda-toolkit", "nvidia-container-toolkit", "ollama", "huggingface-cli") + systemPkgs = filteredSys + flatpakPkgs = nil + } + custom := customPackages() customPtrs := make([]*CustomPackage, 0, len(custom)) for i := range custom { + name := strings.ToLower(custom[i].Name) // Drop firecracker on macOS — it's provisioned inside the Fedora VM // (see setupFirecrackerVM), not on the host. - if isMacOS && strings.ToLower(custom[i].Name) == "firecracker" { + if isMacOS && name == "firecracker" { continue } if *noAI { - name := strings.ToLower(custom[i].Name) if name == "agy" || name == "claude" || name == "codex" || name == "copilot" { continue } } + if *localAI { + excludeCust := map[string]bool{ + "go": true, "zig": true, "rustup": true, "dagger": true, + "cosign": true, "agy": true, "claude": true, "copilot": true, + "codex": true, "playwright": true, + } + if excludeCust[name] { + continue + } + } customPtrs = append(customPtrs, &custom[i]) } diff --git a/main_test.go b/main_test.go index cdbdc95..41631f1 100644 --- a/main_test.go +++ b/main_test.go @@ -127,3 +127,43 @@ func TestRunMainMacos(t *testing.T) { t.Error("expected program to complete successfully on macOS mock run") } } + +func TestRunMainLocalAIValidation(t *testing.T) { + defer resetMocks() + + // 1. macOS validation + isMacOS = true + pkgMgr = "brew" + var exited bool + var exitCode int + osExit = func(code int) { + exited = true + exitCode = code + } + + runMain([]string{"bootstrap_environment", "--local-ai"}) + if !exited { + t.Error("expected runMain with --local-ai on macOS to exit") + } + if exitCode != 1 { + t.Errorf("expected exit code 1, got %d", exitCode) + } + + // 2. Linux non-apt validation + resetMocks() + isMacOS = false + pkgMgr = "dnf" + exited = false + osExit = func(code int) { + exited = true + exitCode = code + } + + runMain([]string{"bootstrap_environment", "--local-ai"}) + if !exited { + t.Error("expected runMain with --local-ai on Fedora/dnf to exit") + } + if exitCode != 1 { + t.Errorf("expected exit code 1, got %d", exitCode) + } +} diff --git a/repos.go b/repos.go index ad3fb9d..90ab94d 100644 --- a/repos.go +++ b/repos.go @@ -244,3 +244,42 @@ func repoGroups() []repoGroup { {mk("lazygit"), setupLazygitCoprRepo}, } } + +func setupCUDARepo() { + if pkgMgr != "apt-get" { + return + } + if repoFileExists("/etc/apt/sources.list.d/cuda.list") || repoFileExists("/etc/apt/sources.list.d/cuda-keyring.list") { + return + } + versionID := strings.Trim(osReleaseField("VERSION_ID"), `"`) + ubuntuVer := strings.ReplaceAll(versionID, ".", "") + if ubuntuVer == "" { + ubuntuVer = "2204" + } + debURL := fmt.Sprintf("https://developer.download.nvidia.com/compute/cuda/repos/ubuntu%s/x86_64/cuda-keyring_1.1-1_all.deb", ubuntuVer) + runShell( + fmt.Sprintf("curl -fsSL %s -o /tmp/cuda-keyring.deb && "+ + "sudo dpkg -i /tmp/cuda-keyring.deb && "+ + "sudo apt-get update", debURL), + CmdOpts{}, + ) +} + +func setupNvidiaContainerToolkitRepo() { + if pkgMgr != "apt-get" { + return + } + if repoFileExists("/etc/apt/sources.list.d/nvidia-container-toolkit.list") { + return + } + runShell( + "curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | "+ + "sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg && "+ + "curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | "+ + "sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | "+ + "sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list >/dev/null && "+ + "sudo apt-get update", + CmdOpts{}, + ) +} diff --git a/system.go b/system.go index d3af1d3..9fb633d 100644 --- a/system.go +++ b/system.go @@ -1,10 +1,13 @@ package main import ( + "flag" "fmt" "os" + "os/exec" "path/filepath" "strings" + "time" ) // Special packages: installed outside the regular package manager because @@ -18,6 +21,9 @@ func specialPkgs() map[string]bool { "github-desktop": true, "zoom": true, "obsidian": true, "minikube": true, "pipx": true, "poetry": true, "pulumi": true, "semgrep": true, + "nvidia-drivers": true, "cuda-toolkit": true, + "nvidia-container-toolkit": true, "ollama": true, + "huggingface-cli": true, } } @@ -51,6 +57,16 @@ func isSpecialPkgInstalled(pkg string) bool { return hasCmd("poetry") case "semgrep": return hasCmd("semgrep") + case "nvidia-drivers": + return hasCmd("nvidia-smi") + case "cuda-toolkit": + return hasCmd("nvcc") || exists("/usr/local/cuda/bin/nvcc") + case "nvidia-container-toolkit": + return hasCmd("nvidia-ctk") + case "ollama": + return hasCmd("ollama") + case "huggingface-cli": + return hasCmd("huggingface-cli") } return isSystemPkgInstalled(pkg) } @@ -357,6 +373,158 @@ func installSpecialPkg(pkg, tmp string) { installPoetry(tmp) case "semgrep": installSemgrep(tmp) + case "nvidia-drivers": + installNvidiaDrivers() + case "cuda-toolkit": + installCUDAToolkit() + case "nvidia-container-toolkit": + installNvidiaContainerToolkit() + case "ollama": + installOllama() + case "huggingface-cli": + installHuggingFaceCLI() + } +} + +func installNvidiaDrivers() { + if pkgMgr != "apt-get" { + errLog("nvidia-drivers can only be automatically installed via apt-get on Ubuntu/Debian") + return + } + fmt.Println(" Installing NVIDIA driver (nvidia-driver-550) ...") + res := pkgInstall("nvidia-driver-550") + if !res.OK() { + errLog(fmt.Sprintf("failed to install nvidia-driver-550: %v", res.Err)) + } +} + +func installCUDAToolkit() { + if pkgMgr != "apt-get" { + errLog("cuda-toolkit can only be automatically installed via apt-get on Ubuntu/Debian") + return + } + fmt.Println(" Setting up CUDA repository keyring ...") + setupCUDARepo() + fmt.Println(" Installing cuda-toolkit ...") + res := pkgInstall("cuda-toolkit") + if !res.OK() { + errLog(fmt.Sprintf("failed to install cuda-toolkit: %v", res.Err)) + return + } + fmt.Println(" Configuring system path for CUDA ...") + appendProfileLine("cuda", `export PATH="/usr/local/cuda/bin:$PATH"`) + appendProfileLine("cuda", `export LD_LIBRARY_PATH="/usr/local/cuda/lib64:$LD_LIBRARY_PATH"`) +} + +func installNvidiaContainerToolkit() { + if pkgMgr != "apt-get" { + errLog("nvidia-container-toolkit can only be automatically installed via apt-get on Ubuntu/Debian") + return + } + fmt.Println(" Setting up NVIDIA Container Toolkit repository ...") + setupNvidiaContainerToolkitRepo() + fmt.Println(" Installing nvidia-container-toolkit ...") + res := pkgInstall("nvidia-container-toolkit") + if !res.OK() { + errLog(fmt.Sprintf("failed to install nvidia-container-toolkit: %v", res.Err)) + return + } + fmt.Println(" Configuring Docker runtime for NVIDIA Container Toolkit ...") + configureRes := runCmd([]string{"nvidia-ctk", "runtime", "configure", "--runtime=docker"}, CmdOpts{AsSudo: true}) + if !configureRes.OK() { + warn(fmt.Sprintf("failed to configure docker runtime: %v", configureRes.Err)) + } + fmt.Println(" Restarting Docker service ...") + restartRes := runCmd([]string{"systemctl", "restart", "docker"}, CmdOpts{AsSudo: true}) + if !restartRes.OK() { + warn(fmt.Sprintf("failed to restart docker service: %v", restartRes.Err)) + } +} + +func installOllama() { + fmt.Println(" Installing Ollama via official install script ...") + res := runShell("curl -fsSL https://ollama.com/install.sh | sh", CmdOpts{}) + if !res.OK() { + errLog(fmt.Sprintf("Ollama installation failed: %v", res.Err)) + return + } + + isCI := os.Getenv("BOOTSTRAP_CI") == "true" + if isCI { + fmt.Println(" [CI] Skipping pulling large Ollama models in integration test container.") + return + } + + // Pull Gemma 4 E4B and Qwen2.5-Coder 7B + fmt.Println(" Pulling Gemma 4 E4B and Qwen2.5-Coder 7B models ...") + + isTesting := flag.Lookup("test.v") != nil || os.Getenv("GO_ENV") == "test" + serverRunning := false + + // Check if Ollama server is already responding + for i := 0; i < 5; i++ { + r := runCmd([]string{"ollama", "list"}, CmdOpts{Capture: true}) + if r.OK() { + serverRunning = true + break + } + if isTesting { + break + } + time.Sleep(1 * time.Second) + } + + var cmd *exec.Cmd + if !serverRunning && !isTesting { + fmt.Println(" Ollama server not running. Starting in background for model pulling ...") + cmd = exec.Command("ollama", "serve") + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Start(); err != nil { + warn(fmt.Sprintf("Failed to start Ollama server in background: %v", err)) + } else { + // Wait up to 30 seconds for server to start + for i := 0; i < 30; i++ { + r := runCmd([]string{"ollama", "list"}, CmdOpts{Capture: true}) + if r.OK() { + serverRunning = true + break + } + time.Sleep(1 * time.Second) + } + } + } + + if serverRunning || isTesting { + fmt.Println(" Pulling Gemma 4 E4B (gemma4:e4b) ...") + pull1 := runCmd([]string{"ollama", "pull", "gemma4:e4b"}, CmdOpts{}) + if !pull1.OK() { + errLog(fmt.Sprintf("Failed to pull gemma4:e4b: %v", pull1.Err)) + } + fmt.Println(" Pulling Qwen2.5-Coder 7B (qwen2.5-coder:7b) ...") + pull2 := runCmd([]string{"ollama", "pull", "qwen2.5-coder:7b"}, CmdOpts{}) + if !pull2.OK() { + errLog(fmt.Sprintf("Failed to pull qwen2.5-coder:7b: %v", pull2.Err)) + } + } else { + errLog("Ollama server failed to start — cannot pull models") + } + + if cmd != nil && cmd.Process != nil { + fmt.Println(" Stopping background Ollama server ...") + cmd.Process.Kill() + } +} + +func installHuggingFaceCLI() { + if !hasCmd("pipx") { + errLog("pipx is not installed — cannot install huggingface-cli") + return + } + fmt.Println(" Installing huggingface-cli via pipx ...") + res := runCmd([]string{"pipx", "install", "huggingface_hub[cli]"}, CmdOpts{}) + if !res.OK() { + errLog(fmt.Sprintf("failed to install huggingface-cli: %v", res.Err)) } } diff --git a/system_test.go b/system_test.go index 4b9a5ed..45fd946 100644 --- a/system_test.go +++ b/system_test.go @@ -528,3 +528,69 @@ func TestInstallSystemPackagesAria2First(t *testing.T) { } } +func TestInstallLocalAISpecialPkgs(t *testing.T) { + defer resetMocks() + + pkgMgr = "apt-get" + isMacOS = false + + var runCmdCalls [][]string + runCmd = func(argv []string, opts CmdOpts) CmdResult { + runCmdCalls = append(runCmdCalls, argv) + return CmdResult{ExitCode: 0} + } + + var runShellCalls []string + runShell = func(cmd string, opts CmdOpts) CmdResult { + runShellCalls = append(runShellCalls, cmd) + return CmdResult{ExitCode: 0} + } + + // 1. nvidia-drivers + installSpecialPkg("nvidia-drivers", "/tmp") + if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "apt-get" || runCmdCalls[0][3] != "nvidia-driver-550" { + t.Errorf("expected apt-get install nvidia-driver-550, got calls: %v", runCmdCalls) + } + + // 2. cuda-toolkit + runCmdCalls = nil + runShellCalls = nil + installSpecialPkg("cuda-toolkit", "/tmp") + if len(runCmdCalls) < 1 || runCmdCalls[0][3] != "cuda-toolkit" { + t.Errorf("expected apt-get install cuda-toolkit, got calls: %v", runCmdCalls) + } + if len(runShellCalls) != 1 || !strings.Contains(runShellCalls[0], "cuda-keyring") { + t.Errorf("expected setupCUDARepo runShell call, got calls: %v", runShellCalls) + } + + // 3. nvidia-container-toolkit + runCmdCalls = nil + runShellCalls = nil + installSpecialPkg("nvidia-container-toolkit", "/tmp") + if len(runCmdCalls) < 3 || runCmdCalls[0][3] != "nvidia-container-toolkit" { + t.Errorf("expected apt-get install nvidia-container-toolkit, got calls: %v", runCmdCalls) + } + if len(runShellCalls) != 1 || !strings.Contains(runShellCalls[0], "nvidia-container-toolkit.list") { + t.Errorf("expected setupNvidiaContainerToolkitRepo runShell call, got calls: %v", runShellCalls) + } + + // 4. ollama + runCmdCalls = nil + runShellCalls = nil + installSpecialPkg("ollama", "/tmp") + if len(runShellCalls) != 1 || !strings.Contains(runShellCalls[0], "ollama.com/install.sh") { + t.Errorf("expected ollama installer runShell call, got calls: %v", runShellCalls) + } + + // 5. huggingface-cli + runCmdCalls = nil + runShellCalls = nil + hasCmd = func(name string) bool { + return name == "pipx" + } + installSpecialPkg("huggingface-cli", "/tmp") + if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "pipx" || runCmdCalls[0][2] != "huggingface_hub[cli]" { + t.Errorf("expected pipx install huggingface_hub[cli], got calls: %v", runCmdCalls) + } +} + From 476607e1081753418448b99231e61b7f5ee2b4b7 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Thu, 11 Jun 2026 11:27:14 -0500 Subject: [PATCH 3/5] add ubuntu to test matrix for local ai and test fixes --- .github/workflows/integration.yml | 2 +- README.md | 2 +- ci/main.go | 2 +- detect.go | 1 + system.go | 2 - system_test.go | 66 +++++++++++++++++++++++++++++++ test_run.log | 2 - testmain_test.go | 1 + 8 files changed, 71 insertions(+), 7 deletions(-) delete mode 100644 test_run.log diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 44f37db..66dc840 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - os: [debian, arch, fedora] + os: [debian, arch, fedora, ubuntu] fail-fast: false steps: diff --git a/README.md b/README.md index 182b64f..79579b0 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ It installs: | Workflow | Trigger | |---|---| | **Unit Tests** — `go test -v ./...` + `go vet ./...` | Pull requests to `main` / `dev` | -| **Integration Tests** — full run on Debian, Arch, Fedora (Dagger) and macOS (native) | Push + pull requests to `main` / `dev` | +| **Integration Tests** — full run on Debian, Arch, Fedora, Ubuntu (Dagger) and macOS (native) | Push + pull requests to `main` / `dev` | ## Install diff --git a/ci/main.go b/ci/main.go index 13e74e0..c76f3d5 100644 --- a/ci/main.go +++ b/ci/main.go @@ -12,7 +12,7 @@ import ( ) func main() { - osFlag := flag.String("os", "all", "OS to test (debian, arch, fedora, or all)") + osFlag := flag.String("os", "all", "OS to test (debian, arch, fedora, ubuntu, or all)") flag.Parse() ctx := context.Background() diff --git a/detect.go b/detect.go index e13f677..9a3cb8c 100644 --- a/detect.go +++ b/detect.go @@ -19,6 +19,7 @@ var ( isMacOS bool isRHELFamily bool isArchFamily bool + isTesting bool ) func init() { diff --git a/system.go b/system.go index 9fb633d..91aeb7f 100644 --- a/system.go +++ b/system.go @@ -1,7 +1,6 @@ package main import ( - "flag" "fmt" "os" "os/exec" @@ -458,7 +457,6 @@ func installOllama() { // Pull Gemma 4 E4B and Qwen2.5-Coder 7B fmt.Println(" Pulling Gemma 4 E4B and Qwen2.5-Coder 7B models ...") - isTesting := flag.Lookup("test.v") != nil || os.Getenv("GO_ENV") == "test" serverRunning := false // Check if Ollama server is already responding diff --git a/system_test.go b/system_test.go index 45fd946..c3d23e3 100644 --- a/system_test.go +++ b/system_test.go @@ -594,3 +594,69 @@ func TestInstallLocalAISpecialPkgs(t *testing.T) { } } +func TestIsSpecialPkgInstalledLocalAI(t *testing.T) { + defer resetMocks() + + isMacOS = false + + // Test case 1: None of the commands/files exist + hasCmd = func(name string) bool { return false } + osStat = func(name string) (os.FileInfo, error) { return nil, os.ErrNotExist } + + pkgs := []string{"nvidia-drivers", "cuda-toolkit", "nvidia-container-toolkit", "ollama", "huggingface-cli"} + for _, p := range pkgs { + if isSpecialPkgInstalled(p) { + t.Errorf("expected %s to be not installed", p) + } + } + + // Test case 2: Check nvidia-drivers + hasCmd = func(name string) bool { return name == "nvidia-smi" } + if !isSpecialPkgInstalled("nvidia-drivers") { + t.Error("expected nvidia-drivers to be installed when nvidia-smi exists") + } + + // Test case 3: Check cuda-toolkit via hasCmd + hasCmd = func(name string) bool { return name == "nvcc" } + if !isSpecialPkgInstalled("cuda-toolkit") { + t.Error("expected cuda-toolkit to be installed when nvcc command exists") + } + + // Test case 4: Check cuda-toolkit via path existence + hasCmd = func(name string) bool { return false } + osStat = func(name string) (os.FileInfo, error) { + if name == "/usr/local/cuda/bin/nvcc" { + return nil, nil // exists + } + return nil, os.ErrNotExist + } + if !isSpecialPkgInstalled("cuda-toolkit") { + t.Error("expected cuda-toolkit to be installed when /usr/local/cuda/bin/nvcc exists") + } + + // Test case 5: Check nvidia-container-toolkit + resetMocks() + isMacOS = false + hasCmd = func(name string) bool { return name == "nvidia-ctk" } + if !isSpecialPkgInstalled("nvidia-container-toolkit") { + t.Error("expected nvidia-container-toolkit to be installed when nvidia-ctk command exists") + } + + // Test case 6: Check ollama + resetMocks() + isMacOS = false + hasCmd = func(name string) bool { return name == "ollama" } + if !isSpecialPkgInstalled("ollama") { + t.Error("expected ollama to be installed when ollama command exists") + } + + // Test case 7: Check huggingface-cli + resetMocks() + isMacOS = false + hasCmd = func(name string) bool { return name == "huggingface-cli" } + if !isSpecialPkgInstalled("huggingface-cli") { + t.Error("expected huggingface-cli to be installed when huggingface-cli command exists") + } +} + + diff --git a/test_run.log b/test_run.log deleted file mode 100644 index 5cf964f..0000000 --- a/test_run.log +++ /dev/null @@ -1,2 +0,0 @@ -ok github.com/JMR-dev/bootstrap_dev_env 20.206s -? github.com/JMR-dev/bootstrap_dev_env/ci [no test files] diff --git a/testmain_test.go b/testmain_test.go index 879daf3..746e910 100644 --- a/testmain_test.go +++ b/testmain_test.go @@ -11,6 +11,7 @@ import ( // don't want the human-facing "[ERROR] ..." prints polluting CI logs // (GitHub Actions auto-annotates "[ERROR]" lines as workflow errors). func TestMain(m *testing.M) { + isTesting = true issueLogWriter = io.Discard disableProgressTracking = true os.Exit(m.Run()) From 1a3f4f2e44fe3c65faa52590e39939d501ea3678 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Thu, 11 Jun 2026 11:36:26 -0500 Subject: [PATCH 4/5] unit test fix to mock successful package install return codes --- progress_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/progress_test.go b/progress_test.go index 9bc4e87..ad7ac50 100644 --- a/progress_test.go +++ b/progress_test.go @@ -185,6 +185,15 @@ func TestRunMainStep0TransitionToStep2(t *testing.T) { } return nil } + osStat = func(name string) (os.FileInfo, error) { + if strings.HasSuffix(name, "/go") || + strings.HasSuffix(name, "/firecracker") || + strings.HasSuffix(name, "/nvim") || + strings.HasSuffix(name, ".oh-my-zsh") { + return nil, nil + } + return nil, os.ErrNotExist + } hasCmd = func(name string) bool { return true // all installed From d679049aca98a0a0c6d995dc93de870a8cf43486 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Thu, 11 Jun 2026 11:40:11 -0500 Subject: [PATCH 5/5] UID fix for tests --- progress_test.go | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/progress_test.go b/progress_test.go index ad7ac50..73d252b 100644 --- a/progress_test.go +++ b/progress_test.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "os/user" "path/filepath" "strings" "testing" @@ -99,6 +100,17 @@ func TestRunMainStep2Exit(t *testing.T) { func TestRunMainStep0OnlyZshGitCurl(t *testing.T) { defer resetMocks() + u, _ := user.Current() + uid := "1000" + username := os.Getenv("USER") + if u != nil { + uid = u.Uid + username = u.Username + } + if username == "" { + username = "user" + } + // Mock file read to return nothing (step=0) var writtenData []byte osReadFile = func(name string) ([]byte, error) { @@ -106,7 +118,7 @@ func TestRunMainStep0OnlyZshGitCurl(t *testing.T) { return nil, os.ErrNotExist } if strings.HasSuffix(name, "/etc/passwd") { - return []byte(fmt.Sprintf("%s:x:1000:1000::/home/user:/bin/bash\n", os.Getenv("USER"))), nil + return []byte(fmt.Sprintf("%s:x:%s:%s::/home/user:/bin/bash\n", username, uid, uid)), nil } return nil, os.ErrNotExist } @@ -165,6 +177,17 @@ func TestRunMainStep0OnlyZshGitCurl(t *testing.T) { func TestRunMainStep0TransitionToStep2(t *testing.T) { defer resetMocks() + u, _ := user.Current() + uid := "1000" + username := os.Getenv("USER") + if u != nil { + uid = u.Uid + username = u.Username + } + if username == "" { + username = "user" + } + // Zsh default is true, oh-my-zsh and zsh/git/curl already installed // Under step=0, we should transition directly to step=1 and then execute step 2 in the same run. var writtenData []byte @@ -174,7 +197,7 @@ func TestRunMainStep0TransitionToStep2(t *testing.T) { } if strings.HasSuffix(name, "/etc/passwd") { // passwd already says zsh is default shell - return []byte(fmt.Sprintf("%s:x:1000:1000::/home/user:/bin/zsh\n", os.Getenv("USER"))), nil + return []byte(fmt.Sprintf("%s:x:%s:%s::/home/user:/bin/zsh\n", username, uid, uid)), nil } return nil, os.ErrNotExist }