diff --git a/README.md b/README.md index 11b344c..abfd21a 100644 --- a/README.md +++ b/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 diff --git a/cmd/declpac/main.go b/cmd/declpac/main.go index 7538797..1afba46 100644 --- a/cmd/declpac/main.go +++ b/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) diff --git a/openspec/changes/declpac-cli-tool/design.md b/openspec/changes/declpac-cli-tool/design.md index fa562df..2c09738 100644 --- a/openspec/changes/declpac-cli-tool/design.md +++ b/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 diff --git a/openspec/changes/declpac-cli-tool/proposal.md b/openspec/changes/declpac-cli-tool/proposal.md index 6345af3..6560e5f 100644 --- a/openspec/changes/declpac-cli-tool/proposal.md +++ b/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 diff --git a/openspec/changes/declpac-cli-tool/specs/aur-sync/spec.md b/openspec/changes/declpac-cli-tool/specs/aur-sync/spec.md index 36cb489..8d5ad49 100644 --- a/openspec/changes/declpac-cli-tool/specs/aur-sync/spec.md +++ b/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 diff --git a/openspec/changes/declpac-cli-tool/specs/dry-run/spec.md b/openspec/changes/declpac-cli-tool/specs/dry-run/spec.md new file mode 100644 index 0000000..87db6b2 --- /dev/null +++ b/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 diff --git a/openspec/changes/declpac-cli-tool/tasks.md b/openspec/changes/declpac-cli-tool/tasks.md index 669da11..3523b72 100644 --- a/openspec/changes/declpac-cli-tool/tasks.md +++ b/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 diff --git a/pkg/output/output.go b/pkg/output/output.go index b35c74b..fd7fd10 100644 --- a/pkg/output/output.go +++ b/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() } diff --git a/pkg/pacman/pacman.go b/pkg/pacman/pacman.go index 3cd8715..a543f2a 100644 --- a/pkg/pacman/pacman.go +++ b/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 +} diff --git a/pkg/validation/validation.go b/pkg/validation/validation.go index 9d04d7a..5897597 100644 --- a/pkg/validation/validation.go +++ b/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 }