From 213a40bc27870f4030db8642c28f428acd77f6f0 Mon Sep 17 00:00:00 2001 From: Nick Mello Date: Thu, 14 Aug 2025 19:20:56 -0500 Subject: [PATCH] Initial Commit Initial dotfile manager and config with tools Signed-off-by: Nicholas Mello --- config.toml | 30 +++++++++ manager | 172 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 config.toml create mode 100755 manager diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..02d5036 --- /dev/null +++ b/config.toml @@ -0,0 +1,30 @@ +[tools] +[tools.nvim] +repo_path = "nvim" +system_path = "~/.config/nvim" +type = "dir" + +[tools.fish] +repo_path = "fish" +system_path = "~/.config/fish" +type = "dir" + +[tools.git.config] +repo_path = "git/gitconfig" +system_path = "~/.gitconfig" +type = "file" + +[tools.git.message] +repo_path = "git/gitmessage" +system_path = "~/.gitmessage" +type = "file" + +[tools.tmux] +repo_path = "tmux/tmux.conf" +system_path = "~/.tmux.conf" +type = "file" + +[tools.bash] +repo_path = "bash/bashrc.sh" +system_path = "~/.bashrc" +type = "file" diff --git a/manager b/manager new file mode 100755 index 0000000..937b3be --- /dev/null +++ b/manager @@ -0,0 +1,172 @@ +#!/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)