mirror of
https://github.com/gogrlx/snack.git
synced 2026-04-02 05:08: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.out
|
||||||
coverage.html
|
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
|
package detect
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,49 @@ func removeRepo(ctx context.Context, id string) error {
|
|||||||
return err
|
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) {
|
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||||
o := snack.ApplyOptions(opts...)
|
o := snack.ApplyOptions(opts...)
|
||||||
var toUpgrade []snack.Target
|
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) {
|
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||||
o := snack.ApplyOptions(opts...)
|
o := snack.ApplyOptions(opts...)
|
||||||
var toUpgrade []snack.Target
|
var toUpgrade []snack.Target
|
||||||
|
|||||||
Reference in New Issue
Block a user