diff --git a/apk/apk.go b/apk/apk.go index 640de8e..9631639 100644 --- a/apk/apk.go +++ b/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) +} diff --git a/apk/apk_linux.go b/apk/apk_linux.go new file mode 100644 index 0000000..6fb0d4b --- /dev/null +++ b/apk/apk_linux.go @@ -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 +} diff --git a/apk/apk_other.go b/apk/apk_other.go new file mode 100644 index 0000000..8e7ea61 --- /dev/null +++ b/apk/apk_other.go @@ -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 +} diff --git a/apk/apk_test.go b/apk/apk_test.go new file mode 100644 index 0000000..33b2e81 --- /dev/null +++ b/apk/apk_test.go @@ -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()) + } +} diff --git a/apk/parse.go b/apk/parse.go new file mode 100644 index 0000000..2a7c7d4 --- /dev/null +++ b/apk/parse.go @@ -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 `. +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 ` 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) +}