LibrePortal/scripts/cli/commands/app/cli_app_commands.sh
librelad 56cd6e7fa4 storage: choose which drive an app installs onto
The resolver already supported per-app placement — CFG_<APP>_STORAGE names a
location and appDir sends data, compose and config there — and 37 of 39 app
templates ship the field. What was missing was choosing AT INSTALL TIME. The
only routes were editing a config by hand before installing, or installing onto
the default disk and then `app move`ing it, which copies the data twice.

    libreportal app install <app> --storage=<location>

and the App Center's existing storage dropdown, which travels inside
config_variables. Both resolve to one answer in storageChoiceFor, so there is a
single code path.

Ordering is the whole difficulty, and getting it wrong is quiet. installApp
copies the app template into appDir(), sources it, and later applies the form
overrides. The choice has to be live before the copy (or the directory is
created on the wrong disk), written into the config before the source (or the
template's "default" wins and every later appDir in that process returns the
primary root), and folded into config_variables (or the override pass writes
"default" back). Miss any one and the directory and its config disagree — which
resolves correctly only until something sources the config.

Refuses an unknown or unmounted location, an existing directory, and an app
whose template marks the field **READONLY** (fixed to the primary root because
other apps reach it by literal path — storageMoveApp already refuses to move
those, and installing one elsewhere is the same violation from the other end).

Three shipped bugs found making this work:

  * updateConfigOption chose its write helper by comparing the path against
    $containers_dir — the PRIMARY root only — so an app on any other registered
    location took the manager branch and `sed -i` failed with exactly the
    permission error the comment above that code describes. `app move` writes
    the new location with `|| true`, so it reported a successful move while
    leaving the config naming the old disk.
  * storageLocationName resolved a location's name only from an in-scope
    CFG_STORAGE_LOC_<id>_NAME, falling back to the bare id. That name is the
    value CFG_<APP>_STORAGE is set to, so the generated dropdown offered
    "location-1" as both label and value — a choice that does not resolve. Read
    it from the location's config when the variable is not in scope.
  * storageSyncAllAppComments was written for "the regen path" and never wired
    into one. Every CFG_<APP>_STORAGE option list was frozen at install time, so
    adding a drive did not make it selectable anywhere. Called from the storage
    generator now, which runs exactly when those lists go stale — and extended
    to app TEMPLATES, since an app not installed yet is precisely the one whose
    install form needs to show which drives exist.

Verified on a live install with three locations: linkding and authelia on disk1,
ipinfo on disk2, fourteen on the default root, each config naming its own drive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 07:22:11 +01:00

293 lines
12 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
# --storage=<location>: which drive this app is installed onto. Scanned
# across the slots rather than fixed to one, because the config argument
# before it is optional and callers write both orders.
local app_storage=""
local _s
for _s in "$config" "$initial_command5" "$initial_command6" "$initial_command7"; do
[[ "$_s" == --storage=* ]] && app_storage="${_s#--storage=}"
done
[[ "$config" == --storage=* ]] && config=""
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
# Read by storagePlaceAppPre, deep inside installApp. Passed as
# an environment variable rather than another positional because
# the install driver is reached through several call sites and
# threading an argument through all of them to be ignored by
# most is worse than one clearly-named variable.
[[ -n "$app_storage" ]] && export LP_INSTALL_STORAGE="$app_storage"
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=""
[[ "$_passthrough_config" == --storage=* ]] && _passthrough_config=""
local _cmd="libreportal app install $app_name"
[[ -n "$_passthrough_config" ]] && _cmd+=" '$_passthrough_config'"
[[ "$reset_network" == "true" ]] && _cmd+=" --reset-network"
# Re-emitted for the task run, which is a separate process.
[[ -n "$app_storage" ]] && _cmd+=" --storage='$app_storage'"
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
;;
"export")
# A copy you can hand around — not a backup. Runs inline: it stops
# the app, and a task row that silently stopped something would be
# worse than watching it happen.
if [[ -z "$app_name" ]]; then
isNotice "Usage: app export <app_name> [file]"
cliShowAppHelp
else
appExport "$app_name" "$initial_command4"
fi
;;
"import-check")
# Report what an import WOULD do, without doing it. Read-only, so it
# runs inline rather than through a task — the WebUI needs the answer
# before it can ask for acceptance.
if [[ -z "$app_name" ]]; then
isNotice "Usage: app import-check <file-or-directory>"
elif [[ "$initial_command4" == "--publish" ]]; then
# Write the result where the WebUI reads it, instead of stdout.
appImportCheckPublish "$app_name"
else
appImportCheck "$app_name"
fi
;;
"import")
# Here $app_name is the FILE, since there is no app yet.
if [[ -z "$app_name" ]]; then
isNotice "Usage: app import <file.lpapp>"
cliShowAppHelp
elif [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
appImport "$app_name" "$initial_command4"
else
cliTaskRun "libreportal app import $app_name" "import" "libreportal"
fi
;;
"move")
# `app move <app> <location>` — relocate an app's data to another
# storage location. Long-running and it stops the app, so it goes
# through the task processor like backup/restore rather than
# blocking the CLI.
if [[ -z "$app_name" || -z "$initial_command4" ]]; then
isNotice "Usage: app move <app_name> <storage_location>"
cliShowAppHelp
elif [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
storageMoveApp "$app_name" "$initial_command4" "$initial_command5"
else
cliTaskRun "libreportal app move $app_name $initial_command4" "move" "$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
}