Merge branch 'cd/fix-flatpak-build': fix ports/detect build issues

This commit is contained in:
2026-03-06 03:40:37 +00:00
7 changed files with 47 additions and 355 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -1,7 +0,0 @@
{
"id": "641b604334578972",
"workspace_root": "/Users/tai/code/foss/snack",
"neovim_pid": 64655,
"created_at": "2026-03-05T20:14:55.362445-05:00",
"socket_path": "/var/folders/4r/k5jjbjzs2qv3mb4s008xs5500000gn/T/neocrush-501/641b604334578972.sock"
}

3
.gitignore vendored
View File

@@ -1,2 +1,5 @@
coverage.out
coverage.html
.DS_Store
.crush/
AGENTS.md

313
AGENTS.md
View File

@@ -1,313 +0,0 @@
# AGENTS.md - snack
Idiomatic Go wrappers for system package managers. Part of the [grlx](https://github.com/gogrlx/grlx) ecosystem.
## Quick Reference
```bash
# Build
go build ./...
# Unit tests (run anywhere)
go test -race ./...
# Lint
go vet ./...
# Integration tests (require real package manager)
go test -tags integration -v ./apt/ # On Debian/Ubuntu
go test -tags integration -v ./pacman/ # On Arch Linux
go test -tags integration -v ./apk/ # On Alpine
go test -tags integration -v ./dnf/ # On Fedora
# Container-based integration tests (require Docker)
go test -tags containertest -v -timeout 15m .
```
## Project Structure
```
snack/
├── snack.go # Core Manager interface + capability interfaces
├── types.go # Package, Options, Repository types + functional options
├── capabilities.go # GetCapabilities() runtime capability detection
├── errors.go # Sentinel errors (ErrNotFound, ErrPermissionDenied, etc.)
├── mutex.go # Locker type for per-provider mutex
├── target.go # Target helper functions
├── cmd/snack/ # CLI tool (cobra-based)
├── detect/ # Auto-detection of system package manager
│ ├── detect.go # Default(), All(), ByName() - shared logic
│ ├── detect_linux.go # Linux candidate ordering
│ ├── detect_freebsd.go # FreeBSD candidates
│ └── detect_openbsd.go # OpenBSD candidates
├── apt/ # Debian/Ubuntu (apt-get, apt-cache, dpkg-query)
├── apk/ # Alpine Linux
├── pacman/ # Arch Linux
├── dnf/ # Fedora/RHEL (supports dnf4 and dnf5)
├── rpm/ # RPM queries
├── dpkg/ # Low-level dpkg operations
├── flatpak/ # Flatpak
├── snap/ # Snapcraft
├── pkg/ # FreeBSD pkg(8)
├── ports/ # OpenBSD ports
└── aur/ # Arch User Repository (stub)
```
## Architecture Patterns
### Interface Hierarchy
All providers implement `snack.Manager` (the base interface). Extended capabilities are optional:
```go
// Base - every provider
snack.Manager // Install, Remove, Purge, Upgrade, Update, List, Search, Info, IsInstalled, Version
// Optional - use type assertions
snack.VersionQuerier // LatestVersion, ListUpgrades, UpgradeAvailable, VersionCmp
snack.Holder // Hold, Unhold, ListHeld (version pinning)
snack.Cleaner // Autoremove, Clean
snack.FileOwner // FileList, Owner
snack.RepoManager // ListRepos, AddRepo, RemoveRepo
snack.KeyManager // AddKey, RemoveKey, ListKeys
snack.Grouper // GroupList, GroupInfo, GroupInstall
snack.NameNormalizer // NormalizeName, ParseArch
```
Check capabilities at runtime:
```go
caps := snack.GetCapabilities(mgr)
if caps.Hold {
mgr.(snack.Holder).Hold(ctx, []string{"nginx"})
}
```
### Provider File Layout
Each provider follows this pattern:
```
apt/
├── apt.go # Public type + methods (delegates to platform-specific)
├── apt_linux.go # Linux implementation (actual CLI calls)
├── apt_other.go # Stub for non-Linux (returns ErrUnsupportedPlatform)
├── capabilities_linux.go # Extended capability implementations
├── capabilities_other.go # Capability stubs
├── normalize.go # Name normalization helpers
├── parse.go # Output parsing functions
├── apt_test.go # Unit tests (parsing, no system calls)
└── apt_integration_test.go # Integration tests (//go:build integration)
```
### Build Tags
- **No tag**: Unit tests, run anywhere
- `integration`: Tests that require the actual package manager to be installed
- `containertest`: Tests using testcontainers (require Docker)
### Per-Provider Mutex
Each provider embeds `snack.Locker` and calls `Lock()`/`Unlock()` around mutating operations:
```go
type Apt struct {
snack.Locker
}
func (a *Apt) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
a.Lock()
defer a.Unlock()
return install(ctx, pkgs, opts...)
}
```
Different providers can run concurrently (apt + snap), but operations on the same provider serialize.
### Functional Options
Use functional options for operation configuration:
```go
mgr.Install(ctx, snack.Targets("nginx"),
snack.WithSudo(),
snack.WithAssumeYes(),
snack.WithDryRun(),
)
```
Available options: `WithSudo()`, `WithAssumeYes()`, `WithDryRun()`, `WithVerbose()`, `WithRefresh()`, `WithReinstall()`, `WithRoot(path)`, `WithFromRepo(repo)`.
### Target Type
Packages are specified using `snack.Target`:
```go
type Target struct {
Name string // Required
Version string // Optional version constraint
FromRepo string // Optional repository constraint
Source string // Local file path or URL
}
// Convenience constructor for name-only targets
targets := snack.Targets("nginx", "redis", "curl")
```
## Code Conventions
### Interface Compliance
Verify interface compliance at compile time:
```go
var (
_ snack.Manager = (*Apt)(nil)
_ snack.VersionQuerier = (*Apt)(nil)
_ snack.Holder = (*Apt)(nil)
)
```
### Error Handling
Use sentinel errors from `errors.go`:
```go
snack.ErrNotInstalled // Package is not installed
snack.ErrNotFound // Package not found in repos
snack.ErrUnsupportedPlatform // Package manager unavailable
snack.ErrPermissionDenied // Need sudo
snack.ErrManagerNotFound // detect couldn't find a manager
snack.ErrDaemonNotRunning // snapd not running, etc.
```
Wrap with context:
```go
return fmt.Errorf("apt-get install: %w", snack.ErrPermissionDenied)
```
### CLI Output Parsing
Parse functions live in `parse.go` and are thoroughly unit-tested:
```go
// apt/parse.go
func parseList(output string) []snack.Package { ... }
func parseSearch(output string) []snack.Package { ... }
func parseInfo(output string) (*snack.Package, error) { ... }
// apt/apt_test.go
func TestParseList(t *testing.T) {
input := "bash\t5.2-1\tGNU Bourne Again SHell\n..."
pkgs := parseList(input)
// assertions
}
```
### Platform-Specific Stubs
Non-supported platforms return `ErrUnsupportedPlatform`:
```go
//go:build !linux
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
```
## Testing
### Unit Tests
Test parsing logic without system calls:
```go
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)
assert.Len(t, pkgs, 2)
assert.Equal(t, "bash", pkgs[0].Name)
}
```
### Integration Tests
Use `//go:build integration` tag. Test against the real system:
```go
//go:build integration
func TestIntegration_Apt(t *testing.T) {
mgr := apt.New()
if !mgr.Available() {
t.Skip("apt not available")
}
// Install, remove, query packages...
}
```
### Test Assertions
Use testify:
```go
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
require.NoError(t, err) // Fatal on error
assert.True(t, installed) // Continue on failure
assert.Equal(t, "apt", mgr.Name())
```
## CI/CD
GitHub Actions workflow at `.github/workflows/integration.yml`:
- **Unit tests**: Run on ubuntu-latest
- **Integration tests**: Run in native containers (debian, alpine, archlinux, fedora)
- **Coverage**: Uploaded to Codecov
Jobs test against:
- Debian (apt)
- Ubuntu (apt + snap)
- Fedora 39 (dnf4)
- Fedora latest (dnf5)
- Alpine (apk)
- Arch Linux (pacman)
- Ubuntu + Flatpak
## Adding a New Provider
1. Create directory: `newpkg/`
2. Create files following the pattern:
- `newpkg.go` - public interface
- `newpkg_linux.go` - Linux implementation
- `newpkg_other.go` - stubs
- `parse.go` - output parsing
- `newpkg_test.go` - parsing unit tests
- `newpkg_integration_test.go` - integration tests
3. Add compile-time interface checks
4. Register in `detect/detect_linux.go` (or appropriate platform file)
5. Add CI job in `.github/workflows/integration.yml`
## Common Gotchas
1. **Build tags**: Integration tests won't run without `-tags integration`
2. **Sudo**: Most operations need `snack.WithSudo()` when running as non-root
3. **Platform stubs**: Every function in `*_linux.go` needs a stub in `*_other.go`
4. **Mutex**: Always lock around mutating operations
5. **dnf4 vs dnf5**: The dnf package handles both versions with different parsing logic
6. **Architecture suffixes**: apt uses `pkg:amd64` format, use `NameNormalizer` to strip
## Dependencies
Key dependencies (from go.mod):
- `github.com/spf13/cobra` - CLI framework
- `github.com/charmbracelet/fang` - Cobra context wrapper
- `github.com/stretchr/testify` - Test assertions
- `github.com/testcontainers/testcontainers-go` - Container-based integration tests

View File

@@ -1,4 +1,4 @@
//go:build !linux && !freebsd && !openbsd && !darwin
//go:build !linux && !freebsd && !openbsd && !darwin && !windows
package detect

View File

@@ -198,6 +198,49 @@ func removeRepo(ctx context.Context, id string) error {
return err
}
func latestVersion(ctx context.Context, pkg string) (string, error) {
out, err := run(ctx, []string{"remote-info", "flathub", pkg})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return "", fmt.Errorf("flatpak latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return "", fmt.Errorf("flatpak latestVersion: %w", err)
}
p := parseInfo(out)
if p == nil || p.Version == "" {
return "", fmt.Errorf("flatpak latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return p.Version, nil
}
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
out, err := run(ctx, []string{"remote-ls", "--updates", "--columns=name,application,version,origin"})
if err != nil {
if strings.Contains(err.Error(), "No updates") {
return nil, nil
}
return nil, fmt.Errorf("flatpak listUpgrades: %w", err)
}
return parseList(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 || u.Description == pkg {
return true, nil
}
}
return false, nil
}
func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
return semverCmp(ver1, ver2), nil
}
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
o := snack.ApplyOptions(opts...)
var toUpgrade []snack.Target

View File

@@ -78,40 +78,6 @@ func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
}
}
func autoremove(ctx context.Context, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
_, err := runCmd(ctx, "pkg_delete", []string{"-a"}, o)
return err
}
func clean(_ context.Context) error {
// OpenBSD does not maintain a package cache like FreeBSD/apt.
// Downloaded packages are removed after installation by default.
return nil
}
func fileList(ctx context.Context, pkg string) ([]string, error) {
out, err := runCmd(ctx, "pkg_info", []string{"-L", pkg}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return nil, fmt.Errorf("ports fileList %s: %w", pkg, snack.ErrNotInstalled)
}
return nil, fmt.Errorf("ports fileList: %w", err)
}
return parseFileListOutput(out), nil
}
func owner(ctx context.Context, path string) (string, error) {
out, err := runCmd(ctx, "pkg_info", []string{"-E", path}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return "", fmt.Errorf("ports owner %s: %w", path, snack.ErrNotFound)
}
return "", fmt.Errorf("ports owner: %w", err)
}
return parseOwnerOutput(out), nil
}
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
o := snack.ApplyOptions(opts...)
var toUpgrade []snack.Target