dotfiles/manage.py

206 lines
6.8 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import subprocess
import json
from pathlib import Path
import shlex
import shutil
DOTFILES_DIR = Path(__file__).parent
SETUPS_DIR = DOTFILES_DIR / "setups"
CONFIG_DIR = DOTFILES_DIR / "config"
CONFIG_PATH = DOTFILES_DIR / "manifest.json"
def load_config():
if not CONFIG_PATH.exists():
raise FileNotFoundError(f"Configuration file not found: {CONFIG_PATH}")
with open(CONFIG_PATH, "r") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError("JSON must be an object")
if "environments" not in data:
raise ValueError("Missing required field: 'environments'")
if not isinstance(data["environments"], dict):
raise ValueError("'environments' must be an object")
if "template" in data and not isinstance(data["template"], dict):
raise ValueError("'template' must be an object if present")
return data
def get_environment_packages(config, env, search_package=None):
env_entries = config["environments"].get(env, [])
if not env_entries:
raise TypeError(f"Environment {env} was not found or it is empty")
template_config = config.get("template", {})
packages = []
for entry in env_entries:
if isinstance(entry, str):
entry = { "package": entry }
package_name = entry.pop("package", None)
if package_name is None:
raise TypeError(f"The following entry is missing `package` field: {entry}")
package = { "name": package_name }
if package_name in template_config and not entry.get("ignore-template", False):
template_entry = template_config[package_name]
package = { **package, **template_entry, **entry }
else:
package.update(entry)
package.pop("ignore-template", None)
if "link" in package:
link_from = package["link"].get("from")
link_to = package["link"].get("to")
if not isinstance(link_from, str) or not isinstance(link_to, str):
raise ValueError("`link` should follow the structure: `{ from: str, to: str }`")
# if len(link_from.split("/")) != 2:
# raise ValueError("`link.from` should be '<env>/<package>'")
package["link"] = {
"from": Path(CONFIG_DIR / link_from).expanduser(),
"to": Path(link_to).expanduser()
}
if search_package == None:
packages.append(package)
else:
if package["name"] == search_package:
packages.append(package)
break
return packages
def force_delete(path):
if path.is_file() or path.is_symlink():
path.unlink()
elif path.is_dir():
shutil.rmtree(path)
def link_environment(config, env, **kwargs):
options = {
"package": kwargs.get("package"),
"copy": kwargs.get("copy", False),
"force": kwargs.get("force", False)
}
packages = get_environment_packages(config, env, search_package=options["package"])
for package in packages:
print(f"[{package['name']}]")
if "link-comment" in package:
print(f"\t> Comment: {package['link-comment']}")
if "link" not in package:
print("\t> Skipped: No link entry")
continue
src = package["link"]["from"]
dest = package["link"]["to"]
if dest.exists() or dest.is_symlink():
if options["force"]:
force_delete(dest)
print(f"\t> Deleted: {dest}")
else:
print(f"\t> Skipped: Already exists {dest}")
continue
dest.parent.mkdir(parents=True, exist_ok=True)
if options["copy"]:
if src.is_dir():
shutil.copytree(src, dest)
else:
shutil.copy(src, dest)
print(f"\t> Copied: {src} -> {dest}")
else:
dest.symlink_to(src)
print(f"\t> Symlinked: {src} -> {dest}")
if "post-link" in package:
command = package["post-link"]
subprocess.run(command, shell=True, check=True)
print(f"\t> Post-link executed: `{command}`")
def install_environment(config, env, **kwargs):
options = {
"package": kwargs.get("package"),
}
packages = get_environment_packages(config, env, search_package=options["package"])
for package in packages:
print(f"[{package['name']}]")
if "install-comment" in package:
print(f"\t> Comment: {package['install-comment']}")
if "install" not in package:
print("\t> Skipped: No install entry")
continue
install_command = package.get("install")
if install_command:
subprocess.run(shlex.split(install_command), check=True)
print(f"\t> Installed: `{install_command}`")
if "post-install" in package:
postinstall_command = package["post-install"]
subprocess.run(command, shell=True, check=True)
print(f"\t> Post-install executed: `{postinstall_command}`")
def setup_environment(config, env, **kwargs):
print(f"[{env}]")
options = {
"extra_args": kwargs.get("extra"),
}
setup_script = SETUPS_DIR / f"{env}.sh"
if setup_script.exists():
cmd = ["bash", str(setup_script)]
if options["extra_args"]:
cmd.extend(shlex.split(options["extra_args"])) # Split extra args safely
subprocess.run(cmd, check=True)
print(f"\t> Setup script executed: {setup_script} {options['extra_args'] or ''}")
else:
print(f"\t> No setup script found for {env}")
def main():
config = load_config()
config_envs = list(config["environments"].keys())
setup_envs = [script.stem for script in SETUPS_DIR.glob("*.sh")]
parser = argparse.ArgumentParser(description="Dotfile & System Setup Manager")
subparsers = parser.add_subparsers(dest="command", required=True)
subparser = subparsers.add_parser("link", help="Link configs")
subparser.add_argument("env", choices=config_envs)
subparser.add_argument("-p", "--package")
subparser.add_argument("-f", "--force", action="store_true")
subparser.add_argument("--copy", action="store_true")
subparser = subparsers.add_parser("install", help="Install packages")
subparser.add_argument("env", choices=config_envs)
subparser.add_argument("-p", "--package")
setup_parser = subparsers.add_parser("setup", help="Run setup script")
setup_parser.add_argument("env", choices=setup_envs)
setup_parser.add_argument("--extra")
args = parser.parse_args()
command_actions = {"link": link_environment, "install": install_environment, "setup": setup_environment}
command_actions[args.command](config, **vars(args))
if __name__ == "__main__":
main()