Merge pull request #8 from gogrlx/cd/flatpak-snap

feat: add flatpak and snap package manager implementations
This commit is contained in:
2026-02-25 17:28:52 -05:00
committed by GitHub
12 changed files with 1306 additions and 2 deletions

44
flatpak/capabilities.go Normal file
View File

@@ -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)
}

View File

@@ -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)

151
flatpak/flatpak_linux.go Normal file
View File

@@ -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
}

63
flatpak/flatpak_other.go Normal file
View File

@@ -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
}

127
flatpak/flatpak_test.go Normal file
View File

@@ -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")
}
}

129
flatpak/parse.go Normal file
View File

@@ -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 <query> --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 <pkg>` 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
}

30
snap/capabilities.go Normal file
View File

@@ -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)
}

183
snap/parse.go Normal file
View File

@@ -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 <query>` 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 <pkg>` 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
}

View File

@@ -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)

182
snap/snap_linux.go Normal file
View File

@@ -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
}

67
snap/snap_other.go Normal file
View File

@@ -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
}

162
snap/snap_test.go Normal file
View File

@@ -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")
}
}