From 02ac070c2e410e41b33ed3b25a3605ac206f23a8 Mon Sep 17 00:00:00 2001 From: kumar vaibhav Date: Fri, 21 Nov 2025 20:58:37 +0530 Subject: [PATCH 01/10] dotfiles working --- forge | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/forge b/forge index 0120279..2b247e4 100755 --- a/forge +++ b/forge @@ -102,8 +102,21 @@ parse_toml() { # Parse key-value pairs for non-packages sections if [[ "$section" != "packages" && "$line" =~ ^([^=]+)=(.+)$ ]]; then - local key="${BASH_REMATCH[1]// /}" - local value="${BASH_REMATCH[2]// /}" + 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 From 47c58ae7eb4683af9c07dde33032a2827f3477eb Mon Sep 17 00:00:00 2001 From: kumar vaibhav Date: Fri, 21 Nov 2025 22:16:20 +0530 Subject: [PATCH 02/10] complete-config --- forge | 95 ++++++++++++++++++++++++++++++----------------------------- 1 file changed, 48 insertions(+), 47 deletions(-) diff --git a/forge b/forge index 2b247e4..dc3b207 100755 --- a/forge +++ b/forge @@ -36,7 +36,7 @@ log_error() { # Initialize command - creates forge.toml and .config directory init() { log_info "Initializing forge..." - + # Create directory mkdir -p "$FORGE_DIR" @@ -93,22 +93,22 @@ parse_toml() { 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]}" @@ -116,7 +116,7 @@ parse_toml() { 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 @@ -133,13 +133,13 @@ parse_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" @@ -150,7 +150,7 @@ enable_copr_repos() { fi fi done < <(parse_toml) - + if [[ "$copr_enabled" == false ]]; then log_info "No COPR repositories to enable" fi @@ -159,21 +159,21 @@ enable_copr_repos() { # 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 @@ -190,13 +190,13 @@ disable_copr_repos() { # 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" @@ -207,7 +207,7 @@ install_packages() { fi fi done < <(parse_toml) - + if [[ "$packages_installed" == false ]]; then log_info "No packages to install" fi @@ -216,29 +216,29 @@ install_packages() { # 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" @@ -247,10 +247,10 @@ deploy_dotfiles() { 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" @@ -259,7 +259,7 @@ deploy_dotfiles() { fi fi done < <(parse_toml) - + if [[ "$dotfiles_deployed" == false ]]; then log_info "No dotfiles to deploy" fi @@ -268,30 +268,30 @@ deploy_dotfiles() { # 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!" } @@ -300,11 +300,11 @@ 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 @@ -312,45 +312,45 @@ status() { 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 @@ -359,13 +359,13 @@ status() { 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 @@ -439,4 +439,5 @@ main() { } # Run main function with all arguments -main "$@" \ No newline at end of file +main "$@" + From 8128e9231114b7b7aa7be5a54544b88c41691ee1 Mon Sep 17 00:00:00 2001 From: kumar vaibhav Date: Tue, 9 Dec 2025 19:40:41 +0530 Subject: [PATCH 03/10] Update forge --- forge | 1 - 1 file changed, 1 deletion(-) diff --git a/forge b/forge index dc3b207..5e2df11 100755 --- a/forge +++ b/forge @@ -440,4 +440,3 @@ main() { # Run main function with all arguments main "$@" - From 359c250101a8e2568371a3c01b0cbfa85b52e58e Mon Sep 17 00:00:00 2001 From: kumar vaibhav Date: Fri, 19 Dec 2025 20:13:03 +0530 Subject: [PATCH 04/10] symlink fixed ! --- README.md | 22 ++++++++++-------- forge | 68 +++++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 61 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index dba85a0..7d4d29c 100644 --- a/README.md +++ b/README.md @@ -118,10 +118,10 @@ wget # 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) -".bashrc" = "dotfiles/.bashrc" -".config/vimrc" = "dotfiles/vimrc" -".config/alacritty/alacritty.yml" = "dotfiles/alacritty.yml" +# source_path: where the actual file is stored (relative to dotfiles directory) +".bashrc" = ".bashrc" +".config/vimrc" = "vimrc" +".config/alacritty/alacritty.yml" = "alacritty.yml" [options] # Additional options @@ -166,7 +166,8 @@ To remove a package, simply delete the line containing the package name. #### `[dotfiles]` Dotfile mappings using symlinks: - **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]` Additional configuration options: @@ -205,9 +206,10 @@ Packages are managed through the `[packages]` section in `forge.toml`: ### 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 +2. Symlinks are created from your home directory to these files using relative paths 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 +5. Source paths are automatically prefixed with "dotfiles/" for convenience ### Backup System Before creating symlinks, Forge: @@ -239,7 +241,7 @@ mkdir -p ~/.config/forge/dotfiles echo "export EDITOR=vim" > ~/.config/forge/dotfiles/.bashrc # Add to [dotfiles] section: -".bashrc" = "dotfiles/.bashrc" +".bashrc" = ".bashrc" # Apply configuration ./forge switch @@ -248,14 +250,14 @@ echo "export EDITOR=vim" > ~/.config/forge/dotfiles/.bashrc ### Managing Application Configurations ```bash # Add alacritty configuration -mkdir -p ~/.config/forge/dotfiles/.config/alacritty -cp ~/.config/alacritty/alacritty.yml ~/.config/forge/dotfiles/.config/alacritty/ +mkdir -p ~/.config/forge/dotfiles +cp ~/.config/alacritty/alacritty.yml ~/.config/forge/dotfiles/ # Edit forge.toml nano ~/.config/forge/forge.toml # Add to [dotfiles] section: -".config/alacritty/alacritty.yml" = "dotfiles/.config/alacritty/alacritty.yml" +".config/alacritty/alacritty.yml" = "alacritty.yml" # Apply changes ./forge switch diff --git a/forge b/forge index 5e2df11..3b3c8a9 100755 --- a/forge +++ b/forge @@ -70,9 +70,11 @@ last_switch = null # 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" = "dotfiles/.bashrc" -# ".config/vimrc" = "dotfiles/vimrc" +# ".bashrc" = ".bashrc" +# ".config/vimrc" = "vimrc" +# ".config/alacritty/alacritty.yml" = "alacritty.yml" [options] # Additional options @@ -218,6 +220,7 @@ 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%%:*}" @@ -228,34 +231,61 @@ deploy_dotfiles() { 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" - # Create source directory if it doesn't exist - local source_dir=$(dirname "$source_path") - mkdir -p "$source_dir" + # 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" - # 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" + # 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 - # 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" + # Create relative symlink for better portability + local relative_source + if [[ -d "$source_path" ]]; then + relative_source=$(realpath --relative-to="$target_dir" "$source_path") else - log_error "Failed to create symlink: $target_path -> $source_path" + 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) From da6d24c74f8b49c2bd0bee457c6b5a7247576285 Mon Sep 17 00:00:00 2001 From: randogoth Date: Wed, 24 Dec 2025 07:56:11 +0200 Subject: [PATCH 05/10] init --- README.md | 206 +++--- forge | 482 +------------- pyproject.toml | 20 + src/forge/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 158 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 160 bytes src/forge/__pycache__/cli.cpython-313.pyc | Bin 0 -> 27871 bytes src/forge/__pycache__/cli.cpython-314.pyc | Bin 0 -> 31794 bytes src/forge/cli.py | 625 ++++++++++++++++++ .../test_config.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 11401 bytes tests/test_config.py | 88 +++ tmpcfg.toml | 14 + uv.lock | 92 +++ 13 files changed, 973 insertions(+), 555 deletions(-) create mode 100644 pyproject.toml create mode 100644 src/forge/__init__.py create mode 100644 src/forge/__pycache__/__init__.cpython-313.pyc create mode 100644 src/forge/__pycache__/__init__.cpython-314.pyc create mode 100644 src/forge/__pycache__/cli.cpython-313.pyc create mode 100644 src/forge/__pycache__/cli.cpython-314.pyc create mode 100644 src/forge/cli.py create mode 100644 tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/test_config.py create mode 100644 tmpcfg.toml create mode 100644 uv.lock diff --git a/README.md b/README.md index 7d4d29c..77e30d2 100644 --- a/README.md +++ b/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. \ No newline at end of file +This project is open source. Feel free to contribute or report issues. diff --git a/forge b/forge index 3b3c8a9..c83e04c 100755 --- a/forge +++ b/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 < - - 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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..462e47b --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/forge/__init__.py b/src/forge/__init__.py new file mode 100644 index 0000000..a762498 --- /dev/null +++ b/src/forge/__init__.py @@ -0,0 +1 @@ +# Forge package diff --git a/src/forge/__pycache__/__init__.cpython-313.pyc b/src/forge/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b85de530786ed033617868e5735239a554330592 GIT binary patch literal 158 zcmey&%ge>Uz`#&C%{xC!t0Qo;AlmGw# literal 0 HcmV?d00001 diff --git a/src/forge/__pycache__/__init__.cpython-314.pyc b/src/forge/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8a8ffc60ce3ad9c4f4753aeafffc7724964cd113 GIT binary patch literal 160 zcmdPq9gC?WjN`@jP1_p-DAjw;{ z`elhl`WgATsrp5Uc`5nn`6U_p0Y&*)smUe9`pNkzsrqU8Md_*f#YM?bR(yPBUS>&r myk0@&Ee@O9{FKt1RJ$Tp1_lO@eZ?TgCuT-Q#v*101_l6yyd}v1 literal 0 HcmV?d00001 diff --git a/src/forge/__pycache__/cli.cpython-313.pyc b/src/forge/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..047e443e20e56a7752e5b849112320ccad2e34b4 GIT binary patch literal 27871 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 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() diff --git a/tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..432987aaafe81849889a343446c4fa3623a2ff2d GIT binary patch literal 11401 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 literal 0 HcmV?d00001 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..7bc0486 --- /dev/null +++ b/tests/test_config.py @@ -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) diff --git a/tmpcfg.toml b/tmpcfg.toml new file mode 100644 index 0000000..180e6a8 --- /dev/null +++ b/tmpcfg.toml @@ -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" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2121675 --- /dev/null +++ b/uv.lock @@ -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" }, +] From df1f387795c7a456fbd35caeb6cf34d385235cb3 Mon Sep 17 00:00:00 2001 From: randogoth Date: Wed, 24 Dec 2025 08:54:24 +0200 Subject: [PATCH 06/10] added package removal, brew, flatpak, ostree, nix xupport --- README.md | 317 ++++++----- forge => curator | 6 +- pyproject.toml | 6 +- src/curator/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 160 bytes .../__pycache__/__init__.cpython-314.pyc | Bin src/curator/__pycache__/cli.cpython-313.pyc | Bin 0 -> 33722 bytes .../__pycache__/cli.cpython-314.pyc | Bin src/{forge => curator}/cli.py | 532 +++++++++++------- src/forge/__init__.py | 1 - .../__pycache__/__init__.cpython-313.pyc | Bin 158 -> 0 bytes src/forge/__pycache__/cli.cpython-313.pyc | Bin 27871 -> 0 bytes .../test_config.cpython-313-pytest-9.0.2.pyc | Bin 11401 -> 15310 bytes tests/test_config.py | 78 ++- tmpcfg.toml | 2 +- uv.lock | 15 +- 16 files changed, 596 insertions(+), 362 deletions(-) rename forge => curator (74%) create mode 100644 src/curator/__init__.py create mode 100644 src/curator/__pycache__/__init__.cpython-313.pyc rename src/{forge => curator}/__pycache__/__init__.cpython-314.pyc (100%) create mode 100644 src/curator/__pycache__/cli.cpython-313.pyc rename src/{forge => curator}/__pycache__/cli.cpython-314.pyc (100%) rename src/{forge => curator}/cli.py (50%) delete mode 100644 src/forge/__init__.py delete mode 100644 src/forge/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/forge/__pycache__/cli.cpython-313.pyc diff --git a/README.md b/README.md index 77e30d2..d757aea 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ -# Forge - A Home-Manager Like Tool for Fedora +# curator - A Home-Manager Like Tool for Fedora 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 Python CLI 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) - Installing and managing system packages via dnf - 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 - Automatic backup of existing files before replacement @@ -21,11 +21,11 @@ Forge is a Python CLI that helps you manage your Fedora system configuration by: ``` 3. Run with `uv`: ```bash - uv run forge --help + uv run curator --help ``` 4. Or use the local shim directly: ```bash - ./forge --help + ./curator --help ``` 5. Optionally install globally via `uv`: ```bash @@ -36,46 +36,48 @@ Forge is a Python CLI that helps you manage your Fedora system configuration by: ```bash # Initialize the configuration structure -uv run forge init +uv run curator init # Edit the configuration file to add packages and dotfiles -nano ~/.config/forge/forge.toml +nano ~/.config/curator/inventory.toml # Apply configuration -uv run forge switch +uv run curator switch # Check status -uv run forge status +uv run curator status ``` -You can swap `uv run forge ...` for `./forge ...` if you prefer the local shim. +You can swap `uv run curator ...` for `./curator ...` if you prefer the local shim. ## Commands ### `init` -Initialize the configuration structure and create `forge.toml`. +Initialize the configuration structure and create `inventory.toml`. ```bash -uv run forge init -# or ./forge init +uv run curator init +# or ./curator init ``` Creates: -- `~/.config/forge/` - Main configuration directory -- `~/.config/forge/forge.toml` - Central configuration file +- `~/.config/curator/` - Main configuration directory +- `~/.config/curator/inventory.toml` - Central configuration file ### `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 -uv run forge switch -# or ./forge switch +uv run curator switch +# or ./curator switch +# rollback to the previous inventory.toml and apply it +uv run curator switch --rollback ``` 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 -3. Installs all packages listed in `forge.toml` +3. Installs all packages listed in `inventory.toml` 4. Creates symlinks for all configured dotfiles 5. Creates backups of existing files before replacing them 6. Updates the last switch timestamp @@ -84,8 +86,8 @@ This command: Show current configuration status and information. ```bash -uv run forge status -# or ./forge status +uv run curator status +# or ./curator status ``` Displays: @@ -97,35 +99,49 @@ Displays: Show help message with all available commands. ```bash -uv run forge help -# or ./forge help +uv run curator help +# or ./curator help ``` ## Configuration -### forge.toml -The central configuration file located at `~/.config/forge/forge.toml`: +### inventory.toml +The central configuration file located at `~/.config/curator/inventory.toml`: ```toml -# Forge Configuration File +# curator 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] +[curator] version = "1.0" last_switch = "" +[copr] +# copr.fedorainfracloud.org/username/repository +# copr.fedorainfracloud.org/anotheruser/anotherrepo + +[dnf] +# git +# vim +# 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" + [dotfiles] # Dotfiles to manage with symlinks # Format: "target_path" = "source_path" @@ -143,40 +159,44 @@ backup_dir = "backup" ### Configuration Sections -#### `[forge]` +#### `[curator]` - `version`: Configuration file version - `last_switch`: Timestamp of last switch operation (auto-updated) -#### `copr` -Array of COPR repositories to enable via `dnf copr enable`. **Just list COPR repository names** - presence means enable, absence means don't enable. +#### `[copr]` +List of COPR repositories to enable via `dnf copr enable`. **One repository per line**—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. +To remove a COPR repository, simply delete the line containing the repository name. curator will automatically disable it during the next switch. -#### `packages` -Array of packages to install via dnf. **Just list package names** - presence means install, absence means don't install. +#### `[dnf]` +List of packages to install via dnf. **One package per line**—presence means install, absence means don't install. **Examples:** ```toml -packages = [ - "git", - "vim", - "curl", - "wget", - "nodejs", - "npm", -] +[dnf] +git +vim +curl +wget +nodejs +npm ``` 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. + #### `[dotfiles]` Dotfile mappings using symlinks: - **Key**: Target path where symlink should be created (relative to home directory) @@ -186,13 +206,13 @@ Dotfile mappings using symlinks: #### `[options]` Additional configuration options: - `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 ``` -~/.config/forge/ -├── forge.toml # Main configuration file +~/.config/curator/ +├── inventory.toml # Main configuration file ├── dotfiles/ # Your actual dotfiles (source files) │ ├── .bashrc │ ├── vimrc @@ -205,162 +225,205 @@ Additional configuration options: ## How It Works ### COPR Repository Management -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 +COPR repositories are managed through the `[copr]` section in `inventory.toml`: +- **Add COPR repo**: Add the repository name on its own line +- **Remove COPR repo**: Remove the entry +- **Automatic cleanup**: curator 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. +- curator stores the previous configuration at `~/.config/curator/inventory.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` array in `forge.toml`: -- **Add package**: Add the package name to the array -- **Remove package**: Remove the entry from the array +Packages are managed through the `[dnf]` section in `inventory.toml`: +- **Add package**: Add the package name on its own line +- **Remove package**: Remove the entry - **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. +- The previous configuration snapshot (`inventory.toml.prev`) is used to detect additions/removals on each `switch`; added packages are installed and removed packages are uninstalled. + +### Brew Management +Homebrew packages are managed through the `[brew]` section: +- **Add package**: Add the package name on its own line +- **Remove package**: Remove the entry +- Additions/removals are detected against `inventory.toml.prev` on `switch`; removed packages are uninstalled. + +### Flatpak Management +Flatpaks are managed through the `[flatpak]` section: +- **Add ref**: Add the ref on its own line +- **Remove ref**: Remove the entry +- Additions/removals are detected against `inventory.toml.prev` on `switch`; removed refs are uninstalled. + +### rpm-ostree Management +rpm-ostree packages are managed through the `[rpm-ostree]` section: +- **Add package**: Add the package name on its own line +- **Remove package**: Remove the entry +- Additions/removals are detected against `inventory.toml.prev` on `switch`; removed packages are uninstalled. + +### Nix Management +Nix packages are managed through the `[nix]` section (if `nix` is available on the system): +- **Add package**: Add the package name (e.g., `nixpkgs#git` or just `git`) on its own line +- **Remove package**: Remove the entry +- Additions/removals are detected against `inventory.toml.prev` on `switch`; installs/removals use `nix profile` and will prefix `nixpkgs#` if missing. ### Dotfile Management -Forge uses symlinks to manage dotfiles: -1. Your actual dotfiles are stored in `~/.config/forge/dotfiles/` +curator uses symlinks to manage dotfiles: +1. Your actual dotfiles are stored in `~/.config/curator/dotfiles/` 2. Symlinks are created from your home directory to these files using relative paths 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 5. Source paths are automatically prefixed with "dotfiles/" for convenience ### Backup System -Before creating symlinks, Forge: +Before creating symlinks, curator: 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 -`forge.toml` is also backed up before the last switch timestamp is updated. +`inventory.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. +The previously applied configuration is stored separately as `~/.config/curator/inventory.toml.prev` to compute diffs for COPR and package changes. ## Examples ### Basic Setup ```bash -# Initialize forge -uv run forge init +# Initialize curator +uv run curator init -# Edit forge.toml to add COPR repos and packages -nano ~/.config/forge/forge.toml +# Edit inventory.toml to add COPR repos and packages +nano ~/.config/curator/inventory.toml -# Add to the arrays: -# copr = [ -# "copr.fedorainfracloud.org/username/cool-repo", -# ] -# packages = [ -# "git", -# "vim", -# "curl", -# ] +# Add to the sections: +# [copr] +# copr.fedorainfracloud.org/username/cool-repo +# [dnf] +# git +# vim +# curl +# [brew] +# wget +# [flatpak] +# org.mozilla.firefox +# [rpm-ostree] +# podman +# [nix] +# nixpkgs#git (or just "git") # Create your dotfiles directory and add files -mkdir -p ~/.config/forge/dotfiles -echo "export EDITOR=vim" > ~/.config/forge/dotfiles/.bashrc +mkdir -p ~/.config/curator/dotfiles +echo "export EDITOR=vim" > ~/.config/curator/dotfiles/.bashrc # Add to [dotfiles] section: ".bashrc" = ".bashrc" # Apply configuration -uv run forge switch +uv run curator switch ``` ### Managing Application Configurations ```bash # Add alacritty configuration -mkdir -p ~/.config/forge/dotfiles -cp ~/.config/alacritty/alacritty.yml ~/.config/forge/dotfiles/ +mkdir -p ~/.config/curator/dotfiles +cp ~/.config/alacritty/alacritty.yml ~/.config/curator/dotfiles/ -# Edit forge.toml -nano ~/.config/forge/forge.toml +# Edit inventory.toml +nano ~/.config/curator/inventory.toml # Add to [dotfiles] section: ".config/alacritty/alacritty.yml" = "alacritty.yml" # Apply changes -uv run forge switch +uv run curator switch ``` ### COPR Repository Management Examples ```toml -copr = [ - # Development tools COPR - "copr.fedorainfracloud.org/development/tools", - "copr.fedorainfracloud.org/user/neovim-nightly", -] +[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 +# curator will automatically disable it during the next switch ``` ### Package Management Examples ```toml -packages = [ - # Development tools - "git", - "vim", - "nodejs", - "npm", +[dnf] +# Development tools +git +vim +nodejs +npm - # System utilities - "curl", - "wget", - "tree", - "htop", -] +# System utilities +curl +wget +tree +htop # To remove a package, just delete the entry + +[brew] +# Utilities and tools +wget +coreutils + +[flatpak] +org.mozilla.firefox +com.spotify.Client + +[rpm-ostree] +podman +htop ``` ### Version Control Your Configuration ```bash -# Initialize git repository in forge directory -cd ~/.config/forge +# Initialize git repository in curator directory +cd ~/.config/curator git init git add . git commit -m "Initial configuration" # Now you can version control your entire system configuration -git add forge.toml dotfiles/ +git add inventory.toml dotfiles/ git commit -m "Updated vim configuration" ``` ## Environment Variables -- `FORGE_DIR`: Override the default configuration directory (default: `~/.config/forge`) +- `CURATOR_DIR`: Override the default configuration directory (default: `~/.config/curator`) ## 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 ## Migration from Previous Version -If you were using the old version of forge with `git = true` or bare keys under `[packages]`/`[copr]`: +If you were using the old version of curator with `git = true` or bare keys under `[packages]`/`[copr]`: 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: +2. Run `uv run curator init` (or `./curator init`) to create the new `inventory.toml` structure. +3. Convert to section-per-line format: ```toml # Old formats - [packages] + [dnf] git = true vim = true # or - [packages] + [dnf] git vim # New format - packages = ["git", "vim"] - copr = ["copr.fedorainfracloud.org/username/repository"] + [dnf] + git + vim + [copr] + copr.fedorainfracloud.org/username/repository ``` -4. Move your existing dotfiles from the old directory to `~/.config/forge/dotfiles/` +4. Move your existing dotfiles from the old directory to `~/.config/curator/dotfiles/` ## License diff --git a/forge b/curator similarity index 74% rename from forge rename to curator index c83e04c..93b8b3f 100755 --- a/forge +++ b/curator @@ -12,12 +12,12 @@ def main() -> None: sys.path.insert(0, str(src_dir)) try: - from forge.cli import main as forge_main + 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 forge CLI: {exc}\n") + sys.stderr.write(f"Failed to import curator CLI: {exc}\n") sys.exit(1) - forge_main() + curator_main() if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 462e47b..8c24ee8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] -name = "forge" +name = "curator" version = "0.1.0" description = "A home-manager style Fedora configuration helper." readme = "README.md" requires-python = ">=3.11" -dependencies = ["tomlkit>=0.12"] +dependencies = [] [project.scripts] -forge = "forge.cli:main" +curator = "curator.cli:main" [build-system] requires = ["hatchling"] diff --git a/src/curator/__init__.py b/src/curator/__init__.py new file mode 100644 index 0000000..87d4f32 --- /dev/null +++ b/src/curator/__init__.py @@ -0,0 +1 @@ +# curator package diff --git a/src/curator/__pycache__/__init__.cpython-313.pyc b/src/curator/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c77895bd24ea280c072a453aab42f1333255c96 GIT binary patch literal 160 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 None: @@ -104,12 +117,16 @@ def parse_value(value: str) -> Any: return strip_quotes(value) -def parse_legacy_config(config_path: Path) -> ForgeConfig: +def parse_config(config_path: Path) -> CuratorConfig: copr: list[str] = [] - packages: list[str] = [] + dnf_packages: list[str] = [] + brew_packages: list[str] = [] + flatpak_refs: list[str] = [] + rpm_ostree_packages: list[str] = [] + nix_packages: list[str] = [] dotfiles: dict[str, str] = {} options: dict[str, Any] = {} - forge_fields: dict[str, Any] = {} + curator_fields: dict[str, Any] = {} section = None with config_path.open() as handle: @@ -126,11 +143,21 @@ def parse_legacy_config(config_path: Path) -> ForgeConfig: if section is None: continue - if section in {"packages", "copr"}: + if section in {"dnf", "copr", "brew", "flatpak", "rpm-ostree", "nix"}: entry = line.split("=", 1)[0].strip() if entry: - target = packages if section == "packages" else copr - target.append(strip_quotes(entry)) + if section == "copr": + copr.append(strip_quotes(entry)) + elif section == "brew": + brew_packages.append(strip_quotes(entry)) + elif section == "flatpak": + flatpak_refs.append(strip_quotes(entry)) + elif section == "rpm-ostree": + rpm_ostree_packages.append(strip_quotes(entry)) + elif section == "nix": + nix_packages.append(strip_quotes(entry)) + else: + dnf_packages.append(strip_quotes(entry)) continue if section == "dotfiles": @@ -154,8 +181,8 @@ def parse_legacy_config(config_path: Path) -> ForgeConfig: if section == "options": options[key] = value - elif section == "forge": - forge_fields[key] = value + elif section == "curator": + curator_fields[key] = value merged_options: dict[str, Any] = {"backup": True, "backup_dir": "backup"} merged_options.update(options) @@ -163,123 +190,37 @@ def parse_legacy_config(config_path: Path) -> ForgeConfig: if not merged_options.get("backup_dir"): merged_options["backup_dir"] = "backup" - return ForgeConfig( + return CuratorConfig( copr=copr, - packages=packages, + packages=dnf_packages, + brew_packages=brew_packages, + flatpak_refs=flatpak_refs, + rpm_ostree_packages=rpm_ostree_packages, + nix_packages=nix_packages, dotfiles=dotfiles, options=merged_options, - forge_fields=forge_fields, + curator_fields=curator_fields, document=None, - legacy=True, ) -def empty_config() -> ForgeConfig: - return ForgeConfig( +def empty_config() -> CuratorConfig: + return CuratorConfig( copr=[], packages=[], + brew_packages=[], + flatpak_refs=[], + rpm_ostree_packages=[], + nix_packages=[], dotfiles={}, options={"backup": True, "backup_dir": "backup"}, - forge_fields={}, + curator_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: +def load_config(config_path: Path) -> CuratorConfig: if not config_path.exists(): return empty_config() try: @@ -297,26 +238,26 @@ def diff_items(current: list[str], previous: list[str]) -> tuple[list[str], list 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 + curator_dir = Path(os.environ.get("CURATOR_DIR", Path.home() / ".config" / "curator")).expanduser() + curator_toml = curator_dir / "inventory.toml" + previous_toml = curator_dir / "inventory.toml.prev" + return curator_dir, curator_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) + curator_dir, curator_toml, _ = get_paths() + log_info("Initializing curator...") + curator_dir.mkdir(parents=True, exist_ok=True) - if forge_toml.exists(): - log_warning(f"Configuration already exists at {forge_toml}") + if curator_toml.exists(): + log_warning(f"Configuration already exists at {curator_toml}") else: - forge_toml.write_text(DEFAULT_CONFIG) - log_success(f"Created forge.toml: {forge_toml}") + curator_toml.write_text(DEFAULT_CONFIG) + log_success(f"Created inventory.toml: {curator_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") + log_info(f"Edit {curator_toml} to configure packages and dotfiles") + log_info("Run 'curator switch' to apply your configuration") def run_command(command: list[str]) -> bool: @@ -399,6 +340,74 @@ def install_packages(packages: list[str]) -> None: log_error(f"Failed to install {package}") +def install_brew_packages(packages: list[str]) -> None: + log_info("Installing brew packages...") + if not packages: + log_info("No brew packages to install") + return + + for package in packages: + log_info(f"Installing brew package: {package}") + if run_command(["brew", "install", package]): + log_success(f"Installed {package}") + else: + log_error(f"Failed to install {package}") + + +def install_flatpaks(refs: list[str]) -> None: + log_info("Installing flatpak refs...") + if not refs: + log_info("No flatpaks to install") + return + + for ref in refs: + log_info(f"Installing flatpak: {ref}") + if run_command(["flatpak", "install", "-y", ref]): + log_success(f"Installed {ref}") + else: + log_error(f"Failed to install {ref}") + + +def install_rpm_ostree_packages(packages: list[str]) -> None: + log_info("Installing rpm-ostree packages...") + if not packages: + log_info("No rpm-ostree packages to install") + return + + for package in packages: + log_info(f"Installing rpm-ostree package: {package}") + if run_command(["rpm-ostree", "install", "-y", package]): + log_success(f"Installed {package}") + else: + log_error(f"Failed to install {package}") + + +def nix_available() -> bool: + return shutil.which("nix") is not None + + +def normalize_nix_ref(ref: str) -> str: + return ref if "#" in ref else f"nixpkgs#{ref}" + + +def install_nix_packages(packages: list[str]) -> None: + log_info("Installing nix packages...") + if not packages: + log_info("No nix packages to install") + return + if not nix_available(): + log_warning("nix not found on PATH; skipping nix package installs") + return + + for package in packages: + ref = normalize_nix_ref(package) + log_info(f"Installing nix package: {ref}") + if run_command(["nix", "profile", "install", ref]): + log_success(f"Installed {ref}") + else: + log_error(f"Failed to install {ref}") + + def remove_packages(packages: list[str]) -> None: log_info("Removing packages...") if not packages: @@ -413,6 +422,66 @@ def remove_packages(packages: list[str]) -> None: log_error(f"Failed to remove {package}") +def remove_brew_packages(packages: list[str]) -> None: + log_info("Removing brew packages...") + if not packages: + log_info("No brew packages to remove") + return + + for package in packages: + log_info(f"Removing brew package: {package}") + if run_command(["brew", "uninstall", package]): + log_success(f"Removed {package}") + else: + log_error(f"Failed to remove {package}") + + +def remove_flatpaks(refs: list[str]) -> None: + log_info("Removing flatpaks...") + if not refs: + log_info("No flatpaks to remove") + return + + for ref in refs: + log_info(f"Removing flatpak: {ref}") + if run_command(["flatpak", "uninstall", "-y", ref]): + log_success(f"Removed {ref}") + else: + log_error(f"Failed to remove {ref}") + + +def remove_rpm_ostree_packages(packages: list[str]) -> None: + log_info("Removing rpm-ostree packages...") + if not packages: + log_info("No rpm-ostree packages to remove") + return + + for package in packages: + log_info(f"Removing rpm-ostree package: {package}") + if run_command(["rpm-ostree", "uninstall", "-y", package]): + log_success(f"Removed {package}") + else: + log_error(f"Failed to remove {package}") + + +def remove_nix_packages(packages: list[str]) -> None: + log_info("Removing nix packages...") + if not packages: + log_info("No nix packages to remove") + return + if not nix_available(): + log_warning("nix not found on PATH; skipping nix package removals") + return + + for package in packages: + ref = normalize_nix_ref(package) + log_info(f"Removing nix package: {ref}") + if run_command(["nix", "profile", "remove", ref]): + log_success(f"Removed {ref}") + else: + log_error(f"Failed to remove {ref}") + + 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) @@ -433,7 +502,7 @@ def remove_existing(target_path: Path) -> None: shutil.rmtree(target_path) -def deploy_dotfiles(config: ForgeConfig, forge_dir: Path) -> None: +def deploy_dotfiles(config: CuratorConfig, curator_dir: Path) -> None: log_info("Deploying dotfiles...") if not config.dotfiles: log_info("No dotfiles to deploy") @@ -441,10 +510,10 @@ def deploy_dotfiles(config: ForgeConfig, forge_dir: Path) -> None: 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 + backup_dir = curator_dir / backup_dir_name for target, source in config.dotfiles.items(): - source_path = forge_dir / (source if source.startswith("dotfiles/") else f"dotfiles/{source}") + source_path = curator_dir / (source if source.startswith("dotfiles/") else f"dotfiles/{source}") target_path = Path.home() / target if not source_path.exists(): @@ -484,33 +553,29 @@ def update_last_switch(config_path: Path, options: dict[str, Any]) -> None: try: backup_target(config_path, backup_dir) except OSError as exc: - log_warning(f"Failed to back up forge.toml: {exc}") + log_warning(f"Failed to back up inventory.toml: {exc}") - timestamp = dt.datetime.now().astimezone() + timestamp = dt.datetime.now().astimezone().isoformat(timespec="seconds") 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 + lines = raw_text.splitlines() - forge_table = document.get("forge") - if not isinstance(forge_table, Table): - forge_table = tomlkit.table() - document["forge"] = forge_table + updated = False + insert_idx: Optional[int] = None + for idx, line in enumerate(lines): + if re.match(r"\s*\[curator\]\s*$", line): + insert_idx = idx + 1 + if re.match(r"\s*last_switch\s*=", line): + lines[idx] = f'last_switch = "{timestamp}"' + updated = True + break - forge_table["last_switch"] = timestamp - config_path.write_text(tomlkit.dumps(document)) + if not updated: + if insert_idx is None: + lines.append("[curator]") + insert_idx = len(lines) + lines.insert(insert_idx, f'last_switch = "{timestamp}"') + + config_path.write_text("\n".join(lines) + "\n") def save_previous_config(current_path: Path, previous_path: Path) -> None: @@ -521,42 +586,84 @@ def save_previous_config(current_path: Path, previous_path: Path) -> None: 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.") + _switch(rollback=False) + + +def switch_command_with_args(rollback: bool = False) -> None: + _switch(rollback=rollback) + + +def _switch(rollback: bool) -> None: + curator_dir, curator_toml, previous_toml = get_paths() + if not curator_toml.exists() and not rollback: + log_error("inventory.toml not found. Run 'curator init' first.") sys.exit(1) - current_config = parse_config(forge_toml) - previous_config = load_config(previous_toml) + if rollback: + if not previous_toml.exists(): + log_error("No previous inventory.toml to roll back to.") + sys.exit(1) + # Backup current config if present + current_before = load_config(curator_toml) if curator_toml.exists() else empty_config() + current_options = current_before.options or {"backup": True, "backup_dir": "backup"} + backup_dir_name = str(current_options.get("backup_dir", "backup") or "backup") + backup_dir = curator_toml.parent / backup_dir_name + if curator_toml.exists(): + try: + backup_target(curator_toml, backup_dir) + except OSError as exc: + log_warning(f"Failed to back up current inventory.toml before rollback: {exc}") + + shutil.copy2(previous_toml, curator_toml) + log_info(f"Restored inventory.toml from {previous_toml}") + current_config = parse_config(curator_toml) + previous_config = current_before + else: + current_config = parse_config(curator_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) + brew_added, brew_removed = diff_items(current_config.brew_packages, previous_config.brew_packages) + flatpak_added, flatpak_removed = diff_items(current_config.flatpak_refs, previous_config.flatpak_refs) + rpm_ostree_added, rpm_ostree_removed = diff_items( + current_config.rpm_ostree_packages, previous_config.rpm_ostree_packages + ) + nix_added, nix_removed = diff_items(current_config.nix_packages, previous_config.nix_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) + install_brew_packages(brew_added if brew_added else current_config.brew_packages) + install_flatpaks(flatpak_added if flatpak_added else current_config.flatpak_refs) + install_rpm_ostree_packages(rpm_ostree_added if rpm_ostree_added else current_config.rpm_ostree_packages) + install_nix_packages(nix_added if nix_added else current_config.nix_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) + remove_brew_packages(brew_removed) + remove_flatpaks(flatpak_removed) + remove_rpm_ostree_packages(rpm_ostree_removed) + remove_nix_packages(nix_removed) + deploy_dotfiles(current_config, curator_dir) + update_last_switch(curator_toml, current_config.options) + save_previous_config(curator_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}") + curator_dir, curator_toml, _ = get_paths() + log_info("curator Status") + print(f" Config directory: {curator_dir}") + print(f" Config file: {curator_toml}") - if not forge_toml.exists(): + if not curator_toml.exists(): print(" No configuration file found") return - config = parse_config(forge_toml) + config = parse_config(curator_toml) print() log_info("Configuration:") - last_switch = config.forge_fields.get("last_switch") + last_switch = config.curator_fields.get("last_switch") if last_switch and str(last_switch).lower() != "null": print(f" Last switch: {last_switch}") else: @@ -572,13 +679,49 @@ def status_command() -> None: print(" No COPR repositories configured") print() - log_info("Packages:") + log_info("DNF Packages:") if config.packages: for package in config.packages: print(f" {package}") - print(f" Total: {len(config.packages)} packages") + print(f" Total: {len(config.packages)} dnf packages") else: - print(" No packages configured") + print(" No dnf packages configured") + + print() + log_info("Brew Packages:") + if config.brew_packages: + for package in config.brew_packages: + print(f" {package}") + print(f" Total: {len(config.brew_packages)} brew packages") + else: + print(" No brew packages configured") + + print() + log_info("Flatpaks:") + if config.flatpak_refs: + for ref in config.flatpak_refs: + print(f" {ref}") + print(f" Total: {len(config.flatpak_refs)} flatpaks") + else: + print(" No flatpaks configured") + + print() + log_info("rpm-ostree Packages:") + if config.rpm_ostree_packages: + for package in config.rpm_ostree_packages: + print(f" {package}") + print(f" Total: {len(config.rpm_ostree_packages)} rpm-ostree packages") + else: + print(" No rpm-ostree packages configured") + + print() + log_info("Nix Packages:") + if config.nix_packages: + for package in config.nix_packages: + print(f" {package}") + print(f" Total: {len(config.nix_packages)} nix packages") + else: + print(" No nix packages configured") print() log_info("Dotfiles:") @@ -592,12 +735,17 @@ def status_command() -> None: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - prog="forge", - description="Forge - A home-manager like script for Fedora", + prog="curator", + description="curator - 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") + switch_parser = subparsers.add_parser("switch", help="Apply configuration") + switch_parser.add_argument( + "--rollback", + action="store_true", + help="Restore the previous inventory.toml snapshot and apply it", + ) subparsers.add_parser("status", help="Show current configuration status") subparsers.add_parser("help", help="Show help message") return parser @@ -610,7 +758,7 @@ def main(argv: list[str] | None = None) -> None: if args.command == "init": init_command() elif args.command == "switch": - switch_command() + switch_command_with_args(getattr(args, "rollback", False)) elif args.command == "status": status_command() elif args.command == "help": diff --git a/src/forge/__init__.py b/src/forge/__init__.py deleted file mode 100644 index a762498..0000000 --- a/src/forge/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Forge package diff --git a/src/forge/__pycache__/__init__.cpython-313.pyc b/src/forge/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index b85de530786ed033617868e5735239a554330592..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 158 zcmey&%ge>Uz`#&C%{xC!t0Qo;AlmGw# diff --git a/src/forge/__pycache__/cli.cpython-313.pyc b/src/forge/__pycache__/cli.cpython-313.pyc deleted file mode 100644 index 047e443e20e56a7752e5b849112320ccad2e34b4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27871 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

_K@D;j1hPpxA?oT}1Fe61EUEmq7+Wn8@T6 z7KI`k1_p*?NaR6Cc81N@G&`B=i3~kWwjyUxc({THa}WUvty|m<1(oq>`9-;jB_>6n z04ru=U|@g*Rgo0~1H&zTa2`m>OiPQ;EJ@8RF7jYtU;u?)@l^%}hK9-abgxQ-9S2QX zVAp{HoSA`v;j`G}b9(KQbF~y$LYabiCkrwv3FWbbvIMh%;x3pSMCP$he#EOmxEFQi0#gp!o|Q4%n{5PEi(C{vE1bS#)6!nR0fh{m~0>=!lJ|w zEj0O~u`Hv+feg8G|{aL6Y*5^Rzixg1PV+#fNMZ9~Psyphn#@ z;ADa@)!AV-1#|1Mg3CRQXi)M8>yAda)rL7a(S$_j0NeTjKM6r+zbqP zoYA0k4%QIL8KepdvdNxCg4}S;L28o&nMFl;;8yX1@=fKXclXLXd}&H9k3cxm>N^K8e`33y_Cd~)RN5H zRIXZ0uISoZT$#n0dBr7(dC94_m{LkKS&R5V8BqX4xPS;j5CO^sRl2Da1*yp;sVNGH z3J|v{T;OxxRlcF5zp}IPI=}Koe&q$e*ZGYu@Eh$apDbkV#VZR6AyH^SJvq}{ zww@hS2WMxN++qb+g10z}QWI0+OHwOJiu^!@0|(59MZO@Xg9_9laQO``l|XS@1WGPN z@Iw3+OHO8S$u0KGlGGwl;0rg5%FmEe4n`JPO>VT%kx5}-U;xEM z@gYWV#V#n>;B`YtW=73LA??YhEG+7ua0&M7cInRGzAmAAQ9}2!g#KkNg9hgtVv;k| zC*~}$y(ngQfyI7{(RS;N))!dpd#o;q8D8eLzrbRDjobb@3ktdjl81{S@vgGiUl22d z%Rm@cK*pLw*walXmtzO(!?pZf(C_a5U5eD0UILCmY%?$=pR&_$3uTnve4f0f1k z0v}ZEWfu1f7+Np0xPxmtO{OAHJ>;h;c#9*cG&83pGq1QvZSp-UV@Q1$1Bzd83S=)x zEXl~pOez9ZH${;kac>X-ZexI2X+@yA3S4Owf#P;01K0vc-Ns>)o1apelWJF#&A`9_ zsw9d*sqX_bBO~K|28I@f%M7A-86+Mv@I7S^{lF&0sI None: - config_path = tmp_path / "forge.toml" +def test_parse_config_line_format(tmp_path: Path) -> None: + config_path = tmp_path / "inventory.toml" config_path.write_text( """ -copr = ["copr.fedorainfracloud.org/user/repo"] -packages = ["git", "vim"] - -[forge] +[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" @@ -30,18 +45,33 @@ backup_dir = "backup" 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 - assert config.legacy is False def test_parse_config_legacy_format(tmp_path: Path) -> None: - config_path = tmp_path / "forge.toml" + config_path = tmp_path / "inventory.toml" config_path.write_text( """ -[packages] -git -vim + [dnf] + git + vim + +[brew] +wget + +[flatpak] +org.mozilla.firefox + +[rpm-ostree] +podman + +[nix] +nixpkgs#git [copr] copr.fedorainfracloud.org/user/repo @@ -54,9 +84,12 @@ copr.fedorainfracloud.org/user/repo 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.legacy is True def test_diff_items() -> None: @@ -67,10 +100,10 @@ def test_diff_items() -> None: def test_update_last_switch_creates_backup(tmp_path: Path) -> None: config_dir = tmp_path - config_path = config_dir / "forge.toml" + config_path = config_dir / "inventory.toml" config_path.write_text( """ -[forge] +[curator] version = "1.0" last_switch = "" """.strip() @@ -79,10 +112,13 @@ last_switch = "" 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) + 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 forge.toml to be created" - assert any(path.name.startswith("forge.toml.") for path in backups) + assert backups, "expected a backup of inventory.toml to be created" + assert any(path.name.startswith("inventory.toml.") for path in backups) diff --git a/tmpcfg.toml b/tmpcfg.toml index 180e6a8..3f04387 100644 --- a/tmpcfg.toml +++ b/tmpcfg.toml @@ -1,5 +1,5 @@ -[forge] +[curator] version = "1.0" last_switch = "" diff --git a/uv.lock b/uv.lock index 2121675..80399ab 100644 --- a/uv.lock +++ b/uv.lock @@ -12,12 +12,9 @@ wheels = [ ] [[package]] -name = "forge" +name = "curator" version = "0.1.0" source = { editable = "." } -dependencies = [ - { name = "tomlkit" }, -] [package.dev-dependencies] dev = [ @@ -25,7 +22,6 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "tomlkit", specifier = ">=0.12" }] [package.metadata.requires-dev] dev = [{ name = "pytest", specifier = ">=7.4" }] @@ -81,12 +77,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049dd 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" }, -] From 7d51de105262c1ac0b5991109e4387ccf3c7cd99 Mon Sep 17 00:00:00 2001 From: randogoth Date: Wed, 24 Dec 2025 12:56:51 +0200 Subject: [PATCH 07/10] finalized for publication --- .gitignore | 75 ++++++ README.md | 287 ++++---------------- src/curator/cli.py | 616 ++++++++++++++++++++++++++++++++----------- tests/test_config.py | 40 +++ 4 files changed, 626 insertions(+), 392 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d78e47 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index d757aea..a3459b1 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,49 @@ -# curator - A Home-Manager Like Tool for Fedora +# curator - A Home-Manager Like Tool for Universal Blue builds -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. +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 curator 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 +- 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 - 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 ## Installation -1. Clone or download this repository. -2. Install dependencies with `uv` (none beyond the standard library, but this sets up the venv): - ```bash - uv sync - ``` -3. Run with `uv`: - ```bash - uv run curator --help - ``` -4. Or use the local shim directly: - ```bash - ./curator --help - ``` -5. Optionally install globally via `uv`: - ```bash - uv tool install . - ``` +Install the CLI directly from GitHub with [uv](https://github.com/astral-sh/uv): + +```bash +uv tool install --from git+https://codeberg.org/randogoth/curator/ curator +``` ## Quick Start ```bash # Initialize the configuration structure -uv run curator init +curator init # Edit the configuration file to add packages and dotfiles nano ~/.config/curator/inventory.toml # Apply configuration -uv run curator switch +curator switch # Check status -uv run curator status +curator status ``` -You can swap `uv run curator ...` for `./curator ...` if you prefer the local shim. - ## Commands ### `init` Initialize the configuration structure and create `inventory.toml`. ```bash -uv run curator init +curator init # or ./curator init ``` @@ -68,10 +55,10 @@ Creates: Apply the current configuration (enable COPR, install packages, deploy dotfiles). Use `--rollback` to restore the previous `inventory.toml` snapshot before applying. ```bash -uv run curator switch +curator switch # or ./curator switch # rollback to the previous inventory.toml and apply it -uv run curator switch --rollback +curator switch --rollback ``` This command: @@ -86,7 +73,7 @@ This command: Show current configuration status and information. ```bash -uv run curator status +curator status # or ./curator status ``` @@ -99,10 +86,37 @@ Displays: Show help message with all available commands. ```bash -uv run curator 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 +``` + ## Configuration ### inventory.toml @@ -197,6 +211,12 @@ List of packages to install via Homebrew. **One package per line**—presence me #### `[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. + #### `[dotfiles]` Dotfile mappings using symlinks: - **Key**: Target path where symlink should be created (relative to home directory) @@ -222,209 +242,6 @@ Additional configuration options: └── vimrc.20231121_143022.bak ``` -## How It Works - -### COPR Repository Management -COPR repositories are managed through the `[copr]` section in `inventory.toml`: -- **Add COPR repo**: Add the repository name on its own line -- **Remove COPR repo**: Remove the entry -- **Automatic cleanup**: curator automatically disables COPR repos that are removed from configuration -- **No flags needed**: Just presence/absence of the repository name matters -- curator stores the previous configuration at `~/.config/curator/inventory.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 `[dnf]` section in `inventory.toml`: -- **Add package**: Add the package name on its own line -- **Remove package**: Remove the entry -- **No flags needed**: Just presence/absence of the package name matters -- The previous configuration snapshot (`inventory.toml.prev`) is used to detect additions/removals on each `switch`; added packages are installed and removed packages are uninstalled. - -### Brew Management -Homebrew packages are managed through the `[brew]` section: -- **Add package**: Add the package name on its own line -- **Remove package**: Remove the entry -- Additions/removals are detected against `inventory.toml.prev` on `switch`; removed packages are uninstalled. - -### Flatpak Management -Flatpaks are managed through the `[flatpak]` section: -- **Add ref**: Add the ref on its own line -- **Remove ref**: Remove the entry -- Additions/removals are detected against `inventory.toml.prev` on `switch`; removed refs are uninstalled. - -### rpm-ostree Management -rpm-ostree packages are managed through the `[rpm-ostree]` section: -- **Add package**: Add the package name on its own line -- **Remove package**: Remove the entry -- Additions/removals are detected against `inventory.toml.prev` on `switch`; removed packages are uninstalled. - -### Nix Management -Nix packages are managed through the `[nix]` section (if `nix` is available on the system): -- **Add package**: Add the package name (e.g., `nixpkgs#git` or just `git`) on its own line -- **Remove package**: Remove the entry -- Additions/removals are detected against `inventory.toml.prev` on `switch`; installs/removals use `nix profile` and will prefix `nixpkgs#` if missing. - -### Dotfile Management -curator uses symlinks to manage dotfiles: -1. Your actual dotfiles are stored in `~/.config/curator/dotfiles/` -2. Symlinks are created from your home directory to these files using relative paths -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 -5. Source paths are automatically prefixed with "dotfiles/" for convenience - -### Backup System -Before creating symlinks, curator: -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 - -`inventory.toml` is also backed up before the last switch timestamp is updated. - -The previously applied configuration is stored separately as `~/.config/curator/inventory.toml.prev` to compute diffs for COPR and package changes. - -## Examples - -### Basic Setup -```bash -# Initialize curator -uv run curator init - -# Edit inventory.toml to add COPR repos and packages -nano ~/.config/curator/inventory.toml - -# Add to the sections: -# [copr] -# copr.fedorainfracloud.org/username/cool-repo -# [dnf] -# git -# vim -# curl -# [brew] -# wget -# [flatpak] -# org.mozilla.firefox -# [rpm-ostree] -# podman -# [nix] -# nixpkgs#git (or just "git") - -# Create your dotfiles directory and add files -mkdir -p ~/.config/curator/dotfiles -echo "export EDITOR=vim" > ~/.config/curator/dotfiles/.bashrc - -# Add to [dotfiles] section: -".bashrc" = ".bashrc" - -# Apply configuration -uv run curator switch -``` - -### Managing Application Configurations -```bash -# Add alacritty configuration -mkdir -p ~/.config/curator/dotfiles -cp ~/.config/alacritty/alacritty.yml ~/.config/curator/dotfiles/ - -# Edit inventory.toml -nano ~/.config/curator/inventory.toml - -# Add to [dotfiles] section: -".config/alacritty/alacritty.yml" = "alacritty.yml" - -# Apply changes -uv run curator 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 entry -# curator will automatically disable it during the next switch -``` - -### Package Management Examples -```toml -[dnf] -# Development tools -git -vim -nodejs -npm - -# System utilities -curl -wget -tree -htop -# To remove a package, just delete the entry - -[brew] -# Utilities and tools -wget -coreutils - -[flatpak] -org.mozilla.firefox -com.spotify.Client - -[rpm-ostree] -podman -htop -``` - -### Version Control Your Configuration -```bash -# Initialize git repository in curator directory -cd ~/.config/curator -git init -git add . -git commit -m "Initial configuration" - -# Now you can version control your entire system configuration -git add inventory.toml dotfiles/ -git commit -m "Updated vim configuration" -``` - ## Environment Variables -- `CURATOR_DIR`: Override the default configuration directory (default: `~/.config/curator`) - -## Dependencies - -- Python 3.11+ -- `uv` for environment and script management -- `dnf` - Fedora package manager -- `dnf-plugins-core` - For COPR repository management - -## Migration from Previous Version - -If you were using the old version of curator with `git = true` or bare keys under `[packages]`/`[copr]`: - -1. Your existing configuration will not be automatically migrated, but the CLI will still read the legacy format. -2. Run `uv run curator init` (or `./curator init`) to create the new `inventory.toml` structure. -3. Convert to section-per-line format: - ```toml - # Old formats - [dnf] - git = true - vim = true - # or - [dnf] - git - vim - - # New format - [dnf] - git - vim - [copr] - copr.fedorainfracloud.org/username/repository - ``` -4. Move your existing dotfiles from the old directory to `~/.config/curator/dotfiles/` - -## License - -This project is open source. Feel free to contribute or report issues. +- `CURATOR_DIR`: Override the default configuration directory (default: `~/.config/curator`) \ No newline at end of file diff --git a/src/curator/cli.py b/src/curator/cli.py index c495e1c..5f46e85 100644 --- a/src/curator/cli.py +++ b/src/curator/cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse import datetime as dt +import json import os import re import shutil @@ -10,7 +11,7 @@ import subprocess import sys from dataclasses import dataclass from pathlib import Path -from typing import Any, Optional +from typing import Any, Optional, Callable BLUE = "\033[0;34m" GREEN = "\033[0;32m" @@ -117,6 +118,27 @@ def parse_value(value: str) -> Any: return strip_quotes(value) +def normalize_nix_ref(ref: str) -> str: + ref = ref.strip() + if not ref: + return ref + return ref if "#" in ref else f"nixpkgs#{ref}" + + +def nix_base_name(val: str) -> str: + val = val.strip() + if not val: + return "" + base = val.split("#", 1)[1] if "#" in val else val + base = re.sub(r"-\d[^\s]*$", "", base) + return base + + +def nix_install_ref(val: str) -> str: + base = nix_base_name(val) + return f"nixpkgs#{base}" if base else "" + + def parse_config(config_path: Path) -> CuratorConfig: copr: list[str] = [] dnf_packages: list[str] = [] @@ -237,15 +259,16 @@ def diff_items(current: list[str], previous: list[str]) -> tuple[list[str], list return added, removed -def get_paths() -> tuple[Path, Path, Path]: +def get_paths() -> tuple[Path, Path, Path, Path]: curator_dir = Path(os.environ.get("CURATOR_DIR", Path.home() / ".config" / "curator")).expanduser() curator_toml = curator_dir / "inventory.toml" - previous_toml = curator_dir / "inventory.toml.prev" - return curator_dir, curator_toml, previous_toml + last_toml = curator_dir / "inventory.toml.last" + rollback_toml = curator_dir / "inventory.toml.rollback" + return curator_dir, curator_toml, last_toml, rollback_toml def init_command() -> None: - curator_dir, curator_toml, _ = get_paths() + curator_dir, curator_toml, _, _ = get_paths() log_info("Initializing curator...") curator_dir.mkdir(parents=True, exist_ok=True) @@ -269,6 +292,60 @@ def run_command(command: list[str]) -> bool: return result.returncode == 0 +def run_capture(command: list[str]) -> tuple[bool, str]: + try: + result = subprocess.run(command, check=False, capture_output=True, text=True) + except FileNotFoundError: + log_error(f"Command not found: {' '.join(command)}") + return False, "" + if result.returncode != 0: + return False, result.stdout + return True, result.stdout + + +def install_entries( + entries: list[str], + installed: set[str], + label: str, + command_builder: Callable[[str], list[str]], +) -> None: + log_info(f"Installing {label}...") + if not entries: + log_info(f"No {label} to install") + return + for entry in entries: + if entry in installed: + log_info(f"Skipping already installed {entry}") + continue + log_info(f"Installing {entry}") + if run_command(command_builder(entry)): + log_success(f"Installed {entry}") + else: + log_error(f"Failed to install {entry}") + + +def remove_entries( + entries: list[str], + label: str, + command_builder: Callable[[str], list[str]], + installed: set[str] | None = None, +) -> None: + installed_set = installed if installed is not None else None + log_info(f"Removing {label}...") + if not entries: + log_info(f"No {label} to remove") + return + for entry in entries: + if installed_set is not None and entry not in installed_set: + log_info(f"Skipping removal (not installed according to inventory): {entry}") + continue + log_info(f"Removing {entry}") + if run_command(command_builder(entry)): + log_success(f"Removed {entry}") + else: + log_error(f"Failed to remove {entry}") + + def enable_copr_repos(repos: list[str]) -> None: log_info("Enabling COPR repositories...") if not repos: @@ -283,7 +360,7 @@ def enable_copr_repos(repos: list[str]) -> None: log_error(f"Failed to enable COPR: {copr_repo}") -def list_enabled_copr() -> set[str]: +def gather_copr_repos() -> list[str]: try: result = subprocess.run( ["sudo", "dnf", "copr", "list", "--enabled"], @@ -293,31 +370,26 @@ def list_enabled_copr() -> set[str]: ) except FileNotFoundError: log_error("dnf not found while listing enabled COPR repositories") - return set() + return [] if result.returncode != 0: log_warning("Unable to list enabled COPR repositories") - return set() + return [] 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 + return sorted(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) - +def disable_copr_repos(to_disable: set[str]) -> None: if not to_disable: log_info("No COPR repositories to disable") return + log_info("Disabling COPR repositories...") for repo in sorted(to_disable): log_info(f"Disabling COPR repository: {repo}") if run_command(["sudo", "dnf", "copr", "disable", "-y", repo]): @@ -326,160 +398,215 @@ def disable_copr_repos(configured: set[str], explicit_removed: set[str] | None = 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 install_packages(packages: list[str], installed: set[str] | None = None) -> None: + install_entries(packages, installed or set(), "dnf packages", lambda p: ["sudo", "dnf", "install", "-y", p]) -def install_brew_packages(packages: list[str]) -> None: - log_info("Installing brew packages...") - if not packages: - log_info("No brew packages to install") - return - - for package in packages: - log_info(f"Installing brew package: {package}") - if run_command(["brew", "install", package]): - log_success(f"Installed {package}") - else: - log_error(f"Failed to install {package}") +def install_brew_packages(packages: list[str], installed: set[str] | None = None) -> None: + install_entries(packages, installed or set(), "brew packages", lambda p: ["brew", "install", p]) -def install_flatpaks(refs: list[str]) -> None: - log_info("Installing flatpak refs...") - if not refs: - log_info("No flatpaks to install") - return - - for ref in refs: - log_info(f"Installing flatpak: {ref}") - if run_command(["flatpak", "install", "-y", ref]): - log_success(f"Installed {ref}") - else: - log_error(f"Failed to install {ref}") +def install_flatpaks(refs: list[str], installed: set[str] | None = None) -> None: + install_entries(refs, installed or set(), "flatpaks", lambda r: ["flatpak", "install", "-y", r]) -def install_rpm_ostree_packages(packages: list[str]) -> None: - log_info("Installing rpm-ostree packages...") - if not packages: - log_info("No rpm-ostree packages to install") - return - - for package in packages: - log_info(f"Installing rpm-ostree package: {package}") - if run_command(["rpm-ostree", "install", "-y", package]): - log_success(f"Installed {package}") - else: - log_error(f"Failed to install {package}") +def install_rpm_ostree_packages(packages: list[str], installed: set[str] | None = None) -> None: + install_entries( + packages, + installed or set(), + "rpm-ostree packages", + lambda p: ["rpm-ostree", "install", "-y", p], + ) def nix_available() -> bool: return shutil.which("nix") is not None -def normalize_nix_ref(ref: str) -> str: - return ref if "#" in ref else f"nixpkgs#{ref}" - - -def install_nix_packages(packages: list[str]) -> None: - log_info("Installing nix packages...") +def install_nix_packages(packages: list[str], installed: set[str] | None = None) -> None: if not packages: log_info("No nix packages to install") return if not nix_available(): log_warning("nix not found on PATH; skipping nix package installs") return - - for package in packages: - ref = normalize_nix_ref(package) - log_info(f"Installing nix package: {ref}") - if run_command(["nix", "profile", "install", ref]): - log_success(f"Installed {ref}") - else: - log_error(f"Failed to install {ref}") + normalized = [nix_install_ref(p) for p in packages if nix_install_ref(p)] + install_entries(normalized, installed or set(), "nix packages", lambda r: ["nix", "profile", "install", r]) -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 remove_packages(packages: list[str], installed: set[str] | None = None) -> None: + remove_entries(packages, "dnf packages", lambda p: ["sudo", "dnf", "remove", "-y", p], installed) -def remove_brew_packages(packages: list[str]) -> None: - log_info("Removing brew packages...") - if not packages: - log_info("No brew packages to remove") - return - - for package in packages: - log_info(f"Removing brew package: {package}") - if run_command(["brew", "uninstall", package]): - log_success(f"Removed {package}") - else: - log_error(f"Failed to remove {package}") +def remove_brew_packages(packages: list[str], installed: set[str] | None = None) -> None: + remove_entries(packages, "brew packages", lambda p: ["brew", "uninstall", p], installed) -def remove_flatpaks(refs: list[str]) -> None: - log_info("Removing flatpaks...") - if not refs: - log_info("No flatpaks to remove") - return - - for ref in refs: - log_info(f"Removing flatpak: {ref}") - if run_command(["flatpak", "uninstall", "-y", ref]): - log_success(f"Removed {ref}") - else: - log_error(f"Failed to remove {ref}") +def remove_flatpaks(refs: list[str], installed: set[str] | None = None) -> None: + remove_entries(refs, "flatpaks", lambda r: ["flatpak", "uninstall", "-y", r], installed) -def remove_rpm_ostree_packages(packages: list[str]) -> None: - log_info("Removing rpm-ostree packages...") - if not packages: - log_info("No rpm-ostree packages to remove") - return - - for package in packages: - log_info(f"Removing rpm-ostree package: {package}") - if run_command(["rpm-ostree", "uninstall", "-y", package]): - log_success(f"Removed {package}") - else: - log_error(f"Failed to remove {package}") +def remove_rpm_ostree_packages(packages: list[str], installed: set[str] | None = None) -> None: + remove_entries(packages, "rpm-ostree packages", lambda p: ["rpm-ostree", "uninstall", "-y", p], installed) -def remove_nix_packages(packages: list[str]) -> None: - log_info("Removing nix packages...") +def remove_nix_packages(packages: list[str], installed: set[str] | None = None) -> None: if not packages: log_info("No nix packages to remove") return if not nix_available(): log_warning("nix not found on PATH; skipping nix package removals") return + base_names = [nix_base_name(p) for p in packages if nix_base_name(p)] + remove_entries(base_names, "nix packages", lambda r: ["nix", "profile", "remove", r], installed) - for package in packages: - ref = normalize_nix_ref(package) - log_info(f"Removing nix package: {ref}") - if run_command(["nix", "profile", "remove", ref]): - log_success(f"Removed {ref}") + +def gather_dnf_packages() -> list[str]: + ok, output = run_capture(["rpm", "-qa", "--qf", "%{NAME}\n"]) + if not ok: + log_warning("Failed to list dnf packages") + return [] + return sorted({line.strip() for line in output.splitlines() if line.strip()}) + + +def gather_brew_packages() -> list[str]: + ok, output = run_capture(["brew", "list", "--formula"]) + if not ok: + log_warning("Failed to list brew packages") + return [] + return sorted({line.strip() for line in output.splitlines() if line.strip()}) + + +def gather_flatpak_refs() -> list[str]: + ok, output = run_capture(["flatpak", "list", "--app", "--columns=ref"]) + if not ok: + log_warning("Failed to list flatpak refs") + return [] + return sorted({line.strip() for line in output.splitlines() if line.strip() and "/" in line}) + + +def gather_rpm_ostree_packages() -> list[str]: + ok, output = run_capture(["rpm-ostree", "status", "--json"]) + pkgs: set[str] = set() + + if ok: + try: + data = json.loads(output) + deployments = data.get("deployments", []) + if deployments: + deployment = deployments[0] + + def collect(entries: Any) -> None: + if isinstance(entries, list): + for entry in entries: + if isinstance(entry, str): + if entry: + pkgs.add(entry) + elif isinstance(entry, dict) and entry.get("name"): + pkgs.add(str(entry["name"])) + + for key in ( + "requested-packages", + "packages", + "layered-packages", + "layered", + "requested-local-packages", + "local-packages", + ): + collect(deployment.get(key)) + except json.JSONDecodeError: + log_warning("Failed to parse rpm-ostree status json") + + # Fallback: parse `rpm-ostree override list` to catch layered packages + if not pkgs: + ok_override, override_out = run_capture(["rpm-ostree", "override", "list"]) + if ok_override: + collecting = False + for line in override_out.splitlines(): + if line.strip().startswith("Packages:"): + collecting = True + continue + if collecting: + if not line.strip(): + break + parts = line.split() + if parts: + pkgs.add(parts[0]) else: - log_error(f"Failed to remove {ref}") + log_warning("Failed to list rpm-ostree overrides") + + if not pkgs and not ok: + log_warning("Failed to list rpm-ostree packages") + + return sorted(pkgs) + + +def gather_nix_packages() -> list[str]: + if not nix_available(): + log_warning("nix not found on PATH; skipping nix package import") + return [] + collected: set[str] = set() + + def collect_profile() -> None: + ok, output = run_capture(["nix", "profile", "list", "--json"]) + if not ok: + log_warning("Failed to list nix profile entries") + return + try: + data = json.loads(output) + elements = data.get("elements") + if isinstance(elements, dict) and elements: + for key, entry in elements.items(): + attr_name = entry.get("attrPath").split(".")[-1] if entry.get("attrPath") else None + candidates = [ + attr_name, + key, + entry.get("name"), + entry.get("originalInput"), + entry.get("source"), + entry.get("originalUrl"), + entry.get("url"), + ] + for val in candidates: + if not val: + continue + norm = nix_base_name(str(val)) + if norm: + collected.add(norm) + break + else: + entries = data.get("entries", []) + for entry in entries: + val = entry.get("originalInput") or entry.get("source") or entry.get("name") + if not val: + continue + norm = nix_base_name(str(val)) + if norm: + collected.add(norm) + except json.JSONDecodeError: + log_warning("Failed to parse nix profile list json") + + def collect_nix_env() -> None: + ok, output = run_capture(["nix-env", "--query", "--installed", "--json"]) + if not ok: + return + try: + data = json.loads(output) + for item in data: + name = item.get("name") + if not name: + continue + norm = nix_base_name(str(name)) + if norm: + collected.add(norm) + except json.JSONDecodeError: + pass + + collect_profile() + if not collected: + collect_nix_env() + + return sorted(collected) def backup_target(target_path: Path, backup_dir: Path) -> None: @@ -585,6 +712,83 @@ def save_previous_config(current_path: Path, previous_path: Path) -> None: log_warning(f"Failed to write previous configuration snapshot: {exc}") +def reset_previous_config() -> None: + _, _, _, rollback_toml = get_paths() + if rollback_toml.exists(): + try: + rollback_toml.unlink() + log_success(f"Removed previous inventory snapshot: {rollback_toml}") + except OSError as exc: + log_error(f"Failed to remove previous inventory snapshot: {exc}") + else: + log_info("No previous inventory snapshot to remove.") + + +def update_section_entries(config_path: Path, section: str, entries: list[str]) -> None: + entries = sorted({entry.strip() for entry in entries if entry.strip()}) + header = f"[{section}]" + lines = config_path.read_text().splitlines() + output: list[str] = [] + i = 0 + found = False + + while i < len(lines): + line = lines[i] + section_match = re.match(r"\s*\[(.+)]\s*$", line) + if section_match: + current_section = section_match.group(1).strip() + if current_section == section: + found = True + output.append(header) + i += 1 + while i < len(lines) and not re.match(r"\s*\[.+]\s*$", lines[i]): + i += 1 + output.extend(entries) + continue + output.append(line) + i += 1 + + if not found: + if output and output[-1].strip(): + output.append("") + output.append(header) + output.extend(entries) + + config_path.write_text("\n".join(output) + "\n") + + +def merge_section_entries(config_path: Path, section: str, add: set[str], remove: set[str]) -> None: + add_clean = {entry.strip() for entry in add if entry.strip()} + remove_clean = {entry.strip() for entry in remove if entry.strip()} + # Load current entries + current_config = parse_config(config_path) + current_entries = set() + if section == "dnf": + current_entries.update(current_config.packages) + elif section == "brew": + current_entries.update(current_config.brew_packages) + elif section == "flatpak": + current_entries.update(current_config.flatpak_refs) + elif section == "rpm-ostree": + current_entries.update(current_config.rpm_ostree_packages) + elif section == "nix": + current_entries.update(current_config.nix_packages) + elif section == "copr": + current_entries.update(current_config.copr) + elif section == "dotfiles": + current_entries.update(f"{k} = {v}" for k, v in current_config.dotfiles.items()) + else: + log_error(f"Unknown section: {section}") + return + + if section == "nix": + add_clean = {normalize_nix_ref(item) for item in add_clean} + remove_clean = {normalize_nix_ref(item) for item in remove_clean} + + new_entries = (current_entries | add_clean) - remove_clean + update_section_entries(config_path, section, sorted(new_entries)) + + def switch_command() -> None: _switch(rollback=False) @@ -594,16 +798,16 @@ def switch_command_with_args(rollback: bool = False) -> None: def _switch(rollback: bool) -> None: - curator_dir, curator_toml, previous_toml = get_paths() + curator_dir, curator_toml, last_toml, rollback_toml = get_paths() if not curator_toml.exists() and not rollback: log_error("inventory.toml not found. Run 'curator init' first.") sys.exit(1) if rollback: - if not previous_toml.exists(): - log_error("No previous inventory.toml to roll back to.") + if not rollback_toml.exists() or not last_toml.exists(): + log_error("No rollback snapshots found. Run a normal switch first.") sys.exit(1) - # Backup current config if present + current_before = load_config(curator_toml) if curator_toml.exists() else empty_config() current_options = current_before.options or {"backup": True, "backup_dir": "backup"} backup_dir_name = str(current_options.get("backup_dir", "backup") or "backup") @@ -614,13 +818,12 @@ def _switch(rollback: bool) -> None: except OSError as exc: log_warning(f"Failed to back up current inventory.toml before rollback: {exc}") - shutil.copy2(previous_toml, curator_toml) - log_info(f"Restored inventory.toml from {previous_toml}") - current_config = parse_config(curator_toml) - previous_config = current_before + target_config = parse_config(last_toml) + baseline_config = parse_config(rollback_toml) + current_config, previous_config = target_config, baseline_config else: current_config = parse_config(curator_toml) - previous_config = load_config(previous_toml) + previous_config = load_config(last_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) @@ -629,28 +832,108 @@ def _switch(rollback: bool) -> None: rpm_ostree_added, rpm_ostree_removed = diff_items( current_config.rpm_ostree_packages, previous_config.rpm_ostree_packages ) - nix_added, nix_removed = diff_items(current_config.nix_packages, previous_config.nix_packages) + current_nix_base = [nix_base_name(p) for p in current_config.nix_packages if nix_base_name(p)] + previous_nix_base = [nix_base_name(p) for p in previous_config.nix_packages if nix_base_name(p)] + nix_added_base, nix_removed_base = diff_items(current_nix_base, previous_nix_base) + current_nix_install = [nix_install_ref(p) for p in current_nix_base if nix_install_ref(p)] + nix_added_install = [nix_install_ref(p) for p in nix_added_base if nix_install_ref(p)] + + need_dnf = bool(current_config.packages or packages_removed) + need_brew = bool(current_config.brew_packages or brew_removed) + need_flatpak = bool(current_config.flatpak_refs or flatpak_removed) + need_rpm_ostree = bool(current_config.rpm_ostree_packages or rpm_ostree_removed) + need_nix = bool(current_nix_install or nix_removed_base) + + dnf_installed = set(gather_dnf_packages()) if need_dnf else set() + brew_installed = set(gather_brew_packages()) if need_brew else set() + flatpak_installed = set(gather_flatpak_refs()) if need_flatpak else set() + rpm_ostree_installed = set(gather_rpm_ostree_packages()) if need_rpm_ostree else set() + nix_installed = set(nix_install_ref(p) for p in gather_nix_packages()) if need_nix else None 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) - install_brew_packages(brew_added if brew_added else current_config.brew_packages) - install_flatpaks(flatpak_added if flatpak_added else current_config.flatpak_refs) - install_rpm_ostree_packages(rpm_ostree_added if rpm_ostree_added else current_config.rpm_ostree_packages) - install_nix_packages(nix_added if nix_added else current_config.nix_packages) - remove_packages(packages_removed) - remove_brew_packages(brew_removed) - remove_flatpaks(flatpak_removed) - remove_rpm_ostree_packages(rpm_ostree_removed) - remove_nix_packages(nix_removed) + disable_copr_repos(set(copr_removed)) + install_packages(packages_added if packages_added else current_config.packages, dnf_installed) + install_brew_packages(brew_added if brew_added else current_config.brew_packages, brew_installed) + install_flatpaks(flatpak_added if flatpak_added else current_config.flatpak_refs, flatpak_installed) + install_rpm_ostree_packages( + rpm_ostree_added if rpm_ostree_added else current_config.rpm_ostree_packages, rpm_ostree_installed + ) + install_nix_packages(nix_added_install if nix_added_install else current_nix_install, nix_installed) + remove_packages(packages_removed, dnf_installed) + remove_brew_packages(brew_removed, brew_installed) + remove_flatpaks(flatpak_removed, flatpak_installed) + remove_rpm_ostree_packages(rpm_ostree_removed, rpm_ostree_installed) + remove_nix_packages(nix_removed_base, set(nix_base_name(p) for p in gather_nix_packages()) if need_nix else None) deploy_dotfiles(current_config, curator_dir) update_last_switch(curator_toml, current_config.options) - save_previous_config(curator_toml, previous_toml) + if rollback: + # Restore files to rollback state + shutil.copy2(rollback_toml, curator_toml) + shutil.copy2(rollback_toml, last_toml) + else: + if last_toml.exists(): + save_previous_config(last_toml, rollback_toml) + if curator_toml.exists(): + save_previous_config(curator_toml, last_toml) log_success("Switch completed successfully!") +def import_from_manager(manager: str) -> None: + curator_dir, curator_toml, _, _ = get_paths() + if not curator_toml.exists(): + log_error("inventory.toml not found. Run 'curator init' first.") + sys.exit(1) + + gatherers = { + "dnf": (gather_dnf_packages, "dnf"), + "brew": (gather_brew_packages, "brew"), + "flatpak": (gather_flatpak_refs, "flatpak"), + "rpm-ostree": (gather_rpm_ostree_packages, "rpm-ostree"), + "nix": (gather_nix_packages, "nix"), + "copr": (gather_copr_repos, "copr"), + } + + gather_fn, section = gatherers[manager] + entries = gather_fn() + if not entries: + log_info(f"No entries found for {manager}; inventory not updated.") + return + + update_section_entries(curator_toml, section, entries) + log_success(f"Imported {len(entries)} {manager} entries into {curator_toml}") + + +def apply_inline_updates(entry_args: list[str], add: bool) -> None: + _, curator_toml, _, _ = get_paths() + if not curator_toml.exists(): + log_error("inventory.toml not found. Run 'curator init' first.") + sys.exit(1) + + changes: dict[str, dict[str, set[str]]] = {} + for arg in entry_args: + if ":" not in arg: + log_warning(f"Skipping invalid entry (expected section:value): {arg}") + continue + section, value = arg.split(":", 1) + section = section.strip() + value = value.strip() + if not section or not value: + log_warning(f"Skipping invalid entry (empty section or value): {arg}") + continue + if section not in {"dnf", "brew", "flatpak", "rpm-ostree", "nix", "copr"}: + log_warning(f"Unknown section '{section}' in entry: {arg}") + continue + bucket = changes.setdefault(section, {"add": set(), "remove": set()}) + target = "add" if add else "remove" + bucket[target].add(value) + + for section, change in changes.items(): + merge_section_entries(curator_toml, section, change["add"], change["remove"]) + log_success(f"Updated section [{section}] in {curator_toml}") + + def status_command() -> None: - curator_dir, curator_toml, _ = get_paths() + curator_dir, curator_toml, _, _ = get_paths() log_info("curator Status") print(f" Config directory: {curator_dir}") print(f" Config file: {curator_toml}") @@ -746,6 +1029,17 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Restore the previous inventory.toml snapshot and apply it", ) + from_parser = subparsers.add_parser("from", help="Import currently installed packages into inventory.toml") + from_parser.add_argument( + "manager", + choices=["dnf", "brew", "flatpak", "rpm-ostree", "nix", "copr"], + help="Package manager to import from", + ) + subparsers.add_parser("reset", help="Remove the previous inventory.toml snapshot") + add_parser = subparsers.add_parser("add", help="Add entries to inventory.toml (e.g. dnf:uv nix:micro)") + add_parser.add_argument("entries", nargs="+", help="Entries in the form section:value") + remove_parser = subparsers.add_parser("remove", help="Remove entries from inventory.toml (e.g. dnf:uv nix:micro)") + remove_parser.add_argument("entries", nargs="+", help="Entries in the form section:value") subparsers.add_parser("status", help="Show current configuration status") subparsers.add_parser("help", help="Show help message") return parser @@ -759,6 +1053,14 @@ def main(argv: list[str] | None = None) -> None: init_command() elif args.command == "switch": switch_command_with_args(getattr(args, "rollback", False)) + elif args.command == "from": + import_from_manager(args.manager) + elif args.command == "reset": + reset_previous_config() + elif args.command == "add": + apply_inline_updates(args.entries, add=True) + elif args.command == "remove": + apply_inline_updates(args.entries, add=False) elif args.command == "status": status_command() elif args.command == "help": diff --git a/tests/test_config.py b/tests/test_config.py index 9313f3d..a5b2a62 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -122,3 +122,43 @@ last_switch = "" 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", + ] From 25c80fdf3b35608e75a2f2e4b3fc5896a32fa731 Mon Sep 17 00:00:00 2001 From: randogoth Date: Thu, 1 Jan 2026 20:21:38 +0200 Subject: [PATCH 08/10] added env var support --- README.md | 35 +++++- src/curator/cli.py | 262 ++++++++++++++++++++++++++++++++++++++++++- tests/test_config.py | 103 ++++++++++++++++- 3 files changed, 390 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a3459b1..e5ef88e 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,9 @@ This command: 2. Disables COPR repositories that are no longer configured 3. Installs all packages listed in `inventory.toml` 4. Creates symlinks for all configured dotfiles -5. Creates backups of existing files before replacing them -6. Updates the last switch timestamp +5. Applies environment variables to `~/.config/environment.d/20-curator.conf` and imports them into the user session +6. Creates backups of existing files before replacing them +7. Updates the last switch timestamp ### `status` Show current configuration status and information. @@ -117,6 +118,15 @@ Remove the rollback snapshot (`inventory.toml.rollback`). 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)" +``` + ## Configuration ### inventory.toml @@ -156,6 +166,10 @@ last_switch = "" # nixpkgs#git # or just "git" (curator will prefix nixpkgs#) # nixpkgs#htop # or just "htop" +[variables] +# ENV_VAR = "value" +# ANOTHER = "another value" + [dotfiles] # Dotfiles to manage with symlinks # Format: "target_path" = "source_path" @@ -217,6 +231,21 @@ List of rpm-ostree layered packages. **One package per line**—presence means i #### `[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)"` after applying. +- Later shell init files (e.g., `.bashrc`, `.zshrc`) can still override these values. + #### `[dotfiles]` Dotfile mappings using symlinks: - **Key**: Target path where symlink should be created (relative to home directory) @@ -244,4 +273,4 @@ Additional configuration options: ## Environment Variables -- `CURATOR_DIR`: Override the default configuration directory (default: `~/.config/curator`) \ No newline at end of file +- `CURATOR_DIR`: Override the default configuration directory (default: `~/.config/curator`) diff --git a/src/curator/cli.py b/src/curator/cli.py index 5f46e85..865745e 100644 --- a/src/curator/cli.py +++ b/src/curator/cli.py @@ -51,6 +51,9 @@ last_switch = "" # nixpkgs#git # nixpkgs#htop +[variables] +# EXAMPLE_VAR = "value" + [dotfiles] # Dotfiles to manage with symlinks # Format: "target_path" = "source_path" @@ -77,6 +80,7 @@ class CuratorConfig: flatpak_refs: list[str] rpm_ostree_packages: list[str] nix_packages: list[str] + variables: dict[str, str] dotfiles: dict[str, str] options: dict[str, Any] curator_fields: dict[str, Any] @@ -118,6 +122,17 @@ def parse_value(value: str) -> Any: return strip_quotes(value) +def parse_key_value(entry: str) -> Optional[tuple[str, str]]: + if "=" not in entry: + return None + key_raw, value_raw = entry.split("=", 1) + key = strip_quotes(key_raw.strip()) + value = strip_quotes(value_raw.strip()) + if not key: + return None + return key, value + + def normalize_nix_ref(ref: str) -> str: ref = ref.strip() if not ref: @@ -146,6 +161,7 @@ def parse_config(config_path: Path) -> CuratorConfig: flatpak_refs: list[str] = [] rpm_ostree_packages: list[str] = [] nix_packages: list[str] = [] + variables: dict[str, str] = {} dotfiles: dict[str, str] = {} options: dict[str, Any] = {} curator_fields: dict[str, Any] = {} @@ -182,6 +198,16 @@ def parse_config(config_path: Path) -> CuratorConfig: dnf_packages.append(strip_quotes(entry)) continue + if section == "variables": + if "=" not in line: + continue + key_raw, value_raw = line.split("=", 1) + key = strip_quotes(key_raw.strip()) + value = strip_quotes(value_raw.strip()) + if key: + variables[key] = value + continue + if section == "dotfiles": if "=" not in line: continue @@ -219,6 +245,7 @@ def parse_config(config_path: Path) -> CuratorConfig: flatpak_refs=flatpak_refs, rpm_ostree_packages=rpm_ostree_packages, nix_packages=nix_packages, + variables=variables, dotfiles=dotfiles, options=merged_options, curator_fields=curator_fields, @@ -234,6 +261,7 @@ def empty_config() -> CuratorConfig: flatpak_refs=[], rpm_ostree_packages=[], nix_packages=[], + variables={}, dotfiles={}, options={"backup": True, "backup_dir": "backup"}, curator_fields={}, @@ -609,7 +637,7 @@ def gather_nix_packages() -> list[str]: return sorted(collected) -def backup_target(target_path: Path, backup_dir: Path) -> None: +def backup_target(target_path: Path, backup_dir: Path, *, quiet: bool = False) -> 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" @@ -619,7 +647,8 @@ def backup_target(target_path: Path, backup_dir: Path) -> None: else: shutil.copy2(target_path, backup_path) - log_info(f"Backed up {target_path} to {backup_path}") + if not quiet: + log_info(f"Backed up {target_path} to {backup_path}") def remove_existing(target_path: Path) -> None: @@ -671,6 +700,162 @@ def deploy_dotfiles(config: CuratorConfig, curator_dir: Path) -> None: log_error(f"Failed to create symlink: {target_path} -> {relative_source} ({exc})") +def _format_env_value(value: str) -> str: + sanitized = value.replace("\r", " ").replace("\n", " ") + escaped = sanitized.replace("\\", "\\\\").replace('"', '\\"') + if re.search(r"\s|#", sanitized) or '"' in sanitized: + return f'"{escaped}"' + return escaped + + +def _read_environment_file_keys(env_file: Path) -> set[str]: + if not env_file.exists(): + return set() + keys: set[str] = set() + try: + for line in env_file.read_text().splitlines(): + match = re.match(r"\s*([A-Za-z_][A-Za-z0-9_]*)\s*=", line) + if match: + keys.add(match.group(1)) + except OSError: + pass + return keys + + +def _run_env_command(command: list[str], env: dict[str, str], quiet: bool, description: str) -> None: + try: + result = subprocess.run(command, check=False, env=env, capture_output=True, text=True) + except FileNotFoundError: + if not quiet: + log_warning(f"{description} skipped (command not found): {command[0]}") + return + + if result.returncode != 0: + if not quiet: + details = result.stderr.strip() or result.stdout.strip() or "unknown error" + log_warning(f"{description} failed: {details}") + return + + if not quiet: + log_success(f"{description} complete") + + +def propagate_environment_variables(vars_map: dict[str, str], unset_keys: set[str], quiet: bool = False) -> None: + if not vars_map and not unset_keys: + return + + env = os.environ.copy() + env.update(vars_map) + + set_keys = sorted(vars_map) + unset_list = sorted(unset_keys) + + if set_keys: + _run_env_command( + ["systemctl", "--user", "import-environment", *set_keys], + env, + quiet, + "Imported environment into systemd --user", + ) + + _run_env_command( + ["dbus-update-activation-environment", "--systemd", *[f"{k}={v}" for k, v in vars_map.items()]], + env, + quiet, + "Updated DBus activation environment", + ) + + if unset_list: + _run_env_command( + ["systemctl", "--user", "unset-environment", *unset_list], + env, + quiet, + "Unset environment in systemd --user", + ) + _run_env_command( + ["dbus-update-activation-environment", "--systemd", *[f"--unset={key}" for key in unset_list]], + env, + quiet, + "Unset DBus activation environment", + ) + + +def apply_environment_variables( + config: CuratorConfig, curator_dir: Path, *, quiet: bool = False, propagate: bool = False +) -> tuple[dict[str, str], set[str]]: + def log_info_if(message: str) -> None: + if not quiet: + log_info(message) + + def log_success_if(message: str) -> None: + if not quiet: + log_success(message) + + def log_warning_if(message: str) -> None: + if not quiet: + log_warning(message) + + log_info_if("Applying environment variables...") + env_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")).expanduser() + env_dir = env_home / "environment.d" + env_file = env_dir / "20-curator.conf" + + backup_enabled = bool(config.options.get("backup", True)) + backup_dir_name = str(config.options.get("backup_dir", "backup") or "backup") + backup_dir = curator_dir / backup_dir_name + + existing_keys = _read_environment_file_keys(env_file) + + variables: dict[str, str] = {} + for key, raw_value in config.variables.items(): + key_clean = key.strip() + if not key_clean: + log_warning_if("Skipping environment variable with empty name") + continue + variables[key_clean] = str(raw_value) + + removed_keys = existing_keys - set(variables.keys()) + + if not variables: + if env_file.exists(): + try: + if backup_enabled: + backup_target(env_file, backup_dir, quiet=quiet) + env_file.unlink() + log_success_if(f"Removed environment file: {env_file}") + except OSError as exc: + log_warning_if(f"Failed to remove environment file {env_file}: {exc}") + else: + log_info_if("No environment variables to apply") + if propagate and (removed_keys or variables): + propagate_environment_variables(variables, removed_keys, quiet) + return variables, removed_keys + + lines: list[str] = [] + for key, raw_value in sorted(variables.items()): + formatted_value = _format_env_value(str(raw_value)) + lines.append(f"{key}={formatted_value}") + + env_dir.mkdir(parents=True, exist_ok=True) + + try: + if backup_enabled and env_file.exists(): + backup_target(env_file, backup_dir, quiet=quiet) + env_file.write_text("\n".join(lines) + "\n") + log_success_if(f"Wrote {len(lines)} environment variables to {env_file}") + except OSError as exc: + if quiet: + print(f"ERROR: Failed to write environment variables to {env_file}: {exc}", file=sys.stderr) + else: + log_error(f"Failed to write environment variables to {env_file}: {exc}") + return variables, removed_keys + + if propagate: + propagate_environment_variables(variables, removed_keys, quiet) + + return variables, removed_keys + + 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") @@ -744,6 +929,8 @@ def update_section_entries(config_path: Path, section: str, entries: list[str]) while i < len(lines) and not re.match(r"\s*\[.+]\s*$", lines[i]): i += 1 output.extend(entries) + if i < len(lines) and (not output or output[-1].strip()): + output.append("") continue output.append(line) i += 1 @@ -777,6 +964,8 @@ def merge_section_entries(config_path: Path, section: str, add: set[str], remove current_entries.update(current_config.copr) elif section == "dotfiles": current_entries.update(f"{k} = {v}" for k, v in current_config.dotfiles.items()) + elif section == "variables": + current_entries.update(f"{k} = {v}" for k, v in current_config.variables.items()) else: log_error(f"Unknown section: {section}") return @@ -785,6 +974,27 @@ def merge_section_entries(config_path: Path, section: str, add: set[str], remove add_clean = {normalize_nix_ref(item) for item in add_clean} remove_clean = {normalize_nix_ref(item) for item in remove_clean} + if section == "variables": + current_vars = dict(current_config.variables) + + for item in add_clean: + parsed = parse_key_value(item) + if not parsed: + log_warning(f"Skipping invalid variable entry: {item}") + continue + key, value = parsed + current_vars[key] = value + + for item in remove_clean: + parsed = parse_key_value(item) + key = parsed[0] if parsed else strip_quotes(item) + key = key.strip() + if key and key in current_vars: + del current_vars[key] + + update_section_entries(config_path, section, [f"{k} = {v}" for k, v in sorted(current_vars.items())]) + return + new_entries = (current_entries | add_clean) - remove_clean update_section_entries(config_path, section, sorted(new_entries)) @@ -864,6 +1074,7 @@ def _switch(rollback: bool) -> None: remove_flatpaks(flatpak_removed, flatpak_installed) remove_rpm_ostree_packages(rpm_ostree_removed, rpm_ostree_installed) remove_nix_packages(nix_removed_base, set(nix_base_name(p) for p in gather_nix_packages()) if need_nix else None) + apply_environment_variables(current_config, curator_dir, propagate=True) deploy_dotfiles(current_config, curator_dir) update_last_switch(curator_toml, current_config.options) if rollback: @@ -920,9 +1131,12 @@ def apply_inline_updates(entry_args: list[str], add: bool) -> None: if not section or not value: log_warning(f"Skipping invalid entry (empty section or value): {arg}") continue - if section not in {"dnf", "brew", "flatpak", "rpm-ostree", "nix", "copr"}: + if section not in {"dnf", "brew", "flatpak", "rpm-ostree", "nix", "copr", "variables"}: log_warning(f"Unknown section '{section}' in entry: {arg}") continue + if section == "variables" and add and "=" not in value: + log_warning(f"Skipping invalid variable entry (expected NAME=VALUE): {arg}") + continue bucket = changes.setdefault(section, {"add": set(), "remove": set()}) target = "add" if add else "remove" bucket[target].add(value) @@ -932,6 +1146,31 @@ def apply_inline_updates(entry_args: list[str], add: bool) -> None: log_success(f"Updated section [{section}] in {curator_toml}") +def env_command(eval_mode: bool = False) -> None: + curator_dir, curator_toml, _, _ = get_paths() + if not curator_toml.exists(): + log_error("inventory.toml not found. Run 'curator init' first.") + sys.exit(1) + + config = parse_config(curator_toml) + variables, removed_keys = apply_environment_variables( + config, curator_dir, quiet=eval_mode, propagate=True + ) + + if eval_mode: + exports: list[str] = [] + for key, value in sorted(variables.items()): + exports.append(f"export {key}={_format_env_value(value)}") + for key in sorted(removed_keys): + exports.append(f"unset {key}") + if exports: + print("\n".join(exports)) + return + + log_info("To update your current shell now, run:") + print(' eval "$(curator env --eval)"') + + def status_command() -> None: curator_dir, curator_toml, _, _ = get_paths() log_info("curator Status") @@ -1006,6 +1245,15 @@ def status_command() -> None: else: print(" No nix packages configured") + print() + log_info("Environment Variables:") + if config.variables: + for key, value in sorted(config.variables.items()): + print(f" {key} = {value}") + print(f" Total: {len(config.variables)} environment variables") + else: + print(" No environment variables configured") + print() log_info("Dotfiles:") if config.dotfiles: @@ -1040,6 +1288,12 @@ def build_parser() -> argparse.ArgumentParser: add_parser.add_argument("entries", nargs="+", help="Entries in the form section:value") remove_parser = subparsers.add_parser("remove", help="Remove entries from inventory.toml (e.g. dnf:uv nix:micro)") remove_parser.add_argument("entries", nargs="+", help="Entries in the form section:value") + env_parser = subparsers.add_parser("env", help="Apply environment variables immediately") + env_parser.add_argument( + "--eval", + action="store_true", + help='Print export/unset statements for the current shell (use with: eval "$(curator env --eval)")', + ) subparsers.add_parser("status", help="Show current configuration status") subparsers.add_parser("help", help="Show help message") return parser @@ -1061,6 +1315,8 @@ def main(argv: list[str] | None = None) -> None: apply_inline_updates(args.entries, add=True) elif args.command == "remove": apply_inline_updates(args.entries, add=False) + elif args.command == "env": + env_command(eval_mode=args.eval) elif args.command == "status": status_command() elif args.command == "help": diff --git a/tests/test_config.py b/tests/test_config.py index a5b2a62..265a561 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -67,11 +67,14 @@ wget [flatpak] org.mozilla.firefox -[rpm-ostree] -podman + [rpm-ostree] + podman -[nix] -nixpkgs#git + [nix] + nixpkgs#git + +[variables] +EDITOR = "nvim" [copr] copr.fedorainfracloud.org/user/repo @@ -90,6 +93,7 @@ copr.fedorainfracloud.org/user/repo 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: @@ -162,3 +166,94 @@ wget "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" +OLD = "1" + """.strip() + ) + + def fake_get_paths(): + return curator_dir, env_file, curator_dir / "last", curator_dir / "rollback" + + def fake_apply_environment_variables(config, path, quiet=False, propagate=False): + assert quiet is True + assert propagate is True + return {"EDITOR": "nvim"}, {"OLD"} + + monkeypatch.setattr(cli, "get_paths", fake_get_paths) + monkeypatch.setattr(cli, "apply_environment_variables", fake_apply_environment_variables) + + cli.env_command(eval_mode=True) + out = capsys.readouterr().out.strip().splitlines() + assert out == ["export EDITOR=nvim", "unset OLD"] From 39692512205a0be73de80b5a92a66481679ab319 Mon Sep 17 00:00:00 2001 From: randogoth Date: Thu, 1 Jan 2026 20:23:05 +0200 Subject: [PATCH 09/10] 0.1.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8c24ee8..71dce85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "curator" -version = "0.1.0" +version = "0.1.1" description = "A home-manager style Fedora configuration helper." readme = "README.md" requires-python = ">=3.11" From fd7ff14ac633b8c37933ffff997cd4cc82b71877 Mon Sep 17 00:00:00 2001 From: randogoth Date: Thu, 1 Jan 2026 20:45:46 +0200 Subject: [PATCH 10/10] eval-only option --- README.md | 6 ++++-- src/curator/cli.py | 21 ++++++++++++--------- tests/test_config.py | 17 ++++++++++------- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index e5ef88e..80a4592 100644 --- a/README.md +++ b/README.md @@ -124,9 +124,11 @@ Apply environment variables defined in `[variables]` and propagate them to syste ```bash curator env # then, to update this shell: -eval "$(curator env --eval)" +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 ### inventory.toml @@ -243,7 +245,7 @@ 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)"` after applying. +- 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]` diff --git a/src/curator/cli.py b/src/curator/cli.py index 865745e..8afb969 100644 --- a/src/curator/cli.py +++ b/src/curator/cli.py @@ -1146,18 +1146,20 @@ def apply_inline_updates(entry_args: list[str], add: bool) -> None: log_success(f"Updated section [{section}] in {curator_toml}") -def env_command(eval_mode: bool = False) -> None: +def env_command(eval_only: bool = False) -> None: curator_dir, curator_toml, _, _ = get_paths() if not curator_toml.exists(): log_error("inventory.toml not found. Run 'curator init' first.") sys.exit(1) config = parse_config(curator_toml) - variables, removed_keys = apply_environment_variables( - config, curator_dir, quiet=eval_mode, propagate=True - ) + if eval_only: + env_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")).expanduser() + env_file = env_home / "environment.d" / "20-curator.conf" + existing_keys = _read_environment_file_keys(env_file) + variables = {k.strip(): str(v) for k, v in config.variables.items() if k.strip()} + removed_keys = existing_keys - set(variables) - if eval_mode: exports: list[str] = [] for key, value in sorted(variables.items()): exports.append(f"export {key}={_format_env_value(value)}") @@ -1167,8 +1169,9 @@ def env_command(eval_mode: bool = False) -> None: print("\n".join(exports)) return + apply_environment_variables(config, curator_dir, propagate=True) log_info("To update your current shell now, run:") - print(' eval "$(curator env --eval)"') + print(' eval "$(curator env --eval-only)"') def status_command() -> None: @@ -1290,9 +1293,9 @@ def build_parser() -> argparse.ArgumentParser: remove_parser.add_argument("entries", nargs="+", help="Entries in the form section:value") env_parser = subparsers.add_parser("env", help="Apply environment variables immediately") env_parser.add_argument( - "--eval", + "--eval-only", action="store_true", - help='Print export/unset statements for the current shell (use with: eval "$(curator env --eval)")', + help='Print export/unset statements for the current shell only (use with: eval "$(curator env --eval-only)")', ) subparsers.add_parser("status", help="Show current configuration status") subparsers.add_parser("help", help="Show help message") @@ -1316,7 +1319,7 @@ def main(argv: list[str] | None = None) -> None: elif args.command == "remove": apply_inline_updates(args.entries, add=False) elif args.command == "env": - env_command(eval_mode=args.eval) + env_command(eval_only=args.eval_only) elif args.command == "status": status_command() elif args.command == "help": diff --git a/tests/test_config.py b/tests/test_config.py index 265a561..abea4db 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -239,21 +239,24 @@ def test_env_command_eval_outputs_exports(tmp_path: Path, monkeypatch, capsys) - """ [variables] EDITOR = "nvim" -OLD = "1" """.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 fake_apply_environment_variables(config, path, quiet=False, propagate=False): - assert quiet is True - assert propagate is True - return {"EDITOR": "nvim"}, {"OLD"} + 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", fake_apply_environment_variables) + monkeypatch.setattr(cli, "apply_environment_variables", fail_apply_environment_variables) - cli.env_command(eval_mode=True) + cli.env_command(eval_only=True) out = capsys.readouterr().out.strip().splitlines() assert out == ["export EDITOR=nvim", "unset OLD"]