#!/bin/bash # arty.sh - A bash library repository and release 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 } # 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" < [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) remove Remove an installed library init [name] Initialize a new arty.yml project [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 init my-project 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 ;; remove | rm) if [[ ${#args[@]} -eq 0 ]]; then log_error "Library name required" exit 1 fi remove_lib "${args[0]}" ;; init) init_project "${args[@]}" ;; *) exec_script "$command" "${args[@]}" ;; esac } # Run main if script is executed directly if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@" fi