From 2c97955a0702e01810b7dbad58af5be2e27ab649 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:40:38 +0200 Subject: [PATCH] feat(easy-docker): manage apps on existing sites --- .../lib/app/wizard/common/site/apps.sh | 10 + .../app/wizard/common/site/apps/lifecycle.sh | 437 ++++++++++++++++++ .../lib/app/wizard/flows/manage/site.sh | 230 ++++++++- .../lib/ui/screens/production/manage.sh | 85 +++- 4 files changed, 760 insertions(+), 2 deletions(-) create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/apps/lifecycle.sh diff --git a/scripts/easy-docker/lib/app/wizard/common/site/apps.sh b/scripts/easy-docker/lib/app/wizard/common/site/apps.sh index 4c57110e..6ccb4ab7 100755 --- a/scripts/easy-docker/lib/app/wizard/common/site/apps.sh +++ b/scripts/easy-docker/lib/app/wizard/common/site/apps.sh @@ -1,5 +1,13 @@ #!/usr/bin/env bash +load_easy_docker_site_apps_modules() { + local apps_dir="" + apps_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/apps" + + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/apps/lifecycle.sh + source "${apps_dir}/lifecycle.sh" +} + append_stack_installable_app_line() { local result_var="${1}" local existing_lines="${2:-}" @@ -74,3 +82,5 @@ get_stack_selected_installable_apps() { printf -v "${result_var}" "%s" "${ordered_app_lines}" return 0 } + +load_easy_docker_site_apps_modules diff --git a/scripts/easy-docker/lib/app/wizard/common/site/apps/lifecycle.sh b/scripts/easy-docker/lib/app/wizard/common/site/apps/lifecycle.sh new file mode 100755 index 00000000..4526606d --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/apps/lifecycle.sh @@ -0,0 +1,437 @@ +#!/usr/bin/env bash + +stack_site_app_lines_contain() { + local app_lines="${1:-}" + local app_name="${2:-}" + + if [ -z "${app_name}" ]; then + return 1 + fi + + printf '%s\n' "${app_lines}" | grep -F -x -- "${app_name}" >/dev/null 2>&1 +} + +remove_stack_site_app_line() { + local result_var="${1}" + local existing_lines="${2:-}" + local app_name="${3:-}" + local existing_app="" + local remaining_lines="" + + while IFS= read -r existing_app; do + if [ -z "${existing_app}" ] || [ "${existing_app}" = "${app_name}" ]; then + continue + fi + + if [ -z "${remaining_lines}" ]; then + remaining_lines="${existing_app}" + else + remaining_lines="${remaining_lines}"$'\n'"${existing_app}" + fi + done </dev/null 2>&1 || true + if ! persist_stack_site_app_operation_metadata \ + "${stack_dir}" \ + "${site_name}" \ + "${current_installed_app_lines}" \ + "install-app" \ + "${EASY_DOCKER_SITE_ERROR_DETAIL}" \ + "${EASY_DOCKER_SITE_ERROR_LOG_PATH}"; then + return 89 + fi + case "${command_status}" in + 54) + return 84 + ;; + 52) + return 82 + ;; + *) + return 88 + ;; + esac + fi + + if ! get_stack_site_managed_runtime_app_lines updated_installed_app_lines "${stack_dir}" "${site_name}"; then + updated_installed_app_lines="${current_installed_app_lines}" + append_stack_installable_app_line updated_installed_app_lines "${updated_installed_app_lines}" "${app_name}" + fi + + if ! persist_stack_site_app_operation_metadata \ + "${stack_dir}" \ + "${site_name}" \ + "${updated_installed_app_lines}" \ + "install-app" \ + "" \ + ""; then + return 89 + fi + + return 0 +} + +uninstall_app_from_configured_stack_site() { + local stack_dir="${1}" + local app_name="${2:-}" + local site_name="" + local uninstallable_app_lines="" + local current_installed_app_lines="" + local updated_installed_app_lines="" + local uninstall_command="" + local uninstall_output="" + local command_status=0 + local uninstallable_status=0 + + reset_easy_docker_site_error_state + + if [ -z "${app_name}" ] || [ "${app_name}" = "frappe" ]; then + return 96 + fi + + if get_configured_stack_site_uninstallable_app_lines uninstallable_app_lines "${stack_dir}"; then + : + else + uninstallable_status=$? + return "${uninstallable_status}" + fi + + if [ -z "${uninstallable_app_lines}" ]; then + return 95 + fi + + site_name="$(get_stack_site_name "${stack_dir}" || true)" + current_installed_app_lines="${uninstallable_app_lines}" + + if ! stack_site_app_lines_contain "${uninstallable_app_lines}" "${app_name}"; then + return 96 + fi + + uninstall_command="$( + printf "bench --site %s uninstall-app %s --yes" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${app_name}")" + )" + + if run_stack_backend_bash_command_capture uninstall_output "${stack_dir}" "${uninstall_command}"; then + : + else + command_status=$? + EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench uninstall-app failed for '%s'." "${app_name}")" + capture_stack_site_error_log "${stack_dir}" "site-uninstall-app-error" "${uninstall_output}" >/dev/null 2>&1 || true + if ! persist_stack_site_app_operation_metadata \ + "${stack_dir}" \ + "${site_name}" \ + "${current_installed_app_lines}" \ + "uninstall-app" \ + "${EASY_DOCKER_SITE_ERROR_DETAIL}" \ + "${EASY_DOCKER_SITE_ERROR_LOG_PATH}"; then + return 99 + fi + case "${command_status}" in + 54) + return 94 + ;; + 52) + return 92 + ;; + *) + return 98 + ;; + esac + fi + + if ! get_stack_site_managed_runtime_app_lines updated_installed_app_lines "${stack_dir}" "${site_name}"; then + remove_stack_site_app_line updated_installed_app_lines "${current_installed_app_lines}" "${app_name}" + fi + + if ! persist_stack_site_app_operation_metadata \ + "${stack_dir}" \ + "${site_name}" \ + "${updated_installed_app_lines}" \ + "uninstall-app" \ + "" \ + ""; then + return 99 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh index b2f055a4..52e1fd80 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh @@ -14,6 +14,10 @@ handle_manage_stack_site_flow() { local existing_site_apps_csv="" local existing_site_last_backup_at="" local existing_site_details_action="" + local existing_site_apps_action="" + local existing_site_app_lines="" + local existing_site_app_selection="" + local existing_site_app_confirmation="" local site_delete_confirmation="" while true; do @@ -100,7 +104,7 @@ handle_manage_stack_site_flow() { existing_site_apps_lines="$(get_stack_site_apps_installed_lines "${stack_dir}" || true)" existing_site_last_backup_at="$(get_stack_site_last_backup_at "${stack_dir}" || true)" if stack_backend_service_is_running "${stack_dir}" >/dev/null 2>&1; then - get_stack_site_runtime_selected_apps_lines existing_site_apps_lines "${stack_dir}" "${existing_site_name}" || true + get_stack_site_managed_runtime_app_lines existing_site_apps_lines "${stack_dir}" "${existing_site_name}" || true fi if [ -n "${existing_site_apps_lines}" ]; then existing_site_apps_csv="$(printf '%s' "${existing_site_apps_lines}" | tr '\n' ',' | sed 's/,$//')" @@ -118,6 +122,230 @@ handle_manage_stack_site_flow() { "${existing_site_last_backup_at}" || true )" case "${existing_site_details_action}" in + "Manage apps on this site") + while true; do + existing_site_apps_action="$( + show_manage_stack_site_apps_menu \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" || true + )" + case "${existing_site_apps_action}" in + "Install app on this site") + if ! get_configured_stack_site_installable_app_lines existing_site_app_lines "${stack_dir}"; then + site_flow_status=$? + case "${site_flow_status}" in + 81) + show_warning_and_wait "Cannot install app: backend service is not running yet. Start the stack first." 4 + ;; + 82) + show_warning_and_wait "Cannot install app for this topology yet. Only single-host stacks are supported." 4 + ;; + 83) + show_warning_and_wait "Cannot install app because no configured site was found in metadata.json." 4 + ;; + 84) + show_warning_and_wait "Cannot install app because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 87) + show_warning_and_wait "Cannot inspect installable apps on this site right now. Check backend readiness and try again." 4 + ;; + *) + show_warning_and_wait "Could not prepare installable site apps (${site_flow_status})." 4 + ;; + esac + continue + fi + + if [ -z "${existing_site_app_lines}" ]; then + show_warning_and_wait "No additional selected stack apps are available to install on this site. No further apps are currently available in this stack environment." 4 + continue + fi + + existing_site_app_selection="$( + show_manage_stack_site_app_selection \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" \ + "Install app on this site" \ + "${existing_site_app_lines}" || true + )" + case "${existing_site_app_selection}" in + "Back" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_message "Installing app on site: ${existing_site_app_selection}" + if install_app_on_configured_stack_site "${stack_dir}" "${existing_site_app_selection}"; then + show_warning_and_wait "App installed successfully on site ${existing_site_name}: ${existing_site_app_selection}" 4 + continue + fi + + site_flow_status=$? + case "${site_flow_status}" in + 81) + show_warning_and_wait "Cannot install app: backend service is not running yet. Start the stack first." 4 + ;; + 82) + show_warning_and_wait "Cannot install app for this topology yet. Only single-host stacks are supported." 4 + ;; + 83) + show_warning_and_wait "Cannot install app because no configured site was found in metadata.json." 4 + ;; + 84) + show_warning_and_wait "Cannot install app because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 85) + show_warning_and_wait "No additional selected stack apps are available to install on this site. No further apps are currently available in this stack environment." 4 + ;; + 86) + show_warning_and_wait "The selected app is not currently installable on this site." 4 + ;; + 87) + show_warning_and_wait "Cannot inspect current site apps right now. Check backend readiness and try again." 4 + ;; + 88) + show_warning_and_wait "App installation failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 + ;; + 89) + show_warning_and_wait "The app command finished, but site metadata could not be written to metadata.json." 5 + ;; + *) + show_warning_and_wait "App installation failed (${site_flow_status})." 4 + ;; + esac + continue + ;; + esac + ;; + "Uninstall app from this site") + if ! get_configured_stack_site_uninstallable_app_lines existing_site_app_lines "${stack_dir}"; then + site_flow_status=$? + case "${site_flow_status}" in + 91) + show_warning_and_wait "Cannot uninstall app: backend service is not running yet. Start the stack first." 4 + ;; + 92) + show_warning_and_wait "Cannot uninstall app for this topology yet. Only single-host stacks are supported." 4 + ;; + 93) + show_warning_and_wait "Cannot uninstall app because no configured site was found in metadata.json." 4 + ;; + 94) + show_warning_and_wait "Cannot uninstall app because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 97) + show_warning_and_wait "Cannot inspect uninstallable apps on this site right now. Check backend readiness and try again." 4 + ;; + *) + show_warning_and_wait "Could not prepare uninstallable site apps (${site_flow_status})." 4 + ;; + esac + continue + fi + + if [ -z "${existing_site_app_lines}" ]; then + show_warning_and_wait "No installed site apps are currently available for uninstall." 4 + continue + fi + + existing_site_app_selection="$( + show_manage_stack_site_app_selection \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" \ + "Uninstall app from this site" \ + "${existing_site_app_lines}" || true + )" + case "${existing_site_app_selection}" in + "Back" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + existing_site_app_confirmation="$( + show_manage_stack_site_app_uninstall_confirmation \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" \ + "${existing_site_app_selection}" || true + )" + case "${existing_site_app_confirmation}" in + "No" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + "Yes") + show_warning_message "Uninstalling app from site: ${existing_site_app_selection}" + if uninstall_app_from_configured_stack_site "${stack_dir}" "${existing_site_app_selection}"; then + show_warning_and_wait "App uninstalled successfully from site ${existing_site_name}: ${existing_site_app_selection}" 4 + continue + fi + + site_flow_status=$? + case "${site_flow_status}" in + 91) + show_warning_and_wait "Cannot uninstall app: backend service is not running yet. Start the stack first." 4 + ;; + 92) + show_warning_and_wait "Cannot uninstall app for this topology yet. Only single-host stacks are supported." 4 + ;; + 93) + show_warning_and_wait "Cannot uninstall app because no configured site was found in metadata.json." 4 + ;; + 94) + show_warning_and_wait "Cannot uninstall app because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 95) + show_warning_and_wait "No installed site apps are currently available for uninstall." 4 + ;; + 96) + show_warning_and_wait "The selected app cannot be uninstalled here. frappe stays blocked, but erpnext is allowed." 4 + ;; + 97) + show_warning_and_wait "Cannot inspect current site apps right now. Check backend readiness and try again." 4 + ;; + 98) + show_warning_and_wait "App uninstall failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 + ;; + 99) + show_warning_and_wait "The app command finished, but site metadata could not be written to metadata.json." 5 + ;; + *) + show_warning_and_wait "App uninstall failed (${site_flow_status})." 4 + ;; + esac + continue + ;; + *) + show_warning_and_wait "Unknown uninstall app confirmation action: ${existing_site_app_confirmation}" 2 + continue + ;; + esac + ;; + esac + ;; + "Back" | "") + break + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown site apps action: ${existing_site_apps_action}" 2 + continue + ;; + esac + done + continue + ;; "Backup site now") show_warning_message "Creating backup for site: ${existing_site_name}" if backup_configured_stack_site "${stack_dir}"; then diff --git a/scripts/easy-docker/lib/ui/screens/production/manage.sh b/scripts/easy-docker/lib/ui/screens/production/manage.sh index cfbba951..29369f72 100755 --- a/scripts/easy-docker/lib/ui/screens/production/manage.sh +++ b/scripts/easy-docker/lib/ui/screens/production/manage.sh @@ -204,16 +204,99 @@ show_manage_stack_site_details() { render_box_message "${status_text}" "0 2" >&2 gum choose \ - --height 9 \ + --height 10 \ --header "Site details" \ --cursor.foreground 63 \ --selected.foreground 45 \ + "Manage apps on this site" \ "Backup site now" \ "Delete site" \ "Back" \ "Exit and close easy-docker" } +show_manage_stack_site_apps_menu() { + local stack_name="${1}" + local stack_dir="${2}" + local site_name="${3}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage site apps\n\nStack: %s\nDirectory: %s\nSite: %s\n\nInstall or uninstall apps for this existing site." "${stack_name}" "${stack_dir}" "${site_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 9 \ + --header "Site app actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Install app on this site" \ + "Uninstall app from this site" \ + "Back" \ + "Exit and close easy-docker" +} + +show_manage_stack_site_app_selection() { + local stack_name="${1}" + local stack_dir="${2}" + local site_name="${3}" + local action_label="${4}" + local app_lines="${5:-}" + local status_text="" + local app_name="" + local -a menu_options=() + + render_main_screen 1 >&2 + + status_text="$(printf "%s\n\nStack: %s\nDirectory: %s\nSite: %s\n\nSelect one app." "${action_label}" "${stack_name}" "${stack_dir}" "${site_name}")" + render_box_message "${status_text}" "0 2" >&2 + + while IFS= read -r app_name; do + if [ -z "${app_name}" ]; then + continue + fi + menu_options+=("${app_name}") + done <&2 + + status_text="$(printf "Uninstall app from site\n\nStack: %s\nDirectory: %s\nSite: %s\nApp: %s\n\nThis removes the app from the site. frappe itself cannot be removed here." "${stack_name}" "${stack_dir}" "${site_name}" "${app_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Confirm uninstall app" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Yes" \ + "No" \ + "Exit and close easy-docker" +} + show_manage_stack_site_delete_confirmation() { local stack_name="${1}" local stack_dir="${2}"