diff --git a/.gitignore b/.gitignore index 8b89c083..1dbac385 100755 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,7 @@ !arty.yml !signature.txt !LICENSE -!README.md \ No newline at end of file +!README.md + +!scripts/ +!scripts/**/* \ No newline at end of file diff --git a/README.md b/README.md index 4594d4d6..dd5bf70c 100755 --- a/README.md +++ b/README.md @@ -56,28 +56,25 @@ git init && git remote add origin git@dev.pivoine.art:2222/valknar/home.git git fetch && git reset --hard origin/main git branch --set-upstream-to=origin/main main -# Or using arty (if already installed) -arty clone home - # 2. Configure git git config --global init.defaultBranch main git config --global --add safe.directory /home/$USER # 3. Sync all dependencies (version managers, shell config, etc.) -arty sync +arty.sh sync # 4. Install system packages -arty debian/update && arty debian/install +arty.sh debian/update && arty.sh debian/install # 5. Install development tools -arty rust/install -arty node/install -arty ruby/install -arty python/install +arty.sh rust/install +arty.sh node/install +arty.sh ruby/install +arty.sh python/install # 6. Install global packages -arty npm/install -arty gem/install +arty.sh npm/install +arty.sh gem/install # 7. Source the shell configuration source ~/.zshrc @@ -85,33 +82,33 @@ source ~/.zshrc --- -## ARTY - REPOSITORY ORCHESTRATION +## arty.sh - REPOSITORY ORCHESTRATION ### Available Scripts ```bash # System setup -arty debian/update # Enable non-free repos and update sources -arty debian/install # curl, wget, zsh, fzf, davfs2, make, imagemagick, ffmpeg, yt-dlp, pkg-config, zstd, libffi-dev, libyaml-dev, libssl-dev, liblzma-dev, libbz2-dev, libncurses-dev, libreadline-dev, libsqlite3-dev, python3-tk +arty.sh debian/update # Enable non-free repos and update sources +arty.sh debian/install # curl, wget, zsh, fzf, davfs2, make, imagemagick, ffmpeg, yt-dlp, pkg-config, zstd, libffi-dev, libyaml-dev, libssl-dev, liblzma-dev, libbz2-dev, libncurses-dev, libreadline-dev, libsqlite3-dev, python3-tk # Development tools -arty docker/install # Docker Engine with user group -arty rust/install # Rust toolchain via rustup -arty node/install # Node.js via nvm -arty ruby/install # Ruby via rbenv -arty python/install # Python via pyenv -arty go/install # Go via apt -arty hugo/install # Hugo extended via go install +arty.sh docker/install # Docker Engine with user group +arty.sh rust/install # Rust toolchain via rustup +arty.sh node/install # Node.js via nvm +arty.sh ruby/install # Ruby via rbenv +arty.sh python/install # Python via pyenv +arty.sh go/install # Go via apt +arty.sh hugo/install # Hugo extended via go install # CLI tools -arty claude/install # Claude AI CLI -arty yq/install # yq YAML processor -arty gh/install # GitHub CLI -arty tailscale/install # Tailscale -arty ollama/install # Ollama +arty.sh claude/install # Claude AI CLI +arty.sh yq/install # yq YAML processor +arty.sh gh/install # GitHub CLI +arty.sh tailscale/install # Tailscale +arty.sh ollama/install # Ollama ``` -### What Arty Manages +### What arty.sh Manages - **Version managers** (nvm, rbenv, pyenv) - **Shell configuration** (Oh-My-Zsh, Powerlevel10k, plugins) @@ -194,6 +191,9 @@ This repository uses an **inverted `.gitignore`** pattern: !signature.txt !LICENSE !README.md + +!scripts/ +!scripts/**/* # ... ``` diff --git a/scripts/arty.sh b/scripts/arty.sh new file mode 100755 index 00000000..27906e34 --- /dev/null +++ b/scripts/arty.sh @@ -0,0 +1,2174 @@ +#!/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 +# ============================================================================ + +# Expand environment variables in a string with validation +# Supports nested expansion (e.g., $AI_ROOT/subdir where AI_ROOT contains another var) +# Fails if any undefined variables remain after expansion +expand_env_vars() { + local input="$1" + local max_iterations=10 + local iteration=0 + local expanded="$input" + + # Keep expanding until no more variables or max iterations reached + while [[ "$expanded" =~ \$ ]] && [[ $iteration -lt $max_iterations ]]; do + # Use eval to expand variables + expanded=$(eval echo "$expanded" 2>/dev/null || echo "$expanded") + iteration=$((iteration+1)) + done + + # Check if any unexpanded variables remain + if [[ "$expanded" =~ \$[A-Za-z_][A-Za-z0-9_]* ]]; then + # Extract the undefined variable name for better error message + local undefined_var=$(echo "$expanded" | grep -o '\$[A-Za-z_][A-Za-z0-9_]*' | head -1) + log_error "Undefined environment variable in path: ${undefined_var}" + log_error " Original: $input" + log_error " Expanded: $expanded" + log_error "" + log_error "Available environment variables from arty.yml:" + if [[ -f "$ARTY_CONFIG_FILE" ]]; then + yq eval '.envs.default | keys | .[]' "$ARTY_CONFIG_FILE" 2>/dev/null | sed 's/^/ - /' || true + fi + return 1 + fi + + echo "$expanded" +} + +# 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 + # Expand environment variables in custom_into path + local expanded_into + if ! expanded_into=$(expand_env_vars "$custom_into"); then + log_error "Failed to expand environment variables in 'into' path for ${repo_url}" + return 1 + fi + + # Check if expanded path is absolute or relative + if [[ "$expanded_into" == /* ]]; then + # Absolute path - use as-is + lib_dir="$expanded_into" + else + # Relative path - make relative to config file directory + local config_dir=$(dirname "$(realpath "${config_file}")") + lib_dir="$config_dir/$expanded_into" + fi + 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 + # Expand environment variables + local expanded_into + expanded_into=$(expand_env_vars "$into" 2>/dev/null) || expanded_into="$into" + + # Check if expanded path is absolute + if [[ "$expanded_into" == /* ]]; then + lib_dir="$expanded_into" + else + local config_dir=$(dirname "$(realpath "${effective_config}")") + lib_dir="$config_dir/$expanded_into" + fi + 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