From df1f387795c7a456fbd35caeb6cf34d385235cb3 Mon Sep 17 00:00:00 2001 From: randogoth Date: Wed, 24 Dec 2025 08:54:24 +0200 Subject: [PATCH] added package removal, brew, flatpak, ostree, nix xupport --- README.md | 317 ++++++----- forge => curator | 6 +- pyproject.toml | 6 +- src/curator/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 160 bytes .../__pycache__/__init__.cpython-314.pyc | Bin src/curator/__pycache__/cli.cpython-313.pyc | Bin 0 -> 33722 bytes .../__pycache__/cli.cpython-314.pyc | Bin src/{forge => curator}/cli.py | 532 +++++++++++------- src/forge/__init__.py | 1 - .../__pycache__/__init__.cpython-313.pyc | Bin 158 -> 0 bytes src/forge/__pycache__/cli.cpython-313.pyc | Bin 27871 -> 0 bytes .../test_config.cpython-313-pytest-9.0.2.pyc | Bin 11401 -> 15310 bytes tests/test_config.py | 78 ++- tmpcfg.toml | 2 +- uv.lock | 15 +- 16 files changed, 596 insertions(+), 362 deletions(-) rename forge => curator (74%) create mode 100644 src/curator/__init__.py create mode 100644 src/curator/__pycache__/__init__.cpython-313.pyc rename src/{forge => curator}/__pycache__/__init__.cpython-314.pyc (100%) create mode 100644 src/curator/__pycache__/cli.cpython-313.pyc rename src/{forge => curator}/__pycache__/cli.cpython-314.pyc (100%) rename src/{forge => curator}/cli.py (50%) delete mode 100644 src/forge/__init__.py delete mode 100644 src/forge/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/forge/__pycache__/cli.cpython-313.pyc diff --git a/README.md b/README.md index 77e30d2..d757aea 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ -# Forge - A Home-Manager Like Tool for Fedora +# curator - A Home-Manager Like Tool for Fedora A lightweight Python CLI (managed with `uv`) for Fedora that provides COPR repository management, dotfile management and package installation functionality through a centralized TOML configuration. ## Overview -Forge is a Python CLI that helps you manage your Fedora system configuration by: +curator is a Python CLI that helps you manage your Fedora system configuration by: - Managing COPR repositories (enable/disable automatically) - Installing and managing system packages via dnf - Managing dotfiles through symlinks to actual files -- Centralized configuration via a single `forge.toml` file +- Centralized configuration via a single `inventory.toml` file - User-level configuration similar to home-manager/nixos - Automatic backup of existing files before replacement @@ -21,11 +21,11 @@ Forge is a Python CLI that helps you manage your Fedora system configuration by: ``` 3. Run with `uv`: ```bash - uv run forge --help + uv run curator --help ``` 4. Or use the local shim directly: ```bash - ./forge --help + ./curator --help ``` 5. Optionally install globally via `uv`: ```bash @@ -36,46 +36,48 @@ Forge is a Python CLI that helps you manage your Fedora system configuration by: ```bash # Initialize the configuration structure -uv run forge init +uv run curator init # Edit the configuration file to add packages and dotfiles -nano ~/.config/forge/forge.toml +nano ~/.config/curator/inventory.toml # Apply configuration -uv run forge switch +uv run curator switch # Check status -uv run forge status +uv run curator status ``` -You can swap `uv run forge ...` for `./forge ...` if you prefer the local shim. +You can swap `uv run curator ...` for `./curator ...` if you prefer the local shim. ## Commands ### `init` -Initialize the configuration structure and create `forge.toml`. +Initialize the configuration structure and create `inventory.toml`. ```bash -uv run forge init -# or ./forge init +uv run curator init +# or ./curator init ``` Creates: -- `~/.config/forge/` - Main configuration directory -- `~/.config/forge/forge.toml` - Central configuration file +- `~/.config/curator/` - Main configuration directory +- `~/.config/curator/inventory.toml` - Central configuration file ### `switch` -Apply the current configuration (enable COPR, install packages, deploy dotfiles). +Apply the current configuration (enable COPR, install packages, deploy dotfiles). Use `--rollback` to restore the previous `inventory.toml` snapshot before applying. ```bash -uv run forge switch -# or ./forge switch +uv run curator switch +# or ./curator switch +# rollback to the previous inventory.toml and apply it +uv run curator switch --rollback ``` This command: -1. Enables all COPR repositories listed in `forge.toml` +1. Enables all COPR repositories listed in `inventory.toml` 2. Disables COPR repositories that are no longer configured -3. Installs all packages listed in `forge.toml` +3. Installs all packages listed in `inventory.toml` 4. Creates symlinks for all configured dotfiles 5. Creates backups of existing files before replacing them 6. Updates the last switch timestamp @@ -84,8 +86,8 @@ This command: Show current configuration status and information. ```bash -uv run forge status -# or ./forge status +uv run curator status +# or ./curator status ``` Displays: @@ -97,35 +99,49 @@ Displays: Show help message with all available commands. ```bash -uv run forge help -# or ./forge help +uv run curator help +# or ./curator help ``` ## Configuration -### forge.toml -The central configuration file located at `~/.config/forge/forge.toml`: +### inventory.toml +The central configuration file located at `~/.config/curator/inventory.toml`: ```toml -# Forge Configuration File +# curator Configuration File # User-level configuration similar to home-manager/nixos -copr = [ - # "copr.fedorainfracloud.org/username/repository", - # "copr.fedorainfracloud.org/anotheruser/anotherrepo", -] - -packages = [ - # "git", - # "vim", - # "curl", - # "wget", -] - -[forge] +[curator] version = "1.0" last_switch = "" +[copr] +# copr.fedorainfracloud.org/username/repository +# copr.fedorainfracloud.org/anotheruser/anotherrepo + +[dnf] +# git +# vim +# curl +# wget + +[brew] +# wget +# coreutils + +[flatpak] +# org.mozilla.firefox +# com.spotify.Client + +[rpm-ostree] +# podman +# htop + +[nix] +# nixpkgs#git # or just "git" (curator will prefix nixpkgs#) +# nixpkgs#htop # or just "htop" + [dotfiles] # Dotfiles to manage with symlinks # Format: "target_path" = "source_path" @@ -143,40 +159,44 @@ backup_dir = "backup" ### Configuration Sections -#### `[forge]` +#### `[curator]` - `version`: Configuration file version - `last_switch`: Timestamp of last switch operation (auto-updated) -#### `copr` -Array of COPR repositories to enable via `dnf copr enable`. **Just list COPR repository names** - presence means enable, absence means don't enable. +#### `[copr]` +List of COPR repositories to enable via `dnf copr enable`. **One repository per line**—presence means enable, absence means don't enable. **Examples:** ```toml -copr = [ - "copr.fedorainfracloud.org/username/repository", - "copr.fedorainfracloud.org/anotheruser/anotherrepo", -] +[copr] +copr.fedorainfracloud.org/username/repository +copr.fedorainfracloud.org/anotheruser/anotherrepo ``` -To remove a COPR repository, simply delete the line containing the repository name. Forge will automatically disable it during the next switch. +To remove a COPR repository, simply delete the line containing the repository name. curator will automatically disable it during the next switch. -#### `packages` -Array of packages to install via dnf. **Just list package names** - presence means install, absence means don't install. +#### `[dnf]` +List of packages to install via dnf. **One package per line**—presence means install, absence means don't install. **Examples:** ```toml -packages = [ - "git", - "vim", - "curl", - "wget", - "nodejs", - "npm", -] +[dnf] +git +vim +curl +wget +nodejs +npm ``` To remove a package, simply delete the line containing the package name. +#### `[brew]` +List of packages to install via Homebrew. **One package per line**—presence means install. + +#### `[flatpak]` +List of Flatpak refs to install. **One ref per line**—presence means install. + #### `[dotfiles]` Dotfile mappings using symlinks: - **Key**: Target path where symlink should be created (relative to home directory) @@ -186,13 +206,13 @@ Dotfile mappings using symlinks: #### `[options]` Additional configuration options: - `backup`: Enable/disable backup of existing files (default: true) -- `backup_dir`: Directory name for backups (relative to forge directory) +- `backup_dir`: Directory name for backups (relative to curator directory) ## File Structure ``` -~/.config/forge/ -├── forge.toml # Main configuration file +~/.config/curator/ +├── inventory.toml # Main configuration file ├── dotfiles/ # Your actual dotfiles (source files) │ ├── .bashrc │ ├── vimrc @@ -205,162 +225,205 @@ Additional configuration options: ## How It Works ### COPR Repository Management -COPR repositories are managed through the `copr` array in `forge.toml`: -- **Add COPR repo**: Add the repository name to the array -- **Remove COPR repo**: Remove the entry from the array -- **Automatic cleanup**: Forge automatically disables COPR repos that are removed from configuration +COPR repositories are managed through the `[copr]` section in `inventory.toml`: +- **Add COPR repo**: Add the repository name on its own line +- **Remove COPR repo**: Remove the entry +- **Automatic cleanup**: curator automatically disables COPR repos that are removed from configuration - **No flags needed**: Just presence/absence of the repository name matters -- Forge stores the previous configuration at `~/.config/forge/forge.toml.prev` and compares it to the current file during `switch`; newly added repos are enabled, removed repos are disabled. +- curator stores the previous configuration at `~/.config/curator/inventory.toml.prev` and compares it to the current file during `switch`; newly added repos are enabled, removed repos are disabled. ### Package Management -Packages are managed through the `packages` array in `forge.toml`: -- **Add package**: Add the package name to the array -- **Remove package**: Remove the entry from the array +Packages are managed through the `[dnf]` section in `inventory.toml`: +- **Add package**: Add the package name on its own line +- **Remove package**: Remove the entry - **No flags needed**: Just presence/absence of the package name matters -- The previous configuration snapshot (`forge.toml.prev`) is used to detect additions/removals on each `switch`; added packages are installed and removed packages are uninstalled. +- The previous configuration snapshot (`inventory.toml.prev`) is used to detect additions/removals on each `switch`; added packages are installed and removed packages are uninstalled. + +### Brew Management +Homebrew packages are managed through the `[brew]` section: +- **Add package**: Add the package name on its own line +- **Remove package**: Remove the entry +- Additions/removals are detected against `inventory.toml.prev` on `switch`; removed packages are uninstalled. + +### Flatpak Management +Flatpaks are managed through the `[flatpak]` section: +- **Add ref**: Add the ref on its own line +- **Remove ref**: Remove the entry +- Additions/removals are detected against `inventory.toml.prev` on `switch`; removed refs are uninstalled. + +### rpm-ostree Management +rpm-ostree packages are managed through the `[rpm-ostree]` section: +- **Add package**: Add the package name on its own line +- **Remove package**: Remove the entry +- Additions/removals are detected against `inventory.toml.prev` on `switch`; removed packages are uninstalled. + +### Nix Management +Nix packages are managed through the `[nix]` section (if `nix` is available on the system): +- **Add package**: Add the package name (e.g., `nixpkgs#git` or just `git`) on its own line +- **Remove package**: Remove the entry +- Additions/removals are detected against `inventory.toml.prev` on `switch`; installs/removals use `nix profile` and will prefix `nixpkgs#` if missing. ### Dotfile Management -Forge uses symlinks to manage dotfiles: -1. Your actual dotfiles are stored in `~/.config/forge/dotfiles/` +curator uses symlinks to manage dotfiles: +1. Your actual dotfiles are stored in `~/.config/curator/dotfiles/` 2. Symlinks are created from your home directory to these files using relative paths 3. This allows you to version control your dotfiles in one place 4. Changes to the source files are immediately reflected in your home directory 5. Source paths are automatically prefixed with "dotfiles/" for convenience ### Backup System -Before creating symlinks, Forge: +Before creating symlinks, curator: 1. Checks if the target file exists and is not a symlink 2. Creates a timestamped backup in the backup directory 3. Removes the original file 4. Creates the symlink to your managed dotfile -`forge.toml` is also backed up before the last switch timestamp is updated. +`inventory.toml` is also backed up before the last switch timestamp is updated. -The previously applied configuration is stored separately as `~/.config/forge/forge.toml.prev` to compute diffs for COPR and package changes. +The previously applied configuration is stored separately as `~/.config/curator/inventory.toml.prev` to compute diffs for COPR and package changes. ## Examples ### Basic Setup ```bash -# Initialize forge -uv run forge init +# Initialize curator +uv run curator init -# Edit forge.toml to add COPR repos and packages -nano ~/.config/forge/forge.toml +# Edit inventory.toml to add COPR repos and packages +nano ~/.config/curator/inventory.toml -# Add to the arrays: -# copr = [ -# "copr.fedorainfracloud.org/username/cool-repo", -# ] -# packages = [ -# "git", -# "vim", -# "curl", -# ] +# Add to the sections: +# [copr] +# copr.fedorainfracloud.org/username/cool-repo +# [dnf] +# git +# vim +# curl +# [brew] +# wget +# [flatpak] +# org.mozilla.firefox +# [rpm-ostree] +# podman +# [nix] +# nixpkgs#git (or just "git") # Create your dotfiles directory and add files -mkdir -p ~/.config/forge/dotfiles -echo "export EDITOR=vim" > ~/.config/forge/dotfiles/.bashrc +mkdir -p ~/.config/curator/dotfiles +echo "export EDITOR=vim" > ~/.config/curator/dotfiles/.bashrc # Add to [dotfiles] section: ".bashrc" = ".bashrc" # Apply configuration -uv run forge switch +uv run curator switch ``` ### Managing Application Configurations ```bash # Add alacritty configuration -mkdir -p ~/.config/forge/dotfiles -cp ~/.config/alacritty/alacritty.yml ~/.config/forge/dotfiles/ +mkdir -p ~/.config/curator/dotfiles +cp ~/.config/alacritty/alacritty.yml ~/.config/curator/dotfiles/ -# Edit forge.toml -nano ~/.config/forge/forge.toml +# Edit inventory.toml +nano ~/.config/curator/inventory.toml # Add to [dotfiles] section: ".config/alacritty/alacritty.yml" = "alacritty.yml" # Apply changes -uv run forge switch +uv run curator switch ``` ### COPR Repository Management Examples ```toml -copr = [ - # Development tools COPR - "copr.fedorainfracloud.org/development/tools", - "copr.fedorainfracloud.org/user/neovim-nightly", -] +[copr] +# Development tools COPR +copr.fedorainfracloud.org/development/tools +copr.fedorainfracloud.org/user/neovim-nightly # To remove a COPR repo, just delete the entry -# Forge will automatically disable it during the next switch +# curator will automatically disable it during the next switch ``` ### Package Management Examples ```toml -packages = [ - # Development tools - "git", - "vim", - "nodejs", - "npm", +[dnf] +# Development tools +git +vim +nodejs +npm - # System utilities - "curl", - "wget", - "tree", - "htop", -] +# System utilities +curl +wget +tree +htop # To remove a package, just delete the entry + +[brew] +# Utilities and tools +wget +coreutils + +[flatpak] +org.mozilla.firefox +com.spotify.Client + +[rpm-ostree] +podman +htop ``` ### Version Control Your Configuration ```bash -# Initialize git repository in forge directory -cd ~/.config/forge +# Initialize git repository in curator directory +cd ~/.config/curator git init git add . git commit -m "Initial configuration" # Now you can version control your entire system configuration -git add forge.toml dotfiles/ +git add inventory.toml dotfiles/ git commit -m "Updated vim configuration" ``` ## Environment Variables -- `FORGE_DIR`: Override the default configuration directory (default: `~/.config/forge`) +- `CURATOR_DIR`: Override the default configuration directory (default: `~/.config/curator`) ## Dependencies - Python 3.11+ -- `tomlkit` (installed automatically via `uv sync`) - `uv` for environment and script management - `dnf` - Fedora package manager - `dnf-plugins-core` - For COPR repository management ## Migration from Previous Version -If you were using the old version of forge with `git = true` or bare keys under `[packages]`/`[copr]`: +If you were using the old version of curator with `git = true` or bare keys under `[packages]`/`[copr]`: 1. Your existing configuration will not be automatically migrated, but the CLI will still read the legacy format. -2. Run `uv run forge init` (or `./forge init`) to create the new `forge.toml` structure. -3. Convert to arrays: +2. Run `uv run curator init` (or `./curator init`) to create the new `inventory.toml` structure. +3. Convert to section-per-line format: ```toml # Old formats - [packages] + [dnf] git = true vim = true # or - [packages] + [dnf] git vim # New format - packages = ["git", "vim"] - copr = ["copr.fedorainfracloud.org/username/repository"] + [dnf] + git + vim + [copr] + copr.fedorainfracloud.org/username/repository ``` -4. Move your existing dotfiles from the old directory to `~/.config/forge/dotfiles/` +4. Move your existing dotfiles from the old directory to `~/.config/curator/dotfiles/` ## License diff --git a/forge b/curator similarity index 74% rename from forge rename to curator index c83e04c..93b8b3f 100755 --- a/forge +++ b/curator @@ -12,12 +12,12 @@ def main() -> None: sys.path.insert(0, str(src_dir)) try: - from forge.cli import main as forge_main + from curator.cli import main as curator_main except ImportError as exc: # pragma: no cover - fallback error path - sys.stderr.write(f"Failed to import forge CLI: {exc}\n") + sys.stderr.write(f"Failed to import curator CLI: {exc}\n") sys.exit(1) - forge_main() + curator_main() if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 462e47b..8c24ee8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] -name = "forge" +name = "curator" version = "0.1.0" description = "A home-manager style Fedora configuration helper." readme = "README.md" requires-python = ">=3.11" -dependencies = ["tomlkit>=0.12"] +dependencies = [] [project.scripts] -forge = "forge.cli:main" +curator = "curator.cli:main" [build-system] requires = ["hatchling"] diff --git a/src/curator/__init__.py b/src/curator/__init__.py new file mode 100644 index 0000000..87d4f32 --- /dev/null +++ b/src/curator/__init__.py @@ -0,0 +1 @@ +# curator package diff --git a/src/curator/__pycache__/__init__.cpython-313.pyc b/src/curator/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c77895bd24ea280c072a453aab42f1333255c96 GIT binary patch literal 160 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 None: @@ -104,12 +117,16 @@ def parse_value(value: str) -> Any: return strip_quotes(value) -def parse_legacy_config(config_path: Path) -> ForgeConfig: +def parse_config(config_path: Path) -> CuratorConfig: copr: list[str] = [] - packages: list[str] = [] + dnf_packages: list[str] = [] + brew_packages: list[str] = [] + flatpak_refs: list[str] = [] + rpm_ostree_packages: list[str] = [] + nix_packages: list[str] = [] dotfiles: dict[str, str] = {} options: dict[str, Any] = {} - forge_fields: dict[str, Any] = {} + curator_fields: dict[str, Any] = {} section = None with config_path.open() as handle: @@ -126,11 +143,21 @@ def parse_legacy_config(config_path: Path) -> ForgeConfig: if section is None: continue - if section in {"packages", "copr"}: + if section in {"dnf", "copr", "brew", "flatpak", "rpm-ostree", "nix"}: entry = line.split("=", 1)[0].strip() if entry: - target = packages if section == "packages" else copr - target.append(strip_quotes(entry)) + if section == "copr": + copr.append(strip_quotes(entry)) + elif section == "brew": + brew_packages.append(strip_quotes(entry)) + elif section == "flatpak": + flatpak_refs.append(strip_quotes(entry)) + elif section == "rpm-ostree": + rpm_ostree_packages.append(strip_quotes(entry)) + elif section == "nix": + nix_packages.append(strip_quotes(entry)) + else: + dnf_packages.append(strip_quotes(entry)) continue if section == "dotfiles": @@ -154,8 +181,8 @@ def parse_legacy_config(config_path: Path) -> ForgeConfig: if section == "options": options[key] = value - elif section == "forge": - forge_fields[key] = value + elif section == "curator": + curator_fields[key] = value merged_options: dict[str, Any] = {"backup": True, "backup_dir": "backup"} merged_options.update(options) @@ -163,123 +190,37 @@ def parse_legacy_config(config_path: Path) -> ForgeConfig: if not merged_options.get("backup_dir"): merged_options["backup_dir"] = "backup" - return ForgeConfig( + return CuratorConfig( copr=copr, - packages=packages, + packages=dnf_packages, + brew_packages=brew_packages, + flatpak_refs=flatpak_refs, + rpm_ostree_packages=rpm_ostree_packages, + nix_packages=nix_packages, dotfiles=dotfiles, options=merged_options, - forge_fields=forge_fields, + curator_fields=curator_fields, document=None, - legacy=True, ) -def empty_config() -> ForgeConfig: - return ForgeConfig( +def empty_config() -> CuratorConfig: + return CuratorConfig( copr=[], packages=[], + brew_packages=[], + flatpak_refs=[], + rpm_ostree_packages=[], + nix_packages=[], dotfiles={}, options={"backup": True, "backup_dir": "backup"}, - forge_fields={}, + curator_fields={}, document=None, - legacy=False, ) -def parse_document(text: str) -> tomlkit.TOMLDocument | None: - try: - return tomlkit.parse(text) - except TOMLKitError: - sanitized = re.sub(r"=\s*null", '= ""', text) - if sanitized != text: - try: - return tomlkit.parse(sanitized) - except TOMLKitError: - pass - return None - -def _coerce_list(raw: Any) -> list[str]: - if raw is None: - return [] - if isinstance(raw, Array): - raw = list(raw) - if isinstance(raw, list): - return [str(item) for item in raw if str(item).strip()] - return [] - - -def _parse_table_list(raw: Any, list_key: str) -> list[str]: - if isinstance(raw, list): - return _coerce_list(raw) - if isinstance(raw, (dict, Table)): - items: list[str] = [] - if list_key in raw: - items.extend(_coerce_list(raw[list_key])) - for key, value in raw.items(): - if key == list_key: - continue - if isinstance(value, bool) and value: - items.append(str(key)) - return items - return [] - - -def parse_config(config_path: Path) -> ForgeConfig: - try: - raw_text = config_path.read_text() - document = parse_document(raw_text) - except FileNotFoundError: - document = None - - if document is None: - log_warning("Configuration is not valid TOML; using legacy parser.") - return parse_legacy_config(config_path) - - copr_raw = document.get("copr", []) - packages_raw = document.get("packages", []) - dotfiles_raw = document.get("dotfiles", {}) - options_raw = document.get("options", {}) - forge_raw = document.get("forge", {}) - - if not copr_raw and isinstance(forge_raw, (dict, Table)) and "copr" in forge_raw: - copr_raw = forge_raw.get("copr", copr_raw) - if not packages_raw and isinstance(forge_raw, (dict, Table)) and "packages" in forge_raw: - packages_raw = forge_raw.get("packages", packages_raw) - - copr = _parse_table_list(copr_raw, "repos") - packages = _parse_table_list(packages_raw, "names") - - dotfiles: dict[str, str] = {} - if isinstance(dotfiles_raw, (dict, Table)): - for target, source in dotfiles_raw.items(): - if source is None: - continue - dotfiles[str(target)] = str(source) - - options: dict[str, Any] = {} - if isinstance(options_raw, (dict, Table)): - options.update({str(key): value for key, value in options_raw.items()}) - - merged_options: dict[str, Any] = {"backup": True, "backup_dir": "backup"} - merged_options.update(options) - - forge_fields: dict[str, Any] = {} - if isinstance(forge_raw, (dict, Table)): - forge_fields.update({str(key): value for key, value in forge_raw.items()}) - - return ForgeConfig( - copr=copr, - packages=packages, - dotfiles=dotfiles, - options=merged_options, - forge_fields=forge_fields, - document=document, - legacy=False, - ) - - -def load_config(config_path: Path) -> ForgeConfig: +def load_config(config_path: Path) -> CuratorConfig: if not config_path.exists(): return empty_config() try: @@ -297,26 +238,26 @@ def diff_items(current: list[str], previous: list[str]) -> tuple[list[str], list def get_paths() -> tuple[Path, Path, Path]: - forge_dir = Path(os.environ.get("FORGE_DIR", Path.home() / ".config" / "forge")).expanduser() - forge_toml = forge_dir / "forge.toml" - previous_toml = forge_dir / "forge.toml.prev" - return forge_dir, forge_toml, previous_toml + curator_dir = Path(os.environ.get("CURATOR_DIR", Path.home() / ".config" / "curator")).expanduser() + curator_toml = curator_dir / "inventory.toml" + previous_toml = curator_dir / "inventory.toml.prev" + return curator_dir, curator_toml, previous_toml def init_command() -> None: - forge_dir, forge_toml, _ = get_paths() - log_info("Initializing forge...") - forge_dir.mkdir(parents=True, exist_ok=True) + curator_dir, curator_toml, _ = get_paths() + log_info("Initializing curator...") + curator_dir.mkdir(parents=True, exist_ok=True) - if forge_toml.exists(): - log_warning(f"Configuration already exists at {forge_toml}") + if curator_toml.exists(): + log_warning(f"Configuration already exists at {curator_toml}") else: - forge_toml.write_text(DEFAULT_CONFIG) - log_success(f"Created forge.toml: {forge_toml}") + curator_toml.write_text(DEFAULT_CONFIG) + log_success(f"Created inventory.toml: {curator_toml}") log_success("Initialization complete!") - log_info(f"Edit {forge_toml} to configure packages and dotfiles") - log_info("Run 'forge switch' to apply your configuration") + log_info(f"Edit {curator_toml} to configure packages and dotfiles") + log_info("Run 'curator switch' to apply your configuration") def run_command(command: list[str]) -> bool: @@ -399,6 +340,74 @@ def install_packages(packages: list[str]) -> None: log_error(f"Failed to install {package}") +def install_brew_packages(packages: list[str]) -> None: + log_info("Installing brew packages...") + if not packages: + log_info("No brew packages to install") + return + + for package in packages: + log_info(f"Installing brew package: {package}") + if run_command(["brew", "install", package]): + log_success(f"Installed {package}") + else: + log_error(f"Failed to install {package}") + + +def install_flatpaks(refs: list[str]) -> None: + log_info("Installing flatpak refs...") + if not refs: + log_info("No flatpaks to install") + return + + for ref in refs: + log_info(f"Installing flatpak: {ref}") + if run_command(["flatpak", "install", "-y", ref]): + log_success(f"Installed {ref}") + else: + log_error(f"Failed to install {ref}") + + +def install_rpm_ostree_packages(packages: list[str]) -> None: + log_info("Installing rpm-ostree packages...") + if not packages: + log_info("No rpm-ostree packages to install") + return + + for package in packages: + log_info(f"Installing rpm-ostree package: {package}") + if run_command(["rpm-ostree", "install", "-y", package]): + log_success(f"Installed {package}") + else: + log_error(f"Failed to install {package}") + + +def nix_available() -> bool: + return shutil.which("nix") is not None + + +def normalize_nix_ref(ref: str) -> str: + return ref if "#" in ref else f"nixpkgs#{ref}" + + +def install_nix_packages(packages: list[str]) -> None: + log_info("Installing nix packages...") + if not packages: + log_info("No nix packages to install") + return + if not nix_available(): + log_warning("nix not found on PATH; skipping nix package installs") + return + + for package in packages: + ref = normalize_nix_ref(package) + log_info(f"Installing nix package: {ref}") + if run_command(["nix", "profile", "install", ref]): + log_success(f"Installed {ref}") + else: + log_error(f"Failed to install {ref}") + + def remove_packages(packages: list[str]) -> None: log_info("Removing packages...") if not packages: @@ -413,6 +422,66 @@ def remove_packages(packages: list[str]) -> None: log_error(f"Failed to remove {package}") +def remove_brew_packages(packages: list[str]) -> None: + log_info("Removing brew packages...") + if not packages: + log_info("No brew packages to remove") + return + + for package in packages: + log_info(f"Removing brew package: {package}") + if run_command(["brew", "uninstall", package]): + log_success(f"Removed {package}") + else: + log_error(f"Failed to remove {package}") + + +def remove_flatpaks(refs: list[str]) -> None: + log_info("Removing flatpaks...") + if not refs: + log_info("No flatpaks to remove") + return + + for ref in refs: + log_info(f"Removing flatpak: {ref}") + if run_command(["flatpak", "uninstall", "-y", ref]): + log_success(f"Removed {ref}") + else: + log_error(f"Failed to remove {ref}") + + +def remove_rpm_ostree_packages(packages: list[str]) -> None: + log_info("Removing rpm-ostree packages...") + if not packages: + log_info("No rpm-ostree packages to remove") + return + + for package in packages: + log_info(f"Removing rpm-ostree package: {package}") + if run_command(["rpm-ostree", "uninstall", "-y", package]): + log_success(f"Removed {package}") + else: + log_error(f"Failed to remove {package}") + + +def remove_nix_packages(packages: list[str]) -> None: + log_info("Removing nix packages...") + if not packages: + log_info("No nix packages to remove") + return + if not nix_available(): + log_warning("nix not found on PATH; skipping nix package removals") + return + + for package in packages: + ref = normalize_nix_ref(package) + log_info(f"Removing nix package: {ref}") + if run_command(["nix", "profile", "remove", ref]): + log_success(f"Removed {ref}") + else: + log_error(f"Failed to remove {ref}") + + def backup_target(target_path: Path, backup_dir: Path) -> None: timestamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S") backup_dir.mkdir(parents=True, exist_ok=True) @@ -433,7 +502,7 @@ def remove_existing(target_path: Path) -> None: shutil.rmtree(target_path) -def deploy_dotfiles(config: ForgeConfig, forge_dir: Path) -> None: +def deploy_dotfiles(config: CuratorConfig, curator_dir: Path) -> None: log_info("Deploying dotfiles...") if not config.dotfiles: log_info("No dotfiles to deploy") @@ -441,10 +510,10 @@ def deploy_dotfiles(config: ForgeConfig, forge_dir: Path) -> None: backup_enabled = bool(config.options.get("backup", True)) backup_dir_name = str(config.options.get("backup_dir", "backup")) or "backup" - backup_dir = forge_dir / backup_dir_name + backup_dir = curator_dir / backup_dir_name for target, source in config.dotfiles.items(): - source_path = forge_dir / (source if source.startswith("dotfiles/") else f"dotfiles/{source}") + source_path = curator_dir / (source if source.startswith("dotfiles/") else f"dotfiles/{source}") target_path = Path.home() / target if not source_path.exists(): @@ -484,33 +553,29 @@ def update_last_switch(config_path: Path, options: dict[str, Any]) -> None: try: backup_target(config_path, backup_dir) except OSError as exc: - log_warning(f"Failed to back up forge.toml: {exc}") + log_warning(f"Failed to back up inventory.toml: {exc}") - timestamp = dt.datetime.now().astimezone() + timestamp = dt.datetime.now().astimezone().isoformat(timespec="seconds") raw_text = config_path.read_text() - document = parse_document(raw_text) - if document is None: - # Fall back to legacy in-place update without rewriting structure. - ts_str = timestamp.isoformat(timespec="seconds") - lines = raw_text.splitlines() - updated = False - for idx, line in enumerate(lines): - if re.match(r"\s*last_switch\s*=", line): - lines[idx] = f'last_switch = "{ts_str}"' - updated = True - break - if not updated: - lines.append(f'last_switch = "{ts_str}"') - config_path.write_text("\n".join(lines) + "\n") - return + lines = raw_text.splitlines() - forge_table = document.get("forge") - if not isinstance(forge_table, Table): - forge_table = tomlkit.table() - document["forge"] = forge_table + updated = False + insert_idx: Optional[int] = None + for idx, line in enumerate(lines): + if re.match(r"\s*\[curator\]\s*$", line): + insert_idx = idx + 1 + if re.match(r"\s*last_switch\s*=", line): + lines[idx] = f'last_switch = "{timestamp}"' + updated = True + break - forge_table["last_switch"] = timestamp - config_path.write_text(tomlkit.dumps(document)) + if not updated: + if insert_idx is None: + lines.append("[curator]") + insert_idx = len(lines) + lines.insert(insert_idx, f'last_switch = "{timestamp}"') + + config_path.write_text("\n".join(lines) + "\n") def save_previous_config(current_path: Path, previous_path: Path) -> None: @@ -521,42 +586,84 @@ def save_previous_config(current_path: Path, previous_path: Path) -> None: def switch_command() -> None: - forge_dir, forge_toml, previous_toml = get_paths() - if not forge_toml.exists(): - log_error("forge.toml not found. Run 'forge init' first.") + _switch(rollback=False) + + +def switch_command_with_args(rollback: bool = False) -> None: + _switch(rollback=rollback) + + +def _switch(rollback: bool) -> None: + curator_dir, curator_toml, previous_toml = get_paths() + if not curator_toml.exists() and not rollback: + log_error("inventory.toml not found. Run 'curator init' first.") sys.exit(1) - current_config = parse_config(forge_toml) - previous_config = load_config(previous_toml) + if rollback: + if not previous_toml.exists(): + log_error("No previous inventory.toml to roll back to.") + sys.exit(1) + # Backup current config if present + current_before = load_config(curator_toml) if curator_toml.exists() else empty_config() + current_options = current_before.options or {"backup": True, "backup_dir": "backup"} + backup_dir_name = str(current_options.get("backup_dir", "backup") or "backup") + backup_dir = curator_toml.parent / backup_dir_name + if curator_toml.exists(): + try: + backup_target(curator_toml, backup_dir) + except OSError as exc: + log_warning(f"Failed to back up current inventory.toml before rollback: {exc}") + + shutil.copy2(previous_toml, curator_toml) + log_info(f"Restored inventory.toml from {previous_toml}") + current_config = parse_config(curator_toml) + previous_config = current_before + else: + current_config = parse_config(curator_toml) + previous_config = load_config(previous_toml) copr_added, copr_removed = diff_items(current_config.copr, previous_config.copr) packages_added, packages_removed = diff_items(current_config.packages, previous_config.packages) + brew_added, brew_removed = diff_items(current_config.brew_packages, previous_config.brew_packages) + flatpak_added, flatpak_removed = diff_items(current_config.flatpak_refs, previous_config.flatpak_refs) + rpm_ostree_added, rpm_ostree_removed = diff_items( + current_config.rpm_ostree_packages, previous_config.rpm_ostree_packages + ) + nix_added, nix_removed = diff_items(current_config.nix_packages, previous_config.nix_packages) enable_copr_repos(copr_added if copr_added else current_config.copr) disable_copr_repos(set(current_config.copr), set(copr_removed)) install_packages(packages_added if packages_added else current_config.packages) + install_brew_packages(brew_added if brew_added else current_config.brew_packages) + install_flatpaks(flatpak_added if flatpak_added else current_config.flatpak_refs) + install_rpm_ostree_packages(rpm_ostree_added if rpm_ostree_added else current_config.rpm_ostree_packages) + install_nix_packages(nix_added if nix_added else current_config.nix_packages) remove_packages(packages_removed) - deploy_dotfiles(current_config, forge_dir) - update_last_switch(forge_toml, current_config.options) - save_previous_config(forge_toml, previous_toml) + remove_brew_packages(brew_removed) + remove_flatpaks(flatpak_removed) + remove_rpm_ostree_packages(rpm_ostree_removed) + remove_nix_packages(nix_removed) + deploy_dotfiles(current_config, curator_dir) + update_last_switch(curator_toml, current_config.options) + save_previous_config(curator_toml, previous_toml) log_success("Switch completed successfully!") def status_command() -> None: - forge_dir, forge_toml, _ = get_paths() - log_info("Forge Status") - print(f" Config directory: {forge_dir}") - print(f" Config file: {forge_toml}") + curator_dir, curator_toml, _ = get_paths() + log_info("curator Status") + print(f" Config directory: {curator_dir}") + print(f" Config file: {curator_toml}") - if not forge_toml.exists(): + if not curator_toml.exists(): print(" No configuration file found") return - config = parse_config(forge_toml) + config = parse_config(curator_toml) print() log_info("Configuration:") - last_switch = config.forge_fields.get("last_switch") + last_switch = config.curator_fields.get("last_switch") if last_switch and str(last_switch).lower() != "null": print(f" Last switch: {last_switch}") else: @@ -572,13 +679,49 @@ def status_command() -> None: print(" No COPR repositories configured") print() - log_info("Packages:") + log_info("DNF Packages:") if config.packages: for package in config.packages: print(f" {package}") - print(f" Total: {len(config.packages)} packages") + print(f" Total: {len(config.packages)} dnf packages") else: - print(" No packages configured") + print(" No dnf packages configured") + + print() + log_info("Brew Packages:") + if config.brew_packages: + for package in config.brew_packages: + print(f" {package}") + print(f" Total: {len(config.brew_packages)} brew packages") + else: + print(" No brew packages configured") + + print() + log_info("Flatpaks:") + if config.flatpak_refs: + for ref in config.flatpak_refs: + print(f" {ref}") + print(f" Total: {len(config.flatpak_refs)} flatpaks") + else: + print(" No flatpaks configured") + + print() + log_info("rpm-ostree Packages:") + if config.rpm_ostree_packages: + for package in config.rpm_ostree_packages: + print(f" {package}") + print(f" Total: {len(config.rpm_ostree_packages)} rpm-ostree packages") + else: + print(" No rpm-ostree packages configured") + + print() + log_info("Nix Packages:") + if config.nix_packages: + for package in config.nix_packages: + print(f" {package}") + print(f" Total: {len(config.nix_packages)} nix packages") + else: + print(" No nix packages configured") print() log_info("Dotfiles:") @@ -592,12 +735,17 @@ def status_command() -> None: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - prog="forge", - description="Forge - A home-manager like script for Fedora", + prog="curator", + description="curator - A home-manager like script for Fedora", ) subparsers = parser.add_subparsers(dest="command", required=True) subparsers.add_parser("init", help="Initialize configuration structure") - subparsers.add_parser("switch", help="Apply configuration") + switch_parser = subparsers.add_parser("switch", help="Apply configuration") + switch_parser.add_argument( + "--rollback", + action="store_true", + help="Restore the previous inventory.toml snapshot and apply it", + ) subparsers.add_parser("status", help="Show current configuration status") subparsers.add_parser("help", help="Show help message") return parser @@ -610,7 +758,7 @@ def main(argv: list[str] | None = None) -> None: if args.command == "init": init_command() elif args.command == "switch": - switch_command() + switch_command_with_args(getattr(args, "rollback", False)) elif args.command == "status": status_command() elif args.command == "help": diff --git a/src/forge/__init__.py b/src/forge/__init__.py deleted file mode 100644 index a762498..0000000 --- a/src/forge/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Forge package diff --git a/src/forge/__pycache__/__init__.cpython-313.pyc b/src/forge/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index b85de530786ed033617868e5735239a554330592..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 158 zcmey&%ge>Uz`#&C%{xC!t0Qo;AlmGw# diff --git a/src/forge/__pycache__/cli.cpython-313.pyc b/src/forge/__pycache__/cli.cpython-313.pyc deleted file mode 100644 index 047e443e20e56a7752e5b849112320ccad2e34b4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27871 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

_K@D;j1hPpxA?oT}1Fe61EUEmq7+Wn8@T6 z7KI`k1_p*?NaR6Cc81N@G&`B=i3~kWwjyUxc({THa}WUvty|m<1(oq>`9-;jB_>6n z04ru=U|@g*Rgo0~1H&zTa2`m>OiPQ;EJ@8RF7jYtU;u?)@l^%}hK9-abgxQ-9S2QX zVAp{HoSA`v;j`G}b9(KQbF~y$LYabiCkrwv3FWbbvIMh%;x3pSMCP$he#EOmxEFQi0#gp!o|Q4%n{5PEi(C{vE1bS#)6!nR0fh{m~0>=!lJ|w zEj0O~u`Hv+feg8G|{aL6Y*5^Rzixg1PV+#fNMZ9~Psyphn#@ z;ADa@)!AV-1#|1Mg3CRQXi)M8>yAda)rL7a(S$_j0NeTjKM6r+zbqP zoYA0k4%QIL8KepdvdNxCg4}S;L28o&nMFl;;8yX1@=fKXclXLXd}&H9k3cxm>N^K8e`33y_Cd~)RN5H zRIXZ0uISoZT$#n0dBr7(dC94_m{LkKS&R5V8BqX4xPS;j5CO^sRl2Da1*yp;sVNGH z3J|v{T;OxxRlcF5zp}IPI=}Koe&q$e*ZGYu@Eh$apDbkV#VZR6AyH^SJvq}{ zww@hS2WMxN++qb+g10z}QWI0+OHwOJiu^!@0|(59MZO@Xg9_9laQO``l|XS@1WGPN z@Iw3+OHO8S$u0KGlGGwl;0rg5%FmEe4n`JPO>VT%kx5}-U;xEM z@gYWV#V#n>;B`YtW=73LA??YhEG+7ua0&M7cInRGzAmAAQ9}2!g#KkNg9hgtVv;k| zC*~}$y(ngQfyI7{(RS;N))!dpd#o;q8D8eLzrbRDjobb@3ktdjl81{S@vgGiUl22d z%Rm@cK*pLw*walXmtzO(!?pZf(C_a5U5eD0UILCmY%?$=pR&_$3uTnve4f0f1k z0v}ZEWfu1f7+Np0xPxmtO{OAHJ>;h;c#9*cG&83pGq1QvZSp-UV@Q1$1Bzd83S=)x zEXl~pOez9ZH${;kac>X-ZexI2X+@yA3S4Owf#P;01K0vc-Ns>)o1apelWJF#&A`9_ zsw9d*sqX_bBO~K|28I@f%M7A-86+Mv@I7S^{lF&0sI None: - config_path = tmp_path / "forge.toml" +def test_parse_config_line_format(tmp_path: Path) -> None: + config_path = tmp_path / "inventory.toml" config_path.write_text( """ -copr = ["copr.fedorainfracloud.org/user/repo"] -packages = ["git", "vim"] - -[forge] +[curator] version = "1.0" last_switch = "" +[copr] +copr.fedorainfracloud.org/user/repo + + [dnf] + git + vim + + [brew] + wget + + [flatpak] + org.mozilla.firefox + + [rpm-ostree] + podman + + [nix] + nixpkgs#git + [dotfiles] ".bashrc" = ".bashrc" @@ -30,18 +45,33 @@ backup_dir = "backup" assert config.copr == ["copr.fedorainfracloud.org/user/repo"] assert config.packages == ["git", "vim"] + assert config.brew_packages == ["wget"] + assert config.flatpak_refs == ["org.mozilla.firefox"] + assert config.rpm_ostree_packages == ["podman"] + assert config.nix_packages == ["nixpkgs#git"] assert config.dotfiles == {".bashrc": ".bashrc"} assert config.options["backup"] is True - assert config.legacy is False def test_parse_config_legacy_format(tmp_path: Path) -> None: - config_path = tmp_path / "forge.toml" + config_path = tmp_path / "inventory.toml" config_path.write_text( """ -[packages] -git -vim + [dnf] + git + vim + +[brew] +wget + +[flatpak] +org.mozilla.firefox + +[rpm-ostree] +podman + +[nix] +nixpkgs#git [copr] copr.fedorainfracloud.org/user/repo @@ -54,9 +84,12 @@ copr.fedorainfracloud.org/user/repo config = cli.parse_config(config_path) assert config.packages == ["git", "vim"] + assert config.brew_packages == ["wget"] + assert config.flatpak_refs == ["org.mozilla.firefox"] + assert config.rpm_ostree_packages == ["podman"] + assert config.nix_packages == ["nixpkgs#git"] assert config.copr == ["copr.fedorainfracloud.org/user/repo"] assert config.dotfiles == {".bashrc": ".bashrc"} - assert config.legacy is True def test_diff_items() -> None: @@ -67,10 +100,10 @@ def test_diff_items() -> None: def test_update_last_switch_creates_backup(tmp_path: Path) -> None: config_dir = tmp_path - config_path = config_dir / "forge.toml" + config_path = config_dir / "inventory.toml" config_path.write_text( """ -[forge] +[curator] version = "1.0" last_switch = "" """.strip() @@ -79,10 +112,13 @@ last_switch = "" options = {"backup": True, "backup_dir": "backup"} cli.update_last_switch(config_path, options) - doc = tomlkit.parse(config_path.read_text()) - assert isinstance(doc["forge"]["last_switch"], dt.datetime) + lines = [line.strip() for line in config_path.read_text().splitlines() if line.strip()] + last_switch_line = [line for line in lines if line.startswith("last_switch")] + assert last_switch_line, "last_switch should be written" + ts_str = last_switch_line[0].split("=", 1)[1].strip().strip('"') + dt.datetime.fromisoformat(ts_str) backup_dir = config_dir / "backup" backups = list(backup_dir.iterdir()) - assert backups, "expected a backup of forge.toml to be created" - assert any(path.name.startswith("forge.toml.") for path in backups) + assert backups, "expected a backup of inventory.toml to be created" + assert any(path.name.startswith("inventory.toml.") for path in backups) diff --git a/tmpcfg.toml b/tmpcfg.toml index 180e6a8..3f04387 100644 --- a/tmpcfg.toml +++ b/tmpcfg.toml @@ -1,5 +1,5 @@ -[forge] +[curator] version = "1.0" last_switch = "" diff --git a/uv.lock b/uv.lock index 2121675..80399ab 100644 --- a/uv.lock +++ b/uv.lock @@ -12,12 +12,9 @@ wheels = [ ] [[package]] -name = "forge" +name = "curator" version = "0.1.0" source = { editable = "." } -dependencies = [ - { name = "tomlkit" }, -] [package.dev-dependencies] dev = [ @@ -25,7 +22,6 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "tomlkit", specifier = ">=0.12" }] [package.metadata.requires-dev] dev = [{ name = "pytest", specifier = ">=7.4" }] @@ -81,12 +77,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049dd wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] - -[[package]] -name = "tomlkit" -version = "0.13.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, -]