mirror of
https://github.com/gogrlx/snack.git
synced 2026-04-02 05:08:42 -07:00
feat(apt,dpkg): implement apt and dpkg package manager wrappers
Implement the snack.Manager interface for both apt (Debian/Ubuntu) and dpkg (low-level Debian package tool). apt wraps apt-get, apt-cache, and dpkg-query for full package management. dpkg wraps dpkg and dpkg-query for low-level .deb operations. Upgrade and Update return ErrUnsupportedPlatform for dpkg. Both packages include: - Linux implementations with proper error wrapping - Non-linux build stubs returning ErrUnsupportedPlatform - Output parsing helpers with tests - Functional options support (sudo, assume-yes, dry-run)
This commit is contained in:
72
apt/apt.go
72
apt/apt.go
@@ -1,2 +1,74 @@
|
||||
// Package apt provides Go bindings for APT (Advanced Packaging Tool) on Debian/Ubuntu.
|
||||
package apt
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Apt implements the snack.Manager interface using apt-get and apt-cache.
|
||||
type Apt struct{}
|
||||
|
||||
// New returns a new Apt manager.
|
||||
func New() *Apt {
|
||||
return &Apt{}
|
||||
}
|
||||
|
||||
// Name returns "apt".
|
||||
func (a *Apt) Name() string { return "apt" }
|
||||
|
||||
// Install one or more packages.
|
||||
func (a *Apt) Install(ctx context.Context, pkgs []string, opts ...snack.Option) error {
|
||||
return install(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Remove one or more packages.
|
||||
func (a *Apt) Remove(ctx context.Context, pkgs []string, opts ...snack.Option) error {
|
||||
return remove(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Purge one or more packages including config files.
|
||||
func (a *Apt) Purge(ctx context.Context, pkgs []string, opts ...snack.Option) error {
|
||||
return purge(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Upgrade all installed packages.
|
||||
func (a *Apt) Upgrade(ctx context.Context, opts ...snack.Option) error {
|
||||
return upgrade(ctx, opts...)
|
||||
}
|
||||
|
||||
// Update refreshes the package index.
|
||||
func (a *Apt) Update(ctx context.Context) error {
|
||||
return update(ctx)
|
||||
}
|
||||
|
||||
// List returns all installed packages.
|
||||
func (a *Apt) List(ctx context.Context) ([]snack.Package, error) {
|
||||
return list(ctx)
|
||||
}
|
||||
|
||||
// Search queries the package index.
|
||||
func (a *Apt) Search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
return search(ctx, query)
|
||||
}
|
||||
|
||||
// Info returns details about a specific package.
|
||||
func (a *Apt) Info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
return info(ctx, pkg)
|
||||
}
|
||||
|
||||
// IsInstalled reports whether a package is currently installed.
|
||||
func (a *Apt) IsInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
return isInstalled(ctx, pkg)
|
||||
}
|
||||
|
||||
// Version returns the installed version of a package.
|
||||
func (a *Apt) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// Available reports whether apt-get is present on the system.
|
||||
func (a *Apt) Available() bool {
|
||||
return available()
|
||||
}
|
||||
|
||||
138
apt/apt_linux.go
Normal file
138
apt/apt_linux.go
Normal file
@@ -0,0 +1,138 @@
|
||||
//go:build linux
|
||||
|
||||
package apt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool {
|
||||
_, err := exec.LookPath("apt-get")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func buildArgs(command string, pkgs []string, opts ...snack.Option) []string {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var args []string
|
||||
if o.Sudo {
|
||||
args = append(args, "sudo")
|
||||
}
|
||||
args = append(args, "apt-get", command)
|
||||
if o.AssumeYes {
|
||||
args = append(args, "-y")
|
||||
}
|
||||
if o.DryRun {
|
||||
args = append(args, "--dry-run")
|
||||
}
|
||||
args = append(args, pkgs...)
|
||||
return args
|
||||
}
|
||||
|
||||
func runAptGet(ctx context.Context, command string, pkgs []string, opts ...snack.Option) error {
|
||||
args := buildArgs(command, pkgs, opts...)
|
||||
var cmd *exec.Cmd
|
||||
if args[0] == "sudo" {
|
||||
cmd = exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
} else {
|
||||
cmd = exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
errMsg := stderr.String()
|
||||
if strings.Contains(errMsg, "Permission denied") || strings.Contains(errMsg, "are you root") {
|
||||
return fmt.Errorf("apt-get %s: %w", command, snack.ErrPermissionDenied)
|
||||
}
|
||||
if strings.Contains(errMsg, "Unable to locate package") {
|
||||
return fmt.Errorf("apt-get %s: %w", command, snack.ErrNotFound)
|
||||
}
|
||||
return fmt.Errorf("apt-get %s: %w: %s", command, err, errMsg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func install(ctx context.Context, pkgs []string, opts ...snack.Option) error {
|
||||
return runAptGet(ctx, "install", pkgs, opts...)
|
||||
}
|
||||
|
||||
func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error {
|
||||
return runAptGet(ctx, "remove", pkgs, opts...)
|
||||
}
|
||||
|
||||
func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error {
|
||||
return runAptGet(ctx, "purge", pkgs, opts...)
|
||||
}
|
||||
|
||||
func upgrade(ctx context.Context, opts ...snack.Option) error {
|
||||
return runAptGet(ctx, "upgrade", nil, opts...)
|
||||
}
|
||||
|
||||
func update(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, "apt-get", "update")
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("apt-get update: %w: %s", err, stderr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func list(ctx context.Context) ([]snack.Package, error) {
|
||||
cmd := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Package}\\t${Version}\\t${Description}\\n")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dpkg-query list: %w", err)
|
||||
}
|
||||
return parseList(string(out)), nil
|
||||
}
|
||||
|
||||
func search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
cmd := exec.CommandContext(ctx, "apt-cache", "search", query)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apt-cache search: %w", err)
|
||||
}
|
||||
return parseSearch(string(out)), nil
|
||||
}
|
||||
|
||||
func info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
cmd := exec.CommandContext(ctx, "apt-cache", "show", pkg)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if strings.Contains(stderr.String(), "No packages found") {
|
||||
return nil, fmt.Errorf("apt-cache show %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("apt-cache show %s: %w", pkg, err)
|
||||
}
|
||||
return parseInfo(string(out))
|
||||
}
|
||||
|
||||
func isInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
cmd := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Status}", pkg)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return strings.TrimSpace(string(out)) == "install ok installed", nil
|
||||
}
|
||||
|
||||
func version(ctx context.Context, pkg string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Version}", pkg)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
v := strings.TrimSpace(string(out))
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
51
apt/apt_other.go
Normal file
51
apt/apt_other.go
Normal file
@@ -0,0 +1,51 @@
|
||||
//go:build !linux
|
||||
|
||||
package apt
|
||||
|
||||
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
|
||||
}
|
||||
71
apt/apt_test.go
Normal file
71
apt/apt_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package apt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func TestParseList(t *testing.T) {
|
||||
input := "bash\t5.2-1\tGNU Bourne Again SHell\ncoreutils\t9.1-1\tGNU core utilities\n"
|
||||
pkgs := parseList(input)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "bash" || pkgs[0].Version != "5.2-1" {
|
||||
t.Errorf("unexpected first package: %+v", pkgs[0])
|
||||
}
|
||||
if !pkgs[0].Installed {
|
||||
t.Error("expected Installed=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSearch(t *testing.T) {
|
||||
input := "vim - Vi IMproved\nnano - small text editor\n"
|
||||
pkgs := parseSearch(input)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "vim" || pkgs[0].Description != "Vi IMproved" {
|
||||
t.Errorf("unexpected package: %+v", pkgs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfo(t *testing.T) {
|
||||
input := `Package: bash
|
||||
Version: 5.2-1
|
||||
Architecture: amd64
|
||||
Description: GNU Bourne Again SHell
|
||||
`
|
||||
p, err := parseInfo(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Name != "bash" || p.Version != "5.2-1" || p.Arch != "amd64" {
|
||||
t.Errorf("unexpected package: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfoEmpty(t *testing.T) {
|
||||
_, err := parseInfo("")
|
||||
if err != snack.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListEmpty(t *testing.T) {
|
||||
pkgs := parseList("")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
a := New()
|
||||
if a.Name() != "apt" {
|
||||
t.Errorf("expected 'apt', got %q", a.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Apt implements snack.Manager at compile time.
|
||||
var _ snack.Manager = (*Apt)(nil)
|
||||
77
apt/parse.go
Normal file
77
apt/parse.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package apt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// parseList parses dpkg-query -W output into packages.
|
||||
func parseList(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "\t", 3)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
p := snack.Package{
|
||||
Name: parts[0],
|
||||
Version: parts[1],
|
||||
Installed: true,
|
||||
}
|
||||
if len(parts) == 3 {
|
||||
p.Description = parts[2]
|
||||
}
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// parseSearch parses apt-cache search output.
|
||||
func parseSearch(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Format: "package - description"
|
||||
parts := strings.SplitN(line, " - ", 2)
|
||||
if len(parts) < 1 {
|
||||
continue
|
||||
}
|
||||
p := snack.Package{Name: strings.TrimSpace(parts[0])}
|
||||
if len(parts) == 2 {
|
||||
p.Description = strings.TrimSpace(parts[1])
|
||||
}
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// parseInfo parses apt-cache show output into a Package.
|
||||
func parseInfo(output string) (*snack.Package, error) {
|
||||
p := &snack.Package{}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
key, val, ok := strings.Cut(line, ": ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "Package":
|
||||
p.Name = val
|
||||
case "Version":
|
||||
p.Version = val
|
||||
case "Description":
|
||||
p.Description = val
|
||||
case "Architecture":
|
||||
p.Arch = val
|
||||
}
|
||||
}
|
||||
if p.Name == "" {
|
||||
return nil, snack.ErrNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
72
dpkg/dpkg.go
72
dpkg/dpkg.go
@@ -1,2 +1,74 @@
|
||||
// Package dpkg provides Go bindings for dpkg (low-level Debian package tool).
|
||||
package dpkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Dpkg implements the snack.Manager interface using dpkg and dpkg-query.
|
||||
type Dpkg struct{}
|
||||
|
||||
// New returns a new Dpkg manager.
|
||||
func New() *Dpkg {
|
||||
return &Dpkg{}
|
||||
}
|
||||
|
||||
// Name returns "dpkg".
|
||||
func (d *Dpkg) Name() string { return "dpkg" }
|
||||
|
||||
// Install one or more .deb files.
|
||||
func (d *Dpkg) Install(ctx context.Context, pkgs []string, opts ...snack.Option) error {
|
||||
return install(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Remove one or more packages.
|
||||
func (d *Dpkg) Remove(ctx context.Context, pkgs []string, opts ...snack.Option) error {
|
||||
return remove(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Purge one or more packages including config files.
|
||||
func (d *Dpkg) Purge(ctx context.Context, pkgs []string, opts ...snack.Option) error {
|
||||
return purge(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Upgrade is not supported by dpkg.
|
||||
func (d *Dpkg) Upgrade(_ context.Context, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
// Update is not supported by dpkg.
|
||||
func (d *Dpkg) Update(_ context.Context) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
// List returns all installed packages.
|
||||
func (d *Dpkg) List(ctx context.Context) ([]snack.Package, error) {
|
||||
return list(ctx)
|
||||
}
|
||||
|
||||
// Search queries installed packages matching the pattern.
|
||||
func (d *Dpkg) Search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
return search(ctx, query)
|
||||
}
|
||||
|
||||
// Info returns details about a specific package.
|
||||
func (d *Dpkg) Info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
return info(ctx, pkg)
|
||||
}
|
||||
|
||||
// IsInstalled reports whether a package is currently installed.
|
||||
func (d *Dpkg) IsInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
return isInstalled(ctx, pkg)
|
||||
}
|
||||
|
||||
// Version returns the installed version of a package.
|
||||
func (d *Dpkg) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// Available reports whether dpkg is present on the system.
|
||||
func (d *Dpkg) Available() bool {
|
||||
return available()
|
||||
}
|
||||
|
||||
138
dpkg/dpkg_linux.go
Normal file
138
dpkg/dpkg_linux.go
Normal file
@@ -0,0 +1,138 @@
|
||||
//go:build linux
|
||||
|
||||
package dpkg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool {
|
||||
_, err := exec.LookPath("dpkg")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func install(ctx context.Context, pkgs []string, opts ...snack.Option) error {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var args []string
|
||||
if o.Sudo {
|
||||
args = append(args, "sudo")
|
||||
}
|
||||
args = append(args, "dpkg", "-i")
|
||||
if o.DryRun {
|
||||
args = append(args, "--simulate")
|
||||
}
|
||||
args = append(args, pkgs...)
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
errMsg := stderr.String()
|
||||
if strings.Contains(errMsg, "Permission denied") || strings.Contains(errMsg, "are you root") {
|
||||
return fmt.Errorf("dpkg -i: %w", snack.ErrPermissionDenied)
|
||||
}
|
||||
return fmt.Errorf("dpkg -i: %w: %s", err, errMsg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var args []string
|
||||
if o.Sudo {
|
||||
args = append(args, "sudo")
|
||||
}
|
||||
args = append(args, "dpkg", "-r")
|
||||
if o.DryRun {
|
||||
args = append(args, "--simulate")
|
||||
}
|
||||
args = append(args, pkgs...)
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("dpkg -r: %w: %s", err, stderr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var args []string
|
||||
if o.Sudo {
|
||||
args = append(args, "sudo")
|
||||
}
|
||||
args = append(args, "dpkg", "-P")
|
||||
if o.DryRun {
|
||||
args = append(args, "--simulate")
|
||||
}
|
||||
args = append(args, pkgs...)
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("dpkg -P: %w: %s", err, stderr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func list(ctx context.Context) ([]snack.Package, error) {
|
||||
cmd := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Package}\\t${Version}\\t${Status}\\n")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dpkg-query list: %w", err)
|
||||
}
|
||||
return parseList(string(out)), nil
|
||||
}
|
||||
|
||||
func search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
pattern := fmt.Sprintf("*%s*", query)
|
||||
cmd := exec.CommandContext(ctx, "dpkg-query", "-l", pattern)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// dpkg-query -l returns exit 1 when no packages match
|
||||
return nil, nil
|
||||
}
|
||||
return parseDpkgList(string(out)), nil
|
||||
}
|
||||
|
||||
func info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
cmd := exec.CommandContext(ctx, "dpkg-query", "-s", pkg)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if strings.Contains(stderr.String(), "is not installed") || strings.Contains(stderr.String(), "not found") {
|
||||
return nil, fmt.Errorf("dpkg-query -s %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return nil, fmt.Errorf("dpkg-query -s %s: %w", pkg, err)
|
||||
}
|
||||
return parseInfo(string(out))
|
||||
}
|
||||
|
||||
func isInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
cmd := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Status}", pkg)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return strings.TrimSpace(string(out)) == "install ok installed", nil
|
||||
}
|
||||
|
||||
func version(ctx context.Context, pkg string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Version}", pkg)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
v := strings.TrimSpace(string(out))
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
43
dpkg/dpkg_other.go
Normal file
43
dpkg/dpkg_other.go
Normal file
@@ -0,0 +1,43 @@
|
||||
//go:build !linux
|
||||
|
||||
package dpkg
|
||||
|
||||
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 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
|
||||
}
|
||||
89
dpkg/dpkg_test.go
Normal file
89
dpkg/dpkg_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package dpkg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func TestParseList(t *testing.T) {
|
||||
input := "bash\t5.2-1\tinstall ok installed\ncoreutils\t9.1-1\tdeinstall ok config-files\n"
|
||||
pkgs := parseList(input)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "bash" || !pkgs[0].Installed {
|
||||
t.Errorf("unexpected first package: %+v", pkgs[0])
|
||||
}
|
||||
if pkgs[1].Installed {
|
||||
t.Errorf("expected second package not installed: %+v", pkgs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDpkgList(t *testing.T) {
|
||||
input := `Desired=Unknown/Install/Remove/Purge/Hold
|
||||
| Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend
|
||||
|/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad)
|
||||
||/ Name Version Architecture Description
|
||||
+++-==============-============-============-=================================
|
||||
ii bash 5.2-1 amd64 GNU Bourne Again SHell
|
||||
rc oldpkg 1.0-1 amd64 Some old package
|
||||
`
|
||||
pkgs := parseDpkgList(input)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "bash" || !pkgs[0].Installed {
|
||||
t.Errorf("unexpected: %+v", pkgs[0])
|
||||
}
|
||||
if pkgs[1].Installed {
|
||||
t.Errorf("expected oldpkg not installed: %+v", pkgs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfo(t *testing.T) {
|
||||
input := `Package: bash
|
||||
Status: install ok installed
|
||||
Version: 5.2-1
|
||||
Architecture: amd64
|
||||
Description: GNU Bourne Again SHell
|
||||
`
|
||||
p, err := parseInfo(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Name != "bash" || p.Version != "5.2-1" || !p.Installed {
|
||||
t.Errorf("unexpected: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfoEmpty(t *testing.T) {
|
||||
_, err := parseInfo("")
|
||||
if err != snack.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
d := New()
|
||||
if d.Name() != "dpkg" {
|
||||
t.Errorf("expected 'dpkg', got %q", d.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeUnsupported(t *testing.T) {
|
||||
d := New()
|
||||
if err := d.Upgrade(nil); err != snack.ErrUnsupportedPlatform {
|
||||
t.Errorf("expected ErrUnsupportedPlatform, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUnsupported(t *testing.T) {
|
||||
d := New()
|
||||
if err := d.Update(nil); err != snack.ErrUnsupportedPlatform {
|
||||
t.Errorf("expected ErrUnsupportedPlatform, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Dpkg implements snack.Manager at compile time.
|
||||
var _ snack.Manager = (*Dpkg)(nil)
|
||||
87
dpkg/parse.go
Normal file
87
dpkg/parse.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package dpkg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// parseList parses dpkg-query -W -f='${Package}\t${Version}\t${Status}\n' output.
|
||||
func parseList(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "\t", 3)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
p := snack.Package{
|
||||
Name: parts[0],
|
||||
Version: parts[1],
|
||||
}
|
||||
if len(parts) == 3 && strings.Contains(parts[2], "install ok installed") {
|
||||
p.Installed = true
|
||||
}
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// parseDpkgList parses dpkg-query -l output (table format with header).
|
||||
func parseDpkgList(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
// Data lines start with status flags like "ii", "rc", etc.
|
||||
if len(line) < 4 || line[2] != ' ' {
|
||||
continue
|
||||
}
|
||||
// Skip lines where flags aren't letters (e.g. "+++-..." separator)
|
||||
if line[0] < 'a' || line[0] > 'z' {
|
||||
continue
|
||||
}
|
||||
status := line[:2]
|
||||
fields := strings.Fields(line[3:])
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
p := snack.Package{
|
||||
Name: fields[0],
|
||||
Version: fields[1],
|
||||
Installed: status == "ii",
|
||||
}
|
||||
if len(fields) > 3 {
|
||||
p.Description = strings.Join(fields[3:], " ")
|
||||
}
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// parseInfo parses dpkg-query -s output into a Package.
|
||||
func parseInfo(output string) (*snack.Package, error) {
|
||||
p := &snack.Package{}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
key, val, ok := strings.Cut(line, ": ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "Package":
|
||||
p.Name = val
|
||||
case "Version":
|
||||
p.Version = val
|
||||
case "Description":
|
||||
p.Description = val
|
||||
case "Architecture":
|
||||
p.Arch = val
|
||||
case "Status":
|
||||
p.Installed = val == "install ok installed"
|
||||
}
|
||||
}
|
||||
if p.Name == "" {
|
||||
return nil, snack.ErrNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
Reference in New Issue
Block a user