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. 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 ## 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) - Managing COPR repositories (enable/disable automatically)
- Installing and managing system packages via dnf - Installing and managing system packages via dnf
- Managing dotfiles through symlinks to actual files - 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 - User-level configuration similar to home-manager/nixos
- Automatic backup of existing files before replacement - 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`: 3. Run with `uv`:
```bash ```bash
uv run forge --help uv run curator --help
``` ```
4. Or use the local shim directly: 4. Or use the local shim directly:
```bash ```bash
./forge --help ./curator --help
``` ```
5. Optionally install globally via `uv`: 5. Optionally install globally via `uv`:
```bash ```bash
@ -36,46 +36,48 @@ Forge is a Python CLI that helps you manage your Fedora system configuration by:
```bash ```bash
# Initialize the configuration structure # Initialize the configuration structure
uv run forge init uv run curator init
# Edit the configuration file to add packages and dotfiles # Edit the configuration file to add packages and dotfiles
nano ~/.config/forge/forge.toml nano ~/.config/curator/inventory.toml
# Apply configuration # Apply configuration
uv run forge switch uv run curator switch
# Check status # 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 ## Commands
### `init` ### `init`
Initialize the configuration structure and create `forge.toml`. Initialize the configuration structure and create `inventory.toml`.
```bash ```bash
uv run forge init uv run curator init
# or ./forge init # or ./curator init
``` ```
Creates: Creates:
- `~/.config/forge/` - Main configuration directory - `~/.config/curator/` - Main configuration directory
- `~/.config/forge/forge.toml` - Central configuration file - `~/.config/curator/inventory.toml` - Central configuration file
### `switch` ### `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 ```bash
uv run forge switch uv run curator switch
# or ./forge switch # or ./curator switch
# rollback to the previous inventory.toml and apply it
uv run curator switch --rollback
``` ```
This command: 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 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 4. Creates symlinks for all configured dotfiles
5. Creates backups of existing files before replacing them 5. Creates backups of existing files before replacing them
6. Updates the last switch timestamp 6. Updates the last switch timestamp
@ -84,8 +86,8 @@ This command:
Show current configuration status and information. Show current configuration status and information.
```bash ```bash
uv run forge status uv run curator status
# or ./forge status # or ./curator status
``` ```
Displays: Displays:
@ -97,35 +99,49 @@ Displays:
Show help message with all available commands. Show help message with all available commands.
```bash ```bash
uv run forge help uv run curator help
# or ./forge help # or ./curator help
``` ```
## Configuration ## Configuration
### forge.toml ### inventory.toml
The central configuration file located at `~/.config/forge/forge.toml`: The central configuration file located at `~/.config/curator/inventory.toml`:
```toml ```toml
# Forge Configuration File # curator Configuration File
# User-level configuration similar to home-manager/nixos # User-level configuration similar to home-manager/nixos
copr = [ [curator]
# "copr.fedorainfracloud.org/username/repository",
# "copr.fedorainfracloud.org/anotheruser/anotherrepo",
]
packages = [
# "git",
# "vim",
# "curl",
# "wget",
]
[forge]
version = "1.0" version = "1.0"
last_switch = "" 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]
# Dotfiles to manage with symlinks # Dotfiles to manage with symlinks
# Format: "target_path" = "source_path" # Format: "target_path" = "source_path"
@ -143,40 +159,44 @@ backup_dir = "backup"
### Configuration Sections ### Configuration Sections
#### `[forge]` #### `[curator]`
- `version`: Configuration file version - `version`: Configuration file version
- `last_switch`: Timestamp of last switch operation (auto-updated) - `last_switch`: Timestamp of last switch operation (auto-updated)
#### `copr` #### `[copr]`
Array of COPR repositories to enable via `dnf copr enable`. **Just list COPR repository names** - presence means enable, absence means don't enable. List of COPR repositories to enable via `dnf copr enable`. **One repository per line**presence means enable, absence means don't enable.
**Examples:** **Examples:**
```toml ```toml
copr = [ [copr]
"copr.fedorainfracloud.org/username/repository", copr.fedorainfracloud.org/username/repository
"copr.fedorainfracloud.org/anotheruser/anotherrepo", 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` #### `[dnf]`
Array of packages to install via dnf. **Just list package names** - presence means install, absence means don't install. List of packages to install via dnf. **One package per line**presence means install, absence means don't install.
**Examples:** **Examples:**
```toml ```toml
packages = [ [dnf]
"git", git
"vim", vim
"curl", curl
"wget", wget
"nodejs", nodejs
"npm", npm
]
``` ```
To remove a package, simply delete the line containing the package name. 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]` #### `[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)
@ -186,13 +206,13 @@ Dotfile mappings using symlinks:
#### `[options]` #### `[options]`
Additional configuration options: Additional configuration options:
- `backup`: Enable/disable backup of existing files (default: true) - `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 ## File Structure
``` ```
~/.config/forge/ ~/.config/curator/
├── forge.toml # Main configuration file ├── inventory.toml # Main configuration file
├── dotfiles/ # Your actual dotfiles (source files) ├── dotfiles/ # Your actual dotfiles (source files)
│ ├── .bashrc │ ├── .bashrc
│ ├── vimrc │ ├── vimrc
@ -205,162 +225,205 @@ Additional configuration options:
## How It Works ## How It Works
### COPR Repository Management ### COPR Repository Management
COPR repositories are managed through the `copr` array in `forge.toml`: COPR repositories are managed through the `[copr]` section in `inventory.toml`:
- **Add COPR repo**: Add the repository name to the array - **Add COPR repo**: Add the repository name on its own line
- **Remove COPR repo**: Remove the entry from the array - **Remove COPR repo**: Remove the entry
- **Automatic cleanup**: Forge automatically disables COPR repos that are removed from configuration - **Automatic cleanup**: curator automatically disables COPR repos that are removed from configuration
- **No flags needed**: Just presence/absence of the repository name matters - **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 ### Package Management
Packages are managed through the `packages` array in `forge.toml`: Packages are managed through the `[dnf]` section in `inventory.toml`:
- **Add package**: Add the package name to the array - **Add package**: Add the package name on its own line
- **Remove package**: Remove the entry from the array - **Remove package**: Remove the entry
- **No flags needed**: Just presence/absence of the package name matters - **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 ### Dotfile Management
Forge uses symlinks to manage dotfiles: curator uses symlinks to manage dotfiles:
1. Your actual dotfiles are stored in `~/.config/forge/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 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 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 4. Changes to the source files are immediately reflected in your home directory
5. Source paths are automatically prefixed with "dotfiles/" for convenience 5. Source paths are automatically prefixed with "dotfiles/" for convenience
### Backup System ### Backup System
Before creating symlinks, Forge: Before creating symlinks, curator:
1. Checks if the target file exists and is not a symlink 1. Checks if the target file exists and is not a symlink
2. Creates a timestamped backup in the backup directory 2. Creates a timestamped backup in the backup directory
3. Removes the original file 3. Removes the original file
4. Creates the symlink to your managed dotfile 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 ## Examples
### Basic Setup ### Basic Setup
```bash ```bash
# Initialize forge # Initialize curator
uv run forge init uv run curator init
# Edit forge.toml to add COPR repos and packages # Edit inventory.toml to add COPR repos and packages
nano ~/.config/forge/forge.toml nano ~/.config/curator/inventory.toml
# Add to the arrays: # Add to the sections:
# copr = [ # [copr]
# "copr.fedorainfracloud.org/username/cool-repo", # copr.fedorainfracloud.org/username/cool-repo
# ] # [dnf]
# packages = [ # git
# "git", # vim
# "vim", # curl
# "curl", # [brew]
# ] # wget
# [flatpak]
# org.mozilla.firefox
# [rpm-ostree]
# podman
# [nix]
# nixpkgs#git (or just "git")
# Create your dotfiles directory and add files # Create your dotfiles directory and add files
mkdir -p ~/.config/forge/dotfiles mkdir -p ~/.config/curator/dotfiles
echo "export EDITOR=vim" > ~/.config/forge/dotfiles/.bashrc echo "export EDITOR=vim" > ~/.config/curator/dotfiles/.bashrc
# Add to [dotfiles] section: # Add to [dotfiles] section:
".bashrc" = ".bashrc" ".bashrc" = ".bashrc"
# Apply configuration # Apply configuration
uv run forge switch uv run curator switch
``` ```
### Managing Application Configurations ### Managing Application Configurations
```bash ```bash
# Add alacritty configuration # Add alacritty configuration
mkdir -p ~/.config/forge/dotfiles mkdir -p ~/.config/curator/dotfiles
cp ~/.config/alacritty/alacritty.yml ~/.config/forge/dotfiles/ cp ~/.config/alacritty/alacritty.yml ~/.config/curator/dotfiles/
# Edit forge.toml # Edit inventory.toml
nano ~/.config/forge/forge.toml nano ~/.config/curator/inventory.toml
# Add to [dotfiles] section: # Add to [dotfiles] section:
".config/alacritty/alacritty.yml" = "alacritty.yml" ".config/alacritty/alacritty.yml" = "alacritty.yml"
# Apply changes # Apply changes
uv run forge switch uv run curator switch
``` ```
### COPR Repository Management Examples ### COPR Repository Management Examples
```toml ```toml
copr = [ [copr]
# Development tools COPR # Development tools COPR
"copr.fedorainfracloud.org/development/tools", copr.fedorainfracloud.org/development/tools
"copr.fedorainfracloud.org/user/neovim-nightly", copr.fedorainfracloud.org/user/neovim-nightly
]
# To remove a COPR repo, just delete the entry # 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 ### Package Management Examples
```toml ```toml
packages = [ [dnf]
# Development tools # Development tools
"git", git
"vim", vim
"nodejs", nodejs
"npm", npm
# System utilities # System utilities
"curl", curl
"wget", wget
"tree", tree
"htop", htop
]
# To remove a package, just delete the entry # 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 ### Version Control Your Configuration
```bash ```bash
# Initialize git repository in forge directory # Initialize git repository in curator directory
cd ~/.config/forge cd ~/.config/curator
git init git init
git add . git add .
git commit -m "Initial configuration" git commit -m "Initial configuration"
# Now you can version control your entire system 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" git commit -m "Updated vim configuration"
``` ```
## Environment Variables ## Environment Variables
- `FORGE_DIR`: Override the default configuration directory (default: `~/.config/forge`) - `CURATOR_DIR`: Override the default configuration directory (default: `~/.config/curator`)
## Dependencies ## Dependencies
- Python 3.11+ - Python 3.11+
- `tomlkit` (installed automatically via `uv sync`)
- `uv` for environment and script management - `uv` for environment and script management
- `dnf` - Fedora package manager - `dnf` - Fedora package manager
- `dnf-plugins-core` - For COPR repository management - `dnf-plugins-core` - For COPR repository management
## Migration from Previous Version ## 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. 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. 2. Run `uv run curator init` (or `./curator init`) to create the new `inventory.toml` structure.
3. Convert to arrays: 3. Convert to section-per-line format:
```toml ```toml
# Old formats # Old formats
[packages] [dnf]
git = true git = true
vim = true vim = true
# or # or
[packages] [dnf]
git git
vim vim
# New format # New format
packages = ["git", "vim"] [dnf]
copr = ["copr.fedorainfracloud.org/username/repository"] 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 ## License

View file

@ -12,12 +12,12 @@ def main() -> None:
sys.path.insert(0, str(src_dir)) sys.path.insert(0, str(src_dir))
try: 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 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) sys.exit(1)
forge_main() curator_main()
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -1,13 +1,13 @@
[project] [project]
name = "forge" name = "curator"
version = "0.1.0" version = "0.1.0"
description = "A home-manager style Fedora configuration helper." description = "A home-manager style Fedora configuration helper."
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["tomlkit>=0.12"] dependencies = []
[project.scripts] [project.scripts]
forge = "forge.cli:main" curator = "curator.cli:main"
[build-system] [build-system]
requires = ["hatchling"] 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 pathlib import Path
from typing import Any, Optional from typing import Any, Optional
import tomlkit
from tomlkit.exceptions import TOMLKitError
from tomlkit.items import Array, Table
BLUE = "\033[0;34m" BLUE = "\033[0;34m"
GREEN = "\033[0;32m" GREEN = "\033[0;32m"
YELLOW = "\033[1;33m" YELLOW = "\033[1;33m"
RED = "\033[0;31m" RED = "\033[0;31m"
RESET = "\033[0m" RESET = "\033[0m"
DEFAULT_CONFIG = """# Forge Configuration File DEFAULT_CONFIG = """# curator Configuration File
# User-level configuration similar to home-manager/nixos # User-level configuration similar to home-manager/nixos
copr = [ [curator]
# "copr.fedorainfracloud.org/username/repository",
# "copr.fedorainfracloud.org/anotheruser/anotherrepo",
]
packages = [
# "git",
# "vim",
# "curl",
]
[forge]
version = "1.0" version = "1.0"
last_switch = "" 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]
# Dotfiles to manage with symlinks # Dotfiles to manage with symlinks
# Format: "target_path" = "source_path" # Format: "target_path" = "source_path"
# target_path: where the symlink should be created (relative to home directory) # 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 # Note: source_path automatically prefixed with "dotfiles/" if not present
# Examples: # Examples:
# ".bashrc" = ".bashrc" # ".bashrc" = ".bashrc"
@ -59,14 +69,17 @@ backup_dir = "backup"
@dataclass @dataclass
class ForgeConfig: class CuratorConfig:
copr: list[str] copr: list[str]
packages: 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] dotfiles: dict[str, str]
options: dict[str, Any] options: dict[str, Any]
forge_fields: dict[str, Any] curator_fields: dict[str, Any]
document: Optional[tomlkit.TOMLDocument] document: Optional[Any] = None
legacy: bool = False
def log(prefix: str, color: str, message: str) -> None: def log(prefix: str, color: str, message: str) -> None:
@ -104,12 +117,16 @@ def parse_value(value: str) -> Any:
return strip_quotes(value) return strip_quotes(value)
def parse_legacy_config(config_path: Path) -> ForgeConfig: def parse_config(config_path: Path) -> CuratorConfig:
copr: list[str] = [] 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] = {} dotfiles: dict[str, str] = {}
options: dict[str, Any] = {} options: dict[str, Any] = {}
forge_fields: dict[str, Any] = {} curator_fields: dict[str, Any] = {}
section = None section = None
with config_path.open() as handle: with config_path.open() as handle:
@ -126,11 +143,21 @@ def parse_legacy_config(config_path: Path) -> ForgeConfig:
if section is None: if section is None:
continue continue
if section in {"packages", "copr"}: if section in {"dnf", "copr", "brew", "flatpak", "rpm-ostree", "nix"}:
entry = line.split("=", 1)[0].strip() entry = line.split("=", 1)[0].strip()
if entry: if entry:
target = packages if section == "packages" else copr if section == "copr":
target.append(strip_quotes(entry)) 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 continue
if section == "dotfiles": if section == "dotfiles":
@ -154,8 +181,8 @@ def parse_legacy_config(config_path: Path) -> ForgeConfig:
if section == "options": if section == "options":
options[key] = value options[key] = value
elif section == "forge": elif section == "curator":
forge_fields[key] = value curator_fields[key] = value
merged_options: dict[str, Any] = {"backup": True, "backup_dir": "backup"} merged_options: dict[str, Any] = {"backup": True, "backup_dir": "backup"}
merged_options.update(options) merged_options.update(options)
@ -163,123 +190,37 @@ def parse_legacy_config(config_path: Path) -> ForgeConfig:
if not merged_options.get("backup_dir"): if not merged_options.get("backup_dir"):
merged_options["backup_dir"] = "backup" merged_options["backup_dir"] = "backup"
return ForgeConfig( return CuratorConfig(
copr=copr, 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, dotfiles=dotfiles,
options=merged_options, options=merged_options,
forge_fields=forge_fields, curator_fields=curator_fields,
document=None, document=None,
legacy=True,
) )
def empty_config() -> ForgeConfig: def empty_config() -> CuratorConfig:
return ForgeConfig( return CuratorConfig(
copr=[], copr=[],
packages=[], packages=[],
brew_packages=[],
flatpak_refs=[],
rpm_ostree_packages=[],
nix_packages=[],
dotfiles={}, dotfiles={},
options={"backup": True, "backup_dir": "backup"}, options={"backup": True, "backup_dir": "backup"},
forge_fields={}, curator_fields={},
document=None, 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 load_config(config_path: Path) -> CuratorConfig:
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:
if not config_path.exists(): if not config_path.exists():
return empty_config() return empty_config()
try: 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]: def get_paths() -> tuple[Path, Path, Path]:
forge_dir = Path(os.environ.get("FORGE_DIR", Path.home() / ".config" / "forge")).expanduser() curator_dir = Path(os.environ.get("CURATOR_DIR", Path.home() / ".config" / "curator")).expanduser()
forge_toml = forge_dir / "forge.toml" curator_toml = curator_dir / "inventory.toml"
previous_toml = forge_dir / "forge.toml.prev" previous_toml = curator_dir / "inventory.toml.prev"
return forge_dir, forge_toml, previous_toml return curator_dir, curator_toml, previous_toml
def init_command() -> None: def init_command() -> None:
forge_dir, forge_toml, _ = get_paths() curator_dir, curator_toml, _ = get_paths()
log_info("Initializing forge...") log_info("Initializing curator...")
forge_dir.mkdir(parents=True, exist_ok=True) curator_dir.mkdir(parents=True, exist_ok=True)
if forge_toml.exists(): if curator_toml.exists():
log_warning(f"Configuration already exists at {forge_toml}") log_warning(f"Configuration already exists at {curator_toml}")
else: else:
forge_toml.write_text(DEFAULT_CONFIG) curator_toml.write_text(DEFAULT_CONFIG)
log_success(f"Created forge.toml: {forge_toml}") log_success(f"Created inventory.toml: {curator_toml}")
log_success("Initialization complete!") log_success("Initialization complete!")
log_info(f"Edit {forge_toml} to configure packages and dotfiles") log_info(f"Edit {curator_toml} to configure packages and dotfiles")
log_info("Run 'forge switch' to apply your configuration") log_info("Run 'curator switch' to apply your configuration")
def run_command(command: list[str]) -> bool: 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}") 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: def remove_packages(packages: list[str]) -> None:
log_info("Removing packages...") log_info("Removing packages...")
if not packages: if not packages:
@ -413,6 +422,66 @@ def remove_packages(packages: list[str]) -> None:
log_error(f"Failed to remove {package}") 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: def backup_target(target_path: Path, backup_dir: Path) -> None:
timestamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_dir.mkdir(parents=True, exist_ok=True) backup_dir.mkdir(parents=True, exist_ok=True)
@ -433,7 +502,7 @@ def remove_existing(target_path: Path) -> None:
shutil.rmtree(target_path) 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...") log_info("Deploying dotfiles...")
if not config.dotfiles: if not config.dotfiles:
log_info("No dotfiles to deploy") 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_enabled = bool(config.options.get("backup", True))
backup_dir_name = str(config.options.get("backup_dir", "backup")) or "backup" 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(): 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 target_path = Path.home() / target
if not source_path.exists(): if not source_path.exists():
@ -484,33 +553,29 @@ def update_last_switch(config_path: Path, options: dict[str, Any]) -> None:
try: try:
backup_target(config_path, backup_dir) backup_target(config_path, backup_dir)
except OSError as exc: 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() 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() lines = raw_text.splitlines()
updated = False updated = False
insert_idx: Optional[int] = None
for idx, line in enumerate(lines): 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): if re.match(r"\s*last_switch\s*=", line):
lines[idx] = f'last_switch = "{ts_str}"' lines[idx] = f'last_switch = "{timestamp}"'
updated = True updated = True
break break
if not updated: if not updated:
lines.append(f'last_switch = "{ts_str}"') 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") config_path.write_text("\n".join(lines) + "\n")
return
forge_table = document.get("forge")
if not isinstance(forge_table, Table):
forge_table = tomlkit.table()
document["forge"] = forge_table
forge_table["last_switch"] = timestamp
config_path.write_text(tomlkit.dumps(document))
def save_previous_config(current_path: Path, previous_path: Path) -> None: 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: def switch_command() -> None:
forge_dir, forge_toml, previous_toml = get_paths() _switch(rollback=False)
if not forge_toml.exists():
log_error("forge.toml not found. Run 'forge init' first.")
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) sys.exit(1)
current_config = parse_config(forge_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) previous_config = load_config(previous_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)
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) 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(current_config.copr), 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)
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) remove_packages(packages_removed)
deploy_dotfiles(current_config, forge_dir) remove_brew_packages(brew_removed)
update_last_switch(forge_toml, current_config.options) remove_flatpaks(flatpak_removed)
save_previous_config(forge_toml, previous_toml) 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!") log_success("Switch completed successfully!")
def status_command() -> None: def status_command() -> None:
forge_dir, forge_toml, _ = get_paths() curator_dir, curator_toml, _ = get_paths()
log_info("Forge Status") log_info("curator Status")
print(f" Config directory: {forge_dir}") print(f" Config directory: {curator_dir}")
print(f" Config file: {forge_toml}") print(f" Config file: {curator_toml}")
if not forge_toml.exists(): if not curator_toml.exists():
print(" No configuration file found") print(" No configuration file found")
return return
config = parse_config(forge_toml) config = parse_config(curator_toml)
print() print()
log_info("Configuration:") 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": if last_switch and str(last_switch).lower() != "null":
print(f" Last switch: {last_switch}") print(f" Last switch: {last_switch}")
else: else:
@ -572,13 +679,49 @@ def status_command() -> None:
print(" No COPR repositories configured") print(" No COPR repositories configured")
print() print()
log_info("Packages:") log_info("DNF Packages:")
if config.packages: if config.packages:
for package in config.packages: for package in config.packages:
print(f" {package}") print(f" {package}")
print(f" Total: {len(config.packages)} packages") print(f" Total: {len(config.packages)} dnf packages")
else: 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() print()
log_info("Dotfiles:") log_info("Dotfiles:")
@ -592,12 +735,17 @@ def status_command() -> None:
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="forge", prog="curator",
description="Forge - A home-manager like script for Fedora", description="curator - A home-manager like script for Fedora",
) )
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("init", help="Initialize configuration structure") 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("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
@ -610,7 +758,7 @@ def main(argv: list[str] | None = None) -> None:
if args.command == "init": if args.command == "init":
init_command() init_command()
elif args.command == "switch": elif args.command == "switch":
switch_command() switch_command_with_args(getattr(args, "rollback", False))
elif args.command == "status": elif args.command == "status":
status_command() status_command()
elif args.command == "help": 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 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: def test_parse_config_line_format(tmp_path: Path) -> None:
config_path = tmp_path / "forge.toml" config_path = tmp_path / "inventory.toml"
config_path.write_text( config_path.write_text(
""" """
copr = ["copr.fedorainfracloud.org/user/repo"] [curator]
packages = ["git", "vim"]
[forge]
version = "1.0" version = "1.0"
last_switch = "" last_switch = ""
[copr]
copr.fedorainfracloud.org/user/repo
[dnf]
git
vim
[brew]
wget
[flatpak]
org.mozilla.firefox
[rpm-ostree]
podman
[nix]
nixpkgs#git
[dotfiles] [dotfiles]
".bashrc" = ".bashrc" ".bashrc" = ".bashrc"
@ -30,18 +45,33 @@ backup_dir = "backup"
assert config.copr == ["copr.fedorainfracloud.org/user/repo"] assert config.copr == ["copr.fedorainfracloud.org/user/repo"]
assert config.packages == ["git", "vim"] 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.dotfiles == {".bashrc": ".bashrc"}
assert config.options["backup"] is True assert config.options["backup"] is True
assert config.legacy is False
def test_parse_config_legacy_format(tmp_path: Path) -> None: 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( config_path.write_text(
""" """
[packages] [dnf]
git git
vim vim
[brew]
wget
[flatpak]
org.mozilla.firefox
[rpm-ostree]
podman
[nix]
nixpkgs#git
[copr] [copr]
copr.fedorainfracloud.org/user/repo copr.fedorainfracloud.org/user/repo
@ -54,9 +84,12 @@ copr.fedorainfracloud.org/user/repo
config = cli.parse_config(config_path) config = cli.parse_config(config_path)
assert config.packages == ["git", "vim"] 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.copr == ["copr.fedorainfracloud.org/user/repo"]
assert config.dotfiles == {".bashrc": ".bashrc"} assert config.dotfiles == {".bashrc": ".bashrc"}
assert config.legacy is True
def test_diff_items() -> None: 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: def test_update_last_switch_creates_backup(tmp_path: Path) -> None:
config_dir = tmp_path config_dir = tmp_path
config_path = config_dir / "forge.toml" config_path = config_dir / "inventory.toml"
config_path.write_text( config_path.write_text(
""" """
[forge] [curator]
version = "1.0" version = "1.0"
last_switch = "" last_switch = ""
""".strip() """.strip()
@ -79,10 +112,13 @@ last_switch = ""
options = {"backup": True, "backup_dir": "backup"} options = {"backup": True, "backup_dir": "backup"}
cli.update_last_switch(config_path, options) cli.update_last_switch(config_path, options)
doc = tomlkit.parse(config_path.read_text()) lines = [line.strip() for line in config_path.read_text().splitlines() if line.strip()]
assert isinstance(doc["forge"]["last_switch"], dt.datetime) 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" backup_dir = config_dir / "backup"
backups = list(backup_dir.iterdir()) backups = list(backup_dir.iterdir())
assert backups, "expected a backup of forge.toml to be created" assert backups, "expected a backup of inventory.toml to be created"
assert any(path.name.startswith("forge.toml.") for path in backups) assert any(path.name.startswith("inventory.toml.") for path in backups)

View file

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

15
uv.lock generated
View file

@ -12,12 +12,9 @@ wheels = [
] ]
[[package]] [[package]]
name = "forge" name = "curator"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [
{ name = "tomlkit" },
]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
@ -25,7 +22,6 @@ dev = [
] ]
[package.metadata] [package.metadata]
requires-dist = [{ name = "tomlkit", specifier = ">=0.12" }]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=7.4" }] dev = [{ name = "pytest", specifier = ">=7.4" }]
@ -81,12 +77,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049dd
wheels = [ 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" }, { 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" },
]