Back to Writing
Post Series

Building EnvGuard: Curing 'It Works On My Machine' with Go and Bubble Tea

Mar 7, 2026• 4-5 MIN READ

The Nightmare of Developer Onboarding

We’ve all been there. You get a fresh Linux machine, or you join a new team, and the onboarding process is a massive, outdated README.md file.

You spend the next three hours blindly copy-pasting terminal commands to install Git, Docker, Go, SSH keys, and various system dependencies. Halfway through, a command fails because your system architecture is slightly different, or a package manager changed its syntax.

Historically, teams try to solve this with massive Bash scripts (setup.sh). But Bash scripts are incredibly brittle. They lack robust error handling, they fail silently, and when they break, you are left staring at a wall of cryptic text.

I wanted to build something better. A tool that validates a Linux environment, clearly reports what is missing, and safely orchestrates the installation of essential tools. That’s why I built EnvGuard.


The Tech Stack: Why Go and Bubble Tea?

I chose Golang because it solves the ultimate “chicken-and-egg” problem of bootstrapping environments. If you write a setup script in Python or Node.js, the user has to install Python or Node just to run the script!

Go compiles down to a single, static, dependency-free binary. You can drop the EnvGuard executable onto a completely bare-bones Linux machine, and it will just run.

But I didn’t just want it to execute commands; I wanted it to feel like a modern application. To fix the ugly terminal output, I used Bubble Tea (by Charmbracelet). Bubble Tea brings the Elm architecture to Go, allowing you to build reactive, state-driven user interfaces right inside the terminal.


The User Experience: Bringing UI to the Terminal

Because of Bubble Tea, the user experience of EnvGuard is entirely guided. When you run the tool, you are greeted by a Welcome Screen. Pressing Enter transitions the state to an interactive checklist where you can navigate and select exactly which tools you want to validate.

Behind the scenes, instead of writing messy loop logic, Bubble Tea uses a beautiful Elm-inspired update loop. By structuring it this way, the UI reacts instantly to user input, updating the terminal screen without ever clearing it or printing messy logs.

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "ctrl+c", "q":
            return m, tea.Quit
        case "up", "k":
            if m.cursor > 0 { m.cursor-- }
        case "down", "j":
            if m.cursor < len(m.choices)-1 { m.cursor++ }
        case "enter", " ":
            _, ok := m.selected[m.cursor]
            if ok { delete(m.selected, m.cursor) } else { m.selected[m.cursor] = struct{}{} }
        }
    }
    return m, nil
}

Conceptual snippet of the Bubble Tea update loop.


Role‑Based Onboarding: Smarter Defaults

One of the most powerful additions to EnvGuard is the role selection screen. Instead of presenting a flat list of tools, EnvGuard first asks the user about their primary role, for instance:

This is implemented by expanding the Bubble Tea model to manage multiple screens (welcome, role selection, tool selection). The state machine tracks the current screen and the chosen role:

type Screen int

const (
    WelcomeScreen Screen = iota
    RoleScreen
    ToolsScreen
)

type model struct {
    currentScreen Screen
    roleChoices   []string
    chosenRole    int
    tools         []string
    selectedTools map[int]struct{}
    // ... other fields
}

When the user picks a role (e.g., “Developer”), the model calls an applyRecommendations() function that populates the selectedTools map with indices of recommended tools.

This dramatically improves the experience: they get a sensible starting point without having to know every tool essential for their role, yet they remain in full control.


The Architecture: Separation of Concerns

I wanted the architecture to be clean, with a strict separation of concerns. Looking at my project structure, you can see how the application is divided:

When the user selects a tool to validate, the CLI orchestrator handles the routing, but delegates the heavy lifting to the checks package.

// Conceptual: The Orchestration Flow
func checkTool(tool string) {
    // 1. Delegate the OS-level check to our isolated 'checks' package
    isInstalled, _ := checks.Verify(tool)

    if !isInstalled {
        fmt.Printf("✘ %s is missing. Attempting automated fix...\n", tool)
        
        // 2. Trigger the OS-specific package manager (e.g., dnf)
        success, _ := checks.Fix(tool)
        
        if success {
            fmt.Printf("✔ Successfully installed %s!\n", tool)
        } else {
            fmt.Printf("Error: Manual intervention required.\n")
        }
    } else {
        fmt.Printf("✔ %s is already installed.\n", tool)
    }
}

What’s Next: Interfaces & Going Cross-Platform

Right now, EnvGuard is heavily optimized for my personal workflow on Linux. But the ultimate goal is universality. My next major milestones are:


Conclusion

Building EnvGuard was a masterclass in combining system-level orchestration with modern UI principles. It proved to me that terminal tools don’t have to be archaic and brittle…they can be robust, beautiful, and deeply helpful.