From c4d74f74b80aac53e40699440f62caad4e3a15e0 Mon Sep 17 00:00:00 2001 From: Cian Hughes Date: Tue, 7 Apr 2026 12:53:21 +0100 Subject: [PATCH] Refactored justfile for more reliability --- justfile | 270 +++++--------------------------------------- scripts/boostrap.py | 217 +++++++++++++++++++++++++++++++++++ scripts/edit.py | 40 +++++++ scripts/packages.bb | 45 ++++++++ 4 files changed, 330 insertions(+), 242 deletions(-) create mode 100755 scripts/boostrap.py create mode 100755 scripts/edit.py create mode 100755 scripts/packages.bb diff --git a/justfile b/justfile index 82fe93c..995f00c 100644 --- a/justfile +++ b/justfile @@ -4,16 +4,22 @@ git := "true" flake := "true" flatpak := "true" +# Show this interactive menu default: @just --choose _git-sync: - git pull --recurse + @if [ -n "$(git status --porcelain)" ]; then \ + echo ">> Error: Git working directory is not clean. Stash or commit your changes first."; \ + exit 1; \ + fi + git pull --ff-only --recurse-submodules git submodule update --remote --recursive _flake-update: nix flake update +# Sync git and update flake inputs (override with git=false or flake=false) prebuild: @if [ "{{git}}" == "true" ]; then \ echo ">> Syncing Git..."; \ @@ -25,50 +31,35 @@ prebuild: fi _update-root: - if `/usr/bin/env grep -Rq "nixos" /etc/*-release`; then \ - sudo nixos-rebuild switch --flake .?submodules=1#$HOSTNAME; \ + @if [ -f /etc/NIXOS ] && grep -q "nixos" /etc/os-release 2>/dev/null; then \ + sudo nixos-rebuild switch --flake .?submodules=1#$(hostname); \ fi +# Rebuild and switch NixOS system configuration update-root: prebuild _update-root +# Install Home Manager standalone (useful for initial non-NixOS setup) install-home: - home-manager switch --flake .?submodules=1#$USER@core \ + home-manager switch --flake .?submodules=1#$(whoami)@core \ --extra-experimental-features nix-command \ --extra-experimental-features flakes _update-home: - home-manager switch --flake .?submodules=1#$USER@$HOSTNAME \ - || home-manager switch --flake .?submodules=1#$USER@core + home-manager switch --flake .?submodules=1#$(whoami)@$(hostname) \ + || home-manager switch --flake .?submodules=1#$(whoami)@core +# Rebuild and switch Home Manager configuration update-home: prebuild _update-home -quick-update-root: - just git=false flake=false update-root - -quick-update-home: - just git=false flake=false update-home - +# Quick update skipping git and flake syncs quick-update: just git=false flake=false update -nogit-update-root: - just git=false update-root - -nogit-update-home: - just git=false update-home - +# Update the system without fetching the latest git commits nogit-update: just git=false update -noflake-update-root: - just flake=false update-root - -noflake-update-home: - just flake=false update-home - -noflake-update: - just flake=false update - +# Update and clean up Flatpak packages update-flatpaks: @if [ "{{flatpak}}" == "true" ] && command -v flatpak &> /dev/null; then \ echo ">> Updating Flatpaks..."; \ @@ -78,17 +69,21 @@ update-flatpaks: echo ">> Flatpak not found or disabled. Skipping."; \ fi +# View the 5 most recent NixOS and Home Manager generations history: @echo ">> System Generations:" @nix-env -p /nix/var/nix/profiles/system --list-generations | tail -n 5 @echo "\n>> Home Manager Generations:" @home-manager generations | head -n 5 +# Open a Nix REPL loaded with the current flake repl: nix repl --file flake.nix +# Fully update the system, home-manager, and flatpaks update: prebuild _update-root _update-home update-flatpaks +# Run Nix and Flatpak garbage collection. Optionally specify age (e.g., 'just cleanup 7d') cleanup days="": @if [ -n "{{days}}" ]; then \ echo ">> Deleting system generations older than {{days}}..."; \ @@ -102,223 +97,14 @@ cleanup days="": flatpak uninstall --unused -y; \ fi +# Open the editor (nvim, yazi, or heh) for a target edit target: - #!/usr/bin/env python3 - import os - import subprocess - import sys + @scripts/edit.py "{{target}}" - target = "{{target}}" - - # If it doesn't exist, assume we are creating a new text file - if not os.path.exists(target): - subprocess.run(["nvim", target]) - sys.exit(0) - - # If it's a directory, use yazi - if os.path.isdir(target): - subprocess.run(["yazi", target]) - else: - # Check if the file is text or binary by attempting to read it - is_text = True - try: - with open(target, 'tr', encoding='utf-8') as check_file: - check_file.read(1024) - except UnicodeDecodeError: - is_text = False - - if is_text: - subprocess.run(["nvim", target]) - else: - subprocess.run(["heh", target]) - -# Opens the relevant packages.nix file for editing. -# Flags: -# --home : Edit home-manager config instead of NixOS config -# --sys : Specify system (defaults to current hostname) -# --user : Specify user (defaults to current user, only relevant if --home is passed) -# --no-update : Skip the auto-update after editing +# Edit packages.nix for a specific system/user (run 'just packages help' for flags) packages *flags: - #!/usr/bin/env bash - set -e + @scripts/packages.bb {{flags}} - # Inject 'just' parameters into the bash script's positional arguments - eval set -- "{{flags}}" - - HOME_FLAG=false - SYS="$(hostname)" - USER_VAL="$(whoami)" - UPDATE_FLAG=true - - # Parse command line flags - while [[ $# -gt 0 ]]; do - case "$1" in - --help|help|-h) - echo "Usage: just packages [OPTIONS]" - echo "" - echo "Opens the packages.nix file for editing and optionally updates." - echo "" - echo "Options:" - echo " home, --home Edit home-manager config instead of NixOS config" - echo " sys, --sys Specify system (defaults to current hostname: $(hostname))" - echo " user, --user Specify user (defaults to current user: $(whoami))" - echo " update, --update Run auto-update after editing (Default)" - echo " no-update, --no-update Skip the auto-update after editing" - echo " help, --help, -h Show this help message" - echo "" - echo "Examples:" - echo " just packages home (Edits HM for current system/user)" - echo " just packages sys homeserver no-update (Edits NixOS on homeserver, skips update)" - exit 0 - ;; - --home|home) - HOME_FLAG=true - shift - ;; - --sys|sys) - SYS="$2" - shift 2 - ;; - --user|user) - USER_VAL="$2" - shift 2 - ;; - --no-update|no-update) - UPDATE_FLAG=false - shift - ;; - --update|update) - UPDATE_FLAG=true - shift - ;; - *) - echo "Unknown option: $1" - echo "Run 'just packages help' for valid options." - exit 1 - ;; - esac - done - - # Determine the target path - if [ "$HOME_FLAG" = "true" ]; then - TARGET="home-manager/$SYS/packages.nix" - else - TARGET="nixos/$SYS/packages.nix" - fi - - echo ">> Editing $TARGET..." - just edit "$TARGET" - - if [ "$UPDATE_FLAG" = "true" ]; then - echo ">> Applying updates..." - just quick-update - else - echo ">> Skipping update (no-update provided)." - fi - -# Bootstraps a system from scratch. Clones the repo and installs configs based on OS. +# Bootstrap a fresh system from the repo bootstrap: - #!/usr/bin/env python3 - import os - import sys - import subprocess - import socket - import getpass - from pathlib import Path - - REPO_URL = "https://github.com/Cian-H/My_NixOS_Config" - TARGET_DIR = os.path.expanduser("~/.config/nix") - - # Prevent running if directory already exists - if os.path.exists(TARGET_DIR): - print(f"Error: {TARGET_DIR} already exists. Aborting bootstrap.", file=sys.stderr) - sys.exit(1) - - # Clone the repository - print(f">> Cloning {REPO_URL} into {TARGET_DIR}...") - try: - subprocess.run(["git", "clone", REPO_URL, TARGET_DIR], check=True) - except subprocess.CalledProcessError: - print("Error: Failed to clone repository.", file=sys.stderr) - sys.exit(1) - - # Change to the newly cloned repo directory for remaining commands - os.chdir(TARGET_DIR) - - # OS Detection - is_nixos = False - if os.path.exists("/etc/os-release"): - with open("/etc/os-release", "r") as f: - if "ID=nixos" in f.read(): - is_nixos = True - - target_sys = socket.gethostname() - target_user = getpass.getuser() - - # Determine if system configuration directories exist - def config_exists(sys_name): - has_nixos = os.path.isdir(f"nixos/{sys_name}") - has_hm = os.path.isdir(f"home-manager/{sys_name}") - return (has_nixos and has_hm) if is_nixos else has_hm - - # Handle Missing Configurations - if not config_exists(target_sys): - print(f"\n>> Configuration for system '{target_sys}' (user: '{target_user}') not found.") - ans = input("Do you want to create the missing directories now? [y/N]: ").strip().lower() - - if ans == 'y': - print(">> Creating directories...") - if is_nixos: - os.makedirs(f"nixos/{target_sys}", exist_ok=True) - os.makedirs(f"home-manager/{target_sys}", exist_ok=True) - - print(">> Opening yazi. Please add your configs and exit yazi when finished.") - subprocess.run(["yazi", TARGET_DIR]) - else: - ans = input("Do you want to switch to one of the existing configs instead? [y/N]: ").strip().lower() - if ans == 'y': - # Gather available options - opts = set() - if os.path.exists("nixos"): - opts.update([d for d in os.listdir("nixos") if os.path.isdir(f"nixos/{d}")]) - if os.path.exists("home-manager"): - opts.update([d for d in os.listdir("home-manager") if os.path.isdir(f"home-manager/{d}")]) - - # Filter out base/core directories - opts = [o for o in opts if o not in ["core"] and not o.startswith(".")] - - print("\nAvailable systems:") - for o in opts: - print(f" - {o}") - - # Option selection for missing parameters - chosen_sys = input(f"\nEnter system to use [default: {target_sys}]: ").strip() - if chosen_sys: - if chosen_sys not in opts: - print(f"Error: '{chosen_sys}' is not a valid option.") - sys.exit(1) - target_sys = chosen_sys - - chosen_user = input(f"Enter user configuration to use [default: {target_user}]: ").strip() - if chosen_user: - target_user = chosen_user - - else: - print("Error: Cannot proceed without a valid configuration. Aborting.", file=sys.stderr) - sys.exit(1) - - print(f"\n>> Proceeding with installation for user '{target_user}' on system '{target_sys}'...") - - # Execute Installations - try: - if is_nixos: - print(">> [NixOS detected] Performing full system and home-manager install...") - subprocess.run(["sudo", "nixos-rebuild", "switch", "--flake", f".#{target_sys}"], check=True) - subprocess.run(["home-manager", "switch", "--flake", f".#{target_user}@{target_sys}"], check=True) - else: - print(">> [Non-NixOS detected] Performing home-manager install only...") - subprocess.run(["home-manager", "switch", "--flake", f".#{target_user}@{target_sys}"], check=True) - print("\n>> Bootstrap complete! Welcome to your new environment.") - except subprocess.CalledProcessError: - print("\nError: An issue occurred during installation.", file=sys.stderr) - sys.exit(1) + @scripts/bootstrap.py diff --git a/scripts/boostrap.py b/scripts/boostrap.py new file mode 100755 index 0000000..ca7602a --- /dev/null +++ b/scripts/boostrap.py @@ -0,0 +1,217 @@ +#!/usr/bin/env nix-shell +#!nix-shell -i python3 -p "python3.withPackages(ps: with ps; [ typer rich ])" +import os +import sys +import subprocess +import socket +import getpass +import shutil + +REPO_URL = "https://github.com/Cian-H/My_NixOS_Config" +TARGET_DIR = os.path.expanduser("~/.config/nix") + + +def command_exists(cmd: str) -> bool: + """Check if a command is available on the system.""" + return shutil.which(cmd) is not None + + +def check_preconditions(target_dir: str): + """Ensure the system is in a valid state to begin bootstrapping.""" + if not command_exists("git"): + print( + "Error: 'git' is not installed. Please install git before bootstrapping.", + file=sys.stderr, + ) + sys.exit(1) + + if os.path.exists(target_dir): + print( + f"Error: {target_dir} already exists. Aborting bootstrap.", file=sys.stderr + ) + sys.exit(1) + + +def clone_repository(repo_url: str, target_dir: str): + """Clone the configuration repository and enter the directory.""" + print(f">> Cloning {repo_url} into {target_dir}...") + try: + subprocess.run(["git", "clone", repo_url, target_dir], check=True) + except subprocess.CalledProcessError: + print("Error: Failed to clone repository.", file=sys.stderr) + sys.exit(1) + + os.chdir(target_dir) + + +def detect_nixos() -> bool: + """Check if the current host system is NixOS.""" + if os.path.exists("/etc/os-release"): + with open("/etc/os-release", "r") as f: + if "ID=nixos" in f.read(): + return True + return False + + +def has_config(sys_name: str, is_nixos: bool) -> bool: + """Determine if system configuration directories exist for the given hostname.""" + has_nixos_dir = os.path.isdir(f"nixos/{sys_name}") + has_hm_dir = os.path.isdir(f"home-manager/{sys_name}") + return (has_nixos_dir and has_hm_dir) if is_nixos else has_hm_dir + + +def get_available_systems() -> list: + """Discover existing system configurations in the repository.""" + opts = set() + if os.path.exists("nixos"): + opts.update([d for d in os.listdir("nixos") if os.path.isdir(f"nixos/{d}")]) + if os.path.exists("home-manager"): + opts.update( + [ + d + for d in os.listdir("home-manager") + if os.path.isdir(f"home-manager/{d}") + ] + ) + + # Filter out base/core directories and hidden folders, then sort for consistent output + return sorted([o for o in opts if o not in ["core"] and not o.startswith(".")]) + + +def setup_new_config(target_sys: str, target_dir: str, is_nixos: bool): + """Create boilerplate directories for a missing system and open an editor.""" + print(">> Creating directories...") + if is_nixos: + os.makedirs(f"nixos/{target_sys}", exist_ok=True) + os.makedirs(f"home-manager/{target_sys}", exist_ok=True) + + if command_exists("yazi"): + print(">> Opening yazi. Please add your configs and exit yazi when finished.") + subprocess.run(["yazi", target_dir]) + else: + print( + f">> Yazi not found. Please manually configure your files in {target_dir} in another terminal." + ) + input("Press Enter when you have finished adding your configurations...") + + +def prompt_for_existing_config(current_sys: str, current_user: str) -> tuple[str, str]: + """Prompt the user to select an existing configuration from the repo.""" + opts = get_available_systems() + if not opts: + print( + "Error: No existing configurations found. Cannot proceed.", file=sys.stderr + ) + sys.exit(1) + + print("\nAvailable systems:") + for o in opts: + print(f" - {o}") + + chosen_sys = input(f"\nEnter system to use [default: {current_sys}]: ").strip() + if chosen_sys: + if chosen_sys not in opts: + print(f"Error: '{chosen_sys}' is not a valid option.") + sys.exit(1) + target_sys = chosen_sys + else: + target_sys = current_sys + # Failsafe: if they press enter, but the default hostname isn't actually a valid config + if target_sys not in opts: + print( + f"Error: Default '{target_sys}' is not in available systems. You must type a valid option." + ) + sys.exit(1) + + chosen_user = input( + f"Enter user configuration to use [default: {current_user}]: " + ).strip() + target_user = chosen_user if chosen_user else current_user + + return target_sys, target_user + + +def resolve_configuration( + target_sys: str, target_user: str, target_dir: str, is_nixos: bool +) -> tuple[str, str]: + """Ensure a valid configuration target exists, prompting the user for resolution if necessary.""" + if has_config(target_sys, is_nixos): + return target_sys, target_user + + print( + f"\n>> Configuration for system '{target_sys}' (user: '{target_user}') not found." + ) + ans = ( + input("Do you want to create the missing directories now? [y/N]: ") + .strip() + .lower() + ) + + if ans == "y": + setup_new_config(target_sys, target_dir, is_nixos) + return target_sys, target_user + + ans = ( + input("Do you want to switch to one of the existing configs instead? [y/N]: ") + .strip() + .lower() + ) + if ans == "y": + return prompt_for_existing_config(target_sys, target_user) + + print( + "Error: Cannot proceed without a valid configuration. Aborting.", + file=sys.stderr, + ) + sys.exit(1) + + +def execute_installation(target_sys: str, target_user: str, is_nixos: bool): + """Execute the final NixOS or Home Manager commands to build the system.""" + print( + f"\n>> Proceeding with installation for user '{target_user}' on system '{target_sys}'..." + ) + try: + if is_nixos: + print( + ">> [NixOS detected] Performing full system and home-manager install..." + ) + subprocess.run( + ["sudo", "nixos-rebuild", "switch", "--flake", f".#{target_sys}"], + check=True, + ) + subprocess.run( + ["home-manager", "switch", "--flake", f".#{target_user}@{target_sys}"], + check=True, + ) + else: + print(">> [Non-NixOS detected] Performing home-manager install only...") + subprocess.run( + ["home-manager", "switch", "--flake", f".#{target_user}@{target_sys}"], + check=True, + ) + print("\n>> Bootstrap complete! Welcome to your new environment.") + except subprocess.CalledProcessError as e: + print(f"\nError: Command '{' '.join(e.cmd)}' failed with exit status {e.returncode}.", file=sys.stderr) + sys.exit(e.returncode) + + +def main(): + """Main orchestration loop.""" + check_preconditions(TARGET_DIR) + clone_repository(REPO_URL, TARGET_DIR) + + is_nixos = detect_nixos() + hostname = socket.gethostname() + username = getpass.getuser() + + # The returned tuple contains the final resolved system/user strings + final_sys, final_user = resolve_configuration( + hostname, username, TARGET_DIR, is_nixos + ) + + execute_installation(final_sys, final_user, is_nixos) + + +if __name__ == "__main__": + main() diff --git a/scripts/edit.py b/scripts/edit.py new file mode 100755 index 0000000..2f58c2f --- /dev/null +++ b/scripts/edit.py @@ -0,0 +1,40 @@ +#!/usr/bin/env nix-shell +#!nix-shell -i python3 -p "python3.withPackages(ps: with ps; [ typer rich ])" +import typer +import subprocess +from pathlib import Path +import mimetypes + + +def is_text_file(filepath): + mime_type, _ = mimetypes.guess_type(filepath) + if mime_type: + return mime_type.startswith("text/") + + try: + with open(filepath, "tr") as f: + f.read(1024) + return True + except (UnicodeDecodeError, IsADirectoryError): + return False + + +app = typer.Typer() + +@app.command() +def edit(target: Path = typer.Argument(..., help="File or directory to edit")): + if not target.exists(): + subprocess.run(["nvim", str(target)]) + return + + if target.is_dir(): + subprocess.run(["yazi", str(target)]) + return + + if is_text_file(str(target)): + subprocess.run(["nvim", str(target)]) + else: + subprocess.run(["heh", str(target)]) + +if __name__ == "__main__": + app() diff --git a/scripts/packages.bb b/scripts/packages.bb new file mode 100755 index 0000000..870b2cd --- /dev/null +++ b/scripts/packages.bb @@ -0,0 +1,45 @@ +#!/usr/bin/env nix-shell +#!nix-shell -i bb -p babashka + +(require '[babashka.cli :as cli] + '[babashka.process :refer [shell]] + '[clojure.string :as str]) + +(defn get-hostname [] + (let [env-host (System/getenv "HOSTNAME") + etc-host (try (str/trim (slurp "/etc/hostname")) (catch Exception _ nil)) + java-host (try (.getHostName (java.net.InetAddress/getLocalHost)) (catch Exception _ nil))] + (first (remove str/blank? [env-host etc-host java-host "default-system"])))) + +(def cli-spec + {:spec {:home {:coerce :boolean :desc "Edit home-manager config instead of NixOS config"} + :sys {:desc "Specify system (defaults to current hostname)"} ; Removed :default from spec + :user {:default (System/getProperty "user.name") + :desc "Specify user (defaults to current user)"} + :update {:coerce :boolean :default true + :desc "Run auto-update after editing"} + :help {:alias :h :coerce :boolean :desc "Show this help message"}}}) + +(let [parsed (cli/parse-opts *command-line-args* cli-spec) + opts (:opts parsed)] + + (when (:help opts) + (println "Usage: just packages [OPTIONS]") + (println (cli/format-opts cli-spec)) + (System/exit 0)) + + (let [ + sys (let [s (:sys opts)] + (if (str/blank? s) (get-hostname) s)) + target (if (:home opts) + (str "home-manager/" sys "/packages.nix") + (str "nixos/" sys "/packages.nix"))] + + (println ">> Editing" target "...") + (shell "just" "edit" target) + + (if (:update opts) + (do + (println ">> Applying updates...") + (shell "just" "quick-update")) + (println ">> Skipping update (no-update provided)."))))