init
This commit is contained in:
parent
359c250101
commit
da6d24c74f
13 changed files with 973 additions and 555 deletions
206
README.md
206
README.md
|
|
@ -1,10 +1,10 @@
|
|||
# Forge - A Home-Manager Like Tool for Fedora
|
||||
|
||||
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 Python CLI (managed with `uv`) for Fedora that provides COPR repository management, dotfile management and package installation functionality through a centralized TOML configuration.
|
||||
|
||||
## Overview
|
||||
|
||||
Forge is a bash script that helps you manage your Fedora system configuration by:
|
||||
Forge is a Python CLI that helps you manage your Fedora system configuration by:
|
||||
- Managing COPR repositories (enable/disable automatically)
|
||||
- Installing and managing system packages via dnf
|
||||
- Managing dotfiles through symlinks to actual files
|
||||
|
|
@ -14,39 +14,50 @@ Forge is a bash script that helps you manage your Fedora system configuration by
|
|||
|
||||
## Installation
|
||||
|
||||
1. Clone or download this repository
|
||||
2. Make the script executable:
|
||||
1. Clone or download this repository.
|
||||
2. Install dependencies with `uv` (none beyond the standard library, but this sets up the venv):
|
||||
```bash
|
||||
chmod +x forge
|
||||
uv sync
|
||||
```
|
||||
3. Optionally, move it to a directory in your PATH:
|
||||
3. Run with `uv`:
|
||||
```bash
|
||||
sudo mv forge /usr/local/bin/forge
|
||||
uv run forge --help
|
||||
```
|
||||
4. Or use the local shim directly:
|
||||
```bash
|
||||
./forge --help
|
||||
```
|
||||
5. Optionally install globally via `uv`:
|
||||
```bash
|
||||
uv tool install .
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Initialize the configuration structure
|
||||
./forge init
|
||||
uv run forge init
|
||||
|
||||
# Edit the configuration file to add packages and dotfiles
|
||||
nano ~/.config/forge/forge.toml
|
||||
|
||||
# Apply configuration
|
||||
./forge switch
|
||||
uv run forge switch
|
||||
|
||||
# Check status
|
||||
./forge status
|
||||
uv run forge status
|
||||
```
|
||||
|
||||
You can swap `uv run forge ...` for `./forge ...` if you prefer the local shim.
|
||||
|
||||
## Commands
|
||||
|
||||
### `init`
|
||||
Initialize the configuration structure and create `forge.toml`.
|
||||
|
||||
```bash
|
||||
./forge init
|
||||
uv run forge init
|
||||
# or ./forge init
|
||||
```
|
||||
|
||||
Creates:
|
||||
|
|
@ -57,7 +68,8 @@ Creates:
|
|||
Apply the current configuration (enable COPR, install packages, deploy dotfiles).
|
||||
|
||||
```bash
|
||||
./forge switch
|
||||
uv run forge switch
|
||||
# or ./forge switch
|
||||
```
|
||||
|
||||
This command:
|
||||
|
|
@ -72,7 +84,8 @@ This command:
|
|||
Show current configuration status and information.
|
||||
|
||||
```bash
|
||||
./forge status
|
||||
uv run forge status
|
||||
# or ./forge status
|
||||
```
|
||||
|
||||
Displays:
|
||||
|
|
@ -84,7 +97,8 @@ Displays:
|
|||
Show help message with all available commands.
|
||||
|
||||
```bash
|
||||
./forge help
|
||||
uv run forge help
|
||||
# or ./forge help
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
|
@ -96,23 +110,21 @@ The central configuration file located at `~/.config/forge/forge.toml`:
|
|||
# Forge Configuration File
|
||||
# User-level configuration similar to home-manager/nixos
|
||||
|
||||
copr = [
|
||||
# "copr.fedorainfracloud.org/username/repository",
|
||||
# "copr.fedorainfracloud.org/anotheruser/anotherrepo",
|
||||
]
|
||||
|
||||
packages = [
|
||||
# "git",
|
||||
# "vim",
|
||||
# "curl",
|
||||
# "wget",
|
||||
]
|
||||
|
||||
[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
|
||||
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
|
||||
git
|
||||
vim
|
||||
curl
|
||||
wget
|
||||
last_switch = ""
|
||||
|
||||
[dotfiles]
|
||||
# Dotfiles to manage with symlinks
|
||||
|
|
@ -135,30 +147,32 @@ backup_dir = "backup"
|
|||
- `version`: Configuration file version
|
||||
- `last_switch`: Timestamp of last switch operation (auto-updated)
|
||||
|
||||
#### `[copr]`
|
||||
List of COPR repositories to enable via `dnf copr enable`. **Just list COPR repository names** - presence means enable, absence means don't enable.
|
||||
#### `copr`
|
||||
Array of COPR repositories to enable via `dnf copr enable`. **Just list COPR repository names** - presence means enable, absence means don't enable.
|
||||
|
||||
**Examples:**
|
||||
```toml
|
||||
[copr]
|
||||
copr.fedorainfracloud.org/username/repository
|
||||
copr.fedorainfracloud.org/anotheruser/anotherrepo
|
||||
copr = [
|
||||
"copr.fedorainfracloud.org/username/repository",
|
||||
"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.
|
||||
|
||||
#### `[packages]`
|
||||
List of packages to install via dnf. **Just list package names** - presence means install, absence means don't install.
|
||||
#### `packages`
|
||||
Array of packages to install via dnf. **Just list package names** - presence means install, absence means don't install.
|
||||
|
||||
**Examples:**
|
||||
```toml
|
||||
[packages]
|
||||
git
|
||||
vim
|
||||
curl
|
||||
wget
|
||||
nodejs
|
||||
npm
|
||||
packages = [
|
||||
"git",
|
||||
"vim",
|
||||
"curl",
|
||||
"wget",
|
||||
"nodejs",
|
||||
"npm",
|
||||
]
|
||||
```
|
||||
|
||||
To remove a package, simply delete the line containing the package name.
|
||||
|
|
@ -191,17 +205,19 @@ Additional configuration options:
|
|||
## 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
|
||||
COPR repositories are managed through the `copr` array in `forge.toml`:
|
||||
- **Add COPR repo**: Add the repository name to the array
|
||||
- **Remove COPR repo**: Remove the entry from the array
|
||||
- **Automatic cleanup**: Forge automatically disables COPR repos that are removed from configuration
|
||||
- **No flags needed**: Just presence/absence of the repository name matters
|
||||
- Forge stores the previous configuration at `~/.config/forge/forge.toml.prev` and compares it to the current file during `switch`; newly added repos are enabled, removed repos are disabled.
|
||||
|
||||
### 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
|
||||
Packages are managed through the `packages` array in `forge.toml`:
|
||||
- **Add package**: Add the package name to the array
|
||||
- **Remove package**: Remove the entry from the array
|
||||
- **No flags needed**: Just presence/absence of the package name matters
|
||||
- The previous configuration snapshot (`forge.toml.prev`) is used to detect additions/removals on each `switch`; added packages are installed and removed packages are uninstalled.
|
||||
|
||||
### Dotfile Management
|
||||
Forge uses symlinks to manage dotfiles:
|
||||
|
|
@ -218,23 +234,29 @@ Before creating symlinks, Forge:
|
|||
3. Removes the original file
|
||||
4. Creates the symlink to your managed dotfile
|
||||
|
||||
`forge.toml` is also backed up before the last switch timestamp is updated.
|
||||
|
||||
The previously applied configuration is stored separately as `~/.config/forge/forge.toml.prev` to compute diffs for COPR and package changes.
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Setup
|
||||
```bash
|
||||
# Initialize forge
|
||||
./forge init
|
||||
uv run 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
|
||||
# Add to the arrays:
|
||||
# copr = [
|
||||
# "copr.fedorainfracloud.org/username/cool-repo",
|
||||
# ]
|
||||
# packages = [
|
||||
# "git",
|
||||
# "vim",
|
||||
# "curl",
|
||||
# ]
|
||||
|
||||
# Create your dotfiles directory and add files
|
||||
mkdir -p ~/.config/forge/dotfiles
|
||||
|
|
@ -244,7 +266,7 @@ echo "export EDITOR=vim" > ~/.config/forge/dotfiles/.bashrc
|
|||
".bashrc" = ".bashrc"
|
||||
|
||||
# Apply configuration
|
||||
./forge switch
|
||||
uv run forge switch
|
||||
```
|
||||
|
||||
### Managing Application Configurations
|
||||
|
|
@ -260,37 +282,36 @@ nano ~/.config/forge/forge.toml
|
|||
".config/alacritty/alacritty.yml" = "alacritty.yml"
|
||||
|
||||
# Apply changes
|
||||
./forge switch
|
||||
uv run 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
|
||||
copr = [
|
||||
# Development tools COPR
|
||||
"copr.fedorainfracloud.org/development/tools",
|
||||
"copr.fedorainfracloud.org/user/neovim-nightly",
|
||||
]
|
||||
# To remove a COPR repo, just delete the entry
|
||||
# Forge will automatically disable it during the next switch
|
||||
```
|
||||
|
||||
### Package Management Examples
|
||||
```toml
|
||||
[packages]
|
||||
# Development tools
|
||||
git
|
||||
vim
|
||||
nodejs
|
||||
npm
|
||||
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
|
||||
# System utilities
|
||||
"curl",
|
||||
"wget",
|
||||
"tree",
|
||||
"htop",
|
||||
]
|
||||
# To remove a package, just delete the entry
|
||||
```
|
||||
|
||||
### Version Control Your Configuration
|
||||
|
|
@ -312,30 +333,35 @@ git commit -m "Updated vim configuration"
|
|||
|
||||
## Dependencies
|
||||
|
||||
- Python 3.11+
|
||||
- `tomlkit` (installed automatically via `uv sync`)
|
||||
- `uv` for environment and script management
|
||||
- `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:
|
||||
If you were using the old version of forge with `git = true` or bare keys under `[packages]`/`[copr]`:
|
||||
|
||||
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:
|
||||
1. Your existing configuration will not be automatically migrated, but the CLI will still read the legacy format.
|
||||
2. Run `uv run forge init` (or `./forge init`) to create the new `forge.toml` structure.
|
||||
3. Convert to arrays:
|
||||
```toml
|
||||
# Old format
|
||||
# Old formats
|
||||
[packages]
|
||||
git = true
|
||||
vim = true
|
||||
```
|
||||
to:
|
||||
```toml
|
||||
# New format
|
||||
# or
|
||||
[packages]
|
||||
git
|
||||
vim
|
||||
|
||||
# New format
|
||||
packages = ["git", "vim"]
|
||||
copr = ["copr.fedorainfracloud.org/username/repository"]
|
||||
```
|
||||
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.
|
||||
This project is open source. Feel free to contribute or report issues.
|
||||
|
|
|
|||
482
forge
482
forge
|
|
@ -1,472 +1,24 @@
|
|||
#!/bin/bash
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
# forge - A home-manager like script for Fedora
|
||||
# Provides dotfile management and package installation functionality
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration
|
||||
FORGE_DIR="${FORGE_DIR:-$HOME/.config/forge}"
|
||||
FORGE_TOML="$FORGE_DIR/forge.toml"
|
||||
def main() -> None:
|
||||
here = Path(__file__).resolve().parent
|
||||
src_dir = here / "src"
|
||||
if src_dir.exists():
|
||||
sys.path.insert(0, str(src_dir))
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
try:
|
||||
from forge.cli import main as forge_main
|
||||
except ImportError as exc: # pragma: no cover - fallback error path
|
||||
sys.stderr.write(f"Failed to import forge CLI: {exc}\n")
|
||||
sys.exit(1)
|
||||
|
||||
# Logging functions
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
forge_main()
|
||||
|
||||
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)
|
||||
# Note: source_path automatically prefixed with "dotfiles/" if not present
|
||||
# Examples:
|
||||
# ".bashrc" = ".bashrc"
|
||||
# ".config/vimrc" = "vimrc"
|
||||
# ".config/alacritty/alacritty.yml" = "alacritty.yml"
|
||||
|
||||
[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]}"
|
||||
|
||||
# Trim leading/trailing whitespace from key and value
|
||||
key=$(echo "$key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
value=$(echo "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
|
||||
# Remove quotes from quoted strings
|
||||
if [[ "$key" =~ ^\"(.*)\"$ ]]; then
|
||||
key="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
if [[ "$value" =~ ^\"(.*)\"$ ]]; then
|
||||
value="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
|
||||
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
|
||||
local dotfiles_dir="$FORGE_DIR/dotfiles"
|
||||
|
||||
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
|
||||
|
||||
# Normalize source path - if it doesn't start with dotfiles/, add it
|
||||
if [[ ! "$source" =~ ^dotfiles/ ]]; then
|
||||
source="dotfiles/$source"
|
||||
fi
|
||||
|
||||
local target_path="$HOME/$target"
|
||||
local source_path="$FORGE_DIR/$source"
|
||||
|
||||
# Verify source file exists
|
||||
if [[ ! -f "$source_path" && ! -d "$source_path" ]]; then
|
||||
log_error "Source file not found: $source_path"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Create target directory if it doesn't exist
|
||||
local target_dir=$(dirname "$target_path")
|
||||
mkdir -p "$target_dir"
|
||||
|
||||
# Handle existing file/symlink
|
||||
if [[ -e "$target_path" || -L "$target_path" ]]; then
|
||||
# Check if it's already a correct symlink
|
||||
if [[ -L "$target_path" ]]; then
|
||||
local current_target=$(readlink "$target_path")
|
||||
if [[ "$current_target" == "$source_path" ]]; then
|
||||
log_info "Symlink already correct: $target_path"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
# Backup existing file if it exists and is not a symlink
|
||||
if [[ -e "$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 -r "$target_path" "$backup_path"
|
||||
log_info "Backed up $target_path to $backup_path"
|
||||
fi
|
||||
|
||||
# Remove existing file/symlink
|
||||
rm -rf "$target_path"
|
||||
fi
|
||||
|
||||
# Create relative symlink for better portability
|
||||
local relative_source
|
||||
if [[ -d "$source_path" ]]; then
|
||||
relative_source=$(realpath --relative-to="$target_dir" "$source_path")
|
||||
else
|
||||
relative_source=$(realpath --relative-to="$target_dir" "$source_path")
|
||||
fi
|
||||
|
||||
# Create symlink using relative path
|
||||
if ln -s "$relative_source" "$target_path"; then
|
||||
log_success "Created symlink: $target_path -> $relative_source"
|
||||
else
|
||||
log_error "Failed to create symlink: $target_path -> $relative_source"
|
||||
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 "$@"
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
20
pyproject.toml
Normal file
20
pyproject.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[project]
|
||||
name = "forge"
|
||||
version = "0.1.0"
|
||||
description = "A home-manager style Fedora configuration helper."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["tomlkit>=0.12"]
|
||||
|
||||
[project.scripts]
|
||||
forge = "forge.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=7.4"]
|
||||
1
src/forge/__init__.py
Normal file
1
src/forge/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Forge package
|
||||
BIN
src/forge/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/forge/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/forge/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
src/forge/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
src/forge/__pycache__/cli.cpython-313.pyc
Normal file
BIN
src/forge/__pycache__/cli.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/forge/__pycache__/cli.cpython-314.pyc
Normal file
BIN
src/forge/__pycache__/cli.cpython-314.pyc
Normal file
Binary file not shown.
625
src/forge/cli.py
Normal file
625
src/forge/cli.py
Normal file
|
|
@ -0,0 +1,625 @@
|
|||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import tomlkit
|
||||
from tomlkit.exceptions import TOMLKitError
|
||||
from tomlkit.items import Array, Table
|
||||
|
||||
BLUE = "\033[0;34m"
|
||||
GREEN = "\033[0;32m"
|
||||
YELLOW = "\033[1;33m"
|
||||
RED = "\033[0;31m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
DEFAULT_CONFIG = """# Forge Configuration File
|
||||
# User-level configuration similar to home-manager/nixos
|
||||
|
||||
copr = [
|
||||
# "copr.fedorainfracloud.org/username/repository",
|
||||
# "copr.fedorainfracloud.org/anotheruser/anotherrepo",
|
||||
]
|
||||
|
||||
packages = [
|
||||
# "git",
|
||||
# "vim",
|
||||
# "curl",
|
||||
]
|
||||
|
||||
[forge]
|
||||
version = "1.0"
|
||||
last_switch = ""
|
||||
|
||||
[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)
|
||||
# Note: source_path automatically prefixed with "dotfiles/" if not present
|
||||
# Examples:
|
||||
# ".bashrc" = ".bashrc"
|
||||
# ".config/vimrc" = "vimrc"
|
||||
# ".config/alacritty/alacritty.yml" = "alacritty.yml"
|
||||
|
||||
[options]
|
||||
# Additional options
|
||||
backup = true
|
||||
backup_dir = "backup"
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ForgeConfig:
|
||||
copr: list[str]
|
||||
packages: list[str]
|
||||
dotfiles: dict[str, str]
|
||||
options: dict[str, Any]
|
||||
forge_fields: dict[str, Any]
|
||||
document: Optional[tomlkit.TOMLDocument]
|
||||
legacy: bool = False
|
||||
|
||||
|
||||
def log(prefix: str, color: str, message: str) -> None:
|
||||
print(f"{color}{prefix}{RESET} {message}")
|
||||
|
||||
|
||||
def log_info(message: str) -> None:
|
||||
log("[INFO]", BLUE, message)
|
||||
|
||||
|
||||
def log_success(message: str) -> None:
|
||||
log("[SUCCESS]", GREEN, message)
|
||||
|
||||
|
||||
def log_warning(message: str) -> None:
|
||||
log("[WARNING]", YELLOW, message)
|
||||
|
||||
|
||||
def log_error(message: str) -> None:
|
||||
log("[ERROR]", RED, message)
|
||||
|
||||
|
||||
def strip_quotes(value: str) -> str:
|
||||
if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
|
||||
def parse_value(value: str) -> Any:
|
||||
lowered = value.lower()
|
||||
if lowered in {"true", "false"}:
|
||||
return lowered == "true"
|
||||
if lowered in {"null", "none"}:
|
||||
return None
|
||||
return strip_quotes(value)
|
||||
|
||||
|
||||
def parse_legacy_config(config_path: Path) -> ForgeConfig:
|
||||
copr: list[str] = []
|
||||
packages: list[str] = []
|
||||
dotfiles: dict[str, str] = {}
|
||||
options: dict[str, Any] = {}
|
||||
forge_fields: dict[str, Any] = {}
|
||||
section = None
|
||||
|
||||
with config_path.open() as handle:
|
||||
for raw_line in handle:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
section_match = re.match(r"\[(.+)]$", line)
|
||||
if section_match:
|
||||
section = section_match.group(1).strip()
|
||||
continue
|
||||
|
||||
if section is None:
|
||||
continue
|
||||
|
||||
if section in {"packages", "copr"}:
|
||||
entry = line.split("=", 1)[0].strip()
|
||||
if entry:
|
||||
target = packages if section == "packages" else copr
|
||||
target.append(strip_quotes(entry))
|
||||
continue
|
||||
|
||||
if section == "dotfiles":
|
||||
if "=" not in line:
|
||||
continue
|
||||
target_raw, source_raw = line.split("=", 1)
|
||||
target = strip_quotes(target_raw.strip())
|
||||
source = strip_quotes(source_raw.strip())
|
||||
if target and source:
|
||||
dotfiles[target] = source
|
||||
continue
|
||||
|
||||
if "=" not in line:
|
||||
continue
|
||||
|
||||
key_raw, value_raw = line.split("=", 1)
|
||||
key = strip_quotes(key_raw.strip())
|
||||
value = parse_value(value_raw.strip())
|
||||
if not key:
|
||||
continue
|
||||
|
||||
if section == "options":
|
||||
options[key] = value
|
||||
elif section == "forge":
|
||||
forge_fields[key] = value
|
||||
|
||||
merged_options: dict[str, Any] = {"backup": True, "backup_dir": "backup"}
|
||||
merged_options.update(options)
|
||||
|
||||
if not merged_options.get("backup_dir"):
|
||||
merged_options["backup_dir"] = "backup"
|
||||
|
||||
return ForgeConfig(
|
||||
copr=copr,
|
||||
packages=packages,
|
||||
dotfiles=dotfiles,
|
||||
options=merged_options,
|
||||
forge_fields=forge_fields,
|
||||
document=None,
|
||||
legacy=True,
|
||||
)
|
||||
|
||||
|
||||
def empty_config() -> ForgeConfig:
|
||||
return ForgeConfig(
|
||||
copr=[],
|
||||
packages=[],
|
||||
dotfiles={},
|
||||
options={"backup": True, "backup_dir": "backup"},
|
||||
forge_fields={},
|
||||
document=None,
|
||||
legacy=False,
|
||||
)
|
||||
|
||||
|
||||
def parse_document(text: str) -> tomlkit.TOMLDocument | None:
|
||||
try:
|
||||
return tomlkit.parse(text)
|
||||
except TOMLKitError:
|
||||
sanitized = re.sub(r"=\s*null", '= ""', text)
|
||||
if sanitized != text:
|
||||
try:
|
||||
return tomlkit.parse(sanitized)
|
||||
except TOMLKitError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_list(raw: Any) -> list[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, Array):
|
||||
raw = list(raw)
|
||||
if isinstance(raw, list):
|
||||
return [str(item) for item in raw if str(item).strip()]
|
||||
return []
|
||||
|
||||
|
||||
def _parse_table_list(raw: Any, list_key: str) -> list[str]:
|
||||
if isinstance(raw, list):
|
||||
return _coerce_list(raw)
|
||||
if isinstance(raw, (dict, Table)):
|
||||
items: list[str] = []
|
||||
if list_key in raw:
|
||||
items.extend(_coerce_list(raw[list_key]))
|
||||
for key, value in raw.items():
|
||||
if key == list_key:
|
||||
continue
|
||||
if isinstance(value, bool) and value:
|
||||
items.append(str(key))
|
||||
return items
|
||||
return []
|
||||
|
||||
|
||||
def parse_config(config_path: Path) -> ForgeConfig:
|
||||
try:
|
||||
raw_text = config_path.read_text()
|
||||
document = parse_document(raw_text)
|
||||
except FileNotFoundError:
|
||||
document = None
|
||||
|
||||
if document is None:
|
||||
log_warning("Configuration is not valid TOML; using legacy parser.")
|
||||
return parse_legacy_config(config_path)
|
||||
|
||||
copr_raw = document.get("copr", [])
|
||||
packages_raw = document.get("packages", [])
|
||||
dotfiles_raw = document.get("dotfiles", {})
|
||||
options_raw = document.get("options", {})
|
||||
forge_raw = document.get("forge", {})
|
||||
|
||||
if not copr_raw and isinstance(forge_raw, (dict, Table)) and "copr" in forge_raw:
|
||||
copr_raw = forge_raw.get("copr", copr_raw)
|
||||
if not packages_raw and isinstance(forge_raw, (dict, Table)) and "packages" in forge_raw:
|
||||
packages_raw = forge_raw.get("packages", packages_raw)
|
||||
|
||||
copr = _parse_table_list(copr_raw, "repos")
|
||||
packages = _parse_table_list(packages_raw, "names")
|
||||
|
||||
dotfiles: dict[str, str] = {}
|
||||
if isinstance(dotfiles_raw, (dict, Table)):
|
||||
for target, source in dotfiles_raw.items():
|
||||
if source is None:
|
||||
continue
|
||||
dotfiles[str(target)] = str(source)
|
||||
|
||||
options: dict[str, Any] = {}
|
||||
if isinstance(options_raw, (dict, Table)):
|
||||
options.update({str(key): value for key, value in options_raw.items()})
|
||||
|
||||
merged_options: dict[str, Any] = {"backup": True, "backup_dir": "backup"}
|
||||
merged_options.update(options)
|
||||
|
||||
forge_fields: dict[str, Any] = {}
|
||||
if isinstance(forge_raw, (dict, Table)):
|
||||
forge_fields.update({str(key): value for key, value in forge_raw.items()})
|
||||
|
||||
return ForgeConfig(
|
||||
copr=copr,
|
||||
packages=packages,
|
||||
dotfiles=dotfiles,
|
||||
options=merged_options,
|
||||
forge_fields=forge_fields,
|
||||
document=document,
|
||||
legacy=False,
|
||||
)
|
||||
|
||||
|
||||
def load_config(config_path: Path) -> ForgeConfig:
|
||||
if not config_path.exists():
|
||||
return empty_config()
|
||||
try:
|
||||
return parse_config(config_path)
|
||||
except FileNotFoundError:
|
||||
return empty_config()
|
||||
|
||||
|
||||
def diff_items(current: list[str], previous: list[str]) -> tuple[list[str], list[str]]:
|
||||
current_set = {item for item in current if item}
|
||||
previous_set = {item for item in previous if item}
|
||||
added = sorted(current_set - previous_set)
|
||||
removed = sorted(previous_set - current_set)
|
||||
return added, removed
|
||||
|
||||
|
||||
def get_paths() -> tuple[Path, Path, Path]:
|
||||
forge_dir = Path(os.environ.get("FORGE_DIR", Path.home() / ".config" / "forge")).expanduser()
|
||||
forge_toml = forge_dir / "forge.toml"
|
||||
previous_toml = forge_dir / "forge.toml.prev"
|
||||
return forge_dir, forge_toml, previous_toml
|
||||
|
||||
|
||||
def init_command() -> None:
|
||||
forge_dir, forge_toml, _ = get_paths()
|
||||
log_info("Initializing forge...")
|
||||
forge_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if forge_toml.exists():
|
||||
log_warning(f"Configuration already exists at {forge_toml}")
|
||||
else:
|
||||
forge_toml.write_text(DEFAULT_CONFIG)
|
||||
log_success(f"Created forge.toml: {forge_toml}")
|
||||
|
||||
log_success("Initialization complete!")
|
||||
log_info(f"Edit {forge_toml} to configure packages and dotfiles")
|
||||
log_info("Run 'forge switch' to apply your configuration")
|
||||
|
||||
|
||||
def run_command(command: list[str]) -> bool:
|
||||
try:
|
||||
result = subprocess.run(command, check=False)
|
||||
except FileNotFoundError:
|
||||
log_error(f"Command not found: {' '.join(command)}")
|
||||
return False
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def enable_copr_repos(repos: list[str]) -> None:
|
||||
log_info("Enabling COPR repositories...")
|
||||
if not repos:
|
||||
log_info("No COPR repositories to enable")
|
||||
return
|
||||
|
||||
for copr_repo in repos:
|
||||
log_info(f"Enabling COPR repository: {copr_repo}")
|
||||
if run_command(["sudo", "dnf", "copr", "enable", "-y", copr_repo]):
|
||||
log_success(f"Enabled COPR: {copr_repo}")
|
||||
else:
|
||||
log_error(f"Failed to enable COPR: {copr_repo}")
|
||||
|
||||
|
||||
def list_enabled_copr() -> set[str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sudo", "dnf", "copr", "list", "--enabled"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
log_error("dnf not found while listing enabled COPR repositories")
|
||||
return set()
|
||||
|
||||
if result.returncode != 0:
|
||||
log_warning("Unable to list enabled COPR repositories")
|
||||
return set()
|
||||
|
||||
enabled: set[str] = set()
|
||||
for line in result.stdout.splitlines():
|
||||
if "copr.fedorainfracloud.org" in line:
|
||||
repo = line.split()[0]
|
||||
enabled.add(repo)
|
||||
return enabled
|
||||
|
||||
|
||||
def disable_copr_repos(configured: set[str], explicit_removed: set[str] | None = None) -> None:
|
||||
log_info("Checking for COPR repositories to disable...")
|
||||
enabled = list_enabled_copr()
|
||||
to_disable: set[str] = {repo for repo in enabled if repo not in configured}
|
||||
if explicit_removed:
|
||||
to_disable.update(explicit_removed)
|
||||
|
||||
if not to_disable:
|
||||
log_info("No COPR repositories to disable")
|
||||
return
|
||||
|
||||
for repo in sorted(to_disable):
|
||||
log_info(f"Disabling COPR repository: {repo}")
|
||||
if run_command(["sudo", "dnf", "copr", "disable", "-y", repo]):
|
||||
log_success(f"Disabled COPR: {repo}")
|
||||
else:
|
||||
log_error(f"Failed to disable COPR: {repo}")
|
||||
|
||||
|
||||
def install_packages(packages: list[str]) -> None:
|
||||
log_info("Installing packages...")
|
||||
if not packages:
|
||||
log_info("No packages to install")
|
||||
return
|
||||
|
||||
for package in packages:
|
||||
log_info(f"Installing package: {package}")
|
||||
if run_command(["sudo", "dnf", "install", "-y", package]):
|
||||
log_success(f"Installed {package}")
|
||||
else:
|
||||
log_error(f"Failed to install {package}")
|
||||
|
||||
|
||||
def remove_packages(packages: list[str]) -> None:
|
||||
log_info("Removing packages...")
|
||||
if not packages:
|
||||
log_info("No packages to remove")
|
||||
return
|
||||
|
||||
for package in packages:
|
||||
log_info(f"Removing package: {package}")
|
||||
if run_command(["sudo", "dnf", "remove", "-y", package]):
|
||||
log_success(f"Removed {package}")
|
||||
else:
|
||||
log_error(f"Failed to remove {package}")
|
||||
|
||||
|
||||
def backup_target(target_path: Path, backup_dir: Path) -> None:
|
||||
timestamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
backup_path = backup_dir / f"{target_path.name}.{timestamp}.bak"
|
||||
|
||||
if target_path.is_dir():
|
||||
shutil.copytree(target_path, backup_path)
|
||||
else:
|
||||
shutil.copy2(target_path, backup_path)
|
||||
|
||||
log_info(f"Backed up {target_path} to {backup_path}")
|
||||
|
||||
|
||||
def remove_existing(target_path: Path) -> None:
|
||||
if target_path.is_symlink() or target_path.is_file():
|
||||
target_path.unlink()
|
||||
elif target_path.is_dir():
|
||||
shutil.rmtree(target_path)
|
||||
|
||||
|
||||
def deploy_dotfiles(config: ForgeConfig, forge_dir: Path) -> None:
|
||||
log_info("Deploying dotfiles...")
|
||||
if not config.dotfiles:
|
||||
log_info("No dotfiles to deploy")
|
||||
return
|
||||
|
||||
backup_enabled = bool(config.options.get("backup", True))
|
||||
backup_dir_name = str(config.options.get("backup_dir", "backup")) or "backup"
|
||||
backup_dir = forge_dir / backup_dir_name
|
||||
|
||||
for target, source in config.dotfiles.items():
|
||||
source_path = forge_dir / (source if source.startswith("dotfiles/") else f"dotfiles/{source}")
|
||||
target_path = Path.home() / target
|
||||
|
||||
if not source_path.exists():
|
||||
log_error(f"Source file not found: {source_path}")
|
||||
continue
|
||||
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if target_path.exists() or target_path.is_symlink():
|
||||
if target_path.is_symlink():
|
||||
try:
|
||||
if target_path.resolve() == source_path.resolve():
|
||||
log_info(f"Symlink already correct: {target_path}")
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if backup_enabled and target_path.exists() and not target_path.is_symlink():
|
||||
backup_target(target_path, backup_dir)
|
||||
|
||||
remove_existing(target_path)
|
||||
|
||||
relative_source = os.path.relpath(source_path, target_path.parent)
|
||||
try:
|
||||
target_path.symlink_to(relative_source)
|
||||
log_success(f"Created symlink: {target_path} -> {relative_source}")
|
||||
except OSError as exc:
|
||||
log_error(f"Failed to create symlink: {target_path} -> {relative_source} ({exc})")
|
||||
|
||||
|
||||
def update_last_switch(config_path: Path, options: dict[str, Any]) -> None:
|
||||
backup_enabled = bool(options.get("backup", True))
|
||||
backup_dir_name = str(options.get("backup_dir", "backup") or "backup")
|
||||
backup_dir = config_path.parent / backup_dir_name
|
||||
|
||||
if backup_enabled and config_path.exists():
|
||||
try:
|
||||
backup_target(config_path, backup_dir)
|
||||
except OSError as exc:
|
||||
log_warning(f"Failed to back up forge.toml: {exc}")
|
||||
|
||||
timestamp = dt.datetime.now().astimezone()
|
||||
raw_text = config_path.read_text()
|
||||
document = parse_document(raw_text)
|
||||
if document is None:
|
||||
# Fall back to legacy in-place update without rewriting structure.
|
||||
ts_str = timestamp.isoformat(timespec="seconds")
|
||||
lines = raw_text.splitlines()
|
||||
updated = False
|
||||
for idx, line in enumerate(lines):
|
||||
if re.match(r"\s*last_switch\s*=", line):
|
||||
lines[idx] = f'last_switch = "{ts_str}"'
|
||||
updated = True
|
||||
break
|
||||
if not updated:
|
||||
lines.append(f'last_switch = "{ts_str}"')
|
||||
config_path.write_text("\n".join(lines) + "\n")
|
||||
return
|
||||
|
||||
forge_table = document.get("forge")
|
||||
if not isinstance(forge_table, Table):
|
||||
forge_table = tomlkit.table()
|
||||
document["forge"] = forge_table
|
||||
|
||||
forge_table["last_switch"] = timestamp
|
||||
config_path.write_text(tomlkit.dumps(document))
|
||||
|
||||
|
||||
def save_previous_config(current_path: Path, previous_path: Path) -> None:
|
||||
try:
|
||||
shutil.copy2(current_path, previous_path)
|
||||
except OSError as exc:
|
||||
log_warning(f"Failed to write previous configuration snapshot: {exc}")
|
||||
|
||||
|
||||
def switch_command() -> None:
|
||||
forge_dir, forge_toml, previous_toml = get_paths()
|
||||
if not forge_toml.exists():
|
||||
log_error("forge.toml not found. Run 'forge init' first.")
|
||||
sys.exit(1)
|
||||
|
||||
current_config = parse_config(forge_toml)
|
||||
previous_config = load_config(previous_toml)
|
||||
|
||||
copr_added, copr_removed = diff_items(current_config.copr, previous_config.copr)
|
||||
packages_added, packages_removed = diff_items(current_config.packages, previous_config.packages)
|
||||
|
||||
enable_copr_repos(copr_added if copr_added else current_config.copr)
|
||||
disable_copr_repos(set(current_config.copr), set(copr_removed))
|
||||
install_packages(packages_added if packages_added else current_config.packages)
|
||||
remove_packages(packages_removed)
|
||||
deploy_dotfiles(current_config, forge_dir)
|
||||
update_last_switch(forge_toml, current_config.options)
|
||||
save_previous_config(forge_toml, previous_toml)
|
||||
log_success("Switch completed successfully!")
|
||||
|
||||
|
||||
def status_command() -> None:
|
||||
forge_dir, forge_toml, _ = get_paths()
|
||||
log_info("Forge Status")
|
||||
print(f" Config directory: {forge_dir}")
|
||||
print(f" Config file: {forge_toml}")
|
||||
|
||||
if not forge_toml.exists():
|
||||
print(" No configuration file found")
|
||||
return
|
||||
|
||||
config = parse_config(forge_toml)
|
||||
|
||||
print()
|
||||
log_info("Configuration:")
|
||||
last_switch = config.forge_fields.get("last_switch")
|
||||
if last_switch and str(last_switch).lower() != "null":
|
||||
print(f" Last switch: {last_switch}")
|
||||
else:
|
||||
print(" Last switch: Never")
|
||||
|
||||
print()
|
||||
log_info("COPR Repositories:")
|
||||
if config.copr:
|
||||
for repo in config.copr:
|
||||
print(f" {repo}")
|
||||
print(f" Total: {len(config.copr)} COPR repositories")
|
||||
else:
|
||||
print(" No COPR repositories configured")
|
||||
|
||||
print()
|
||||
log_info("Packages:")
|
||||
if config.packages:
|
||||
for package in config.packages:
|
||||
print(f" {package}")
|
||||
print(f" Total: {len(config.packages)} packages")
|
||||
else:
|
||||
print(" No packages configured")
|
||||
|
||||
print()
|
||||
log_info("Dotfiles:")
|
||||
if config.dotfiles:
|
||||
for target, source in config.dotfiles.items():
|
||||
print(f" {target} -> {source}")
|
||||
print(f" Total: {len(config.dotfiles)} dotfiles")
|
||||
else:
|
||||
print(" No dotfiles configured")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="forge",
|
||||
description="Forge - A home-manager like script for Fedora",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
subparsers.add_parser("init", help="Initialize configuration structure")
|
||||
subparsers.add_parser("switch", help="Apply configuration")
|
||||
subparsers.add_parser("status", help="Show current configuration status")
|
||||
subparsers.add_parser("help", help="Show help message")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.command == "init":
|
||||
init_command()
|
||||
elif args.command == "switch":
|
||||
switch_command()
|
||||
elif args.command == "status":
|
||||
status_command()
|
||||
elif args.command == "help":
|
||||
parser.print_help()
|
||||
else:
|
||||
log_error(f"Unknown command: {args.command}")
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
88
tests/test_config.py
Normal file
88
tests/test_config.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import datetime as dt
|
||||
from pathlib import Path
|
||||
|
||||
import tomlkit
|
||||
|
||||
from forge import cli
|
||||
|
||||
|
||||
def test_parse_config_arrays(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "forge.toml"
|
||||
config_path.write_text(
|
||||
"""
|
||||
copr = ["copr.fedorainfracloud.org/user/repo"]
|
||||
packages = ["git", "vim"]
|
||||
|
||||
[forge]
|
||||
version = "1.0"
|
||||
last_switch = ""
|
||||
|
||||
[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.dotfiles == {".bashrc": ".bashrc"}
|
||||
assert config.options["backup"] is True
|
||||
assert config.legacy is False
|
||||
|
||||
|
||||
def test_parse_config_legacy_format(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "forge.toml"
|
||||
config_path.write_text(
|
||||
"""
|
||||
[packages]
|
||||
git
|
||||
vim
|
||||
|
||||
[copr]
|
||||
copr.fedorainfracloud.org/user/repo
|
||||
|
||||
[dotfiles]
|
||||
".bashrc" = ".bashrc"
|
||||
""".strip()
|
||||
)
|
||||
|
||||
config = cli.parse_config(config_path)
|
||||
|
||||
assert config.packages == ["git", "vim"]
|
||||
assert config.copr == ["copr.fedorainfracloud.org/user/repo"]
|
||||
assert config.dotfiles == {".bashrc": ".bashrc"}
|
||||
assert config.legacy is True
|
||||
|
||||
|
||||
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 / "forge.toml"
|
||||
config_path.write_text(
|
||||
"""
|
||||
[forge]
|
||||
version = "1.0"
|
||||
last_switch = ""
|
||||
""".strip()
|
||||
)
|
||||
|
||||
options = {"backup": True, "backup_dir": "backup"}
|
||||
cli.update_last_switch(config_path, options)
|
||||
|
||||
doc = tomlkit.parse(config_path.read_text())
|
||||
assert isinstance(doc["forge"]["last_switch"], dt.datetime)
|
||||
|
||||
backup_dir = config_dir / "backup"
|
||||
backups = list(backup_dir.iterdir())
|
||||
assert backups, "expected a backup of forge.toml to be created"
|
||||
assert any(path.name.startswith("forge.toml.") for path in backups)
|
||||
14
tmpcfg.toml
Normal file
14
tmpcfg.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
|
||||
[forge]
|
||||
version = "1.0"
|
||||
last_switch = ""
|
||||
|
||||
copr = ["copr.fedorainfracloud.org/user/repo"]
|
||||
packages = ["git", "vim"]
|
||||
|
||||
[dotfiles]
|
||||
".bashrc" = ".bashrc"
|
||||
|
||||
[options]
|
||||
backup = true
|
||||
backup_dir = "backup"
|
||||
92
uv.lock
generated
Normal file
92
uv.lock
generated
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
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 = "forge"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "tomlkit" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "tomlkit", specifier = ">=0.12" }]
|
||||
|
||||
[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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomlkit"
|
||||
version = "0.13.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" },
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue