Introducing lazypkg – A Cross-Package Manager Tool for Effortless Updates

Introducing lazypkg – A Cross-Package Manager Tool for Effortless Updates


How do you manage packages installed via different package managers?
In my case, I use macOS for work and Linux for personal development, so I manage most of my tools and configurations (like dotfiles) using Nix flakes + Home Manager.

That said, for tools I just want to quickly try out or tools specific to a single project, I often end up installing them via brew, npm install -g, or apt.

The problem is—once you’re juggling multiple package managers, it becomes hard to keep track of which packages are outdated. Plus, the command-line options vary across tools (was it update or upgrade …?).



So I Built a TUI App Called lazypkg

To solve this, I built lazypkg—a terminal-based tool that lets you view and update packages across multiple package managers.

https://github.com/ymtdzzz/lazypkg

screen of lazypkg

We’ll dive into usage later, but here’s a quick overview of what it can do:

  • List upgradable packages:
    • Package name
    • Current version
    • Latest version
  • Update individual packages
  • Update all packages at once

As of March 22, 2025, the following package managers are supported (with a modular design to allow easy additions):

  • apt
  • gem
  • homebrew
  • npm
  • docker

Docker support is disabled by default to avoid hitting Docker Hub API rate limits.



Getting Started



Installation

You can install lazypkg via Homebrew or go install.

# Homebrew
brew install ymtdzzz/tap/lazypkg

# go install
go install github.com/ymtdzzz/lazypkg@latest
Enter fullscreen mode

Exit fullscreen mode

Check if it’s installed:

lazypkg -v
# -> lazypkg version 0.0.7
Enter fullscreen mode

Exit fullscreen mode



Launch & View Upgradable Packages

Running lazypkg automatically detects supported package managers, populates the sidebar, and fetches package updates.

If a package manager (like apt) requires root, a password prompt will appear.

password input dialog in lazypkg

I updated everything before writing this post, so screenshots below use demo data.

Initially, focus is on the sidebar:

initial screen of lazypkg

Here are the basic keybindings:

  • ↑↓ or j/k: Move focus up/down in the sidebar
  • Enter or or l: Switch focus to the package list pane
  • Space: Add manager to bulk update targets
  • r: Refresh updates for the selected manager
  • u:

    • With no bulk target: Update all packages under current manager
    • With bulk target(s): Update all selected managers’ packages

These operations mostly work the same in the package list pane as well.



Use Cases



Updating a Specific Package

Say you want to update only terraform under homebrew.
Focus on homebrew in the sidebar, then press Enter or or l to move to the package list.

focus on the package list pane

Focus on terraform, press u, and a confirmation dialog appears.

focus on terraform package

Press Enter to execute. Logs appear in real-time at the bottom (ctrl+j / ctrl+k to scroll).

The demo just fakes logs—actual command would be brew upgrade terraform.

updating screen

Once done, the package list refreshes and removes terraform from the upgrade list.



Updating Multiple Packages

You can select multiple packages with Space. Let’s try selecting ffmpeg, terraform, and wget.

multiple package selection

Then press u, confirm, and they’ll all be updated in one go.



Update All Packages under a Package Manager

To update all packages managed by, say, apt:
Return focus to the sidebar with Backspace, , or h, focus on apt, and press u. That’s it.

Alternatively, pressing a in the package list achieves the same thing.


That’s the gist of using lazypkg. Next up, the motivation and implementation details.



Why Build This When Tools Like topgrade Exist?

There are already some tools which have the same concept like topgrade

https://github.com/topgrade-rs/topgrade

It’s a fantastic tool and I used it for a while myself. But topgrade doesn’t show you which packages can be updated or what the new versions will be before updating—it just updates everything.

While that simplicity is great, I wanted a tool that takes a slightly different approach.

When it comes to globally installed packages, despite the interface differences, our needs are often:

  • View installed packages
  • Check for updates
  • Update selected packages

And frankly, I don’t care which manager is handling it—as long as something like lazypkg abstracts over the differences, the UX becomes so much smoother.

I decided to skip the “list installed packages” feature, since just knowing what’s outdated is usually enough.



Implementation Notes

Still a bit rough, but I want to highlight some design choices. The tool is written in Go, which I’m most comfortable with.



Chose bubbletea for TUI Framework

I’ve built other TUI tools, like otel-tui, which uses tview.

https://github.com/rivo/tview

otel-tui needed a custom frame graph renderer, and tview was perfect for that.
But this time, I wanted an Elm-style architecture with clear state transitions, so I chose bubbletea.

It wasn’t a heavily researched choice (it’s personal dev, after all), but I figured the event-driven model would better suit async tasks like package updates.

In bubbletea, you modify state (Model) only via messages. For example, updating a package involves two messages:

// https://github.com/ymtdzzz/lazypkg/blob/85b8a4e01fb5c5a75797f530e8893e871759104b/components/messages.go#L27C1-L36C2
type updatePackagesStartMsg struct {
    name string
    pkgs []string
}

type updatePackagesFinishMsg struct {
    name string
    pkgs []string
    err  error
}
Enter fullscreen mode

Exit fullscreen mode

On update finish, I hide the loading spinner and re-fetch updates:

// https://github.com/ymtdzzz/lazypkg/blob/85b8a4e01fb5c5a75797f530e8893e871759104b/components/packages.go#L149-L157
    case updatePackagesFinishMsg:
        if msg.name == m.name {
            for _, pkg := range msg.pkgs {
                if i, ok := m.pkgToIdx[pkg]; ok {
                    m.loading[i] = false
                }
            }
            cmds = append(cmds, m.getPackagesCmd())
        }
Enter fullscreen mode

Exit fullscreen mode

Compared to tview, which offers a lot of flexibility and lets you structure state management your own way, bubbletea provides more built-in guidance with its Elm-style architecture—making it easier to follow a clear path, especially in personal projects.

Still, as the app grows, centralizing all messages in one file won’t scale. That’s a future refactor.



Extensible Design

Right now, only a few package managers are supported, but I’ve made it easy to extend.

Each package manager is implemented as an executor that fulfills this interface:

// https://github.com/ymtdzzz/lazypkg/blob/85b8a4e01fb5c5a75797f530e8893e871759104b/executors/executor.go#L18-L39
// Executor defines the interface for package management operations
type Executor interface {
    // GetPackages retrieves a list of available package updates.
    // The password parameter is required for package managers that need elevated privileges.
    GetPackages(password string) ([]*PackageInfo, error)

    // Update performs an update operation on a single package.
    // If dryRun is true, it will only simulate the update without making actual changes.
    // The password parameter is required for package managers that need elevated privileges.
    Update(pkg, password string, dryRun bool) error

    // BulkUpdate performs update operations on multiple packages simultaneously.
    // If dryRun is true, it will only simulate the updates without making actual changes.
    // The password parameter is required for package managers that need elevated privileges.
    BulkUpdate(pkgs []string, password string, dryRun bool) error

    // Valid checks if the package manager is available and usable on the current system.
    Valid() bool

    // Close performs any necessary cleanup operations when the executor is no longer needed.
    Close()
}
Enter fullscreen mode

Exit fullscreen mode

If you’re curious, here’s the Homebrew executor implementation, where brew outdated --verbose output is parsed via regex (yep, kind of hacky).

As for the password argument—I’ve built in a prompt that’s triggered automatically when elevated privileges are needed. The flow looks like this:

  1. Attempt command with empty password (expecting failure)
  2. Detect error output, show password dialog
  3. On user input, re-run command with provided password

Passwords aren’t stored in structs or memory unnecessarily—they only exist within specific function scopes.



Closing

lazypkg is a pretty simple tool, but I use it every day and find it genuinely useful.
There are tons of features I’d like to add—UI polish, support for more managers—but it’s already functional and I plan to keep maintaining it.

If you face similar pain points with managing packages, I hope lazypkg helps.

Feedback is more than welcome!



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *