#!/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" # ISO-8601 UTC with milliseconds. Matches the format the WebUI's # tasks-manager.js emits (Date.now() → toISOString()), so the tasks # panel's `new Date(task.createdAt).toLocaleString()` renders correctly. # Falls back to seconds-precision if %3N isn't supported (busybox). local created_at created_at=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ" 2>/dev/null) \ || created_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ") [[ -d "$task_dir" ]] || runFileOp mkdir -p "$task_dir" # CamelCase initial fields match the WebUI's task shape; processor adds # snake_case status fields on top as it runs. Both task-manager.js and # tasks-manager.js read camelCase directly (no alias needed for createdAt). 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 createdAt "$created_at" \ --arg type "${task_type:-}" \ --arg app "${app_name:-}" \ '{id:$id, command:$command, status:$status, createdAt:$createdAt, startedAt:null, completedAt:null, heartbeatAt:null, exitCode:null, errorMessage:null} + (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 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\",\"createdAt\":\"$created_at\"" task_json+=",\"startedAt\":null,\"completedAt\":null,\"heartbeatAt\":null" task_json+=",\"exitCode\":null,\"errorMessage\":null" [[ -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" }