diff --git a/apk/capabilities.go b/apk/capabilities.go new file mode 100644 index 0000000..ba67975 --- /dev/null +++ b/apk/capabilities.go @@ -0,0 +1,58 @@ +package apk + +import ( + "context" + + "github.com/gogrlx/snack" +) + +// Compile-time interface checks. +var ( + _ snack.VersionQuerier = (*Apk)(nil) + _ snack.Cleaner = (*Apk)(nil) + _ snack.FileOwner = (*Apk)(nil) +) + +// LatestVersion returns the latest available version from configured repositories. +func (a *Apk) LatestVersion(ctx context.Context, pkg string) (string, error) { + return latestVersion(ctx, pkg) +} + +// ListUpgrades returns packages that have newer versions available. +func (a *Apk) ListUpgrades(ctx context.Context) ([]snack.Package, error) { + return listUpgrades(ctx) +} + +// UpgradeAvailable reports whether a newer version is available. +func (a *Apk) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) { + return upgradeAvailable(ctx, pkg) +} + +// VersionCmp compares two version strings using apk's native comparison. +func (a *Apk) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) { + return versionCmp(ctx, ver1, ver2) +} + +// Autoremove is not directly supported by apk. apk does not track +// "auto-installed" dependencies in a way that allows safe autoremoval. +// This is a no-op that returns nil. +func (a *Apk) Autoremove(ctx context.Context, opts ...snack.Option) error { + return autoremove(ctx, opts...) +} + +// Clean removes cached package files. +func (a *Apk) Clean(ctx context.Context) error { + a.Lock() + defer a.Unlock() + return clean(ctx) +} + +// FileList returns all files installed by a package. +func (a *Apk) FileList(ctx context.Context, pkg string) ([]string, error) { + return fileList(ctx, pkg) +} + +// Owner returns the package that owns a given file path. +func (a *Apk) Owner(ctx context.Context, path string) (string, error) { + return owner(ctx, path) +} diff --git a/apk/capabilities_linux.go b/apk/capabilities_linux.go new file mode 100644 index 0000000..3d9cc23 --- /dev/null +++ b/apk/capabilities_linux.go @@ -0,0 +1,177 @@ +//go:build linux + +package apk + +import ( + "context" + "fmt" + "os/exec" + "strings" + + "github.com/gogrlx/snack" +) + +func latestVersion(ctx context.Context, pkg string) (string, error) { + // Use `apk policy` to get available versions + c := exec.CommandContext(ctx, "apk", "policy", pkg) + out, err := c.CombinedOutput() + if err != nil { + return "", fmt.Errorf("apk latestVersion %s: %w", pkg, snack.ErrNotFound) + } + // Output format: + // pkg-1.2.3-r0: + // lib/apk/db/installed + // http://... + // First line contains the version + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) == 0 { + return "", fmt.Errorf("apk latestVersion %s: %w", pkg, snack.ErrNotFound) + } + // First line: "pkg-1.2.3-r0:" + first := strings.TrimSuffix(strings.TrimSpace(lines[0]), ":") + _, ver := splitNameVersion(first) + if ver == "" { + return "", fmt.Errorf("apk latestVersion %s: %w", pkg, snack.ErrNotFound) + } + return ver, nil +} + +func listUpgrades(ctx context.Context) ([]snack.Package, error) { + c := exec.CommandContext(ctx, "apk", "upgrade", "--simulate") + out, err := c.CombinedOutput() + if err != nil { + // If simulate fails, it could mean no upgrades or an error + outStr := strings.TrimSpace(string(out)) + if outStr == "" || strings.Contains(outStr, "OK") { + return nil, nil + } + return nil, fmt.Errorf("apk listUpgrades: %w", err) + } + return parseUpgradeSimulation(string(out)), nil +} + +// parseUpgradeSimulation parses `apk upgrade --simulate` output. +// Lines look like: "(1/3) Upgrading pkg (oldver -> newver)" +func parseUpgradeSimulation(output string) []snack.Package { + var pkgs []snack.Package + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if !strings.Contains(line, "Upgrading") { + continue + } + // "(1/3) Upgrading pkg (oldver -> newver)" + idx := strings.Index(line, "Upgrading ") + if idx < 0 { + continue + } + rest := line[idx+len("Upgrading "):] + // "pkg (oldver -> newver)" + parts := strings.SplitN(rest, " (", 2) + if len(parts) < 1 { + continue + } + name := strings.TrimSpace(parts[0]) + var ver string + if len(parts) == 2 { + // "oldver -> newver)" + verPart := strings.TrimSuffix(parts[1], ")") + arrow := strings.Split(verPart, " -> ") + if len(arrow) == 2 { + ver = strings.TrimSpace(arrow[1]) + } + } + pkgs = append(pkgs, snack.Package{ + Name: name, + Version: ver, + Installed: true, + }) + } + return pkgs +} + +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(ctx context.Context, ver1, ver2 string) (int, error) { + c := exec.CommandContext(ctx, "apk", "version", "-t", ver1, ver2) + out, err := c.CombinedOutput() + if err != nil { + return 0, fmt.Errorf("apk versionCmp: %w", err) + } + result := strings.TrimSpace(string(out)) + switch result { + case "<": + return -1, nil + case ">": + return 1, nil + case "=": + return 0, nil + default: + return 0, fmt.Errorf("apk versionCmp: unexpected output %q", result) + } +} + +// autoremove is a no-op for apk. Alpine's apk does not have a direct +// autoremove equivalent. `apk fix` repairs packages but does not remove +// unused dependencies. +func autoremove(_ context.Context, _ ...snack.Option) error { + return nil +} + +func clean(ctx context.Context) error { + c := exec.CommandContext(ctx, "apk", "cache", "clean") + out, err := c.CombinedOutput() + if err != nil { + return fmt.Errorf("apk clean: %s: %w", strings.TrimSpace(string(out)), err) + } + return nil +} + +func fileList(ctx context.Context, pkg string) ([]string, error) { + c := exec.CommandContext(ctx, "apk", "info", "-L", pkg) + out, err := c.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("apk fileList %s: %w", pkg, snack.ErrNotInstalled) + } + var files []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // First line is "pkg-version contains:" — skip it + if strings.Contains(line, " contains:") { + continue + } + files = append(files, line) + } + return files, nil +} + +func owner(ctx context.Context, path string) (string, error) { + c := exec.CommandContext(ctx, "apk", "info", "--who-owns", path) + out, err := c.CombinedOutput() + if err != nil { + return "", fmt.Errorf("apk owner %s: %w", path, snack.ErrNotFound) + } + // Output: "/path is owned by pkg-version" + outStr := strings.TrimSpace(string(out)) + if idx := strings.Index(outStr, "is owned by "); idx != -1 { + nameVer := strings.TrimSpace(outStr[idx+len("is owned by "):]) + name, _ := splitNameVersion(nameVer) + if name != "" { + return name, nil + } + } + return "", fmt.Errorf("apk owner %s: unexpected output %q", path, outStr) +} diff --git a/apk/capabilities_other.go b/apk/capabilities_other.go new file mode 100644 index 0000000..e290df5 --- /dev/null +++ b/apk/capabilities_other.go @@ -0,0 +1,41 @@ +//go:build !linux + +package apk + +import ( + "context" + + "github.com/gogrlx/snack" +) + +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 +} + +func autoremove(_ context.Context, _ ...snack.Option) error { + return snack.ErrUnsupportedPlatform +} + +func clean(_ context.Context) error { + return snack.ErrUnsupportedPlatform +} + +func fileList(_ context.Context, _ string) ([]string, error) { + return nil, snack.ErrUnsupportedPlatform +} + +func owner(_ context.Context, _ string) (string, error) { + return "", snack.ErrUnsupportedPlatform +} diff --git a/pacman/capabilities.go b/pacman/capabilities.go new file mode 100644 index 0000000..cd71012 --- /dev/null +++ b/pacman/capabilities.go @@ -0,0 +1,80 @@ +package pacman + +import ( + "context" + + "github.com/gogrlx/snack" +) + +// Compile-time interface checks. +var ( + _ snack.VersionQuerier = (*Pacman)(nil) + _ snack.Cleaner = (*Pacman)(nil) + _ snack.FileOwner = (*Pacman)(nil) + _ snack.Grouper = (*Pacman)(nil) +) + +// NOTE: snack.Holder is not implemented for pacman. While pacman supports +// IgnorePkg in /etc/pacman.conf, there is no clean CLI command to hold/unhold +// packages. pacman-contrib provides some tooling but it's not standard. + +// LatestVersion returns the latest available version from configured repositories. +func (p *Pacman) LatestVersion(ctx context.Context, pkg string) (string, error) { + return latestVersion(ctx, pkg) +} + +// ListUpgrades returns packages that have newer versions available. +func (p *Pacman) ListUpgrades(ctx context.Context) ([]snack.Package, error) { + return listUpgrades(ctx) +} + +// UpgradeAvailable reports whether a newer version is available. +func (p *Pacman) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) { + return upgradeAvailable(ctx, pkg) +} + +// VersionCmp compares two version strings using pacman's vercmp. +func (p *Pacman) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) { + return versionCmp(ctx, ver1, ver2) +} + +// Autoremove removes orphaned packages. +func (p *Pacman) Autoremove(ctx context.Context, opts ...snack.Option) error { + p.Lock() + defer p.Unlock() + return autoremove(ctx, opts...) +} + +// Clean removes cached package files. +func (p *Pacman) Clean(ctx context.Context) error { + p.Lock() + defer p.Unlock() + return clean(ctx) +} + +// FileList returns all files installed by a package. +func (p *Pacman) FileList(ctx context.Context, pkg string) ([]string, error) { + return fileList(ctx, pkg) +} + +// Owner returns the package that owns a given file path. +func (p *Pacman) Owner(ctx context.Context, path string) (string, error) { + return owner(ctx, path) +} + +// GroupList returns all available package groups. +func (p *Pacman) GroupList(ctx context.Context) ([]string, error) { + return groupList(ctx) +} + +// GroupInfo returns the packages in a group. +func (p *Pacman) GroupInfo(ctx context.Context, group string) ([]snack.Package, error) { + return groupInfo(ctx, group) +} + +// GroupInstall installs all packages in a group. +func (p *Pacman) GroupInstall(ctx context.Context, group string, opts ...snack.Option) error { + p.Lock() + defer p.Unlock() + return groupInstall(ctx, group, opts...) +} diff --git a/pacman/capabilities_linux.go b/pacman/capabilities_linux.go new file mode 100644 index 0000000..3a35c89 --- /dev/null +++ b/pacman/capabilities_linux.go @@ -0,0 +1,221 @@ +//go:build linux + +package pacman + +import ( + "context" + "fmt" + "os/exec" + "strconv" + "strings" + + "github.com/gogrlx/snack" +) + +func latestVersion(ctx context.Context, pkg string) (string, error) { + out, err := run(ctx, []string{"-Si", pkg}, snack.Options{}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") { + return "", fmt.Errorf("pacman latestVersion %s: %w", pkg, snack.ErrNotFound) + } + return "", fmt.Errorf("pacman latestVersion: %w", err) + } + p := parseInfo(out) + if p == nil || p.Version == "" { + return "", fmt.Errorf("pacman latestVersion %s: %w", pkg, snack.ErrNotFound) + } + return p.Version, nil +} + +func listUpgrades(ctx context.Context) ([]snack.Package, error) { + out, err := run(ctx, []string{"-Qu"}, snack.Options{}) + if err != nil { + // exit status 1 means no upgrades available + if strings.Contains(err.Error(), "exit status 1") { + return nil, nil + } + return nil, fmt.Errorf("pacman listUpgrades: %w", err) + } + return parseUpgrades(out), nil +} + +// parseUpgrades parses `pacman -Qu` output. +// Format: "pkg oldver -> newver" +func parseUpgrades(output string) []snack.Package { + var pkgs []snack.Package + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // "pkg oldver -> newver" + parts := strings.Fields(line) + if len(parts) >= 4 && parts[2] == "->" { + pkgs = append(pkgs, snack.Package{ + Name: parts[0], + Version: parts[3], + Installed: true, + }) + } else if len(parts) >= 2 { + // fallback: "pkg newver" + pkgs = append(pkgs, snack.Package{ + Name: parts[0], + Version: parts[1], + Installed: true, + }) + } + } + return pkgs +} + +func upgradeAvailable(ctx context.Context, pkg string) (bool, error) { + _, err := run(ctx, []string{"-Qu", pkg}, snack.Options{}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") { + return false, nil + } + return false, fmt.Errorf("pacman upgradeAvailable: %w", err) + } + return true, nil +} + +func versionCmp(ctx context.Context, ver1, ver2 string) (int, error) { + c := exec.CommandContext(ctx, "vercmp", ver1, ver2) + out, err := c.Output() + if err != nil { + return 0, fmt.Errorf("vercmp: %w", err) + } + n, err := strconv.Atoi(strings.TrimSpace(string(out))) + if err != nil { + return 0, fmt.Errorf("vercmp: unexpected output %q: %w", string(out), err) + } + // Normalize to -1, 0, 1 + switch { + case n < 0: + return -1, nil + case n > 0: + return 1, nil + default: + return 0, nil + } +} + +func autoremove(ctx context.Context, opts ...snack.Option) error { + o := snack.ApplyOptions(opts...) + + // First get list of orphans + orphans, err := run(ctx, []string{"-Qdtq"}, snack.Options{}) + if err != nil { + // exit status 1 means no orphans + if strings.Contains(err.Error(), "exit status 1") { + return nil + } + return fmt.Errorf("pacman autoremove: %w", err) + } + orphans = strings.TrimSpace(orphans) + if orphans == "" { + return nil + } + + pkgs := strings.Fields(orphans) + args := append([]string{"-Rns", "--noconfirm"}, pkgs...) + _, err = run(ctx, args, o) + return err +} + +func clean(ctx context.Context) error { + _, err := run(ctx, []string{"-Sc", "--noconfirm"}, snack.Options{}) + return err +} + +func fileList(ctx context.Context, pkg string) ([]string, error) { + out, err := run(ctx, []string{"-Ql", pkg}, snack.Options{}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") { + return nil, fmt.Errorf("pacman fileList %s: %w", pkg, snack.ErrNotInstalled) + } + return nil, fmt.Errorf("pacman fileList: %w", err) + } + var files []string + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // "pkg /path/to/file" + parts := strings.SplitN(line, " ", 2) + if len(parts) == 2 { + files = append(files, strings.TrimSpace(parts[1])) + } + } + return files, nil +} + +func owner(ctx context.Context, path string) (string, error) { + out, err := run(ctx, []string{"-Qo", path}, snack.Options{}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") || strings.Contains(err.Error(), "No package owns") { + return "", fmt.Errorf("pacman owner %s: %w", path, snack.ErrNotFound) + } + return "", fmt.Errorf("pacman owner: %w", err) + } + // Output: "/path is owned by pkg ver" + out = strings.TrimSpace(out) + if idx := strings.Index(out, "is owned by "); idx != -1 { + remainder := out[idx+len("is owned by "):] + parts := strings.Fields(remainder) + if len(parts) >= 1 { + return parts[0], nil + } + } + return "", fmt.Errorf("pacman owner %s: unexpected output %q", path, out) +} + +func groupList(ctx context.Context) ([]string, error) { + out, err := run(ctx, []string{"-Sg"}, snack.Options{}) + if err != nil { + return nil, fmt.Errorf("pacman groupList: %w", err) + } + seen := make(map[string]struct{}) + var groups []string + for _, line := range strings.Split(out, "\n") { + parts := strings.Fields(line) + if len(parts) >= 1 { + g := parts[0] + if _, ok := seen[g]; !ok { + seen[g] = struct{}{} + groups = append(groups, g) + } + } + } + return groups, nil +} + +func groupInfo(ctx context.Context, group string) ([]snack.Package, error) { + out, err := run(ctx, []string{"-Sg", group}, snack.Options{}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") { + return nil, fmt.Errorf("pacman groupInfo %s: %w", group, snack.ErrNotFound) + } + return nil, fmt.Errorf("pacman groupInfo: %w", err) + } + var pkgs []snack.Package + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // "group pkg" + parts := strings.Fields(line) + if len(parts) >= 2 { + pkgs = append(pkgs, snack.Package{Name: parts[1]}) + } + } + return pkgs, nil +} + +func groupInstall(ctx context.Context, group string, opts ...snack.Option) error { + o := snack.ApplyOptions(opts...) + _, err := run(ctx, []string{"-S", "--noconfirm", group}, o) + return err +} diff --git a/pacman/capabilities_other.go b/pacman/capabilities_other.go new file mode 100644 index 0000000..08831a2 --- /dev/null +++ b/pacman/capabilities_other.go @@ -0,0 +1,53 @@ +//go:build !linux + +package pacman + +import ( + "context" + + "github.com/gogrlx/snack" +) + +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 +} + +func autoremove(_ context.Context, _ ...snack.Option) error { + return snack.ErrUnsupportedPlatform +} + +func clean(_ context.Context) error { + return snack.ErrUnsupportedPlatform +} + +func fileList(_ context.Context, _ string) ([]string, error) { + return nil, snack.ErrUnsupportedPlatform +} + +func owner(_ context.Context, _ string) (string, error) { + return "", snack.ErrUnsupportedPlatform +} + +func groupList(_ context.Context) ([]string, error) { + return nil, snack.ErrUnsupportedPlatform +} + +func groupInfo(_ context.Context, _ string) ([]snack.Package, error) { + return nil, snack.ErrUnsupportedPlatform +} + +func groupInstall(_ context.Context, _ string, _ ...snack.Option) error { + return snack.ErrUnsupportedPlatform +}