finalized for publication

This commit is contained in:
randogoth 2025-12-24 12:56:51 +02:00
parent df1f387795
commit 7d51de1052
4 changed files with 626 additions and 392 deletions

75
.gitignore vendored Normal file
View file

@ -0,0 +1,75 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Jupyter
.ipynb_checkpoints
# pyenv
.python-version
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre
.pyre/
# UV
uv.lock
# Backup/config artifacts
*.bak
inventory.toml.last
inventory.toml.rollback

281
README.md
View file

@ -1,62 +1,49 @@
# curator - A Home-Manager Like Tool for Fedora # curator - A Home-Manager Like Tool for Universal Blue builds
A lightweight Python CLI (managed with `uv`) for Fedora that provides COPR repository management, dotfile management and package installation functionality through a centralized TOML configuration. A lightweight CLI that provides repository and dotfile management and package installation functionality through a centralized TOML configuration. Ported and expanded from [forge](https://github.com/ijadux2/forge) and inspired by [home-manager](https://github.com/nix-community/home-manager/).
## Overview ## Overview
curator 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) - Managing COPR repositories (enable/disable automatically)
- Installing and managing system packages via dnf - Installing and managing system packages via `dnf` or `rpm-ostree`
- Installing and managing userspace packages via `brew`, `flatpak`, or `nix`
- Managing dotfiles through symlinks to actual files - Managing dotfiles through symlinks to actual files
- Centralized configuration via a single `inventory.toml` file - Centralized configuration via a single `inventory.toml` file
- User-level configuration similar to home-manager/nixos - Declarative user-level configuration similar to `home-manager`/`nixos`
- Automatic backup of existing files before replacement - Automatic backup of existing files before replacement
## Installation ## Installation
1. Clone or download this repository. Install the CLI directly from GitHub with [uv](https://github.com/astral-sh/uv):
2. Install dependencies with `uv` (none beyond the standard library, but this sets up the venv):
```bash ```bash
uv sync uv tool install --from git+https://codeberg.org/randogoth/curator/ curator
```
3. Run with `uv`:
```bash
uv run curator --help
```
4. Or use the local shim directly:
```bash
./curator --help
```
5. Optionally install globally via `uv`:
```bash
uv tool install .
``` ```
## Quick Start ## Quick Start
```bash ```bash
# Initialize the configuration structure # Initialize the configuration structure
uv run curator init curator init
# Edit the configuration file to add packages and dotfiles # Edit the configuration file to add packages and dotfiles
nano ~/.config/curator/inventory.toml nano ~/.config/curator/inventory.toml
# Apply configuration # Apply configuration
uv run curator switch curator switch
# Check status # Check status
uv run curator status curator status
``` ```
You can swap `uv run curator ...` for `./curator ...` if you prefer the local shim.
## Commands ## Commands
### `init` ### `init`
Initialize the configuration structure and create `inventory.toml`. Initialize the configuration structure and create `inventory.toml`.
```bash ```bash
uv run curator init curator init
# or ./curator init # or ./curator init
``` ```
@ -68,10 +55,10 @@ Creates:
Apply the current configuration (enable COPR, install packages, deploy dotfiles). Use `--rollback` to restore the previous `inventory.toml` snapshot before applying. Apply the current configuration (enable COPR, install packages, deploy dotfiles). Use `--rollback` to restore the previous `inventory.toml` snapshot before applying.
```bash ```bash
uv run curator switch curator switch
# or ./curator switch # or ./curator switch
# rollback to the previous inventory.toml and apply it # rollback to the previous inventory.toml and apply it
uv run curator switch --rollback curator switch --rollback
``` ```
This command: This command:
@ -86,7 +73,7 @@ This command:
Show current configuration status and information. Show current configuration status and information.
```bash ```bash
uv run curator status curator status
# or ./curator status # or ./curator status
``` ```
@ -99,10 +86,37 @@ Displays:
Show help message with all available commands. Show help message with all available commands.
```bash ```bash
uv run curator help curator help
# or ./curator help # or ./curator help
``` ```
### `from`
Import currently installed packages/repos for a manager into `inventory.toml` so curator can manage them.
```bash
curator from dnf
curator from brew
curator from flatpak
curator from rpm-ostree
curator from nix
curator from copr
```
### `add` / `remove`
Add or remove entries directly in `inventory.toml` using `section:value` pairs. Supports `dnf`, `brew`, `flatpak`, `rpm-ostree`, `nix`, and `copr`.
```bash
curator add dnf:uv nix:micro
curator remove dnf:curl flatpak:org.mozilla.firefox
```
### `reset`
Remove the rollback snapshot (`inventory.toml.rollback`).
```bash
curator reset
```
## Configuration ## Configuration
### inventory.toml ### inventory.toml
@ -197,6 +211,12 @@ List of packages to install via Homebrew. **One package per line**—presence me
#### `[flatpak]` #### `[flatpak]`
List of Flatpak refs to install. **One ref per line**—presence means install. List of Flatpak refs to install. **One ref per line**—presence means install.
#### `[rpm-ostree]`
List of rpm-ostree layered packages. **One package per line**—presence means install.
#### `[nix]`
List of nix packages. **One package per line**—presence means install. You can specify plain names (e.g., `neovim`) or `nixpkgs#name`; curator will prefix `nixpkgs#` for installs and use the base name for removals.
#### `[dotfiles]` #### `[dotfiles]`
Dotfile mappings using symlinks: Dotfile mappings using symlinks:
- **Key**: Target path where symlink should be created (relative to home directory) - **Key**: Target path where symlink should be created (relative to home directory)
@ -222,209 +242,6 @@ Additional configuration options:
└── vimrc.20231121_143022.bak └── vimrc.20231121_143022.bak
``` ```
## How It Works
### COPR Repository Management
COPR repositories are managed through the `[copr]` section in `inventory.toml`:
- **Add COPR repo**: Add the repository name on its own line
- **Remove COPR repo**: Remove the entry
- **Automatic cleanup**: curator automatically disables COPR repos that are removed from configuration
- **No flags needed**: Just presence/absence of the repository name matters
- curator stores the previous configuration at `~/.config/curator/inventory.toml.prev` and compares it to the current file during `switch`; newly added repos are enabled, removed repos are disabled.
### Package Management
Packages are managed through the `[dnf]` section in `inventory.toml`:
- **Add package**: Add the package name on its own line
- **Remove package**: Remove the entry
- **No flags needed**: Just presence/absence of the package name matters
- The previous configuration snapshot (`inventory.toml.prev`) is used to detect additions/removals on each `switch`; added packages are installed and removed packages are uninstalled.
### Brew Management
Homebrew packages are managed through the `[brew]` section:
- **Add package**: Add the package name on its own line
- **Remove package**: Remove the entry
- Additions/removals are detected against `inventory.toml.prev` on `switch`; removed packages are uninstalled.
### Flatpak Management
Flatpaks are managed through the `[flatpak]` section:
- **Add ref**: Add the ref on its own line
- **Remove ref**: Remove the entry
- Additions/removals are detected against `inventory.toml.prev` on `switch`; removed refs are uninstalled.
### rpm-ostree Management
rpm-ostree packages are managed through the `[rpm-ostree]` section:
- **Add package**: Add the package name on its own line
- **Remove package**: Remove the entry
- Additions/removals are detected against `inventory.toml.prev` on `switch`; removed packages are uninstalled.
### Nix Management
Nix packages are managed through the `[nix]` section (if `nix` is available on the system):
- **Add package**: Add the package name (e.g., `nixpkgs#git` or just `git`) on its own line
- **Remove package**: Remove the entry
- Additions/removals are detected against `inventory.toml.prev` on `switch`; installs/removals use `nix profile` and will prefix `nixpkgs#` if missing.
### Dotfile Management
curator uses symlinks to manage dotfiles:
1. Your actual dotfiles are stored in `~/.config/curator/dotfiles/`
2. Symlinks are created from your home directory to these files using relative paths
3. This allows you to version control your dotfiles in one place
4. Changes to the source files are immediately reflected in your home directory
5. Source paths are automatically prefixed with "dotfiles/" for convenience
### Backup System
Before creating symlinks, curator:
1. Checks if the target file exists and is not a symlink
2. Creates a timestamped backup in the backup directory
3. Removes the original file
4. Creates the symlink to your managed dotfile
`inventory.toml` is also backed up before the last switch timestamp is updated.
The previously applied configuration is stored separately as `~/.config/curator/inventory.toml.prev` to compute diffs for COPR and package changes.
## Examples
### Basic Setup
```bash
# Initialize curator
uv run curator init
# Edit inventory.toml to add COPR repos and packages
nano ~/.config/curator/inventory.toml
# Add to the sections:
# [copr]
# copr.fedorainfracloud.org/username/cool-repo
# [dnf]
# git
# vim
# curl
# [brew]
# wget
# [flatpak]
# org.mozilla.firefox
# [rpm-ostree]
# podman
# [nix]
# nixpkgs#git (or just "git")
# Create your dotfiles directory and add files
mkdir -p ~/.config/curator/dotfiles
echo "export EDITOR=vim" > ~/.config/curator/dotfiles/.bashrc
# Add to [dotfiles] section:
".bashrc" = ".bashrc"
# Apply configuration
uv run curator switch
```
### Managing Application Configurations
```bash
# Add alacritty configuration
mkdir -p ~/.config/curator/dotfiles
cp ~/.config/alacritty/alacritty.yml ~/.config/curator/dotfiles/
# Edit inventory.toml
nano ~/.config/curator/inventory.toml
# Add to [dotfiles] section:
".config/alacritty/alacritty.yml" = "alacritty.yml"
# Apply changes
uv run curator switch
```
### COPR Repository Management Examples
```toml
[copr]
# Development tools COPR
copr.fedorainfracloud.org/development/tools
copr.fedorainfracloud.org/user/neovim-nightly
# To remove a COPR repo, just delete the entry
# curator will automatically disable it during the next switch
```
### Package Management Examples
```toml
[dnf]
# Development tools
git
vim
nodejs
npm
# System utilities
curl
wget
tree
htop
# To remove a package, just delete the entry
[brew]
# Utilities and tools
wget
coreutils
[flatpak]
org.mozilla.firefox
com.spotify.Client
[rpm-ostree]
podman
htop
```
### Version Control Your Configuration
```bash
# Initialize git repository in curator directory
cd ~/.config/curator
git init
git add .
git commit -m "Initial configuration"
# Now you can version control your entire system configuration
git add inventory.toml dotfiles/
git commit -m "Updated vim configuration"
```
## Environment Variables ## Environment Variables
- `CURATOR_DIR`: Override the default configuration directory (default: `~/.config/curator`) - `CURATOR_DIR`: Override the default configuration directory (default: `~/.config/curator`)
## Dependencies
- Python 3.11+
- `uv` for environment and script management
- `dnf` - Fedora package manager
- `dnf-plugins-core` - For COPR repository management
## Migration from Previous Version
If you were using the old version of curator with `git = true` or bare keys under `[packages]`/`[copr]`:
1. Your existing configuration will not be automatically migrated, but the CLI will still read the legacy format.
2. Run `uv run curator init` (or `./curator init`) to create the new `inventory.toml` structure.
3. Convert to section-per-line format:
```toml
# Old formats
[dnf]
git = true
vim = true
# or
[dnf]
git
vim
# New format
[dnf]
git
vim
[copr]
copr.fedorainfracloud.org/username/repository
```
4. Move your existing dotfiles from the old directory to `~/.config/curator/dotfiles/`
## License
This project is open source. Feel free to contribute or report issues.

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import argparse import argparse
import datetime as dt import datetime as dt
import json
import os import os
import re import re
import shutil import shutil
@ -10,7 +11,7 @@ import subprocess
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional, Callable
BLUE = "\033[0;34m" BLUE = "\033[0;34m"
GREEN = "\033[0;32m" GREEN = "\033[0;32m"
@ -117,6 +118,27 @@ def parse_value(value: str) -> Any:
return strip_quotes(value) return strip_quotes(value)
def normalize_nix_ref(ref: str) -> str:
ref = ref.strip()
if not ref:
return ref
return ref if "#" in ref else f"nixpkgs#{ref}"
def nix_base_name(val: str) -> str:
val = val.strip()
if not val:
return ""
base = val.split("#", 1)[1] if "#" in val else val
base = re.sub(r"-\d[^\s]*$", "", base)
return base
def nix_install_ref(val: str) -> str:
base = nix_base_name(val)
return f"nixpkgs#{base}" if base else ""
def parse_config(config_path: Path) -> CuratorConfig: def parse_config(config_path: Path) -> CuratorConfig:
copr: list[str] = [] copr: list[str] = []
dnf_packages: list[str] = [] dnf_packages: list[str] = []
@ -237,15 +259,16 @@ def diff_items(current: list[str], previous: list[str]) -> tuple[list[str], list
return added, removed return added, removed
def get_paths() -> tuple[Path, Path, Path]: def get_paths() -> tuple[Path, Path, Path, Path]:
curator_dir = Path(os.environ.get("CURATOR_DIR", Path.home() / ".config" / "curator")).expanduser() curator_dir = Path(os.environ.get("CURATOR_DIR", Path.home() / ".config" / "curator")).expanduser()
curator_toml = curator_dir / "inventory.toml" curator_toml = curator_dir / "inventory.toml"
previous_toml = curator_dir / "inventory.toml.prev" last_toml = curator_dir / "inventory.toml.last"
return curator_dir, curator_toml, previous_toml rollback_toml = curator_dir / "inventory.toml.rollback"
return curator_dir, curator_toml, last_toml, rollback_toml
def init_command() -> None: def init_command() -> None:
curator_dir, curator_toml, _ = get_paths() curator_dir, curator_toml, _, _ = get_paths()
log_info("Initializing curator...") log_info("Initializing curator...")
curator_dir.mkdir(parents=True, exist_ok=True) curator_dir.mkdir(parents=True, exist_ok=True)
@ -269,6 +292,60 @@ def run_command(command: list[str]) -> bool:
return result.returncode == 0 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: def enable_copr_repos(repos: list[str]) -> None:
log_info("Enabling COPR repositories...") log_info("Enabling COPR repositories...")
if not repos: if not repos:
@ -283,7 +360,7 @@ def enable_copr_repos(repos: list[str]) -> None:
log_error(f"Failed to enable COPR: {copr_repo}") log_error(f"Failed to enable COPR: {copr_repo}")
def list_enabled_copr() -> set[str]: def gather_copr_repos() -> list[str]:
try: try:
result = subprocess.run( result = subprocess.run(
["sudo", "dnf", "copr", "list", "--enabled"], ["sudo", "dnf", "copr", "list", "--enabled"],
@ -293,31 +370,26 @@ def list_enabled_copr() -> set[str]:
) )
except FileNotFoundError: except FileNotFoundError:
log_error("dnf not found while listing enabled COPR repositories") log_error("dnf not found while listing enabled COPR repositories")
return set() return []
if result.returncode != 0: if result.returncode != 0:
log_warning("Unable to list enabled COPR repositories") log_warning("Unable to list enabled COPR repositories")
return set() return []
enabled: set[str] = set() enabled: set[str] = set()
for line in result.stdout.splitlines(): for line in result.stdout.splitlines():
if "copr.fedorainfracloud.org" in line: if "copr.fedorainfracloud.org" in line:
repo = line.split()[0] repo = line.split()[0]
enabled.add(repo) enabled.add(repo)
return enabled return sorted(enabled)
def disable_copr_repos(configured: set[str], explicit_removed: set[str] | None = None) -> None: def disable_copr_repos(to_disable: set[str]) -> None:
log_info("Checking for COPR repositories to disable...")
enabled = list_enabled_copr()
to_disable: set[str] = {repo for repo in enabled if repo not in configured}
if explicit_removed:
to_disable.update(explicit_removed)
if not to_disable: if not to_disable:
log_info("No COPR repositories to disable") log_info("No COPR repositories to disable")
return return
log_info("Disabling COPR repositories...")
for repo in sorted(to_disable): for repo in sorted(to_disable):
log_info(f"Disabling COPR repository: {repo}") log_info(f"Disabling COPR repository: {repo}")
if run_command(["sudo", "dnf", "copr", "disable", "-y", repo]): if run_command(["sudo", "dnf", "copr", "disable", "-y", repo]):
@ -326,160 +398,215 @@ def disable_copr_repos(configured: set[str], explicit_removed: set[str] | None =
log_error(f"Failed to disable COPR: {repo}") log_error(f"Failed to disable COPR: {repo}")
def install_packages(packages: list[str]) -> None: def install_packages(packages: list[str], installed: set[str] | None = None) -> None:
log_info("Installing packages...") install_entries(packages, installed or set(), "dnf packages", lambda p: ["sudo", "dnf", "install", "-y", p])
if not packages:
log_info("No packages to install")
return
for package in packages:
log_info(f"Installing package: {package}")
if run_command(["sudo", "dnf", "install", "-y", package]):
log_success(f"Installed {package}")
else:
log_error(f"Failed to install {package}")
def install_brew_packages(packages: list[str]) -> None: def install_brew_packages(packages: list[str], installed: set[str] | None = None) -> None:
log_info("Installing brew packages...") install_entries(packages, installed or set(), "brew packages", lambda p: ["brew", "install", p])
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: def install_flatpaks(refs: list[str], installed: set[str] | None = None) -> None:
log_info("Installing flatpak refs...") install_entries(refs, installed or set(), "flatpaks", lambda r: ["flatpak", "install", "-y", r])
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: def install_rpm_ostree_packages(packages: list[str], installed: set[str] | None = None) -> None:
log_info("Installing rpm-ostree packages...") install_entries(
if not packages: packages,
log_info("No rpm-ostree packages to install") installed or set(),
return "rpm-ostree packages",
lambda p: ["rpm-ostree", "install", "-y", p],
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: def nix_available() -> bool:
return shutil.which("nix") is not None return shutil.which("nix") is not None
def normalize_nix_ref(ref: str) -> str: def install_nix_packages(packages: list[str], installed: set[str] | None = None) -> None:
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: if not packages:
log_info("No nix packages to install") log_info("No nix packages to install")
return return
if not nix_available(): if not nix_available():
log_warning("nix not found on PATH; skipping nix package installs") log_warning("nix not found on PATH; skipping nix package installs")
return return
normalized = [nix_install_ref(p) for p in packages if nix_install_ref(p)]
for package in packages: install_entries(normalized, installed or set(), "nix packages", lambda r: ["nix", "profile", "install", r])
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: def remove_packages(packages: list[str], installed: set[str] | None = None) -> None:
log_info("Removing packages...") remove_entries(packages, "dnf packages", lambda p: ["sudo", "dnf", "remove", "-y", p], installed)
if not packages:
log_info("No packages to remove")
return
for package in packages:
log_info(f"Removing package: {package}")
if run_command(["sudo", "dnf", "remove", "-y", package]):
log_success(f"Removed {package}")
else:
log_error(f"Failed to remove {package}")
def remove_brew_packages(packages: list[str]) -> None: def remove_brew_packages(packages: list[str], installed: set[str] | None = None) -> None:
log_info("Removing brew packages...") remove_entries(packages, "brew packages", lambda p: ["brew", "uninstall", p], installed)
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: def remove_flatpaks(refs: list[str], installed: set[str] | None = None) -> None:
log_info("Removing flatpaks...") remove_entries(refs, "flatpaks", lambda r: ["flatpak", "uninstall", "-y", r], installed)
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: def remove_rpm_ostree_packages(packages: list[str], installed: set[str] | None = None) -> None:
log_info("Removing rpm-ostree packages...") remove_entries(packages, "rpm-ostree packages", lambda p: ["rpm-ostree", "uninstall", "-y", p], installed)
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: def remove_nix_packages(packages: list[str], installed: set[str] | None = None) -> None:
log_info("Removing nix packages...")
if not packages: if not packages:
log_info("No nix packages to remove") log_info("No nix packages to remove")
return return
if not nix_available(): if not nix_available():
log_warning("nix not found on PATH; skipping nix package removals") log_warning("nix not found on PATH; skipping nix package removals")
return return
base_names = [nix_base_name(p) for p in packages if nix_base_name(p)]
remove_entries(base_names, "nix packages", lambda r: ["nix", "profile", "remove", r], installed)
for package in packages:
ref = normalize_nix_ref(package) def gather_dnf_packages() -> list[str]:
log_info(f"Removing nix package: {ref}") ok, output = run_capture(["rpm", "-qa", "--qf", "%{NAME}\n"])
if run_command(["nix", "profile", "remove", ref]): if not ok:
log_success(f"Removed {ref}") 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: else:
log_error(f"Failed to remove {ref}") log_warning("Failed to list rpm-ostree overrides")
if not pkgs and not ok:
log_warning("Failed to list rpm-ostree packages")
return sorted(pkgs)
def gather_nix_packages() -> list[str]:
if not nix_available():
log_warning("nix not found on PATH; skipping nix package import")
return []
collected: set[str] = set()
def collect_profile() -> None:
ok, output = run_capture(["nix", "profile", "list", "--json"])
if not ok:
log_warning("Failed to list nix profile entries")
return
try:
data = json.loads(output)
elements = data.get("elements")
if isinstance(elements, dict) and elements:
for key, entry in elements.items():
attr_name = entry.get("attrPath").split(".")[-1] if entry.get("attrPath") else None
candidates = [
attr_name,
key,
entry.get("name"),
entry.get("originalInput"),
entry.get("source"),
entry.get("originalUrl"),
entry.get("url"),
]
for val in candidates:
if not val:
continue
norm = nix_base_name(str(val))
if norm:
collected.add(norm)
break
else:
entries = data.get("entries", [])
for entry in entries:
val = entry.get("originalInput") or entry.get("source") or entry.get("name")
if not val:
continue
norm = nix_base_name(str(val))
if norm:
collected.add(norm)
except json.JSONDecodeError:
log_warning("Failed to parse nix profile list json")
def collect_nix_env() -> None:
ok, output = run_capture(["nix-env", "--query", "--installed", "--json"])
if not ok:
return
try:
data = json.loads(output)
for item in data:
name = item.get("name")
if not name:
continue
norm = nix_base_name(str(name))
if norm:
collected.add(norm)
except json.JSONDecodeError:
pass
collect_profile()
if not collected:
collect_nix_env()
return sorted(collected)
def backup_target(target_path: Path, backup_dir: Path) -> None: def backup_target(target_path: Path, backup_dir: Path) -> None:
@ -585,6 +712,83 @@ def save_previous_config(current_path: Path, previous_path: Path) -> None:
log_warning(f"Failed to write previous configuration snapshot: {exc}") log_warning(f"Failed to write previous configuration snapshot: {exc}")
def reset_previous_config() -> None:
_, _, _, rollback_toml = get_paths()
if rollback_toml.exists():
try:
rollback_toml.unlink()
log_success(f"Removed previous inventory snapshot: {rollback_toml}")
except OSError as exc:
log_error(f"Failed to remove previous inventory snapshot: {exc}")
else:
log_info("No previous inventory snapshot to remove.")
def update_section_entries(config_path: Path, section: str, entries: list[str]) -> None:
entries = sorted({entry.strip() for entry in entries if entry.strip()})
header = f"[{section}]"
lines = config_path.read_text().splitlines()
output: list[str] = []
i = 0
found = False
while i < len(lines):
line = lines[i]
section_match = re.match(r"\s*\[(.+)]\s*$", line)
if section_match:
current_section = section_match.group(1).strip()
if current_section == section:
found = True
output.append(header)
i += 1
while i < len(lines) and not re.match(r"\s*\[.+]\s*$", lines[i]):
i += 1
output.extend(entries)
continue
output.append(line)
i += 1
if not found:
if output and output[-1].strip():
output.append("")
output.append(header)
output.extend(entries)
config_path.write_text("\n".join(output) + "\n")
def merge_section_entries(config_path: Path, section: str, add: set[str], remove: set[str]) -> None:
add_clean = {entry.strip() for entry in add if entry.strip()}
remove_clean = {entry.strip() for entry in remove if entry.strip()}
# Load current entries
current_config = parse_config(config_path)
current_entries = set()
if section == "dnf":
current_entries.update(current_config.packages)
elif section == "brew":
current_entries.update(current_config.brew_packages)
elif section == "flatpak":
current_entries.update(current_config.flatpak_refs)
elif section == "rpm-ostree":
current_entries.update(current_config.rpm_ostree_packages)
elif section == "nix":
current_entries.update(current_config.nix_packages)
elif section == "copr":
current_entries.update(current_config.copr)
elif section == "dotfiles":
current_entries.update(f"{k} = {v}" for k, v in current_config.dotfiles.items())
else:
log_error(f"Unknown section: {section}")
return
if section == "nix":
add_clean = {normalize_nix_ref(item) for item in add_clean}
remove_clean = {normalize_nix_ref(item) for item in remove_clean}
new_entries = (current_entries | add_clean) - remove_clean
update_section_entries(config_path, section, sorted(new_entries))
def switch_command() -> None: def switch_command() -> None:
_switch(rollback=False) _switch(rollback=False)
@ -594,16 +798,16 @@ def switch_command_with_args(rollback: bool = False) -> None:
def _switch(rollback: bool) -> None: def _switch(rollback: bool) -> None:
curator_dir, curator_toml, previous_toml = get_paths() curator_dir, curator_toml, last_toml, rollback_toml = get_paths()
if not curator_toml.exists() and not rollback: if not curator_toml.exists() and not rollback:
log_error("inventory.toml not found. Run 'curator init' first.") log_error("inventory.toml not found. Run 'curator init' first.")
sys.exit(1) sys.exit(1)
if rollback: if rollback:
if not previous_toml.exists(): if not rollback_toml.exists() or not last_toml.exists():
log_error("No previous inventory.toml to roll back to.") log_error("No rollback snapshots found. Run a normal switch first.")
sys.exit(1) sys.exit(1)
# Backup current config if present
current_before = load_config(curator_toml) if curator_toml.exists() else empty_config() current_before = load_config(curator_toml) if curator_toml.exists() else empty_config()
current_options = current_before.options or {"backup": True, "backup_dir": "backup"} 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_name = str(current_options.get("backup_dir", "backup") or "backup")
@ -614,13 +818,12 @@ def _switch(rollback: bool) -> None:
except OSError as exc: except OSError as exc:
log_warning(f"Failed to back up current inventory.toml before rollback: {exc}") log_warning(f"Failed to back up current inventory.toml before rollback: {exc}")
shutil.copy2(previous_toml, curator_toml) target_config = parse_config(last_toml)
log_info(f"Restored inventory.toml from {previous_toml}") baseline_config = parse_config(rollback_toml)
current_config = parse_config(curator_toml) current_config, previous_config = target_config, baseline_config
previous_config = current_before
else: else:
current_config = parse_config(curator_toml) current_config = parse_config(curator_toml)
previous_config = load_config(previous_toml) previous_config = load_config(last_toml)
copr_added, copr_removed = diff_items(current_config.copr, previous_config.copr) copr_added, copr_removed = diff_items(current_config.copr, previous_config.copr)
packages_added, packages_removed = diff_items(current_config.packages, previous_config.packages) packages_added, packages_removed = diff_items(current_config.packages, previous_config.packages)
@ -629,28 +832,108 @@ def _switch(rollback: bool) -> None:
rpm_ostree_added, rpm_ostree_removed = diff_items( rpm_ostree_added, rpm_ostree_removed = diff_items(
current_config.rpm_ostree_packages, previous_config.rpm_ostree_packages current_config.rpm_ostree_packages, previous_config.rpm_ostree_packages
) )
nix_added, nix_removed = diff_items(current_config.nix_packages, previous_config.nix_packages) current_nix_base = [nix_base_name(p) for p in current_config.nix_packages if nix_base_name(p)]
previous_nix_base = [nix_base_name(p) for p in previous_config.nix_packages if nix_base_name(p)]
nix_added_base, nix_removed_base = diff_items(current_nix_base, previous_nix_base)
current_nix_install = [nix_install_ref(p) for p in current_nix_base if nix_install_ref(p)]
nix_added_install = [nix_install_ref(p) for p in nix_added_base if nix_install_ref(p)]
need_dnf = bool(current_config.packages or packages_removed)
need_brew = bool(current_config.brew_packages or brew_removed)
need_flatpak = bool(current_config.flatpak_refs or flatpak_removed)
need_rpm_ostree = bool(current_config.rpm_ostree_packages or rpm_ostree_removed)
need_nix = bool(current_nix_install or nix_removed_base)
dnf_installed = set(gather_dnf_packages()) if need_dnf else set()
brew_installed = set(gather_brew_packages()) if need_brew else set()
flatpak_installed = set(gather_flatpak_refs()) if need_flatpak else set()
rpm_ostree_installed = set(gather_rpm_ostree_packages()) if need_rpm_ostree else set()
nix_installed = set(nix_install_ref(p) for p in gather_nix_packages()) if need_nix else None
enable_copr_repos(copr_added if copr_added else current_config.copr) enable_copr_repos(copr_added if copr_added else current_config.copr)
disable_copr_repos(set(current_config.copr), set(copr_removed)) disable_copr_repos(set(copr_removed))
install_packages(packages_added if packages_added else current_config.packages) 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) 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) 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) install_rpm_ostree_packages(
install_nix_packages(nix_added if nix_added else current_config.nix_packages) rpm_ostree_added if rpm_ostree_added else current_config.rpm_ostree_packages, rpm_ostree_installed
remove_packages(packages_removed) )
remove_brew_packages(brew_removed) install_nix_packages(nix_added_install if nix_added_install else current_nix_install, nix_installed)
remove_flatpaks(flatpak_removed) remove_packages(packages_removed, dnf_installed)
remove_rpm_ostree_packages(rpm_ostree_removed) remove_brew_packages(brew_removed, brew_installed)
remove_nix_packages(nix_removed) remove_flatpaks(flatpak_removed, flatpak_installed)
remove_rpm_ostree_packages(rpm_ostree_removed, rpm_ostree_installed)
remove_nix_packages(nix_removed_base, set(nix_base_name(p) for p in gather_nix_packages()) if need_nix else None)
deploy_dotfiles(current_config, curator_dir) deploy_dotfiles(current_config, curator_dir)
update_last_switch(curator_toml, current_config.options) update_last_switch(curator_toml, current_config.options)
save_previous_config(curator_toml, previous_toml) if rollback:
# Restore files to rollback state
shutil.copy2(rollback_toml, curator_toml)
shutil.copy2(rollback_toml, last_toml)
else:
if last_toml.exists():
save_previous_config(last_toml, rollback_toml)
if curator_toml.exists():
save_previous_config(curator_toml, last_toml)
log_success("Switch completed successfully!") log_success("Switch completed successfully!")
def import_from_manager(manager: str) -> None:
curator_dir, curator_toml, _, _ = get_paths()
if not curator_toml.exists():
log_error("inventory.toml not found. Run 'curator init' first.")
sys.exit(1)
gatherers = {
"dnf": (gather_dnf_packages, "dnf"),
"brew": (gather_brew_packages, "brew"),
"flatpak": (gather_flatpak_refs, "flatpak"),
"rpm-ostree": (gather_rpm_ostree_packages, "rpm-ostree"),
"nix": (gather_nix_packages, "nix"),
"copr": (gather_copr_repos, "copr"),
}
gather_fn, section = gatherers[manager]
entries = gather_fn()
if not entries:
log_info(f"No entries found for {manager}; inventory not updated.")
return
update_section_entries(curator_toml, section, entries)
log_success(f"Imported {len(entries)} {manager} entries into {curator_toml}")
def apply_inline_updates(entry_args: list[str], add: bool) -> None:
_, curator_toml, _, _ = get_paths()
if not curator_toml.exists():
log_error("inventory.toml not found. Run 'curator init' first.")
sys.exit(1)
changes: dict[str, dict[str, set[str]]] = {}
for arg in entry_args:
if ":" not in arg:
log_warning(f"Skipping invalid entry (expected section:value): {arg}")
continue
section, value = arg.split(":", 1)
section = section.strip()
value = value.strip()
if not section or not value:
log_warning(f"Skipping invalid entry (empty section or value): {arg}")
continue
if section not in {"dnf", "brew", "flatpak", "rpm-ostree", "nix", "copr"}:
log_warning(f"Unknown section '{section}' in entry: {arg}")
continue
bucket = changes.setdefault(section, {"add": set(), "remove": set()})
target = "add" if add else "remove"
bucket[target].add(value)
for section, change in changes.items():
merge_section_entries(curator_toml, section, change["add"], change["remove"])
log_success(f"Updated section [{section}] in {curator_toml}")
def status_command() -> None: def status_command() -> None:
curator_dir, curator_toml, _ = get_paths() curator_dir, curator_toml, _, _ = get_paths()
log_info("curator Status") log_info("curator Status")
print(f" Config directory: {curator_dir}") print(f" Config directory: {curator_dir}")
print(f" Config file: {curator_toml}") print(f" Config file: {curator_toml}")
@ -746,6 +1029,17 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true", action="store_true",
help="Restore the previous inventory.toml snapshot and apply it", help="Restore the previous inventory.toml snapshot and apply it",
) )
from_parser = subparsers.add_parser("from", help="Import currently installed packages into inventory.toml")
from_parser.add_argument(
"manager",
choices=["dnf", "brew", "flatpak", "rpm-ostree", "nix", "copr"],
help="Package manager to import from",
)
subparsers.add_parser("reset", help="Remove the previous inventory.toml snapshot")
add_parser = subparsers.add_parser("add", help="Add entries to inventory.toml (e.g. dnf:uv nix:micro)")
add_parser.add_argument("entries", nargs="+", help="Entries in the form section:value")
remove_parser = subparsers.add_parser("remove", help="Remove entries from inventory.toml (e.g. dnf:uv nix:micro)")
remove_parser.add_argument("entries", nargs="+", help="Entries in the form section:value")
subparsers.add_parser("status", help="Show current configuration status") subparsers.add_parser("status", help="Show current configuration status")
subparsers.add_parser("help", help="Show help message") subparsers.add_parser("help", help="Show help message")
return parser return parser
@ -759,6 +1053,14 @@ def main(argv: list[str] | None = None) -> None:
init_command() init_command()
elif args.command == "switch": elif args.command == "switch":
switch_command_with_args(getattr(args, "rollback", False)) switch_command_with_args(getattr(args, "rollback", False))
elif args.command == "from":
import_from_manager(args.manager)
elif args.command == "reset":
reset_previous_config()
elif args.command == "add":
apply_inline_updates(args.entries, add=True)
elif args.command == "remove":
apply_inline_updates(args.entries, add=False)
elif args.command == "status": elif args.command == "status":
status_command() status_command()
elif args.command == "help": elif args.command == "help":

View file

@ -122,3 +122,43 @@ last_switch = ""
backups = list(backup_dir.iterdir()) backups = list(backup_dir.iterdir())
assert backups, "expected a backup of inventory.toml to be created" assert backups, "expected a backup of inventory.toml to be created"
assert any(path.name.startswith("inventory.toml.") for path in backups) 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",
]