LibrePortal/scripts/cli/task/cli_task_run.sh
librelad e5273a482d feat(cli): route app install through the task processor + live follower
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 <name>`. 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 <cmd> [type] [app] [--detach]
    Enqueues + follows; --detach prints the task id and exits 0.
  cliTaskFollow <task_id>
    `tail -F` the log + jq-poll the status; returns the task's exit_code.
    Designed to be reused for `libreportal task log <id>` 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 <librelad@digitalangels.vip>
2026-05-27 14:29:30 +01:00

176 lines
6.8 KiB
Bash

#!/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"
}