diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..26edebd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# This WSL-first project keeps text files in LF format on every platform. +* text=auto eol=lf diff --git a/.github/workflows/shell-tests.yml b/.github/workflows/shell-tests.yml index cf00f0e..ef28ab6 100644 --- a/.github/workflows/shell-tests.yml +++ b/.github/workflows/shell-tests.yml @@ -9,6 +9,7 @@ on: branches: - main - develop + - 'release/**' permissions: contents: read diff --git a/README-JA.md b/README-JA.md index c9374f7..4fea211 100644 --- a/README-JA.md +++ b/README-JA.md @@ -170,6 +170,9 @@ mcserver-kit server logs mcserver-kit server logs --no-follow mcserver-kit server down mcserver-kit server properties +mcserver-kit server import-properties /path/to/server.properties +mcserver-kit server open data +mcserver-kit server open server ``` `stop`と`shutdown`はコンテナを削除せず停止します。`down`はコンテナとネットワークを削除します。いずれもサーバーの`data/`は削除しません。 @@ -184,7 +187,11 @@ mcserver-kit server properties MOTD、難易度、ゲームモード、最大人数、オンラインモード、ホワイトリスト、OP、飛行、コマンドブロック、PvP、描画・シミュレーション距離、スポーン保護、ネザー、Mob/NPC生成、リソースパックなどを編集できます。 -ツールが管理する設定の正本は各サーバーの`server.env`です。Docker Composeが値を`itzg/minecraft-server`へ渡し、コンテナ起動時に`server.properties`へ反映します。 +通常、ツールが管理する設定の正本は各サーバーの`server.env`です。Docker Composeが値を`itzg/minecraft-server`へ渡し、コンテナ起動時に`server.properties`へ反映します。 + +配布された`server.properties`を使う場合は、サーバーを停止して「サーバー設定 → server.propertiesをインポート」を選ぶか、`mcserver-kit server import-properties /path/to/server.properties`を実行します。Windowsダイアログを有効にしているWSL環境では、設定画面からWindowsのファイル選択画面を開けます。既存のデータファイルは置換前にバックアップします。対応する項目を`server.env`へ取り込み、その他のキーは`CUSTOM_SERVER_PROPERTIES`へ保存します。設定の正本は引き続き`server.env`で、元ファイルも`data/server.properties`へコピーします。ワールド名やポートがこのサーバー構成と異なる場合、または未対応の書式の場合は黙って変更せずエラーにします。サーバー起動中にファイルを直接編集しないでください。 + +ホーム画面からサーバーフォルダ、または永続データの`data/`をWindowsのExplorerで開けます。コマンドでは`mcserver-kit server open server`または`open data`です。WSLとExplorerの連携が必要です。 古い形式のサーバーを初めて開く場合は、移行前に確認画面を表示します。元のComposeは`compose.yaml.mcserver-kit.bak`として保存します。 diff --git a/README.md b/README.md index 6984bb6..da3563c 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,9 @@ mcserver-kit server logs mcserver-kit server logs --no-follow mcserver-kit server down mcserver-kit server properties +mcserver-kit server import-properties /path/to/server.properties +mcserver-kit server open data +mcserver-kit server open server ``` `stop` and `shutdown` stop the container without removing it. `down` removes the container and network. These commands do not delete the server's `data/` directory. @@ -183,7 +186,11 @@ mcserver-kit server properties The editor covers MOTD, difficulty, game mode, player limit, online mode, whitelist, operators, flight, command blocks, PvP, view and simulation distance, spawn protection, Nether and entity spawning, and resource packs. -`server.env` is the source of truth for settings managed by the toolkit. Docker Compose passes these values to `itzg/minecraft-server`, which applies them to `server.properties` when the container starts. +Normally, `server.env` is the source of truth for settings managed by the toolkit. Docker Compose passes these values to `itzg/minecraft-server`, which applies them to `server.properties` when the container starts. + +For a distributed `server.properties`, stop the server and choose **Server settings → Import server.properties**, or run `mcserver-kit server import-properties /path/to/server.properties`. On WSL, the settings screen offers a Windows file picker when Windows dialogs are enabled. The existing data file is backed up before replacement. Supported properties are imported into `server.env`; additional keys are stored in `CUSTOM_SERVER_PROPERTIES`. `server.env` remains the source of truth, and the imported file is also copied to `data/server.properties`. Imports with a nonstandard world name, port, or unsupported property syntax are rejected rather than silently changing the server layout. Do not edit the file while the server is running. + +The dashboard can also open the server folder or its persistent `data/` folder in Windows Explorer. From a terminal, use `mcserver-kit server open server` or `open data`. This requires WSL interop with Explorer. When an older server is opened for the first time, the editor asks before migrating it. The original Compose file is saved as `compose.yaml.mcserver-kit.bak`. diff --git a/VERSION b/VERSION index 524cb55..45a1b3f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.1 +1.1.2 diff --git a/libexec/mcserver-kit/home-tui.sh b/libexec/mcserver-kit/home-tui.sh index d7f397e..8988f0e 100755 --- a/libexec/mcserver-kit/home-tui.sh +++ b/libexec/mcserver-kit/home-tui.sh @@ -116,6 +116,9 @@ dashboard_text() { pause_for_enter() { printf '\n%s' "$(tr home.press_enter)" read -r _ || true + # whiptail restores the underlying terminal screen when a dialog closes. + # Do not leave output from the previous CLI action beneath the next menu. + clear } run_and_show() { @@ -131,15 +134,17 @@ run_and_show() { } server_action_menu() { - local id="$1" directory="$2" choice + local id="$1" directory="$2" choice output while true; do - choice="$(whiptail --title "$id" --menu "$(tr home.server_status "$(server_status "$directory")")" 21 78 11 \ + choice="$(whiptail --title "$id" --menu "$(tr home.server_status "$(server_status "$directory")")" 23 78 13 \ start "$(tr home.start)" \ stop "$(tr home.stop)" \ restart "$(tr home.restart)" \ status "$(tr home.status)" \ logs "$(tr home.logs)" \ 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 @@ -155,6 +160,16 @@ server_action_menu() { properties) "${SCRIPT_DIR}/server-manager.sh" server "$id" properties || true ;; + open-data | open-server) + new_temp_file output + if [[ "$choice" == open-data ]]; then + "${SCRIPT_DIR}/server-manager.sh" server "$id" open data >"$output" 2>&1 || + whiptail --title "$(tr common.error)" --textbox "$output" 14 78 + else + "${SCRIPT_DIR}/server-manager.sh" server "$id" open server >"$output" 2>&1 || + whiptail --title "$(tr common.error)" --textbox "$output" 14 78 + fi + ;; down) if whiptail --yesno "$(tr home.down_confirm "$id")" 10 72; then run_and_show "$id" "${SCRIPT_DIR}/server-manager.sh" server "$id" down @@ -220,7 +235,7 @@ diagnostics() { } main() { - local root choice + local root choice installed_version command -v whiptail >/dev/null 2>&1 || { tr tui.missing >&2 exit 1 @@ -256,7 +271,11 @@ main() { update) clear if "${SCRIPT_DIR}/update.sh"; then - exec "${ROOT_DIR}/mcserver-kit" home + 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 + fi fi pause_for_enter ;; diff --git a/libexec/mcserver-kit/server-config.py b/libexec/mcserver-kit/server-config.py index fa6c696..f52d542 100755 --- a/libexec/mcserver-kit/server-config.py +++ b/libexec/mcserver-kit/server-config.py @@ -9,6 +9,7 @@ import shutil import sys import tempfile +from datetime import datetime from pathlib import Path MANAGED_DEFAULTS = { @@ -116,6 +117,98 @@ def read_properties(path: Path) -> dict[str, str]: return values +def set_property(path: Path, env_key: str, value: str) -> None: + property_key = PROPERTY_KEYS[env_key] + if "\n" in value or "\r" in value: + raise ValueError("Property values cannot contain newlines") + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + replacement = f"{property_key}={value}\n" + indices = [ + index for index, line in enumerate(lines) + if line.partition("=")[0].strip() == property_key + ] + if indices: + lines[indices[-1]] = replacement + else: + if lines and not lines[-1].endswith("\n"): + lines[-1] += "\n" + lines.append(replacement) + atomic_write(path, "".join(lines)) + + +def validate_properties_source(source: Path) -> None: + if not source.is_file(): + raise ValueError(f"Not a file: {source}") + if source.name != "server.properties": + raise ValueError("Select a file named server.properties") + if not parse_import_properties(source): + raise ValueError("The selected file has no property entries") + + +def parse_import_properties(source: Path) -> dict[str, str]: + """Read the simple key=value format emitted by Minecraft without dropping entries.""" + values: dict[str, str] = {} + for number, line in enumerate(source.read_text(encoding="utf-8-sig").splitlines(), 1): + stripped = line.strip() + if not stripped or stripped.startswith(("#", "!")): + continue + if "=" not in line: + raise ValueError(f"Line {number}: expected key=value") + key, value = line.split("=", 1) + key = key.strip() + if not re.fullmatch(r"[A-Za-z0-9_.-]+", key): + raise ValueError(f"Line {number}: unsupported property key: {key}") + if "\\" in key or "\\" in value: + raise ValueError(f"Line {number}: escaped properties are not supported") + values[key] = value + if values.get("level-name", "world") != "world": + raise ValueError("level-name must be world for this server layout") + if values.get("server-port", "25565") != "25565": + raise ValueError("server-port must be 25565 for this server layout") + return values + + +def import_properties(server_dir: Path, source: Path) -> Path | None: + validate_properties_source(source) + imported = parse_import_properties(source) + env_path = server_dir / "server.env" + values = read_env(env_path) + for env_key, property_key in PROPERTY_KEYS.items(): + if property_key in imported: + values[env_key] = imported[property_key] + known = set(PROPERTY_KEYS.values()) | {"level-name", "server-port"} + extras = [f"{key}={value}" for key, value in imported.items() if key not in known] + values["CUSTOM_SERVER_PROPERTIES"] = "\n".join(extras) + values["OVERRIDE_SERVER_PROPERTIES"] = "true" + + destination = server_dir / "data" / "server.properties" + destination.parent.mkdir(parents=True, exist_ok=True) + if source.resolve() == destination.resolve(): + raise ValueError("The source is already this server's server.properties") + backup = None + if destination.exists(): + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + backup = destination.with_name(f"server.properties.mcserver-kit.{stamp}.bak") + suffix = 1 + while backup.exists(): + backup = destination.with_name(f"server.properties.mcserver-kit.{stamp}.{suffix}.bak") + suffix += 1 + shutil.copy2(destination, backup) + + descriptor, temporary_name = tempfile.mkstemp(prefix="server.properties.", dir=destination.parent) + try: + with os.fdopen(descriptor, "wb") as output, source.open("rb") as input_file: + shutil.copyfileobj(input_file, output) + os.chmod(temporary_name, 0o644) + write_env(env_path, values) + os.replace(temporary_name, destination) + finally: + if os.path.exists(temporary_name): + os.unlink(temporary_name) + + return backup + + def compose_environment(path: Path, dotenv: dict[str, str]) -> dict[str, str]: values: dict[str, str] = {} if not path.is_file(): @@ -195,7 +288,7 @@ def migrate(server_dir: Path) -> None: backup = server_dir / "compose.yaml.mcserver-kit.bak" if not target.exists() and not backup.exists(): shutil.copy2(compose, backup) - values: dict[str, str] = {} + values: dict[str, str] = dict(existing) for env_key, default in MANAGED_DEFAULTS.items(): property_key = PROPERTY_KEYS.get(env_key, "") values[env_key] = existing.get( @@ -214,13 +307,45 @@ def migrate(server_dir: Path) -> None: def main() -> int: if len(sys.argv) < 3: - print("usage: server-config.py get|set|migrate TARGET [KEY] [VALUE]", file=sys.stderr) + print("usage: server-config.py get|set|migrate|property-get|property-set|import-properties TARGET [KEY] [VALUE]", file=sys.stderr) return 2 operation = sys.argv[1] target = Path(sys.argv[2]) if operation == "migrate" and len(sys.argv) == 3: migrate(target) return 0 + if operation == "validate-properties" and len(sys.argv) == 3: + try: + validate_properties_source(target) + except (OSError, ValueError, UnicodeError) as error: + print(f"Invalid server.properties: {error}", file=sys.stderr) + return 1 + return 0 + if operation == "import-properties" and len(sys.argv) == 4: + try: + backup = import_properties(target, Path(sys.argv[3])) + except (OSError, ValueError, UnicodeError) as error: + print(f"Import failed: {error}", file=sys.stderr) + return 1 + if backup: + print(backup) + return 0 + if operation == "property-get" and len(sys.argv) in (4, 5): + key = PROPERTY_KEYS.get(sys.argv[3]) + if key is None: + return 2 + default = sys.argv[4] if len(sys.argv) == 5 else "" + print(read_properties(target).get(key, default)) + return 0 + if operation == "property-set" and len(sys.argv) == 5: + if sys.argv[3] not in PROPERTY_KEYS: + return 2 + try: + set_property(target, sys.argv[3], sys.argv[4]) + except (OSError, ValueError, UnicodeError) as error: + print(f"Could not edit server.properties: {error}", file=sys.stderr) + return 1 + return 0 if operation == "get" and len(sys.argv) in (4, 5): values = read_env(target) default = sys.argv[4] if len(sys.argv) == 5 else "" diff --git a/libexec/mcserver-kit/server-manager.sh b/libexec/mcserver-kit/server-manager.sh index a52321f..0c24749 100755 --- a/libexec/mcserver-kit/server-manager.sh +++ b/libexec/mcserver-kit/server-manager.sh @@ -6,6 +6,8 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" CONFIG_FILE="${MCSERVER_KIT_CONFIG:-${HOME}/.config/mcserver-compose-kit/config.yml}" CONFIG_VALUE="${SCRIPT_DIR}/config-value.py" +CONFIG_TOOL="${SCRIPT_DIR}/server-config.py" +WINDOWS_DIALOG="${SCRIPT_DIR}/windows-dialog.ps1" # shellcheck source=libexec/mcserver-kit/i18n.sh source "${SCRIPT_DIR}/i18n.sh" @@ -82,6 +84,70 @@ compose_in() { ) } +require_stopped() { + local directory="$1" running + running="$(compose_in "$directory" ps --status running --services)" || die "$(tr server.status_failed)" + [[ -z "$running" ]] || die "$(tr server.stop_before_import)" +} + +choose_properties_file() { + local enabled dialog_path encoded selected + enabled="$(python3 "$CONFIG_VALUE" get "$CONFIG_FILE" ui windows_dialogs 2>/dev/null || printf true)" + if [[ "$enabled" == true ]] && command -v wslpath >/dev/null 2>&1 && + [[ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe && -f "$WINDOWS_DIALOG" ]]; then + dialog_path="$(wslpath -w "$WINDOWS_DIALOG")" + encoded="$(/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe \ + -NoProfile -ExecutionPolicy Bypass -File "$dialog_path" -Mode SelectProperties 2>/dev/null)" || return 1 + encoded="${encoded//$'\r'/}" + selected="$(printf '%s' "$encoded" | base64 --decode)" || return 1 + wslpath -u "$selected" + return + fi + [[ -t 0 ]] || die "$(tr server.import_usage)" + read -r -p "$(tr server.import_path_prompt)" selected + [[ -n "$selected" ]] || return 1 + printf '%s' "$selected" +} + +import_properties() { + local directory="$1" source="${2-}" backup + require_stopped "$directory" + if [[ -z "$source" ]]; then + source="$(choose_properties_file)" || return 0 + fi + [[ -f "$source" ]] || die "$(tr server.import_source_missing "$source")" + python3 "$CONFIG_TOOL" validate-properties "$source" || die "$(tr server.import_failed)" + python3 "$CONFIG_TOOL" migrate "$directory" || die "$(tr server.import_failed)" + backup="$(python3 "$CONFIG_TOOL" import-properties "$directory" "$source")" || die "$(tr server.import_failed)" + compose_in "$directory" config --quiet || die "$(tr properties.compose_invalid)" + [[ -z "$backup" ]] || printf '%s\n' "$(tr server.import_backup "$backup")" + printf '%s\n' "$(tr server.import_done "${directory}/server.env")" +} + +open_folder() { + local directory="$1" part="${2:-data}" target windows_path output status + case "$part" in + data) target="${directory}/data" ;; + server) target="$directory" ;; + *) die "$(tr server.open_usage)" ;; + esac + [[ -d "$target" ]] || die "$(tr server.open_missing "$target")" + if ! command -v wslpath >/dev/null 2>&1 || ! command -v explorer.exe >/dev/null 2>&1; then + die "$(tr server.explorer_unavailable)" + fi + windows_path="$(wslpath -w "$target")" || die "$(tr server.explorer_unavailable)" + # Explorer can return 1 after handing the folder to an existing window. + # Only accept that status when it supplied no error diagnostics. + if output="$(explorer.exe "$windows_path" 2>&1)"; then + return 0 + else + status=$? + fi + [[ "$status" == 1 && -z "$output" ]] && return 0 + [[ -z "$output" ]] || printf '%s\n' "$output" >&2 + die "$(tr server.explorer_unavailable)" +} + manage_server() { local id="${1-}" local action="${2-}" @@ -124,6 +190,14 @@ manage_server() { properties) exec "${SCRIPT_DIR}/server-properties-tui.sh" "$id" "$directory" ;; + import-properties) + [[ $# -le 1 ]] || die "$(tr server.import_usage)" + import_properties "$directory" "${1-}" + ;; + open) + [[ $# -le 1 ]] || die "$(tr server.open_usage)" + open_folder "$directory" "${1:-data}" + ;; *) die "$(tr server.unknown_action "$action")" ;; diff --git a/libexec/mcserver-kit/server-properties-tui.sh b/libexec/mcserver-kit/server-properties-tui.sh index 46dd053..79052b2 100755 --- a/libexec/mcserver-kit/server-properties-tui.sh +++ b/libexec/mcserver-kit/server-properties-tui.sh @@ -7,8 +7,10 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" SERVER_ID="${1-}" SERVER_DIR="${2-}" SERVER_ENV="${SERVER_DIR}/server.env" +SERVER_PROPERTIES="${SERVER_DIR}/data/server.properties" CONFIG_TOOL="${SCRIPT_DIR}/server-config.py" changed=false +manual_properties=false # shellcheck source=libexec/mcserver-kit/i18n.sh source "${SCRIPT_DIR}/i18n.sh" @@ -35,16 +37,52 @@ actcheckbox=white,blue fi die() { - printf '%s: %s\n' "$(tr common.error)" "$*" >&2 + if command -v whiptail >/dev/null 2>&1; then + whiptail --title "$(tr common.error)" --msgbox "$*" 14 82 || true + else + printf '%s: %s\n' "$(tr common.error)" "$*" >&2 + fi exit 1 } +run_checked() { + local output status + output="$(mktemp)" + if "$@" >"$output" 2>&1; then + [[ ! -s "$output" ]] || whiptail --title "$SERVER_ID" --textbox "$output" 22 84 || true + rm -f -- "$output" + else + status=$? + # Preserve Docker's actual diagnostic until the user dismisses the dialog. + [[ -s "$output" ]] || printf '%s (exit %s)\n' "$1" "$status" >"$output" + whiptail --title "$(tr common.error)" --textbox "$output" 22 84 || true + rm -f -- "$output" + exit "$status" + fi +} + setting_get() { - python3 "$CONFIG_TOOL" get "$SERVER_ENV" "$1" "$2" + if [[ "$manual_properties" == true ]]; then + case "$1" in + WHITELIST | EXISTING_WHITELIST_FILE | OPS | EXISTING_OPS_FILE) + python3 "$CONFIG_TOOL" get "$SERVER_ENV" "$1" "$2" ;; + *) python3 "$CONFIG_TOOL" property-get "$SERVER_PROPERTIES" "$1" "$2" ;; + esac + else + python3 "$CONFIG_TOOL" get "$SERVER_ENV" "$1" "$2" + fi } setting_set() { - python3 "$CONFIG_TOOL" set "$SERVER_ENV" "$1" "$2" + if [[ "$manual_properties" == true ]]; then + case "$1" in + WHITELIST | EXISTING_WHITELIST_FILE | OPS | EXISTING_OPS_FILE) + python3 "$CONFIG_TOOL" set "$SERVER_ENV" "$1" "$2" ;; + *) python3 "$CONFIG_TOOL" property-set "$SERVER_PROPERTIES" "$1" "$2" ;; + esac + else + python3 "$CONFIG_TOOL" set "$SERVER_ENV" "$1" "$2" + fi changed=true } @@ -192,16 +230,35 @@ menu_item() { } finish() { - [[ "$changed" == true ]] || return - (cd "$SERVER_DIR" && docker compose config --quiet) || die "$(tr properties.compose_invalid)" + [[ "$changed" == true ]] || return 0 + cd -- "$SERVER_DIR" + run_checked docker compose config --quiet if whiptail --yesno "$(tr properties.restart_prompt "$SERVER_ID")" 10 72; then - printf '%s\n' "$(tr properties.applying "$SERVER_ID")" - (cd "$SERVER_DIR" && docker compose up -d --force-recreate) + run_checked docker compose up -d --force-recreate else - printf '%s\n' "$(tr properties.saved_restart_later)" + whiptail --msgbox "$(tr properties.saved_restart_later)" 10 76 || true fi } +import_properties() { + local output result + whiptail --yesno "$(tr home.import_confirm "$SERVER_ID")" 12 76 || return + output="$(mktemp)" + if "${SCRIPT_DIR}/server-manager.sh" server "$SERVER_ID" import-properties >"$output" 2>&1; then + if [[ -s "$output" ]]; then + whiptail --title "$(tr properties.import)" --textbox "$output" 16 82 + manual_properties=false + changed=true + fi + else + result=$? + whiptail --title "$(tr common.error)" --textbox "$output" 16 82 + rm -f -- "$output" + return "$result" + fi + rm -f -- "$output" +} + main() { local selected local items @@ -209,10 +266,20 @@ main() { [[ -f "${SERVER_DIR}/compose.yaml" ]] || die "$(tr server.compose_missing "$SERVER_ID")" if [[ ! -f "$SERVER_ENV" ]]; then - whiptail --yesno "$(tr properties.migration_prompt)" 11 76 || return + whiptail --yesno "$(tr properties.migration_prompt)" 11 76 || return 0 + fi + run_checked python3 "$CONFIG_TOOL" migrate "$SERVER_DIR" + if [[ "$(python3 "$CONFIG_TOOL" get "$SERVER_ENV" OVERRIDE_SERVER_PROPERTIES true)" == false ]]; then + [[ -f "$SERVER_PROPERTIES" ]] || die "$(tr properties.file_missing)" + local running + running="$(cd "$SERVER_DIR" && docker compose ps --status running --services)" || die "$(tr server.status_failed)" + if [[ -n "$running" ]]; then + die "$(tr properties.stop_first "$SERVER_ID")" + fi + manual_properties=true fi - python3 "$CONFIG_TOOL" migrate "$SERVER_DIR" - (cd "$SERVER_DIR" && docker compose config --quiet) || die "$(tr properties.compose_invalid)" + cd -- "$SERVER_DIR" + run_checked docker compose config --quiet while true; do items=( @@ -233,11 +300,13 @@ main() { SIMULATION_DISTANCE "$(menu_item "$(tr properties.simulation_distance)" "$(setting_get SIMULATION_DISTANCE 10)")" __more "$(tr properties.more_settings)" __resource "$(tr properties.resource_pack_settings)" + __import "$(tr properties.import)" __exit "$(tr properties.exit)" ) selected="$(whiptail --title "${SERVER_ID}" --menu "$(tr properties.choose)" 25 94 18 "${items[@]}" 3>&1 1>&2 2>&3)" || break case "$selected" in __exit) break ;; + __import) import_properties || true ;; __more) selected="$(whiptail --title "${SERVER_ID}" --menu "$(tr properties.more_settings)" 24 90 15 \ FORCE_GAMEMODE "$(menu_item "$(tr properties.force_gamemode)" "$(setting_get FORCE_GAMEMODE false)")" \ diff --git a/libexec/mcserver-kit/windows-dialog.ps1 b/libexec/mcserver-kit/windows-dialog.ps1 index 5899e1f..d4db1ff 100644 --- a/libexec/mcserver-kit/windows-dialog.ps1 +++ b/libexec/mcserver-kit/windows-dialog.ps1 @@ -1,6 +1,6 @@ param( [Parameter(Mandatory = $true)] - [ValidateSet('SelectWorld', 'InputMotd', 'Check')] + [ValidateSet('SelectWorld', 'SelectProperties', 'InputMotd', 'Check')] [string]$Mode, [string]$DefaultValue = '' @@ -58,6 +58,18 @@ if ($Mode -eq 'SelectWorld') { exit 0 } +if ($Mode -eq 'SelectProperties') { + $dialog = New-Object System.Windows.Forms.OpenFileDialog + $dialog.Title = 'Select server.properties / server.propertiesを選択' + $dialog.Filter = 'server.properties|server.properties|All files (*.*)|*.*' + $dialog.CheckFileExists = $true + if ($dialog.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { + exit 1 + } + Write-Utf8Base64 $dialog.FileName + exit 0 +} + if ($Mode -eq 'InputMotd') { $form = New-Object System.Windows.Forms.Form $form.Text = 'Minecraft Server Kit' diff --git a/share/mcserver-kit/locales/en.json b/share/mcserver-kit/locales/en.json index cc69484..4618302 100644 --- a/share/mcserver-kit/locales/en.json +++ b/share/mcserver-kit/locales/en.json @@ -40,6 +40,10 @@ "home.status": "Show status", "home.logs": "Follow logs", "home.properties": "Server settings", + "home.import_properties": "Import server.properties", + "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_confirm": "Remove containers and networks for %s? World data is preserved.", "home.diagnostics_title": "Environment diagnostics", @@ -130,7 +134,18 @@ "server.running": "running", "server.stopped": "stopped", "server.none": "(no servers)", - "server.usage": "Usage: mcserver-kit server SERVER_ID start|stop|restart|status|logs|down|properties", + "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.", + "server.stop_before_import": "Stop the server before importing server.properties.", + "server.import_usage": "Usage: mcserver-kit server SERVER_ID import-properties [PATH]", + "server.import_path_prompt": "Path to server.properties: ", + "server.import_source_missing": "File not found: %s", + "server.import_failed": "Could not import server.properties.", + "server.import_backup": "Previous server.properties backup: %s", + "server.import_done": "Imported successfully. Property settings are managed in %s; the original file was copied to data/server.properties.", + "server.open_usage": "Usage: mcserver-kit server SERVER_ID open [data|server]", + "server.open_missing": "Folder not found: %s", + "server.explorer_unavailable": "Windows Explorer is unavailable. This action requires WSL interop and wslpath.", "server.validating": "Validating Docker Compose configuration for %s...", "server.starting": "Starting %s...", "server.stopping": "Stopping %s...", @@ -162,6 +177,7 @@ "properties.current": "%s: %s", "properties.exit": "Save and exit", "properties.choose": "Choose a setting to edit", + "properties.import": "Import server.properties", "properties.selected": "(current)", "properties.motd": "Message of the day (MOTD)", "properties.max_players": "Maximum players", diff --git a/share/mcserver-kit/locales/ja.json b/share/mcserver-kit/locales/ja.json index 3280b12..94fdf86 100644 --- a/share/mcserver-kit/locales/ja.json +++ b/share/mcserver-kit/locales/ja.json @@ -40,6 +40,10 @@ "home.status": "状態を表示", "home.logs": "ログを表示", "home.properties": "サーバー設定", + "home.import_properties": "server.propertiesをインポート", + "home.import_confirm": "%sにserver.propertiesをインポートしますか?先にサーバーを停止してください。既存ファイルをバックアップし、インポートした設定はserver.envで管理します。", + "home.open_data": "永続データをExplorerで開く", + "home.open_server": "サーバーフォルダをExplorerで開く", "home.down": "コンテナを削除", "home.down_confirm": "%sのコンテナとネットワークを削除しますか?ワールドデータは保持されます。", "home.diagnostics_title": "動作環境の診断", @@ -130,7 +134,18 @@ "server.running": "起動中", "server.stopped": "停止中", "server.none": "(サーバーがありません)", - "server.usage": "使い方: mcserver-kit server サーバーID start|stop|restart|status|logs|down|properties", + "server.usage": "使い方: mcserver-kit server サーバーID start|stop|restart|status|logs|down|properties|import-properties|open", + "server.status_failed": "サーバーが起動中か確認できませんでした。Dockerを確認して再試行してください。", + "server.stop_before_import": "server.propertiesのインポート前にサーバーを停止してください。", + "server.import_usage": "使い方: mcserver-kit server サーバーID import-properties [パス]", + "server.import_path_prompt": "server.propertiesのパス: ", + "server.import_source_missing": "ファイルが見つかりません: %s", + "server.import_failed": "server.propertiesをインポートできませんでした。", + "server.import_backup": "元のserver.propertiesのバックアップ: %s", + "server.import_done": "インポートしました。プロパティ設定は%sで管理し、元ファイルはdata/server.propertiesにコピーしました。", + "server.open_usage": "使い方: mcserver-kit server サーバーID open [data|server]", + "server.open_missing": "フォルダが見つかりません: %s", + "server.explorer_unavailable": "Windows Explorerを利用できません。WSL連携とwslpathが必要です。", "server.validating": "%sのDocker Compose設定を検証しています...", "server.starting": "%sを起動しています...", "server.stopping": "%sを停止しています...", @@ -162,6 +177,7 @@ "properties.current": "%s: %s", "properties.exit": "保存して終了", "properties.choose": "編集する設定を選択してください", + "properties.import": "server.propertiesをインポート", "properties.selected": "(現在値)", "properties.motd": "サーバー説明(MOTD)", "properties.max_players": "最大プレイヤー数", diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 8d97bee..18cf47d 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -379,7 +379,9 @@ test_local_installation() { local install_log="${temp_dir}/install/install.log" local shell_rc="${temp_dir}/install/bashrc" local update_cache="${temp_dir}/install/cache" - local installed_help + local installed_help repo_version + + repo_version="$(head -n 1 "${REPO_ROOT}/VERSION")" mkdir -p "$fake_bin" ln -s /usr/bin/true "${fake_bin}/unzip" @@ -394,7 +396,7 @@ test_local_installation() { assert_equal 'present' "$([[ -x "${bin_dir}/mcserver-kit" ]] && printf present)" 'the installer creates the launcher' installed_help="$("${bin_dir}/mcserver-kit" --help)" assert_equal 'present' "$(grep -q 'mcserver-kit setup' <<<"$installed_help" && printf present)" 'the installed launcher exposes subcommand help' - assert_equal '1.1.1' "$("${bin_dir}/mcserver-kit" --version | awk '{print $2}')" 'the installed launcher reports its version' + assert_equal "$repo_version" "$("${bin_dir}/mcserver-kit" --version | awk '{print $2}')" 'the installed launcher reports its version' assert_equal 'present' "$([[ -f "${config_dir}/config.yml" ]] && printf present)" 'the installer creates the initial config' assert_equal 'present' "$([[ -f "${install_dir}/libexec/mcserver-kit/windows-dialog.ps1" ]] && printf present)" 'the installer includes the Windows dialog helper' assert_equal 'present' "$([[ -x "${install_dir}/libexec/mcserver-kit/lang.sh" ]] && printf present)" 'the installer includes the language command' @@ -408,7 +410,7 @@ test_local_installation() { assert_equal 'en' "$(cat "${config_dir}/language")" 'the installer defaults to English' assert_equal '1' "$(grep -Fxc '# >>> mcserver-kit PATH >>>' "$shell_rc")" 'the installer registers one managed PATH block' assert_equal 'present' "$(grep -q 'mcserver-kit setup' "$install_log" && printf present)" 'the installer instructs the user to run setup' - assert_equal 'present' "$(grep -q 'mcserver-kit 1.1.1' "$install_log" && printf present)" 'the installer shows the installed version' + assert_equal 'present' "$(grep -Fq "mcserver-kit ${repo_version}" "$install_log" && printf present)" 'the installer shows the installed version' assert_equal 'absent' "$(! grep -q '初回セットアップを開始' "$install_log" && printf absent)" 'the installer does not start setup automatically' printf '\n# preserve-on-update\n' >>"${config_dir}/config.yml" @@ -474,6 +476,51 @@ CURL assert_equal 'present' "$(grep -q 'Update available: v1.1.1' "$whiptail_log" && printf present)" 'the dashboard announces a newer cached release' } +test_home_screen_transition_after_current_update() { + local temp_dir="$1" repo_version + local config_file="${temp_dir}/home-transition/config.yml" + local fake_bin="${temp_dir}/home-transition/bin" + local event_log="${temp_dir}/home-transition/events.log" + local menu_count="${temp_dir}/home-transition/menu-count" + local output + repo_version="$(head -n 1 "${REPO_ROOT}/VERSION")" + mkdir -p "$fake_bin" + cat >"$config_file" <"${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 +WHIPTAIL + cat >"${fake_bin}/clear" <<'CLEAR' +#!/usr/bin/env bash +printf 'clear\n' >>"$MCSERVER_KIT_TEST_EVENTS" +CLEAR + cat >"${fake_bin}/curl" <<'CURL' +#!/usr/bin/env bash +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 \ + 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' +} + test_setup_command() { local temp_dir="$1" local config_file="${temp_dir}/setup/config.yml" @@ -674,6 +721,168 @@ WHIPTAIL assert_equal 'Unified MOTD' "$(python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" get "$server_env" MOTD)" 'the properties TUI updates unified settings' } +test_property_import_and_explorer() { + local temp_dir="$1" root + root="${temp_dir}/property-import/servers" + local config_file="${temp_dir}/property-import/config.yml" + local fake_bin="${temp_dir}/property-import/bin" + local source_dir="${temp_dir}/property-import/distribution" + local server_dir="${root}/alpha" output + mkdir -p "${server_dir}/data" "$fake_bin" "$source_dir" + cat >"$config_file" <"${server_dir}/compose.yaml" <<'COMPOSE' +services: + minecraft: + image: itzg/minecraft-server:java21 + env_file: + - server.env + volumes: + - ./data:/data +COMPOSE + cat >"${server_dir}/server.env" <<'ENV' +MOTD="Old MOTD" +DIFFICULTY="easy" +ENV + cat >"${server_dir}/data/server.properties" <<'PROPERTIES' +# original +motd=Old MOTD +PROPERTIES + cat >"${source_dir}/server.properties" <<'PROPERTIES' +# distributed settings +motd=Distributed MOTD +difficulty=hard +custom-setting=preserve-me +plugin.option=enabled +PROPERTIES + cat >"${fake_bin}/docker" <<'DOCKER' +#!/usr/bin/env bash +if [[ "$*" == 'compose ps --status running --services' && "${MCSERVER_KIT_TEST_RUNNING:-false}" == true ]]; then + printf 'minecraft\n' +fi +if [[ "${MCSERVER_KIT_TEST_DOCKER_MISSING:-false}" == true ]]; then + printf "The command 'docker' could not be found in this WSL 2 distro.\n" >&2 + exit 127 +fi +DOCKER + cat >"${fake_bin}/wslpath" <<'WSLPATH' +#!/usr/bin/env bash +printf 'WIN:%s\n' "$2" +WSLPATH + cat >"${fake_bin}/explorer.exe" <<'EXPLORER' +#!/usr/bin/env bash +printf '%s\n' "$1" >"$MCSERVER_KIT_TEST_EXPLORER_LOG" +[[ -z "${MCSERVER_KIT_TEST_EXPLORER_ERROR:-}" ]] || printf '%s\n' "$MCSERVER_KIT_TEST_EXPLORER_ERROR" >&2 +exit "${MCSERVER_KIT_TEST_EXPLORER_STATUS:-0}" +EXPLORER + chmod +x "${fake_bin}/docker" "${fake_bin}/wslpath" "${fake_bin}/explorer.exe" + + : >"${source_dir}/invalid.properties" + assert_fails 'invalid property input is rejected before migration' \ + env PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en MCSERVER_KIT_CONFIG="$config_file" \ + bash "${REPO_ROOT}/mcserver-kit" server alpha import-properties "${source_dir}/invalid.properties" + assert_equal 'absent' "$(! grep -q '^MAX_PLAYERS=' "${server_dir}/server.env" && printf absent)" 'invalid import does not migrate server settings' + + mkdir -p "${source_dir}/incompatible" + cat >"${source_dir}/incompatible/server.properties" <<'PROPERTIES' +motd=Should not be imported +server-port=25566 +PROPERTIES + assert_fails 'an incompatible server port is rejected before migration' \ + env PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en MCSERVER_KIT_CONFIG="$config_file" \ + bash "${REPO_ROOT}/mcserver-kit" server alpha import-properties "${source_dir}/incompatible/server.properties" + assert_equal 'Old MOTD' "$(python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" get "${server_dir}/server.env" MOTD)" 'rejected import leaves the environment unchanged' + + assert_fails 'import refuses to replace properties while the server is running' \ + env PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en MCSERVER_KIT_CONFIG="$config_file" \ + MCSERVER_KIT_TEST_RUNNING=true bash "${REPO_ROOT}/mcserver-kit" server alpha import-properties "${source_dir}/server.properties" + assert_equal 'Old MOTD' "$(python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" property-get "${server_dir}/data/server.properties" MOTD)" 'running server keeps the original properties' + + output="$(PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en MCSERVER_KIT_CONFIG="$config_file" \ + bash "${REPO_ROOT}/mcserver-kit" server alpha import-properties "${source_dir}/server.properties")" + assert_equal 'Distributed MOTD' "$(python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" property-get "${server_dir}/data/server.properties" MOTD)" 'import copies the supplied server.properties' + assert_equal 'true' "$(python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" get "${server_dir}/server.env" OVERRIDE_SERVER_PROPERTIES)" 'import keeps server.env authoritative' + assert_equal 'Distributed MOTD' "$(python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" get "${server_dir}/server.env" MOTD)" 'import converts managed properties to environment settings' + assert_equal 'hard' "$(python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" get "${server_dir}/server.env" DIFFICULTY)" 'import updates the difficulty' + assert_equal $'custom-setting=preserve-me\nplugin.option=enabled' "$(python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" get "${server_dir}/server.env" CUSTOM_SERVER_PROPERTIES)" 'import converts extra properties to CUSTOM_SERVER_PROPERTIES' + assert_equal 'present' "$(grep -q 'custom-setting=preserve-me' "${server_dir}/data/server.properties" && printf present)" 'import keeps custom settings' + assert_equal 'present' "$(grep -q 'motd=Old MOTD' "${server_dir}"/data/server.properties.mcserver-kit.*.bak && printf present)" 'import backs up the previous properties' + assert_equal 'present' "$(grep -q 'Previous server.properties backup' <<<"$output" && printf present)" 'import reports the backup location' + + python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" migrate "$server_dir" + assert_equal 'true' "$(python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" get "${server_dir}/server.env" OVERRIDE_SERVER_PROPERTIES)" 'later migration preserves environment property mode' + + cat >"${fake_bin}/whiptail" <<'WHIPTAIL' +#!/usr/bin/env bash +case " $* " in + *' --inputbox '*) printf 'TUI MOTD' >&2 ;; + *' --yesno '*) exit 1 ;; + *' --menu '*) + if [[ -f "$MCSERVER_KIT_TEST_WHIPTAIL_STATE" ]]; then + printf '__exit' >&2 + else + : >"$MCSERVER_KIT_TEST_WHIPTAIL_STATE" + printf 'MOTD' >&2 + fi + ;; + *) exit 0 ;; +esac +WHIPTAIL + chmod +x "${fake_bin}/whiptail" + PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en \ + MCSERVER_KIT_TEST_WHIPTAIL_STATE="${temp_dir}/property-import/whiptail-state" \ + bash "${REPO_ROOT}/libexec/mcserver-kit/server-properties-tui.sh" alpha "$server_dir" >/dev/null + assert_equal 'TUI MOTD' "$(python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" get "${server_dir}/server.env" MOTD)" 'the TUI edits the authoritative environment value' + assert_equal 'Distributed MOTD' "$(python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" property-get "${server_dir}/data/server.properties" MOTD)" 'the data file changes only when the container applies server.env' + + PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en MCSERVER_KIT_CONFIG="$config_file" \ + MCSERVER_KIT_TEST_EXPLORER_LOG="${temp_dir}/property-import/explorer.log" \ + bash "${REPO_ROOT}/mcserver-kit" server alpha open data + assert_equal "WIN:${server_dir}/data" "$(<"${temp_dir}/property-import/explorer.log")" 'open data passes the persistent folder to Explorer' + + for part in data server; do + output="$(PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en MCSERVER_KIT_CONFIG="$config_file" \ + MCSERVER_KIT_TEST_EXPLORER_LOG="${temp_dir}/property-import/explorer.log" \ + MCSERVER_KIT_TEST_EXPLORER_STATUS=1 \ + bash "${REPO_ROOT}/mcserver-kit" server alpha open "$part")" + assert_equal '' "$output" "Explorer silent status 1 succeeds for $part without an error dialog" + done + assert_fails 'Explorer failure with diagnostics remains an error' \ + env PATH="${fake_bin}:$PATH" MCSERVER_KIT_CONFIG="$config_file" \ + MCSERVER_KIT_TEST_EXPLORER_LOG="${temp_dir}/property-import/explorer.log" \ + MCSERVER_KIT_TEST_EXPLORER_STATUS=1 MCSERVER_KIT_TEST_EXPLORER_ERROR='Access denied' \ + bash "${REPO_ROOT}/mcserver-kit" server alpha open data + assert_fails 'Explorer execution failure remains an error' \ + env PATH="${fake_bin}:$PATH" MCSERVER_KIT_CONFIG="$config_file" \ + MCSERVER_KIT_TEST_EXPLORER_LOG="${temp_dir}/property-import/explorer.log" \ + MCSERVER_KIT_TEST_EXPLORER_STATUS=126 \ + bash "${REPO_ROOT}/mcserver-kit" server alpha open server + + cat >"${fake_bin}/whiptail" <<'WHIPTAIL' +#!/usr/bin/env bash +while (($#)); do + case "$1" in + --textbox) cat "$2" >"$MCSERVER_KIT_TEST_DIALOG_LOG"; exit 0 ;; + --menu) printf '__exit' >&2; exit 0 ;; + esac + shift +done +WHIPTAIL + local result=0 + output="$(PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en \ + MCSERVER_KIT_TEST_DOCKER_MISSING=true \ + MCSERVER_KIT_TEST_DIALOG_LOG="${temp_dir}/property-import/dialog.log" \ + bash "${REPO_ROOT}/libexec/mcserver-kit/server-properties-tui.sh" alpha "$server_dir" 2>&1)" || result=$? + assert_equal 127 "$result" 'properties preserves the Docker failure exit code' + assert_equal '' "$output" 'Docker errors do not flash on the underlying terminal' + assert_equal "The command 'docker' could not be found in this WSL 2 distro." "$(<"${temp_dir}/property-import/dialog.log")" 'Docker diagnostic is presented in a persistent TUI textbox' + PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en \ + bash "${REPO_ROOT}/libexec/mcserver-kit/server-properties-tui.sh" alpha "$server_dir" + assert_equal 0 "$?" 'closing properties without changes succeeds' +} + main() { TEST_TEMP_DIR="$(mktemp -d)" trap cleanup EXIT @@ -695,7 +904,9 @@ main() { test_config_value_editor "$TEST_TEMP_DIR" test_server_management "$TEST_TEMP_DIR" test_server_property_editor "$TEST_TEMP_DIR" + test_property_import_and_explorer "$TEST_TEMP_DIR" test_home_dashboard "$TEST_TEMP_DIR" + test_home_screen_transition_after_current_update "$TEST_TEMP_DIR" printf 'PASS: %d specification tests, %d skipped\n' "$tests_run" "$tests_skipped" }