Compare commits

...
63 Commits
Author SHA1 Message Date
AI BotandRiyyi 1159be59d0 Add safety check before package pruning
Verifies all state packages are installed locally before running
markAllAsDeps and orphan cleanup.
2026-05-04 23:28:58 +02:00
Riyyi 0fc0684801 Improve tarball selection when installing AUR packages 2026-05-04 22:47:07 +02:00
AI BotandRiyyi bcddfd13e7 Also install repo dependencies when installing AUR packages 2026-05-04 21:13:25 +02:00
Riyyi 4472bbc2e9 Add grill-me skill 2026-05-04 20:25:43 +02:00
Riyyi b371c74e9a Make tool request elevated privileges on-demand 2026-05-03 23:04:34 +02:00
Riyyi 76b83437ac Add safety check before rm -rf call 2026-05-03 22:17:51 +02:00
AI BotandRiyyi b04869f0e1 Move log to user owned directory 2026-05-03 21:29:49 +02:00
AI BotandRiyyi 7c4b0eda8b Apply coding conventions 2026-05-03 18:01:00 +02:00
AI BotandRiyyi 317d5f4be9 Cache sudo user to avoid repeated env lookups 2026-05-03 17:49:53 +02:00
AI BotandRiyyi b13c9da7a1 Improve AUR with AUR dependency installations 2026-05-03 17:40:31 +02:00
AI BotandRiyyi c8cdace4bf Update Go file structure conventions in AGENTS.md 2026-05-03 15:48:00 +02:00
AI BotandRiyyi 31fad42ab3 Reorder functions to follow public-first convention 2026-05-03 15:40:07 +02:00
AI BotandRiyyi db4e22a580 Add --prune flag for explicit marking and orphan cleanup 2026-05-03 15:10:54 +02:00
AI BotandRiyyi d59d5c084d Add implicit state file support
Read implicit package list from /home/rick/.config/declpac or ~/.config/declpac
2026-05-03 14:30:16 +02:00
AI BotandRiyyi c4746697b5 Remove merge package, move Merge to input 2026-05-03 14:30:16 +02:00
AI BotandRiyyi 5ad29767c9 Add safety check for package list validation 2026-05-03 14:30:16 +02:00
AI BotandRiyyi 38d26fd2d8 Add Go line length guidelines
Adds 120/100/80 character guidelines to AGENTS.md
2026-05-03 12:17:12 +02:00
AI BotandRiyyi 399a7157e2 Add tilde path expansion for state files 2026-05-03 11:48:05 +02:00
AI BotandRiyyi 578eb16ab2 Add Go file structure conventions to AGENTS.md 2026-05-03 11:27:30 +02:00
AI BotandRiyyi 584f4fbdd9 Skip debug packages when finding built AUR package
The findPKGFile function now takes pkgName as parameter to skip
debug packages (*-debug.pkg.tar.zst) when locating built packages.
2026-05-03 11:13:06 +02:00
Riyyi 39b8d9ac4b Add fmt command to makefile 2026-05-03 10:22:30 +02:00
AI BotandRiyyi d36bba6925 Add centralized command logging function
Refactors command execution to use a new log.Command helper that handles both logging and cmd creation, reducing duplication in sync.go
2026-05-03 10:05:10 +02:00
AI BotandRiyyi b1a376dbbe Add build, install and uninstall targets to Makefile 2026-04-24 21:14:03 +02:00
Riyyi 8a074e02b0 Update README.md 2026-04-19 11:20:08 +02:00
AI BotandRiyyi fdd9ecb3a5 Handle pacman -Qdtq exit code 1 as non-error
pacman -Qdtq exits with code 1 when there are no orphans, which is not an error condition. Handle this gracefully by returning nil instead of an error.
2026-04-19 11:08:32 +02:00
AI BotandRiyyi c90d2e8916 Add verbose flag for debug output control
Introduces log.Debug() function that respects a Verbose config flag, replacing direct [debug] fmt.Fprintf calls throughout the codebase. This allows debug logging to be toggled via -v/--verbose CLI flag.
2026-04-19 11:06:52 +02:00
Riyyi 1dffd70db2 Skip interactive prompt when removing orphans 2026-04-19 10:21:57 +02:00
AI BotandRiyyi 5ebb958bf1 Add command logging and skip already installed AUR packages 2026-04-19 10:15:00 +02:00
Riyyi c7bed95107 Skip confirmation when installing packages 2026-04-18 20:35:18 +02:00
AI BotandRiyyi a934dfa1f0 Update make-commit skill with message format rules 2026-04-18 18:50:25 +02:00
AI BotandRiyyi e64382c1bd Fix issues with dry-run package reporting
- Filter orphans to only those not in requested packages
- Fix Removed count to reflect only actual removals
2026-04-18 18:30:45 +02:00
AI BotandRiyyi 83de6acc74 Refactor fetch and pacman packages into subpackages
Split fetch into alpm and aur subpackages for better organization.
Rename state to log. Split pacman into read and sync subpackages.
Remove validation in favor of read.DBFreshness.
2026-04-18 18:18:23 +02:00
Riyyi 88ff093365 Extract PacmanState constant 2026-04-18 16:16:13 +02:00
Riyyi c2a7b9ab9c Improve make-commit skill commit message formatting
- Fix typo 'thed' to 'the'
- Add 72-character title convention
- Change --stat flag formatting to be more standard
2026-04-18 15:39:43 +02:00
AI BotandRiyyi 86c6d078d6 Output actual commands run instead of generic progress messages 2026-04-18 15:35:22 +02:00
AI BotandRiyyi bd1108a93f Dont use CombinedOutput() to capture error separately 2026-04-18 15:07:56 +02:00
AI BotandRiyyi 82aeceacac Fixup: correct log path in add-operation-logging, fix pacman -Rns flag in declpac-cli-tool, update pkg path in fix-sync-dbs-notloaded 2026-04-18 14:27:47 +02:00
AI BotandRiyyi 86c1abaad4 Add opsx-fixup command for diverging change specs 2026-04-18 14:07:47 +02:00
AI BotandRiyyi 2b0cb39746 Update operation logging spec to use single timestamp per tool call 2026-04-18 13:44:48 +02:00
Riyyi fc18e27c25 Log timestamp once per tool invocation 2026-04-18 13:37:50 +02:00
AI BotandRiyyi 8348cc21df Suppress command output from stdout, only log to file 2026-04-17 23:34:53 +02:00
AI BotandRiyyi 01338cb671 Archive experimental OpenSpec changes 2026-04-17 19:35:25 +02:00
AI BotandRiyyi b4b12b39b6 Openspec fixups 2026-04-17 19:26:06 +02:00
AI BotandRiyyi 720be92d82 Fix MarkAllAsDeps to retrieve all packages first 2026-04-17 19:06:03 +02:00
AI BotandRiyyi 4143a23c91 Implement package resolve logic fixes 2026-04-17 18:24:14 +02:00
AI BotandRiyyi 536a8d5ccb Add package fetching logic fix proposal 2026-04-17 18:14:21 +02:00
AI BotandRiyyi 11a5e57516 Fix AUR package installation root privileges 2026-04-16 22:55:41 +02:00
AI BotandRiyyi cffccf2390 Add Makefile and fix pacman.go 2026-04-16 22:24:36 +02:00
Riyyi 6fd4b64d92 Reorder main calls 2026-04-16 21:35:04 +02:00
AI BotandRiyyi e74c38cb3f Add input handling improvements 2026-04-16 21:31:05 +02:00
AI BotandRiyyi 192b41e18b Rewrite README with accurate docs 2026-04-15 22:51:30 +02:00
Riyyi 1c74373d6b Remove --yes flag 2026-04-15 22:46:11 +02:00
Riyyi 5e9b2521bd Do not gitignore main.go file 2026-04-15 22:44:44 +02:00
AI BotandRiyyi eb6bb8fb76 Add state tracking and pacman package enhancements 2026-04-15 22:40:02 +02:00
AI BotandRiyyi 0b834214cd Add OpenSpec change for operation logging 2026-04-15 22:40:02 +02:00
Riyyi 5031fdc1f8 Improve make-commit skill documentation 2026-04-15 20:51:49 +02:00
AI BotandRiyyi 0fa0988666 Fix AUR package detection for installed packages 2026-04-15 19:57:34 +02:00
AI BotandRiyyi 2bead6af27 Refactor pkg into modular packages 2026-04-14 22:37:48 +02:00
AI BotandRiyyi 9aeedb39b8 Add OpenSpec change for pkg modularization refactor 2026-04-14 22:23:12 +02:00
AI BotandRiyyi 076bebbedf Register sync DBs manually to fix dyalpm returning empty list
The dyalpm library's SyncDBs() returns an empty slice, causing packages
from official repos (extra, core, multilib) to not be found. This fix
manually registers each repo after getting an empty result.
2026-04-14 22:05:42 +02:00
AI BotandRiyyi 836d8600c6 Add fix for sync DBs not loaded 2026-04-14 21:59:08 +02:00
AI BotandRiyyi be9c9c6df1 Integrate dyalpm for local/sync package queries 2026-04-14 21:46:02 +02:00
AI BotandRiyyi 2a90c0cd11 Add batch pacman checks and package resolution specs 2026-04-14 19:57:29 +02:00
65 changed files with 2396 additions and 648 deletions
+1 -1
View File
@@ -32,4 +32,4 @@ go.work.sum
# .vscode/ # .vscode/
# Binary output # Binary output
declpac bin/
+1 -1
View File
@@ -2,4 +2,4 @@
description: Make a git commit, asking if it was by the user or AI description: Make a git commit, asking if it was by the user or AI
--- ---
skill name=make-commit $1 skill [name=make-commit] message: $1
+28
View File
@@ -0,0 +1,28 @@
---
description: Fixup diverging archived openspec change artifacts
---
Fixup archived openspec change artifacts in @openspec/changes/archive/ that have diverged from the codebase.
For each archived change directory:
1. Read the .md artifact
2. Review related code to verify consistency
3. Fix any mismatches (status, fields, missing links, etc.)
Only run make-commit if changes were made, with a specific message like "Fixup: add missing X field to Y change".
---
**Steps**
1. **For each archived openspec change**
Read the change artifact.
Go through the related code.
Correct the change artifact where needed.
2. **Run make-commit if changes were made**
skill [name=make-commit] message: "Fixup: <specific fix>"
+10
View File
@@ -0,0 +1,10 @@
---
name: grill-me
description: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me".
---
Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.
Ask the questions one at a time.
If a question can be answered by exploring the codebase, explore the codebase instead.
+51 -30
View File
@@ -7,16 +7,29 @@ description: >
license: GPL-3.0 license: GPL-3.0
metadata: metadata:
author: riyyi author: riyyi
version: "1.0" version: "1.1"
--- ---
Make a git commit, distinguishing between user and AI contributions. Make a git commit, distinguishing between user and AI contributions.
--- ---
**Commit message format**
A valid commit message consists of a required **title** and an optional **body**:
Rules:
- **Title** (required): max 72 characters, starts with a capital letter —
unless referring to a tool/project that explicitly uses lowercase (e.g.,
"go", "npm", "rustc"). No trailing period.
- **Body** (optional): any further elaboration. Each line max 72 characters.
Wrap manually — do not rely on the terminal to wrap.
---
**Steps** **Steps**
1. **Ask user if this commit is by them or by AI** 1. **[REQUIRED] Ask user if this commit is by them or by AI**
Use the **question tool** to ask: Use the **question tool** to ask:
> "Was this commit made by you or by AI?" > "Was this commit made by you or by AI?"
@@ -25,55 +38,63 @@ Make a git commit, distinguishing between user and AI contributions.
- "By me" - User made the commit - "By me" - User made the commit
- "By AI" - AI made the commit - "By AI" - AI made the commit
2. **Check for commit message** 2. **Compose the commit message**
If the user did NOT provide a commit message, generate one from staged changes: If the user did NOT provide a commit message, generate one from staged
changes:
```bash ```bash
git diff --staged --stat git --no-pager diff --staged
``` ```
Create a reasonable commit message based on the changes. Write a commit message following the format above.
**Capitalization rule**: Commit message should start with a capital letter, If the user **DID** provide a message, treat it as raw input and apply the
unless it refers to a tool or project that explicitly uses lowercase as its format rules to it.
name (e.g., "go", "npm", "rustc").
3. **Show commit message and confirm** 3. **Validate the commit message**
Display the commit message to the user. Before presenting the message to the user, check it against every rule:
- [ ] Title is present and non-empty
- [ ] Title is at most 72 characters
- [ ] Title starts with a capital letter (or an intentionally lowercase name)
- [ ] Title has no trailing period
- [ ] Every line in the body is at most 72 characters
Fix any violations silently before showing the message to the user.
4. **Show commit message and confirm**
Use the **question tool** to ask: Use the **question tool** to ask:
> "Is this commit message okay, or would you like to make tweaks?" > "Is this commit message okay, or would you like to make tweaks?"
> ```
> <message>
> ```
Options: Options:
- "Looks good" - Proceed with this message - "Looks good" - Proceed with this message
- "Make tweaks" - User will provide a new message - "Make tweaks" - User will provide a new message or describe changes
**If user wants tweaks**: Ask them for the new commit message. **If user wants tweaks**: apply the same validation (step 3) to the revised
message before committing.
4. **Get git user config**
```bash
git config user.name
git config user.email
```
5. **Make the commit** 5. **Make the commit**
Use the commit message provided by the user. For a title-only message:
**If by user:**
```bash ```bash
git commit -m "<message>" git commit -m "<title>"
``` ```
(Uses git config user as both committer and author)
**If by AI:** For a message with a body, pass `-m` twice (git inserts the blank line):
```bash ```bash
git -c user.name="<git-config-name>" -c user.email="<git-config-email>" commit -m "<message>" --author="AI Bot <ai@local>" git commit -m "<title>" -m "<body>"
```
Append `--author="AI Bot <ai@local>"` when the commit is by AI:
```bash
git commit -m "<title>" [-m "<body>"] --author="AI Bot <ai@local>"
``` ```
(Uses git config for committer, but sets author to AI Bot)
**Output** **Output**
- Tell user the commit was made - Tell user the commit was made.
- If AI commit, mention that the author was set to "AI Bot <ai@local>" - If AI commit, mention that the author was set to "AI Bot <ai@local>".
+25 -1
View File
@@ -2,6 +2,30 @@
## Skills: Always Active ## Skills: Always Active
At the start of every conversation, load the following skills using the `skill` tool before responding to the user: At the start of every conversation, load the following skills using the `skill`
tool before responding to the user:
1. **caveman** — Always use caveman mode (full intensity) for all responses 1. **caveman** — Always use caveman mode (full intensity) for all responses
## Code Organization
### Go: File Structure
- Variables must appear at the top of each file.
- Types must appear after variables.
- Constructors must appear after types.
- Public (exported) functions must appear after constructors.
- Private (unexported) functions must appear at the bottom of each file.
- Within each section, definitions must be sorted alphabetically by name.
- The sections must be separated by exactly these dividers, filling in the section:
// -----------------------------------------
// <section>
### Go: Line Length
Keep Go lines as short as reasonably possible.
- **Hard limit:** 120 characters — never exceed this.
- **Preferred:** 100 characters or fewer in normal cases.
- **Target:** 80 characters if breaking the line produces cleaner, more readable
code (e.g. chained calls, long argument lists, multi-condition `if` statements).
+46
View File
@@ -0,0 +1,46 @@
.PHONY: build fmt fmtcheck vet check githook install uninstall
PKG := github.com/Riyyi/declpac
BIN := declpac
BUILD_DIR := bin
BUILD := $(BUILD_DIR)/$(BIN)
PREFIX ?= /usr/local
DESTDIR ?=
STAGED_GO := $(shell git diff --cached --name-only --diff-filter=ACM | grep '\.go$$')
STAGED_PKGS := $(shell echo "$(STAGED_GO)" | tr ' ' '\n' | xargs -I{} dirname {} | sort -u | sed 's|^|./|')
build:
@mkdir -p $(BUILD_DIR)
go build -o $(BUILD) ./cmd/declpac
fmt:
@gofmt -w cmd pkg
fmtcheck:
@if [ -z "$(STAGED_GO)" ]; then exit 0; fi; \
unformatted=$$(gofmt -l $(STAGED_GO)); \
if [ -n "$$unformatted" ]; then \
echo "$$unformatted: unformatted staged file"; \
exit 1; \
fi
vet:
@if [ -z "$(STAGED_GO)" ]; then exit 0; fi; \
go vet $(STAGED_PKGS)
check: fmtcheck vet
githook:
@mkdir -p .git/hooks
@echo '#!/bin/sh' > .git/hooks/pre-commit
@echo 'make check' >> .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
@echo "Installed pre-commit hook: .git/hooks/pre-commit"
install: build
@mkdir -p $(DESTDIR)$(PREFIX)/bin
@install -m 0755 $(BUILD) $(DESTDIR)$(PREFIX)/bin/$(BIN)
uninstall:
@rm -f $(DESTDIR)$(PREFIX)/bin/$(BIN)
+47 -138
View File
@@ -1,18 +1,14 @@
# declpac # declpac
`declpac` is a declarative package manager for Arch Linux that syncs your system Declarative package manager for Arch Linux that syncs your system with a declared package list using pacman.
with a declared package list using `pacman`. It ensures your system matches your
desired state, handling package installation, upgrades, and orphan cleanup
automatically.
## Features ## Features
- **Declarative state management**Define your desired package list in files or stdin - Declarative state management — define your desired package list in files or stdin
- **Automatic dependency resolution** — Pacman handles transitive dependencies - Explicit package tracking — marks declared packages as explicit (with `--prune`)
- **Smart orphan cleanup**Removes packages no longer needed - Smart orphan cleanup — removes packages no longer needed (with `--prune`)
- **Explicit package tracking** — Marks your declared packages as explicit - AUR support — builds and installs AUR packages automatically
- **AUR support** — Falls back to AUR for packages not in official repos - Machine-readable output — perfect for scripting
- **Machine-readable output** — Perfect for scripting and automation
## Installation ## Installation
@@ -28,175 +24,85 @@ sudo mv declpac /usr/local/bin/
### Dependencies ### Dependencies
- Go 1.21+ - Go 1.21+
- pacman (system package manager) - pacman
- aur (AUR helper, optional for AUR support) - makepkg (for AUR support)
- Root privileges (required for pacman operations) - git (for AUR support)
- sudo/doas (root privileges)
## Usage ## Usage
### Basic Usage
```bash ```bash
# Single state file # Single state file
sudo declpac --state packages.txt declpac --state packages.txt
# Multiple state files # Multiple state files
sudo declpac --state base.txt --state apps.txt declpac --state base.txt --state apps.txt
# From stdin # From stdin
cat packages.txt | sudo declpac cat packages.txt | declpac
# Preview changes without applying
declpac --dry-run --state packages.txt
``` ```
### State File Format ### State File Format
State files contain one package name per line: One package name per line, lines beginning with `#` are comments:
``` ```
bash bash
vim vim
git git
docker docker
# this is a comment
``` ```
Lines are treated as package names with whitespace trimmed: ### Implicit State File
``` If `$XDG_CONFIG_HOME/declpac` (or `~/.config/declpac` on fallback) exists, its
bash # bash contents are automatically included in the package list.
vim # vim
# comment # ignored
```
### Command Line Options ### Options
| Flag | Alias | Description | | Flag | Alias | Description |
|------|-------|-------------| |------|-------|-------------|
| `--state` | `-s` | State file(s) to read package list from (can be used multiple times) | | `--state` | `-s` | State file(s) to read package list from (can be used multiple times) |
| `--yes` | `-y` | Skip confirmation prompts (for scripting) | | `--nocheck` | | Skip safety check (allow significant package count reductions) |
| `--dry-run` | | Simulate sync without making changes | | `--dry-run` | | Preview changes without applying them |
| `--prune` | | Mark packages as explicit and cleanup orphans |
| `--verbose` | `-v` | Enable verbose output |
| `--help` | `-h` | Show help message | | `--help` | `-h` | Show help message |
### Examples
#### Minimal System
```bash
# Create a minimal system package list
echo -e "base\nbase-devel\nlinux-headers\nvim\ngit\ncurl\nwget" > ~/.config/declpac/minimal.txt
# Apply the state
sudo declpac --state ~/.config/declpac/minimal.txt
```
#### Development Environment
```bash
# development.txt
go
nodejs
python
rust
docker
docker-compose
kubectl
helm
terraform
# Apply
sudo declpac --state development.txt
```
#### Full System Sync
```bash
# Combine multiple files
sudo declpac --state ~/.config/declpac/base.txt --state ~/.config/declpac/desktop.txt
# Or use stdin
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 ## How It Works
1. **Collect packages** — Reads from all `--state` files and stdin 1. **Read**Collect packages from all state files and stdin
2. **Merge** — Combines all packages additively (duplicates allowed) 2. **Merge** — Combine into single package list
3. **Validate** — Checks packages exist in repos or AUR 3. **Categorize** — Check if packages are in official repos or AUR
4. **Mark explicit** — Marks declared packages as explicit dependencies 4. **Sync** — Install/update packages via pacman
5. **Sync**Runs `pacman -Syu` to install/upgrade packages 5. **Build**Build and install AUR packages via makepkg
6. **Cleanup** — Removes orphaned packages with `pacman -Rns` 6. **Mark** (with `--prune`) — Mark declared packages as explicit, all others as dependencies
7. **Report** — Outputs summary: `Installed X packages, removed Y packages` 7. **Cleanup** (with `--prune`) — Remove orphaned packages
### Database Freshness ### Database Freshness
If the pacman database is older than 24 hours, `declpac` automatically refreshes it with `pacman -Syy` before validation. If the pacman database is older than 24 hours, it is automatically refreshed.
### Orphan Cleanup ### Logging
After syncing, `declpac` identifies and removes packages that are: Operation are logged to `$XDG_STATE_HOME/declpac.log`
- Not explicitly installed (or `~/.local/state/declpac.log` on fallback)
- Not required by any other package
This keeps your system clean from dependency artifacts.
## Output Format
```
# Success (packages installed/removed)
Installed 5 packages, removed 2 packages
# Success (no changes)
Installed 0 packages, removed 0 packages
# Error
error: package not found: <package-name>
```
### Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | Error (no packages, validation failure, pacman error) |
## Security Considerations
- **Run as root** — `declpac` requires root privileges for pacman operations
- **Review state files** — Only install packages from trusted sources
- **Backup** — Consider backing up your system before major changes
## Troubleshooting ## Troubleshooting
### "Permission denied" ### Package not found
`declpac` requires root privileges. Use `sudo`:
```bash
sudo declpac --state packages.txt
```
### "Package not found"
The package doesn't exist in pacman repos or AUR. Check the package name:
Check if the package exists:
```bash ```bash
pacman -Ss <package> pacman -Ss <package>
``` ```
### Database sync fails ### Database sync fails
Refresh manually:
```bash ```bash
sudo pacman -Syy sudo pacman -Syy
``` ```
@@ -209,12 +115,15 @@ declpac/
│ └── main.go # Entry point │ └── main.go # Entry point
├── pkg/ ├── pkg/
│ ├── input/ # State file/stdin reading │ ├── input/ # State file/stdin reading
│ ├── merge/ # Package merging │ ├── fetch/ # Package resolution
│ ├── validation/ # Package validation │ ├── aur/ # AUR support
├── pacman/ # Pacman integration │ └── alpm/ # ALPM support
│ ├── pacman/ # Pacman operations
│ │ ├── read/ # Read packages
│ │ └── sync/ # Sync packages
│ ├── log/ # Logging
│ └── output/ # Output formatting │ └── output/ # Output formatting
── go.mod # Go module ── README.md
└── README.md # This file
``` ```
## License ## License
+40 -12
View File
@@ -4,20 +4,24 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"time"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
"github.com/Riyyi/declpac/pkg/auth"
"github.com/Riyyi/declpac/pkg/input" "github.com/Riyyi/declpac/pkg/input"
"github.com/Riyyi/declpac/pkg/merge" "github.com/Riyyi/declpac/pkg/log"
"github.com/Riyyi/declpac/pkg/output" "github.com/Riyyi/declpac/pkg/output"
"github.com/Riyyi/declpac/pkg/pacman" "github.com/Riyyi/declpac/pkg/pacman"
"github.com/Riyyi/declpac/pkg/validation" "github.com/Riyyi/declpac/pkg/pacman/read"
) )
type Config struct { type Config struct {
StateFiles []string StateFiles []string
NoConfirm bool NoCheck bool
DryRun bool DryRun bool
Prune bool
Verbose bool
} }
func main() { func main() {
@@ -34,18 +38,29 @@ func main() {
Destination: &cfg.StateFiles, Destination: &cfg.StateFiles,
}, },
&cli.BoolFlag{ &cli.BoolFlag{
Name: "yes", Name: "nocheck",
Aliases: []string{"y"}, Usage: "Skip safety check",
Usage: "Skip confirmation prompts", Destination: &cfg.NoCheck,
Destination: &cfg.NoConfirm,
}, },
&cli.BoolFlag{ &cli.BoolFlag{
Name: "dry-run", Name: "dry-run",
Usage: "Simulate the sync without making changes", Usage: "Simulate the sync without making changes",
Destination: &cfg.DryRun, Destination: &cfg.DryRun,
}, },
&cli.BoolFlag{
Name: "prune",
Usage: "Mark packages and cleanup orphans",
Destination: &cfg.Prune,
},
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "Enable verbose output",
Destination: &cfg.Verbose,
},
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
log.Verbose = cfg.Verbose
return run(cfg) return run(cfg)
}, },
} }
@@ -56,35 +71,48 @@ func main() {
} }
func run(cfg *Config) error { func run(cfg *Config) error {
start := time.Now()
log.Debug("run: starting...")
packages, err := input.ReadPackages(cfg.StateFiles) packages, err := input.ReadPackages(cfg.StateFiles)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return err return err
} }
log.Debug("run: packages read (%.2fs)", time.Since(start).Seconds())
merged := merge.Merge(packages) merged, err := input.Merge(packages)
if err != nil {
if err := validation.Validate(merged); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return err return err
} }
if cfg.DryRun { if cfg.DryRun {
result, err := pacman.DryRun(merged) result, err := read.DryRun(merged)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return err return err
} }
fmt.Println(output.Format(result)) fmt.Println(output.Format(result))
log.Debug("run: dry-run done (%.2fs)", time.Since(start).Seconds())
return nil return nil
} }
result, err := pacman.Sync(merged) auth.Start()
if err := log.OpenLog(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return err
}
defer log.Close()
result, err := pacman.Sync(merged, cfg.NoCheck, cfg.Prune)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return err return err
} }
fmt.Println(output.Format(result)) fmt.Println(output.Format(result))
log.Debug("run: sync done (%.2fs)", time.Since(start).Seconds())
return nil return nil
} }
+5 -3
View File
@@ -2,6 +2,8 @@ module github.com/Riyyi/declpac
go 1.26.2 go 1.26.2
require ( require github.com/urfave/cli/v3 v3.8.0
github.com/urfave/cli/v3 v3.8.0
) require github.com/Jguer/dyalpm v0.1.2
require github.com/ebitengine/purego v0.10.0 // indirect
+8 -2
View File
@@ -1,8 +1,14 @@
github.com/Jguer/aur v1.3.0 h1:skdjp/P9kB75TBaJmn9PKK/kCeA9QsgjdUrORZ3gldU=
github.com/Jguer/aur v1.3.0/go.mod h1:F8Awo+WKzTxlXtNOO4pDQjMkePLZ+oMSbu+1fKLTTLo=
github.com/Jguer/dyalpm v0.1.2 h1:Gl0+GDWBQmo3DSsfzTPnKqCwYqcroq0j6kAtsIUkpUw= github.com/Jguer/dyalpm v0.1.2 h1:Gl0+GDWBQmo3DSsfzTPnKqCwYqcroq0j6kAtsIUkpUw=
github.com/Jguer/dyalpm v0.1.2/go.mod h1:FpcWwU1eYHVWMKmr/yHFqHYKsS+qGKCtk/FIXirj2MY= github.com/Jguer/dyalpm v0.1.2/go.mod h1:FpcWwU1eYHVWMKmr/yHFqHYKsS+qGKCtk/FIXirj2MY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI= github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-15
@@ -0,0 +1,46 @@
## Implementation
### Log File Location
- Path: `/var/log/declpac.log`
- Single merged log file (stdout + stderr intermingled in order of arrival)
### State-Modifying Functions (need logging)
1. `SyncPackages()` - `pacman -S --needed <packages>`
2. `InstallAUR()` - `git clone` + `makepkg -si --noconfirm`
3. `MarkAllAsDeps()` - `pacman -D --asdeps`
4. `MarkAsExplicit()` - `pacman -D --asexplicit <packages>`
5. `CleanupOrphans()` - `pacman -Rns`
### Functions to Skip (read-only)
- `DryRun()` - queries only
- `getInstalledCount()` - pacman -Qq
### Execution Patterns
| Pattern | Functions | How |
|---------|------------|-----|
| Captured | All state-modifying functions | capture output, write to log with single timestamp at start, write to terminal |
### One Timestamp Per Tool Call
Instead of streaming with MultiWriter (multiple timestamps), each state-modifying function:
1. Writes timestamp + operation name to log
2. Runs command, captures output
3. Writes captured output to log
4. Writes output to terminal
This ensures exactly 1 timestamp print per tool call.
### Error Handling
- Write error to log BEFORE returning from function
- Print error to stderr so user sees it
### Dependencies
- Add to imports: `os`, `path/filepath`
### Flow in Sync() or main entrypoint
1. Call `OpenLog()` at program start, defer close
2. Each state-modifying function calls `state.Write()` with timestamp prefix
### Wire into main.go
- Open log at start of `run()`
- Pass log writer to pacman package (via exported function or global)
@@ -0,0 +1,26 @@
## Why
The tool exits without saving the full pacman output, making debugging difficult
when operations fail. Users need a persistent log of all pacman operations for
debugging and audit.
## What Changes
- Add state directory initialization creating `~/.local/state/declpac` if not exists
- Open/manage a single log file at `/var/log/declpac.log`
- Instrument all state-modifying exec calls in `pkg/pacman/pacman.go` to tee or append output to this file
- Skip debug messages (internal timing logs)
- Capture and write errors before returning
## Capabilities
### New Capabilities
- Operation logging: Persist stdout/stderr from all pacman operations
### Modified Capabilities
- None
## Impact
- `pkg/pacman/pacman.go`: Instrument all state-modifying functions to write to log file
- New module: May create `pkg/state/state.go` or similar for log file management
@@ -0,0 +1,41 @@
## Tasks
- [x] 1. Create state module
Create `pkg/state/state.go`:
- `OpenLog()` - opens `/var/log/declpac.log` in append mode
- `GetLogWriter()` - returns the raw log file writer (for MultiWriter)
- `Write(msg []byte)` - writes message with timestamp + dashes separator
- `Close()` - closes the file
- [x] 2. Wire into main.go
In `cmd/declpac/main.go` `run()`:
- Call `OpenLog()` at start
- `defer Close()` log
- [x] 3. Instrument pkg/pacman
Modify `pkg/pacman/pacman.go`:
Each state-modifying function writes timestamp ONCE at start, then captures output:
- Write `timestamp - operation name` to log
- Run command, capture output
- Write captured output to log
- Write output to terminal
**Functions updated:**
- `SyncPackages()` - write output with timestamp
- `CleanupOrphans()` - write output with timestamp
- `MarkAllAsDeps()` - write operation name with timestamp before running
- `MarkAsExplicit()` - write operation name with timestamp before running
- `InstallAUR()` - write "Cloning ..." and "Building package..." with timestamps
- Error handling - `state.Write([]byte("error: ..."))` for all error paths
- [x] 4. Add io import
Add `io` to imports in pacman.go
- [x] 5. Test
Run a sync operation and verify log file created at `/var/log/declpac.log`
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-14
@@ -0,0 +1,137 @@
## Context
Currently, `pkg/pacman/pacman.go` uses subprocess calls to query pacman for package existence:
- `pacman -Qip <pkg>` to check local DB (per package)
- `pacman -Sip <pkg>` to check sync repos (per package)
For n packages, this spawns 2n subprocesses (up to ~300 for typical package lists). Each subprocess has fork/exec overhead, making this the primary performance bottleneck.
The AUR queries are already batched (single HTTP POST with all package names), which is the desired pattern.
## Goals / Non-Goals
**Goals:**
- Eliminate subprocess overhead for local/sync DB package lookups
- Maintain batched AUR HTTP calls (single request per batch)
- Track installed status per package in PackageInfo
- Provide dry-run output showing exact packages to install/remove
- Handle orphan cleanup correctly
**Non-Goals:**
- Parallel AUR builds (still sequential)
- Custom pacman transaction handling (use system pacman)
- Repository configuration changes
- Package download/compile optimization
## Decisions
### 1. Use Jguer/dyalpm for DB access
**Decision**: Use `github.com/Jguer/dyalpm` library instead of spawning subprocesses.
**Rationale**:
- Direct libalpm access (same backend as pacman)
- Already Go-native with proper type safety
- Supports batch operations via `GetPkgCache()` and `PkgCache()` iterators
**Alternatives considered**:
- Parse `pacman -Qs` output - fragile, still subprocess-based
- Write custom libalpm bindings - unnecessary effort
### 2. Single-pass package resolution algorithm
**Decision**: Process all packages through local DB → sync DBs → AUR in one pass.
```
For each package in collected state:
1. Check local DB (batch lookup) → if found, mark Installed=true
2. If not local, check all sync DBs (batch lookup per repo)
3. If not in sync, append to AUR batch
Batch query AUR with all remaining packages
Throw error if any package not found in local/sync/AUR
Collect installed status from local DB
(Perform sync operations - skip in dry-run)
(Mark ALL currently installed packages as deps - skip in dry-run)
(Then mark collected state packages as explicit - skip in dry-run)
(Cleanup orphans - skip in dry-run)
Output summary
```
**Rationale**:
- Single iteration over packages
- Batch DB lookups minimize libalpm calls
- Clear error handling for missing packages
- Consistent with existing behavior
### 3. Batch local/sync DB lookup implementation
**Decision**: For local DB, iterate `localDB.PkgCache()` once and build a map. For sync DBs, iterate each repo's `PkgCache()`.
**Implementation**:
```go
// Build local package map in one pass
localPkgs := make(map[string]bool)
localDB.PkgCache().ForEach(func(pkg alpm.Package) error {
localPkgs[pkg.Name()] = true
return nil
})
// Similarly for each sync DB
for _, syncDB := range syncDBs {
syncDB.PkgCache().ForEach(...)
}
```
**Rationale**:
- O(n) iteration where n = total packages in DB (not n queries)
- Single map construction, O(1) lookups per state package
- libalpm iterators are already lazy, no additional overhead
### 4. Dry-run behavior
**Decision**: Dry-run outputs exact packages that would be installed/removed without making any system changes.
**Implementation**:
- Skip `pacman -Syu` call
- Skip `pacman -D --asdeps` (mark all installed as deps)
- Skip `pacman -D --asexplicit` (mark state packages as explicit)
- Skip `pacman -Rns` orphan cleanup
- Still compute what WOULD happen for output
**Note on marking strategy**:
Instead of diffing between before/after installed packages, we simply:
1. After sync completes, run `pacman -D --asdeps` on ALL currently installed packages (this marks everything as deps)
2. Then run `pacman -D --asexplicit` on the collected state packages (this overrides them to explicit)
This is simpler and achieves the same result.
## Risks / Trade-offs
1. **[Risk]** dyalpm initialization requires root privileges
- **[Mitigation]** This is same as pacman itself; if user can't run pacman, declpac won't work
2. **[Risk]** libalpm state becomes stale if another pacman instance runs concurrently
- **[Mitigation]** Use proper locking, rely on pacman's own locking mechanism
3. **[Risk]** AUR packages still built sequentially
- **[Acceptable]** Parallel AUR builds out of scope for this change
4. **[Risk]** Memory usage for large package lists
- **[Mitigation]** Package map is ~100 bytes per package; 10k packages = ~1MB
## Migration Plan
1. Add `github.com/Jguer/dyalpm` to go.mod
2. Refactor `ValidatePackage()` to use dyalpm instead of subprocesses
3. Add `Installed bool` to `PackageInfo` struct
4. Implement new resolution algorithm in `categorizePackages()`
5. Update `Sync()` and `DryRun()` to use new algorithm
6. Test with various package combinations
7. Verify output matches previous behavior
## Open Questions
- **Q**: Should we also use dyalpm for `GetInstalledPackages()`?
- **A**: Yes, can use localDB.PkgCache().Collect() or iterate - aligns with overall approach
@@ -0,0 +1,35 @@
## Why
The current pacman implementation spawns multiple subprocesses per package (pacman -Qip, pacman -Sip) to check if packages exist in local/sync DBs or AUR. With many packages, this creates significant overhead. Using the Jguer/dyalpm library provides direct libalpm access for batch queries, eliminating subprocess overhead while maintaining the batched AUR HTTP calls.
## What Changes
- **Add dyalpm dependency**: Integrate Jguer/dyalpm library for direct libalpm access
- **Batch local DB check**: Use `localDB.PkgCache()` to check all packages at once instead of per-package `pacman -Qip`
- **Batch sync DB check**: Use `syncDBs[i].PkgCache()` to check all sync repos at once instead of per-package `pacman -Sip`
- **Enhance PackageInfo**: Add `Installed bool` field to track if package is already installed
- **New algorithm**: Implement unified package resolution flow:
1. Batch check local DB for all packages
2. Batch check sync DBs for remaining packages
3. Batch query AUR for non-found packages
4. Track installed status throughout
5. Perform sync operations with proper marking
6. Output summary of changes
## Capabilities
### New Capabilities
- `batch-package-resolution`: Unified algorithm that batch-resolves packages from local DB → sync DBs → AUR with proper installed tracking
- `dry-run-simulation`: Shows exact packages that would be installed/removed without making changes
### Modified Capabilities
- None - this is a pure optimization with no behavior changes visible to users
## Impact
- **Code**: `pkg/pacman/pacman.go` - refactored to use dyalpm
- **Dependencies**: Add Jguer/dyalpm to go.mod
- **APIs**: `ValidatePackage()` signature changes (returns installed status)
- **Performance**: O(n) subprocess calls → O(1) for local/sync DB checks
@@ -0,0 +1,72 @@
## ADDED Requirements
### Requirement: Batch package resolution from local, sync, and AUR databases
The system SHALL resolve packages in a single pass through local DB → sync DBs → AUR using batch operations to minimize subprocess/API calls.
#### Scenario: Package exists in local DB
- **WHEN** a package from collected state exists in the local database
- **THEN** the system SHALL mark it as found, set `Installed=true`, and exclude it from AUR queries
#### Scenario: Package exists in sync DB
- **WHEN** a package from collected state does NOT exist in local DB but exists in ANY enabled sync database
- **THEN** the system SHALL mark it as found, set `Installed=false`, and exclude it from AUR queries
#### Scenario: Package exists only in AUR
- **WHEN** a package from collected state does NOT exist in local or sync databases but exists in AUR
- **THEN** the system SHALL mark it as found with `InAUR=true`, set `Installed=false`, and use the cached AUR info
#### Scenario: Package not found anywhere
- **WHEN** a package from collected state is NOT in local DB, NOT in any sync DB, and NOT in AUR
- **THEN** the system SHALL return an error listing the unfound package(s)
#### Scenario: Batch AUR query
- **WHEN** multiple packages need AUR lookup
- **THEN** the system SHALL make a SINGLE HTTP request to AUR RPC with all package names (existing behavior preserved)
### Requirement: Efficient local DB lookup using dyalpm
The system SHALL use dyalpm's `PkgCache()` iterator to build a lookup map in O(n) time, where n is total packages in local DB, instead of O(n*m) subprocess calls.
#### Scenario: Build local package map
- **WHEN** initializing package resolution
- **THEN** the system SHALL iterate localDB.PkgCache() once and store all package names in a map for O(1) lookups
#### Scenario: Check package in local map
- **WHEN** checking if a package exists in local DB
- **THEN** the system SHALL perform an O(1) map lookup instead of spawning a subprocess
### Requirement: Efficient sync DB lookup using dyalpm
The system SHALL use each sync DB's `PkgCache()` iterator to check packages across all enabled repositories.
#### Scenario: Check package in sync DBs
- **WHEN** a package is not found in local DB
- **THEN** the system SHALL check all enabled sync databases using their iterators
#### Scenario: Package found in multiple sync repos
- **WHEN** a package exists in more than one sync repository (e.g., core and community)
- **THEN** the system SHALL use the first match found
### Requirement: Track installed status in PackageInfo
The system SHALL include an `Installed bool` field in `PackageInfo` to indicate whether the package is currently installed.
#### Scenario: Package is installed
- **WHEN** a package exists in the local database
- **THEN** `PackageInfo.Installed` SHALL be `true`
#### Scenario: Package is not installed
- **WHEN** a package exists only in sync DB or AUR (not in local DB)
- **THEN** `PackageInfo.Installed` SHALL be `false`
### Requirement: Mark installed packages as deps, then state packages as explicit
After package sync completes, the system SHALL mark all installed packages as dependencies, then override the collected state packages to be explicit. This avoids diffing before/after states.
#### Scenario: Mark all installed as deps
- **WHEN** package sync has completed (non-dry-run)
- **THEN** the system SHALL run `pacman -D --asdeps` to mark ALL currently installed packages as dependencies
#### Scenario: Override state packages to explicit
- **WHEN** all installed packages have been marked as deps
- **THEN** the system SHALL run `pacman -D --asexplicit` on the collected state packages, overriding their dependency status
#### Scenario: Dry-run skips marking
- **WHEN** operating in dry-run mode
- **THEN** the system SHALL NOT execute any `pacman -D` marking operations
@@ -0,0 +1,28 @@
## ADDED Requirements
### Requirement: Dry-run shows packages to install without making changes
In dry-run mode, the system SHALL compute what WOULD happen without executing any pacman operations.
#### Scenario: Dry-run lists packages to install
- **WHEN** dry-run is enabled and packages need to be installed
- **THEN** the system SHALL populate `Result.ToInstall` with all packages that would be installed (both sync and AUR)
#### Scenario: Dry-run lists packages to remove
- **WHEN** dry-run is enabled and orphan packages exist
- **THEN** the system SHALL populate `Result.ToRemove` with the list of orphan packages and `Result.Removed` with the count
#### Scenario: Dry-run skips pacman sync
- **WHEN** dry-run is enabled
- **THEN** the system SHALL NOT execute `pacman -Syu` for package installation
#### Scenario: Dry-run skips explicit/deps marking
- **WHEN** dry-run is enabled
- **THEN** the system SHALL NOT execute `pacman -D --asdeps` or `pacman -D --asexplicit`
#### Scenario: Dry-run skips orphan cleanup
- **WHEN** dry-run is enabled
- **THEN** the system SHALL NOT execute `pacman -Rns` for orphan removal
#### Scenario: Dry-run outputs count summary
- **WHEN** dry-run is enabled
- **THEN** the system SHALL still compute and output `Result.Installed` and `Result.Removed` counts as if the operations had run
@@ -0,0 +1,51 @@
## 1. Setup
- [x] 1.1 Add `github.com/Jguer/dyalpm` to go.mod
- [x] 1.2 Run `go mod tidy` to fetch dependencies
## 2. Core Refactoring
- [x] 2.1 Update `PackageInfo` struct to add `Installed bool` field
- [x] 2.2 Create `Pac` struct with `alpm.Handle` instead of just aurCache
- [x] 2.3 Implement `NewPac()` that initializes alpm handle and local/sync DBs
## 3. Package Resolution Algorithm
- [x] 3.1 Implement `buildLocalPkgMap()` - iterate localDB.PkgCache() to create lookup map
- [x] 3.2 Implement `checkSyncDBs()` - iterate each sync DB's PkgCache() to find packages
- [x] 3.3 Implement `resolvePackages()` - unified algorithm:
- Step 1: Check local DB for all packages (batch)
- Step 2: Check sync DBs for remaining packages (batch per repo)
- Step 3: Batch query AUR for remaining packages
- Step 4: Return error if any package unfound
- Step 5: Track installed status from local DB
## 4. Sync and DryRun Integration
- [x] 4.1 Refactor `Sync()` function to use new resolution algorithm
- [x] 4.2 Refactor `DryRun()` function to use new resolution algorithm
- [x] 4.3 Preserve AUR batched HTTP calls (existing `fetchAURInfo`)
- [x] 4.4 Preserve orphan cleanup logic (`CleanupOrphans()`)
## 5. Marking Operations
- [x] 5.1 Keep `MarkExplicit()` for marking state packages
- [x] 5.2 After sync, run `pacman -D --asdeps` on ALL installed packages (simplifies tracking)
- [x] 5.3 After deps marking, run `pacman -D --asexplicit` on collected state packages (overrides deps)
- [x] 5.4 Skip marking operations in dry-run mode
## 6. Cleanup and Output
- [x] 6.1 Remove subprocess-based `ValidatePackage()` implementation
- [x] 6.2 Remove subprocess-based `GetInstalledPackages()` implementation
- [x] 6.3 Update output summary to show installed/removed counts
- [x] 6.4 In dry-run mode, populate `ToInstall` and `ToRemove` lists
## 7. Testing
- [ ] 7.1 Test with packages in local DB only
- [ ] 7.2 Test with packages in sync DBs only
- [ ] 7.3 Test with AUR packages
- [ ] 7.4 Test with missing packages (should error)
- [ ] 7.5 Test dry-run mode output
- [ ] 7.6 Test orphan detection and cleanup
@@ -18,7 +18,6 @@ strategy without conflict resolution.
- **WHEN** stdin contains packages not in state files - **WHEN** stdin contains packages not in state files
- **THEN** those packages shall be added to the final state - **THEN** those packages shall be added to the final state
#### Scenario: Duplicate packages accumulate #### Scenario: Duplicate packages are deduplicated
- **WHEN** the same package appears in multiple inputs - **WHEN** the same package appears in multiple inputs
- **THEN** it shall be included multiple times in the final state (pacman - **THEN** it shall be included once in the final state (map deduplication)
handles duplicates)
@@ -17,7 +17,7 @@ After syncing, the system SHALL remove packages that are no longer required
#### Scenario: Orphan cleanup after sync #### Scenario: Orphan cleanup after sync
- **WHEN** sync operation completes successfully - **WHEN** sync operation completes successfully
- **THEN** system shall run pacman -Rsu to remove unneeded dependencies - **THEN** system shall run pacman -Rns to remove unneeded dependencies
- **AND** report the number of packages removed - **AND** report the number of packages removed
#### Scenario: Orphan cleanup respects explicitly installed #### Scenario: Orphan cleanup respects explicitly installed
@@ -10,7 +10,7 @@ The system SHALL execute pacman operations to install and upgrade all declared p
#### Scenario: No partial upgrades #### Scenario: No partial upgrades
- **WHEN** running pacman commands - **WHEN** running pacman commands
- **THEN** system shall use -Syu flag (full system upgrade) ensuring all packages are latest - **THEN** system shall use -S --needed flag (sync with skip if up-to-date) ensuring declared packages are present
#### Scenario: Package availability check #### Scenario: Package availability check
- **WHEN** a package from input is not in pacman repositories - **WHEN** a package from input is not in pacman repositories
@@ -47,7 +47,7 @@
- [x] 6.1 Get list of currently installed packages before sync - [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.2 Mark declared state packages as explicitly installed via pacman -D --explicit
- [x] 6.3 Run pacman sync operation (5.x series) - [x] 6.3 Run pacman sync operation (5.x series)
- [x] 6.4 Run pacman -Rsu to remove orphaned packages - [x] 6.4 Run pacman -Rns to remove orphaned packages
- [x] 6.5 Capture and report number of packages removed - [x] 6.5 Capture and report number of packages removed
- [x] 6.6 Handle case where no orphans exist (no packages removed) - [x] 6.6 Handle case where no orphans exist (no packages removed)
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-17
@@ -0,0 +1,44 @@
## Context
Current `Resolve` in `pkg/fetch/fetch.go`:
1. Initialize all packages with `Exists: true`
2. Check local DB, set `Installed: true` if found
3. Check sync DBs, set `Installed: false`
4. Check AUR, set `InAUR: true`
5. Error if not found anywhere
Bug: wrong default + wrong order + conflates two concerns.
## Goals / Non-Goals
**Goals:**
- Fix Resolve algorithm to correctly classify packages
- Separate "available" from "installed" concerns
**Non-Goals:**
- No new APIs or features
- No refactoring outside Resolve function
## Decisions
**Decision 1: Start with Exists: false**
- Default `Exists: false` (unknown) instead of `true`
- Only set true when confirmed in sync DBs
**Decision 2: Check sync DBs first**
- Order: sync → local → AUR
- First determine if package exists (anywhere)
- Then determine if installed (local only)
**Decision 3: Two-phase classification**
| Phase | Check | Sets |
|------|-------|-----|
| 1. Availability | sync DBs | Exists: true/false |
| 2. Installation | local DB | Installed: true/false |
| 3. Fallback | AUR | InAUR: true if not in sync |
## Risks / Trade-offs
- Minimal risk: localized fix only
- Trade-off: slight performance cost (check sync before local) - acceptable for correctness
@@ -0,0 +1,26 @@
## Why
The `Resolve` function in `pkg/fetch/fetch.go` has incorrect logic flow. It
initializes all packages with `Exists: true`, then checks local DB first, then
sync DBs, then AUR. This wrong order causes incorrect package state
classification - packages that exist only in AUR may be incorrectly marked as
found.
## What Changes
- Fix initialization: packages should start with `Exists: false` (unknown), not `true`
- Fix order: check sync DBs BEFORE local DB to determine availability
- Separate independent concerns: "available" (sync/AUR) from "installed" (local)
- All packages must validate: either `Exists: true` or `InAUR: true`
## Capabilities
### New Capabilities
- `package-resolve-logic`: Correct resolution algorithm for pacman packages
### Modified Capabilities
- None
## Impact
- `pkg/fetch/fetch.go`: `Resolve` function
@@ -0,0 +1,7 @@
## 1. Fix Resolve Function Logic
- [x] 1.1 Change initialization: start packages with Exists: false instead of true
- [x] 1.2 Reorder to check sync DBs BEFORE local DB
- [x] 1.3 Separate availability check (sync DBs) from installation check (local DB)
- [x] 1.4 Add AUR fallback for packages not in sync DBs
- [x] 1.5 Validate all packages have either Exists or InAUR before returning
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-14
@@ -0,0 +1,25 @@
## Context
The dyalpm library's `SyncDBs()` returns an empty slice because it doesn't automatically load sync databases from pacman.conf. This causes `resolvePackages()` to skip the sync DB search entirely and fall through to checking the AUR, which fails for official repo packages.
## Goals / Non-Goals
**Goals:**
- Register sync databases so `resolvePackages()` can find official repo packages
**Non-Goals:**
- Modify package installation logic
- Add support for custom repositories
## Decisions
1. **Register each repo manually** - After `handle.SyncDBs()` returns empty, loop through known repos (core, extra, multilib) and call `handle.RegisterSyncDB()` for each.
2. **Use hardcoded repo list** - Arch Linux standard repos are core, extra, multilib. This matches pacman.conf.
3. **Silent failure for missing repos** - If a repo isn't configured in pacman.conf, `RegisterSyncDB` will return a valid but empty DB. Filter by checking if `PkgCache()` has any packages.
## Risks / Trade-offs
- Hardcoded repo names may need updating if Arch adds/removes standard repos → Low risk, rare change
- Repo registration could fail silently → Mitigated by checking PkgCache count
@@ -0,0 +1,21 @@
## Why
The program fails to find packages that exist in official repositories (like `cmake` in `extra`). The dyalpm library's `SyncDBs()` returns an empty list, so the code never searches the sync databases and falls through to checking the AUR, which also doesn't have the package.
## What Changes
- Register sync databases manually after initializing the dyalpm handle
- Loop through known repos (core, extra, multilib) and call `RegisterSyncDB` for each
- Handle the case where a repo might not be configured in pacman.conf
## Capabilities
### New Capabilities
- None - this is a bug fix to existing functionality
### Modified Capabilities
- None
## Impact
- `pkg/fetch/fetch.go`: Modify `New()` function to register sync DBs after getting them from the handle
@@ -0,0 +1,7 @@
## ADDED Requirements
No new requirements - this is a bug fix to existing package resolution functionality.
## MODIFIED Requirements
No modified requirements.
@@ -0,0 +1,10 @@
## 1. Implement sync DB registration
- [x] 1.1 Modify Pac struct to include a method for registering sync DBs
- [x] 1.2 In New(), after getting empty syncDBs from handle, loop through ["core", "extra", "multilib"] and call RegisterSyncDB for each
- [x] 1.3 Filter out repos that have no packages (not configured in pacman.conf)
## 2. Test the fix
- [x] 2.1 Run `./declpac --dry-run --state <(echo "cmake")` and verify it resolves cmake from extra repo
- [x] 2.2 Test with other official repo packages (e.g., "git" from extra, "base" from core)
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-14
@@ -0,0 +1,141 @@
# Design: Refactor pkg Into Modular Packages
## New Structure
```
pkg/
├── pacman/ # write operations only
│ └── pacman.go
├── fetch/ # package resolution (NEW)
│ └── fetch.go
├── validation/ # DB freshness (existing, expand)
│ └── validation.go
├── output/ # (unchanged)
│ └── output.go
├── merge/ # (unchanged)
│ └── merge.go
└── input/ # (unchanged)
└── input.go
```
## Package Responsibilities
### pkg/fetch (NEW)
```
type Fetcher struct {
handle dyalpm.Handle
localDB dyalpm.Database
syncDBs []dyalpm.Database
aurCache map[string]AURPackage
}
func New() *Fetcher
func (f *Fetcher) Close() error
func (f *Fetcher) Resolve(packages []string) (map[string]*PackageInfo, error)
func (f *Fetcher) ListOrphans() ([]string, error)
```
Extracted from `pacman.go`:
- `buildLocalPkgMap()`
- `checkSyncDBs()`
- `resolvePackages()`
- AUR cache (`ensureAURCache()`, `fetchAURInfo()`)
### pkg/pacman (REFACTORED)
```
func Sync(packages []string) (*output.Result, error)
func DryRun(packages []string) (*output.Result, error)
func MarkAsExplicit(packages []string) error
func MarkAllAsDeps() error
func CleanupOrphans() (int, error)
```
Write actions only:
- `Sync()` - calls `Fetcher` for resolution, then pacman commands
- `DryRun()` - calls `Fetcher.Resolve()` + `ListOrphans()`
- `MarkAsExplicit()`, `MarkAllAsDeps()`
- `CleanupOrphans()` - calls `ListOrphans()` then removes
### pkg/validation (REFACTORED)
```
func CheckDBFreshness() error
```
Keep as-is: checks lock file age, auto-synces if stale.
Remove from `pacman.go`:
- `IsDBFresh()` - replaced by `CheckDBFreshness()`
- `SyncDB()` - called by validation when stale
### Orphan Deduplication
```go
// In fetch/fetch.go
func (f *Fetcher) ListOrphans() ([]string, error) {
cmd := exec.Command("pacman", "-Qdtq")
// ...
}
// In pacman/pacman.go
func CleanupOrphans() (int, error) {
orphans, err := fetcher.ListOrphans() // reuse
if err != nil || len(orphans) == 0 {
return 0, nil
}
// ... remove
}
func DryRun(...) (*output.Result, error) {
orphans, err := fetcher.ListOrphans() // reuse
// ...
}
```
## Data Structures Move to fetch
```go
// In fetch/fetch.go
type PackageInfo struct {
Name string
InAUR bool
Exists bool
Installed bool
AURInfo *AURPackage
syncPkg dyalpm.Package
}
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"`
}
```
## Import Changes
`pkg/pacman/fetch.go` will need:
- `github.com/Jguer/dyalpm`
- `github.com/Riyyi/declpac/pkg/output`
`pkg/pacman/pacman.go` will need:
- `github.com/Riyyi/declpac/pkg/fetch`
- `github.com/Riyyi/declpac/pkg/output`
## Dependencies to Add
- New import: `pkg/fetch` in `pacman` package
## No Changes To
- CLI entry points
- `output.Result` struct
- `input.ReadPackages()`
- `merge.Merge()`
@@ -0,0 +1,30 @@
# Proposal: Refactor pkg Into Modular Packages
## Summary
Split monolithic `pkg/pacman/pacman.go` into focused packages: `fetch` (package resolution), `validation` (DB checks), and keep `pacman` for write actions only. Also deduplicate orphan detection logic.
## Motivation
`pacman.go` is 645 lines doing too much:
- Package resolution (local + sync DBs + AUR)
- DB freshness checks
- Pacman write operations (sync, mark, clean)
- Orphan listing/cleanup
This violates single responsibility. Hard to test, reason about, or reuse. Also has duplication:
- `validation.CheckDBFreshness()` and `pacman.IsDBFresh()` both check DB freshness
- `listOrphans()` and `CleanupOrphans()` duplicate orphan detection
## Scope
- Extract package resolution to new `pkg/fetch/`
- Move DB freshness to `pkg/validation/` (keep `CheckDBFreshness()`)
- Keep only write actions in `pkg/pacman/`
- Deduplicate orphan logic: one function for listing, reuse in cleanup and dry-run
## Out of Scope
- No new features
- No API changes to CLI
- No changes to `pkg/output/`, `pkg/merge/`, `pkg/input/`
@@ -0,0 +1,15 @@
# Scope
## In Scope
- Extract package resolution from pacman.go to pkg/fetch
- Deduplicate orphan listing
- Keep pacman write operations in pacman package
- Maintain existing CLI API
## Out of Scope
- New features
- New package management backends (e.g., libalpm alternatives)
- Config file changes
- State file format changes
@@ -0,0 +1,38 @@
# Tasks: Refactor pkg Into Modular Packages
## Phase 1: Create pkg/fetch
- [x] 1.1 Create `pkg/fetch/fetch.go`
- [x] 1.2 Move `AURResponse`, `AURPackage`, `PackageInfo` structs to fetch
- [x] 1.3 Move `buildLocalPkgMap()` to fetch as `Fetcher.buildLocalPkgMap()`
- [x] 1.4 Move `checkSyncDBs()` to fetch as `Fetcher.checkSyncDBs()`
- [x] 1.5 Move `resolvePackages()` to fetch as `Fetcher.Resolve()`
- [x] 1.6 Move AUR cache methods (`ensureAURCache`, `fetchAURInfo`) to fetch
- [x] 1.7 Add `New()` and `Close()` to Fetcher
- [x] 1.8 Add `ListOrphans()` to Fetcher
## Phase 2: Refactor pkg/pacman
- [x] 2.1 Remove from pacman.go (now in fetch):
- `buildLocalPkgMap()`
- `checkSyncDBs()`
- `resolvePackages()`
- `ensureAURCache()`
- `fetchAURInfo()`
- `AURResponse`, `AURPackage`, `PackageInfo` structs
- [x] 2.2 Remove `IsDBFresh()` and `SyncDB()` (use validation instead)
- [x] 2.3 Update imports in pacman.go to include fetch package
- [x] 2.4 Update `Sync()` to use `fetch.Fetcher` for resolution
- [x] 2.5 Update `DryRun()` to call `fetcher.ListOrphans()` instead of duplicate call
- [x] 2.6 Update `CleanupOrphans()` to call `fetcher.ListOrphans()` instead of duplicate call
## Phase 3: Clean Up Validation
- [x] 3.1 Keep `validation.CheckDBFreshness()` as-is
- [x] 3.2 Remove any remaining DB freshness duplication
## Phase 4: Verify
- [x] 4.1 Run tests (if any exist)
- [x] 4.2 Build: `go build ./...`
- [x] 4.3 Verify CLI still works: test dry-run, sync, orphan cleanup
@@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: Package existence check via sync DBs
When resolving packages, the system SHALL first check sync databases (core, extra, multilib) to determine if a package exists in official repositories. If found in sync DBs, `Exists` SHALL be set to true.
#### Scenario: Package found in sync DB
- **WHEN** package name exists in any sync database
- **THEN** set `Exists: true`, `InAUR: false`
#### Scenario: Package not found in sync DB
- **WHEN** package name does not exist in any sync database
- **THEN** keep `Exists: false` for further lookup
### Requirement: Package installation check via local database
After checking sync DBs, the system SHALL check the local package database to determine if a package is installed. This is independent of existence check.
#### Scenario: Package installed locally
- **WHEN** package is installed on the system
- **THEN** set `Installed: true`
#### Scenario: Package not installed locally
- **WHEN** package is not installed on the system
- **THEN** set `Installed: false`
### Requirement: AUR fallback check
If package is not found in sync DBs, the system SHALL check the AUR as a fallback.
#### Scenario: Package found in AUR
- **WHEN** package exists in AUR but not in sync DBs
- **THEN** set `InAUR: true`
#### Scenario: Package not found anywhere
- **WHEN** package not in sync DBs, not in local DB, not in AUR
- **THEN** return error "package not found"
### Requirement: Validation at resolution end
After all checks complete, the system SHALL ensure every package has either `Exists: true` or `InAUR: true`. No package SHALL leave the resolver in an ambiguous state.
#### Scenario: All packages valid
- **WHEN** all packages resolved successfully
- **THEN** every package has Exists=true OR InAUR=true
+104
View File
@@ -0,0 +1,104 @@
package auth
import (
"fmt"
"os/exec"
"regexp"
"strconv"
"time"
"github.com/Riyyi/declpac/pkg/log"
)
var tool string
var timeout time.Duration = 5 * time.Minute
var refreshCommand []string = []string{"-n", "true"}
// -----------------------------------------
// public
func Command(name string, args ...string) *exec.Cmd {
if tool == "" {
return log.Command(name, args...)
}
args = append([]string{name}, args...)
return log.Command(tool, args...)
}
func Run() {
exec.Command(tool, refreshCommand...).Run()
}
func Start() error {
err := detect()
if err != nil {
return err
}
// Automatically refresh privilege elevation to prevent user prompts
go func() {
for {
Run()
time.Sleep(timeout)
}
}()
return nil
}
// -----------------------------------------
// private
func detect() error {
tool = getTool()
if tool == "" {
return fmt.Errorf("no privilege elevation tool detected in PATH")
}
parseTimeout()
// We have to be a little faster than the actual timeout
timeout -= 30 * time.Second
return nil
}
func execLookPath(name string) string {
path, err := exec.LookPath(name)
if err != nil {
return ""
}
return path
}
func getTool() string {
sudo := execLookPath("sudo")
doas := execLookPath("doas")
if sudo != "" {
return "sudo"
}
if doas != "" {
return "doas"
}
return ""
}
func parseTimeout() {
switch tool {
case "sudo":
out, err := exec.Command("sudo", "sudo", "-V").CombinedOutput()
if err != nil {
return
}
re := regexp.MustCompile(`Authentication timestamp timeout: (\d+)\..*`)
matches := re.FindStringSubmatch(string(out))
if len(matches) == 2 {
if minutes, err := strconv.Atoi(matches[1]); err == nil {
timeout = time.Duration(minutes) * time.Minute
}
}
case "doas":
exec.Command("doas", "true").Run()
}
}
+148
View File
@@ -0,0 +1,148 @@
package alpm
import (
"fmt"
"time"
"github.com/Jguer/dyalpm"
"github.com/Riyyi/declpac/pkg/log"
)
var (
Root = "/"
PacmanState = "/var/lib/pacman"
)
type Handle struct {
handle dyalpm.Handle
localDB dyalpm.Database
syncDBs []dyalpm.Database
}
func (h *Handle) LocalPackages() (map[string]dyalpm.Package, error) {
start := time.Now()
log.Debug("LocalPackages: starting...")
localPkgs := make(map[string]dyalpm.Package)
err := h.localDB.PkgCache().ForEach(func(pkg dyalpm.Package) error {
localPkgs[pkg.Name()] = pkg
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to iterate local package cache: %w", err)
}
log.Debug("LocalPackages: done (%.2fs)", time.Since(start).Seconds())
return localPkgs, nil
}
func (h *Handle) FindProvidingPackage(depName string) (string, bool) {
pkg := h.handle.FindDBSatisfier(h.syncDBs, depName)
if pkg != nil {
return pkg.Name(), true
}
return "", false
}
func (h *Handle) Release() error {
if h.handle != nil {
h.handle.Release()
}
return nil
}
func (h *Handle) SyncPackages(pkgNames []string) (map[string]dyalpm.Package, error) {
start := time.Now()
log.Debug("SyncPackages: starting...")
syncPkgs := make(map[string]dyalpm.Package)
pkgSet := make(map[string]bool)
for _, name := range pkgNames {
pkgSet[name] = true
}
for _, db := range h.syncDBs {
err := db.PkgCache().ForEach(func(pkg dyalpm.Package) error {
if pkgSet[pkg.Name()] {
if _, exists := syncPkgs[pkg.Name()]; !exists {
syncPkgs[pkg.Name()] = pkg
}
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to iterate sync database %s: %w", db.Name(), err)
}
}
log.Debug("SyncPackages: done (%.2fs)", time.Since(start).Seconds())
return syncPkgs, nil
}
func New() (*Handle, error) {
start := time.Now()
log.Debug("alpm.New: starting...")
handle, err := dyalpm.Initialize(Root, PacmanState)
if err != nil {
return nil, fmt.Errorf("failed to initialize alpm: %w", err)
}
localDB, err := handle.LocalDB()
if err != nil {
handle.Release()
return nil, fmt.Errorf("failed to get local database: %w", err)
}
syncDBs, err := handle.SyncDBs()
if err != nil {
handle.Release()
return nil, fmt.Errorf("failed to get sync databases: %w", err)
}
if len(syncDBs) == 0 {
syncDBs, err = registerSyncDBs(handle)
if err != nil {
handle.Release()
return nil, fmt.Errorf("failed to register sync databases: %w", err)
}
}
log.Debug("alpm.New: done (%.2fs)", time.Since(start).Seconds())
return &Handle{
handle: handle,
localDB: localDB,
syncDBs: syncDBs,
}, nil
}
// -----------------------------------------
// private
func registerSyncDBs(handle dyalpm.Handle) ([]dyalpm.Database, error) {
log.Debug("registerSyncDBs: starting...")
repos := []string{"core", "extra", "multilib"}
var dbs []dyalpm.Database
for _, repo := range repos {
db, err := handle.RegisterSyncDB(repo, 0)
if err != nil {
continue
}
count := 0
db.PkgCache().ForEach(func(pkg dyalpm.Package) error {
count++
return nil
})
if count > 0 {
dbs = append(dbs, db)
}
}
log.Debug("registerSyncDBs: done (%d dbs)", len(dbs))
return dbs, nil
}
+100
View File
@@ -0,0 +1,100 @@
package aur
import (
"encoding/json"
"io"
"net/http"
"net/url"
"time"
"github.com/Riyyi/declpac/pkg/log"
)
var AURInfoURL = "https://aur.archlinux.org/rpc?v=5&type=info"
type Package struct {
Name string `json:"Name"`
PackageBase string `json:"PackageBase"`
Version string `json:"Version"`
URL string `json:"URL"`
Depends []string `json:"Depends"`
MakeDepends []string `json:"MakeDepends"`
}
func (p Package) AllDepends() []string {
return append(p.Depends, p.MakeDepends...)
}
type Response struct {
Results []Package `json:"results"`
}
type Client struct {
cache map[string]Package
}
func New() *Client {
return &Client{
cache: make(map[string]Package),
}
}
func (c *Client) Fetch(packages []string) (map[string]Package, error) {
start := time.Now()
log.Debug("aur.Fetch: starting...")
result := make(map[string]Package)
if len(packages) == 0 {
return result, nil
}
var uncached []string
for _, pkg := range packages {
if _, ok := c.cache[pkg]; !ok {
uncached = append(uncached, pkg)
}
}
if len(uncached) == 0 {
log.Debug("aur.Fetch: done (cached) (%.2fs)", time.Since(start).Seconds())
for _, pkg := range packages {
result[pkg] = c.cache[pkg]
}
return result, nil
}
v := url.Values{}
for _, pkg := range packages {
v.Add("arg[]", pkg)
}
resp, err := http.Get(AURInfoURL + "&" + v.Encode())
if err != nil {
return result, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return result, err
}
var aurResp Response
if err := json.Unmarshal(body, &aurResp); err != nil {
return result, err
}
for _, r := range aurResp.Results {
c.cache[r.Name] = r
result[r.Name] = r
}
log.Debug("aur.Fetch: done (%.2fs)", time.Since(start).Seconds())
return result, nil
}
func (c *Client) Get(name string) (Package, bool) {
pkg, ok := c.cache[name]
return pkg, ok
}
+154
View File
@@ -0,0 +1,154 @@
package fetch
import (
"fmt"
"time"
"github.com/Riyyi/declpac/pkg/fetch/alpm"
"github.com/Riyyi/declpac/pkg/fetch/aur"
"github.com/Riyyi/declpac/pkg/log"
)
type PackageInfo struct {
Name string
InAUR bool
Exists bool
Installed bool
Provided string
AURInfo *aur.Package
}
type Fetcher struct {
alpmHandle *alpm.Handle
aurClient *aur.Client
}
// -----------------------------------------
// constructor
func New() (*Fetcher, error) {
start := time.Now()
log.Debug("fetch.Fetcher New: starting...")
alpmHandle, err := alpm.New()
if err != nil {
return nil, err
}
aurClient := aur.New()
log.Debug("fetch.Fetcher New: done (%.2fs)", time.Since(start).Seconds())
return &Fetcher{
alpmHandle: alpmHandle,
aurClient: aurClient,
}, nil
}
// -----------------------------------------
// public
func (f *Fetcher) BuildLocalPkgMap() (map[string]interface{}, error) {
localPkgs, err := f.alpmHandle.LocalPackages()
if err != nil {
return nil, err
}
result := make(map[string]interface{})
for k, v := range localPkgs {
result[k] = v
}
return result, nil
}
func (f *Fetcher) Close() error {
return f.alpmHandle.Release()
}
func (f *Fetcher) FetchAur(packages []string) (map[string]aur.Package, error) {
return f.aurClient.Fetch(packages)
}
func (f *Fetcher) FindProvidingPackage(depName string) (string, bool) {
return f.alpmHandle.FindProvidingPackage(depName)
}
func (f *Fetcher) GetAURPackage(name string) (aur.Package, bool) {
return f.aurClient.Get(name)
}
func (f *Fetcher) Resolve(packages []string) (map[string]*PackageInfo, error) {
start := time.Now()
log.Debug("fetch.Resolve: starting...")
result := make(map[string]*PackageInfo)
for _, pkg := range packages {
result[pkg] = &PackageInfo{Name: pkg, Exists: false}
}
syncPkgs, err := f.alpmHandle.SyncPackages(packages)
if err != nil {
return nil, err
}
log.Debug("fetch.Resolve: sync db check done (%.2fs)", time.Since(start).Seconds())
for pkg := range syncPkgs {
result[pkg].Exists = true
result[pkg].InAUR = false
}
localPkgs, err := f.alpmHandle.LocalPackages()
if err != nil {
return nil, err
}
log.Debug("fetch.Resolve: local pkgs built (%.2fs)", time.Since(start).Seconds())
for pkg := range localPkgs {
if info, ok := result[pkg]; ok {
info.Installed = true
}
}
var notInSync []string
for _, pkg := range packages {
if !result[pkg].Exists {
notInSync = append(notInSync, pkg)
}
}
if len(notInSync) > 0 {
if _, err := f.aurClient.Fetch(notInSync); err != nil {
log.Debug("fetch.Resolve: aur fetch error: %v", err)
}
for _, pkg := range packages {
info := result[pkg]
if info.Exists {
continue
}
if aurInfo, ok := f.aurClient.Get(pkg); ok {
info.InAUR = true
info.AURInfo = &aurInfo
continue
}
if providedBy, ok := f.FindProvidingPackage(pkg); ok {
log.Debug("fetch.Resolve: %s provided by %s", pkg, providedBy)
info.Provided = providedBy
info.Exists = true
continue
}
return nil, fmt.Errorf("package not found: %s", pkg)
}
}
for _, pkg := range packages {
info := result[pkg]
if !info.Exists && !info.InAUR {
return nil, fmt.Errorf("package not validated: %s", pkg)
}
}
log.Debug("fetch.Resolve: done (%.2fs)", time.Since(start).Seconds())
return result, nil
}
+54 -5
View File
@@ -2,15 +2,43 @@ package input
import ( import (
"bufio" "bufio"
"errors"
"os" "os"
"path/filepath"
"strings" "strings"
"github.com/Riyyi/declpac/pkg/lib"
) )
var ErrEmptyList = errors.New("package list is empty")
// -----------------------------------------
// public
func Merge(packages map[string]bool) ([]string, error) {
result := make([]string, 0, len(packages))
for name := range packages {
result = append(result, name)
}
if len(result) == 0 {
return nil, ErrEmptyList
}
return result, nil
}
func ReadPackages(stateFiles []string) (map[string]bool, error) { func ReadPackages(stateFiles []string) (map[string]bool, error) {
packages := make(map[string]bool) packages := make(map[string]bool)
for _, file := range stateFiles { for _, file := range stateFiles {
if err := readStateFile(file, packages); err != nil { expanded := lib.ExpandPath(file)
if err := readStateFile(expanded, packages); err != nil {
return nil, err
}
}
implicitStateFile := getImplicitStateFile()
if fileExists(implicitStateFile) {
if err := readStateFile(implicitStateFile, packages); err != nil {
return nil, err return nil, err
} }
} }
@@ -22,6 +50,31 @@ func ReadPackages(stateFiles []string) (map[string]bool, error) {
return packages, nil return packages, nil
} }
// -----------------------------------------
// private
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func getImplicitStateFile() string {
cfgDir := os.Getenv("XDG_CONFIG_HOME")
if cfgDir == "" {
cfgDir = "~/.config"
}
cfgDir = lib.ExpandPath(cfgDir)
return filepath.Join(cfgDir, "declpac")
}
func normalizePackageName(name string) string {
name = strings.TrimSpace(name)
if name == "" || strings.HasPrefix(name, "#") {
return ""
}
return name
}
func readStateFile(path string, packages map[string]bool) error { func readStateFile(path string, packages map[string]bool) error {
file, err := os.Open(path) file, err := os.Open(path)
if err != nil { if err != nil {
@@ -60,7 +113,3 @@ func readStdin(packages map[string]bool) error {
return scanner.Err() return scanner.Err()
} }
func normalizePackageName(name string) string {
return strings.TrimSpace(name)
}
+21
View File
@@ -0,0 +1,21 @@
package lib
import (
"os"
"path/filepath"
"strings"
)
// -----------------------------------------
// public
func ExpandPath(path string) string {
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return path
}
return filepath.Join(home, path[2:])
}
return path
}
+74
View File
@@ -0,0 +1,74 @@
package log
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/Riyyi/declpac/pkg/lib"
)
var logFile *os.File
var Verbose bool
// -----------------------------------------
// public
func Close() error {
if logFile == nil {
return nil
}
return logFile.Close()
}
func Command(name string, args ...string) *exec.Cmd {
cmdStr := name + " " + strings.Join(args, " ")
fmt.Fprintf(logFile, "[cmd] %s\n", cmdStr)
return exec.Command(name, args...)
}
func Debug(format string, args ...any) {
if !Verbose {
return
}
fmt.Fprintf(os.Stderr, "[debug] "+format+"\n", args...)
}
func GetLogWriter() io.Writer {
return logFile
}
func OpenLog() error {
stateDir := os.Getenv("XDG_STATE_HOME")
if stateDir == "" {
stateDir = "~/.local/state"
}
stateDir = lib.ExpandPath(stateDir)
logPath := filepath.Join(stateDir, "declpac.log")
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
logFile = f
writeTimestamp()
return nil
}
func Write(msg []byte) {
logFile.Write(msg)
}
// -----------------------------------------
// private
func writeTimestamp() {
ts := time.Now().Format("2006-01-02 15:04:05")
header := fmt.Sprintf("\n--- %s ---\n", ts)
logFile.Write([]byte(header))
}
-9
View File
@@ -1,9 +0,0 @@
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
}
+6 -3
View File
@@ -12,15 +12,18 @@ type Result struct {
ToRemove []string ToRemove []string
} }
// -----------------------------------------
// public
func Format(r *Result) string { func Format(r *Result) string {
var b strings.Builder var b strings.Builder
b.WriteString(fmt.Sprintf("Installed %d packages, removed %d packages", r.Installed, r.Removed)) b.WriteString(fmt.Sprintf("installed %d packages, removed %d packages", r.Installed, r.Removed))
if len(r.ToInstall) > 0 { if len(r.ToInstall) > 0 {
b.WriteString("\nWould install: ") b.WriteString("\nwould install: ")
b.WriteString(strings.Join(r.ToInstall, ", ")) b.WriteString(strings.Join(r.ToInstall, ", "))
} }
if len(r.ToRemove) > 0 { if len(r.ToRemove) > 0 {
b.WriteString("\nWould remove: ") b.WriteString("\nwould remove: ")
b.WriteString(strings.Join(r.ToRemove, ", ")) b.WriteString(strings.Join(r.ToRemove, ", "))
} }
return b.String() return b.String()
+147 -310
View File
@@ -1,380 +1,217 @@
package pacman package pacman
import ( import (
"encoding/json"
"fmt" "fmt"
"io"
"net/http"
"net/url"
"os" "os"
"os/exec" "slices"
"regexp"
"strings"
"time" "time"
"github.com/Riyyi/declpac/pkg/fetch"
"github.com/Riyyi/declpac/pkg/log"
"github.com/Riyyi/declpac/pkg/output" "github.com/Riyyi/declpac/pkg/output"
"github.com/Riyyi/declpac/pkg/pacman/read"
"github.com/Riyyi/declpac/pkg/pacman/sync"
) )
var ( func Sync(packages []string, noCheck bool, prune bool) (*output.Result, error) {
Root = "/" start := time.Now()
LockFile = "/var/lib/pacman/db.lock" log.Debug("Sync: starting...")
AURInfoURL = "https://aur.archlinux.org/rpc?v=5&type=info"
)
type Pac struct { explicitList, err := read.ExplicitList()
aurCache map[string]AURPackage
}
func New() (*Pac, error) {
return &Pac{aurCache: make(map[string]AURPackage)}, nil
}
func (p *Pac) Close() error {
return nil
}
type PackageInfo struct {
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) {
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
}
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
}
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 { if err != nil {
return nil, err return nil, err
} }
explicitCount := len(explicitList)
packages := strings.Split(strings.TrimSpace(string(output)), "\n") if !noCheck && len(packages) < explicitCount/2 {
return packages, nil errMsg := "safety check: state packages (%d) less than half of explicitly installed (%d), override with --nocheck"
return nil, fmt.Errorf(errMsg, len(packages), explicitCount)
} }
func (p *Pac) MarkExplicit(pkgName string) error { list, err := read.List()
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 { if err != nil {
return nil, err return nil, err
} }
before := len(list)
p, err := New() fresh, err := read.DBFreshness()
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer p.Close() if !fresh {
if err := sync.RefreshDB(log.GetLogWriter()); err != nil {
return nil, err
}
}
log.Debug("Sync: database fresh (%.2fs)", time.Since(start).Seconds())
fresh, err := p.IsDBFresh() f, err := fetch.New()
if err != nil || !fresh { if err != nil {
if err := p.SyncDB(); err != nil { return nil, err
return nil, fmt.Errorf("failed to sync database: %w", err)
}
} }
defer f.Close()
log.Debug("Sync: initialized fetcher (%.2fs)", time.Since(start).Seconds())
pacmanPkgs, aurPkgs := p.categorizePackages(packages) log.Debug("Sync: categorizing packages...")
pacmanPkgs, aurPkgs, err := categorizePackages(f, packages)
for _, pkg := range packages { if err != nil {
if err := p.MarkExplicit(pkg); err != nil { return nil, err
fmt.Fprintf(os.Stderr, "warning: could not mark %s as explicit: %v\n", pkg, err)
}
} }
log.Debug("Sync: packages categorized (%.2fs)", time.Since(start).Seconds())
if len(pacmanPkgs) > 0 { if len(pacmanPkgs) > 0 {
_, err = p.SyncPackages(pacmanPkgs) log.Debug("Sync: syncing %d pacman packages...", len(pacmanPkgs))
if err != nil { if err := sync.SyncPackages(pacmanPkgs, log.GetLogWriter()); err != nil {
return nil, err return nil, err
} }
log.Debug("Sync: pacman packages synced (%.2fs)", time.Since(start).Seconds())
} }
for _, pkg := range aurPkgs { for _, pkg := range aurPkgs {
if err := p.InstallAUR(pkg); err != nil { if slices.Contains(list, pkg) {
continue
}
log.Debug("Sync: installing AUR package %s...", pkg)
aurInfo, ok := f.GetAURPackage(pkg)
if !ok {
return nil, fmt.Errorf("AUR package not found in cache: %s", pkg)
}
if err := sync.InstallAUR(f, pkg, aurInfo.PackageBase, false, log.GetLogWriter()); err != nil {
return nil, err return nil, err
} }
log.Debug("Sync: AUR package %s installed (%.2fs)", pkg, time.Since(start).Seconds())
} }
removed, err := p.CleanupOrphans() var removed int
if prune {
log.Debug("Sync: running prune sanity check...")
if err := pruneSanityCheck(packages); err != nil {
return nil, err
}
log.Debug("Sync: prune sanity check passed (%.2fs)", time.Since(start).Seconds())
log.Debug("Sync: marking all as deps...")
if err := markAllAsDeps(); err != nil {
return nil, err
}
log.Debug("Sync: all marked as deps (%.2fs)", time.Since(start).Seconds())
log.Debug("Sync: marking state packages as explicit...")
if err := sync.MarkAs(packages, "explicit", log.GetLogWriter()); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not mark state packages as explicit: %v\n", err)
}
log.Debug("Sync: state packages marked as explicit (%.2fs)", time.Since(start).Seconds())
removed, err = cleanupOrphans()
if err != nil { if err != nil {
return nil, err return nil, err
} }
}
list, _ = read.List()
if err != nil {
return nil, err
}
after := len(list)
after, _ := getInstalledCount()
installedCount := max(after-before, 0) installedCount := max(after-before, 0)
log.Debug("Sync: done (%.2fs)", time.Since(start).Seconds())
return &output.Result{ return &output.Result{
Installed: installedCount, Installed: installedCount,
Removed: removed, Removed: removed,
}, nil }, nil
} }
func (p *Pac) categorizePackages(packages []string) (pacmanPkgs, aurPkgs []string) { // -----------------------------------------
var notInPacman []string // private
func categorizePackages(f *fetch.Fetcher, packages []string) (pacmanPkgs, aurPkgs []string, err error) {
start := time.Now()
log.Debug("categorizePackages: starting...")
resolved, err := f.Resolve(packages)
if err != nil {
return nil, nil, err
}
for _, pkg := range packages { for _, pkg := range packages {
info, err := p.ValidatePackage(pkg) info := resolved[pkg]
if err != nil || !info.Exists { if info == nil || (!info.Exists && !info.InAUR) {
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) fmt.Fprintf(os.Stderr, "error: package not found: %s\n", pkg)
} continue
}
}
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()
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
}
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 { if info.InAUR {
aurPkgs = append(aurPkgs, pkg) aurPkgs = append(aurPkgs, pkg)
} else { } else {
toInstall = append(toInstall, pkg) pacmanPkgs = append(pacmanPkgs, pkg)
}
} }
} }
orphans, err := p.listOrphans() log.Debug("categorizePackages: done (%.2fs)", time.Since(start).Seconds())
return pacmanPkgs, aurPkgs, nil
}
func cleanupOrphans() (int, error) {
start := time.Now()
log.Debug("cleanupOrphans: starting...")
orphans, err := read.ListOrphans()
if err != nil { if err != nil {
return nil, err log.Write([]byte(fmt.Sprintf("error: %v\n", err)))
return 0, err
} }
return &output.Result{ removed, err := sync.RemoveOrphans(orphans, log.GetLogWriter())
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 { if err != nil {
return nil, nil log.Write([]byte(fmt.Sprintf("error: %v\n", err)))
return 0, err
} }
list := strings.TrimSpace(string(orphans)) log.Debug("cleanupOrphans: done (%.2fs)", time.Since(start).Seconds())
if list == "" { return removed, nil
return nil, nil
} }
return strings.Split(list, "\n"), nil func markAllAsDeps() error {
start := time.Now()
log.Debug("markAllAsDeps: starting...")
packages, err := read.List()
if err != nil || len(packages) == 0 {
return fmt.Errorf("failed to list packages: %w", err)
}
if err := sync.MarkAs(packages, "deps", log.GetLogWriter()); err != nil {
log.Write([]byte(fmt.Sprintf("error: %v\n", err)))
return err
}
log.Debug("markAllAsDeps: done (%.2fs)", time.Since(start).Seconds())
return nil
}
// pruneSanityCheck checks if the installation of all state packages succeeded,
// before attempting to do package marking and orphan cleanup.
func pruneSanityCheck(statePackages []string) error {
start := time.Now()
log.Debug("pruneSanityCheck: starting...")
localPackages, err := read.List()
if err != nil {
return fmt.Errorf("failed to list local packages: %w", err)
}
var missing []string
for _, pkg := range statePackages {
if !slices.Contains(localPackages, pkg) {
missing = append(missing, pkg)
}
}
if len(missing) > 0 {
return fmt.Errorf("safety check: missing state packages: %v", missing)
}
log.Debug("pruneSanityCheck: done (%.2fs)", time.Since(start).Seconds())
return nil
} }
+149
View File
@@ -0,0 +1,149 @@
package read
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/Riyyi/declpac/pkg/fetch"
"github.com/Riyyi/declpac/pkg/log"
"github.com/Riyyi/declpac/pkg/output"
)
var LockFile = "/var/lib/pacman/db.lock"
func DBFreshness() (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 DryRun(packages []string) (*output.Result, error) {
start := time.Now()
log.Debug("DryRun: starting...")
f, err := fetch.New()
if err != nil {
return nil, err
}
defer f.Close()
log.Debug("DryRun: initialized fetcher (%.2fs)", time.Since(start).Seconds())
resolved, err := f.Resolve(packages)
if err != nil {
return nil, err
}
log.Debug("DryRun: packages resolved (%.2fs)", time.Since(start).Seconds())
var toInstall []string
var aurPkgs []string
for _, pkg := range packages {
info := resolved[pkg]
if info == nil || (!info.Exists && !info.InAUR) {
return nil, fmt.Errorf("package not found: %s", pkg)
}
if info.InAUR && !info.Installed {
aurPkgs = append(aurPkgs, pkg)
} else if !info.Installed {
toInstall = append(toInstall, pkg)
}
}
log.Debug("DryRun: packages categorized (%.2fs)", time.Since(start).Seconds())
orphans, err := ListOrphans()
if err != nil {
return nil, err
}
log.Debug("DryRun: orphans listed (%.2fs)", time.Since(start).Seconds())
pkgSet := make(map[string]bool)
for _, p := range packages {
pkgSet[p] = true
}
var toRemove []string
for _, o := range orphans {
if !pkgSet[o] {
toRemove = append(toRemove, o)
}
}
log.Debug("DryRun: done (%.2fs)", time.Since(start).Seconds())
return &output.Result{
Installed: len(toInstall) + len(aurPkgs),
Removed: len(toRemove),
ToInstall: append(toInstall, aurPkgs...),
ToRemove: toRemove,
}, nil
}
func ExplicitList() ([]string, error) {
start := time.Now()
log.Debug("ExplicitList: starting...")
cmd := exec.Command("pacman", "-Qqe")
output, err := cmd.Output()
if err != nil {
return nil, err
}
list := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(list) > 0 && list[0] == "" {
list = nil
}
log.Debug("ExplicitList: done (%.2fs)", time.Since(start).Seconds())
return list, nil
}
func List() ([]string, error) {
start := time.Now()
log.Debug("List: starting...")
cmd := exec.Command("pacman", "-Qq")
output, err := cmd.Output()
if err != nil {
return nil, err
}
list := strings.Split(strings.TrimSpace(string(output)), "\n")
if list[0] == "" {
list = nil
}
log.Debug("List: done (%.2fs)", time.Since(start).Seconds())
return list, nil
}
func ListOrphans() ([]string, error) {
start := time.Now()
log.Debug("ListOrphans: starting...")
cmd := exec.Command("pacman", "-Qdtq")
var stderr bytes.Buffer
cmd.Stderr = &stderr
output, err := cmd.Output()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 && stderr.Len() == 0 {
return nil, nil
}
return nil, err
}
orphans := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(orphans) > 0 && orphans[0] == "" {
orphans = orphans[1:]
}
log.Debug("ListOrphans: done (%.2fs)", time.Since(start).Seconds())
return orphans, nil
}
+318
View File
@@ -0,0 +1,318 @@
package sync
import (
"fmt"
"io"
"os"
"os/user"
"strings"
"time"
"github.com/Riyyi/declpac/pkg/auth"
"github.com/Riyyi/declpac/pkg/fetch"
"github.com/Riyyi/declpac/pkg/fetch/aur"
"github.com/Riyyi/declpac/pkg/log"
)
type Result struct {
Installed int
Removed int
}
// -----------------------------------------
// public
func InstallAUR(f *fetch.Fetcher, pkgName string, packageBase string, asDeps bool, logWriter io.Writer) error {
start := time.Now()
log.Debug("InstallAUR: starting...")
if logWriter == nil {
logWriter = os.Stderr
}
aurInfo := getAURInfo(f, pkgName, packageBase)
if err := resolveAndInstallDeps(f, aurInfo, logWriter); err != nil {
return err
}
tmpDir := getTempDirName() + "/" + pkgName
if err := createTempDir(tmpDir); err != nil {
return err
}
defer os.RemoveAll(tmpDir)
if err := cloneRepo(packageBase, tmpDir, logWriter); err != nil {
return err
}
log.Debug("InstallAUR: cloned (%.2fs)", time.Since(start).Seconds())
if err := buildPackage(tmpDir, asDeps, logWriter); err != nil {
return err
}
log.Debug("InstallAUR: built (%.2fs)", time.Since(start).Seconds())
pkgFile, err := findPKGFile(pkgName, tmpDir)
if err != nil {
return fmt.Errorf("failed to find built package: %w", err)
}
if err := installBuiltPackage(pkgFile, logWriter); err != nil {
return err
}
log.Debug("InstallAUR: done (%.2fs)", time.Since(start).Seconds())
return nil
}
func MarkAs(packages []string, flag string, logWriter io.Writer) error {
if len(packages) == 0 {
return nil
}
start := time.Now()
flagName := map[string]string{"deps": "asdeps", "explicit": "asexplicit"}[flag]
log.Debug("MarkAs(%s): starting...", flag)
if logWriter == nil {
logWriter = os.Stderr
}
args := append([]string{"-D", "--" + flagName}, packages...)
cmd := auth.Command("pacman", args...)
cmd.Stdout = logWriter
cmd.Stderr = logWriter
err := cmd.Run()
if err != nil {
return fmt.Errorf("mark as %s failed: %w", flag, err)
}
log.Debug("MarkAs(%s): done (%.2fs)", flag, time.Since(start).Seconds())
return nil
}
func RefreshDB(logWriter io.Writer) error {
start := time.Now()
log.Debug("RefreshDB: starting...")
if logWriter == nil {
logWriter = os.Stderr
}
cmd := auth.Command("pacman", "-Syy")
cmd.Stdout = logWriter
cmd.Stderr = logWriter
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to refresh pacman database: %w", err)
}
log.Debug("RefreshDB: done (%.2fs)", time.Since(start).Seconds())
return nil
}
func RemoveOrphans(orphans []string, logWriter io.Writer) (int, error) {
start := time.Now()
log.Debug("RemoveOrphans: starting...")
if logWriter == nil {
logWriter = os.Stderr
}
if len(orphans) == 0 {
log.Debug("RemoveOrphans: done (no orphans) (%.2fs)", time.Since(start).Seconds())
return 0, nil
}
args := make([]string, 0, 3+len(orphans))
args = append(args, "pacman", "-Rns", "--noconfirm")
args = append(args, orphans...)
removeCmd := auth.Command(args[0], args[1:]...)
removeCmd.Stdout = logWriter
removeCmd.Stderr = logWriter
err := removeCmd.Run()
if err != nil {
return 0, fmt.Errorf("remove orphans failed: %w", err)
}
count := len(orphans)
log.Debug("RemoveOrphans: done (%d) (%.2fs)", count, time.Since(start).Seconds())
return count, nil
}
func SyncPackages(packages []string, logWriter io.Writer) error {
start := time.Now()
log.Debug("SyncPackages: starting...")
if logWriter == nil {
logWriter = os.Stderr
}
args := append([]string{"-S", "--needed", "--noconfirm"}, packages...)
cmd := auth.Command("pacman", args...)
cmd.Stdout = logWriter
cmd.Stderr = logWriter
err := cmd.Run()
if err != nil {
return fmt.Errorf("pacman sync failed: %w", err)
}
log.Debug("SyncPackages: done (%.2fs)", time.Since(start).Seconds())
return nil
}
// -----------------------------------------
// private
func buildPackage(tmpDir string, asDeps bool, logWriter io.Writer) error {
makepkgArgs := []string{"-D", tmpDir, "-s", "--noconfirm"}
if asDeps {
makepkgArgs = append(makepkgArgs, "--asdeps")
}
makepkgCmd := log.Command("makepkg", makepkgArgs...)
makepkgCmd.Stdout = logWriter
makepkgCmd.Stderr = logWriter
if err := makepkgCmd.Run(); err != nil {
return fmt.Errorf("makepkg failed to build AUR package: %w", err)
}
return nil
}
func cloneRepo(packageBase string, tmpDir string, logWriter io.Writer) error {
cloneURL := "https://aur.archlinux.org/" + packageBase + ".git"
cloneCmd := log.Command("git", "clone", cloneURL, tmpDir)
cloneCmd.Stdout = logWriter
cloneCmd.Stderr = logWriter
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone AUR repo: %w", err)
}
return nil
}
func createTempDir(tmpDir string) error {
if tmpDir == "" || tmpDir == "/" || !strings.HasPrefix(tmpDir, "/tmp") {
return fmt.Errorf("safety check: prevented malformed rm -rf call")
}
rmdirCmd := log.Command("rm", "-rf", tmpDir)
if err := rmdirCmd.Run(); err != nil {
return fmt.Errorf("failed to remove temp directory: %w", err)
}
mkdirCmd := log.Command("mkdir", "-p", tmpDir)
if err := mkdirCmd.Run(); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
return nil
}
func findPKGFile(pkgName string, dir string) (string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
for _, entry := range entries {
name := entry.Name()
if !strings.HasSuffix(name, ".pkg.tar.zst") && !strings.HasSuffix(name, ".pkg.tar.gz") {
continue
}
// Skip packages that do not start with the exact package name, ex: sunshine-bin -> sunshine
if !strings.HasPrefix(name, pkgName) {
continue
}
// Skip packages that provide a debug package, ex: sunshine-bin -> sunshine-debug
if strings.HasPrefix(name, pkgName+"-debug") {
continue
}
return strings.Join([]string{dir, name}, "/"), nil
}
return "", fmt.Errorf("no package file found in %s", dir)
}
func getAURInfo(f *fetch.Fetcher, pkgName string, packageBase string) *aur.Package {
if packageBase == "" {
return nil
}
info, ok := f.GetAURPackage(pkgName)
if !ok {
return nil
}
return &info
}
func getTempDirName() string {
user, err := user.Current()
if err != nil {
return "/tmp/declpac"
}
return "/tmp/declpac-" + user.Username
}
func installBuiltPackage(pkgFile string, logWriter io.Writer) error {
installCmd := auth.Command("pacman", "-U", "--noconfirm", pkgFile)
installCmd.Stdout = logWriter
installCmd.Stderr = logWriter
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install package: %w", err)
}
return nil
}
func resolveAndInstallDeps(f *fetch.Fetcher, aurInfo *aur.Package, logWriter io.Writer) error {
if aurInfo == nil {
return nil
}
depends := aurInfo.AllDepends()
if len(depends) == 0 {
return nil
}
resolved, err := f.Resolve(depends)
if err != nil {
return fmt.Errorf("failed to resolve dependencies: %w", err)
}
var repoDeps, aurDeps []string
for _, dep := range depends {
info := resolved[dep]
if info.Installed {
continue
}
pkg := dep
if info.Provided != "" {
pkg = info.Provided
}
if info.Exists {
repoDeps = append(repoDeps, pkg)
} else if info.InAUR {
aurDeps = append(aurDeps, pkg)
}
}
if len(repoDeps) > 0 {
if err := SyncPackages(repoDeps, logWriter); err != nil {
return fmt.Errorf("failed to install repo dependencies: %w", err)
}
}
if len(aurDeps) == 0 {
return nil
}
fetched, err := f.FetchAur(aurDeps)
if err != nil {
log.Debug("sync.resolveAndInstallDeps: aur fetch error: %v", err)
}
for _, dep := range aurDeps {
depInfo, ok := fetched[dep]
if !ok {
continue
}
if err := InstallAUR(f, dep, depInfo.PackageBase, true, logWriter); err != nil {
return err
}
}
return nil
}
-134
View File
@@ -1,134 +0,0 @@
package validation
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"time"
)
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")
}
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 {
var pacmanPkgs []string
var aurPkgs []string
for _, pkg := range packages {
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 inPacman(name string) bool {
cmd := exec.Command("pacman", "-Qip", name)
if err := cmd.Run(); err == nil {
return true
}
cmd = exec.Command("pacman", "-Sip", name)
if err := cmd.Run(); err == nil {
return true
}
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 result
}
+6
View File
@@ -5,6 +5,12 @@
"source": "JuliusBrussee/caveman", "source": "JuliusBrussee/caveman",
"sourceType": "github", "sourceType": "github",
"computedHash": "a818cdc41dcfaa50dd891c5cb5e5705968338de02e7e37949ca56e8c30ad4176" "computedHash": "a818cdc41dcfaa50dd891c5cb5e5705968338de02e7e37949ca56e8c30ad4176"
},
"grill-me": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/productivity/grill-me/SKILL.md",
"computedHash": "784f0dbb7403b0f00324bce9a112f715342777a0daee7bbb7385f9c6f0a170ea"
} }
} }
} }