Files
home/scripts/arty.sh
T
valknar b870738f9a refactor: strip release cycle, monorepo, exec-lib, and source from arty.sh
- Remove whip/release-cycle commands: release, version, bump, changelog,
  tag, hooks (and all associated functions)
- Remove monorepo commands: mono/monorepo (and all associated functions)
- Remove exec command (exec_lib / .arty/bin runner)
- Remove source command (source_lib)
- Remove test/build scripts from init_project template
- Restore exec_script wildcard so arty.yml scripts (npm/install, etc.) still work
- Add auto-install for mikefarah/yq v4 with multi-method fallback
- Fix silent crash: command -v matched the yq shell wrapper function;
  switched to type -P and guarded version-check assignment with || true

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 00:10:32 +02:00

1327 lines
39 KiB
Bash
Executable File

#!/bin/bash
# arty.sh - A bash library repository and release management system
# Version: 1.0.0
set -euo pipefail
ARTY_CONFIG_FILE="${ARTY_CONFIG_FILE:-arty.yml}"
ARTY_ENV="${ARTY_ENV:-default}"
ARTY_DRY_RUN="${ARTY_DRY_RUN:-0}"
ARTY_VERBOSE="${ARTY_VERBOSE:-0}"
# Enable colors only when stdout is a real terminal (or FORCE_COLOR=1 overrides)
if [[ -t 1 && "${TERM:-}" != "dumb" ]]; then
export FORCE_COLOR=${FORCE_COLOR:-"1"}
else
export FORCE_COLOR=${FORCE_COLOR:-"0"}
fi
if [[ "$FORCE_COLOR" = "0" ]]; then
export RED=''
export GREEN=''
export YELLOW=''
export BLUE=''
export CYAN=''
export MAGENTA=''
export BOLD=''
export NC=''
else
export RED='\033[0;31m'
export GREEN='\033[0;32m'
export YELLOW='\033[1;33m'
export BLUE='\033[0;34m'
export CYAN='\033[0;36m'
export MAGENTA='\033[0;35m'
export BOLD='\033[1m'
export NC='\033[0m'
fi
# Global array to track installation stack (prevent circular dependencies)
declare -g -A ARTY_INSTALL_STACK
# Logging functions
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[ok]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[!]${NC} $1"
}
log_error() {
echo -e "${RED}[x]${NC} $1"
}
log_debug() {
if [[ "$ARTY_VERBOSE" == "1" ]]; then
echo -e "${CYAN}[DEBUG]${NC} $1"
fi
}
log_step() {
echo -e "${CYAN}[!]${NC} $1"
}
# ============================================================================
# ARTY.SH CORE FUNCTIONS - Repository Management
# ============================================================================
# Expand environment variables in a string with validation
# Supports nested expansion (e.g., $AI_ROOT/subdir where AI_ROOT contains another var)
# Fails if any undefined variables remain after expansion
expand_env_vars() {
local input="$1"
local max_iterations=10
local iteration=0
local expanded="$input"
# Keep expanding until no more variables or max iterations reached
while [[ "$expanded" =~ \$ ]] && [[ $iteration -lt $max_iterations ]]; do
# Use eval to expand variables
expanded=$(eval echo "$expanded" 2>/dev/null || echo "$expanded")
iteration=$((iteration+1))
done
# Check if any unexpanded variables remain
if [[ "$expanded" =~ \$[A-Za-z_][A-Za-z0-9_]* ]]; then
# Extract the undefined variable name for better error message
local undefined_var=$(echo "$expanded" | grep -o '\$[A-Za-z_][A-Za-z0-9_]*' | head -1)
log_error "Undefined environment variable in path: ${undefined_var}"
log_error " Original: $input"
log_error " Expanded: $expanded"
log_error ""
log_error "Available environment variables from arty.yml:"
if [[ -f "$ARTY_CONFIG_FILE" ]]; then
yq eval '.envs.default | keys | .[]' "$ARTY_CONFIG_FILE" 2>/dev/null | sed 's/^/ - /' || true
fi
return 1
fi
echo "$expanded"
}
# Load environment variables from arty.yml
load_env_vars() {
local config_file="${1:-$ARTY_CONFIG_FILE}"
if [[ ! -f "$config_file" ]]; then
return 0 # No config file, nothing to load
fi
# Check if YAML is valid
if ! yq eval '.' "$config_file" >/dev/null 2>&1; then
log_warn "Invalid YAML in config file, skipping env vars"
return 0
fi
# Check if envs section exists
local has_envs=$(yq eval '.envs' "$config_file" 2>/dev/null)
if [[ "$has_envs" == "null" ]] || [[ -z "$has_envs" ]]; then
return 0 # No envs section
fi
local current_env="$ARTY_ENV"
log_debug "Loading environment variables from '$current_env' environment"
# First load default variables if they exist
local default_envs=$(yq eval '.envs.default | to_entries | .[] | .key + "=" + .value' "$config_file" 2>/dev/null)
if [[ -n "$default_envs" ]] && [[ "$default_envs" != "null" ]]; then
while IFS='=' read -r key value; do
if [[ -n "$key" ]] && [[ "$key" != "null" ]] && [[ -n "$value" ]]; then
# Only export if not already set (for default env only)
if [[ "$current_env" == "default" ]] && [[ -n "${!key:-}" ]]; then
log_debug " Skipping $key (already set)"
continue
fi
export "$key=$value"
log_debug " Set $key (from default)"
fi
done <<< "$default_envs"
fi
# Then load environment-specific variables (which can override defaults)
if [[ "$current_env" != "default" ]]; then
local env_envs=$(yq eval ".envs.$current_env | to_entries | .[] | .key + \"=\" + .value" "$config_file" 2>/dev/null)
if [[ -n "$env_envs" ]] && [[ "$env_envs" != "null" ]]; then
while IFS='=' read -r key value; do
if [[ -n "$key" ]] && [[ "$key" != "null" ]] && [[ -n "$value" ]]; then
export "$key=$value"
log_debug " Set $key (from $current_env)"
fi
done <<< "$env_envs"
fi
fi
}
# Wrapper to limit Go thread usage on hosts with low ulimit -u (e.g. IONOS shared hosting).
# Always returns 0 so set -e doesn't propagate a yq crash through `local var=$(yq ...)`.
yq() {
GOMAXPROCS=1 GOGC=off command yq "$@" || return 0
}
# Auto-install mikefarah/yq v4 using whatever package manager is available.
_install_yq() {
if command -v brew &>/dev/null; then
log_info "Installing yq via Homebrew..."
brew install yq && return 0
fi
if command -v snap &>/dev/null; then
log_info "Installing yq via snap..."
snap install yq && return 0
fi
if command -v apk &>/dev/null; then
log_info "Installing yq via apk..."
apk add --no-cache yq-go && return 0
fi
if command -v pacman &>/dev/null; then
log_info "Installing yq via pacman..."
pacman -S --noconfirm go-yq && return 0
fi
if command -v go &>/dev/null; then
log_info "Installing yq via go install..."
local go_bin="${HOME}/.local/bin"
mkdir -p "$go_bin"
GOBIN="$go_bin" go install github.com/mikefarah/yq/v4@latest && {
export PATH="${go_bin}:${PATH}"
return 0
}
fi
# Binary download fallback (Linux / macOS)
local os arch install_dir use_sudo=0
os=$(uname -s | tr '[:upper:]' '[:lower:]')
case "$(uname -m)" in
x86_64) arch="amd64" ;;
aarch64|arm64) arch="arm64" ;;
armv7*) arch="arm" ;;
*) arch="amd64" ;;
esac
local yq_url="https://github.com/mikefarah/yq/releases/latest/download/yq_${os}_${arch}"
if [[ -w /usr/local/bin ]]; then
install_dir="/usr/local/bin"
elif command -v sudo &>/dev/null && sudo -n true 2>/dev/null; then
install_dir="/usr/local/bin"
use_sudo=1
else
install_dir="${HOME}/.local/bin"
mkdir -p "$install_dir"
export PATH="${install_dir}:${PATH}"
fi
log_info "Downloading yq binary (${os}/${arch}) to ${install_dir}..."
if command -v wget &>/dev/null; then
if [[ "$use_sudo" == "1" ]]; then
sudo wget -q "$yq_url" -O "${install_dir}/yq" || return 1
sudo chmod +x "${install_dir}/yq"
else
wget -q "$yq_url" -O "${install_dir}/yq" || return 1
chmod +x "${install_dir}/yq"
fi
return 0
elif command -v curl &>/dev/null; then
if [[ "$use_sudo" == "1" ]]; then
sudo curl -fsSL "$yq_url" -o "${install_dir}/yq" || return 1
sudo chmod +x "${install_dir}/yq"
else
curl -fsSL "$yq_url" -o "${install_dir}/yq" || return 1
chmod +x "${install_dir}/yq"
fi
return 0
fi
return 1
}
# Check if mikefarah/yq v4 is installed; auto-install if not.
check_yq() {
local need_install=0
# Use type -P to find the actual binary; command -v also matches the yq shell wrapper
# function defined in this script and would give a false positive.
if ! type -P yq &>/dev/null; then
log_warn "yq is not installed — attempting automatic installation..."
need_install=1
else
local ver_out
ver_out=$(command yq --version 2>&1) || true # guard: command not found → exit 127 + set -e
if [[ ! "$ver_out" =~ mikefarah ]]; then
log_warn "Found yq but it is not mikefarah/yq v4 (got: ${ver_out})"
log_warn "arty requires mikefarah/yq v4 — attempting automatic installation..."
need_install=1
fi
fi
if [[ "$need_install" == "0" ]]; then
return 0
fi
if _install_yq && command -v yq &>/dev/null; then
local ver_out
ver_out=$(command yq --version 2>&1)
log_success "yq installed: ${ver_out}"
return 0
fi
log_error "Could not install yq automatically. Please install mikefarah/yq v4:"
log_error " brew install yq (macOS / Linuxbrew)"
log_error " snap install yq (Linux with snap)"
log_error " apk add yq-go (Alpine Linux)"
log_error " pacman -S go-yq (Arch Linux)"
log_error " go install github.com/mikefarah/yq/v4@latest (Go toolchain)"
log_error " wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 \\"
log_error " -O /usr/local/bin/yq && chmod +x /usr/local/bin/yq"
log_error "See: https://github.com/mikefarah/yq#install"
exit 1
}
# Initialize arty environment
init_arty() {
local arty_home="${ARTY_HOME:-$PWD/.arty}"
local libs_dir="${ARTY_LIBS_DIR:-$arty_home/libs}"
local bin_dir="${ARTY_BIN_DIR:-$arty_home/bin}"
if [[ ! -d "$arty_home" ]]; then
mkdir -p "$libs_dir"
mkdir -p "$bin_dir"
log_success "Initialized arty at $arty_home"
fi
}
# Get a field from YAML using yq
get_yaml_field() {
local file="$1"
local field="$2"
if [[ ! -f "$file" ]]; then
return 1
fi
# Check if file has valid YAML - yq will exit with error on invalid YAML
if ! yq eval '.' "$file" >/dev/null 2>&1; then
return 1
fi
yq eval ".$field" "$file" 2>/dev/null || echo ""
}
# Get array items from YAML using yq
get_yaml_array() {
local file="$1"
local field="$2"
if [[ ! -f "$file" ]]; then
return 1
fi
# Check if file has valid YAML - yq will exit with error on invalid YAML
if ! yq eval '.' "$file" >/dev/null 2>&1; then
return 1
fi
yq eval ".${field}[]" "$file" 2>/dev/null
}
# Get script command from YAML
get_yaml_script() {
local file="$1"
local script_name="$2"
if [[ ! -f "$file" ]]; then
return 1
fi
yq eval ".scripts.${script_name}" "$file" 2>/dev/null || echo "null"
}
# List all script names from YAML
list_yaml_scripts() {
local file="$1"
if [[ ! -f "$file" ]]; then
return 1
fi
yq eval '.scripts | keys | .[]' "$file" 2>/dev/null
}
# Execute a script defined in arty.yml
exec_script() {
local script_name="$1"
shift
if [[ ! -f "$ARTY_CONFIG_FILE" ]]; then
log_error "Config file not found: $ARTY_CONFIG_FILE"
log_info "Run this command in a directory with arty.yml"
return 1
fi
local cmd
cmd=$(get_yaml_script "$ARTY_CONFIG_FILE" "$script_name")
if [[ -z "$cmd" ]] || [[ "$cmd" == "null" ]]; then
log_error "Unknown command: $script_name"
log_info "Available scripts:"
while IFS= read -r name; do
[[ -n "$name" ]] && echo " - $name"
done < <(list_yaml_scripts "$ARTY_CONFIG_FILE")
return 1
fi
log_info "Running: $script_name"
[[ ! "$cmd" =~ \$@ ]] && cmd="$cmd"' "$@"'
bash -c "$cmd" -- "$@"
}
# Get library name from repository URL
get_lib_name() {
local repo_url="$1"
basename "$repo_url" .git
}
# Parse reference - can be a string URL or an object with url, into, ref, env
# Returns: url|into|ref|env (pipe-delimited)
# env can be a single value or comma-separated list
parse_reference() {
local config_file="$1"
local ref_index="$2"
# Single yq call using only // and select — if-then-else is not supported in
# all yq v4 builds. .url//. handles both string refs (no .url key) and object
# refs. env array is joined; null env falls back to empty string.
yq eval ".references[$ref_index] | (.url // .) + \"|\" + (.into // \"\") + \"|\" + (.ref // \"\") + \"|\" + ((.env | select(tag == \"!!seq\") | join(\",\")) // .env // \"\")" "$config_file" 2>/dev/null
}
# Check if current environment matches the filter
# env_filter can be a single env or comma-separated list
check_env_match() {
local current_env="$1"
local env_filter="$2"
# No filter means match all
[[ -z "$env_filter" ]] && return 0
# Convert comma-separated list to array
IFS=',' read -ra env_list <<< "$env_filter"
# Check if current env is in the list
for env in "${env_list[@]}"; do
# Trim whitespace
env=$(echo "$env" | xargs)
if [[ "$env" == "$current_env" ]]; then
return 0
fi
done
return 1
}
# Get git information for a repository
get_git_info() {
local repo_dir="$1"
if [[ ! -d "$repo_dir/.git" ]]; then
echo "|||0"
return
fi
# Get short commit hash
local commit_hash=$(cd "$repo_dir" && git rev-parse --short HEAD 2>/dev/null || echo "")
# Get all refs pointing to current commit (tags, branches)
local refs=$(cd "$repo_dir" && git describe --all --exact-match 2>/dev/null || git symbolic-ref --short HEAD 2>/dev/null || echo "")
# Clean up refs (remove heads/ and tags/ prefixes)
refs=$(echo "$refs" | sed 's#^heads/##' | sed 's#^tags/##')
# Check if dirty (has uncommitted changes)
local is_dirty=0
if [[ -n "$(cd "$repo_dir" && git status --porcelain 2>/dev/null)" ]]; then
is_dirty=1
fi
echo "$commit_hash|$refs|$is_dirty"
}
# Normalize library identifier for tracking
normalize_lib_id() {
local repo_url="$1"
# Convert to lowercase and remove .git suffix for consistent tracking
local normalized="${repo_url,,}"
normalized=$(echo "$normalized" | sed 's/\.git$//')
# Normalize different Git URL formats to the same path
# https://github.com/user/repo -> github.com/user/repo
# git@github.com:user/repo -> github.com/user/repo
# ssh://git@github.com/user/repo -> github.com/user/repo
normalized=$(echo "$normalized" | sed -E 's#^https?://##' | sed -E 's#^git@([^:]+):#\1/#' | sed -E 's#^ssh://git@##')
echo "$normalized"
}
# Check if library is in installation stack
is_installing() {
local lib_id="$1"
[[ -n "${ARTY_INSTALL_STACK[$lib_id]:-}" ]]
}
# Add library to installation stack
mark_installing() {
local lib_id="$1"
ARTY_INSTALL_STACK[$lib_id]=1
}
# Remove library from installation stack
unmark_installing() {
local lib_id="$1"
unset ARTY_INSTALL_STACK[$lib_id]
}
# Check if library is already installed
is_installed() {
local lib_name="$1"
local libs_dir="${ARTY_LIBS_DIR:-${ARTY_HOME:-$PWD/.arty}/libs}"
[[ -d "$libs_dir/$lib_name" ]]
}
# Install a library from git repository
install_lib() {
local repo_url="$1"
local lib_name="${2:-$(get_lib_name "$repo_url")}"
local git_ref="${3:-}"
local custom_into="${4:-}"
local config_file="${5:-$ARTY_CONFIG_FILE}"
# Determine installation directory
local lib_dir
if [[ -n "$custom_into" ]]; then
# Expand environment variables in custom_into path
local expanded_into
if ! expanded_into=$(expand_env_vars "$custom_into"); then
log_error "Failed to expand environment variables in 'into' path for ${repo_url}"
return 1
fi
# Check if expanded path is absolute or relative
if [[ "$expanded_into" == /* ]]; then
# Absolute path - use as-is
lib_dir="$expanded_into"
else
# Relative path - make relative to config file directory
local config_dir=$(dirname "$(realpath "${config_file}")")
lib_dir="$config_dir/$expanded_into"
fi
else
# Use global .arty/libs for libraries without custom 'into'
lib_dir="$ARTY_LIBS_DIR/$lib_name"
fi
# Normalize the library identifier for circular dependency detection
# Include installation path to allow same library at different locations
local lib_id=$(normalize_lib_id "$repo_url")
local lib_id_with_path="${lib_id}@${lib_dir}"
# Check for circular dependency
if is_installing "$lib_id_with_path"; then
log_warn "Circular dependency detected: $lib_name (already being installed at $lib_dir)"
log_info "Skipping to prevent infinite loop"
return 0
fi
# Check if already installed (optimization)
if [[ -d "$lib_dir" ]]; then
log_info "Library '$lib_name' already installed at $lib_dir"
if [[ "$ARTY_DRY_RUN" == "1" ]]; then
log_info "[DRY RUN] Would check for updates..."
return 0
fi
# Try to update
if [[ -n "$git_ref" ]]; then
(cd "$lib_dir" && git fetch -q 2>/dev/null && git checkout -q "$git_ref" && git pull -q 2>/dev/null) || {
log_warn "Failed to update library (continuing with existing version)"
}
else
(cd "$lib_dir" && git fetch -q 2>/dev/null && git pull -q 2>/dev/null) || {
log_warn "Failed to update library (continuing with existing version)"
}
fi
return 0
fi
# Mark as currently installing
mark_installing "$lib_id_with_path"
if [[ "$ARTY_DRY_RUN" != "1" ]]; then
init_arty
fi
log_info "Installing library: $lib_name"
log_info "Repository: $repo_url"
log_info "Git ref: ${git_ref:-<default>}"
log_info "Location: $lib_dir"
if [[ "$ARTY_DRY_RUN" == "1" ]]; then
log_info "[DRY RUN] Would clone repository${git_ref:+ and checkout $git_ref}"
unmark_installing "$lib_id_with_path"
return 0
fi
# Clone the repository
git clone "$repo_url" "$lib_dir" || {
log_error "Failed to clone repository"
unmark_installing "$lib_id_with_path"
return 1
}
# Checkout the specified ref (skip if none given; clone already checked out the default branch)
if [[ -n "$git_ref" ]]; then
(cd "$lib_dir" && git checkout -q "$git_ref") || {
log_warn "Failed to checkout ref '$git_ref', using default branch"
}
fi
# Run setup hook if exists
if [[ -f "$lib_dir/setup.sh" ]]; then
log_info "Running setup hook..."
(cd "$lib_dir" && bash setup.sh) || {
log_warn "Setup hook failed, continuing anyway..."
}
fi
# Process arty.yml if it exists
if [[ -f "$lib_dir/arty.yml" ]]; then
# Link main script to .arty/bin (only for standard installations, not custom 'into')
if [[ -z "$custom_into" ]]; then
local main_script=$(get_yaml_field "$lib_dir/arty.yml" "main")
if [[ -n "$main_script" ]] && [[ "$main_script" != "null" ]]; then
local main_file="$lib_dir/$main_script"
if [[ -f "$main_file" ]]; then
local local_bin_dir="$ARTY_BIN_DIR"
local lib_name_stripped="$(basename $main_file .sh)"
local bin_link="$local_bin_dir/$lib_name_stripped"
log_info "Linking main script: $main_script -> $bin_link"
ln -sf "$main_file" "$bin_link"
chmod +x "$main_file"
log_success "Main script linked to $bin_link"
fi
fi
fi
# Install nested dependencies (always, regardless of 'into')
log_info "Found arty.yml, checking for references..."
install_references "$lib_dir/arty.yml"
fi
# Unmark as installing (we're done with this library)
unmark_installing "$lib_id_with_path"
log_success "Library '$lib_name' installed successfully"
log_info "Location: $lib_dir"
}
# Find if a library is defined in current or ancestor configs with 'into'
find_ancestor_into() {
local lib_url="$1"
local current_config="$2"
local lib_url_normalized=$(normalize_lib_id "$lib_url")
# Build list of ancestor configs to check
# Start with current config's directory and walk up to find parent arty.yml files
local config_to_check="$current_config"
local current_dir=$(dirname "$(realpath "$current_config")")
# Check current config and walk up the directory tree
while true; do
if [[ -f "$config_to_check" ]]; then
# Get count of references
local ref_count=$(yq eval '.references | length' "$config_to_check" 2>/dev/null)
if [[ -n "$ref_count" ]] && [[ "$ref_count" != "null" ]] && [[ "$ref_count" != "0" ]]; then
# Check each reference
for ((i = 0; i < ref_count; i++)); do
local ref_data=$(parse_reference "$config_to_check" "$i")
IFS='|' read -r url into git_ref env_filter <<<"$ref_data"
local url_normalized=$(normalize_lib_id "$url")
if [[ "$url_normalized" == "$lib_url_normalized" ]] && [[ -n "$into" ]]; then
# Found it with an 'into' directive
echo "$into|$config_to_check"
return
fi
done
fi
fi
# Check if we've reached the root ARTY_CONFIG_FILE (after checking it)
if [[ "$(realpath "$config_to_check" 2>/dev/null)" == "$(realpath "$ARTY_CONFIG_FILE" 2>/dev/null)" ]]; then
# We've checked the root config, stop here
break
fi
# Move up one directory
local parent_dir=$(dirname "$current_dir")
if [[ "$parent_dir" == "$current_dir" ]] || [[ "$parent_dir" == "/" ]]; then
# Reached filesystem root, stop
break
fi
current_dir="$parent_dir"
config_to_check="$current_dir/arty.yml"
done
echo ""
}
# Install all references from arty.yml
install_references() {
local config_file="${1:-$ARTY_CONFIG_FILE}"
if [[ ! -f "$config_file" ]]; then
log_error "Config file not found: $config_file"
return 1
fi
# Check if YAML is valid by trying to read it
if ! yq eval '.' "$config_file" >/dev/null 2>&1; then
log_error "Invalid YAML in config file: $config_file"
return 1
fi
# Initialize arty directory structure first (unless dry run)
if [[ "$ARTY_DRY_RUN" != "1" ]]; then
init_arty
fi
# Count references
local ref_count=$(yq eval '.references | length' "$config_file" 2>/dev/null)
if [[ -z "$ref_count" ]] || [[ "$ref_count" == "null" ]] || [[ "$ref_count" == "0" ]]; then
log_info "No references to install"
return 0
fi
# Process each reference by index
local i
for ((i = 0; i < ref_count; i++)); do
# Parse the reference
local ref_data=$(parse_reference "$config_file" "$i")
IFS='|' read -r url into git_ref env_filter <<<"$ref_data"
# Skip empty URLs
if [[ -z "$url" ]] || [[ "$url" == "null" ]]; then
continue
fi
# Check environment filter using the new check_env_match function
if [[ -n "$env_filter" ]] && ! check_env_match "$ARTY_ENV" "$env_filter"; then
log_info "Skipping reference (env filter: [$env_filter], current: $ARTY_ENV): $url"
continue
fi
# Get library name
local lib_name=$(get_lib_name "$url")
# If no 'into' specified, check if an ancestor config defines it with 'into'
local effective_config="$config_file"
if [[ -z "$into" ]] && [[ "$config_file" != "$ARTY_CONFIG_FILE" ]]; then
local ancestor_result=$(find_ancestor_into "$url" "$config_file")
if [[ -n "$ancestor_result" ]]; then
IFS='|' read -r ancestor_into ancestor_config <<<"$ancestor_result"
into="$ancestor_into"
effective_config="$ancestor_config"
fi
fi
log_info "Installing reference: $url"
[[ -n "$into" ]] && log_info "Custom location: $into"
[[ -n "$env_filter" ]] && log_info "Environment filter: [$env_filter]"
install_lib "$url" "$lib_name" "$git_ref" "$into" "$effective_config" || log_warn "Failed to install reference: $url"
done
}
# Build a reference tree from arty.yml showing all dependencies
build_reference_tree() {
local config_file="${1:-$ARTY_CONFIG_FILE}"
local indent="${2:-}"
local is_last="${3:-1}"
local visited_file="${4:-/tmp/arty_visited_$$}"
local depth="${5:-0}"
if [[ ! -f "$config_file" ]]; then
return 0
fi
# Track visited library directories to prevent infinite circular recursion
# Only track at depth > 0 to allow root-level refs to show even if already nested
local config_dir=$(dirname "$(realpath "$config_file" 2>/dev/null || echo "$config_file")")
if [[ "$depth" -gt 0 ]]; then
if grep -qx "$config_dir" "$visited_file" 2>/dev/null; then
return 0
fi
echo "$config_dir" >>"$visited_file"
fi
# Count references
local ref_count=$(yq eval '.references | length' "$config_file" 2>/dev/null)
if [[ -z "$ref_count" ]] || [[ "$ref_count" == "null" ]] || [[ "$ref_count" == "0" ]]; then
return 0
fi
# Process each reference
local i
for ((i = 0; i < ref_count; i++)); do
local ref_data=$(parse_reference "$config_file" "$i")
IFS='|' read -r url into git_ref env_filter <<<"$ref_data"
# Skip if no URL or env filter doesn't match
if [[ -z "$url" ]] || [[ "$url" == "null" ]]; then
continue
fi
if [[ -n "$env_filter" ]] && ! check_env_match "$ARTY_ENV" "$env_filter"; then
continue
fi
local lib_name=$(get_lib_name "$url")
# Calculate is_last_ref by counting remaining valid refs
local is_last_ref=0
local remaining=0
for ((j = i + 1; j < ref_count; j++)); do
local check_ref=$(parse_reference "$config_file" "$j")
IFS='|' read -r check_url _ _ check_env <<<"$check_ref"
if [[ -n "$check_url" ]] && [[ "$check_url" != "null" ]]; then
if [[ -z "$check_env" ]] || check_env_match "$ARTY_ENV" "$check_env"; then
remaining=1
break
fi
fi
done
[[ "$remaining" == "0" ]] && is_last_ref=1
# If no 'into' specified, check if an ancestor config defines it with 'into'
local effective_config="$config_file"
if [[ -z "$into" ]] && [[ "$config_file" != "$ARTY_CONFIG_FILE" ]]; then
local ancestor_result=$(find_ancestor_into "$url" "$config_file")
if [[ -n "$ancestor_result" ]]; then
IFS='|' read -r ancestor_into ancestor_config <<<"$ancestor_result"
into="$ancestor_into"
effective_config="$ancestor_config"
fi
fi
# Determine installation directory
local lib_dir
if [[ -n "$into" ]]; then
# Expand environment variables
local expanded_into
expanded_into=$(expand_env_vars "$into" 2>/dev/null) || expanded_into="$into"
# Check if expanded path is absolute
if [[ "$expanded_into" == /* ]]; then
lib_dir="$expanded_into"
else
local config_dir=$(dirname "$(realpath "${effective_config}")")
lib_dir="$config_dir/$expanded_into"
fi
else
lib_dir="$ARTY_LIBS_DIR/$lib_name"
fi
# Get version and git info
local version=""
local location="$lib_dir"
if [[ -f "$lib_dir/arty.yml" ]]; then
version=$(get_yaml_field "$lib_dir/arty.yml" "version")
[[ "$version" == "null" ]] || [[ -z "$version" ]] && version=""
fi
# Get git information
local git_info=$(get_git_info "$lib_dir")
IFS='|' read -r commit_hash git_refs is_dirty <<<"$git_info"
# Determine display version
local display_version="${version:-$commit_hash}"
[[ -z "$display_version" ]] && display_version="unknown"
# Tree characters
local tree_char=""
local tree_continue=" "
if [[ "$is_last_ref" == "1" ]]; then
tree_char=""
tree_continue=" "
fi
# Print the reference line
printf "%s${tree_char} ${BOLD}${GREEN}%s${NC}" "$indent" "$lib_name"
printf " ${CYAN}%s${NC}" "$display_version"
# Add git refs if available
if [[ -n "$git_refs" ]]; then
printf " ${BLUE}(%s)${NC}" "$git_refs"
fi
# Add dirty indicator
if [[ "$is_dirty" == "1" ]]; then
printf " ${YELLOW}*${NC}"
fi
# Add location info
if [[ -n "$into" ]]; then
printf " ${MAGENTA}-> %s${NC}" "$into"
fi
echo
# Recursively show nested dependencies
if [[ -f "$lib_dir/arty.yml" ]]; then
local next_depth=$((depth + 1))
build_reference_tree "$lib_dir/arty.yml" "${indent}${tree_continue}" "$is_last_ref" "$visited_file" "$next_depth"
fi
done
}
# List installed libraries with tree visualization
list_libs() {
init_arty
if [[ ! -f "$ARTY_CONFIG_FILE" ]]; then
# Fallback to simple list if no arty.yml
if [[ ! -d "$ARTY_LIBS_DIR" ]] || [[ -z "$(ls -A "$ARTY_LIBS_DIR" 2>/dev/null)" ]]; then
log_info "No libraries installed"
return 0
fi
log_info "Installed libraries:"
echo
for lib_dir in "$ARTY_LIBS_DIR"/*; do
if [[ -d "$lib_dir" ]]; then
local lib_name=$(basename "$lib_dir")
local version=""
# Try to get version from arty.yml using yq
if [[ -f "$lib_dir/arty.yml" ]]; then
version=$(get_yaml_field "$lib_dir/arty.yml" "version")
if [[ "$version" == "null" ]] || [[ -z "$version" ]]; then
version=""
fi
fi
printf " ${GREEN}%-20s${NC} %s\n" "$lib_name" "${version:-(unknown version)}"
fi
done
echo
return 0
fi
# Get project info
local project_name=$(get_yaml_field "$ARTY_CONFIG_FILE" "name")
local project_version=$(get_yaml_field "$ARTY_CONFIG_FILE" "version")
# Clean up name/version
[[ "$project_name" == "null" ]] && project_name="$(basename "$PWD")"
[[ "$project_version" == "null" ]] && project_version=""
# Check if there are any references
local ref_count=$(yq eval '.references | length' "$ARTY_CONFIG_FILE" 2>/dev/null)
if [[ -z "$ref_count" ]] || [[ "$ref_count" == "null" ]] || [[ "$ref_count" == "0" ]]; then
# No references defined, check for installed libraries
local libs_dir="${ARTY_LIBS_DIR:-${ARTY_HOME:-$PWD/.arty}/libs}"
if [[ ! -d "$libs_dir" ]] || [[ -z "$(ls -A "$libs_dir" 2>/dev/null)" ]]; then
# Show header but indicate no libraries
echo
printf "${BOLD}${GREEN}%s${NC}" "$project_name"
if [[ -n "$project_version" ]]; then
printf " ${CYAN}%s${NC}" "$project_version"
fi
echo
echo
log_info "No libraries installed"
echo
return 0
fi
fi
# Print header
echo
printf "${BOLD}${GREEN}%s${NC}" "$project_name"
if [[ -n "$project_version" ]]; then
printf " ${CYAN}%s${NC}" "$project_version"
fi
echo
echo
# Create temporary file for tracking visited configs
local visited_file="/tmp/arty_visited_$$"
: >"$visited_file"
# Build and display tree
build_reference_tree "$ARTY_CONFIG_FILE" "" 1 "$visited_file"
# Cleanup
rm -f "$visited_file"
echo
}
# Display notes from arty.yml with markdown rendering
show_notes() {
local config_file="${1:-$ARTY_CONFIG_FILE}"
if [[ ! -f "$config_file" ]]; then
log_error "Config file not found: $config_file"
return 1
fi
# Get notes field from YAML
local notes=$(yq eval '.notes' "$config_file" 2>/dev/null)
if [[ -z "$notes" ]] || [[ "$notes" == "null" ]]; then
log_info "No notes found in $config_file"
log_info "Add a 'notes' field to your arty.yml to display project notes"
return 0
fi
# Get project name for header
local project_name=$(get_yaml_field "$config_file" "name")
[[ "$project_name" == "null" ]] && project_name="$(basename "$PWD")"
echo
echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BOLD}${GREEN} Notes: ${project_name}${NC}"
echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo
# Try to render markdown with available tools
if command -v glow &>/dev/null; then
# Use glow if available (best option)
echo "$notes" | glow -
elif command -v mdcat &>/dev/null; then
# Use mdcat if available (good alternative)
echo "$notes" | mdcat
elif command -v python3 &>/dev/null; then
# Fallback to Python markdown rendering - create temp file to avoid quoting issues
local temp_notes=$(mktemp)
echo "$notes" > "$temp_notes"
python3 - "$temp_notes" <<'EOPY' 2>/dev/null || echo "$notes"
import sys
import re
# Read from temp file
with open(sys.argv[1], 'r') as f:
text = f.read()
# Simple markdown-like formatting for terminal
# Code blocks first (before other processing)
lines = text.split('\n')
in_code_block = False
processed_lines = []
for line in lines:
if line.strip().startswith('```'):
in_code_block = not in_code_block
if in_code_block:
processed_lines.append('\033[2;37m' + '─' * 60 + '\033[0m')
else:
processed_lines.append('\033[2;37m' + '─' * 60 + '\033[0m')
continue
if in_code_block:
processed_lines.append('\033[0;36m ' + line + '\033[0m')
else:
processed_lines.append(line)
text = '\n'.join(processed_lines)
# Headers
text = re.sub(r'^### (.+)$', r'\033[1;36m\1\033[0m', text, flags=re.MULTILINE)
text = re.sub(r'^## (.+)$', r'\033[1;32m\1\033[0m', text, flags=re.MULTILINE)
text = re.sub(r'^# (.+)$', r'\033[1;33m\1\033[0m', text, flags=re.MULTILINE)
# Bold
text = re.sub(r'\*\*(.+?)\*\*', r'\033[1m\1\033[0m', text)
text = re.sub(r'__(.+?)__', r'\033[1m\1\033[0m', text)
# Italic (avoid matching bold)
text = re.sub(r'(?<!\*)\*([^\*]+?)\*(?!\*)', 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"
references:
# - https://github.com/user/some-lib.git
# - url: git@github.com:user/repo.git
# into: some/path
# ref: v1.0.0
EOF
log_success "Created $ARTY_CONFIG_FILE"
log_success "Created .arty/ folder structure"
}
# 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]
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
<script-name> [args] Run a script defined in arty.yml scripts section
FLAGS:
--dry-run Simulate installation without making changes
-v, --verbose Enable verbose/debug output
-h, --help Show this help message
EXAMPLES:
arty install https://github.com/user/bash-utils.git
arty install https://github.com/user/lib.git my-lib
arty deps
arty deps --dry-run
arty list
arty notes
arty init my-project
arty npm/install
arty rust/install
REFERENCE FORMATS:
Simple URL:
references:
- https://github.com/user/repo.git
Extended object:
references:
- url: git@github.com:user/repo.git
into: custom/path # installation directory (relative to arty.yml)
ref: v1.0.0 # branch, tag, or commit hash
env: production # only install in this environment
Multiple environments:
references:
- url: git@github.com:user/dev-tools.git
env: [dev, ci]
PROJECT STRUCTURE:
project/
.arty/
libs/ # cloned dependencies
arty.yml
ENVIRONMENT:
ARTY_HOME Home directory for arty (default: $PWD/.arty)
ARTY_ENV Environment name to load from arty.yml (default: default)
EOF
}
# ============================================================================
# MAIN FUNCTION
# ============================================================================
# Main function
main() {
# Check for yq availability first
check_yq
# Configuration
PROJECT_DIR="$PWD/.arty"
ARTY_HOME="${ARTY_HOME:-$PROJECT_DIR}"
ARTY_LIBS_DIR="${ARTY_LIBS_DIR:-$ARTY_HOME/libs}"
ARTY_BIN_DIR="${ARTY_BIN_DIR:-$ARTY_HOME/bin}"
if [[ $# -eq 0 ]]; then
show_usage
exit 0
fi
# Scan flags first
local dry_run_flag=0
local verbose_flag=0
for arg in "$@"; do
case "$arg" in
--dry-run) dry_run_flag=1 ;;
--verbose|-v) verbose_flag=1 ;;
esac
done
if [[ "$verbose_flag" == "1" ]]; then
export ARTY_VERBOSE=1
fi
load_env_vars
if [[ "$dry_run_flag" == "1" ]]; then
export ARTY_DRY_RUN=1
log_info "${YELLOW}[DRY RUN MODE]${NC} Simulating actions, no changes will be made"
echo
fi
# Extract command and positional args, stripping flags
local args=()
local command=""
for arg in "$@"; do
case "$arg" in
--dry-run|--verbose|-v|-h|--help) continue ;;
*)
if [[ -z "$command" ]]; then
command="$arg"
else
args+=("$arg")
fi
;;
esac
done
if [[ "$command" == "help" ]] || [[ "$command" == "--help" ]] || [[ "$command" == "-h" ]]; then
show_usage
exit 0
fi
case "$command" in
install)
if [[ ${#args[@]} -eq 0 ]]; then
install_references
local install_result=$?
else
install_lib "${args[@]}"
local install_result=$?
fi
if [[ $install_result -eq 0 ]] && [[ -f "$ARTY_CONFIG_FILE" ]]; then
local ref_count=$(yq eval '.references | length' "$ARTY_CONFIG_FILE" 2>/dev/null)
if [[ -n "$ref_count" ]] && [[ "$ref_count" != "null" ]] && [[ "$ref_count" != "0" ]]; then
echo
log_success "Installation complete!"
list_libs
fi
fi
;;
deps)
install_references
local install_result=$?
if [[ $install_result -eq 0 ]] && [[ -f "$ARTY_CONFIG_FILE" ]]; then
local ref_count=$(yq eval '.references | length' "$ARTY_CONFIG_FILE" 2>/dev/null)
if [[ -n "$ref_count" ]] && [[ "$ref_count" != "null" ]] && [[ "$ref_count" != "0" ]]; then
echo
log_success "Dependencies installed!"
list_libs
fi
fi
;;
list | ls)
list_libs
;;
notes)
show_notes
;;
remove | rm)
if [[ ${#args[@]} -eq 0 ]]; then
log_error "Library name required"
exit 1
fi
remove_lib "${args[0]}"
;;
init)
init_project "${args[@]}"
;;
*)
exec_script "$command" "${args[@]}"
;;
esac
}
# Run main if script is executed directly
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi