diff --git a/libexec/mcserver-kit/compose-state.py b/libexec/mcserver-kit/compose-state.py new file mode 100644 index 0000000..010486e --- /dev/null +++ b/libexec/mcserver-kit/compose-state.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Classify Docker Compose's JSON container listing for the CLI and TUI.""" + +import json +import sys + + +def classify(raw: str) -> str: + raw = raw.strip() + if not raw: + return "absent" + + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + try: + parsed = [json.loads(line) for line in raw.splitlines()] + except json.JSONDecodeError: + return "unavailable" + + if isinstance(parsed, dict): + containers = [parsed] + elif isinstance(parsed, list) and all(isinstance(item, dict) for item in parsed): + containers = parsed + else: + return "unavailable" + + if any( + item.get("Service") == "minecraft" + and str(item.get("State", "")).lower() == "running" + for item in containers + ): + return "running" + return "stopped" if containers else "absent" + + +if __name__ == "__main__": + print(classify(sys.stdin.read())) diff --git a/libexec/mcserver-kit/home-tui.sh b/libexec/mcserver-kit/home-tui.sh index 8988f0e..d88a8e9 100755 --- a/libexec/mcserver-kit/home-tui.sh +++ b/libexec/mcserver-kit/home-tui.sh @@ -68,12 +68,7 @@ tool_availability() { } server_status() { - local directory="$1" - if (cd "$directory" && docker compose ps --status running --services 2>/dev/null | grep -qx minecraft); then - tr home.running - else - tr home.stopped - fi + "${SCRIPT_DIR}/server-manager.sh" server "$(basename "$1")" state } logo() { @@ -100,7 +95,7 @@ dashboard_text() { for directory in "$root"/*; do [[ -d "$directory" && -f "${directory}/compose.yaml" ]] || continue total=$((total + 1)) - if (cd "$directory" && docker compose ps --status running --services 2>/dev/null | grep -qx minecraft); then + if [[ "$("${SCRIPT_DIR}/server-manager.sh" server "$(basename "$directory")" state --raw)" == running ]]; then running=$((running + 1)) fi done @@ -124,13 +119,30 @@ pause_for_enter() { run_and_show() { local title="$1" shift - local output + local output pid result=0 frame_index=0 + local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') new_temp_file output - if "$@" >"$output" 2>&1; then - whiptail --title "$title" --textbox "$output" 22 84 + "$@" >"$output" 2>&1 & + pid=$! + while kill -0 "$pid" 2>/dev/null; do + whiptail --title "$title" --infobox "$(tr home.processing "${frames[frame_index]}" "$title")" 8 72 + frame_index=$(((frame_index + 1) % ${#frames[@]})) + sleep 0.15 + done + wait "$pid" || result=$? + if [[ ! -s "$output" ]]; then + if ((result == 0)); then + tr home.completed >"$output" + else + tr home.failed_without_output >"$output" + fi + fi + if ((result == 0)); then + whiptail --title "$title" --textbox "$output" 22 84 || true else - whiptail --title "$(tr common.error)" --textbox "$output" 22 84 + whiptail --title "$(tr common.error)" --textbox "$output" 22 84 || true fi + RUN_RESULT=$result } server_action_menu() { @@ -138,6 +150,7 @@ server_action_menu() { while true; do choice="$(whiptail --title "$id" --menu "$(tr home.server_status "$(server_status "$directory")")" 23 78 13 \ start "$(tr home.start)" \ + down "$(tr home.down)" \ stop "$(tr home.stop)" \ restart "$(tr home.restart)" \ status "$(tr home.status)" \ @@ -145,15 +158,15 @@ server_action_menu() { properties "$(tr home.properties)" \ open-data "$(tr home.open_data)" \ open-server "$(tr home.open_server)" \ - down "$(tr home.down)" \ back "$(tr tui.back)" \ 3>&1 1>&2 2>&3)" || return case "$choice" in start | stop | restart | status) - run_and_show "$id" "${SCRIPT_DIR}/server-manager.sh" server "$id" "$choice" + run_and_show "$(tr "home.${choice}") · $id" "${SCRIPT_DIR}/server-manager.sh" server "$id" "$choice" ;; logs) clear + printf '%s\n\n' "$(tr home.logs_return_hint)" "${SCRIPT_DIR}/server-manager.sh" server "$id" logs || true pause_for_enter ;; @@ -172,7 +185,7 @@ server_action_menu() { ;; down) if whiptail --yesno "$(tr home.down_confirm "$id")" 10 72; then - run_and_show "$id" "${SCRIPT_DIR}/server-manager.sh" server "$id" down + run_and_show "$(tr home.down) · $id" "${SCRIPT_DIR}/server-manager.sh" server "$id" down fi ;; back) return ;; @@ -235,7 +248,7 @@ diagnostics() { } main() { - local root choice installed_version + local root choice installed_version latest command -v whiptail >/dev/null 2>&1 || { tr tui.missing >&2 exit 1 @@ -269,15 +282,18 @@ main() { language) language_menu ;; diagnostics) diagnostics ;; update) - clear - if "${SCRIPT_DIR}/update.sh"; then - installed_version="$(head -n 1 "${ROOT_DIR}/VERSION" 2>/dev/null || printf unknown)" - if [[ "$installed_version" != "$VERSION" ]]; then - clear - exec "${ROOT_DIR}/mcserver-kit" home + run_and_show "$(tr home.update)" "${SCRIPT_DIR}/update.sh" check + if ((RUN_RESULT == 0)); then + latest="$("${SCRIPT_DIR}/update.sh" --cached-quiet 2>/dev/null || true)" + if [[ -n "$latest" ]] && whiptail --yesno "$(tr home.update_confirm "$VERSION" "$latest")" 10 72; then + run_and_show "$(tr home.update)" "${SCRIPT_DIR}/update.sh" --yes + installed_version="$(head -n 1 "${ROOT_DIR}/VERSION" 2>/dev/null || printf unknown)" + if ((RUN_RESULT == 0)) && [[ "$installed_version" != "$VERSION" ]]; then + clear + exec "${ROOT_DIR}/mcserver-kit" home + fi fi fi - pause_for_enter ;; help) run_and_show "$(tr home.help)" "${ROOT_DIR}/mcserver-kit" --help ;; exit) return ;; diff --git a/libexec/mcserver-kit/i18n.sh b/libexec/mcserver-kit/i18n.sh index 617c5da..6b9c31f 100644 --- a/libexec/mcserver-kit/i18n.sh +++ b/libexec/mcserver-kit/i18n.sh @@ -43,10 +43,12 @@ load_messages() { load_catalog() { local catalog="$1" - local key encoded + local key encoded decoded while IFS=$'\t' read -r key encoded; do [[ -n "$key" ]] || continue - I18N_MESSAGES["$key"]="$(printf '%s' "$encoded" | base64 --decode)" + # The sentinel prevents command substitution from stripping translated newlines. + decoded="$(printf '%s' "$encoded" | base64 --decode; printf '\034')" + I18N_MESSAGES["$key"]="${decoded%$'\034'}" done < <(python3 - "$catalog" <<'PYTHON' import base64 import json diff --git a/libexec/mcserver-kit/server-manager.sh b/libexec/mcserver-kit/server-manager.sh index 0c24749..311abab 100755 --- a/libexec/mcserver-kit/server-manager.sh +++ b/libexec/mcserver-kit/server-manager.sh @@ -66,10 +66,7 @@ list_servers() { [[ -d "$directory" && -f "${directory}/compose.yaml" ]] || continue found=true printf '%-28s ' "$(basename "$directory")" - ( - cd "$directory" - docker compose ps --status running --services 2>/dev/null | grep -qx minecraft && printf '%s\n' "$(tr server.running)" || printf '%s\n' "$(tr server.stopped)" - ) + server_state "$directory" done shopt -u nullglob [[ "$found" == true ]] || printf '%s\n' "$(tr server.none)" @@ -84,6 +81,24 @@ compose_in() { ) } +# Compose emits either a JSON array or one object per line, depending on version. +# Keep these machine-readable values separate from the translated menu labels. +container_state() { + local directory="$1" listing + if ! listing="$(compose_in "$directory" ps -a --format json 2>/dev/null)"; then + printf 'unavailable\n' + return + fi + printf '%s\n' "$listing" | python3 "${SCRIPT_DIR}/compose-state.py" +} + +server_state() { + local state + state="$(container_state "$1")" + tr "server.state_${state}" + printf '\n' +} + require_stopped() { local directory="$1" running running="$(compose_in "$directory" ps --status running --services)" || die "$(tr server.status_failed)" @@ -174,15 +189,41 @@ manage_server() { compose_in "$directory" ps ;; status) - compose_in "$directory" ps + if [[ "$(container_state "$directory")" == absent ]]; then + tr server.no_containers + else + compose_in "$directory" ps -a + fi ;; logs) + if [[ "$(container_state "$directory")" == absent ]]; then + tr server.no_containers_logs + return + fi if [[ "${1-}" == '--no-follow' ]]; then - compose_in "$directory" logs minecraft + local log_file result=0 + log_file="$(mktemp /tmp/mcserver-kit-logs.XXXXXX)" + compose_in "$directory" logs minecraft >"$log_file" || result=$? + if [[ -s "$log_file" ]]; then + cat -- "$log_file" + elif ((result == 0)); then + tr server.no_logs + fi + rm -f -- "$log_file" + if ((result != 0)); then + return "$result" + fi else compose_in "$directory" logs --follow minecraft fi ;; + state) + if [[ "${1-}" == '--raw' ]]; then + container_state "$directory" + else + server_state "$directory" + fi + ;; down) printf '%s\n' "$(tr server.down "$id")" compose_in "$directory" down diff --git a/share/mcserver-kit/locales/en.json b/share/mcserver-kit/locales/en.json index 4618302..5bcd815 100644 --- a/share/mcserver-kit/locales/en.json +++ b/share/mcserver-kit/locales/en.json @@ -35,7 +35,7 @@ "home.help": "Help", "home.exit": "Exit", "home.start": "Start", - "home.stop": "Stop", + "home.stop": "Stop (keep container)", "home.restart": "Restart", "home.status": "Show status", "home.logs": "Follow logs", @@ -44,8 +44,13 @@ "home.import_confirm": "Import server.properties for %s? Stop the server first. The current file will be backed up. Imported settings will be managed in server.env.", "home.open_data": "Open persistent data in Explorer", "home.open_server": "Open server folder in Explorer", - "home.down": "Remove containers", + "home.down": "Remove containers (keep world)", "home.down_confirm": "Remove containers and networks for %s? World data is preserved.", + "home.processing": "%s Working on %s...", + "home.completed": "Completed.\n", + "home.failed_without_output": "The operation failed without a diagnostic message.\n", + "home.logs_return_hint": "Live logs: press Ctrl+C to stop following, then Enter to return. If no container exists, there are no logs to show.", + "home.update_confirm": "Install update %s -> %s now?", "home.diagnostics_title": "Environment diagnostics", "home.available": "available", "home.unavailable": "unavailable", @@ -133,6 +138,13 @@ "server.list_status": "STATUS", "server.running": "running", "server.stopped": "stopped", + "server.state_running": "running", + "server.state_stopped": "stopped (container retained)", + "server.state_absent": "no container", + "server.state_unavailable": "Docker unavailable", + "server.no_containers": "No containers exist for this server. Its world data is still on disk.\n", + "server.no_containers_logs": "No containers exist for this server, so there are no container logs.\n", + "server.no_logs": "This container has no logs yet.\n", "server.none": "(no servers)", "server.usage": "Usage: mcserver-kit server SERVER_ID start|stop|restart|status|logs|down|properties|import-properties|open", "server.status_failed": "Could not check whether the server is running. Check Docker and try again.", diff --git a/share/mcserver-kit/locales/ja.json b/share/mcserver-kit/locales/ja.json index 94fdf86..887f652 100644 --- a/share/mcserver-kit/locales/ja.json +++ b/share/mcserver-kit/locales/ja.json @@ -35,7 +35,7 @@ "home.help": "ヘルプ", "home.exit": "終了", "home.start": "起動", - "home.stop": "停止", + "home.stop": "停止(コンテナを残す)", "home.restart": "再起動", "home.status": "状態を表示", "home.logs": "ログを表示", @@ -44,8 +44,13 @@ "home.import_confirm": "%sにserver.propertiesをインポートしますか?先にサーバーを停止してください。既存ファイルをバックアップし、インポートした設定はserver.envで管理します。", "home.open_data": "永続データをExplorerで開く", "home.open_server": "サーバーフォルダをExplorerで開く", - "home.down": "コンテナを削除", + "home.down": "コンテナを削除(ワールドは保持)", "home.down_confirm": "%sのコンテナとネットワークを削除しますか?ワールドデータは保持されます。", + "home.processing": "%s %sを処理中...", + "home.completed": "処理が完了しました。\n", + "home.failed_without_output": "処理に失敗しましたが、詳細なエラーメッセージはありません。\n", + "home.logs_return_hint": "ログの追跡を止めるにはCtrl+C、その後Enterで戻ります。コンテナがない場合はログもありません。", + "home.update_confirm": "%sから%sへ更新しますか?", "home.diagnostics_title": "動作環境の診断", "home.available": "利用可能", "home.unavailable": "利用不可", @@ -133,6 +138,13 @@ "server.list_status": "状態", "server.running": "起動中", "server.stopped": "停止中", + "server.state_running": "起動中", + "server.state_stopped": "停止中(コンテナあり)", + "server.state_absent": "コンテナなし", + "server.state_unavailable": "Dockerを利用できません", + "server.no_containers": "このサーバーのコンテナはありません。ワールドデータはディスクに残っています。\n", + "server.no_containers_logs": "このサーバーのコンテナがないため、コンテナログはありません。\n", + "server.no_logs": "このコンテナにはまだログがありません。\n", "server.none": "(サーバーがありません)", "server.usage": "使い方: mcserver-kit server サーバーID start|stop|restart|status|logs|down|properties|import-properties|open", "server.status_failed": "サーバーが起動中か確認できませんでした。Dockerを確認して再試行してください。", diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 0fd9bed..2833167 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -101,6 +101,11 @@ JSON python3 "${REPO_ROOT}/scripts/validate-locales.py" "${locale_root}/locales" 2>"$validation_log" >/dev/null assert_equal 'present' "$(grep -q 'missing keys use English fallback: new_key' "$validation_log" && printf present)" \ 'locale validation reports missing translations without failing' + + MCSERVER_KIT_LANG=en bash -c 'source "$1"; load_messages; tr update.up_to_date v1.1.3' \ + _ "${REPO_ROOT}/libexec/mcserver-kit/i18n.sh" >"${locale_root}/newline" + assert_equal '10' "$(tail -c 1 "${locale_root}/newline" | od -An -tu1 | tr -d ' ')" \ + 'locale messages preserve their final newline' } test_installer_version_selection() { @@ -494,13 +499,18 @@ paths: CONFIG cat >"${fake_bin}/whiptail" <<'WHIPTAIL' #!/usr/bin/env bash -printf 'menu\n' >>"$MCSERVER_KIT_TEST_EVENTS" -if [[ ! -f "$MCSERVER_KIT_TEST_MENU_COUNT" ]]; then - : >"$MCSERVER_KIT_TEST_MENU_COUNT" - printf 'update' >&2 -else - printf 'exit' >&2 -fi +case " $* " in + *' --menu '*) + printf 'menu\n' >>"$MCSERVER_KIT_TEST_EVENTS" + if [[ ! -f "$MCSERVER_KIT_TEST_MENU_COUNT" ]]; then + : >"$MCSERVER_KIT_TEST_MENU_COUNT" + printf 'update' >&2 + else + printf 'exit' >&2 + fi ;; + *' --infobox '*) printf 'loading\n' >>"$MCSERVER_KIT_TEST_EVENTS" ;; + *' --textbox '*) printf 'result\n' >>"$MCSERVER_KIT_TEST_EVENTS" ;; +esac WHIPTAIL cat >"${fake_bin}/clear" <<'CLEAR' #!/usr/bin/env bash @@ -512,16 +522,16 @@ printf '%s' "$MCSERVER_KIT_TEST_LATEST_URL" CURL chmod +x "${fake_bin}/whiptail" "${fake_bin}/clear" "${fake_bin}/curl" - output="$(printf '\n' | PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en \ + output="$(PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en \ MCSERVER_KIT_CONFIG="$config_file" MCSERVER_KIT_TUI_TEST=true \ MCSERVER_KIT_CURRENT_VERSION="$repo_version" \ MCSERVER_KIT_TEST_LATEST_URL="https://github.com/cotore-game/mcserver-compose-kit/releases/tag/v${repo_version}" \ MCSERVER_KIT_UPDATE_CACHE_DIR="${temp_dir}/home-transition/cache" \ MCSERVER_KIT_TEST_EVENTS="$event_log" MCSERVER_KIT_TEST_MENU_COUNT="$menu_count" \ bash "${REPO_ROOT}/mcserver-kit" home)" - assert_equal 'present' "$(grep -Fq "v${repo_version} is up to date." <<<"$output" && printf present)" 'up-to-date status is shown before returning to the home menu' - assert_equal 'present' "$(grep -Fq 'Press Enter to return' <<<"$output" && printf present)" 'up-to-date status waits for Enter instead of restarting the TUI' - assert_equal $'menu\nclear\nclear\nmenu' "$(<"$event_log")" 'terminal output is cleared before the next TUI menu' + assert_equal 'present' "$(grep -Fq 'loading' "$event_log" && printf present)" 'update checks retain a progress dialog' + assert_equal 'present' "$(grep -Fq 'result' "$event_log" && printf present)" 'update result is shown in a persistent dialog' + assert_equal 'menu' "$(tail -n 1 "$event_log")" 'the home menu returns after viewing the update result' } test_setup_command() { @@ -605,6 +615,17 @@ CONFIG assert_equal '600' "$(stat -c '%a' "$config_file")" 'config editor preserves restricted permissions' } +test_compose_state_parser() { + local parser="${REPO_ROOT}/libexec/mcserver-kit/compose-state.py" + assert_equal 'absent' "$(printf '\n' | python3 "$parser")" 'empty Compose output means no containers' + assert_equal 'absent' "$(printf '[]\n' | python3 "$parser")" 'an empty Compose array means no containers' + assert_equal 'running' "$(printf '[{"Service":"minecraft","State":"running"}]\n' | python3 "$parser")" 'array output detects the running server' + assert_equal 'stopped' "$(printf '{"Service":"minecraft","State":"exited"}\n' | python3 "$parser")" 'object output detects a retained container' + assert_equal 'running' "$(printf '%s\n%s\n' '{"Service":"playit","State":"running"}' '{"Service":"minecraft","State":"running"}' | python3 "$parser")" 'newline-delimited output finds the Minecraft container' + assert_equal 'unavailable' "$(printf '{invalid\n' | python3 "$parser")" 'malformed Compose JSON is not mistaken for no container' + assert_equal 'unavailable' "$(printf '123\n' | python3 "$parser")" 'unexpected Compose JSON shapes are rejected' +} + test_server_management() { local temp_dir="$1" local root="${temp_dir}/server-management/servers" @@ -622,9 +643,16 @@ CONFIG cat >"${fake_bin}/docker" <<'DOCKER' #!/usr/bin/env bash printf '%s|%s\n' "$PWD" "$*" >>"$MCSERVER_KIT_TEST_DOCKER_LOG" -if [[ "$*" == 'compose ps --status running --services' ]]; then - printf 'minecraft\n' -fi +case "$*" in + 'compose ps -a --format json') + case "${MCSERVER_KIT_TEST_DOCKER_STATE:-running}" in + running) printf '{"Service":"minecraft","State":"running"}\n' ;; + exited) printf '[{"Service":"minecraft","State":"exited"}]\n' ;; + absent) : ;; + unavailable) exit 1 ;; + esac ;; + 'compose ps -a') printf 'NAME SERVICE STATUS\n' ;; +esac DOCKER chmod +x "${fake_bin}/docker" @@ -632,6 +660,27 @@ DOCKER MCSERVER_KIT_TEST_DOCKER_LOG="$docker_log" bash "${REPO_ROOT}/mcserver-kit" list)" assert_equal 'present' "$(grep -q 'alpha.*running' <<<"$output" && printf present)" 'list shows managed servers and their status' + output="$(PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en MCSERVER_KIT_CONFIG="$config_file" \ + MCSERVER_KIT_TEST_DOCKER_STATE=exited MCSERVER_KIT_TEST_DOCKER_LOG="$docker_log" \ + bash "${REPO_ROOT}/mcserver-kit" list)" + assert_equal 'present' "$(grep -q 'alpha.*stopped (container retained)' <<<"$output" && printf present)" \ + 'list distinguishes stopped containers from removed containers' + output="$(PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en MCSERVER_KIT_CONFIG="$config_file" \ + MCSERVER_KIT_TEST_DOCKER_STATE=absent MCSERVER_KIT_TEST_DOCKER_LOG="$docker_log" \ + bash "${REPO_ROOT}/mcserver-kit" server alpha status)" + assert_equal 'present' "$(grep -q 'No containers exist' <<<"$output" && printf present)" \ + 'status explains an empty container list' + output="$(PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en MCSERVER_KIT_CONFIG="$config_file" \ + MCSERVER_KIT_TEST_DOCKER_STATE=absent MCSERVER_KIT_TEST_DOCKER_LOG="$docker_log" \ + bash "${REPO_ROOT}/mcserver-kit" server alpha logs --no-follow)" + assert_equal 'present' "$(grep -q 'there are no container logs' <<<"$output" && printf present)" \ + 'logs explain when no container exists' + output="$(PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en MCSERVER_KIT_CONFIG="$config_file" \ + MCSERVER_KIT_TEST_DOCKER_STATE=unavailable MCSERVER_KIT_TEST_DOCKER_LOG="$docker_log" \ + bash "${REPO_ROOT}/mcserver-kit" list)" + assert_equal 'present' "$(grep -q 'Docker unavailable' <<<"$output" && printf present)" \ + 'Docker failures are not mislabeled as stopped servers' + PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en MCSERVER_KIT_CONFIG="$config_file" \ MCSERVER_KIT_TEST_DOCKER_LOG="$docker_log" bash "${REPO_ROOT}/mcserver-kit" server alpha start >/dev/null assert_equal 'present' "$(grep -qF "${root}/alpha|compose config --quiet" "$docker_log" && printf present)" 'start validates compose in the selected server directory' @@ -919,6 +968,7 @@ main() { test_setup_command "$TEST_TEMP_DIR" test_reset_command "$TEST_TEMP_DIR" test_config_value_editor "$TEST_TEMP_DIR" + test_compose_state_parser test_server_management "$TEST_TEMP_DIR" test_server_property_editor "$TEST_TEMP_DIR" test_property_import_and_explorer "$TEST_TEMP_DIR"