diff --git a/scripts/easy-docker/README.md b/scripts/easy-docker/README.md index f6f0c4d1..53b32396 100644 --- a/scripts/easy-docker/README.md +++ b/scripts/easy-docker/README.md @@ -25,3 +25,26 @@ bash easy-docker.sh - `--no-installation-fallback` - Disables GitHub binary fallback for `gum` - If package manager installation fails, the script exits with manual installation guidance + +## Apps Catalog + +- App options in the wizard are read from: + - `scripts/easy-docker/config/apps.tsv` +- Format per line: + - `idlabelrepodefault_branchbranches_csv` +- Example: + - `erpnextERPNexthttps://github.com/frappe/erpnextversion-15version-15,version-16,develop` +- The install selection in the wizard is limited to apps from this catalog. +- For each selected app, the wizard shows the configured branch list from this catalog and prompts branch selection. + +## Frappe Version Profiles + +- During new stack creation (after stack name), the wizard asks for a Frappe branch profile from: + - `scripts/easy-docker/config/frappe.tsv` +- Format per line: + - `idlabelfrappe_branch` +- Example: + - `v16Frappe v16 (version-16)version-16` +- The selected `frappe_branch` is saved in stack `metadata.json` and used as default branch suggestion for app branch selection. +- In `metadata.json`, this value is stored top-level as: + - `"frappe_branch": "version-16"` (example) diff --git a/scripts/easy-docker/config/apps.tsv b/scripts/easy-docker/config/apps.tsv new file mode 100644 index 00000000..bbce0ae4 --- /dev/null +++ b/scripts/easy-docker/config/apps.tsv @@ -0,0 +1,7 @@ +# id label repo default_branch branches_csv +erpnext ERPNext https://github.com/frappe/erpnext develop version-15,version-16,develop +crm CRM https://github.com/frappe/crm develop develop,version-16,version-15 +hrms HRMS https://github.com/frappe/hrms develop develop,version-16,version-15 +lms LMS https://github.com/frappe/lms develop develop,version-16,version-15 +helpdesk Helpdesk https://github.com/frappe/helpdesk develop develop,version-16,version-15 +drive Drive https://github.com/frappe/drive develop develop,version-16,version-15 diff --git a/scripts/easy-docker/config/frappe.tsv b/scripts/easy-docker/config/frappe.tsv new file mode 100644 index 00000000..e612713b --- /dev/null +++ b/scripts/easy-docker/config/frappe.tsv @@ -0,0 +1,4 @@ +# id label frappe_branch +v16 Frappe v16 (version-16) version-16 +v15 Frappe v15 (version-15) version-15 +develop Frappe develop (develop) develop diff --git a/scripts/easy-docker/lib/app/wizard/common.sh b/scripts/easy-docker/lib/app/wizard/common.sh index 6c9396cc..b7a9d146 100755 --- a/scripts/easy-docker/lib/app/wizard/common.sh +++ b/scripts/easy-docker/lib/app/wizard/common.sh @@ -12,6 +12,8 @@ load_easy_docker_wizard_common_modules() { source "${wizard_dir}/common/helpers.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose.sh source "${wizard_dir}/common/compose.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/frappe.sh + source "${wizard_dir}/common/frappe.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps.sh source "${wizard_dir}/common/apps.sh" # shellcheck source=scripts/easy-docker/lib/app/wizard/common/ux.sh diff --git a/scripts/easy-docker/lib/app/wizard/common/apps.sh b/scripts/easy-docker/lib/app/wizard/common/apps.sh index 0a3bfbba..b9ecd802 100755 --- a/scripts/easy-docker/lib/app/wizard/common/apps.sh +++ b/scripts/easy-docker/lib/app/wizard/common/apps.sh @@ -1,5 +1,541 @@ #!/usr/bin/env bash +trim_predefined_catalog_field() { + local result_var="${1}" + local value="${2}" + + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + printf -v "${result_var}" "%s" "${value}" +} + +is_valid_predefined_app_id() { + local value="${1}" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + *[!a-z0-9._-]*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +generate_predefined_app_id_from_label() { + local result_var="${1}" + local app_label="${2}" + local generated_id="" + + generated_id="$( + printf '%s' "${app_label}" | + tr '[:upper:]' '[:lower:]' | + sed -E 's/[[:space:]]+/_/g; s/[^a-z0-9._-]+/_/g; s/_+/_/g; s/^_+//; s/_+$//' + )" + + if ! is_valid_predefined_app_id "${generated_id}"; then + return 1 + fi + + printf -v "${result_var}" "%s" "${generated_id}" + return 0 +} + +is_valid_predefined_app_repo() { + local value="${1}" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + https://* | http://* | ssh://* | git://* | git@*:* | file://*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +is_valid_predefined_app_branch() { + local value="${1}" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + *[!A-Za-z0-9._/-]* | .* | *..* | */ | /*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +csv_contains_branch() { + local csv_values="${1}" + local value="${2}" + + case ",${csv_values}," in + *,"${value}",*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +normalize_predefined_branches_csv() { + local result_csv_var="${1}" + local branches_csv_raw="${2}" + local branch_token="" + local normalized_csv="" + local -a raw_tokens=() + + IFS=',' read -r -a raw_tokens <<<"${branches_csv_raw}" + for branch_token in "${raw_tokens[@]}"; do + trim_predefined_catalog_field branch_token "${branch_token}" + if [ -z "${branch_token}" ]; then + continue + fi + + if ! is_valid_predefined_app_branch "${branch_token}"; then + return 1 + fi + + if csv_contains_branch "${normalized_csv}" "${branch_token}"; then + continue + fi + + if [ -z "${normalized_csv}" ]; then + normalized_csv="${branch_token}" + else + normalized_csv="${normalized_csv},${branch_token}" + fi + done + + if [ -z "${normalized_csv}" ]; then + return 1 + fi + + printf -v "${result_csv_var}" "%s" "${normalized_csv}" + return 0 +} + +get_predefined_apps_catalog_path() { + local repo_root="" + + repo_root="$(get_easy_docker_repo_root)" + printf '%s/scripts/easy-docker/config/apps.tsv\n' "${repo_root}" +} + +get_predefined_apps_catalog_entries() { + local catalog_path="" + local raw_line="" + local line="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + local normalized_branches_csv="" + local first_branch="" + local extra="" + local seen_ids="," + local seen_labels="," + + catalog_path="$(get_predefined_apps_catalog_path)" + if [ ! -f "${catalog_path}" ]; then + return 1 + fi + + while IFS= read -r raw_line || [ -n "${raw_line}" ]; do + trim_predefined_catalog_field line "${raw_line}" + if [ -z "${line}" ]; then + continue + fi + + case "${line}" in + \#*) + continue + ;; + esac + + if [[ "${line}" == *$'\t'* ]]; then + IFS=$'\t' read -r app_id app_label app_repo app_default_branch app_branches_csv extra <<<"${line}" + else + # Backward compatibility for older catalog rows. + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv extra <<<"${line}" + fi + trim_predefined_catalog_field app_id "${app_id}" + trim_predefined_catalog_field app_label "${app_label}" + trim_predefined_catalog_field app_repo "${app_repo}" + trim_predefined_catalog_field app_default_branch "${app_default_branch}" + trim_predefined_catalog_field app_branches_csv "${app_branches_csv}" + trim_predefined_catalog_field extra "${extra}" + + if [ -n "${extra}" ] || [ -z "${app_id}" ] || [ -z "${app_label}" ] || [ -z "${app_repo}" ] || [ -z "${app_branches_csv}" ]; then + return 1 + fi + + if ! is_valid_predefined_app_id "${app_id}"; then + return 1 + fi + + if ! is_valid_predefined_app_repo "${app_repo}"; then + return 1 + fi + + if ! normalize_predefined_branches_csv normalized_branches_csv "${app_branches_csv}"; then + return 1 + fi + + if [ -z "${app_default_branch}" ]; then + first_branch="${normalized_branches_csv%%,*}" + app_default_branch="${first_branch}" + fi + + if ! is_valid_predefined_app_branch "${app_default_branch}"; then + return 1 + fi + + if ! csv_contains_branch "${normalized_branches_csv}" "${app_default_branch}"; then + return 1 + fi + + case "${seen_ids}" in + *,"${app_id}",*) + return 1 + ;; + esac + case "${seen_labels}" in + *,"${app_label}",*) + return 1 + ;; + esac + + seen_ids="${seen_ids}${app_id}," + seen_labels="${seen_labels}${app_label}," + + printf '%s|%s|%s|%s|%s\n' "${app_id}" "${app_label}" "${app_repo}" "${app_default_branch}" "${normalized_branches_csv}" + done <"${catalog_path}" +} + +get_predefined_app_labels_lines() { + local entry="" + local app_label="" + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + app_label="${entry#*|}" + app_label="${app_label%%|*}" + printf '%s\n' "${app_label}" + done < <(get_predefined_apps_catalog_entries) +} + +get_predefined_app_id_by_label() { + local label="${1}" + local entry="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" + if [ "${app_label}" = "${label}" ]; then + printf '%s\n' "${app_id}" + return 0 + fi + done < <(get_predefined_apps_catalog_entries) + + return 1 +} + +get_predefined_app_repo_by_id() { + local app_id_lookup="${1}" + local entry="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" + if [ "${app_id}" = "${app_id_lookup}" ]; then + printf '%s\n' "${app_repo}" + return 0 + fi + done < <(get_predefined_apps_catalog_entries) + + return 1 +} + +get_predefined_app_label_by_id() { + local app_id_lookup="${1}" + local entry="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" + if [ "${app_id}" = "${app_id_lookup}" ]; then + printf '%s\n' "${app_label}" + return 0 + fi + done < <(get_predefined_apps_catalog_entries) + + return 1 +} + +get_predefined_app_default_branch_by_id() { + local app_id_lookup="${1}" + local entry="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" + if [ "${app_id}" = "${app_id_lookup}" ]; then + printf '%s\n' "${app_default_branch}" + return 0 + fi + done < <(get_predefined_apps_catalog_entries) + + return 1 +} + +get_predefined_app_branch_lines_by_id() { + local result_var="${1}" + local app_id_lookup="${2}" + local entry="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + local branch="" + local branch_lines="" + local -a branches=() + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" + if [ "${app_id}" != "${app_id_lookup}" ]; then + continue + fi + + IFS=',' read -r -a branches <<<"${app_branches_csv}" + for branch in "${branches[@]}"; do + trim_predefined_catalog_field branch "${branch}" + if [ -z "${branch}" ]; then + continue + fi + if [ -z "${branch_lines}" ]; then + branch_lines="${branch}" + else + branch_lines="${branch_lines}"$'\n'"${branch}" + fi + done + + if [ -z "${branch_lines}" ]; then + return 1 + fi + + printf -v "${result_var}" "%s" "${branch_lines}" + return 0 + done < <(get_predefined_apps_catalog_entries) + + return 1 +} + +predefined_app_catalog_has_id() { + local app_id_lookup="${1}" + local entry="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + + if [ -z "${app_id_lookup}" ]; then + return 1 + fi + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" + if [ "${app_id}" = "${app_id_lookup}" ]; then + return 0 + fi + done < <(get_predefined_apps_catalog_entries || true) + + return 1 +} + +predefined_app_catalog_has_label() { + local app_label_lookup="${1}" + local entry="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + + if [ -z "${app_label_lookup}" ]; then + return 1 + fi + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv <<<"${entry}" + if [ "${app_label}" = "${app_label_lookup}" ]; then + return 0 + fi + done < <(get_predefined_apps_catalog_entries || true) + + return 1 +} + +append_predefined_app_catalog_entry() { + local app_id="${1}" + local app_label="${2}" + local app_repo="${3}" + local app_default_branch="${4}" + local app_branches_csv="${5}" + local normalized_branches_csv="" + local first_branch="" + local catalog_path="" + local catalog_tmp_path="" + local last_char="" + + if ! get_predefined_apps_catalog_entries >/dev/null 2>&1; then + return 1 + fi + + trim_predefined_catalog_field app_id "${app_id}" + trim_predefined_catalog_field app_label "${app_label}" + trim_predefined_catalog_field app_repo "${app_repo}" + trim_predefined_catalog_field app_default_branch "${app_default_branch}" + trim_predefined_catalog_field app_branches_csv "${app_branches_csv}" + + if ! is_valid_predefined_app_id "${app_id}"; then + return 1 + fi + if [ -z "${app_label}" ]; then + return 1 + fi + if ! is_valid_predefined_app_repo "${app_repo}"; then + return 1 + fi + if ! normalize_predefined_branches_csv normalized_branches_csv "${app_branches_csv}"; then + return 1 + fi + + if [ -z "${app_default_branch}" ]; then + first_branch="${normalized_branches_csv%%,*}" + app_default_branch="${first_branch}" + fi + if ! is_valid_predefined_app_branch "${app_default_branch}"; then + return 1 + fi + if ! csv_contains_branch "${normalized_branches_csv}" "${app_default_branch}"; then + return 1 + fi + + if predefined_app_catalog_has_id "${app_id}"; then + return 1 + fi + if predefined_app_catalog_has_label "${app_label}"; then + return 1 + fi + + catalog_path="$(get_predefined_apps_catalog_path)" + catalog_tmp_path="${catalog_path}.tmp" + if [ ! -f "${catalog_path}" ]; then + return 1 + fi + + if ! cp -- "${catalog_path}" "${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if [ -s "${catalog_tmp_path}" ]; then + if command_exists tail; then + last_char="$(tail -c 1 "${catalog_tmp_path}" 2>/dev/null || true)" + if [ -n "${last_char}" ]; then + if ! printf '\n' >>"${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + fi + else + if ! printf '\n' >>"${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + fi + fi + + if ! printf '%s\t%s\t%s\t%s\t%s\n' "${app_id}" "${app_label}" "${app_repo}" "${app_default_branch}" "${normalized_branches_csv}" >>"${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${catalog_tmp_path}" "${catalog_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + persist_stack_apps_json_content() { local stack_dir="${1}" local apps_json_content="${2}" @@ -95,12 +631,63 @@ get_metadata_apps_custom_lines() { ' "${metadata_path}" } +get_metadata_apps_predefined_branch_lines() { + local metadata_path="${1}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + awk ' + /"apps"[[:space:]]*:[[:space:]]*{/ { + in_apps = 1 + } + in_apps && /"predefined_branches"[[:space:]]*:[[:space:]]*{/ { + in_predefined_branches = 1 + next + } + in_predefined_branches && /}/ { + in_predefined_branches = 0 + next + } + in_predefined_branches { + if (match($0, /"([^"]+)"[[:space:]]*:[[:space:]]*"([^"]+)"/, parts)) { + print parts[1] "|" parts[2] + } + } + ' "${metadata_path}" +} + +get_metadata_apps_predefined_branch_for_id() { + local metadata_path="${1}" + local app_id_lookup="${2}" + local line="" + local app_id="" + local app_branch="" + + while IFS= read -r line; do + if [ -z "${line}" ]; then + continue + fi + + app_id="${line%%|*}" + app_branch="${line#*|}" + if [ "${app_id}" = "${app_id_lookup}" ] && [ -n "${app_branch}" ]; then + printf '%s\n' "${app_branch}" + return 0 + fi + done < <(get_metadata_apps_predefined_branch_lines "${metadata_path}" || true) + + return 1 +} + build_stack_apps_json_content_from_metadata_apps() { local result_var="${1}" local stack_dir="${2}" local metadata_path="" local preset_apps_csv="" local custom_apps_lines="" + local predefined_branch="" local preset_branch="" local app="" local line="" @@ -120,25 +707,26 @@ build_stack_apps_json_content_from_metadata_apps() { preset_apps_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)" custom_apps_lines="$(get_metadata_apps_custom_lines "${metadata_path}" || true)" - preset_branch="$(get_default_frappe_branch)" + preset_branch="$(get_stack_frappe_branch "${stack_dir}" || true)" + if [ -z "${preset_branch}" ]; then + preset_branch="$(get_default_frappe_branch)" + fi if [ -n "${preset_apps_csv}" ]; then IFS=',' read -r -a preset_apps <<<"${preset_apps_csv}" for app in "${preset_apps[@]}"; do - case "${app}" in - erpnext) - url="https://github.com/frappe/erpnext" - ;; - crm) - url="https://github.com/frappe/crm" - ;; - *) - continue - ;; - esac + url="$(get_predefined_app_repo_by_id "${app}" || true)" + if [ -z "${url}" ]; then + return 1 + fi + + predefined_branch="$(get_metadata_apps_predefined_branch_for_id "${metadata_path}" "${app}" || true)" + if [ -z "${predefined_branch}" ]; then + predefined_branch="${preset_branch}" + fi escaped_url="$(json_escape_string "${url}")" - escaped_branch="$(json_escape_string "${preset_branch}")" + escaped_branch="$(json_escape_string "${predefined_branch}")" entry_json="$(printf ' {"url": "%s", "branch": "%s"}' "${escaped_url}" "${escaped_branch}")" if [ -z "${entries_json}" ]; then entries_json="${entry_json}" @@ -216,9 +804,17 @@ persist_stack_metadata_apps_object() { in_top_level_apps = 0 apps_depth = 0 inserted = 0 + prev = "" + } + function flush_prev() { + if (prev != "") { + print prev + prev = "" + } } { if (!in_top_level_apps && $0 ~ /^ "apps"[[:space:]]*:/) { + flush_prev() print " \"apps\": " apps_object "," in_top_level_apps = 1 inserted = 1 @@ -244,18 +840,122 @@ persist_stack_metadata_apps_object() { } if (!inserted && $0 ~ /^ "wizard"[[:space:]]*:/) { + flush_prev() print " \"apps\": " apps_object "," inserted = 1 } if (!inserted && $0 ~ /^}/) { + if (prev != "") { + if (prev !~ /,[[:space:]]*$/) { + prev = prev "," + } + print prev + prev = "" + } print " \"apps\": " apps_object inserted = 1 + print $0 + next } - print + flush_prev() + prev = $0 } END { + flush_prev() + if (!inserted) { + exit 2 + } + } + ' "${metadata_path}" >"${metadata_tmp_path}"; then + rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${metadata_tmp_path}" "${metadata_path}"; then + rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + +persist_stack_metadata_wizard_object() { + local stack_dir="${1}" + local wizard_json_object="${2}" + local metadata_path="" + local metadata_tmp_path="" + + metadata_path="${stack_dir}/metadata.json" + metadata_tmp_path="${metadata_path}.tmp" + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + if [ -z "${wizard_json_object}" ]; then + return 1 + fi + + if ! awk -v wizard_object="${wizard_json_object}" ' + BEGIN { + in_top_level_wizard = 0 + wizard_depth = 0 + inserted = 0 + prev = "" + } + function flush_prev() { + if (prev != "") { + print prev + prev = "" + } + } + { + if (!in_top_level_wizard && $0 ~ /^ "wizard"[[:space:]]*:/) { + flush_prev() + print " \"wizard\": " wizard_object + in_top_level_wizard = 1 + inserted = 1 + if ($0 ~ /{/) { + wizard_depth += gsub(/{/, "{", $0) + wizard_depth -= gsub(/}/, "}", $0) + } else { + wizard_depth = 0 + } + if (wizard_depth <= 0) { + in_top_level_wizard = 0 + } + next + } + + if (in_top_level_wizard) { + wizard_depth += gsub(/{/, "{", $0) + wizard_depth -= gsub(/}/, "}", $0) + if (wizard_depth <= 0) { + in_top_level_wizard = 0 + } + next + } + + if (!inserted && $0 ~ /^}/) { + if (prev != "") { + if (prev !~ /,[[:space:]]*$/) { + prev = prev "," + } + print prev + prev = "" + } + print " \"wizard\": " wizard_object + inserted = 1 + print $0 + next + } + + flush_prev() + prev = $0 + } + END { + flush_prev() if (!inserted) { exit 2 } diff --git a/scripts/easy-docker/lib/app/wizard/common/constants.sh b/scripts/easy-docker/lib/app/wizard/common/constants.sh index 4f61acc2..9809dc09 100755 --- a/scripts/easy-docker/lib/app/wizard/common/constants.sh +++ b/scripts/easy-docker/lib/app/wizard/common/constants.sh @@ -6,3 +6,4 @@ readonly FLOW_CONTINUE=0 readonly FLOW_BACK_TO_MAIN=10 readonly FLOW_EXIT_APP=11 readonly FLOW_ABORT_INPUT=12 +readonly FLOW_OPEN_MANAGE_STACK=13 diff --git a/scripts/easy-docker/lib/app/wizard/common/core.sh b/scripts/easy-docker/lib/app/wizard/common/core.sh index f4bdb28d..3402f3d1 100755 --- a/scripts/easy-docker/lib/app/wizard/common/core.sh +++ b/scripts/easy-docker/lib/app/wizard/common/core.sh @@ -3,7 +3,9 @@ get_easy_docker_repo_root() { local app_lib_dir="" app_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - (cd "${app_lib_dir}/../../../../.." && pwd) + # core.sh lives in scripts/easy-docker/lib/app/wizard/common + # so we need 6 levels up to reach repository root. + (cd "${app_lib_dir}/../../../../../.." && pwd) } get_easy_docker_stacks_dir() { @@ -35,6 +37,7 @@ create_stack_directory_with_metadata() { local stack_dir_var="${1}" local stack_name="${2}" local setup_type="${3:-production}" + local frappe_branch="${4:-}" local stacks_dir="" local created_stack_dir="" local metadata_path="" @@ -57,11 +60,16 @@ create_stack_directory_with_metadata() { fi created_at="$(get_current_utc_timestamp)" + if [ -z "${frappe_branch}" ]; then + return 1 + fi + if ! cat >"${metadata_path}" <&2 + status_text="$(printf "Stack: %s\n\nSelect branch for %s (%s)\nRepo: %s\n%s" "${stack_dir##*/}" "${app_label}" "${app_id}" "${repo_url}" "${default_hint}")" + render_box_message "${status_text}" "0 2" >&2 + + if selection="$( + gum choose \ + --height 16 \ + --header "Branch selection (${app_label})" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "${branch_options[@]}" \ + "Back to app selection" + )"; then + : + else + return 2 + fi + + case "${selection}" in + "Back to app selection" | "") + return 2 + ;; + *) + printf -v "${result_var}" "%s" "${selection}" + return 0 + ;; + esac } prompt_custom_modular_apps_data() { local result_apps_metadata_var="${1}" local stack_dir="${2}" - local back_option_label="${3:-Back to topology selection}" + local metadata_path="" + local options_lines="" + local selected_labels_csv="" local selection_raw="" - local selection="" - local preset_apps_csv="" - local has_custom_apps=0 local prompt_status=0 - local preset_app="" - local preset_repo_url="" - local preset_branch="" - local escaped_url="" - local escaped_branch="" - local apps_entry="" - local metadata_predefined_entry="" - local custom_apps_entries="" - local metadata_custom_entries="" - local apps_entries="" - local metadata_predefined_entries="" + local selected_predefined_csv="" + local parsed_predefined_csv="" + local selected_label="" + local predefined_app_id="" + local predefined_app_label="" + local predefined_repo_url="" + local selected_branch="" + local preferred_branch="" + local existing_branch_lines="" + local selected_branch_lines="" + local selected_app_count=0 local built_apps_metadata_json_object="" - local -a selections=() - local -a preset_apps=() + local -a predefined_catalog_entries=() + local -a selected_predefined_ids=() + + metadata_path="${stack_dir}/metadata.json" + if [ -f "${metadata_path}" ]; then + selected_predefined_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)" + existing_branch_lines="$(get_metadata_apps_predefined_branch_lines "${metadata_path}" || true)" + fi while true; do - selection_raw="$(show_custom_modular_apps_multi_select "${stack_dir}" "${back_option_label}" || true)" - prompt_status=$? + options_lines="" + selected_labels_csv="" + predefined_catalog_entries=() + + mapfile -t predefined_catalog_entries < <(get_predefined_apps_catalog_entries || true) + for selected_label in "${predefined_catalog_entries[@]}"; do + IFS='|' read -r predefined_app_id predefined_app_label predefined_repo_url _ _ <<<"${selected_label}" + if [ -z "${predefined_app_id}" ] || [ -z "${predefined_app_label}" ]; then + continue + fi + + if [ -z "${options_lines}" ]; then + options_lines="$(printf '%s' "${predefined_app_label}")" + else + options_lines="$(printf '%s\n%s' "${options_lines}" "${predefined_app_label}")" + fi + done + + if [ -n "${selected_predefined_csv}" ]; then + IFS=',' read -r -a selected_predefined_ids <<<"${selected_predefined_csv}" + for predefined_app_id in "${selected_predefined_ids[@]}"; do + if [ -z "${predefined_app_id}" ]; then + continue + fi + predefined_app_label="$(get_predefined_app_label_by_id "${predefined_app_id}" || true)" + if [ -z "${predefined_app_label}" ]; then + continue + fi + append_csv_unique selected_labels_csv "${selected_labels_csv}" "${predefined_app_label}" + done + fi + + if [ -z "${options_lines}" ]; then + show_warning_and_wait "No apps available in catalog." 3 + return 1 + fi + + if selection_raw="$(show_custom_modular_apps_multi_select "${stack_dir}" "${options_lines}" "${selected_labels_csv}")"; then + prompt_status=0 + else + prompt_status=$? + fi if [ "${prompt_status}" -ne 0 ]; then return 2 fi if [ -z "${selection_raw}" ]; then - show_warning_message "Select at least one app option." + show_warning_message "Select at least one app." continue fi - preset_apps_csv="" - has_custom_apps=0 - custom_apps_entries="" - metadata_custom_entries="" - apps_entries="" - metadata_predefined_entries="" + parsed_predefined_csv="" - mapfile -t selections <<<"${selection_raw}" - for selection in "${selections[@]}"; do - case "${selection}" in - "ERPNext") - if [ -z "${preset_apps_csv}" ]; then - preset_apps_csv="erpnext" - else - preset_apps_csv="${preset_apps_csv},erpnext" + while IFS= read -r selected_label; do + if [ -z "${selected_label}" ]; then + continue + fi + + predefined_app_id="$(get_predefined_app_id_by_label "${selected_label}" || true)" + if [ -z "${predefined_app_id}" ]; then + continue + fi + append_csv_unique parsed_predefined_csv "${parsed_predefined_csv}" "${predefined_app_id}" + done </dev/null 2>&1; then + show_warning_and_wait "Could not load scripts/easy-docker/config/apps.tsv. Check format before adding new entries." 3 + return 1 + fi + + while true; do + if prompt_tools_apps_catalog_value_with_back \ + app_label \ + "App Label" \ + "Display name used in the app selection list." \ + "My Custom App"; then + : + else + input_status=$? + return "${input_status}" + fi + + trim_predefined_catalog_field app_label "${app_label}" + if [ -z "${app_label}" ]; then + show_warning_and_wait "App label is required." 2 + continue + fi + + if predefined_app_catalog_has_label "${app_label}"; then + show_warning_and_wait "App label already exists in apps.tsv: ${app_label}" 2 + continue + fi + + if ! generate_predefined_app_id_from_label app_id "${app_label}"; then + show_warning_and_wait "Could not generate a valid app id from label. Use letters/numbers and simple separators." 2 + continue + fi + + if predefined_app_catalog_has_id "${app_id}"; then + show_warning_and_wait "Generated app id already exists (${app_id}). Choose a different label." 2 + continue + fi + + break + done + + while true; do + if prompt_tools_apps_catalog_value_with_back \ + app_repo \ + "Repository URL" \ + "Git repository URL for this app." \ + "https://github.com/acme/my-custom-app"; then + : + else + input_status=$? + return "${input_status}" + fi + + if ! is_valid_predefined_app_repo "${app_repo}"; then + show_warning_and_wait "Invalid repository URL. Use https/http/ssh/git formats." 2 + continue + fi + + break + done + + while true; do + if prompt_tools_apps_catalog_value_with_back \ + app_branches_csv \ + "Branches (CSV)" \ + "Comma-separated branches for selection. Example: version-15,version-16,develop" \ + "version-15,version-16,develop"; then + : + else + input_status=$? + return "${input_status}" + fi + + if ! normalize_predefined_branches_csv normalized_branches_csv "${app_branches_csv}"; then + show_warning_and_wait "Invalid branch list. Use a comma-separated list with valid branch names." 2 + continue + fi + + break + done + + while true; do + if prompt_tools_apps_default_branch_from_csv_with_back app_default_branch "${normalized_branches_csv}"; then + : + else + input_status=$? + if [ "${input_status}" -eq "${FLOW_ABORT_INPUT}" ]; then + return "${FLOW_ABORT_INPUT}" + fi + show_warning_and_wait "Could not select default branch from branch list." 2 + return "${input_status}" + fi + break + done + + if ! append_predefined_app_catalog_entry "${app_id}" "${app_label}" "${app_repo}" "${app_default_branch}" "${normalized_branches_csv}"; then + show_warning_and_wait "Could not append app entry to scripts/easy-docker/config/apps.tsv." 3 + return 1 + fi + + show_warning_and_wait "App added to apps.tsv: ${app_label} (${app_id})" 2 + return 0 +} + +handle_tools_flow() { + local tools_action="" + + while true; do + tools_action="$(show_tools_menu || true)" + + case "${tools_action}" in + "Add Apps for App Selection") + run_add_app_catalog_entry_wizard || true + ;; + "Back to main menu" | "") + return "${FLOW_BACK_TO_MAIN}" + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown tools action: ${tools_action}" + ;; + esac + done +} diff --git a/scripts/easy-docker/lib/app/wizard/single_host.sh b/scripts/easy-docker/lib/app/wizard/single_host.sh index 90a0f824..87ff2ecb 100755 --- a/scripts/easy-docker/lib/app/wizard/single_host.sh +++ b/scripts/easy-docker/lib/app/wizard/single_host.sh @@ -155,56 +155,19 @@ persist_single_host_selection_metadata() { local database_id="${3}" local redis_id="${4}" local compose_files_lines="${5}" - local apps_json_object="${6}" - local env_lines="${7}" - local metadata_path="" - local metadata_tmp_path="" - local schema_version="" - local stack_name="" - local setup_type="" - local created_at="" + local env_lines="${6}" local updated_at="" local compose_files_json="" local env_json_object="" - - metadata_path="${stack_dir}/metadata.json" - metadata_tmp_path="${metadata_path}.tmp" - - schema_version="$(get_metadata_number_field "${metadata_path}" "schema_version" || true)" - if [ -z "${schema_version}" ]; then - schema_version="1" - fi - - stack_name="$(get_metadata_string_field "${metadata_path}" "stack_name" || true)" - if [ -z "${stack_name}" ]; then - stack_name="${stack_dir##*/}" - fi - - setup_type="$(get_metadata_string_field "${metadata_path}" "setup_type" || true)" - if [ -z "${setup_type}" ]; then - setup_type="production" - fi - - created_at="$(get_metadata_string_field "${metadata_path}" "created_at" || true)" - if [ -z "${created_at}" ]; then - created_at="$(get_current_utc_timestamp)" - fi + local wizard_json_object="" updated_at="$(get_current_utc_timestamp)" compose_files_json="$(build_compose_files_json_array "${compose_files_lines}")" env_json_object="$(build_env_json_object "${env_lines}")" - if [ -z "${apps_json_object}" ]; then - return 1 - fi - if ! cat >"${metadata_tmp_path}" </dev/null 2>&1 || true + )"; then return 1 fi - if ! mv -- "${metadata_tmp_path}" "${metadata_path}"; then - rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + if ! persist_stack_metadata_wizard_object "${stack_dir}" "${wizard_json_object}"; then return 1 fi @@ -245,6 +206,8 @@ save_single_host_selection() { local compose_files_lines="" local env_lines="" local apps_metadata_json_object="" + local wizard_metadata_status=0 + local apps_metadata_status=0 local collect_env_status=0 proxy_mode_id="$(get_single_host_proxy_mode_id "${proxy_mode}")" || return 1 @@ -261,7 +224,9 @@ save_single_host_selection() { fi compose_files_lines="$(printf '%s\n%s' "${compose_files_lines}" "${proxy_overrides}")" - if ! collect_single_host_env_lines env_lines apps_metadata_json_object "${stack_dir}" "${proxy_mode_id}" "${database_id}"; then + if collect_single_host_env_lines env_lines apps_metadata_json_object "${stack_dir}" "${proxy_mode_id}" "${database_id}"; then + : + else collect_env_status=$? return "${collect_env_status}" fi @@ -270,17 +235,30 @@ save_single_host_selection() { return 1 fi - if ! persist_single_host_selection_metadata \ + if persist_single_host_selection_metadata \ "${stack_dir}" \ "${proxy_mode_id}" \ "${database_id}" \ "${redis_id}" \ "${compose_files_lines}" \ - "${apps_metadata_json_object}" \ "${env_lines}"; then + : + else + wizard_metadata_status=$? + return "${wizard_metadata_status}" + fi + + if [ -z "${apps_metadata_json_object}" ]; then return 1 fi + if persist_stack_metadata_apps_object "${stack_dir}" "${apps_metadata_json_object}"; then + : + else + apps_metadata_status=$? + return "${apps_metadata_status}" + fi + if ! persist_stack_apps_json_from_metadata_apps "${stack_dir}"; then return 1 fi diff --git a/scripts/easy-docker/lib/ui/screens.sh b/scripts/easy-docker/lib/ui/screens.sh index 80456d69..d6d917ad 100755 --- a/scripts/easy-docker/lib/ui/screens.sh +++ b/scripts/easy-docker/lib/ui/screens.sh @@ -10,6 +10,8 @@ load_ui_screen_modules() { source "${screens_dir}/production.sh" # shellcheck source=scripts/easy-docker/lib/ui/screens/environment.sh source "${screens_dir}/environment.sh" + # shellcheck source=scripts/easy-docker/lib/ui/screens/tools.sh + source "${screens_dir}/tools.sh" } load_ui_screen_modules diff --git a/scripts/easy-docker/lib/ui/screens/base.sh b/scripts/easy-docker/lib/ui/screens/base.sh index 2b02c3fc..18215e7b 100755 --- a/scripts/easy-docker/lib/ui/screens/base.sh +++ b/scripts/easy-docker/lib/ui/screens/base.sh @@ -74,12 +74,13 @@ render_main_screen() { show_main_menu() { gum choose \ - --height 8 \ + --height 10 \ --header "Choose an action" \ --cursor.foreground 63 \ --selected.foreground 45 \ "Production Stack" \ "Development Stack" \ + "Tools" \ "Environment check" \ "Exit" } diff --git a/scripts/easy-docker/lib/ui/screens/production.sh b/scripts/easy-docker/lib/ui/screens/production.sh index 4098d6e0..a28df88d 100755 --- a/scripts/easy-docker/lib/ui/screens/production.sh +++ b/scripts/easy-docker/lib/ui/screens/production.sh @@ -59,6 +59,46 @@ prompt_new_stack_name() { --placeholder "my-production-stack" } +show_frappe_version_profile_menu() { + local stack_name="${1}" + local options_lines="${2:-}" + local selected_label="${3:-}" + local status_text="" + local option_line="" + local -a menu_options=() + local -a gum_args=() + + render_main_screen 1 >&2 + + status_text="$(printf "Create stack: %s\n\nSelect the Frappe branch profile from frappe.tsv.\nThis sets the stack default for branch suggestions." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + while IFS= read -r option_line; do + if [ -z "${option_line}" ]; then + continue + fi + menu_options+=("${option_line}") + done <&2 stack_name="${stack_dir##*/}" - status_text="$(printf "Stack: %s\n\nCustom modular apps\nSelect one or more options.\nUse Space to toggle and Enter to confirm." "${stack_name}")" + status_text="$(printf "Stack: %s\n\nApps\nUse Space to toggle apps from apps.tsv. Press Enter to continue to branch selection per app.\nUse Ctrl+C to go back." "${stack_name}")" render_box_message "${status_text}" "0 2" >&2 - gum choose \ - --no-limit \ - --height 10 \ - --header "Custom modular apps" \ - --cursor.foreground 63 \ - --selected.foreground 45 \ - "ERPNext" \ - "CRM" \ - "Custom Git app(s)" \ - "${back_option_label}" + while IFS= read -r option_line; do + if [ -z "${option_line}" ]; then + continue + fi + menu_options+=("${option_line}") + done <&2 + + status_text="$(printf "Tools\n\nManage helper wizards for easy-docker.\nUse this area to maintain the app catalog shown in app selection.")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Tools - App Catalog Utilities" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Add Apps for App Selection" \ + "Back to main menu" \ + "Exit and close easy-docker" +} + +prompt_tools_apps_catalog_input() { + local field_label="${1}" + local help_text="${2}" + local placeholder="${3:-}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Tools\n\nAdd Apps for App Selection\nThis wizard updates scripts/easy-docker/config/apps.tsv used by app selection.\n\n%s\nType /back or press Ctrl+C to cancel." "${help_text}")" + render_box_message "${status_text}" "0 2" >&2 + + gum input \ + --header "${field_label}" \ + --prompt "value> " \ + --placeholder "${placeholder}" +} + +show_tools_apps_default_branch_menu() { + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Tools\n\nAdd Apps for App Selection\nSelect the default branch from the configured branch list.\nUse Ctrl+C or choose Back to return.")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 14 \ + --header "Default Branch - Choose from List" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "$@" \ + "Back" +}