feat(apk): implement apk-tools package manager wrapper

Implements the snack.Manager interface for Alpine Linux's apk-tools:
- Install, Remove, Purge, Upgrade, Update operations
- List installed, Search, Info, IsInstalled, Version queries
- Output parsing for apk list, search, and info formats
- Linux-only implementation with build-tag stubs for other platforms
- Options support: WithSudo, WithDryRun, WithRoot
- Tests for all output parsing functions
This commit is contained in:
2026-02-25 20:23:55 +00:00
parent f04365e600
commit 6480c1142d
5 changed files with 521 additions and 0 deletions

View File

@@ -1,2 +1,75 @@
// Package apk provides Go bindings for apk-tools (Alpine Linux package manager).
package apk
import (
"context"
"github.com/gogrlx/snack"
)
// Apk wraps apk-tools operations.
type Apk struct{}
// New returns a new Apk manager.
func New() *Apk {
return &Apk{}
}
// compile-time check
var _ snack.Manager = (*Apk)(nil)
// Name returns "apk".
func (a *Apk) Name() string { return "apk" }
// Available reports whether apk is present on the system.
func (a *Apk) Available() bool { return available() }
// Install one or more packages.
func (a *Apk) Install(ctx context.Context, pkgs []string, opts ...snack.Option) error {
return install(ctx, pkgs, opts...)
}
// Remove one or more packages.
func (a *Apk) Remove(ctx context.Context, pkgs []string, opts ...snack.Option) error {
return remove(ctx, pkgs, opts...)
}
// Purge removes packages including config files.
func (a *Apk) Purge(ctx context.Context, pkgs []string, opts ...snack.Option) error {
return purge(ctx, pkgs, opts...)
}
// Upgrade all installed packages.
func (a *Apk) Upgrade(ctx context.Context, opts ...snack.Option) error {
return upgrade(ctx, opts...)
}
// Update refreshes the package index.
func (a *Apk) Update(ctx context.Context) error {
return update(ctx)
}
// List returns all installed packages.
func (a *Apk) List(ctx context.Context) ([]snack.Package, error) {
return list(ctx)
}
// Search queries the index for matching packages.
func (a *Apk) Search(ctx context.Context, query string) ([]snack.Package, error) {
return search(ctx, query)
}
// Info returns details about a package.
func (a *Apk) Info(ctx context.Context, pkg string) (*snack.Package, error) {
return info(ctx, pkg)
}
// IsInstalled reports whether a package is installed.
func (a *Apk) IsInstalled(ctx context.Context, pkg string) (bool, error) {
return isInstalled(ctx, pkg)
}
// Version returns the installed version of a package.
func (a *Apk) Version(ctx context.Context, pkg string) (string, error) {
return version(ctx, pkg)
}

147
apk/apk_linux.go Normal file
View File

@@ -0,0 +1,147 @@
//go:build linux
package apk
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/gogrlx/snack"
)
func available() bool {
_, err := exec.LookPath("apk")
return err == nil
}
func buildArgs(base []string, opts snack.Options) (string, []string) {
cmd := "apk"
var args []string
if opts.Root != "" {
args = append(args, "--root", opts.Root)
}
if opts.DryRun {
args = append(args, "--simulate")
}
args = append(args, base...)
if opts.Sudo {
args = append([]string{cmd}, args...)
cmd = "sudo"
}
return cmd, args
}
func run(ctx context.Context, base []string, opts ...snack.Option) (string, error) {
o := snack.ApplyOptions(opts...)
cmd, args := buildArgs(base, o)
c := exec.CommandContext(ctx, cmd, args...)
out, err := c.CombinedOutput()
if err != nil {
outStr := strings.TrimSpace(string(out))
if strings.Contains(outStr, "permission denied") || strings.Contains(outStr, "Permission denied") {
return outStr, fmt.Errorf("%s: %w", outStr, snack.ErrPermissionDenied)
}
return outStr, fmt.Errorf("apk: %s: %w", outStr, err)
}
return strings.TrimSpace(string(out)), nil
}
func install(ctx context.Context, pkgs []string, opts ...snack.Option) error {
args := append([]string{"add"}, pkgs...)
_, err := run(ctx, args, opts...)
return err
}
func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error {
args := append([]string{"del"}, pkgs...)
_, err := run(ctx, args, opts...)
return err
}
func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error {
args := append([]string{"del", "--purge"}, pkgs...)
_, err := run(ctx, args, opts...)
return err
}
func upgrade(ctx context.Context, opts ...snack.Option) error {
_, err := run(ctx, []string{"upgrade"}, opts...)
return err
}
func update(ctx context.Context) error {
_, err := run(ctx, []string{"update"})
return err
}
func list(ctx context.Context) ([]snack.Package, error) {
cmd := exec.CommandContext(ctx, "apk", "list", "--installed")
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("apk list: %w", err)
}
return parseListInstalled(string(out)), nil
}
func search(ctx context.Context, query string) ([]snack.Package, error) {
cmd := exec.CommandContext(ctx, "apk", "search", "-v", query)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("apk search: %w", err)
}
results := parseSearch(string(out))
if len(results) == 0 {
return nil, fmt.Errorf("apk search %q: %w", query, snack.ErrNotFound)
}
return results, nil
}
func info(ctx context.Context, pkg string) (*snack.Package, error) {
cmd := exec.CommandContext(ctx, "apk", "info", "-a", pkg)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("apk info %q: %w", pkg, snack.ErrNotFound)
}
p := parseInfo(string(out))
if p == nil {
return nil, fmt.Errorf("apk info %q: %w", pkg, snack.ErrNotFound)
}
name, ver := parseInfoNameVersion(string(out))
if name == "" {
name = pkg
}
p.Name = name
p.Version = ver
return p, nil
}
func isInstalled(ctx context.Context, pkg string) (bool, error) {
cmd := exec.CommandContext(ctx, "apk", "info", "-e", pkg)
err := cmd.Run()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return false, nil
}
return false, fmt.Errorf("apk info -e %q: %w", pkg, err)
}
return true, nil
}
func version(ctx context.Context, pkg string) (string, error) {
cmd := exec.CommandContext(ctx, "apk", "info", pkg)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("apk info %q: %w", pkg, snack.ErrNotInstalled)
}
_, ver := parseInfoNameVersion(string(out))
if ver == "" {
return "", fmt.Errorf("apk version %q: %w", pkg, snack.ErrNotInstalled)
}
return ver, nil
}

51
apk/apk_other.go Normal file
View File

@@ -0,0 +1,51 @@
//go:build !linux
package apk
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
}

114
apk/apk_test.go Normal file
View File

@@ -0,0 +1,114 @@
package apk
import (
"testing"
"github.com/gogrlx/snack"
)
func TestSplitNameVersion(t *testing.T) {
tests := []struct {
input string
name string
version string
}{
{"curl-8.5.0-r0", "curl", "8.5.0-r0"},
{"musl-1.2.4-r2", "musl", "1.2.4-r2"},
{"libcurl-doc-8.5.0-r0", "libcurl-doc", "8.5.0-r0"},
{"go-1.21.5-r0", "go", "1.21.5-r0"},
{"noversion", "noversion", ""},
}
for _, tt := range tests {
name, ver := splitNameVersion(tt.input)
if name != tt.name || ver != tt.version {
t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)",
tt.input, name, ver, tt.name, tt.version)
}
}
}
func TestParseListInstalled(t *testing.T) {
output := `curl-8.5.0-r0 x86_64 {curl} (MIT) [installed]
musl-1.2.4-r2 x86_64 {musl} (MIT) [installed]
`
pkgs := parseListInstalled(output)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "curl" || pkgs[0].Version != "8.5.0-r0" {
t.Errorf("unexpected first package: %+v", pkgs[0])
}
if !pkgs[0].Installed {
t.Error("expected Installed=true")
}
if pkgs[0].Arch != "x86_64" {
t.Errorf("expected arch x86_64, got %q", pkgs[0].Arch)
}
}
func TestParseSearch(t *testing.T) {
// verbose output
output := `curl-8.5.0-r0 - URL retrieval utility and library
curl-doc-8.5.0-r0 - URL retrieval utility and library (documentation)
`
pkgs := parseSearch(output)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "curl" || pkgs[0].Version != "8.5.0-r0" {
t.Errorf("unexpected package: %+v", pkgs[0])
}
if pkgs[0].Description != "URL retrieval utility and library" {
t.Errorf("unexpected description: %q", pkgs[0].Description)
}
}
func TestParseSearchPlain(t *testing.T) {
output := `curl-8.5.0-r0
curl-doc-8.5.0-r0
`
pkgs := parseSearch(output)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "curl" {
t.Errorf("expected curl, got %q", pkgs[0].Name)
}
}
func TestParseInfo(t *testing.T) {
output := `curl-8.5.0-r0 installed size:
description: URL retrieval utility and library
arch: x86_64
webpage: https://curl.se/
`
pkg := parseInfo(output)
if pkg == nil {
t.Fatal("expected non-nil package")
}
if pkg.Description != "URL retrieval utility and library" {
t.Errorf("unexpected description: %q", pkg.Description)
}
if pkg.Arch != "x86_64" {
t.Errorf("unexpected arch: %q", pkg.Arch)
}
}
func TestParseInfoNameVersion(t *testing.T) {
output := "curl-8.5.0-r0 description:\nsome stuff"
name, ver := parseInfoNameVersion(output)
if name != "curl" || ver != "8.5.0-r0" {
t.Errorf("got (%q, %q), want (curl, 8.5.0-r0)", name, ver)
}
}
func TestNewImplementsManager(t *testing.T) {
var _ snack.Manager = New()
}
func TestName(t *testing.T) {
a := New()
if a.Name() != "apk" {
t.Errorf("expected apk, got %q", a.Name())
}
}

136
apk/parse.go Normal file
View File

@@ -0,0 +1,136 @@
package apk
import (
"strings"
"github.com/gogrlx/snack"
)
// parseListInstalled parses output from `apk list --installed`.
// Each line looks like: "name-1.2.3-r0 x86_64 {origin} (license) [installed]"
func parseListInstalled(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
pkg := parseListLine(line)
if pkg.Name != "" {
pkgs = append(pkgs, pkg)
}
}
return pkgs
}
// parseListLine parses a single line from `apk list`.
// Format: "name-1.2.3-r0 x86_64 {origin} (license) [installed]"
func parseListLine(line string) snack.Package {
var pkg snack.Package
pkg.Installed = strings.Contains(line, "[installed]")
fields := strings.Fields(line)
if len(fields) < 1 {
return pkg
}
// First field is name-version
nameVer := fields[0]
pkg.Name, pkg.Version = splitNameVersion(nameVer)
if len(fields) >= 2 {
pkg.Arch = fields[1]
}
return pkg
}
// splitNameVersion splits "name-1.2.3-r0" into ("name", "1.2.3-r0").
// apk versions start with a digit, so we find the last hyphen before a digit.
func splitNameVersion(s string) (string, string) {
for i := len(s) - 1; i > 0; i-- {
if s[i] == '-' && i+1 < len(s) && s[i+1] >= '0' && s[i+1] <= '9' {
return s[:i], s[i+1:]
}
}
return s, ""
}
// parseSearch parses output from `apk search` or `apk search -v`.
func parseSearch(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// `apk search -v` output: "name-version - description"
if idx := strings.Index(line, " - "); idx != -1 {
nameVer := line[:idx]
desc := line[idx+3:]
name, ver := splitNameVersion(nameVer)
pkgs = append(pkgs, snack.Package{
Name: name,
Version: ver,
Description: desc,
})
} else {
// plain `apk search` just returns name-version
name, ver := splitNameVersion(line)
pkgs = append(pkgs, snack.Package{
Name: name,
Version: ver,
})
}
}
return pkgs
}
// parseInfo parses output from `apk info -a <pkg>`.
func parseInfo(output string) *snack.Package {
pkg := &snack.Package{}
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "description:") {
pkg.Description = strings.TrimSpace(strings.TrimPrefix(line, "description:"))
}
}
// First line is typically "pkgname-version description"
// But `apk info -a` starts with "pkgname-version installed size:"
// Let's parse key-value style
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) == 0 {
return nil
}
for _, line := range lines {
line = strings.TrimSpace(line)
if k, v, ok := strings.Cut(line, ":"); ok {
k = strings.TrimSpace(k)
v = strings.TrimSpace(v)
switch strings.ToLower(k) {
case "description":
pkg.Description = v
case "arch":
pkg.Arch = v
case "url", "webpage":
// skip
}
}
}
return pkg
}
// parseInfoNameVersion extracts name and version from `apk info <pkg>` output.
// The first line is typically "pkgname-version description".
func parseInfoNameVersion(output string) (string, string) {
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) == 0 {
return "", ""
}
// first line: name-version
first := strings.Fields(lines[0])[0]
return splitNameVersion(first)
}