#!/bin/bash # arty.sh - A bash library repository and release management system # Combined functionality from arty.sh (repository management) and whip.sh (release cycle) # Version: 1.0.0 set -euo pipefail ARTY_CONFIG_FILE="${ARTY_CONFIG_FILE:-arty.yml}" ARTY_ENV="${ARTY_ENV:-default}" ARTY_DRY_RUN="${ARTY_DRY_RUN:-0}" ARTY_VERBOSE="${ARTY_VERBOSE:-0}" # Whip configuration WHIP_CONFIG="${WHIP_CONFIG:-arty.yml}" WHIP_HOOKS_DIR=".whip/hooks" WHIP_CHANGELOG="${WHIP_CHANGELOG:-CHANGELOG.md}" # Colors for output - only use colors if output is to a terminal or if FORCE_COLOR is set export FORCE_COLOR=${FORCE_COLOR:-"1"} if [[ "$FORCE_COLOR" = "0" ]]; then export RED='' export GREEN='' export YELLOW='' export BLUE='' export CYAN='' export MAGENTA='' export BOLD='' export NC='' else export RED='\033[0;31m' export GREEN='\033[0;32m' export YELLOW='\033[1;33m' export BLUE='\033[0;34m' export CYAN='\033[0;36m' export MAGENTA='\033[0;35m' export BOLD='\033[1m' export NC='\033[0m' fi # Global array to track installation stack (prevent circular dependencies) declare -g -A ARTY_INSTALL_STACK # Logging functions log_info() { echo -e "${BLUE}[INFO]${NC} $1" >&2 } log_success() { echo -e "${GREEN}[]${NC} $1" >&2 } log_warn() { echo -e "${YELLOW}[�]${NC} $1" >&2 } log_error() { echo -e "${RED}[]${NC} $1" >&2 } log_debug() { if [[ "$ARTY_VERBOSE" == "1" ]]; then echo -e "${CYAN}[DEBUG]${NC} $1" >&2 fi } log_step() { echo -e "${CYAN}[�]${NC} $1" >&2 } # ============================================================================ # ARTY.SH CORE FUNCTIONS - Repository Management # ============================================================================ # Load environment variables from arty.yml load_env_vars() { local config_file="${1:-$ARTY_CONFIG_FILE}" if [[ ! -f "$config_file" ]]; then return 0 # No config file, nothing to load fi # Check if YAML is valid if ! yq eval '.' "$config_file" >/dev/null 2>&1; then log_warn "Invalid YAML in config file, skipping env vars" return 0 fi # Check if envs section exists local has_envs=$(yq eval '.envs' "$config_file" 2>/dev/null) if [[ "$has_envs" == "null" ]] || [[ -z "$has_envs" ]]; then return 0 # No envs section fi local current_env="$ARTY_ENV" log_debug "Loading environment variables from '$current_env' environment" # First load default variables if they exist local default_envs=$(yq eval '.envs.default | to_entries | .[] | .key + "=" + .value' "$config_file" 2>/dev/null) if [[ -n "$default_envs" ]] && [[ "$default_envs" != "null" ]]; then while IFS='=' read -r key value; do if [[ -n "$key" ]] && [[ "$key" != "null" ]] && [[ -n "$value" ]]; then # Only export if not already set (for default env only) if [[ "$current_env" == "default" ]] && [[ -n "${!key:-}" ]]; then log_debug " Skipping $key (already set)" continue fi export "$key=$value" log_debug " Set $key (from default)" fi done <<< "$default_envs" fi # Then load environment-specific variables (which can override defaults) if [[ "$current_env" != "default" ]]; then local env_envs=$(yq eval ".envs.$current_env | to_entries | .[] | .key + \"=\" + .value" "$config_file" 2>/dev/null) if [[ -n "$env_envs" ]] && [[ "$env_envs" != "null" ]]; then while IFS='=' read -r key value; do if [[ -n "$key" ]] && [[ "$key" != "null" ]] && [[ -n "$value" ]]; then export "$key=$value" log_debug " Set $key (from $current_env)" fi done <<< "$env_envs" fi fi } # Check if yq is installed check_yq() { if ! command -v yq &>/dev/null; then log_error "yq is not installed. Please install yq to use arty." log_info "Visit https://github.com/mikefarah/yq for installation instructions" log_info "Quick install: brew install yq (macOS) or see README.md" exit 1 fi } # Check if git is installed check_dependencies() { local missing=0 if ! command -v yq &>/dev/null; then log_error "yq is not installed" missing=1 fi if ! command -v git &>/dev/null; then log_error "git is not installed" missing=1 fi if [[ $missing -eq 1 ]]; then log_error "Missing required dependencies" exit 1 fi } # Initialize arty environment init_arty() { local arty_home="${ARTY_HOME:-$PWD/.arty}" local libs_dir="${ARTY_LIBS_DIR:-$arty_home/libs}" local bin_dir="${ARTY_BIN_DIR:-$arty_home/bin}" if [[ ! -d "$arty_home" ]]; then mkdir -p "$libs_dir" mkdir -p "$bin_dir" log_success "Initialized arty at $arty_home" fi } # Get a field from YAML using yq get_yaml_field() { local file="$1" local field="$2" if [[ ! -f "$file" ]]; then return 1 fi # Check if file has valid YAML - yq will exit with error on invalid YAML if ! yq eval '.' "$file" >/dev/null 2>&1; then return 1 fi yq eval ".$field" "$file" 2>/dev/null || echo "" } # Get array items from YAML using yq get_yaml_array() { local file="$1" local field="$2" if [[ ! -f "$file" ]]; then return 1 fi # Check if file has valid YAML - yq will exit with error on invalid YAML if ! yq eval '.' "$file" >/dev/null 2>&1; then return 1 fi yq eval ".${field}[]" "$file" 2>/dev/null } # Get script command from YAML get_yaml_script() { local file="$1" local script_name="$2" if [[ ! -f "$file" ]]; then return 1 fi yq eval ".scripts.${script_name}" "$file" 2>/dev/null || echo "null" } # List all script names from YAML list_yaml_scripts() { local file="$1" if [[ ! -f "$file" ]]; then return 1 fi yq eval '.scripts | keys | .[]' "$file" 2>/dev/null } # Get library name from repository URL get_lib_name() { local repo_url="$1" basename "$repo_url" .git } # Parse reference - can be a string URL or an object with url, into, ref, env # Returns: url|into|ref|env (pipe-delimited) # env can be a single value or comma-separated list parse_reference() { local config_file="$1" local ref_index="$2" # Check if reference is a string or object local ref_type=$(yq eval ".references[$ref_index] | type" "$config_file" 2>/dev/null) if [[ "$ref_type" == "!!str" ]]; then # Simple string format: just the URL local url=$(yq eval ".references[$ref_index]" "$config_file" 2>/dev/null) echo "$url||||" else # Object format with url, into, ref, env fields local url=$(yq eval ".references[$ref_index].url" "$config_file" 2>/dev/null) local into=$(yq eval ".references[$ref_index].into" "$config_file" 2>/dev/null) local ref=$(yq eval ".references[$ref_index].ref" "$config_file" 2>/dev/null) # Check if env is an array or string local env_type=$(yq eval ".references[$ref_index].env | type" "$config_file" 2>/dev/null) local env="" if [[ "$env_type" == "!!seq" ]]; then # Array format - convert to comma-separated string env=$(yq eval ".references[$ref_index].env | join(\",\")" "$config_file" 2>/dev/null) else # Single value or null env=$(yq eval ".references[$ref_index].env" "$config_file" 2>/dev/null) fi # Replace "null" with empty string [[ "$url" == "null" ]] && url="" [[ "$into" == "null" ]] && into="" [[ "$ref" == "null" ]] && ref="" [[ "$env" == "null" ]] && env="" echo "$url|$into|$ref|$env" fi } # Check if current environment matches the filter # env_filter can be a single env or comma-separated list check_env_match() { local current_env="$1" local env_filter="$2" # No filter means match all [[ -z "$env_filter" ]] && return 0 # Convert comma-separated list to array IFS=',' read -ra env_list <<< "$env_filter" # Check if current env is in the list for env in "${env_list[@]}"; do # Trim whitespace env=$(echo "$env" | xargs) if [[ "$env" == "$current_env" ]]; then return 0 fi done return 1 } # Get git information for a repository get_git_info() { local repo_dir="$1" if [[ ! -d "$repo_dir/.git" ]]; then echo "|||0" return fi # Get short commit hash local commit_hash=$(cd "$repo_dir" && git rev-parse --short HEAD 2>/dev/null || echo "") # Get all refs pointing to current commit (tags, branches) local refs=$(cd "$repo_dir" && git describe --all --exact-match 2>/dev/null || git symbolic-ref --short HEAD 2>/dev/null || echo "") # Clean up refs (remove heads/ and tags/ prefixes) refs=$(echo "$refs" | sed 's#^heads/##' | sed 's#^tags/##') # Check if dirty (has uncommitted changes) local is_dirty=0 if [[ -n "$(cd "$repo_dir" && git status --porcelain 2>/dev/null)" ]]; then is_dirty=1 fi echo "$commit_hash|$refs|$is_dirty" } # Normalize library identifier for tracking normalize_lib_id() { local repo_url="$1" # Convert to lowercase and remove .git suffix for consistent tracking local normalized="${repo_url,,}" normalized=$(echo "$normalized" | sed 's/\.git$//') # Normalize different Git URL formats to the same path # https://github.com/user/repo -> github.com/user/repo # git@github.com:user/repo -> github.com/user/repo # ssh://git@github.com/user/repo -> github.com/user/repo normalized=$(echo "$normalized" | sed -E 's#^https?://##' | sed -E 's#^git@([^:]+):#\1/#' | sed -E 's#^ssh://git@##') echo "$normalized" } # Check if library is in installation stack is_installing() { local lib_id="$1" [[ -n "${ARTY_INSTALL_STACK[$lib_id]:-}" ]] } # Add library to installation stack mark_installing() { local lib_id="$1" ARTY_INSTALL_STACK[$lib_id]=1 } # Remove library from installation stack unmark_installing() { local lib_id="$1" unset ARTY_INSTALL_STACK[$lib_id] } # Check if library is already installed is_installed() { local lib_name="$1" local libs_dir="${ARTY_LIBS_DIR:-${ARTY_HOME:-$PWD/.arty}/libs}" [[ -d "$libs_dir/$lib_name" ]] } # Install a library from git repository install_lib() { local repo_url="$1" local lib_name="${2:-$(get_lib_name "$repo_url")}" local git_ref="${3:-main}" local custom_into="${4:-}" local config_file="${5:-$ARTY_CONFIG_FILE}" # Determine installation directory local lib_dir if [[ -n "$custom_into" ]]; then # Custom directory relative to config file directory local config_dir=$(dirname "$(realpath "${config_file}")") lib_dir="$config_dir/$custom_into" else # Use global .arty/libs for libraries without custom 'into' lib_dir="$ARTY_LIBS_DIR/$lib_name" fi # Normalize the library identifier for circular dependency detection # Include installation path to allow same library at different locations local lib_id=$(normalize_lib_id "$repo_url") local lib_id_with_path="${lib_id}@${lib_dir}" # Check for circular dependency if is_installing "$lib_id_with_path"; then log_warn "Circular dependency detected: $lib_name (already being installed at $lib_dir)" log_info "Skipping to prevent infinite loop" return 0 fi # Check if already installed (optimization) if [[ -d "$lib_dir" ]]; then log_info "Library '$lib_name' already installed at $lib_dir" if [[ "$ARTY_DRY_RUN" == "1" ]]; then log_info "[DRY RUN] Would check for updates..." return 0 fi # Try to update (cd "$lib_dir" && git fetch -q && git checkout -q "$git_ref" && git pull -q) || { log_warn "Failed to update library (continuing with existing version)" } return 0 fi # Mark as currently installing mark_installing "$lib_id_with_path" if [[ "$ARTY_DRY_RUN" != "1" ]]; then init_arty fi log_info "Installing library: $lib_name" log_info "Repository: $repo_url" log_info "Git ref: $git_ref" log_info "Location: $lib_dir" if [[ "$ARTY_DRY_RUN" == "1" ]]; then log_info "[DRY RUN] Would clone repository and checkout $git_ref" unmark_installing "$lib_id_with_path" return 0 fi # Clone the repository git clone "$repo_url" "$lib_dir" || { log_error "Failed to clone repository" unmark_installing "$lib_id_with_path" return 1 } # Checkout the specified ref (cd "$lib_dir" && git checkout -q "$git_ref") || { log_warn "Failed to checkout ref '$git_ref', using default branch" } # Run setup hook if exists if [[ -f "$lib_dir/setup.sh" ]]; then log_info "Running setup hook..." (cd "$lib_dir" && bash setup.sh) || { log_warn "Setup hook failed, continuing anyway..." } fi # Process arty.yml if it exists if [[ -f "$lib_dir/arty.yml" ]]; then # Link main script to .arty/bin (only for standard installations, not custom 'into') if [[ -z "$custom_into" ]]; then local main_script=$(get_yaml_field "$lib_dir/arty.yml" "main") if [[ -n "$main_script" ]] && [[ "$main_script" != "null" ]]; then local main_file="$lib_dir/$main_script" if [[ -f "$main_file" ]]; then local local_bin_dir="$ARTY_BIN_DIR" local lib_name_stripped="$(basename $main_file .sh)" local bin_link="$local_bin_dir/$lib_name_stripped" log_info "Linking main script: $main_script -> $bin_link" ln -sf "$main_file" "$bin_link" chmod +x "$main_file" log_success "Main script linked to $bin_link" fi fi fi # Install nested dependencies (always, regardless of 'into') log_info "Found arty.yml, checking for references..." install_references "$lib_dir/arty.yml" fi # Unmark as installing (we're done with this library) unmark_installing "$lib_id_with_path" log_success "Library '$lib_name' installed successfully" log_info "Location: $lib_dir" } # Find if a library is defined in current or ancestor configs with 'into' find_ancestor_into() { local lib_url="$1" local current_config="$2" local lib_url_normalized=$(normalize_lib_id "$lib_url") # Build list of ancestor configs to check # Start with current config's directory and walk up to find parent arty.yml files local config_to_check="$current_config" local current_dir=$(dirname "$(realpath "$current_config")") # Check current config and walk up the directory tree while true; do if [[ -f "$config_to_check" ]]; then # Get count of references local ref_count=$(yq eval '.references | length' "$config_to_check" 2>/dev/null) if [[ "$ref_count" != "null" ]] && [[ "$ref_count" != "0" ]]; then # Check each reference for ((i = 0; i < ref_count; i++)); do local ref_data=$(parse_reference "$config_to_check" "$i") IFS='|' read -r url into git_ref env_filter <<<"$ref_data" local url_normalized=$(normalize_lib_id "$url") if [[ "$url_normalized" == "$lib_url_normalized" ]] && [[ -n "$into" ]]; then # Found it with an 'into' directive echo "$into|$config_to_check" return fi done fi fi # Check if we've reached the root ARTY_CONFIG_FILE (after checking it) if [[ "$(realpath "$config_to_check" 2>/dev/null)" == "$(realpath "$ARTY_CONFIG_FILE" 2>/dev/null)" ]]; then # We've checked the root config, stop here break fi # Move up one directory local parent_dir=$(dirname "$current_dir") if [[ "$parent_dir" == "$current_dir" ]] || [[ "$parent_dir" == "/" ]]; then # Reached filesystem root, stop break fi current_dir="$parent_dir" config_to_check="$current_dir/arty.yml" done echo "" } # Install all references from arty.yml install_references() { local config_file="${1:-$ARTY_CONFIG_FILE}" if [[ ! -f "$config_file" ]]; then log_error "Config file not found: $config_file" return 1 fi # Check if YAML is valid by trying to read it if ! yq eval '.' "$config_file" >/dev/null 2>&1; then log_error "Invalid YAML in config file: $config_file" return 1 fi # Initialize arty directory structure first (unless dry run) if [[ "$ARTY_DRY_RUN" != "1" ]]; then init_arty fi # Count references local ref_count=$(yq eval '.references | length' "$config_file" 2>/dev/null) if [[ "$ref_count" == "null" ]] || [[ "$ref_count" == "0" ]]; then log_info "No references to install" return 0 fi # Process each reference by index local i for ((i = 0; i < ref_count; i++)); do # Parse the reference local ref_data=$(parse_reference "$config_file" "$i") IFS='|' read -r url into git_ref env_filter <<<"$ref_data" # Skip empty URLs if [[ -z "$url" ]] || [[ "$url" == "null" ]]; then continue fi # Check environment filter using the new check_env_match function if [[ -n "$env_filter" ]] && ! check_env_match "$ARTY_ENV" "$env_filter"; then log_info "Skipping reference (env filter: [$env_filter], current: $ARTY_ENV): $url" continue fi # Use default ref if not specified [[ -z "$git_ref" ]] && git_ref="main" # Get library name local lib_name=$(get_lib_name "$url") # If no 'into' specified, check if an ancestor config defines it with 'into' local effective_config="$config_file" if [[ -z "$into" ]] && [[ "$config_file" != "$ARTY_CONFIG_FILE" ]]; then local ancestor_result=$(find_ancestor_into "$url" "$config_file") if [[ -n "$ancestor_result" ]]; then IFS='|' read -r ancestor_into ancestor_config <<<"$ancestor_result" into="$ancestor_into" effective_config="$ancestor_config" fi fi log_info "Installing reference: $url" [[ -n "$into" ]] && log_info "Custom location: $into" [[ -n "$env_filter" ]] && log_info "Environment filter: [$env_filter]" install_lib "$url" "$lib_name" "$git_ref" "$into" "$effective_config" || log_warn "Failed to install reference: $url" done } # Build a reference tree from arty.yml showing all dependencies build_reference_tree() { local config_file="${1:-$ARTY_CONFIG_FILE}" local indent="${2:-}" local is_last="${3:-1}" local visited_file="${4:-/tmp/arty_visited_$$}" local depth="${5:-0}" if [[ ! -f "$config_file" ]]; then return 0 fi # Track visited library directories to prevent infinite circular recursion # Only track at depth > 0 to allow root-level refs to show even if already nested local config_dir=$(dirname "$(realpath "$config_file" 2>/dev/null || echo "$config_file")") if [[ "$depth" -gt 0 ]]; then if grep -qx "$config_dir" "$visited_file" 2>/dev/null; then return 0 fi echo "$config_dir" >>"$visited_file" fi # Count references local ref_count=$(yq eval '.references | length' "$config_file" 2>/dev/null) if [[ "$ref_count" == "null" ]] || [[ "$ref_count" == "0" ]]; then return 0 fi # Process each reference local i for ((i = 0; i < ref_count; i++)); do local ref_data=$(parse_reference "$config_file" "$i") IFS='|' read -r url into git_ref env_filter <<<"$ref_data" # Skip if no URL or env filter doesn't match if [[ -z "$url" ]] || [[ "$url" == "null" ]]; then continue fi if [[ -n "$env_filter" ]] && ! check_env_match "$ARTY_ENV" "$env_filter"; then continue fi local lib_name=$(get_lib_name "$url") # Calculate is_last_ref by counting remaining valid refs local is_last_ref=0 local remaining=0 for ((j = i + 1; j < ref_count; j++)); do local check_ref=$(parse_reference "$config_file" "$j") IFS='|' read -r check_url _ _ check_env <<<"$check_ref" if [[ -n "$check_url" ]] && [[ "$check_url" != "null" ]]; then if [[ -z "$check_env" ]] || check_env_match "$ARTY_ENV" "$check_env"; then remaining=1 break fi fi done [[ "$remaining" == "0" ]] && is_last_ref=1 # If no 'into' specified, check if an ancestor config defines it with 'into' local effective_config="$config_file" if [[ -z "$into" ]] && [[ "$config_file" != "$ARTY_CONFIG_FILE" ]]; then local ancestor_result=$(find_ancestor_into "$url" "$config_file") if [[ -n "$ancestor_result" ]]; then IFS='|' read -r ancestor_into ancestor_config <<<"$ancestor_result" into="$ancestor_into" effective_config="$ancestor_config" fi fi # Determine installation directory local lib_dir if [[ -n "$into" ]]; then local config_dir=$(dirname "$(realpath "${effective_config}")") lib_dir="$config_dir/$into" else lib_dir="$ARTY_LIBS_DIR/$lib_name" fi # Get version and git info local version="" local location="$lib_dir" if [[ -f "$lib_dir/arty.yml" ]]; then version=$(get_yaml_field "$lib_dir/arty.yml" "version") [[ "$version" == "null" ]] || [[ -z "$version" ]] && version="" fi # Get git information local git_info=$(get_git_info "$lib_dir") IFS='|' read -r commit_hash git_refs is_dirty <<<"$git_info" # Determine display version local display_version="${version:-$commit_hash}" [[ -z "$display_version" ]] && display_version="unknown" # Tree characters local tree_char="" local tree_continue=" " if [[ "$is_last_ref" == "1" ]]; then tree_char="" tree_continue=" " fi # Print the reference line printf "%s${tree_char} ${BOLD}${GREEN}%s${NC}" "$indent" "$lib_name" printf " ${CYAN}%s${NC}" "$display_version" # Add git refs if available if [[ -n "$git_refs" ]]; then printf " ${BLUE}(%s)${NC}" "$git_refs" fi # Add dirty indicator if [[ "$is_dirty" == "1" ]]; then printf " ${YELLOW}${NC}" fi # Add location info if [[ -n "$into" ]]; then printf " ${MAGENTA}� %s${NC}" "$into" fi echo # Recursively show nested dependencies if [[ -f "$lib_dir/arty.yml" ]]; then local next_depth=$((depth + 1)) build_reference_tree "$lib_dir/arty.yml" "${indent}${tree_continue}" "$is_last_ref" "$visited_file" "$next_depth" fi done } # List installed libraries with tree visualization list_libs() { init_arty if [[ ! -f "$ARTY_CONFIG_FILE" ]]; then # Fallback to simple list if no arty.yml if [[ ! -d "$ARTY_LIBS_DIR" ]] || [[ -z "$(ls -A "$ARTY_LIBS_DIR" 2>/dev/null)" ]]; then log_info "No libraries installed" return 0 fi log_info "Installed libraries:" echo for lib_dir in "$ARTY_LIBS_DIR"/*; do if [[ -d "$lib_dir" ]]; then local lib_name=$(basename "$lib_dir") local version="" # Try to get version from arty.yml using yq if [[ -f "$lib_dir/arty.yml" ]]; then version=$(get_yaml_field "$lib_dir/arty.yml" "version") if [[ "$version" == "null" ]] || [[ -z "$version" ]]; then version="" fi fi printf " ${GREEN}%-20s${NC} %s\n" "$lib_name" "${version:-(unknown version)}" fi done echo return 0 fi # Get project info local project_name=$(get_yaml_field "$ARTY_CONFIG_FILE" "name") local project_version=$(get_yaml_field "$ARTY_CONFIG_FILE" "version") # Clean up name/version [[ "$project_name" == "null" ]] && project_name="$(basename "$PWD")" [[ "$project_version" == "null" ]] && project_version="" # Check if there are any references local ref_count=$(yq eval '.references | length' "$ARTY_CONFIG_FILE" 2>/dev/null) if [[ "$ref_count" == "null" ]] || [[ "$ref_count" == "0" ]]; then # No references defined, check for installed libraries local libs_dir="${ARTY_LIBS_DIR:-${ARTY_HOME:-$PWD/.arty}/libs}" if [[ ! -d "$libs_dir" ]] || [[ -z "$(ls -A "$libs_dir" 2>/dev/null)" ]]; then # Show header but indicate no libraries echo printf "${BOLD}${GREEN}%s${NC}" "$project_name" if [[ -n "$project_version" ]]; then printf " ${CYAN}%s${NC}" "$project_version" fi echo echo log_info "No libraries installed" echo return 0 fi fi # Print header echo printf "${BOLD}${GREEN}%s${NC}" "$project_name" if [[ -n "$project_version" ]]; then printf " ${CYAN}%s${NC}" "$project_version" fi echo echo # Create temporary file for tracking visited configs local visited_file="/tmp/arty_visited_$$" : >"$visited_file" # Build and display tree build_reference_tree "$ARTY_CONFIG_FILE" "" 1 "$visited_file" # Cleanup rm -f "$visited_file" echo } # Display notes from arty.yml with markdown rendering show_notes() { local config_file="${1:-$ARTY_CONFIG_FILE}" if [[ ! -f "$config_file" ]]; then log_error "Config file not found: $config_file" return 1 fi # Get notes field from YAML local notes=$(yq eval '.notes' "$config_file" 2>/dev/null) if [[ -z "$notes" ]] || [[ "$notes" == "null" ]]; then log_info "No notes found in $config_file" log_info "Add a 'notes' field to your arty.yml to display project notes" return 0 fi # Get project name for header local project_name=$(get_yaml_field "$config_file" "name") [[ "$project_name" == "null" ]] && project_name="$(basename "$PWD")" echo echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo -e "${BOLD}${GREEN} Notes: ${project_name}${NC}" echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo # Try to render markdown with available tools if command -v glow &>/dev/null; then # Use glow if available (best option) echo "$notes" | glow - elif command -v mdcat &>/dev/null; then # Use mdcat if available (good alternative) echo "$notes" | mdcat elif command -v python3 &>/dev/null; then # Fallback to Python markdown rendering - create temp file to avoid quoting issues local temp_notes=$(mktemp) echo "$notes" > "$temp_notes" python3 - "$temp_notes" <<'EOPY' 2>/dev/null || echo "$notes" import sys import re # Read from temp file with open(sys.argv[1], 'r') as f: text = f.read() # Simple markdown-like formatting for terminal # Code blocks first (before other processing) lines = text.split('\n') in_code_block = False processed_lines = [] for line in lines: if line.strip().startswith('```'): in_code_block = not in_code_block if in_code_block: processed_lines.append('\033[2;37m' + '─' * 60 + '\033[0m') else: processed_lines.append('\033[2;37m' + '─' * 60 + '\033[0m') continue if in_code_block: processed_lines.append('\033[0;36m ' + line + '\033[0m') else: processed_lines.append(line) text = '\n'.join(processed_lines) # Headers text = re.sub(r'^### (.+)$', r'\033[1;36m\1\033[0m', text, flags=re.MULTILINE) text = re.sub(r'^## (.+)$', r'\033[1;32m\1\033[0m', text, flags=re.MULTILINE) text = re.sub(r'^# (.+)$', r'\033[1;33m\1\033[0m', text, flags=re.MULTILINE) # Bold text = re.sub(r'\*\*(.+?)\*\*', r'\033[1m\1\033[0m', text) text = re.sub(r'__(.+?)__', r'\033[1m\1\033[0m', text) # Italic (avoid matching bold) text = re.sub(r'(?"$ARTY_CONFIG_FILE" </dev/null) if [[ -z "$version" ]] || [[ "$version" == "null" ]]; then echo "0.0.0" else echo "$version" fi } # Update version in arty.yml update_version() { local new_version="$1" local config="${2:-$WHIP_CONFIG}" if [[ ! -f "$config" ]]; then log_error "Config file not found: $config" return 1 fi yq eval ".version = \"$new_version\"" -i "$config" log_success "Updated version to $new_version in $config" } # Parse semver components parse_version() { local version="$1" local -n major_ref=$2 local -n minor_ref=$3 local -n patch_ref=$4 # Remove 'v' prefix if present version="${version#v}" if [[ "$version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then major_ref="${BASH_REMATCH[1]}" minor_ref="${BASH_REMATCH[2]}" patch_ref="${BASH_REMATCH[3]}" return 0 else return 1 fi } # Bump version (major, minor, or patch) bump_version() { local bump_type="$1" local config="${2:-$WHIP_CONFIG}" local current_version=$(get_current_version "$config") local major minor patch if ! parse_version "$current_version" major minor patch; then log_error "Invalid version format: $current_version" return 1 fi case "$bump_type" in major) major=$((major + 1)) minor=0 patch=0 ;; minor) minor=$((minor + 1)) patch=0 ;; patch) patch=$((patch + 1)) ;; *) log_error "Invalid bump type: $bump_type (use major, minor, or patch)" return 1 ;; esac local new_version="${major}.${minor}.${patch}" echo "$new_version" } # Generate changelog from git commits generate_changelog() { local from_tag="${1:-}" local to_ref="${2:-HEAD}" local title="${3:-Changelog}" local range if [[ -z "$from_tag" ]]; then # Get all commits if no from_tag range="$to_ref" else range="${from_tag}..${to_ref}" fi echo "# $title" echo "" echo "## Changes" echo "" git log "$range" --pretty=format:"- %s (%h)" --reverse 2>/dev/null || { echo "- Initial release" } echo "" } # Update CHANGELOG.md file update_changelog_file() { local new_version="$1" local changelog_file="${2:-$WHIP_CHANGELOG}" local previous_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "") local date=$(date +%Y-%m-%d) local temp_file=$(mktemp) # Generate new version section { echo "# Changelog" echo "" echo "## [$new_version] - $date" echo "" if [[ -n "$previous_tag" ]]; then git log "${previous_tag}..HEAD" --pretty=format:"- %s" --reverse else echo "- Initial release" fi echo "" echo "" # Append existing changelog if it exists if [[ -f "$changelog_file" ]]; then # Skip the first "# Changelog" line and empty lines tail -n +2 "$changelog_file" | sed '/^$/d; 1s/^//' fi } >"$temp_file" mv "$temp_file" "$changelog_file" log_success "Updated $changelog_file" } # Create and push git tag create_release_tag() { local version="$1" local message="${2:-Release version $version}" local push="${3:-true}" local tag="v${version}" # Check if tag already exists if git rev-parse "$tag" >/dev/null 2>&1; then log_warn "Tag $tag already exists" return 1 fi # Create annotated tag git tag -a "$tag" -m "$message" log_success "Created tag: $tag" # Push tag if requested if [[ "$push" == "true" ]]; then git push origin "$tag" log_success "Pushed tag: $tag" fi } # Full release workflow release() { local bump_type="${1:-patch}" local config="${2:-$WHIP_CONFIG}" local push="${3:-true}" check_dependencies # Check if we're in a git repository if ! git rev-parse --git-dir >/dev/null 2>&1; then log_error "Not a git repository" return 1 fi # Check for uncommitted changes if [[ -n $(git status --porcelain) ]]; then log_warn "You have uncommitted changes" read -p "Continue anyway? [y/N] " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then log_info "Release cancelled" return 1 fi fi log_step "Starting release process" # Bump version local new_version=$(bump_version "$bump_type" "$config") log_info "New version: $new_version" # Update version in config update_version "$new_version" "$config" # Update changelog update_changelog_file "$new_version" # Commit changes git add "$config" "$WHIP_CHANGELOG" git commit -m "chore: release version $new_version" log_success "Committed version changes" # Create and push tag create_release_tag "$new_version" "Release version $new_version" "$push" # Push commits if requested if [[ "$push" == "true" ]]; then git push log_success "Pushed commits" fi log_success "Release $new_version completed successfully!" } # Install git hooks install_hooks() { local hooks_source_dir="${1:-$WHIP_HOOKS_DIR}" local git_hooks_dir=".git/hooks" if [[ ! -d "$git_hooks_dir" ]]; then log_error "Not a git repository" return 1 fi if [[ ! -d "$hooks_source_dir" ]]; then log_warn "Hooks directory not found: $hooks_source_dir" log_info "Creating default hooks directory..." mkdir -p "$hooks_source_dir" create_default_hooks "$hooks_source_dir" fi log_step "Installing git hooks from $hooks_source_dir" local installed=0 for hook_file in "$hooks_source_dir"/*; do if [[ -f "$hook_file" ]]; then local hook_name=$(basename "$hook_file") local target="$git_hooks_dir/$hook_name" cp "$hook_file" "$target" chmod +x "$target" log_success "Installed: $hook_name" installed=$((installed + 1)) fi done if [[ $installed -eq 0 ]]; then log_warn "No hooks found in $hooks_source_dir" else log_success "Installed $installed hook(s)" fi } # Create default hooks with validation create_default_hooks() { local hooks_dir="$1" mkdir -p "$hooks_dir" # Pre-commit hook with shellcheck and bash -n validation cat >"$hooks_dir/pre-commit" <<'EOF' #!/usr/bin/env bash # Pre-commit hook: validates bash scripts set -e echo "Running pre-commit checks..." # Find all staged .sh files staged_files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.sh$' || true) if [[ -z "$staged_files" ]]; then echo "No shell scripts to check" exit 0 fi errors=0 # Check each file for file in $staged_files; do if [[ ! -f "$file" ]]; then continue fi echo "Checking: $file" # Bash syntax check if ! bash -n "$file" 2>&1; then echo "ERROR: Syntax error in $file" errors=$((errors + 1)) fi # ShellCheck if available if command -v shellcheck &> /dev/null; then if ! shellcheck "$file" 2>&1; then echo "WARNING: ShellCheck found issues in $file" # Don't fail on shellcheck warnings, just inform fi fi done if [[ $errors -gt 0 ]]; then echo "ERROR: $errors file(s) with syntax errors" echo "Please fix the errors before committing" exit 1 fi echo "Pre-commit checks passed!" exit 0 EOF chmod +x "$hooks_dir/pre-commit" log_success "Created default pre-commit hook" } # Uninstall git hooks uninstall_hooks() { local git_hooks_dir=".git/hooks" if [[ ! -d "$git_hooks_dir" ]]; then log_error "Not a git repository" return 1 fi log_step "Removing git hooks" # Remove hooks that were installed by whip local hooks=("pre-commit" "pre-push" "commit-msg") local removed=0 for hook in "${hooks[@]}"; do local hook_file="$git_hooks_dir/$hook" if [[ -f "$hook_file" ]]; then rm "$hook_file" log_success "Removed: $hook" removed=$((removed + 1)) fi done if [[ $removed -eq 0 ]]; then log_info "No hooks to remove" else log_success "Removed $removed hook(s)" fi } # Find arty.yml projects in subdirectories find_arty_projects() { local root_dir="${1:-.}" local pattern="${2:-*}" find "$root_dir" -maxdepth 2 -type f -name "arty.yml" | while read -r config; do local project_dir=$(dirname "$config") local project_name=$(basename "$project_dir") # Apply glob pattern filter if [[ "$project_name" == $pattern ]]; then echo "$project_dir" fi done } # Execute bash command on monorepo projects monorepo_exec() { local bash_cmd="$1" local root_dir="${2:-.}" local pattern="${3:-*}" if [[ -z "$bash_cmd" ]]; then log_error "Command required" return 1 fi log_step "Scanning for arty.yml projects in $root_dir" local projects=() while IFS= read -r project_dir; do projects+=("$project_dir") done < <(find_arty_projects "$root_dir" "$pattern") if [[ ${#projects[@]} -eq 0 ]]; then log_warn "No arty.yml projects found matching pattern: $pattern" return 1 fi log_info "Found ${#projects[@]} project(s)" log_info "Executing: $bash_cmd" echo local failed=0 for project_dir in "${projects[@]}"; do local project_name=$(basename "$project_dir") echo -e "${CYAN} $project_name ${NC}" ( # Export variables for use in command export WHIP_PROJECT_DIR="$project_dir" export WHIP_PROJECT_NAME="$project_name" cd "$project_dir" || exit 1 # Execute the bash command eval "$bash_cmd" ) || { log_error "Failed for $project_name" failed=$((failed + 1)) } echo done if [[ $failed -gt 0 ]]; then log_warn "$failed project(s) failed" return 1 else log_success "All projects processed successfully" fi } # Batch operation on monorepo projects monorepo_batch() { local command="$1" local root_dir="${2:-.}" local pattern="${3:-*}" log_step "Scanning for arty.yml projects in $root_dir" local projects=() while IFS= read -r project_dir; do projects+=("$project_dir") done < <(find_arty_projects "$root_dir" "$pattern") if [[ ${#projects[@]} -eq 0 ]]; then log_warn "No arty.yml projects found matching pattern: $pattern" return 1 fi log_info "Found ${#projects[@]} project(s)" echo local failed=0 for project_dir in "${projects[@]}"; do local project_name=$(basename "$project_dir") echo -e "${CYAN} Processing: $project_name ${NC}" ( cd "$project_dir" case "$command" in version) local version=$(get_current_version) echo "Version: $version" ;; bump) local bump_type="${4:-patch}" local new_version=$(bump_version "$bump_type") update_version "$new_version" echo "Bumped to: $new_version" ;; status) if git rev-parse --git-dir >/dev/null 2>&1; then git status --short else echo "Not a git repository" fi ;; *) log_error "Unknown command: $command" return 1 ;; esac ) || { log_error "Failed for $project_name" failed=$((failed + 1)) } echo done if [[ $failed -gt 0 ]]; then log_warn "$failed project(s) failed" return 1 else log_success "All projects processed successfully" fi } # Show comprehensive mono help show_mono_help() { cat <<'EOF' arty mono - Monorepo management commands USAGE: arty mono [options] [pattern] SUBCOMMANDS: list [root] [pattern] List all arty.yml projects version [root] [pattern] Show version of all projects bump [root] [pattern] Bump version (major|minor|patch) status [root] [pattern] Show git status for all projects exec [root] [pattern] Execute bash command on all projects help Show this help message ARGUMENTS: root Root directory to search (default: current directory) pattern Glob pattern to filter projects (default: *) type Version bump type: major, minor, or patch command Bash command or script to execute AVAILABLE VARIABLES (in exec): $WHIP_PROJECT_DIR Full path to project directory $WHIP_PROJECT_NAME Project name (basename) $PWD Current directory (already cd'd into project) EXAMPLES: Basic Operations: arty mono list # List all projects in current dir arty mono list ../monorepo # List projects in specific dir arty mono list . "lib-*" # List only lib-* projects arty mono version # Show all project versions arty mono status # Git status for all projects Version Management: arty mono bump patch # Bump patch version for all arty mono bump minor "lib-*" # Bump minor for lib-* projects arty mono bump major . "*-core" # Bump major for *-core projects Executing Commands: # Simple commands arty mono exec "pwd" arty mono exec "echo \$WHIP_PROJECT_NAME" arty mono exec "git status" # Using project variables arty mono exec 'echo "Project: $WHIP_PROJECT_NAME at $WHIP_PROJECT_DIR"' # Multi-line commands (use quotes) arty mono exec 'git add . && git commit -m "chore: update" && git push' # Conditional execution arty mono exec 'if [[ -f package.json ]]; then npm install; fi' # Complex operations arty mono exec ' echo "Cleaning $WHIP_PROJECT_NAME..." rm -rf node_modules dist echo "Building..." npm run build ' # With pattern filtering arty mono exec "npm test" . "lib-*" arty mono exec "make clean && make" . "*-service" Real-World Scenarios: # Commit and push all projects arty mono exec 'git add . && git commit -m "chore: streamline" && git push origin main' # Update dependencies arty mono exec 'arty deps' # Run tests arty mono exec 'bash test.sh' # Create git tags arty mono exec 'git tag -a v1.0.0 -m "Release 1.0.0" && git push --tags' # Check for uncommitted changes arty mono exec '[[ -n $(git status --porcelain) ]] && echo "Has changes" || echo "Clean"' # Generate documentation arty mono exec 'leaf.sh . && echo "Docs generated"' # Sync with remote arty mono exec 'git fetch && git pull origin main' PATTERN MATCHING: Glob patterns filter which projects to process: * All projects (default) lib-* Projects starting with "lib-" *-core Projects ending with "-core" app-* Projects starting with "app-" *-service Projects ending with "-service" test-* Projects starting with "test-" PROJECT DISCOVERY: arty searches for arty.yml files up to 2 levels deep: monorepo/  lib-core/   arty.yml  Found  services/   api-service/    arty.yml  Found   web-service/   arty.yml  Found  tools/  deep/  nested/  arty.yml  Too deep (>2 levels) ERROR HANDLING: - Individual project failures don't stop the batch - Failed projects are reported at the end - Exit code reflects overall success/failure - Use -e in commands for strict error handling TIPS: - Quote commands with special characters - Use single quotes to prevent variable expansion - Test commands on one project first - Use pattern matching to limit scope - Check for uncommitted changes before operations - Combine with other arty commands for workflows SEE ALSO: arty --help Main help arty release --help Release workflow help arty hooks --help Git hooks help EOF } # ============================================================================ # USAGE AND HELP # ============================================================================ # Show usage show_usage() { cat <<'EOF' arty.sh - A bash library repository and release management system USAGE: arty [arguments] [--dry-run] [-v|--verbose] REPOSITORY COMMANDS: install [name] Install a library from git repository deps [--dry-run] Install all dependencies from arty.yml list List installed libraries with dependency tree notes Display project notes from arty.yml (supports markdown) remove Remove an installed library init [name] Initialize a new arty.yml project source [file] Source a library (for use in scripts) exec [args] Execute a library's main script with arguments Execute a script defined in arty.yml RELEASE COMMANDS: release [major|minor|patch] Full release workflow (default: patch) - Bumps version in arty.yml - Updates CHANGELOG.md from git history - Creates git commit - Creates and pushes git tag version Show current version from arty.yml bump Bump version in arty.yml (no commit/tag) changelog Generate changelog from git history tag Create and push git tag HOOK COMMANDS: hooks install Install git commit hooks - Includes shellcheck validation - Includes bash -n syntax check hooks uninstall Remove git commit hooks hooks create Create default hook templates MONOREPO COMMANDS: mono list [root] [pattern] List arty.yml projects mono version [root] [pattern] Show versions of all projects mono bump [root] [pattern] Bump version for all projects mono status [root] [pattern] Show git status for all projects mono exec [root] [pattern] Execute bash command on all projects mono help Show detailed mono help FLAGS: --dry-run Simulate installation without making changes --no-push Don't push commits/tags (for release) --config Use custom config file (default: arty.yml) --changelog Use custom changelog file (default: CHANGELOG.md) -v, --verbose Enable verbose/debug output -h, --help Show this help message EXAMPLES: Repository Management: arty install https://github.com/user/bash-utils.git arty install https://github.com/user/lib.git my-lib arty deps arty deps --dry-run arty list arty notes arty init my-project arty test arty build arty exec leaf --help arty exec mylib process file.txt Release Cycle: arty release # Patch release arty release major # Major version release arty release minor --no-push # Minor release without pushing arty bump minor # Just bump version arty hooks install # Install commit hooks arty version # Show current version arty changelog # Generate changelog Monorepo Management: arty mono list # List all projects arty mono version # Show all project versions arty mono bump patch . "lib-*" # Bump patch for lib-* projects arty mono exec "git status" # Run command on all projects arty mono exec 'git add . && git commit -m "update" && git push' . "lib-*" arty mono help # Detailed monorepo help REFERENCE FORMATS: References in arty.yml can be specified in two formats: 1. Simple URL format: references: - https://github.com/user/repo.git 2. Extended object format: references: - url: git@github.com:user/repo.git into: custom/path # Custom installation directory (relative to arty.yml) ref: v1.0.0 # Git ref (branch, tag, or commit hash; default: main) env: production # Only install in this environment 3. Extended format with multiple environments: references: - url: git@github.com:user/dev-tools.git env: [dev, ci] # Install in dev OR ci environment PROJECT STRUCTURE: When running 'arty init' or 'arty deps', the following structure is created: project/  .arty/   bin/ # Linked executables (from 'main' field)    index # Project's main script    leaf # Dependency's main script    mylib # Another dependency's main script   libs/ # Dependencies (from 'references' field)   dep1/   dep2/  .whip/   hooks/ # Git hooks   pre-commit  arty.yml # Project configuration  CHANGELOG.md # Release changelog ENVIRONMENT: ARTY_HOME Home directory for arty (default: ~/.arty) ARTY_CONFIG Config file name (default: arty.yml) ARTY_ENV Environment to load from arty.yml envs section (default: default) EOF } # ============================================================================ # MAIN FUNCTION # ============================================================================ # Main function main() { # Check for yq availability first check_yq # Configuration PROJECT_DIR="$PWD/.arty" ARTY_HOME="${ARTY_HOME:-$PROJECT_DIR}" ARTY_LIBS_DIR="${ARTY_LIBS_DIR:-$ARTY_HOME/libs}" ARTY_BIN_DIR="${ARTY_BIN_DIR:-$ARTY_HOME/bin}" if [[ $# -eq 0 ]]; then show_usage exit 0 fi # Check for flags across all arguments FIRST local dry_run_flag=0 local verbose_flag=0 local push=true local config="$WHIP_CONFIG" local changelog="$WHIP_CHANGELOG" for arg in "$@"; do case "$arg" in --dry-run) dry_run_flag=1 ;; --verbose|-v) verbose_flag=1 ;; --no-push) push=false ;; --config) # Will be handled in the next loop ;; --changelog) # Will be handled in the next loop ;; esac done # Set verbose mode first (before loading env vars) if [[ "$verbose_flag" == "1" ]]; then export ARTY_VERBOSE=1 fi # Load environment variables after verbose flag is set load_env_vars # Set dry run mode if [[ "$dry_run_flag" == "1" ]]; then export ARTY_DRY_RUN=1 log_info "${YELLOW}[DRY RUN MODE]${NC} Simulating actions, no changes will be made" echo fi # Remove flags from all arguments and extract command local args=() local command="" local skip_next=0 for arg in "$@"; do if [[ $skip_next -eq 1 ]]; then skip_next=0 continue fi case "$arg" in --dry-run|--verbose|-v|--no-push|-h|--help) continue ;; --config) skip_next=1 if [[ -n "${2:-}" ]]; then config="$2" WHIP_CONFIG="$config" fi continue ;; --changelog) skip_next=1 if [[ -n "${2:-}" ]]; then changelog="$2" WHIP_CHANGELOG="$changelog" fi continue ;; *) if [[ -z "$command" ]]; then command="$arg" else args+=("$arg") fi ;; esac done # Handle help if [[ "$command" == "help" ]] || [[ "$command" == "--help" ]] || [[ "$command" == "-h" ]]; then show_usage exit 0 fi case "$command" in install) if [[ ${#args[@]} -eq 0 ]]; then install_references local install_result=$? else install_lib "${args[@]}" local install_result=$? fi # Show tree after installation if successful and arty.yml exists and has references if [[ $install_result -eq 0 ]] && [[ -f "$ARTY_CONFIG_FILE" ]]; then local ref_count=$(yq eval '.references | length' "$ARTY_CONFIG_FILE" 2>/dev/null) if [[ "$ref_count" != "null" ]] && [[ "$ref_count" != "0" ]]; then echo log_success "Installation complete!" list_libs fi fi ;; deps) install_references local install_result=$? # Show tree after installation if successful and arty.yml exists and has references if [[ $install_result -eq 0 ]] && [[ -f "$ARTY_CONFIG_FILE" ]]; then local ref_count=$(yq eval '.references | length' "$ARTY_CONFIG_FILE" 2>/dev/null) if [[ "$ref_count" != "null" ]] && [[ "$ref_count" != "0" ]]; then echo log_success "Dependencies installed!" list_libs fi fi ;; list | ls) list_libs ;; notes) show_notes "$config" ;; remove | rm) if [[ ${#args[@]} -eq 0 ]]; then log_error "Library name required" exit 1 fi remove_lib "${args[0]}" ;; init) init_project "${args[@]}" ;; exec) if [[ ${#args[@]} -eq 0 ]]; then log_error "Library name required" log_info "Usage: arty exec [arguments]" exit 1 fi exec_lib "${args[@]}" ;; source) if [[ ${#args[@]} -eq 0 ]]; then log_error "Library name required" exit 1 fi source_lib "${args[@]}" ;; release) local bump_type="${args[0]:-patch}" release "$bump_type" "$config" "$push" ;; version) get_current_version "$config" ;; bump) if [[ ${#args[@]} -eq 0 ]]; then log_error "Bump type required (major, minor, or patch)" exit 1 fi local new_version=$(bump_version "${args[0]}" "$config") update_version "$new_version" "$config" echo "$new_version" ;; changelog) generate_changelog "${args[0]:-}" "${args[1]:-HEAD}" ;; tag) if [[ ${#args[@]} -eq 0 ]]; then log_error "Version required" exit 1 fi create_release_tag "${args[0]}" "${args[1]:-Release version ${args[0]}}" "$push" ;; hooks) if [[ ${#args[@]} -eq 0 ]]; then log_error "Hooks subcommand required (install, uninstall, create)" exit 1 fi local subcommand="${args[0]}" case "$subcommand" in install) install_hooks "${args[1]:-$WHIP_HOOKS_DIR}" ;; uninstall) uninstall_hooks ;; create) create_default_hooks "${args[1]:-$WHIP_HOOKS_DIR}" ;; *) log_error "Unknown hooks subcommand: $subcommand" exit 1 ;; esac ;; mono | monorepo) if [[ ${#args[@]} -eq 0 ]]; then log_error "Monorepo subcommand required" show_mono_help exit 1 fi local subcommand="${args[0]}" case "$subcommand" in help | --help | -h) show_mono_help ;; list) find_arty_projects "${args[1]:-.}" "${args[2]:-*}" ;; version | bump | status) monorepo_batch "$subcommand" "${args[1]:-.}" "${args[2]:-*}" "${args[3]:-}" ;; exec) if [[ ${#args[@]} -lt 2 ]]; then log_error "Command required for exec" echo "Usage: arty mono exec [root] [pattern]" echo "Example: arty mono exec 'git status' . 'lib-*'" exit 1 fi local cmd="${args[1]}" monorepo_exec "$cmd" "${args[2]:-.}" "${args[3]:-*}" ;; *) log_error "Unknown monorepo subcommand: $subcommand" echo "Run 'arty mono help' for detailed usage" exit 1 ;; esac ;; *) # Try to execute as a script from arty.yml exec_script "$command" "${args[@]}" ;; esac } # Run main if script is executed directly if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@" fi