Files
dotfiles/manager
Nick Mello 213a40bc27 Initial Commit
Initial dotfile manager and config with tools

Signed-off-by: Nicholas Mello <nick@nmello.dev>
2025-08-14 19:20:56 -05:00

173 lines
5.1 KiB
Python
Executable File

#!/usr/bin/env python3
import tomllib
from pathlib import Path
import shutil
from enum import Enum
CONFIG_FILE = Path(__file__).parent / "config.toml"
class ToolType(str, Enum):
FILE = "file"
DIR = "dir"
def parse_dotfiles_toml(path: Path):
"""
Parse a dotfiles TOML and return a flat list of entries.
Rules:
- Every entry (top-level or nested) must have a 'type' (file or dir)
- Nested entries belong to the same tool
Returns a list of dicts with keys: tool, repo_path, system_path, type, ...
"""
with open(path, "rb") as f:
cfg = tomllib.load(f)
all_entries = []
tools = cfg.get("tools", {})
def process_table(tool_name, table):
"""
Process a tool table: flatten nested entries, enforce 'type'.
"""
# If table itself has 'type', treat it as a single entry
if "type" in table:
if table["type"] not in ("file", "dir"):
raise ValueError(f"Invalid type for tool '{tool_name}': {table['type']}")
entry = dict(table)
entry["tool"] = tool_name
all_entries.append(entry)
return # stop recursion here
# Otherwise recurse into nested tables
for key, value in table.items():
if isinstance(value, dict):
process_table(tool_name, value)
else:
raise ValueError(f"Invalid entry in tool '{tool_name}': {key}={value}")
for tool_name, tool_data in tools.items():
if not isinstance(tool_data, dict):
raise ValueError(f"Tool '{tool_name}' must be a table")
process_table(tool_name, tool_data)
return all_entries
def filter_entries(entries, targets=None):
if not targets:
return entries
available_tools = {e["tool"] for e in entries}
unknown = [t for t in targets if t not in available_tools]
if unknown:
raise ValueError(f"Unknown tool(s) specified: {', '.join(unknown)}")
return [e for e in entries if e["tool"] in targets]
def install(entries):
"""
Install all entries from repo_path → system_path.
If system_path exists, move it to .old before overwriting.
"""
for entry in entries:
repo_path = Path(entry["repo_path"]).expanduser()
system_path = Path(entry["system_path"]).expanduser()
backup_path = system_path.with_name(system_path.name + ".old")
# Backup existing file/folder
if system_path.exists():
if backup_path.exists():
shutil.rmtree(backup_path) if backup_path.is_dir() else backup_path.unlink()
system_path.rename(backup_path)
# Copy from repo to system
if entry["type"] == "dir":
shutil.copytree(repo_path, system_path)
else: # file
system_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(repo_path, system_path)
def store(entries):
"""
Store all entries: system_path → repo_path (strict sync).
- For directories: remove repo folder first
- For files: overwrite
"""
for entry in entries:
repo_path = Path(entry["repo_path"]).expanduser()
system_path = Path(entry["system_path"]).expanduser()
if entry["type"] == "dir":
if repo_path.exists():
shutil.rmtree(repo_path)
shutil.copytree(system_path, repo_path)
else: # file
repo_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(system_path, repo_path)
def restore(entries):
"""
Restore all entries from .old → system_path.
"""
for entry in entries:
system_path = Path(entry["system_path"]).expanduser()
backup_path = system_path.with_name(system_path.name + ".old")
if backup_path.exists():
if system_path.exists():
if system_path.is_dir():
shutil.rmtree(system_path)
else:
system_path.unlink()
backup_path.rename(system_path)
def clean(entries):
"""
Remove all .old backups created during install.
"""
for entry in entries:
system_path = Path(entry["system_path"]).expanduser()
backup_path = system_path.with_name(system_path.name + ".old")
if backup_path.exists():
if backup_path.is_dir():
shutil.rmtree(backup_path)
else:
backup_path.unlink()
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Manage dotfiles from TOML config")
parser.add_argument(
"action",
choices=["install", "store", "restore", "clean"],
help="Action to perform"
)
parser.add_argument(
"targets",
nargs="*",
help="Tools to act on (default: all)"
)
args = parser.parse_args()
entries = parse_dotfiles_toml(CONFIG_FILE)
entries = filter_entries(entries, args.targets)
if args.action == "install":
install(entries)
elif args.action == "store":
store(entries)
elif args.action == "restore":
restore(entries)
elif args.action == "clean":
clean(entries)