mirror of
https://github.com/gogrlx/snack.git
synced 2026-04-02 05:08:42 -07:00
Merge pull request #2 from gogrlx/cd/apk
feat(apk): implement apk-tools package manager wrapper
This commit is contained in:
73
apk/apk.go
73
apk/apk.go
@@ -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
147
apk/apk_linux.go
Normal 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
51
apk/apk_other.go
Normal 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
114
apk/apk_test.go
Normal 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
136
apk/parse.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user