diff --git a/flake.nix b/flake.nix index f087d7e..4543659 100644 --- a/flake.nix +++ b/flake.nix @@ -16,6 +16,7 @@ ./system/default/nix.nix ./system/default/packages.nix ./system/default/prompt.nix + ./system/default/scripts.nix ./system/default/ssh-authorized-keys.nix ]; }; @@ -46,6 +47,7 @@ nixosModules.develop = { ... }: { imports = [ ./system/develop/packages.nix + ./system/develop/scripts.nix ]; }; diff --git a/pkgs/tb-dev-scripts/cmake_update_fetchcontent.py b/pkgs/tb-dev-scripts/cmake_update_fetchcontent.py new file mode 100755 index 0000000..2ceee34 --- /dev/null +++ b/pkgs/tb-dev-scripts/cmake_update_fetchcontent.py @@ -0,0 +1,552 @@ +#!/usr/bin/env python3 +""" +CMakeLists.txt FetchContent Dependency Updater + +This script: +1. Searches upward for all CMakeLists.txt files containing FetchContent +2. Extracts current git hashes/tags/branches +3. Queries remote repositories for latest versions +4. Shows available updates and prompts for confirmation +5. Updates CMakeLists.txt files with new versions +""" + +import re +import subprocess +import sys +import urllib.request +import json +from pathlib import Path +from typing import List, Dict, Optional, Tuple + + +def parse_semver(version_string: str) -> Tuple[int, int, int]: + """Parse a semantic version string (e.g., 'v1.2.3' or '1.2.3') into (major, minor, patch)""" + # Remove 'v' prefix if present + clean = version_string.lstrip('v') + + # Split by dots and take first 3 components + parts = clean.split('.') + major = int(parts[0]) if len(parts) > 0 and parts[0].isdigit() else 0 + minor = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0 + + # Handle patch with possible suffix (e.g., "3-rc1") + patch = 0 + if len(parts) > 2: + patch_str = parts[2].split('-')[0] # Take only numeric part before dash + patch = int(patch_str) if patch_str.isdigit() else 0 + + return (major, minor, patch) + + +class FetchContentDependency: + """Represents a single FetchContent_Declare dependency""" + + def __init__(self, name: str, git_repo: str, git_tag: str, + file_path: Path, line_start: int, line_end: int, full_text: str): + self.name = name + self.git_repo = git_repo + self.git_tag = git_tag + self.file_path = file_path + self.line_start = line_start + self.line_end = line_end + self.full_text = full_text + self.latest_version: Optional[str] = None + self.update_available = False + + def __repr__(self): + return f"FetchContentDependency({self.name}, {self.git_tag})" + + +def extract_subdirectories(cmake_file: Path, content: str) -> List[Path]: + """Extract add_subdirectory() calls from a CMakeLists.txt file content""" + subdirs = [] + try: + # Match add_subdirectory(path) with/without quotes + pattern = r'add_subdirectory\s*\(\s*(["\']?)([^)"\']+)\1' + for match in re.finditer(pattern, content, re.IGNORECASE): + subdir_name = match.group(2).strip() + # Resolve relative to the CMakeLists.txt directory + subdir_path = (cmake_file.parent / subdir_name).resolve() + subdirs.append(subdir_path) + except Exception as e: + print(f"Warning: Error parsing subdirectories in {cmake_file}: {e}", file=sys.stderr) + return subdirs + + +def find_cmake_files_recursive(cmake_dir: Path, visited: set) -> List[Path]: + """Recursively find CMakeLists.txt files by following add_subdirectory() calls""" + cmake_files = [] + + # Avoid infinite loops + cmake_dir = cmake_dir.resolve() + if cmake_dir in visited: + return cmake_files + visited.add(cmake_dir) + + # Check if this directory has a CMakeLists.txt + cmake_path = cmake_dir / "CMakeLists.txt" + if not cmake_path.exists(): + return cmake_files + + # Read the file once + try: + content = cmake_path.read_text() + except Exception as e: + print(f"Warning: Error reading {cmake_path}: {e}", file=sys.stderr) + return cmake_files + + # Check if it contains FetchContent (if so, add to results) + if "FetchContent" in content: + cmake_files.append(cmake_path) + + # Always recurse into subdirectories (even if this file has no FetchContent) + # because subdirectories might have FetchContent + subdirs = extract_subdirectories(cmake_path, content) + for subdir in subdirs: + cmake_files.extend(find_cmake_files_recursive(subdir, visited)) + + return cmake_files + + +def find_cmake_files(start_dir: Path = None) -> List[Path]: + """Search upward from start_dir for root CMakeLists.txt, then recursively find all via add_subdirectory()""" + if start_dir is None: + start_dir = Path.cwd() + + current = start_dir.resolve() + + # Search upward to find the root CMakeLists.txt + root_cmake = None + while True: + cmake_path = current / "CMakeLists.txt" + if cmake_path.exists(): + root_cmake = cmake_path + + # Stop at filesystem root + if current.parent == current: + break + current = current.parent + + # If we found a root CMakeLists.txt, start recursive search from there + if root_cmake: + visited = set() + return sorted(set(find_cmake_files_recursive(root_cmake.parent, visited))) + + return [] + + +def parse_fetchcontent(cmake_file: Path) -> List[FetchContentDependency]: + """Parse FetchContent_Declare blocks from CMakeLists.txt""" + content = cmake_file.read_text() + lines = content.split('\n') + + dependencies = [] + + # Pattern to match FetchContent_Declare blocks + # This matches the opening, extracts the name, then finds GIT_REPOSITORY and GIT_TAG + i = 0 + while i < len(lines): + line = lines[i] + + # Look for FetchContent_Declare( + if "FetchContent_Declare" in line: + start_line = i + + # Find the closing parenthesis + paren_count = line.count('(') - line.count(')') + block_lines = [line] + j = i + 1 + + while j < len(lines) and paren_count > 0: + block_lines.append(lines[j]) + paren_count += lines[j].count('(') - lines[j].count(')') + j += 1 + + end_line = j - 1 + full_block = '\n'.join(block_lines) + + # Extract dependency name + name_match = re.search( + r'FetchContent_Declare\s*\(\s*(\S+)', full_block + ) + if not name_match: + i = j + continue + + dep_name = name_match.group(1) + + # Extract GIT_REPOSITORY + repo_match = re.search(r'GIT_REPOSITORY\s+(\S+)', full_block) + # Extract GIT_TAG + tag_match = re.search(r'GIT_TAG\s+(\S+)', full_block) + + if repo_match and tag_match: + git_repo = repo_match.group(1) + git_tag = tag_match.group(1) + + dep = FetchContentDependency( + name=dep_name, + git_repo=git_repo, + git_tag=git_tag, + file_path=cmake_file, + line_start=start_line, + line_end=end_line, + full_text=full_block + ) + dependencies.append(dep) + + i = j + else: + i += 1 + + return dependencies + + +def run_git_command(args: List[str]) -> Optional[str]: + """Run a git command and return output, or None on error""" + try: + result = subprocess.run( + args, + capture_output=True, + text=True, + timeout=30, + check=True + ) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + print( + f"Warning: Error running git command: {' '.join(args)}", + file=sys.stderr + ) + print(f" {e.stderr}", file=sys.stderr) + return None + except subprocess.TimeoutExpired: + print( + f"Warning: Timeout running git command: {' '.join(args)}", + file=sys.stderr + ) + return None + + +def get_latest_commit_hash(repo_url: str, branch: str) -> Optional[str]: + """Get the latest commit hash for a branch using git ls-remote""" + output = run_git_command( + ['git', 'ls-remote', repo_url, f'refs/heads/{branch}'] + ) + + if not output: + print( + f"Warning: Branch '{branch}' not found in {repo_url}", + file=sys.stderr + ) + return None + + # Output format: "commit_hash\trefs/heads/branch" + commit_hash = output.split()[0] + return commit_hash + + +def extract_github_info(repo_url: str) -> Optional[Tuple[str, str]]: + """Extract owner/repo from GitHub URL""" + # Handle both https and git@ URLs + patterns = [ + r'github\.com[:/]([^/]+)/([^/.]+?)(?:\.git)?$', + ] + + for pattern in patterns: + match = re.search(pattern, repo_url) + if match: + return match.group(1), match.group(2) + + return None + + +def get_latest_github_release(repo_url: str) -> Optional[str]: + """Get the latest release tag from GitHub API""" + github_info = extract_github_info(repo_url) + if not github_info: + return None + + owner, repo = github_info + api_url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest" + + try: + req = urllib.request.Request(api_url) + req.add_header('Accept', 'application/vnd.github.v3+json') + + with urllib.request.urlopen(req, timeout=10) as response: + data = json.loads(response.read()) + return data.get('tag_name') + except urllib.error.HTTPError as e: + if e.code == 404: + # No releases found, try tags + return get_latest_github_tag(repo_url) + print( + f"Warning: Error fetching GitHub release for {owner}/{repo}: {e}", + file=sys.stderr + ) + return None + except Exception as e: + print(f"Warning: Error fetching GitHub release: {e}", file=sys.stderr) + return None + + +def get_latest_github_tag(repo_url: str) -> Optional[str]: + """Get the latest tag from GitHub API""" + github_info = extract_github_info(repo_url) + if not github_info: + return None + + owner, repo = github_info + api_url = f"https://api.github.com/repos/{owner}/{repo}/tags" + + try: + req = urllib.request.Request(api_url) + req.add_header('Accept', 'application/vnd.github.v3+json') + + with urllib.request.urlopen(req, timeout=10) as response: + data = json.loads(response.read()) + if data: + # Filter to semantic version tags and find the latest + version_tags = [] + for tag in data: + tag_name = tag['name'] + try: + # Try to parse as semantic version + v = parse_semver(tag_name) + version_tags.append((v, tag_name)) + except Exception: + pass + + if version_tags: + # Sort by version and return the latest + version_tags.sort(reverse=True) + return version_tags[0][1] + + # If no semantic versions, return the first tag + return data[0]['name'] + return None + except Exception as e: + print(f"Warning: Error fetching GitHub tags: {e}", file=sys.stderr) + return None + + +def is_commit_hash(git_tag: str) -> bool: + """Check if a git tag looks like a commit hash""" + return bool(re.match(r'^[0-9a-f]{7,40}$', git_tag)) + + +def is_semantic_version(git_tag: str) -> bool: + """Check if a git tag is a semantic version""" + try: + parse_semver(git_tag) + return True + except Exception: + return False + + +def compare_versions(current: str, latest: str) -> bool: + """Compare two version strings, return True if latest is newer""" + try: + current_v = parse_semver(current) + latest_v = parse_semver(latest) + return latest_v > current_v + except Exception: + return current != latest + + +def fetch_latest_version(dep: FetchContentDependency) -> None: + """Fetch the latest version for a dependency""" + git_tag = dep.git_tag + git_repo = dep.git_repo + + # Case 1: Branch name (main, master, develop, etc.) + if git_tag in ['main', 'master', 'develop', 'trunk']: + latest = get_latest_commit_hash(git_repo, git_tag) + if latest: + dep.latest_version = latest + # Always update branches to commit hash + dep.update_available = True + return + + # Case 2: Commit hash - check if it's the latest on main/master + # IMPORTANT: Check this BEFORE semantic version to avoid false positives + if is_commit_hash(git_tag): + # Try to get latest from main first, then master + for branch in ['main', 'master']: + latest = get_latest_commit_hash(git_repo, branch) + if latest: + dep.latest_version = latest + dep.update_available = (git_tag != latest) + return + return + + # Case 3: Semantic version tag + if is_semantic_version(git_tag): + latest = get_latest_github_release(git_repo) + if latest: + dep.latest_version = latest + dep.update_available = compare_versions(git_tag, latest) + return + + # Case 4: Unknown format - treat as branch name + latest = get_latest_commit_hash(git_repo, git_tag) + if latest: + dep.latest_version = latest + # Convert branch to commit hash + dep.update_available = True + else: + print( + f"Warning: Could not determine version type for " + f"{dep.name}: {git_tag}", + file=sys.stderr + ) + + +def print_updates_table(dependencies: List[FetchContentDependency]) -> None: + """Print a formatted table of available updates""" + updates = [dep for dep in dependencies if dep.update_available] + + if not updates: + print("\nNo updates available. All dependencies are up to date.") + return + + print(f"\n{len(updates)} update(s) available:\n") + + # Calculate column widths + name_width = max(len(dep.name) for dep in updates) + current_width = max(len(dep.git_tag) for dep in updates) + latest_width = max(len(dep.latest_version or '') for dep in updates) + file_width = max(len(str(dep.file_path.name)) for dep in updates) + + # Print header + header = ( + f"{'Name':<{name_width}} {'Current':<{current_width}} " + f"{'Latest':<{latest_width}} {'File':<{file_width}}" + ) + print(header) + print('-' * len(header)) + + # Print rows + for dep in updates: + current = dep.git_tag + latest = dep.latest_version or '?' + filename = dep.file_path.name + + # Truncate long hashes for display + if len(current) > 12: + current_display = current[:12] + '...' + else: + current_display = current + + if len(latest) > 12: + latest_display = latest[:12] + '...' + else: + latest_display = latest + + print( + f"{dep.name:<{name_width}} {current_display:<{current_width}} " + f"{latest_display:<{latest_width}} {filename:<{file_width}}" + ) + + +def update_cmake_file(dep: FetchContentDependency) -> None: + """Update a single dependency in its CMakeLists.txt file""" + content = dep.file_path.read_text() + + # Create the updated version of this specific FetchContent block + old_tag_line = f"GIT_TAG {dep.git_tag}" + new_tag_line = f"GIT_TAG {dep.latest_version}" + + if old_tag_line not in dep.full_text: + print( + f"Warning: Could not find exact match for {dep.name} tag line", + file=sys.stderr + ) + return + + # Update only this dependency's block + new_block = dep.full_text.replace(old_tag_line, new_tag_line, 1) + + # Replace the old block with the new block in the file content + # Use replace with count=1 to only replace the first occurrence + if dep.full_text not in content: + print( + f"Warning: Could not find block for {dep.name} in file", + file=sys.stderr + ) + return + + new_content = content.replace(dep.full_text, new_block, 1) + + if new_content == content: + print(f"Warning: No changes made for {dep.name}", file=sys.stderr) + return + + # Write back + dep.file_path.write_text(new_content) + print(f"Updated {dep.name} in {dep.file_path.name}") + + +def main(): + """Main entry point""" + print("Searching for CMakeLists.txt files with FetchContent...") + + cmake_files = find_cmake_files() + + if not cmake_files: + print("No CMakeLists.txt files with FetchContent found.") + return + + print(f"Found {len(cmake_files)} CMakeLists.txt file(s):") + for f in cmake_files: + print(f" - {f}") + + # Parse all dependencies + all_deps = [] + for cmake_file in cmake_files: + deps = parse_fetchcontent(cmake_file) + all_deps.extend(deps) + + print(f"\nFound {len(all_deps)} FetchContent dependencies") + + # Fetch latest versions + print("\nChecking for updates...") + for dep in all_deps: + print(f" Checking {dep.name} ({dep.git_repo})...", end=' ') + fetch_latest_version(dep) + if dep.update_available: + print("UPDATE AVAILABLE") + elif dep.latest_version: + print("up to date") + else: + print("SKIPPED (error)") + + # Show updates table + print_updates_table(all_deps) + + # Get confirmation + updates = [dep for dep in all_deps if dep.update_available] + if not updates: + return + + print() + response = input( + f"Apply {len(updates)} update(s)? [y/N]: " + ).strip().lower() + + if response != 'y': + print("Aborted.") + return + + # Apply updates + print("\nApplying updates...") + for dep in updates: + update_cmake_file(dep) + + print(f"\nSuccessfully updated {len(updates)} dependencies.") + print("Please review the changes and test your build.") + + +if __name__ == "__main__": + main() diff --git a/pkgs/tb-dev-scripts/default.nix b/pkgs/tb-dev-scripts/default.nix new file mode 100644 index 0000000..3aa4a12 --- /dev/null +++ b/pkgs/tb-dev-scripts/default.nix @@ -0,0 +1,17 @@ +{ pkgs, lib }: + +pkgs.symlinkJoin { + name = "tb-dev-scripts"; + meta = { + description = "Custom development scripts for TB"; + maintainers = [ ]; + }; + paths = [ + (pkgs.writers.writePython3Bin ",cmake_update_fetchcontent" { + libraries = [ ]; + makeWrapperArgs = [ + "--prefix PATH : ${lib.makeBinPath [ pkgs.git ]}" + ]; + } (builtins.readFile ./cmake_update_fetchcontent.py)) + ]; +} diff --git a/pkgs/tb-scripts/default.nix b/pkgs/tb-scripts/default.nix new file mode 100644 index 0000000..d13b71e --- /dev/null +++ b/pkgs/tb-scripts/default.nix @@ -0,0 +1,19 @@ +{ pkgs }: + +pkgs.symlinkJoin { + name = "tb-scripts"; + meta = { + description = "Custom utility scripts for TB - available on all systems"; + maintainers = [ ]; + }; + paths = [ + (pkgs.writeShellApplication { + name = ",jq_reformat"; + runtimeInputs = [ pkgs.jq ]; + text = builtins.readFile ./jq_reformat.sh; + }) + (pkgs.writers.writePython3Bin ",rename_lower" { + libraries = [ ]; + } (builtins.readFile ./rename_lower.py)) + ]; +} diff --git a/pkgs/tb-scripts/jq_reformat.sh b/pkgs/tb-scripts/jq_reformat.sh new file mode 100644 index 0000000..257091d --- /dev/null +++ b/pkgs/tb-scripts/jq_reformat.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash + +# +# jq_reformat - Reformat JSON files in-place using jq +# +# Usage: jq_reformat FILE [FILE...] +# +# This script reformats JSON files in-place by running them through jq. +# It preserves the original files by using atomic temporary file replacement, +# ensuring data safety even if the process is interrupted. +# +# Examples: +# jq_reformat config.json +# jq_reformat *.json +# jq_reformat data/*.json settings.json +# + +set -euo pipefail + +# +# Show usage information +# +usage() { + cat <&2 + return 1 + fi + + if [[ ! -r "$file" ]]; then + echo "Error: File not readable: $file" >&2 + return 1 + fi + + # Create a secure temporary file in the same directory as the target + # This ensures we're on the same filesystem for atomic mv operation + temp_file=$(mktemp "${file}.XXXXXX") || { + echo "Error: Failed to create temporary file for: $file" >&2 + return 1 + } + + # Set up cleanup trap to remove temp file on error + # shellcheck disable=SC2064 + trap "rm -f '$temp_file'" EXIT ERR + + # Run jq to reformat the JSON + # - Read from the original file + # - Write to temp file + # - If successful, atomically replace the original + if jq . <"$file" >"$temp_file" 2>/dev/null; then + # Preserve original file permissions + chmod --reference="$file" "$temp_file" 2>/dev/null || true + + # Atomically replace the original file + mv "$temp_file" "$file" + + # Clear the trap since we succeeded + trap - EXIT ERR + return 0 + else + # jq failed - the file is likely not valid JSON + echo "Error: Failed to parse JSON in: $file" >&2 + rm -f "$temp_file" + trap - EXIT ERR + return 1 + fi +} + +# +# Main script logic +# +main() { + # Handle help flag + if [[ $# -eq 0 ]] || [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then + usage + [[ $# -eq 0 ]] && return 1 + return 0 + fi + + local exit_code=0 + local file + + # Process each file argument + # Using "$@" properly handles filenames with spaces and special characters + for file in "$@"; do + if ! reformat_file "$file"; then + exit_code=1 + # Continue processing other files even if one fails + fi + done + + return "$exit_code" +} + +# Run main function with all arguments +main "$@" diff --git a/pkgs/tb-scripts/rename_lower.py b/pkgs/tb-scripts/rename_lower.py new file mode 100644 index 0000000..792aa71 --- /dev/null +++ b/pkgs/tb-scripts/rename_lower.py @@ -0,0 +1,112 @@ +""" +rename_lower - Rename files to lowercase + +Usage: rename_lower FILE [FILE...] + +This script renames files by converting their filenames to lowercase. +It performs validation to ensure files exist and that the target lowercase +filename doesn't already exist before renaming. + +Examples: + rename_lower MyFile.TXT + rename_lower *.JPG + rename_lower Document.PDF Image.PNG +""" + +import sys +from pathlib import Path + + +def usage(): + """Show usage information""" + print("""Usage: ,rename_lower FILE [FILE...] + +Rename files to lowercase filenames. + +This script converts filenames to lowercase. It validates that files +exist and checks for conflicts before renaming. + +Arguments: + FILE One or more files to rename + +Options: + -h, --help Show this help message + +Examples: + ,rename_lower MyFile.TXT + ,rename_lower *.JPG + ,rename_lower Document.PDF Image.PNG + +Exit codes: + 0 Success - all files renamed (or already lowercase) + 1 Error - invalid arguments, missing files, or conflicts +""") + + +def rename_to_lowercase(filepath: str) -> bool: + """ + Rename a single file to lowercase. + + Arguments: + filepath: Path to the file to rename + + Returns: + True on success, False on error + """ + # Convert to Path object for easier manipulation + original = Path(filepath) + + # Validate that the file exists + if not original.exists(): + print(f"Error: File not found: {filepath}", file=sys.stderr) + return False + + # Get the directory and filename components + directory = original.parent + original_name = original.name + lowercase_name = original_name.lower() + + # Check if the filename is already lowercase + if original_name == lowercase_name: + # Already lowercase, nothing to do + return True + + # Construct the target path + target = directory / lowercase_name + + # Check if target already exists + if target.exists(): + print(f"Error: Target file already exists: {target}", file=sys.stderr) + print(f" Cannot rename: {filepath}", file=sys.stderr) + return False + + # Perform the rename + try: + original.rename(target) + print(f"Renamed: {original_name} -> {lowercase_name}") + return True + except OSError as e: + print(f"Error: Failed to rename {filepath}: {e}", file=sys.stderr) + return False + + +def main(): + """Main script logic""" + # Handle help flag or no arguments + if len(sys.argv) == 1 or sys.argv[1] in ("-h", "--help"): + usage() + return 0 if len(sys.argv) > 1 else 1 + + success = True + + # Process each file argument + for filepath in sys.argv[1:]: + if not rename_to_lowercase(filepath): + success = False + # Continue processing other files even if one fails + + return 0 if success else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/system/default/scripts.nix b/system/default/scripts.nix new file mode 100644 index 0000000..7f223f1 --- /dev/null +++ b/system/default/scripts.nix @@ -0,0 +1,7 @@ +{ pkgs, ... }: + +{ + environment.systemPackages = [ + (pkgs.callPackage ../../pkgs/tb-scripts { }) + ]; +} diff --git a/system/develop/scripts.nix b/system/develop/scripts.nix new file mode 100644 index 0000000..c6363fd --- /dev/null +++ b/system/develop/scripts.nix @@ -0,0 +1,7 @@ +{ pkgs, ... }: + +{ + environment.systemPackages = [ + (pkgs.callPackage ../../pkgs/tb-dev-scripts { }) + ]; +}