Add bundle option that packages up with an extract script Signed-off-by: Nicholas Mello <nick@nmello.dev>
233 lines
7.1 KiB
Python
Executable File
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)
|