This commit introduces two new custom script packages: tb-scripts for all systems and tb-dev-scripts for development machines. These packages provide a maintainable way to distribute utility scripts across the infrastructure with proper dependency management. Created pkgs/ directory with two script collections: 1. **tb-scripts** - General utilities available on all systems: - ,jq_reformat: Reformat JSON files in-place with atomic file operations - ,rename_lower: Convert filenames to lowercase with validation 2. **tb-dev-scripts** - Development-specific tools: - ,cmake_update_fetchcontent: Update CMake FetchContent dependencies All scripts have been significantly enhanced from their original versions: - Proper quoting to handle filenames with spaces - Secure temporary file creation using mktemp - Atomic file replacement to prevent data loss - Input validation and comprehensive error handling - Usage help with -h/--help flag - Extensive inline comments explaining each section - Cleanup traps on error - Complete rewrite in Python for consistency - Validates files exist before attempting rename - Checks if target lowercase filename already exists - Skips files already lowercase (no-op) - Descriptive error messages for each failure case - Usage documentation with examples - Proper exit codes - Interactive CMake FetchContent dependency updater - Recursively finds all CMakeLists.txt files via add_subdirectory() - Queries GitHub API for latest releases/tags - Compares semantic versions and commit hashes - Shows available updates in formatted table - Prompts for confirmation before applying updates - Atomic file updates with validation Scripts are packaged using writeShellApplication with proper dependency injection via runtimeInputs: - tb-scripts requires: jq, python3 - tb-dev-scripts requires: python3, git Dependencies are automatically available in PATH when scripts run, eliminating manual dependency checks. Created system module files to import the script packages: - system/default/scripts.nix: Adds tb-scripts to nixosModules.default - system/develop/scripts.nix: Adds tb-dev-scripts to nixosModules.develop Updated flake.nix to import these modules in the appropriate contexts. - Scripts have proper Nix-managed dependencies - No manual installation or PATH configuration needed - Easy to extend with additional scripts - Scripts are validated with shellcheck during build - Clear separation between all-systems and dev-only utilities - Comprehensive error handling and user documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
572 lines
17 KiB
Python
Executable File
572 lines
17 KiB
Python
Executable File
"""
|
|
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, Optional, Tuple
|
|
|
|
|
|
def parse_semver(version_string: str) -> Tuple[int, int, int]:
|
|
"""
|
|
Parse a semantic version string.
|
|
|
|
Converts version strings like 'v1.2.3' or '1.2.3'
|
|
into (major, minor, patch) tuple.
|
|
"""
|
|
# 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:
|
|
# Take only numeric part before dash
|
|
patch_str = parts[2].split('-')[0]
|
|
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.
|
|
|
|
Follows add_subdirectory() calls to discover all CMake files.
|
|
"""
|
|
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
|
|
# (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 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()
|
|
files = find_cmake_files_recursive(root_cmake.parent, visited)
|
|
return sorted(set(files))
|
|
|
|
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
|
|
# Extracts name, 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()
|