LibrePortal/scripts/cli/task/cli_task_run.sh
librelad 2d24a764a8 refactor(storage): route elevation tests and the WebUI tree through paths.sh
Two mechanical sweeps, no behaviour change on a single-root install.

The 14 `[[ "$p" == "$containers_dir"* ]]` prefix tests that decide
manager-vs-container-user elevation become pathIsContainerData, so a file
on a second storage root is no longer misclassified as manager-owned —
which would have written it with the wrong owner and failed later, far
from the cause. The 65 references to the WebUI's own tree become
webuiDir(), which is pinned to the primary root by design.

Two traps found while doing it:

run_privileged.sh is sourced directly by init.sh without paths.sh, so it
needs a fallback. Defining one named pathIsContainerData was wrong:
generate_function_manifest.sh indexes top-level definitions, and the
resulting autoload stub would have shadowed the real multi-root
implementation with the primary-only fallback — silently classifying
every file on a second disk as manager-owned, which is exactly the bug
this sweep exists to prevent. Renamed to _runCfgIsContainerPath, which
delegates when the real one is loaded.

setup_lock.sh built its path in a top-level assignment, so it was
evaluated at source time and needed the file flagged eager. Made it a
function instead: the path resolves on call, and the file drops off
LP_EAGER_FILES entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:04:19 +01:00

189 lines
7.6 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 "$(webuiDir)/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"
}