From 4beeb0a0d775671c42f92b8782227ef5a51328f6 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Wed, 25 Feb 2026 22:23:26 +0000 Subject: [PATCH] feat: add flatpak and snap package manager implementations flatpak: implements Manager, Cleaner, and RepoManager interfaces - Install/Remove/Purge/Upgrade with flatpak CLI - Repository management (add/remove/list remotes) - Autoremove unused runtimes snap: implements Manager and VersionQuerier interfaces - Install with --classic/--channel support via Target.FromRepo - Remove/Purge/Upgrade via snap CLI - Version queries with semver comparison - Upgrade availability via snap refresh --list Both packages follow the existing pattern: - Exported methods on struct delegate to unexported functions - _linux.go for real implementation, _other.go stubs - Compile-time interface checks - Parser tests for all output formats --- flatpak/capabilities.go | 44 ++++++++++ flatpak/flatpak.go | 85 +++++++++++++++++- flatpak/flatpak_linux.go | 151 ++++++++++++++++++++++++++++++++ flatpak/flatpak_other.go | 63 ++++++++++++++ flatpak/flatpak_test.go | 127 +++++++++++++++++++++++++++ flatpak/parse.go | 129 +++++++++++++++++++++++++++ snap/capabilities.go | 30 +++++++ snap/parse.go | 183 +++++++++++++++++++++++++++++++++++++++ snap/snap.go | 85 +++++++++++++++++- snap/snap_linux.go | 182 ++++++++++++++++++++++++++++++++++++++ snap/snap_other.go | 67 ++++++++++++++ snap/snap_test.go | 162 ++++++++++++++++++++++++++++++++++ 12 files changed, 1306 insertions(+), 2 deletions(-) create mode 100644 flatpak/capabilities.go create mode 100644 flatpak/flatpak_linux.go create mode 100644 flatpak/flatpak_other.go create mode 100644 flatpak/flatpak_test.go create mode 100644 flatpak/parse.go create mode 100644 snap/capabilities.go create mode 100644 snap/parse.go create mode 100644 snap/snap_linux.go create mode 100644 snap/snap_other.go create mode 100644 snap/snap_test.go diff --git a/flatpak/capabilities.go b/flatpak/capabilities.go new file mode 100644 index 0000000..c63c818 --- /dev/null +++ b/flatpak/capabilities.go @@ -0,0 +1,44 @@ +package flatpak + +import ( + "context" + + "github.com/gogrlx/snack" +) + +// Compile-time interface checks. +var ( + _ snack.Cleaner = (*Flatpak)(nil) + _ snack.RepoManager = (*Flatpak)(nil) +) + +// Autoremove removes unused runtimes and extensions. +func (f *Flatpak) Autoremove(ctx context.Context, opts ...snack.Option) error { + f.Lock() + defer f.Unlock() + return autoremove(ctx, opts...) +} + +// Clean is a no-op for flatpak (no direct cache clean equivalent). +func (f *Flatpak) Clean(ctx context.Context) error { + return nil +} + +// ListRepos returns all configured remotes. +func (f *Flatpak) ListRepos(ctx context.Context) ([]snack.Repository, error) { + return listRepos(ctx) +} + +// AddRepo adds a new remote. +func (f *Flatpak) AddRepo(ctx context.Context, repo snack.Repository) error { + f.Lock() + defer f.Unlock() + return addRepo(ctx, repo) +} + +// RemoveRepo removes a configured remote. +func (f *Flatpak) RemoveRepo(ctx context.Context, id string) error { + f.Lock() + defer f.Unlock() + return removeRepo(ctx, id) +} diff --git a/flatpak/flatpak.go b/flatpak/flatpak.go index b854723..e9e1834 100644 --- a/flatpak/flatpak.go +++ b/flatpak/flatpak.go @@ -1,2 +1,85 @@ -// Package flatpak provides Go bindings for Flatpak (cross-distribution application packaging). +// Package flatpak provides Go bindings for the Flatpak package manager. package flatpak + +import ( + "context" + + "github.com/gogrlx/snack" +) + +// Flatpak wraps the flatpak CLI. +type Flatpak struct { + snack.Locker +} + +// New returns a new Flatpak manager. +func New() *Flatpak { + return &Flatpak{} +} + +// Name returns "flatpak". +func (f *Flatpak) Name() string { return "flatpak" } + +// Available reports whether flatpak is present on the system. +func (f *Flatpak) Available() bool { return available() } + +// Install one or more packages. +func (f *Flatpak) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + f.Lock() + defer f.Unlock() + return install(ctx, pkgs, opts...) +} + +// Remove one or more packages. +func (f *Flatpak) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + f.Lock() + defer f.Unlock() + return remove(ctx, pkgs, opts...) +} + +// Purge removes packages including their data. +func (f *Flatpak) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + f.Lock() + defer f.Unlock() + return purge(ctx, pkgs, opts...) +} + +// Upgrade all installed packages to their latest versions. +func (f *Flatpak) Upgrade(ctx context.Context, opts ...snack.Option) error { + f.Lock() + defer f.Unlock() + return upgrade(ctx, opts...) +} + +// Update is a no-op for flatpak (auto-refreshes). +func (f *Flatpak) Update(ctx context.Context) error { + return nil +} + +// List returns all installed packages. +func (f *Flatpak) List(ctx context.Context) ([]snack.Package, error) { + return list(ctx) +} + +// Search queries for packages matching the query. +func (f *Flatpak) Search(ctx context.Context, query string) ([]snack.Package, error) { + return search(ctx, query) +} + +// Info returns details about a specific package. +func (f *Flatpak) Info(ctx context.Context, pkg string) (*snack.Package, error) { + return info(ctx, pkg) +} + +// IsInstalled reports whether a package is currently installed. +func (f *Flatpak) IsInstalled(ctx context.Context, pkg string) (bool, error) { + return isInstalled(ctx, pkg) +} + +// Version returns the installed version of a package. +func (f *Flatpak) Version(ctx context.Context, pkg string) (string, error) { + return version(ctx, pkg) +} + +// Verify interface compliance at compile time. +var _ snack.Manager = (*Flatpak)(nil) diff --git a/flatpak/flatpak_linux.go b/flatpak/flatpak_linux.go new file mode 100644 index 0000000..845ea61 --- /dev/null +++ b/flatpak/flatpak_linux.go @@ -0,0 +1,151 @@ +//go:build linux + +package flatpak + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + "github.com/gogrlx/snack" +) + +func available() bool { + _, err := exec.LookPath("flatpak") + return err == nil +} + +func run(ctx context.Context, args []string) (string, error) { + c := exec.CommandContext(ctx, "flatpak", args...) + 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, "requires root") { + return "", fmt.Errorf("flatpak: %w", snack.ErrPermissionDenied) + } + return "", fmt.Errorf("flatpak: %s: %w", strings.TrimSpace(se), err) + } + return stdout.String(), nil +} + +func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + _ = snack.ApplyOptions(opts...) + for _, t := range pkgs { + remote := t.FromRepo + if remote == "" { + remote = "flathub" + } + args := []string{"install", "-y", remote, t.Name} + if _, err := run(ctx, args); err != nil { + return err + } + } + return nil +} + +func remove(ctx context.Context, pkgs []snack.Target, _ ...snack.Option) error { + args := append([]string{"uninstall", "-y"}, snack.TargetNames(pkgs)...) + _, err := run(ctx, args) + return err +} + +func purge(ctx context.Context, pkgs []snack.Target, _ ...snack.Option) error { + args := append([]string{"uninstall", "-y", "--delete-data"}, snack.TargetNames(pkgs)...) + _, err := run(ctx, args) + return err +} + +func upgrade(ctx context.Context, _ ...snack.Option) error { + _, err := run(ctx, []string{"update", "-y"}) + return err +} + +func list(ctx context.Context) ([]snack.Package, error) { + out, err := run(ctx, []string{"list", "--columns=name,application,version,origin"}) + if err != nil { + return nil, fmt.Errorf("flatpak list: %w", err) + } + return parseList(out), nil +} + +func search(ctx context.Context, query string) ([]snack.Package, error) { + out, err := run(ctx, []string{"search", query, "--columns=name,application,version,remotes"}) + if err != nil { + if strings.Contains(err.Error(), "No matches found") { + return nil, nil + } + return nil, fmt.Errorf("flatpak search: %w", err) + } + return parseSearch(out), nil +} + +func info(ctx context.Context, pkg string) (*snack.Package, error) { + out, err := run(ctx, []string{"info", pkg}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") { + return nil, fmt.Errorf("flatpak info %s: %w", pkg, snack.ErrNotFound) + } + return nil, fmt.Errorf("flatpak info: %w", err) + } + p := parseInfo(out) + if p == nil { + return nil, fmt.Errorf("flatpak info %s: %w", pkg, snack.ErrNotFound) + } + p.Installed = true + return p, nil +} + +func isInstalled(ctx context.Context, pkg string) (bool, error) { + c := exec.CommandContext(ctx, "flatpak", "info", pkg) + err := c.Run() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return false, nil + } + return false, fmt.Errorf("flatpak isInstalled: %w", err) + } + return true, nil +} + +func version(ctx context.Context, pkg string) (string, error) { + out, err := run(ctx, []string{"info", pkg}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") { + return "", fmt.Errorf("flatpak version %s: %w", pkg, snack.ErrNotInstalled) + } + return "", fmt.Errorf("flatpak version: %w", err) + } + p := parseInfo(out) + if p == nil || p.Version == "" { + return "", fmt.Errorf("flatpak version %s: %w", pkg, snack.ErrNotInstalled) + } + return p.Version, nil +} + +func autoremove(ctx context.Context, _ ...snack.Option) error { + _, err := run(ctx, []string{"uninstall", "--unused", "-y"}) + return err +} + +func listRepos(ctx context.Context) ([]snack.Repository, error) { + out, err := run(ctx, []string{"remotes", "--columns=name,url,options"}) + if err != nil { + return nil, fmt.Errorf("flatpak listRepos: %w", err) + } + return parseRemotes(out), nil +} + +func addRepo(ctx context.Context, repo snack.Repository) error { + _, err := run(ctx, []string{"remote-add", "--if-not-exists", repo.Name, repo.URL}) + return err +} + +func removeRepo(ctx context.Context, id string) error { + _, err := run(ctx, []string{"remote-delete", id}) + return err +} diff --git a/flatpak/flatpak_other.go b/flatpak/flatpak_other.go new file mode 100644 index 0000000..62395ef --- /dev/null +++ b/flatpak/flatpak_other.go @@ -0,0 +1,63 @@ +//go:build !linux + +package flatpak + +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 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 +} + +func autoremove(_ context.Context, _ ...snack.Option) error { + return snack.ErrUnsupportedPlatform +} + +func listRepos(_ context.Context) ([]snack.Repository, error) { + return nil, snack.ErrUnsupportedPlatform +} + +func addRepo(_ context.Context, _ snack.Repository) error { + return snack.ErrUnsupportedPlatform +} + +func removeRepo(_ context.Context, _ string) error { + return snack.ErrUnsupportedPlatform +} diff --git a/flatpak/flatpak_test.go b/flatpak/flatpak_test.go new file mode 100644 index 0000000..0305dad --- /dev/null +++ b/flatpak/flatpak_test.go @@ -0,0 +1,127 @@ +package flatpak + +import ( + "testing" + + "github.com/gogrlx/snack" +) + +func TestParseList(t *testing.T) { + input := "Firefox\torg.mozilla.Firefox\t131.0\tflathub\n" + + "GIMP\torg.gimp.GIMP\t2.10.38\tflathub\n" + pkgs := parseList(input) + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages, got %d", len(pkgs)) + } + if pkgs[0].Name != "Firefox" { + t.Errorf("expected name 'Firefox', got %q", pkgs[0].Name) + } + if pkgs[0].Description != "org.mozilla.Firefox" { + t.Errorf("expected description 'org.mozilla.Firefox', got %q", pkgs[0].Description) + } + if pkgs[0].Version != "131.0" { + t.Errorf("expected version '131.0', got %q", pkgs[0].Version) + } + if pkgs[0].Repository != "flathub" { + t.Errorf("expected repository 'flathub', got %q", pkgs[0].Repository) + } + if !pkgs[0].Installed { + t.Error("expected Installed=true") + } +} + +func TestParseListEmpty(t *testing.T) { + pkgs := parseList("") + if len(pkgs) != 0 { + t.Fatalf("expected 0 packages, got %d", len(pkgs)) + } +} + +func TestParseSearch(t *testing.T) { + input := "Firefox\torg.mozilla.Firefox\t131.0\tflathub\n" + + "Firefox ESR\torg.mozilla.FirefoxESR\t128.3\tflathub\n" + pkgs := parseSearch(input) + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages, got %d", len(pkgs)) + } + if pkgs[0].Name != "Firefox" { + t.Errorf("unexpected name: %q", pkgs[0].Name) + } + if pkgs[1].Version != "128.3" { + t.Errorf("unexpected version: %q", pkgs[1].Version) + } +} + +func TestParseSearchNoMatches(t *testing.T) { + input := "No matches found\n" + pkgs := parseSearch(input) + if len(pkgs) != 0 { + t.Fatalf("expected 0 packages, got %d", len(pkgs)) + } +} + +func TestParseInfo(t *testing.T) { + input := `Name: Firefox +Description: Fast, private web browser +Version: 131.0 +Arch: x86_64 +Origin: flathub +` + pkg := parseInfo(input) + if pkg == nil { + t.Fatal("expected non-nil package") + } + if pkg.Name != "Firefox" { + t.Errorf("expected name 'Firefox', got %q", pkg.Name) + } + if pkg.Version != "131.0" { + t.Errorf("expected version '131.0', got %q", pkg.Version) + } + if pkg.Arch != "x86_64" { + t.Errorf("expected arch 'x86_64', got %q", pkg.Arch) + } + if pkg.Repository != "flathub" { + t.Errorf("expected repository 'flathub', got %q", pkg.Repository) + } +} + +func TestParseInfoEmpty(t *testing.T) { + pkg := parseInfo("") + if pkg != nil { + t.Error("expected nil for empty input") + } +} + +func TestParseRemotes(t *testing.T) { + input := "flathub\thttps://dl.flathub.org/repo/\t\n" + + "gnome-nightly\thttps://nightly.gnome.org/repo/\tdisabled\n" + repos := parseRemotes(input) + if len(repos) != 2 { + t.Fatalf("expected 2 repos, got %d", len(repos)) + } + if repos[0].ID != "flathub" { + t.Errorf("expected ID 'flathub', got %q", repos[0].ID) + } + if repos[0].URL != "https://dl.flathub.org/repo/" { + t.Errorf("unexpected URL: %q", repos[0].URL) + } + if !repos[0].Enabled { + t.Error("expected first repo to be enabled") + } + if repos[1].Enabled { + t.Error("expected second repo to be disabled") + } +} + +func TestInterfaceCompliance(t *testing.T) { + var _ snack.Manager = (*Flatpak)(nil) + var _ snack.Cleaner = (*Flatpak)(nil) + var _ snack.RepoManager = (*Flatpak)(nil) +} + +func TestName(t *testing.T) { + f := New() + if f.Name() != "flatpak" { + t.Errorf("Name() = %q, want %q", f.Name(), "flatpak") + } +} diff --git a/flatpak/parse.go b/flatpak/parse.go new file mode 100644 index 0000000..ff0f36d --- /dev/null +++ b/flatpak/parse.go @@ -0,0 +1,129 @@ +package flatpak + +import ( + "strings" + + "github.com/gogrlx/snack" +) + +// parseList parses `flatpak list --columns=name,application,version,origin`. +// Format: "Name\tApplication ID\tVersion\tOrigin" +func parseList(output string) []snack.Package { + var pkgs []snack.Package + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Split(line, "\t") + if len(parts) < 2 { + continue + } + pkg := snack.Package{ + Name: strings.TrimSpace(parts[0]), + Installed: true, + } + if len(parts) >= 2 { + pkg.Description = strings.TrimSpace(parts[1]) // application ID + } + if len(parts) >= 3 { + pkg.Version = strings.TrimSpace(parts[2]) + } + if len(parts) >= 4 { + pkg.Repository = strings.TrimSpace(parts[3]) + } + pkgs = append(pkgs, pkg) + } + return pkgs +} + +// parseSearch parses `flatpak search --columns=name,application,version,remotes`. +// Format: "Name\tApplication ID\tVersion\tRemotes" +func parseSearch(output string) []snack.Package { + var pkgs []snack.Package + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "No matches found") { + continue + } + parts := strings.Split(line, "\t") + if len(parts) < 2 { + continue + } + pkg := snack.Package{ + Name: strings.TrimSpace(parts[0]), + } + if len(parts) >= 2 { + pkg.Description = strings.TrimSpace(parts[1]) + } + if len(parts) >= 3 { + pkg.Version = strings.TrimSpace(parts[2]) + } + if len(parts) >= 4 { + pkg.Repository = strings.TrimSpace(parts[3]) + } + pkgs = append(pkgs, pkg) + } + return pkgs +} + +// parseInfo parses `flatpak info ` output (key: value format). +func parseInfo(output string) *snack.Package { + pkg := &snack.Package{} + for _, line := range strings.Split(output, "\n") { + idx := strings.Index(line, ":") + if idx < 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+1:]) + switch key { + case "Name": + pkg.Name = val + case "Version": + pkg.Version = val + case "Description": + pkg.Description = val + case "Arch": + pkg.Arch = val + case "Origin": + pkg.Repository = val + } + } + if pkg.Name == "" { + return nil + } + return pkg +} + +// parseRemotes parses `flatpak remotes --columns=name,url,options`. +// Format: "Name\tURL\tOptions" +func parseRemotes(output string) []snack.Repository { + var repos []snack.Repository + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Split(line, "\t") + if len(parts) < 1 { + continue + } + repo := snack.Repository{ + ID: strings.TrimSpace(parts[0]), + Name: strings.TrimSpace(parts[0]), + Enabled: true, + } + if len(parts) >= 2 { + repo.URL = strings.TrimSpace(parts[1]) + } + if len(parts) >= 3 { + opts := strings.TrimSpace(parts[2]) + if strings.Contains(opts, "disabled") { + repo.Enabled = false + } + } + repos = append(repos, repo) + } + return repos +} diff --git a/snap/capabilities.go b/snap/capabilities.go new file mode 100644 index 0000000..6aba608 --- /dev/null +++ b/snap/capabilities.go @@ -0,0 +1,30 @@ +package snap + +import ( + "context" + + "github.com/gogrlx/snack" +) + +// Compile-time interface checks. +var _ snack.VersionQuerier = (*Snap)(nil) + +// LatestVersion returns the latest stable version of a snap. +func (s *Snap) LatestVersion(ctx context.Context, pkg string) (string, error) { + return latestVersion(ctx, pkg) +} + +// ListUpgrades returns snaps that have newer versions available. +func (s *Snap) ListUpgrades(ctx context.Context) ([]snack.Package, error) { + return listUpgrades(ctx) +} + +// UpgradeAvailable reports whether a newer version is available. +func (s *Snap) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) { + return upgradeAvailable(ctx, pkg) +} + +// VersionCmp compares two version strings. +func (s *Snap) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) { + return versionCmp(ctx, ver1, ver2) +} diff --git a/snap/parse.go b/snap/parse.go new file mode 100644 index 0000000..3fef2b6 --- /dev/null +++ b/snap/parse.go @@ -0,0 +1,183 @@ +package snap + +import ( + "strconv" + "strings" + + "github.com/gogrlx/snack" +) + +// parseSnapList parses `snap list` tabular output. +// Header: Name Version Rev Tracking Publisher Notes +func parseSnapList(output string) []snack.Package { + var pkgs []snack.Package + lines := strings.Split(output, "\n") + for i, line := range lines { + if i == 0 { // skip header + continue + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + pkg := snack.Package{ + Name: fields[0], + Version: fields[1], + Installed: true, + } + pkgs = append(pkgs, pkg) + } + return pkgs +} + +// parseSnapFind parses `snap find ` tabular output. +// Header: Name Version Publisher Notes Summary +func parseSnapFind(output string) []snack.Package { + var pkgs []snack.Package + lines := strings.Split(output, "\n") + for i, line := range lines { + if i == 0 { // skip header + continue + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + pkg := snack.Package{ + Name: fields[0], + Version: fields[1], + } + // Summary is everything after the 4th field + if len(fields) > 4 { + pkg.Description = strings.Join(fields[4:], " ") + } + pkgs = append(pkgs, pkg) + } + return pkgs +} + +// parseSnapInfo parses `snap info ` key:value output. +func parseSnapInfo(output string) *snack.Package { + pkg := &snack.Package{} + for _, line := range strings.Split(output, "\n") { + idx := strings.Index(line, ":") + if idx < 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+1:]) + switch key { + case "name": + pkg.Name = val + case "summary": + pkg.Description = val + case "installed": + // "installed: 1.2.3 (rev) 100MB ..." + parts := strings.Fields(val) + if len(parts) >= 1 { + pkg.Version = parts[0] + pkg.Installed = true + } + case "snap-id": + // presence indicates it exists + } + } + if pkg.Name == "" { + return nil + } + return pkg +} + +// parseSnapInfoVersion extracts the latest/stable version from `snap info` output. +func parseSnapInfoVersion(output string) string { + // Look for "latest/stable:" line + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "latest/stable:") { + val := strings.TrimPrefix(line, "latest/stable:") + val = strings.TrimSpace(val) + fields := strings.Fields(val) + if len(fields) >= 1 && fields[0] != "--" && fields[0] != "^" { + return fields[0] + } + } + } + return "" +} + +// parseSnapRefreshList parses `snap refresh --list` tabular output. +// Header: Name Version Rev Publisher Notes +func parseSnapRefreshList(output string) []snack.Package { + var pkgs []snack.Package + lines := strings.Split(output, "\n") + for i, line := range lines { + if i == 0 { // skip header + continue + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + // "All snaps up to date." means no upgrades + if strings.Contains(line, "All snaps up to date") { + return nil + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + pkgs = append(pkgs, snack.Package{ + Name: fields[0], + Version: fields[1], + Installed: true, + }) + } + return pkgs +} + +// semverCmp does a basic semver-ish comparison. +// Returns -1 if a < b, 0 if equal, 1 if a > b. +func semverCmp(a, b string) int { + partsA := strings.Split(a, ".") + partsB := strings.Split(b, ".") + + maxLen := len(partsA) + if len(partsB) > maxLen { + maxLen = len(partsB) + } + + for i := 0; i < maxLen; i++ { + var numA, numB int + if i < len(partsA) { + numA, _ = strconv.Atoi(stripNonNumeric(partsA[i])) + } + if i < len(partsB) { + numB, _ = strconv.Atoi(stripNonNumeric(partsB[i])) + } + if numA < numB { + return -1 + } + if numA > numB { + return 1 + } + } + return 0 +} + +// stripNonNumeric keeps only leading digits from a string. +func stripNonNumeric(s string) string { + for i, c := range s { + if c < '0' || c > '9' { + return s[:i] + } + } + return s +} diff --git a/snap/snap.go b/snap/snap.go index 5280099..1bb4c0c 100644 --- a/snap/snap.go +++ b/snap/snap.go @@ -1,2 +1,85 @@ -// Package snap provides Go bindings for snapd (Canonical's cross-distribution package manager). +// Package snap provides Go bindings for the snap package manager. package snap + +import ( + "context" + + "github.com/gogrlx/snack" +) + +// Snap wraps the snap CLI. +type Snap struct { + snack.Locker +} + +// New returns a new Snap manager. +func New() *Snap { + return &Snap{} +} + +// Name returns "snap". +func (s *Snap) Name() string { return "snap" } + +// Available reports whether snap is present on the system. +func (s *Snap) Available() bool { return available() } + +// Install one or more packages. +func (s *Snap) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + s.Lock() + defer s.Unlock() + return install(ctx, pkgs, opts...) +} + +// Remove one or more packages. +func (s *Snap) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + s.Lock() + defer s.Unlock() + return remove(ctx, pkgs, opts...) +} + +// Purge removes packages including all data. +func (s *Snap) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + s.Lock() + defer s.Unlock() + return purge(ctx, pkgs, opts...) +} + +// Upgrade all installed snaps. +func (s *Snap) Upgrade(ctx context.Context, opts ...snack.Option) error { + s.Lock() + defer s.Unlock() + return upgrade(ctx, opts...) +} + +// Update checks for available updates (snap auto-refreshes). +func (s *Snap) Update(ctx context.Context) error { + return update(ctx) +} + +// List returns all installed snaps. +func (s *Snap) List(ctx context.Context) ([]snack.Package, error) { + return list(ctx) +} + +// Search queries the snap store. +func (s *Snap) Search(ctx context.Context, query string) ([]snack.Package, error) { + return search(ctx, query) +} + +// Info returns details about a specific snap. +func (s *Snap) Info(ctx context.Context, pkg string) (*snack.Package, error) { + return info(ctx, pkg) +} + +// IsInstalled reports whether a snap is currently installed. +func (s *Snap) IsInstalled(ctx context.Context, pkg string) (bool, error) { + return isInstalled(ctx, pkg) +} + +// Version returns the installed version of a snap. +func (s *Snap) Version(ctx context.Context, pkg string) (string, error) { + return version(ctx, pkg) +} + +// Verify interface compliance at compile time. +var _ snack.Manager = (*Snap)(nil) diff --git a/snap/snap_linux.go b/snap/snap_linux.go new file mode 100644 index 0000000..7efa4c6 --- /dev/null +++ b/snap/snap_linux.go @@ -0,0 +1,182 @@ +//go:build linux + +package snap + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + "github.com/gogrlx/snack" +) + +func available() bool { + _, err := exec.LookPath("snap") + return err == nil +} + +func run(ctx context.Context, args []string) (string, error) { + c := exec.CommandContext(ctx, "snap", args...) + 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, "requires root") { + return "", fmt.Errorf("snap: %w", snack.ErrPermissionDenied) + } + return "", fmt.Errorf("snap: %s: %w", strings.TrimSpace(se), err) + } + return stdout.String(), nil +} + +func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + _ = snack.ApplyOptions(opts...) + for _, t := range pkgs { + args := []string{"install"} + // Handle --classic or --channel via FromRepo + if t.FromRepo != "" { + if t.FromRepo == "classic" { + args = append(args, "--classic") + } else { + args = append(args, "--channel="+t.FromRepo) + } + } + args = append(args, t.Name) + if _, err := run(ctx, args); err != nil { + return err + } + } + return nil +} + +func remove(ctx context.Context, pkgs []snack.Target, _ ...snack.Option) error { + args := append([]string{"remove"}, snack.TargetNames(pkgs)...) + _, err := run(ctx, args) + return err +} + +func purge(ctx context.Context, pkgs []snack.Target, _ ...snack.Option) error { + args := append([]string{"remove", "--purge"}, snack.TargetNames(pkgs)...) + _, err := run(ctx, args) + return err +} + +func upgrade(ctx context.Context, _ ...snack.Option) error { + _, err := run(ctx, []string{"refresh"}) + return err +} + +func update(ctx context.Context) error { + // snap auto-refreshes; just check for available updates + _, _ = run(ctx, []string{"refresh", "--list"}) + return nil +} + +func list(ctx context.Context) ([]snack.Package, error) { + out, err := run(ctx, []string{"list"}) + if err != nil { + return nil, fmt.Errorf("snap list: %w", err) + } + return parseSnapList(out), nil +} + +func search(ctx context.Context, query string) ([]snack.Package, error) { + out, err := run(ctx, []string{"find", query}) + if err != nil { + if strings.Contains(err.Error(), "No matching snaps") { + return nil, nil + } + return nil, fmt.Errorf("snap search: %w", err) + } + return parseSnapFind(out), nil +} + +func info(ctx context.Context, pkg string) (*snack.Package, error) { + out, err := run(ctx, []string{"info", pkg}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") { + return nil, fmt.Errorf("snap info %s: %w", pkg, snack.ErrNotFound) + } + return nil, fmt.Errorf("snap info: %w", err) + } + p := parseSnapInfo(out) + if p == nil { + return nil, fmt.Errorf("snap info %s: %w", pkg, snack.ErrNotFound) + } + return p, nil +} + +func isInstalled(ctx context.Context, pkg string) (bool, error) { + c := exec.CommandContext(ctx, "snap", "list", pkg) + err := c.Run() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return false, nil + } + return false, fmt.Errorf("snap isInstalled: %w", err) + } + return true, nil +} + +func version(ctx context.Context, pkg string) (string, error) { + out, err := run(ctx, []string{"list", pkg}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") { + return "", fmt.Errorf("snap version %s: %w", pkg, snack.ErrNotInstalled) + } + return "", fmt.Errorf("snap version: %w", err) + } + pkgs := parseSnapList(out) + if len(pkgs) == 0 { + return "", fmt.Errorf("snap version %s: %w", pkg, snack.ErrNotInstalled) + } + return pkgs[0].Version, nil +} + +func latestVersion(ctx context.Context, pkg string) (string, error) { + out, err := run(ctx, []string{"info", pkg}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") { + return "", fmt.Errorf("snap latestVersion %s: %w", pkg, snack.ErrNotFound) + } + return "", fmt.Errorf("snap latestVersion: %w", err) + } + ver := parseSnapInfoVersion(out) + if ver == "" { + return "", fmt.Errorf("snap latestVersion %s: %w", pkg, snack.ErrNotFound) + } + return ver, nil +} + +func listUpgrades(ctx context.Context) ([]snack.Package, error) { + out, err := run(ctx, []string{"refresh", "--list"}) + if err != nil { + // "All snaps up to date" exits with code 0 on some versions, 1 on others + if strings.Contains(err.Error(), "All snaps up to date") { + return nil, nil + } + return nil, fmt.Errorf("snap listUpgrades: %w", err) + } + return parseSnapRefreshList(out), nil +} + +func upgradeAvailable(ctx context.Context, pkg string) (bool, error) { + upgrades, err := listUpgrades(ctx) + if err != nil { + return false, err + } + for _, u := range upgrades { + if u.Name == pkg { + return true, nil + } + } + return false, nil +} + +func versionCmp(_ context.Context, ver1, ver2 string) (int, error) { + return semverCmp(ver1, ver2), nil +} diff --git a/snap/snap_other.go b/snap/snap_other.go new file mode 100644 index 0000000..861c052 --- /dev/null +++ b/snap/snap_other.go @@ -0,0 +1,67 @@ +//go:build !linux + +package snap + +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 +} + +func latestVersion(_ context.Context, _ string) (string, error) { + return "", snack.ErrUnsupportedPlatform +} + +func listUpgrades(_ context.Context) ([]snack.Package, error) { + return nil, snack.ErrUnsupportedPlatform +} + +func upgradeAvailable(_ context.Context, _ string) (bool, error) { + return false, snack.ErrUnsupportedPlatform +} + +func versionCmp(_ context.Context, _, _ string) (int, error) { + return 0, snack.ErrUnsupportedPlatform +} diff --git a/snap/snap_test.go b/snap/snap_test.go new file mode 100644 index 0000000..845257d --- /dev/null +++ b/snap/snap_test.go @@ -0,0 +1,162 @@ +package snap + +import ( + "testing" + + "github.com/gogrlx/snack" +) + +func TestParseSnapList(t *testing.T) { + input := `Name Version Rev Tracking Publisher Notes +core22 20240111 1122 latest/stable canonical✓ base +firefox 131.0 4647 latest/stable mozilla✓ - +` + pkgs := parseSnapList(input) + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages, got %d", len(pkgs)) + } + if pkgs[0].Name != "core22" || pkgs[0].Version != "20240111" { + t.Errorf("unexpected first package: %+v", pkgs[0]) + } + if pkgs[1].Name != "firefox" || pkgs[1].Version != "131.0" { + t.Errorf("unexpected second package: %+v", pkgs[1]) + } + if !pkgs[0].Installed { + t.Error("expected Installed=true") + } +} + +func TestParseSnapListEmpty(t *testing.T) { + input := `Name Version Rev Tracking Publisher Notes +` + pkgs := parseSnapList(input) + if len(pkgs) != 0 { + t.Fatalf("expected 0 packages, got %d", len(pkgs)) + } +} + +func TestParseSnapFind(t *testing.T) { + input := `Name Version Publisher Notes Summary +firefox 131.0 mozilla✓ - Mozilla Firefox web browser +chromium 129.0 nickvdp - Chromium web browser +` + pkgs := parseSnapFind(input) + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages, got %d", len(pkgs)) + } + if pkgs[0].Name != "firefox" || pkgs[0].Version != "131.0" { + t.Errorf("unexpected first package: %+v", pkgs[0]) + } + if pkgs[0].Description != "Mozilla Firefox web browser" { + t.Errorf("unexpected description: %q", pkgs[0].Description) + } +} + +func TestParseSnapInfo(t *testing.T) { + input := `name: firefox +summary: Mozilla Firefox web browser +publisher: Mozilla✓ (mozilla✓) +snap-id: 3wdHCAVyZEmYsCMFDE9qt92UV8rC8Wdk +installed: 131.0 (4647) 283MB - +` + pkg := parseSnapInfo(input) + if pkg == nil { + t.Fatal("expected non-nil package") + } + if pkg.Name != "firefox" { + t.Errorf("expected name 'firefox', got %q", pkg.Name) + } + if pkg.Version != "131.0" { + t.Errorf("expected version '131.0', got %q", pkg.Version) + } + if pkg.Description != "Mozilla Firefox web browser" { + t.Errorf("unexpected description: %q", pkg.Description) + } + if !pkg.Installed { + t.Error("expected Installed=true") + } +} + +func TestParseSnapInfoEmpty(t *testing.T) { + pkg := parseSnapInfo("") + if pkg != nil { + t.Error("expected nil for empty input") + } +} + +func TestParseSnapInfoVersion(t *testing.T) { + input := `name: firefox +channels: + latest/stable: 131.0 2024-10-01 (4647) 283MB - + latest/candidate: 132.0b5 2024-10-05 (4650) 285MB - + latest/beta: 132.0b5 2024-10-05 (4650) 285MB - + latest/edge: 133.0a1 2024-10-06 (4655) 290MB - +` + ver := parseSnapInfoVersion(input) + if ver != "131.0" { + t.Errorf("expected '131.0', got %q", ver) + } +} + +func TestParseSnapInfoVersionMissing(t *testing.T) { + ver := parseSnapInfoVersion("name: test\n") + if ver != "" { + t.Errorf("expected empty, got %q", ver) + } +} + +func TestParseSnapRefreshList(t *testing.T) { + input := `Name Version Rev Publisher Notes +firefox 132.0 4650 mozilla✓ - +` + pkgs := parseSnapRefreshList(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "firefox" || pkgs[0].Version != "132.0" { + t.Errorf("unexpected package: %+v", pkgs[0]) + } +} + +func TestParseSnapRefreshListUpToDate(t *testing.T) { + input := `Name Version Rev Publisher Notes +All snaps up to date. +` + pkgs := parseSnapRefreshList(input) + if len(pkgs) != 0 { + t.Fatalf("expected 0 packages, got %d", len(pkgs)) + } +} + +func TestSemverCmp(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"1.0.0", "1.0.0", 0}, + {"1.0.0", "2.0.0", -1}, + {"2.0.0", "1.0.0", 1}, + {"1.2.3", "1.2.4", -1}, + {"1.10.0", "1.9.0", 1}, + {"1.0", "1.0.0", 0}, + {"131.0", "132.0", -1}, + } + for _, tt := range tests { + got := semverCmp(tt.a, tt.b) + if got != tt.want { + t.Errorf("semverCmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + } +} + +func TestInterfaceCompliance(t *testing.T) { + var _ snack.Manager = (*Snap)(nil) + var _ snack.VersionQuerier = (*Snap)(nil) +} + +func TestName(t *testing.T) { + s := New() + if s.Name() != "snap" { + t.Errorf("Name() = %q, want %q", s.Name(), "snap") + } +}