From e5273a482d42b9fa49a1c6ae8d17dba1d90b1228 Mon Sep 17 00:00:00 2001 From: librelad Date: Wed, 27 May 2026 14:29:30 +0100 Subject: [PATCH] feat(cli): route `app install` through the task processor + live follower MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spike — closes the gap where the CLI install bypassed the very task system the WebUI uses. Now both surfaces hit the same path: user types `libreportal app install dashy` → CLI enqueues a task file in $TASK_DIR (identical shape to the WebUI's createTaskFile) → pokes $TASK_DIR/.queue.fifo so the processor dispatches in <100ms instead of waiting up to IDLE_POLL_SECS → CLI tails the task log + polls .status, exits with the task's exit_code on terminal state → Ctrl-C detaches the follower without killing the task — the WebUI's tasks panel keeps showing it Bypass: the recursive command in the task file is prefixed `LIBREPORTAL_TASK_EXEC=1 libreportal app install `. The install branch in cli_app_commands.sh honours that env var by running inline, which is what the processor's eval invocation hits. No processor changes — the bypass travels with the task. Wins: - one log file per install, shared by CLI + WebUI (audit trail + replay) - locking serialises CLI + WebUI installs (no more two-frontend race) - WebUI's "current task" indicator now reflects CLI work too - free `--detach` for fire-and-forget queueing New: scripts/cli/task/cli_task_run.sh cliTaskRun [type] [app] [--detach] Enqueues + follows; --detach prints the task id and exits 0. cliTaskFollow `tail -F` the log + jq-poll the status; returns the task's exit_code. Designed to be reused for `libreportal task log ` reattach later. Trade-off: ~200-500ms latency before the first byte (write task file, processor wakes, opens log, follower starts tailing). Negligible for install/update/backup — fast commands (list/status/config get) still run inline. The current branch only changes `app install`; uninstall + update + backup can be moved on the same pattern once this lands clean. Signed-off-by: librelad --- scripts/cli/commands/app/cli_app_commands.sh | 26 ++- scripts/cli/task/cli_task_run.sh | 175 ++++++++++++++++++ scripts/source/files/arrays/files_cli.sh | 1 + .../source/files/arrays/function_manifest.sh | 15 ++ 4 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 scripts/cli/task/cli_task_run.sh diff --git a/scripts/cli/commands/app/cli_app_commands.sh b/scripts/cli/commands/app/cli_app_commands.sh index be97b97..953db78 100755 --- a/scripts/cli/commands/app/cli_app_commands.sh +++ b/scripts/cli/commands/app/cli_app_commands.sh @@ -37,7 +37,31 @@ cliHandleAppCommands() ;; "install") - dockerInstallApp "$app_name" "$config" "$reset_network" + # Two paths in / out: + # * The task processor's recursive invocation carries + # LIBREPORTAL_TASK_EXEC=1 — run the install inline so it + # actually does the work (otherwise we'd loop forever + # re-enqueueing). + # * The user typed `libreportal app install dashy` — enqueue + # a task and follow its log in the foreground, so the + # CLI + WebUI watch the same job and locking serialises + # parallel attempts. `--detach` queues and exits. + if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then + dockerInstallApp "$app_name" "$config" "$reset_network" + else + local _mode="" + for _arg in "$config" "$initial_command5" "$initial_command6"; do + [[ "$_arg" == "--detach" ]] && _mode="--detach" + done + # config / --reset-network passthrough — strip the flags + # that are CLI-only, keep what dockerInstallApp expects. + local _passthrough_config="$config" + [[ "$_passthrough_config" == "--detach" || "$_passthrough_config" == "--reset-network" ]] && _passthrough_config="" + local _cmd="LIBREPORTAL_TASK_EXEC=1 libreportal app install $app_name" + [[ -n "$_passthrough_config" ]] && _cmd+=" '$_passthrough_config'" + [[ "$reset_network" == "true" ]] && _cmd+=" --reset-network" + cliTaskRun "$_cmd" "install" "$app_name" "$_mode" + fi ;; "uninstall") diff --git a/scripts/cli/task/cli_task_run.sh b/scripts/cli/task/cli_task_run.sh new file mode 100644 index 0000000..1284a56 --- /dev/null +++ b/scripts/cli/task/cli_task_run.sh @@ -0,0 +1,175 @@ +#!/bin/bash + +# Routes a CLI command through the existing libreportal task processor and +# follows its log in the foreground, so the terminal sees the same output +# the WebUI sees and Ctrl-C detaches cleanly without killing the task. +# +# Usage: +# cliTaskRun "libreportal app install dashy" install dashy +# cliTaskRun "libreportal app install dashy" install dashy --detach +# +# Sets up a task file that's identical to what the WebUI's "install" button +# produces — same format, same task dir, same processor. The CLI just adds +# itself as a viewer. +# +# How the command bypass works: the recursive command in the task file is +# prefixed with `LIBREPORTAL_TASK_EXEC=1`, and the caller (the app install +# handler) honours that by running inline instead of re-enqueueing. No +# processor changes required — the bypass travels with the task. + +# Resolve task dir, FIFO, the manifest of tasks dir/log paths. Mirrors what +# crontab_task_processor.sh and webui_task_create.sh use; if those move, both +# this file and they have to update together. +_taskDir() { + echo "${containers_dir}libreportal/frontend/data/tasks" +} + +_genTaskId() { + local rand + rand=$(openssl rand -hex 4 2>/dev/null || printf '%08x' $RANDOM) + echo "task_$(date +%s)_${rand}" +} + +# Write the task file + append to queue.json. Identical shape to +# createTaskFile in webui_task_create.sh, but inlined here so the CLI doesn't +# have to source the WebUI generators when LP_LAZY=1 hasn't pulled them in. +_writeTaskFile() { + local task_id="$1" command="$2" task_type="$3" app_name="$4" + local task_dir; task_dir=$(_taskDir) + local task_file="$task_dir/${task_id}.json" + local created_at; created_at=$(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S') + + [[ -d "$task_dir" ]] || runFileOp mkdir -p "$task_dir" + + local task_json + if command -v jq >/dev/null 2>&1; then + task_json=$(jq -n \ + --arg id "$task_id" \ + --arg command "$command" \ + --arg status queued \ + --arg created_at "$created_at" \ + --arg type "${task_type:-}" \ + --arg app "${app_name:-}" \ + '{id:$id, command:$command, status:$status, created_at:$created_at} + + (if $type != "" then {type:$type} else {} end) + + (if $app != "" then {app:$app} else {} end)') + else + # Plain string interpolation — command is the only field that needs + # escaping. Strip control chars; the install dispatcher uses ascii + # only so this is sufficient without pulling jq in. + local esc="${command//\\/\\\\}"; esc="${esc//\"/\\\"}" + task_json="{\"id\":\"$task_id\",\"command\":\"$esc\",\"status\":\"queued\",\"created_at\":\"$created_at\"" + [[ -n "$task_type" ]] && task_json+=",\"type\":\"$task_type\"" + [[ -n "$app_name" ]] && task_json+=",\"app\":\"$app_name\"" + task_json+="}" + fi + + printf '%s' "$task_json" | runFileWrite "$task_file" + runFileOp chmod 644 "$task_file" 2>/dev/null + + local queue="$task_dir/queue.json" + [[ -f "$queue" ]] || printf '[]' | runFileWrite "$queue" + if command -v jq >/dev/null 2>&1; then + local updated; updated=$(jq --arg id "$task_id" '. + [$id]' "$queue" 2>/dev/null || echo "[\"$task_id\"]") + printf '%s' "$updated" | runFileWrite "$queue" + else + # Best-effort fallback. + local cur; cur=$(cat "$queue" 2>/dev/null) + if [[ "$cur" == "[]" || -z "$cur" ]]; then + printf '["%s"]' "$task_id" | runFileWrite "$queue" + else + printf '%s' "${cur%]}, \"$task_id\"]" | runFileWrite "$queue" + fi + fi + + # Poke the FIFO so the processor dispatches immediately instead of + # waiting up to IDLE_POLL_SECS (3s) for its next read timeout. + local fifo="$task_dir/.queue.fifo" + [[ -p "$fifo" ]] && printf '%s\n' "$task_id" >> "$fifo" 2>/dev/null || true +} + +# Follow a task's log + status until terminal state. +# Returns the task's exit_code (0 on completed, non-zero on failed/cancelled). +# Ctrl-C from the user detaches the follower without cancelling the task. +cliTaskFollow() { + local task_id="$1" + local task_dir; task_dir=$(_taskDir) + local task_file="$task_dir/${task_id}.json" + local log_file="$task_dir/${task_id}.log" + + [[ -f "$task_file" ]] || { isError "Task $task_id not found."; return 1; } + + # Ctrl-C detaches us, NOT the task. Print a hint and exit 0 — the + # processor keeps running in the background and the WebUI continues + # to show the task. `libreportal task log $task_id` (future) can + # reattach. + local detached=0 + trap 'detached=1' INT + + # tail -F (capital): retry-on-error, so we can start tailing before + # the log file exists. Processor creates it just before exec. + tail -n +1 -F "$log_file" 2>/dev/null & + local tail_pid=$! + + # Poll the task file until status hits a terminal state OR the user + # detaches. 0.5s polling — bash + jq is cheap and the UX feels live. + local status="queued" exit_code=0 + while (( detached == 0 )); do + if command -v jq >/dev/null 2>&1; then + status=$(jq -r '.status // "queued"' "$task_file" 2>/dev/null) + else + status=$(grep -oE '"status"[[:space:]]*:[[:space:]]*"[^"]+"' "$task_file" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/') + fi + case "$status" in + completed|failed|cancelled) break ;; + esac + sleep 0.5 + done + trap - INT + + # Let tail flush the final output buffered behind the log writer, then + # kill it cleanly. + sleep 0.3 + kill "$tail_pid" 2>/dev/null + wait "$tail_pid" 2>/dev/null + + if (( detached )); then + echo "" + isNotice "Detached from $task_id. Task continues in the background; the WebUI tasks panel will show its progress." + return 0 + fi + + if command -v jq >/dev/null 2>&1; then + exit_code=$(jq -r '.exit_code // 0' "$task_file" 2>/dev/null) + fi + case "$status" in + completed) return "${exit_code:-0}" ;; + failed) return "${exit_code:-1}" ;; + cancelled) return 130 ;; + esac + return 0 +} + +# Enqueue a command and follow until terminal state (or --detach). +# Args: command_string task_type app_name [--detach] +cliTaskRun() { + local command="$1" task_type="$2" app_name="$3" mode="$4" + + if [[ -z "$command" ]]; then + isError "cliTaskRun: empty command." + return 1 + fi + + local task_id; task_id=$(_genTaskId) + _writeTaskFile "$task_id" "$command" "$task_type" "$app_name" + + if [[ "$mode" == "--detach" ]]; then + isSuccessful "Task queued: $task_id" + isNotice "Detached — track progress in the WebUI tasks panel or via 'libreportal task log $task_id' (future)." + return 0 + fi + + isNotice "Queued task $task_id ($task_type${app_name:+ $app_name}). Following live output — Ctrl-C detaches without cancelling." + echo "" + cliTaskFollow "$task_id" +} diff --git a/scripts/source/files/arrays/files_cli.sh b/scripts/source/files/arrays/files_cli.sh index 5bfc977..9a5ecd5 100755 --- a/scripts/source/files/arrays/files_cli.sh +++ b/scripts/source/files/arrays/files_cli.sh @@ -46,5 +46,6 @@ cli_scripts=( "cli/commands/validation/cli_validation_header.sh" "cli/commands/webui/cli_webui_commands.sh" "cli/commands/webui/cli_webui_header.sh" + "cli/task/cli_task_run.sh" ) diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 74251f3..dccb02c 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -286,6 +286,8 @@ declare -gA LP_FN_MAP=( [cliShowUpdateHelp]="cli/commands/update/cli_update_header.sh" [cliShowValidationHelp]="cli/commands/validation/cli_validation_header.sh" [cliShowWebuiHelp]="cli/commands/webui/cli_webui_header.sh" + [cliTaskFollow]="cli/task/cli_task_run.sh" + [cliTaskRun]="cli/task/cli_task_run.sh" [cliUpdateCommands]="cli/cli_update.sh" [cliWebuiLoginReset]="cli/commands/webui/cli_webui_commands.sh" [completeMessage]="menu/message/complete.sh" @@ -422,6 +424,7 @@ declare -gA LP_FN_MAP=( [generateInstallName]="checks/generate_install_name.sh" [generateRandomPassword]="config/password/password_generate.sh" [generateRandomUsername]="config/password/password_user_generator.sh" + [_genTaskId]="cli/task/cli_task_run.sh" [getConfigOptionData]="config/core/config_get_config_data.sh" [getLibrePortalWebUIUrls]="webui/webui_display_logins.sh" [getStoredPassword]="config/password/bcrypt/password_retreive_bcrypt.sh" @@ -797,6 +800,7 @@ declare -gA LP_FN_MAP=( [tagsProcessorTraefikControl]="config/tags/processors/tags_processor_traefik_control.sh" [tagsProcessorTrustedDomains]="config/tags/processors/tags_processor_trusted_domains.sh" [tailscaleInstallToContainer]="headscale/scripts/tailscale_install.sh" + [_taskDir]="cli/task/cli_task_run.sh" [toolArgsGet]="docker/app/functions/function_app_tool.sh" [toolsMenu]="menu/tools/manage_main.sh" [traefik_install_post_compose]="traefik/scripts/traefik_install_hooks.sh" @@ -889,6 +893,7 @@ declare -gA LP_FN_MAP=( [wireguard_install_post_start]="wireguard/scripts/wireguard_install_hooks.sh" [wireguard_install_pre]="wireguard/scripts/wireguard_install_hooks.sh" [writeAtomic]="crontab/task/crontab_task_processor.sh" + [_writeTaskFile]="cli/task/cli_task_run.sh" [zipFile]="function/file/zip_file.sh" ) @@ -1170,6 +1175,8 @@ declare -gA LP_FN_ROOT=( [cliShowUpdateHelp]="scripts" [cliShowValidationHelp]="scripts" [cliShowWebuiHelp]="scripts" + [cliTaskFollow]="scripts" + [cliTaskRun]="scripts" [cliUpdateCommands]="scripts" [cliWebuiLoginReset]="scripts" [completeMessage]="scripts" @@ -1306,6 +1313,7 @@ declare -gA LP_FN_ROOT=( [generateInstallName]="scripts" [generateRandomPassword]="scripts" [generateRandomUsername]="scripts" + [_genTaskId]="scripts" [getConfigOptionData]="scripts" [getLibrePortalWebUIUrls]="scripts" [getStoredPassword]="scripts" @@ -1681,6 +1689,7 @@ declare -gA LP_FN_ROOT=( [tagsProcessorTraefikControl]="scripts" [tagsProcessorTrustedDomains]="scripts" [tailscaleInstallToContainer]="containers" + [_taskDir]="scripts" [toolArgsGet]="scripts" [toolsMenu]="scripts" [traefik_install_post_compose]="containers" @@ -1773,6 +1782,7 @@ declare -gA LP_FN_ROOT=( [wireguard_install_post_start]="containers" [wireguard_install_pre]="containers" [writeAtomic]="scripts" + [_writeTaskFile]="scripts" [zipFile]="scripts" ) @@ -2074,6 +2084,8 @@ cliShowSystemHelp() { source "${install_scripts_dir}cli/commands/system/cli_syst cliShowUpdateHelp() { source "${install_scripts_dir}cli/commands/update/cli_update_header.sh"; cliShowUpdateHelp "$@"; } cliShowValidationHelp() { source "${install_scripts_dir}cli/commands/validation/cli_validation_header.sh"; cliShowValidationHelp "$@"; } cliShowWebuiHelp() { source "${install_scripts_dir}cli/commands/webui/cli_webui_header.sh"; cliShowWebuiHelp "$@"; } +cliTaskFollow() { source "${install_scripts_dir}cli/task/cli_task_run.sh"; cliTaskFollow "$@"; } +cliTaskRun() { source "${install_scripts_dir}cli/task/cli_task_run.sh"; cliTaskRun "$@"; } cliUpdateCommands() { source "${install_scripts_dir}cli/cli_update.sh"; cliUpdateCommands "$@"; } cliWebuiLoginReset() { source "${install_scripts_dir}cli/commands/webui/cli_webui_commands.sh"; cliWebuiLoginReset "$@"; } completeMessage() { source "${install_scripts_dir}menu/message/complete.sh"; completeMessage "$@"; } @@ -2210,6 +2222,7 @@ generateHealthReport() { source "${install_scripts_dir}crontab/task/crontab_chec generateInstallName() { source "${install_scripts_dir}checks/generate_install_name.sh"; generateInstallName "$@"; } generateRandomPassword() { source "${install_scripts_dir}config/password/password_generate.sh"; generateRandomPassword "$@"; } generateRandomUsername() { source "${install_scripts_dir}config/password/password_user_generator.sh"; generateRandomUsername "$@"; } +_genTaskId() { source "${install_scripts_dir}cli/task/cli_task_run.sh"; _genTaskId "$@"; } getConfigOptionData() { source "${install_scripts_dir}config/core/config_get_config_data.sh"; getConfigOptionData "$@"; } getLibrePortalWebUIUrls() { source "${install_scripts_dir}webui/webui_display_logins.sh"; getLibrePortalWebUIUrls "$@"; } getStoredPassword() { source "${install_scripts_dir}config/password/bcrypt/password_retreive_bcrypt.sh"; getStoredPassword "$@"; } @@ -2585,6 +2598,7 @@ tagsProcessorSpeedtestPass() { source "${install_scripts_dir}config/tags/process tagsProcessorTraefikControl() { source "${install_scripts_dir}config/tags/processors/tags_processor_traefik_control.sh"; tagsProcessorTraefikControl "$@"; } tagsProcessorTrustedDomains() { source "${install_scripts_dir}config/tags/processors/tags_processor_trusted_domains.sh"; tagsProcessorTrustedDomains "$@"; } tailscaleInstallToContainer() { source "${install_containers_dir}headscale/scripts/tailscale_install.sh"; tailscaleInstallToContainer "$@"; } +_taskDir() { source "${install_scripts_dir}cli/task/cli_task_run.sh"; _taskDir "$@"; } toolArgsGet() { source "${install_scripts_dir}docker/app/functions/function_app_tool.sh"; toolArgsGet "$@"; } toolsMenu() { source "${install_scripts_dir}menu/tools/manage_main.sh"; toolsMenu "$@"; } traefik_install_post_compose() { source "${install_containers_dir}traefik/scripts/traefik_install_hooks.sh"; traefik_install_post_compose "$@"; } @@ -2677,4 +2691,5 @@ wireguard_install_post_compose() { source "${install_containers_dir}wireguard/sc wireguard_install_post_start() { source "${install_containers_dir}wireguard/scripts/wireguard_install_hooks.sh"; wireguard_install_post_start "$@"; } wireguard_install_pre() { source "${install_containers_dir}wireguard/scripts/wireguard_install_hooks.sh"; wireguard_install_pre "$@"; } writeAtomic() { source "${install_scripts_dir}crontab/task/crontab_task_processor.sh"; writeAtomic "$@"; } +_writeTaskFile() { source "${install_scripts_dir}cli/task/cli_task_run.sh"; _writeTaskFile "$@"; } zipFile() { source "${install_scripts_dir}function/file/zip_file.sh"; zipFile "$@"; }