Files
bin/arty.sh

2004 lines
54 KiB
Bash
Raw Normal View History

#!/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
# ============================================================================
# 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
if yq eval '.envs.default' "$config_file" 2>/dev/null | grep -q -v '^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 < <(yq eval '.envs.default | to_entries | .[] | .key + "=" + .value' "$config_file" 2>/dev/null)
fi
# Then load environment-specific variables (which can override defaults)
if [[ "$current_env" != "default" ]]; then
if yq eval ".envs.$current_env" "$config_file" 2>/dev/null | grep -q -v '^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 < <(yq eval ".envs.$current_env | to_entries | .[] | .key + \"=\" + .value" "$config_file" 2>/dev/null)
fi
fi
}
# Check if yq is installed
check_yq() {
if ! command -v yq &>/dev/null; then
log_error "yq is not installed. Please install yq to use arty."
log_info "Visit https://github.com/mikefarah/yq for installation instructions"
log_info "Quick install: brew install yq (macOS) or see README.md"
exit 1
fi
}
# Check if git is installed
check_dependencies() {
local missing=0
if ! command -v yq &>/dev/null; then
log_error "yq is not installed"
missing=1
fi
if ! command -v git &>/dev/null; then
log_error "git is not installed"
missing=1
fi
if [[ $missing -eq 1 ]]; then
log_error "Missing required dependencies"
exit 1
fi
}
# Initialize arty environment
init_arty() {
local arty_home="${ARTY_HOME:-$PWD/.arty}"
local libs_dir="${ARTY_LIBS_DIR:-$arty_home/libs}"
local bin_dir="${ARTY_BIN_DIR:-$arty_home/bin}"
if [[ ! -d "$arty_home" ]]; then
mkdir -p "$libs_dir"
mkdir -p "$bin_dir"
log_success "Initialized arty at $arty_home"
fi
}
# Get a field from YAML using yq
get_yaml_field() {
local file="$1"
local field="$2"
if [[ ! -f "$file" ]]; then
return 1
fi
# Check if file has valid YAML - yq will exit with error on invalid YAML
if ! yq eval '.' "$file" >/dev/null 2>&1; then
return 1
fi
yq eval ".$field" "$file" 2>/dev/null || echo ""
}
# Get array items from YAML using yq
get_yaml_array() {
local file="$1"
local field="$2"
if [[ ! -f "$file" ]]; then
return 1
fi
# Check if file has valid YAML - yq will exit with error on invalid YAML
if ! yq eval '.' "$file" >/dev/null 2>&1; then
return 1
fi
yq eval ".${field}[]" "$file" 2>/dev/null
}
# Get script command from YAML
get_yaml_script() {
local file="$1"
local script_name="$2"
if [[ ! -f "$file" ]]; then
return 1
fi
yq eval ".scripts.${script_name}" "$file" 2>/dev/null || echo "null"
}
# List all script names from YAML
list_yaml_scripts() {
local file="$1"
if [[ ! -f "$file" ]]; then
return 1
fi
yq eval '.scripts | keys | .[]' "$file" 2>/dev/null
}
# Get library name from repository URL
get_lib_name() {
local repo_url="$1"
basename "$repo_url" .git
}
# Parse reference - can be a string URL or an object with url, into, ref, env
# Returns: url|into|ref|env (pipe-delimited)
# env can be a single value or comma-separated list
parse_reference() {
local config_file="$1"
local ref_index="$2"
# Check if reference is a string or object
local ref_type=$(yq eval ".references[$ref_index] | type" "$config_file" 2>/dev/null)
if [[ "$ref_type" == "!!str" ]]; then
# Simple string format: just the URL
local url=$(yq eval ".references[$ref_index]" "$config_file" 2>/dev/null)
echo "$url||||"
else
# Object format with url, into, ref, env fields
local url=$(yq eval ".references[$ref_index].url" "$config_file" 2>/dev/null)
local into=$(yq eval ".references[$ref_index].into" "$config_file" 2>/dev/null)
local ref=$(yq eval ".references[$ref_index].ref" "$config_file" 2>/dev/null)
# Check if env is an array or string
local env_type=$(yq eval ".references[$ref_index].env | type" "$config_file" 2>/dev/null)
local env=""
if [[ "$env_type" == "!!seq" ]]; then
# Array format - convert to comma-separated string
env=$(yq eval ".references[$ref_index].env | join(\",\")" "$config_file" 2>/dev/null)
else
# Single value or null
env=$(yq eval ".references[$ref_index].env" "$config_file" 2>/dev/null)
fi
# Replace "null" with empty string
[[ "$url" == "null" ]] && url=""
[[ "$into" == "null" ]] && into=""
[[ "$ref" == "null" ]] && ref=""
[[ "$env" == "null" ]] && env=""
echo "$url|$into|$ref|$env"
fi
}
# Check if current environment matches the filter
# env_filter can be a single env or comma-separated list
check_env_match() {
local current_env="$1"
local env_filter="$2"
# No filter means match all
[[ -z "$env_filter" ]] && return 0
# Convert comma-separated list to array
IFS=',' read -ra env_list <<< "$env_filter"
# Check if current env is in the list
for env in "${env_list[@]}"; do
# Trim whitespace
env=$(echo "$env" | xargs)
if [[ "$env" == "$current_env" ]]; then
return 0
fi
done
return 1
}
# Get git information for a repository
get_git_info() {
local repo_dir="$1"
if [[ ! -d "$repo_dir/.git" ]]; then
echo "|||0"
return
fi
# Get short commit hash
local commit_hash=$(cd "$repo_dir" && git rev-parse --short HEAD 2>/dev/null || echo "")
# Get all refs pointing to current commit (tags, branches)
local refs=$(cd "$repo_dir" && git describe --all --exact-match 2>/dev/null || git symbolic-ref --short HEAD 2>/dev/null || echo "")
# Clean up refs (remove heads/ and tags/ prefixes)
refs=$(echo "$refs" | sed 's#^heads/##' | sed 's#^tags/##')
# Check if dirty (has uncommitted changes)
local is_dirty=0
if [[ -n "$(cd "$repo_dir" && git status --porcelain 2>/dev/null)" ]]; then
is_dirty=1
fi
echo "$commit_hash|$refs|$is_dirty"
}
# Normalize library identifier for tracking
normalize_lib_id() {
local repo_url="$1"
# Convert to lowercase and remove .git suffix for consistent tracking
local normalized="${repo_url,,}"
normalized=$(echo "$normalized" | sed 's/\.git$//')
# Normalize different Git URL formats to the same path
# https://github.com/user/repo -> github.com/user/repo
# git@github.com:user/repo -> github.com/user/repo
# ssh://git@github.com/user/repo -> github.com/user/repo
normalized=$(echo "$normalized" | sed -E 's#^https?://##' | sed -E 's#^git@([^:]+):#\1/#' | sed -E 's#^ssh://git@##')
echo "$normalized"
}
# Check if library is in installation stack
is_installing() {
local lib_id="$1"
[[ -n "${ARTY_INSTALL_STACK[$lib_id]:-}" ]]
}
# Add library to installation stack
mark_installing() {
local lib_id="$1"
ARTY_INSTALL_STACK[$lib_id]=1
}
# Remove library from installation stack
unmark_installing() {
local lib_id="$1"
unset ARTY_INSTALL_STACK[$lib_id]
}
# Check if library is already installed
is_installed() {
local lib_name="$1"
local libs_dir="${ARTY_LIBS_DIR:-${ARTY_HOME:-$PWD/.arty}/libs}"
[[ -d "$libs_dir/$lib_name" ]]
}
# Install a library from git repository
install_lib() {
local repo_url="$1"
local lib_name="${2:-$(get_lib_name "$repo_url")}"
local git_ref="${3:-main}"
local custom_into="${4:-}"
local config_file="${5:-$ARTY_CONFIG_FILE}"
# Determine installation directory
local lib_dir
if [[ -n "$custom_into" ]]; then
# Custom directory relative to config file directory
local config_dir=$(dirname "$(realpath "${config_file}")")
lib_dir="$config_dir/$custom_into"
else
# Use global .arty/libs for libraries without custom 'into'
lib_dir="$ARTY_LIBS_DIR/$lib_name"
fi
# Normalize the library identifier for circular dependency detection
# Include installation path to allow same library at different locations
local lib_id=$(normalize_lib_id "$repo_url")
local lib_id_with_path="${lib_id}@${lib_dir}"
# Check for circular dependency
if is_installing "$lib_id_with_path"; then
log_warn "Circular dependency detected: $lib_name (already being installed at $lib_dir)"
log_info "Skipping to prevent infinite loop"
return 0
fi
# Check if already installed (optimization)
if [[ -d "$lib_dir" ]]; then
log_info "Library '$lib_name' already installed at $lib_dir"
if [[ "$ARTY_DRY_RUN" == "1" ]]; then
log_info "[DRY RUN] Would check for updates..."
return 0
fi
# Try to update
(cd "$lib_dir" && git fetch -q && git checkout -q "$git_ref" && git pull -q) || {
log_warn "Failed to update library (continuing with existing version)"
}
return 0
fi
# Mark as currently installing
mark_installing "$lib_id_with_path"
if [[ "$ARTY_DRY_RUN" != "1" ]]; then
init_arty
fi
log_info "Installing library: $lib_name"
log_info "Repository: $repo_url"
log_info "Git ref: $git_ref"
log_info "Location: $lib_dir"
if [[ "$ARTY_DRY_RUN" == "1" ]]; then
log_info "[DRY RUN] Would clone repository and checkout $git_ref"
unmark_installing "$lib_id_with_path"
return 0
fi
# Clone the repository
git clone "$repo_url" "$lib_dir" || {
log_error "Failed to clone repository"
unmark_installing "$lib_id_with_path"
return 1
}
# Checkout the specified ref
(cd "$lib_dir" && git checkout -q "$git_ref") || {
log_warn "Failed to checkout ref '$git_ref', using default branch"
}
# Run setup hook if exists
if [[ -f "$lib_dir/setup.sh" ]]; then
log_info "Running setup hook..."
(cd "$lib_dir" && bash setup.sh) || {
log_warn "Setup hook failed, continuing anyway..."
}
fi
# Process arty.yml if it exists
if [[ -f "$lib_dir/arty.yml" ]]; then
# Link main script to .arty/bin (only for standard installations, not custom 'into')
if [[ -z "$custom_into" ]]; then
local main_script=$(get_yaml_field "$lib_dir/arty.yml" "main")
if [[ -n "$main_script" ]] && [[ "$main_script" != "null" ]]; then
local main_file="$lib_dir/$main_script"
if [[ -f "$main_file" ]]; then
local local_bin_dir="$ARTY_BIN_DIR"
local lib_name_stripped="$(basename $main_file .sh)"
local bin_link="$local_bin_dir/$lib_name_stripped"
log_info "Linking main script: $main_script -> $bin_link"
ln -sf "$main_file" "$bin_link"
chmod +x "$main_file"
log_success "Main script linked to $bin_link"
fi
fi
fi
# Install nested dependencies (always, regardless of 'into')
log_info "Found arty.yml, checking for references..."
install_references "$lib_dir/arty.yml"
fi
# Unmark as installing (we're done with this library)
unmark_installing "$lib_id_with_path"
log_success "Library '$lib_name' installed successfully"
log_info "Location: $lib_dir"
}
# Find if a library is defined in current or ancestor configs with 'into'
find_ancestor_into() {
local lib_url="$1"
local current_config="$2"
local lib_url_normalized=$(normalize_lib_id "$lib_url")
# Build list of ancestor configs to check
# Start with current config's directory and walk up to find parent arty.yml files
local config_to_check="$current_config"
local current_dir=$(dirname "$(realpath "$current_config")")
# Check current config and walk up the directory tree
while true; do
if [[ -f "$config_to_check" ]]; then
# Get count of references
local ref_count=$(yq eval '.references | length' "$config_to_check" 2>/dev/null)
if [[ "$ref_count" != "null" ]] && [[ "$ref_count" != "0" ]]; then
# Check each reference
for ((i = 0; i < ref_count; i++)); do
local ref_data=$(parse_reference "$config_to_check" "$i")
IFS='|' read -r url into git_ref env_filter <<<"$ref_data"
local url_normalized=$(normalize_lib_id "$url")
if [[ "$url_normalized" == "$lib_url_normalized" ]] && [[ -n "$into" ]]; then
# Found it with an 'into' directive
echo "$into|$config_to_check"
return
fi
done
fi
fi
# Check if we've reached the root ARTY_CONFIG_FILE (after checking it)
if [[ "$(realpath "$config_to_check" 2>/dev/null)" == "$(realpath "$ARTY_CONFIG_FILE" 2>/dev/null)" ]]; then
# We've checked the root config, stop here
break
fi
# Move up one directory
local parent_dir=$(dirname "$current_dir")
if [[ "$parent_dir" == "$current_dir" ]] || [[ "$parent_dir" == "/" ]]; then
# Reached filesystem root, stop
break
fi
current_dir="$parent_dir"
config_to_check="$current_dir/arty.yml"
done
echo ""
}
# Install all references from arty.yml
install_references() {
local config_file="${1:-$ARTY_CONFIG_FILE}"
if [[ ! -f "$config_file" ]]; then
log_error "Config file not found: $config_file"
return 1
fi
# Check if YAML is valid by trying to read it
if ! yq eval '.' "$config_file" >/dev/null 2>&1; then
log_error "Invalid YAML in config file: $config_file"
return 1
fi
# Initialize arty directory structure first (unless dry run)
if [[ "$ARTY_DRY_RUN" != "1" ]]; then
init_arty
fi
# Count references
local ref_count=$(yq eval '.references | length' "$config_file" 2>/dev/null)
if [[ "$ref_count" == "null" ]] || [[ "$ref_count" == "0" ]]; then
log_info "No references to install"
return 0
fi
# Process each reference by index
local i
for ((i = 0; i < ref_count; i++)); do
# Parse the reference
local ref_data=$(parse_reference "$config_file" "$i")
IFS='|' read -r url into git_ref env_filter <<<"$ref_data"
# Skip empty URLs
if [[ -z "$url" ]] || [[ "$url" == "null" ]]; then
continue
fi
# Check environment filter using the new check_env_match function
if [[ -n "$env_filter" ]] && ! check_env_match "$ARTY_ENV" "$env_filter"; then
log_info "Skipping reference (env filter: [$env_filter], current: $ARTY_ENV): $url"
continue
fi
# Use default ref if not specified
[[ -z "$git_ref" ]] && git_ref="main"
# Get library name
local lib_name=$(get_lib_name "$url")
# If no 'into' specified, check if an ancestor config defines it with 'into'
local effective_config="$config_file"
if [[ -z "$into" ]] && [[ "$config_file" != "$ARTY_CONFIG_FILE" ]]; then
local ancestor_result=$(find_ancestor_into "$url" "$config_file")
if [[ -n "$ancestor_result" ]]; then
IFS='|' read -r ancestor_into ancestor_config <<<"$ancestor_result"
into="$ancestor_into"
effective_config="$ancestor_config"
fi
fi
log_info "Installing reference: $url"
[[ -n "$into" ]] && log_info "Custom location: $into"
[[ -n "$env_filter" ]] && log_info "Environment filter: [$env_filter]"
install_lib "$url" "$lib_name" "$git_ref" "$into" "$effective_config" || log_warn "Failed to install reference: $url"
done
}
# Build a reference tree from arty.yml showing all dependencies
build_reference_tree() {
local config_file="${1:-$ARTY_CONFIG_FILE}"
local indent="${2:-}"
local is_last="${3:-1}"
local visited_file="${4:-/tmp/arty_visited_$$}"
local depth="${5:-0}"
if [[ ! -f "$config_file" ]]; then
return 0
fi
# Track visited library directories to prevent infinite circular recursion
# Only track at depth > 0 to allow root-level refs to show even if already nested
local config_dir=$(dirname "$(realpath "$config_file" 2>/dev/null || echo "$config_file")")
if [[ "$depth" -gt 0 ]]; then
if grep -qx "$config_dir" "$visited_file" 2>/dev/null; then
return 0
fi
echo "$config_dir" >>"$visited_file"
fi
# Count references
local ref_count=$(yq eval '.references | length' "$config_file" 2>/dev/null)
if [[ "$ref_count" == "null" ]] || [[ "$ref_count" == "0" ]]; then
return 0
fi
# Process each reference
local i
for ((i = 0; i < ref_count; i++)); do
local ref_data=$(parse_reference "$config_file" "$i")
IFS='|' read -r url into git_ref env_filter <<<"$ref_data"
# Skip if no URL or env filter doesn't match
if [[ -z "$url" ]] || [[ "$url" == "null" ]]; then
continue
fi
if [[ -n "$env_filter" ]] && ! check_env_match "$ARTY_ENV" "$env_filter"; then
continue
fi
local lib_name=$(get_lib_name "$url")
# Calculate is_last_ref by counting remaining valid refs
local is_last_ref=0
local remaining=0
for ((j = i + 1; j < ref_count; j++)); do
local check_ref=$(parse_reference "$config_file" "$j")
IFS='|' read -r check_url _ _ check_env <<<"$check_ref"
if [[ -n "$check_url" ]] && [[ "$check_url" != "null" ]]; then
if [[ -z "$check_env" ]] || check_env_match "$ARTY_ENV" "$check_env"; then
remaining=1
break
fi
fi
done
[[ "$remaining" == "0" ]] && is_last_ref=1
# If no 'into' specified, check if an ancestor config defines it with 'into'
local effective_config="$config_file"
if [[ -z "$into" ]] && [[ "$config_file" != "$ARTY_CONFIG_FILE" ]]; then
local ancestor_result=$(find_ancestor_into "$url" "$config_file")
if [[ -n "$ancestor_result" ]]; then
IFS='|' read -r ancestor_into ancestor_config <<<"$ancestor_result"
into="$ancestor_into"
effective_config="$ancestor_config"
fi
fi
# Determine installation directory
local lib_dir
if [[ -n "$into" ]]; then
local config_dir=$(dirname "$(realpath "${effective_config}")")
lib_dir="$config_dir/$into"
else
lib_dir="$ARTY_LIBS_DIR/$lib_name"
fi
# Get version and git info
local version=""
local location="$lib_dir"
if [[ -f "$lib_dir/arty.yml" ]]; then
version=$(get_yaml_field "$lib_dir/arty.yml" "version")
[[ "$version" == "null" ]] || [[ -z "$version" ]] && version=""
fi
# Get git information
local git_info=$(get_git_info "$lib_dir")
IFS='|' read -r commit_hash git_refs is_dirty <<<"$git_info"
# Determine display version
local display_version="${version:-$commit_hash}"
[[ -z "$display_version" ]] && display_version="unknown"
# Tree characters
local tree_char=""
local tree_continue=" "
if [[ "$is_last_ref" == "1" ]]; then
tree_char=""
tree_continue=" "
fi
# Print the reference line
printf "%s${tree_char} ${BOLD}${GREEN}%s${NC}" "$indent" "$lib_name"
printf " ${CYAN}%s${NC}" "$display_version"
# Add git refs if available
if [[ -n "$git_refs" ]]; then
printf " ${BLUE}(%s)${NC}" "$git_refs"
fi
# Add dirty indicator
if [[ "$is_dirty" == "1" ]]; then
printf " ${YELLOW}${NC}"
fi
# Add location info
if [[ -n "$into" ]]; then
printf " ${MAGENTA}<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
}
# 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
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 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
;;
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