LibrePortal/scripts/cli/commands/app/cli_app_commands.sh
librelad 4a1aa43083 feat(artifact): app bundle applier + libreportal app add — the marketplace verb
Opens the two designed seams (roadmap §8.4): _artifactResolve accepts
type:"app" (slug validated, the installed-app gate skipped — presence is
the collision policy's call), and payload.kind:"bundle" gets its own APPLY
flow. The download core (sha256 pin vs the signed index + minisig +
refuse-unsigned) is factored into _artifactDownloadVerified, shared by ops
and bundle payloads.

A bundle add: fetch → quarantine-validate → place in the definition tree
(staging + one rename, manager funnel) → lpRegenWebui → verify the app
surfaced in apps.json → applied-record with a precise undo → History.
The validator is fail-closed (traversal/absolute paths, links/devices,
single top-level dir == slug, charset, size/entry caps, set-id strip,
config TITLE+CATEGORY + compose present, bash -n every .sh) because the
definition tree is live-sourced on every CLI start — nothing lands there
before trust + quarantine pass. Collision policy: installed-live refused,
local definitions win, registry-owned re-add = reversible definition
update (prior tree packed into the undo). Revert removes/restores the
definition (refused while installed) and regens. Apps never auto-apply
(type filter kept + publisher forces auto:false).

New verb: libreportal app add <slug|artifact-id> (app_add task; resolves
by slug via appAddFromRegistry, ambiguity refused).

Also fixes the second half of the sigstate-propagation bug class:
artifactApply captured $(_artifactResolve) in a subshell, stranding
_ART_INDEX/_ART_APP/_ART_SCOPE AND the LP_INDEX_SIGSTATE the apply gate
enforces — on a signed box every apply would have refused as unsigned.
Resolve now assigns globals (_ART_JSON) and is called directly.

Source-and-mock harness: 46/46 (resolve gates, 14 validator refusals,
happy add, collision matrix, definition-update round-trip, revert
semantics, postcheck + record-failure rollbacks, apply-auto exclusion,
app add verb).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-03 21:14:14 +01:00

221 lines
8.9 KiB
Bash
Executable File

#!/bin/bash
# App Commands Handler
# Handles all app subcommands by calling core functions
cliHandleAppCommands()
{
local action="$initial_command2"
local app_name="$initial_command3"
local config="$initial_command4"
local restore_arg2="$initial_command4"
local restore_arg3="$initial_command5"
local restore_arg4="$initial_command6"
local tool_name="$initial_command4"
local tool_args="$initial_command5"
local reset_network="false"
if [[ "$config" == "--reset-network" ]]; then
reset_network="true"
config=""
elif [[ "$initial_command5" == "--reset-network" ]]; then
reset_network="true"
fi
case "$action" in
"list")
if [[ -z "$app_name" ]]; then
cliShowAppHelp
elif [ "$app_name" = "available" ]; then
appScanAvailable
elif [ "$app_name" = "installed" ]; then
databaseListInstalledApps
else
isNotice "Invalid list type: $app_name"
cliShowAppHelp
fi
;;
"install")
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 CLI-only
# flags, keep what dockerInstallApp expects.
local _passthrough_config="$config"
[[ "$_passthrough_config" == "--detach" || "$_passthrough_config" == "--reset-network" ]] && _passthrough_config=""
local _cmd="libreportal app install $app_name"
[[ -n "$_passthrough_config" ]] && _cmd+=" '$_passthrough_config'"
[[ "$reset_network" == "true" ]] && _cmd+=" --reset-network"
cliTaskRun "$_cmd" "install" "$app_name" "$_mode"
fi
;;
"add")
# Add an app DEFINITION from the signed registry catalog (the
# marketplace verb -- docs/roadmap/updates-and-distribution.md §8).
# Mutating, so it routes through the task system like install.
if [[ -z "$app_name" ]]; then isError "Usage: libreportal app add <app|artifact-id>"; return 1; fi
if [[ ! "$app_name" =~ ^[A-Za-z0-9._-]+$ ]]; then isError "app add: argument has unsafe characters"; return 1; fi
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
# Lazy-loader gap: the registry read + apply pipeline live in
# their own files; mirror the artifact handler's checked source.
if ! declare -F lpFetchIndex >/dev/null 2>&1; then
local _f
for _f in source/fetch.sh source/artifacts.sh; do
if [[ ! -f "$install_scripts_dir/$_f" ]] || ! source "$install_scripts_dir/$_f"; then
isError "app add: failed to load the read pipeline ($_f) — try: libreportal regen"; return 1
fi
done
fi
if ! declare -F appAddFromRegistry >/dev/null 2>&1; then
local _af="cli/commands/artifact/cli_artifact_apply.sh"
if [[ ! -f "$install_scripts_dir/$_af" ]] || ! source "$install_scripts_dir/$_af"; then
isError "app add: failed to load the apply pipeline ($_af) — try: libreportal regen"; return 1
fi
fi
appAddFromRegistry "$app_name"
else
cliTaskRun "libreportal app add $app_name" "app_add" "$app_name" ""
fi
;;
"uninstall")
# Optional `--delete-images` flag (in any of the trailing
# positions) tells the uninstall to also remove the app's
# docker images. Default behaviour: keep them so a reinstall
# is fast and offline-friendly.
local _del_images="false"
local _del_tasks="false"
local _u_mode=""
for _arg in "$config" "$initial_command5" "$initial_command6" "$initial_command7"; do
[[ "$_arg" == "--delete-images" ]] && _del_images="true"
[[ "$_arg" == "--delete-tasks" ]] && _del_tasks="true"
[[ "$_arg" == "--detach" ]] && _u_mode="--detach"
done
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
dockerUninstallApp "$app_name" "$_del_images" "$_del_tasks"
else
local _cmd="libreportal app uninstall $app_name"
[[ "$_del_images" == "true" ]] && _cmd+=" --delete-images"
[[ "$_del_tasks" == "true" ]] && _cmd+=" --delete-tasks"
cliTaskRun "$_cmd" "uninstall" "$app_name" "$_u_mode"
fi
;;
"start")
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
dockerStartApp "$app_name"
else
cliTaskRun "libreportal app start $app_name" "start" "$app_name"
fi
;;
"stop")
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
dockerStopApp "$app_name"
else
cliTaskRun "libreportal app stop $app_name" "stop" "$app_name"
fi
;;
"restart")
# Optional 4th arg = one compose service to restart instead of the
# whole app (the WebUI Services tab routes per-service restarts
# here): libreportal app restart <app> [service]
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
dockerRestartApp "$app_name" "$config"
else
cliTaskRun "libreportal app restart $app_name${config:+ $config}" "restart" "$app_name"
fi
;;
"up")
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
dockerComposeUp "$app_name"
else
cliTaskRun "libreportal app up $app_name" "up" "$app_name"
fi
;;
"down")
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
dockerComposeDown "$app_name"
else
cliTaskRun "libreportal app down $app_name" "down" "$app_name"
fi
;;
"reload")
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
dockerRestartAppViaInstall "$app_name"
else
cliTaskRun "libreportal app reload $app_name" "reload" "$app_name"
fi
;;
"backup")
if [[ -z "$app_name" ]]; then
isNotice "No app provided."
cliShowAppHelp
elif [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
backupAppStart "$app_name"
else
cliTaskRun "libreportal app backup $app_name" "backup" "$app_name"
fi
;;
"restore")
if [[ -z "$app_name" ]]; then
isNotice "No app provided."
cliShowAppHelp
elif [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
cliAppRestore "$app_name" "$restore_arg2" "$restore_arg3" "$restore_arg4"
else
# Pass the positional args through verbatim. They may
# include local|remote1|… selector, filename, password —
# quote each to survive shell re-parse in the processor.
local _cmd="libreportal app restore $app_name"
for _a in "$restore_arg2" "$restore_arg3" "$restore_arg4"; do
[[ -n "$_a" ]] && _cmd+=" '$_a'"
done
cliTaskRun "$_cmd" "restore" "$app_name"
fi
;;
"status")
if [[ -z "$app_name" ]]; then
isNotice "No app provided."
cliShowAppHelp
else
appStatus "$app_name"
fi
;;
"tool")
# `libreportal app tool list [<app>]` — discover available tools.
# When the second arg is the literal `list`, the third is treated
# as an optional app filter. Otherwise the standard run shape
# applies: `libreportal app tool <app> <tool_id> [args]`.
if [[ "$app_name" == "list" ]]; then
cliAppToolList "$tool_name"
elif [[ -z "$app_name" || -z "$tool_name" ]]; then
isNotice "Usage: libreportal app tool <app_name> <tool_name> [args]"
isNotice " libreportal app tool list [<app_name>]"
cliShowAppHelp
else
dockerAppRunTool "$app_name" "$tool_name" "$tool_args"
fi
;;
*)
isNotice "Invalid app command: $action"
cliShowAppHelp
;;
esac
}