mirror of
https://github.com/gogrlx/snack.git
synced 2026-04-01 20:58:42 -07:00
Merge branch 'cd/fix-flatpak-build': fix ports/detect build issues
This commit is contained in:
@@ -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
3
.gitignore
vendored
@@ -1,2 +1,5 @@
|
||||
coverage.out
|
||||
coverage.html
|
||||
.DS_Store
|
||||
.crush/
|
||||
AGENTS.md
|
||||
|
||||
313
AGENTS.md
313
AGENTS.md
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !linux && !freebsd && !openbsd && !darwin
|
||||
//go:build !linux && !freebsd && !openbsd && !darwin && !windows
|
||||
|
||||
package detect
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user