- commands/list/list.sh: router + command::define show [*] - commands/list/show.sh: flag::define + flag::parse, all helpers - flag::exclusive: register under top-level command name (strip ::subcmd) - flag::parse: validate exclusive groups, error on conflicting user flags - --help: intercept in command::run before routing - help::auto: don't show default subcommand in usage line
412 lines
12 KiB
Bash
412 lines
12 KiB
Bash
#!/usr/bin/env bash
|
|
# core/framework/command.sh
|
|
#
|
|
# Command definition, routing, and lazy loading.
|
|
#
|
|
# Usage in on_load:
|
|
# command::define show "Show logs" [*]
|
|
# command::define clean "Remove keepalives" [c, dump]
|
|
# command::define watch "Watch live" [w, follow]
|
|
#
|
|
# Routing:
|
|
# wgctl logs → cmd::logs::show::run (default)
|
|
# wgctl logs clean → cmd::logs::clean::run
|
|
# wgctl logs c → cmd::logs::clean::run (alias)
|
|
# wgctl logs --fw → cmd::logs::show::run (default, flag passed through)
|
|
|
|
# ── Storage ───────────────────────────────────────────────────────────────────
|
|
|
|
# Command definitions: "cmd:subcmd" → "desc|aliases|is_default"
|
|
declare -gA _COMMAND_DEFS=()
|
|
|
|
# Alias map: "cmd:alias" → "subcmd"
|
|
declare -gA _COMMAND_ALIASES_MAP=()
|
|
|
|
# Default subcommand per command: "cmd" → "subcmd"
|
|
declare -gA _COMMAND_DEFAULT=()
|
|
|
|
# Currently loading command context (set by load_command)
|
|
declare -g _CURRENT_LOADING_CMD=""
|
|
|
|
# Currently executing command context (set by command::run)
|
|
declare -g _CURRENT_COMMAND=""
|
|
|
|
# ── Definition ────────────────────────────────────────────────────────────────
|
|
|
|
# command::define <subcmd> "description" [*] or [alias1, alias2]
|
|
# [*] marks as default subcommand
|
|
function command::define() {
|
|
local subcmd="${1:-}"
|
|
local desc="${2:-}"
|
|
local meta="${3:-}" # [*] or [alias1, alias2] or [*, alias1]
|
|
local cmd="${_CURRENT_LOADING_CMD:-}"
|
|
|
|
[[ -z "$cmd" || -z "$subcmd" ]] && return 1
|
|
|
|
local is_default=false
|
|
local aliases=""
|
|
|
|
if [[ -n "$meta" ]]; then
|
|
# Strip brackets
|
|
local inner="${meta#[}"
|
|
inner="${inner%]}"
|
|
|
|
# Check for * (default marker)
|
|
if [[ "$inner" == *"*"* ]]; then
|
|
is_default=true
|
|
inner="${inner//\*/}"
|
|
inner="${inner//,/}"
|
|
inner="${inner// /}"
|
|
fi
|
|
|
|
# Remaining are aliases
|
|
aliases="$inner"
|
|
# Clean up leading/trailing commas and spaces
|
|
aliases=$(echo "$aliases" | sed 's/^[, ]*//' | sed 's/[, ]*$//' | tr -s ' ,')
|
|
fi
|
|
|
|
# Store definition: desc|aliases|is_default
|
|
_COMMAND_DEFS["${cmd}:${subcmd}"]="${desc}|${aliases}|${is_default}"
|
|
|
|
# Register as default
|
|
if $is_default; then
|
|
_COMMAND_DEFAULT["$cmd"]="$subcmd"
|
|
fi
|
|
|
|
# Register aliases
|
|
if [[ -n "$aliases" ]]; then
|
|
local alias
|
|
# Split aliases on comma or space
|
|
while IFS=',' read -ra alias_list; do
|
|
for alias in "${alias_list[@]}"; do
|
|
alias="${alias// /}"
|
|
[[ -n "$alias" ]] && _COMMAND_ALIASES_MAP["${cmd}:${alias}"]="$subcmd"
|
|
done
|
|
done <<< "$aliases"
|
|
fi
|
|
}
|
|
|
|
# ── Routing ───────────────────────────────────────────────────────────────────
|
|
|
|
# command::route <cmd> <args...>
|
|
# Determines which subcommand to run and routes to it.
|
|
# Returns: sets _ROUTED_SUBCMD and _ROUTED_ARGS
|
|
declare -g _ROUTED_SUBCMD=""
|
|
declare -ga _ROUTED_ARGS=()
|
|
|
|
function command::route() {
|
|
local cmd="$1"
|
|
shift
|
|
local -a args=("$@")
|
|
|
|
_ROUTED_SUBCMD=""
|
|
_ROUTED_ARGS=()
|
|
|
|
# Check if _COMMAND_DEFS has any entries for this cmd
|
|
local has_defs=false
|
|
local key
|
|
for key in "${!_COMMAND_DEFS[@]}"; do
|
|
[[ "$key" == "${cmd}:"* ]] && has_defs=true && break
|
|
done
|
|
|
|
if ! $has_defs; then
|
|
# No subcommands defined — run directly
|
|
_ROUTED_SUBCMD=""
|
|
_ROUTED_ARGS=("${args[@]:-}")
|
|
return 0
|
|
fi
|
|
|
|
# Find first non-flag arg
|
|
local first_nonflag="" first_nonflag_idx=-1
|
|
local i
|
|
for (( i=0; i<${#args[@]}; i++ )); do
|
|
if [[ "${args[$i]}" != --* && "${args[$i]}" != -* ]]; then
|
|
first_nonflag="${args[$i]}"
|
|
first_nonflag_idx=$i
|
|
break
|
|
fi
|
|
done
|
|
|
|
local matched_subcmd=""
|
|
|
|
if [[ -n "$first_nonflag" ]]; then
|
|
# Check direct match
|
|
if [[ -n "${_COMMAND_DEFS[${cmd}:${first_nonflag}]+x}" ]]; then
|
|
matched_subcmd="$first_nonflag"
|
|
# Check alias
|
|
elif [[ -n "${_COMMAND_ALIASES_MAP[${cmd}:${first_nonflag}]+x}" ]]; then
|
|
matched_subcmd="${_COMMAND_ALIASES_MAP[${cmd}:${first_nonflag}]}"
|
|
fi
|
|
fi
|
|
|
|
if [[ -n "$matched_subcmd" ]]; then
|
|
# Remove matched subcmd from args
|
|
local -a new_args=()
|
|
for (( i=0; i<${#args[@]}; i++ )); do
|
|
[[ $i -eq $first_nonflag_idx ]] && continue
|
|
new_args+=("${args[$i]}")
|
|
done
|
|
_ROUTED_SUBCMD="$matched_subcmd"
|
|
_ROUTED_ARGS=("${new_args[@]:-}")
|
|
else
|
|
# Use default subcommand
|
|
local default_subcmd="${_COMMAND_DEFAULT[$cmd]:-}"
|
|
_ROUTED_SUBCMD="$default_subcmd"
|
|
_ROUTED_ARGS=("${args[@]:-}")
|
|
fi
|
|
}
|
|
|
|
# ── Loading ───────────────────────────────────────────────────────────────────
|
|
|
|
# command::load_subcmd <cmd> <subcmd>
|
|
# Lazy-loads a subcommand's file and calls its on_load
|
|
function command::load_subcmd() {
|
|
local cmd="$1" subcmd="$2"
|
|
[[ -z "$cmd" || -z "$subcmd" ]] && return 1
|
|
|
|
# Determine file path — commands/<cmd>/<subcmd>.sh
|
|
local cmd_dir
|
|
cmd_dir="${WGCTL_DIR}/commands/${cmd}"
|
|
|
|
local subcmd_file="${cmd_dir}/${subcmd}.sh"
|
|
[[ ! -f "$subcmd_file" ]] && return 1
|
|
|
|
_CURRENT_LOADING_CMD="${cmd}::${subcmd}"
|
|
_CURRENT_COMMAND="${cmd}::${subcmd}"
|
|
source "$subcmd_file"
|
|
|
|
local on_load_fn="cmd::${cmd}::${subcmd}::on_load"
|
|
if declare -f "$on_load_fn" &>/dev/null; then
|
|
"$on_load_fn"
|
|
fi
|
|
|
|
_CURRENT_LOADING_CMD=""
|
|
}
|
|
|
|
# ── Run ───────────────────────────────────────────────────────────────────────
|
|
|
|
# command::run_routed <cmd> <subcmd> <args...>
|
|
# Runs a subcommand after routing is resolved.
|
|
function command::run_routed() {
|
|
local cmd="$1" subcmd="$2"
|
|
shift 2
|
|
local -a args=("$@")
|
|
|
|
_CURRENT_COMMAND="${cmd}::${subcmd}"
|
|
|
|
# Apply command defaults (only for default subcommand)
|
|
local default_subcmd="${_COMMAND_DEFAULT[$cmd]:-}"
|
|
local -a default_args=()
|
|
if [[ "$subcmd" == "$default_subcmd" || -z "$default_subcmd" ]]; then
|
|
local defaults="${_COMMAND_DEFAULTS[$cmd]:-}"
|
|
if [[ -n "$defaults" ]]; then
|
|
read -ra default_args <<< "$defaults"
|
|
fi
|
|
fi
|
|
|
|
local -a user_args=()
|
|
[[ ${#args[@]} -gt 0 ]] && user_args=("${args[@]}")
|
|
|
|
# Resolve exclusive group conflicts
|
|
local groups="${_FLAG_EXCLUSIVE_GROUPS[$cmd]:-}"
|
|
if [[ -n "$groups" && ${#default_args[@]} -gt 0 && ${#user_args[@]} -gt 0 ]]; then
|
|
command::_resolve_conflicts default_args user_args "$groups"
|
|
fi
|
|
|
|
# Combine args
|
|
local -a final_args=()
|
|
for _d in "${default_args[@]:-}"; do
|
|
[[ -n "$_d" ]] && final_args+=("$_d")
|
|
done
|
|
for _u in "${user_args[@]:-}"; do
|
|
[[ -n "$_u" ]] && final_args+=("$_u")
|
|
done
|
|
|
|
# Preprocess mixin flags (--json, --no-color etc)
|
|
command::_preprocess_flags final_args
|
|
|
|
# Run
|
|
local run_fn="cmd::${cmd}::${subcmd}::run"
|
|
if declare -f "$run_fn" &>/dev/null; then
|
|
if [[ ${#final_args[@]} -gt 0 ]]; then
|
|
"$run_fn" "${final_args[@]}"
|
|
else
|
|
"$run_fn"
|
|
fi
|
|
else
|
|
log::error "Run function not found: ${run_fn}"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
# command::has_subcmds <cmd>
|
|
# Returns 0 if command has defined subcommands
|
|
function command::has_subcmds() {
|
|
local cmd="$1" key
|
|
for key in "${!_COMMAND_DEFS[@]}"; do
|
|
[[ "$key" == "${cmd}:"* ]] && return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# command::subcmds <cmd>
|
|
# Prints list of subcommand names
|
|
function command::subcmds() {
|
|
local cmd="$1" key
|
|
for key in "${!_COMMAND_DEFS[@]}"; do
|
|
[[ "$key" == "${cmd}:"* ]] && echo "${key#${cmd}:}"
|
|
done
|
|
}
|
|
|
|
# ============================================
|
|
# Command Registry
|
|
# ============================================
|
|
|
|
declare -A _LOADED_COMMANDS=()
|
|
|
|
readonly _COMMAND_NAMESPACE="cmd"
|
|
readonly _COMMAND_AUTO_LOAD_HOOK="on_load"
|
|
_CURRENT_LOADING_CMD=""
|
|
|
|
# ============================================
|
|
# Helpers
|
|
# ============================================
|
|
|
|
function command::loaded() { [[ -n "${_LOADED_COMMANDS["$1"]:-}" ]]; }
|
|
|
|
# Convert path-style name to namespace
|
|
# e.g. service/wireguard -> service::wireguard
|
|
function command::to_namespace() { echo "${1//\//:}"; }
|
|
|
|
# Build fully qualified function name
|
|
# e.g. command::fn "add" "run" -> cmd::add::run
|
|
# e.g. command::fn "service/wg" "run" -> cmd::service::wg::run
|
|
function command::fn() {
|
|
local name namespace
|
|
namespace=$(command::to_namespace "$1")
|
|
echo "${_COMMAND_NAMESPACE}::${namespace}::${2}"
|
|
}
|
|
|
|
function command::has_function() { declare -F "$(command::fn "$1" "$2")" >/dev/null 2>&1; }
|
|
function command::is_auto_load() { declare -F "$(command::fn "$1" on_load)" >/dev/null 2>&1; }
|
|
function command::exists() {
|
|
local name="$1"
|
|
# New-style: has subcommands defined
|
|
command::has_subcmds "$name" && return 0
|
|
# Legacy: has cmd::name::run function
|
|
declare -f "$(command::fn "$name" run)" &>/dev/null
|
|
}
|
|
|
|
# ============================================
|
|
# Runner
|
|
# ============================================
|
|
|
|
function command::run() {
|
|
local cmd="$1"
|
|
shift
|
|
|
|
command::_reset_mixin_state
|
|
|
|
# Check if this command uses new directory-based routing
|
|
if command::has_subcmds "$cmd"; then
|
|
# Route to subcommand
|
|
command::route "$cmd" "$@"
|
|
local subcmd="$_ROUTED_SUBCMD"
|
|
local -a routed_args=("${_ROUTED_ARGS[@]:-}")
|
|
|
|
# Lazy load subcommand file
|
|
local subcmd_file="$(ctx::commands)/${cmd}/${subcmd}.sh"
|
|
if [[ -f "$subcmd_file" ]]; then
|
|
_CURRENT_LOADING_CMD="${cmd}::${subcmd}"
|
|
_CURRENT_COMMAND="${cmd}::${subcmd}"
|
|
source "$subcmd_file"
|
|
core::call_if_exists "cmd::${cmd}::${subcmd}::on_load"
|
|
_CURRENT_LOADING_CMD=""
|
|
|
|
for arg in "$@"; do
|
|
[[ "$arg" == "--help" || "$arg" == "-h" ]] && {
|
|
hook::fire "command:help:${cmd}" "$cmd" "$_ROUTED_SUBCMD"
|
|
return 0
|
|
}
|
|
done
|
|
fi
|
|
|
|
command::run_routed "$cmd" "$subcmd" "${routed_args[@]:-}"
|
|
return $?
|
|
fi
|
|
|
|
# Legacy path — existing command::run logic
|
|
local -a default_args=()
|
|
local defaults="${_COMMAND_DEFAULTS[$cmd]:-}"
|
|
if [[ -n "$defaults" ]]; then
|
|
read -ra default_args <<< "$defaults"
|
|
fi
|
|
|
|
local -a user_args=()
|
|
[[ $# -gt 0 ]] && user_args=("$@")
|
|
|
|
local groups="${_FLAG_EXCLUSIVE_GROUPS[$cmd]:-}"
|
|
if [[ -n "$groups" && ${#default_args[@]} -gt 0 && ${#user_args[@]} -gt 0 ]]; then
|
|
command::_resolve_conflicts default_args user_args "$groups"
|
|
fi
|
|
|
|
local -a args=()
|
|
for _d in "${default_args[@]:-}"; do [[ -n "$_d" ]] && args+=("$_d"); done
|
|
for _u in "${user_args[@]:-}"; do [[ -n "$_u" ]] && args+=("$_u"); done
|
|
|
|
command::_preprocess_flags args
|
|
|
|
local fn
|
|
fn=$(command::fn "$cmd" run)
|
|
if [[ ${#args[@]} -gt 0 ]]; then
|
|
core::call_function "$fn" "${args[@]}"
|
|
else
|
|
core::call_function "$fn"
|
|
fi
|
|
}
|
|
|
|
|
|
function core::call_function() {
|
|
local fn="$1"
|
|
shift
|
|
"$fn" "$@"
|
|
}
|
|
|
|
# ============================================
|
|
# Loader
|
|
# ============================================
|
|
|
|
function load_command() {
|
|
local name="$1"
|
|
command::loaded "$name" && return 0
|
|
|
|
# Check for new directory-based structure first
|
|
local cmd_dir
|
|
cmd_dir="$(ctx::commands)/${name}"
|
|
local cmd_file="${cmd_dir}/${name}.sh"
|
|
|
|
if [[ -d "$cmd_dir" && -f "$cmd_file" ]]; then
|
|
source "$cmd_file"
|
|
_LOADED_COMMANDS["$name"]=1
|
|
_CURRENT_LOADING_CMD="$name"
|
|
core::call_if_exists "cmd::${name}::on_load"
|
|
_CURRENT_LOADING_CMD=""
|
|
return 0
|
|
fi
|
|
|
|
# Fall back to legacy .command.sh
|
|
local path
|
|
path="$(ctx::commands)/${name}.command.sh"
|
|
if [[ ! -f "$path" ]]; then
|
|
log::error "Command not found: ${name} (${path})"
|
|
return 1
|
|
fi
|
|
source "$path"
|
|
_LOADED_COMMANDS["$name"]=1
|
|
_CURRENT_LOADING_CMD="$name"
|
|
core::call_if_exists "$(command::fn "$name" on_load)"
|
|
_CURRENT_LOADING_CMD=""
|
|
return 0
|
|
}
|