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>
293 lines
12 KiB
Bash
Executable File
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
|
|
}
|