Files
dotfiles/manager
Nick Mello afa723300a github: Add workflow to build bundles
Bundles contain an extract script that can be run when unbundled that
puts everything in its place just like an install would do but without
the need for python or the json files.

Signed-off-by: Nicholas Mello <nick@nmello.dev>
2025-08-14 22:12:03 -05:00

235 lines
7.2 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":
tools = "-".join(sorted(list({entry['tool'] for entry in entries})))
bundle_name = f"{datetime.now().strftime('%Y-%m-%d-%H-%M')}-{tools}"
create_tool_bundle(bundle_name, entries)