diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b1f772a..4570e3b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,8 +6,10 @@ ## Validation - [ ] `bash -n` checks run for touched scripts +- [ ] `go test ./...` run for touched Go code +- [ ] `CHANGELOG.md` updated for user-facing changes - [ ] relevant command or menu path tested -- [ ] README/docs updated if behavior changed +- [ ] README/docs updated if setup, behavior, or file layout changed ## Notes diff --git a/.gitignore b/.gitignore index 69eab83..ef34fa2 100644 --- a/.gitignore +++ b/.gitignore @@ -20,9 +20,14 @@ /Testing/*.log /Testing/*.tmp /bin/ +/tmodloader-ui +/cmd/tmodloader-ui/tmodloader-ui +/cmd/tmodloader-ui/tmodloader-ui.exe coverage.out *.coverprofile *.test +*.prof +*.pprof # Temporary and backup files *.bak diff --git a/Addons/README.md b/Addons/README.md new file mode 100644 index 0000000..a4517b6 --- /dev/null +++ b/Addons/README.md @@ -0,0 +1,35 @@ +# Addons + +Drop addon manifests into `Addons//addon.json` to add extra sections and actions to the Go control room. + +The loader currently supports command-style actions only. + +## Manifest + +```json +{ + "name": "admin-tools", + "section": "Admin", + "actions": [ + { + "title": "Audit World", + "description": "Run the world audit helper.", + "command": ["bash", "scripts/audit-world.sh"] + }, + { + "title": "Rotate Admin Tokens", + "description": "Rotate admin auth material.", + "command": ["bash", "scripts/rotate-admin-tokens.sh"], + "confirm_text": "Rotate admin tokens now?" + } + ] +} +``` + +## Notes + +- `section` is the default category name in the UI. +- `actions[].section` can override the manifest-level section. +- `actions[].working_dir` defaults to the addon directory. +- `${repo_dir}` and `${addon_dir}` placeholders are expanded in `command` and `working_dir`. +- Invalid addon entries are skipped, surfaced as warnings in the control room, and noted in `Logs/control.log`. diff --git a/Addons/example-admin/addon.json.example b/Addons/example-admin/addon.json.example new file mode 100644 index 0000000..5fa50fa --- /dev/null +++ b/Addons/example-admin/addon.json.example @@ -0,0 +1,17 @@ +{ + "name": "example-admin", + "section": "Admin", + "actions": [ + { + "title": "Audit World", + "description": "Run an addon-local audit helper.", + "command": ["bash", "scripts/audit-world.sh"] + }, + { + "title": "Run Repo Script", + "description": "Call back into a repo-root script from an addon.", + "command": ["bash", "${repo_dir}/Scripts/hub/tmod-control.sh", "health"], + "working_dir": "${repo_dir}" + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4401dcb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,122 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on Keep a Changelog. Add new user-facing changes to `Unreleased`, then move them into a versioned section when cutting a release. + +## [Unreleased] + +### Added + +- Added a standalone project `CHANGELOG.md` so release history no longer has to live inside the README. +- Added manifest-driven `Addons/*/addon.json` loading so extra sections and command actions can be plugged into the Go control room without editing the built-in action list. +- Added addon warning surfacing in the Go control room header and command-output pane, so broken addon manifests are easier to troubleshoot than a log-only failure. + +### Changed + +- Tightened the README around the repo-local, self-contained layout so setup-generated local files and gitignored runtime data are documented in one place. +- Retired the legacy shell UI entrypoints so interactive launches now go straight to the Go control room or fail with a clear setup message. +- Restored per-mod config editing in the Go control room through a config picker that opens the selected file in your terminal editor. +- Restored native Workshop URL/ID entry and a native installed-mod load manager in the Go control room, so adding queued mods and editing `enabled.json` no longer depend on the old shell menus. +- Added a subtle hotkey legend to the left panel that keeps the full shortcut set visible while giving the active bindings slightly more contrast on each screen. +- Moved the horizontal scroll readout to a fixed footer line in the output pane and removed the duplicate server state row from the snapshot panel. +- Added a minimum supported terminal size guard so narrow windows show a resize prompt instead of crunching the fixed-width control panels. +- Removed the transient yellow notice line from the header area and made mouse hover track the currently highlighted row in the action and picker lists. +- Normalized Go control room command output and simplified the shell hub's status and maintenance copy so severity stays readable without shell-era emoji formatting. +- Moved the Go UI entrypoint into `cmd/tmodloader-ui` and the app code into `internal/controlroom`, keeping the repo root focused on the Makefile, docs, and script surface. +- Refreshed the README, shell help, man page, contributor docs, and `.gitignore` around the newer control-room, addon, and native workshop flows. + +### Fixed + +- Fixed laptop-style temperature reporting and integer temperature formatting so representative CPU readings do not collapse into misleading values like `7C`. +- Fixed the snapshot panel so server-only metrics such as CPU, memory, uptime, and players show `n/a` instead of fake zeroes while the server is offline. +- Fixed header truncation caused by transient notices and invalid non-log `l` handling, keeping the top status line stable. + +## [2.6.0] - 2026-03-27 + +### Added + +- Added a Bubble Tea-based headless server console that keeps the screen alive while backend actions run. +- Added a section overview, native section pages, and a broader set of shell-backed admin actions in the Go TUI. +- Added `make tui-run` and `make tui-build` so the Go UI is easy to launch from source or build into `bin/tmodloader-ui`. +- Added a live `Server Snapshot` with running state, PID, world, players, mod and backup counts, CPU, RSS memory, uptime, disk activity, and host temperature. + +### Changed + +- `bash Scripts/hub/tmod-control.sh` now prefers the Go TUI for interactive launches, while keeping `interactive classic` and `TMOD_FORCE_LEGACY_UI=1` for the legacy shell UI. +- Log tails and command output now stay inside the app instead of dropping users back into raw shell output. +- Tightened pane layout, empty states, mouse-wheel handling, and overview/action previews so the interface behaves more like a persistent SSH console than a shell launcher. +- Existing script-driven workflows continue to work through the same `tmod-control.sh`, backup, workshop, diagnostics, and monitor commands. + +### Fixed + +- Hardened status polling so offline states do not flicker or report bogus PID, memory, or uptime values. + +## [2.5.2] - 2026-03-27 + +### Added + +- Added a dependency-aware interactive hub that can use `dialog` for boxed menus and log viewers plus `fzf` for searchable pickers, while keeping plain-Bash fallback intact. +- Added `--yes` support to workshop sync, workshop archive, mod-list clearing, and backup restore so scripted flows do not hang on confirmations. + +### Changed + +- Expanded the command palette into a fuller direct-action launcher instead of mostly routing through submenu pages. +- Unified page navigation around shared menu and picker helpers for worlds, backups, mod configs, logs, and common prompts. +- Updated maintenance to run workshop sync non-interactively, matching the cron-style examples in the docs. + +### Fixed + +- Tightened workshop sync so pre-2023 mod builds are skipped consistently instead of being copied into `Mods/`. +- Fixed restore correctness by switching rsync-based restore paths to checksum mode. +- Fixed same-second backup filename collisions so pre-restore safety backups cannot overwrite the archive being restored. + +## [2.5.1] - 2026-03-27 + +### Added + +- Added `make steamcmd-local` for repo-local SteamCMD bootstrap. +- Added `Scripts/env.example.sh` and improved `make setup` for portable onboarding. +- Added GitHub community health files, issue templates, a PR template, and a CI workflow for the public repo. + +### Changed + +- Reworked the toolkit into a project-root portable layout instead of assuming a fixed home-directory install. +- `steamcmd_path` now defaults to `./Tools/SteamCMD/steamcmd.sh`. +- Added support for project-relative paths in config so tracked examples stay machine-agnostic. +- Reframed the repo and README around the portable public edition. + +### Fixed + +- Fixed full backup and full restore to respect the capitalized `Logs/` and `Backups/` layout. +- Fixed diagnostics `auto_fix` to recreate the actual repo directory structure instead of legacy lowercase paths. +- Fixed monitor process detection so monitoring and health checks agree with the server start mode. +- Fixed `tmod-control.sh diagnostics` to run the full diagnostics script instead of the lightweight inline summary. + +## [2.5.0] - 2026-03-01 + +These notes were inherited from the original `tmodloaderserver` line before the portable fork became the public repo. + +### Added + +- Expanded Monitoring with dashboard, health check, live monitor, log viewing, and console attach. +- Expanded Backup with inline restore and verify pickers, cleanup, and log viewing. +- Added Mod Configs editing from the Mods page. +- Added world import flow from pre-uploaded `.wld` files. +- Added `--debug` support through `TMOD_DEBUG=1` for noisier child-script output. +- Added configurable log rotation settings and improved rotated log handling. +- Added `server_config_get()` helper support in core scripts. + +### Changed + +- Restructured all five menu pages for a better headless-server workflow. +- Moved script logging to file-first behavior while keeping warnings and errors visible in the terminal. +- Renamed the main directories to the capitalized tModLoader-style layout. +- Moved `STEAMCMD_PATH` handling into `serverconfig.txt` as `steamcmd_path=`. +- Started treating `Scripts/steam/mod_ids.txt` as a local gitignored file with a tracked example template. +- Excluded `*.bak` from git. + +### Removed + +- Removed obsolete Download Mods and Sync Mods menu items in favor of the URL-to-`enabled.json` flow. +- Removed the whitelist system from the toolkit. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cdb684e..b25d1d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,9 +14,11 @@ This repo is the portable/public edition of the toolkit. Changes should favor: ## Before You Open A PR 1. Run shell syntax checks for any scripts you touched. -2. Update `README.md` when behavior, setup, or file layout changes. -3. Keep new tracked files generic and reusable. -4. Do not commit local runtime data such as worlds, logs, backups, or SteamCMD contents. +2. Run `go test ./...` if you touched the Go control room. +3. Update `CHANGELOG.md` for user-facing changes. +4. Update `README.md` when setup, behavior, or file layout changes. +5. Keep new tracked files generic and reusable. +6. Do not commit local runtime data such as worlds, logs, backups, or SteamCMD contents. ## Style Notes @@ -30,9 +32,11 @@ This repo is the portable/public edition of the toolkit. Changes should favor: Useful checks: ```bash +go test ./... # run the Go control room tests when Go code changed bash -n Scripts/core/tmod-core.sh bash -n Scripts/hub/tmod-control.sh bash -n Scripts/steam/tmod-workshop.sh +bash -n Scripts/diag/tmod-diagnostics.sh bash Scripts/hub/tmod-control.sh status ``` @@ -42,5 +46,6 @@ If your change affects other scripts, run the relevant `bash -n` checks too. - Keep PRs focused. - Explain the user-facing impact. +- Add or update the `Unreleased` entry in `CHANGELOG.md` when behavior changes for users. - Mention any setup or migration changes. - Include follow-up work separately instead of bundling unrelated cleanup. diff --git a/Makefile b/Makefile index 39d6ed0..fbdacc0 100644 --- a/Makefile +++ b/Makefile @@ -87,11 +87,11 @@ engine-github: tui-build: @mkdir -p bin - @go build -o bin/tmodloader-ui . + @go build -o bin/tmodloader-ui ./cmd/tmodloader-ui @echo "Built bin/tmodloader-ui" tui-run: - @go run . + @go run ./cmd/tmodloader-ui install-man: @echo "Installing man page to $(MANDIR)..." diff --git a/README.md b/README.md index 223995b..a68c4d2 100644 --- a/README.md +++ b/README.md @@ -9,75 +9,72 @@ Portable Linux toolkit for running and managing a tModLoader dedicated server. -This edition is built around a self-contained project layout: the server engine, worlds, mods, logs, backups, and optional local SteamCMD install all live inside the repo folder by default. That makes it easier to clone, move, test, back up, and publish without dragging around machine-specific paths. +This repo is meant to be its own server home. By default the engine, worlds, mods, configs, logs, backups, and optional SteamCMD install all live under the project root. `make setup` turns the tracked example files into local working files, so the project can be cloned, moved, backed up, or published without dragging machine-specific paths through git. -## Why This Repo +## Highlights -- Portable by default: the project folder acts as the server home. -- Public-repo friendly: runtime data and local machine config stay out of git. -- Practical for real hosting: workshop sync, backups, monitoring, diagnostics, and world management are already wired together. -- Easy to bootstrap: `make setup` prepares the layout, `make steamcmd-local` installs SteamCMD locally, and `make engine-github` installs the engine from the official GitHub release. - -## Feature Summary - -- Persistent Go TUI with section overview, live server snapshot, log tail, and in-app command output -- Shell hub preserved as a CLI backend and legacy fallback for users who still want menu-driven Bash -- Live host and process metrics for PID, players, mods, backups, CPU, memory, uptime, disk activity, and temperature -- Repo-local layout with `Engine/`, `Mods/`, `Worlds/`, `Logs/`, `Backups/`, and `Tools/SteamCMD/` -- Engine bootstrap via official GitHub release or SteamCMD -- Steam Workshop tooling for mod download, sync, archive, and cleanup -- Built-in backup flows for worlds, configs, and full-server snapshots -- Diagnostics and repair helpers for common setup mistakes -- Per-mod config editing from the control menu -- Log rotation and project-relative config path support +- Repo-local layout with `Engine/`, `Mods/`, `Worlds/`, `ModConfigs/`, `Backups/`, `Logs/`, and optional `Tools/SteamCMD/` +- Persistent Go control room with live server snapshot, log tails, in-app command output, and mouse-friendly navigation +- Native control-room flows for Workshop URL or ID queueing, installed-mod load management, and per-mod config editing +- Manifest-driven addon sections from `Addons/*/addon.json`, so new tool groups can plug into the UI without editing core code +- Shell hub kept for automation and direct command entrypoints while interactive launches go straight to the Go control room +- Backup, monitoring, diagnostics, and workshop maintenance still live in the same repo-local toolkit +- Local-only files and Go build artifacts are already covered by `.gitignore` ## Quick Start -1. Install system packages. -2. Run `make setup`. -3. Optionally run `make steamcmd-local`. -4. Install tModLoader server files into `Engine/`. -5. Start the persistent control room. - -### 1. Install Packages +### 1. Install packages Debian / Ubuntu: ```bash -sudo apt update -y -sudo apt install -y git screen curl jq pigz rsync unzip htop ncdu net-tools dos2unix fzf dialog golang +sudo apt update -y # refresh package metadata +sudo apt install -y git screen curl jq pigz rsync unzip net-tools dos2unix htop ncdu # core runtime and admin tools +sudo apt install -y golang # Go toolchain for make tui-run and shell-launched UI ``` Fedora: ```bash -sudo dnf install -y git screen curl jq pigz rsync unzip htop ncdu net-tools dos2unix fzf dialog golang +sudo dnf install -y git screen curl jq pigz rsync unzip net-tools dos2unix htop ncdu # core runtime and admin tools +sudo dnf install -y golang # Go toolchain for make tui-run and shell-launched UI ``` -### 2. Bootstrap the Project +Notes: + +- `golang` is required for `make tui-run` or for `bash Scripts/hub/tmod-control.sh` when no built binary exists yet. +- `screen` is required for normal server start and stop flows. + +### 2. Bootstrap the repo ```bash -make setup +make setup # create repo-local directories and missing local config files ``` -This creates the expected directory layout, copies local config templates, and makes the scripts executable. +This creates the expected directory layout, copies local working files from tracked examples, and makes the scripts executable. Existing local files are left alone. + +Created on first run: + +- `Configs/serverconfig.txt` from `Configs/serverconfig.example.txt` +- `Scripts/env.sh` from `Scripts/env.example.sh` +- `Scripts/steam/mod_ids.txt` from `Scripts/steam/mod_ids.example.txt` -### 3. Install SteamCMD Locally +### 3. Optionally install SteamCMD locally ```bash -make steamcmd-local +make steamcmd-local # install SteamCMD into Tools/SteamCMD/ ``` This installs SteamCMD into `Tools/SteamCMD/steamcmd.sh`, which matches the default `steamcmd_path` in `Configs/serverconfig.txt`. -If you plan to use Workshop downloads, install SteamCMD even if you used `make engine-github` for the engine itself. +Run this if you want Workshop downloads, or if you prefer installing the engine through SteamCMD instead of GitHub releases. -### 4. Install tModLoader Server Files +### 4. Install tModLoader into `Engine/` Recommended public-friendly path: ```bash -make engine-github +make engine-github # download and extract the latest tModLoader release into Engine/ ``` This downloads the latest official `tModLoader.zip` release from GitHub and extracts it into `Engine/`. @@ -85,165 +82,167 @@ This downloads the latest official `tModLoader.zip` release from GitHub and extr Alternative SteamCMD path: ```bash -export STEAM_USERNAME="your_steam_username" -./Tools/SteamCMD/steamcmd.sh \ - +force_install_dir "$PWD/Engine" \ - +login "$STEAM_USERNAME" \ - +app_update 1281930 validate \ - +quit +export STEAM_USERNAME="your_steam_username" # Steam account used for owned-app installs +./Tools/SteamCMD/steamcmd.sh \ # run the local SteamCMD install + +force_install_dir "$PWD/Engine" \ # place server files in this repo's Engine/ + +login "$STEAM_USERNAME" \ # authenticate with your Steam account + +app_update 1281930 validate \ # install or update tModLoader server files + +quit # exit SteamCMD ``` Notes: - `1281930` is the tModLoader app ID. -- Steam reports that app as requiring ownership of Terraria (`105600`), so anonymous SteamCMD downloads can appear to succeed while leaving `Engine/` empty. +- Steam reports that app as requiring Terraria ownership (`105600`), so anonymous SteamCMD downloads can appear to succeed while leaving `Engine/` empty. - The GitHub release path avoids that first-run trap for public users. -After a successful install, `Engine/` should contain the tModLoader binaries, `tModLoader.dll`, `tModLoader.runtimeconfig.json`, and `start-tModLoaderServer.sh`. +After a successful install, `Engine/` should contain `tModLoader.dll`, `tModLoader.runtimeconfig.json`, and `start-tModLoaderServer.sh`. -### 5. Launch the Persistent TUI +### 5. Launch the control room + +Run the Go UI from source: ```bash -make tui-run +make tui-run # launch the Go control room from source ``` -For verbose terminal logging: +Run with verbose terminal logging: ```bash -TMOD_DEBUG=1 make tui-run +TMOD_DEBUG=1 make tui-run # launch the Go control room with verbose terminal logging ``` -Shell entrypoint that now prefers the Go control room: +Use the shell entrypoint to open the Go control room from the repo root: ```bash -bash Scripts/hub/tmod-control.sh +bash Scripts/hub/tmod-control.sh tui # launch the Go control room through the shell hub ``` -## Project Layout +If Go is not installed on the host yet, build the binary first and then use the same shell entrypoint: + +```bash +make tui-build # build bin/tmodloader-ui once +bash Scripts/hub/tmod-control.sh tui # launch the built control room +``` + +Optional extras: + +```bash +make tui-build # build bin/tmodloader-ui +make install-man # install the man page system-wide +make help # list the available make targets +``` + +## Self-Contained Layout ```text / -├── Engine/ # tModLoader server files -├── Mods/ # Installed .tmod files + enabled.json -├── Worlds/ # World save files -├── ModConfigs/ # Per-mod config files -├── Configs/ # Server and workshop config +├── Engine/ # tModLoader server files +├── Mods/ # Installed .tmod files + enabled.json +├── Worlds/ # World save files +├── ModConfigs/ # Per-mod config files +├── Configs/ +│ ├── serverconfig.example.txt +│ ├── serverconfig.txt # local, created by make setup +│ └── workshop_map.json # optional local Workshop-ID hints ├── Backups/ │ ├── Worlds/ │ ├── Configs/ │ └── Full/ -├── Logs/ # Script, server, monitor, and dotnet logs +├── Logs/ # Script, server, monitor, backup, and dotnet logs ├── Tools/ │ └── SteamCMD/ -└── Scripts/ - ├── backup/ - ├── core/ - ├── diag/ - ├── hub/ - └── steam/ +├── Addons/ # optional manifest-driven UI extensions +├── Scripts/ +│ ├── env.example.sh +│ ├── env.sh # local, created by make setup +│ ├── backup/ +│ ├── core/ +│ ├── diag/ +│ ├── hub/ +│ └── steam/ +│ ├── mod_ids.example.txt +│ └── mod_ids.txt # local, created by make setup +└── Testing/ + ├── local/ # ignored scratch scripts + ├── output/ # ignored captured output + └── tmp/ # ignored disposable workspace ``` -Everything above is expected to live inside the project by default. That is the main difference between this public portable repo and the older machine-specific setup it came from. +Tracked examples stay in git. Generated working copies, runtime content, and scratch space stay local. -## Common Workflows +## Daily Use -### Run the Persistent TUI +### Control room and UI ```bash -make tui-run +bash Scripts/hub/tmod-control.sh tui # launch the Go control room +bash Scripts/hub/tmod-control.sh interactive # accepted alias for the same control room ``` -This is the new primary interface. It keeps the screen alive while commands run, streams backend script output inside the app, refreshes server status in place, and lets you cycle through the project log files without dropping back to raw shell output. - -The default landing view is a section overview, so you drill into `Server`, `Workshop`, `Backup`, `Monitor`, `Diagnostics`, or `Maintenance` instead of starting on one long action list. The right side of the UI stays anchored around a selected-section/action panel, a live `Server Snapshot`, and a lower pane for log tails or command output. - -The shell entrypoint now prefers the same control room when it can find a built `bin/tmodloader-ui` or a local Go toolchain: - -```bash -bash Scripts/hub/tmod-control.sh -``` +Go UI basics: -Useful keys: +- `Enter` opens a section or runs the selected action +- `r` refreshes status and the current log view +- `l` cycles between log files when log-tail view is active +- `Tab` switches between log-tail view and command-output view +- `Esc` returns to the section overview +- `q` quits when idle +- mouse hover and click can select and activate rows when your terminal forwards mouse events -- `Enter`: open the selected section or run the selected action -- `r`: refresh status and the current log view -- `l`: cycle between `server.log`, `control.log`, `workshop.log`, `backup.log`, `monitor.log`, and `diagnostics.log` -- `Tab`: switch between log-tail view and command-output view -- `Shift+Left` / `Shift+Right`: horizontal scroll in the lower pane when output is wider than the window -- `Esc`: return to the section overview from a category page -- Mouse wheel: move one item at a time in the current list -- `q`: quit when idle -- `Ctrl+C`: force quit immediately +The left legend stays visible on every screen and gives the currently valid hotkeys a little more contrast, so the control scheme stays learn-once instead of page-by-page. -### Run the Legacy Shell Fallback +Workshop tools in the Go UI now include native screens for `Add Mod by URL or ID`, `Manage Installed Mods`, and `Edit Mod Configs`. The config editor still hands off to your terminal editor and returns you to the TUI when you exit. -```bash -bash Scripts/hub/tmod-control.sh interactive classic -``` +Addon action packs under `Addons/*/addon.json` are loaded into the control room automatically. If an addon manifest is invalid, the control room shows a warning in the header and command-output pane, and the loader details are still written to `Logs/control.log`. -If you want the older searchable shell palette instead of the Go TUI, force the legacy path: +### Server commands ```bash -TMOD_FORCE_LEGACY_UI=1 bash Scripts/hub/tmod-control.sh interactive +bash Scripts/hub/tmod-control.sh start # start the server in its managed screen session +bash Scripts/hub/tmod-control.sh stop # stop the running server cleanly +bash Scripts/hub/tmod-control.sh restart # restart the managed server process +bash Scripts/hub/tmod-control.sh status # print a quick status summary ``` -When available, the legacy hub uses `fzf` for searchable pickers and `dialog` for boxed menus and log viewers. You can force a legacy shell mode with `TMOD_UI_MODE=dialog`, `TMOD_UI_MODE=fzf`, or `TMOD_UI_MODE=plain`. - -Main areas exposed through the palette: - -- `Server`: start, stop, restart, select world, create world, import world -- `Mods`: add by Workshop URL or ID, toggle enabled mods, inspect downloads, edit mod configs -- `Monitoring`: dashboard, health check, live monitor, log viewing, console attach -- `Backup`: create, restore, verify, and clean up backups -- `Maintenance`: diagnostics, engine update, emergency controls - -### Server Commands +### Workshop commands ```bash -bash Scripts/hub/tmod-control.sh start -bash Scripts/hub/tmod-control.sh stop -bash Scripts/hub/tmod-control.sh restart -bash Scripts/hub/tmod-control.sh status +bash Scripts/hub/tmod-control.sh workshop download # download Workshop mods listed in mod_ids.txt +bash Scripts/hub/tmod-control.sh workshop sync # copy compatible Workshop mods into Mods/ +bash Scripts/hub/tmod-control.sh workshop sync --yes # run sync non-interactively +bash Scripts/hub/tmod-control.sh workshop list # inspect downloaded Workshop mods +bash Scripts/hub/tmod-control.sh workshop archive # archive old incompatible mod versions +bash Scripts/hub/tmod-control.sh workshop archive --yes # run archival non-interactively +bash Scripts/hub/tmod-control.sh workshop cleanup # remove incomplete Workshop leftovers +bash Scripts/hub/tmod-control.sh workshop status # show Workshop paths and SteamCMD status ``` -### Workshop Commands +### Backup and diagnostics ```bash -bash Scripts/hub/tmod-control.sh workshop download -bash Scripts/hub/tmod-control.sh workshop sync -bash Scripts/hub/tmod-control.sh workshop sync --yes -bash Scripts/hub/tmod-control.sh workshop list -bash Scripts/hub/tmod-control.sh workshop archive -bash Scripts/hub/tmod-control.sh workshop archive --yes -bash Scripts/hub/tmod-control.sh workshop cleanup +bash Scripts/hub/tmod-control.sh backup worlds # back up world save files +bash Scripts/hub/tmod-control.sh backup configs # back up server and script config files +bash Scripts/hub/tmod-control.sh backup full # create a full repo-local server backup +bash Scripts/hub/tmod-control.sh backup auto # run the default automatic backup flow +bash Scripts/hub/tmod-control.sh monitor start # start the background health monitor +bash Scripts/hub/tmod-control.sh monitor status # inspect monitor status +bash Scripts/hub/tmod-control.sh diagnostics # run the full diagnostics entrypoint +bash Scripts/diag/tmod-diagnostics.sh report # generate a standalone diagnostics report ``` -### Backup Commands +For the full command surface: ```bash -bash Scripts/hub/tmod-control.sh backup worlds -bash Scripts/hub/tmod-control.sh backup configs -bash Scripts/hub/tmod-control.sh backup full -bash Scripts/hub/tmod-control.sh backup auto -bash Scripts/backup/tmod-backup.sh restore --yes Backups/Worlds/worlds_YYYYMMDD_HHMMSS.tar.gz +bash Scripts/hub/tmod-control.sh help # print the shell hub usage summary +man tmod-control # open the installed man page ``` -### Diagnostics Commands - -```bash -bash Scripts/diag/tmod-diagnostics.sh quick -bash Scripts/diag/tmod-diagnostics.sh full -bash Scripts/diag/tmod-diagnostics.sh binaries -bash Scripts/diag/tmod-diagnostics.sh config -bash Scripts/diag/tmod-diagnostics.sh fix -bash Scripts/diag/tmod-diagnostics.sh report -``` - -## Configuration +## Local Configuration ### `Configs/serverconfig.txt` -`make setup` creates `Configs/serverconfig.txt` from the tracked example file. This local config is intentionally gitignored. +`make setup` creates this file from `Configs/serverconfig.example.txt`. It is the main local server config and is intentionally gitignored. Useful notes: @@ -252,7 +251,7 @@ Useful notes: - script settings live under the `tmod-scripts` section at the bottom - paths support absolute values, `~/...`, and project-relative paths like `./Tools/SteamCMD/steamcmd.sh` -Example script settings: +Example: ```ini # ─── tmod-scripts ───────────────────────────────────────────────────────────── @@ -263,28 +262,31 @@ log_keep_days=14 ### `Scripts/env.sh` -`make setup` also creates `Scripts/env.sh` from `Scripts/env.example.sh`. - -Use it for local values you do not want in tracked files, such as: +`make setup` creates this file from `Scripts/env.example.sh`. Use it for local values you do not want in tracked files, such as: - `STEAM_USERNAME` - `STEAM_API_KEY` - webhook URLs - machine-specific overrides -For Workshop downloads, `STEAM_USERNAME` is optional. The toolkit will fall back to anonymous SteamCMD access if it is unset, but a real Steam account may be more reliable for larger download batches. +For Workshop downloads, `STEAM_USERNAME` is optional. Anonymous SteamCMD access is used as a fallback, but a real Steam account may be more reliable for larger download batches. ### `Scripts/steam/mod_ids.txt` -This file is also local and gitignored. It accepts one Steam Workshop URL or numeric ID per line. Lines starting with `#` are ignored. +`make setup` creates this file from `Scripts/steam/mod_ids.example.txt` if it is missing. It is local and gitignored. Add one Steam Workshop URL or numeric ID per line. Lines starting with `#` are ignored. -The control hub can manage it for you through the Mods page, but direct editing works fine too. +The Go UI can manage this file natively through `Workshop / Add Mod by URL or ID`, and `Workshop / Manage Installed Mods` writes the matching `Mods/enabled.json` load list: -### `Configs/workshop_map.json` +```bash +bash Scripts/hub/tmod-control.sh mods add https://steamcommunity.com/sharedfiles/filedetails/?id=2824688804 # add by Workshop URL +bash Scripts/hub/tmod-control.sh mods add 2824688804 # add by numeric Workshop ID +``` -Optional file for mapping mod names to Workshop IDs when dependency helpers need a manual hint. +Direct editing works fine too. -Example: +### `Configs/workshop_map.json` + +Optional local file for mapping mod names to Workshop IDs when dependency helpers need a manual hint. ```json { @@ -294,7 +296,7 @@ Example: ## Runtime Notes -### .NET Runtime +### .NET runtime You do not need to install the tModLoader runtime manually. On first server start, the toolkit reads the required version from `Engine/tModLoader.runtimeconfig.json` and runs tModLoader's bundled installer into `Engine/dotnet/`. @@ -304,96 +306,77 @@ If that install fails, check: - that `Engine/` contains a valid tModLoader server install - that the host can reach the required download endpoints -### Git Hygiene +### What stays out of git -The repo is set up so that runtime data stays local. `.gitignore` already excludes: +`.gitignore` already excludes: -- `Engine/`, `Mods/`, `Worlds/`, `Backups/`, `Logs/`, `Tools/SteamCMD/` -- `Configs/serverconfig.txt` -- `Scripts/env.sh` -- `Scripts/steam/mod_ids.txt` -- `bin/`, `coverage.out`, `*.coverprofile`, and `*.test` +- `Engine/`, `Mods/`, `Worlds/`, `ModConfigs/`, `Backups/`, `Logs/`, and `Tools/SteamCMD/` +- `Configs/serverconfig.txt` and `Configs/workshop_map.json` +- `Scripts/env.sh` and `Scripts/steam/mod_ids.txt` - `Testing/local/`, `Testing/output/`, and `Testing/tmp/` +- `bin/`, `tmodloader-ui`, `cmd/tmodloader-ui/tmodloader-ui`, `coverage.out`, `*.coverprofile`, `*.test`, `*.prof`, and `*.pprof` That keeps the public repo clean while still letting the project behave like a complete local server workspace. +## Addons + +The Go control room can load extra sections and actions from addon manifests in `Addons//addon.json`. + +Each manifest defines a default `section` plus one or more `actions`. By default, addon actions run with their own addon directory as the working directory, so local helper scripts can be referenced directly. + +Example structure: + +```text +Addons/ +└── admin-tools/ + ├── addon.json + └── scripts/ + ├── audit-world.sh + └── rotate-admin-tokens.sh +``` + +Example manifest: + +```json +{ + "name": "admin-tools", + "section": "Admin", + "actions": [ + { + "title": "Audit World", + "description": "Run the world audit helper.", + "command": ["bash", "scripts/audit-world.sh"] + }, + { + "title": "Rotate Admin Tokens", + "description": "Rotate admin auth material.", + "command": ["bash", "scripts/rotate-admin-tokens.sh"], + "confirm_text": "Rotate admin tokens now?" + } + ] +} +``` + +Supported manifest fields: + +- `name`: optional label for the addon bundle +- `section`: default UI section name for all actions in the file +- `actions[].section`: optional per-action override if one addon needs multiple sections +- `actions[].title`: action label shown in the UI +- `actions[].description`: short help text for the selected action panel +- `actions[].command`: argv array to execute +- `actions[].confirm_text`: optional confirm prompt before running +- `actions[].working_dir`: optional working directory + +`actions[].command` and `actions[].working_dir` also support `${repo_dir}` and `${addon_dir}` placeholders. Invalid addon manifests are skipped with warnings in the control room and details in `Logs/control.log`. + +See [Addons/README.md](/home/matt/githubprojects/tmodloader-github/tmodloader-server/Addons/README.md) for the same rules in a smaller reference format. + ## Changelog -### v2.6.0 — 2026-03-27 - -**Persistent Go TUI** -- Added a Bubble Tea-based headless server console that keeps the screen alive while backend actions run. -- Replaced the old catch-all landing list with a section overview, native section pages, and a broader set of shell-backed admin actions. -- Added `make tui-run` and `make tui-build` so the Go UI is easy to launch from source or build into `bin/tmodloader-ui`. -- Made `bash Scripts/hub/tmod-control.sh` prefer the Go TUI for interactive launches, while keeping `interactive classic` and `TMOD_FORCE_LEGACY_UI=1` for the legacy shell UI. - -**Observability & Layout** -- Added a live `Server Snapshot` with running state, PID, world, players, mod and backup counts, CPU, RSS memory, uptime, disk activity, and host temperature. -- Kept log tails and command output inside the app so server actions no longer dump you back into raw shell output. -- Tightened pane layout, empty states, mouse-wheel handling, and overview/action previews so the interface behaves more like a persistent SSH console than a shell launcher. -- Hardened status polling so offline states do not flicker or report bogus PID, memory, or uptime values. - -**Compatibility** -- The Bash control hub remains available as a legacy fallback and backend command surface. -- Existing script-driven workflows continue to work through the same `tmod-control.sh`, backup, workshop, diagnostics, and monitor commands. - -### v2.5.2 — 2026-03-27 - -**Headless UI** -- Added a dependency-aware interactive hub that can use `dialog` for boxed menus/log viewers and `fzf` for searchable pickers, while keeping plain-Bash fallback intact. -- Expanded the command palette into a fuller direct-action launcher instead of mostly routing through submenu pages. -- Unified page navigation around shared menu and picker helpers for worlds, backups, mod configs, logs, and common prompts. - -**Automation & Workflow Fixes** -- Added `--yes` support to workshop sync, workshop archive, mod-list clearing, and backup restore so scripted flows do not hang on confirmations. -- Updated maintenance to run workshop sync non-interactively, matching the cron-style examples in the docs. -- Tightened workshop sync so pre-2023 mod builds are skipped consistently instead of being copied into `Mods/`. - -**Backup Safety** -- Fixed restore correctness by switching rsync-based restore paths to checksum mode. -- Fixed same-second backup filename collisions so pre-restore safety backups cannot overwrite the archive you are trying to restore. - -### v2.5.1 — 2026-03-27 - -**Portable/Public Release** -- Reworked the toolkit into a project-root portable layout instead of assuming a fixed home-directory install. -- Made `steamcmd_path` default to `./Tools/SteamCMD/steamcmd.sh`. -- Added support for project-relative paths in config so tracked examples stay machine-agnostic. -- Added `make steamcmd-local` for repo-local SteamCMD bootstrap. -- Added `Scripts/env.example.sh` and improved `make setup` for portable onboarding. -- Added GitHub community health files, issue templates, PR template, and CI workflow for the public repo. -- Reframed the repo and README around the portable public edition. - -**Bug Fixes** -- Fixed full backup and full restore to respect the capitalized `Logs/` and `Backups/` layout. -- Fixed diagnostics `auto_fix` to recreate the actual repo directory structure instead of legacy lowercase paths. -- Fixed monitor process detection so monitoring and health checks agree with the server start mode. -- Fixed `tmod-control.sh diagnostics` to run the full diagnostics script instead of the lightweight inline summary. - -### v2.5.0 — 2026-03-01 - -Inherited from the original `tmodloaderserver` line before the portable fork became the public repo. - -**Menu & UX** -- Removed obsolete Download Mods and Sync Mods menu items in favor of the URL-to-`enabled.json` flow. -- Restructured all five menu pages for a better headless-server workflow. -- Expanded Monitoring with dashboard, health check, live monitor, log viewing, and console attach. -- Expanded Backup with inline restore and verify pickers, cleanup, and log viewing. -- Added Mod Configs editing from the Mods page. -- Added world import flow from pre-uploaded `.wld` files. - -**Logging and Config** -- Moved script logging to file-first behavior while keeping warnings and errors visible in the terminal. -- Added `--debug` support through `TMOD_DEBUG=1` for noisier child-script output. -- Renamed the main directories to the capitalized tModLoader-style layout. -- Moved `STEAMCMD_PATH` handling into `serverconfig.txt` as `steamcmd_path=`. -- Added configurable log rotation settings and improved rotated log handling. -- Added `server_config_get()` helper support in core scripts. - -**Cleanup** -- Removed the whitelist system from the toolkit. -- Excluded `*.bak` from git. -- Started treating `Scripts/steam/mod_ids.txt` as a local gitignored file with a tracked example template. +Release history lives in [`CHANGELOG.md`](CHANGELOG.md). + +Add new user-facing changes to `Unreleased` there so the README can stay focused on setup, usage, and the self-contained layout. ## Releases diff --git a/Scripts/backup/tmod-backup.sh b/Scripts/backup/tmod-backup.sh index f0c9bf6..27b2e71 100755 --- a/Scripts/backup/tmod-backup.sh +++ b/Scripts/backup/tmod-backup.sh @@ -10,16 +10,20 @@ CORE_SCRIPT="$SCRIPT_DIR/../core/tmod-core.sh" if [[ -f "$CORE_SCRIPT" ]]; then # shellcheck disable=SC1090 source "$CORE_SCRIPT" || { - echo "❌ Failed to load core functions from $CORE_SCRIPT" + echo "Error: Failed to load core functions from $CORE_SCRIPT" exit 1 } else - echo "❌ Cannot find core functions at: $CORE_SCRIPT" - echo "📁 Current directory: $(pwd)" - echo "📁 Script directory: $SCRIPT_DIR" + echo "Error: Cannot find core functions at: $CORE_SCRIPT" + echo "Current directory: $(pwd)" + echo "Script directory: $SCRIPT_DIR" exit 1 fi +print_divider() { + printf '%s\n' '------------------------------------------------------------' +} + # Enhanced backup configuration BACKUP_ROOT="$BASE_DIR/Backups" WORLD_BACKUP_DIR="$BACKUP_ROOT/Worlds" @@ -508,9 +512,9 @@ verify_backup() { # Enhanced status with detailed breakdown show_status() { - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "💾 tModLoader Enhanced Backup System Status" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider + echo "tModLoader Backup System Status" + print_divider # Storage usage with breakdown local total_size world_size config_size full_size @@ -519,18 +523,18 @@ show_status() { config_size=$(du -sh "$CONFIG_BACKUP_DIR" 2>/dev/null | cut -f1) full_size=$(du -sh "$FULL_BACKUP_DIR" 2>/dev/null | cut -f1) - echo "📊 Storage Usage:" + echo "Storage Usage:" echo " Total: $total_size" - echo " └── Worlds: $world_size" - echo " └── Configs: $config_size" - echo " └── Full: $full_size" - echo "📁 Location: $BACKUP_ROOT" + echo " Worlds: $world_size" + echo " Configs: $config_size" + echo " Full: $full_size" + echo "Location: $BACKUP_ROOT" echo # World backups with latest info local world_count world_count=$(find "$WORLD_BACKUP_DIR" -name "worlds_*.tar.gz" 2>/dev/null | wc -l) - echo "🌍 World Backups: $world_count (retention: ${WORLD_RETENTION_DAYS} days)" + echo "World Backups: $world_count (retention: ${WORLD_RETENTION_DAYS} days)" if (( world_count > 0 )); then local latest_world latest_world=$(find "$WORLD_BACKUP_DIR" -name "worlds_*.tar.gz" -printf '%T@ %p\n' | sort -n | tail -1 | cut -d' ' -f2-) @@ -542,9 +546,9 @@ show_status() { # Check integrity of latest if verify_backup "$latest_world" >/dev/null 2>&1; then - echo " Status: ✅ Latest backup verified" + echo " Status: OK - latest backup verified" else - echo " Status: ⚠️ Latest backup needs verification" + echo " Status: Warning - latest backup needs verification" fi fi echo @@ -552,7 +556,7 @@ show_status() { # Config backups local config_count config_count=$(find "$CONFIG_BACKUP_DIR" -name "configs_*.tar.gz" 2>/dev/null | wc -l) - echo "⚙️ Config Backups: $config_count (retention: ${CONFIG_RETENTION_DAYS} days)" + echo "Config Backups: $config_count (retention: ${CONFIG_RETENTION_DAYS} days)" if (( config_count > 0 )); then local latest_config latest_config=$(find "$CONFIG_BACKUP_DIR" -name "configs_*.tar.gz" -printf '%T@ %p\n' | sort -n | tail -1 | cut -d' ' -f2-) @@ -567,7 +571,7 @@ show_status() { # Full backups local full_count full_count=$(find "$FULL_BACKUP_DIR" -name "full_*.tar.gz" 2>/dev/null | wc -l) - echo "💾 Full Backups: $full_count (retention: ${FULL_RETENTION_DAYS} days)" + echo "Full Backups: $full_count (retention: ${FULL_RETENTION_DAYS} days)" if (( full_count > 0 )); then local latest_full latest_full=$(find "$FULL_BACKUP_DIR" -name "full_*.tar.gz" -printf '%T@ %p\n' | sort -n | tail -1 | cut -d' ' -f2-) @@ -581,22 +585,23 @@ show_status() { # Recent activity if [[ -f "$LOG_DIR/backup.log" ]]; then - echo "📋 Recent Activity (last 5 entries):" + echo "Recent Activity (last 5 entries):" tail -5 "$LOG_DIR/backup.log" | sed 's/^/ /' fi - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider } # List backups with enhanced details list_backups() { local backup_type="${1:-all}" - echo "📋 Enhanced Backup Inventory" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider + echo "Enhanced Backup Inventory" + print_divider if [[ "$backup_type" == "all" || "$backup_type" == "worlds" ]]; then - echo "🌍 World Backups:" + echo "World Backups:" if compgen -G "$WORLD_BACKUP_DIR/worlds_*.tar.gz" > /dev/null; then printf " %-15s %-10s %-8s %-10s %s\n" "Date" "Time" "Size" "Status" "Filename" echo " $(printf '%0.1s' '-'{1..70})" @@ -607,9 +612,9 @@ list_backups() { # Check if backup has checksum if [[ -f "$WORLD_BACKUP_DIR/$filename.md5" ]]; then - status_icon="✅" + status_icon="OK" else - status_icon="⚠️" + status_icon="Warning" fi printf " %-15s %-10s %-8s %-10s %s\n" "$date" "$time" "$size_human" "$status_icon" "$filename" @@ -621,7 +626,7 @@ list_backups() { fi if [[ "$backup_type" == "all" || "$backup_type" == "configs" ]]; then - echo "⚙️ Configuration Backups:" + echo "Configuration Backups:" if compgen -G "$CONFIG_BACKUP_DIR/configs_*.tar.gz" > /dev/null; then printf " %-15s %-10s %-8s %-10s %s\n" "Date" "Time" "Size" "Status" "Filename" echo " $(printf '%0.1s' '-'{1..70})" @@ -631,9 +636,9 @@ list_backups() { size_human=$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size}B") if [[ -f "$CONFIG_BACKUP_DIR/$filename.md5" ]]; then - status_icon="✅" + status_icon="OK" else - status_icon="⚠️" + status_icon="Warning" fi printf " %-15s %-10s %-8s %-10s %s\n" "$date" "$time" "$size_human" "$status_icon" "$filename" @@ -645,7 +650,7 @@ list_backups() { fi if [[ "$backup_type" == "all" || "$backup_type" == "full" ]]; then - echo "💾 Full Server Backups:" + echo "Full Server Backups:" if compgen -G "$FULL_BACKUP_DIR/full_*.tar.gz" > /dev/null; then printf " %-15s %-10s %-8s %-10s %s\n" "Date" "Time" "Size" "Status" "Filename" echo " $(printf '%0.1s' '-'{1..70})" @@ -655,9 +660,9 @@ list_backups() { size_human=$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size}B") if [[ -f "$FULL_BACKUP_DIR/$filename.md5" ]]; then - status_icon="✅" + status_icon="OK" else - status_icon="⚠️" + status_icon="Warning" fi printf " %-15s %-10s %-8s %-10s %s\n" "$date" "$time" "$size_human" "$status_icon" "$filename" @@ -667,8 +672,8 @@ list_backups() { fi fi - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Legend: ✅ = Verified/Has checksum ⚠️ = Needs verification" + print_divider + echo "Legend: OK = verified or has checksum, Warning = needs verification" } # Safe restore with pre-restore backup @@ -676,14 +681,14 @@ restore_backup() { local backup_file="$1" if [[ ! -f "$backup_file" ]]; then - echo "❌ Backup file not found: $backup_file" + echo "Error: Backup file not found: $backup_file" return 1 fi # Verify backup integrity first - echo "🔍 Verifying backup integrity..." + echo "Verifying backup integrity..." if ! verify_backup "$backup_file"; then - echo "❌ Backup verification failed. Restore aborted for safety." + echo "Error: Backup verification failed. Restore aborted for safety." return 1 fi @@ -697,21 +702,21 @@ restore_backup() { elif [[ "$filename" == full_* ]]; then backup_type="full" else - echo "❌ Unknown backup type: $filename" + echo "Error: Unknown backup type: $filename" return 1 fi - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "⚠️ RESTORE OPERATION" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider + echo "RESTORE OPERATION" + print_divider echo "This will restore: $backup_type" echo "From backup: $filename" echo "Target: $backup_type files will be OVERWRITTEN" echo - echo "⚠️ WARNING: Current $backup_type data will be replaced!" + echo "Warning: Current $backup_type data will be replaced!" echo if (( BACKUP_ASSUME_YES )); then - echo "ℹ️ Auto-confirm enabled — proceeding with restore" + echo "Info: Auto-confirm enabled - proceeding with restore" else read -p "Are you absolutely sure? Type 'yes' to continue: " -r if [[ ! "$REPLY" =~ ^[Yy][Ee][Ss]$ ]]; then @@ -721,28 +726,28 @@ restore_backup() { fi # Create pre-restore backup - echo "📦 Creating pre-restore safety backup..." + echo "Creating pre-restore safety backup..." local safety_backup_created=false case "$backup_type" in worlds) if backup_world >/dev/null 2>&1; then safety_backup_created=true - echo "✅ Pre-restore world backup created" + echo "OK: Pre-restore world backup created" fi ;; configs) if backup_config >/dev/null 2>&1; then safety_backup_created=true - echo "✅ Pre-restore config backup created" + echo "OK: Pre-restore config backup created" fi ;; full) - echo "⚠️ Full restore - safety backup skipped (would be too large)" + echo "Warning: Full restore - safety backup skipped (would be too large)" ;; esac # Perform restore - echo "🔄 Restoring from $filename..." + echo "Restoring from $filename..." local temp_extract="$TEMP_DIR/restore_$$" mkdir -p "$temp_extract" @@ -768,12 +773,12 @@ restore_backup() { else cp -rf "$temp_extract/$relative_path" "$config_item" fi - echo " ✅ Restored $relative_path" + echo " OK: Restored $relative_path" fi done ;; full) - echo "⚠️ Full restore requires server to be offline" + echo "Warning: Full restore requires server to be offline" if command -v rsync >/dev/null; then rsync -avc --exclude="Logs/" --exclude="Backups/" "$temp_extract/$(basename "$BASE_DIR")/" "$BASE_DIR/" else @@ -784,17 +789,17 @@ restore_backup() { rm -rf "$temp_extract" log_backup "Restore completed: $filename" "SUCCESS" - echo "✅ Restore completed successfully!" + echo "OK: Restore completed successfully!" if [[ "$safety_backup_created" == "true" ]]; then - echo "💡 Pre-restore backup is available if you need to revert" + echo "Tip: Pre-restore backup is available if you need to revert" fi return 0 else rm -rf "$temp_extract" log_backup "Restore failed: $filename" "ERROR" - echo "❌ Restore failed - original data unchanged" + echo "Error: Restore failed - original data unchanged" return 1 fi } @@ -802,7 +807,7 @@ restore_backup() { # Show enhanced help show_help() { cat << 'EOF' -💾 tModLoader Enhanced Backup System +tModLoader Backup System Comprehensive backup solution with compression, integrity checking, and safe restore @@ -848,13 +853,13 @@ Automation Examples: 0 4 * * * $SCRIPT_DIR/tmod-backup.sh cleanup Features: - ✅ Parallel compression (pigz) for faster backups - ✅ MD5 integrity verification with automatic repair - ✅ Safe restore with pre-restore backups - ✅ Automated retention policy with space reporting - ✅ Enhanced Discord notifications with timing/size - ✅ Comprehensive logging and error handling - ✅ Smart exclusion patterns to avoid bloat + - Parallel compression (pigz) for faster backups + - MD5 integrity verification with automatic repair + - Safe restore with pre-restore backups + - Automated retention policy with space reporting + - Enhanced Discord notifications with timing/size + - Comprehensive logging and error handling + - Smart exclusion patterns to avoid bloat EOF } diff --git a/Scripts/core/tmod-core.sh b/Scripts/core/tmod-core.sh index 046bb4d..dfb4cf7 100755 --- a/Scripts/core/tmod-core.sh +++ b/Scripts/core/tmod-core.sh @@ -282,55 +282,148 @@ read_temp_from_thermal_zones() { read_temp_from_sensors() { local want="$1" local line chip + local cpu_package="" cpu_tctl="" cpu_tdie="" + local -a cpu_core_temps=() [[ -x "$(command -v sensors 2>/dev/null)" ]] || return 1 + _emit_cpu_temp_from_sensor_block() { + local package_value="" median_value="" value_count=0 + + if [[ -n "$cpu_tdie" ]]; then + echo "${cpu_tdie}C" + return 0 + fi + + if [[ ${#cpu_core_temps[@]} -gt 0 ]]; then + local -a sorted_core_temps=() + mapfile -t sorted_core_temps < <(printf '%s\n' "${cpu_core_temps[@]}" | sort -n) + value_count=${#sorted_core_temps[@]} + if (( value_count % 2 == 1 )); then + median_value="${sorted_core_temps[$((value_count / 2))]}" + else + median_value=$(( (sorted_core_temps[(value_count / 2) - 1] + sorted_core_temps[value_count / 2]) / 2 )) + fi + fi + + if [[ -n "$cpu_package" ]]; then + package_value="$cpu_package" + elif [[ -n "$cpu_tctl" ]]; then + package_value="$cpu_tctl" + fi + + if [[ -n "$package_value" && -n "$median_value" ]]; then + if (( package_value > median_value + 12 )); then + echo "${median_value}C" + else + echo "${package_value}C" + fi + return 0 + fi + + if [[ -n "$package_value" ]]; then + echo "${package_value}C" + return 0 + fi + + if [[ -n "$median_value" ]]; then + echo "${median_value}C" + return 0 + fi + + return 1 + } + while IFS= read -r line; do if [[ -z "$line" ]]; then + if [[ "$want" == "cpu" ]] && _emit_cpu_temp_from_sensor_block; then + return 0 + fi chip="" + cpu_package="" + cpu_tctl="" + cpu_tdie="" + cpu_core_temps=() continue fi if [[ "$line" != *:* ]]; then + if [[ "$want" == "cpu" ]] && _emit_cpu_temp_from_sensor_block; then + return 0 + fi chip="$(tr '[:upper:]' '[:lower:]' <<<"$line")" + cpu_package="" + cpu_tctl="" + cpu_tdie="" + cpu_core_temps=() continue fi case "$want" in cpu) + if [[ "$line" =~ ^Tdie:[[:space:]]*\+?([0-9]+)(\.[0-9]+)?[[:space:]]*°?C ]]; then + cpu_tdie="${BASH_REMATCH[1]}" + continue + fi if [[ "$line" =~ ^Tctl:[[:space:]]*\+?([0-9]+)(\.[0-9]+)?[[:space:]]*°?C ]]; then - echo "${BASH_REMATCH[1]}C" - return 0 + cpu_tctl="${BASH_REMATCH[1]}" + continue fi if [[ "$line" =~ ^Package[[:space:]]id[[:space:]]0:[[:space:]]*\+?([0-9]+)(\.[0-9]+)?[[:space:]]*°?C ]]; then - echo "${BASH_REMATCH[1]}C" - return 0 + cpu_package="${BASH_REMATCH[1]}" + continue fi if [[ "$line" =~ ^CPU[[:space:]]Temp:[[:space:]]*\+?([0-9]+)(\.[0-9]+)?[[:space:]]*°?C ]]; then - echo "${BASH_REMATCH[1]}C" - return 0 + cpu_package="${BASH_REMATCH[1]}" + continue fi - if [[ "$line" =~ ^temp1:[[:space:]]*\+?([0-9]+)(\.[0-9]+)?[[:space:]]*°?C ]] && [[ "$chip" =~ (k10temp|coretemp|cpu) ]]; then - echo "${BASH_REMATCH[1]}C" - return 0 + if [[ "$line" =~ ^Core[[:space:]]+[0-9]+:[[:space:]]*\+?([0-9]+)(\.[0-9]+)?[[:space:]]*°?C ]]; then + local sensor_value="${BASH_REMATCH[1]}" + if [[ "$chip" =~ coretemp ]]; then + cpu_core_temps+=("$sensor_value") + continue + fi + fi + if [[ "$line" =~ ^ccd[[:space:]]*[0-9]+:[[:space:]]*\+?([0-9]+)(\.[0-9]+)?[[:space:]]*°?C ]]; then + local sensor_value="${BASH_REMATCH[1]}" + if [[ "$chip" =~ (k10temp|zenpower|cpu) ]]; then + cpu_core_temps+=("$sensor_value") + continue + fi + fi + if [[ "$line" =~ ^temp1:[[:space:]]*\+?([0-9]+)(\.[0-9]+)?[[:space:]]*°?C ]]; then + local sensor_value="${BASH_REMATCH[1]}" + if [[ "$chip" =~ (k10temp|coretemp|cpu) ]] && [[ -z "$cpu_package" ]]; then + cpu_package="$sensor_value" + continue + fi fi ;; gpu) - if [[ "$line" =~ ^(edge|junction|temp1):[[:space:]]*\+?([0-9]+)(\.[0-9]+)?[[:space:]]*°?C ]] && [[ "$chip" =~ (amdgpu|radeon|nouveau|gpu) ]]; then - echo "${BASH_REMATCH[2]}C" - return 0 + if [[ "$line" =~ ^(edge|junction|temp1):[[:space:]]*\+?([0-9]+)(\.[0-9]+)?[[:space:]]*°?C ]]; then + local sensor_value="${BASH_REMATCH[2]}" + if [[ "$chip" =~ (amdgpu|radeon|nouveau|gpu) ]]; then + echo "${sensor_value}C" + return 0 + fi fi ;; ram) - if [[ "$line" =~ ^(DIMM|SODIMM|temp1):[[:space:]]*\+?([0-9]+)(\.[0-9]+)?[[:space:]]*°?C ]] && [[ "$chip" =~ (dimm|spd|jc42|ddr|ram|memory) ]]; then - echo "${BASH_REMATCH[2]}C" - return 0 + if [[ "$line" =~ ^(DIMM|SODIMM|temp1):[[:space:]]*\+?([0-9]+)(\.[0-9]+)?[[:space:]]*°?C ]]; then + local sensor_value="${BASH_REMATCH[2]}" + if [[ "$chip" =~ (dimm|spd|jc42|ddr|ram|memory) ]]; then + echo "${sensor_value}C" + return 0 + fi fi ;; nvme) - if [[ "$line" =~ ^(Composite|temp1):[[:space:]]*\+?([0-9]+)(\.[0-9]+)?[[:space:]]*°?C ]] && [[ "$chip" =~ nvme ]]; then - echo "${BASH_REMATCH[2]}C" - return 0 + if [[ "$line" =~ ^(Composite|temp1):[[:space:]]*\+?([0-9]+)(\.[0-9]+)?[[:space:]]*°?C ]]; then + local sensor_value="${BASH_REMATCH[2]}" + if [[ "$chip" =~ nvme ]]; then + echo "${sensor_value}C" + return 0 + fi fi ;; board) @@ -342,6 +435,10 @@ read_temp_from_sensors() { esac done < <(LC_ALL=C sensors 2>/dev/null) + if [[ "$want" == "cpu" ]] && _emit_cpu_temp_from_sensor_block; then + return 0 + fi + return 1 } @@ -349,9 +446,16 @@ get_host_temperatures() { local want label temp for want in cpu gpu ram nvme board; do - temp="$(read_temp_from_thermal_zones "$want" 2>/dev/null || true)" - if [[ -z "$temp" ]]; then + if [[ "$want" == "cpu" ]]; then temp="$(read_temp_from_sensors "$want" 2>/dev/null || true)" + if [[ -z "$temp" ]]; then + temp="$(read_temp_from_thermal_zones "$want" 2>/dev/null || true)" + fi + else + temp="$(read_temp_from_thermal_zones "$want" 2>/dev/null || true)" + if [[ -z "$temp" ]]; then + temp="$(read_temp_from_sensors "$want" 2>/dev/null || true)" + fi fi [[ -n "$temp" ]] || continue diff --git a/Scripts/core/tmod-monitor.sh b/Scripts/core/tmod-monitor.sh index dfd0ecf..c1e8bcc 100755 --- a/Scripts/core/tmod-monitor.sh +++ b/Scripts/core/tmod-monitor.sh @@ -11,14 +11,18 @@ if [[ -f "$CORE_SCRIPT" ]]; then # shellcheck disable=SC1090 source "$CORE_SCRIPT" || { - echo "❌ Failed to load core functions from $CORE_SCRIPT" + echo "Error: Failed to load core functions from $CORE_SCRIPT" exit 1 } else - echo "❌ Cannot find core functions at: $CORE_SCRIPT" + echo "Error: Cannot find core functions at: $CORE_SCRIPT" exit 1 fi +print_divider() { + printf '%s\n' '------------------------------------------------------------' +} + # Monitoring configuration HEALTH_CHECK_INTERVAL=60 CPU_THRESHOLD=80 @@ -117,22 +121,23 @@ perform_health_check() { # Comprehensive status dashboard (unique to monitor) show_status() { - clear - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "🎮 tModLoader Server Status Dashboard" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + if [[ -t 1 && "${TERM:-dumb}" != "dumb" ]]; then + clear + fi + + print_divider + echo "tModLoader Server Status Dashboard" + print_divider - local status_icon status_text + local status_text if is_server_up; then - status_icon="🟢" status_text="ONLINE" else - status_icon="🔴" status_text="OFFLINE" fi - echo "📊 Server Status: $status_icon $status_text" - echo "📅 Last Check: $(date '+%Y-%m-%d %H:%M:%S')" + echo "Server Status: $status_text" + echo "Last Check: $(date '+%Y-%m-%d %H:%M:%S')" if is_server_up; then local stats @@ -142,41 +147,41 @@ show_status() { local cpu mem disk uptime read -r cpu mem disk uptime <<< "$stats" - echo "⚡ CPU Usage: ${cpu}%" - echo "💾 Memory Usage: ${mem}%" - echo "💿 Disk Usage: ${disk}%" - echo "⏱️ Uptime: $uptime" - echo "👥 Players Online: $(get_player_count)" + echo "CPU Usage: ${cpu}%" + echo "Memory Usage: ${mem}%" + echo "Disk Usage: ${disk}%" + echo "Uptime: $uptime" + echo "Players Online: $(get_player_count)" # Show mod count local mod_count mod_count=$(get_mod_list | wc -l) - echo "📦 Mods Loaded: $mod_count" + echo "Mods Loaded: $mod_count" # Screen session info local screen_info screen_info=$(screen -list | grep tmodloader_server || echo 'Not found') - echo "📺 Screen Session: $screen_info" + echo "Screen Session: $screen_info" fi # Recent log entries echo - echo "📋 Recent Activity (last 5 lines):" - echo "┌────────────────────────────────────────────────────────────┐" + echo "Recent Activity (last 5 lines):" + print_divider if [[ -f "$LOG_DIR/server.log" ]]; then - tail -5 "$LOG_DIR/server.log" | sed 's/^/│ /' | cut -c1-60 + tail -5 "$LOG_DIR/server.log" | sed 's/^/ /' | cut -c1-62 else - echo "│ No log file found" + echo " No log file found" fi - echo "└────────────────────────────────────────────────────────────┘" + print_divider else - echo "❌ Server is not running" + echo "Error: Server is not running" echo - echo "💡 To start the server: ./tmod-server.sh start" - echo "💡 To check logs: tail -f $LOG_DIR/server.log" + echo "Tip: To start the server: ./tmod-server.sh start" + echo "Tip: To check logs: tail -f $LOG_DIR/server.log" fi - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider } # Continuous monitoring mode (unique to monitor) @@ -221,18 +226,18 @@ monitor_continuously() { # Show monitoring logs show_logs() { if [[ -f "$LOG_DIR/monitor.log" ]]; then - echo "📋 Monitor Logs (last 20 entries):" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "Monitor Logs (last 20 entries):" + print_divider tail -20 "$LOG_DIR/monitor.log" else - echo "📋 No monitor logs found" + echo "No monitor logs found" fi } # Show help show_help() { cat << 'EOF' -🔧 tModLoader Enhanced Server Monitor +tModLoader Server Monitor Monitor server health, performance, and send intelligent alerts @@ -251,12 +256,12 @@ Examples: ./tmod-monitor.sh logs # View monitor history Features: - ✅ Real-time server health monitoring - ✅ Discord alerts for critical issues - ✅ Resource usage tracking (CPU/Memory/Disk) - ✅ Player count tracking - ✅ Mod error detection and alerting - ✅ Configurable alert thresholds + - Real-time server health monitoring + - Discord alerts for critical issues + - Resource usage tracking (CPU/Memory/Disk) + - Player count tracking + - Mod error detection and alerting + - Configurable alert thresholds Configuration: Edit thresholds at top of script: @@ -275,9 +280,9 @@ case "${1:-status}" in monitor) monitor_continuously ;; check) if perform_health_check; then - echo "✅ Health check passed" + echo "OK: Health check passed" else - echo "❌ Health check failed" + echo "Error: Health check failed" exit 1 fi ;; diff --git a/Scripts/core/tmod-server.sh b/Scripts/core/tmod-server.sh index c957a9c..ab36869 100755 --- a/Scripts/core/tmod-server.sh +++ b/Scripts/core/tmod-server.sh @@ -10,17 +10,21 @@ if [[ -f "$CORE_SCRIPT" ]]; then # shellcheck disable=SC1090 source "$CORE_SCRIPT" || { - echo "❌ Failed to load core functions from $CORE_SCRIPT" + echo "Error: Failed to load core functions from $CORE_SCRIPT" exit 1 } else - echo "❌ Cannot find core functions at: $CORE_SCRIPT" + echo "Error: Cannot find core functions at: $CORE_SCRIPT" exit 1 fi +print_divider() { + printf '%s\n' '------------------------------------------------------------' +} + start_server() { if is_server_up; then - echo "ℹ️ Server is already running" + echo "Info: Server is already running" return 0 fi @@ -28,10 +32,10 @@ start_server() { log_it "Starting tModLoader server" if start_server_screen; then - echo "✅ Server started successfully" + echo "OK: Server started successfully" return 0 else - echo "❌ Failed to start server" + echo "Error: Failed to start server" return 1 fi } @@ -56,11 +60,11 @@ stop_server() { fi if is_server_up; then - echo "❌ Failed to stop server" + echo "Error: Failed to stop server" log_it "Server stop failed" "ERROR" return 1 else - echo "✅ Server stopped" + echo "OK: Server stopped" log_it "Server stopped successfully" return 0 fi @@ -76,12 +80,12 @@ restart_server() { } show_status() { - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "🎮 tModLoader Server Status" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider + echo "tModLoader Server Status" + print_divider if is_server_up; then - echo "Status: 🟢 ONLINE" + echo "Status: ONLINE" local info info=$(get_server_info) @@ -99,15 +103,18 @@ show_status() { # Show recent activity echo "" echo "Recent log entries:" + print_divider if [[ -f "$LOG_DIR/server.log" ]]; then tail -3 "$LOG_DIR/server.log" | sed 's/^/ /' + else + echo " No server log file found" fi else - echo "Status: 🔴 OFFLINE" + echo "Status: OFFLINE" echo "" echo "Use: $0 start" fi - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider } # Simple help @@ -140,4 +147,4 @@ case "${1:-help}" in restart) restart_server ;; status) show_status ;; help|*) show_help ;; -esac \ No newline at end of file +esac diff --git a/Scripts/diag/tmod-diagnostics.sh b/Scripts/diag/tmod-diagnostics.sh index c113c40..5ced0f5 100755 --- a/Scripts/diag/tmod-diagnostics.sh +++ b/Scripts/diag/tmod-diagnostics.sh @@ -8,11 +8,11 @@ CORE_SCRIPT="$SCRIPT_DIR/../core/tmod-core.sh" if [[ -f "$CORE_SCRIPT" ]]; then # shellcheck disable=SC1090 source "$CORE_SCRIPT" || { - echo "❌ Failed to load core functions from $CORE_SCRIPT" + echo "Error: Failed to load core functions from $CORE_SCRIPT" exit 1 } else - echo "❌ Cannot find core functions at: $CORE_SCRIPT" + echo "Error: Cannot find core functions at: $CORE_SCRIPT" exit 1 fi @@ -31,6 +31,10 @@ log_diagnostic() { log_it "Diagnostics: $message" "$level" } +print_divider() { + printf '%s\n' '------------------------------------------------------------' +} + # Initialize diagnostics system - single init, no double-call init_diagnostics() { mkdir -p "$TEMP_DIR" "$LOG_DIR" @@ -973,8 +977,9 @@ generate_report() { # Quick diagnostic quick_diagnostic() { - echo "🚀 Quick Diagnostic Mode" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider + echo "Quick Diagnostic Mode" + print_divider gather_system_info check_directory_structure @@ -982,16 +987,17 @@ quick_diagnostic() { echo if is_server_up; then - echo "✅ Quick Check: Server is running and basic structure is OK" + echo "OK: Server is running and basic structure is healthy" else - echo "⚠️ Quick Check: Server is not running - run full diagnostic for details" + echo "Warning: Server is not running - run the full diagnostic for details" fi } # Auto-fix common issues auto_fix() { - echo "🔧 Auto-Fix Mode - Attempting to resolve common issues..." - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider + echo "Auto-Fix Mode - Attempting to resolve common issues..." + print_divider local fixes_applied=0 @@ -999,7 +1005,7 @@ auto_fix() { local required_dirs=("Engine" "Logs" "Worlds" "Mods" "Configs" "Backups") for dir in "${required_dirs[@]}"; do if [[ ! -d "$BASE_DIR/$dir" ]]; then - echo "🔧 Creating missing directory: $BASE_DIR/$dir" + echo "Creating missing directory: $BASE_DIR/$dir" mkdir -p "$BASE_DIR/$dir" ((fixes_applied++)) fi @@ -1009,7 +1015,7 @@ auto_fix() { local script_dirs=("core" "hub" "backup" "steam" "diag") for dir in "${script_dirs[@]}"; do if [[ ! -d "$BASE_DIR/Scripts/$dir" ]]; then - echo "🔧 Creating missing scripts directory: scripts/$dir" + echo "Creating missing scripts directory: scripts/$dir" mkdir -p "$BASE_DIR/Scripts/$dir" ((fixes_applied++)) fi @@ -1018,16 +1024,16 @@ auto_fix() { # Fix script permissions local fixed_perms=0 while IFS= read -r script; do - echo "🔧 Making script executable: $(basename "$script")" + echo "Making script executable: $(basename "$script")" chmod +x "$script" ((fixes_applied++)) ((fixed_perms++)) done < <(find "$BASE_DIR/Scripts" -name "tmod-*.sh" -not -executable 2>/dev/null) - [[ $fixed_perms -gt 0 ]] && echo " ✅ Fixed permissions on $fixed_perms scripts" + [[ $fixed_perms -gt 0 ]] && echo " OK: Fixed permissions on $fixed_perms scripts" # Create basic server config if missing if [[ ! -f "$BASE_DIR/Configs/serverconfig.txt" ]]; then - echo "🔧 Creating basic server configuration" + echo "Creating basic server configuration" mkdir -p "$BASE_DIR/Configs" if [[ -f "$BASE_DIR/Configs/serverconfig.example.txt" ]]; then cp "$BASE_DIR/Configs/serverconfig.example.txt" "$BASE_DIR/Configs/serverconfig.txt" @@ -1047,16 +1053,16 @@ EOF echo if [[ $fixes_applied -gt 0 ]]; then - echo "✅ Applied $fixes_applied fixes" + echo "OK: Applied $fixes_applied fixes" log_diagnostic "Auto-fix applied $fixes_applied fixes" "INFO" else - echo "ℹ️ No common issues found to fix automatically" + echo "Info: No common issues found to fix automatically" fi } show_help() { cat << 'EOF' -🔧 tModLoader System Diagnostics +tModLoader System Diagnostics Usage: ./tmod-diagnostics.sh [command] [options] @@ -1115,8 +1121,9 @@ init_diagnostics # Main execution case "${1:-full}" in full) - echo "🔍 tModLoader System Diagnostics" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider + echo "tModLoader System Diagnostics" + print_divider gather_system_info check_directory_structure check_tmodloader_binaries @@ -1129,7 +1136,7 @@ case "${1:-full}" in check_security performance_analysis echo - echo "🏁 Diagnostic scan completed" + echo "OK: Diagnostic scan completed" log_diagnostic "Full diagnostic scan completed" "INFO" ;; quick) quick_diagnostic ;; @@ -1148,8 +1155,8 @@ case "${1:-full}" in performance) performance_analysis ;; help|--help|-h) show_help ;; *) - echo "❌ Unknown command: $1" - echo "Use 'help' for usage information" + echo "Error: Unknown command: $1" + echo "Tip: Use 'help' for usage information" exit 1 ;; esac diff --git a/Scripts/hub/tmod-control.sh b/Scripts/hub/tmod-control.sh index 23d4f4e..787a029 100755 --- a/Scripts/hub/tmod-control.sh +++ b/Scripts/hub/tmod-control.sh @@ -34,6 +34,10 @@ log_control() { esac } +print_divider() { + printf '%s\n' '------------------------------------------------------------' +} + # Validate script dependencies check_dependencies() { local missing_scripts=() @@ -56,7 +60,7 @@ check_dependencies() { done if (( ${#missing_scripts[@]} > 0 )); then - echo "❌ Missing or invalid scripts:" + echo "Error: Missing or invalid scripts:" printf ' %s\n' "${missing_scripts[@]}" return 1 fi @@ -65,16 +69,6 @@ check_dependencies() { } launch_go_tui() { - local mode="${1:-interactive}" - - case "${TMOD_FORCE_LEGACY_UI:-0}" in - 1|true|TRUE|yes|YES) return 1 ;; - esac - - case "$mode" in - classic|legacy|plain|palette|fzf|dialog) return 1 ;; - esac - local tui_bin="$BASE_DIR/bin/tmodloader-ui" if [[ -x "$tui_bin" ]]; then cd "$BASE_DIR" || return 1 @@ -83,7 +77,7 @@ launch_go_tui() { if command -v go >/dev/null 2>&1 && [[ -f "$BASE_DIR/go.mod" ]]; then cd "$BASE_DIR" || return 1 - exec go run . + exec go run ./cmd/tmodloader-ui fi return 1 @@ -92,18 +86,18 @@ launch_go_tui() { # Enhanced server control functions start_server() { if is_server_up; then - echo "ℹ️ Server is already running" + echo "Info: Server is already running" return 0 fi - echo "🚀 Starting tModLoader server..." + echo "Starting tModLoader server..." log_control "Starting server via enhanced control system" "INFO" if "$SCRIPT_DIR/../core/tmod-server.sh" start; then - echo "✅ Server start initiated successfully" + echo "OK: Server start initiated successfully" return 0 else - echo "❌ Failed to start server" + echo "Error: Failed to start server" log_control "Server start failed" "ERROR" return 1 fi @@ -111,40 +105,40 @@ start_server() { stop_server() { if ! is_server_up; then - echo "ℹ️ Server is not running" + echo "Info: Server is not running" return 0 fi - echo "🛑 Stopping tModLoader server..." + echo "Stopping tModLoader server..." log_control "Stopping server via control system" "INFO" # Create automatic backup before stopping - echo "📦 Creating pre-shutdown backup..." + echo "Creating pre-shutdown backup..." if "$SCRIPT_DIR/../backup/tmod-backup.sh" worlds >/dev/null 2>&1; then - echo "✅ Pre-shutdown backup completed" + echo "OK: Pre-shutdown backup completed" else - echo "⚠️ Pre-shutdown backup failed (continuing with shutdown)" + echo "Warning: Pre-shutdown backup failed (continuing with shutdown)" fi if "$SCRIPT_DIR/../core/tmod-server.sh" stop; then - echo "✅ Server stopped successfully" + echo "OK: Server stopped successfully" return 0 else - echo "❌ Failed to stop server" + echo "Error: Failed to stop server" log_control "Server stop failed" "ERROR" return 1 fi } restart_server() { - echo "🔄 Restarting tModLoader server..." + echo "Restarting tModLoader server..." log_control "Restarting server via control system" "INFO" if "$SCRIPT_DIR/../core/tmod-server.sh" restart; then - echo "✅ Server restart completed" + echo "OK: Server restart completed" return 0 else - echo "❌ Failed to restart server" + echo "Error: Failed to restart server" log_control "Server restart failed" "ERROR" return 1 fi @@ -152,12 +146,13 @@ restart_server() { # Enhanced status with comprehensive overview quick_status() { - echo "🎮 tModLoader Enhanced Status Overview" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider + echo "tModLoader Status" + print_divider # Server status with detailed info if is_server_up; then - echo "Server: 🟢 ONLINE" + echo "Server: ONLINE" local info info=$(get_server_info) @@ -175,23 +170,23 @@ quick_status() { # Check for mod errors if check_mod_errors >/dev/null 2>&1; then - echo "Mod Status: ✅ No errors" + echo "Mod Status: OK" else - echo "Mod Status: ⚠️ Errors detected" + echo "Mod Status: Issues detected" fi else - echo "Server: 🔴 OFFLINE" + echo "Server: OFFLINE" fi # System health indicators local disk_usage disk_usage=$(df "$BASE_DIR" | awk 'NR==2 {print $5}' | sed 's/%//') if (( disk_usage > 90 )); then - echo "Disk: ❌ Critical (${disk_usage}%)" + echo "Disk: Critical (${disk_usage}%)" elif (( disk_usage > 80 )); then - echo "Disk: ⚠️ High (${disk_usage}%)" + echo "Disk: High (${disk_usage}%)" else - echo "Disk: ✅ OK (${disk_usage}%)" + echo "Disk: OK (${disk_usage}%)" fi # Backup status @@ -201,1313 +196,41 @@ quick_status() { echo "Backups: $world_backups world backups available" fi - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider } -# ─── World management helpers ───────────────────────────────────────────────── - -# World picker page — lists worlds, user selects one, updates serverconfig.txt -# Pass "start" as $1 to also start the server after selecting. -_page_world_picker() { - local and_start="${1:-}" - local worlds_dir="$BASE_DIR/Worlds" - local title="Server / Select World" - [[ "$and_start" == "start" ]] && title="Server / Select World & Start" - - local world_files=() - mapfile -t world_files < <(find "$worlds_dir" -maxdepth 1 -name "*.wld" 2>/dev/null | sort) - - if [[ ${#world_files[@]} -eq 0 ]]; then - _header "$title" - _gap - echo " No worlds found in $worlds_dir" - echo " Use 'Create New World' to generate one first." - _pause - return - fi - - local active_world - active_world=$(basename "$(server_config_get "world" "" 2>/dev/null)" .wld 2>/dev/null) - - local -a labels=() - local wld - for wld in "${world_files[@]}"; do - local name size mtime marker="" - name=$(basename "$wld" .wld) - size=$(du -sh "$wld" 2>/dev/null | cut -f1) - mtime=$(date -r "$wld" '+%Y-%m-%d %H:%M' 2>/dev/null \ - || stat -c '%y' "$wld" 2>/dev/null | cut -c1-16) - [[ "$name" == "$active_world" ]] && marker=" ◄ active" - labels+=("$(printf '%-28s %6s %s%s' "$name" "$size" "$mtime" "$marker")") - done - - local picked_idx - if ! _pick_index "$title" "Select world" labels picked_idx; then - return - fi - - local selected="${world_files[$picked_idx]}" - local selected_name - selected_name=$(basename "$selected" .wld) - - server_config_set "world" "$BASE_DIR/Worlds/${selected_name}.wld" - server_config_set "worldname" "$selected_name" - - echo " ✅ Active world set to: $selected_name" - log_control "Active world changed to: $selected_name" "INFO" - - if [[ "$and_start" == "start" ]]; then - echo - start_server - fi - _pause -} - -# World importer — copy a pre-uploaded .wld into Worlds/, rename, set active -_page_world_importer() { - _header "Server / Import World" - _gap - echo " Transfer your .wld to the server first (SCP / SFTP / rsync)," - echo " then paste the full path below." - echo - echo " Example: scp MyWorld.wld plex@server:~/MyWorld.wld" - _gap - read -p " Path to .wld file: " -r src_path - echo - - # Expand ~ if typed - src_path="${src_path/#\~/$HOME}" - - if [[ -z "$src_path" ]]; then - echo " Cancelled."; sleep 1; return - fi - - if [[ ! -f "$src_path" ]]; then - echo " ❌ File not found: $src_path"; _pause; return - fi - - if [[ "${src_path##*.}" != "wld" ]]; then - echo " ❌ Not a .wld file: $(basename "$src_path")"; _pause; return - fi - - local src_name - src_name=$(basename "$src_path" .wld) - - read -p " World name (Enter to keep '$src_name'): " -r new_name - echo - [[ -z "$new_name" ]] && new_name="$src_name" - new_name="${new_name//[\/.]/_}" - - local dest="$BASE_DIR/Worlds/${new_name}.wld" - - if [[ -f "$dest" ]]; then - echo " ⚠️ '$new_name' already exists in Worlds/" - read -p " Overwrite? Type YES to confirm: " -r confirm - echo - [[ "$confirm" != "YES" ]] && { echo " Cancelled."; sleep 1; return; } - fi - - echo " Copying..." - if cp "$src_path" "$dest"; then - local size - size=$(du -sh "$dest" 2>/dev/null | cut -f1) - echo " ✅ Imported: $new_name ($size)" - log_control "World imported: $new_name (from $src_path)" "INFO" - else - echo " ❌ Copy failed — check permissions."; _pause; return - fi - - echo - read -p " Set '$new_name' as active world? (yes/no): " -r set_active - echo - if [[ "$set_active" == "yes" ]]; then - server_config_set "world" "$BASE_DIR/Worlds/${new_name}.wld" - server_config_set "worldname" "$new_name" - echo " ✅ Active world set to: $new_name" - log_control "Active world set to: $new_name" "INFO" - echo - read -p " Start server with '$new_name' now? (yes/no): " -r do_start - echo - [[ "$do_start" == "yes" ]] && start_server - fi - - _pause -} - -# World creator page — prompts for config, generates world via tModLoader autocreate -_page_world_creator() { - _header "Server / Create World" - _gap - - # ── World name ────────────────────────────────────────────────────────────── - read -p " World name: " -r world_name - echo - if [[ -z "$world_name" ]]; then - echo " Cancelled." - sleep 1 - return - fi - - # Sanitise — no slashes, no dots - world_name="${world_name//[\/.]/_}" - - if [[ -f "$BASE_DIR/Worlds/${world_name}.wld" ]]; then - echo " ⚠️ '$world_name' already exists." - read -p " Overwrite? Type YES to confirm: " -r confirm - [[ "$confirm" != "YES" ]] && { echo " Cancelled."; sleep 1; return; } - fi - - # ── Size ──────────────────────────────────────────────────────────────────── - echo " World size:" - _item 1 "Small" - _item 2 "Medium" - _item 3 "Large" - _gap - read -p " Select [1-3] (default 2): " -r size_choice - echo - local autocreate_size - case "$size_choice" in - 1) autocreate_size=1 ;; - 3) autocreate_size=3 ;; - *) autocreate_size=2 ;; - esac - - # ── Difficulty ────────────────────────────────────────────────────────────── - echo " Difficulty:" - _item 0 "Classic" - _item 1 "Expert" - _item 2 "Master" - _item 3 "Journey" - _gap - read -p " Select [0-3] (default 0): " -r diff_choice - echo - local difficulty - case "$diff_choice" in - 1) difficulty=1 ;; - 2) difficulty=2 ;; - 3) difficulty=3 ;; - *) difficulty=0 ;; - esac - - # ── Seed ──────────────────────────────────────────────────────────────────── - read -p " Seed (leave blank for random): " -r seed_input - echo - - # ── Summary + confirm ─────────────────────────────────────────────────────── - local size_name diff_name - case "$autocreate_size" in 1) size_name="Small" ;; 2) size_name="Medium" ;; 3) size_name="Large" ;; esac - case "$difficulty" in 0) diff_name="Classic" ;; 1) diff_name="Expert" ;; 2) diff_name="Master" ;; 3) diff_name="Journey" ;; esac - - echo " ── Summary ─────────────────────────────────────────────" - printf " %-12s %s\n" "Name:" "$world_name" - printf " %-12s %s\n" "Size:" "$size_name" - printf " %-12s %s\n" "Difficulty:" "$diff_name" - [[ -n "$seed_input" ]] && printf " %-12s %s\n" "Seed:" "$seed_input" - echo " ────────────────────────────────────────────────────────" - _gap - read -p " Generate this world? Type YES to confirm: " -r confirm - echo - if [[ "$confirm" != "YES" ]]; then - echo " Cancelled." - sleep 1 - return - fi - - # ── Find binary ───────────────────────────────────────────────────────────── - local tmod_binary - tmod_binary=$(find_tmodloader_binary 2>/dev/null) - if [[ -z "$tmod_binary" ]]; then - echo " ❌ tModLoader binary not found — cannot generate world" - log_control "World creation failed: binary not found" "ERROR" - _pause - return 1 - fi - - # ── Build arg list ────────────────────────────────────────────────────────── - local gen_args=( - -server - -logpath "$LOG_DIR" - -tmlsavedirectory "$BASE_DIR" - -world "$BASE_DIR/Worlds/${world_name}.wld" - -autocreate "$autocreate_size" - -worldname "$world_name" - -difficulty "$difficulty" - ) - [[ -n "$seed_input" ]] && gen_args+=(-seed "$seed_input") - - # ── Launch in temp screen session ─────────────────────────────────────────── - local gen_log="$LOG_DIR/worldgen.log" - : > "$gen_log" - - mkdir -p "$BASE_DIR/Worlds" - cd "$BASE_DIR/Engine" 2>/dev/null || true - - configure_steam_runtime_env - - if [[ "$tmod_binary" == *"dotnet"* ]]; then - local dotnet_exe="${tmod_binary%% *}" - local dll_file="${tmod_binary##* }" - screen -L -Logfile "$gen_log" -dmS tmod_worldgen \ - "$dotnet_exe" "$dll_file" "${gen_args[@]}" 2>/dev/null - else - screen -L -Logfile "$gen_log" -dmS tmod_worldgen \ - "$tmod_binary" "${gen_args[@]}" 2>/dev/null - fi - - echo " 🌍 Generating '$world_name'... (Ctrl+C to cancel, 5 min timeout)" - echo " ────────────────────────────────────────────────────────" - - # ── Monitor for completion ────────────────────────────────────────────────── - # tModLoader prints "Listening on port" when the server is ready — at that - # point world generation is complete and we can shut down the gen session. - local timeout=300 - local elapsed=0 - local done=false - - while (( elapsed < timeout )); do - if grep -qi "listening on port\|server started" "$gen_log" 2>/dev/null; then - done=true - break - fi - - # Show a simple elapsed counter in place - printf " ⏳ %ds elapsed...\r" "$elapsed" - - sleep 3 - (( elapsed += 3 )) - - # If the screen session already exited on its own, stop waiting - if ! screen -list 2>/dev/null | grep -q "tmod_worldgen"; then - break - fi - done - - # Kill the worldgen server — we only needed it to generate the file - screen -S tmod_worldgen -X quit 2>/dev/null - sleep 1 - echo - - # ── Result ────────────────────────────────────────────────────────────────── - if [[ "$done" == "true" ]] || [[ -f "$BASE_DIR/Worlds/${world_name}.wld" ]]; then - echo " ✅ World '$world_name' created successfully!" - log_control "World created: $world_name (${size_name}, ${diff_name})" "INFO" - - _gap - read -p " Set '$world_name' as active world? (yes/no): " -r set_active - echo - if [[ "$set_active" == "yes" ]]; then - server_config_set "world" "$BASE_DIR/Worlds/${world_name}.wld" - server_config_set "worldname" "$world_name" - echo " ✅ Active world set to: $world_name" - log_control "Active world set to: $world_name" "INFO" - fi - else - echo " ❌ World generation failed or timed out after ${elapsed}s" - echo " Check $gen_log for details" - log_control "World generation failed/timed out: $world_name" "ERROR" - fi - - _pause -} - -# ─── Shared UI helpers ──────────────────────────────────────────────────────── -_SEP="━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - -_use_fzf_ui() { - local mode="${TMOD_UI_MODE:-auto}" - case "$mode" in - classic|legacy|plain) return 1 ;; - auto|fzf) - [[ -t 0 && -t 1 ]] || return 1 - command -v fzf >/dev/null 2>&1 - ;; - *) return 1 ;; - esac -} - -_use_dialog_ui() { - local mode="${TMOD_UI_MODE:-auto}" - case "$mode" in - classic|legacy|plain|fzf) return 1 ;; - auto|dialog) - [[ -t 0 && -t 1 ]] || return 1 - command -v dialog >/dev/null 2>&1 - ;; - *) return 1 ;; - esac -} - -_status_summary_line() { - local state="OFFLINE" - is_server_up && state="ONLINE" - - local mod_count - mod_count=$(get_mod_list | wc -l) - - local active_world - active_world=$(basename "$(server_config_get "world" "" 2>/dev/null)" .wld 2>/dev/null) - [[ -z "$active_world" ]] && active_world="none" - - local disk_pct - disk_pct=$(df "$BASE_DIR" | awk 'NR==2 {print $5}') - - local world_backups=0 - if [[ -d "$BASE_DIR/Backups/Worlds" ]]; then - world_backups=$(find "$BASE_DIR/Backups/Worlds" -name "worlds_*.tar.gz" 2>/dev/null | wc -l) - fi - - printf 'State: %s | World: %s | Mods: %s | World Backups: %s | Disk: %s' \ - "$state" "$active_world" "$mod_count" "$world_backups" "$disk_pct" -} - -_prompt_add_mods() { - local ws="$SCRIPT_DIR/../steam/tmod-workshop.sh" - local added=0 - - if [[ ! -x "$ws" ]]; then - _unavailable "tmod-workshop.sh" - return 1 - fi - - echo " Paste Workshop URLs or IDs — blank line when done." - echo " (Multiple URLs concatenated on one line are fine)" - echo - - while true; do - local input ids - read -p " URL(s) or ID(s): " -r input - [[ -z "$input" ]] && break - - ids=$(echo "$input" | grep -oP '(?<=[?&]id=)[0-9]+') - if [[ -n "$ids" ]]; then - while IFS= read -r id; do - "$ws" mods add "$id" 2>&1 | grep -v "^\[20[0-9][0-9]-" && (( added++ )) || true - done <<< "$ids" - else - "$ws" mods add "$input" 2>&1 | grep -v "^\[20[0-9][0-9]-" && (( added++ )) || true - fi - done - - if (( added > 0 )); then - echo - if _confirm_action "Mods" "Download and sync $added mod(s) now?"; then - "$ws" download && "$ws" sync --yes - fi - fi -} - -_menu_choice() { - local title="$1" - local prompt="$2" - local out_var="$3" - shift 3 - - local -n _out_ref="$out_var" - _out_ref="" - - if _use_dialog_ui; then - local dialog_prompt - dialog_prompt="${prompt}"$'\n\n'"$(_status_summary_line)" - local item_count=$(( $# / 2 )) - local menu_height=$item_count - (( menu_height < 10 )) && menu_height=10 - (( menu_height > 18 )) && menu_height=18 - - local selected - selected=$(dialog --clear --stdout \ - --title "tModLoader / $title" \ - --cancel-label "Back" \ - --menu "$dialog_prompt" 22 96 "$menu_height" "$@") - local status=$? - clear - [[ $status -eq 0 ]] || return 1 - _out_ref="$selected" - return 0 - fi - - local -a tags=() - local -a descriptions=() - while (( $# >= 2 )); do - tags+=("$1") - descriptions+=("$2") - shift 2 - done - - while true; do - _header "$title" - _gap - local idx - for idx in "${!tags[@]}"; do - _item "${tags[$idx]}" "${descriptions[$idx]}" - done - _gap - echo "$_SEP" - read -p " Select: " -r input - echo - - case "$input" in - $'\033') return 1 ;; - esac - - for idx in "${!tags[@]}"; do - if [[ "$input" == "${tags[$idx]}" ]]; then - _out_ref="$input" - return 0 - fi - done - - echo " Invalid option." - sleep 1 - done -} - -_prompt_text() { - local title="$1" - local prompt="$2" - local out_var="$3" - local initial_value="${4:-}" - local -n _out_ref="$out_var" - - _out_ref="" - - if _use_dialog_ui; then - local input - input=$(dialog --clear --stdout \ - --title "tModLoader / $title" \ - --inputbox "$prompt" 10 90 "$initial_value") - local status=$? - clear - [[ $status -eq 0 ]] || return 1 - _out_ref="$input" - return 0 - fi - - read -p " $prompt" -r _out_ref -} - -_confirm_action() { - local title="$1" - local prompt="$2" - local rendered_prompt - rendered_prompt=$(printf '%b' "$prompt") - - if _use_dialog_ui; then - dialog --clear --title "tModLoader / $title" --yesno "$rendered_prompt" 10 90 - local status=$? - clear - return $status - fi - - local reply - read -p " $rendered_prompt [y/N]: " -r reply - [[ "$reply" =~ ^([Yy]|[Yy][Ee][Ss])$ ]] -} - -_show_log_tail() { - local file_path="$1" - local title="$2" - local lines="${3:-50}" - - if _use_dialog_ui; then - local temp_view - temp_view=$(mktemp) - if [[ -f "$file_path" ]]; then - tail -n "$lines" "$file_path" > "$temp_view" - else - printf 'No log found: %s\n' "$file_path" > "$temp_view" - fi - dialog --clear --title "tModLoader / $title" --textbox "$temp_view" 24 110 - local status=$? - rm -f "$temp_view" - clear - return $status - fi - - echo " ── $title ─────────────────────────────────────────" - if [[ -f "$file_path" ]]; then - tail -n "$lines" "$file_path" - else - echo " No log found: $file_path" - fi -} - -_follow_log_file() { - local file_path="$1" - local title="$2" - - if _use_dialog_ui; then - if [[ -f "$file_path" ]]; then - dialog --clear --title "tModLoader / $title" --tailbox "$file_path" 24 110 - else - dialog --clear --title "tModLoader / $title" --msgbox "No log found:\n$file_path" 10 90 - fi - clear - return 0 - fi - - echo " ── Following $title — Ctrl+C to stop ──────────────────────" - if [[ -f "$file_path" ]]; then - tail -f "$file_path" - else - echo " No log found: $file_path" - fi -} - -_attach_server_console() { - if screen -list 2>/dev/null | grep -q "tmodloader_server"; then - echo " Attaching to server console — Ctrl+A D to detach..." - sleep 1 - screen -r tmodloader_server - else - echo " ❌ No server screen session found (is the server running?)" - fi -} - -_status_bar() { - # ── Line 1: server state ─────────────────────────────────────────────────── - local status_icon status_text status_color - if is_server_up; then - status_icon="🟢"; status_text="ONLINE"; status_color="\e[32m" - else - status_icon="🔴"; status_text="OFFLINE"; status_color="\e[31m" - fi - - local mod_count enabled_count mod_display - mod_count=$(get_mod_list | wc -l) - local _enabled_json="$MODS_DIR/enabled.json" - if [[ -f "$_enabled_json" ]] && command -v jq >/dev/null 2>&1; then - enabled_count=$(jq 'length' "$_enabled_json" 2>/dev/null || echo "$mod_count") - else - enabled_count="$mod_count" - fi - if (( enabled_count == mod_count )); then - mod_display="${mod_count}" - else - mod_display="${enabled_count}/${mod_count}" - fi - - local line1=" ${status_icon} " - if is_server_up; then - local info uptime_min uptime_fmt players - info=$(get_server_info) - read -r _ _ uptime_min <<< "$info" - if (( uptime_min >= 60 )); then - uptime_fmt=$(printf "%dh %02dm" $((uptime_min/60)) $((uptime_min%60))) - else - uptime_fmt="${uptime_min}m" - fi - players=$(get_player_count) - local player_label="players" - (( players == 1 )) && player_label="player" - echo -e "${line1}${status_color}${status_text}\e[0m ⏱ ${uptime_fmt} 👥 ${players} ${player_label} 🧩 ${mod_display} mods" - else - echo -e "${line1}${status_color}${status_text}\e[0m 🧩 ${mod_display} mods" - fi - - # ── Line 2: storage + world ──────────────────────────────────────────────── - local disk_used disk_total disk_pct mods_size worlds_size active_world - read -r disk_used disk_total disk_pct < <(df -h "$BASE_DIR" \ - | awk 'NR==2 {gsub(/%/,"",$5); print $3, $2, $5}') - - mods_size=$(find "$MODS_DIR" -maxdepth 1 -name "*.tmod" -exec du -shc {} + 2>/dev/null | tail -1 | cut -f1) - [[ -z "$mods_size" ]] && mods_size="0B" - worlds_size=$(du -sh "$BASE_DIR/Worlds" 2>/dev/null | cut -f1) - [[ -z "$worlds_size" ]] && worlds_size="0B" - active_world=$(basename "$(server_config_get "world" "" 2>/dev/null)" .wld 2>/dev/null) - - local world_label="🌍 no world set" - [[ -n "$active_world" ]] && world_label="🌍 ${active_world}" - - echo " ${world_label} 📦 Mods: ${mods_size} 🗺 Worlds: ${worlds_size} 💿 ${disk_used}/${disk_total} (${disk_pct}%)" -} - -_header() { - local title="$1" - echo "$_SEP" - echo " 🎮 tModLoader / $title" - echo "$_SEP" - _status_bar - echo "$_SEP" -} - -_item() { printf " %2s) %s\n" "$1" "$2"; } -_gap() { echo; } -_pause() { echo; read -p " Press Enter to continue..." -r; } -_back() { _item 0 "← Back"; } - -_pick_index() { - local title="$1" - local prompt="$2" - local labels_name="$3" - local out_var="$4" - local -n _labels_ref="$labels_name" - local -n _out_ref="$out_var" - - _out_ref="" - (( ${#_labels_ref[@]} > 0 )) || return 1 - - if _use_fzf_ui; then - local selected - selected=$(printf '%s\n' "${_labels_ref[@]}" \ - | fzf \ - --height=80% \ - --layout=reverse \ - --border \ - --prompt="${prompt}> " \ - --header="$title") - [[ -z "$selected" ]] && return 1 - local idx - for idx in "${!_labels_ref[@]}"; do - if [[ "${_labels_ref[$idx]}" == "$selected" ]]; then - _out_ref="$idx" - return 0 - fi - done - return 1 - fi - - if _use_dialog_ui; then - local -a menu_items=() - local idx - for idx in "${!_labels_ref[@]}"; do - menu_items+=("$idx" "${_labels_ref[$idx]}") - done - - local selected - selected=$(dialog --clear --stdout \ - --title "tModLoader / $title" \ - --cancel-label "Back" \ - --menu "$prompt"$'\n\n'"$(_status_summary_line)" 22 110 16 \ - "${menu_items[@]}") - local status=$? - clear - [[ $status -eq 0 ]] || return 1 - _out_ref="$selected" - return 0 - fi - - local query="" - local input - local filtered=() - - while true; do - _header "$title" - [[ -n "$query" ]] && { echo " Filter: $query"; _gap; } - - filtered=() - local idx - for idx in "${!_labels_ref[@]}"; do - local label_lc="${_labels_ref[$idx],,}" - local query_lc="${query,,}" - if [[ -z "$query" || "$label_lc" == *"$query_lc"* ]]; then - filtered+=("$idx") - fi - done - - if (( ${#filtered[@]} == 0 )); then - echo " No matches." - else - local shown=0 - local actual_idx - for actual_idx in "${filtered[@]}"; do - printf " %2d) %s\n" "$((shown + 1))" "${_labels_ref[$actual_idx]}" - (( shown++ )) - if (( shown >= 20 )) && (( ${#filtered[@]} > shown )); then - echo " … refine your filter to narrow the list" - break - fi - done - fi - - _gap - echo "$_SEP" - read -p " $prompt (number, text filter, / clear, 0 cancel): " -r input - echo - - case "$input" in - 0|$'\033') return 1 ;; - /) query="" ;; - "") - if (( ${#filtered[@]} == 1 )); then - _out_ref="${filtered[0]}" - return 0 - fi - ;; - *) - if [[ "$input" =~ ^[0-9]+$ ]] && (( input >= 1 && input <= ${#filtered[@]} )); then - _out_ref="${filtered[$((input - 1))]}" - return 0 - else - query="$input" - fi - ;; - esac - done -} - -_pick_backup_archive() { - local title="$1" - local out_var="$2" - local -n _out_ref="$out_var" - local backup_root="$BASE_DIR/Backups" - local -a all_backups=() - local -a labels=() - - mapfile -t all_backups < <( - find "$backup_root" -maxdepth 2 -name "*.tar.gz" 2>/dev/null \ - | sort -t/ -k1,1r -k2,2r - ) - - if (( ${#all_backups[@]} == 0 )); then - echo " No backups found." - return 1 - fi - - local f - for f in "${all_backups[@]}"; do - local sz rel - sz=$(du -h "$f" 2>/dev/null | cut -f1) - rel="${f#"$backup_root"/}" - labels+=("$(printf '%-48s %6s' "$rel" "$sz")") - done - - local picked_idx - if ! _pick_index "$title" "Select backup" labels picked_idx; then - return 1 - fi - - _out_ref="${all_backups[$picked_idx]}" -} - -_restore_backup_interactive() { - local bs="$SCRIPT_DIR/../backup/tmod-backup.sh" - local picked - if _pick_backup_archive "Restore Backup" picked; then - "$bs" restore "$picked" - fi -} - -_verify_backup_interactive() { - local bs="$SCRIPT_DIR/../backup/tmod-backup.sh" - local picked - if _pick_backup_archive "Verify Backup" picked; then - "$bs" verify "$picked" - fi -} - -_unavailable() { - echo " ❌ Script not available: $1" - _pause -} - -# ─── Pages ──────────────────────────────────────────────────────────────────── - -_page_server() { - while true; do - local choice - if ! _menu_choice "Server" "Choose a server action." choice \ - "1" "Show Status" \ - "2" "Start Server" \ - "3" "Stop Server" \ - "4" "Restart Server" \ - "5" "Select Active World" \ - "6" "Start with World Select" \ - "7" "Create New World" \ - "8" "Import World (from uploaded .wld file)" \ - "0" "Back"; then - return - fi - case "$choice" in - 1) quick_status; _pause ;; - 2) start_server; _pause ;; - 3) stop_server; _pause ;; - 4) restart_server; _pause ;; - 5) _page_world_picker ;; - 6) _page_world_picker "start" ;; - 7) _page_world_creator ;; - 8) _page_world_importer ;; - 0) return ;; - $'\033') return ;; - *) echo " Invalid option."; sleep 1 ;; - esac - done -} - -_page_mods() { - local ws="$SCRIPT_DIR/../steam/tmod-workshop.sh" - while true; do - local choice - if ! _menu_choice "Mods" "Choose a mod or workshop action." choice \ - "1" "Add Mod by URL or ID" \ - "2" "Show mod_ids.txt (queued mods with names)" \ - "3" "Clear mod_ids.txt (fresh start)" \ - "4" "Mod Picker (interactive toggle)" \ - "5" "Enable a Mod" \ - "6" "Disable a Mod" \ - "7" "List Mods (enabled/disabled)" \ - "8" "List Installed Mods" \ - "9" "Check for Errors" \ - "10" "Workshop Status" \ - "11" "List Workshop Downloads" \ - "12" "Archive Old Versions" \ - "13" "Cleanup Downloads" \ - "14" "Mod Configs (edit per-mod settings)" \ - "0" "Back"; then - return - fi - case "$choice" in - 1) if [[ -x "$ws" ]]; then _prompt_add_mods; else _unavailable "tmod-workshop.sh"; fi; _pause ;; - 2) if [[ -x "$ws" ]]; then "$ws" mods ids; else _unavailable "tmod-workshop.sh"; fi; _pause ;; - 3) if [[ -x "$ws" ]]; then "$ws" mods clear; else _unavailable "tmod-workshop.sh"; fi; _pause ;; - 4) if [[ -x "$ws" ]]; then "$ws" mods pick; else _unavailable "tmod-workshop.sh"; fi; _pause ;; - 5) if [[ -x "$ws" ]]; then - local m - if _prompt_text "Mods" "Mod name to enable (or 'all'):" m && [[ -n "$m" ]]; then - "$ws" mods enable "$m" - else - echo " No input." - fi - else _unavailable "tmod-workshop.sh"; fi; _pause ;; - 6) if [[ -x "$ws" ]]; then - local m - if _prompt_text "Mods" "Mod name to disable (or 'all'):" m && [[ -n "$m" ]]; then - "$ws" mods disable "$m" - else - echo " No input." - fi - else _unavailable "tmod-workshop.sh"; fi; _pause ;; - 7) if [[ -x "$ws" ]]; then "$ws" mods list; else _unavailable "tmod-workshop.sh"; fi; _pause ;; - 8) get_mod_list; _pause ;; - 9) check_mod_errors; _pause ;; - 10) if [[ -x "$ws" ]]; then "$ws" status; else _unavailable "tmod-workshop.sh"; fi; _pause ;; - 11) if [[ -x "$ws" ]]; then "$ws" list; else _unavailable "tmod-workshop.sh"; fi; _pause ;; - 12) if [[ -x "$ws" ]]; then "$ws" archive; else _unavailable "tmod-workshop.sh"; fi; _pause ;; - 13) if [[ -x "$ws" ]]; then "$ws" cleanup; else _unavailable "tmod-workshop.sh"; fi; _pause ;; - 14) _page_mod_configs ;; - 0) return ;; - $'\033') return ;; - *) echo " Invalid option."; sleep 1 ;; - esac - done -} - -_page_mod_configs() { - while true; do - _header "Mod Configs" - _gap - - # Scan for config files: - # - any file directly inside ModConfigs/ - # - *.json / *.toml / *.cfg / *.ini inside mod-created subdirs - # (skip known system dirs: backups engine logs Mods scripts Worlds ModConfigs) - local -a cfg_files=() - mapfile -t cfg_files < <( - { - find "$BASE_DIR/ModConfigs" -maxdepth 1 -type f 2>/dev/null - find "$BASE_DIR" -mindepth 2 -maxdepth 2 -type f \ - \( -name "*.json" -o -name "*.toml" -o -name "*.cfg" -o -name "*.ini" \) \ - -not -path "$BASE_DIR/Backups/*" \ - -not -path "$BASE_DIR/Engine/*" \ - -not -path "$BASE_DIR/Logs/*" \ - -not -path "$BASE_DIR/Mods/*" \ - -not -path "$BASE_DIR/Scripts/*" \ - -not -path "$BASE_DIR/Worlds/*" \ - -not -path "$BASE_DIR/ModConfigs/*" \ - 2>/dev/null - } | sort -u - ) - - if [[ ${#cfg_files[@]} -eq 0 ]]; then - echo " No config files found under:" - echo " $BASE_DIR" - _pause - return - fi - - local -a labels=() - local f - for f in "${cfg_files[@]}"; do - local relpath size mtime - relpath="${f#"$BASE_DIR"/}" - size=$(du -sh "$f" 2>/dev/null | cut -f1) - mtime=$(stat -c '%y' "$f" 2>/dev/null | cut -d. -f1) - labels+=("$(printf '%-48s %4s %s' "$relpath" "$size" "$mtime")") - done - - local picked_idx - if ! _pick_index "Mod Configs" "Select config" labels picked_idx; then - return - fi - - nano "${cfg_files[$picked_idx]}" - done -} - -_page_backup() { - local bs="$SCRIPT_DIR/../backup/tmod-backup.sh" - while true; do - local choice - if ! _menu_choice "Backup" "Choose a backup action." choice \ - "1" "Backup Status" \ - "2" "World Backup" \ - "3" "Config Backup" \ - "4" "Full Server Backup" \ - "5" "Auto Backup (all three)" \ - "6" "List Backups" \ - "7" "Restore from Backup" \ - "8" "Verify a Backup" \ - "9" "Cleanup Old Backups" \ - "10" "View Backup Log" \ - "0" "Back"; then - return - fi - case "$choice" in - 1) if [[ -x "$bs" ]]; then "$bs" status; else _unavailable "tmod-backup.sh"; fi; _pause ;; - 2) if [[ -x "$bs" ]]; then "$bs" worlds; else _unavailable "tmod-backup.sh"; fi; _pause ;; - 3) if [[ -x "$bs" ]]; then "$bs" configs; else _unavailable "tmod-backup.sh"; fi; _pause ;; - 4) if [[ -x "$bs" ]]; then "$bs" full; else _unavailable "tmod-backup.sh"; fi; _pause ;; - 5) if [[ -x "$bs" ]]; then "$bs" auto; else _unavailable "tmod-backup.sh"; fi; _pause ;; - 6) if [[ -x "$bs" ]]; then "$bs" list; else _unavailable "tmod-backup.sh"; fi; _pause ;; - 7) if [[ -x "$bs" ]]; then _restore_backup_interactive; else _unavailable "tmod-backup.sh"; fi; _pause ;; - 8) if [[ -x "$bs" ]]; then _verify_backup_interactive; else _unavailable "tmod-backup.sh"; fi; _pause ;; - 9) if [[ -x "$bs" ]]; then "$bs" cleanup; else _unavailable "tmod-backup.sh"; fi; _pause ;; - 10) _show_log_tail "$LOG_DIR/backup.log" "Backup Log" 30 ;; - 0) return ;; - $'\033') return ;; - *) echo " Invalid option."; sleep 1 ;; - esac - done -} - - -_page_monitoring() { - local mon="$SCRIPT_DIR/../core/tmod-monitor.sh" - while true; do - local choice - if ! _menu_choice "Monitoring" "Choose a monitoring action." choice \ - "1" "Status Dashboard" \ - "2" "Health Check (single pass)" \ - "3" "Live Monitor (Ctrl+C to stop)" \ - "4" "Follow Server Log (live stream)" \ - "5" "View Server Log (last 50 lines)" \ - "6" "View Monitor Log" \ - "7" "View Control Log" \ - "8" "Attach to Server Console" \ - "0" "Back"; then - return - fi - case "$choice" in - 1) if [[ -x "$mon" ]]; then "$mon" status; else _unavailable "tmod-monitor.sh"; fi; _pause ;; - 2) if [[ -x "$mon" ]]; then "$mon" check; else _unavailable "tmod-monitor.sh"; fi; _pause ;; - 3) if [[ -x "$mon" ]]; then "$mon" monitor; else _unavailable "tmod-monitor.sh"; fi ;; - 4) _follow_log_file "$LOG_DIR/server.log" "Server Log" ;; - 5) _show_log_tail "$LOG_DIR/server.log" "Server Log" 50 ;; - 6) if [[ -x "$mon" ]]; then "$mon" logs; else _unavailable "tmod-monitor.sh"; fi; _pause ;; - 7) _show_log_tail "$MAIN_LOG" "Control Log" 30 ;; - 8) _attach_server_console; _pause ;; - 0) return ;; - $'\033') return ;; - *) echo " Invalid option."; sleep 1 ;; - esac - done -} - -_update_engine() { - local steamcmd - steamcmd="$(get_steamcmd_path)" - local engine_dir="$BASE_DIR/Engine" - local steam_user="${STEAM_USERNAME:-}" - - if [[ ! -f "$steamcmd" ]]; then - echo " ❌ SteamCMD not found at: $steamcmd" - return 1 - fi - - if [[ -z "$steam_user" ]]; then - echo " ⚠️ tModLoader engine downloads require a Steam account that owns Terraria." - echo " 💡 For a public no-login install, you can also run: make engine-github" - echo " 💡 Set STEAM_USERNAME in Scripts/env.sh or enter it now." - if ! _prompt_text "Maintenance" "Steam username:" steam_user; then - echo " Cancelled." - return 1 - fi - if [[ -z "$steam_user" ]]; then - echo " Cancelled." - return 1 - fi - fi - - local current_build - current_build=$(grep '"buildid"' "$engine_dir/steamapps/appmanifest_1281930.acf" 2>/dev/null | grep -o '[0-9]*' | head -1) - echo " Current build: ${current_build:-unknown}" - - if is_server_up; then - echo " ⚠️ Server is running. Stop it before updating." - if ! _confirm_action "Maintenance" "Stop the server and continue with the engine update?"; then - echo " Cancelled." - return 0 - fi - stop_server - sleep 2 - fi - - echo " Updating tModLoader engine (app 1281930)..." - "$steamcmd" \ - +force_install_dir "$engine_dir" \ - +login "$steam_user" \ - +app_update 1281930 \ - +quit - - if [[ ! -f "$engine_dir/steamapps/appmanifest_1281930.acf" ]]; then - echo " ❌ Engine install did not produce appmanifest_1281930.acf" - echo " 💡 Make sure the Steam account owns Terraria and completed any password/Steam Guard prompt." - echo " 💡 Or use the GitHub release path instead: make engine-github" - log_control "Engine update failed: no appmanifest_1281930.acf after SteamCMD run" "ERROR" - return 1 - fi - - local new_build - new_build=$(grep '"buildid"' "$engine_dir/steamapps/appmanifest_1281930.acf" 2>/dev/null | grep -o '[0-9]*' | head -1) - if [[ "$new_build" != "$current_build" ]]; then - echo " ✅ Updated: build $current_build → $new_build" - log_control "Engine updated: build $current_build -> $new_build" "INFO" - else - echo " ✅ Engine is already up to date (build $new_build)" - log_control "Engine up to date: build $new_build" "INFO" - fi -} - -_page_maintenance() { - local diag="$SCRIPT_DIR/../diag/tmod-diagnostics.sh" - while true; do - local choice - if ! _menu_choice "Maintenance" "Choose a maintenance action." choice \ - "1" "System Diagnostics" \ - "2" "Run All Maintenance Tasks" \ - "3" "Update Engine" \ - "4" "Emergency Shutdown" \ - "0" "Back"; then - return - fi - case "$choice" in - 1) if [[ -x "$diag" ]]; then "$diag" full; else _unavailable "tmod-diagnostics.sh"; fi; _pause ;; - 2) run_maintenance; _pause ;; - 3) _update_engine; _pause ;; - 4) if _confirm_action "Maintenance" "Force-kill the server immediately?\n\nThis is only for emergencies."; then - emergency_shutdown - else - echo " Cancelled." - fi - _pause ;; - 0) return ;; - $'\033') return ;; - *) echo " Invalid option."; sleep 1 ;; - esac - done -} - -# ─── Main menu ──────────────────────────────────────────────────────────────── -show_classic_menu() { - while true; do - local choice - if ! _menu_choice "Main Menu" "Choose an area." choice \ - "1" "Server start / stop / restart / status" \ - "2" "Mods add, enable, manage, workshop" \ - "3" "Monitoring dashboard, logs, console" \ - "4" "Backup create, restore, verify, cleanup" \ - "5" "Maintenance diagnostics, emergency shutdown" \ - "0" "Exit"; then - echo " Goodbye!" - log_control "Control system session ended" "INFO" - exit 0 - fi - case "$choice" in - 1) _page_server ;; - 2) _page_mods ;; - 3) _page_monitoring ;; - 4) _page_backup ;; - 5) _page_maintenance ;; - 0) echo " Goodbye!"; log_control "Control system session ended" "INFO"; exit 0 ;; - $'\033') echo " Goodbye!"; log_control "Control system session ended" "INFO"; exit 0 ;; - *) echo " Invalid option."; sleep 1 ;; - esac - done -} - -_run_palette_action() { - local command="$1" - local mode="$2" - - case "$mode" in - page) - eval "$command" - ;; - pause) - eval "$command" - _pause - ;; - exit) - echo " Goodbye!" - log_control "Control system session ended" "INFO" - exit 0 - ;; - esac -} - -show_command_palette() { - while true; do - local -a labels=( - "Server / Show Status" - "Server / Start Server" - "Server / Stop Server" - "Server / Restart Server" - "Server / Select Active World" - "Server / Start with World Select" - "Server / Create New World" - "Server / Import World" - "Mods / Add Mod by URL or ID" - "Mods / Show queued mod IDs" - "Mods / Toggle enabled mods" - "Mods / Edit Mod Configs" - "Mods / List Installed Mods" - "Workshop / Status" - "Workshop / Download Mods" - "Workshop / Sync Mods" - "Workshop / List Downloads" - "Workshop / Archive Old Versions" - "Monitoring / Status Dashboard" - "Monitoring / Health Check" - "Monitoring / Follow Server Log" - "Monitoring / Attach Server Console" - "Backup / Status" - "Backup / Full Server Backup" - "Backup / Restore Backup" - "Backup / Verify Backup" - "Backup / Cleanup Old Backups" - "Maintenance / Run All Maintenance Tasks" - "Maintenance / Update Engine" - "Diagnostics / Full Diagnostics" - "Logs / View Server Log" - "Logs / View Control Log" - "UI / Open Classic Menu" - "Exit" - ) - local -a commands=( - "quick_status" - "start_server" - "stop_server" - "restart_server" - "_page_world_picker" - "_page_world_picker start" - "_page_world_creator" - "_page_world_importer" - "_prompt_add_mods" - "\"$SCRIPT_DIR/../steam/tmod-workshop.sh\" mods ids" - "\"$SCRIPT_DIR/../steam/tmod-workshop.sh\" mods pick" - "_page_mod_configs" - "get_mod_list" - "\"$SCRIPT_DIR/../steam/tmod-workshop.sh\" status" - "\"$SCRIPT_DIR/../steam/tmod-workshop.sh\" download" - "\"$SCRIPT_DIR/../steam/tmod-workshop.sh\" sync" - "\"$SCRIPT_DIR/../steam/tmod-workshop.sh\" list" - "\"$SCRIPT_DIR/../steam/tmod-workshop.sh\" archive" - "\"$SCRIPT_DIR/../core/tmod-monitor.sh\" status" - "\"$SCRIPT_DIR/../core/tmod-monitor.sh\" check" - "_follow_log_file \"$LOG_DIR/server.log\" \"Server Log\"" - "_attach_server_console" - "\"$SCRIPT_DIR/../backup/tmod-backup.sh\" status" - "\"$SCRIPT_DIR/../backup/tmod-backup.sh\" full" - "_restore_backup_interactive" - "_verify_backup_interactive" - "\"$SCRIPT_DIR/../backup/tmod-backup.sh\" cleanup" - "run_maintenance" - "_update_engine" - "\"$SCRIPT_DIR/../diag/tmod-diagnostics.sh\" full" - "_show_log_tail \"$LOG_DIR/server.log\" \"Server Log\" 50" - "_show_log_tail \"$MAIN_LOG\" \"Control Log\" 30" - "show_classic_menu" - "" - ) - local -a modes=( - "pause" - "pause" - "pause" - "pause" - "page" - "page" - "page" - "page" - "pause" - "pause" - "pause" - "page" - "pause" - "pause" - "pause" - "pause" - "pause" - "pause" - "pause" - "pause" - "page" - "page" - "pause" - "pause" - "pause" - "pause" - "pause" - "pause" - "pause" - "pause" - "page" - "page" - "page" - "exit" - ) - - local picked_idx - if ! _pick_index "Command Palette" "Select action" labels picked_idx; then - echo " Goodbye!" - log_control "Control system session ended" "INFO" - exit 0 - fi - - _run_palette_action "${commands[$picked_idx]}" "${modes[$picked_idx]}" - done -} +# Legacy shell UI removed. Interactive entrypoints now launch only the Go control room. show_interactive_menu() { local mode="${1:-interactive}" case "$mode" in - classic) show_classic_menu ;; - palette|legacy|plain|fzf|dialog) show_command_palette ;; - tui|go) - if launch_go_tui "interactive"; then + ""|interactive|menu|tui|go) + if launch_go_tui; then return 0 fi - echo "⚠️ Go TUI unavailable; falling back to the legacy command palette." - show_command_palette + + echo "Error: Go control room is not available." + echo "Tip: Build it with 'make tui-build' or run it from source with 'make tui-run'." + echo "Tip: Install Go locally if you want 'bash Scripts/hub/tmod-control.sh' to launch from source." + return 1 ;; - ""|interactive|menu) - if launch_go_tui "interactive"; then - return 0 - fi - echo "ℹ️ Go TUI not found; using the legacy shell command palette." - show_command_palette + classic|palette|legacy|plain|fzf|dialog) + echo "Error: The legacy shell UI has been removed." + echo "Tip: Use './tmod-control.sh interactive' or './tmod-control.sh tui' for the Go control room." + return 1 ;; *) - if launch_go_tui "$mode"; then - return 0 - fi - show_command_palette + echo "Error: Unknown interactive mode: $mode" + echo "Tip: Use './tmod-control.sh interactive' or './tmod-control.sh tui'." + return 1 ;; esac } # Enhanced maintenance with comprehensive tasks run_maintenance() { - echo "🔧 Running comprehensive maintenance tasks..." + echo "Running maintenance tasks..." log_control "Starting maintenance sequence" "INFO" local tasks_completed=0 @@ -1515,57 +238,57 @@ run_maintenance() { local start_time start_time=$(date +%s) - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "🔄 Maintenance Task Progress:" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider + echo "Maintenance Task Progress" + print_divider # Task 1: Create maintenance backup - echo "📦 [1/5] Creating maintenance backup..." + echo "[1/5] Creating maintenance backup..." if [[ -x "$SCRIPT_DIR/../backup/tmod-backup.sh" ]] && "$SCRIPT_DIR/../backup/tmod-backup.sh" worlds >/dev/null 2>&1; then - echo " ✅ Maintenance backup completed" + echo " OK: Maintenance backup completed" ((tasks_completed++)) else - echo " ❌ Maintenance backup failed" + echo " Error: Maintenance backup failed" ((tasks_failed++)) fi # Task 2: Clean old backups - echo "🧹 [2/5] Cleaning old backups..." + echo "[2/5] Cleaning old backups..." if [[ -x "$SCRIPT_DIR/../backup/tmod-backup.sh" ]] && "$SCRIPT_DIR/../backup/tmod-backup.sh" cleanup >/dev/null 2>&1; then - echo " ✅ Old backups cleaned" + echo " OK: Old backups cleaned" ((tasks_completed++)) else - echo " ❌ Backup cleanup failed" + echo " Error: Backup cleanup failed" ((tasks_failed++)) fi # Task 3: Rotate logs - echo "📋 [3/5] Rotating logs..." + echo "[3/5] Rotating logs..." if rotate_logs; then - echo " ✅ Log rotation completed" + echo " OK: Log rotation completed" ((tasks_completed++)) else - echo " ❌ Log rotation failed" + echo " Error: Log rotation failed" ((tasks_failed++)) fi # Task 4: Sync mods - echo "🔄 [4/5] Syncing mods..." + echo "[4/5] Syncing mods..." if [[ -x "$SCRIPT_DIR/../steam/tmod-workshop.sh" ]] && "$SCRIPT_DIR/../steam/tmod-workshop.sh" sync --yes >/dev/null 2>&1; then - echo " ✅ Mod sync completed" + echo " OK: Mod sync completed" ((tasks_completed++)) else - echo " ❌ Mod sync failed" + echo " Error: Mod sync failed" ((tasks_failed++)) fi # Task 5: Check mod errors - echo "🔍 [5/5] Checking for mod errors..." + echo "[5/5] Checking for mod errors..." if check_mod_errors >/dev/null 2>&1; then - echo " ✅ No mod errors found" + echo " OK: No mod errors found" ((tasks_completed++)) else - echo " ⚠️ Mod errors detected (check logs)" + echo " Warning: Mod errors detected (check logs)" ((tasks_failed++)) fi @@ -1575,115 +298,117 @@ run_maintenance() { local time_formatted time_formatted=$(printf "%dm %02ds" $((total_time/60)) $((total_time%60))) - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "📊 Maintenance Summary:" - echo "✅ Completed: $tasks_completed/5 tasks" - echo "❌ Failed: $tasks_failed/5 tasks" - echo "⏱️ Duration: $time_formatted" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider + echo "Maintenance Summary" + echo "Completed: $tasks_completed/5 tasks" + echo "Failed: $tasks_failed/5 tasks" + echo "Duration: $time_formatted" + print_divider local total_tasks=5 log_control "Maintenance completed: $tasks_completed/$total_tasks successful in $time_formatted" "INFO" if (( tasks_failed == 0 )); then - echo "🎉 All maintenance tasks completed successfully!" + echo "OK: All maintenance tasks completed successfully" else - echo "⚠️ Maintenance completed with some issues" + echo "Warning: Maintenance completed with some issues" fi } # Quick inline system diagnostics (full diagnostics use tmod-diagnostics.sh) show_system_diagnostics() { - echo "🔧 System Diagnostics Report" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider + echo "System Diagnostics Report" + print_divider # Check script dependencies - echo "📋 Script Dependencies:" + echo "Script Dependencies:" if check_dependencies; then - echo " ✅ All management scripts available and executable" + echo " OK: All management scripts available and executable" else - echo " ❌ Some management scripts missing or not executable" + echo " Error: Some management scripts missing or not executable" fi echo # Directory structure check - echo "📁 Directory Structure:" + echo "Directory Structure:" local dirs dirs=("$BASE_DIR" "$LOG_DIR" "$MODS_DIR" "$BASE_DIR/Configs" "$BASE_DIR/Worlds") for dir in "${dirs[@]}"; do if [[ -d "$dir" ]]; then - echo " ✅ $dir" + echo " OK: $dir" else - echo " ❌ Missing: $dir" + echo " Missing: $dir" fi done echo # Process information - echo "⚙️ Process Information:" + echo "Process Information:" if is_server_up; then local pid pid=$(get_server_pid) - echo " ✅ Server PID: $pid" - echo " 📊 Process info: $(ps -p "$pid" -o pid,ppid,cmd --no-headers 2>/dev/null || echo "Process details unavailable")" + echo " OK: Server PID: $pid" + echo " Process info: $(ps -p "$pid" -o pid,ppid,cmd --no-headers 2>/dev/null || echo "Process details unavailable")" else - echo " ❌ No server process running" + echo " Error: No server process running" fi echo # Network and screen sessions - echo "🖥️ Screen Sessions:" + echo "Screen Sessions:" local screen_sessions screen_sessions=$(screen -list 2>/dev/null | grep -c tmodloader || echo "0") if (( screen_sessions > 0 )); then - echo " ✅ tModLoader screen sessions: $screen_sessions" + echo " OK: tModLoader screen sessions: $screen_sessions" screen -list | grep tmodloader | sed 's/^/ /' else - echo " ❌ No tModLoader screen sessions found" + echo " Error: No tModLoader screen sessions found" fi echo # Recent activity analysis - echo "📈 Recent Activity:" + echo "Recent Activity:" if [[ -f "$LOG_DIR/server.log" ]]; then local log_size="?" log_size=$(stat --format="%s" "$LOG_DIR/server.log" 2>/dev/null | numfmt --to=iec || echo "?") local last_modified last_modified=$(date -r "$LOG_DIR/server.log" '+%Y-%m-%d %H:%M') - echo " 📋 Server log: $log_size (last modified: $last_modified)" + echo " Server log: $log_size (last modified: $last_modified)" local recent_errors recent_errors=$(tail -100 "$LOG_DIR/server.log" | grep -c "ERROR" || echo "0") local recent_warnings recent_warnings=$(tail -100 "$LOG_DIR/server.log" | grep -c "WARN" || echo "0") - echo " ⚠️ Recent errors: $recent_errors, warnings: $recent_warnings" + echo " Recent errors: $recent_errors, warnings: $recent_warnings" else - echo " ❌ No server log file found" + echo " Error: No server log file found" fi - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider } # Emergency shutdown with comprehensive safety emergency_shutdown() { - echo "🚨 EMERGENCY SHUTDOWN INITIATED" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider + echo "EMERGENCY SHUTDOWN INITIATED" + print_divider log_control "Emergency shutdown initiated" "CRITICAL" # Create emergency backup if possible if [[ -x "$SCRIPT_DIR/../backup/tmod-backup.sh" ]]; then - echo "📦 Creating emergency backup..." + echo "Creating emergency backup..." if "$SCRIPT_DIR/../backup/tmod-backup.sh" worlds >/dev/null 2>&1; then - echo "✅ Emergency backup completed" + echo "OK: Emergency backup completed" log_control "Emergency backup completed" "SUCCESS" else - echo "⚠️ Emergency backup failed" + echo "Warning: Emergency backup failed" log_control "Emergency backup failed" "WARN" fi fi # Force kill all related processes - echo "🔪 Terminating server processes..." + echo "Terminating server processes..." pkill -f "tModLoader.dll" 2>/dev/null || true pkill -f "dotnet.*tModLoader" 2>/dev/null || true screen -S tmodloader_server -X quit 2>/dev/null || true @@ -1692,15 +417,15 @@ emergency_shutdown() { sleep 2 pkill -9 -f "tModLoader.dll" 2>/dev/null || true - echo "✅ Emergency shutdown completed" + echo "OK: Emergency shutdown completed" log_control "Emergency shutdown completed" "INFO" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider } # Show enhanced help show_help() { cat << 'EOF' -🎮 tModLoader Enhanced Unified Control System +tModLoader Unified Control System Central command center for all server operations with advanced management features @@ -1731,8 +456,10 @@ Mod Load Management: mods list Show installed mods with enabled/disabled status mods enable Enable a mod (or 'all') mods disable Disable a mod (or 'all') - mods pick Interactive toggle menu — no file editing needed - mods add [--yes] Add Workshop IDs and auto-clean placeholders if confirmed + mods pick Interactive mod toggle picker + mods add [--yes] Add a Workshop URL or ID and auto-clean placeholders if confirmed + mods ids Show queued Workshop IDs or URLs from mod_ids.txt + mods clear [--yes] Clear queued Workshop IDs from mod_ids.txt Maintenance & Utilities: maintenance Run comprehensive maintenance tasks @@ -1748,40 +475,31 @@ Quick Commands: restart Restart server (shortcut) status Quick status overview backup Auto backup (shortcut) - interactive Launch Go TUI when available, otherwise legacy palette - interactive classic Classic numbered menu - interactive palette Force the legacy shell palette - tui Force the Go TUI + interactive Launch the Go control room + tui Alias for the Go control room help Show this help Examples: ./tmod-control.sh start # Quick start ./tmod-control.sh workshop sync --yes # Sync mods from workshop ./tmod-control.sh mods pick # Interactive mod toggle menu + ./tmod-control.sh mods add 2824688804 # Queue a Workshop mod by ID ./tmod-control.sh mods list # See enabled/disabled status ./tmod-control.sh mods enable CalamityMod # Enable a specific mod ./tmod-control.sh backup auto # Complete backup ./tmod-control.sh monitor start # Start monitoring ./tmod-control.sh workshop download # Download workshop mods - ./tmod-control.sh interactive # Launch Go TUI if available - ./tmod-control.sh interactive classic # Launch classic numbered menu - ./tmod-control.sh tui # Force the Go TUI + ./tmod-control.sh tui # Launch the Go control room + ./tmod-control.sh interactive # Accepted alias for the Go control room ./tmod-control.sh maintenance # Run maintenance Interactive Mode: - ./tmod-control.sh interactive ./tmod-control.sh tui - ./tmod-control.sh interactive classic - TMOD_FORCE_LEGACY_UI=1 ./tmod-control.sh interactive - TMOD_UI_MODE=dialog ./tmod-control.sh interactive - TMOD_UI_MODE=fzf ./tmod-control.sh interactive + ./tmod-control.sh interactive - Interactive requests prefer the Go TUI when a built binary or local Go toolchain is available. - When the Go TUI is unavailable, the legacy shell hub falls back to a dependency-aware command palette: - - fzf is used for searchable pickers when available - - dialog is used for boxed menus and log viewers when available - - plain Bash menus remain as the fallback - Use TMOD_FORCE_LEGACY_UI=1 or interactive classic if you want the old shell UI on purpose. + Interactive requests now open only the Go control room. + If no built binary is present, the shell entrypoint will try 'go run ./cmd/tmodloader-ui' from the repo root. + If Go is not installed, build the UI first with 'make tui-build'. Automation Examples: # Daily maintenance at 3 AM @@ -1798,22 +516,21 @@ Automation Examples: Features: - ✅ Unified control of all server operations - ✅ Persistent Go TUI with shell-script backend fallback - ✅ Comprehensive maintenance automation - ✅ Emergency procedures with safety backups - ✅ System health monitoring and diagnostics - ✅ Dependency validation and error handling - ✅ Advanced backup integration - ✅ Performance monitoring integration - ✅ Steam Workshop management integration - ✅ Automated log rotation and cleanup + - Unified control of all server operations + - Persistent Go TUI as the only interactive control room + - Manifest-driven addon action packs from Addons/*/addon.json + - Comprehensive maintenance automation + - Emergency procedures with safety backups + - System health monitoring and diagnostics + - Dependency validation and error handling + - Advanced backup integration + - Performance monitoring integration + - Steam Workshop management integration + - Automated log rotation and cleanup Dependencies: - All tmod-* management scripts in same directory - Screen for server management - - fzf for searchable pickers (optional) - - dialog for boxed menus and log viewers (optional) - jq for enhanced JSON formatting (optional) - Standard GNU utilities (find, tar, gzip, etc.) EOF @@ -1843,8 +560,8 @@ main() { esac if [[ "$skip_dep_check" == "false" ]] && ! check_dependencies; then - echo "❌ Critical dependencies missing. Please ensure all tmod-* scripts are present." - echo "💡 Run './tmod-control.sh scripts' to see detailed script status." + echo "Error: Critical dependencies missing. Please ensure all tmod-* scripts are present." + echo "Tip: Run './tmod-control.sh scripts' to see detailed script status." exit 1 fi @@ -1874,7 +591,7 @@ main() { exec "$SCRIPT_DIR/../backup/tmod-backup.sh" "$@" fi else - echo "❌ Backup script not available" + echo "Error: Backup script not available" exit 1 fi ;; @@ -1889,7 +606,7 @@ main() { *) exec "$SCRIPT_DIR/../core/tmod-monitor.sh" help ;; esac else - echo "❌ Monitoring script not available" + echo "Error: Monitoring script not available" exit 1 fi ;; @@ -1908,7 +625,7 @@ main() { *) exec "$SCRIPT_DIR/../steam/tmod-workshop.sh" help ;; esac else - echo "❌ Workshop script not available" + echo "Error: Workshop script not available" exit 1 fi ;; @@ -1919,7 +636,7 @@ main() { shift exec "$SCRIPT_DIR/../steam/tmod-workshop.sh" mods "$@" else - echo "❌ Workshop script not available" + echo "Error: Workshop script not available" exit 1 fi ;; @@ -1963,9 +680,9 @@ main() { # Unknown command *) - echo "❌ Unknown command: $1" - echo "💡 Use './tmod-control.sh help' for usage information" - echo "💡 Use './tmod-control.sh interactive' for the control room" + echo "Error: Unknown command: $1" + echo "Tip: Use './tmod-control.sh help' for usage information" + echo "Tip: Use './tmod-control.sh interactive' for the control room" exit 1 ;; esac diff --git a/Scripts/steam/mod_ids.example.txt b/Scripts/steam/mod_ids.example.txt index 53df664..bcfd28c 100644 --- a/Scripts/steam/mod_ids.example.txt +++ b/Scripts/steam/mod_ids.example.txt @@ -5,7 +5,10 @@ # # Lines starting with # are ignored. # One Steam Workshop URL or numeric ID per line. -# IDs can be added via the menu: Mods → Add Mod by URL or ID +# You can add entries from the Go control room: +# Workshop / Add Mod by URL or ID +# Or from the CLI: +# bash Scripts/hub/tmod-control.sh mods add # # Example mods (uncomment to use): # 2563309347 # Calamity Mod @@ -17,4 +20,3 @@ # 2564593266 # VeinMiner # Add your mod IDs below: - diff --git a/Scripts/steam/tmod-workshop.sh b/Scripts/steam/tmod-workshop.sh index 44e9e82..b328da9 100755 --- a/Scripts/steam/tmod-workshop.sh +++ b/Scripts/steam/tmod-workshop.sh @@ -9,14 +9,18 @@ if [[ -f "$CORE_SCRIPT" ]]; then # shellcheck disable=SC1090 source "$CORE_SCRIPT" || { - echo "❌ Failed to load core functions from $CORE_SCRIPT" + echo "Error: Failed to load core functions from $CORE_SCRIPT" exit 1 } else - echo "❌ Cannot find core functions at: $CORE_SCRIPT" + echo "Error: Cannot find core functions at: $CORE_SCRIPT" exit 1 fi +print_divider() { + printf '%s\n' '------------------------------------------------------------' +} + # Workshop configuration STEAMCMD_PATH="$(get_steamcmd_path)" STEAM_USERNAME="${STEAM_USERNAME:-}" @@ -109,23 +113,23 @@ validate_workshop_config() { # Check SteamCMD if [[ ! -f "$STEAMCMD_PATH" ]]; then - echo "❌ SteamCMD not found at: $STEAMCMD_PATH" - echo "💡 Install SteamCMD or set STEAMCMD_PATH environment variable" + echo "Error: SteamCMD not found at: $STEAMCMD_PATH" + echo "Tip: Install SteamCMD or set the STEAMCMD_PATH environment variable" ((errors++)) fi # Steam username is optional for Workshop downloads. Anonymous works, but # a real account may be more resilient for larger download batches. if [[ -z "$STEAM_USERNAME" ]]; then - echo "ℹ️ Steam username not set" - echo "💡 Workshop downloads will use anonymous login" - echo "💡 Set STEAM_USERNAME in your shell or Scripts/env.sh if you want logged-in downloads" + echo "Info: Steam username not set" + echo "Tip: Workshop downloads will use anonymous login" + echo "Tip: Set STEAM_USERNAME in your shell or Scripts/env.sh if you want logged-in downloads" fi # Check mod IDs file if [[ ! -f "$MOD_IDS_FILE" ]]; then - echo "⚠️ Mod IDs file not found: $MOD_IDS_FILE" - echo "💡 Creating example mod_ids.txt file..." + echo "Warning: Mod IDs file not found: $MOD_IDS_FILE" + echo "Tip: Creating example mod_ids.txt file..." create_example_mod_ids_file fi @@ -137,7 +141,7 @@ create_example_mod_ids_file() { local example_file="$BASE_DIR/Scripts/steam/mod_ids.example.txt" if [[ -f "$example_file" ]]; then cp "$example_file" "$MOD_IDS_FILE" - echo "📄 Created mod_ids.txt from mod_ids.example.txt" + echo "Created mod_ids.txt from mod_ids.example.txt" else cat > "$MOD_IDS_FILE" << 'EOF' # tModLoader Workshop Mod IDs @@ -152,9 +156,9 @@ create_example_mod_ids_file() { # Add your mod IDs below: EOF - echo "📄 Created example mod_ids.txt file" + echo "Created example mod_ids.txt file" fi - echo "💡 Edit $MOD_IDS_FILE to add your desired mod IDs" + echo "Tip: Edit $MOD_IDS_FILE to add your desired mod IDs" } count_configured_mod_ids() { @@ -171,7 +175,7 @@ download_mods() { # Validate configuration if ! validate_workshop_config; then - echo "❌ Configuration validation failed" + echo "Error: Configuration validation failed" return 1 fi @@ -716,30 +720,31 @@ cleanup_workshop() { # Show workshop status and configuration show_status() { - echo "📊 Steam Workshop Manager Status" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider + echo "Steam Workshop Manager Status" + print_divider # Configuration status - echo "⚙️ Configuration:" + echo "Configuration:" if [[ -f "$STEAMCMD_PATH" ]]; then - echo " ✅ SteamCMD: Found at $STEAMCMD_PATH" + echo " OK: SteamCMD found at $STEAMCMD_PATH" else - echo " ❌ SteamCMD: Not found at $STEAMCMD_PATH" + echo " Error: SteamCMD not found at $STEAMCMD_PATH" fi if [[ -n "$STEAM_USERNAME" ]]; then - echo " ✅ Steam Login: logged-in ($STEAM_USERNAME)" + echo " OK: Steam login - logged in ($STEAM_USERNAME)" else - echo " ℹ️ Steam Login: anonymous fallback" + echo " Info: Steam login - anonymous fallback" fi if [[ -f "$MOD_IDS_FILE" ]]; then local mod_count mod_count=$(count_configured_mod_ids) - echo " ✅ Mod IDs File: $mod_count mod IDs configured" + echo " OK: Mod IDs file - $mod_count mod IDs configured" else - echo " ❌ Mod IDs File: Not found" + echo " Error: Mod IDs file not found" fi echo @@ -754,13 +759,13 @@ show_status() { local total_size total_size=$(du -sh "$WORKSHOP_DOWNLOAD_DIR" 2>/dev/null | cut -f1) - echo "📥 Downloads:" - echo " 📁 Directory: $WORKSHOP_DOWNLOAD_DIR" - echo " 📋 Total files: $downloaded_count .tmod files" - echo " 🎮 Unique mods: $unique_mods different mods" - echo " 💾 Total size: $total_size" + echo "Downloads:" + echo " Directory: $WORKSHOP_DOWNLOAD_DIR" + echo " Total files: $downloaded_count .tmod files" + echo " Unique mods: $unique_mods different mods" + echo " Total size: $total_size" else - echo "📥 Downloads: Directory not found" + echo "Downloads: directory not found" fi echo @@ -768,24 +773,24 @@ show_status() { # Server mods status local server_mod_count server_mod_count=$(get_mod_list | wc -l) - echo "🎮 Server Mods: $server_mod_count mods in server directory" + echo "Server Mods: $server_mod_count mods in server directory" # Archive status if [[ -d "$ARCHIVE_DIR" ]]; then local archived_count # Fixed: Use -print0 with process substitution archived_count=$(find "$ARCHIVE_DIR" -name "*.tmod" -print0 2>/dev/null | xargs -0 -n1 | wc -l) - echo "📦 Archive: $archived_count archived mod versions" + echo "Archive: $archived_count archived mod versions" else - echo "📦 Archive: Not initialized" + echo "Archive: not initialized" fi - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider } # Initialize workshop system init_workshop() { - echo "🔧 Initializing Steam Workshop system..." + echo "Initializing Steam Workshop system..." mkdir -p "$ARCHIVE_DIR" @@ -795,17 +800,17 @@ init_workshop() { fi # Validate configuration - echo "🔍 Validating configuration..." + echo "Validating configuration..." if validate_workshop_config; then - echo "✅ Workshop system initialized successfully" - echo "💡 Edit $MOD_IDS_FILE to add your desired mod IDs" + echo "OK: Workshop system initialized successfully" + echo "Tip: Edit $MOD_IDS_FILE to add your desired mod IDs" if [[ -z "$STEAM_USERNAME" ]]; then - echo "💡 Downloads will use anonymous login until STEAM_USERNAME is configured" + echo "Tip: Downloads will use anonymous login until STEAM_USERNAME is configured" fi - echo "💡 Then run './tmod-workshop.sh download' to download mods" + echo "Tip: Then run './tmod-workshop.sh download' to download mods" return 0 else - echo "❌ Workshop system initialization failed" + echo "Error: Workshop system initialization failed" return 1 fi } @@ -891,15 +896,15 @@ mods_list() { mapfile -t installed < <(_installed_mods) if [[ ${#installed[@]} -eq 0 ]]; then - echo "📭 No mods installed in $MODS_DIR" + echo "No mods installed in $MODS_DIR" echo " Run './tmod-workshop.sh sync' to copy mods from the workshop." return 0 fi _load_enabled - echo "📦 Installed Mods (${#installed[@]} total)" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "Installed Mods (${#installed[@]} total)" + print_divider printf " %-3s %-40s %s\n" "#" "Mod Name" "Status" printf " %-3s %-40s %s\n" "---" "----------------------------------------" "--------" @@ -908,16 +913,16 @@ mods_list() { local mod="${installed[$i]}" local num=$(( i + 1 )) if [[ -v "ENABLED_MODS[${mod,,}]" ]]; then - printf " %-3s %-40s 🟢 enabled\n" "$num" "$mod" + printf " %-3s %-40s enabled\n" "$num" "$mod" (( enabled_count++ )) else - printf " %-3s %-40s ⚫ disabled\n" "$num" "$mod" + printf " %-3s %-40s disabled\n" "$num" "$mod" (( disabled_count++ )) fi done - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " 🟢 $enabled_count enabled ⚫ $disabled_count disabled" + print_divider + echo " $enabled_count enabled $disabled_count disabled" } # ── mods enable ──────────────────────────────────────────────── @@ -930,12 +935,12 @@ mods_enable() { while IFS= read -r mod; do if [[ ! -v "ENABLED_MODS[${mod,,}]" ]]; then ENABLED_MODS["${mod,,}"]="$mod" - echo " 🟢 Enabled: $mod" + echo " OK: Enabled: $mod" (( count++ )) fi done < <(_installed_mods) _save_enabled - echo "✅ Enabled $count mod(s)" + echo "OK: Enabled $count mod(s)" return 0 fi @@ -971,13 +976,13 @@ mods_enable() { fi if [[ -z "$matched" ]]; then - echo " ❌ Not found: $name" + echo " Error: Not found: $name" (( errors++ )) elif [[ -v "ENABLED_MODS[${matched,,}]" ]]; then - echo " ℹ️ Already enabled: $matched" + echo " Info: Already enabled: $matched" else ENABLED_MODS["${matched,,}"]="$matched" - echo " 🟢 Enabled: $matched" + echo " OK: Enabled: $matched" (( changed++ )) fi done @@ -995,7 +1000,7 @@ mods_disable() { local count=${#ENABLED_MODS[@]} ENABLED_MODS=() _save_enabled - echo "✅ Disabled all $count mod(s)" + echo "OK: Disabled all $count mod(s)" return 0 fi @@ -1032,18 +1037,18 @@ mods_disable() { fi if [[ -z "$matched" ]]; then - echo " ❌ Not found: $name" + echo " Error: Not found: $name" (( errors++ )) continue fi local key="${matched,,}" if [[ ! -v "ENABLED_MODS[$key]" ]]; then - echo " ℹ️ Not enabled: $matched" + echo " Info: Not enabled: $matched" else local actual="${ENABLED_MODS[$key]}" unset "ENABLED_MODS[$key]" - echo " ⚫ Disabled: $actual" + echo " OK: Disabled: $actual" (( changed++ )) fi done @@ -1061,7 +1066,7 @@ mods_pick() { mapfile -t installed < <(_installed_mods) if [[ ${#installed[@]} -eq 0 ]]; then - echo "📭 No mods installed. Run './tmod-workshop.sh sync' first." + echo "No mods installed. Run './tmod-workshop.sh sync' first." return 0 fi @@ -1074,9 +1079,9 @@ mods_pick() { while true; do clear - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " 🎮 Mod Load Manager — toggle which mods load at server start" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider + echo "Mod Load Manager - toggle which mods load at server start" + print_divider printf " %-5s %-42s %s\n" "NUM" "Mod Name" "Load?" printf " %-5s %-42s %s\n" "-----" "------------------------------------------" "------" @@ -1085,14 +1090,14 @@ mods_pick() { local mod="${installed[$i]}" local num=$(( i + 1 )) if [[ -v "local_enabled[${mod,,}]" ]]; then - printf " [%-3s] %-42s 🟢 YES\n" "$num" "$mod" + printf " [%-3s] %-42s YES\n" "$num" "$mod" (( enabled_now++ )) else - printf " [%-3s] %-42s ⚫ no\n" "$num" "$mod" + printf " [%-3s] %-42s no\n" "$num" "$mod" fi done - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + print_divider echo " $enabled_now / ${#installed[@]} mods enabled" echo echo " Type a number to toggle | a = enable all | n = disable all" @@ -1113,7 +1118,7 @@ mods_pick() { local_enabled["${mod,,}"]="$mod" fi else - echo " ⚠️ Number out of range. Press Enter to continue." + echo " Warning: Number out of range. Press Enter to continue." read -r fi fi @@ -1134,16 +1139,16 @@ mods_pick() { done _save_enabled echo - echo " ✅ Saved — $enabled_now mod(s) will load on next server start." + echo " OK: Saved - $enabled_now mod(s) will load on next server start." # Fixed: Use proper if statement instead of A && B || C if is_server_up; then - echo " ⚠️ Server is running — restart required for changes to take effect." + echo " Warning: Server is running - restart required for changes to take effect." fi return 0 ;; q|Q|$'\033') - echo " ↩️ Cancelled — no changes saved." + echo " Cancelled - no changes saved." return 0 ;; esac @@ -1272,14 +1277,14 @@ mod_ids_add() { elif [[ "$input" =~ ^[0-9]+$ ]]; then mod_id="$input" else - echo "❌ Could not extract a Workshop ID from: $input" + echo "Error: Could not extract a Workshop ID from: $input" echo " Expected a numeric ID or a URL containing '?id=XXXXXXXXX'" return 1 fi # Sanity check — Workshop IDs are typically 7-12 digits if [[ ${#mod_id} -lt 7 || ${#mod_id} -gt 12 ]]; then - echo "❌ '$mod_id' doesn't look like a valid Workshop ID (expected 7-12 digits)" + echo "Error: '$mod_id' doesn't look like a valid Workshop ID (expected 7-12 digits)" return 1 fi @@ -1292,15 +1297,15 @@ mod_ids_add() { # Warn if placeholder/example lines are still present if grep -qE '^\.\.\.|^[0-9].*#\s*EXAMPLE' "$MOD_IDS_FILE" 2>/dev/null; then - echo " ⚠️ mod_ids.txt has example/placeholder entries (... or # EXAMPLE lines)." + echo " Warning: mod_ids.txt has example/placeholder entries (... or # EXAMPLE lines)." if (( WORKSHOP_ASSUME_YES )); then sed -i '/^\.\.\./d; /[0-9].*#[[:space:]]*EXAMPLE/d' "$MOD_IDS_FILE" - echo " ✅ Placeholder entries removed." + echo " OK: Placeholder entries removed." else read -p " Clean them out before adding? (yes/no): " -r _clean if [[ "$_clean" == "yes" ]]; then sed -i '/^\.\.\./d; /[0-9].*#[[:space:]]*EXAMPLE/d' "$MOD_IDS_FILE" - echo " ✅ Placeholder entries removed." + echo " OK: Placeholder entries removed." fi fi fi @@ -1309,7 +1314,7 @@ mod_ids_add() { if grep -qE "^[[:space:]]*${mod_id}([[:space:]]|$)" "$MOD_IDS_FILE" 2>/dev/null; then local name name=$(get_workshop_mod_name "$mod_id") - echo "ℹ️ Already in list: $name ($mod_id)" + echo "Info: Already in list: $name ($mod_id)" return 0 fi @@ -1318,7 +1323,7 @@ mod_ids_add() { mod_name=$(get_workshop_mod_name "$mod_id") echo "$mod_id" >> "$MOD_IDS_FILE" - echo "✅ Added: $mod_name ($mod_id)" + echo "OK: Added: $mod_name ($mod_id)" log_workshop "Added mod: $mod_name ($mod_id)" "INFO" local total @@ -1331,7 +1336,7 @@ mod_ids_add() { # Backs up the file first so it can be recovered if needed. mod_ids_clear() { if [[ ! -f "$MOD_IDS_FILE" ]]; then - echo "ℹ️ mod_ids.txt doesn't exist yet — nothing to clear." + echo "Info: mod_ids.txt doesn't exist yet - nothing to clear." return 0 fi @@ -1339,14 +1344,14 @@ mod_ids_clear() { total=$(count_configured_mod_ids) if [[ "$total" -eq 0 ]]; then - echo "ℹ️ mod_ids.txt already has no mod IDs." + echo "Info: mod_ids.txt already has no mod IDs." return 0 fi - echo "⚠️ This will remove all $total mod ID(s) from mod_ids.txt." + echo "Warning: This will remove all $total mod ID(s) from mod_ids.txt." echo " A backup will be saved to mod_ids.txt.bak" if (( WORKSHOP_ASSUME_YES )); then - echo " Auto-confirm enabled — clearing mod IDs." + echo " Auto-confirm enabled - clearing mod IDs." else read -p " Type YES to confirm: " -r confirm if [[ "$confirm" != "YES" ]]; then @@ -1361,7 +1366,7 @@ mod_ids_clear() { grep "^[[:space:]]*#\|^[[:space:]]*$" "$MOD_IDS_FILE" > "${MOD_IDS_FILE}.tmp" mv "${MOD_IDS_FILE}.tmp" "$MOD_IDS_FILE" - echo "✅ Cleared $total mod ID(s). Backup saved to mod_ids.txt.bak" + echo "OK: Cleared $total mod ID(s). Backup saved to mod_ids.txt.bak" log_workshop "Cleared $total mod IDs from $MOD_IDS_FILE (backup saved)" "INFO" } @@ -1396,7 +1401,7 @@ mods_cmd() { # Show help show_help() { cat << 'EOF' -🎮 tModLoader Steam Workshop Manager +tModLoader Steam Workshop Manager Download and manage mods directly from Steam Workshop @@ -1417,13 +1422,13 @@ Mod Load Management: mods list Show installed mods with enabled/disabled status mods enable Enable a mod (use 'all' to enable everything) mods disable Disable a mod (use 'all' to disable everything) - mods pick Interactive toggle menu — no nano needed - mods add [--yes] Add a mod ID and auto-clean placeholders if confirmed + mods pick Interactive toggle menu + mods add [--yes] Add a Workshop URL or ID and auto-clean placeholders if confirmed mods clear [--yes] Clear mod_ids.txt without a prompt when scripted Workflow: 1. ./tmod-workshop.sh init # Initialize system - 2. Edit mod_ids.txt # Add desired mod IDs + 2. Edit mod_ids.txt # Add desired Workshop URLs or IDs 3. ./tmod-workshop.sh download # Download from Workshop 4. ./tmod-workshop.sh sync # Copy to server 5. ./tmod-workshop.sh mods pick # Toggle which mods load @@ -1453,13 +1458,13 @@ Examples: ./tmod-workshop.sh status # Check system configuration Features: - ✅ Bulk mod downloading from Steam Workshop - ✅ Version compatibility checking (2024.5+ and 2025.x) - ✅ Automatic old version archival - ✅ Enable/disable individual mods without editing files - ✅ Interactive mod picker (works over SSH, no arrow keys needed) - ✅ Integration with tmod-* script system - ✅ Comprehensive logging and error handling + - Bulk mod downloading from Steam Workshop + - Version compatibility checking (2024.5+ and 2025.x) + - Automatic old version archival + - Enable/disable individual mods without editing files + - Interactive mod picker (works over SSH, no arrow keys needed) + - Integration with tmod-* script system + - Comprehensive logging and error handling EOF } diff --git a/cmd/tmodloader-ui/main.go b/cmd/tmodloader-ui/main.go new file mode 100644 index 0000000..7fe417d --- /dev/null +++ b/cmd/tmodloader-ui/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "log" + "os" + + "github.com/appaKappaK/tmodloader-server/internal/controlroom" +) + +func main() { + baseDir, err := os.Getwd() + if err != nil { + log.Fatal(err) + } + + if err := controlroom.Run(baseDir); err != nil { + log.Fatal(err) + } +} diff --git a/app.go b/internal/controlroom/app.go similarity index 54% rename from app.go rename to internal/controlroom/app.go index 5d5b6e7..1ee0ecd 100644 --- a/app.go +++ b/internal/controlroom/app.go @@ -1,7 +1,8 @@ -package main +package controlroom import ( "fmt" + "path/filepath" "strings" "time" @@ -14,6 +15,9 @@ type actionKind int const ( actionRunCommand actionKind = iota actionSelectWorld + actionEditModConfig + actionAddWorkshopMod + actionManageInstalledMods ) type outputMode int @@ -29,18 +33,28 @@ const ( uiModeNormal uiMode = iota uiModeConfirm uiModeWorldPicker + uiModeConfigPicker + uiModeWorkshopInput + uiModeModPicker ) const overviewCategory = "Overview" const panelVerticalChrome = 2 const panelHorizontalChrome = 3 const panelHorizontalPadding = 2 +const actionPanelTotalWidth = 36 +const statusPanelMaxTotalWidth = 48 +const minAppWidth = actionPanelTotalWidth + 44 +const minAppHeight = 20 type action struct { category string title string description string command []string + workDir string + addonName string + addonManifest string kind actionKind confirmText string startAfterSelect bool @@ -69,12 +83,34 @@ type worldSetMsg struct { err error } +type configListMsg struct { + configs []configOption + err error +} + +type configEditDoneMsg struct { + config configOption + err error +} + +type modListMsg struct { + mods []modOption + err error +} + +type modSaveMsg struct { + enabledCount int + changedCount int + err error +} + type outputLineMsg struct { text string } type commandDoneMsg struct { - action string + act action + label string duration time.Duration err error } @@ -96,9 +132,10 @@ type model struct { categoryIndex int cursor int - status appStatus - statusError string - lastRefresh time.Time + status appStatus + statusError string + addonWarnings []string + lastRefresh time.Time logSources []logSource logSourceIndex int @@ -118,27 +155,68 @@ type model struct { pendingAction action worldOptions []worldOption worldCursor int + configOptions []configOption + configCursor int + workshopInput string + modOptions []modOption + modCursor int } type outputView struct { - title string - subtitle string - lines []string - emptyState string - offset int - maxOffset int - showOverflow bool + title string + subtitle string + lines []string + emptyState string + indicator string + offset int + maxOffset int +} + +type hotkeyHint struct { + key string + desc string + active bool +} + +func Run(baseDir string) error { + m := newModel(baseDir) + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseAllMotion()) + m.program = p + _, err := p.Run() + return err } func newModel(baseDir string) *model { + actions := defaultActions() + addonActions, addonWarnings := loadAddonActions(baseDir) + actions = append(actions, addonActions...) + + for _, warning := range addonWarnings { + appendControlLog(baseDir, warning, "WARN") + } + + var initialOutput []string + if len(addonWarnings) > 0 { + initialOutput = []string{ + fmt.Sprintf("[warn] %d addon load warning(s) detected during startup.", len(addonWarnings)), + "Addons with invalid manifests were skipped.", + "", + } + for _, warning := range addonWarnings { + initialOutput = append(initialOutput, "[warn] "+warning) + } + initialOutput = append(initialOutput, "", "Review Logs/control.log for the same warnings later.") + } + return &model{ baseDir: baseDir, - actions: defaultActions(), - categories: []string{overviewCategory, "Server", "Workshop", "Backup", "Monitor", "Diagnostics", "Maintenance"}, + actions: actions, + categories: categoriesForActions(actions), logSources: defaultLogSources(baseDir), outputMode: outputModeLogs, + addonWarnings: addonWarnings, footer: "↑/↓ move • Enter open/run • Esc back • wheel scrolls 1 item • q quit • Ctrl+C force quit", - outputLines: nil, + outputLines: initialOutput, categoryIndex: 0, cursor: 0, logSourceIndex: 0, @@ -149,6 +227,10 @@ func (m *model) footerActive() bool { return m.footer != "" && time.Now().Before(m.footerTimestamp) } +func (m *model) minimumSizeOK() bool { + return m.width >= minAppWidth && m.height >= minAppHeight +} + func joinHeaderBits(bits []string) string { filtered := make([]string, 0, len(bits)) for _, bit := range bits { @@ -170,6 +252,17 @@ func joinHeaderBits(bits []string) string { return joined } +func (m *model) headerStatusBits() []string { + bits := []string{} + if m.running { + bits = append(bits, runningStyle.Render(spinnerFrames[m.spinnerIndex]+" "+m.activeAction)) + } + if len(m.addonWarnings) > 0 { + bits = append(bits, infoStyle.Render(fmt.Sprintf("addon warnings: %d (Tab for details)", len(m.addonWarnings)))) + } + return bits +} + func (m *model) Init() tea.Cmd { return tea.Batch( refreshStatusCmd(m.baseDir), @@ -189,21 +282,39 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.ClearScreen case tea.MouseMsg: + if m.ready && !m.minimumSizeOK() { + return m, nil + } switch m.uiMode { case uiModeConfirm: return m, nil case uiModeWorldPicker: return m.handleWorldPickerMouse(msg) + case uiModeConfigPicker: + return m.handleConfigPickerMouse(msg) + case uiModeWorkshopInput: + return m.handleWorkshopInputMouse(msg) + case uiModeModPicker: + return m.handleModPickerMouse(msg) default: return m.handleNormalMouse(msg) } case tea.KeyMsg: + if m.ready && !m.minimumSizeOK() { + return m.handleSmallWindowKeys(msg) + } switch m.uiMode { case uiModeConfirm: return m.handleConfirmKeys(msg) case uiModeWorldPicker: return m.handleWorldPickerKeys(msg) + case uiModeConfigPicker: + return m.handleConfigPickerKeys(msg) + case uiModeWorkshopInput: + return m.handleWorkshopInputKeys(msg) + case uiModeModPicker: + return m.handleModPickerKeys(msg) default: return m.handleNormalKeys(msg) } @@ -272,6 +383,85 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, refreshStatusCmd(m.baseDir) + case configListMsg: + if msg.err != nil { + m.configOptions = nil + m.configCursor = 0 + m.setFooter("Unable to load mod configs: "+msg.err.Error(), 4*time.Second) + return m, nil + } + m.configOptions = msg.configs + m.configCursor = 0 + if len(msg.configs) == 0 { + m.outputMode = outputModeCommand + m.outputLines = []string{"No mod config files were found under ModConfigs/ or other repo-local config directories."} + m.setFooter("No mod config files found.", 4*time.Second) + return m, nil + } + m.uiMode = uiModeConfigPicker + m.outputMode = outputModeCommand + m.commandXOffset = 0 + m.outputLines = []string{"Select a config file and press Enter to open it in your terminal editor."} + m.setFooter("Mod config picker opened. Enter to edit, Esc to cancel.", 4*time.Second) + return m, nil + + case configEditDoneMsg: + m.running = false + m.activeAction = "" + m.activeSince = time.Time{} + if msg.err != nil { + m.appendOutput("✗ Failed to edit config: " + msg.err.Error()) + m.setFooter("Failed to open editor for "+msg.config.RelPath+".", 4*time.Second) + return m, nil + } + m.appendOutput(fmt.Sprintf("✓ Finished editing %s", msg.config.RelPath)) + m.setFooter("Finished editing "+msg.config.RelPath+".", 4*time.Second) + return m, nil + + case modListMsg: + if msg.err != nil { + m.modOptions = nil + m.modCursor = 0 + m.setFooter("Unable to load installed mods: "+msg.err.Error(), 4*time.Second) + return m, nil + } + m.modOptions = msg.mods + m.modCursor = 0 + if len(msg.mods) == 0 { + m.outputMode = outputModeCommand + m.outputLines = []string{"No installed .tmod files were found under Mods/. Run Workshop / Sync Mods first."} + m.setFooter("No installed mods found.", 4*time.Second) + return m, nil + } + m.uiMode = uiModeModPicker + m.outputMode = outputModeCommand + m.commandXOffset = 0 + m.outputLines = []string{"Toggle installed mods, then press S to save the load list."} + m.setFooter("Mod load manager opened. Enter toggles, S saves, Esc cancels.", 4*time.Second) + return m, nil + + case modSaveMsg: + m.running = false + m.activeAction = "" + m.activeSince = time.Time{} + if msg.err != nil { + m.appendOutput("✗ Failed to save mod load selection: " + msg.err.Error()) + m.setFooter("Failed to save enabled.json.", 4*time.Second) + return m, nil + } + m.modOptions = nil + m.modCursor = 0 + m.appendOutput(fmt.Sprintf("✓ Saved enabled.json with %d enabled mod(s)", msg.enabledCount)) + if msg.changedCount == 0 { + m.setFooter("enabled.json was already up to date.", 4*time.Second) + } else if m.status.Online { + m.appendOutput("Info: Server is running; restart required for mod load changes to take effect.") + m.setFooter("Saved mod load selection. Restart the server to apply it.", 4*time.Second) + } else { + m.setFooter("Saved mod load selection.", 4*time.Second) + } + return m, nil + case outputLineMsg: m.appendOutput(msg.text) m.outputMode = outputModeCommand @@ -282,11 +472,16 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.activeAction = "" m.activeSince = time.Time{} if msg.err != nil { - m.appendOutput(fmt.Sprintf("✗ %s failed after %s: %v", msg.action, msg.duration.Round(time.Second), msg.err)) - m.setFooter(msg.action+" failed.", 4*time.Second) + if msg.act.isAddonAction() { + m.appendOutput(fmt.Sprintf("✗ Addon action %s failed after %s: %v", msg.label, msg.duration.Round(time.Second), msg.err)) + m.appendAddonFailureDetails(msg.act, msg.err) + } else { + m.appendOutput(fmt.Sprintf("✗ %s failed after %s: %v", msg.label, msg.duration.Round(time.Second), msg.err)) + } + m.setFooter(msg.label+" failed.", 4*time.Second) } else { - m.appendOutput(fmt.Sprintf("✓ %s finished in %s", msg.action, msg.duration.Round(time.Second))) - m.setFooter(msg.action+" finished successfully.", 4*time.Second) + m.appendOutput(fmt.Sprintf("✓ %s finished in %s", msg.label, msg.duration.Round(time.Second))) + m.setFooter(msg.label+" finished successfully.", 4*time.Second) } return m, tea.Batch( refreshStatusCmd(m.baseDir), @@ -342,35 +537,18 @@ func (m *model) handleNormalKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil case "enter": - if m.running { - m.setFooter("A command is already running.", 2*time.Second) - return m, nil - } - if m.currentCategory() == overviewCategory { - category, ok := m.selectedCategoryEntry() - if !ok { - return m, nil - } - m.openCategory(category) - return m, nil - } - act, ok := m.selectedAction() - if !ok { - return m, nil - } - return m, m.triggerAction(act) + return m, m.activateCurrentSelection() case "r": m.setFooter("Refreshing status and current log…", 2*time.Second) return m, tea.Batch(refreshStatusCmd(m.baseDir), refreshLogCmd(m.currentLogSource())) case "l": + if m.outputMode != outputModeLogs { + return m, nil + } m.logSourceIndex = (m.logSourceIndex + 1) % len(m.logSources) m.logXOffset = 0 - if m.outputMode == outputModeLogs { - m.setFooter("Switched log source to "+m.currentLogSource().label+".", 2*time.Second) - return m, refreshLogCmd(m.currentLogSource()) - } - m.setFooter("Next log source: "+m.currentLogSource().label+". Press Tab to view.", 2*time.Second) - return m, nil + m.setFooter("Switched log source to "+m.currentLogSource().label+".", 2*time.Second) + return m, refreshLogCmd(m.currentLogSource()) case "tab": if m.outputMode == outputModeLogs { m.outputMode = outputModeCommand @@ -386,28 +564,47 @@ func (m *model) handleNormalKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m *model) handleNormalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - if msg.Action != tea.MouseActionPress { +func (m *model) handleSmallWindowKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + default: return m, nil } - if m.mouseOverActionPanel(msg.X) { +} + +func (m *model) handleNormalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.mouseOverActionPanel(msg.X, msg.Y) { + if msg.Action == tea.MouseActionMotion && msg.Button == tea.MouseButtonNone { + index, ok := m.actionPanelListIndexAt(msg.Y) + if ok { + m.cursor = index + } + return m, nil + } + if msg.Action != tea.MouseActionPress { + return m, nil + } switch msg.Button { case tea.MouseButtonWheelUp: m.moveCursor(-1) case tea.MouseButtonWheelDown: m.moveCursor(1) + case tea.MouseButtonLeft: + index, ok := m.actionPanelListIndexAt(msg.Y) + if !ok { + return m, nil + } + m.cursor = index + return m, m.activateCurrentSelection() } return m, nil } if !m.mouseOverOutputPanel(msg.X, msg.Y) { return m, nil } - - switch msg.Button { - case tea.MouseButtonWheelLeft: - m.shiftOutputScroll(-6) - case tea.MouseButtonWheelRight: - m.shiftOutputScroll(6) + if msg.Action != tea.MouseActionPress { + return m, nil } return m, nil @@ -449,27 +646,134 @@ func (m *model) handleWorldPickerKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.moveWorldCursor(1) return m, nil case "enter": - if len(m.worldOptions) == 0 { + return m, m.activateWorldSelection() + } + + return m, nil +} + +func (m *model) handleConfigPickerKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "q", "esc": + m.uiMode = uiModeNormal + m.configOptions = nil + m.configCursor = 0 + m.setFooter("Mod config selection cancelled.", 2*time.Second) + return m, nil + case "up", "k": + m.moveConfigCursor(-1) + return m, nil + case "down", "j": + m.moveConfigCursor(1) + return m, nil + case "enter": + return m, m.activateConfigSelection() + } + + return m, nil +} + +func (m *model) handleWorkshopInputKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "esc": + m.uiMode = uiModeNormal + m.workshopInput = "" + m.setFooter("Workshop mod add cancelled.", 2*time.Second) + return m, nil + case "enter": + input := strings.TrimSpace(m.workshopInput) + if input == "" { + m.setFooter("Enter a Workshop URL or numeric ID first.", 3*time.Second) + return m, nil + } + m.uiMode = uiModeNormal + m.workshopInput = "" + act := action{ + category: "Workshop", + title: "Workshop / Add Mod by URL or ID", + description: "Add a Workshop URL or numeric ID to mod_ids.txt.", + command: []string{"bash", "Scripts/steam/tmod-workshop.sh", "mods", "add", "--yes", input}, + } + m.startAction(act) + return m, nil + case "backspace", "ctrl+h": + runes := []rune(m.workshopInput) + if len(runes) > 0 { + m.workshopInput = string(runes[:len(runes)-1]) + } + return m, nil + case "ctrl+u": + m.workshopInput = "" + return m, nil + } + + if msg.Type == tea.KeyRunes && len(msg.Runes) > 0 { + m.workshopInput += string(msg.Runes) + } + + return m, nil +} + +func (m *model) handleModPickerKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "q", "esc": + m.uiMode = uiModeNormal + m.modOptions = nil + m.modCursor = 0 + m.setFooter("Mod load changes discarded.", 2*time.Second) + return m, nil + case "up", "k": + m.moveModCursor(-1) + return m, nil + case "down", "j": + m.moveModCursor(1) + return m, nil + case " ", "enter": + m.toggleCurrentMod() + return m, nil + case "a": + m.setAllModsEnabled(true) + return m, nil + case "n": + m.setAllModsEnabled(false) + return m, nil + case "s": + if len(m.modOptions) == 0 { return m, nil } - selected := m.worldOptions[m.worldCursor] m.uiMode = uiModeNormal m.running = true - m.activeAction = "Set Active World" + m.activeAction = "Save Mod Selection" m.activeSince = time.Now() m.outputMode = outputModeCommand m.outputLines = []string{ - fmt.Sprintf("Applying active world: %s", selected.Name), + "Saving mod load selection to Mods/enabled.json…", "", } - m.setFooter("Setting active world to "+selected.Name+"…", 3*time.Second) - return m, setWorldCmd(m.baseDir, selected, m.pendingAction.startAfterSelect) + m.setFooter("Saving mod load selection…", 3*time.Second) + return m, saveModSelectionCmd(m.baseDir, m.modOptions) } return m, nil } func (m *model) handleWorldPickerMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if !m.mouseOverOutputPanel(msg.X, msg.Y) { + return m, nil + } + if msg.Action == tea.MouseActionMotion && msg.Button == tea.MouseButtonNone { + index, ok := m.worldPickerIndexAt(msg.Y) + if ok { + m.worldCursor = index + } + return m, nil + } if msg.Action != tea.MouseActionPress { return m, nil } @@ -479,15 +783,166 @@ func (m *model) handleWorldPickerMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { m.moveWorldCursor(-1) case tea.MouseButtonWheelDown: m.moveWorldCursor(1) + case tea.MouseButtonLeft: + index, ok := m.worldPickerIndexAt(msg.Y) + if !ok { + return m, nil + } + m.worldCursor = index + return m, m.activateWorldSelection() + } + + return m, nil +} + +func (m *model) handleConfigPickerMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if !m.mouseOverOutputPanel(msg.X, msg.Y) { + return m, nil + } + if msg.Action == tea.MouseActionMotion && msg.Button == tea.MouseButtonNone { + index, ok := m.configPickerIndexAt(msg.Y) + if ok { + m.configCursor = index + } + return m, nil + } + if msg.Action != tea.MouseActionPress { + return m, nil + } + + switch msg.Button { + case tea.MouseButtonWheelUp: + m.moveConfigCursor(-1) + case tea.MouseButtonWheelDown: + m.moveConfigCursor(1) + case tea.MouseButtonLeft: + index, ok := m.configPickerIndexAt(msg.Y) + if !ok { + return m, nil + } + m.configCursor = index + return m, m.activateConfigSelection() } return m, nil } +func (m *model) handleWorkshopInputMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if msg.Action != tea.MouseActionPress { + return m, nil + } + if !m.mouseOverOutputPanel(msg.X, msg.Y) { + return m, nil + } + return m, nil +} + +func (m *model) handleModPickerMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if !m.mouseOverOutputPanel(msg.X, msg.Y) { + return m, nil + } + if msg.Action == tea.MouseActionMotion && msg.Button == tea.MouseButtonNone { + index, ok := m.modPickerIndexAt(msg.Y) + if ok { + m.modCursor = index + } + return m, nil + } + if msg.Action != tea.MouseActionPress { + return m, nil + } + + switch msg.Button { + case tea.MouseButtonWheelUp: + m.moveModCursor(-1) + case tea.MouseButtonWheelDown: + m.moveModCursor(1) + case tea.MouseButtonLeft: + index, ok := m.modPickerIndexAt(msg.Y) + if !ok { + return m, nil + } + m.modCursor = index + m.toggleCurrentMod() + } + + return m, nil +} + +func (m *model) activateCurrentSelection() tea.Cmd { + if m.running { + m.setFooter("A command is already running.", 2*time.Second) + return nil + } + if m.currentCategory() == overviewCategory { + category, ok := m.selectedCategoryEntry() + if !ok { + return nil + } + m.openCategory(category) + return nil + } + act, ok := m.selectedAction() + if !ok { + return nil + } + return m.triggerAction(act) +} + +func (m *model) activateWorldSelection() tea.Cmd { + if len(m.worldOptions) == 0 { + return nil + } + selected := m.worldOptions[m.worldCursor] + m.uiMode = uiModeNormal + m.running = true + m.activeAction = "Set Active World" + m.activeSince = time.Now() + m.outputMode = outputModeCommand + m.outputLines = []string{ + fmt.Sprintf("Applying active world: %s", selected.Name), + "", + } + m.setFooter("Setting active world to "+selected.Name+"…", 3*time.Second) + return setWorldCmd(m.baseDir, selected, m.pendingAction.startAfterSelect) +} + +func (m *model) activateConfigSelection() tea.Cmd { + if len(m.configOptions) == 0 { + return nil + } + selected := m.configOptions[m.configCursor] + m.uiMode = uiModeNormal + m.configOptions = nil + m.configCursor = 0 + m.running = true + m.activeAction = "Edit Mod Config" + m.activeSince = time.Now() + m.outputMode = outputModeCommand + m.outputLines = []string{ + fmt.Sprintf("Opening %s in your terminal editor…", selected.RelPath), + "", + } + m.setFooter("Opening "+selected.RelPath+" in editor…", 3*time.Second) + return editModConfigCmd(m.baseDir, selected) +} + func (m *model) triggerAction(act action) tea.Cmd { switch act.kind { case actionSelectWorld: return listWorldsCmd(m.baseDir, act.startAfterSelect) + case actionEditModConfig: + return listModConfigsCmd(m.baseDir) + case actionAddWorkshopMod: + m.uiMode = uiModeWorkshopInput + m.workshopInput = "" + m.outputMode = outputModeCommand + m.commandXOffset = 0 + m.outputLines = []string{"Paste a Workshop URL or numeric ID, then press Enter to add it to mod_ids.txt."} + m.setFooter("Workshop URL or ID entry opened. Enter adds, Esc cancels.", 4*time.Second) + return nil + case actionManageInstalledMods: + return listInstalledModsCmd(m.baseDir) default: if act.confirmText != "" { m.uiMode = uiModeConfirm @@ -504,12 +959,27 @@ func (m *model) View() string { if !m.ready { return "Loading tModLoader control room…" } + if !m.minimumSizeOK() { + return m.renderMinimumSizeView() + } header := m.renderHeader() body := lipgloss.JoinHorizontal(lipgloss.Top, m.renderActionPanel(), m.renderRightColumn()) return lipgloss.JoinVertical(lipgloss.Left, header, body) } +func (m *model) renderMinimumSizeView() string { + lines := []string{ + sectionTitleStyle.Render("Window Too Small"), + sectionMutedStyle.Render(fmt.Sprintf("Resize the terminal to at least %dx%d.", minAppWidth, minAppHeight)), + sectionMutedStyle.Render(fmt.Sprintf("Current size: %dx%d", m.width, m.height)), + "", + sectionMutedStyle.Render("q or Ctrl+C quits."), + } + body := strings.Join(lines, "\n") + return lipgloss.Place(max(1, m.width), max(1, m.height), lipgloss.Center, lipgloss.Center, body) +} + func (m *model) renderHeader() string { title := headerTitleStyle.Render("tmodloader-server") subtitle := headerSubtleStyle.Render("Persistent headless server console") @@ -521,11 +991,7 @@ func (m *model) renderHeader() string { stateStyle = stateOnlineStyle } - statusBits := []string{} - - if m.running { - statusBits = append(statusBits, runningStyle.Render(spinnerFrames[m.spinnerIndex]+" "+m.activeAction)) - } + statusBits := m.headerStatusBits() headerLine := lipgloss.JoinHorizontal( lipgloss.Left, @@ -536,27 +1002,6 @@ func (m *model) renderHeader() string { subtitle, ) statusLine := joinHeaderBits(statusBits) - if m.footerActive() { - available := m.width - lipgloss.Width(headerLine) - 4 - if available > 8 { - headerLine = lipgloss.JoinHorizontal( - lipgloss.Left, - headerLine, - infoStyle.Render(" • "+fitLine(m.footer, available)), - ) - } else if statusLine == "" { - statusLine = infoStyle.Render(fitLine(m.footer, m.width-4)) - } else { - available = m.width - lipgloss.Width(statusLine) - 6 - if available > 4 { - statusLine = lipgloss.JoinHorizontal( - lipgloss.Left, - statusLine, - infoStyle.Render(" • "+fitLine(m.footer, available)), - ) - } - } - } lines := []string{headerLine} if statusLine != "" { @@ -570,12 +1015,14 @@ func (m *model) renderHeader() string { } func (m *model) renderActionPanel() string { - panelTotalWidth := clamp(m.width/3, 36, 52) + panelTotalWidth := actionPanelTotalWidth panelWidth := availablePanelContentWidth(panelTotalWidth) textWidth := panelInnerTextWidth(panelWidth) contentHeight := availablePanelContentHeight(m.bodyHeight()) + hotkeyRows := m.renderHotkeyLegend(textWidth) + hotkeyFootprint := panelBlockFootprint(hotkeyRows) if m.currentCategory() == overviewCategory { - return m.renderOverviewPanel(panelWidth, contentHeight) + return m.renderOverviewPanel(panelWidth, contentHeight, hotkeyRows, hotkeyFootprint) } lines := []string{ @@ -585,7 +1032,7 @@ func (m *model) renderActionPanel() string { } filtered := m.filteredActions() - visibleSlots := max(1, contentHeight-12) + visibleSlots := max(1, contentHeight-5-hotkeyFootprint) start, end := visibleWindow(len(filtered), visibleSlots, m.cursor) if start > 0 { @@ -609,10 +1056,12 @@ func (m *model) renderActionPanel() string { lines = append(lines, sectionMutedStyle.Render(" …")) } + lines = m.appendBottomPanelBlock(lines, hotkeyRows, contentHeight) + lines = truncatePanelLines(lines, contentHeight) return panelStyle.Width(panelWidth).Height(contentHeight).Render(strings.Join(lines, "\n")) } -func (m *model) renderOverviewPanel(panelWidth int, contentHeight int) string { +func (m *model) renderOverviewPanel(panelWidth int, contentHeight int, hotkeyRows []string, hotkeyFootprint int) string { textWidth := panelInnerTextWidth(panelWidth) lines := []string{ m.renderCategoryTabs(textWidth), @@ -622,7 +1071,7 @@ func (m *model) renderOverviewPanel(panelWidth int, contentHeight int) string { entries := m.categoryEntries() nameWidth := m.overviewNameWidth() - visibleSlots := max(1, contentHeight-11) + visibleSlots := max(1, contentHeight-5-hotkeyFootprint) start, end := visibleWindow(len(entries), visibleSlots, m.cursor) if start > 0 { @@ -646,6 +1095,8 @@ func (m *model) renderOverviewPanel(panelWidth int, contentHeight int) string { lines = append(lines, sectionMutedStyle.Render(" …")) } + lines = m.appendBottomPanelBlock(lines, hotkeyRows, contentHeight) + lines = truncatePanelLines(lines, contentHeight) return panelStyle.Width(panelWidth).Height(contentHeight).Render(strings.Join(lines, "\n")) } @@ -662,14 +1113,183 @@ func (m *model) renderCategoryTabs(width int) string { func (m *model) renderRightColumn() string { _, rightTotalWidth, statusTotalHeight, outputTotalHeight := m.rightColumnLayout() rightWidth := availablePanelContentWidth(rightTotalWidth) + statusTotalWidth := min(rightTotalWidth, statusPanelMaxTotalWidth) + statusWidth := availablePanelContentWidth(statusTotalWidth) + + statusContentHeight := availablePanelContentHeight(statusTotalHeight) + outputContentHeight := availablePanelContentHeight(outputTotalHeight) + + statusPanel := panelStyle.Width(statusWidth).Height(statusContentHeight).Render(m.renderStatusPanel(panelInnerTextWidth(statusWidth), statusContentHeight)) + outputPanel := panelStyle.Width(rightWidth).Height(outputContentHeight).Render(m.renderOutputPanel(panelInnerTextWidth(rightWidth), outputContentHeight)) + + return lipgloss.JoinVertical(lipgloss.Left, statusPanel, outputPanel) +} + +func (m *model) renderHotkeyLegend(width int) []string { + if width <= 0 { + return nil + } + + rows := []string{ + hotkeyTitleStyle.Render("Hotkeys"), + } + + hints := m.hotkeyHints() + if len(hints) == 0 { + return rows + } + + for _, hint := range hints { + rows = append(rows, renderHotkeyCell(hint, width)) + } + + return rows +} + +func renderHotkeyCell(hint hotkeyHint, width int) string { + if width <= 0 { + return "" + } + + keyLabel := formatHotkeyLabel(hint.key) + keyWidth := min(9, max(5, width/2)) + if keyWidth >= width { + keyWidth = max(1, width-1) + } + descWidth := width - keyWidth - 1 + + keyStyle := hotkeyInactiveKeyStyle + descStyle := hotkeyInactiveDescStyle + if hint.active { + keyStyle = hotkeyActiveKeyStyle + descStyle = hotkeyActiveDescStyle + } + + if descWidth <= 0 { + return keyStyle.Render(fitAndPadLine(keyLabel, width)) + } + + keyText := keyStyle.Render(fitAndPadLine(keyLabel, keyWidth)) + descText := descStyle.Render(fitLine(hint.desc, descWidth)) + padding := width - lipgloss.Width(keyText) - 1 - lipgloss.Width(descText) + if padding < 0 { + padding = 0 + } + + return keyText + " " + descText + strings.Repeat(" ", padding) +} + +func renderWorkshopInputField(value string, width int) string { + if width <= 0 { + return "" + } + + prompt := "> " + cursor := "█" + if strings.TrimSpace(value) == "" { + return inputFieldStyle.Render(fitAndPadLine(prompt+cursor, width)) + } - statusContentHeight := availablePanelContentHeight(statusTotalHeight) - outputContentHeight := availablePanelContentHeight(outputTotalHeight) + available := max(1, width-len([]rune(prompt))-len([]rune(cursor))) + runes := []rune(value) + if len(runes) > available { + runes = runes[len(runes)-available:] + } - statusPanel := panelStyle.Width(rightWidth).Height(statusContentHeight).Render(m.renderStatusPanel(panelInnerTextWidth(rightWidth), statusContentHeight)) - outputPanel := panelStyle.Width(rightWidth).Height(outputContentHeight).Render(m.renderOutputPanel(panelInnerTextWidth(rightWidth), outputContentHeight)) + return inputFieldStyle.Render(fitAndPadLine(prompt+string(runes)+cursor, width)) +} - return lipgloss.JoinVertical(lipgloss.Left, statusPanel, outputPanel) +func formatHotkeyLabel(label string) string { + if len([]rune(label)) != 1 { + return label + } + return strings.ToUpper(label) +} + +func (m *model) hotkeyHints() []hotkeyHint { + switch m.uiMode { + case uiModeConfirm: + return []hotkeyHint{ + {key: "Up/Down", desc: "move", active: false}, + {key: "Enter/y", desc: "confirm", active: true}, + {key: "Esc/n", desc: "cancel", active: true}, + {key: "Tab", desc: "swap view", active: false}, + {key: "l", desc: "next log", active: false}, + {key: "r", desc: "refresh", active: false}, + {key: "q", desc: "cancel", active: true}, + {key: "Ctrl+C", desc: "force quit", active: true}, + } + case uiModeWorldPicker: + return []hotkeyHint{ + {key: "Up/Down", desc: "move", active: true}, + {key: "Enter", desc: "set world", active: true}, + {key: "Esc", desc: "cancel", active: true}, + {key: "Tab", desc: "swap view", active: false}, + {key: "l", desc: "next log", active: false}, + {key: "r", desc: "refresh", active: false}, + {key: "q", desc: "cancel", active: true}, + {key: "Ctrl+C", desc: "force quit", active: true}, + } + case uiModeConfigPicker: + return []hotkeyHint{ + {key: "Up/Down", desc: "move", active: true}, + {key: "Enter", desc: "edit file", active: true}, + {key: "Esc", desc: "cancel", active: true}, + {key: "Tab", desc: "swap view", active: false}, + {key: "l", desc: "next log", active: false}, + {key: "r", desc: "refresh", active: false}, + {key: "q", desc: "cancel", active: true}, + {key: "Ctrl+C", desc: "force quit", active: true}, + } + case uiModeWorkshopInput: + return []hotkeyHint{ + {key: "Type", desc: "enter url/id", active: true}, + {key: "Enter", desc: "add mod", active: true}, + {key: "Backspace", desc: "erase", active: true}, + {key: "Ctrl+U", desc: "clear", active: true}, + {key: "Esc", desc: "cancel", active: true}, + {key: "Tab", desc: "swap view", active: false}, + {key: "l", desc: "next log", active: false}, + {key: "r", desc: "refresh", active: false}, + {key: "Ctrl+C", desc: "force quit", active: true}, + } + case uiModeModPicker: + return []hotkeyHint{ + {key: "Up/Down", desc: "move", active: true}, + {key: "Enter", desc: "toggle", active: true}, + {key: "A", desc: "enable all", active: true}, + {key: "N", desc: "disable all", active: true}, + {key: "S", desc: "save", active: true}, + {key: "Esc", desc: "cancel", active: true}, + {key: "Tab", desc: "swap view", active: false}, + {key: "l", desc: "next log", active: false}, + {key: "q", desc: "cancel", active: true}, + {key: "Ctrl+C", desc: "force quit", active: true}, + } + default: + if m.outputMode == outputModeLogs { + return []hotkeyHint{ + {key: "Up/Down", desc: "move", active: true}, + {key: "Enter", desc: "open/run", active: true}, + {key: "Esc", desc: "back", active: true}, + {key: "Tab", desc: "command out", active: true}, + {key: "l", desc: "next log", active: true}, + {key: "r", desc: "refresh", active: true}, + {key: "q", desc: "quit", active: true}, + {key: "Ctrl+C", desc: "force quit", active: true}, + } + } + return []hotkeyHint{ + {key: "Up/Down", desc: "move", active: true}, + {key: "Enter", desc: "open/run", active: true}, + {key: "Esc", desc: "back", active: true}, + {key: "Tab", desc: "log tail", active: true}, + {key: "l", desc: "next log", active: false}, + {key: "r", desc: "refresh", active: true}, + {key: "q", desc: "quit", active: true}, + {key: "Ctrl+C", desc: "force quit", active: true}, + } + } } func (m *model) renderStatusPanel(width, height int) string { @@ -700,26 +1320,37 @@ func (m *model) renderOutputPanel(width, height int) string { return m.renderConfirmPanel(width, height) case uiModeWorldPicker: return m.renderWorldPickerPanel(width, height) + case uiModeConfigPicker: + return m.renderConfigPickerPanel(width, height) + case uiModeWorkshopInput: + return m.renderWorkshopInputPanel(width, height) + case uiModeModPicker: + return m.renderModPickerPanel(width, height) } view := m.buildOutputView(width, height) + bodyHeight := max(1, height-3) rendered := []string{ outputHeaderTitleStyle.Width(width).Render(view.title), } rendered = append(rendered, outputHeaderMetaStyle.Width(width).Render(fitLine(view.subtitle, width))) if view.emptyState != "" { - bodyHeight := max(1, height-2) placeholder := lipgloss.Place(width, bodyHeight, lipgloss.Center, lipgloss.Center, outputPlaceholderStyle.Render(view.emptyState)) rendered = append(rendered, placeholder) + rendered = append(rendered, outputIndicatorStyle.Render(fitLine(view.indicator, width))) return strings.Join(rendered, "\n") } - for _, line := range view.lines { - rendered = append(rendered, outputBodyStyle.Render(fitLine(line, width))) + + bodyLines := make([]string, 0, bodyHeight) + for _, line := range truncatePanelLines(view.lines, bodyHeight) { + bodyLines = append(bodyLines, outputBodyStyle.Width(width).Render(fitLine(line, width))) } - if view.showOverflow { - rendered = append(rendered, outputIndicatorStyle.Render(fitLine(renderOutputIndicator(view.offset, view.maxOffset, width), width))) + for len(bodyLines) < bodyHeight { + bodyLines = append(bodyLines, outputBodyStyle.Width(width).Render("")) } + rendered = append(rendered, bodyLines...) + rendered = append(rendered, outputIndicatorStyle.Render(fitLine(view.indicator, width))) return strings.Join(rendered, "\n") } @@ -781,6 +1412,112 @@ func (m *model) renderWorldPickerPanel(width, height int) string { return strings.Join(lines, "\n") } +func (m *model) renderConfigPickerPanel(width, height int) string { + lines := []string{ + sectionTitleStyle.Render("Mod Config Picker"), + sectionMutedStyle.Render("Select a config file, then press Enter to open it in your editor."), + } + + if len(m.configOptions) == 0 { + lines = append(lines, "No mod config files found.") + return strings.Join(lines, "\n") + } + + visibleSlots := max(1, height-6) + start, end := visibleWindow(len(m.configOptions), visibleSlots, m.configCursor) + if start > 0 { + lines = append(lines, sectionMutedStyle.Render(" …")) + } + + for i := start; i < end; i++ { + config := m.configOptions[i] + prefix := " " + style := actionStyle + if i == m.configCursor { + prefix = "▶ " + style = selectedActionStyle + } + + lines = append(lines, style.Render(fitLine(prefix+config.RelPath, width))) + lines = append(lines, actionDescStyle.Render(fitLine(fmt.Sprintf(" %s %s", config.Size, config.Modified), width))) + } + + if end < len(m.configOptions) { + lines = append(lines, sectionMutedStyle.Render(" …")) + } + + lines = append(lines, "") + lines = append(lines, sectionMutedStyle.Render("Esc cancels without opening a file.")) + lines = truncatePanelLines(lines, height) + return strings.Join(lines, "\n") +} + +func (m *model) renderWorkshopInputPanel(width, height int) string { + lines := []string{ + sectionTitleStyle.Render("Add Workshop Mod"), + sectionMutedStyle.Render("Paste a Steam Workshop URL or numeric ID, then press Enter to add it to mod_ids.txt."), + "", + renderWorkshopInputField(m.workshopInput, width), + "", + sectionMutedStyle.Render("Examples:"), + sectionMutedStyle.Render(fitLine(" 2824688804", width)), + sectionMutedStyle.Render(fitLine(" https://steamcommunity.com/sharedfiles/filedetails/?id=2824688804", width)), + "", + sectionMutedStyle.Render("Esc cancels without changes."), + } + lines = truncatePanelLines(lines, height) + return strings.Join(lines, "\n") +} + +func (m *model) renderModPickerPanel(width, height int) string { + lines := []string{ + sectionTitleStyle.Render("Mod Load Manager"), + sectionMutedStyle.Render(m.modPickerSummary(width)), + } + + if len(m.modOptions) == 0 { + lines = append(lines, "No installed mods found.") + return strings.Join(lines, "\n") + } + + visibleSlots := max(1, height-6) + start, end := visibleWindow(len(m.modOptions), visibleSlots, m.modCursor) + if start > 0 { + lines = append(lines, sectionMutedStyle.Render(" …")) + } + + for i := start; i < end; i++ { + mod := m.modOptions[i] + prefix := " " + style := actionStyle + if i == m.modCursor { + prefix = "▶ " + style = selectedActionStyle + } + + state := "[off]" + if mod.Enabled { + state = "[ON ]" + } + changed := "" + if mod.Enabled != mod.OriginalEnabled { + changed = " *" + } + + lines = append(lines, style.Render(fitLine(prefix+state+" "+mod.Name+changed, width))) + } + + if end < len(m.modOptions) { + lines = append(lines, sectionMutedStyle.Render(" …")) + } + + lines = append(lines, "") + lines = append(lines, sectionMutedStyle.Render("S saves to enabled.json | A enable all | N disable all")) + lines = append(lines, sectionMutedStyle.Render("Esc cancels without saving.")) + lines = truncatePanelLines(lines, height) + return strings.Join(lines, "\n") +} + func (m *model) bodyHeight() int { return max(1, m.height-m.headerHeight()) } @@ -846,6 +1583,66 @@ func (m *model) actionLabel(act action) string { return act.title } +func (act action) isAddonAction() bool { + return strings.TrimSpace(act.addonManifest) != "" +} + +func (m *model) displayPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "." + } + rel, err := filepath.Rel(m.baseDir, path) + if err == nil { + if rel == "." { + return "." + } + if rel != "" && !strings.HasPrefix(rel, "..") { + return rel + } + } + return path +} + +func (m *model) actionOutputIntroLines(act action) []string { + lines := []string{} + if act.isAddonAction() { + lines = append(lines, "Addon: "+blankFallback(act.addonName, m.actionLabel(act))) + lines = append(lines, "Manifest: "+m.displayPath(act.addonManifest)) + lines = append(lines, "Working dir: "+m.displayPath(blankFallback(strings.TrimSpace(act.workDir), m.baseDir))) + lines = append(lines, "") + } + lines = append(lines, fmt.Sprintf("$ %s", strings.Join(act.command, " ")), "") + return lines +} + +func addonFailureHint(err error) string { + if err == nil { + return "Check the addon command, working_dir, and any script dependencies." + } + text := err.Error() + switch { + case strings.Contains(text, "working_dir"): + return "Check the addon working_dir path in addon.json." + case strings.Contains(text, "chdir"): + return "The addon working_dir could not be entered. Check that the directory exists and is accessible." + case strings.Contains(text, "executable file not found"): + return "The command was not found in PATH. Use an installed binary or launch through bash." + case strings.Contains(text, "permission denied"): + return "The command or script is not executable from this environment." + default: + return "Check the addon command, working_dir, and any script dependencies." + } +} + +func (m *model) appendAddonFailureDetails(act action, err error) { + m.appendOutput("Addon: " + blankFallback(act.addonName, m.actionLabel(act))) + m.appendOutput("Manifest: " + m.displayPath(act.addonManifest)) + m.appendOutput("Working dir: " + m.displayPath(blankFallback(strings.TrimSpace(act.workDir), m.baseDir))) + m.appendOutput("Hint: " + addonFailureHint(err)) + m.appendOutput("Hint: Review Logs/control.log for addon load warnings.") +} + func (m *model) openCategory(category string) { for i, name := range m.categories { if name == category { @@ -894,9 +1691,52 @@ func (m *model) moveWorldCursor(delta int) { } } -func (m *model) mouseOverActionPanel(x int) bool { - panelWidth := clamp(m.width/3, 36, 52) - return x <= panelWidth+3 +func (m *model) moveConfigCursor(delta int) { + if len(m.configOptions) == 0 { + m.configCursor = 0 + return + } + + m.configCursor += delta + if m.configCursor < 0 { + m.configCursor = 0 + } + if m.configCursor >= len(m.configOptions) { + m.configCursor = len(m.configOptions) - 1 + } +} + +func (m *model) moveModCursor(delta int) { + if len(m.modOptions) == 0 { + m.modCursor = 0 + return + } + + m.modCursor += delta + if m.modCursor < 0 { + m.modCursor = 0 + } + if m.modCursor >= len(m.modOptions) { + m.modCursor = len(m.modOptions) - 1 + } +} + +func (m *model) toggleCurrentMod() { + if len(m.modOptions) == 0 { + return + } + m.modOptions[m.modCursor].Enabled = !m.modOptions[m.modCursor].Enabled +} + +func (m *model) setAllModsEnabled(enabled bool) { + for i := range m.modOptions { + m.modOptions[i].Enabled = enabled + } +} + +func (m *model) mouseOverActionPanel(x, y int) bool { + panelX, panelY, panelWidth, panelHeight, _, _, _, _ := m.actionPanelGeometry() + return x >= panelX && x < panelX+panelWidth && y >= panelY && y < panelY+panelHeight } func (m *model) mouseOverOutputPanel(x, y int) bool { @@ -904,6 +1744,128 @@ func (m *model) mouseOverOutputPanel(x, y int) bool { return x >= panelX && x < panelX+panelWidth && y >= panelY && y < panelY+panelHeight } +func (m *model) actionPanelListIndexAt(y int) (int, bool) { + _, _, _, _, _, contentY, _, contentHeight := m.actionPanelGeometry() + relativeY := y - contentY + if relativeY < 0 || relativeY >= contentHeight { + return 0, false + } + + hotkeyFootprint := panelBlockFootprint(m.renderHotkeyLegend(m.actionPanelTextWidth())) + clickableRow := 3 + if m.currentCategory() == overviewCategory { + entries := m.categoryEntries() + if len(entries) == 0 { + return 0, false + } + start, end := visibleWindow(len(entries), max(1, contentHeight-5-hotkeyFootprint), m.cursor) + if start > 0 { + if relativeY == clickableRow { + return 0, false + } + clickableRow++ + } + for i := start; i < end; i++ { + if relativeY == clickableRow { + return i, true + } + clickableRow++ + } + return 0, false + } + + actions := m.filteredActions() + if len(actions) == 0 { + return 0, false + } + start, end := visibleWindow(len(actions), max(1, contentHeight-5-hotkeyFootprint), m.cursor) + if start > 0 { + if relativeY == clickableRow { + return 0, false + } + clickableRow++ + } + for i := start; i < end; i++ { + if relativeY == clickableRow { + return i, true + } + clickableRow++ + } + return 0, false +} + +func (m *model) worldPickerIndexAt(y int) (int, bool) { + _, _, _, _, _, contentY, _, contentHeight := m.outputPanelGeometry() + relativeY := y - contentY + if relativeY < 0 || relativeY >= contentHeight || len(m.worldOptions) == 0 { + return 0, false + } + + start, end := visibleWindow(len(m.worldOptions), max(1, contentHeight-6), m.worldCursor) + row := 2 + if start > 0 { + if relativeY == row { + return 0, false + } + row++ + } + for i := start; i < end; i++ { + if relativeY == row || relativeY == row+1 { + return i, true + } + row += 2 + } + return 0, false +} + +func (m *model) configPickerIndexAt(y int) (int, bool) { + _, _, _, _, _, contentY, _, contentHeight := m.outputPanelGeometry() + relativeY := y - contentY + if relativeY < 0 || relativeY >= contentHeight || len(m.configOptions) == 0 { + return 0, false + } + + start, end := visibleWindow(len(m.configOptions), max(1, contentHeight-6), m.configCursor) + row := 2 + if start > 0 { + if relativeY == row { + return 0, false + } + row++ + } + for i := start; i < end; i++ { + if relativeY == row || relativeY == row+1 { + return i, true + } + row += 2 + } + return 0, false +} + +func (m *model) modPickerIndexAt(y int) (int, bool) { + _, _, _, _, _, contentY, _, contentHeight := m.outputPanelGeometry() + relativeY := y - contentY + if relativeY < 0 || relativeY >= contentHeight || len(m.modOptions) == 0 { + return 0, false + } + + start, end := visibleWindow(len(m.modOptions), max(1, contentHeight-6), m.modCursor) + row := 2 + if start > 0 { + if relativeY == row { + return 0, false + } + row++ + } + for i := start; i < end; i++ { + if relativeY == row { + return i, true + } + row++ + } + return 0, false +} + func (m *model) categoryActionCount(category string) int { count := 0 for _, act := range m.actions { @@ -929,7 +1891,7 @@ func (m *model) categorySummary(category string) string { case "Server": return "Lifecycle controls, quick status, and active-world selection." case "Workshop": - return "SteamCMD readiness, downloads, sync, archive, and installed-mod views." + return "SteamCMD readiness, downloads, URL entry, installed-mod load control, sync, archive, and mod-config editing." case "Backup": return "World, config, and full snapshots plus retention cleanup." case "Monitor": @@ -968,6 +1930,25 @@ func (m *model) currentLogSource() logSource { return m.logSources[m.logSourceIndex] } +func (m *model) modPickerSummary(width int) string { + enabledCount := 0 + changedCount := 0 + for _, mod := range m.modOptions { + if mod.Enabled { + enabledCount++ + } + if mod.Enabled != mod.OriginalEnabled { + changedCount++ + } + } + + summary := fmt.Sprintf("%d of %d mods enabled", enabledCount, len(m.modOptions)) + if changedCount > 0 { + summary += fmt.Sprintf(" | %d unsaved change(s)", changedCount) + } + return fitLine(summary, width) +} + func (m *model) currentOutputXOffset() int { if m.outputMode == outputModeCommand { return m.commandXOffset @@ -1005,7 +1986,7 @@ func (m *model) currentOutputRawLines() []string { func (m *model) headerHeight() int { height := 1 - if m.running { + if len(m.headerStatusBits()) > 0 { height++ } if m.statusError != "" { @@ -1015,7 +1996,7 @@ func (m *model) headerHeight() int { } func (m *model) rightColumnLayout() (leftTotalWidth, rightTotalWidth, statusTotalHeight, outputTotalHeight int) { - leftTotalWidth = clamp(m.width/3, 36, 52) + leftTotalWidth = actionPanelTotalWidth rightTotalWidth = m.width - leftTotalWidth if rightTotalWidth < 44 { rightTotalWidth = 44 @@ -1048,6 +2029,25 @@ func (m *model) outputContentWidth() int { return panelInnerTextWidth(rightWidth) } +func (m *model) actionPanelTextWidth() int { + leftTotalWidth, _, _, _ := m.rightColumnLayout() + panelWidth := availablePanelContentWidth(leftTotalWidth) + return panelInnerTextWidth(panelWidth) +} + +func (m *model) actionPanelGeometry() (panelX, panelY, panelWidth, panelHeight, contentX, contentY, contentWidth, contentHeight int) { + leftTotalWidth, _, _, _ := m.rightColumnLayout() + panelX = 0 + panelY = m.headerHeight() + panelWidth = leftTotalWidth + panelHeight = m.bodyHeight() + contentX = panelX + 2 + contentY = panelY + 1 + contentWidth = m.actionPanelTextWidth() + contentHeight = max(1, availablePanelContentHeight(panelHeight)) + return +} + func (m *model) outputPanelGeometry() (panelX, panelY, panelWidth, panelHeight, contentX, contentY, contentWidth, contentHeight int) { leftTotalWidth, rightTotalWidth, statusTotalHeight, outputTotalHeight := m.rightColumnLayout() panelX = leftTotalWidth @@ -1080,37 +2080,33 @@ func (m *model) buildOutputView(width, height int) outputView { view := outputView{ title: "Log Tail", lines: m.logLines, - subtitle: fmt.Sprintf("%s | Shift+Arrows", m.currentLogSource().label), + subtitle: m.currentLogSource().label, } if m.outputMode == outputModeCommand { view.title = "Command Output" view.lines = m.outputLines - view.subtitle = fmt.Sprintf("Tab | %s", m.currentLogSource().label) + view.subtitle = fmt.Sprintf("Current log source: %s", m.currentLogSource().label) } if m.outputMode == outputModeLogs && len(view.lines) == 0 { m.setCurrentOutputXOffset(0) view.emptyState = "Waiting for " + m.currentLogSource().label + view.indicator = renderOutputIndicator(0, 0, width) return view } if m.outputMode == outputModeCommand && len(view.lines) == 0 { m.setCurrentOutputXOffset(0) view.emptyState = "Command output will appear here." + view.indicator = renderOutputIndicator(0, 0, width) return view } if len(view.lines) == 0 { view.lines = []string{"No output yet."} } - textRows := 2 + textRows := 3 visibleSlots := max(1, height-textRows) visible := tailLines(view.lines, visibleSlots) view.maxOffset = maxOutputOffset(visible, width) - view.showOverflow = view.maxOffset > 0 - if view.showOverflow { - visibleSlots = max(1, height-textRows-1) - visible = tailLines(view.lines, visibleSlots) - view.maxOffset = maxOutputOffset(visible, width) - } view.offset = m.currentOutputXOffset() if view.offset > view.maxOffset { @@ -1119,6 +2115,7 @@ func (m *model) buildOutputView(width, height int) outputView { } view.lines = sliceLinesHorizontallyWithIndicators(visible, view.offset, width) + view.indicator = renderOutputIndicator(view.offset, view.maxOffset, width) return view } @@ -1165,6 +2162,12 @@ func (m *model) renderSelectionRows(width, height int) []string { for _, line := range wrapLine(act.description, width) { rows = append(rows, sectionMutedStyle.Render(line)) } + if act.isAddonAction() { + rows = append(rows, "") + rows = append(rows, sectionMutedStyle.Render(fitLine("Addon: "+blankFallback(act.addonName, m.actionLabel(act)), width))) + rows = append(rows, sectionMutedStyle.Render(fitLine("Manifest: "+m.displayPath(act.addonManifest), width))) + rows = append(rows, sectionMutedStyle.Render(fitLine("Working dir: "+m.displayPath(blankFallback(strings.TrimSpace(act.workDir), m.baseDir)), width))) + } return rows } @@ -1173,20 +2176,14 @@ func (m *model) renderSnapshotRows(width int) []string { return nil } - state := "OFFLINE" - if m.status.Online { - state = "ONLINE" - } - rows := []string{ sectionTitleStyle.Render("Server Snapshot"), - renderSnapshotDataRow(0, width, formatSnapshotPair(width, "State", state, "PID", blankFallback(m.status.PID, "not running"))), - renderSnapshotDataRow(1, width, formatSnapshotPair(width, "World", blankFallback(m.status.World, "none"), "Players", blankFallback(m.status.Players, "0"))), - renderSnapshotDataRow(2, width, formatSnapshotPair(width, "Mods", fmt.Sprintf("%d", m.status.ModCount), "Backups", fmt.Sprintf("%d", m.status.WorldBackups))), + renderSnapshotDataRow(0, width, formatSnapshotPair(width, "PID", blankFallback(m.status.PID, "not running"), "World", blankFallback(m.status.World, "none"))), + renderSnapshotDataRow(1, width, formatSnapshotPair(width, "Players", blankFallback(m.status.Players, "n/a"), "Mods", fmt.Sprintf("%d", m.status.ModCount))), + renderSnapshotDataRow(2, width, formatSnapshotPair(width, "Backups", fmt.Sprintf("%d", m.status.WorldBackups), "Disk", blankFallback(m.status.DiskBusy, "n/a"))), panelDivider(width), - renderSnapshotDataRow(3, width, formatSnapshotPair(width, blankFallback(m.status.TempLabel, "Temp"), blankFallback(m.status.TempValue, "n/a"), "CPU", blankFallback(m.status.CPU, "0%"))), - renderSnapshotDataRow(4, width, formatSnapshotPair(width, "Mem", blankFallback(m.status.Memory, "0%"), "Uptime", blankFallback(m.status.Uptime, "0m"))), - renderSnapshotDataRow(5, width, formatSnapshotPair(width, "Disk", blankFallback(m.status.DiskBusy, "n/a"), "", "")), + renderSnapshotDataRow(3, width, formatSnapshotPair(width, blankFallback(m.status.TempLabel, "Temp"), blankFallback(m.status.TempValue, "n/a"), "CPU", blankFallback(m.status.CPU, "n/a"))), + renderSnapshotDataRow(4, width, formatSnapshotPair(width, "Mem", blankFallback(m.status.Memory, "n/a"), "Uptime", blankFallback(m.status.Uptime, "n/a"))), } return rows } @@ -1270,6 +2267,40 @@ func normalizePanelBlock(lines []string, height int) []string { return block } +func (m *model) appendBottomPanelBlock(lines, block []string, height int) []string { + if height <= 0 { + return nil + } + if len(block) == 0 { + return truncatePanelLines(lines, height) + } + + required := len(block) + if len(lines) > 0 { + required++ + } + if len(lines)+required > height { + return truncatePanelLines(lines, height) + } + + rows := append([]string{}, lines...) + for len(rows)+required < height { + rows = append(rows, "") + } + if len(rows) > 0 { + rows = append(rows, "") + } + rows = append(rows, block...) + return truncatePanelLines(rows, height) +} + +func panelBlockFootprint(block []string) int { + if len(block) == 0 { + return 0 + } + return len(block) + 1 +} + func panelDivider(width int) string { if width <= 0 { return "" @@ -1291,17 +2322,23 @@ func formatSnapshotPair(width int, leftLabel, leftValue, rightLabel, rightValue } usableWidth := width - separatorWidth - leftWidth := usableWidth * 44 / 100 + leftWidth := 21 + rightWidth := usableWidth - leftWidth + if rightWidth > 22 { + rightWidth = 22 + } + if rightWidth < 14 { + rightWidth = 14 + leftWidth = usableWidth - rightWidth + } if leftWidth < 14 { leftWidth = 14 + rightWidth = usableWidth - leftWidth } - if leftWidth > usableWidth-14 { - leftWidth = usableWidth - 14 - } - rightWidth := usableWidth - leftWidth left = formatSnapshotField(leftLabel, leftValue, leftWidth) right = formatSnapshotField(rightLabel, rightValue, rightWidth) - return fitAndPadLine(left, leftWidth) + separator + fitLine(right, rightWidth) + block := fitAndPadLine(left, leftWidth) + separator + fitAndPadLine(right, rightWidth) + return fitAndPadLine(block, width) } func fitAndPadLine(line string, width int) string { @@ -1391,16 +2428,22 @@ func sliceLineHorizontallyWithIndicators(line string, offset, width int) string } func renderOutputIndicator(offset, maxOffset, width int) string { - if maxOffset <= 0 { + if width <= 0 { return "" } start := offset + 1 end := offset + width total := width + maxOffset + if total < width { + total = width + } if end > total { end = total } - return fmt.Sprintf("Horizontal scroll %d-%d of %d | Shift+Arrows", start, end, total) + if start < 1 { + start = 1 + } + return fmt.Sprintf("Viewing columns %d-%d of %d | Shift+Arrows", start, end, total) } func truncatePanelLines(lines []string, height int) []string { @@ -1554,6 +2597,10 @@ var ( Foreground(lipgloss.Color("#93C5FD")). Background(lipgloss.Color("#0B1E33")) + inputFieldStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#E2E8F0")). + Background(lipgloss.Color("#0B1E33")) + sectionTitleStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#7DD3FC")) @@ -1561,6 +2608,24 @@ var ( sectionMutedStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#94A3B8")) + hotkeyTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#64748B")). + Faint(true) + + hotkeyActiveKeyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A7B4C5")) + + hotkeyActiveDescStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7C8DA1")) + + hotkeyInactiveKeyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#64748B")). + Faint(true) + + hotkeyInactiveDescStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#475569")). + Faint(true) + actionStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#E2E8F0")) @@ -1605,10 +2670,13 @@ func defaultActions() []action { {category: "Workshop", title: "Workshop / Download Mods", description: "Download queued workshop mods.", command: []string{"bash", "Scripts/steam/tmod-workshop.sh", "download"}}, {category: "Workshop", title: "Workshop / Sync Mods", description: "Copy workshop mods into Mods/ without prompts.", command: []string{"bash", "Scripts/steam/tmod-workshop.sh", "sync", "--yes"}}, {category: "Workshop", title: "Workshop / List Downloads", description: "Show the downloaded workshop mod table.", command: []string{"bash", "Scripts/steam/tmod-workshop.sh", "list"}}, + {category: "Workshop", title: "Workshop / Add Mod by URL or ID", description: "Paste a Steam Workshop URL or numeric ID and add it to mod_ids.txt.", kind: actionAddWorkshopMod}, + {category: "Workshop", title: "Workshop / Manage Installed Mods", description: "Toggle which installed mods load at server start, then save enabled.json.", kind: actionManageInstalledMods}, {category: "Workshop", title: "Workshop / Archive Old Versions", description: "Archive old incompatible workshop builds.", command: []string{"bash", "Scripts/steam/tmod-workshop.sh", "archive", "--yes"}, confirmText: "Archive old workshop versions now?"}, {category: "Workshop", title: "Workshop / Cleanup Downloads", description: "Clean incomplete workshop downloads.", command: []string{"bash", "Scripts/steam/tmod-workshop.sh", "cleanup"}}, {category: "Workshop", title: "Workshop / Show Queued Mod IDs", description: "Display mod_ids.txt with resolved names.", command: []string{"bash", "Scripts/steam/tmod-workshop.sh", "mods", "ids"}}, {category: "Workshop", title: "Workshop / List Installed Mods", description: "Show enabled and disabled installed mods.", command: []string{"bash", "Scripts/steam/tmod-workshop.sh", "mods", "list"}}, + {category: "Workshop", title: "Workshop / Edit Mod Configs", description: "Pick a mod config file and open it in your terminal editor.", kind: actionEditModConfig}, {category: "Backup", title: "Backup / Status", description: "Inspect backup counts and retention state.", command: []string{"bash", "Scripts/backup/tmod-backup.sh", "status"}}, {category: "Backup", title: "Backup / World Backup", description: "Create a world backup archive.", command: []string{"bash", "Scripts/backup/tmod-backup.sh", "worlds"}}, @@ -1639,8 +2707,44 @@ func defaultActions() []action { {category: "Maintenance", title: "Maintenance / Run All Tasks", description: "Run backup cleanup, log rotation, sync, and mod checks.", command: []string{"bash", "Scripts/hub/tmod-control.sh", "maintenance"}, confirmText: "Run the full maintenance sequence now?"}, {category: "Maintenance", title: "Maintenance / Health Snapshot", description: "Run the lightweight admin health summary.", command: []string{"bash", "Scripts/hub/tmod-control.sh", "health"}}, - {category: "Maintenance", title: "Maintenance / Scripts Status", description: "Check the backend script surface from the legacy admin command.", command: []string{"bash", "Scripts/hub/tmod-control.sh", "scripts"}}, + {category: "Maintenance", title: "Maintenance / Scripts Status", description: "Check the backend script surface from the shell hub.", command: []string{"bash", "Scripts/hub/tmod-control.sh", "scripts"}}, + } +} + +func categoriesForActions(actions []action) []string { + categories := []string{overviewCategory} + seen := map[string]bool{ + overviewCategory: true, + } + + builtInOrder := []string{"Server", "Workshop", "Backup", "Monitor", "Diagnostics", "Maintenance"} + for _, category := range builtInOrder { + if !actionCategoryPresent(actions, category) { + continue + } + categories = append(categories, category) + seen[category] = true + } + + for _, act := range actions { + category := strings.TrimSpace(act.category) + if category == "" || seen[category] { + continue + } + categories = append(categories, category) + seen[category] = true + } + + return categories +} + +func actionCategoryPresent(actions []action, category string) bool { + for _, act := range actions { + if act.category == category { + return true + } } + return false } func boolBadge(ok bool) string { diff --git a/internal/controlroom/app_test.go b/internal/controlroom/app_test.go new file mode 100644 index 0000000..5f3abcf --- /dev/null +++ b/internal/controlroom/app_test.go @@ -0,0 +1,468 @@ +package controlroom + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestAddonSmokeMatrixLoadsIntoModelAndRunsCommands(t *testing.T) { + baseDir := t.TempDir() + + if err := os.MkdirAll(filepath.Join(baseDir, "Addons", "smoke-scripts", "scripts"), 0o755); err != nil { + t.Fatalf("mkdir smoke-scripts addon: %v", err) + } + if err := os.MkdirAll(filepath.Join(baseDir, "Addons", "smoke-admin"), 0o755); err != nil { + t.Fatalf("mkdir smoke-admin addon: %v", err) + } + if err := os.MkdirAll(filepath.Join(baseDir, "Addons", "smoke-broken"), 0o755); err != nil { + t.Fatalf("mkdir smoke-broken addon: %v", err) + } + if err := os.MkdirAll(filepath.Join(baseDir, "Addons", "smoke-ignored"), 0o755); err != nil { + t.Fatalf("mkdir smoke-ignored addon: %v", err) + } + + if err := os.WriteFile(filepath.Join(baseDir, "Addons", "smoke-scripts", "addon.json"), []byte(`{ + "name": "smoke-scripts", + "section": "Smoke", + "actions": [ + { + "title": "Addon Local", + "description": "Temporary smoke-test action using the addon root.", + "command": [ + "bash", + "-lc", + "printf 'smoke local ok\n'; [ -d \"$1\" ] && printf 'addon arg ok\n'; basename \"$PWD\"", + "_", + "${addon_dir}" + ] + }, + { + "title": "Relative Dir", + "description": "Temporary smoke-test action using a relative working dir.", + "command": [ + "bash", + "-lc", + "printf 'smoke relative ok\n'; basename \"$PWD\"" + ], + "working_dir": "scripts" + } + ] +}`), 0o644); err != nil { + t.Fatalf("write smoke-scripts manifest: %v", err) + } + + if err := os.WriteFile(filepath.Join(baseDir, "Addons", "smoke-admin", "addon.json"), []byte(`{ + "name": "smoke-admin", + "section": "Admin", + "actions": [ + { + "title": "Repo Root", + "description": "Temporary smoke-test action using the repo root.", + "command": [ + "bash", + "-lc", + "printf 'admin repo ok\n'; [ -d Addons ] && printf 'repo dir ok\n'; basename \"$PWD\"" + ], + "working_dir": "${repo_dir}" + }, + { + "section": "Ops", + "title": "Override", + "description": "Temporary smoke-test action using a section override.", + "command": [ + "bash", + "-lc", + "printf 'ops override ok\n'; basename \"$PWD\"" + ] + } + ] +}`), 0o644); err != nil { + t.Fatalf("write smoke-admin manifest: %v", err) + } + + if err := os.WriteFile(filepath.Join(baseDir, "Addons", "smoke-broken", "addon.json"), []byte(`{"section":"Broken","actions":[`), 0o644); err != nil { + t.Fatalf("write broken manifest: %v", err) + } + + if err := os.WriteFile(filepath.Join(baseDir, "Addons", "smoke-ignored", "addon.json.example"), []byte(`{ + "section": "Ignored", + "actions": [ + { + "title": "Should Not Load", + "command": ["bash", "-lc", "printf 'ignored\n'"] + } + ] +}`), 0o644); err != nil { + t.Fatalf("write ignored example manifest: %v", err) + } + + m := newModel(baseDir) + + for _, category := range []string{"Smoke", "Admin", "Ops"} { + if !containsString(m.categories, category) { + t.Fatalf("expected category %q in %v", category, m.categories) + } + } + for _, category := range []string{"Broken", "Ignored"} { + if containsString(m.categories, category) { + t.Fatalf("did not expect category %q in %v", category, m.categories) + } + } + + smokeLocal, ok := actionByTitle(m.actions, "Smoke / Addon Local") + if !ok { + t.Fatalf("expected Smoke / Addon Local action") + } + smokeRelative, ok := actionByTitle(m.actions, "Smoke / Relative Dir") + if !ok { + t.Fatalf("expected Smoke / Relative Dir action") + } + adminRepo, ok := actionByTitle(m.actions, "Admin / Repo Root") + if !ok { + t.Fatalf("expected Admin / Repo Root action") + } + opsOverride, ok := actionByTitle(m.actions, "Ops / Override") + if !ok { + t.Fatalf("expected Ops / Override action") + } + + assertActionOutputs := func(act action, expected ...string) { + t.Helper() + lines := []string{} + err := streamCommandTo(act.workDir, act.command, func(line string) { + lines = append(lines, line) + }) + if err != nil { + t.Fatalf("run %q: %v", act.title, err) + } + for _, want := range expected { + if !containsWarning(lines, want) { + t.Fatalf("expected output %q in %v", want, lines) + } + } + } + + assertActionOutputs(smokeLocal, "smoke local ok", "addon arg ok", "smoke-scripts") + assertActionOutputs(smokeRelative, "smoke relative ok", "scripts") + assertActionOutputs(adminRepo, "admin repo ok", "repo dir ok", filepath.Base(baseDir)) + assertActionOutputs(opsOverride, "ops override ok", "smoke-admin") + + controlLog := filepath.Join(baseDir, "Logs", "control.log") + data, err := os.ReadFile(controlLog) + if err != nil { + t.Fatalf("read control log: %v", err) + } + if !strings.Contains(string(data), "Skipping addon manifest") || !strings.Contains(string(data), "smoke-broken") { + t.Fatalf("expected broken addon warning in control log, got %q", string(data)) + } +} + +func TestNewModelLoadsAddonActionsAndLogsWarnings(t *testing.T) { + baseDir := t.TempDir() + + goodAddonDir := filepath.Join(baseDir, "Addons", "admin-tools") + if err := os.MkdirAll(goodAddonDir, 0o755); err != nil { + t.Fatalf("mkdir good addon dir: %v", err) + } + if err := os.WriteFile(filepath.Join(goodAddonDir, "addon.json"), []byte(`{ + "section": "Admin", + "actions": [ + { + "title": "Health Snapshot", + "description": "Run a custom admin helper.", + "command": ["bash", "-lc", "true"] + } + ] +}`), 0o644); err != nil { + t.Fatalf("write good addon manifest: %v", err) + } + + badAddonDir := filepath.Join(baseDir, "Addons", "broken") + if err := os.MkdirAll(badAddonDir, 0o755); err != nil { + t.Fatalf("mkdir bad addon dir: %v", err) + } + if err := os.WriteFile(filepath.Join(badAddonDir, "addon.json"), []byte(`{"section":"Broken","actions":[`), 0o644); err != nil { + t.Fatalf("write bad addon manifest: %v", err) + } + + m := newModel(baseDir) + + if !containsString(m.categories, "Admin") { + t.Fatalf("expected Admin category in %v", m.categories) + } + + foundAction := false + for _, act := range m.actions { + if act.title == "Admin / Health Snapshot" { + foundAction = true + if act.workDir != goodAddonDir { + t.Fatalf("expected addon workDir %q, got %q", goodAddonDir, act.workDir) + } + } + } + if !foundAction { + t.Fatalf("expected addon action to be loaded") + } + + controlLog := filepath.Join(baseDir, "Logs", "control.log") + data, err := os.ReadFile(controlLog) + if err != nil { + t.Fatalf("read control log: %v", err) + } + if !strings.Contains(string(data), "Skipping addon manifest") { + t.Fatalf("expected addon warning in control log, got %q", string(data)) + } + if len(m.addonWarnings) != 1 { + t.Fatalf("expected 1 addon warning, got %d", len(m.addonWarnings)) + } + if !containsWarning(m.outputLines, "[warn] Skipping addon manifest") { + t.Fatalf("expected startup command output warning, got %v", m.outputLines) + } +} + +func TestHandleNormalMouseMotionHighlightsAction(t *testing.T) { + m := newModel(t.TempDir()) + m.width = 140 + m.height = 40 + m.ready = true + m.categoryIndex = 1 // Server + m.cursor = 0 + + y, ok := actionPanelYForIndex(m, 2) + if !ok { + t.Fatalf("unable to find y coordinate for action index 2") + } + + panelX, _, _, _, _, _, _, _ := m.actionPanelGeometry() + msg := tea.MouseMsg{ + X: panelX + 1, + Y: y, + Action: tea.MouseActionMotion, + Button: tea.MouseButtonNone, + } + + if _, cmd := m.handleNormalMouse(msg); cmd != nil { + t.Fatalf("expected no command from hover motion") + } + if m.cursor != 2 { + t.Fatalf("expected cursor to move to 2, got %d", m.cursor) + } +} + +func TestHandleNormalMouseShiftWheelDoesNotScrollOutputHorizontally(t *testing.T) { + m := newModel(t.TempDir()) + m.width = 140 + m.height = 40 + m.ready = true + m.outputMode = outputModeCommand + m.outputLines = []string{ + "This is a deliberately long command output line that should require horizontal scrolling in the output panel.", + } + + _, _, _, _, contentX, contentY, _, _ := m.outputPanelGeometry() + msg := tea.MouseMsg{ + X: contentX + 1, + Y: contentY + 1, + Action: tea.MouseActionPress, + Button: tea.MouseButtonWheelDown, + Shift: true, + } + + if _, cmd := m.handleNormalMouse(msg); cmd != nil { + t.Fatalf("expected no command from shift+wheel scroll") + } + if m.commandXOffset != 0 { + t.Fatalf("expected horizontal offset to stay 0, got %d", m.commandXOffset) + } +} + +func TestHandleNormalMouseWheelDoesNotScrollOutputHorizontallyWithoutShift(t *testing.T) { + m := newModel(t.TempDir()) + m.width = 140 + m.height = 40 + m.ready = true + m.outputMode = outputModeCommand + m.outputLines = []string{ + "This is a deliberately long command output line that should require horizontal scrolling in the output panel.", + } + + _, _, _, _, contentX, contentY, _, _ := m.outputPanelGeometry() + msg := tea.MouseMsg{ + X: contentX + 1, + Y: contentY + 1, + Action: tea.MouseActionPress, + Button: tea.MouseButtonWheelDown, + } + + if _, cmd := m.handleNormalMouse(msg); cmd != nil { + t.Fatalf("expected no command from wheel scroll") + } + if m.commandXOffset != 0 { + t.Fatalf("expected horizontal offset to stay 0 without shift, got %d", m.commandXOffset) + } +} + +func TestHandleNormalMouseHorizontalWheelDoesNotScrollOutputHorizontally(t *testing.T) { + m := newModel(t.TempDir()) + m.width = 140 + m.height = 40 + m.ready = true + m.outputMode = outputModeCommand + m.outputLines = []string{ + "This is a deliberately long command output line that should require horizontal scrolling in the output panel.", + } + + _, _, _, _, contentX, contentY, _, _ := m.outputPanelGeometry() + msg := tea.MouseMsg{ + X: contentX + 1, + Y: contentY + 1, + Action: tea.MouseActionPress, + Button: tea.MouseButtonWheelRight, + } + + if _, cmd := m.handleNormalMouse(msg); cmd != nil { + t.Fatalf("expected no command from horizontal wheel scroll") + } + if m.commandXOffset != 0 { + t.Fatalf("expected horizontal offset to stay 0 with horizontal wheel, got %d", m.commandXOffset) + } +} + +func TestHandleNormalKeysIgnoresLogSwitchOutsideLogView(t *testing.T) { + m := newModel(t.TempDir()) + m.outputMode = outputModeCommand + m.logSourceIndex = 2 + m.footer = "unchanged" + m.footerTimestamp = time.Now().Add(time.Minute) + + if _, cmd := m.handleNormalKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}); cmd != nil { + t.Fatalf("expected no command from l outside log view") + } + if m.logSourceIndex != 2 { + t.Fatalf("expected log source index to stay 2, got %d", m.logSourceIndex) + } + if m.footer != "unchanged" { + t.Fatalf("expected footer to stay unchanged, got %q", m.footer) + } +} + +func TestHeaderShowsAddonWarningSummary(t *testing.T) { + baseDir := t.TempDir() + badAddonDir := filepath.Join(baseDir, "Addons", "broken") + if err := os.MkdirAll(badAddonDir, 0o755); err != nil { + t.Fatalf("mkdir bad addon dir: %v", err) + } + if err := os.WriteFile(filepath.Join(badAddonDir, "addon.json"), []byte(`{"section":"Broken","actions":[`), 0o644); err != nil { + t.Fatalf("write bad addon manifest: %v", err) + } + + m := newModel(baseDir) + m.width = 120 + m.height = 30 + + rendered := m.renderHeader() + if !strings.Contains(rendered, "addon warnings: 1") { + t.Fatalf("expected addon warning summary in header, got %q", rendered) + } + if m.headerHeight() != 2 { + t.Fatalf("expected header height 2 with addon warnings, got %d", m.headerHeight()) + } +} + +func TestStartActionShowsAddonTroubleshootingForInvalidWorkingDir(t *testing.T) { + baseDir := t.TempDir() + m := newModel(baseDir) + + act := action{ + category: "Smoke", + title: "Smoke / Broken Action", + description: "Broken addon action for troubleshooting output.", + command: []string{"bash", "-lc", "printf 'should not run\\n'"}, + workDir: filepath.Join(baseDir, "Addons", "broken-addon", "missing"), + addonName: "broken-addon", + addonManifest: filepath.Join(baseDir, "Addons", "broken-addon", "addon.json"), + } + + m.startAction(act) + + if m.running { + t.Fatalf("expected invalid addon action to fail before running") + } + if !containsWarning(m.outputLines, "Unable to start addon action") { + t.Fatalf("expected startup failure line in %v", m.outputLines) + } + if !containsWarning(m.outputLines, "Manifest: Addons/broken-addon/addon.json") { + t.Fatalf("expected manifest line in %v", m.outputLines) + } + if !containsWarning(m.outputLines, "Working dir: Addons/broken-addon/missing") { + t.Fatalf("expected working dir line in %v", m.outputLines) + } + if !containsWarning(m.outputLines, "Hint: Check the addon working_dir path in addon.json.") { + t.Fatalf("expected working_dir hint in %v", m.outputLines) + } +} + +func TestHeaderNoLongerRendersFooterNotice(t *testing.T) { + m := newModel(t.TempDir()) + m.width = 120 + m.height = 30 + m.footer = "This old yellow popup should not render anymore." + m.footerTimestamp = time.Now().Add(time.Minute) + + rendered := m.renderHeader() + if strings.Contains(rendered, m.footer) { + t.Fatalf("expected header to omit footer text, got %q", rendered) + } + if m.headerHeight() != 1 { + t.Fatalf("expected base header height 1, got %d", m.headerHeight()) + } +} + +func TestRenderOutputIndicatorUsesColumnLanguage(t *testing.T) { + got := renderOutputIndicator(0, 60, 39) + if !strings.Contains(got, "Viewing columns 1-39 of 99") { + t.Fatalf("expected column wording in indicator, got %q", got) + } + if strings.Contains(got, "Horizontal scroll") { + t.Fatalf("expected old horizontal scroll wording to be gone, got %q", got) + } +} + +func TestBuildOutputViewCommandSubtitleUsesStatusLineInsteadOfTabHint(t *testing.T) { + m := newModel(t.TempDir()) + m.outputMode = outputModeCommand + m.outputLines = []string{"hello"} + + view := m.buildOutputView(80, 12) + if !strings.Contains(view.subtitle, "Current log source: server.log") { + t.Fatalf("expected status subtitle, got %q", view.subtitle) + } + if strings.Contains(view.subtitle, "Tab") { + t.Fatalf("expected tab hint to be removed from subtitle, got %q", view.subtitle) + } +} + +func actionPanelYForIndex(m *model, target int) (int, bool) { + _, _, _, _, _, contentY, _, contentHeight := m.actionPanelGeometry() + for y := contentY; y < contentY+contentHeight; y++ { + index, ok := m.actionPanelListIndexAt(y) + if ok && index == target { + return y, true + } + } + return 0, false +} + +func actionByTitle(actions []action, want string) (action, bool) { + for _, act := range actions { + if act.title == want { + return act, true + } + } + return action{}, false +} diff --git a/backend.go b/internal/controlroom/backend.go similarity index 51% rename from backend.go rename to internal/controlroom/backend.go index 26f32be..5b78e42 100644 --- a/backend.go +++ b/internal/controlroom/backend.go @@ -1,8 +1,9 @@ -package main +package controlroom import ( "bufio" "bytes" + "encoding/json" "errors" "fmt" "io" @@ -32,6 +33,34 @@ type worldOption struct { Active bool } +type configOption struct { + RelPath string + Path string + Size string + Modified string +} + +type modOption struct { + Name string + Enabled bool + OriginalEnabled bool +} + +type addonManifest struct { + Name string `json:"name"` + Section string `json:"section"` + Actions []addonManifestAction `json:"actions"` +} + +type addonManifestAction struct { + Section string `json:"section"` + Title string `json:"title"` + Description string `json:"description"` + Command []string `json:"command"` + ConfirmText string `json:"confirm_text"` + WorkingDir string `json:"working_dir"` +} + type appStatus struct { Online bool PID string @@ -51,6 +80,44 @@ var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) var numericValuePattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`) var wholeNumberPattern = regexp.MustCompile(`^[0-9]+$`) var signedNumericValuePattern = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?$`) +var decorativeLinePattern = regexp.MustCompile(`^[\s\p{So}\p{Sk}\p{Pd}\p{Pc}━─│┌┐└┘]+$`) +var outputGlyphReplacer = strings.NewReplacer( + "\uFE0F", "", + "✅ ", "[ok] ", + "❌ ", "[error] ", + "⚠ ", "[warn] ", + "💡 ", "[tip] ", + "ℹ ", "[info] ", + "🟢 ", "", + "🔴 ", "", + "🟡 ", "", + "🔵 ", "", + "🎮 ", "", + "🚀 ", "", + "🛑 ", "", + "🔄 ", "", + "📦 ", "", + "📋 ", "", + "📁 ", "", + "🧹 ", "", + "🔍 ", "", + "🔧 ", "", + "📊 ", "", + "🎉 ", "", + "⚙ ", "", + "🖥 ", "", + "📈 ", "", + "🔪 ", "", + "📺 ", "", + "💾 ", "", + "💿 ", "", + "⚡ ", "", + "👥 ", "", + "📅 ", "", + "📝 ", "", + "⏱ ", "Duration: ", + "·", "|", +) type diskBusySample struct { ioMillis int64 @@ -73,29 +140,72 @@ func defaultLogSources(baseDir string) []logSource { func (m *model) startAction(act action) { label := m.actionLabel(act) + if err := validateRunnableAction(act); err != nil { + m.running = false + m.activeAction = "" + m.activeSince = time.Time{} + m.outputMode = outputModeCommand + m.commandXOffset = 0 + m.outputLines = m.actionOutputIntroLines(act) + if act.isAddonAction() { + m.appendOutput("✗ Unable to start addon action: " + err.Error()) + m.appendAddonFailureDetails(act, err) + } else { + m.appendOutput("✗ Unable to start action: " + err.Error()) + } + return + } m.running = true m.activeAction = label m.activeSince = time.Now() m.outputMode = outputModeCommand m.commandXOffset = 0 - m.outputLines = []string{ - fmt.Sprintf("$ %s", strings.Join(act.command, " ")), - "", - } + m.outputLines = m.actionOutputIntroLines(act) m.setFooter("Running "+label+"…", 4*time.Second) go func() { start := time.Now() - err := streamCommand(m.program, m.baseDir, act.command) + runDir := act.workDir + if strings.TrimSpace(runDir) == "" { + runDir = m.baseDir + } + err := streamCommand(m.program, runDir, act.command) m.program.Send(commandDoneMsg{ - action: label, + act: act, + label: label, duration: time.Since(start), err: err, }) }() } +func validateRunnableAction(act action) error { + if len(act.command) == 0 { + return errors.New("empty command") + } + if strings.TrimSpace(act.workDir) == "" { + return nil + } + info, err := os.Stat(act.workDir) + if err != nil { + return fmt.Errorf("working_dir %q is unavailable: %w", act.workDir, err) + } + if !info.IsDir() { + return fmt.Errorf("working_dir %q is not a directory", act.workDir) + } + return nil +} + func streamCommand(program *tea.Program, baseDir string, argv []string) error { + if program == nil { + return streamCommandTo(baseDir, argv, nil) + } + return streamCommandTo(baseDir, argv, func(line string) { + program.Send(outputLineMsg{text: line}) + }) +} + +func streamCommandTo(baseDir string, argv []string, emit func(string)) error { if len(argv) == 0 { return errors.New("empty command") } @@ -118,6 +228,7 @@ func streamCommand(program *tea.Program, baseDir string, argv []string) error { } done := make(chan struct{}, 2) + var emitMu sync.Mutex stream := func(r io.Reader) { scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) @@ -127,7 +238,11 @@ func streamCommand(program *tea.Program, baseDir string, argv []string) error { if line == "" { continue } - program.Send(outputLineMsg{text: line}) + if emit != nil { + emitMu.Lock() + emit(line) + emitMu.Unlock() + } } done <- struct{}{} } @@ -183,6 +298,54 @@ server_config_set "worldname" %q } } +func listModConfigsCmd(baseDir string) tea.Cmd { + return func() tea.Msg { + configs, err := listModConfigOptions(baseDir) + return configListMsg{configs: configs, err: err} + } +} + +func editModConfigCmd(baseDir string, config configOption) tea.Cmd { + editorSnippet := ` +if [[ -n "${VISUAL:-}" ]]; then + exec ${VISUAL} "$1" +fi +if [[ -n "${EDITOR:-}" ]]; then + exec ${EDITOR} "$1" +fi +for candidate in nano nvim vim vi; do + if command -v "$candidate" >/dev/null 2>&1; then + exec "$candidate" "$1" + fi +done +echo "No terminal editor found. Set \$VISUAL or \$EDITOR." >&2 +exit 127 +` + + cmd := exec.Command("bash", "-lc", editorSnippet, "bash", config.Path) + cmd.Dir = baseDir + cmd.Env = os.Environ() + + return tea.ExecProcess(cmd, func(err error) tea.Msg { + return configEditDoneMsg{config: config, err: err} + }) +} + +func listInstalledModsCmd(baseDir string) tea.Cmd { + return func() tea.Msg { + mods, err := listInstalledModOptions(baseDir) + return modListMsg{mods: mods, err: err} + } +} + +func saveModSelectionCmd(baseDir string, mods []modOption) tea.Cmd { + snapshot := append([]modOption(nil), mods...) + return func() tea.Msg { + enabledCount, changedCount, err := saveInstalledModOptions(baseDir, snapshot) + return modSaveMsg{enabledCount: enabledCount, changedCount: changedCount, err: err} + } +} + func statusTickCmd() tea.Cmd { return tea.Tick(3*time.Second, func(t time.Time) tea.Msg { return statusTickMsg(t) }) } @@ -254,10 +417,10 @@ printf 'temp_value=%s\n' "${temp_value:-n/a}" if !online || pid == "" { online = false pid = "" - values["cpu"] = "0" - values["mem"] = "0" - values["uptime"] = "0" - values["players"] = "0" + values["cpu"] = "n/a" + values["mem"] = "n/a" + values["uptime"] = "n/a" + values["players"] = "n/a" } status := appStatus{ @@ -305,6 +468,300 @@ func listWorldOptions(baseDir string) ([]worldOption, error) { return options, nil } +func listModConfigOptions(baseDir string) ([]configOption, error) { + paths := map[string]struct{}{} + modConfigDir := filepath.Join(baseDir, "ModConfigs") + + if entries, err := os.ReadDir(modConfigDir); err == nil { + for _, entry := range entries { + if entry.IsDir() { + continue + } + paths[filepath.Join(modConfigDir, entry.Name())] = struct{}{} + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + + excludedDirs := map[string]struct{}{ + "Backups": {}, + "Engine": {}, + "Logs": {}, + "Mods": {}, + "Scripts": {}, + "Worlds": {}, + "ModConfigs": {}, + } + extensions := []string{".json", ".toml", ".cfg", ".ini"} + + baseEntries, err := os.ReadDir(baseDir) + if err != nil { + return nil, err + } + for _, entry := range baseEntries { + if !entry.IsDir() { + continue + } + if _, skip := excludedDirs[entry.Name()]; skip { + continue + } + + subdir := filepath.Join(baseDir, entry.Name()) + subEntries, err := os.ReadDir(subdir) + if err != nil { + continue + } + for _, subEntry := range subEntries { + if subEntry.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(subEntry.Name())) + if !containsString(extensions, ext) { + continue + } + paths[filepath.Join(subdir, subEntry.Name())] = struct{}{} + } + } + + sortedPaths := make([]string, 0, len(paths)) + for path := range paths { + sortedPaths = append(sortedPaths, path) + } + sort.Strings(sortedPaths) + + options := make([]configOption, 0, len(sortedPaths)) + for _, path := range sortedPaths { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + continue + } + relPath, err := filepath.Rel(baseDir, path) + if err != nil { + relPath = path + } + options = append(options, configOption{ + RelPath: filepath.ToSlash(relPath), + Path: path, + Size: humanSize(info.Size()), + Modified: info.ModTime().Format("2006-01-02 15:04"), + }) + } + + return options, nil +} + +func listInstalledModOptions(baseDir string) ([]modOption, error) { + modPaths, err := filepath.Glob(filepath.Join(baseDir, "Mods", "*.tmod")) + if err != nil { + return nil, err + } + sort.Strings(modPaths) + + enabledMods, err := readEnabledModNames(filepath.Join(baseDir, "Mods", "enabled.json")) + if err != nil { + return nil, err + } + + options := make([]modOption, 0, len(modPaths)) + for _, path := range modPaths { + name := strings.TrimSuffix(filepath.Base(path), ".tmod") + enabled := enabledMods[strings.ToLower(name)] + options = append(options, modOption{ + Name: name, + Enabled: enabled, + OriginalEnabled: enabled, + }) + } + + return options, nil +} + +func loadAddonActions(baseDir string) ([]action, []string) { + pattern := filepath.Join(baseDir, "Addons", "*", "addon.json") + manifestPaths, err := filepath.Glob(pattern) + if err != nil { + return nil, []string{fmt.Sprintf("Unable to scan addon manifests: %v", err)} + } + sort.Strings(manifestPaths) + + actions := []action{} + warnings := []string{} + for _, manifestPath := range manifestPaths { + addonDir := filepath.Dir(manifestPath) + + raw, err := os.ReadFile(manifestPath) + if err != nil { + warnings = append(warnings, fmt.Sprintf("Skipping addon manifest %s: %v", manifestPath, err)) + continue + } + + var manifest addonManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + warnings = append(warnings, fmt.Sprintf("Skipping addon manifest %s: %v", manifestPath, err)) + continue + } + + for index, item := range manifest.Actions { + section := strings.TrimSpace(item.Section) + if section == "" { + section = strings.TrimSpace(manifest.Section) + } + title := strings.TrimSpace(item.Title) + if section == "" || title == "" || len(item.Command) == 0 { + warnings = append(warnings, fmt.Sprintf("Skipping action %d in %s: section, title, and command are required", index+1, manifestPath)) + continue + } + + command := make([]string, 0, len(item.Command)) + for _, part := range item.Command { + command = append(command, expandAddonString(part, baseDir, addonDir)) + } + + workDir := strings.TrimSpace(item.WorkingDir) + if workDir == "" { + workDir = addonDir + } else { + workDir = expandAddonString(workDir, baseDir, addonDir) + if !filepath.IsAbs(workDir) { + workDir = filepath.Join(addonDir, workDir) + } + } + + actions = append(actions, action{ + category: section, + title: section + " / " + title, + description: blankFallback(strings.TrimSpace(item.Description), "Run addon action."), + command: command, + workDir: workDir, + addonName: blankFallback(strings.TrimSpace(manifest.Name), filepath.Base(addonDir)), + addonManifest: manifestPath, + confirmText: strings.TrimSpace(item.ConfirmText), + }) + } + } + + return actions, warnings +} + +func expandAddonString(value, baseDir, addonDir string) string { + replacer := strings.NewReplacer( + "${repo_dir}", baseDir, + "${addon_dir}", addonDir, + ) + return replacer.Replace(value) +} + +func readEnabledModNames(path string) (map[string]bool, error) { + enabled := map[string]bool{} + + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return enabled, nil + } + return nil, err + } + + var names []string + if err := json.Unmarshal(data, &names); err == nil { + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + enabled[strings.ToLower(name)] = true + } + return enabled, nil + } + + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + line = strings.Trim(line, "[],") + line = strings.Trim(line, "\"") + line = strings.TrimSpace(line) + if line == "" { + continue + } + enabled[strings.ToLower(line)] = true + } + if err := scanner.Err(); err != nil { + return nil, err + } + + return enabled, nil +} + +func saveInstalledModOptions(baseDir string, mods []modOption) (enabledCount, changedCount int, err error) { + enabledJSON := filepath.Join(baseDir, "Mods", "enabled.json") + if err := os.MkdirAll(filepath.Dir(enabledJSON), 0o755); err != nil { + return 0, 0, err + } + + names := make([]string, 0, len(mods)) + for _, mod := range mods { + if mod.Enabled { + names = append(names, mod.Name) + enabledCount++ + } + if mod.Enabled != mod.OriginalEnabled { + changedCount++ + } + } + sort.Strings(names) + + if current, readErr := os.ReadFile(enabledJSON); readErr == nil { + if writeErr := os.WriteFile(enabledJSON+".bak", current, 0o644); writeErr != nil { + return 0, 0, writeErr + } + } else if !errors.Is(readErr, os.ErrNotExist) { + return 0, 0, readErr + } + + data, err := json.Marshal(names) + if err != nil { + return 0, 0, err + } + + tmpPath := enabledJSON + ".tmp" + if err := os.WriteFile(tmpPath, append(data, '\n'), 0o644); err != nil { + return 0, 0, err + } + if err := os.Rename(tmpPath, enabledJSON); err != nil { + return 0, 0, err + } + + appendWorkshopLog(baseDir, fmt.Sprintf("Saved enabled.json (%d mods)", enabledCount)) + return enabledCount, changedCount, nil +} + +func appendWorkshopLog(baseDir, message string) { + appendNamedLog(baseDir, "workshop.log", "INFO", message) +} + +func appendControlLog(baseDir, message, level string) { + appendNamedLog(baseDir, "control.log", level, message) +} + +func appendNamedLog(baseDir, filename, level, message string) { + logPath := filepath.Join(baseDir, "Logs", filename) + if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { + return + } + + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + + timestamp := time.Now().Format("2006-01-02 15:04:05") + if strings.TrimSpace(level) == "" { + level = "INFO" + } + _, _ = fmt.Fprintf(f, "[%s] [%s] %s\n", timestamp, level, message) +} + func parseKeyValues(raw []byte) map[string]string { values := map[string]string{} scanner := bufio.NewScanner(bytes.NewReader(raw)) @@ -377,11 +834,32 @@ func scanConsoleLines(data []byte, atEOF bool) (advance int, token []byte, err e func cleanOutputLine(line string) string { line = ansiEscapePattern.ReplaceAllString(line, "") + line = outputGlyphReplacer.Replace(line) line = strings.ReplaceAll(line, "\r", "") + line = normalizeDecorativeLine(line) line = strings.TrimSpace(line) return line } +func normalizeDecorativeLine(line string) string { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return line + } + if !decorativeLinePattern.MatchString(trimmed) { + return line + } + + width := len([]rune(trimmed)) + if width < 6 { + return line + } + if width > 62 { + width = 62 + } + return strings.Repeat("-", width) +} + func humanSize(size int64) string { units := []string{"B", "KB", "MB", "GB", "TB"} value := float64(size) @@ -410,22 +888,34 @@ func blankFallback(value, fallback string) string { return value } +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + func formatPercent(raw string) string { raw = strings.TrimSpace(raw) - if raw == "" || raw == "not_running" { - return "0%" + if raw == "" || raw == "not_running" || strings.EqualFold(raw, "n/a") || strings.EqualFold(raw, "unknown") { + return "n/a" } raw = strings.TrimSuffix(raw, "%") if !numericValuePattern.MatchString(raw) { - return "0%" + return "n/a" } return raw + "%" } func formatUptimeDuration(raw string) string { raw = strings.TrimSpace(raw) + if raw == "" || strings.EqualFold(raw, "n/a") || strings.EqualFold(raw, "unknown") { + return "n/a" + } if !wholeNumberPattern.MatchString(raw) { - return "0s" + return "n/a" } seconds := atoi(raw) if seconds <= 0 { @@ -451,17 +941,25 @@ func formatTemperature(raw string) string { if !signedNumericValuePattern.MatchString(raw) { return "n/a" } - raw = strings.TrimRight(strings.TrimRight(raw, "0"), ".") + if strings.Contains(raw, ".") { + raw = strings.TrimRight(strings.TrimRight(raw, "0"), ".") + } return raw + "C" } func formatMemoryRSS(raw string) string { raw = strings.TrimSpace(raw) + if raw == "" || strings.EqualFold(raw, "n/a") || strings.EqualFold(raw, "unknown") { + return "n/a" + } if !wholeNumberPattern.MatchString(raw) { - return "0MB" + return "n/a" } kb, err := strconv.ParseInt(raw, 10, 64) - if err != nil || kb <= 0 { + if err != nil || kb < 0 { + return "n/a" + } + if kb == 0 { return "0MB" } bytes := kb * 1024 diff --git a/internal/controlroom/backend_test.go b/internal/controlroom/backend_test.go new file mode 100644 index 0000000..aff0725 --- /dev/null +++ b/internal/controlroom/backend_test.go @@ -0,0 +1,285 @@ +package controlroom + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestLoadAddonActionsLoadsManifestActions(t *testing.T) { + baseDir := t.TempDir() + addonDir := filepath.Join(baseDir, "Addons", "admin-tools") + if err := os.MkdirAll(filepath.Join(addonDir, "scripts"), 0o755); err != nil { + t.Fatalf("mkdir addon dir: %v", err) + } + + manifest := `{ + "name": "admin-tools", + "section": "Admin", + "actions": [ + { + "title": "Audit World", + "description": "Run an addon-local audit helper.", + "command": ["bash", "${addon_dir}/scripts/audit-world.sh"] + }, + { + "section": "Ops", + "title": "Health Snapshot", + "description": "Call back into the repo root.", + "command": ["bash", "${repo_dir}/Scripts/hub/tmod-control.sh", "health"], + "working_dir": "${repo_dir}", + "confirm_text": "Run health snapshot now?" + } + ] +}` + + manifestPath := filepath.Join(addonDir, "addon.json") + if err := os.WriteFile(manifestPath, []byte(manifest), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + + actions, warnings := loadAddonActions(baseDir) + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(actions) != 2 { + t.Fatalf("expected 2 actions, got %d", len(actions)) + } + + first := actions[0] + if first.category != "Admin" { + t.Fatalf("expected first category Admin, got %q", first.category) + } + if first.title != "Admin / Audit World" { + t.Fatalf("unexpected first title: %q", first.title) + } + if first.workDir != addonDir { + t.Fatalf("expected default workDir %q, got %q", addonDir, first.workDir) + } + if first.addonName != "admin-tools" { + t.Fatalf("expected addonName admin-tools, got %q", first.addonName) + } + if first.addonManifest != manifestPath { + t.Fatalf("expected addonManifest %q, got %q", manifestPath, first.addonManifest) + } + wantFirstCommand := []string{"bash", filepath.Join(addonDir, "scripts", "audit-world.sh")} + if !reflect.DeepEqual(first.command, wantFirstCommand) { + t.Fatalf("unexpected first command: got %v want %v", first.command, wantFirstCommand) + } + + second := actions[1] + if second.category != "Ops" { + t.Fatalf("expected second category Ops, got %q", second.category) + } + if second.title != "Ops / Health Snapshot" { + t.Fatalf("unexpected second title: %q", second.title) + } + if second.workDir != baseDir { + t.Fatalf("expected second workDir %q, got %q", baseDir, second.workDir) + } + if second.confirmText != "Run health snapshot now?" { + t.Fatalf("unexpected confirm text: %q", second.confirmText) + } + wantSecondCommand := []string{"bash", filepath.Join(baseDir, "Scripts", "hub", "tmod-control.sh"), "health"} + if !reflect.DeepEqual(second.command, wantSecondCommand) { + t.Fatalf("unexpected second command: got %v want %v", second.command, wantSecondCommand) + } +} + +func TestLoadAddonActionsWarnsAndSkipsInvalidEntries(t *testing.T) { + baseDir := t.TempDir() + + badJSONDir := filepath.Join(baseDir, "Addons", "broken-json") + if err := os.MkdirAll(badJSONDir, 0o755); err != nil { + t.Fatalf("mkdir broken addon dir: %v", err) + } + if err := os.WriteFile(filepath.Join(badJSONDir, "addon.json"), []byte(`{"section":"Broken","actions":[`), 0o644); err != nil { + t.Fatalf("write broken manifest: %v", err) + } + + badActionDir := filepath.Join(baseDir, "Addons", "broken-action") + if err := os.MkdirAll(badActionDir, 0o755); err != nil { + t.Fatalf("mkdir broken action dir: %v", err) + } + if err := os.WriteFile(filepath.Join(badActionDir, "addon.json"), []byte(`{ + "section": "Admin", + "actions": [ + { + "description": "Missing title and command" + } + ] +}`), 0o644); err != nil { + t.Fatalf("write invalid action manifest: %v", err) + } + + actions, warnings := loadAddonActions(baseDir) + if len(actions) != 0 { + t.Fatalf("expected no actions, got %d", len(actions)) + } + if len(warnings) != 2 { + t.Fatalf("expected 2 warnings, got %d: %v", len(warnings), warnings) + } + if !containsWarning(warnings, "broken-json") { + t.Fatalf("expected warning mentioning broken-json, got %v", warnings) + } + if !containsWarning(warnings, "Skipping action 1") { + t.Fatalf("expected warning mentioning invalid action, got %v", warnings) + } +} + +func TestLoadAddonActionsUsesRelativeWorkingDirAndFallbackDescription(t *testing.T) { + baseDir := t.TempDir() + addonDir := filepath.Join(baseDir, "Addons", "admin-tools") + if err := os.MkdirAll(filepath.Join(addonDir, "scripts"), 0o755); err != nil { + t.Fatalf("mkdir addon dir: %v", err) + } + + if err := os.WriteFile(filepath.Join(addonDir, "addon.json"), []byte(`{ + "section": "Admin", + "actions": [ + { + "title": "Relative Runner", + "command": ["bash", "run.sh"], + "working_dir": "scripts" + } + ] +}`), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + + actions, warnings := loadAddonActions(baseDir) + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(actions) != 1 { + t.Fatalf("expected 1 action, got %d", len(actions)) + } + + got := actions[0] + if got.workDir != filepath.Join(addonDir, "scripts") { + t.Fatalf("unexpected workDir: %q", got.workDir) + } + if got.description != "Run addon action." { + t.Fatalf("unexpected fallback description: %q", got.description) + } +} + +func TestCategoriesForActionsKeepsBuiltInsOrderedAndAppendsCustomSections(t *testing.T) { + actions := []action{ + {category: "Admin"}, + {category: "Workshop"}, + {category: "Custom"}, + {category: "Maintenance"}, + {category: "Admin"}, + } + + got := categoriesForActions(actions) + want := []string{overviewCategory, "Workshop", "Maintenance", "Admin", "Custom"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected categories: got %v want %v", got, want) + } +} + +func TestStreamCommandToUsesWorkingDirAndCleansOutput(t *testing.T) { + baseDir := t.TempDir() + lines := []string{} + + err := streamCommandTo(baseDir, []string{ + "bash", + "-lc", + `printf '✅ Ready\n⚠ Watch\n'; printf '\033[31mDanger\033[0m\n'; pwd`, + }, func(line string) { + lines = append(lines, line) + }) + if err != nil { + t.Fatalf("stream command: %v", err) + } + + want := []string{"[ok] Ready", "[warn] Watch", "Danger", baseDir} + if !reflect.DeepEqual(lines, want) { + t.Fatalf("unexpected cleaned output: got %v want %v", lines, want) + } +} + +func TestStreamCommandToCapturesStdoutAndStderr(t *testing.T) { + baseDir := t.TempDir() + lines := []string{} + + err := streamCommandTo(baseDir, []string{ + "bash", + "-lc", + `printf 'stdout ok\n'; printf 'stderr warn\n' >&2`, + }, func(line string) { + lines = append(lines, line) + }) + if err != nil { + t.Fatalf("stream command: %v", err) + } + + if !containsWarning(lines, "stdout ok") { + t.Fatalf("expected stdout line in %v", lines) + } + if !containsWarning(lines, "stderr warn") { + t.Fatalf("expected stderr line in %v", lines) + } +} + +func TestFormatTemperatureKeepsWholeNumberTrailingZeroes(t *testing.T) { + cases := map[string]string{ + "70": "70C", + "70C": "70C", + "70.0": "70C", + "70.5": "70.5C", + "88": "88C", + "68": "68C", + "n/a": "n/a", + "weird": "n/a", + } + + for input, want := range cases { + if got := formatTemperature(input); got != want { + t.Fatalf("formatTemperature(%q) = %q, want %q", input, got, want) + } + } +} + +func TestFormatPercentAndMemoryShowUnavailableWhenNotAvailable(t *testing.T) { + if got := formatPercent("n/a"); got != "n/a" { + t.Fatalf("formatPercent(n/a) = %q, want n/a", got) + } + if got := formatPercent(""); got != "n/a" { + t.Fatalf("formatPercent(empty) = %q, want n/a", got) + } + if got := formatMemoryRSS("n/a"); got != "n/a" { + t.Fatalf("formatMemoryRSS(n/a) = %q, want n/a", got) + } + if got := formatMemoryRSS(""); got != "n/a" { + t.Fatalf("formatMemoryRSS(empty) = %q, want n/a", got) + } + if got := formatMemoryRSS("0"); got != "0MB" { + t.Fatalf("formatMemoryRSS(0) = %q, want 0MB", got) + } +} + +func TestFormatUptimeAndBackupCountShowUnavailableWhenNotAvailable(t *testing.T) { + if got := formatUptimeDuration("n/a"); got != "n/a" { + t.Fatalf("formatUptimeDuration(n/a) = %q, want n/a", got) + } + if got := formatUptimeDuration(""); got != "n/a" { + t.Fatalf("formatUptimeDuration(empty) = %q, want n/a", got) + } + if got := formatUptimeDuration("0"); got != "0s" { + t.Fatalf("formatUptimeDuration(0) = %q, want 0s", got) + } +} + +func containsWarning(warnings []string, fragment string) bool { + for _, warning := range warnings { + if strings.Contains(warning, fragment) { + return true + } + } + return false +} diff --git a/main.go b/main.go deleted file mode 100644 index b16da15..0000000 --- a/main.go +++ /dev/null @@ -1,22 +0,0 @@ -package main - -import ( - "log" - "os" - - tea "github.com/charmbracelet/bubbletea" -) - -func main() { - baseDir, err := os.Getwd() - if err != nil { - log.Fatal(err) - } - - m := newModel(baseDir) - p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) - m.program = p - if _, err := p.Run(); err != nil { - log.Fatal(err) - } -} diff --git a/man/tmod-control.1 b/man/tmod-control.1 index b76da21..c4f9c20 100644 --- a/man/tmod-control.1 +++ b/man/tmod-control.1 @@ -1,4 +1,4 @@ -.TH TMOD\-CONTROL 1 "2026-03-27" "v2.6.0" "tModLoader Server Management" +.TH TMOD\-CONTROL 1 "2026-03-29" "v2.6.0" "tModLoader Server Management" .SH NAME tmod\-control \- headless tModLoader dedicated server management toolkit .SH SYNOPSIS @@ -7,15 +7,20 @@ tmod\-control \- headless tModLoader dedicated server management toolkit [\fIcommand\fR] [\fIsubcommand\fR] [\fIargs\fR...] -.SH DESCRIPTION -.B tmod\-control.sh +.SH DESCRIPTION +.B tmod\-control.sh is the central entry point for managing a headless tModLoader dedicated server. -With no arguments it prefers the persistent Go TUI when a built binary or local -Go toolchain is available. Otherwise it falls back to the legacy shell menu. -Commands can also be passed directly for scripting or automation. -.PP -All child scripts (backup, workshop, monitor, diagnostics) are invoked through -this hub and inherit any flags set here. +With no arguments it launches the persistent Go TUI when a built binary or local +Go toolchain is available. Commands can also be passed directly for scripting +or automation. +.PP +All child scripts (backup, workshop, monitor, diagnostics) are invoked through +this hub and inherit any flags set here. +.PP +The Go control room can also load addon sections from +.IR Addons/*/addon.json , +so extra command groups can be plugged into the UI without editing the built-in +action list. .SH OPTIONS .TP .B \-\-debug @@ -117,25 +122,37 @@ Show all installed mods with enabled/disabled state. Enable a mod by name. Use .B all to enable every installed mod. -.TP -.B mods disable \fIname\fR -Disable a mod by name. Use -.B all -to disable every installed mod. -.TP -.B mods pick -Open an interactive numbered toggle menu — no arrow keys needed, works over SSH. -.SS Utility +.TP +.B mods disable \fIname\fR +Disable a mod by name. Use +.B all +to disable every installed mod. +.TP +.B mods add [\-\-yes] \fIurl-or-id\fR +Add a Steam Workshop URL or numeric Workshop ID to +.IR Scripts/steam/mod_ids.txt . +.TP +.B mods ids +Show queued Workshop IDs or URLs from +.I Scripts/steam/mod_ids.txt +with resolved names when available. +.TP +.B mods clear [\-\-yes] +Clear queued Workshop IDs from +.IR Scripts/steam/mod_ids.txt . +.TP +.B mods pick +Open the interactive mod toggle picker. +.SS Utility .TP .B logs Print the last 20 lines of the server log. -.TP -.B diagnostics -Run the full system diagnostic report (script status, paths, config, disk). -.TP -.B health -Alias for -.BR diagnostics . +.TP +.B diagnostics +Run the full system diagnostic report (script status, paths, config, disk). +.TP +.B health +Run the comprehensive system health check summary. .TP .B maintenance Run routine maintenance tasks (log rotation, backup cleanup). @@ -144,16 +161,10 @@ Run routine maintenance tasks (log rotation, backup cleanup). Emergency shutdown — kills the server screen session immediately. .TP .B interactive -Launch the Go control room when available, otherwise the legacy shell palette. -.TP -.B interactive classic -Force the classic numbered shell menu. -.TP -.B interactive palette -Force the searchable legacy shell palette. +Launch the Go control room. .TP .B tui -Force the Go control room. +Alias for the Go control room. .SH FILES .TP .I Configs/serverconfig.txt @@ -163,18 +174,25 @@ Created from .I Configs/serverconfig.example.txt on first setup via .BR "make setup" . -.TP -.I Scripts/steam/mod_ids.txt -Steam Workshop mod IDs — one numeric ID or Workshop URL per line. Lines -beginning with -.B # -are ignored. Created from -.I Scripts/steam/mod_ids.example.txt -on first setup. Gitignored (personal mod list). -.TP -.I Logs/ -Script and server log files. Rotated automatically based on -.B log_max_size +.TP +.I Scripts/steam/mod_ids.txt +Steam Workshop mod IDs — one numeric ID or Workshop URL per line. Lines +beginning with +.B # +are ignored. Created from +.I Scripts/steam/mod_ids.example.txt +on first setup. Gitignored (personal mod list). +.TP +.I Scripts/env.sh +Optional local environment overrides. Created from +.I Scripts/env.example.sh +on first setup and intended for values such as +.BR STEAM_USERNAME ", " STEAM_API_KEY , +and webhook URLs. +.TP +.I Logs/ +Script and server log files. Rotated automatically based on +.B log_max_size and .B log_keep_days in @@ -182,12 +200,17 @@ in .TP .I Backups/ Automated backup storage. Subdirectories: Worlds/, Configs/, Full/. +.TP +.I Engine/ +tModLoader binary files. Installed via GitHub release download or SteamCMD (gitignored). .TP -.I Engine/ -tModLoader binary files. Installed via SteamCMD (gitignored). -.TP -.I Mods/ -Installed .tmod files and enabled.json. Managed by the workshop and mods commands. +.I Mods/ +Installed .tmod files and enabled.json. Managed by the workshop and mods commands. +.TP +.I Addons/ +Optional manifest-driven control-room extensions. Each addon can provide +.I addon.json +plus companion scripts under its own folder. .SH ENVIRONMENT .TP .B TMOD_DEBUG @@ -198,32 +221,22 @@ to enable verbose terminal output across all scripts. Equivalent to passing to .BR tmod\-control.sh . .TP -.B TMOD_FORCE_LEGACY_UI -Set to -.B 1 -to force the old shell interface for -.B interactive -requests even if the Go TUI is available. -.TP .B STEAM_USERNAME Steam account username for Workshop downloads requiring login. Should be -exported from -.I ~/.bashrc_secrets -or equivalent — never stored in a tracked file. -.TP -.B STEAM_API_KEY -Steam Web API key used by -.BR tmod\-deps.sh . -Should be exported from -.I ~/.bashrc_secrets -or equivalent. +exported from +.I Scripts/env.sh +or your shell profile — never stored in a tracked file. +.TP +.B STEAM_API_KEY +Steam Web API key used by +.BR tmod\-deps.sh . +Should be exported from +.I Scripts/env.sh +or your shell profile. .SH EXAMPLES -.TP -Open the persistent server console: -.B ./Scripts/hub/tmod-control.sh .TP -Open the classic shell fallback explicitly: -.B ./Scripts/hub/tmod-control.sh interactive classic +Open the persistent server console: +.B ./Scripts/hub/tmod-control.sh tui .TP Start the server with verbose output: .B ./Scripts/hub/tmod-control.sh \-\-debug start @@ -232,12 +245,15 @@ Download workshop mods and sync to server: .B ./Scripts/hub/tmod-control.sh workshop download .br .B ./Scripts/hub/tmod-control.sh workshop sync -.TP -Enable a mod by name: -.B ./Scripts/hub/tmod-control.sh mods enable CalamityMod -.TP -Take a full backup: -.B ./Scripts/hub/tmod-control.sh backup full +.TP +Enable a mod by name: +.B ./Scripts/hub/tmod-control.sh mods enable CalamityMod +.TP +Queue a Workshop mod by ID: +.B ./Scripts/hub/tmod-control.sh mods add 2824688804 +.TP +Take a full backup: +.B ./Scripts/hub/tmod-control.sh backup full .TP Run diagnostics: .B ./Scripts/hub/tmod-control.sh diagnostics