diff --git a/.gitignore b/.gitignore index 3ce762e..32ef871 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ dream.md # Agent process reports — local-only shared memory (not tracked) AgentsReport/ + +# Task breakdown notes — never committed +.tasks/ diff --git a/DOC/AGENT_Context_Project.md b/DOC/AGENT_Context_Project.md index 2d3d3ed..5a55347 100644 --- a/DOC/AGENT_Context_Project.md +++ b/DOC/AGENT_Context_Project.md @@ -10,19 +10,19 @@ | ## 1. Project Overview | 28–43 | -| ## 2. Directory Structure | 44–217 | -| ## 3. Installation Flow | 218–276 | -| ## 4. The `pos` CLI System | 277–365 | -| ## 5. Shared Library — `lib/common.sh` | 366–397 | -| ## 6. Docker Compose / ScaleTail | 398–440 | -| ## 7. Optional Apps (`apps/`) | 441–470 | -| ## 8. Entertainment Module | 471–484 | -| ## 9. Systemd Services | 485–496 | -| ## 10. Configuration Files | 497–523 | -| ## 11. Coding Conventions | 524–556 | -| ## 12. Development Workflow | 557–609 | -| ## 13. Key File Quick Reference | 610–693 | -| ## 14. Common Tasks for Agents | 694–727 | +| ## 2. Directory Structure | 44–218 | +| ## 3. Installation Flow | 219–277 | +| ## 4. The `pos` CLI System | 278–367 | +| ## 5. Shared Library — `lib/common.sh` | 368–399 | +| ## 6. Docker Compose / ScaleTail | 400–442 | +| ## 7. Optional Apps (`apps/`) | 443–472 | +| ## 8. Entertainment Module | 473–486 | +| ## 9. Systemd Services | 487–498 | +| ## 10. Configuration Files | 499–525 | +| ## 11. Coding Conventions | 526–558 | +| ## 12. Development Workflow | 559–611 | +| ## 13. Key File Quick Reference | 612–696 | +| ## 14. Common Tasks for Agents | 697–730 | ## 1. Project Overview @@ -117,6 +117,7 @@ Linux_post_install/ │ ├── pos-system-schedule # Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently │ ├── pos-system-uninstall # Remove pos toolkit binaries, services, shell integration, config, and data │ ├── pos-ai # AI assistant: ask, chat, sessions, capture, models, providers +│ ├── pos-bank # Persistent command bank for saving and running shell commands │ ├── pos-config # Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) │ ├── pos-tree # Show the pos CLI command tree: categories, commands, and subcommands @@ -343,6 +344,7 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst | system | schedule | `pos-system-schedule` | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently | | | | system | uninstall | `pos-system-uninstall` | Remove pos toolkit binaries, services, shell integration, config, and data | | | | | ai | `pos-ai` | AI assistant: ask, chat, sessions, capture, models, providers | | | +| | bank | `pos-bank` | Persistent command bank for saving and running shell commands | | | | | config | `pos-config` | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) | | | | | tree | `pos-tree` | Show the pos CLI command tree: categories, commands, and subcommands | | | @@ -683,9 +685,10 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `bin/pos-system-schedule` | 151 | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently | | `bin/pos-system-uninstall` | 517 | Remove pos toolkit binaries, services, shell integration, config, and data | | `bin/pos-ai` | 714 | AI assistant: ask, chat, sessions, capture, models, providers | +| `bin/pos-bank` | 313 | Persistent command bank for saving and running shell commands | | `bin/pos-config` | 80 | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) | | `bin/pos-tree` | 118 | Show the pos CLI command tree: categories, commands, and subcommands | -| `completions/pos.bash` | 317 | Dynamic bash completion | +| `completions/pos.bash` | 318 | Dynamic bash completion | | `apps/install.sh` | 171 | App install/uninstall picker/orchestrator | diff --git a/DOC/POS.md b/DOC/POS.md index f18692a..79743e1 100644 --- a/DOC/POS.md +++ b/DOC/POS.md @@ -534,6 +534,30 @@ Feature-flag management CLIs (see [SCRIPTS.md → lib/flags.sh](SCRIPTS.md#libfl | `flag-set [value]` | Set a flag, optionally with a value (requires sudo) | | `flag-clear ` | Unset a flag (requires sudo) | +### bank + +**File:** `bin/pos-bank` + +Persistent command bank for saving and running shell commands. Commands are stored in `~/.config/linux_post_install/bank.env` (pipe-delimited: `name|description|command`). Supports parameterized templates with `{param}` placeholders that are substituted at run time (quoted for safe shell evaluation). + +On a TTY with no arguments, `pos bank` opens an interactive menu (list / add / run / edit / remove). + +| Command | Purpose | +|---------|---------| +| `pos bank list` | List all saved commands | +| `pos bank add [desc] [cmd]` | Add a new command (interactive for missing args) | +| `pos bank show ` | Show command details and detected parameters | +| `pos bank run [key=val …]` | Run a command (interactive for missing params) | +| `pos bank edit ` | Edit an existing command | +| `pos bank remove ` | Remove a command | + +Example with parameters: + +``` +pos bank add convert "Convert video" "ffmpeg -i {input} -crf {quality} {output}" +pos bank run convert input=clip.mp4 quality=23 output=clip.mkv +``` + ### config **File:** `bin/pos-config` diff --git a/bin/pos b/bin/pos index 072486b..39de892 100755 --- a/bin/pos +++ b/bin/pos @@ -266,7 +266,7 @@ MAIN_LOG="$LOG_DIR/pos.log" log_cmd() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $* → exit $2" >> "$MAIN_LOG"; } # Commands that read from stdin interactively — only log invocation -INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-yt-mp4 media-sync system-backup system-uninstall share-usb-server share-smb-server share-smb-client share-nfs-client share-nfs-server communication-telegram-listener communication-matrix-listener ai ai-gemini ai-openrouter ai-llamacpp ai-alias system-alias system-schedule entertainment-config config" +INTERACTIVE_CMDS="bank docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-yt-mp4 media-sync system-backup system-uninstall share-usb-server share-smb-server share-smb-client share-nfs-client share-nfs-server communication-telegram-listener communication-matrix-listener ai ai-gemini ai-openrouter ai-llamacpp ai-alias system-alias system-schedule entertainment-config config" for ((i=n-1; i>=0; i--)); do cmd="pos" diff --git a/bin/pos-bank b/bin/pos-bank new file mode 100755 index 0000000..75cd78d --- /dev/null +++ b/bin/pos-bank @@ -0,0 +1,313 @@ +#!/usr/bin/env bash +set -euo pipefail +# POS: bank — Persistent command bank for saving and running shell commands +# POS_SUBCMDS: list add show run edit remove + +source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" +source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh" +source "$(dirname "$0")/../lib/bank-lib.sh" 2>/dev/null || source "$(dirname "$0")/bank-lib.sh" + +usage() { + cat <<'EOF' +Usage: pos bank [subcommand] [args] + +Persistent command bank for saving and running shell commands. + +Subcommands: + list List all saved commands + add [desc] [cmd] Add a new command (interactive if args missing) + show Show command details + run [key=val ...] Run a command (interactive for missing params) + edit Edit an existing command + remove Remove a command + +Interactive menu: + pos bank (no args on a terminal) + +Examples: + pos bank add disk-info "Disk usage" "df -h && du -sh /mnt/12T" + pos bank run disk-info + pos bank run convert-video input=/path/to/video.mp4 quality=23 output=/path/out.mp4 +EOF + exit 0 +} + +case "${1:-}" in + -h|--help) usage ;; +esac + +# ── Interactive command picker ─────────────────────────────────── +_pick_command() { + bank_load + local count="${#BANK_NAMES[@]}" + if [ "$count" -eq 0 ]; then + warn "Command bank is empty — add one with: pos bank add \"desc\" \"cmd\"" + return 1 + fi + local -a items=() + local i + for ((i = 0; i < count; i++)); do + if [ -n "${BANK_DESCS[$i]}" ]; then + items+=("${BANK_NAMES[$i]} — ${BANK_DESCS[$i]}") + else + items+=("${BANK_NAMES[$i]}") + fi + done + local choice + choice="$(menu_pick "Pick command" "${items[@]}")" || return 1 + echo "${BANK_NAMES[$((choice - 1))]}" +} + +# ── cmd_list ───────────────────────────────────────────────────── +cmd_list() { + bank_load + local count="${#BANK_NAMES[@]}" + if [ "$count" -eq 0 ]; then + log "Command bank is empty — add one with: pos bank add \"desc\" \"cmd\"" + return 0 + fi + echo + echo "COMMAND BANK" + echo + local i + for ((i = 0; i < count; i++)); do + if [ -n "${BANK_DESCS[$i]}" ]; then + printf ' %-25s %s\n' "${BANK_NAMES[$i]}" "${BANK_DESCS[$i]}" + else + printf ' %s\n' "${BANK_NAMES[$i]}" + fi + done + echo +} + +# ── cmd_add ────────────────────────────────────────────────────── +cmd_add() { + local name="" desc="" cmd="" + case $# in + 0) ;; + 1) name="$1" ;; + 2) name="$1"; desc="$2" ;; + *) name="$1"; desc="$2"; cmd="$3" ;; + esac + if [ -z "$name" ]; then + name="$(menu_ask_value "Name")" || return 0 + fi + if [ -z "$desc" ]; then + desc="$(menu_ask_value "Description" "")" || return 0 + fi + if [ -z "$cmd" ]; then + cmd="$(menu_read_value "Command")" || return 0 + fi + bank_valid_name "$name" || err "Invalid name: $name (use letters, numbers, hyphens, underscores)" + [ -n "$cmd" ] || err "Command cannot be empty" + bank_load + local i + for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do + [[ "${BANK_NAMES[$i]}" == "$name" ]] && err "Command '$name' already exists (use 'pos bank edit' to modify)" + done + bank_add "$name" "$desc" "$cmd" + ok "Saved: $name" +} + +# ── cmd_show ───────────────────────────────────────────────────── +cmd_show() { + [ -n "${1:-}" ] || err "Usage: pos bank show " + local name="$1" + bank_load + local entry + entry="$(bank_get "$name")" || err "Command not found: $name" + local cmd_name cmd_desc cmd_cmd + cmd_name="$(printf '%s' "$entry" | cut -f1)" + cmd_desc="$(printf '%s' "$entry" | cut -f2)" + cmd_cmd="$(printf '%s' "$entry" | cut -f3)" + echo + echo "Name: $cmd_name" + echo + echo "Description:" + if [ -n "$cmd_desc" ]; then + echo "$cmd_desc" + else + echo " (none)" + fi + echo + echo "Command:" + echo + echo "$cmd_cmd" + local params + params="$(_extract_params "$cmd_cmd")" + if [ -n "$params" ]; then + echo + echo "Parameters:" + echo "$params" | while IFS= read -r p; do + echo " $p" + done + fi + echo +} + +# ── cmd_run ────────────────────────────────────────────────────── +cmd_run() { + local name="" -a cli_params=() + if [ $# -eq 0 ]; then + name="$(_pick_command)" || return 0 + else + name="$1"; shift + while [ $# -gt 0 ]; do + case "$1" in + *=*) cli_params+=("$1") ;; + *) err "Invalid parameter format: $1 (expected key=value)" ;; + esac + shift + done + fi + bank_load + local entry + entry="$(bank_get "$name")" || err "Command not found: $name" + local cmd_name cmd_desc cmd_cmd + cmd_name="$(printf '%s' "$entry" | cut -f1)" + cmd_desc="$(printf '%s' "$entry" | cut -f2)" + cmd_cmd="$(printf '%s' "$entry" | cut -f3)" + # Detect parameters + local params_str + params_str="$(_extract_params "$cmd_cmd")" + local -a params=() + if [ -n "$params_str" ]; then + while IFS= read -r p; do + params+=("$p") + done <<< "$params_str" + fi + # Build parameter values from CLI args + local -A param_values=() + for p in "${cli_params[@]}"; do + local key="${p%%=*}" val="${p#*=}" + param_values["$key"]="$val" + done + # Interactively prompt for missing parameters + local -a missing=() + for p in "${params[@]}"; do + [[ -z "${param_values[$p]+x}" ]] && missing+=("$p") + done + if [ ${#missing[@]} -gt 0 ]; then + for p in "${missing[@]}"; do + local val + val="$(menu_ask_value "$p" "")" || return 0 + param_values["$p"]="$val" + done + fi + # Substitute parameters + local final_cmd="$cmd_cmd" + if [ ${#params[@]} -gt 0 ]; then + local -a sub_args=() + for p in "${params[@]}"; do + sub_args+=("${p}=${param_values[$p]:-}") + done + final_cmd="$(_substitute_params "$cmd_cmd" "${sub_args[@]}")" + fi + echo + log "Running: $final_cmd" + echo + eval "$final_cmd" + return $? +} + +# ── cmd_edit ───────────────────────────────────────────────────── +cmd_edit() { + local name="${1:-}" + if [ -z "$name" ]; then + name="$(_pick_command)" || return 0 + fi + bank_load + local entry + entry="$(bank_get "$name")" || err "Command not found: $name" + local old_name old_desc old_cmd + old_name="$(printf '%s' "$entry" | cut -f1)" + old_desc="$(printf '%s' "$entry" | cut -f2)" + old_cmd="$(printf '%s' "$entry" | cut -f3)" + echo + log "Editing: $old_name" + echo + local new_name new_desc new_cmd + new_name="$(menu_ask_value "New name" "$old_name")" || return 0 + new_desc="$(menu_ask_value "New description" "$old_desc")" || return 0 + new_cmd="$(menu_read_value "New command")" || return 0 + [ -z "$new_name" ] && new_name="$old_name" + [ -z "$new_desc" ] && new_desc="$old_desc" + [ -z "$new_cmd" ] && new_cmd="$old_cmd" + if [ "$new_name" != "$old_name" ]; then + bank_valid_name "$new_name" || err "Invalid name: $new_name" + local i found=0 + for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do + [[ "${BANK_NAMES[$i]}" == "$new_name" ]] && found=1 + done + [[ "$found" -eq 1 ]] && err "A command named '$new_name' already exists" + bank_remove "$old_name" + bank_add "$new_name" "$new_desc" "$new_cmd" + else + bank_update "$new_name" "$new_desc" "$new_cmd" + fi + ok "Updated: $new_name" +} + +# ── cmd_remove ─────────────────────────────────────────────────── +cmd_remove() { + local name="${1:-}" + if [ -z "$name" ]; then + name="$(_pick_command)" || return 0 + fi + bank_load + bank_find "$name" >/dev/null 2>&1 || err "Command not found: $name" + if [ -t 0 ]; then + confirm "Remove '$name'?" n || { log "Cancelled"; return 0; } + fi + bank_remove "$name" + ok "Removed: $name" +} + +# ── Interactive menu ───────────────────────────────────────────── +run_menu() { + menu_guard || exit 1 + while true; do + local choice + choice="$(menu_run "Command Bank" \ + "List commands" \ + "Add command" \ + "Run command" \ + "Edit command" \ + "Remove command")" || return 0 + case "$choice" in + 1) cmd_list ;; + 2) cmd_add ;; + 3) cmd_run ;; + 4) cmd_edit ;; + 5) cmd_remove ;; + esac + done +} + +# ── Dispatch ───────────────────────────────────────────────────── +if [ "${1:-}" = "menu" ]; then + run_menu + exit 0 +fi + +for arg in "$@"; do + case "$arg" in + -h|--help) usage ;; + esac +done + +if [ $# -eq 0 ] && [ -t 0 ]; then + run_menu + exit 0 +fi + +case "${1:-}" in + list) shift; cmd_list "$@" ;; + add) shift; cmd_add "$@" ;; + show) shift; cmd_show "$@" ;; + run) shift; cmd_run "$@" ;; + edit) shift; cmd_edit "$@" ;; + remove) shift; cmd_remove "$@" ;; + "") usage ;; + *) err "Unknown subcommand: $1 (see --help)" ;; +esac diff --git a/completions/pos.bash b/completions/pos.bash index 0160284..ffebfc6 100644 --- a/completions/pos.bash +++ b/completions/pos.bash @@ -53,6 +53,7 @@ _pos_subcmds[system-alias]="create edit remove list show" _pos_subcmds[system-backup]="menu" _pos_subcmds[system-schedule]="run list config enable disable status migrate menu" _pos_subcmds[ai]="ask chat sessions capture models providers llamacpp alias gemini hf openrouter server" +_pos_subcmds[bank]="list add show run edit remove" # GEN:END possubcmds # GEN:START posconfigscopes declare -a _pos_config_scopes=(ai compose entertainment grab matrix notify scrcpy system telegram ytsync) diff --git a/lib/bank-lib.sh b/lib/bank-lib.sh new file mode 100644 index 0000000..4bf7866 --- /dev/null +++ b/lib/bank-lib.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# lib/bank-lib.sh — shared storage helpers for the Command Bank. +# Sourced by bin/pos-bank. Uses err() from lib/common.sh. +# +# Storage: ~/.config/linux_post_install/bank.env +# Format: name|description|command +# +# Contracts: +# * Defines ONLY bank_* functions — sourcing never clobbers a tool's helpers. +# * Requires common.sh to be sourced by the CALLER. +# * NEVER exits — return codes only. +# * Performs NO interactive prompts (those stay in bin/pos-bank). + +CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}" +BANK_FILE="${BANK_FILE:-${CONFIG_DIR}/bank.env}" + +# ── Load ──────────────────────────────────────────────────────── +# Populate parallel arrays: BANK_NAMES, BANK_DESCS, BANK_CMDS. +bank_load() { + BANK_NAMES=(); BANK_DESCS=(); BANK_CMDS=() + [ -f "$BANK_FILE" ] || return 0 + + local line name desc cmd + while IFS= read -r line; do + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ -z "${line// /}" ]] && continue + IFS='|' read -r name desc cmd <<< "$line" + [[ -z "$name" ]] && continue + BANK_NAMES+=("$name") + BANK_DESCS+=("${desc:-}") + BANK_CMDS+=("${cmd:-}") + done < "$BANK_FILE" +} + +# ── Save ──────────────────────────────────────────────────────── +# Atomic overwrite from parallel arrays. +bank_save() { + local dir + dir="$(dirname "$BANK_FILE")" + mkdir -p "$dir" + local tmp + tmp="$(mktemp "${dir}/.bank.XXXXXX")" + { + printf '%s\n' "# Command Bank — managed by pos bank (do not hand-edit)" + printf '%s\n' "# Format: name|description|command" + local i + for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do + printf '%s|%s|%s\n' "${BANK_NAMES[$i]}" "${BANK_DESCS[$i]}" "${BANK_CMDS[$i]}" + done + } > "$tmp" + chmod 600 "$tmp" + mv "$tmp" "$BANK_FILE" +} + +# ── Add ───────────────────────────────────────────────────────── +bank_add() { + local name="$1" desc="$2" cmd="$3" + bank_load + local i + for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do + [[ "${BANK_NAMES[$i]}" == "$name" ]] && return 1 + done + BANK_NAMES+=("$name") + BANK_DESCS+=("$desc") + BANK_CMDS+=("$cmd") + bank_save +} + +# ── Remove ────────────────────────────────────────────────────── +bank_remove() { + local name="$1" + bank_load + local found=0 i + for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do + if [[ "${BANK_NAMES[$i]}" == "$name" ]]; then + unset 'BANK_NAMES[i]' + unset 'BANK_DESCS[i]' + unset 'BANK_CMDS[i]' + found=1 + break + fi + done + [[ "$found" -eq 1 ]] || return 1 + # Re-index arrays (unset leaves gaps) + BANK_NAMES=("${BANK_NAMES[@]}") + BANK_DESCS=("${BANK_DESCS[@]}") + BANK_CMDS=("${BANK_CMDS[@]}") + bank_save +} + +# ── Update ────────────────────────────────────────────────────── +bank_update() { + local name="$1" desc="$2" cmd="$3" + bank_load + local i + for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do + if [[ "${BANK_NAMES[$i]}" == "$name" ]]; then + BANK_DESCS[$i]="$desc" + BANK_CMDS[$i]="$cmd" + bank_save + return 0 + fi + done + return 1 +} + +# ── Find (by name) ────────────────────────────────────────────── +# Sets BANK_IDX. Returns 0 if found, 1 if not. +bank_find() { + local name="$1" + bank_load + local i + for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do + if [[ "${BANK_NAMES[$i]}" == "$name" ]]; then + BANK_IDX=$i + return 0 + fi + done + return 1 +} + +# ── Get (by name) ─────────────────────────────────────────────── +# Outputs: name\tdescription\tcommand (tab-separated). +bank_get() { + local name="$1" + bank_find "$name" || return 1 + printf '%s\t%s\t%s\n' "${BANK_NAMES[$BANK_IDX]}" "${BANK_DESCS[$BANK_IDX]}" "${BANK_CMDS[$BANK_IDX]}" +} + +# ── List names ────────────────────────────────────────────────── +bank_list_names() { + bank_load + local i + for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do + printf '%s\n' "${BANK_NAMES[$i]}" + done +} + +# ── Count ─────────────────────────────────────────────────────── +bank_count() { + bank_load + echo "${#BANK_NAMES[@]}" +} + +# ── Validate name ─────────────────────────────────────────────── +bank_valid_name() { + [[ "$1" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]] +} + +# ── Extract {param} names from a command template ─────────────── +_extract_params() { + local cmd="$1" + grep -oE '\{[a-zA-Z_][a-zA-Z0-9_]*\}' <<< "$cmd" 2>/dev/null | \ + sed 's/[{}]//g' | \ + awk '!seen[$0]++' || true +} + +# ── Substitute {param} with quoted values ─────────────────────── +# Usage: _substitute_params "cmd template" "key1=val1" "key2=val2" ... +# Uses awk to build quoted replacements safely, avoiding bash quote-nesting. +_substitute_params() { + local cmd="$1"; shift + # Build a sed expression: for each key=val, replace {key} with "val" + local sed_expr="" + while [ $# -gt 0 ]; do + local key="${1%%=*}" + local val="${1#*=}" + # Escape sed special chars in the value + val="${val//\\/\\\\}" + val="${val//\//\\/}" + sed_expr+="s/{${key}}/\"${val}\"/g; " + shift + done + [ -n "$sed_expr" ] && printf '%s' "$cmd" | sed "$sed_expr" || printf '%s' "$cmd" +} diff --git a/tests/README.md b/tests/README.md index 27022ec..3d38599 100644 --- a/tests/README.md +++ b/tests/README.md @@ -56,4 +56,5 @@ silently. | `t-install-version.sh` | install.sh version gate: match→skip, mismatch→proceed, --force bypass, dry-run variant, flag write, numeric comparison | | `t-share-mountpoint.sh` | share-client `ask_mountpoint` UX: existing/new/declined/rejected paths, confirm gate, mkdir side effects, non-TTY stdin contract, static `n`→`t` guards | | `t-pos-media-yt.sh` | unified `pos media yt` suite: dispatcher + forwarder resolution, shared yt-lib helpers, yt-mp3/mp4/grab/subtitles flags, dry-run deps, `YT_OUT_DIR` seam, `GRAB_DEFAULT` config, negative controls (unsafe-URL no-expansion, `--lang en,ar` single arg, txt timestamp-stripping) | -| `t-telegram-listener-singleton.sh` | Telegram listener single-instance guard: first `--run` acquires the flock, second `--run` fails fast with the exact message, lock auto-releases so the next start is clean, `--status` reports the lock state | \ No newline at end of file +| `t-telegram-listener-singleton.sh` | Telegram listener single-instance guard: first `--run` acquires the flock, second `--run` fails fast with the exact message, lock auto-releases so the next start is clean, `--status` reports the lock state | +| `t-bank.sh` | Command Bank: bank-lib.sh unit tests (add/remove/update/find/get/list/count/valid/extract_params/substitute_params) + pos-bank CLI integration (help, list, add, show, remove, params, invalid name) | \ No newline at end of file diff --git a/tests/bank-test-helper.sh b/tests/bank-test-helper.sh new file mode 100644 index 0000000..0f02818 --- /dev/null +++ b/tests/bank-test-helper.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# tests/bank-test-helper.sh — minimal shim that sources bank-lib.sh for unit +# testing. Provides a stub `err()` so bank-lib.sh can load without the full +# common.sh / menu-lib.sh dependency chain. +set -euo pipefail + +# Stub err() — bank-lib.sh is contractually "never exits" but bank_valid_name +# callers sometimes chain via `|| err …`. This version returns 1 so the test +# harness can assert rc. +err() { printf 'ERROR: %s\n' "$*" >&2; return 1; } + +# Source the library under test. +source "$ROOT/lib/bank-lib.sh" diff --git a/tests/t-bank.sh b/tests/t-bank.sh new file mode 100755 index 0000000..f0db244 --- /dev/null +++ b/tests/t-bank.sh @@ -0,0 +1,337 @@ +#!/usr/bin/env bash +set -euo pipefail +# t-bank.sh — Command Bank feature: bank-lib.sh unit tests + pos-bank CLI +# integration tests. +# +# Storage contract: pipe-delimited name|description|command in $BANK_FILE. +# Parameter syntax: {param} placeholders in command templates. + +run_test() { + local sandbox helper + sandbox="$(mksandbox bank)" + helper="$sandbox/helper.sh" + + # ── Build a sourced helper that gives us bank-lib functions ── + cat > "$helper" <<'HELPER' +source "$ROOT/tests/bank-test-helper.sh" +HELPER + + # ═══════════════════════════════════════════════════════════════ + # Part A: bank-lib.sh unit tests + # ═══════════════════════════════════════════════════════════════ + + # A1: bank_add + bank_load — add two entries, verify arrays + ( + BANK_FILE="$sandbox/a1.bank.env" + : > "$BANK_FILE" + source "$helper" + bank_add "deploy" "Deploy to prod" "ssh prod 'cd /app && git pull'" + bank_add "backup" "Backup database" "pg_dump mydb | gzip > /tmp/db.sql.gz" + bank_load + # Check arrays + [ "${#BANK_NAMES[@]}" -eq 2 ] || { echo "FAIL A1: expected 2 names, got ${#BANK_NAMES[@]}"; exit 1; } + [ "${BANK_NAMES[0]}" = "deploy" ] || { echo "FAIL A1: name[0] mismatch"; exit 1; } + [ "${BANK_DESCS[0]}" = "Deploy to prod" ] || { echo "FAIL A1: desc[0] mismatch"; exit 1; } + [ "${BANK_CMDS[1]}" = "pg_dump mydb | gzip > /tmp/db.sql.gz" ] || { echo "FAIL A1: cmd[1] mismatch"; exit 1; } + echo "PASS A1: bank_add + bank_load populates arrays correctly" + ) && printf ' PASS bank_add + bank_load populates arrays\n' \ + || printf ' FAIL bank_add + bank_load populates arrays\n' + + # A2: bank_add duplicate — returns rc 1 + ( + BANK_FILE="$sandbox/a2.bank.env" + printf 'old|Old cmd|echo old\n' > "$BANK_FILE" + source "$helper" + bank_add "old" "New desc" "echo new" + exit $? + ) + check_rc "bank_add duplicate returns rc 1" 1 $? + + # A3: bank_remove — remove entry, verify gone + ( + BANK_FILE="$sandbox/a3.bank.env" + printf 'aaa|desc a|cmd a\nbbb|desc b|cmd b\nccc|desc c|cmd c\n' > "$BANK_FILE" + source "$helper" + bank_remove "bbb" + bank_load + [ "${#BANK_NAMES[@]}" -eq 2 ] || { echo "FAIL A3: expected 2, got ${#BANK_NAMES[@]}"; exit 1; } + [ "${BANK_NAMES[0]}" = "aaa" ] || { echo "FAIL A3: name[0] mismatch"; exit 1; } + [ "${BANK_NAMES[1]}" = "ccc" ] || { echo "FAIL A3: name[1] mismatch"; exit 1; } + # Verify file on disk + grep -q '^bbb|' "$BANK_FILE" && { echo "FAIL A3: bbb still in file"; exit 1; } + echo "PASS A3: bank_remove" + ) && printf ' PASS bank_remove removes entry\n' \ + || printf ' FAIL bank_remove removes entry\n' + + # A4: bank_remove missing — returns rc 1 + ( + BANK_FILE="$sandbox/a4.bank.env" + printf 'xxx|desc|cmd\n' > "$BANK_FILE" + source "$helper" + bank_remove "nonexistent" + exit $? + ) + check_rc "bank_remove missing returns rc 1" 1 $? + + # A5: bank_update — update entry, verify changed + ( + BANK_FILE="$sandbox/a5.bank.env" + printf 'mycmd|old desc|old cmd\n' > "$BANK_FILE" + source "$helper" + bank_update "mycmd" "new desc" "new cmd" + bank_load + [ "${BANK_DESCS[0]}" = "new desc" ] || { echo "FAIL A5: desc mismatch"; exit 1; } + [ "${BANK_CMDS[0]}" = "new cmd" ] || { echo "FAIL A5: cmd mismatch"; exit 1; } + # Verify on disk + grep -q 'new desc|new cmd' "$BANK_FILE" || { echo "FAIL A5: not persisted"; exit 1; } + echo "PASS A5: bank_update" + ) && printf ' PASS bank_update updates entry\n' \ + || printf ' FAIL bank_update updates entry\n' + + # A6: bank_update missing — returns rc 1 + ( + BANK_FILE="$sandbox/a6.bank.env" + printf 'exists|desc|cmd\n' > "$BANK_FILE" + source "$helper" + bank_update "nope" "desc" "cmd" + exit $? + ) + check_rc "bank_update missing returns rc 1" 1 $? + + # A7: bank_find — found sets BANK_IDX, not-found returns 1 + ( + BANK_FILE="$sandbox/a7.bank.env" + printf 'alpha|d1|c1\nbeta|d2|c2\ngamma|d3|c3\n' > "$BANK_FILE" + source "$helper" + bank_find "beta" + [ "${BANK_IDX:-}" = "1" ] || { echo "FAIL A7: expected BANK_IDX=1, got ${BANK_IDX:-unset}"; exit 1; } + bank_find "nonexistent" && { echo "FAIL A7: should return 1 for missing"; exit 1; } + echo "PASS A7: bank_find" + ) && printf ' PASS bank_find sets BANK_IDX / returns 1\n' \ + || printf ' FAIL bank_find sets BANK_IDX / returns 1\n' + + # A8: bank_get — returns tab-separated output + ( + BANK_FILE="$sandbox/a8.bank.env" + printf 'mytool|My Tool|echo hello\n' > "$BANK_FILE" + source "$helper" + local out + out="$(bank_get "mytool")" + [ "$out" = "mytool My Tool echo hello" ] || { echo "FAIL A8: unexpected output [$out]"; exit 1; } + echo "PASS A8: bank_get" + ) && printf ' PASS bank_get returns tab-separated output\n' \ + || printf ' FAIL bank_get returns tab-separated output\n' + + # A9: bank_list_names — lists all names + ( + BANK_FILE="$sandbox/a9.bank.env" + printf 'one|x|y\ntwo|a|b\nthree|c|d\n' > "$BANK_FILE" + source "$helper" + local names + names="$(bank_list_names)" + local expected + expected="$(printf 'one\ntwo\nthree')" + [ "$names" = "$expected" ] || { echo "FAIL A9: expected [$expected] got [$names]"; exit 1; } + echo "PASS A9: bank_list_names" + ) && printf ' PASS bank_list_names lists all names\n' \ + || printf ' FAIL bank_list_names lists all names\n' + + # A10: bank_count — returns correct count + ( + BANK_FILE="$sandbox/a10.bank.env" + printf 'a|1|2\nb|3|4\nc|5|6\nd|7|8\n' > "$BANK_FILE" + source "$helper" + local cnt + cnt="$(bank_count)" + [ "$cnt" -eq 4 ] || { echo "FAIL A10: expected 4, got $cnt"; exit 1; } + echo "PASS A10: bank_count" + ) && printf ' PASS bank_count returns correct count\n' \ + || printf ' FAIL bank_count returns correct count\n' + + # A11: bank_count — empty file returns 0 + ( + BANK_FILE="$sandbox/a11.bank.env" + : > "$BANK_FILE" + source "$helper" + local cnt + cnt="$(bank_count)" + [ "$cnt" -eq 0 ] || { echo "FAIL A11: expected 0, got $cnt"; exit 1; } + echo "PASS A11: bank_count empty" + ) && printf ' PASS bank_count returns 0 for empty file\n' \ + || printf ' FAIL bank_count returns 0 for empty file\n' + + # A12: bank_valid_name — valid names pass + ( + source "$helper" + local fail=0 + for n in "deploy" "my-cmd" "test_123" "A" "z9"; do + bank_valid_name "$n" || { fail=1; printf 'FAIL A12: %s should be valid\n' "$n" >&2; } + done + [ "$fail" -eq 0 ] || exit 1 + echo "PASS A12: bank_valid_name accepts valid names" + ) && printf ' PASS bank_valid_name accepts valid names\n' \ + || printf ' FAIL bank_valid_name accepts valid names\n' + + # A13: bank_valid_name — invalid names fail + ( + source "$helper" + local fail=0 + for n in "" "1abc" "-start" "_under" "has space" "special!"; do + bank_valid_name "$n" && { fail=1; printf 'FAIL A13: %s should be invalid\n' "$n" >&2; } + done + [ "$fail" -eq 0 ] || exit 1 + echo "PASS A13: bank_valid_name rejects invalid names" + ) && printf ' PASS bank_valid_name rejects invalid names\n' \ + || printf ' FAIL bank_valid_name rejects invalid names\n' + + # A14: _extract_params — extracts {param} names from template + ( + source "$helper" + local params + params="$(_extract_params "echo {name} {path}")" + local expected + expected="$(printf 'name\npath')" + [ "$params" = "$expected" ] || { echo "FAIL A14: expected [$expected] got [$params]"; exit 1; } + echo "PASS A14: _extract_params" + ) && printf ' PASS _extract_params extracts param names\n' \ + || printf ' FAIL _extract_params extracts param names\n' + + # A15: _extract_params — no params returns empty + ( + source "$helper" + local params + params="$(_extract_params "echo hello world")" + [ -z "$params" ] || { echo "FAIL A15: expected empty, got [$params]"; exit 1; } + echo "PASS A15: _extract_params empty" + ) && printf ' PASS _extract_params returns empty for no params\n' \ + || printf ' FAIL _extract_params returns empty for no params\n' + + # A16: _extract_params — deduplicates repeated params + ( + source "$helper" + local params + params="$(_extract_params "cp {src} {dst} && ls {src}")" + local count + count="$(printf '%s' "$params" | grep -c '^src$' || true)" + [ "$count" -eq 1 ] || { echo "FAIL A16: expected 1 occurrence of src, got $count"; exit 1; } + echo "PASS A16: _extract_params deduplicates" + ) && printf ' PASS _extract_params deduplicates\n' \ + || printf ' FAIL _extract_params deduplicates\n' + + # A17: _substitute_params — replaces {param} with quoted values + ( + source "$helper" + local result + result="$(_substitute_params "scp {file} user@host:{dest}" "file=/tmp/data.csv" "dest=/var/data")" + local expected='scp "/tmp/data.csv" user@host:"/var/data"' + [ "$result" = "$expected" ] || { echo "FAIL A17: expected [$expected] got [$result]"; exit 1; } + echo "PASS A17: _substitute_params" + ) && printf ' PASS _substitute_params replaces params\n' \ + || printf ' FAIL _substitute_params replaces params\n' + + # A18: bank_load — comments and blank lines are skipped + ( + BANK_FILE="$sandbox/a18.bank.env" + cat > "$BANK_FILE" <<'BANK' +# Command Bank — managed by pos bank (do not hand-edit) +# Format: name|description|command + +real|Real command|echo real +# another comment + + # indented comment + +BANK + source "$helper" + bank_load + [ "${#BANK_NAMES[@]}" -eq 1 ] || { echo "FAIL A18: expected 1, got ${#BANK_NAMES[@]}"; exit 1; } + [ "${BANK_NAMES[0]}" = "real" ] || { echo "FAIL A18: name mismatch"; exit 1; } + echo "PASS A18: bank_load skips comments and blanks" + ) && printf ' PASS bank_load skips comments and blank lines\n' \ + || printf ' FAIL bank_load skips comments and blank lines\n' + + # ═══════════════════════════════════════════════════════════════ + # Part B: pos-bank CLI integration tests + # ═══════════════════════════════════════════════════════════════ + + local pos_bank="$ROOT/bin/pos-bank" + + # B1: pos bank --help shows usage + test_run env BANK_FILE="$sandbox/b1.bank.env" "$pos_bank" --help + check_rc "pos bank --help exits 0" 0 "$TR_RC" + check_contains "pos bank --help shows Usage" "Usage:" "$TR_OUT" + check_contains "pos bank --help mentions subcommands" "Subcommands:" "$TR_OUT" + + # B2: pos bank list on empty bank shows empty message + : > "$sandbox/b2.bank.env" + test_run env BANK_FILE="$sandbox/b2.bank.env" "$pos_bank" list + check_rc "pos bank list empty exits 0" 0 "$TR_RC" + check_contains "pos bank list empty message" "Command bank is empty" "$TR_OUT" + + # B3: pos bank add — adds a command, verify in bank.env + : > "$sandbox/b3.bank.env" + test_run env BANK_FILE="$sandbox/b3.bank.env" "$pos_bank" add "disk-usage" "Check disk usage" "df -h" + check_rc "pos bank add exits 0" 0 "$TR_RC" + check_contains "pos bank add confirms" "Saved: disk-usage" "$TR_OUT" + # Verify the file + if grep -q '^disk-usage|Check disk usage|df -h$' "$sandbox/b3.bank.env"; then + printf ' PASS pos bank add persists to bank.env\n' + else + printf ' FAIL pos bank add did not persist to bank.env\n' + fi + + # B4: pos bank add duplicate — fails + test_run env BANK_FILE="$sandbox/b3.bank.env" "$pos_bank" add "disk-usage" "dup" "echo dup" + check_not_contains "pos bank add duplicate errors" "0" "$TR_RC" + check_contains "pos bank add duplicate message" "already exists" "$TR_OUT" + + # B5: pos bank show — shows command details + test_run env BANK_FILE="$sandbox/b3.bank.env" "$pos_bank" show "disk-usage" + check_rc "pos bank show exits 0" 0 "$TR_RC" + check_contains "pos bank show shows name" "Name: disk-usage" "$TR_OUT" + check_contains "pos bank show shows description" "Check disk usage" "$TR_OUT" + check_contains "pos bank show shows command" "df -h" "$TR_OUT" + + # B6: pos bank show missing — fails + test_run env BANK_FILE="$sandbox/b3.bank.env" "$pos_bank" show "nonexistent" + check_not_contains "pos bank show missing exits non-zero" "0" "$TR_RC" + check_contains "pos bank show missing message" "not found" "$TR_OUT" + + # B7: pos bank list after add — shows the entry + test_run env BANK_FILE="$sandbox/b3.bank.env" "$pos_bank" list + check_rc "pos bank list populated exits 0" 0 "$TR_RC" + check_contains "pos bank list shows header" "COMMAND BANK" "$TR_OUT" + check_contains "pos bank list shows entry" "disk-usage" "$TR_OUT" + + # B8: pos bank remove — removes a command + test_run env BANK_FILE="$sandbox/b3.bank.env" "$pos_bank" remove "disk-usage" + check_rc "pos bank remove exits 0" 0 "$TR_RC" + check_contains "pos bank remove confirms" "Removed: disk-usage" "$TR_OUT" + # Verify gone + if grep -q '^disk-usage|' "$sandbox/b3.bank.env"; then + printf ' FAIL pos bank remove did not delete from bank.env\n' + else + printf ' PASS pos bank remove deletes from bank.env\n' + fi + + # B9: pos bank remove missing — fails + : > "$sandbox/b9.bank.env" + test_run env BANK_FILE="$sandbox/b9.bank.env" "$pos_bank" remove "ghost" + check_not_contains "pos bank remove missing exits non-zero" "0" "$TR_RC" + check_contains "pos bank remove missing message" "not found" "$TR_OUT" + + # B10: pos bank add with parameters — show lists them + : > "$sandbox/b10.bank.env" + test_run env BANK_FILE="$sandbox/b10.bank.env" "$pos_bank" add "convert" "Convert video" "ffmpeg -i {input} -q:v {quality} {output}" + check_rc "pos bank add with params exits 0" 0 "$TR_RC" + test_run env BANK_FILE="$sandbox/b10.bank.env" "$pos_bank" show "convert" + check_contains "show lists input param" "input" "$TR_OUT" + check_contains "show lists quality param" "quality" "$TR_OUT" + check_contains "show lists output param" "output" "$TR_OUT" + + # B11: pos bank add invalid name — fails + : > "$sandbox/b11.bank.env" + test_run env BANK_FILE="$sandbox/b11.bank.env" "$pos_bank" add "1bad" "desc" "cmd" + check_not_contains "pos bank add invalid name exits non-zero" "0" "$TR_RC" + check_contains "pos bank add invalid name message" "Invalid name" "$TR_OUT" +}