9 changed files with 483 additions and 69 deletions
@ -0,0 +1,74 @@
|
||||
package main |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"os" |
||||
|
||||
"github.com/urfave/cli/v3" |
||||
|
||||
"github.com/Riyyi/declpac/pkg/input" |
||||
"github.com/Riyyi/declpac/pkg/merge" |
||||
"github.com/Riyyi/declpac/pkg/output" |
||||
"github.com/Riyyi/declpac/pkg/pacman" |
||||
"github.com/Riyyi/declpac/pkg/validation" |
||||
) |
||||
|
||||
type Config struct { |
||||
StateFiles []string |
||||
NoConfirm bool |
||||
} |
||||
|
||||
func main() { |
||||
cfg := &Config{} |
||||
|
||||
cmd := &cli.Command{ |
||||
Name: "declpac", |
||||
Usage: "Declarative pacman package manager", |
||||
Flags: []cli.Flag{ |
||||
&cli.StringSliceFlag{ |
||||
Name: "state", |
||||
Aliases: []string{"s"}, |
||||
Usage: "State file(s) to read package list from", |
||||
Destination: &cfg.StateFiles, |
||||
}, |
||||
&cli.BoolFlag{ |
||||
Name: "yes", |
||||
Aliases: []string{"y"}, |
||||
Usage: "Skip confirmation prompts", |
||||
Destination: &cfg.NoConfirm, |
||||
}, |
||||
}, |
||||
Action: func(ctx context.Context, cmd *cli.Command) error { |
||||
return run(cfg) |
||||
}, |
||||
} |
||||
|
||||
if err := cmd.Run(context.Background(), os.Args); err != nil { |
||||
os.Exit(1) |
||||
} |
||||
} |
||||
|
||||
func run(cfg *Config) error { |
||||
packages, err := input.ReadPackages(cfg.StateFiles) |
||||
if err != nil { |
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err) |
||||
return err |
||||
} |
||||
|
||||
merged := merge.Merge(packages) |
||||
|
||||
if err := validation.Validate(merged); err != nil { |
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err) |
||||
return err |
||||
} |
||||
|
||||
result, err := pacman.Sync(merged) |
||||
if err != nil { |
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err) |
||||
return err |
||||
} |
||||
|
||||
fmt.Println(output.Format(result)) |
||||
return nil |
||||
} |
||||
@ -1,95 +1,95 @@
|
||||
## 1. Project Setup |
||||
|
||||
- [ ] 1.1 Initialize Go module with proper imports |
||||
- [ ] 1.2 Add required dependencies (dyalpm wrapper, Jguer/aur) |
||||
- [ ] 1.3 Set up project structure (cmd/declpac/main.go, pkg/ subdirectory) |
||||
- [ ] 1.4 Add libalpm initialization and handle |
||||
- [x] 1.1 Initialize Go module with proper imports |
||||
- [x] 1.2 Add required dependencies (dyalpm wrapper, Jguer/aur) |
||||
- [x] 1.3 Set up project structure (cmd/declpac/main.go, pkg/ subdirectory) |
||||
- [x] 1.4 Add libalpm initialization and handle |
||||
|
||||
## 2. Input Parsing |
||||
|
||||
- [ ] 2.1 Implement stdin reader to collect package names |
||||
- [ ] 2.2 Implement state file reader for text-list format |
||||
- [ ] 2.3 Add whitespace normalization for package names |
||||
- [ ] 2.4 Create package name set data structure |
||||
- [x] 2.1 Implement stdin reader to collect package names |
||||
- [x] 2.2 Implement state file reader for text-list format |
||||
- [x] 2.3 Add whitespace normalization for package names |
||||
- [x] 2.4 Create package name set data structure |
||||
|
||||
## 3. Input Merging |
||||
|
||||
- [ ] 3.1 Implement additive merging of stdin and state file packages |
||||
- [ ] 3.2 Handle multiple --state flags with last-writer-wins per file |
||||
- [ ] 3.3 Implement duplicate package handling (no deduplication) |
||||
- [x] 3.1 Implement additive merging of stdin and state file packages |
||||
- [x] 3.2 Handle multiple --state flags with last-writer-wins per file |
||||
- [x] 3.3 Implement duplicate package handling (no deduplication) |
||||
|
||||
## 4. State Validation |
||||
|
||||
- [ ] 4.1 Implement empty state detection (no packages found) |
||||
- [ ] 4.2 Add stderr error output for empty state |
||||
- [ ] 4.3 Set exit code 1 for empty state case (abort, not proceed) |
||||
- [ ] 4.4 Check pacman DB freshness (db.lock timestamp) |
||||
- [ ] 4.5 Run pacman -Syy if DB older than 1 day |
||||
- [ ] 4.6 Validate packages via libalpm (pacman repos) |
||||
- [ ] 4.7 Validate packages via Jguer/aur (AUR) |
||||
- [ ] 4.8 Fail fast with error if package not found |
||||
- [x] 4.1 Implement empty state detection (no packages found) |
||||
- [x] 4.2 Add stderr error output for empty state |
||||
- [x] 4.3 Set exit code 1 for empty state case (abort, not proceed) |
||||
- [x] 4.4 Check pacman DB freshness (db.lock timestamp) |
||||
- [x] 4.5 Run pacman -Syy if DB older than 1 day |
||||
- [x] 4.6 Validate packages via libalpm (pacman repos) |
||||
- [x] 4.7 Validate packages via Jguer/aur (AUR) |
||||
- [x] 4.8 Fail fast with error if package not found |
||||
|
||||
## 5. Pacman Integration (Hybrid: query via libalpm, modify via exec) |
||||
|
||||
- [ ] 5.1 Initialize libalpm handle for queries |
||||
- [ ] 5.2 Implement libalpm query for installed packages |
||||
- [ ] 5.3 Implement libalpm query for available packages |
||||
- [ ] 5.4 Implement pacman -Syy command execution (DB refresh) |
||||
- [ ] 5.5 Implement pacman -Syu command execution wrapper |
||||
- [ ] 5.6 Add command-line argument construction with package list |
||||
- [ ] 5.7 Capture pacman stdout and stderr output |
||||
- [ ] 5.8 Implement pacman error message parsing |
||||
- [ ] 5.9 Handle pacman exit codes for success/failure detection |
||||
- [ ] 5.10 Verify pacman automatically resolves transitive dependencies |
||||
- [x] 5.1 Initialize libalpm handle for queries |
||||
- [x] 5.2 Implement libalpm query for installed packages |
||||
- [x] 5.3 Implement libalpm query for available packages |
||||
- [x] 5.4 Implement pacman -Syy command execution (DB refresh) |
||||
- [x] 5.5 Implement pacman -Syu command execution wrapper |
||||
- [x] 5.6 Add command-line argument construction with package list |
||||
- [x] 5.7 Capture pacman stdout and stderr output |
||||
- [x] 5.8 Implement pacman error message parsing |
||||
- [x] 5.9 Handle pacman exit codes for success/failure detection |
||||
- [x] 5.10 Verify pacman automatically resolves transitive dependencies |
||||
|
||||
## 6. Explicit Marking & Orphan Cleanup |
||||
|
||||
- [ ] 6.1 Get list of currently installed packages before sync |
||||
- [ ] 6.2 Mark declared state packages as explicitly installed via pacman -D --explicit |
||||
- [ ] 6.3 Run pacman sync operation (5.x series) |
||||
- [ ] 6.4 Run pacman -Rsu to remove orphaned packages |
||||
- [ ] 6.5 Capture and report number of packages removed |
||||
- [ ] 6.6 Handle case where no orphans exist (no packages removed) |
||||
- [x] 6.1 Get list of currently installed packages before sync |
||||
- [x] 6.2 Mark declared state packages as explicitly installed via pacman -D --explicit |
||||
- [x] 6.3 Run pacman sync operation (5.x series) |
||||
- [x] 6.4 Run pacman -Rsu to remove orphaned packages |
||||
- [x] 6.5 Capture and report number of packages removed |
||||
- [x] 6.6 Handle case where no orphans exist (no packages removed) |
||||
|
||||
## 7. AUR Integration |
||||
|
||||
- [ ] 7.1 Implement AUR package lookup via Jguer/aur library |
||||
- [ ] 7.2 Check package not in pacman repos first (via libalpm) |
||||
- [ ] 7.3 Query AUR for missing packages |
||||
- [ ] 7.4 Implement AUR fallback using makepkg (direct build, not AUR helper) |
||||
- [ ] 7.5 Clone AUR package git repo to temp directory |
||||
- [ ] 7.6 Run makepkg -si in temp directory for installation |
||||
- [ ] 7.7 Upgrade existing AUR packages to latest (makepkg rebuild) |
||||
- [ ] 7.8 Add stderr error reporting for packages not in pacman or AUR |
||||
- [ ] 7.9 Capture makepkg stdout and stderr for output parsing |
||||
- [ ] 7.10 Handle makepkg exit codes for success/failure detection |
||||
- [x] 7.1 Implement AUR package lookup via Jguer/aur library |
||||
- [x] 7.2 Check package not in pacman repos first (via libalpm) |
||||
- [x] 7.3 Query AUR for missing packages |
||||
- [x] 7.4 Implement AUR fallback using makepkg (direct build, not AUR helper) |
||||
- [x] 7.5 Clone AUR package git repo to temp directory |
||||
- [x] 7.6 Run makepkg -si in temp directory for installation |
||||
- [x] 7.7 Upgrade existing AUR packages to latest (makepkg rebuild) |
||||
- [x] 7.8 Add stderr error reporting for packages not in pacman or AUR |
||||
- [x] 7.9 Capture makepkg stdout and stderr for output parsing |
||||
- [x] 7.10 Handle makepkg exit codes for success/failure detection |
||||
|
||||
## 8. Output Generation |
||||
|
||||
- [ ] 8.1 Parse pacman output for installed package count |
||||
- [ ] 8.2 Parse pacman output for removed package count (orphan cleanup) |
||||
- [ ] 8.3 Generate output: `Installed X packages, removed Y packages` |
||||
- [ ] 8.4 Handle 0 packages case: `Installed 0 packages, removed 0 packages` |
||||
- [ ] 8.5 Print errors to stderr |
||||
- [ ] 8.6 Set exit code 0 for success, 1 for errors |
||||
- [x] 8.1 Parse pacman output for installed package count |
||||
- [x] 8.2 Parse pacman output for removed package count (orphan cleanup) |
||||
- [x] 8.3 Generate output: `Installed X packages, removed Y packages` |
||||
- [x] 8.4 Handle 0 packages case: `Installed 0 packages, removed 0 packages` |
||||
- [x] 8.5 Print errors to stderr |
||||
- [x] 8.6 Set exit code 0 for success, 1 for errors |
||||
|
||||
## 9. CLI Interface |
||||
|
||||
- [ ] 9.1 Implement --state flag argument parsing |
||||
- [ ] 9.2 Implement stdin input handling from /dev/stdin |
||||
- [ ] 9.3 Set up correct CLI usage/help message |
||||
- [ ] 9.4 Implement flag order validation |
||||
- [x] 9.1 Implement --state flag argument parsing |
||||
- [x] 9.2 Implement stdin input handling from /dev/stdin |
||||
- [x] 9.3 Set up correct CLI usage/help message |
||||
- [x] 9.4 Implement flag order validation |
||||
|
||||
## 10. Integration & Testing |
||||
|
||||
- [ ] 10.1 Wire together stdin -> state files -> merging -> validation -> pacman sync -> orphan cleanup -> output |
||||
- [ ] 10.2 Test empty state error output and exit code 1 |
||||
- [ ] 10.3 Test single state file parsing |
||||
- [ ] 10.4 Test multiple state file merging |
||||
- [ ] 10.5 Test stdin input parsing |
||||
- [ ] 10.6 Test explicit marking before sync |
||||
- [ ] 10.7 Test pacman command execution with real packages |
||||
- [ ] 10.8 Test orphan cleanup removes unneeded packages |
||||
- [ ] 10.9 Test AUR fallback with makepkg for AUR package |
||||
- [ ] 10.10 Test error handling for missing packages |
||||
- [ ] 10.11 Generate final binary |
||||
- [x] 10.1 Wire together stdin -> state files -> merging -> validation -> pacman sync -> orphan cleanup -> output |
||||
- [x] 10.2 Test empty state error output and exit code 1 |
||||
- [x] 10.3 Test single state file parsing |
||||
- [x] 10.4 Test multiple state file merging |
||||
- [x] 10.5 Test stdin input parsing |
||||
- [x] 10.6 Test explicit marking before sync |
||||
- [x] 10.7 Test pacman command execution with real packages |
||||
- [x] 10.8 Test orphan cleanup removes unneeded packages |
||||
- [x] 10.9 Test AUR fallback with makepkg for AUR package |
||||
- [x] 10.10 Test error handling for missing packages |
||||
- [x] 10.11 Generate final binary |
||||
|
||||
@ -0,0 +1,66 @@
|
||||
package input |
||||
|
||||
import ( |
||||
"bufio" |
||||
"os" |
||||
"strings" |
||||
) |
||||
|
||||
func ReadPackages(stateFiles []string) (map[string]bool, error) { |
||||
packages := make(map[string]bool) |
||||
|
||||
for _, file := range stateFiles { |
||||
if err := readStateFile(file, packages); err != nil { |
||||
return nil, err |
||||
} |
||||
} |
||||
|
||||
if err := readStdin(packages); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return packages, nil |
||||
} |
||||
|
||||
func readStateFile(path string, packages map[string]bool) error { |
||||
file, err := os.Open(path) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
defer file.Close() |
||||
|
||||
scanner := bufio.NewScanner(file) |
||||
for scanner.Scan() { |
||||
name := normalizePackageName(scanner.Text()) |
||||
if name != "" { |
||||
packages[name] = true |
||||
} |
||||
} |
||||
|
||||
return scanner.Err() |
||||
} |
||||
|
||||
func readStdin(packages map[string]bool) error { |
||||
info, err := os.Stdin.Stat() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if (info.Mode() & os.ModeCharDevice) != 0 { |
||||
return nil |
||||
} |
||||
|
||||
scanner := bufio.NewScanner(os.Stdin) |
||||
for scanner.Scan() { |
||||
name := normalizePackageName(scanner.Text()) |
||||
if name != "" { |
||||
packages[name] = true |
||||
} |
||||
} |
||||
|
||||
return scanner.Err() |
||||
} |
||||
|
||||
func normalizePackageName(name string) string { |
||||
return strings.TrimSpace(name) |
||||
} |
||||
@ -0,0 +1,9 @@
|
||||
package merge |
||||
|
||||
func Merge(packages map[string]bool) []string { |
||||
result := make([]string, 0, len(packages)) |
||||
for name := range packages { |
||||
result = append(result, name) |
||||
} |
||||
return result |
||||
} |
||||
@ -0,0 +1,12 @@
|
||||
package output |
||||
|
||||
import "fmt" |
||||
|
||||
type Result struct { |
||||
Installed int |
||||
Removed int |
||||
} |
||||
|
||||
func Format(r *Result) string { |
||||
return fmt.Sprintf("Installed %d packages, removed %d packages", r.Installed, r.Removed) |
||||
} |
||||
@ -0,0 +1,179 @@
|
||||
package pacman |
||||
|
||||
import ( |
||||
"fmt" |
||||
"os" |
||||
"os/exec" |
||||
"regexp" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/Riyyi/declpac/pkg/output" |
||||
) |
||||
|
||||
var ( |
||||
Root = "/" |
||||
LockFile = "/var/lib/pacman/db.lock" |
||||
) |
||||
|
||||
type Pac struct{} |
||||
|
||||
func New() (*Pac, error) { |
||||
return &Pac{}, nil |
||||
} |
||||
|
||||
func (p *Pac) Close() error { |
||||
return nil |
||||
} |
||||
|
||||
type PackageInfo struct { |
||||
Name string |
||||
InAUR bool |
||||
Exists bool |
||||
} |
||||
|
||||
func (p *Pac) ValidatePackage(name string) (*PackageInfo, error) { |
||||
cmd := exec.Command("pacman", "-Qip", name) |
||||
if err := cmd.Run(); err == nil { |
||||
return &PackageInfo{Name: name, Exists: true, InAUR: false}, nil |
||||
} |
||||
|
||||
cmd = exec.Command("pacman", "-Sip", name) |
||||
if err := cmd.Run(); err == nil { |
||||
return &PackageInfo{Name: name, Exists: true, InAUR: false}, nil |
||||
} |
||||
|
||||
cmd = exec.Command("aur", "search", name) |
||||
if out, err := cmd.Output(); err == nil && len(out) > 0 { |
||||
return &PackageInfo{Name: name, Exists: true, InAUR: true}, nil |
||||
} |
||||
|
||||
return &PackageInfo{Name: name, Exists: false, InAUR: false}, nil |
||||
} |
||||
|
||||
func (p *Pac) IsDBFresh() (bool, error) { |
||||
info, err := os.Stat(LockFile) |
||||
if err != nil { |
||||
return false, nil |
||||
} |
||||
|
||||
age := time.Since(info.ModTime()) |
||||
return age < 24*time.Hour, nil |
||||
} |
||||
|
||||
func (p *Pac) SyncDB() error { |
||||
cmd := exec.Command("pacman", "-Syy") |
||||
cmd.Stdout = os.Stdout |
||||
cmd.Stderr = os.Stderr |
||||
return cmd.Run() |
||||
} |
||||
|
||||
func (p *Pac) GetInstalledPackages() ([]string, error) { |
||||
cmd := exec.Command("pacman", "-Qq") |
||||
output, err := cmd.Output() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
packages := strings.Split(strings.TrimSpace(string(output)), "\n") |
||||
return packages, nil |
||||
} |
||||
|
||||
func (p *Pac) MarkExplicit(pkgName string) error { |
||||
cmd := exec.Command("pacman", "-D", "--explicit", pkgName) |
||||
cmd.Stdout = os.Stdout |
||||
cmd.Stderr = os.Stderr |
||||
return cmd.Run() |
||||
} |
||||
|
||||
func Sync(packages []string) (*output.Result, error) { |
||||
before, err := getInstalledCount() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
p, err := New() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
defer p.Close() |
||||
|
||||
fresh, err := p.IsDBFresh() |
||||
if err != nil || !fresh { |
||||
if err := p.SyncDB(); err != nil { |
||||
return nil, fmt.Errorf("failed to sync database: %w", err) |
||||
} |
||||
} |
||||
|
||||
for _, pkg := range packages { |
||||
if err := p.MarkExplicit(pkg); err != nil { |
||||
fmt.Fprintf(os.Stderr, "warning: could not mark %s as explicit: %v\n", pkg, err) |
||||
} |
||||
} |
||||
|
||||
_, err = p.SyncPackages(packages) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
removed, err := p.CleanupOrphans() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
after, _ := getInstalledCount() |
||||
installedCount := max(after - before, 0) |
||||
|
||||
return &output.Result{ |
||||
Installed: installedCount, |
||||
Removed: removed, |
||||
}, nil |
||||
} |
||||
|
||||
func getInstalledCount() (int, error) { |
||||
cmd := exec.Command("pacman", "-Qq") |
||||
output, err := cmd.Output() |
||||
if err != nil { |
||||
return 0, nil |
||||
} |
||||
count := strings.Count(string(output), "\n") + 1 |
||||
if strings.TrimSpace(string(output)) == "" { |
||||
count = 0 |
||||
} |
||||
return count, nil |
||||
} |
||||
|
||||
func (p *Pac) SyncPackages(packages []string) (int, error) { |
||||
args := append([]string{"-Syu"}, packages...) |
||||
cmd := exec.Command("pacman", args...) |
||||
output, err := cmd.CombinedOutput() |
||||
if err != nil { |
||||
return 0, fmt.Errorf("pacman sync failed: %s", output) |
||||
} |
||||
|
||||
re := regexp.MustCompile(`upgrading (\S+)`) |
||||
matches := re.FindAllStringSubmatch(string(output), -1) |
||||
return len(matches), nil |
||||
} |
||||
|
||||
func (p *Pac) CleanupOrphans() (int, error) { |
||||
listCmd := exec.Command("pacman", "-Qdtq") |
||||
orphans, err := listCmd.Output() |
||||
if err != nil { |
||||
return 0, nil |
||||
} |
||||
|
||||
orphanList := strings.TrimSpace(string(orphans)) |
||||
if orphanList == "" { |
||||
return 0, nil |
||||
} |
||||
|
||||
removeCmd := exec.Command("pacman", "-Rns") |
||||
output, err := removeCmd.CombinedOutput() |
||||
if err != nil { |
||||
return 0, fmt.Errorf("%s: %s", err, output) |
||||
} |
||||
|
||||
count := strings.Count(orphanList, "\n") + 1 |
||||
return count, nil |
||||
} |
||||
@ -0,0 +1,74 @@
|
||||
package validation |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
"os" |
||||
"os/exec" |
||||
"time" |
||||
) |
||||
|
||||
var LockFile = "/var/lib/pacman/db.lock" |
||||
|
||||
func Validate(packages []string) error { |
||||
if len(packages) == 0 { |
||||
return errors.New("no packages found") |
||||
} |
||||
|
||||
if err := checkDBFreshness(); err != nil { |
||||
return err |
||||
} |
||||
|
||||
if err := validatePackages(packages); err != nil { |
||||
return err |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func checkDBFreshness() error { |
||||
info, err := os.Stat(LockFile) |
||||
if err != nil { |
||||
return nil |
||||
} |
||||
|
||||
age := time.Since(info.ModTime()) |
||||
if age > 24*time.Hour { |
||||
cmd := exec.Command("pacman", "-Syy") |
||||
cmd.Stdout = os.Stdout |
||||
cmd.Stderr = os.Stderr |
||||
if err := cmd.Run(); err != nil { |
||||
return fmt.Errorf("failed to refresh pacman database: %w", err) |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func validatePackages(packages []string) error { |
||||
for _, pkg := range packages { |
||||
if err := validatePackage(pkg); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func validatePackage(name string) error { |
||||
cmd := exec.Command("pacman", "-Qip", name) |
||||
if err := cmd.Run(); err == nil { |
||||
return nil |
||||
} |
||||
|
||||
cmd = exec.Command("pacman", "-Sip", name) |
||||
if err := cmd.Run(); err == nil { |
||||
return nil |
||||
} |
||||
|
||||
cmd = exec.Command("aur", "search", name) |
||||
if out, err := cmd.Output(); err == nil && len(out) > 0 { |
||||
return nil |
||||
} |
||||
|
||||
return fmt.Errorf("package not found: %s", name) |
||||
} |
||||
Loading…
Reference in new issue