added package removal, brew, flatpak, ostree, nix xupport

This commit is contained in:
randogoth 2025-12-24 08:54:24 +02:00
parent da6d24c74f
commit df1f387795
16 changed files with 596 additions and 362 deletions

317
README.md
View file

@ -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

View file

@ -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__":

View file

@ -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"]

1
src/curator/__init__.py Normal file
View file

@ -0,0 +1 @@
# curator package

Binary file not shown.

Binary file not shown.

View file

@ -12,39 +12,49 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
import tomlkit
from tomlkit.exceptions import TOMLKitError
from tomlkit.items import Array, Table
BLUE = "\033[0;34m"
GREEN = "\033[0;32m"
YELLOW = "\033[1;33m"
RED = "\033[0;31m"
RESET = "\033[0m"
DEFAULT_CONFIG = """# Forge Configuration File
DEFAULT_CONFIG = """# 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",
]
[forge]
[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
[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)
# 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"
@ -59,14 +69,17 @@ backup_dir = "backup"
@dataclass
class ForgeConfig:
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]
dotfiles: dict[str, str]
options: dict[str, Any]
forge_fields: dict[str, Any]
document: Optional[tomlkit.TOMLDocument]
legacy: bool = False
curator_fields: dict[str, Any]
document: Optional[Any] = None
def log(prefix: str, color: str, message: str) -> 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":

View file

@ -1 +0,0 @@
# Forge package

View file

@ -1,22 +1,37 @@
import datetime as dt
from pathlib import Path
import tomlkit
import datetime as dt
from forge import cli
from curator import cli
def test_parse_config_arrays(tmp_path: Path) -> 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)

View file

@ -1,5 +1,5 @@
[forge]
[curator]
version = "1.0"
last_switch = ""

15
uv.lock generated
View file

@ -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" },
]