diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 3d78e47..0000000 --- a/.gitignore +++ /dev/null @@ -1,75 +0,0 @@ -# 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 80a4592..dba85a0 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,78 @@ -# curator - A Home-Manager Like Tool for Universal Blue builds +# Forge - A Home-Manager Like Tool for Fedora -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/). +A lightweight configuration management tool for Fedora that provides COPR repository management, dotfile management and package installation functionality through a centralized TOML configuration. ## Overview -curator is a Python CLI that helps you manage your Fedora system configuration by: +Forge is a bash script that helps you manage your Fedora system configuration by: - Managing COPR repositories (enable/disable automatically) -- Installing and managing system packages via `dnf` or `rpm-ostree` -- Installing and managing userspace packages via `brew`, `flatpak`, or `nix` +- Installing and managing system packages via dnf - Managing dotfiles through symlinks to actual files -- Centralized configuration via a single `inventory.toml` file -- Declarative user-level configuration similar to `home-manager`/`nixos` +- Centralized configuration via a single `forge.toml` file +- User-level configuration similar to home-manager/nixos - Automatic backup of existing files before replacement ## Installation -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 -``` +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 + ``` ## Quick Start ```bash # Initialize the configuration structure -curator init +./forge init # Edit the configuration file to add packages and dotfiles -nano ~/.config/curator/inventory.toml +nano ~/.config/forge/forge.toml # Apply configuration -curator switch +./forge switch # Check status -curator status +./forge status ``` ## Commands ### `init` -Initialize the configuration structure and create `inventory.toml`. +Initialize the configuration structure and create `forge.toml`. ```bash -curator init -# or ./curator init +./forge init ``` Creates: -- `~/.config/curator/` - Main configuration directory -- `~/.config/curator/inventory.toml` - Central configuration file +- `~/.config/forge/` - Main configuration directory +- `~/.config/forge/forge.toml` - Central configuration file ### `switch` -Apply the current configuration (enable COPR, install packages, deploy dotfiles). Use `--rollback` to restore the previous `inventory.toml` snapshot before applying. +Apply the current configuration (enable COPR, install packages, deploy dotfiles). ```bash -curator switch -# or ./curator switch -# rollback to the previous inventory.toml and apply it -curator switch --rollback +./forge switch ``` This command: -1. Enables all COPR repositories listed in `inventory.toml` +1. Enables all COPR repositories listed in `forge.toml` 2. Disables COPR repositories that are no longer configured -3. Installs all packages listed in `inventory.toml` +3. Installs all packages listed in `forge.toml` 4. Creates symlinks for all configured dotfiles -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 +5. Creates backups of existing files before replacing them +6. Updates the last switch timestamp ### `status` Show current configuration status and information. ```bash -curator status -# or ./curator status +./forge status ``` Displays: @@ -87,99 +84,44 @@ Displays: Show help message with all available commands. ```bash -curator help -# or ./curator help +./forge 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 -### inventory.toml -The central configuration file located at `~/.config/curator/inventory.toml`: +### forge.toml +The central configuration file located at `~/.config/forge/forge.toml`: ```toml -# curator Configuration File +# Forge Configuration File # User-level configuration similar to home-manager/nixos -[curator] +[forge] version = "1.0" -last_switch = "" +last_switch = null [copr] -# copr.fedorainfracloud.org/username/repository -# copr.fedorainfracloud.org/anotheruser/anotherrepo +# 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 -[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" +[packages] +# List of packages to install using dnf +# Just list package names - presence means install, absence means don't install +git +vim +curl +wget [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 dotfiles directory) -".bashrc" = ".bashrc" -".config/vimrc" = "vimrc" -".config/alacritty/alacritty.yml" = "alacritty.yml" +# 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" [options] # Additional options @@ -189,12 +131,12 @@ backup_dir = "backup" ### Configuration Sections -#### `[curator]` +#### `[forge]` - `version`: Configuration file version - `last_switch`: Timestamp of last switch operation (auto-updated) #### `[copr]` -List of COPR repositories to enable via `dnf copr enable`. **One repository per line**—presence means enable, absence means don't enable. +List of COPR repositories to enable via `dnf copr enable`. **Just list COPR repository names** - presence means enable, absence means don't enable. **Examples:** ```toml @@ -203,14 +145,14 @@ copr.fedorainfracloud.org/username/repository copr.fedorainfracloud.org/anotheruser/anotherrepo ``` -To remove a COPR repository, simply delete the line containing the repository name. curator will automatically disable it during the next switch. +To remove a COPR repository, simply delete the line containing the repository name. Forge will automatically disable it during the next switch. -#### `[dnf]` -List of packages to install via dnf. **One package per line**—presence means install, absence means don't install. +#### `[packages]` +List of packages to install via dnf. **Just list package names** - presence means install, absence means don't install. **Examples:** ```toml -[dnf] +[packages] git vim curl @@ -221,49 +163,21 @@ 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 dotfiles directory) -- Note: Source path automatically prefixed with "dotfiles/" if not present +- **Value**: Source path where actual file is stored (relative to forge directory) #### `[options]` Additional configuration options: - `backup`: Enable/disable backup of existing files (default: true) -- `backup_dir`: Directory name for backups (relative to curator directory) +- `backup_dir`: Directory name for backups (relative to forge directory) ## File Structure ``` -~/.config/curator/ -├── inventory.toml # Main configuration file +~/.config/forge/ +├── forge.toml # Main configuration file ├── dotfiles/ # Your actual dotfiles (source files) │ ├── .bashrc │ ├── vimrc @@ -273,6 +187,153 @@ 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 -- `CURATOR_DIR`: Override the default configuration directory (default: `~/.config/curator`) +- `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 diff --git a/curator b/curator deleted file mode 100755 index 93b8b3f..0000000 --- a/curator +++ /dev/null @@ -1,24 +0,0 @@ -#!/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 new file mode 100755 index 0000000..0120279 --- /dev/null +++ b/forge @@ -0,0 +1,429 @@ +#!/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 deleted file mode 100644 index 71dce85..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,20 +0,0 @@ -[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 deleted file mode 100644 index 87d4f32..0000000 --- a/src/curator/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# curator package diff --git a/src/curator/__pycache__/__init__.cpython-313.pyc b/src/curator/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 1c77895..0000000 Binary files a/src/curator/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/curator/__pycache__/__init__.cpython-314.pyc b/src/curator/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 8a8ffc6..0000000 Binary files a/src/curator/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/src/curator/__pycache__/cli.cpython-313.pyc b/src/curator/__pycache__/cli.cpython-313.pyc deleted file mode 100644 index f16a7bb..0000000 Binary files a/src/curator/__pycache__/cli.cpython-313.pyc and /dev/null differ diff --git a/src/curator/__pycache__/cli.cpython-314.pyc b/src/curator/__pycache__/cli.cpython-314.pyc deleted file mode 100644 index f5bea85..0000000 Binary files a/src/curator/__pycache__/cli.cpython-314.pyc and /dev/null differ diff --git a/src/curator/cli.py b/src/curator/cli.py deleted file mode 100644 index 8afb969..0000000 --- a/src/curator/cli.py +++ /dev/null @@ -1,1334 +0,0 @@ -#!/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 deleted file mode 100644 index cc52ee7..0000000 Binary files a/tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index abea4db..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,262 +0,0 @@ -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 deleted file mode 100644 index 3f04387..0000000 --- a/tmpcfg.toml +++ /dev/null @@ -1,14 +0,0 @@ - -[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 deleted file mode 100644 index 80399ab..0000000 --- a/uv.lock +++ /dev/null @@ -1,79 +0,0 @@ -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" }, -]