Browse Source

Add dry-run flag and implement AUR support

master
AI Bot 5 days ago committed by Riyyi
parent
commit
521b862919
  1. 13
      README.md
  2. 16
      cmd/declpac/main.go
  3. 4
      openspec/changes/declpac-cli-tool/design.md
  4. 1
      openspec/changes/declpac-cli-tool/proposal.md
  5. 2
      openspec/changes/declpac-cli-tool/specs/aur-sync/spec.md
  6. 49
      openspec/changes/declpac-cli-tool/specs/dry-run/spec.md
  7. 9
      openspec/changes/declpac-cli-tool/tasks.md
  8. 19
      pkg/output/output.go
  9. 229
      pkg/pacman/pacman.go
  10. 78
      pkg/validation/validation.go

13
README.md

@ -72,6 +72,7 @@ bash # bash
|------|-------|-------------|
| `--state` | `-s` | State file(s) to read package list from (can be used multiple times) |
| `--yes` | `-y` | Skip confirmation prompts (for scripting) |
| `--dry-run` | | Simulate sync without making changes |
| `--help` | `-h` | Show help message |
### Examples
@ -114,6 +115,18 @@ sudo declpac --state ~/.config/declpac/base.txt --state ~/.config/declpac/deskto
cat ~/.config/declpac/full-system.txt | sudo declpac
```
#### Dry-Run Preview
```bash
# Preview what would happen without making changes
sudo declpac --dry-run --state packages.txt
# Example output:
# Installed 3 packages, removed 2 packages
# Would install: vim, git, docker
# Would remove: python2, perl-xml-parser
```
## How It Works
1. **Collect packages** — Reads from all `--state` files and stdin

16
cmd/declpac/main.go

@ -17,6 +17,7 @@ import (
type Config struct {
StateFiles []string
NoConfirm bool
DryRun bool
}
func main() {
@ -38,6 +39,11 @@ func main() {
Usage: "Skip confirmation prompts",
Destination: &cfg.NoConfirm,
},
&cli.BoolFlag{
Name: "dry-run",
Usage: "Simulate the sync without making changes",
Destination: &cfg.DryRun,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return run(cfg)
@ -63,6 +69,16 @@ func run(cfg *Config) error {
return err
}
if cfg.DryRun {
result, err := pacman.DryRun(merged)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return err
}
fmt.Println(output.Format(result))
return nil
}
result, err := pacman.Sync(merged)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)

4
openspec/changes/declpac-cli-tool/design.md

@ -77,7 +77,7 @@ package list, with all packages at their latest available versions.
**AUR Integration**
- First attempt: Try pacman -Syu for all packages (includes AUR auto-install if enabled)
- For packages not found in pacman repos: Check AUR via Jguer/aur library
- For packages not found in pacman repos: Batch query AUR via info endpoint (single HTTP request for multiple packages)
- If package in AUR: Build and install with makepkg (no AUR helpers)
- AUR packages should also upgrade to latest version (no partial updates)
- Clone AUR git repo to temp directory
@ -110,10 +110,12 @@ package list, with all packages at their latest available versions.
- Multiple --state flags allowed, all additive
- Stdin input via standard input stream
- No interactive prompts - fully automated
- `--dry-run`: Simulate sync without making changes, print what would be installed/removed
**Output Format**
- Success: Print to stdout: `Installed X packages, removed Y packages`
- No changes: Print `Installed 0 packages, removed 0 packages`
- Dry-run: Print `Installed X packages, removed Y packages` with `Would install: ...` and `Would remove: ...` lines
- Errors: Print error message to stderr
- Exit codes: 0 for success, 1 for errors

1
openspec/changes/declpac-cli-tool/proposal.md

@ -19,6 +19,7 @@ list, ensuring all packages are at the latest version.
- Machine-readable output (install/remove counts, exit codes)
- No conflict resolution for missing packages (append-only)
- Print error to stderr for empty state input and exit with code 1
- Dry-run mode: simulate sync without making changes, show what would be installed/removed
## Capabilities

2
openspec/changes/declpac-cli-tool/specs/aur-sync/spec.md

@ -12,7 +12,7 @@ latest versions.
#### Scenario: Fall back to AUR
- **WHEN** package is not in pacman repositories but is in AUR
- **THEN** query AUR via Jguer/aur library
- **THEN** batch query AUR via info endpoint (multiple packages in single request)
- **AND** build and install with makepkg -si
#### Scenario: Upgrade AUR packages

49
openspec/changes/declpac-cli-tool/specs/dry-run/spec.md

@ -0,0 +1,49 @@
# Dry-Run Mode
## Summary
Add `--dry-run` flag to simulate the sync operation without making any changes
to the system. Shows what packages would be installed and what would be removed.
## Motivation
Users want to preview the effects of a sync operation before committing changes.
This is useful for:
- Verifying the intended changes are correct
- Avoiding unintended package installations
- Understanding what orphan cleanup will remove
## Interface
```
declpac --dry-run --state packages.txt
```
## Behavior
1. Read state files and stdin (same as normal mode)
2. Validate packages exist (same as normal mode)
3. Query current installed packages via `pacman -Qq`
4. Compare declared packages to current state
5. Identify packages that would be installed (not currently installed)
6. Identify orphans that would be removed via `pacman -Qdtq`
7. Output results with "Would install:" and "Would remove:" sections
## Output Format
```
Installed 3 packages, removed 2 packages
Would install: vim, git, docker
Would remove: python2, perl-xml-parser
```
## Non-Goals
- Actual package operations (no pacman -Syu, no pacman -Rns execution)
- Package version comparison
- Detailed dependency analysis
## Trade-offs
- Doesn't predict transitive dependencies that pacman might install
- Orphan list may change after packages are installed

9
openspec/changes/declpac-cli-tool/tasks.md

@ -93,3 +93,12 @@
- [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
## 11. Dry-Run Mode
- [x] 11.1 Add --dry-run flag to CLI argument parsing
- [x] 11.2 Implement DryRun function to query current state
- [x] 11.3 Compare declared packages to current installations
- [x] 11.4 Identify packages to install (not currently installed)
- [x] 11.5 Identify orphans to remove via pacman -Qdtq
- [x] 11.6 Output "Would install:" and "Would remove:" sections

19
pkg/output/output.go

@ -1,12 +1,27 @@
package output
import "fmt"
import (
"fmt"
"strings"
)
type Result struct {
Installed int
Removed int
ToInstall []string
ToRemove []string
}
func Format(r *Result) string {
return fmt.Sprintf("Installed %d packages, removed %d packages", r.Installed, r.Removed)
var b strings.Builder
b.WriteString(fmt.Sprintf("Installed %d packages, removed %d packages", r.Installed, r.Removed))
if len(r.ToInstall) > 0 {
b.WriteString("\nWould install: ")
b.WriteString(strings.Join(r.ToInstall, ", "))
}
if len(r.ToRemove) > 0 {
b.WriteString("\nWould remove: ")
b.WriteString(strings.Join(r.ToRemove, ", "))
}
return b.String()
}

229
pkg/pacman/pacman.go

@ -1,7 +1,11 @@
package pacman
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"regexp"
@ -12,14 +16,17 @@ import (
)
var (
Root = "/"
LockFile = "/var/lib/pacman/db.lock"
Root = "/"
LockFile = "/var/lib/pacman/db.lock"
AURInfoURL = "https://aur.archlinux.org/rpc?v=5&type=info"
)
type Pac struct{}
type Pac struct {
aurCache map[string]AURPackage
}
func New() (*Pac, error) {
return &Pac{}, nil
return &Pac{aurCache: make(map[string]AURPackage)}, nil
}
func (p *Pac) Close() error {
@ -27,9 +34,21 @@ func (p *Pac) Close() error {
}
type PackageInfo struct {
Name string
InAUR bool
Exists bool
Name string
InAUR bool
Exists bool
AURInfo *AURPackage
}
type AURResponse struct {
Results []AURPackage `json:"results"`
}
type AURPackage struct {
Name string `json:"Name"`
PackageBase string `json:"PackageBase"`
Version string `json:"Version"`
URL string `json:"URL"`
}
func (p *Pac) ValidatePackage(name string) (*PackageInfo, error) {
@ -43,9 +62,9 @@ func (p *Pac) ValidatePackage(name string) (*PackageInfo, error) {
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
p.ensureAURCache([]string{name})
if aurInfo, ok := p.aurCache[name]; ok {
return &PackageInfo{Name: name, Exists: true, InAUR: true, AURInfo: &aurInfo}, nil
}
return &PackageInfo{Name: name, Exists: false, InAUR: false}, nil
@ -105,15 +124,25 @@ func Sync(packages []string) (*output.Result, error) {
}
}
pacmanPkgs, aurPkgs := p.categorizePackages(packages)
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
if len(pacmanPkgs) > 0 {
_, err = p.SyncPackages(pacmanPkgs)
if err != nil {
return nil, err
}
}
for _, pkg := range aurPkgs {
if err := p.InstallAUR(pkg); err != nil {
return nil, err
}
}
removed, err := p.CleanupOrphans()
@ -122,7 +151,7 @@ func Sync(packages []string) (*output.Result, error) {
}
after, _ := getInstalledCount()
installedCount := max(after - before, 0)
installedCount := max(after-before, 0)
return &output.Result{
Installed: installedCount,
@ -130,6 +159,118 @@ func Sync(packages []string) (*output.Result, error) {
}, nil
}
func (p *Pac) categorizePackages(packages []string) (pacmanPkgs, aurPkgs []string) {
var notInPacman []string
for _, pkg := range packages {
info, err := p.ValidatePackage(pkg)
if err != nil || !info.Exists {
notInPacman = append(notInPacman, pkg)
} else if !info.InAUR {
pacmanPkgs = append(pacmanPkgs, pkg)
}
}
if len(notInPacman) > 0 {
p.ensureAURCache(notInPacman)
for _, pkg := range notInPacman {
if _, ok := p.aurCache[pkg]; ok {
aurPkgs = append(aurPkgs, pkg)
} else {
fmt.Fprintf(os.Stderr, "error: package not found: %s\n", pkg)
}
}
}
return pacmanPkgs, aurPkgs
}
func (p *Pac) ensureAURCache(packages []string) {
if len(packages) == 0 {
return
}
var uncached []string
for _, pkg := range packages {
if _, ok := p.aurCache[pkg]; !ok {
uncached = append(uncached, pkg)
}
}
if len(uncached) == 0 {
return
}
p.fetchAURInfo(uncached)
}
func (p *Pac) fetchAURInfo(packages []string) map[string]AURPackage {
result := make(map[string]AURPackage)
if len(packages) == 0 {
return result
}
v := url.Values{}
for _, pkg := range packages {
v.Add("arg[]", pkg)
}
resp, err := http.Get(AURInfoURL + "&" + v.Encode())
if err != nil {
return result
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return result
}
var aurResp AURResponse
if err := json.Unmarshal(body, &aurResp); err != nil {
return result
}
for _, r := range aurResp.Results {
p.aurCache[r.Name] = r
result[r.Name] = r
}
return result
}
func (p *Pac) InstallAUR(pkgName string) error {
aurInfo, ok := p.aurCache[pkgName]
if !ok {
return fmt.Errorf("AUR package not found in cache: %s", pkgName)
}
tmpDir, err := os.MkdirTemp("", "declpac-aur-")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
cloneURL := "https://aur.archlinux.org/" + aurInfo.PackageBase + ".git"
cloneCmd := exec.Command("git", "clone", cloneURL, tmpDir)
cloneCmd.Stdout = os.Stdout
cloneCmd.Stderr = os.Stderr
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone AUR repo: %w", err)
}
makepkgCmd := exec.Command("makepkg", "-si", "--noconfirm")
makepkgCmd.Stdout = os.Stdout
makepkgCmd.Stderr = os.Stderr
makepkgCmd.Dir = tmpDir
if err := makepkgCmd.Run(); err != nil {
return fmt.Errorf("makepkg failed to build AUR package: %w", err)
}
return nil
}
func getInstalledCount() (int, error) {
cmd := exec.Command("pacman", "-Qq")
output, err := cmd.Output()
@ -177,3 +318,63 @@ func (p *Pac) CleanupOrphans() (int, error) {
count := strings.Count(orphanList, "\n") + 1
return count, nil
}
func DryRun(packages []string) (*output.Result, error) {
p, err := New()
if err != nil {
return nil, err
}
defer p.Close()
current, err := p.GetInstalledPackages()
if err != nil {
return nil, err
}
currentSet := make(map[string]bool)
for _, pkg := range current {
currentSet[pkg] = true
}
var toInstall []string
var aurPkgs []string
for _, pkg := range packages {
if !currentSet[pkg] {
info, err := p.ValidatePackage(pkg)
if err != nil || !info.Exists {
return nil, fmt.Errorf("package not found: %s", pkg)
}
if info.InAUR {
aurPkgs = append(aurPkgs, pkg)
} else {
toInstall = append(toInstall, pkg)
}
}
}
orphans, err := p.listOrphans()
if err != nil {
return nil, err
}
return &output.Result{
Installed: len(toInstall) + len(aurPkgs),
Removed: len(orphans),
ToInstall: append(toInstall, aurPkgs...),
ToRemove: orphans,
}, nil
}
func (p *Pac) listOrphans() ([]string, error) {
cmd := exec.Command("pacman", "-Qdtq")
orphans, err := cmd.Output()
if err != nil {
return nil, nil
}
list := strings.TrimSpace(string(orphans))
if list == "" {
return nil, nil
}
return strings.Split(list, "\n"), nil
}

78
pkg/validation/validation.go

@ -1,8 +1,12 @@
package validation
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"time"
@ -10,6 +14,16 @@ import (
var LockFile = "/var/lib/pacman/db.lock"
const AURInfoURL = "https://aur.archlinux.org/rpc?v=5&type=info"
type AURResponse struct {
Results []AURResult `json:"results"`
}
type AURResult struct {
Name string `json:"Name"`
}
func Validate(packages []string) error {
if len(packages) == 0 {
return errors.New("no packages found")
@ -46,29 +60,75 @@ func checkDBFreshness() error {
}
func validatePackages(packages []string) error {
var pacmanPkgs []string
var aurPkgs []string
for _, pkg := range packages {
if err := validatePackage(pkg); err != nil {
return err
if inPacman(pkg) {
pacmanPkgs = append(pacmanPkgs, pkg)
} else {
aurPkgs = append(aurPkgs, pkg)
}
}
if len(aurPkgs) > 0 {
foundAUR := batchSearchAUR(aurPkgs)
for _, pkg := range aurPkgs {
if !foundAUR[pkg] {
return fmt.Errorf("package not found: %s", pkg)
}
}
}
return nil
}
func validatePackage(name string) error {
func inPacman(name string) bool {
cmd := exec.Command("pacman", "-Qip", name)
if err := cmd.Run(); err == nil {
return nil
return true
}
cmd = exec.Command("pacman", "-Sip", name)
if err := cmd.Run(); err == nil {
return nil
return true
}
cmd = exec.Command("aur", "search", name)
if out, err := cmd.Output(); err == nil && len(out) > 0 {
return nil
return false
}
func batchSearchAUR(packages []string) map[string]bool {
result := make(map[string]bool)
if len(packages) == 0 {
return result
}
v := url.Values{}
v.Set("type", "info")
for _, pkg := range packages {
v.Add("arg[]", pkg)
}
resp, err := http.Get(AURInfoURL + "&" + v.Encode())
if err != nil {
return result
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return result
}
var aurResp AURResponse
if err := json.Unmarshal(body, &aurResp); err != nil {
return result
}
for _, r := range aurResp.Results {
result[r.Name] = true
}
return fmt.Errorf("package not found: %s", name)
return result
}

Loading…
Cancel
Save