Files
bin/artifact_git_download.sh
Sebastian Krüger 60fcc359a4 fix: expand env vars and check absolute paths in build_reference_tree
The build_reference_tree() function was prepending config_dir to all
'into' paths without expanding environment variables or checking if the
result was absolute. This caused paths like /workspace/ai//workspace/ComfyUI
instead of /workspace/ComfyUI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 10:55:57 +01:00

2175 lines
60 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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}[<5B>]${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}[<5B>]${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}<EFBFBD> %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'(?<!\*)\*([^\*]+?)\*(?!\*)', r'\033[3m\1\033[0m', text)
text = re.sub(r'(?<!_)_([^_]+?)_(?!_)', r'\033[3m\1\033[0m', text)
# Inline code (using backticks)
text = re.sub(r'`([^`]+?)`', r'\033[0;36m\1\033[0m', text)
# Links
text = re.sub(r'\[(.+?)\]\((.+?)\)', r'\033[4;34m\1\033[0m (\2)', text)
# Lists
text = re.sub(r'^- (.+)$', r' • \1', text, flags=re.MULTILINE)
text = re.sub(r'^• (.+)$', r' • \1', text, flags=re.MULTILINE)
text = re.sub(r'^\* (.+)$', r' • \1', text, flags=re.MULTILINE)
text = re.sub(r'^\+ (.+)$', r' • \1', text, flags=re.MULTILINE)
# Numbered lists
text = re.sub(r'^(\d+)\. (.+)$', r' \1. \2', text, flags=re.MULTILINE)
print(text)
EOPY
rm -f "$temp_notes"
else
# Absolute fallback: just print the raw text
echo "$notes"
fi
echo
echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo
}
# Remove a library
remove_lib() {
local lib_name="$1"
local lib_dir="$ARTY_LIBS_DIR/$lib_name"
if [[ ! -d "$lib_dir" ]]; then
log_error "Library not found: $lib_name"
return 1
fi
log_info "Removing library: $lib_name"
rm -rf "$lib_dir"
log_success "Library removed"
}
# Initialize a new arty.yml project
init_project() {
local project_name="${1:-$(basename "$PWD")}"
if [[ -f "$ARTY_CONFIG_FILE" ]]; then
log_error "arty.yml already exists in current directory"
return 1
fi
log_info "Initializing new arty project: $project_name"
# Create local .arty folder structure
local local_arty_dir=".arty"
local local_bin_dir="$local_arty_dir/bin"
local local_libs_dir="$local_arty_dir/libs"
log_info "Creating project structure"
mkdir -p "$local_bin_dir" "$local_libs_dir"
cat >"$ARTY_CONFIG_FILE" <<EOF
name: "$project_name"
version: "0.1.0"
description: "A bash library project"
author: ""
license: "MIT"
# Dependencies from other arty.sh repositories
references:
# - https://github.com/user/some-bash-lib.git
# - https://github.com/user/another-lib.git
# Entry point script
main: "index.sh"
# Scripts that can be executed
scripts:
test: "bash test.sh"
build: "bash build.sh"
EOF
log_success "Created $ARTY_CONFIG_FILE"
log_success "Created .arty/ folder structure"
}
# Source/load a library
source_lib() {
local lib_name="$1"
local lib_file="${2:-index.sh}"
local libs_dir="${ARTY_LIBS_DIR:-${ARTY_HOME:-$PWD/.arty}/libs}"
local lib_path="$libs_dir/$lib_name/$lib_file"
if [[ ! -f "$lib_path" ]]; then
log_error "Library file not found: $lib_path"
return 1
fi
source "$lib_path"
}
# Execute a library's main script
exec_lib() {
local lib_name="$1"
shift # Remove lib_name from arguments, rest are passed to the script
local lib_name_stripped="$(basename $lib_name .sh)"
local bin_dir="${ARTY_BIN_DIR:-${ARTY_HOME:-$PWD/.arty}/bin}"
local bin_path="$bin_dir/$lib_name_stripped"
if [[ ! -f "$bin_path" ]]; then
log_error "Library executable not found: $lib_name_stripped"
log_info "Make sure the library is installed with 'arty deps' or 'arty install'"
log_info "Available executables:"
if [[ -d "$bin_dir" ]]; then
for exec_file in $bin_dir/*; do
if [[ -f "$exec_file" ]]; then
echo " -- $(basename "$exec_file")"
fi
done
else
echo " (none found - run 'arty deps' first)"
fi
return 1
fi
if [[ ! -x "$bin_path" ]]; then
log_error "Library executable is not executable: $bin_path"
return 1
fi
# Execute the library's main script with all passed arguments
"$bin_path" "$@"
}
# Execute a script from arty.yml
exec_script() {
local script_name="$1"
shift # Remove script name from arguments
local config_file="${ARTY_CONFIG_FILE}"
if [[ ! -f "$config_file" ]]; then
log_error "Config file not found: $config_file"
log_info "Run this command in a directory with arty.yml"
return 1
fi
# Get script command using yq
local cmd=$(get_yaml_script "$config_file" "$script_name")
if [[ -z "$cmd" ]] || [[ "$cmd" == "null" ]]; then
log_error "Script not found in arty.yml: $script_name"
log_info "Available scripts:"
while IFS= read -r name; do
if [[ -n "$name" ]]; then
echo " - $name"
fi
done < <(list_yaml_scripts "$config_file")
return 1
fi
log_info "Executing script: $script_name"
# Pass all remaining arguments to the script
# If the command doesn't already reference $@, append it
if [[ ! "$cmd" =~ \$@ ]]; then
cmd="$cmd"' "$@"'
fi
# The $@ in the command string will expand to the arguments passed after --
bash -c "$cmd" -- "$@"
return $?
}
# ============================================================================
# WHIP.SH FUNCTIONS - Release Cycle Management
# ============================================================================
# Get current version from arty.yml
get_current_version() {
local config="${1:-$WHIP_CONFIG}"
if [[ ! -f "$config" ]]; then
echo "0.0.0"
return
fi
local version=$(yq eval '.version' "$config" 2>/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 <subcommand> [options] [pattern]
SUBCOMMANDS:
list [root] [pattern] List all arty.yml projects
version [root] [pattern] Show version of all projects
bump <type> [root] [pattern] Bump version (major|minor|patch)
status [root] [pattern] Show git status for all projects
exec <command> [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 <command> [arguments] [--dry-run] [-v|--verbose]
REPOSITORY COMMANDS:
install <repo-url> [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 <name> Remove an installed library
init [name] Initialize a new arty.yml project
source <name> [file] Source a library (for use in scripts)
exec <lib-name> [args] Execute a library's main script with arguments
<script-name> 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 <major|minor|patch> Bump version in arty.yml (no commit/tag)
changelog Generate changelog from git history
tag <version> 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 <type> [root] [pattern] Bump version for all projects
mono status [root] [pattern] Show git status for all projects
mono exec <cmd> [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 <file> Use custom config file (default: arty.yml)
--changelog <file> 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 <library-name> [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 <command> [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