feat: add pkg (FreeBSD) and ports (OpenBSD) package manager implementations

pkg implements Manager, VersionQuerier, Cleaner, and FileOwner interfaces
wrapping FreeBSD's pkg(8) CLI. ports implements Manager wrapping OpenBSD's
pkg_add/pkg_delete/pkg_info tools.

Both use build tags (freebsd/openbsd) for real implementations with stub
files for other platforms. Includes parser tests for all output formats.
This commit is contained in:
2026-02-26 01:11:23 +00:00
parent 14ba7cfb53
commit 99dfe59f40
6 changed files with 640 additions and 0 deletions

141
pkg/pkg_test.go Normal file
View File

@@ -0,0 +1,141 @@
package pkg
import (
"testing"
"github.com/gogrlx/snack"
)
func TestParseQuery(t *testing.T) {
input := "nginx\t1.24.0\tRobust and small WWW server\ncurl\t8.5.0\tCommand line tool for transferring data\n"
pkgs := parseQuery(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "nginx" || pkgs[0].Version != "1.24.0" {
t.Errorf("unexpected first package: %+v", pkgs[0])
}
if pkgs[0].Description != "Robust and small WWW server" {
t.Errorf("unexpected description: %q", pkgs[0].Description)
}
if !pkgs[0].Installed {
t.Error("expected Installed=true")
}
}
func TestParseSearch(t *testing.T) {
input := `nginx-1.24.0 Robust and small WWW server
curl-8.5.0 Command line tool for transferring data
`
pkgs := parseSearch(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "nginx" || pkgs[0].Version != "1.24.0" {
t.Errorf("unexpected first package: %+v", pkgs[0])
}
if pkgs[1].Name != "curl" || pkgs[1].Version != "8.5.0" {
t.Errorf("unexpected second package: %+v", pkgs[1])
}
}
func TestParseInfo(t *testing.T) {
input := `Name : nginx
Version : 1.24.0
Comment : Robust and small WWW server
Arch : FreeBSD:14:amd64
`
pkg := parseInfo(input)
if pkg == nil {
t.Fatal("expected non-nil package")
}
if pkg.Name != "nginx" {
t.Errorf("expected name 'nginx', got %q", pkg.Name)
}
if pkg.Version != "1.24.0" {
t.Errorf("unexpected version: %q", pkg.Version)
}
if pkg.Description != "Robust and small WWW server" {
t.Errorf("unexpected description: %q", pkg.Description)
}
if pkg.Arch != "FreeBSD:14:amd64" {
t.Errorf("unexpected arch: %q", pkg.Arch)
}
}
func TestParseUpgrades(t *testing.T) {
input := `Updating FreeBSD repository catalogue...
The following 2 package(s) will be affected:
Upgrading nginx: 1.24.0 -> 1.26.0
Upgrading curl: 8.5.0 -> 8.6.0
Number of packages to be upgraded: 2
`
pkgs := parseUpgrades(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "nginx" || pkgs[0].Version != "1.26.0" {
t.Errorf("unexpected first package: %+v", pkgs[0])
}
if pkgs[1].Name != "curl" || pkgs[1].Version != "8.6.0" {
t.Errorf("unexpected second package: %+v", pkgs[1])
}
}
func TestParseFileList(t *testing.T) {
input := `nginx-1.24.0:
/usr/local/sbin/nginx
/usr/local/etc/nginx/nginx.conf
/usr/local/share/doc/nginx/README
`
files := parseFileList(input)
if len(files) != 3 {
t.Fatalf("expected 3 files, got %d", len(files))
}
if files[0] != "/usr/local/sbin/nginx" {
t.Errorf("unexpected file: %q", files[0])
}
}
func TestParseOwner(t *testing.T) {
input := "/usr/local/sbin/nginx was installed by package nginx-1.24.0\n"
name := parseOwner(input)
if name != "nginx" {
t.Errorf("expected 'nginx', got %q", name)
}
}
func TestSplitNameVersion(t *testing.T) {
tests := []struct {
input string
wantName string
wantVersion string
}{
{"nginx-1.24.0", "nginx", "1.24.0"},
{"py39-pip-23.1", "py39-pip", "23.1"},
{"bash", "bash", ""},
}
for _, tt := range tests {
name, ver := splitNameVersion(tt.input)
if name != tt.wantName || ver != tt.wantVersion {
t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)",
tt.input, name, ver, tt.wantName, tt.wantVersion)
}
}
}
func TestInterfaceCompliance(t *testing.T) {
var _ snack.Manager = (*Pkg)(nil)
var _ snack.VersionQuerier = (*Pkg)(nil)
var _ snack.Cleaner = (*Pkg)(nil)
var _ snack.FileOwner = (*Pkg)(nil)
}
func TestName(t *testing.T) {
p := New()
if p.Name() != "pkg" {
t.Errorf("Name() = %q, want %q", p.Name(), "pkg")
}
}

116
ports/parse.go Normal file
View File

@@ -0,0 +1,116 @@
package ports
import (
"strings"
"github.com/gogrlx/snack"
)
// parseList parses the output of `pkg_info`.
// Format: "name-version description text"
func parseList(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// First field is name-version, rest is description
parts := strings.SplitN(line, " ", 2)
nameVer := parts[0]
name, ver := splitNameVersion(nameVer)
p := snack.Package{
Name: name,
Version: ver,
Installed: true,
}
if len(parts) == 2 {
p.Description = strings.TrimSpace(parts[1])
}
pkgs = append(pkgs, p)
}
return pkgs
}
// parseSearchResults parses the output of `pkg_info -Q <query>`.
// Each line is a package name-version.
func parseSearchResults(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
name, ver := splitNameVersion(line)
pkgs = append(pkgs, snack.Package{
Name: name,
Version: ver,
})
}
return pkgs
}
// parseInfoOutput parses `pkg_info <pkg>` output.
// The first line is typically "Information for name-version" or
// the package description block. We extract name/version from
// the stem or the provided pkg name.
func parseInfoOutput(output string, pkg string) *snack.Package {
lines := strings.Split(output, "\n")
p := &snack.Package{Installed: true}
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Information for ") {
nameVer := strings.TrimPrefix(line, "Information for ")
nameVer = strings.TrimSuffix(nameVer, ":")
p.Name, p.Version = splitNameVersion(nameVer)
continue
}
}
// If we got description lines (after the header), join them
if p.Name != "" {
var desc []string
inDesc := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "Information for ") {
inDesc = false
continue
}
if strings.HasPrefix(trimmed, "Comment:") {
p.Description = strings.TrimSpace(strings.TrimPrefix(trimmed, "Comment:"))
continue
}
if strings.HasPrefix(trimmed, "Description:") {
inDesc = true
continue
}
if inDesc && trimmed != "" {
desc = append(desc, trimmed)
}
}
if p.Description == "" && len(desc) > 0 {
p.Description = strings.Join(desc, " ")
}
}
if p.Name == "" {
// Fallback: try to parse from pkg argument
p.Name, p.Version = splitNameVersion(pkg)
if p.Name == "" {
return nil
}
}
return p
}
// splitNameVersion splits "name-version" at the last hyphen.
// OpenBSD packages use the last hyphen before a version number as separator.
func splitNameVersion(s string) (string, string) {
idx := strings.LastIndex(s, "-")
if idx <= 0 {
return s, ""
}
return s[:idx], s[idx+1:]
}

View File

@@ -1,2 +1,85 @@
// Package ports provides Go bindings for OpenBSD ports/packages.
package ports
import (
"context"
"github.com/gogrlx/snack"
)
// Ports wraps the OpenBSD pkg_add/pkg_delete/pkg_info CLI tools.
type Ports struct {
snack.Locker
}
// New returns a new Ports manager.
func New() *Ports {
return &Ports{}
}
// Name returns "ports".
func (p *Ports) Name() string { return "ports" }
// Available reports whether pkg_add is present on the system.
func (p *Ports) Available() bool { return available() }
// Install one or more packages.
func (p *Ports) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
p.Lock()
defer p.Unlock()
return install(ctx, pkgs, opts...)
}
// Remove one or more packages.
func (p *Ports) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
p.Lock()
defer p.Unlock()
return remove(ctx, pkgs, opts...)
}
// Purge removes packages and cleans up dependencies.
func (p *Ports) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
p.Lock()
defer p.Unlock()
return purge(ctx, pkgs, opts...)
}
// Upgrade all installed packages.
func (p *Ports) Upgrade(ctx context.Context, opts ...snack.Option) error {
p.Lock()
defer p.Unlock()
return upgrade(ctx, opts...)
}
// Update is a no-op on OpenBSD (updates via fw_update or syspatch).
func (p *Ports) Update(ctx context.Context) error {
return update(ctx)
}
// List returns all installed packages.
func (p *Ports) List(ctx context.Context) ([]snack.Package, error) {
return list(ctx)
}
// Search queries for packages matching the query.
func (p *Ports) Search(ctx context.Context, query string) ([]snack.Package, error) {
return search(ctx, query)
}
// Info returns details about a specific package.
func (p *Ports) Info(ctx context.Context, pkg string) (*snack.Package, error) {
return info(ctx, pkg)
}
// IsInstalled reports whether a package is currently installed.
func (p *Ports) IsInstalled(ctx context.Context, pkg string) (bool, error) {
return isInstalled(ctx, pkg)
}
// Version returns the installed version of a package.
func (p *Ports) Version(ctx context.Context, pkg string) (string, error) {
return version(ctx, pkg)
}
// Verify interface compliance at compile time.
var _ snack.Manager = (*Ports)(nil)

136
ports/ports_openbsd.go Normal file
View File

@@ -0,0 +1,136 @@
//go:build openbsd
package ports
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"github.com/gogrlx/snack"
)
func available() bool {
_, err := exec.LookPath("pkg_add")
return err == nil
}
func runCmd(ctx context.Context, name string, args []string, opts snack.Options) (string, error) {
cmdName := name
cmdArgs := make([]string, 0, len(args)+2)
cmdArgs = append(cmdArgs, args...)
if opts.Sudo {
cmdArgs = append([]string{cmdName}, cmdArgs...)
cmdName = "sudo"
}
c := exec.CommandContext(ctx, cmdName, cmdArgs...)
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
err := c.Run()
if err != nil {
se := stderr.String()
if strings.Contains(se, "permission denied") || strings.Contains(se, "need root") {
return "", fmt.Errorf("ports: %w", snack.ErrPermissionDenied)
}
return "", fmt.Errorf("ports: %s: %w", strings.TrimSpace(se), err)
}
return stdout.String(), nil
}
func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
args := snack.TargetNames(pkgs)
_, err := runCmd(ctx, "pkg_add", args, o)
return err
}
func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
args := snack.TargetNames(pkgs)
_, err := runCmd(ctx, "pkg_delete", args, o)
return err
}
func purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
args := append([]string{"-c"}, snack.TargetNames(pkgs)...)
_, err := runCmd(ctx, "pkg_delete", args, o)
return err
}
func upgrade(ctx context.Context, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
_, err := runCmd(ctx, "pkg_add", []string{"-u"}, o)
return err
}
func update(_ context.Context) error {
// No-op on OpenBSD; updates handled via fw_update or syspatch.
return nil
}
func list(ctx context.Context) ([]snack.Package, error) {
out, err := runCmd(ctx, "pkg_info", nil, snack.Options{})
if err != nil {
return nil, fmt.Errorf("ports list: %w", err)
}
return parseList(out), nil
}
func search(ctx context.Context, query string) ([]snack.Package, error) {
out, err := runCmd(ctx, "pkg_info", []string{"-Q", query}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return nil, nil
}
return nil, fmt.Errorf("ports search: %w", err)
}
return parseSearchResults(out), nil
}
func info(ctx context.Context, pkg string) (*snack.Package, error) {
out, err := runCmd(ctx, "pkg_info", []string{pkg}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return nil, fmt.Errorf("ports info %s: %w", pkg, snack.ErrNotFound)
}
return nil, fmt.Errorf("ports info: %w", err)
}
p := parseInfoOutput(out, pkg)
if p == nil {
return nil, fmt.Errorf("ports info %s: %w", pkg, snack.ErrNotFound)
}
return p, nil
}
func isInstalled(ctx context.Context, pkg string) (bool, error) {
c := exec.CommandContext(ctx, "pkg_info", pkg)
err := c.Run()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() != 0 {
return false, nil
}
return false, fmt.Errorf("ports isInstalled: %w", err)
}
return true, nil
}
func version(ctx context.Context, pkg string) (string, error) {
out, err := runCmd(ctx, "pkg_info", []string{pkg}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return "", fmt.Errorf("ports version %s: %w", pkg, snack.ErrNotInstalled)
}
return "", fmt.Errorf("ports version: %w", err)
}
p := parseInfoOutput(out, pkg)
if p == nil || p.Version == "" {
return "", fmt.Errorf("ports version %s: %w", pkg, snack.ErrNotInstalled)
}
return p.Version, nil
}

51
ports/ports_other.go Normal file
View File

@@ -0,0 +1,51 @@
//go:build !openbsd
package ports
import (
"context"
"github.com/gogrlx/snack"
)
func available() bool { return false }
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func purge(_ context.Context, _ []snack.Target, _ ...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
}

113
ports/ports_test.go Normal file
View File

@@ -0,0 +1,113 @@
package ports
import (
"testing"
"github.com/gogrlx/snack"
)
func TestParseList(t *testing.T) {
input := `bash-5.2.21 GNU Bourne Again Shell
curl-8.5.0 command line tool for transferring data
python-3.11.7p0 interpreted object-oriented programming language
`
pkgs := parseList(input)
if len(pkgs) != 3 {
t.Fatalf("expected 3 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "bash" || pkgs[0].Version != "5.2.21" {
t.Errorf("unexpected first package: %+v", pkgs[0])
}
if pkgs[0].Description != "GNU Bourne Again Shell" {
t.Errorf("unexpected description: %q", pkgs[0].Description)
}
if !pkgs[0].Installed {
t.Error("expected Installed=true")
}
if pkgs[2].Name != "python" || pkgs[2].Version != "3.11.7p0" {
t.Errorf("unexpected third package: %+v", pkgs[2])
}
}
func TestParseSearchResults(t *testing.T) {
input := `nginx-1.24.0
nginx-1.25.3
`
pkgs := parseSearchResults(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "nginx" || pkgs[0].Version != "1.24.0" {
t.Errorf("unexpected first package: %+v", pkgs[0])
}
}
func TestParseInfoOutput(t *testing.T) {
input := `Information for nginx-1.24.0:
Comment:
robust and small WWW server
Description:
nginx is an HTTP and reverse proxy server, a mail proxy server,
and a generic TCP/UDP proxy server.
`
pkg := parseInfoOutput(input, "nginx-1.24.0")
if pkg == nil {
t.Fatal("expected non-nil package")
}
if pkg.Name != "nginx" {
t.Errorf("expected name 'nginx', got %q", pkg.Name)
}
if pkg.Version != "1.24.0" {
t.Errorf("unexpected version: %q", pkg.Version)
}
}
func TestParseInfoOutputWithComment(t *testing.T) {
input := `Information for curl-8.5.0:
Comment: command line tool for transferring data
Description:
curl is a tool to transfer data from or to a server.
`
pkg := parseInfoOutput(input, "curl-8.5.0")
if pkg == nil {
t.Fatal("expected non-nil package")
}
if pkg.Name != "curl" {
t.Errorf("expected name 'curl', got %q", pkg.Name)
}
if pkg.Description != "command line tool for transferring data" {
t.Errorf("unexpected description: %q", pkg.Description)
}
}
func TestSplitNameVersion(t *testing.T) {
tests := []struct {
input string
wantName string
wantVersion string
}{
{"nginx-1.24.0", "nginx", "1.24.0"},
{"py3-pip-23.1", "py3-pip", "23.1"},
{"bash", "bash", ""},
}
for _, tt := range tests {
name, ver := splitNameVersion(tt.input)
if name != tt.wantName || ver != tt.wantVersion {
t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)",
tt.input, name, ver, tt.wantName, tt.wantVersion)
}
}
}
func TestInterfaceCompliance(t *testing.T) {
var _ snack.Manager = (*Ports)(nil)
}
func TestName(t *testing.T) {
p := New()
if p.Name() != "ports" {
t.Errorf("Name() = %q, want %q", p.Name(), "ports")
}
}