polish: CLI UX overhaul and rich .env.example metadata

CLI improvements:
- Unicode status indicators (✔ ✘ ▶ ● ○ ⚠) and braille spinners
- Animated spinner for docker pull/up operations
- Project metadata parsed from .env.example (@name, @desc, @url, @port, @note)
- Descriptions shown in list, deploy selection, and status views
- Auto-generate passwords for secret fields (PASSWORD/TOKEN/AUTHKEY)
- Confirmation prompt before deploy with project summary
- Post-deploy access URL hint based on @port metadata
- Divider lines for visual section separation
- Helpful error messages with suggested commands
- Command aliases: ls, st, ps, down, log, configure
- Bash 3.2 compatible (no associative arrays)

.env.example enrichment:
- All projects now have @name, @desc, @url, @port metadata headers
- Inline field descriptions shown as context during interactive config
- Tailscale: @note hints for profile-based DERP deployment
- Structured comments group related settings visually

Installer:
- Prerequisite check with per-tool status (✔/✘)
- Quieter git operations
- Cleaner post-install instructions
This commit is contained in:
m1ngsama 2026-04-15 10:15:43 +08:00
parent adcf0b1884
commit e5542de818
9 changed files with 522 additions and 245 deletions

607
automa
View file

@ -1,55 +1,101 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# automa - interactive Docker Compose project deployer # automa - interactive Docker Compose project deployer
# #
# Install & run: # Quick start:
# curl -fsSL https://raw.githubusercontent.com/m1ngsama/automa/main/install.sh | bash # curl -fsSL https://raw.githubusercontent.com/m1ngsama/automa/main/install.sh | bash
# cd ~/automa && ./automa deploy # cd ~/automa && ./automa deploy
set -euo pipefail set -euo pipefail
# ============================================================================
# Constants
# ============================================================================
AUTOMA_VERSION="1.0.0" AUTOMA_VERSION="1.0.0"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Colors (disabled when not a terminal) # ============================================================================
# Terminal
# ============================================================================
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m'
CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' NC='\033[0m' BLUE='\033[0;34m' CYAN='\033[0;36m'
BOLD='\033[1m' DIM='\033[2m' NC='\033[0m'
COLS=$(tput cols 2>/dev/null || echo 80)
else else
RED='' GREEN='' YELLOW='' CYAN='' BOLD='' DIM='' NC='' RED='' GREEN='' YELLOW='' BLUE='' CYAN=''
BOLD='' DIM='' NC=''
COLS=80
fi fi
# ============================================================================ # ============================================================================
# Helpers # Output helpers
# ============================================================================ # ============================================================================
info() { echo -e "${GREEN}[+]${NC} $*"; } info() { echo -e " ${GREEN}\xe2\x9c\x94${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; } warn() { echo -e " ${YELLOW}\xe2\x9a\xa0${NC} $*"; }
error() { echo -e "${RED}[-]${NC} $*" >&2; } error() { echo -e " ${RED}\xe2\x9c\x98${NC} $*" >&2; }
step() { echo -e " ${CYAN}\xe2\x96\xb6${NC} ${BOLD}$*${NC}"; }
dim() { echo -e " ${DIM}$*${NC}"; }
banner() { divider() {
echo "" printf " ${DIM}"
echo -e "${CYAN}${BOLD} automa${NC} ${DIM}v${AUTOMA_VERSION}${NC}" printf '%.0s\xe2\x94\x80' $(seq 1 $(( (COLS - 4) / 3 + 1 )) )
echo -e " ${DIM}docker compose project deployer${NC}" printf "${NC}\n"
echo ""
} }
# Spinner for long operations
spinner() {
local pid=$1 msg="${2:-Working...}"
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
local i=0
while kill -0 "$pid" 2>/dev/null; do
printf "\r ${CYAN}%s${NC} %s" "${frames[$i]}" "$msg"
i=$(( (i + 1) % ${#frames[@]} ))
sleep 0.1
done
wait "$pid"
local rc=$?
printf "\r\033[2K" # clear line
return $rc
}
run_with_spinner() {
local msg="$1"; shift
"$@" &>/dev/null &
local pid=$!
if spinner "$pid" "$msg"; then
info "$msg"
return 0
else
error "$msg"
return 1
fi
}
# ============================================================================
# Prerequisites
# ============================================================================
check_docker() { check_docker() {
if ! command -v docker &>/dev/null; then if ! command -v docker &>/dev/null; then
error "docker is required but not installed." echo ""
echo -e " ${DIM}Install: https://docs.docker.com/engine/install/${NC}" error "Docker is not installed"
echo ""
dim "Install Docker:"
dim " curl -fsSL https://get.docker.com | sh"
dim ""
dim "Or visit: https://docs.docker.com/engine/install/"
echo ""
exit 1 exit 1
fi fi
if ! docker compose version &>/dev/null 2>&1; then if ! docker compose version &>/dev/null 2>&1; then
error "docker compose plugin is required." echo ""
echo -e " ${DIM}Install: https://docs.docker.com/compose/install/${NC}" error "Docker Compose plugin is not installed"
dim "Install: https://docs.docker.com/compose/install/"
echo ""
exit 1 exit 1
fi fi
} }
# ============================================================================ # ============================================================================
# Project discovery # Project helpers
# ============================================================================ # ============================================================================
PROJECTS=() PROJECTS=()
@ -60,120 +106,205 @@ discover_projects() {
done done
} }
project_exists() { project_exists() { [[ -f "$SCRIPT_DIR/$1/compose.yaml" ]]; }
local name="$1"
[[ -f "$SCRIPT_DIR/$name/compose.yaml" ]]
}
project_dir() { # Parse @key from .env.example header
echo "$SCRIPT_DIR/$1" project_meta() {
} local slug="$1" key="$2"
local env_example="$SCRIPT_DIR/$slug/.env.example"
# compose wrapper that auto-adds --env-file if .env exists [[ -f "$env_example" ]] || return
compose() { while IFS= read -r line; do
local name="$1"; shift if [[ "$line" =~ ^#\ @${key}\ (.+) ]]; then
local dir="$SCRIPT_DIR/$name" echo "${BASH_REMATCH[1]}"
local args=(-f "$dir/compose.yaml") return
if [[ -f "$dir/.env" ]]; then
args+=(--env-file "$dir/.env")
fi fi
[[ ! "$line" =~ ^# ]] && return # stop at first non-comment
done < "$env_example"
}
# Collect all @note lines
project_notes() {
local slug="$1"
local env_example="$SCRIPT_DIR/$slug/.env.example"
[[ -f "$env_example" ]] || return
while IFS= read -r line; do
[[ "$line" =~ ^#\ @note\ (.+) ]] && echo "${BASH_REMATCH[1]}"
[[ ! "$line" =~ ^# ]] && return
done < "$env_example"
}
project_status() {
local slug="$1"
if [[ ! -f "$SCRIPT_DIR/$slug/.env" ]]; then
echo "not_configured"
elif compose "$slug" ps --status running 2>/dev/null | grep -q .; then
echo "running"
else
echo "stopped"
fi
}
status_badge() {
case "$1" in
running) echo -e "${GREEN}\xe2\x97\x8f running${NC}" ;;
stopped) echo -e "${YELLOW}\xe2\x97\x8f stopped${NC}" ;;
not_configured) echo -e "${DIM}\xe2\x97\x8b not configured${NC}" ;;
esac
}
# compose wrapper
compose() {
local slug="$1"; shift
local dir="$SCRIPT_DIR/$slug"
local args=(-f "$dir/compose.yaml")
[[ -f "$dir/.env" ]] && args+=(--env-file "$dir/.env")
docker compose "${args[@]}" "$@" docker compose "${args[@]}" "$@"
} }
# Get access URL after deploy
access_hint() {
local slug="$1"
local port_var
port_var=$(project_meta "$slug" "port")
[[ -z "$port_var" ]] && return
local env_file="$SCRIPT_DIR/$slug/.env"
[[ -f "$env_file" ]] || return
local port_val
port_val=$(grep "^${port_var}=" "$env_file" 2>/dev/null | cut -d= -f2)
[[ -z "$port_val" ]] && return
if [[ "$port_val" == *:* ]]; then
echo "http://${port_val}"
elif [[ "$port_val" == */* ]]; then
echo "$port_val"
else
echo "http://localhost:${port_val}"
fi
}
# ============================================================================ # ============================================================================
# Interactive .env configuration # Interactive .env configuration
# ============================================================================ # ============================================================================
generate_password() {
LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom 2>/dev/null | head -c 24 || openssl rand -hex 12
}
configure_env() { configure_env() {
local name="$1" local slug="$1"
local dir="$SCRIPT_DIR/$name" local dir="$SCRIPT_DIR/$slug"
local env_example="$dir/.env.example" local env_example="$dir/.env.example"
local env_file="$dir/.env" local env_file="$dir/.env"
if [[ ! -f "$env_example" ]]; then if [[ ! -f "$env_example" ]]; then
warn "No .env.example found, skipping configuration" warn "No .env.example found, skipping"
return 0 return 0
fi fi
# If .env already exists, ask what to do local name
name=$(project_meta "$slug" "name")
name="${name:-$slug}"
# Handle existing .env
if [[ -f "$env_file" ]]; then if [[ -f "$env_file" ]]; then
echo "" echo ""
echo -e " ${YELLOW}.env already exists for ${BOLD}${name}${NC}" dim ".env already exists for ${BOLD}${name}${NC}"
echo -e " ${DIM}[k]eep current [r]econfigure [v]iew${NC}"
while true; do
read -rp " > " choice
case "${choice,,}" in
k|keep|"") info "Keeping existing .env"; return 0 ;;
r|reconfigure) break ;;
v|view)
echo "" echo ""
echo -e " ${BOLD}k${NC} Keep current configuration"
echo -e " ${BOLD}r${NC} Reconfigure from scratch"
echo -e " ${BOLD}v${NC} View current values"
echo ""
while true; do
read -rp " Choose [k]: " choice
case "${choice:-k}" in
k) info "Keeping existing .env"; return 0 ;;
r) break ;;
v)
echo ""
divider
while IFS= read -r line; do while IFS= read -r line; do
echo -e " ${DIM}${line}${NC}" echo -e " ${DIM}${line}${NC}"
done < "$env_file" done < "$env_file"
divider
echo "" echo ""
echo -e " ${DIM}[k]eep [r]econfigure${NC}"
;; ;;
*) echo -e " ${DIM}Enter k, r, or v${NC}" ;; *) dim "Enter k, r, or v" ;;
esac esac
done done
fi fi
echo "" echo ""
echo -e " ${BOLD}Configure: ${CYAN}${name}${NC}" step "Configure ${name}"
echo "" local desc
desc=$(project_meta "$slug" "desc")
local url
url=$(project_meta "$slug" "url")
[[ -n "$desc" ]] && dim "$desc"
[[ -n "$url" ]] && dim "Docs: ${url}"
# Show notes
local has_notes=0
while IFS= read -r note; do
[[ -z "$note" ]] && continue
[[ $has_notes -eq 0 ]] && echo ""
echo -e " ${YELLOW}!${NC} ${DIM}${note}${NC}"
has_notes=1
done < <(project_notes "$slug")
# Show header comments from .env.example
while IFS= read -r line; do
[[ "$line" =~ ^#.* ]] && echo -e " ${DIM}${line}${NC}" || break
done < "$env_example"
echo "" echo ""
echo -e " ${DIM}Enter values (blank = accept default in brackets)${NC}" divider
dim "Press ${BOLD}Enter${NC}${DIM} to accept [default] values${NC}"
echo "" echo ""
local tmp_env local tmp_env
tmp_env="$(mktemp)" tmp_env="$(mktemp)"
local pending_comment=""
while IFS= read -r line; do while IFS= read -r line; do
# Blank line # Blank line
if [[ -z "$line" ]]; then [[ -z "$line" ]] && continue
[[ -n "$pending_comment" ]] && { echo "" ; pending_comment=""; }
continue
fi
# Comment line — show as hint for next variable # Skip @metadata
[[ "$line" =~ ^#\ @(name|desc|url|port|note) ]] && continue
# Comment → show as hint
if [[ "$line" =~ ^#.* ]]; then if [[ "$line" =~ ^#.* ]]; then
echo -e " ${DIM}${line}${NC}" echo -e " ${DIM}${line#\# }${NC}"
pending_comment="$line"
continue continue
fi fi
local key="${line%%=*}" local key="${line%%=*}"
local default="${line#*=}" local default="${line#*=}"
pending_comment=""
local val
if [[ -n "$default" ]]; then if [[ -n "$default" ]]; then
read -rp " ${key} [${default}]: " val read -rp " ${BOLD}${key}${NC} [${DIM}${default}${NC}]: " val
echo "${key}=${val:-$default}" >> "$tmp_env" echo "${key}=${val:-$default}" >> "$tmp_env"
else else
# Required field — no default # Required — check if it's a secret
while true; do if [[ "$key" =~ PASSWORD|SECRET|TOKEN|AUTHKEY ]]; then
read -rp " ${key} (required): " val echo -e " ${DIM}Leave blank to auto-generate${NC}"
if [[ -n "$val" ]]; then read -rp " ${BOLD}${key}${NC}: " val
break if [[ -z "$val" ]]; then
val=$(generate_password)
echo -e " ${DIM}Generated: ${val}${NC}"
fi fi
else
while true; do
read -rp " ${BOLD}${key}${NC} ${RED}(required)${NC}: " val
[[ -n "$val" ]] && break
echo -e " ${RED}This field cannot be empty${NC}" echo -e " ${RED}This field cannot be empty${NC}"
done done
fi
echo "${key}=${val}" >> "$tmp_env" echo "${key}=${val}" >> "$tmp_env"
fi fi
done < "$env_example" done < "$env_example"
echo ""
divider
mv "$tmp_env" "$env_file" mv "$tmp_env" "$env_file"
chmod 600 "$env_file" chmod 600 "$env_file"
echo "" info "Configuration saved"
info ".env saved (chmod 600)"
} }
# ============================================================================ # ============================================================================
@ -182,6 +313,7 @@ configure_env() {
cmd_list() { cmd_list() {
banner banner
check_docker
discover_projects discover_projects
if [[ ${#PROJECTS[@]} -eq 0 ]]; then if [[ ${#PROJECTS[@]} -eq 0 ]]; then
@ -189,20 +321,18 @@ cmd_list() {
return 1 return 1
fi fi
echo -e " ${BOLD}Available projects:${NC}"
echo ""
local i=1 local i=1
for p in "${PROJECTS[@]}"; do for slug in "${PROJECTS[@]}"; do
local status="${DIM}not configured${NC}" local st
if [[ -f "$SCRIPT_DIR/$p/.env" ]]; then st=$(project_status "$slug")
if compose "$p" ps --status running 2>/dev/null | grep -q .; then local badge
status="${GREEN}running${NC}" badge=$(status_badge "$st")
else local desc
status="${YELLOW}stopped${NC}" desc=$(project_meta "$slug" "desc")
fi
fi printf " ${BOLD}%2d${NC} %-20s %b\n" "$i" "$slug" "$badge"
printf " ${BOLD}%2d${NC} %-24s %b\n" "$i" "$p" "$status" [[ -n "$desc" ]] && echo -e " ${DIM}${desc}${NC}"
((i++)) ((i++))
done done
echo "" echo ""
@ -218,27 +348,42 @@ cmd_deploy() {
return 1 return 1
fi fi
# If arguments provided, deploy those directly # Direct deploy
if [[ $# -gt 0 ]]; then if [[ $# -gt 0 ]]; then
local ok=0 fail=0
for name in "$@"; do for name in "$@"; do
deploy_project "$name" echo ""
if deploy_project "$name"; then ((ok++)); else ((fail++)); fi
done done
deploy_summary $ok $fail
return return
fi fi
# Interactive selection # Interactive
echo -e " ${BOLD}Select projects to deploy:${NC}" step "Select projects to deploy"
echo "" echo ""
local i=1 local i=1
for p in "${PROJECTS[@]}"; do for slug in "${PROJECTS[@]}"; do
printf " ${BOLD}%2d${NC} %s\n" "$i" "$p" local st
st=$(project_status "$slug")
local badge
badge=$(status_badge "$st")
local desc
desc=$(project_meta "$slug" "desc")
printf " ${BOLD}%2d${NC} %-20s %b\n" "$i" "$slug" "$badge"
[[ -n "$desc" ]] && echo -e " ${DIM}${desc}${NC}"
((i++)) ((i++))
done done
echo ""
dim "Enter numbers separated by spaces, e.g. ${BOLD}1 3 5${NC}"
dim "Type ${BOLD}all${NC} to deploy everything, or ${BOLD}q${NC} to quit"
echo "" echo ""
echo -e " ${DIM}Enter numbers (space-separated), or 'q' to quit${NC}"
read -rp " > " selection read -rp " > " selection
[[ "$selection" == "q" || -z "$selection" ]] && return 0 [[ -z "$selection" || "$selection" == "q" ]] && return 0
local selected=() local selected=()
if [[ "$selection" == "all" ]]; then if [[ "$selection" == "all" ]]; then
@ -248,83 +393,122 @@ cmd_deploy() {
if [[ "$num" =~ ^[0-9]+$ ]] && ((num > 0 && num <= ${#PROJECTS[@]})); then if [[ "$num" =~ ^[0-9]+$ ]] && ((num > 0 && num <= ${#PROJECTS[@]})); then
selected+=("${PROJECTS[$((num-1))]}") selected+=("${PROJECTS[$((num-1))]}")
else else
warn "Invalid: $num (skipped)" warn "Skipping invalid: $num"
fi fi
done done
fi fi
if [[ ${#selected[@]} -eq 0 ]]; then [[ ${#selected[@]} -eq 0 ]] && return 0
return 0
fi
# Confirmation
echo "" echo ""
info "Will deploy: ${selected[*]}" divider
step "Will deploy:"
for s in "${selected[@]}"; do
local desc
desc=$(project_meta "$s" "desc")
echo -e " ${CYAN}\xe2\x96\xb6${NC} ${s} ${DIM}${desc:-}${NC}"
done
echo "" echo ""
read -rp " Proceed? [Y/n] " confirm
[[ "${confirm:-y}" =~ ^[Nn] ]] && { echo ""; dim "Cancelled."; return 0; }
divider
local ok=0 fail=0 local ok=0 fail=0
for name in "${selected[@]}"; do for name in "${selected[@]}"; do
if deploy_project "$name"; then
((ok++))
else
((fail++))
fi
echo "" echo ""
if deploy_project "$name"; then ((ok++)); else ((fail++)); fi
done done
echo -e " ${BOLD}Done.${NC} ${GREEN}${ok} deployed${NC}" \ deploy_summary $ok $fail
"$( ((fail > 0)) && echo -e ", ${RED}${fail} failed${NC}" )"
} }
deploy_project() { deploy_project() {
local name="$1" local slug="$1"
if ! project_exists "$name"; then if ! project_exists "$slug"; then
error "Project not found: $name" error "Project not found: ${slug}"
dim "Run ${BOLD}automa list${NC}${DIM} to see available projects${NC}"
return 1 return 1
fi fi
echo -e " ${CYAN}── ${BOLD}${name}${NC} ${CYAN}──${NC}" local name
name=$(project_meta "$slug" "name")
name="${name:-$slug}"
local desc
desc=$(project_meta "$slug" "desc")
configure_env "$name" step "${name}"
[[ -n "$desc" ]] && dim "${desc}"
if [[ ! -f "$SCRIPT_DIR/$name/.env" ]]; then configure_env "$slug"
error "No .env file — run: automa config $name"
if [[ ! -f "$SCRIPT_DIR/$slug/.env" ]]; then
error "No .env — run: ${BOLD}automa config $slug${NC}"
return 1 return 1
fi fi
info "Starting containers..." echo ""
if compose "$name" up -d 2>&1; then if run_with_spinner "Pulling images..." compose "$slug" pull; then
info "${name} is up" if run_with_spinner "Starting containers..." compose "$slug" up -d; then
local url
url=$(access_hint "$slug")
[[ -n "$url" ]] && dim "Access: ${BOLD}${url}${NC}"
return 0 return 0
else
error "${name} failed to start"
return 1
fi fi
fi
return 1
}
deploy_summary() {
local ok=$1 fail=$2
echo ""
divider
echo ""
if [[ $fail -eq 0 ]]; then
info "${BOLD}All done!${NC} ${ok} project(s) deployed"
else
warn "${BOLD}Done.${NC} ${GREEN}${ok} deployed${NC}, ${RED}${fail} failed${NC}"
fi
echo ""
dim "Useful commands:"
dim " ${BOLD}automa status${NC}${DIM} — check running state${NC}"
dim " ${BOLD}automa logs${NC}${DIM} <project> — view logs${NC}"
dim " ${BOLD}automa update${NC}${DIM} <project> — pull & restart${NC}"
echo ""
} }
cmd_stop() { cmd_stop() {
local name="${1:?Usage: automa stop <project>}" local slug="${1:-}"
if [[ -z "$slug" ]]; then
if ! project_exists "$name"; then error "Usage: ${BOLD}automa stop <project>${NC}"
error "Project not found: $name" dim "Run ${BOLD}automa list${NC}${DIM} to see available projects${NC}"
return 1 return 1
fi fi
info "Stopping ${name}..." if ! project_exists "$slug"; then
compose "$name" down error "Project not found: ${slug}"
info "${name} stopped" return 1
fi
run_with_spinner "Stopping ${slug}..." compose "$slug" down
} }
cmd_logs() { cmd_logs() {
local name="${1:?Usage: automa logs <project>}" local slug="${1:-}"
if [[ -z "$slug" ]]; then
error "Usage: ${BOLD}automa logs <project>${NC}"
return 1
fi
shift shift
if ! project_exists "$name"; then if ! project_exists "$slug"; then
error "Project not found: $name" error "Project not found: ${slug}"
return 1 return 1
fi fi
compose "$name" logs -f "$@" compose "$slug" logs -f "$@"
} }
cmd_status() { cmd_status() {
@ -332,86 +516,120 @@ cmd_status() {
check_docker check_docker
discover_projects discover_projects
for p in "${PROJECTS[@]}"; do if [[ ${#PROJECTS[@]} -eq 0 ]]; then
echo -e " ${BOLD}${p}${NC}" warn "No projects found"
local output return 1
output=$(compose "$p" ps --format "table {{.Name}}\t{{.Status}}" 2>/dev/null | tail -n +2) || true fi
if [[ -n "$output" ]]; then
while IFS= read -r line; do for slug in "${PROJECTS[@]}"; do
echo -e " ${line}" local st name
done <<< "$output" st=$(project_status "$slug")
else name=$(project_meta "$slug" "name")
echo -e " ${DIM}not running${NC}" name="${name:-$slug}"
local badge
badge=$(status_badge "$st")
echo -e " ${BOLD}${name}${NC} ${badge}"
if [[ "$st" == "running" ]]; then
compose "$slug" ps --format "table {{.Name}}\t{{.Status}}" 2>/dev/null \
| tail -n +2 \
| while IFS= read -r line; do
echo -e " ${DIM}${line}${NC}"
done
fi fi
echo "" echo ""
done done
} }
cmd_restart() { cmd_restart() {
local name="${1:?Usage: automa restart <project>}" local slug="${1:-}"
if [[ -z "$slug" ]]; then
if ! project_exists "$name"; then error "Usage: ${BOLD}automa restart <project>${NC}"
error "Project not found: $name"
return 1 return 1
fi fi
info "Restarting ${name}..." if ! project_exists "$slug"; then
compose "$name" restart error "Project not found: ${slug}"
info "${name} restarted" return 1
fi
run_with_spinner "Restarting ${slug}..." compose "$slug" restart
} }
cmd_config() { cmd_config() {
local name="${1:?Usage: automa config <project>}" local slug="${1:-}"
if [[ -z "$slug" ]]; then
if ! project_exists "$name"; then error "Usage: ${BOLD}automa config <project>${NC}"
error "Project not found: $name"
return 1 return 1
fi fi
configure_env "$name" if ! project_exists "$slug"; then
error "Project not found: ${slug}"
return 1
fi
configure_env "$slug"
} }
cmd_update() { cmd_update() {
local name="${1:?Usage: automa update <project>}" local slug="${1:-}"
if [[ -z "$slug" ]]; then
if ! project_exists "$name"; then error "Usage: ${BOLD}automa update <project>${NC}"
error "Project not found: $name"
return 1 return 1
fi fi
info "Pulling latest images for ${name}..." if ! project_exists "$slug"; then
compose "$name" pull error "Project not found: ${slug}"
info "Recreating containers..." return 1
compose "$name" up -d fi
info "${name} updated"
local name
name=$(project_meta "$slug" "name")
name="${name:-$slug}"
echo ""
step "Updating ${name}"
run_with_spinner "Pulling latest images..." compose "$slug" pull
run_with_spinner "Recreating containers..." compose "$slug" up -d
echo ""
info "Update complete"
echo ""
}
banner() {
echo ""
echo -e " ${BOLD}${CYAN}automa${NC} ${DIM}v${AUTOMA_VERSION}${NC}"
dim "Self-hosted Docker Compose deployer"
echo ""
} }
cmd_help() { cmd_help() {
banner banner
cat <<EOF cat <<EOF
${BOLD}Usage:${NC} automa <command> [args] ${BOLD}Usage${NC}
automa <command> [options]
${BOLD}Commands:${NC} ${BOLD}Commands${NC}
deploy [project...] Interactive deploy (or specify project names) ${BOLD}deploy${NC} [project...] Deploy projects interactively or by name
list List all available projects and status ${BOLD}list${NC} List all projects and their status
status Show running containers per project ${BOLD}status${NC} Show running containers
stop <project> Stop a project (docker compose down) ${BOLD}config${NC} <project> Configure environment variables
restart <project> Restart a project ${BOLD}stop${NC} <project> Stop a running project
logs <project> Follow logs of a project ${BOLD}restart${NC} <project> Restart a project
config <project> (Re)configure .env for a project ${BOLD}update${NC} <project> Pull latest images and recreate
update <project> Pull latest images and recreate ${BOLD}logs${NC} <project> Follow container logs
help Show this help ${BOLD}help${NC} Show this help message
${BOLD}Examples:${NC} ${BOLD}Examples${NC}
automa deploy # interactive project selection ${DIM}\$${NC} automa deploy ${DIM}# interactive selection${NC}
automa deploy forgejo filesuite # deploy specific projects ${DIM}\$${NC} automa deploy forgejo nextcloud ${DIM}# deploy by name${NC}
automa status # check all project status ${DIM}\$${NC} automa status ${DIM}# overview dashboard${NC}
automa logs forgejo # follow forgejo logs ${DIM}\$${NC} automa logs minecraft ${DIM}# follow logs${NC}
automa update nextcloud # pull & restart nextcloud
${BOLD}Quick start:${NC} ${BOLD}Quick start${NC}
curl -fsSL https://raw.githubusercontent.com/m1ngsama/automa/main/install.sh | bash ${DIM}\$${NC} curl -fsSL https://raw.githubusercontent.com/m1ngsama/automa/main/install.sh | bash
cd ~/automa && ./automa deploy ${DIM}\$${NC} cd ~/automa && ./automa deploy
EOF EOF
} }
@ -420,21 +638,26 @@ EOF
# Main # Main
# ============================================================================ # ============================================================================
main() { main() {
local cmd="${1:-help}" local cmd="${1:-}"
shift 2>/dev/null || true [[ -z "$cmd" ]] && { cmd_help; return; }
shift
case "$cmd" in case "$cmd" in
deploy) cmd_deploy "$@" ;; deploy) cmd_deploy "$@" ;;
list|ls) cmd_list ;; list|ls) cmd_list ;;
status) cmd_status ;; status|st|ps) cmd_status ;;
stop) cmd_stop "$@" ;; stop|down) cmd_stop "$@" ;;
restart) cmd_restart "$@" ;; restart) cmd_restart "$@" ;;
logs) cmd_logs "$@" ;; logs|log) cmd_logs "$@" ;;
config) cmd_config "$@" ;; config|configure) cmd_config "$@" ;;
update) cmd_update "$@" ;; update|upgrade) cmd_update "$@" ;;
help|-h|--help) cmd_help ;; help|-h|--help) cmd_help ;;
version|-v|--version) echo "automa v${AUTOMA_VERSION}" ;; version|-v|--version) echo "automa v${AUTOMA_VERSION}" ;;
*) error "Unknown command: $cmd"; cmd_help; exit 1 ;; *)
error "Unknown command: ${cmd}"
dim "Run ${BOLD}automa help${NC}${DIM} for usage${NC}"
exit 1
;;
esac esac
} }

View file

@ -1,17 +1,20 @@
# Filesuite - Cloudreve cloud storage + qBittorrent # @name Filesuite
# Both services share the same downloads directory # @desc Cloudreve cloud storage + qBittorrent downloader
# @url https://cloudreve.org
# @port CLOUDREVE_PORT
TZ=Asia/Shanghai TZ=Asia/Shanghai
PUID=1000 PUID=1000
PGID=1000 PGID=1000
# Shared downloads path (absolute path recommended) # Shared download directory — both services read/write here
# Use an absolute path for external drives (e.g. /mnt/data/downloads)
DOWNLOADS_DIR=./downloads DOWNLOADS_DIR=./downloads
# Cloudreve # Cloudreve — web file manager
CLOUDREVE_PORT=5212 CLOUDREVE_PORT=5212
CR_ENABLE_ARIA2=0
# qBittorrent # qBittorrent — torrent client
QB_WEBUI_PORT=8090 QB_WEBUI_PORT=8090
# BT listen port — must be forwarded in your router/firewall
QB_BT_PORT=44773 QB_BT_PORT=44773

View file

@ -1,8 +1,11 @@
# Forgejo - self-hosted Git service # @name Forgejo
# Docs: https://forgejo.org/docs/latest/admin/config-cheat-sheet/ # @desc Self-hosted Git service (Gitea fork)
# @url https://forgejo.org
# @port FORGEJO_HTTP_PORT
# Web and SSH access ports
FORGEJO_HTTP_PORT=3000 FORGEJO_HTTP_PORT=3000
FORGEJO_SSH_PORT=2223 FORGEJO_SSH_PORT=2223
# Set this to your public URL when behind a reverse proxy # Public URL — set this to your domain when behind a reverse proxy
FORGEJO_ROOT_URL=http://localhost:3000 FORGEJO_ROOT_URL=http://localhost:3000

View file

@ -6,49 +6,61 @@ set -euo pipefail
REPO="https://github.com/m1ngsama/automa.git" REPO="https://github.com/m1ngsama/automa.git"
INSTALL_DIR="${AUTOMA_DIR:-$HOME/automa}" INSTALL_DIR="${AUTOMA_DIR:-$HOME/automa}"
RED='\033[0;31m' RED='\033[0;31m' GREEN='\033[0;32m' CYAN='\033[0;36m'
GREEN='\033[0;32m' BOLD='\033[1m' DIM='\033[2m' NC='\033[0m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'
info() { echo -e "${GREEN}[+]${NC} $*"; } info() { echo -e " ${GREEN}\xe2\x9c\x94${NC} $*"; }
error() { echo -e "${RED}[-]${NC} $*" >&2; } error() { echo -e " ${RED}\xe2\x9c\x98${NC} $*" >&2; }
step() { echo -e " ${CYAN}\xe2\x96\xb6${NC} ${BOLD}$*${NC}"; }
echo "" echo ""
echo -e "${CYAN}${BOLD} automa installer${NC}" echo -e " ${BOLD}${CYAN}automa${NC}${BOLD} installer${NC}"
echo "" echo ""
# Check prerequisites # Check prerequisites
missing=0
for cmd in git docker; do for cmd in git docker; do
if ! command -v "$cmd" &>/dev/null; then if command -v "$cmd" &>/dev/null; then
error "$cmd is required but not installed." info "$cmd found"
exit 1 else
error "$cmd is not installed"
missing=1
fi fi
done done
if ! docker compose version &>/dev/null 2>&1; then if docker compose version &>/dev/null 2>&1; then
error "docker compose plugin is required." info "docker compose plugin found"
error "Install: https://docs.docker.com/compose/install/" else
error "docker compose plugin is not installed"
echo -e " ${DIM}Install: https://docs.docker.com/compose/install/${NC}"
missing=1
fi
if [[ $missing -eq 1 ]]; then
echo ""
error "Please install missing dependencies and try again"
exit 1 exit 1
fi fi
echo ""
# Clone or update # Clone or update
if [[ -d "$INSTALL_DIR/.git" ]]; then if [[ -d "$INSTALL_DIR/.git" ]]; then
info "Updating existing installation..." step "Updating existing installation..."
git -C "$INSTALL_DIR" pull --ff-only git -C "$INSTALL_DIR" pull --ff-only --quiet
info "Updated"
else else
info "Cloning automa to ${INSTALL_DIR}..." step "Installing to ${INSTALL_DIR}..."
git clone "$REPO" "$INSTALL_DIR" git clone --quiet "$REPO" "$INSTALL_DIR"
info "Cloned"
fi fi
chmod +x "$INSTALL_DIR/automa" chmod +x "$INSTALL_DIR/automa"
echo "" echo ""
info "Installed to ${INSTALL_DIR}" echo -e " ${GREEN}${BOLD}Ready!${NC}"
echo "" echo ""
echo -e " ${BOLD}Next steps:${NC}" echo -e " ${DIM}Get started:${NC}"
echo -e " cd ${INSTALL_DIR}" echo -e " ${BOLD}cd ${INSTALL_DIR}${NC}"
echo -e " ./automa deploy" echo -e " ${BOLD}./automa deploy${NC}"
echo "" echo ""

View file

@ -1,11 +1,23 @@
# Minecraft server (itzg/minecraft-server) # @name Minecraft
# Docs: https://docker-minecraft-server.readthedocs.io/ # @desc Fabric Minecraft server (itzg/minecraft-server)
# @url https://docker-minecraft-server.readthedocs.io
# @port MC_PORT
TZ=Asia/Shanghai TZ=Asia/Shanghai
# Server type and version
MC_TYPE=FABRIC MC_TYPE=FABRIC
MC_VERSION=1.21.1 MC_VERSION=1.21.1
# Memory allocation — adjust based on player count and mods
MC_MEMORY=4G MC_MEMORY=4G
# Set to true for Mojang account verification
MC_ONLINE_MODE=false MC_ONLINE_MODE=false
# Ports
MC_PORT=25565 MC_PORT=25565
RCON_PORT=25575 RCON_PORT=25575
# RCON password for remote console access
RCON_PASSWORD= RCON_PASSWORD=

View file

@ -1,17 +1,25 @@
# Nextcloud with MariaDB + Redis # @name Nextcloud
# Docs: https://hub.docker.com/_/nextcloud # @desc Nextcloud private cloud with MariaDB + Redis
# @url https://nextcloud.com
# @port NC_PORT
TZ=Asia/Shanghai TZ=Asia/Shanghai
# Web interface
NC_PORT=8080 NC_PORT=8080
# Admin account — created on first startup
NC_ADMIN_USER=admin NC_ADMIN_USER=admin
NC_ADMIN_PASSWORD= NC_ADMIN_PASSWORD=
# Trusted domains — space-separated list of domains/IPs that can access Nextcloud
NC_TRUSTED_DOMAINS=localhost NC_TRUSTED_DOMAINS=localhost
# MariaDB # MariaDB database
MYSQL_DATABASE=nextcloud MYSQL_DATABASE=nextcloud
MYSQL_USER=nextcloud MYSQL_USER=nextcloud
MYSQL_PASSWORD= MYSQL_PASSWORD=
MYSQL_ROOT_PASSWORD= MYSQL_ROOT_PASSWORD=
# Redis # Redis cache
REDIS_PASSWORD= REDIS_PASSWORD=

View file

@ -1,19 +1,28 @@
# Tailscale + DERP relay server # @name Tailscale + DERP
# # @desc Tailscale mesh VPN client with optional DERP relay
# Deploy tailscale only: docker compose --profile tailscale up -d # @url https://tailscale.com/kb/1282/docker
# Deploy with DERP: docker compose --profile derp up -d # @note Deploy tailscale only: docker compose --profile tailscale up -d
# @note Deploy with DERP relay: docker compose --profile derp up -d
TZ=Asia/Shanghai TZ=Asia/Shanghai
# Hostname shown in the Tailscale admin console
TS_HOSTNAME= TS_HOSTNAME=
# Auth key — generate at https://login.tailscale.com/admin/settings/keys
# For headscale: generate via headscale CLI
TS_AUTHKEY= TS_AUTHKEY=
# For headscale: --advertise-tags=tag:container --login-server=https://your.headscale.host # Extra arguments passed to tailscaled
# For headscale users, add: --login-server=https://your.headscale.host
TS_EXTRA_ARGS=--advertise-tags=tag:container TS_EXTRA_ARGS=--advertise-tags=tag:container
# Networking mode: false = kernel (better performance), true = userspace
TS_USERSPACE=false TS_USERSPACE=false
TS_FIREWALL_MODE=nftables TS_FIREWALL_MODE=nftables
# DERP relay (only needed with --profile derp) # DERP relay settings (only used with --profile derp)
# Public IP of this server — clients connect to this address
DERP_HOST= DERP_HOST=
DERP_PORT=443 DERP_PORT=443
STUN_PORT=3478 STUN_PORT=3478

View file

@ -1,3 +1,7 @@
# TeamSpeak voice server # @name TeamSpeak
# @desc TeamSpeak voice communication server
# @url https://teamspeak.com
# @port 9987/udp
# Server admin password — set on first run, used for ServerQuery
TS3_ADMIN_PASSWORD= TS3_ADMIN_PASSWORD=

View file

@ -1,4 +1,7 @@
# Uptime Kuma - uptime monitoring # @name Uptime Kuma
# Default binds to localhost only; change to 0.0.0.0:3001 for external access # @desc Uptime monitoring dashboard
# @url https://github.com/louislam/uptime-kuma
# @port KUMA_PORT
# Bind address — default localhost only; use 0.0.0.0:3001 for external access
KUMA_PORT=127.0.0.1:3001 KUMA_PORT=127.0.0.1:3001