From 8ed491313d026a982c28ced48fb4e7880acb3d0b Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Sat, 30 May 2026 03:44:08 +0000 Subject: [PATCH] feat: command framework + logs migration - core/framework/flag.sh: flag::define, flag::parse, accessors - core/framework/hook.sh: hook::on, hook::fire, hook::off, hook::has - core/framework/help.sh: help::section, command::help::auto - core/framework/command.sh: command::define, command::route, lazy loading - core restructure: framework/ + app/ separation - load_command: directory-based command detection - command::exists: accepts new-style commands - command::run: routing for new-style, legacy fallback - commands/logs/: migrated to new framework - logs.sh: router + command::define - show.sh: flag::define + flag::parse, no manual case blocks - clean.sh: flag::define + flag::parse - remove.sh: flag::define + flag::parse - rotate.sh: flag::define + flag::parse - logs clean: fix dry_run bool to int conversion - ctx::json_helper: fixed path after core restructure - PYTHONPATH: exported in app/core.sh --- commands/logs/clean.sh | 30 ++ commands/logs/logs.sh | 11 + commands/logs/remove.sh | 22 ++ commands/logs/rotate.sh | 18 ++ commands/logs/show.sh | 504 ++++++++++++++++++++++++++++++++++ core/app/context.sh | 2 +- core/app/json.sh | 3 +- core/framework/command.sh | 359 +++++++++++++++++++++--- core/framework/command.sh.bak | 130 +++++++++ core/framework/flag.sh | 356 ++++++++++++++++++++---- core/framework/flag.sh.bak | 66 +++++ core/framework/help.sh | 275 +++++++++++++++++++ core/framework/hook.sh | 46 ++++ 13 files changed, 1730 insertions(+), 92 deletions(-) create mode 100644 commands/logs/clean.sh create mode 100644 commands/logs/logs.sh create mode 100644 commands/logs/remove.sh create mode 100644 commands/logs/rotate.sh create mode 100644 commands/logs/show.sh create mode 100644 core/framework/command.sh.bak create mode 100644 core/framework/flag.sh.bak diff --git a/commands/logs/clean.sh b/commands/logs/clean.sh new file mode 100644 index 0000000..4b0b21c --- /dev/null +++ b/commands/logs/clean.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# commands/logs/clean.sh + +function cmd::logs::clean::on_load() { + flag::define --force bool "Skip confirmation" + flag::define --dry-run bool "Show what would be removed" +} + +function cmd::logs::clean::run() { + flag::parse "$@" || return 1 + local force=false dry_run=false + flag::bool --force && force=true + flag::bool --dry-run && dry_run=true + + local dry_run_flag="0" + $dry_run && dry_run_flag="1" + + local count + count=$(json::clean_handshakes \ + "$(ctx::events_log)" "$dry_run_flag" 2>/dev/null) || { + log::error "Failed to clean handshakes" + return 1 + } + + if $dry_run; then + log::wg_warning "Dry run — would remove ${count} keepalive handshake(s)" + else + log::wg_success "Removed ${count} keepalive handshake(s)" + fi +} \ No newline at end of file diff --git a/commands/logs/logs.sh b/commands/logs/logs.sh new file mode 100644 index 0000000..e8471e4 --- /dev/null +++ b/commands/logs/logs.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# commands/logs/logs.sh — router only + +function cmd::logs::on_load() { + command::define show "Show WireGuard and firewall logs" [*] + command::define clean "Remove keepalive handshakes" [c] + command::define remove "Remove log entries" [rm, del] + command::define rotate "Remove entries older than N days" +} + +hook::on "command:help:logs" command::help::auto \ No newline at end of file diff --git a/commands/logs/remove.sh b/commands/logs/remove.sh new file mode 100644 index 0000000..014213b --- /dev/null +++ b/commands/logs/remove.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# commands/logs/remove.sh + +function cmd::logs::remove::on_load() { + flag::define --name value "Filter by peer name" [label="name"] + flag::define --since value "Remove entries since" [label="time"] + flag::define --fw bool "Remove firewall logs only" + flag::define --wg bool "Remove WireGuard logs only" + flag::define --force bool "Skip confirmation" +} + +function cmd::logs::remove::run() { + flag::parse "$@" || return 1 + local name; name=$(flag::value --name) + local since; since=$(flag::value --since) + local force=false fw=false wg=false + flag::bool --force && force=true + flag::bool --fw && fw=true + flag::bool --wg && wg=true + + cmd::logs::_remove_impl "$name" "$since" "$fw" "$wg" "$force" +} \ No newline at end of file diff --git a/commands/logs/rotate.sh b/commands/logs/rotate.sh new file mode 100644 index 0000000..b0f5796 --- /dev/null +++ b/commands/logs/rotate.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# commands/logs/rotate.sh + +function cmd::logs::rotate::on_load() { + flag::define --days value "Remove entries older than N days" [default=30, type=int, min=1, label="days"] + flag::define --force bool "Skip confirmation" + flag::define --dry-run bool "Show what would be removed" +} + +function cmd::logs::rotate::run() { + flag::parse "$@" || return 1 + local days; days=$(flag::value --days) + local force=false dry_run=false + flag::bool --force && force=true + flag::bool --dry-run && dry_run=true + + cmd::logs::_rotate_impl "$days" "$force" "$dry_run" +} \ No newline at end of file diff --git a/commands/logs/show.sh b/commands/logs/show.sh new file mode 100644 index 0000000..6f221d3 --- /dev/null +++ b/commands/logs/show.sh @@ -0,0 +1,504 @@ +#!/usr/bin/env bash +# commands/logs/show.sh + +FW_EVENTS_LOG="$(ctx::fw_events_log)" +WG_EVENTS_LOG="$(ctx::events_log)" + +function cmd::logs::show::on_load() { + command::mixin json_output [section="Output"] + + help::section "Filters" + flag::define --name value "Filter by peer name" [label="name", section="Filters"] + flag::define --type value "Filter by device type" [label="type", section="Filters"] + flag::define --since value "Show events since (2h, 7d)" [label="time", section="Filters"] + flag::define --service value "Filter by service/IP" [label="service", section="Filters"] + flag::define --event value "Filter wg events" [label="type", section="Filters"] + flag::define --fw bool "Show firewall drops only" [section="Filters"] + flag::define --wg bool "Show WireGuard events only" [section="Filters"] + flag::define --merged bool "Show all events interleaved" [section="Filters"] + + help::section "Output" + flag::define --limit value "Max results per source" [default=50, type=int, min=1, section="Output"] + flag::define --ascending bool "Sort ascending" [section="Output"] + flag::define --descending bool "Sort descending" [section="Output"] + flag::define --resolved bool "Resolve endpoints" [section="Output"] + flag::define --detailed bool "Show per-event detail" [section="Output"] + flag::define --raw bool "Skip service resolution" [section="Output"] + flag::define --follow bool "Follow live" [section="Output"] + + flag::exclusive --fw --wg + flag::exclusive --ascending --descending +} + +function cmd::logs::show::run() { + flag::parse "$@" || return 1 + + local name; name=$(flag::value --name) + local type; type=$(flag::value --type) + local limit; limit=$(flag::value --limit) + local since; since=$(flag::value --since) + local filter_service; filter_service=$(flag::value --service) + local filter_event; filter_event=$(flag::value --event) + local net_file="" + + local fw_only=false wg_only=false merged=false + local follow=false raw=false resolved=false detailed=false + flag::bool --fw && fw_only=true + flag::bool --wg && wg_only=true + flag::bool --merged && merged=true + flag::bool --follow && follow=true + flag::bool --raw && raw=true + flag::bool --resolved && resolved=true + flag::bool --detailed && detailed=true + + local sort_order="desc" + flag::bool --ascending && sort_order="asc" + flag::bool --descending && sort_order="desc" + + local collapse=1 + $detailed && collapse=0 + + if [[ -n "$name" && -n "$type" ]]; then + name=$(peers::resolve_and_require "$name" "$type") || return 1 + fi + + local filter_ip="" + if [[ -n "$name" ]]; then + filter_ip=$(peers::get_ip "$name") + [[ -z "$filter_ip" ]] && log::error "Could not find IP for: $name" && return 1 + fi + + $fw_only && $wg_only && fw_only=false && wg_only=false + + if $follow; then + cmd::logs::follow "$filter_ip" "$name" "$type" "$fw_only" "$wg_only" + return + fi + + $raw || net_file="$(ctx::net)" + + local filter_dest_ip="" filter_dest_port="" + if [[ -n "$filter_service" ]]; then + if [[ "$filter_service" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(:[0-9]+)?$ ]]; then + filter_dest_ip="${filter_service%%:*}" + local maybe_port="${filter_service##*:}" + [[ "$maybe_port" != "$filter_dest_ip" ]] && filter_dest_port="$maybe_port" + else + local svc_resolved + svc_resolved=$(net::resolve "$filter_service" 2>/dev/null | head -1) + if [[ -n "$svc_resolved" ]]; then + filter_dest_ip="${svc_resolved%%:*}" + local rest="${svc_resolved#*:}" + [[ "$rest" != "$filter_dest_ip" ]] && filter_dest_port="${rest%%:*}" + else + log::error "Service not found: ${filter_service}" + return 1 + fi + fi + fi + + if $merged; then + log::section "WireGuard Activity Log" + printf "\n" + cmd::logs::show_merged "$filter_ip" "$name" "$type" "$limit" "$net_file" "$since" + return + fi + + local fw_output="" wg_output="" + + $wg_only || fw_output=$(cmd::logs::show_fw_events \ + "$filter_ip" "$name" "$type" "$limit" "$net_file" \ + "$collapse" "$since" "$filter_dest_ip" "$filter_dest_port" "$sort_order" "$resolved") + + $fw_only || wg_output=$(cmd::logs::show_wg_events \ + "$filter_ip" "$name" "$type" "$limit" \ + "$collapse" "$since" "$filter_event" "$sort_order" "$resolved") + + if [[ -z "$(echo "$fw_output" | tr -d '[:space:]')" && \ + -z "$(echo "$wg_output" | tr -d '[:space:]')" ]]; then + log::wg_warning "No logs found" + return 0 + fi + + log::section "WireGuard Activity Log" + printf "\n" + + if [[ -n "$fw_output" && -n "$wg_output" ]]; then + printf "%s\n\n" "$fw_output" + printf "%s\n" "$wg_output" + elif [[ -n "$fw_output" ]]; then + printf "%s\n" "$fw_output" + else + printf "%s\n" "$wg_output" + fi +} + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +function cmd::logs::show_fw_events() { + local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \ + limit="${4:-50}" net_file="${5:-}" collapse="${6:-1}" \ + since="${7:-}" filter_dest_ip="${8:-}" filter_dest_port="${9:-}" \ + sort_order="${10:-desc}" resolved_only="${11:-false}" + + [[ ! -f "$FW_EVENTS_LOG" ]] && return 0 + + local data + data=$(json::fw_events \ + "$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \ + "$(ctx::clients)" "${net_file:-}" \ + "$limit" "$collapse" "$since" \ + "$filter_dest_ip" "$filter_dest_port" \ + "$sort_order" \ + 2>/dev/null) + + [[ -z "$data" ]] && return 0 + + # ── Collect unique endpoints for batch resolution ── + local -a ep_list=() + while IFS='|' read -r ts client dest_ip dest_port proto svc count src_endpoint; do + [[ -z "$ts" || -z "$src_endpoint" ]] && continue + ep_list+=("$src_endpoint") + done <<< "$data" + + declare -A resolve_cache=() + if [[ ${#ep_list[@]} -gt 0 ]]; then + while IFS='|' read -r ip name; do + [[ -n "$ip" ]] && resolve_cache["$ip"]="$name" + done < <(json::batch_resolve "${ep_list[@]}" 2>/dev/null) + fi + + # ── Pass 1: measure widths ── + local w_client=16 w_dest=20 w_endpoint=0 + local resolved_data="" + + while IFS='|' read -r ts client dest_ip dest_port proto svc count src_endpoint; do + [[ -z "$ts" ]] && continue + + (( ${#client} > w_client )) && w_client=${#client} + + local svc_display="" + if [[ -n "$svc" ]]; then + [[ -n "$dest_port" ]] && svc_display="${svc}/${proto}" \ + || svc_display="${svc} (${proto})" + else + [[ -n "$dest_port" ]] && svc_display="${dest_ip}:${dest_port}/${proto}" \ + || svc_display="${dest_ip} (${proto})" + fi + + local measure_len + if $resolved_only; then + measure_len=${#svc_display} + else + local raw_plain="" + [[ -n "$svc" && -n "$dest_port" ]] && raw_plain=" (${dest_ip}:${dest_port})" + [[ -n "$svc" && -z "$dest_port" ]] && raw_plain=" (${dest_ip})" + measure_len=$(( ${#svc_display} + ${#raw_plain} )) + fi + (( measure_len > w_dest )) && w_dest=$measure_len + + local src_resolved="" + if [[ -n "$src_endpoint" ]]; then + src_resolved="${resolve_cache[$src_endpoint]:-}" + [[ "$src_resolved" == "$src_endpoint" ]] && src_resolved="" + + local ep_measure_len + if $resolved_only; then + ep_measure_len=${#src_resolved} + [[ -z "$src_resolved" ]] && ep_measure_len=${#src_endpoint} + else + ep_measure_len=${#src_endpoint} + [[ -n "$src_resolved" ]] && \ + ep_measure_len=$(( ${#src_endpoint} + 4 + ${#src_resolved} )) + fi + (( ep_measure_len > w_endpoint )) && w_endpoint=$ep_measure_len + fi + + resolved_data+="${ts}|${client}|${dest_ip}|${dest_port}|${proto}|${svc}|${count}|${src_endpoint}|${src_resolved}"$'\n' + done <<< "$data" + + (( w_client += 2 )) + (( w_dest += 2 )) + [[ "$w_endpoint" -gt 0 ]] && (( w_endpoint += 2 )) + + # ── Pass 2: render ── + ui::logs::fw_section_header + while IFS='|' read -r ts client dest_ip dest_port proto svc count src_endpoint src_resolved; do + [[ -z "$ts" ]] && continue + ui::logs::fw_row "$ts" "$client" "$dest_ip" "$dest_port" \ + "$proto" "$svc" "$count" "$w_client" "$w_dest" \ + "$src_endpoint" "$src_resolved" "$w_endpoint" "$resolved_only" + done <<< "$resolved_data" + printf "\n" +} + +function cmd::logs::show_wg_events() { + local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \ + limit="${4:-50}" collapse="${5:-1}" \ + since="${6:-}" filter_event="${7:-}" sort_order="${8:-desc}" \ + resolved_only="${9:-false}" + + [[ ! -f "$WG_EVENTS_LOG" ]] && return 0 + + local data + data=$(json::wg_events \ + "$WG_EVENTS_LOG" "$filter_name" "$filter_type" \ + "$limit" "$collapse" "$since" "$filter_event" \ + "$(ctx::endpoint_cache)" "$sort_order" \ + 2>/dev/null) + + [[ -z "$data" ]] && return 0 + + # ── Collect unique endpoints for batch resolution ── + local -a ep_list=() + while IFS='|' read -r ts client endpoint event count gap_seconds; do + [[ -z "$ts" || -z "$endpoint" ]] && continue + ep_list+=("$endpoint") + done <<< "$data" + + declare -A resolve_cache=() + if [[ ${#ep_list[@]} -gt 0 ]]; then + while IFS='|' read -r ip name; do + [[ -n "$ip" ]] && resolve_cache["$ip"]="$name" + done < <(json::batch_resolve "${ep_list[@]}" 2>/dev/null) + fi + + # ── Measure widths ── + local w_client=16 w_endpoint=16 + local resolved_data="" + + while IFS='|' read -r ts client endpoint event count gap_seconds; do + [[ -z "$ts" ]] && continue + (( ${#client} > w_client )) && w_client=${#client} + + local resolved="" + if [[ -n "$endpoint" ]]; then + resolved="${resolve_cache[$endpoint]:-}" + [[ "$resolved" == "$endpoint" ]] && resolved="" + fi + + local ep_raw="${endpoint:--}" + local ep_measure_len + if $resolved_only; then + local ep_display="${resolved:-$endpoint}" + [[ -z "$ep_display" ]] && ep_display="-" + ep_measure_len=${#ep_display} + else + ep_measure_len=${#ep_raw} + [[ -n "$resolved" && -n "$endpoint" ]] && \ + ep_measure_len=$(( ${#endpoint} + 4 + ${#resolved} )) + fi + (( ep_measure_len > w_endpoint )) && w_endpoint=$ep_measure_len + + resolved_data+="${ts}|${client}|${endpoint}|${event}|${count}|${gap_seconds}|${resolved}"$'\n' + done <<< "$data" + + (( w_client += 2 )) + (( w_endpoint += 2 )) + + # ── Render ── + ui::logs::wg_section_header + while IFS='|' read -r ts client endpoint event count gap_seconds resolved; do + [[ -z "$ts" ]] && continue + if $resolved_only; then + local ep_display="${resolved:-$endpoint}" + [[ -z "$ep_display" ]] && ep_display="-" + ui::logs::wg_row "$ts" "$client" "$ep_display" "$event" \ + "$count" "$w_client" "$w_endpoint" "$gap_seconds" "" + else + ui::logs::wg_row "$ts" "$client" "$endpoint" "$event" \ + "$count" "$w_client" "$w_endpoint" "$gap_seconds" "$resolved" + fi + done <<< "$resolved_data" + printf "\n" +} + +function cmd::logs::show_merged() { + local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \ + limit="${4:-50}" net_file="${5:-}" since="${6:-}" + + local fw_data wg_data + fw_data=$(json::fw_events \ + "$FW_EVENTS_LOG" "$filter_ip" "$filter_type" \ + "$(ctx::clients)" "${net_file:-}" \ + "$limit" "1" "$since" "" "" \ + 2>/dev/null) + wg_data=$(json::wg_events \ + "$WG_EVENTS_LOG" "$filter_name" "$filter_type" \ + "$limit" "1" "$since" "" \ + 2>/dev/null) + + local w_client=16 w_dest=20 + while IFS='|' read -r ts client rest; do + [[ -z "$ts" ]] && continue + (( ${#client} > w_client )) && w_client=${#client} + done < <(echo "$fw_data"; echo "$wg_data") + (( w_client += 2 )) + + local merged_data + merged_data=$( + while IFS='|' read -r ts client dest_ip dest_port proto svc count; do + [[ -z "$ts" ]] && continue + echo "fw|${ts}|${client}|${dest_ip}|${dest_port}|${proto}|${svc}|${count}" + done <<< "$fw_data" + while IFS='|' read -r ts client endpoint event count; do + [[ -z "$ts" ]] && continue + echo "wg|${ts}|${client}|${endpoint}|${event}|${count}" + done <<< "$wg_data" + ) + + while IFS='|' read -r source ts rest; do + [[ -z "$source" ]] && continue + case "$source" in + fw) + IFS='|' read -r client dest_ip dest_port proto svc count <<< "$rest" + local dest_display + if [[ -n "$svc" ]]; then + [[ -n "$dest_port" ]] && dest_display="${svc}/${proto}" || dest_display="${svc} (${proto})" + else + [[ -n "$dest_port" ]] && dest_display="${dest_ip}:${dest_port}/${proto}" || dest_display="${dest_ip} (${proto})" + fi + (( ${#dest_display} > w_dest )) && w_dest=${#dest_display} + ;; + esac + done <<< "$merged_data" + (( w_dest += 2 )) + + while IFS='|' read -r source ts rest; do + [[ -z "$source" ]] && continue + case "$source" in + fw) + IFS='|' read -r client dest_ip dest_port proto svc count <<< "$rest" + ui::watch::fw_row "$ts" "$client" \ + "$(ui::logs::build_dest "$dest_ip" "$dest_port" "$proto" "$svc")" \ + "$w_client" "$w_dest" + ;; + wg) + IFS='|' read -r client endpoint event count <<< "$rest" + ui::watch::wg_row "$ts" "$client" "$endpoint" "$event" \ + "$w_client" "$w_dest" + ;; + esac + done < <(echo "$merged_data" | sort -t'|' -k2,2) + + printf "\n" +} + +function cmd::logs::follow() { + local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" + local fw_only="${4:-false}" wg_only="${5:-false}" + + log::section "WireGuard Live Log (Ctrl+C to stop)" + printf "\n" + + local restricted_only=false blocked_only=false + $fw_only && restricted_only=true + $wg_only && blocked_only=true + + monitor::live "$filter_name" "$filter_type" "" \ + "$blocked_only" "$restricted_only" "false" "false" +} + +function cmd::logs::remove() { + local name="" type="" before="" force=false + local fw_only=false wg_only=false all=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --type) type="$2"; shift 2 ;; + --before) before="$2"; shift 2 ;; + --fw) fw_only=true; shift ;; + --wg) wg_only=true; shift ;; + --all) all=true; shift ;; + --force) force=true; shift ;; + --help) cmd::logs::help; return ;; + *) + log::error "Unknown flag: $1" + return 1 + ;; + esac + done + + if ! $all && [[ -z "$name" && -z "$before" ]]; then + log::error "Specify --name, --before, or --all" + cmd::logs::help + return 1 + fi + + local filter_ip="" + if [[ -n "$name" ]]; then + name=$(peers::resolve_and_require "$name" "$type") || return 1 + filter_ip=$(peers::get_ip "$name") + fi + + local desc="" + $all && desc="all entries" + [[ -n "$name" ]] && desc="entries for '${name}'" + [[ -n "$before" ]] && desc="${desc:+$desc, }entries older than ${before} days" + $fw_only && desc="${desc} (fw only)" + $wg_only && desc="${desc} (wg only)" + + if ! $force; then + read -r -p "Remove ${desc}? [y/N] " confirm + case "$confirm" in + [yY]*) ;; + *) log::info "Aborted"; return 0 ;; + esac + fi + + local result + result=$(json::remove_events_filtered \ + "$WG_EVENTS_LOG" "$FW_EVENTS_LOG" \ + "${name:-}" "${filter_ip:-}" \ + "$fw_only" "$wg_only" \ + "${before:-}") + + local removed_wg removed_fw + IFS="|" read -r removed_wg removed_fw <<< "$result" + local total=$(( removed_wg + removed_fw )) + + if [[ "$total" -eq 0 ]]; then + log::wg_warning "No log entries found matching the criteria" + return 0 + fi + + log::wg_success "Removed ${total} log entries (wg: ${removed_wg}, fw: ${removed_fw})" +} + +function cmd::logs::rotate() { + local days=7 force=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --days) days="$2"; shift 2 ;; + --force) force=true; shift ;; + --help) cmd::logs::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + $force || { + read -r -p "Remove log entries older than ${days} days? [y/N] " confirm + case "$confirm" in + [yY]*) ;; + *) log::info "Aborted"; return 0 ;; + esac + } + + local result + result=$(json::remove_events_filtered \ + "$WG_EVENTS_LOG" "$FW_EVENTS_LOG" \ + "" "" "false" "false" "$days") + + local removed_wg removed_fw + IFS="|" read -r removed_wg removed_fw <<< "$result" + local total=$(( removed_wg + removed_fw )) + + if [[ "$total" -eq 0 ]]; then + log::wg_warning "No log entries older than ${days} days" + return 0 + fi + + log::wg_success "Rotated ${total} entries older than ${days} days (wg: ${removed_wg}, fw: ${removed_fw})" +} \ No newline at end of file diff --git a/core/app/context.sh b/core/app/context.sh index 45ff0b9..1abbf2b 100644 --- a/core/app/context.sh +++ b/core/app/context.sh @@ -76,7 +76,7 @@ function ctx::endpoint_cache() { echo "${_CTX_DAEMON}/endpoint_cache.json"; function ctx::accept_events_log() { echo "${_CTX_DAEMON}/accept_events.log"; } # Tool paths -function ctx::json_helper() { echo "${_CTX_WGCTL}/core/json_helper.py"; } +function ctx::json_helper() { echo "$(ctx::app)/json_helper.py"; } function ctx::monitor_script() { echo "${_CTX_WGCTL}/daemon/wgctl-monitor.py"; } function ctx::lib() { echo "${_CTX_WGCTL}/core/lib"; } diff --git a/core/app/json.sh b/core/app/json.sh index 457c6f1..2222a72 100644 --- a/core/app/json.sh +++ b/core/app/json.sh @@ -1,7 +1,6 @@ #!/usr/bin/env bash -JSON_HELPER="$(ctx::app)/json_helper.py" -# echo "JSON_HELPER: $JSON_HELPER" +JSON_HELPER="$(ctx::json_helper)" function json::get() { python3 "$JSON_HELPER" get "$@" "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 +# 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 +# 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//.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 +# 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 +# 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 +# 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 @@ -31,68 +291,73 @@ function command::fn() { 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() { command::has_function "$1" run; } +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 # reset values only, keep _ACTIVE_MIXINS - -# local -a args=("$@") -# command::_preprocess_flags args - -# local fn -# fn=$(command::fn "$cmd" run) -# core::call_function "$fn" ${args[@]+"${args[@]}"} -# } - function command::run() { local cmd="$1" shift - + command::_reset_mixin_state - - # Build default args from config + + # 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="" + 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=("$@") + + local -a user_args=() [[ $# -gt 0 ]] && user_args=("$@") - - # Resolve exclusive group conflicts — user args override defaults + 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 cleaned_defaults=() - for _d in "${default_args[@]:-}"; do - [[ -n "$_d" ]] && cleaned_defaults+=("$_d") - done - default_args=("${cleaned_defaults[@]:-}") - 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 - - # Preprocess mixin flags (--json, --no-color etc) + 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) - core::call_function "$fn" ${args[@]+"${args[@]}"} + if [[ ${#args[@]} -gt 0 ]]; then + core::call_function "$fn" "${args[@]}" + else + core::call_function "$fn" + fi } @@ -108,23 +373,33 @@ function core::call_function() { 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 } diff --git a/core/framework/command.sh.bak b/core/framework/command.sh.bak new file mode 100644 index 0000000..fd422aa --- /dev/null +++ b/core/framework/command.sh.bak @@ -0,0 +1,130 @@ +#!/usr/bin/env bash + +# ============================================ +# 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() { command::has_function "$1" run; } + +# ============================================ +# Runner +# ============================================ + +# function command::run() { +# local cmd="$1" +# shift + +# command::_reset_mixin_state # reset values only, keep _ACTIVE_MIXINS + +# local -a args=("$@") +# command::_preprocess_flags args + +# local fn +# fn=$(command::fn "$cmd" run) +# core::call_function "$fn" ${args[@]+"${args[@]}"} +# } + +function command::run() { + local cmd="$1" + shift + + command::_reset_mixin_state + + # Build default args from config + 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=("$@") + + # Resolve exclusive group conflicts — user args override defaults + 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 cleaned_defaults=() + for _d in "${default_args[@]:-}"; do + [[ -n "$_d" ]] && cleaned_defaults+=("$_d") + done + default_args=("${cleaned_defaults[@]:-}") + + 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 + + # Preprocess mixin flags (--json, --no-color etc) + command::_preprocess_flags args + + local fn + fn=$(command::fn "$cmd" run) + core::call_function "$fn" ${args[@]+"${args[@]}"} +} + + +function core::call_function() { + local fn="$1" + shift + "$fn" "$@" +} + +# ============================================ +# Loader +# ============================================ + +function load_command() { + local name="$1" + + command::loaded "$name" && return 0 + + 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 +} diff --git a/core/framework/flag.sh b/core/framework/flag.sh index b3ac04e..1b2bc38 100644 --- a/core/framework/flag.sh +++ b/core/framework/flag.sh @@ -1,66 +1,328 @@ #!/usr/bin/env bash +# core/framework/flag.sh +# +# Declarative flag registration and parsing framework. +# +# Registration (in on_load): +# flag::define --name value "Filter by peer name" +# flag::define --fw bool "Firewall events only" +# flag::define --limit value "Max results" [default=50, type=int, min=1] +# flag::define --exclude[] "Exclude service" [label="service"] +# flag::exclusive --fw --wg +# +# Parsing (in run): +# flag::parse "$@" || return 1 +# +# Accessors: +# flag::bool --fw → exits 0/1 +# flag::value --name → prints value or default +# flag::array --exclude → prints newline-separated values +# flag::set --name → exits 0 if explicitly passed +# flag::args → prints remaining positional args -# ============================================ -# Flag Registry -# ============================================ +# ── Storage ──────────────────────────────────────────────────────────────── -declare -A _REGISTERED_FLAGS=() +# Per-command flag registry — populated by flag::define during on_load +# Key: "cmd::subcmd:--flag" +# Value: "type|description|constraints" +declare -gA _FLAG_REGISTRY=() -# ============================================ -# Registration -# ============================================ +# Runtime values — populated by flag::parse during run +declare -gA _FLAG_VALUES=() # --flag → value (bool: "true"/"false") +declare -gA _FLAG_ARRAYS=() # --flag → newline-separated values +declare -gA _FLAG_SET=() # --flag → "1" if explicitly passed +declare -ga _FLAG_ARGS=() # remaining positional args -function flag::register() { - local flag="$1" - _REGISTERED_FLAGS["$flag"]=1 +# Defaults — populated at registration time +declare -gA _FLAG_DEFAULTS=() # "cmd::subcmd:--flag" → default value + +# ── Helpers ───────────────────────────────────────────────────────────────── + +function flag::_context() { + echo "${_CURRENT_COMMAND:-__global__}" } -function flag::registered() { - [[ -n "${_REGISTERED_FLAGS["$1"]:-}" ]] +function flag::_key() { + echo "$(flag::_context):${1}" } -# ============================================ -# Parsing -# ============================================ +function flag::_parse_constraints() { + # Parse "[key=val, key=val]" constraint string + # Output: "key=val\nkey=val" + local constraints="${1:-}" + constraints="${constraints#[}" + constraints="${constraints%]}" + echo "$constraints" | tr ',' '\n' | sed 's/^ *//' | sed 's/ *$//' +} -# Parse flags from args into associative array -# Usage: flag::parse "$@" -# Access: flag::get --name -declare -A _FLAG_VALUES=() - -function flag::parse() { - _FLAG_VALUES=() - - while [[ $# -gt 0 ]]; do - case "$1" in - --*) - local key="$1" - # Boolean flag (no value follows, or next arg is another flag) - if [[ $# -eq 1 || "$2" == --* ]]; then - _FLAG_VALUES["$key"]="true" - shift - else - _FLAG_VALUES["$key"]="$2" - shift 2 - fi - ;; - *) - shift - ;; - esac +function flag::_constraint_get() { + local constraints="$1" key="$2" + echo "$constraints" | while IFS='=' read -r k v; do + k="${k// /}" + if [[ "$k" == "$key" ]]; then + echo "${v//\"/}" + return 0 + fi done } -function flag::get() { - echo "${_FLAG_VALUES["$1"]:-}" +# ── Registration ───────────────────────────────────────────────────────────── + +# flag::define --flag [bool|value] "description" [constraints] +# flag::define --flag[] "description" [constraints] ← array flag +function flag::define() { + local raw_flag="$1" + local is_array=false + + # Detect array flag syntax: --flag[] + if [[ "$raw_flag" == *"[]" ]]; then + is_array=true + raw_flag="${raw_flag%[]}" + fi + + local type description constraints="" + + if $is_array; then + type="array" + description="${2:-}" + constraints="${3:-}" + else + type="${2:-bool}" + description="${3:-}" + constraints="${4:-}" + fi + + local key + key=$(flag::_key "$raw_flag") + + _FLAG_REGISTRY["$key"]="${type}|${description}|${constraints}" + + # Extract and store default + if [[ -n "$constraints" ]]; then + local parsed_constraints + parsed_constraints=$(flag::_parse_constraints "$constraints") + local default_val + default_val=$(flag::_constraint_get "$parsed_constraints" "default") + if [[ -n "$default_val" ]]; then + _FLAG_DEFAULTS["$key"]="$default_val" + fi + fi + + # Set bool default to false if not specified + if [[ "$type" == "bool" && -z "${_FLAG_DEFAULTS[$key]:-}" ]]; then + _FLAG_DEFAULTS["$key"]="false" + fi + + # Register for shell completion (backward compat) + flag::register "$raw_flag" 2>/dev/null || true + + help::_assign_flag_section "$raw_flag" "$constraints" } -function flag::enabled() { - [[ "${_FLAG_VALUES["$1"]:-}" == "true" ]] +# flag::register remains for backward compat and completion-only registration +# Already defined in command.sh — this is a no-op if already defined +function flag::register() { + : # handled by command.sh completion registry } +# ── Parsing ────────────────────────────────────────────────────────────────── + +function flag::parse() { + local ctx + ctx=$(flag::_context) + + # Reset runtime state + _FLAG_VALUES=() + _FLAG_ARRAYS=() + _FLAG_SET=() + _FLAG_ARGS=() + + # Initialize bools to false, values to defaults + local key + for key in "${!_FLAG_REGISTRY[@]}"; do + [[ "$key" != "${ctx}:"* ]] && continue + local flag="${key#${ctx}:}" + local reg="${_FLAG_REGISTRY[$key]}" + local type="${reg%%|*}" + local default_val="${_FLAG_DEFAULTS[$key]:-}" + + case "$type" in + bool) _FLAG_VALUES["$flag"]="${default_val:-false}" ;; + value) [[ -n "$default_val" ]] && _FLAG_VALUES["$flag"]="$default_val" ;; + array) _FLAG_ARRAYS["$flag"]="" ;; + esac + done + + # Parse args + while [[ $# -gt 0 ]]; do + local arg="$1" + + if [[ "$arg" == "--" ]]; then + shift + _FLAG_ARGS+=("$@") + break + fi + + if [[ "$arg" != --* ]]; then + _FLAG_ARGS+=("$arg") + shift + continue + fi + + local key + key=$(flag::_key "$arg") + + if [[ -z "${_FLAG_REGISTRY[$key]+x}" ]]; then + log::error "Unknown flag: ${arg}" + return 1 + fi + + local reg="${_FLAG_REGISTRY[$key]}" + local type="${reg%%|*}" + local constraints + constraints=$(echo "$reg" | cut -d'|' -f3) + + case "$type" in + bool) + _FLAG_VALUES["$arg"]="true" + _FLAG_SET["$arg"]="1" + shift + ;; + value) + if [[ $# -lt 2 || "$2" == --* ]]; then + log::error "Flag ${arg} requires a value" + return 1 + fi + local val="$2" + # Type validation + if [[ -n "$constraints" ]]; then + local parsed + parsed=$(flag::_parse_constraints "$constraints") + local vtype + vtype=$(flag::_constraint_get "$parsed" "type") + if [[ "$vtype" == "int" ]]; then + if ! [[ "$val" =~ ^[0-9]+$ ]]; then + log::error "Flag ${arg} requires an integer, got: ${val}" + return 1 + fi + local min max + min=$(flag::_constraint_get "$parsed" "min") + max=$(flag::_constraint_get "$parsed" "max") + [[ -n "$min" && "$val" -lt "$min" ]] && \ + log::error "Flag ${arg} minimum is ${min}, got: ${val}" && return 1 + [[ -n "$max" && "$val" -gt "$max" ]] && \ + log::error "Flag ${arg} maximum is ${max}, got: ${val}" && return 1 + fi + local choices + choices=$(flag::_constraint_get "$parsed" "choices") + if [[ -n "$choices" ]]; then + local valid=false + local choice + IFS='|' read -ra choice_list <<< "$choices" + for choice in "${choice_list[@]}"; do + [[ "$val" == "$choice" ]] && valid=true && break + done + if ! $valid; then + log::error "Flag ${arg} must be one of: ${choices//|/, }, got: ${val}" + return 1 + fi + fi + fi + _FLAG_VALUES["$arg"]="$val" + _FLAG_SET["$arg"]="1" + shift 2 + ;; + array) + if [[ $# -lt 2 || "$2" == --* ]]; then + log::error "Flag ${arg} requires a value" + return 1 + fi + if [[ -n "${_FLAG_ARRAYS[$arg]:-}" ]]; then + _FLAG_ARRAYS["$arg"]+=$'\n'"$2" + else + _FLAG_ARRAYS["$arg"]="$2" + fi + _FLAG_SET["$arg"]="1" + shift 2 + ;; + esac + done + + # Validate required flags + for key in "${!_FLAG_REGISTRY[@]}"; do + [[ "$key" != "${ctx}:"* ]] && continue + local flag="${key#${ctx}:}" + local reg="${_FLAG_REGISTRY[$key]}" + local constraints + constraints=$(echo "$reg" | cut -d'|' -f3) + if [[ -n "$constraints" ]]; then + local parsed + parsed=$(flag::_parse_constraints "$constraints") + local required + required=$(flag::_constraint_get "$parsed" "required") + if [[ "$required" == "true" && -z "${_FLAG_SET[$flag]+x}" ]]; then + # Check if provided by defaults + if [[ -z "${_FLAG_VALUES[$flag]:-}" ]]; then + log::error "Flag ${flag} is required" + return 1 + fi + fi + fi + done + + return 0 +} + +# ── Accessors ───────────────────────────────────────────────────────────────── + +# flag::bool --flag → exits 0 if true, 1 if false +function flag::bool() { + [[ "${_FLAG_VALUES[$1]:-false}" == "true" ]] +} + +# flag::value --flag → prints value or empty string +function flag::value() { + echo "${_FLAG_VALUES[$1]:-}" +} + +# flag::array --flag → prints newline-separated values +function flag::array() { + echo "${_FLAG_ARRAYS[$1]:-}" +} + +# flag::set --flag → exits 0 if explicitly passed by user function flag::set() { - local key="$1" - local value="$2" - _FLAG_VALUES["$key"]="$value" + [[ -n "${_FLAG_SET[$1]+x}" ]] } + +# flag::args → prints positional args array +function flag::args() { + echo "${_FLAG_ARGS[@]:-}" +} + +# flag::args_array → populates a nameref array with positional args +function flag::args_array() { + local -n _arr_ref="$1" + _arr_ref=("${_FLAG_ARGS[@]:-}") +} + +# ── Reset ───────────────────────────────────────────────────────────────────── + +function flag::_reset_runtime() { + _FLAG_VALUES=() + _FLAG_ARRAYS=() + _FLAG_SET=() + _FLAG_ARGS=() +} + +function flag::_reset_registry() { + # Clear registry entries for a specific command context + local ctx="${1:-$(flag::_context)}" + local key + for key in "${!_FLAG_REGISTRY[@]}"; do + [[ "$key" == "${ctx}:"* ]] && unset "_FLAG_REGISTRY[$key]" + done + for key in "${!_FLAG_DEFAULTS[@]}"; do + [[ "$key" == "${ctx}:"* ]] && unset "_FLAG_DEFAULTS[$key]" + done +} \ No newline at end of file diff --git a/core/framework/flag.sh.bak b/core/framework/flag.sh.bak new file mode 100644 index 0000000..b3ac04e --- /dev/null +++ b/core/framework/flag.sh.bak @@ -0,0 +1,66 @@ +#!/usr/bin/env bash + +# ============================================ +# Flag Registry +# ============================================ + +declare -A _REGISTERED_FLAGS=() + +# ============================================ +# Registration +# ============================================ + +function flag::register() { + local flag="$1" + _REGISTERED_FLAGS["$flag"]=1 +} + +function flag::registered() { + [[ -n "${_REGISTERED_FLAGS["$1"]:-}" ]] +} + +# ============================================ +# Parsing +# ============================================ + +# Parse flags from args into associative array +# Usage: flag::parse "$@" +# Access: flag::get --name +declare -A _FLAG_VALUES=() + +function flag::parse() { + _FLAG_VALUES=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --*) + local key="$1" + # Boolean flag (no value follows, or next arg is another flag) + if [[ $# -eq 1 || "$2" == --* ]]; then + _FLAG_VALUES["$key"]="true" + shift + else + _FLAG_VALUES["$key"]="$2" + shift 2 + fi + ;; + *) + shift + ;; + esac + done +} + +function flag::get() { + echo "${_FLAG_VALUES["$1"]:-}" +} + +function flag::enabled() { + [[ "${_FLAG_VALUES["$1"]:-}" == "true" ]] +} + +function flag::set() { + local key="$1" + local value="$2" + _FLAG_VALUES["$key"]="$value" +} diff --git a/core/framework/help.sh b/core/framework/help.sh index f1f641a..58f0d3f 100644 --- a/core/framework/help.sh +++ b/core/framework/help.sh @@ -1 +1,276 @@ #!/usr/bin/env bash +# core/framework/help.sh +# +# Dynamic help generation from command::define and flag::define metadata. +# +# Usage in on_load: +# help::section "Filters" +# help::section "Output" +# +# Flags assigned to sections via flag::define constraint: +# flag::define --fw bool "Firewall only" [section="Filters"] +# +# Or via active section (set before flag::define calls): +# help::section "Filters" +# command::mixin time_filter [section="Filters"] +# flag::define --fw bool "Firewall only" # inherits "Filters" +# +# Auto-generate help: +# hook::on "command:help" command::help::auto +# +# Or custom: +# hook::on "command:help" cmd::logs::help + +# ── Storage ─────────────────────────────────────────────────────────────────── + +# Section registry: "ctx:section_name" → order_index +declare -gA _HELP_SECTIONS=() +declare -gi _HELP_SECTION_COUNT=0 + +# Flag-to-section mapping: "ctx:--flag" → section_name +declare -gA _HELP_FLAG_SECTION=() + +# Current active section (set by help::section, used by subsequent flag::define) +declare -g _CURRENT_HELP_SECTION="" + +# Command descriptions: "ctx" → description +declare -gA _HELP_CMD_DESC=() + +# ── Section registration ────────────────────────────────────────────────────── + +# help::section "Section Name" +# Registers a section and sets it as active for subsequent flag::define calls +function help::section() { + local name="${1:-}" + [[ -z "$name" ]] && return 1 + + local ctx + ctx="${_CURRENT_COMMAND:-__global__}" + local key="${ctx}:${name}" + + if [[ -z "${_HELP_SECTIONS[$key]+x}" ]]; then + _HELP_SECTIONS["$key"]=$(( _HELP_SECTION_COUNT++ )) + fi + + _CURRENT_HELP_SECTION="$name" +} + +# Called by flag::define to record which section a flag belongs to +# Checks constraint [section="..."] first, falls back to _CURRENT_HELP_SECTION +function help::_assign_flag_section() { + local flag="$1" constraints="${2:-}" + local ctx="${_CURRENT_COMMAND:-__global__}" + + # Check constraint + local section="" + if [[ -n "$constraints" ]]; then + local parsed + parsed=$(flag::_parse_constraints "$constraints") + section=$(flag::_constraint_get "$parsed" "section") + fi + + # Fall back to active section + [[ -z "$section" ]] && section="${_CURRENT_HELP_SECTION:-}" + + # Register section if new + if [[ -n "$section" ]]; then + local skey="${ctx}:${section}" + if [[ -z "${_HELP_SECTIONS[$skey]+x}" ]]; then + _HELP_SECTIONS["$skey"]=$(( _HELP_SECTION_COUNT++ )) + fi + _HELP_FLAG_SECTION["${ctx}:${flag}"]="$section" + fi +} + +# ── Auto help generation ────────────────────────────────────────────────────── + +# command::help::auto +# Generates help from registered metadata. +# Called via: hook::on "command:help" command::help::auto +function command::help::auto() { + local cmd="${1:-}" subcmd="${2:-}" + local ctx="${_CURRENT_COMMAND:-__global__}" + + local desc="${_HELP_CMD_DESC[$ctx]:-}" + + # Usage line — build from required flags + local usage_parts=() + local key + for key in "${!_FLAG_REGISTRY[@]}"; do + [[ "$key" != "${ctx}:"* ]] && continue + local flag="${key#${ctx}:}" + local reg="${_FLAG_REGISTRY[$key]}" + local type="${reg%%|*}" + local constraints + constraints=$(echo "$reg" | cut -d'|' -f3) + + if [[ -n "$constraints" ]]; then + local parsed + parsed=$(flag::_parse_constraints "$constraints") + local required + required=$(flag::_constraint_get "$parsed" "required") + local label + label=$(flag::_constraint_get "$parsed" "label") + [[ -z "$label" ]] && label="value" + + if [[ "$required" == "true" ]]; then + case "$type" in + bool) usage_parts+=("$flag") ;; + value) usage_parts+=("${flag} <${label}>*") ;; + array) usage_parts+=("${flag} <${label}>*") ;; + esac + fi + fi + done + + # Print usage + local cmd_path="${cmd}" + [[ -n "$subcmd" ]] && cmd_path="${cmd} ${subcmd}" + + printf "\nUsage: wgctl %s" "$cmd_path" + for part in "${usage_parts[@]:-}"; do + printf " %s" "$part" + done + + # Show subcommands if any defined + local -a subcmds=() + for key in "${!_COMMAND_DEFS[@]}"; do + [[ "$key" == "${cmd}:"* ]] && subcmds+=("${key#${cmd}:}") + done + [[ ${#subcmds[@]} -gt 0 ]] && printf " [command]" + printf " [options]\n" + + [[ -n "$desc" ]] && printf "\n%s\n" "$desc" + + # Print subcommands + if [[ ${#subcmds[@]} -gt 0 ]]; then + printf "\nCommands:\n" + for sc in "${subcmds[@]}"; do + local sc_key="${cmd}:${sc}" + local sc_desc="${_COMMAND_DEFS[$sc_key]:-}" + local sc_type + sc_type=$(echo "$sc_desc" | cut -d'|' -f1) + local sc_text + sc_text=$(echo "$sc_desc" | cut -d'|' -f2) + local sc_aliases + sc_aliases=$(echo "$sc_desc" | cut -d'|' -f3) + local sc_default + sc_default=$(echo "$sc_desc" | cut -d'|' -f4) + + local suffix="" + [[ "$sc_default" == "true" ]] && suffix=" (default)" + [[ -n "$sc_aliases" ]] && suffix+=" aliases: ${sc_aliases}" + + printf " %-12s %s%s\n" "$sc" "$sc_text" "$suffix" + done + fi + + # Print flags by section + # Collect sections for this context, sorted by order + local -a section_names=() + local -A section_order=() + for key in "${!_HELP_SECTIONS[@]}"; do + [[ "$key" != "${ctx}:"* ]] && continue + local sname="${key#${ctx}:}" + section_names+=("$sname") + section_order["$sname"]="${_HELP_SECTIONS[$key]}" + done + + # Sort sections by registration order + local -a sorted_sections=() + if [[ ${#section_names[@]} -gt 0 ]]; then + while IFS= read -r sname; do + sorted_sections+=("$sname") + done < <( + for sname in "${section_names[@]}"; do + echo "${section_order[$sname]} $sname" + done | sort -n | cut -d' ' -f2- + ) + fi + + # Flags without a section + local -a unsectioned=() + for key in "${!_FLAG_REGISTRY[@]}"; do + [[ "$key" != "${ctx}:"* ]] && continue + local flag="${key#${ctx}:}" + [[ -z "${_HELP_FLAG_SECTION[${ctx}:${flag}]:-}" ]] && unsectioned+=("$flag") + done + + # Print unsectioned flags first as "Options" + if [[ ${#unsectioned[@]} -gt 0 ]]; then + printf "\nOptions:\n" + for flag in "${unsectioned[@]}"; do + help::_print_flag "$flag" "$ctx" + done + fi + + # Print sectioned flags + for sname in "${sorted_sections[@]:-}"; do + local printed_header=false + for key in "${!_FLAG_REGISTRY[@]}"; do + [[ "$key" != "${ctx}:"* ]] && continue + local flag="${key#${ctx}:}" + local flag_section="${_HELP_FLAG_SECTION[${ctx}:${flag}]:-}" + [[ "$flag_section" != "$sname" ]] && continue + if ! $printed_header; then + printf "\n%s:\n" "$sname" + printed_header=true + fi + help::_print_flag "$flag" "$ctx" + done + done + + printf "\n" +} + +function help::_print_flag() { + local flag="$1" ctx="$2" + local key="${ctx}:${flag}" + local reg="${_FLAG_REGISTRY[$key]:-}" + [[ -z "$reg" ]] && return 0 + + local type="${reg%%|*}" + local rest="${reg#*|}" + local desc="${rest%%|*}" + local constraints="${rest#*|}" + + local label="" required=false default_val="" choices="" + if [[ -n "$constraints" ]]; then + local parsed + parsed=$(flag::_parse_constraints "$constraints") + label=$(flag::_constraint_get "$parsed" "label") + local req + req=$(flag::_constraint_get "$parsed" "required") + [[ "$req" == "true" ]] && required=true + default_val=$(flag::_constraint_get "$parsed" "default") + choices=$(flag::_constraint_get "$parsed" "choices") + fi + [[ -z "$label" ]] && label="value" + + local flag_display="$flag" + case "$type" in + value) flag_display="${flag} <${label}>" ;; + array) flag_display="${flag} <${label}>" ;; + esac + + local suffix="" + $required && suffix=" *" + [[ -n "$default_val" ]] && suffix+=" [default: ${default_val}]" + [[ -n "$choices" ]] && suffix+=" (${choices//|/|})" + + printf " %-28s %s%s\n" "$flag_display" "$desc" "$suffix" +} + +# ── Reset ───────────────────────────────────────────────────────────────────── + +function help::_reset() { + local ctx="${_CURRENT_COMMAND:-__global__}" + local key + for key in "${!_HELP_SECTIONS[@]}"; do + [[ "$key" == "${ctx}:"* ]] && unset "_HELP_SECTIONS[$key]" + done + for key in "${!_HELP_FLAG_SECTION[@]}"; do + [[ "$key" == "${ctx}:"* ]] && unset "_HELP_FLAG_SECTION[$key]" + done + _CURRENT_HELP_SECTION="" +} \ No newline at end of file diff --git a/core/framework/hook.sh b/core/framework/hook.sh index f1f641a..0b12943 100644 --- a/core/framework/hook.sh +++ b/core/framework/hook.sh @@ -1 +1,47 @@ #!/usr/bin/env bash +# core/framework/hook.sh +# +# Simple callback registry — fire named hooks, register handlers. +# +# Usage: +# hook::on "command:help" my_handler_function +# hook::off "command:help" my_handler_function +# hook::fire "command:help" arg1 arg2 +# +# If no handler registered, hook::fire returns 0 silently. +# hook::fire returns the exit code of the handler. + +# Registry: event_name → handler_function +declare -gA _HOOK_REGISTRY=() + +# hook::on +# Register a handler for an event. Replaces existing handler. +function hook::on() { + local event="${1:-}" handler="${2:-}" + [[ -z "$event" || -z "$handler" ]] && return 1 + _HOOK_REGISTRY["$event"]="$handler" +} + +# hook::off +# Remove handler for an event. +function hook::off() { + local event="${1:-}" + [[ -z "$event" ]] && return 1 + unset "_HOOK_REGISTRY[$event]" +} + +# hook::fire [args...] +# Call registered handler with args. Silent no-op if no handler. +function hook::fire() { + local event="${1:-}" + shift || true + local handler="${_HOOK_REGISTRY[$event]:-}" + [[ -z "$handler" ]] && return 0 + "$handler" "$@" +} + +# hook::has +# Returns 0 if a handler is registered for event. +function hook::has() { + [[ -n "${_HOOK_REGISTRY[$1]:-}" ]] +} \ No newline at end of file