Merge pull request #3 from gogrlx/cd/apt-dpkg

feat(apt,dpkg): implement apt and dpkg package manager wrappers
This commit is contained in:
2026-02-25 15:30:02 -05:00
committed by GitHub
10 changed files with 838 additions and 0 deletions

View File

@@ -1,2 +1,74 @@
// Package apt provides Go bindings for APT (Advanced Packaging Tool) on Debian/Ubuntu.
package apt
import (
"context"
"github.com/gogrlx/snack"
)
// Apt implements the snack.Manager interface using apt-get and apt-cache.
type Apt struct{}
// New returns a new Apt manager.
func New() *Apt {
return &Apt{}
}
// Name returns "apt".
func (a *Apt) Name() string { return "apt" }
// Install one or more packages.
func (a *Apt) Install(ctx context.Context, pkgs []string, opts ...snack.Option) error {
return install(ctx, pkgs, opts...)
}
// Remove one or more packages.
func (a *Apt) Remove(ctx context.Context, pkgs []string, opts ...snack.Option) error {
return remove(ctx, pkgs, opts...)
}
// Purge one or more packages including config files.
func (a *Apt) Purge(ctx context.Context, pkgs []string, opts ...snack.Option) error {
return purge(ctx, pkgs, opts...)
}
// Upgrade all installed packages.
func (a *Apt) Upgrade(ctx context.Context, opts ...snack.Option) error {
return upgrade(ctx, opts...)
}
// Update refreshes the package index.
func (a *Apt) Update(ctx context.Context) error {
return update(ctx)
}
// List returns all installed packages.
func (a *Apt) List(ctx context.Context) ([]snack.Package, error) {
return list(ctx)
}
// Search queries the package index.
func (a *Apt) Search(ctx context.Context, query string) ([]snack.Package, error) {
return search(ctx, query)
}
// Info returns details about a specific package.
func (a *Apt) Info(ctx context.Context, pkg string) (*snack.Package, error) {
return info(ctx, pkg)
}
// IsInstalled reports whether a package is currently installed.
func (a *Apt) IsInstalled(ctx context.Context, pkg string) (bool, error) {
return isInstalled(ctx, pkg)
}
// Version returns the installed version of a package.
func (a *Apt) Version(ctx context.Context, pkg string) (string, error) {
return version(ctx, pkg)
}
// Available reports whether apt-get is present on the system.
func (a *Apt) Available() bool {
return available()
}

138
apt/apt_linux.go Normal file
View File

@@ -0,0 +1,138 @@
//go:build linux
package apt
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"github.com/gogrlx/snack"
)
func available() bool {
_, err := exec.LookPath("apt-get")
return err == nil
}
func buildArgs(command string, pkgs []string, opts ...snack.Option) []string {
o := snack.ApplyOptions(opts...)
var args []string
if o.Sudo {
args = append(args, "sudo")
}
args = append(args, "apt-get", command)
if o.AssumeYes {
args = append(args, "-y")
}
if o.DryRun {
args = append(args, "--dry-run")
}
args = append(args, pkgs...)
return args
}
func runAptGet(ctx context.Context, command string, pkgs []string, opts ...snack.Option) error {
args := buildArgs(command, pkgs, opts...)
var cmd *exec.Cmd
if args[0] == "sudo" {
cmd = exec.CommandContext(ctx, args[0], args[1:]...)
} else {
cmd = exec.CommandContext(ctx, args[0], args[1:]...)
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := stderr.String()
if strings.Contains(errMsg, "Permission denied") || strings.Contains(errMsg, "are you root") {
return fmt.Errorf("apt-get %s: %w", command, snack.ErrPermissionDenied)
}
if strings.Contains(errMsg, "Unable to locate package") {
return fmt.Errorf("apt-get %s: %w", command, snack.ErrNotFound)
}
return fmt.Errorf("apt-get %s: %w: %s", command, err, errMsg)
}
return nil
}
func install(ctx context.Context, pkgs []string, opts ...snack.Option) error {
return runAptGet(ctx, "install", pkgs, opts...)
}
func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error {
return runAptGet(ctx, "remove", pkgs, opts...)
}
func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error {
return runAptGet(ctx, "purge", pkgs, opts...)
}
func upgrade(ctx context.Context, opts ...snack.Option) error {
return runAptGet(ctx, "upgrade", nil, opts...)
}
func update(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "apt-get", "update")
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("apt-get update: %w: %s", err, stderr.String())
}
return nil
}
func list(ctx context.Context) ([]snack.Package, error) {
cmd := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Package}\\t${Version}\\t${Description}\\n")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("dpkg-query list: %w", err)
}
return parseList(string(out)), nil
}
func search(ctx context.Context, query string) ([]snack.Package, error) {
cmd := exec.CommandContext(ctx, "apt-cache", "search", query)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("apt-cache search: %w", err)
}
return parseSearch(string(out)), nil
}
func info(ctx context.Context, pkg string) (*snack.Package, error) {
cmd := exec.CommandContext(ctx, "apt-cache", "show", pkg)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
if strings.Contains(stderr.String(), "No packages found") {
return nil, fmt.Errorf("apt-cache show %s: %w", pkg, snack.ErrNotFound)
}
return nil, fmt.Errorf("apt-cache show %s: %w", pkg, err)
}
return parseInfo(string(out))
}
func isInstalled(ctx context.Context, pkg string) (bool, error) {
cmd := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Status}", pkg)
out, err := cmd.Output()
if err != nil {
return false, nil
}
return strings.TrimSpace(string(out)) == "install ok installed", nil
}
func version(ctx context.Context, pkg string) (string, error) {
cmd := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Version}", pkg)
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("version %s: %w", pkg, snack.ErrNotInstalled)
}
v := strings.TrimSpace(string(out))
if v == "" {
return "", fmt.Errorf("version %s: %w", pkg, snack.ErrNotInstalled)
}
return v, nil
}

51
apt/apt_other.go Normal file
View File

@@ -0,0 +1,51 @@
//go:build !linux
package apt
import (
"context"
"github.com/gogrlx/snack"
)
func available() bool { return false }
func install(_ context.Context, _ []string, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func remove(_ context.Context, _ []string, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func purge(_ context.Context, _ []string, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func upgrade(_ context.Context, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func update(_ context.Context) error {
return snack.ErrUnsupportedPlatform
}
func list(_ context.Context) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func search(_ context.Context, _ string) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func info(_ context.Context, _ string) (*snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func isInstalled(_ context.Context, _ string) (bool, error) {
return false, snack.ErrUnsupportedPlatform
}
func version(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}

71
apt/apt_test.go Normal file
View File

@@ -0,0 +1,71 @@
package apt
import (
"testing"
"github.com/gogrlx/snack"
)
func TestParseList(t *testing.T) {
input := "bash\t5.2-1\tGNU Bourne Again SHell\ncoreutils\t9.1-1\tGNU core utilities\n"
pkgs := parseList(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "bash" || pkgs[0].Version != "5.2-1" {
t.Errorf("unexpected first package: %+v", pkgs[0])
}
if !pkgs[0].Installed {
t.Error("expected Installed=true")
}
}
func TestParseSearch(t *testing.T) {
input := "vim - Vi IMproved\nnano - small text editor\n"
pkgs := parseSearch(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "vim" || pkgs[0].Description != "Vi IMproved" {
t.Errorf("unexpected package: %+v", pkgs[0])
}
}
func TestParseInfo(t *testing.T) {
input := `Package: bash
Version: 5.2-1
Architecture: amd64
Description: GNU Bourne Again SHell
`
p, err := parseInfo(input)
if err != nil {
t.Fatal(err)
}
if p.Name != "bash" || p.Version != "5.2-1" || p.Arch != "amd64" {
t.Errorf("unexpected package: %+v", p)
}
}
func TestParseInfoEmpty(t *testing.T) {
_, err := parseInfo("")
if err != snack.ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestParseListEmpty(t *testing.T) {
pkgs := parseList("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
}
func TestNew(t *testing.T) {
a := New()
if a.Name() != "apt" {
t.Errorf("expected 'apt', got %q", a.Name())
}
}
// Verify Apt implements snack.Manager at compile time.
var _ snack.Manager = (*Apt)(nil)

77
apt/parse.go Normal file
View File

@@ -0,0 +1,77 @@
package apt
import (
"strings"
"github.com/gogrlx/snack"
)
// parseList parses dpkg-query -W output into packages.
func parseList(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 3)
if len(parts) < 2 {
continue
}
p := snack.Package{
Name: parts[0],
Version: parts[1],
Installed: true,
}
if len(parts) == 3 {
p.Description = parts[2]
}
pkgs = append(pkgs, p)
}
return pkgs
}
// parseSearch parses apt-cache search output.
func parseSearch(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
if line == "" {
continue
}
// Format: "package - description"
parts := strings.SplitN(line, " - ", 2)
if len(parts) < 1 {
continue
}
p := snack.Package{Name: strings.TrimSpace(parts[0])}
if len(parts) == 2 {
p.Description = strings.TrimSpace(parts[1])
}
pkgs = append(pkgs, p)
}
return pkgs
}
// parseInfo parses apt-cache show output into a Package.
func parseInfo(output string) (*snack.Package, error) {
p := &snack.Package{}
for _, line := range strings.Split(output, "\n") {
key, val, ok := strings.Cut(line, ": ")
if !ok {
continue
}
switch key {
case "Package":
p.Name = val
case "Version":
p.Version = val
case "Description":
p.Description = val
case "Architecture":
p.Arch = val
}
}
if p.Name == "" {
return nil, snack.ErrNotFound
}
return p, nil
}

View File

@@ -1,2 +1,74 @@
// Package dpkg provides Go bindings for dpkg (low-level Debian package tool).
package dpkg
import (
"context"
"github.com/gogrlx/snack"
)
// Dpkg implements the snack.Manager interface using dpkg and dpkg-query.
type Dpkg struct{}
// New returns a new Dpkg manager.
func New() *Dpkg {
return &Dpkg{}
}
// Name returns "dpkg".
func (d *Dpkg) Name() string { return "dpkg" }
// Install one or more .deb files.
func (d *Dpkg) Install(ctx context.Context, pkgs []string, opts ...snack.Option) error {
return install(ctx, pkgs, opts...)
}
// Remove one or more packages.
func (d *Dpkg) Remove(ctx context.Context, pkgs []string, opts ...snack.Option) error {
return remove(ctx, pkgs, opts...)
}
// Purge one or more packages including config files.
func (d *Dpkg) Purge(ctx context.Context, pkgs []string, opts ...snack.Option) error {
return purge(ctx, pkgs, opts...)
}
// Upgrade is not supported by dpkg.
func (d *Dpkg) Upgrade(_ context.Context, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
// Update is not supported by dpkg.
func (d *Dpkg) Update(_ context.Context) error {
return snack.ErrUnsupportedPlatform
}
// List returns all installed packages.
func (d *Dpkg) List(ctx context.Context) ([]snack.Package, error) {
return list(ctx)
}
// Search queries installed packages matching the pattern.
func (d *Dpkg) Search(ctx context.Context, query string) ([]snack.Package, error) {
return search(ctx, query)
}
// Info returns details about a specific package.
func (d *Dpkg) Info(ctx context.Context, pkg string) (*snack.Package, error) {
return info(ctx, pkg)
}
// IsInstalled reports whether a package is currently installed.
func (d *Dpkg) IsInstalled(ctx context.Context, pkg string) (bool, error) {
return isInstalled(ctx, pkg)
}
// Version returns the installed version of a package.
func (d *Dpkg) Version(ctx context.Context, pkg string) (string, error) {
return version(ctx, pkg)
}
// Available reports whether dpkg is present on the system.
func (d *Dpkg) Available() bool {
return available()
}

138
dpkg/dpkg_linux.go Normal file
View File

@@ -0,0 +1,138 @@
//go:build linux
package dpkg
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"github.com/gogrlx/snack"
)
func available() bool {
_, err := exec.LookPath("dpkg")
return err == nil
}
func install(ctx context.Context, pkgs []string, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
var args []string
if o.Sudo {
args = append(args, "sudo")
}
args = append(args, "dpkg", "-i")
if o.DryRun {
args = append(args, "--simulate")
}
args = append(args, pkgs...)
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := stderr.String()
if strings.Contains(errMsg, "Permission denied") || strings.Contains(errMsg, "are you root") {
return fmt.Errorf("dpkg -i: %w", snack.ErrPermissionDenied)
}
return fmt.Errorf("dpkg -i: %w: %s", err, errMsg)
}
return nil
}
func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
var args []string
if o.Sudo {
args = append(args, "sudo")
}
args = append(args, "dpkg", "-r")
if o.DryRun {
args = append(args, "--simulate")
}
args = append(args, pkgs...)
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("dpkg -r: %w: %s", err, stderr.String())
}
return nil
}
func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
var args []string
if o.Sudo {
args = append(args, "sudo")
}
args = append(args, "dpkg", "-P")
if o.DryRun {
args = append(args, "--simulate")
}
args = append(args, pkgs...)
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("dpkg -P: %w: %s", err, stderr.String())
}
return nil
}
func list(ctx context.Context) ([]snack.Package, error) {
cmd := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Package}\\t${Version}\\t${Status}\\n")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("dpkg-query list: %w", err)
}
return parseList(string(out)), nil
}
func search(ctx context.Context, query string) ([]snack.Package, error) {
pattern := fmt.Sprintf("*%s*", query)
cmd := exec.CommandContext(ctx, "dpkg-query", "-l", pattern)
out, err := cmd.Output()
if err != nil {
// dpkg-query -l returns exit 1 when no packages match
return nil, nil
}
return parseDpkgList(string(out)), nil
}
func info(ctx context.Context, pkg string) (*snack.Package, error) {
cmd := exec.CommandContext(ctx, "dpkg-query", "-s", pkg)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
if strings.Contains(stderr.String(), "is not installed") || strings.Contains(stderr.String(), "not found") {
return nil, fmt.Errorf("dpkg-query -s %s: %w", pkg, snack.ErrNotInstalled)
}
return nil, fmt.Errorf("dpkg-query -s %s: %w", pkg, err)
}
return parseInfo(string(out))
}
func isInstalled(ctx context.Context, pkg string) (bool, error) {
cmd := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Status}", pkg)
out, err := cmd.Output()
if err != nil {
return false, nil
}
return strings.TrimSpace(string(out)) == "install ok installed", nil
}
func version(ctx context.Context, pkg string) (string, error) {
cmd := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Version}", pkg)
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("version %s: %w", pkg, snack.ErrNotInstalled)
}
v := strings.TrimSpace(string(out))
if v == "" {
return "", fmt.Errorf("version %s: %w", pkg, snack.ErrNotInstalled)
}
return v, nil
}

43
dpkg/dpkg_other.go Normal file
View File

@@ -0,0 +1,43 @@
//go:build !linux
package dpkg
import (
"context"
"github.com/gogrlx/snack"
)
func available() bool { return false }
func install(_ context.Context, _ []string, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func remove(_ context.Context, _ []string, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func purge(_ context.Context, _ []string, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func list(_ context.Context) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func search(_ context.Context, _ string) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func info(_ context.Context, _ string) (*snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func isInstalled(_ context.Context, _ string) (bool, error) {
return false, snack.ErrUnsupportedPlatform
}
func version(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}

89
dpkg/dpkg_test.go Normal file
View File

@@ -0,0 +1,89 @@
package dpkg
import (
"testing"
"github.com/gogrlx/snack"
)
func TestParseList(t *testing.T) {
input := "bash\t5.2-1\tinstall ok installed\ncoreutils\t9.1-1\tdeinstall ok config-files\n"
pkgs := parseList(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "bash" || !pkgs[0].Installed {
t.Errorf("unexpected first package: %+v", pkgs[0])
}
if pkgs[1].Installed {
t.Errorf("expected second package not installed: %+v", pkgs[1])
}
}
func TestParseDpkgList(t *testing.T) {
input := `Desired=Unknown/Install/Remove/Purge/Hold
| Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend
|/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad)
||/ Name Version Architecture Description
+++-==============-============-============-=================================
ii bash 5.2-1 amd64 GNU Bourne Again SHell
rc oldpkg 1.0-1 amd64 Some old package
`
pkgs := parseDpkgList(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "bash" || !pkgs[0].Installed {
t.Errorf("unexpected: %+v", pkgs[0])
}
if pkgs[1].Installed {
t.Errorf("expected oldpkg not installed: %+v", pkgs[1])
}
}
func TestParseInfo(t *testing.T) {
input := `Package: bash
Status: install ok installed
Version: 5.2-1
Architecture: amd64
Description: GNU Bourne Again SHell
`
p, err := parseInfo(input)
if err != nil {
t.Fatal(err)
}
if p.Name != "bash" || p.Version != "5.2-1" || !p.Installed {
t.Errorf("unexpected: %+v", p)
}
}
func TestParseInfoEmpty(t *testing.T) {
_, err := parseInfo("")
if err != snack.ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestNew(t *testing.T) {
d := New()
if d.Name() != "dpkg" {
t.Errorf("expected 'dpkg', got %q", d.Name())
}
}
func TestUpgradeUnsupported(t *testing.T) {
d := New()
if err := d.Upgrade(nil); err != snack.ErrUnsupportedPlatform {
t.Errorf("expected ErrUnsupportedPlatform, got %v", err)
}
}
func TestUpdateUnsupported(t *testing.T) {
d := New()
if err := d.Update(nil); err != snack.ErrUnsupportedPlatform {
t.Errorf("expected ErrUnsupportedPlatform, got %v", err)
}
}
// Verify Dpkg implements snack.Manager at compile time.
var _ snack.Manager = (*Dpkg)(nil)

87
dpkg/parse.go Normal file
View File

@@ -0,0 +1,87 @@
package dpkg
import (
"strings"
"github.com/gogrlx/snack"
)
// parseList parses dpkg-query -W -f='${Package}\t${Version}\t${Status}\n' output.
func parseList(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 3)
if len(parts) < 2 {
continue
}
p := snack.Package{
Name: parts[0],
Version: parts[1],
}
if len(parts) == 3 && strings.Contains(parts[2], "install ok installed") {
p.Installed = true
}
pkgs = append(pkgs, p)
}
return pkgs
}
// parseDpkgList parses dpkg-query -l output (table format with header).
func parseDpkgList(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
// Data lines start with status flags like "ii", "rc", etc.
if len(line) < 4 || line[2] != ' ' {
continue
}
// Skip lines where flags aren't letters (e.g. "+++-..." separator)
if line[0] < 'a' || line[0] > 'z' {
continue
}
status := line[:2]
fields := strings.Fields(line[3:])
if len(fields) < 2 {
continue
}
p := snack.Package{
Name: fields[0],
Version: fields[1],
Installed: status == "ii",
}
if len(fields) > 3 {
p.Description = strings.Join(fields[3:], " ")
}
pkgs = append(pkgs, p)
}
return pkgs
}
// parseInfo parses dpkg-query -s output into a Package.
func parseInfo(output string) (*snack.Package, error) {
p := &snack.Package{}
for _, line := range strings.Split(output, "\n") {
key, val, ok := strings.Cut(line, ": ")
if !ok {
continue
}
switch key {
case "Package":
p.Name = val
case "Version":
p.Version = val
case "Description":
p.Description = val
case "Architecture":
p.Arch = val
case "Status":
p.Installed = val == "install ok installed"
}
}
if p.Name == "" {
return nil, snack.ErrNotFound
}
return p, nil
}