#!/bin/bash # arty - A bash library repository management system # 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}" # Enable colors only when stdout is a real terminal (or FORCE_COLOR=1 overrides) if [[ -t 1 && "${TERM:-}" != "dumb" ]]; then export FORCE_COLOR=${FORCE_COLOR:-"1"} else export FORCE_COLOR=${FORCE_COLOR:-"0"} fi 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" } log_success() { echo -e "${GREEN}[ok]${NC} $1" } log_warn() { echo -e "${YELLOW}[!]${NC} $1" } log_error() { echo -e "${RED}[x]${NC} $1" } log_debug() { if [[ "$ARTY_VERBOSE" == "1" ]]; then echo -e "${CYAN}[DEBUG]${NC} $1" fi } log_step() { echo -e "${CYAN}[!]${NC} $1" } # ============================================================================ # 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 } # Wrapper to limit Go thread usage on hosts with low ulimit -u (e.g. IONOS shared hosting). # Always returns 0 so set -e doesn't propagate a yq crash through `local var=$(yq ...)`. yq() { GOMAXPROCS=1 GOGC=off command yq "$@" || return 0 } # Auto-install mikefarah/yq v4 using whatever package manager is available. _install_yq() { if command -v brew &>/dev/null; then log_info "Installing yq via Homebrew..." brew install yq && return 0 fi if command -v snap &>/dev/null; then log_info "Installing yq via snap..." snap install yq && return 0 fi if command -v apk &>/dev/null; then log_info "Installing yq via apk..." apk add --no-cache yq-go && return 0 fi if command -v pacman &>/dev/null; then log_info "Installing yq via pacman..." pacman -S --noconfirm go-yq && return 0 fi if command -v go &>/dev/null; then log_info "Installing yq via go install..." local go_bin="${HOME}/.local/bin" mkdir -p "$go_bin" GOBIN="$go_bin" go install github.com/mikefarah/yq/v4@latest && { export PATH="${go_bin}:${PATH}" return 0 } fi # Binary download fallback (Linux / macOS) local os arch install_dir use_sudo=0 os=$(uname -s | tr '[:upper:]' '[:lower:]') case "$(uname -m)" in x86_64) arch="amd64" ;; aarch64|arm64) arch="arm64" ;; armv7*) arch="arm" ;; *) arch="amd64" ;; esac local yq_url="https://github.com/mikefarah/yq/releases/latest/download/yq_${os}_${arch}" if [[ -w /usr/local/bin ]]; then install_dir="/usr/local/bin" elif command -v sudo &>/dev/null && sudo -n true 2>/dev/null; then install_dir="/usr/local/bin" use_sudo=1 else install_dir="${HOME}/.local/bin" mkdir -p "$install_dir" export PATH="${install_dir}:${PATH}" fi log_info "Downloading yq binary (${os}/${arch}) to ${install_dir}..." if command -v wget &>/dev/null; then if [[ "$use_sudo" == "1" ]]; then sudo wget -q "$yq_url" -O "${install_dir}/yq" || return 1 sudo chmod +x "${install_dir}/yq" else wget -q "$yq_url" -O "${install_dir}/yq" || return 1 chmod +x "${install_dir}/yq" fi return 0 elif command -v curl &>/dev/null; then if [[ "$use_sudo" == "1" ]]; then sudo curl -fsSL "$yq_url" -o "${install_dir}/yq" || return 1 sudo chmod +x "${install_dir}/yq" else curl -fsSL "$yq_url" -o "${install_dir}/yq" || return 1 chmod +x "${install_dir}/yq" fi return 0 fi return 1 } # Check if mikefarah/yq v4 is installed; auto-install if not. check_yq() { local need_install=0 # Use type -P to find the actual binary; command -v also matches the yq shell wrapper # function defined in this script and would give a false positive. if ! type -P yq &>/dev/null; then log_warn "yq is not installed — attempting automatic installation..." need_install=1 else local ver_out ver_out=$(command yq --version 2>&1) || true # guard: command not found → exit 127 + set -e if [[ ! "$ver_out" =~ mikefarah ]]; then log_warn "Found yq but it is not mikefarah/yq v4 (got: ${ver_out})" log_warn "arty requires mikefarah/yq v4 — attempting automatic installation..." need_install=1 fi fi if [[ "$need_install" == "0" ]]; then return 0 fi if _install_yq && command -v yq &>/dev/null; then local ver_out ver_out=$(command yq --version 2>&1) log_success "yq installed: ${ver_out}" return 0 fi log_error "Could not install yq automatically. Please install mikefarah/yq v4:" log_error " brew install yq (macOS / Linuxbrew)" log_error " snap install yq (Linux with snap)" log_error " apk add yq-go (Alpine Linux)" log_error " pacman -S go-yq (Arch Linux)" log_error " go install github.com/mikefarah/yq/v4@latest (Go toolchain)" log_error " wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 \\" log_error " -O /usr/local/bin/yq && chmod +x /usr/local/bin/yq" log_error "See: https://github.com/mikefarah/yq#install" exit 1 } # 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 } # List all scripts defined in arty.yml list_scripts() { if [[ ! -f "$ARTY_CONFIG_FILE" ]]; then log_error "Config file not found: $ARTY_CONFIG_FILE" log_info "Run this command in a directory with arty.yml" return 1 fi local script_names script_names=$(list_yaml_scripts "$ARTY_CONFIG_FILE") if [[ -z "$script_names" ]]; then log_info "No scripts defined in $ARTY_CONFIG_FILE" return 0 fi echo printf "${BOLD}Scripts${NC}\n" echo while IFS= read -r name; do [[ -z "$name" ]] && continue local cmd cmd=$(get_yaml_script "$ARTY_CONFIG_FILE" "$name") printf " ${GREEN}%-20s${NC} %s\n" "$name" "$cmd" done <<< "$script_names" echo } # Execute a script defined in arty.yml exec_script() { local script_name="$1" shift if [[ ! -f "$ARTY_CONFIG_FILE" ]]; then log_error "Config file not found: $ARTY_CONFIG_FILE" log_info "Run this command in a directory with arty.yml" return 1 fi local cmd cmd=$(get_yaml_script "$ARTY_CONFIG_FILE" "$script_name") if [[ -z "$cmd" ]] || [[ "$cmd" == "null" ]]; then log_error "Unknown command: $script_name" log_info "Available scripts:" while IFS= read -r name; do [[ -n "$name" ]] && echo " - $name" done < <(list_yaml_scripts "$ARTY_CONFIG_FILE") return 1 fi log_info "Running: $script_name" [[ ! "$cmd" =~ \$@ ]] && cmd="$cmd"' "$@"' bash -c "$cmd" -- "$@" } # 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" # Single yq call using only // and select — if-then-else is not supported in # all yq v4 builds. .url//. handles both string refs (no .url key) and object # refs. env array is joined; null env falls back to empty string. yq eval ".references[$ref_index] | (.url // .) + \"|\" + (.into // \"\") + \"|\" + (.ref // \"\") + \"|\" + ((.env | select(tag == \"!!seq\") | join(\",\")) // .env // \"\")" "$config_file" 2>/dev/null } # 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:-}" 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 if [[ -n "$git_ref" ]]; then (cd "$lib_dir" && git fetch -q 2>/dev/null && git checkout -q "$git_ref" && git pull -q 2>/dev/null) || { log_warn "Failed to update library (continuing with existing version)" } else (cd "$lib_dir" && git fetch -q 2>/dev/null && git pull -q 2>/dev/null) || { log_warn "Failed to update library (continuing with existing version)" } fi 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${git_ref:+ 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 (skip if none given; clone already checked out the default branch) if [[ -n "$git_ref" ]]; then (cd "$lib_dir" && git checkout -q "$git_ref") || { log_warn "Failed to checkout ref '$git_ref', using default branch" } fi # 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 [[ -n "$ref_count" ]] && [[ "$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 [[ -z "$ref_count" ]] || [[ "$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 # 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 [[ -z "$ref_count" ]] || [[ "$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 [[ -z "$ref_count" ]] || [[ "$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 || { COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" } local script="${COMP_WORDS[0]}" # Discover scripts from arty.yml in the current directory at completion time local -a arty_scripts=() if [[ -f arty.yml ]] && command -v yq &>/dev/null; then mapfile -t arty_scripts < <(yq eval '.scripts | keys | .[]' arty.yml 2>/dev/null) fi # Discover installed libraries for the remove command local -a arty_libs=() if [[ -d .arty/libs ]]; then local d for d in .arty/libs/*/; do [[ -d "$d" ]] && arty_libs+=("$(basename "$d")") done fi local commands="install deps list ls notes scripts remove rm init completion help" local global_flags="--dry-run --verbose -v --help -h" case "${COMP_WORDS[1]}" in completion) COMPREPLY=( $(compgen -W "bash zsh --install" -- "$cur") ); return ;; remove|rm) COMPREPLY=( $(compgen -W "${arty_libs[*]}" -- "$cur") ); return ;; install|deps|list|ls|notes|scripts|init) return ;; esac if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "$global_flags" -- "$cur") ); return fi if [[ $COMP_CWORD -eq 1 ]]; then COMPREPLY=( $(compgen -W "$commands ${arty_scripts[*]}" -- "$cur") ) # Trim the portion before the last colon from each candidate so bash can # complete 'go:in' → 'go:install' without duplicating the prefix. declare -f __ltrim_colon_completions >/dev/null 2>&1 && __ltrim_colon_completions "$cur" fi } complete -F _arty_complete arty.sh complete -F _arty_complete arty BASH } _completion_zsh() { cat << 'ZSH' #compdef arty.sh arty _arty() { # Discover scripts from arty.yml in the current directory at completion time. # Fetch key + first line of value together so display shows the command. local -a arty_scripts=() arty_script_cmds=() if [[ -f arty.yml ]] && command -v yq &>/dev/null; then local _k _v while IFS=$'\t' read -r _k _v; do [[ -n "$_k" ]] || continue arty_scripts+=("$_k") arty_script_cmds+=("$_v") done < <(yq eval '.scripts | to_entries | .[] | .key + "\t" + (.value | split("\n") | .[0])' arty.yml 2>/dev/null) fi # Discover installed libraries for the remove command local -a arty_libs=() if [[ -d .arty/libs ]]; then local d for d in .arty/libs/*/; do [[ -d "$d" ]] && arty_libs+=("${d:t}") done fi local -a commands=( 'install:Install a library from git' 'deps:Install all dependencies from arty.yml' 'list:List installed libraries' 'notes:Display project notes from arty.yml' 'scripts:List all scripts defined in arty.yml' 'remove:Remove an installed library' 'init:Initialize a new arty.yml project' 'completion:Generate shell completion script' 'help:Show help' ) _arguments -C \ '--dry-run[Simulate without making changes]' \ '(-v --verbose)'{-v,--verbose}'[Verbose output]' \ '(-h --help)'{-h,--help}'[Show help]' \ '1:command:->cmd' \ '*:args:->args' && return case "$state" in cmd) _describe 'command' commands if [[ ${#arty_scripts[@]} -gt 0 ]]; then local -a _arty_disp=() local _i for (( _i = 1; _i <= ${#arty_scripts[@]}; _i++ )); do _arty_disp+=("${arty_scripts[_i]} -- ${arty_script_cmds[_i]}") done compadd -d _arty_disp -a arty_scripts fi ;; args) case "${words[2]}" in remove|rm) _values 'library' "${arty_libs[@]}" ;; completion) _arguments \ '--install[Install the completion file]' \ '1:shell:(bash zsh)' ;; esac ;; esac } _arty "$@" ZSH } cmd_completion() { local shell="" install=false for arg in "$@"; do case "$arg" in bash|zsh) shell="$arg" ;; --install) install=true ;; --dry-run) ;; # already handled globally *) log_error "Unknown argument: $arg (bash|zsh [--install])"; return 1 ;; esac done if [[ -z "$shell" ]]; then shell="$(basename "${SHELL:-bash}")" [[ "$shell" == "bash" || "$shell" == "zsh" ]] || shell="bash" log_warn "No shell specified, defaulting to: $shell" fi local content case "$shell" in bash) content="$(_completion_bash)" ;; zsh) content="$(_completion_zsh)" ;; *) log_error "Unsupported shell: $shell (bash|zsh)"; return 1 ;; esac if ! $install; then echo "$content" return fi local dest case "$shell" in bash) if [[ -w /etc/bash_completion.d ]]; then dest="/etc/bash_completion.d/arty" else dest="${HOME}/.local/share/bash-completion/completions/arty" mkdir -p "$(dirname "$dest")" fi ;; zsh) dest="" local -a fpath_dirs=() while IFS=: read -ra fpath_dirs; do :; done <<< "${FPATH:-}" for d in "${fpath_dirs[@]}"; do [[ -d "$d" && -w "$d" ]] && { dest="$d/_arty"; break; } done if [[ -z "$dest" ]]; then dest="${HOME}/.zsh/completions/_arty" mkdir -p "$(dirname "$dest")" log_info "Add to ~/.zshrc: ${BOLD}fpath=(~/.zsh/completions \$fpath)${NC}" log_info "Then reload: ${BOLD}autoload -U compinit && compinit${NC}" fi ;; esac if [[ "$ARTY_DRY_RUN" == "1" ]]; then log_info "[DRY RUN] Would write $shell completion to: $dest" return fi echo "$content" > "$dest" log_success "$shell completion installed: $dest" [[ "$shell" == "bash" ]] && log_info "Reload: source $dest" } # 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] 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) scripts List all scripts defined in arty.yml remove Remove an installed library init [name] Initialize a new arty.yml project completion bash|zsh Generate shell completion script [args] Run a script defined in arty.yml scripts section FLAGS: --dry-run Simulate installation without making changes -v, --verbose Enable verbose/debug output -h, --help Show this help message EXAMPLES: 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 scripts arty init my-project arty completion zsh --install arty completion bash --install arty npm/install arty rust/install REFERENCE FORMATS: Simple URL: references: - https://github.com/user/repo.git Extended object: references: - url: git@github.com:user/repo.git into: custom/path # installation directory (relative to arty.yml) ref: v1.0.0 # branch, tag, or commit hash env: production # only install in this environment Multiple environments: references: - url: git@github.com:user/dev-tools.git env: [dev, ci] PROJECT STRUCTURE: project/ .arty/ libs/ # cloned dependencies arty.yml ENVIRONMENT: ARTY_HOME Home directory for arty (default: $PWD/.arty) ARTY_ENV Environment name to load from arty.yml (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 # Scan flags first local dry_run_flag=0 local verbose_flag=0 for arg in "$@"; do case "$arg" in --dry-run) dry_run_flag=1 ;; --verbose|-v) verbose_flag=1 ;; esac done if [[ "$verbose_flag" == "1" ]]; then export ARTY_VERBOSE=1 fi load_env_vars 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 # Extract command and positional args, stripping flags local args=() local command="" for arg in "$@"; do case "$arg" in --dry-run|--verbose|-v|-h|--help) continue ;; *) if [[ -z "$command" ]]; then command="$arg" else args+=("$arg") fi ;; esac done 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 if [[ $install_result -eq 0 ]] && [[ -f "$ARTY_CONFIG_FILE" ]]; then local ref_count=$(yq eval '.references | length' "$ARTY_CONFIG_FILE" 2>/dev/null) if [[ -n "$ref_count" ]] && [[ "$ref_count" != "null" ]] && [[ "$ref_count" != "0" ]]; then echo log_success "Installation complete!" list_libs fi fi ;; deps) install_references local install_result=$? if [[ $install_result -eq 0 ]] && [[ -f "$ARTY_CONFIG_FILE" ]]; then local ref_count=$(yq eval '.references | length' "$ARTY_CONFIG_FILE" 2>/dev/null) if [[ -n "$ref_count" ]] && [[ "$ref_count" != "null" ]] && [[ "$ref_count" != "0" ]]; then echo log_success "Dependencies installed!" list_libs fi fi ;; list | ls) list_libs ;; notes) show_notes ;; scripts) list_scripts ;; remove | rm) if [[ ${#args[@]} -eq 0 ]]; then log_error "Library name required" exit 1 fi remove_lib "${args[0]}" ;; init) init_project "${args[@]}" ;; completion) cmd_completion "${args[@]+"${args[@]}"}" ;; *) exec_script "$command" "${args[@]}" ;; esac } # Run main if script is executed directly if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@" fi