Compare commits

..

10 commits

Author SHA1 Message Date
randogoth
fd7ff14ac6 eval-only option 2026-01-01 20:45:46 +02:00
randogoth
3969251220 0.1.1 2026-01-01 20:23:05 +02:00
randogoth
25c80fdf3b added env var support 2026-01-01 20:21:38 +02:00
randogoth
7d51de1052 finalized for publication 2025-12-24 12:56:51 +02:00
randogoth
df1f387795 added package removal, brew, flatpak, ostree, nix xupport 2025-12-24 08:54:24 +02:00
randogoth
da6d24c74f init 2025-12-24 07:56:11 +02:00
kumar vaibhav
359c250101 symlink fixed ! 2025-12-19 20:13:03 +05:30
kumar vaibhav
8128e92311 Update forge 2025-12-09 19:40:41 +05:30
kumar vaibhav
47c58ae7eb complete-config 2025-11-21 22:16:20 +05:30
kumar vaibhav
02ac070c2e dotfiles working 2025-11-21 20:58:37 +05:30
15 changed files with 1957 additions and 638 deletions

75
.gitignore vendored Normal file
View file

@ -0,0 +1,75 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Jupyter
.ipynb_checkpoints
# pyenv
.python-version
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre
.pyre/
# UV
uv.lock
# Backup/config artifacts
*.bak
inventory.toml.last
inventory.toml.rollback

357
README.md
View file

@ -1,78 +1,81 @@
# Forge - A Home-Manager Like Tool for Fedora # curator - A Home-Manager Like Tool for Universal Blue builds
A lightweight configuration management tool for Fedora that provides COPR repository management, dotfile management and package installation functionality through a centralized TOML configuration. A lightweight CLI that provides repository and dotfile management and package installation functionality through a centralized TOML configuration. Ported and expanded from [forge](https://github.com/ijadux2/forge) and inspired by [home-manager](https://github.com/nix-community/home-manager/).
## Overview ## Overview
Forge is a bash script that helps you manage your Fedora system configuration by: curator is a Python CLI that helps you manage your Fedora system configuration by:
- Managing COPR repositories (enable/disable automatically) - Managing COPR repositories (enable/disable automatically)
- Installing and managing system packages via dnf - Installing and managing system packages via `dnf` or `rpm-ostree`
- Installing and managing userspace packages via `brew`, `flatpak`, or `nix`
- Managing dotfiles through symlinks to actual files - Managing dotfiles through symlinks to actual files
- Centralized configuration via a single `forge.toml` file - Centralized configuration via a single `inventory.toml` file
- User-level configuration similar to home-manager/nixos - Declarative user-level configuration similar to `home-manager`/`nixos`
- Automatic backup of existing files before replacement - Automatic backup of existing files before replacement
## Installation ## Installation
1. Clone or download this repository Install the CLI directly from GitHub with [uv](https://github.com/astral-sh/uv):
2. Make the script executable:
```bash ```bash
chmod +x forge uv tool install --from git+https://codeberg.org/randogoth/curator/ curator
``` ```
3. Optionally, move it to a directory in your PATH:
```bash
sudo mv forge /usr/local/bin/forge
```
## Quick Start ## Quick Start
```bash ```bash
# Initialize the configuration structure # Initialize the configuration structure
./forge init curator init
# Edit the configuration file to add packages and dotfiles # Edit the configuration file to add packages and dotfiles
nano ~/.config/forge/forge.toml nano ~/.config/curator/inventory.toml
# Apply configuration # Apply configuration
./forge switch curator switch
# Check status # Check status
./forge status curator status
``` ```
## Commands ## Commands
### `init` ### `init`
Initialize the configuration structure and create `forge.toml`. Initialize the configuration structure and create `inventory.toml`.
```bash ```bash
./forge init curator init
# or ./curator init
``` ```
Creates: Creates:
- `~/.config/forge/` - Main configuration directory - `~/.config/curator/` - Main configuration directory
- `~/.config/forge/forge.toml` - Central configuration file - `~/.config/curator/inventory.toml` - Central configuration file
### `switch` ### `switch`
Apply the current configuration (enable COPR, install packages, deploy dotfiles). Apply the current configuration (enable COPR, install packages, deploy dotfiles). Use `--rollback` to restore the previous `inventory.toml` snapshot before applying.
```bash ```bash
./forge switch curator switch
# or ./curator switch
# rollback to the previous inventory.toml and apply it
curator switch --rollback
``` ```
This command: This command:
1. Enables all COPR repositories listed in `forge.toml` 1. Enables all COPR repositories listed in `inventory.toml`
2. Disables COPR repositories that are no longer configured 2. Disables COPR repositories that are no longer configured
3. Installs all packages listed in `forge.toml` 3. Installs all packages listed in `inventory.toml`
4. Creates symlinks for all configured dotfiles 4. Creates symlinks for all configured dotfiles
5. Creates backups of existing files before replacing them 5. Applies environment variables to `~/.config/environment.d/20-curator.conf` and imports them into the user session
6. Updates the last switch timestamp 6. Creates backups of existing files before replacing them
7. Updates the last switch timestamp
### `status` ### `status`
Show current configuration status and information. Show current configuration status and information.
```bash ```bash
./forge status curator status
# or ./curator status
``` ```
Displays: Displays:
@ -84,44 +87,99 @@ Displays:
Show help message with all available commands. Show help message with all available commands.
```bash ```bash
./forge help curator help
# or ./curator help
``` ```
### `from`
Import currently installed packages/repos for a manager into `inventory.toml` so curator can manage them.
```bash
curator from dnf
curator from brew
curator from flatpak
curator from rpm-ostree
curator from nix
curator from copr
```
### `add` / `remove`
Add or remove entries directly in `inventory.toml` using `section:value` pairs. Supports `dnf`, `brew`, `flatpak`, `rpm-ostree`, `nix`, and `copr`.
```bash
curator add dnf:uv nix:micro
curator remove dnf:curl flatpak:org.mozilla.firefox
```
### `reset`
Remove the rollback snapshot (`inventory.toml.rollback`).
```bash
curator reset
```
### `env`
Apply environment variables defined in `[variables]` and propagate them to systemd/DBus. For your current shell, run:
```bash
curator env
# then, to update this shell:
eval "$(curator env --eval-only)"
```
Use `curator env --eval-only` in shell init files if you want each shell to export the current `[variables]` values without touching `environment.d` or systemd/DBus.
## Configuration ## Configuration
### forge.toml ### inventory.toml
The central configuration file located at `~/.config/forge/forge.toml`: The central configuration file located at `~/.config/curator/inventory.toml`:
```toml ```toml
# Forge Configuration File # curator Configuration File
# User-level configuration similar to home-manager/nixos # User-level configuration similar to home-manager/nixos
[forge] [curator]
version = "1.0" version = "1.0"
last_switch = null last_switch = ""
[copr] [copr]
# COPR repositories to enable # copr.fedorainfracloud.org/username/repository
# Just list COPR repository names - presence means enable, absence means don't enable # copr.fedorainfracloud.org/anotheruser/anotherrepo
copr.fedorainfracloud.org/username/repository
copr.fedorainfracloud.org/anotheruser/anotherrepo
[packages] [dnf]
# List of packages to install using dnf # git
# Just list package names - presence means install, absence means don't install # vim
git # curl
vim # wget
curl
wget [brew]
# wget
# coreutils
[flatpak]
# org.mozilla.firefox
# com.spotify.Client
[rpm-ostree]
# podman
# htop
[nix]
# nixpkgs#git # or just "git" (curator will prefix nixpkgs#)
# nixpkgs#htop # or just "htop"
[variables]
# ENV_VAR = "value"
# ANOTHER = "another value"
[dotfiles] [dotfiles]
# Dotfiles to manage with symlinks # Dotfiles to manage with symlinks
# Format: "target_path" = "source_path" # Format: "target_path" = "source_path"
# target_path: where the symlink should be created (relative to home directory) # target_path: where the symlink should be created (relative to home directory)
# source_path: where the actual file is stored (relative to forge directory) # source_path: where the actual file is stored (relative to dotfiles directory)
".bashrc" = "dotfiles/.bashrc" ".bashrc" = ".bashrc"
".config/vimrc" = "dotfiles/vimrc" ".config/vimrc" = "vimrc"
".config/alacritty/alacritty.yml" = "dotfiles/alacritty.yml" ".config/alacritty/alacritty.yml" = "alacritty.yml"
[options] [options]
# Additional options # Additional options
@ -131,12 +189,12 @@ backup_dir = "backup"
### Configuration Sections ### Configuration Sections
#### `[forge]` #### `[curator]`
- `version`: Configuration file version - `version`: Configuration file version
- `last_switch`: Timestamp of last switch operation (auto-updated) - `last_switch`: Timestamp of last switch operation (auto-updated)
#### `[copr]` #### `[copr]`
List of COPR repositories to enable via `dnf copr enable`. **Just list COPR repository names** - presence means enable, absence means don't enable. List of COPR repositories to enable via `dnf copr enable`. **One repository per line**—presence means enable, absence means don't enable.
**Examples:** **Examples:**
```toml ```toml
@ -145,14 +203,14 @@ copr.fedorainfracloud.org/username/repository
copr.fedorainfracloud.org/anotheruser/anotherrepo copr.fedorainfracloud.org/anotheruser/anotherrepo
``` ```
To remove a COPR repository, simply delete the line containing the repository name. Forge will automatically disable it during the next switch. To remove a COPR repository, simply delete the line containing the repository name. curator will automatically disable it during the next switch.
#### `[packages]` #### `[dnf]`
List of packages to install via dnf. **Just list package names** - presence means install, absence means don't install. List of packages to install via dnf. **One package per line**—presence means install, absence means don't install.
**Examples:** **Examples:**
```toml ```toml
[packages] [dnf]
git git
vim vim
curl curl
@ -163,21 +221,49 @@ npm
To remove a package, simply delete the line containing the package name. To remove a package, simply delete the line containing the package name.
#### `[brew]`
List of packages to install via Homebrew. **One package per line**—presence means install.
#### `[flatpak]`
List of Flatpak refs to install. **One ref per line**—presence means install.
#### `[rpm-ostree]`
List of rpm-ostree layered packages. **One package per line**—presence means install.
#### `[nix]`
List of nix packages. **One package per line**—presence means install. You can specify plain names (e.g., `neovim`) or `nixpkgs#name`; curator will prefix `nixpkgs#` for installs and use the base name for removals.
#### `[variables]`
User environment variables to set via systemd `environment.d`. curator writes them to `~/.config/environment.d/20-curator.conf` during `curator switch`; new sessions will load them automatically.
**Examples:**
```toml
[variables]
EDITOR = "nvim"
PAGER = "less -R"
```
Notes:
- Values are imported into the user systemd and DBus environments during `curator switch` or `curator env`.
- To update a currently running shell, run `eval "$(curator env --eval-only)"` after applying.
- Later shell init files (e.g., `.bashrc`, `.zshrc`) can still override these values.
#### `[dotfiles]` #### `[dotfiles]`
Dotfile mappings using symlinks: Dotfile mappings using symlinks:
- **Key**: Target path where symlink should be created (relative to home directory) - **Key**: Target path where symlink should be created (relative to home directory)
- **Value**: Source path where actual file is stored (relative to forge directory) - **Value**: Source path where actual file is stored (relative to dotfiles directory)
- Note: Source path automatically prefixed with "dotfiles/" if not present
#### `[options]` #### `[options]`
Additional configuration options: Additional configuration options:
- `backup`: Enable/disable backup of existing files (default: true) - `backup`: Enable/disable backup of existing files (default: true)
- `backup_dir`: Directory name for backups (relative to forge directory) - `backup_dir`: Directory name for backups (relative to curator directory)
## File Structure ## File Structure
``` ```
~/.config/forge/ ~/.config/curator/
├── forge.toml # Main configuration file ├── inventory.toml # Main configuration file
├── dotfiles/ # Your actual dotfiles (source files) ├── dotfiles/ # Your actual dotfiles (source files)
│ ├── .bashrc │ ├── .bashrc
│ ├── vimrc │ ├── vimrc
@ -187,153 +273,6 @@ Additional configuration options:
└── vimrc.20231121_143022.bak └── vimrc.20231121_143022.bak
``` ```
## How It Works
### COPR Repository Management
COPR repositories are managed through the `[copr]` section in `forge.toml`:
- **Add COPR repo**: Simply add the repository name on a new line
- **Remove COPR repo**: Delete the line containing the repository name
- **Automatic cleanup**: Forge automatically disables COPR repos that are removed from configuration
- **No flags needed**: Just presence/absence of the repository name matters
### Package Management
Packages are managed through the `[packages]` section in `forge.toml`:
- **Add package**: Simply add the package name on a new line
- **Remove package**: Delete the line containing the package name
- **No flags needed**: Just presence/absence of the package name matters
### Dotfile Management
Forge uses symlinks to manage dotfiles:
1. Your actual dotfiles are stored in `~/.config/forge/dotfiles/`
2. Symlinks are created from your home directory to these files
3. This allows you to version control your dotfiles in one place
4. Changes to the source files are immediately reflected in your home directory
### Backup System
Before creating symlinks, Forge:
1. Checks if the target file exists and is not a symlink
2. Creates a timestamped backup in the backup directory
3. Removes the original file
4. Creates the symlink to your managed dotfile
## Examples
### Basic Setup
```bash
# Initialize forge
./forge init
# Edit forge.toml to add COPR repos and packages
nano ~/.config/forge/forge.toml
# Add to [copr] section:
copr.fedorainfracloud.org/username/cool-repo
# Add to [packages] section:
git
vim
curl
# Create your dotfiles directory and add files
mkdir -p ~/.config/forge/dotfiles
echo "export EDITOR=vim" > ~/.config/forge/dotfiles/.bashrc
# Add to [dotfiles] section:
".bashrc" = "dotfiles/.bashrc"
# Apply configuration
./forge switch
```
### Managing Application Configurations
```bash
# Add alacritty configuration
mkdir -p ~/.config/forge/dotfiles/.config/alacritty
cp ~/.config/alacritty/alacritty.yml ~/.config/forge/dotfiles/.config/alacritty/
# Edit forge.toml
nano ~/.config/forge/forge.toml
# Add to [dotfiles] section:
".config/alacritty/alacritty.yml" = "dotfiles/.config/alacritty/alacritty.yml"
# Apply changes
./forge switch
```
### COPR Repository Management Examples
```toml
[copr]
# Development tools COPR
copr.fedorainfracloud.org/development/tools
copr.fedorainfracloud.org/user/neovim-nightly
# To remove a COPR repo, just delete the line
# Forge will automatically disable it during the next switch
```
### Package Management Examples
```toml
[packages]
# Development tools
git
vim
nodejs
npm
# System utilities
curl
wget
tree
htop
# To remove a package, just delete the line
# For example, to remove htop, delete the "htop" line
```
### Version Control Your Configuration
```bash
# Initialize git repository in forge directory
cd ~/.config/forge
git init
git add .
git commit -m "Initial configuration"
# Now you can version control your entire system configuration
git add forge.toml dotfiles/
git commit -m "Updated vim configuration"
```
## Environment Variables ## Environment Variables
- `FORGE_DIR`: Override the default configuration directory (default: `~/.config/forge`) - `CURATOR_DIR`: Override the default configuration directory (default: `~/.config/curator`)
## Dependencies
- `dnf` - Fedora package manager
- `dnf-plugins-core` - For COPR repository management
- `sed` - For updating configuration file (usually pre-installed)
## Migration from Previous Version
If you were using the old version of forge with `git = true` format:
1. Your existing configuration will not be automatically migrated
2. Run `./forge init` to create the new `forge.toml` structure
3. Convert your package configuration from:
```toml
# Old format
git = true
vim = true
```
to:
```toml
# New format
git
vim
```
4. Move your existing dotfiles from the old directory to `~/.config/forge/dotfiles/`
## License
This project is open source. Feel free to contribute or report issues.

24
curator Executable file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env python3
from __future__ import annotations
import sys
from pathlib import Path
def main() -> None:
here = Path(__file__).resolve().parent
src_dir = here / "src"
if src_dir.exists():
sys.path.insert(0, str(src_dir))
try:
from curator.cli import main as curator_main
except ImportError as exc: # pragma: no cover - fallback error path
sys.stderr.write(f"Failed to import curator CLI: {exc}\n")
sys.exit(1)
curator_main()
if __name__ == "__main__":
main()

429
forge
View file

@ -1,429 +0,0 @@
#!/bin/bash
# forge - A home-manager like script for Fedora
# Provides dotfile management and package installation functionality
set -euo pipefail
# Configuration
FORGE_DIR="${FORGE_DIR:-$HOME/.config/forge}"
FORGE_TOML="$FORGE_DIR/forge.toml"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Initialize command - creates forge.toml and .config directory
init() {
log_info "Initializing forge..."
# Create directory
mkdir -p "$FORGE_DIR"
# Create initial forge.toml if it doesn't exist
if [[ ! -f "$FORGE_TOML" ]]; then
cat >"$FORGE_TOML" <<'EOF'
# Forge Configuration File
# User-level configuration similar to home-manager/nixos
[forge]
version = "1.0"
last_switch = null
[copr]
# COPR repositories to enable
# Just list COPR repository names - presence means enable, absence means don't enable
# Examples:
# copr.fedorainfracloud.org/username/repository
# copr.fedorainfracloud.org/anotheruser/anotherrepo
[packages]
# List of packages to install using dnf
# Just list package names - presence means install, absence means don't install
# Examples:
# git
# vim
# curl
[dotfiles]
# Dotfiles to manage with symlinks
# Format: "target_path" = "source_path"
# target_path: where the symlink should be created (relative to home directory)
# source_path: where the actual file is stored (relative to forge directory)
# Examples:
# ".bashrc" = "dotfiles/.bashrc"
# ".config/vimrc" = "dotfiles/vimrc"
[options]
# Additional options
backup = true
backup_dir = "backup"
EOF
log_success "Created forge.toml: $FORGE_TOML"
fi
log_success "Initialization complete!"
log_info "Edit $FORGE_TOML to configure packages and dotfiles"
log_info "Run 'forge switch' to apply your configuration"
}
# Parse TOML file (basic implementation)
parse_toml() {
local section=""
while IFS= read -r line; do
# Skip comments and empty lines
[[ -z "$line" || "$line" == \#* ]] && continue
# Check for section headers
if [[ "$line" =~ ^\[(.+)\]$ ]]; then
section="${BASH_REMATCH[1]}"
continue
fi
# Parse key-value pairs for non-packages sections
if [[ "$section" != "packages" && "$line" =~ ^([^=]+)=(.+)$ ]]; then
local key="${BASH_REMATCH[1]// /}"
local value="${BASH_REMATCH[2]// /}"
echo "$section:$key=$value"
# Parse package names (just keys without values in packages section)
elif [[ "$section" == "packages" && "$line" =~ ^([^=]+)$ ]]; then
local package="${BASH_REMATCH[1]// /}"
echo "$section:$package"
# Parse COPR repository names (just keys without values in copr section)
elif [[ "$section" == "copr" && "$line" =~ ^([^=]+)$ ]]; then
local copr_repo="${BASH_REMATCH[1]// /}"
echo "$section:$copr_repo"
fi
done <"$FORGE_TOML"
}
# Enable COPR repositories from forge.toml
enable_copr_repos() {
log_info "Enabling COPR repositories..."
local copr_enabled=false
while IFS='=' read -r entry; do
local section="${entry%%:*}"
local copr_repo="${entry#*:}"
if [[ "$section" == "copr" && -n "$copr_repo" ]]; then
copr_enabled=true
log_info "Enabling COPR repository: $copr_repo"
if sudo dnf copr enable -y "$copr_repo"; then
log_success "Enabled COPR: $copr_repo"
else
log_error "Failed to enable COPR: $copr_repo"
fi
fi
done < <(parse_toml)
if [[ "$copr_enabled" == false ]]; then
log_info "No COPR repositories to enable"
fi
}
# Disable COPR repositories that are no longer in forge.toml
disable_copr_repos() {
log_info "Checking for COPR repositories to disable..."
# Get currently enabled COPR repos
local enabled_repos=$(sudo dnf copr list --enabled 2>/dev/null | grep -E "copr\.fedorainfracloud\.org" | awk '{print $1}' || true)
# Get configured COPR repos from forge.toml
local configured_repos=""
while IFS='=' read -r entry; do
local section="${entry%%:*}"
local copr_repo="${entry#*:}"
if [[ "$section" == "copr" && -n "$copr_repo" ]]; then
configured_repos="$configured_repos $copr_repo"
fi
done < <(parse_toml)
# Disable repos that are enabled but not configured
for repo in $enabled_repos; do
if [[ ! " $configured_repos " =~ " $repo " ]]; then
log_info "Disabling COPR repository: $repo"
if sudo dnf copr disable -y "$repo"; then
log_success "Disabled COPR: $repo"
else
log_error "Failed to disable COPR: $repo"
fi
fi
done
}
# Install packages from forge.toml
install_packages() {
log_info "Installing packages..."
local packages_installed=false
while IFS='=' read -r entry; do
local section="${entry%%:*}"
local package="${entry#*:}"
if [[ "$section" == "packages" && -n "$package" ]]; then
packages_installed=true
log_info "Installing package: $package"
if sudo dnf install -y "$package"; then
log_success "Installed $package"
else
log_error "Failed to install $package"
fi
fi
done < <(parse_toml)
if [[ "$packages_installed" == false ]]; then
log_info "No packages to install"
fi
}
# Deploy dotfiles using symlinks
deploy_dotfiles() {
log_info "Deploying dotfiles..."
local dotfiles_deployed=false
while IFS='=' read -r entry; do
local section="${entry%%:*}"
local key_value="${entry#*:}"
local target="${key_value%%=*}"
local source="${key_value##*=}"
if [[ "$section" == "dotfiles" ]]; then
dotfiles_deployed=true
local target_path="$HOME/$target"
local source_path="$FORGE_DIR/$source"
# Create source directory if it doesn't exist
local source_dir=$(dirname "$source_path")
mkdir -p "$source_dir"
# Create target directory if it doesn't exist
local target_dir=$(dirname "$target_path")
mkdir -p "$target_dir"
# Backup existing file if it exists and is not a symlink
if [[ -f "$target_path" && ! -L "$target_path" ]]; then
local backup_dir="$FORGE_DIR/backup"
mkdir -p "$backup_dir"
local backup_path="$backup_dir/$(basename "$target").$(date +%Y%m%d_%H%M%S).bak"
cp "$target_path" "$backup_path"
log_info "Backed up $target_path to $backup_path"
fi
# Remove existing file/symlink
rm -f "$target_path"
# Create symlink
if ln -s "$source_path" "$target_path"; then
log_success "Created symlink: $target_path -> $source_path"
else
log_error "Failed to create symlink: $target_path -> $source_path"
fi
fi
done < <(parse_toml)
if [[ "$dotfiles_deployed" == false ]]; then
log_info "No dotfiles to deploy"
fi
}
# Switch command - apply configuration
switch() {
log_info "Starting forge switch..."
# Check if forge.toml exists
if [[ ! -f "$FORGE_TOML" ]]; then
log_error "forge.toml not found. Run 'forge init' first."
exit 1
fi
# Enable COPR repositories
enable_copr_repos
# Disable COPR repositories that are no longer configured
disable_copr_repos
# Install packages
install_packages
# Deploy dotfiles
deploy_dotfiles
# Update last_switch timestamp
if command -v sed >/dev/null 2>&1; then
sed -i "s/last_switch = .*/last_switch = \"$(date -Iseconds)\"/" "$FORGE_TOML"
fi
log_success "Switch completed successfully!"
}
# Status command - show current configuration
status() {
log_info "Forge Status"
echo " Config directory: $FORGE_DIR"
echo " Config file: $FORGE_TOML"
if [[ -f "$FORGE_TOML" ]]; then
echo
log_info "Configuration:"
# Show last switch time
local last_switch=$(grep "last_switch" "$FORGE_TOML" | cut -d'=' -f2 | tr -d ' "')
if [[ -n "$last_switch" && "$last_switch" != "null" ]]; then
echo " Last switch: $last_switch"
else
echo " Last switch: Never"
fi
echo
log_info "COPR Repositories:"
local copr_count=0
while IFS='=' read -r entry; do
local section="${entry%%:*}"
local copr_repo="${entry#*:}"
if [[ "$section" == "copr" && -n "$copr_repo" ]]; then
echo " $copr_repo"
((copr_count++))
fi
done < <(parse_toml)
if [[ $copr_count -eq 0 ]]; then
echo " No COPR repositories configured"
else
echo " Total: $copr_count COPR repositories"
fi
echo
log_info "Packages:"
local package_count=0
while IFS='=' read -r entry; do
local section="${entry%%:*}"
local package="${entry#*:}"
if [[ "$section" == "packages" && -n "$package" ]]; then
echo " $package"
((package_count++))
fi
done < <(parse_toml)
if [[ $package_count -eq 0 ]]; then
echo " No packages configured"
else
echo " Total: $package_count packages"
fi
echo
log_info "Dotfiles:"
local dotfile_count=0
while IFS='=' read -r entry; do
local section="${entry%%:*}"
local key_value="${entry#*:}"
local target="${key_value%%=*}"
local source="${key_value##*=}"
if [[ "$section" == "dotfiles" ]]; then
echo " $target -> $source"
((dotfile_count++))
fi
done < <(parse_toml)
if [[ $dotfile_count -eq 0 ]]; then
echo " No dotfiles configured"
else
echo " Total: $dotfile_count dotfiles"
fi
else
echo " No configuration file found"
fi
}
# Show help
show_help() {
cat <<EOF
forge - A home-manager like script for Fedora
USAGE:
forge <COMMAND>
COMMANDS:
init Initialize configuration structure (creates forge.toml)
switch Apply configuration (enable COPR, install packages, deploy dotfiles)
status Show current configuration status
help Show this help message
FEATURES:
- TOML-based configuration in forge.toml
- COPR repository management (enable/disable automatically)
- Package management via dnf
- Dotfile management via symlinks
- Automatic backup of existing files
- User-level configuration like home-manager/nixos
FILES:
$FORGE_DIR/ Main configuration directory
$FORGE_TOML Configuration file for COPR, packages and dotfiles
EXAMPLES:
forge init
# Edit $FORGE_TOML to add COPR repos, packages and dotfiles
forge switch
forge status
EOF
}
# Main script logic
main() {
case "${1:-}" in
"init")
init
;;
"switch")
switch
;;
"status")
status
;;
"help" | "--help" | "-h")
show_help
;;
"")
log_error "No command specified. Use 'help' for usage information."
exit 1
;;
*)
log_error "Unknown command: $1"
show_help
exit 1
;;
esac
}
# Run main function with all arguments
main "$@"

20
pyproject.toml Normal file
View file

@ -0,0 +1,20 @@
[project]
name = "curator"
version = "0.1.1"
description = "A home-manager style Fedora configuration helper."
readme = "README.md"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
curator = "curator.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.uv]
package = true
[dependency-groups]
dev = ["pytest>=7.4"]

1
src/curator/__init__.py Normal file
View file

@ -0,0 +1 @@
# curator package

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1334
src/curator/cli.py Normal file

File diff suppressed because it is too large Load diff

262
tests/test_config.py Normal file
View file

@ -0,0 +1,262 @@
from pathlib import Path
import datetime as dt
from curator import cli
def test_parse_config_line_format(tmp_path: Path) -> None:
config_path = tmp_path / "inventory.toml"
config_path.write_text(
"""
[curator]
version = "1.0"
last_switch = ""
[copr]
copr.fedorainfracloud.org/user/repo
[dnf]
git
vim
[brew]
wget
[flatpak]
org.mozilla.firefox
[rpm-ostree]
podman
[nix]
nixpkgs#git
[dotfiles]
".bashrc" = ".bashrc"
[options]
backup = true
backup_dir = "backup"
""".strip()
)
config = cli.parse_config(config_path)
assert config.copr == ["copr.fedorainfracloud.org/user/repo"]
assert config.packages == ["git", "vim"]
assert config.brew_packages == ["wget"]
assert config.flatpak_refs == ["org.mozilla.firefox"]
assert config.rpm_ostree_packages == ["podman"]
assert config.nix_packages == ["nixpkgs#git"]
assert config.dotfiles == {".bashrc": ".bashrc"}
assert config.options["backup"] is True
def test_parse_config_legacy_format(tmp_path: Path) -> None:
config_path = tmp_path / "inventory.toml"
config_path.write_text(
"""
[dnf]
git
vim
[brew]
wget
[flatpak]
org.mozilla.firefox
[rpm-ostree]
podman
[nix]
nixpkgs#git
[variables]
EDITOR = "nvim"
[copr]
copr.fedorainfracloud.org/user/repo
[dotfiles]
".bashrc" = ".bashrc"
""".strip()
)
config = cli.parse_config(config_path)
assert config.packages == ["git", "vim"]
assert config.brew_packages == ["wget"]
assert config.flatpak_refs == ["org.mozilla.firefox"]
assert config.rpm_ostree_packages == ["podman"]
assert config.nix_packages == ["nixpkgs#git"]
assert config.copr == ["copr.fedorainfracloud.org/user/repo"]
assert config.dotfiles == {".bashrc": ".bashrc"}
assert config.variables == {"EDITOR": "nvim"}
def test_diff_items() -> None:
added, removed = cli.diff_items(["a", "b"], ["b", "c"])
assert added == ["a"]
assert removed == ["c"]
def test_update_last_switch_creates_backup(tmp_path: Path) -> None:
config_dir = tmp_path
config_path = config_dir / "inventory.toml"
config_path.write_text(
"""
[curator]
version = "1.0"
last_switch = ""
""".strip()
)
options = {"backup": True, "backup_dir": "backup"}
cli.update_last_switch(config_path, options)
lines = [line.strip() for line in config_path.read_text().splitlines() if line.strip()]
last_switch_line = [line for line in lines if line.startswith("last_switch")]
assert last_switch_line, "last_switch should be written"
ts_str = last_switch_line[0].split("=", 1)[1].strip().strip('"')
dt.datetime.fromisoformat(ts_str)
backup_dir = config_dir / "backup"
backups = list(backup_dir.iterdir())
assert backups, "expected a backup of inventory.toml to be created"
assert any(path.name.startswith("inventory.toml.") for path in backups)
def test_reset_previous_config(tmp_path: Path, monkeypatch) -> None:
rollback = tmp_path / "inventory.toml.rollback"
rollback.write_text("prev")
def fake_get_paths():
return tmp_path, tmp_path / "inventory.toml", tmp_path / "inventory.toml.last", rollback
monkeypatch.setattr(cli, "get_paths", fake_get_paths)
cli.reset_previous_config()
assert not rollback.exists()
def test_update_section_entries_rewrites_section(tmp_path: Path) -> None:
config_path = tmp_path / "inventory.toml"
config_path.write_text(
"""
[dnf]
git
[copr]
copr.fedorainfracloud.org/user/repo
[brew]
wget
""".strip()
)
cli.update_section_entries(config_path, "dnf", ["vim", "curl", "vim"])
cli.update_section_entries(config_path, "copr", ["copr.fedorainfracloud.org/user/repo", "copr.fedorainfracloud.org/another/repo"])
content = config_path.read_text().strip().splitlines()
dnf_index = content.index("[dnf]")
assert content[dnf_index + 1 : dnf_index + 3] == ["curl", "vim"]
assert "[copr]" in content
copr_index = content.index("[copr]")
assert content[copr_index + 1 : copr_index + 3] == [
"copr.fedorainfracloud.org/another/repo",
"copr.fedorainfracloud.org/user/repo",
]
def test_merge_section_entries_preserves_spacing(tmp_path: Path) -> None:
config_path = tmp_path / "inventory.toml"
config_path.write_text(
"""
[dnf]
git
[brew]
wget
""".strip()
)
cli.merge_section_entries(config_path, "dnf", {"vim"}, set())
lines = config_path.read_text().splitlines()
assert "" in lines, "expected to keep a blank line between sections"
dnf_index = lines.index("[dnf]")
brew_index = lines.index("[brew]")
assert lines[dnf_index + 1 : dnf_index + 3] == ["git", "vim"]
assert lines[brew_index - 1] == ""
def test_apply_environment_variables(tmp_path: Path, monkeypatch) -> None:
curator_dir = tmp_path / "curator"
curator_dir.mkdir()
env_home = tmp_path / "xdg"
monkeypatch.setenv("XDG_CONFIG_HOME", str(env_home))
config = cli.empty_config()
config.variables = {"EDITOR": "nvim", "WITH_SPACE": "some value"}
config.options["backup"] = False
written, removed = cli.apply_environment_variables(config, curator_dir)
env_file = env_home / "environment.d" / "20-curator.conf"
assert env_file.exists()
lines = env_file.read_text().splitlines()
assert "EDITOR=nvim" in lines
assert 'WITH_SPACE="some value"' in lines
assert written == {"EDITOR": "nvim", "WITH_SPACE": "some value"}
assert removed == set()
def test_apply_environment_variables_removes_file_when_empty(tmp_path: Path, monkeypatch) -> None:
curator_dir = tmp_path / "curator"
curator_dir.mkdir()
env_home = tmp_path / "xdg"
env_dir = env_home / "environment.d"
env_dir.mkdir(parents=True, exist_ok=True)
env_file = env_dir / "20-curator.conf"
env_file.write_text("OLD=1\n")
monkeypatch.setenv("XDG_CONFIG_HOME", str(env_home))
config = cli.empty_config()
config.options["backup"] = False
variables, removed = cli.apply_environment_variables(config, curator_dir)
assert not env_file.exists()
assert variables == {}
assert removed == {"OLD"}
def test_env_command_eval_outputs_exports(tmp_path: Path, monkeypatch, capsys) -> None:
curator_dir = tmp_path / "curator"
curator_dir.mkdir()
env_file = curator_dir / "inventory.toml"
env_file.write_text(
"""
[variables]
EDITOR = "nvim"
""".strip()
)
env_home = tmp_path / "xdg"
env_dir = env_home / "environment.d"
env_dir.mkdir(parents=True, exist_ok=True)
env_conf = env_dir / "20-curator.conf"
env_conf.write_text("OLD=1\n")
monkeypatch.setenv("XDG_CONFIG_HOME", str(env_home))
def fake_get_paths():
return curator_dir, env_file, curator_dir / "last", curator_dir / "rollback"
def fail_apply_environment_variables(*_args, **_kwargs):
raise AssertionError("apply_environment_variables should not be called in eval-only mode")
monkeypatch.setattr(cli, "get_paths", fake_get_paths)
monkeypatch.setattr(cli, "apply_environment_variables", fail_apply_environment_variables)
cli.env_command(eval_only=True)
out = capsys.readouterr().out.strip().splitlines()
assert out == ["export EDITOR=nvim", "unset OLD"]

14
tmpcfg.toml Normal file
View file

@ -0,0 +1,14 @@
[curator]
version = "1.0"
last_switch = ""
copr = ["copr.fedorainfracloud.org/user/repo"]
packages = ["git", "vim"]
[dotfiles]
".bashrc" = ".bashrc"
[options]
backup = true
backup_dir = "backup"

79
uv.lock generated Normal file
View file

@ -0,0 +1,79 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "curator"
version = "0.1.0"
source = { editable = "." }
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=7.4" }]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]