diff --git a/pkg/pkg_test.go b/pkg/pkg_test.go new file mode 100644 index 0000000..6311e09 --- /dev/null +++ b/pkg/pkg_test.go @@ -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") + } +} diff --git a/ports/parse.go b/ports/parse.go new file mode 100644 index 0000000..e568d79 --- /dev/null +++ b/ports/parse.go @@ -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 `. +// 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 ` 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:] +} diff --git a/ports/ports.go b/ports/ports.go index 465b7b9..099e096 100644 --- a/ports/ports.go +++ b/ports/ports.go @@ -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) diff --git a/ports/ports_openbsd.go b/ports/ports_openbsd.go new file mode 100644 index 0000000..e473c29 --- /dev/null +++ b/ports/ports_openbsd.go @@ -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 +} diff --git a/ports/ports_other.go b/ports/ports_other.go new file mode 100644 index 0000000..25e2238 --- /dev/null +++ b/ports/ports_other.go @@ -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 +} diff --git a/ports/ports_test.go b/ports/ports_test.go new file mode 100644 index 0000000..d06c309 --- /dev/null +++ b/ports/ports_test.go @@ -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") + } +}