Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# This WSL-first project keeps text files in LF format on every platform.
* text=auto eol=lf
1 change: 1 addition & 0 deletions .github/workflows/shell-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
branches:
- main
- develop
- 'release/**'

permissions:
contents: read
Expand Down
9 changes: 8 additions & 1 deletion README-JA.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ mcserver-kit server <server-id> logs
mcserver-kit server <server-id> logs --no-follow
mcserver-kit server <server-id> down
mcserver-kit server <server-id> properties
mcserver-kit server <server-id> import-properties /path/to/server.properties
mcserver-kit server <server-id> open data
mcserver-kit server <server-id> open server
```

`stop`と`shutdown`はコンテナを削除せず停止します。`down`はコンテナとネットワークを削除します。いずれもサーバーの`data/`は削除しません。
Expand All @@ -184,7 +187,11 @@ mcserver-kit server <server-id> 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 <server-id> 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 <server-id> open server`または`open data`です。WSLとExplorerの連携が必要です。

古い形式のサーバーを初めて開く場合は、移行前に確認画面を表示します。元のComposeは`compose.yaml.mcserver-kit.bak`として保存します。

Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ mcserver-kit server <server-id> logs
mcserver-kit server <server-id> logs --no-follow
mcserver-kit server <server-id> down
mcserver-kit server <server-id> properties
mcserver-kit server <server-id> import-properties /path/to/server.properties
mcserver-kit server <server-id> open data
mcserver-kit server <server-id> 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.
Expand All @@ -183,7 +186,11 @@ mcserver-kit server <server-id> 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 <server-id> 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 <server-id> 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`.

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.1.1
1.1.2
27 changes: 23 additions & 4 deletions libexec/mcserver-kit/home-tui.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
;;
Expand Down
129 changes: 127 additions & 2 deletions libexec/mcserver-kit/server-config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import shutil
import sys
import tempfile
from datetime import datetime
from pathlib import Path

MANAGED_DEFAULTS = {
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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(
Expand All @@ -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 ""
Expand Down
74 changes: 74 additions & 0 deletions libexec/mcserver-kit/server-manager.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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-}"
Expand Down Expand Up @@ -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")"
;;
Expand Down
Loading