Initial dotfile manager and config with tools Signed-off-by: Nicholas Mello <nick@nmello.dev>
173 lines
5.1 KiB
Python
Executable File
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)
|