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 dba85a0..80a4592 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,81 @@ -# Forge - A Home-Manager Like Tool for Fedora +# curator - A Home-Manager Like Tool for Universal Blue builds -A lightweight configuration management tool for Fedora that provides COPR repository management, dotfile management and package installation functionality through a centralized TOML configuration. +A lightweight CLI that provides repository and dotfile management and package installation functionality through a centralized TOML configuration. Ported and expanded from [forge](https://github.com/ijadux2/forge) and inspired by [home-manager](https://github.com/nix-community/home-manager/). ## Overview -Forge is a bash script that helps you manage your Fedora system configuration by: +curator is a Python CLI that helps you manage your Fedora system configuration by: - Managing COPR repositories (enable/disable automatically) -- 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 `forge.toml` file -- User-level configuration similar to home-manager/nixos +- Centralized configuration via a single `inventory.toml` file +- Declarative user-level configuration similar to `home-manager`/`nixos` - Automatic backup of existing files before replacement ## Installation -1. Clone or download this repository -2. Make the script executable: - ```bash - chmod +x forge - ``` -3. Optionally, move it to a directory in your PATH: - ```bash - sudo mv forge /usr/local/bin/forge - ``` +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 -./forge init +curator init # Edit the configuration file to add packages and dotfiles -nano ~/.config/forge/forge.toml +nano ~/.config/curator/inventory.toml # Apply configuration -./forge switch +curator switch # Check status -./forge status +curator status ``` ## Commands ### `init` -Initialize the configuration structure and create `forge.toml`. +Initialize the configuration structure and create `inventory.toml`. ```bash -./forge init +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 -./forge switch +curator switch +# or ./curator switch +# rollback to the previous inventory.toml and apply it +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 +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. ```bash -./forge status +curator status +# or ./curator status ``` Displays: @@ -84,44 +87,99 @@ Displays: Show help message with all available commands. ```bash -./forge help +curator help +# or ./curator help ``` +### `from` +Import currently installed packages/repos for a manager into `inventory.toml` so curator can manage them. + +```bash +curator from dnf +curator from brew +curator from flatpak +curator from rpm-ostree +curator from nix +curator from copr +``` + +### `add` / `remove` +Add or remove entries directly in `inventory.toml` using `section:value` pairs. Supports `dnf`, `brew`, `flatpak`, `rpm-ostree`, `nix`, and `copr`. + +```bash +curator add dnf:uv nix:micro +curator remove dnf:curl flatpak:org.mozilla.firefox +``` + +### `reset` +Remove the rollback snapshot (`inventory.toml.rollback`). + +```bash +curator reset +``` + +### `env` +Apply environment variables defined in `[variables]` and propagate them to systemd/DBus. For your current shell, run: + +```bash +curator env +# then, to update this shell: +eval "$(curator env --eval-only)" +``` + +Use `curator env --eval-only` in shell init files if you want each shell to export the current `[variables]` values without touching `environment.d` or systemd/DBus. + ## Configuration -### 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 -[forge] +[curator] version = "1.0" -last_switch = null +last_switch = "" [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 +# 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 +[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" + +[variables] +# ENV_VAR = "value" +# ANOTHER = "another value" [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) -".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 @@ -131,12 +189,12 @@ backup_dir = "backup" ### Configuration Sections -#### `[forge]` +#### `[curator]` - `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. +List of COPR repositories to enable via `dnf copr enable`. **One repository per line**—presence means enable, absence means don't enable. **Examples:** ```toml @@ -145,14 +203,14 @@ 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]` -List 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] +[dnf] git vim curl @@ -163,21 +221,49 @@ 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. + +#### `[rpm-ostree]` +List of rpm-ostree layered packages. **One package per line**—presence means install. + +#### `[nix]` +List of nix packages. **One package per line**—presence means install. You can specify plain names (e.g., `neovim`) or `nixpkgs#name`; curator will prefix `nixpkgs#` for installs and use the base name for removals. + +#### `[variables]` +User environment variables to set via systemd `environment.d`. curator writes them to `~/.config/environment.d/20-curator.conf` during `curator switch`; new sessions will load them automatically. + +**Examples:** +```toml +[variables] +EDITOR = "nvim" +PAGER = "less -R" +``` + +Notes: +- Values are imported into the user systemd and DBus environments during `curator switch` or `curator env`. +- To update a currently running shell, run `eval "$(curator env --eval-only)"` after applying. +- Later shell init files (e.g., `.bashrc`, `.zshrc`) can still override these values. + #### `[dotfiles]` 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: - `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 @@ -187,153 +273,6 @@ Additional configuration options: └── vimrc.20231121_143022.bak ``` -## How It Works - -### COPR Repository Management -COPR repositories are managed through the `[copr]` section in `forge.toml`: -- **Add COPR repo**: Simply add the repository name on a new line -- **Remove COPR repo**: Delete the line containing the repository name -- **Automatic cleanup**: Forge automatically disables COPR repos that are removed from configuration -- **No flags needed**: Just presence/absence of the repository name matters - -### Package Management -Packages are managed through the `[packages]` section in `forge.toml`: -- **Add package**: Simply add the package name on a new line -- **Remove package**: Delete the line containing the package name -- **No flags needed**: Just presence/absence of the package name matters - -### Dotfile Management -Forge uses symlinks to manage dotfiles: -1. Your actual dotfiles are stored in `~/.config/forge/dotfiles/` -2. Symlinks are created from your home directory to these files -3. This allows you to version control your dotfiles in one place -4. Changes to the source files are immediately reflected in your home directory - -### Backup System -Before creating symlinks, Forge: -1. Checks if the target file exists and is not a symlink -2. Creates a timestamped backup in the backup directory -3. Removes the original file -4. Creates the symlink to your managed dotfile - -## Examples - -### Basic Setup -```bash -# Initialize forge -./forge init - -# Edit forge.toml to add COPR repos and packages -nano ~/.config/forge/forge.toml - -# Add to [copr] section: -copr.fedorainfracloud.org/username/cool-repo - -# Add to [packages] section: -git -vim -curl - -# Create your dotfiles directory and add files -mkdir -p ~/.config/forge/dotfiles -echo "export EDITOR=vim" > ~/.config/forge/dotfiles/.bashrc - -# Add to [dotfiles] section: -".bashrc" = "dotfiles/.bashrc" - -# Apply configuration -./forge switch -``` - -### Managing Application Configurations -```bash -# Add alacritty configuration -mkdir -p ~/.config/forge/dotfiles/.config/alacritty -cp ~/.config/alacritty/alacritty.yml ~/.config/forge/dotfiles/.config/alacritty/ - -# Edit forge.toml -nano ~/.config/forge/forge.toml - -# Add to [dotfiles] section: -".config/alacritty/alacritty.yml" = "dotfiles/.config/alacritty/alacritty.yml" - -# Apply changes -./forge switch -``` - -### COPR Repository Management Examples -```toml -[copr] -# Development tools COPR -copr.fedorainfracloud.org/development/tools -copr.fedorainfracloud.org/user/neovim-nightly - -# To remove a COPR repo, just delete the line -# Forge will automatically disable it during the next switch -``` - -### Package Management Examples -```toml -[packages] -# Development tools -git -vim -nodejs -npm - -# System utilities -curl -wget -tree -htop - -# To remove a package, just delete the line -# For example, to remove htop, delete the "htop" line -``` - -### Version Control Your Configuration -```bash -# Initialize git repository in forge directory -cd ~/.config/forge -git init -git add . -git commit -m "Initial configuration" - -# Now you can version control your entire system configuration -git add forge.toml dotfiles/ -git commit -m "Updated vim configuration" -``` - ## Environment Variables -- `FORGE_DIR`: Override the default configuration directory (default: `~/.config/forge`) - -## Dependencies - -- `dnf` - Fedora package manager -- `dnf-plugins-core` - For COPR repository management -- `sed` - For updating configuration file (usually pre-installed) - -## Migration from Previous Version - -If you were using the old version of forge with `git = true` format: - -1. Your existing configuration will not be automatically migrated -2. Run `./forge init` to create the new `forge.toml` structure -3. Convert your package configuration from: - ```toml - # Old format - git = true - vim = true - ``` - to: - ```toml - # New format - git - vim - ``` -4. Move your existing dotfiles from the old directory to `~/.config/forge/dotfiles/` - -## License - -This project is open source. Feel free to contribute or report issues. \ No newline at end of file +- `CURATOR_DIR`: Override the default configuration directory (default: `~/.config/curator`) diff --git a/curator b/curator new file mode 100755 index 0000000..93b8b3f --- /dev/null +++ b/curator @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import sys +from pathlib import Path + + +def main() -> None: + here = Path(__file__).resolve().parent + src_dir = here / "src" + if src_dir.exists(): + sys.path.insert(0, str(src_dir)) + + try: + from curator.cli import main as curator_main + except ImportError as exc: # pragma: no cover - fallback error path + sys.stderr.write(f"Failed to import curator CLI: {exc}\n") + sys.exit(1) + + curator_main() + + +if __name__ == "__main__": + main() diff --git a/forge b/forge deleted file mode 100755 index 0120279..0000000 --- a/forge +++ /dev/null @@ -1,429 +0,0 @@ -#!/bin/bash - -# forge - A home-manager like script for Fedora -# Provides dotfile management and package installation functionality - -set -euo pipefail - -# Configuration -FORGE_DIR="${FORGE_DIR:-$HOME/.config/forge}" -FORGE_TOML="$FORGE_DIR/forge.toml" - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Logging functions -log_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -log_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -log_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -log_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -# Initialize command - creates forge.toml and .config directory -init() { - log_info "Initializing forge..." - - # Create directory - mkdir -p "$FORGE_DIR" - - # Create initial forge.toml if it doesn't exist - if [[ ! -f "$FORGE_TOML" ]]; then - cat >"$FORGE_TOML" <<'EOF' -# Forge Configuration File -# User-level configuration similar to home-manager/nixos - -[forge] -version = "1.0" -last_switch = null - -[copr] -# COPR repositories to enable -# Just list COPR repository names - presence means enable, absence means don't enable -# Examples: -# copr.fedorainfracloud.org/username/repository -# copr.fedorainfracloud.org/anotheruser/anotherrepo - -[packages] -# List of packages to install using dnf -# Just list package names - presence means install, absence means don't install -# Examples: -# git -# vim -# curl - -[dotfiles] -# Dotfiles to manage with symlinks -# Format: "target_path" = "source_path" -# target_path: where the symlink should be created (relative to home directory) -# source_path: where the actual file is stored (relative to forge directory) -# Examples: -# ".bashrc" = "dotfiles/.bashrc" -# ".config/vimrc" = "dotfiles/vimrc" - -[options] -# Additional options -backup = true -backup_dir = "backup" -EOF - log_success "Created forge.toml: $FORGE_TOML" - fi - - log_success "Initialization complete!" - log_info "Edit $FORGE_TOML to configure packages and dotfiles" - log_info "Run 'forge switch' to apply your configuration" -} - -# Parse TOML file (basic implementation) -parse_toml() { - local section="" - while IFS= read -r line; do - # Skip comments and empty lines - [[ -z "$line" || "$line" == \#* ]] && continue - - # Check for section headers - if [[ "$line" =~ ^\[(.+)\]$ ]]; then - section="${BASH_REMATCH[1]}" - continue - fi - - # Parse key-value pairs for non-packages sections - if [[ "$section" != "packages" && "$line" =~ ^([^=]+)=(.+)$ ]]; then - local key="${BASH_REMATCH[1]// /}" - local value="${BASH_REMATCH[2]// /}" - echo "$section:$key=$value" - # Parse package names (just keys without values in packages section) - elif [[ "$section" == "packages" && "$line" =~ ^([^=]+)$ ]]; then - local package="${BASH_REMATCH[1]// /}" - echo "$section:$package" - # Parse COPR repository names (just keys without values in copr section) - elif [[ "$section" == "copr" && "$line" =~ ^([^=]+)$ ]]; then - local copr_repo="${BASH_REMATCH[1]// /}" - echo "$section:$copr_repo" - fi - done <"$FORGE_TOML" -} - -# Enable COPR repositories from forge.toml -enable_copr_repos() { - log_info "Enabling COPR repositories..." - - local copr_enabled=false - - while IFS='=' read -r entry; do - local section="${entry%%:*}" - local copr_repo="${entry#*:}" - - if [[ "$section" == "copr" && -n "$copr_repo" ]]; then - copr_enabled=true - log_info "Enabling COPR repository: $copr_repo" - if sudo dnf copr enable -y "$copr_repo"; then - log_success "Enabled COPR: $copr_repo" - else - log_error "Failed to enable COPR: $copr_repo" - fi - fi - done < <(parse_toml) - - if [[ "$copr_enabled" == false ]]; then - log_info "No COPR repositories to enable" - fi -} - -# Disable COPR repositories that are no longer in forge.toml -disable_copr_repos() { - log_info "Checking for COPR repositories to disable..." - - # Get currently enabled COPR repos - local enabled_repos=$(sudo dnf copr list --enabled 2>/dev/null | grep -E "copr\.fedorainfracloud\.org" | awk '{print $1}' || true) - - # Get configured COPR repos from forge.toml - local configured_repos="" - while IFS='=' read -r entry; do - local section="${entry%%:*}" - local copr_repo="${entry#*:}" - - if [[ "$section" == "copr" && -n "$copr_repo" ]]; then - configured_repos="$configured_repos $copr_repo" - fi - done < <(parse_toml) - - # Disable repos that are enabled but not configured - for repo in $enabled_repos; do - if [[ ! " $configured_repos " =~ " $repo " ]]; then - log_info "Disabling COPR repository: $repo" - if sudo dnf copr disable -y "$repo"; then - log_success "Disabled COPR: $repo" - else - log_error "Failed to disable COPR: $repo" - fi - fi - done -} - -# Install packages from forge.toml -install_packages() { - log_info "Installing packages..." - - local packages_installed=false - - while IFS='=' read -r entry; do - local section="${entry%%:*}" - local package="${entry#*:}" - - if [[ "$section" == "packages" && -n "$package" ]]; then - packages_installed=true - log_info "Installing package: $package" - if sudo dnf install -y "$package"; then - log_success "Installed $package" - else - log_error "Failed to install $package" - fi - fi - done < <(parse_toml) - - if [[ "$packages_installed" == false ]]; then - log_info "No packages to install" - fi -} - -# Deploy dotfiles using symlinks -deploy_dotfiles() { - log_info "Deploying dotfiles..." - - local dotfiles_deployed=false - - while IFS='=' read -r entry; do - local section="${entry%%:*}" - local key_value="${entry#*:}" - local target="${key_value%%=*}" - local source="${key_value##*=}" - - if [[ "$section" == "dotfiles" ]]; then - dotfiles_deployed=true - - local target_path="$HOME/$target" - local source_path="$FORGE_DIR/$source" - - # Create source directory if it doesn't exist - local source_dir=$(dirname "$source_path") - mkdir -p "$source_dir" - - # Create target directory if it doesn't exist - local target_dir=$(dirname "$target_path") - mkdir -p "$target_dir" - - # Backup existing file if it exists and is not a symlink - if [[ -f "$target_path" && ! -L "$target_path" ]]; then - local backup_dir="$FORGE_DIR/backup" - mkdir -p "$backup_dir" - local backup_path="$backup_dir/$(basename "$target").$(date +%Y%m%d_%H%M%S).bak" - cp "$target_path" "$backup_path" - log_info "Backed up $target_path to $backup_path" - fi - - # Remove existing file/symlink - rm -f "$target_path" - - # Create symlink - if ln -s "$source_path" "$target_path"; then - log_success "Created symlink: $target_path -> $source_path" - else - log_error "Failed to create symlink: $target_path -> $source_path" - fi - fi - done < <(parse_toml) - - if [[ "$dotfiles_deployed" == false ]]; then - log_info "No dotfiles to deploy" - fi -} - -# Switch command - apply configuration -switch() { - log_info "Starting forge switch..." - - # Check if forge.toml exists - if [[ ! -f "$FORGE_TOML" ]]; then - log_error "forge.toml not found. Run 'forge init' first." - exit 1 - fi - - # Enable COPR repositories - enable_copr_repos - - # Disable COPR repositories that are no longer configured - disable_copr_repos - - # Install packages - install_packages - - # Deploy dotfiles - deploy_dotfiles - - # Update last_switch timestamp - if command -v sed >/dev/null 2>&1; then - sed -i "s/last_switch = .*/last_switch = \"$(date -Iseconds)\"/" "$FORGE_TOML" - fi - - log_success "Switch completed successfully!" -} - -# Status command - show current configuration -status() { - log_info "Forge Status" - echo " Config directory: $FORGE_DIR" - echo " Config file: $FORGE_TOML" - - if [[ -f "$FORGE_TOML" ]]; then - echo - log_info "Configuration:" - - # Show last switch time - local last_switch=$(grep "last_switch" "$FORGE_TOML" | cut -d'=' -f2 | tr -d ' "') - if [[ -n "$last_switch" && "$last_switch" != "null" ]]; then - echo " Last switch: $last_switch" - else - echo " Last switch: Never" - fi - - echo - log_info "COPR Repositories:" - local copr_count=0 - while IFS='=' read -r entry; do - local section="${entry%%:*}" - local copr_repo="${entry#*:}" - - if [[ "$section" == "copr" && -n "$copr_repo" ]]; then - echo " $copr_repo" - ((copr_count++)) - fi - done < <(parse_toml) - - if [[ $copr_count -eq 0 ]]; then - echo " No COPR repositories configured" - else - echo " Total: $copr_count COPR repositories" - fi - - echo - log_info "Packages:" - local package_count=0 - while IFS='=' read -r entry; do - local section="${entry%%:*}" - local package="${entry#*:}" - - if [[ "$section" == "packages" && -n "$package" ]]; then - echo " $package" - ((package_count++)) - fi - done < <(parse_toml) - - if [[ $package_count -eq 0 ]]; then - echo " No packages configured" - else - echo " Total: $package_count packages" - fi - - echo - log_info "Dotfiles:" - local dotfile_count=0 - while IFS='=' read -r entry; do - local section="${entry%%:*}" - local key_value="${entry#*:}" - local target="${key_value%%=*}" - local source="${key_value##*=}" - - if [[ "$section" == "dotfiles" ]]; then - echo " $target -> $source" - ((dotfile_count++)) - fi - done < <(parse_toml) - - if [[ $dotfile_count -eq 0 ]]; then - echo " No dotfiles configured" - else - echo " Total: $dotfile_count dotfiles" - fi - else - echo " No configuration file found" - fi -} - -# Show help -show_help() { - cat < - - 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 "$@" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..71dce85 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "curator" +version = "0.1.1" +description = "A home-manager style Fedora configuration helper." +readme = "README.md" +requires-python = ">=3.11" +dependencies = [] + +[project.scripts] +curator = "curator.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv] +package = true + +[dependency-groups] +dev = ["pytest>=7.4"] 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 0000000..1c77895 Binary files /dev/null and b/src/curator/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/curator/__pycache__/__init__.cpython-314.pyc b/src/curator/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..8a8ffc6 Binary files /dev/null and b/src/curator/__pycache__/__init__.cpython-314.pyc differ diff --git a/src/curator/__pycache__/cli.cpython-313.pyc b/src/curator/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000..f16a7bb Binary files /dev/null and b/src/curator/__pycache__/cli.cpython-313.pyc differ diff --git a/src/curator/__pycache__/cli.cpython-314.pyc b/src/curator/__pycache__/cli.cpython-314.pyc new file mode 100644 index 0000000..f5bea85 Binary files /dev/null and b/src/curator/__pycache__/cli.cpython-314.pyc differ diff --git a/src/curator/cli.py b/src/curator/cli.py new file mode 100644 index 0000000..8afb969 --- /dev/null +++ b/src/curator/cli.py @@ -0,0 +1,1334 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional, Callable + +BLUE = "\033[0;34m" +GREEN = "\033[0;32m" +YELLOW = "\033[1;33m" +RED = "\033[0;31m" +RESET = "\033[0m" + +DEFAULT_CONFIG = """# curator Configuration File +# User-level configuration similar to home-manager/nixos + +[curator] +version = "1.0" +last_switch = "" + +[copr] +# copr.fedorainfracloud.org/username/repository +# copr.fedorainfracloud.org/anotheruser/anotherrepo + +[dnf] +# git +# vim +# curl + +[brew] +# wget +# coreutils + +[flatpak] +# org.mozilla.firefox +# com.spotify.Client + +[rpm-ostree] +# podman +# htop + +[nix] +# nixpkgs#git +# nixpkgs#htop + +[variables] +# EXAMPLE_VAR = "value" + +[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 curator directory) +# Note: source_path automatically prefixed with "dotfiles/" if not present +# Examples: +# ".bashrc" = ".bashrc" +# ".config/vimrc" = "vimrc" +# ".config/alacritty/alacritty.yml" = "alacritty.yml" + +[options] +# Additional options +backup = true +backup_dir = "backup" +""" + + +@dataclass +class CuratorConfig: + copr: list[str] + packages: list[str] + brew_packages: list[str] + 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] + document: Optional[Any] = None + + +def log(prefix: str, color: str, message: str) -> None: + print(f"{color}{prefix}{RESET} {message}") + + +def log_info(message: str) -> None: + log("[INFO]", BLUE, message) + + +def log_success(message: str) -> None: + log("[SUCCESS]", GREEN, message) + + +def log_warning(message: str) -> None: + log("[WARNING]", YELLOW, message) + + +def log_error(message: str) -> None: + log("[ERROR]", RED, message) + + +def strip_quotes(value: str) -> str: + if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")): + return value[1:-1] + return value + + +def parse_value(value: str) -> Any: + lowered = value.lower() + if lowered in {"true", "false"}: + return lowered == "true" + if lowered in {"null", "none"}: + return None + return strip_quotes(value) + + +def parse_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: + 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] = [] + brew_packages: list[str] = [] + 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] = {} + 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 {"dnf", "copr", "brew", "flatpak", "rpm-ostree", "nix"}: + entry = line.split("=", 1)[0].strip() + if 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 == "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 + 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 == "curator": + curator_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 CuratorConfig( + copr=copr, + packages=dnf_packages, + brew_packages=brew_packages, + 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, + document=None, + ) + + +def empty_config() -> CuratorConfig: + return CuratorConfig( + copr=[], + packages=[], + brew_packages=[], + flatpak_refs=[], + rpm_ostree_packages=[], + nix_packages=[], + variables={}, + dotfiles={}, + options={"backup": True, "backup_dir": "backup"}, + curator_fields={}, + document=None, + ) + + + +def load_config(config_path: Path) -> CuratorConfig: + 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, Path]: + curator_dir = Path(os.environ.get("CURATOR_DIR", Path.home() / ".config" / "curator")).expanduser() + curator_toml = curator_dir / "inventory.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() + log_info("Initializing curator...") + curator_dir.mkdir(parents=True, exist_ok=True) + + if curator_toml.exists(): + log_warning(f"Configuration already exists at {curator_toml}") + else: + curator_toml.write_text(DEFAULT_CONFIG) + log_success(f"Created inventory.toml: {curator_toml}") + + log_success("Initialization complete!") + 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: + try: + result = subprocess.run(command, check=False) + except FileNotFoundError: + log_error(f"Command not found: {' '.join(command)}") + return False + 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: + 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 gather_copr_repos() -> list[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 [] + + if result.returncode != 0: + log_warning("Unable to list enabled COPR repositories") + 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 sorted(enabled) + + +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]): + log_success(f"Disabled COPR: {repo}") + else: + log_error(f"Failed to disable COPR: {repo}") + + +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], 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], 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], 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 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 + 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], 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], installed: set[str] | None = None) -> None: + remove_entries(packages, "brew packages", lambda p: ["brew", "uninstall", p], installed) + + +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], 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], 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) + + +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_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, *, 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" + + if target_path.is_dir(): + shutil.copytree(target_path, backup_path) + else: + shutil.copy2(target_path, backup_path) + + if not quiet: + 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: CuratorConfig, curator_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 = curator_dir / backup_dir_name + + for target, source in config.dotfiles.items(): + source_path = curator_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 _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") + 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 inventory.toml: {exc}") + + timestamp = dt.datetime.now().astimezone().isoformat(timespec="seconds") + raw_text = config_path.read_text() + lines = raw_text.splitlines() + + 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 + + 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: + try: + shutil.copy2(current_path, previous_path) + except OSError as exc: + 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) + if i < len(lines) and (not output or output[-1].strip()): + output.append("") + 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()) + 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 + + 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} + + 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)) + + +def switch_command() -> None: + _switch(rollback=False) + + +def switch_command_with_args(rollback: bool = False) -> None: + _switch(rollback=rollback) + + +def _switch(rollback: bool) -> None: + 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 rollback_toml.exists() or not last_toml.exists(): + log_error("No rollback snapshots found. Run a normal switch first.") + sys.exit(1) + + 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}") + + 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(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) + 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 + ) + 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(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) + 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: + # 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", "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) + + 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 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) + 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) + + 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 + + apply_environment_variables(config, curator_dir, propagate=True) + log_info("To update your current shell now, run:") + print(' eval "$(curator env --eval-only)"') + + +def status_command() -> None: + curator_dir, curator_toml, _, _ = get_paths() + log_info("curator Status") + print(f" Config directory: {curator_dir}") + print(f" Config file: {curator_toml}") + + if not curator_toml.exists(): + print(" No configuration file found") + return + + config = parse_config(curator_toml) + + print() + log_info("Configuration:") + last_switch = config.curator_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("DNF Packages:") + if config.packages: + for package in config.packages: + print(f" {package}") + print(f" Total: {len(config.packages)} dnf packages") + else: + 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("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: + 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="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") + 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", + ) + 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") + env_parser = subparsers.add_parser("env", help="Apply environment variables immediately") + env_parser.add_argument( + "--eval-only", + action="store_true", + 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") + 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_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 == "env": + env_command(eval_only=args.eval_only) + 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 0000000..cc52ee7 Binary files /dev/null and b/tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..abea4db --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,262 @@ +from pathlib import Path + +import datetime as dt + +from curator import cli + + +def test_parse_config_line_format(tmp_path: Path) -> None: + config_path = tmp_path / "inventory.toml" + config_path.write_text( + """ +[curator] +version = "1.0" +last_switch = "" + +[copr] +copr.fedorainfracloud.org/user/repo + + [dnf] + git + vim + + [brew] + wget + + [flatpak] + org.mozilla.firefox + + [rpm-ostree] + podman + + [nix] + nixpkgs#git + +[dotfiles] +".bashrc" = ".bashrc" + +[options] +backup = true +backup_dir = "backup" + """.strip() + ) + + config = cli.parse_config(config_path) + + assert config.copr == ["copr.fedorainfracloud.org/user/repo"] + assert config.packages == ["git", "vim"] + assert config.brew_packages == ["wget"] + assert config.flatpak_refs == ["org.mozilla.firefox"] + assert config.rpm_ostree_packages == ["podman"] + assert config.nix_packages == ["nixpkgs#git"] + assert config.dotfiles == {".bashrc": ".bashrc"} + assert config.options["backup"] is True + + +def test_parse_config_legacy_format(tmp_path: Path) -> None: + config_path = tmp_path / "inventory.toml" + config_path.write_text( + """ + [dnf] + git + vim + +[brew] +wget + +[flatpak] +org.mozilla.firefox + + [rpm-ostree] + podman + + [nix] + nixpkgs#git + +[variables] +EDITOR = "nvim" + +[copr] +copr.fedorainfracloud.org/user/repo + +[dotfiles] +".bashrc" = ".bashrc" + """.strip() + ) + + config = cli.parse_config(config_path) + + assert config.packages == ["git", "vim"] + assert config.brew_packages == ["wget"] + assert config.flatpak_refs == ["org.mozilla.firefox"] + assert config.rpm_ostree_packages == ["podman"] + assert config.nix_packages == ["nixpkgs#git"] + assert config.copr == ["copr.fedorainfracloud.org/user/repo"] + assert config.dotfiles == {".bashrc": ".bashrc"} + assert config.variables == {"EDITOR": "nvim"} + + +def test_diff_items() -> None: + added, removed = cli.diff_items(["a", "b"], ["b", "c"]) + assert added == ["a"] + assert removed == ["c"] + + +def test_update_last_switch_creates_backup(tmp_path: Path) -> None: + config_dir = tmp_path + config_path = config_dir / "inventory.toml" + config_path.write_text( + """ +[curator] +version = "1.0" +last_switch = "" + """.strip() + ) + + options = {"backup": True, "backup_dir": "backup"} + cli.update_last_switch(config_path, options) + + lines = [line.strip() for line in config_path.read_text().splitlines() if line.strip()] + last_switch_line = [line for line in lines if line.startswith("last_switch")] + assert last_switch_line, "last_switch should be written" + ts_str = last_switch_line[0].split("=", 1)[1].strip().strip('"') + dt.datetime.fromisoformat(ts_str) + + backup_dir = config_dir / "backup" + backups = list(backup_dir.iterdir()) + assert backups, "expected a backup of inventory.toml to be created" + assert any(path.name.startswith("inventory.toml.") for path in backups) + +def test_reset_previous_config(tmp_path: Path, monkeypatch) -> None: + rollback = tmp_path / "inventory.toml.rollback" + rollback.write_text("prev") + + def fake_get_paths(): + return tmp_path, tmp_path / "inventory.toml", tmp_path / "inventory.toml.last", rollback + + monkeypatch.setattr(cli, "get_paths", fake_get_paths) + cli.reset_previous_config() + assert not rollback.exists() + + +def test_update_section_entries_rewrites_section(tmp_path: Path) -> None: + config_path = tmp_path / "inventory.toml" + config_path.write_text( + """ +[dnf] +git + +[copr] +copr.fedorainfracloud.org/user/repo + +[brew] +wget + """.strip() + ) + + cli.update_section_entries(config_path, "dnf", ["vim", "curl", "vim"]) + cli.update_section_entries(config_path, "copr", ["copr.fedorainfracloud.org/user/repo", "copr.fedorainfracloud.org/another/repo"]) + + content = config_path.read_text().strip().splitlines() + dnf_index = content.index("[dnf]") + assert content[dnf_index + 1 : dnf_index + 3] == ["curl", "vim"] + assert "[copr]" in content + copr_index = content.index("[copr]") + assert content[copr_index + 1 : copr_index + 3] == [ + "copr.fedorainfracloud.org/another/repo", + "copr.fedorainfracloud.org/user/repo", + ] + + +def test_merge_section_entries_preserves_spacing(tmp_path: Path) -> None: + config_path = tmp_path / "inventory.toml" + config_path.write_text( + """ +[dnf] +git + +[brew] +wget + """.strip() + ) + + cli.merge_section_entries(config_path, "dnf", {"vim"}, set()) + + lines = config_path.read_text().splitlines() + assert "" in lines, "expected to keep a blank line between sections" + dnf_index = lines.index("[dnf]") + brew_index = lines.index("[brew]") + assert lines[dnf_index + 1 : dnf_index + 3] == ["git", "vim"] + assert lines[brew_index - 1] == "" + + +def test_apply_environment_variables(tmp_path: Path, monkeypatch) -> None: + curator_dir = tmp_path / "curator" + curator_dir.mkdir() + env_home = tmp_path / "xdg" + monkeypatch.setenv("XDG_CONFIG_HOME", str(env_home)) + + config = cli.empty_config() + config.variables = {"EDITOR": "nvim", "WITH_SPACE": "some value"} + config.options["backup"] = False + + written, removed = cli.apply_environment_variables(config, curator_dir) + + env_file = env_home / "environment.d" / "20-curator.conf" + assert env_file.exists() + lines = env_file.read_text().splitlines() + assert "EDITOR=nvim" in lines + assert 'WITH_SPACE="some value"' in lines + assert written == {"EDITOR": "nvim", "WITH_SPACE": "some value"} + assert removed == set() + + +def test_apply_environment_variables_removes_file_when_empty(tmp_path: Path, monkeypatch) -> None: + curator_dir = tmp_path / "curator" + curator_dir.mkdir() + env_home = tmp_path / "xdg" + env_dir = env_home / "environment.d" + env_dir.mkdir(parents=True, exist_ok=True) + env_file = env_dir / "20-curator.conf" + env_file.write_text("OLD=1\n") + monkeypatch.setenv("XDG_CONFIG_HOME", str(env_home)) + + config = cli.empty_config() + config.options["backup"] = False + + variables, removed = cli.apply_environment_variables(config, curator_dir) + + assert not env_file.exists() + assert variables == {} + assert removed == {"OLD"} + + +def test_env_command_eval_outputs_exports(tmp_path: Path, monkeypatch, capsys) -> None: + curator_dir = tmp_path / "curator" + curator_dir.mkdir() + env_file = curator_dir / "inventory.toml" + env_file.write_text( + """ +[variables] +EDITOR = "nvim" + """.strip() + ) + env_home = tmp_path / "xdg" + env_dir = env_home / "environment.d" + env_dir.mkdir(parents=True, exist_ok=True) + env_conf = env_dir / "20-curator.conf" + env_conf.write_text("OLD=1\n") + monkeypatch.setenv("XDG_CONFIG_HOME", str(env_home)) + + def fake_get_paths(): + return curator_dir, env_file, curator_dir / "last", curator_dir / "rollback" + + def fail_apply_environment_variables(*_args, **_kwargs): + raise AssertionError("apply_environment_variables should not be called in eval-only mode") + + monkeypatch.setattr(cli, "get_paths", fake_get_paths) + monkeypatch.setattr(cli, "apply_environment_variables", fail_apply_environment_variables) + + cli.env_command(eval_only=True) + out = capsys.readouterr().out.strip().splitlines() + assert out == ["export EDITOR=nvim", "unset OLD"] diff --git a/tmpcfg.toml b/tmpcfg.toml new file mode 100644 index 0000000..3f04387 --- /dev/null +++ b/tmpcfg.toml @@ -0,0 +1,14 @@ + +[curator] +version = "1.0" +last_switch = "" + +copr = ["copr.fedorainfracloud.org/user/repo"] +packages = ["git", "vim"] + +[dotfiles] +".bashrc" = ".bashrc" + +[options] +backup = true +backup_dir = "backup" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..80399ab --- /dev/null +++ b/uv.lock @@ -0,0 +1,79 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "curator" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=7.4" }] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +]