Files
dotfiles/manager
Nick Mello 7869819571 bundle: Add bundle option
Add bundle option that packages up with an extract script

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

233 lines
7.1 KiB
Python
Executable File

#!/usr/bin/env python3
import tomllib
import shutil
import tarfile
import tempfile
from datetime import datetime
from pathlib import Path
from enum import Enum
CONFIG_FILE = Path(__file__).parent / "config.toml"
def create_tool_bundle(bundle_name, entries):
"""
Create a tar.gz bundle for a single tool.
`entries` should be a list of entries for that tool only.
"""
tempdir = Path(tempfile.mkdtemp(prefix="dotfile_manager"))
bundle_dir = Path(f"{tempdir}/dotfiles-{bundle_name}")
bundle_dir.mkdir()
# Create extract.sh
extract_script = bundle_dir / "extract.sh"
with extract_script.open("w") as f:
f.write("#!/bin/sh\n")
f.write("set -e\n\n")
f.write("# Extract dotfiles for {}\n".format(bundle_name))
for entry in entries:
src = Path(entry["repo_path"]).name
dest = entry["system_path"].replace("$HOME", str(Path.home()))
backup = f"{dest}.old"
f.write(f"echo 'Installing {entry['repo_path']} -> {dest}'\n")
f.write(f"if [ -e '{dest}' ]; then\n")
f.write(f" mv '{dest}' '{backup}'\n")
f.write("fi\n")
if entry["type"] == "dir":
f.write(f"cp -r '{src}' '{dest}'\n")
else:
f.write(f"mkdir -p $(dirname '{dest}')\n")
f.write(f"cp '{src}' '{dest}'\n")
f.write("\n")
extract_script.chmod(0o755)
# Copy files into bundle_dir
for entry in entries:
src_path = Path(entry["repo_path"])
dest_path = bundle_dir / src_path.name
if src_path.is_dir():
shutil.copytree(src_path, dest_path)
else:
dest_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_path, dest_path)
# Create tar.gz
tar_path = Path(f"dotfiles-{bundle_name}.tar.gz")
with tarfile.open(tar_path, "w:gz") as tar:
tar.add(bundle_dir, arcname=bundle_dir.name)
# Cleanup temp directory
shutil.rmtree(bundle_dir)
print(f"Bundle created: {tar_path}")
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", "bundle"],
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)
elif args.action == "bundle":
create_tool_bundle(datetime.now().strftime("%Y-%m-%d-%H-%M"), entries)