feat: Adds Build Tool for Publishing/Setup (#2392)

## Summary

Replaces the basic `publish.cmd`/`publish.sh` scripts with an interactive **BuildTool** — a C# console app using [Spectre.Console](https://spectreconsole.net/) that guides users through publishing, prerequisite checking, and cross-compilation.

### Why
The community found the existing publish scripts unhelpful for newcomers. They worked but didn't walk users through the process, didn't check prerequisites, and provided no feedback when things went wrong.

### What's New

**Interactive BuildTool** (`Projects/BuildTool/`)
- NativeAOT-compiled C# console app with true-color ASCII logo and ModernUO brand gold/silver palette
- Guided publish wizard with step-by-step back navigation (Ctrl+C or menu "Back" to go to previous step)
- Prerequisite checking: .NET SDK version, VC++ Redistributable (Windows), native libraries (Linux/macOS)
- .NET SDK auto-install offer via Microsoft's official install scripts
- Platform detection: Windows 10 vs 11 (build number), macOS codenames, Linux distro + kernel version
- Cross-compilation support: skips native library checks, shows target prerequisites after build
- Non-interactive mode for CI: `--config Release --skip-prereqs`
- Backward-compatible positional args: `publish.cmd release win x64` still works

**Shell Wrappers** (`publish.cmd`, `publish.ps1`, `publish.sh`)
- Try native BuildTool binary first (downloaded from GitHub Releases)
- Fall back to `dotnet run --project Projects/BuildTool` if unavailable
- SDK bootstrapping: offer to install .NET if not found

**CI/CD Updates**
- Build/test workflows target `Projects/Application/Application.csproj` instead of the solution (excludes BuildTool and test projects from publish)
- New `build-tool-release.yml` workflow builds NativeAOT binaries for win-x64, win-arm64, osx-arm64, linux-x64, linux-arm64
- Minimum SDK bumped to 10.0.201 (required for Serialization Generator 2.14.3 / Roslyn 5.3.0)

**Other Changes**
- Solution converted from `.sln` to `.slnx`
- Updated README with interactive mode instructions and deployment guidance

## Screenshots

<img width="320" height="378" alt="image" src="https://github.com/user-attachments/assets/83c057c5-3992-4dbd-99fa-0e3c24ef6428" />

<img width="749" height="554" alt="image" src="https://github.com/user-attachments/assets/e4ee4d6f-71d4-47f9-86b6-8fd1ca3c3e7a" />
This commit is contained in:
Kamron Batman 2026-03-28 21:21:50 -07:00 committed by GitHub
parent 3b4e1137f6
commit ec4d6a7a85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 2372 additions and 165 deletions

View file

@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"modernuoschemagenerator": {
"version": "2.14.2",
"version": "2.14.3",
"commands": [
"ModernUOSchemaGenerator"
]

View file

@ -14,10 +14,10 @@ jobs:
fail-fast: false
matrix:
include:
- os: macos-14
name: MacOS 14
- os: macos-15
name: MacOS 15
- os: macos-26
name: MacOS 26
steps:
- uses: actions/checkout@v4
@ -34,7 +34,7 @@ jobs:
- name: Set Library Path
run: echo "DYLD_LIBRARY_PATH=/opt/homebrew/lib:\$DYLD_LIBRARY_PATH" >> $GITHUB_ENV
- name: Build
run: ./publish.cmd Release
run: dotnet run --project Projects/BuildTool -- --config Release --skip-prereqs
- name: Migration Changes
run: git diff --exit-code ./**/Migrations/*.v*.json
- name: Test
@ -90,6 +90,6 @@ jobs:
with:
global-json-file: global.json
- name: Build
run: ./publish.cmd Release
run: dotnet run --project Projects/BuildTool -- --config Release --skip-prereqs
- name: Test
run: dotnet test --no-restore

View file

@ -0,0 +1,91 @@
name: Build Tool Release
on:
push:
branches: [main]
paths:
- 'Projects/BuildTool/**'
workflow_dispatch:
jobs:
build:
runs-on: ${{ matrix.os }}
name: Build (${{ matrix.rid }})
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
rid: win-x64
artifact: build-tool-win-x64.exe
- os: windows-latest
rid: win-arm64
artifact: build-tool-win-arm64.exe
- os: macos-15
rid: osx-arm64
artifact: build-tool-osx-arm64
- os: ubuntu-latest
rid: linux-x64
artifact: build-tool-linux-x64
- os: ubuntu-24.04-arm
rid: linux-arm64
artifact: build-tool-linux-arm64
steps:
- uses: actions/checkout@v4
- name: Install .NET
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Publish NativeAOT
run: dotnet publish Projects/BuildTool/BuildTool.csproj -c Release -r ${{ matrix.rid }} -o publish/
- name: Rename artifact (Unix)
if: runner.os != 'Windows'
run: mv publish/build-tool publish/${{ matrix.artifact }}
- name: Rename artifact (Windows)
if: runner.os == 'Windows'
run: mv publish/build-tool.exe publish/${{ matrix.artifact }}
shell: bash
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: publish/${{ matrix.artifact }}
release:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts/
merge-multiple: true
- name: Generate checksums
run: |
cd artifacts
sha256sum build-tool-* > checksums-sha256.txt
- name: Create or update release
uses: softprops/action-gh-release@v2
with:
tag_name: build-tool-latest
name: Build Tool (Latest)
body: |
Latest NativeAOT-compiled build tool binaries.
These are automatically downloaded by `publish.cmd` / `publish.sh`.
prerelease: true
files: |
artifacts/build-tool-*
artifacts/checksums-sha256.txt
make_latest: false

3
.gitignore vendored
View file

@ -45,3 +45,6 @@
/packages/*
/Distribution/Configuration/server-access.json
# BuildTool native binaries (downloaded from GitHub Releases)
/tools/

View file

@ -1,67 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.0.11222.15 d18.0
MinimumVisualStudioVersion = 18.0.11222.15
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Server", "Projects\Server\Server.csproj", "{5E93BB35-3661-4822-9A8A-859726BAD87F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UOContent", "Projects\UOContent\UOContent.csproj", "{83CF2484-BCCB-4B7C-9C5F-7AB43AEA5E8F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Server.Tests", "Projects\Server.Tests\Server.Tests.csproj", "{D7A5D3AF-D607-46EF-BAAD-0D424190311F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UOContent.Tests", "Projects\UOContent.Tests\UOContent.Tests.csproj", "{3C4797F9-603E-44EF-8E8C-9275CC9EA74B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application", "Projects\Application\Application.csproj", "{E9849FA1-D4F5-4D68-A36B-249F4CB4E374}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logger", "Projects\Logger\Logger.csproj", "{ECAD3793-A7C5-4546-AA88-77DD24574410}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Analyze|Any CPU = Analyze|Any CPU
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{5E93BB35-3661-4822-9A8A-859726BAD87F}.Analyze|Any CPU.ActiveCfg = Analyze|Any CPU
{5E93BB35-3661-4822-9A8A-859726BAD87F}.Analyze|Any CPU.Build.0 = Analyze|Any CPU
{5E93BB35-3661-4822-9A8A-859726BAD87F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5E93BB35-3661-4822-9A8A-859726BAD87F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5E93BB35-3661-4822-9A8A-859726BAD87F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5E93BB35-3661-4822-9A8A-859726BAD87F}.Release|Any CPU.Build.0 = Release|Any CPU
{83CF2484-BCCB-4B7C-9C5F-7AB43AEA5E8F}.Analyze|Any CPU.ActiveCfg = Analyze|Any CPU
{83CF2484-BCCB-4B7C-9C5F-7AB43AEA5E8F}.Analyze|Any CPU.Build.0 = Analyze|Any CPU
{83CF2484-BCCB-4B7C-9C5F-7AB43AEA5E8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{83CF2484-BCCB-4B7C-9C5F-7AB43AEA5E8F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{83CF2484-BCCB-4B7C-9C5F-7AB43AEA5E8F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{83CF2484-BCCB-4B7C-9C5F-7AB43AEA5E8F}.Release|Any CPU.Build.0 = Release|Any CPU
{D7A5D3AF-D607-46EF-BAAD-0D424190311F}.Analyze|Any CPU.ActiveCfg = Analyze|Any CPU
{D7A5D3AF-D607-46EF-BAAD-0D424190311F}.Analyze|Any CPU.Build.0 = Analyze|Any CPU
{D7A5D3AF-D607-46EF-BAAD-0D424190311F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D7A5D3AF-D607-46EF-BAAD-0D424190311F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D7A5D3AF-D607-46EF-BAAD-0D424190311F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D7A5D3AF-D607-46EF-BAAD-0D424190311F}.Release|Any CPU.Build.0 = Release|Any CPU
{3C4797F9-603E-44EF-8E8C-9275CC9EA74B}.Analyze|Any CPU.ActiveCfg = Analyze|Any CPU
{3C4797F9-603E-44EF-8E8C-9275CC9EA74B}.Analyze|Any CPU.Build.0 = Analyze|Any CPU
{3C4797F9-603E-44EF-8E8C-9275CC9EA74B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3C4797F9-603E-44EF-8E8C-9275CC9EA74B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3C4797F9-603E-44EF-8E8C-9275CC9EA74B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3C4797F9-603E-44EF-8E8C-9275CC9EA74B}.Release|Any CPU.Build.0 = Release|Any CPU
{E9849FA1-D4F5-4D68-A36B-249F4CB4E374}.Analyze|Any CPU.ActiveCfg = Analyze|Any CPU
{E9849FA1-D4F5-4D68-A36B-249F4CB4E374}.Analyze|Any CPU.Build.0 = Analyze|Any CPU
{E9849FA1-D4F5-4D68-A36B-249F4CB4E374}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E9849FA1-D4F5-4D68-A36B-249F4CB4E374}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E9849FA1-D4F5-4D68-A36B-249F4CB4E374}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E9849FA1-D4F5-4D68-A36B-249F4CB4E374}.Release|Any CPU.Build.0 = Release|Any CPU
{ECAD3793-A7C5-4546-AA88-77DD24574410}.Analyze|Any CPU.ActiveCfg = Analyze|Any CPU
{ECAD3793-A7C5-4546-AA88-77DD24574410}.Analyze|Any CPU.Build.0 = Analyze|Any CPU
{ECAD3793-A7C5-4546-AA88-77DD24574410}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ECAD3793-A7C5-4546-AA88-77DD24574410}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ECAD3793-A7C5-4546-AA88-77DD24574410}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ECAD3793-A7C5-4546-AA88-77DD24574410}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {8F69EF13-E193-40B9-BA9F-4862A9F0C038}
EndGlobalSection
EndGlobal

21
ModernUO.slnx Normal file
View file

@ -0,0 +1,21 @@
<Solution>
<Configurations>
<BuildType Name="Analyze" />
<BuildType Name="Debug" />
<BuildType Name="Release" />
</Configurations>
<Project Path="Projects/Application/Application.csproj" />
<Project Path="Projects/BuildTool/BuildTool.csproj">
<BuildType Solution="Analyze|*" Project="Release" />
<Build Solution="Analyze|*" Project="false" />
<Build Solution="Debug|*" Project="false" />
</Project>
<Project Path="Projects/Logger/Logger.csproj" />
<Project Path="Projects/Server.Tests/Server.Tests.csproj" />
<Project Path="Projects/Server/Server.csproj" />
<Project Path="Projects/UOContent.Tests/UOContent.Tests.csproj" />
<Project Path="Projects/UOContent/UOContent.csproj" />
<Properties Name="Visual Studio">
<Property Name="MinimumVersion" Value="18.0.11222.15" />
</Properties>
</Solution>

View file

@ -0,0 +1,55 @@
using Spectre.Console;
namespace BuildTool;
/// <summary>
/// Static branding assets for the BuildTool CLI.
/// Colors sourced from ModernUO brand palette (docs/packets/template.html, docs/branding/logo.svg).
/// </summary>
public static class Branding
{
// ModernUO brand gold palette (from the "U" in the logo)
public static readonly Color Gold = new(213, 191, 116); // #d5bf74 - primary brand gold
public static readonly Color GoldLight = new(223, 198, 136); // #dfc688 - interactive elements
public static readonly Color GoldMuted = new(232, 206, 161); // #e8cea1 - body text
public static readonly Color GoldDim = new(162, 145, 71); // #a29147 - dimmed gold
// Silver palette (from the "O" in the logo)
public static readonly Color Silver = new(223, 223, 221); // #dfdfdd - primary silver
public static readonly Color SilverDim = new(150, 150, 148); // #969694 - dimmed silver
// Functional colors
public static readonly Color Success = new(34, 197, 94); // #22c55e - green
public static readonly Color Info = new(59, 130, 246); // #3b82f6 - blue
// Styles
public static readonly Style GoldStyle = new(Gold);
public static readonly Style SilverStyle = new(Silver);
public static readonly Style HighlightStyle = new(GoldLight);
public static readonly Style DimGoldStyle = new(GoldDim);
/// <summary>
/// The iconic ModernUO circular UO monogram with true-color ANSI escape codes.
/// Converted from the brand PNG (docs/branding/android-chrome-512x512.png).
/// Gold U on the left, silver O on the right, rendered with per-character RGB colors.
/// Output with Console.Write() to bypass Spectre markup parsing.
/// </summary>
public const string UOLogoAnsi =
" \e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;2;2;1m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;19;17;10m \e[0m\e[38;2;56;49;31m.\e[0m\e[38;2;46;41;24m.\e[0m\e[38;2;86;86;87m-\e[0m\e[38;2;134;133;131m+\e[0m\e[38;2;136;135;134m+\e[0m\e[38;2;133;132;131m+\e[0m\e[38;2;119;118;117m=\e[0m\e[38;2;87;86;85m-\e[0m\e[38;2;52;52;51m:\e[0m\e[38;2;20;20;20m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;2;2;2m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\n" +
" \e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;19;17;11m \e[0m\e[38;2;97;87;52m-\e[0m\e[38;2;163;145;87m+\e[0m\e[38;2;184;164;101m*\e[0m\e[38;2;202;179;111m*\e[0m\e[38;2;135;121;74m=\e[0m\e[38;2;96;97;101m-\e[0m\e[38;2;134;134;131m+\e[0m\e[38;2;124;123;123m=\e[0m\e[38;2;131;131;130m+\e[0m\e[38;2;144;143;143m+\e[0m\e[38;2;197;196;195m#\e[0m\e[38;2;211;210;209m%\e[0m\e[38;2;195;194;193m#\e[0m\e[38;2;169;168;167m*\e[0m\e[38;2;99;99;98m-\e[0m\e[38;2;19;18;18m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\n" +
" \e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;6;6;4m \e[0m\e[38;2;120;109;64m=\e[0m\e[38;2;228;204;120m#\e[0m\e[38;2;218;196;118m#\e[0m\e[38;2;180;163;101m*\e[0m\e[38;2;229;206;122m%\e[0m\e[38;2;231;207;122m%\e[0m\e[38;2;145;130;80m+\e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;139;140;139m+\e[0m\e[38;2;236;237;236m@\e[0m\e[38;2;234;234;234m@\e[0m\e[38;2;183;183;182m#\e[0m\e[38;2;222;222;222m%\e[0m\e[38;2;231;231;231m@\e[0m\e[38;2;120;120;119m=\e[0m\e[38;2;6;6;6m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\n" +
" \e[38;2;0;0;0m \e[0m\e[38;2;18;17;11m \e[0m\e[38;2;180;162;93m*\e[0m\e[38;2;241;216;123m%\e[0m\e[38;2;172;156;94m*\e[0m\e[38;2;22;21;15m \e[0m\e[38;2;68;63;41m:\e[0m\e[38;2;240;216;123m%\e[0m\e[38;2;244;219;125m%\e[0m\e[38;2;150;135;80m+\e[0m\e[38;2;2;2;1m \e[0m\e[38;2;3;3;3m \e[0m\e[38;2;3;3;3m \e[0m\e[38;2;3;3;3m \e[0m\e[38;2;2;2;2m \e[0m\e[38;2;148;148;148m+\e[0m\e[38;2;244;245;245m@\e[0m\e[38;2;240;241;241m@\e[0m\e[38;2;66;66;66m:\e[0m\e[38;2;21;21;21m \e[0m\e[38;2;171;172;171m*\e[0m\e[38;2;241;242;242m@\e[0m\e[38;2;179;179;179m#\e[0m\e[38;2;17;17;17m \e[0m\e[38;2;0;0;0m \e[0m\n" +
" \e[38;2;1;1;2m \e[0m\e[38;2;136;120;70m=\e[0m\e[38;2;207;180;98m#\e[0m\e[38;2;146;130;76m+\e[0m\e[38;2;0;1;4m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;76;68;42m:\e[0m\e[38;2;204;178;98m*\e[0m\e[38;2;204;177;97m*\e[0m\e[38;2;135;119;70m=\e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;134;135;134m+\e[0m\e[38;2;208;209;209m%\e[0m\e[38;2;207;208;207m%\e[0m\e[38;2;75;75;75m:\e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;146;147;146m+\e[0m\e[38;2;211;212;212m%\e[0m\e[38;2;135;135;135m+\e[0m\e[38;2;0;0;0m \e[0m\n" +
" \e[38;2;33;30;21m.\e[0m\e[38;2;161;134;70m+\e[0m\e[38;2;161;133;69m+\e[0m\e[38;2;51;46;30m.\e[0m\e[38;2;0;0;0m \e[0m\e[38;2;1;1;0m \e[0m\e[38;2;67;59;37m:\e[0m\e[38;2;162;134;69m+\e[0m\e[38;2;158;129;65m+\e[0m\e[38;2;118;102;58m=\e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;118;118;118m=\e[0m\e[38;2;165;166;166m*\e[0m\e[38;2;167;168;168m*\e[0m\e[38;2;66;66;66m:\e[0m\e[38;2;1;1;1m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;50;50;50m.\e[0m\e[38;2;166;167;167m*\e[0m\e[38;2;165;166;165m*\e[0m\e[38;2;32;32;31m.\e[0m\n" +
" \e[38;2;35;32;23m.\e[0m\e[38;2;154;128;62m+\e[0m\e[38;2;154;127;61m+\e[0m\e[38;2;49;44;29m.\e[0m\e[38;2;0;0;0m \e[0m\e[38;2;1;1;0m \e[0m\e[38;2;66;59;36m:\e[0m\e[38;2;154;126;61m+\e[0m\e[38;2;150;121;56m=\e[0m\e[38;2;112;96;54m-\e[0m\e[38;2;0;0;0m \e[0m\e[38;2;3;2;0m \e[0m\e[38;2;3;3;1m \e[0m\e[38;2;2;2;1m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;118;119;118m=\e[0m\e[38;2;165;165;165m*\e[0m\e[38;2;167;168;168m*\e[0m\e[38;2;67;67;66m:\e[0m\e[38;2;1;1;1m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;48;48;47m.\e[0m\e[38;2;167;168;168m*\e[0m\e[38;2;166;167;166m*\e[0m\e[38;2;33;33;33m.\e[0m\n" +
" \e[38;2;3;3;5m \e[0m\e[38;2;130;114;62m=\e[0m\e[38;2;181;154;68m+\e[0m\e[38;2;132;115;59m=\e[0m\e[38;2;0;0;2m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;46;42;28m.\e[0m\e[38;2;184;159;76m*\e[0m\e[38;2;178;152;66m+\e[0m\e[38;2;151;132;66m+\e[0m\e[38;2;13;12;9m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;132;132;132m+\e[0m\e[38;2;212;213;213m%\e[0m\e[38;2;208;208;208m%\e[0m\e[38;2;50;50;49m.\e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;146;146;146m+\e[0m\e[38;2;213;213;213m%\e[0m\e[38;2;139;139;139m+\e[0m\e[38;2;1;1;1m \e[0m\n" +
" \e[38;2;0;0;0m \e[0m\e[38;2;22;21;16m \e[0m\e[38;2;161;142;72m+\e[0m\e[38;2;197;172;77m*\e[0m\e[38;2;138;122;60m=\e[0m\e[38;2;24;22;13m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;89;80;46m-\e[0m\e[38;2;185;162;76m*\e[0m\e[38;2;195;171;77m*\e[0m\e[38;2;192;168;78m*\e[0m\e[38;2;151;132;62m+\e[0m\e[38;2;136;119;56m=\e[0m\e[38;2;160;140;67m+\e[0m\e[38;2;115;100;48m-\e[0m\e[38;2;143;145;150m+\e[0m\e[38;2;220;219;218m%\e[0m\e[38;2;92;92;92m-\e[0m\e[38;2;0;0;0m \e[0m\e[38;2;24;24;24m \e[0m\e[38;2;155;155;155m*\e[0m\e[38;2;232;232;232m@\e[0m\e[38;2;180;180;180m#\e[0m\e[38;2;21;21;21m \e[0m\e[38;2;0;0;0m \e[0m\n" +
" \e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;11;11;11m \e[0m\e[38;2;109;97;54m-\e[0m\e[38;2;183;159;79m*\e[0m\e[38;2;181;157;78m*\e[0m\e[38;2;109;94;48m-\e[0m\e[38;2;36;32;19m.\e[0m\e[38;2;33;31;21m.\e[0m\e[38;2;68;62;36m:\e[0m\e[38;2;102;91;48m-\e[0m\e[38;2;126;111;57m=\e[0m\e[38;2;131;116;59m=\e[0m\e[38;2;127;113;58m=\e[0m\e[38;2;71;64;36m:\e[0m\e[38;2;37;37;40m.\e[0m\e[38;2;39;39;38m.\e[0m\e[38;2;38;38;38m.\e[0m\e[38;2;122;122;121m=\e[0m\e[38;2;204;204;204m%\e[0m\e[38;2;208;208;208m%\e[0m\e[38;2;116;116;115m=\e[0m\e[38;2;9;9;9m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\n" +
" \e[38;2;0;0;0m \e[0m\e[38;2;1;1;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;24;23;17m \e[0m\e[38;2;88;77;46m-\e[0m\e[38;2;142;123;67m=\e[0m\e[38;2;161;138;75m+\e[0m\e[38;2;143;121;65m=\e[0m\e[38;2;115;97;54m-\e[0m\e[38;2;95;80;46m-\e[0m\e[38;2;83;71;42m:\e[0m\e[38;2;79;67;40m:\e[0m\e[38;2;86;74;44m:\e[0m\e[38;2;62;54;30m:\e[0m\e[38;2;87;88;89m-\e[0m\e[38;2;160;160;159m*\e[0m\e[38;2;178;178;178m#\e[0m\e[38;2;156;156;156m*\e[0m\e[38;2;94;94;94m-\e[0m\e[38;2;22;22;22m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;1;1;1m \e[0m\e[38;2;0;0;0m \e[0m\n" +
" \e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;2;1;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;1m \e[0m\e[38;2;25;22;17m \e[0m\e[38;2;58;51;34m:\e[0m\e[38;2;87;75;47m:\e[0m\e[38;2;105;90;56m-\e[0m\e[38;2;115;98;60m-\e[0m\e[38;2;117;99;60m-\e[0m\e[38;2;115;99;60m-\e[0m\e[38;2;76;67;41m:\e[0m\e[38;2;57;58;60m:\e[0m\e[38;2;66;65;65m:\e[0m\e[38;2;23;23;23m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;2;2;2m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\n";
/// <summary>
/// Subtitle shown below the banner.
/// </summary>
public const string Subtitle = "Ultima Online Server Emulator";
}

View file

@ -0,0 +1,17 @@
namespace BuildTool;
public enum BuildAction
{
Publish,
Migrate
}
public sealed class BuildOptions
{
public BuildAction Action { get; set; } = BuildAction.Publish;
public string Config { get; set; } = "Release";
public string? Os { get; set; }
public string? Arch { get; set; }
public bool SkipPrereqs { get; set; }
public bool Interactive { get; set; }
}

View file

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>Preview</LangVersion>
<ImportDirectoryBuildProps>false</ImportDirectoryBuildProps>
<RootNamespace>BuildTool</RootNamespace>
<AssemblyName>build-tool</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<TrimMode>link</TrimMode>
<IsAotCompatible>true</IsAotCompatible>
<RuntimeIdentifiers>win-x64;win-arm64;osx-x64;osx-arm64;linux-x64;linux-arm64</RuntimeIdentifiers>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Spectre.Console" Version="0.54.0" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,25 @@
namespace BuildTool.Interactive;
/// <summary>
/// Tracks Ctrl+C press timing to detect double-press for exit.
/// </summary>
public sealed class CancellationTracker
{
private DateTime _lastCancelTime = DateTime.MinValue;
private const int DoublePressMs = 500;
/// <summary>
/// Checks if this is a double-press (within 500ms of last press).
/// Also updates the last press time.
/// </summary>
public bool IsDoublePress()
{
var now = DateTime.UtcNow;
var isDouble = (now - _lastCancelTime).TotalMilliseconds < DoublePressMs;
_lastCancelTime = now;
return isDouble;
}
/// <summary>Global instance for app-wide tracking.</summary>
public static CancellationTracker Instance { get; } = new();
}

View file

@ -0,0 +1,365 @@
using BuildTool.Platform;
using BuildTool.Prerequisites;
using BuildTool.Publishing;
using Spectre.Console;
namespace BuildTool.Interactive;
/// <summary>
/// Handles interactive mode when the BuildTool is run without arguments.
/// Provides a polished menu-driven experience with proper Ctrl+C navigation.
/// Uses ModernUO brand gold color palette.
/// </summary>
public static class GuidedMode
{
// Brand gold as markup string for inline use
private const string GoldMarkup = "rgb(213,191,116)";
private const string GoldLightMarkup = "rgb(223,198,136)";
/// <summary>
/// Tracks menu depth. 0 = root menu, >0 = submenu.
/// Used by ShowPrompt to automatically add Back option in submenus.
/// </summary>
private static int _menuDepth;
public static int Run(PlatformInfo platform, string repoRoot)
{
return ShowMainMenu(platform, repoRoot);
}
/// <summary>
/// Shows a selection prompt with automatic Back support.
/// When _menuDepth > 0, appends a ":left_arrow: Back" choice.
/// Returns null if the user chose Back or pressed Ctrl+C in a submenu.
/// At the root menu, Ctrl+C requires double-press to exit (returns null on double-press).
/// </summary>
private static string? ShowPrompt(string title, params string[] choices)
{
var allChoices = choices.Where(c => !string.IsNullOrEmpty(c)).ToList();
if (_menuDepth > 0)
{
allChoices.Add(":left_arrow: Back");
}
var prompt = new SelectionPrompt<string>()
.Title($"[{GoldMarkup}]{title}[/]")
.PageSize(10)
.HighlightStyle(Branding.HighlightStyle)
.AddChoices(allChoices);
string selection;
try
{
selection = prompt.Show(AnsiConsole.Console);
}
catch (OperationCanceledException)
{
InteractiveCancellation.Instance.Reset();
if (_menuDepth > 0)
{
return null; // Back
}
// Root menu: require double-press to exit
if (CancellationTracker.Instance.IsDoublePress())
{
AnsiConsole.MarkupLine("\n[grey]Exiting...[/]");
return ":cross_mark: Exit";
}
AnsiConsole.Write(new Text("\nPress Ctrl+C again to exit\n", Branding.DimGoldStyle));
Thread.Sleep(100);
return ""; // Signal to redraw menu
}
if (selection.Contains("Back"))
{
return null;
}
return selection;
}
private static int ShowMainMenu(PlatformInfo platform, string repoRoot)
{
while (true)
{
AnsiConsole.Clear();
// Write the true-color ANSI logo directly (bypasses Spectre markup parsing)
Console.Write(Branding.UOLogoAnsi);
AnsiConsole.Write(new Text($" {Branding.Subtitle}\n", Branding.DimGoldStyle));
AnsiConsole.WriteLine();
// Show environment info
var sdkVersion = GetDotNetSdkVersion();
var table = new Table()
.Border(TableBorder.Rounded)
.BorderColor(Branding.GoldDim)
.HideHeaders()
.AddColumn("Key")
.AddColumn("Value");
table.AddRow(
new Text("Platform", Branding.DimGoldStyle),
new Markup($"[white]{Markup.Escape(platform.OsName)} {platform.ArchRid}[/]")
);
table.AddRow(
new Text(".NET SDK", Branding.DimGoldStyle),
sdkVersion is not null
? new Markup($"[white]{Markup.Escape(sdkVersion)}[/]")
: new Markup("[red]Not found[/]")
);
if (platform.DistroName is not null)
{
table.AddRow(
new Text("Distribution", Branding.DimGoldStyle),
new Markup($"[white]{Markup.Escape(platform.DistroName)}[/]")
);
}
if (platform.KernelVersion is not null)
{
table.AddRow(
new Text("Kernel", Branding.DimGoldStyle),
new Markup($"[white]{Markup.Escape(platform.KernelVersion)}[/]")
);
}
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
var selection = ShowPrompt(
"What would you like to do?",
":hammer: Publish Server",
":stethoscope: Check Prerequisites",
"",
":cross_mark: Exit"
);
if (string.IsNullOrEmpty(selection))
{
continue; // Redraw (single Ctrl+C)
}
if (selection.Contains("Publish"))
{
_menuDepth++;
AnsiConsole.Clear();
AnsiConsole.MarkupLine($"[bold {GoldMarkup}]:hammer: PUBLISH SERVER[/]\n");
var result = RunPublish(platform, repoRoot);
_menuDepth--;
if (result is not null) // null = Back, skip WaitForKey
{
WaitForKey();
}
}
else if (selection.Contains("Prerequisites"))
{
_menuDepth++;
AnsiConsole.Clear();
AnsiConsole.MarkupLine($"[bold {GoldMarkup}]:stethoscope: PREREQUISITE CHECK[/]\n");
PrerequisiteChecker.CheckAll(platform, repoRoot, interactive: true);
_menuDepth--;
WaitForKey();
}
else if (selection.Contains("Exit"))
{
return 0;
}
}
}
/// <summary>
/// Returns null if user navigated Back to main menu, 0 on success, non-zero on error.
/// Uses a step loop so Back goes to the previous step, not the main menu.
/// </summary>
/// <summary>
/// Returns null if user navigated Back to main menu, 0 on success, non-zero on error.
/// Uses a step loop so Back goes to the previous step, not the main menu.
/// </summary>
private static int? RunPublish(PlatformInfo platform, string repoRoot)
{
// Step 0: SDK check (always needed for building)
if (!PrerequisiteChecker.CheckSdk(platform, repoRoot, interactive: true))
{
return 1;
}
var config = "Release";
var rid = platform.Rid;
var os = platform.OsRid;
var arch = platform.ArchRid;
// Steps 1+: interactive prompts with back navigation
var step = 1;
while (step <= 4)
{
switch (step)
{
case 1: // Configuration
{
var choice = ShowPrompt("Configuration:", "Release (Recommended)", "Debug");
if (choice is null)
{
return null; // Back from first prompt = back to main menu
}
config = choice.Contains("Release") ? "Release" : "Debug";
step++;
break;
}
case 2: // Platform confirmation
{
var choice = ShowPrompt(
$"Target platform: [{GoldLightMarkup}]{platform.Rid}[/] [grey](auto-detected)[/]",
$":check_mark: Use {platform.Rid}",
":wrench: Choose different platform"
);
if (choice is null)
{
step--;
break;
}
if (choice.Contains("Choose"))
{
step++;
}
else
{
rid = platform.Rid;
os = platform.OsRid;
step = 5; // Skip OS/arch selection, go to publish
}
break;
}
case 3: // OS selection (only if custom platform)
{
var choice = ShowPrompt(
"Operating System:",
"win (Windows)",
"osx (macOS)",
"linux (Linux)"
);
if (choice is null)
{
step--;
break;
}
os = choice.Split(' ')[0].Trim();
step++;
break;
}
case 4: // Architecture selection (only if custom platform)
{
var choice = ShowPrompt(
"Architecture:",
"x64 (Intel/AMD 64-bit)",
"arm64 (ARM 64-bit)"
);
if (choice is null)
{
step--;
break;
}
arch = choice.Split(' ')[0].Trim();
rid = $"{os}-{arch}";
step++;
break;
}
}
}
var isCrossCompile = os != platform.OsRid;
// Native library checks — only relevant when targeting the current OS
if (!isCrossCompile)
{
if (!PrerequisiteChecker.CheckNativeLibraries(platform, interactive: true))
{
return 1;
}
}
// Run publish
var exitCode = PublishOrchestrator.Run(config, rid, interactive: true, isCrossCompile);
if (exitCode == 0)
{
CheckFirstTimeSetup(repoRoot);
if (isCrossCompile)
{
DisplayTargetRequirements(os, rid);
}
}
return exitCode;
}
private static void DisplayTargetRequirements(string targetOs, string rid)
{
var (osName, commands) = NativeLibraryChecker.GetRequirementsForTarget(targetOs);
var rows = new List<Spectre.Console.Rendering.IRenderable>
{
new Markup($"[bold {GoldMarkup}]:clipboard: Prerequisites for {Markup.Escape(osName)} target ({rid})[/]"),
new Markup("")
};
for (var i = 0; i < commands.Length; i++)
{
var cmd = commands[i];
if (i > 0)
{
rows.Add(new Markup(""));
}
rows.Add(new Markup($" [grey]\u2022[/] {Markup.Escape(cmd)}"));
}
rows.Add(new Markup(""));
rows.Add(new Markup(""));
rows.Add(new Markup("[grey]Install these on the target machine, then copy the [/][rgb(213,191,116)]Distribution[/] [grey]folder to the server and run it.[/]"));
AnsiConsole.Write(new Panel(new Rows(rows))
.Border(BoxBorder.Rounded)
.BorderColor(Branding.GoldDim)
.Padding(1, 0));
AnsiConsole.WriteLine();
}
private static void CheckFirstTimeSetup(string repoRoot)
{
var configPath = Path.Combine(repoRoot, "Distribution", "Configuration", "modernuo.json");
if (!File.Exists(configPath))
{
AnsiConsole.WriteLine();
AnsiConsole.Write(new Text(
":light_bulb: Tip: The server will prompt you for game data file locations on first run.\n",
Branding.DimGoldStyle));
}
}
private static string? GetDotNetSdkVersion()
{
var result = ProcessRunner.RunCaptured("dotnet", "--version");
return result.Success ? result.StandardOutput.Trim() : null;
}
private static void WaitForKey()
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[grey]Press any key to continue...[/]");
Console.ReadKey(true);
}
}

View file

@ -0,0 +1,58 @@
namespace BuildTool.Interactive;
/// <summary>
/// Manages cancellation for interactive mode.
/// When Ctrl+C is pressed, the current token is cancelled, causing prompts to throw OperationCanceledException.
/// The token source is then reset so the next prompt can work.
/// </summary>
public sealed class InteractiveCancellation : IDisposable
{
private CancellationTokenSource _cts = new();
private readonly object _lock = new();
/// <summary>
/// Gets the current cancellation token to pass to prompts.
/// </summary>
public CancellationToken Token
{
get
{
lock (_lock)
{
return _cts.Token;
}
}
}
/// <summary>
/// Signals cancellation (called from Ctrl+C handler).
/// </summary>
public void Cancel()
{
lock (_lock)
{
_cts.Cancel();
}
}
/// <summary>
/// Resets the cancellation token source so new prompts can work.
/// Call this after catching OperationCanceledException.
/// </summary>
public void Reset()
{
lock (_lock)
{
_cts.Dispose();
_cts = new CancellationTokenSource();
}
}
/// <summary>Global instance for app-wide interactive cancellation.</summary>
public static InteractiveCancellation Instance { get; } = new();
public void Dispose()
{
_cts.Dispose();
}
}

View file

@ -0,0 +1,25 @@
using System.Text.Json.Serialization;
namespace BuildTool.Json;
public sealed class GlobalJson
{
[JsonPropertyName("sdk")]
public SdkConfig? Sdk { get; set; }
}
public sealed class SdkConfig
{
[JsonPropertyName("version")]
public string? Version { get; set; }
[JsonPropertyName("rollForward")]
public string? RollForward { get; set; }
[JsonPropertyName("allowPrerelease")]
public bool AllowPrerelease { get; set; }
}
[JsonSerializable(typeof(GlobalJson))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal partial class GlobalJsonContext : JsonSerializerContext;

View file

@ -0,0 +1,212 @@
using System.Runtime.InteropServices;
using BuildTool.Publishing;
namespace BuildTool.Platform;
public static class PlatformDetector
{
// Windows 11 starts at build 22000
private const int Windows11MinBuild = 22000;
public static PlatformInfo Detect()
{
var osRid = GetOsRid();
var archRid = GetArchRid();
var (distroId, distroName) = GetLinuxDistroInfo();
var kernelVersion = GetKernelVersion(osRid);
var osName = GetOsDisplayName(osRid, distroName, kernelVersion);
var packageManager = DetectPackageManager(osRid, distroId);
return new PlatformInfo
{
OsName = osName,
OsRid = osRid,
ArchRid = archRid,
DistroId = distroId,
DistroName = distroName,
KernelVersion = kernelVersion,
PackageManager = packageManager
};
}
private static string GetOsDisplayName(string osRid, string? distroName, string? kernelVersion)
{
if (OperatingSystem.IsWindows())
{
var version = Environment.OSVersion.Version;
var windowsVersion = version.Build >= Windows11MinBuild ? "11" : "10";
return $"Windows {windowsVersion} (Build {version.Build})";
}
if (OperatingSystem.IsMacOS())
{
var version = Environment.OSVersion.Version;
var macosName = GetMacOSCodename(version.Major);
return macosName is not null
? $"macOS {version.Major}.{version.Minor} {macosName}"
: $"macOS {version.Major}.{version.Minor}";
}
// Linux: show distro name with kernel version
if (distroName is not null && kernelVersion is not null)
{
return $"{distroName} (kernel {kernelVersion})";
}
return distroName ?? (kernelVersion is not null ? $"Linux (kernel {kernelVersion})" : "Linux");
}
private static string? GetMacOSCodename(int majorVersion) =>
majorVersion switch
{
15 => "Sequoia",
14 => "Sonoma",
13 => "Ventura",
12 => "Monterey",
_ => null
};
private static string GetOsRid()
{
if (OperatingSystem.IsWindows())
{
return "win";
}
if (OperatingSystem.IsMacOS())
{
return "osx";
}
return "linux";
}
private static string GetArchRid() =>
RuntimeInformation.ProcessArchitecture switch
{
Architecture.X64 => "x64",
Architecture.Arm64 => "arm64",
_ => "x64"
};
private static string? GetKernelVersion(string osRid)
{
if (osRid != "linux")
{
return null;
}
// Try /proc/version first (most reliable)
if (File.Exists("/proc/version"))
{
try
{
var procVersion = File.ReadAllText("/proc/version");
// Format: "Linux version 6.5.0-44-generic ..."
var parts = procVersion.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 3 && parts[0] == "Linux" && parts[1] == "version")
{
return parts[2];
}
}
catch
{
// Fall through to uname
}
}
// Fall back to uname -r
var result = ProcessRunner.RunCaptured("uname", "-r");
return result.Success ? result.StandardOutput.Trim() : null;
}
private static (string? Id, string? Name) GetLinuxDistroInfo()
{
if (!OperatingSystem.IsLinux())
{
return (null, null);
}
const string osReleasePath = "/etc/os-release";
if (!File.Exists(osReleasePath))
{
return (null, null);
}
string? id = null;
string? name = null;
foreach (var line in File.ReadLines(osReleasePath))
{
if (line.StartsWith("ID=", StringComparison.Ordinal))
{
id = line[3..].Trim('"');
}
else if (line.StartsWith("PRETTY_NAME=", StringComparison.Ordinal))
{
name = line[12..].Trim('"');
}
if (id is not null && name is not null)
{
break;
}
}
return (id, name);
}
private static PackageManager DetectPackageManager(string osRid, string? distroId)
{
if (osRid == "osx")
{
return PackageManager.Brew;
}
if (osRid != "linux")
{
return PackageManager.Unknown;
}
// Check distro ID first for accuracy
return distroId?.ToLowerInvariant() switch
{
"ubuntu" or "debian" or "linuxmint" or "pop" or "elementary" or "zorin" => PackageManager.Apt,
"fedora" or "centos" or "rhel" or "rocky" or "alma" or "ol" => PackageManager.Dnf,
"opensuse" or "opensuse-leap" or "opensuse-tumbleweed" or "sles" => PackageManager.Zypper,
"arch" or "manjaro" or "endeavouros" => PackageManager.Pacman,
"alpine" => PackageManager.Apk,
_ => DetectPackageManagerFromBinaries()
};
}
private static PackageManager DetectPackageManagerFromBinaries()
{
if (File.Exists("/usr/bin/apt-get") || File.Exists("/usr/bin/apt"))
{
return PackageManager.Apt;
}
if (File.Exists("/usr/bin/dnf"))
{
return PackageManager.Dnf;
}
if (File.Exists("/usr/bin/zypper"))
{
return PackageManager.Zypper;
}
if (File.Exists("/usr/bin/pacman"))
{
return PackageManager.Pacman;
}
if (File.Exists("/sbin/apk"))
{
return PackageManager.Apk;
}
return PackageManager.Unknown;
}
}

View file

@ -0,0 +1,28 @@
namespace BuildTool.Platform;
public enum PackageManager
{
Unknown,
Apt,
Dnf,
Zypper,
Pacman,
Apk,
Brew
}
public sealed class PlatformInfo
{
public required string OsName { get; init; }
public required string OsRid { get; init; }
public required string ArchRid { get; init; }
public string Rid => $"{OsRid}-{ArchRid}";
public string? DistroId { get; init; }
public string? DistroName { get; init; }
public string? KernelVersion { get; init; }
public PackageManager PackageManager { get; init; } = PackageManager.Unknown;
public bool IsWindows => OsRid == "win";
public bool IsMacOS => OsRid == "osx";
public bool IsLinux => OsRid == "linux";
}

View file

@ -0,0 +1,190 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using BuildTool.Json;
using BuildTool.Platform;
using BuildTool.Publishing;
using Spectre.Console;
namespace BuildTool.Prerequisites;
public static partial class DotNetSdkManager
{
private const string DotNetDownloadUrl = "https://dotnet.microsoft.com/download";
private const string InstallScriptUrlWindows = "https://dot.net/v1/dotnet-install.ps1";
private const string InstallScriptUrlUnix = "https://dot.net/v1/dotnet-install.sh";
public static PrerequisiteResult CheckSdk(string repoRoot)
{
var requiredVersionStr = ReadRequiredVersion(repoRoot) ?? "10.0.201";
if (!Version.TryParse(requiredVersionStr, out var requiredVersion))
{
requiredVersion = new Version(10, 0, 201);
}
// Check if dotnet is on PATH
var result = ProcessRunner.RunCaptured("dotnet", "--list-sdks");
if (!result.Success)
{
return new PrerequisiteResult
{
Name = ".NET SDK",
Passed = false,
Details = $".NET SDK {requiredVersionStr}+ is not installed",
DownloadUrl = DotNetDownloadUrl
};
}
// Parse installed SDK versions — find the best match
Version? bestVersion = null;
string? bestVersionStr = null;
foreach (var line in result.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var match = SdkVersionRegex().Match(line);
if (!match.Success)
{
continue;
}
var versionStr = match.Groups[1].Value;
if (!Version.TryParse(versionStr, out var version))
{
continue;
}
if (bestVersion is null || version > bestVersion)
{
bestVersion = version;
bestVersionStr = versionStr;
}
}
if (bestVersion is null || bestVersion < requiredVersion)
{
return new PrerequisiteResult
{
Name = ".NET SDK",
Passed = false,
Details = bestVersionStr is not null
? $".NET SDK {bestVersionStr} found, but {requiredVersionStr}+ is required"
: $".NET SDK {requiredVersionStr}+ is required but no SDK was found",
DownloadUrl = DotNetDownloadUrl
};
}
return new PrerequisiteResult
{
Name = ".NET SDK",
Passed = true,
Details = $".NET SDK {bestVersionStr}"
};
}
public static bool OfferInstall(PlatformInfo platform, bool interactive)
{
if (!interactive)
{
AnsiConsole.MarkupLine("[red]Error:[/] .NET SDK is not installed. Download from:");
AnsiConsole.MarkupLine($"[link]{DotNetDownloadUrl}[/]");
return false;
}
var install = AnsiConsole.Confirm(
"[yellow].NET SDK is required but not found.[/] Would you like to install it now?",
defaultValue: true
);
if (!install)
{
AnsiConsole.MarkupLine($"\nDownload the .NET SDK from: [link]{DotNetDownloadUrl}[/]");
return false;
}
return RunInstallScript(platform);
}
private static bool RunInstallScript(PlatformInfo platform)
{
AnsiConsole.MarkupLine("\n[blue]Installing .NET SDK...[/]");
if (platform.IsWindows)
{
return RunWindowsInstall();
}
return RunUnixInstall();
}
private static bool RunWindowsInstall()
{
// Download and run the official Microsoft install script
var result = ProcessRunner.RunPassthrough(
"powershell",
$"-NoProfile -ExecutionPolicy Bypass -Command \"& {{ " +
$"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; " +
$"$script = Invoke-WebRequest -Uri '{InstallScriptUrlWindows}' -UseBasicParsing; " +
$"$scriptBlock = [scriptblock]::Create($script.Content); " +
$"& $scriptBlock -Channel 10.0 }}\""
);
if (result != 0)
{
AnsiConsole.MarkupLine("[red]Failed to install .NET SDK.[/]");
AnsiConsole.MarkupLine($"Please install manually from: [link]{DotNetDownloadUrl}[/]");
return false;
}
AnsiConsole.MarkupLine("[green]Successfully installed .NET SDK.[/]");
AnsiConsole.MarkupLine("[yellow]Note:[/] You may need to restart your terminal for the PATH to update.");
return true;
}
private static bool RunUnixInstall()
{
// Download and run the official Microsoft install script
var result = ProcessRunner.RunPassthrough(
"bash",
$"-c \"curl -fsSL {InstallScriptUrlUnix} | bash -s -- --channel 10.0\""
);
if (result != 0)
{
AnsiConsole.MarkupLine("[red]Failed to install .NET SDK.[/]");
AnsiConsole.MarkupLine($"Please install manually from: [link]{DotNetDownloadUrl}[/]");
return false;
}
AnsiConsole.MarkupLine("[green]Successfully installed .NET SDK.[/]");
AnsiConsole.MarkupLine("[yellow]Note:[/] You may need to add ~/.dotnet to your PATH or restart your terminal.");
return true;
}
private static string? ReadRequiredVersion(string repoRoot)
{
var globalJsonPath = Path.Combine(repoRoot, "global.json");
if (!File.Exists(globalJsonPath))
{
return null;
}
var json = File.ReadAllText(globalJsonPath);
var globalJson = JsonSerializer.Deserialize(json, GlobalJsonContext.Default.GlobalJson);
return globalJson?.Sdk?.Version;
}
// Kept for reference but no longer used — Version.TryParse handles comparison now
private static int ParseMajorVersion(string version)
{
var dotIndex = version.IndexOf('.');
if (dotIndex <= 0)
{
return 0;
}
return int.TryParse(version.AsSpan(0, dotIndex), out var major) ? major : 0;
}
[GeneratedRegex(@"^(\d+\.\d+\.\d+\S*)")]
private static partial Regex SdkVersionRegex();
}

View file

@ -0,0 +1,309 @@
using BuildTool.Platform;
using BuildTool.Publishing;
namespace BuildTool.Prerequisites;
public static class NativeLibraryChecker
{
/// <summary>
/// Returns the required prerequisites for a target OS (without checking the local machine).
/// Used to inform users what they need to install on the deployment target after cross-compiling.
/// </summary>
public static (string Description, string[] InstallCommands) GetRequirementsForTarget(string targetOs)
{
return targetOs switch
{
"win" => (
"Windows",
[
".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0",
"VC++ Redistributable v14 — https://aka.ms/vs/17/release/vc_redist.x64.exe"
]
),
"osx" => (
"macOS",
[
".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0",
"brew install icu4c libdeflate zstd argon2"
]
),
"linux" => (
"Linux",
[
".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0",
"Debian/Ubuntu: sudo apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev liburing-dev",
"Fedora/RHEL: sudo dnf install -y libicu libdeflate-devel zstd libargon2-devel liburing-devel",
"CentOS: Also requires epel-release and CRB enabled"
]
),
_ => ("Unknown", [".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0"])
};
}
public static List<PrerequisiteResult> Check(PlatformInfo platform)
{
if (platform.IsWindows)
{
return CheckWindows(platform);
}
if (platform.IsMacOS)
{
return CheckMacOS();
}
if (platform.IsLinux)
{
return CheckLinux(platform);
}
return [];
}
private static List<PrerequisiteResult> CheckWindows(PlatformInfo platform)
{
var results = new List<PrerequisiteResult>();
// Check VC++ Redistributable via registry
var vcRedistInstalled = CheckVcRedist(platform.ArchRid);
var downloadUrl = platform.ArchRid == "arm64"
? "https://aka.ms/vs/17/release/vc_redist.arm64.exe"
: "https://aka.ms/vs/17/release/vc_redist.x64.exe";
results.Add(new PrerequisiteResult
{
Name = "VC++ Redistributable v14",
Passed = vcRedistInstalled,
Details = vcRedistInstalled ? "Installed" : "Not found",
DownloadUrl = vcRedistInstalled ? null : downloadUrl
});
return results;
}
private static bool CheckVcRedist(string arch)
{
if (!OperatingSystem.IsWindows())
{
return false;
}
// Check multiple known registry paths for VC++ 14.x Redistributable
string[] registryPaths =
[
$@"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\{arch}",
$@"SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\{arch}"
];
foreach (var path in registryPaths)
{
try
{
using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(path);
if (key?.GetValue("Installed") is int installed && installed == 1)
{
return true;
}
}
catch
{
// Registry access may fail, continue checking
}
}
return false;
}
private static List<PrerequisiteResult> CheckMacOS()
{
var results = new List<PrerequisiteResult>();
// Check if Homebrew is installed
var brewResult = ProcessRunner.RunCaptured("which", "brew");
if (!brewResult.Success)
{
results.Add(new PrerequisiteResult
{
Name = "Homebrew",
Passed = false,
Details = "Homebrew is required to install native dependencies",
DownloadUrl = "https://brew.sh"
});
return results;
}
// Check required Homebrew formulae
var formulae = new[] { "icu4c", "libdeflate", "zstd", "argon2" };
var listResult = ProcessRunner.RunCaptured("brew", "list --formula");
var installedFormulae = listResult.Success
? new HashSet<string>(
listResult.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries),
StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var missing = new List<string>();
foreach (var formula in formulae)
{
var installed = installedFormulae.Contains(formula);
if (!installed)
{
missing.Add(formula);
}
results.Add(new PrerequisiteResult
{
Name = formula,
Passed = installed,
Details = installed ? "Installed" : "Not installed",
InstallCommand = installed ? null : $"brew install {formula}"
});
}
if (missing.Count > 0)
{
results.Add(new PrerequisiteResult
{
Name = "Install all missing",
Passed = false,
IsWarning = true,
Details = "Run the following command to install all missing dependencies:",
InstallCommand = $"brew install {string.Join(' ', missing)}"
});
}
return results;
}
private static List<PrerequisiteResult> CheckLinux(PlatformInfo platform)
{
return platform.PackageManager switch
{
PackageManager.Apt => CheckLinuxApt(),
PackageManager.Dnf => CheckLinuxDnf(platform),
_ => CheckLinuxGeneric(platform)
};
}
private static List<PrerequisiteResult> CheckLinuxApt()
{
var results = new List<PrerequisiteResult>();
var packages = new[] { "libicu-dev", "libdeflate-dev", "zstd", "libargon2-dev", "liburing-dev" };
var missing = new List<string>();
foreach (var package in packages)
{
var result = ProcessRunner.RunCaptured("dpkg", $"-l {package}");
var installed = result.Success && result.StandardOutput.Contains("ii");
if (!installed)
{
missing.Add(package);
}
results.Add(new PrerequisiteResult
{
Name = package,
Passed = installed,
Details = installed ? "Installed" : "Not installed"
});
}
if (missing.Count > 0)
{
results.Add(new PrerequisiteResult
{
Name = "Install all missing",
Passed = false,
IsWarning = true,
Details = "Run the following command to install all missing dependencies:",
InstallCommand = $"sudo apt-get install -y {string.Join(' ', missing)}"
});
}
return results;
}
private static List<PrerequisiteResult> CheckLinuxDnf(PlatformInfo platform)
{
var results = new List<PrerequisiteResult>();
var packages = new[] { "libicu", "libdeflate-devel", "zstd", "libargon2-devel", "liburing-devel" };
var missing = new List<string>();
foreach (var package in packages)
{
var result = ProcessRunner.RunCaptured("rpm", $"-q {package}");
var installed = result.Success;
if (!installed)
{
missing.Add(package);
}
results.Add(new PrerequisiteResult
{
Name = package,
Passed = installed,
Details = installed ? "Installed" : "Not installed"
});
}
// Check if this is CentOS (needs EPEL)
var isCentOs = platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true;
if (isCentOs && missing.Count > 0)
{
results.Add(new PrerequisiteResult
{
Name = "EPEL Repository",
Passed = false,
IsWarning = true,
Details = "CentOS requires EPEL for some packages. Enable it first:",
InstallCommand = "sudo dnf install -y epel-release epel-next-release && sudo dnf config-manager --set-enabled crb"
});
}
if (missing.Count > 0)
{
results.Add(new PrerequisiteResult
{
Name = "Install all missing",
Passed = false,
IsWarning = true,
Details = "Run the following command to install all missing dependencies:",
InstallCommand = $"sudo dnf install -y {string.Join(' ', missing)}"
});
}
return results;
}
private static List<PrerequisiteResult> CheckLinuxGeneric(PlatformInfo platform)
{
var results = new List<PrerequisiteResult>();
// Use ldconfig to check for shared libraries
var ldResult = ProcessRunner.RunCaptured("ldconfig", "-p");
var ldOutput = ldResult.Success ? ldResult.StandardOutput : "";
var libraries = new Dictionary<string, string>
{
["libicu"] = "libicuuc",
["libdeflate"] = "libdeflate",
["zstd"] = "libzstd",
["libargon2"] = "libargon2",
["liburing"] = "liburing"
};
foreach (var (name, soName) in libraries)
{
var found = ldOutput.Contains(soName, StringComparison.OrdinalIgnoreCase);
results.Add(new PrerequisiteResult
{
Name = name,
Passed = found,
Details = found ? "Found" : "Not found — install using your package manager"
});
}
return results;
}
}

View file

@ -0,0 +1,165 @@
using BuildTool.Platform;
using Spectre.Console;
namespace BuildTool.Prerequisites;
public static class PrerequisiteChecker
{
/// <summary>
/// Runs all prerequisite checks (SDK + native libraries) and displays results.
/// Returns true if all critical prerequisites pass.
/// </summary>
public static bool CheckAll(PlatformInfo platform, string repoRoot, bool interactive)
{
var panel = new Panel("[bold]Checking prerequisites[/]")
.Border(BoxBorder.Rounded)
.BorderColor(Branding.Gold)
.Padding(1, 0);
AnsiConsole.Write(panel);
AnsiConsole.WriteLine();
if (!CheckSdkInternal(platform, repoRoot, interactive))
{
return false;
}
return CheckNativeLibrariesInternal(platform, interactive);
}
/// <summary>
/// Checks only the .NET SDK (sufficient for cross-compilation).
/// Returns true if the SDK is available.
/// </summary>
public static bool CheckSdk(PlatformInfo platform, string repoRoot, bool interactive)
{
var panel = new Panel("[bold]Checking .NET SDK[/]")
.Border(BoxBorder.Rounded)
.BorderColor(Branding.Gold)
.Padding(1, 0);
AnsiConsole.Write(panel);
AnsiConsole.WriteLine();
var passed = CheckSdkInternal(platform, repoRoot, interactive);
AnsiConsole.WriteLine();
return passed;
}
/// <summary>
/// Checks native libraries for the current platform.
/// Call this only when the publish target matches the current OS.
/// Returns true if all pass or the user chose to continue.
/// </summary>
public static bool CheckNativeLibraries(PlatformInfo platform, bool interactive)
{
AnsiConsole.Write(new Panel("[bold]Checking native libraries[/]")
.Border(BoxBorder.Rounded)
.BorderColor(Branding.Gold)
.Padding(1, 0));
AnsiConsole.WriteLine();
var passed = CheckNativeLibrariesInternal(platform, interactive);
AnsiConsole.WriteLine();
return passed;
}
private static bool CheckSdkInternal(PlatformInfo platform, string repoRoot, bool interactive)
{
var sdkResult = DotNetSdkManager.CheckSdk(repoRoot);
DisplayResult(sdkResult);
if (!sdkResult.Passed)
{
var installed = DotNetSdkManager.OfferInstall(platform, interactive);
if (!installed)
{
return false;
}
sdkResult = DotNetSdkManager.CheckSdk(repoRoot);
if (!sdkResult.Passed)
{
AnsiConsole.MarkupLine("[red]SDK installation may require a terminal restart.[/]");
return false;
}
DisplayResult(sdkResult);
}
return true;
}
private static bool CheckNativeLibrariesInternal(PlatformInfo platform, bool interactive)
{
var nativeResults = NativeLibraryChecker.Check(platform);
var hasMissing = false;
foreach (var result in nativeResults)
{
if (result.IsWarning)
{
continue;
}
DisplayResult(result);
if (!result.Passed)
{
hasMissing = true;
}
}
if (hasMissing)
{
AnsiConsole.WriteLine();
foreach (var result in nativeResults)
{
if (!result.IsWarning)
{
continue;
}
if (result.InstallCommand is not null)
{
AnsiConsole.MarkupLine($" [yellow]:warning: {result.Details}[/]");
AnsiConsole.MarkupLine($" [white on grey23] {result.InstallCommand} [/]");
AnsiConsole.WriteLine();
}
}
if (interactive)
{
var continueAnyway = AnsiConsole.Confirm(
"[yellow]Some prerequisites are missing. Continue anyway?[/]",
defaultValue: false
);
if (!continueAnyway)
{
return false;
}
}
return interactive; // In interactive mode, user chose to continue
}
AnsiConsole.MarkupLine("[green]:check_mark_button: All prerequisites satisfied.[/]");
return true;
}
private static void DisplayResult(PrerequisiteResult result)
{
if (result.IsWarning)
{
return;
}
var icon = result.Passed ? "[green]:check_mark:[/]" : "[red]:cross_mark:[/]";
var details = result.Details is not null ? $" [grey]({Markup.Escape(result.Details)})[/]" : "";
AnsiConsole.MarkupLine($" {icon} {Markup.Escape(result.Name)}{details}");
if (!result.Passed && result.DownloadUrl is not null)
{
AnsiConsole.MarkupLine($" [grey]Download: {result.DownloadUrl}[/]");
}
}
}

View file

@ -0,0 +1,11 @@
namespace BuildTool.Prerequisites;
public sealed class PrerequisiteResult
{
public required string Name { get; init; }
public required bool Passed { get; init; }
public string? Details { get; init; }
public string? InstallCommand { get; init; }
public string? DownloadUrl { get; init; }
public bool IsWarning { get; init; }
}

View file

@ -0,0 +1,173 @@
using System.Text;
using BuildTool;
using BuildTool.Interactive;
using BuildTool.Platform;
using BuildTool.Prerequisites;
using BuildTool.Publishing;
Console.OutputEncoding = Encoding.UTF8;
var repoRoot = FindRepoRoot();
if (repoRoot is null)
{
Console.Error.WriteLine("Error: Could not find ModernUO.slnx. Run this tool from the repository root.");
return 1;
}
var options = ParseArguments(args);
// Interactive mode: no args provided
if (options.Interactive)
{
// Handle Ctrl+C by signaling our cancellation token (not killing the process)
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
InteractiveCancellation.Instance.Cancel();
};
var platform = PlatformDetector.Detect();
return GuidedMode.Run(platform, repoRoot);
}
// Non-interactive mode
var detectedPlatform = PlatformDetector.Detect();
options.Os ??= detectedPlatform.OsRid;
options.Arch ??= detectedPlatform.ArchRid;
var rid = $"{options.Os}-{options.Arch}";
// Run prerequisite checks unless skipped
if (!options.SkipPrereqs)
{
var sdkResult = DotNetSdkManager.CheckSdk(repoRoot);
if (!sdkResult.Passed)
{
Console.Error.WriteLine($"Error: {sdkResult.Details}");
if (sdkResult.DownloadUrl is not null)
{
Console.Error.WriteLine($"Download: {sdkResult.DownloadUrl}");
}
return 1;
}
}
return options.Action switch
{
BuildAction.Publish => PublishOrchestrator.Run(options.Config, rid, interactive: false),
BuildAction.Migrate => SchemaMigrator.Run(interactive: false),
_ => 1
};
static BuildOptions ParseArguments(string[] args)
{
var options = new BuildOptions();
if (args.Length == 0)
{
options.Interactive = true;
return options;
}
// Check for named arguments first
var hasNamedArgs = false;
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--config" when i + 1 < args.Length:
{
options.Config = NormalizeConfig(args[++i]);
hasNamedArgs = true;
break;
}
case "--os" when i + 1 < args.Length:
{
options.Os = args[++i].ToLowerInvariant();
hasNamedArgs = true;
break;
}
case "--arch" when i + 1 < args.Length:
{
options.Arch = args[++i].ToLowerInvariant();
hasNamedArgs = true;
break;
}
case "--action" when i + 1 < args.Length:
{
options.Action = args[++i].ToLowerInvariant() switch
{
"migrate" => BuildAction.Migrate,
_ => BuildAction.Publish
};
hasNamedArgs = true;
break;
}
case "--skip-prereqs":
{
options.SkipPrereqs = true;
hasNamedArgs = true;
break;
}
case "--interactive":
{
options.Interactive = true;
hasNamedArgs = true;
break;
}
}
}
if (hasNamedArgs)
{
return options;
}
// Positional argument parsing (backward compat: [config] [os] [arch])
if (args.Length >= 1)
{
options.Config = NormalizeConfig(args[0]);
}
if (args.Length >= 2)
{
options.Os = args[1].ToLowerInvariant();
}
if (args.Length >= 3)
{
options.Arch = args[2].ToLowerInvariant();
}
return options;
}
static string NormalizeConfig(string config) =>
config.ToLowerInvariant() switch
{
"release" => "Release",
"debug" => "Debug",
_ => char.ToUpperInvariant(config[0]) + config[1..].ToLowerInvariant()
};
static string? FindRepoRoot()
{
// Check current directory first
var current = Directory.GetCurrentDirectory();
if (File.Exists(Path.Combine(current, "ModernUO.slnx")))
{
return current;
}
// Walk up to find the solution file
var dir = new DirectoryInfo(current);
while (dir?.Parent is not null)
{
dir = dir.Parent;
if (File.Exists(Path.Combine(dir.FullName, "ModernUO.slnx")))
{
return dir.FullName;
}
}
return null;
}

View file

@ -0,0 +1,95 @@
using System.Diagnostics;
using System.Text;
namespace BuildTool.Publishing;
public sealed class ProcessResult
{
public required int ExitCode { get; init; }
public required string StandardOutput { get; init; }
public required string StandardError { get; init; }
public bool Success => ExitCode == 0;
}
public static class ProcessRunner
{
/// <summary>
/// Runs a command with output captured (for parsing results).
/// </summary>
public static ProcessResult RunCaptured(string fileName, string arguments, string? workingDirectory = null)
{
var stdout = new StringBuilder();
var stderr = new StringBuilder();
var psi = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
WorkingDirectory = workingDirectory ?? Directory.GetCurrentDirectory(),
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi);
if (process is null)
{
return new ProcessResult
{
ExitCode = -1,
StandardOutput = "",
StandardError = $"Failed to start process: {fileName}"
};
}
process.OutputDataReceived += (_, e) =>
{
if (e.Data is not null)
{
stdout.AppendLine(e.Data);
}
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data is not null)
{
stderr.AppendLine(e.Data);
}
};
process.BeginOutputReadLine();
process.BeginErrorReadLine();
process.WaitForExit();
return new ProcessResult
{
ExitCode = process.ExitCode,
StandardOutput = stdout.ToString(),
StandardError = stderr.ToString()
};
}
/// <summary>
/// Runs a command with output passed through to the console (for build commands in non-interactive mode).
/// </summary>
public static int RunPassthrough(string fileName, string arguments, string? workingDirectory = null)
{
var psi = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
WorkingDirectory = workingDirectory ?? Directory.GetCurrentDirectory(),
UseShellExecute = false
};
using var process = Process.Start(psi);
if (process is null)
{
return -1;
}
process.WaitForExit();
return process.ExitCode;
}
}

View file

@ -0,0 +1,182 @@
using BuildTool.Platform;
using Spectre.Console;
namespace BuildTool.Publishing;
public static class PublishOrchestrator
{
// Target the Application project (not the solution) to avoid building BuildTool and test projects
private const string AppProject = "Projects/Application/Application.csproj";
private static readonly (string Description, string Command, string Arguments)[] BuildSteps =
[
("Restoring tools", "dotnet", "tool restore"),
("Cleaning project", "dotnet", $"clean {AppProject} --verbosity quiet"),
("Restoring packages", "dotnet", $"restore {AppProject} --force-evaluate --source https://api.nuget.org/v3/index.json"),
];
public static int Run(string config, PlatformInfo platform, bool interactive)
{
return Run(config, platform.Rid, interactive);
}
public static int Run(string config, string rid, bool interactive, bool isCrossCompile = false)
{
if (interactive)
{
return RunInteractive(config, rid, isCrossCompile);
}
return RunNonInteractive(config, rid);
}
private static int RunInteractive(string config, string rid, bool isCrossCompile)
{
var panel = new Panel($"[bold]Publishing[/] [rgb(223,198,136)]{config}[/] for [rgb(223,198,136)]{rid}[/]")
.Border(BoxBorder.Rounded)
.BorderColor(Branding.Gold)
.Padding(1, 0);
AnsiConsole.Write(panel);
AnsiConsole.WriteLine();
var allSteps = new List<(string Description, string Command, string Arguments)>(BuildSteps)
{
($"Publishing ({config}, {rid})", "dotnet", $"publish {AppProject} -c {config} -r {rid} --no-restore --self-contained=false"),
("Generating serialization schema", "dotnet", "tool run ModernUOSchemaGenerator -- ModernUO.slnx")
};
var completed = 0;
var total = allSteps.Count;
foreach (var (description, command, arguments) in allSteps)
{
completed++;
var exitCode = RunStepInteractive(description, command, arguments, completed, total);
if (exitCode != 0)
{
AnsiConsole.WriteLine();
AnsiConsole.Write(new Panel("[red bold]Build failed.[/] See error output above for details.")
.Border(BoxBorder.Rounded)
.BorderColor(Color.Red)
.Padding(1, 0));
return exitCode;
}
}
AnsiConsole.WriteLine();
DisplaySuccess(rid, isCrossCompile);
return 0;
}
private static int RunStepInteractive(string description, string command, string arguments, int step, int total)
{
var exitCode = 0;
string errorOutput = "";
AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Branding.GoldStyle)
.Start($"[grey]({step}/{total})[/] {description}...", _ =>
{
var result = ProcessRunner.RunCaptured(command, arguments);
exitCode = result.ExitCode;
errorOutput = result.StandardError;
});
if (exitCode == 0)
{
AnsiConsole.MarkupLine($" [green]:check_mark:[/] [grey]({step}/{total})[/] {description}");
}
else
{
AnsiConsole.MarkupLine($" [red]:cross_mark:[/] [grey]({step}/{total})[/] {description} [red](failed)[/]");
if (!string.IsNullOrWhiteSpace(errorOutput))
{
AnsiConsole.WriteLine();
AnsiConsole.Write(new Panel(Markup.Escape(errorOutput.Trim()))
.Header("[red]Error Output[/]")
.Border(BoxBorder.Rounded)
.BorderColor(Color.Red)
.Expand());
}
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($" [grey]Command: {command} {arguments}[/]");
}
return exitCode;
}
private static int RunNonInteractive(string config, string rid)
{
// Run common build steps
foreach (var (description, command, arguments) in BuildSteps)
{
Console.WriteLine($"{command} {arguments}");
var exitCode = ProcessRunner.RunPassthrough(command, arguments);
if (exitCode != 0)
{
Console.Error.WriteLine($"Error: '{description}' failed with exit code {exitCode}");
return exitCode;
}
}
// Publish step
{
var publishArgs = $"publish {AppProject} -c {config} -r {rid} --no-restore --self-contained=false";
Console.WriteLine($"dotnet {publishArgs}");
var exitCode = ProcessRunner.RunPassthrough("dotnet", publishArgs);
if (exitCode != 0)
{
Console.Error.WriteLine($"Error: 'publish' failed with exit code {exitCode}");
return exitCode;
}
}
// Schema generation
{
Console.WriteLine("Generating serialization migration schema...");
const string schemaArgs = "tool run ModernUOSchemaGenerator -- ModernUO.slnx";
var exitCode = ProcessRunner.RunPassthrough("dotnet", schemaArgs);
if (exitCode != 0)
{
Console.Error.WriteLine($"Error: schema generation failed with exit code {exitCode}");
return exitCode;
}
}
return 0;
}
internal static void DisplaySuccess(string rid, bool isCrossCompile = false)
{
var isWindows = rid.StartsWith("win", StringComparison.OrdinalIgnoreCase);
var runCommand = isWindows ? "ModernUO.exe" : "dotnet ModernUO.dll";
var rows = new List<Spectre.Console.Rendering.IRenderable>
{
new Markup("[green bold]:check_mark_button: Build complete![/]"),
new Markup("")
};
if (isCrossCompile)
{
rows.Add(new Markup($"Copy the [rgb(213,191,116)]Distribution[/] folder to your [rgb(213,191,116)]{rid}[/] server, then run:"));
}
else
{
rows.Add(new Markup("Run the server from the [rgb(213,191,116)]Distribution[/] directory:"));
}
rows.Add(new Markup(""));
rows.Add(new Markup(" [white on grey23] cd Distribution [/]"));
rows.Add(new Markup($" [white on grey23] {runCommand} [/]"));
AnsiConsole.Write(new Panel(new Rows(rows))
.Border(BoxBorder.Rounded)
.BorderColor(Color.Green)
.Padding(1, 0));
AnsiConsole.WriteLine();
}
}

View file

@ -0,0 +1,126 @@
using Spectre.Console;
namespace BuildTool.Publishing;
public static class SchemaMigrator
{
public static int Run(bool interactive)
{
if (interactive)
{
return RunInteractive();
}
return RunNonInteractive();
}
private static int RunInteractive()
{
var panel = new Panel("[bold]Running schema migration[/]")
.Border(BoxBorder.Rounded)
.BorderColor(Branding.Gold)
.Padding(1, 0);
AnsiConsole.Write(panel);
AnsiConsole.WriteLine();
// Tool restore
{
var exitCode = 0;
string errorOutput = "";
AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Branding.GoldStyle)
.Start("[grey](1/2)[/] Restoring tools...", _ =>
{
var result = ProcessRunner.RunCaptured("dotnet", "tool restore");
exitCode = result.ExitCode;
errorOutput = result.StandardError;
});
if (exitCode == 0)
{
AnsiConsole.MarkupLine(" [green]:check_mark:[/] [grey](1/2)[/] Tools restored");
}
else
{
AnsiConsole.MarkupLine(" [red]:cross_mark:[/] [grey](1/2)[/] Tool restore [red](failed)[/]");
if (!string.IsNullOrWhiteSpace(errorOutput))
{
AnsiConsole.Write(new Panel(Markup.Escape(errorOutput.Trim()))
.Header("[red]Error[/]")
.Border(BoxBorder.Rounded)
.BorderColor(Color.Red));
}
return exitCode;
}
}
// Schema generation
{
var exitCode = 0;
string errorOutput = "";
AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Branding.GoldStyle)
.Start("[grey](2/2)[/] Generating serialization schema...", _ =>
{
var result = ProcessRunner.RunCaptured(
"dotnet",
"tool run ModernUOSchemaGenerator -- ModernUO.slnx"
);
exitCode = result.ExitCode;
errorOutput = result.StandardError;
});
if (exitCode == 0)
{
AnsiConsole.MarkupLine(" [green]:check_mark:[/] [grey](2/2)[/] Schema generated");
}
else
{
AnsiConsole.MarkupLine(" [red]:cross_mark:[/] [grey](2/2)[/] Schema generation [red](failed)[/]");
if (!string.IsNullOrWhiteSpace(errorOutput))
{
AnsiConsole.Write(new Panel(Markup.Escape(errorOutput.Trim()))
.Header("[red]Error[/]")
.Border(BoxBorder.Rounded)
.BorderColor(Color.Red));
}
return exitCode;
}
}
AnsiConsole.WriteLine();
AnsiConsole.Write(new Panel("[green bold]:check_mark_button: Schema migration complete.[/]")
.Border(BoxBorder.Rounded)
.BorderColor(Color.Green)
.Padding(1, 0));
AnsiConsole.WriteLine();
return 0;
}
private static int RunNonInteractive()
{
Console.WriteLine("dotnet tool restore");
var exitCode = ProcessRunner.RunPassthrough("dotnet", "tool restore");
if (exitCode != 0)
{
Console.Error.WriteLine($"Error: tool restore failed with exit code {exitCode}");
return exitCode;
}
Console.WriteLine("Generating serialization migration schema...");
exitCode = ProcessRunner.RunPassthrough("dotnet", "tool run ModernUOSchemaGenerator -- ModernUO.slnx");
if (exitCode != 0)
{
Console.Error.WriteLine($"Error: schema generation failed with exit code {exitCode}");
return exitCode;
}
return 0;
}
}

View file

@ -35,12 +35,12 @@
<ItemGroup>
<ProjectReference Include="..\Logger\Logger.csproj" />
<PackageReference Include="IORingGroup" Version="1.0.6" />
<PackageReference Include="CommunityToolkit.HighPerformance" Version="8.4.0" />
<PackageReference Include="CommunityToolkit.HighPerformance" Version="8.4.2" />
<PackageReference Include="LibDeflate.Bindings" Version="1.0.3" />
<PackageReference Include="System.IO.Hashing" Version="10.0.3" />
<PackageReference Include="System.IO.Hashing" Version="10.0.5" />
<PackageReference Include="ModernUO.Serialization.Annotations" Version="2.14.2" />
<PackageReference Include="ModernUO.Serialization.Generator" Version="2.14.2" />
<PackageReference Include="ModernUO.Serialization.Generator" Version="2.14.3" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Migrations/*.v*.json" />

View file

@ -39,15 +39,15 @@
</ProjectReference>
<PackageReference Include="LibDeflate.Bindings" Version="1.0.3" />
<PackageReference Include="MailKit" Version="4.15.1" />
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.3" />
<PackageReference Include="CommunityToolkit.HighPerformance" Version="8.4.0" />
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.5" />
<PackageReference Include="CommunityToolkit.HighPerformance" Version="8.4.2" />
<PackageReference Include="Argon2.Bindings" Version="1.17.0" />
<PackageReference Include="ModernUO.CodeGeneratedEvents.Annotations" Version="1.0.0" />
<PackageReference Include="ModernUO.CodeGeneratedEvents.Generator" Version="1.0.3.2" PrivateAssets="all" />
<PackageReference Include="ZstdNet" Version="1.5.7" />
<PackageReference Include="ModernUO.Serialization.Annotations" Version="2.14.2" />
<PackageReference Include="ModernUO.Serialization.Generator" Version="2.14.2" />
<PackageReference Include="ModernUO.Serialization.Generator" Version="2.14.3" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Migrations/*.v*.json" />

View file

@ -56,14 +56,25 @@ ModernUO [![Discord](https://img.shields.io/discord/751317910504603701?logo=disc
- Open `ModernUO.sln` to start developing
## Building/Publishing
- Run `./publish.cmd [release|debug (default: release)] [os] [arch (default: x64)]`
- `os` - [Supported operating systems](https://github.com/dotnet/core/blob/main/release-notes/10.0/supported-os.md)
#### Interactive Mode (Recommended for new users)
Run `./publish.cmd` (Windows) or `./publish.sh` (Linux/macOS) with no arguments to launch the guided build tool. It will:
- Check prerequisites (.NET SDK, native libraries)
- Walk you through configuration and platform selection
- Build and publish the server to the `Distribution` directory
- Show deployment instructions for cross-compiled builds
#### Command Line
```shell
./publish.cmd [release|debug] [os] [arch]
```
- `os` - [Supported operating systems](https://github.com/dotnet/core/blob/main/release-notes/10.0/supported-os.md)
- `win` - [Windows](https://learn.microsoft.com/en-us/dotnet/core/install/windows)
- `osx` - [MacOS](https://learn.microsoft.com/en-us/dotnet/core/install/macos)
- `osx` - [macOS](https://learn.microsoft.com/en-us/dotnet/core/install/macos)
- `linux` - [Linux](https://learn.microsoft.com/en-us/dotnet/core/install/linux)
- `arch`
- `arch`
- `x64` - Intel/AMD 64-bit
- `arm64` - ARM 64-bit (Windows not supported)
- `arm64` - ARM 64-bit
## Linux Prerequisites
### Fedora, CentOS, RHEL, etc
@ -87,7 +98,9 @@ brew install icu4c libdeflate zstd argon2
## Running the Server
- Follow the [publish](https://github.com/modernuo/ModernUO#buildingpublishing) instructions
- The `Distribution` directory is portable — copy it to your production server for deployment
- Run `ModernUO.exe` or `dotnet ModernUO.dll` from the `Distribution` directory
- On first run, the server will prompt you to configure game data file locations
## Troubleshooting / FAQ
- See [FAQ](./FAQ.md)

View file

@ -18,7 +18,7 @@ jobs:
inputs:
useGlobalJson: true
- task: NuGetAuthenticate@1
- script: ./publish.cmd Release
- script: dotnet run --project Projects\BuildTool -- --config Release --skip-prereqs
displayName: 'Build'
- powershell: ./.github/porcelain.ps1
displayName: Migration Changes

View file

@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"version": "10.0.201",
"rollForward": "latestMajor",
"allowPrerelease": false
}

View file

@ -1,46 +1,9 @@
:<<"::SHELLSCRIPT"
@ECHO OFF
GOTO :CMDSCRIPT
powershell -ExecutionPolicy Bypass -NoProfile -File "%~dp0publish.ps1" %*
exit /b %ERRORLEVEL%
::SHELLSCRIPT
# If run from bash (e.g., CI on Linux/macOS), dispatch to publish.sh
path=$(dirname "$0")
cd $path
./publish.sh
exit $?
:CMDSCRIPT
IF "%~1" == "" (
SET config=-c Release
) ELSE (
IF "%~1" == "release" (
SET config=-c Release
) ELSE (
SET config=-c Debug
)
)
IF "%~2" == "" (
SET os=-r win
) ELSE (
SET os=-r %~2
)
IF "%~3" == "" (
SET arch=x64
) ELSE (
SET arch=%~3
)
echo dotnet tool restore
dotnet tool restore
echo dotnet clean --verbosity quiet
dotnet clean --verbosity quiet
echo dotnet restore --force-evaluate --source https://api.nuget.org/v3/index.json
dotnet restore --force-evaluate --source https://api.nuget.org/v3/index.json
echo dotnet publish %config% %os%-%arch% --no-restore --self-contained=false
dotnet publish %config% %os%-%arch% --no-restore --self-contained=false
echo Generating serialization migration schema...
dotnet tool run ModernUOSchemaGenerator -- ModernUO.sln
exec "$path/publish.sh" "$@"

79
publish.ps1 Normal file
View file

@ -0,0 +1,79 @@
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$toolBinary = Join-Path (Join-Path $repoRoot 'tools') 'build-tool.exe'
$buildToolProject = Join-Path (Join-Path (Join-Path $repoRoot 'Projects') 'BuildTool') 'BuildTool.csproj'
function Get-BuildToolFromRelease {
try {
# Determine platform
$arch = if ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq 'Arm64') { 'arm64' } else { 'x64' }
$assetName = "build-tool-win-$arch.exe"
Write-Host "Downloading build tool..." -ForegroundColor Blue
$releaseUrl = 'https://api.github.com/repos/modernuo/ModernUO/releases/tags/build-tool-latest'
$headers = @{ 'User-Agent' = 'ModernUO-BuildTool' }
$release = Invoke-RestMethod -Uri $releaseUrl -Headers $headers -TimeoutSec 10
$asset = $release.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1
if (-not $asset) {
return $false
}
$toolsDir = Join-Path $repoRoot 'tools'
if (-not (Test-Path $toolsDir)) {
New-Item -ItemType Directory -Path $toolsDir -Force | Out-Null
}
Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $toolBinary -TimeoutSec 60
return $true
}
catch {
return $false
}
}
function Test-DotNetAvailable {
try {
$null = & dotnet --version 2>$null
return $LASTEXITCODE -eq 0
}
catch {
return $false
}
}
# Try native binary first
if (Test-Path $toolBinary) {
& $toolBinary @args
exit $LASTEXITCODE
}
# Try to download native binary
if (Get-BuildToolFromRelease) {
& $toolBinary @args
exit $LASTEXITCODE
}
# Fall back to dotnet run
if (Test-DotNetAvailable) {
if (Test-Path $buildToolProject) {
Write-Host "Using dotnet run fallback..." -ForegroundColor Yellow
& dotnet run --project $buildToolProject -- @args
exit $LASTEXITCODE
}
else {
Write-Error "BuildTool project not found at: $buildToolProject"
exit 1
}
}
# Nothing works
Write-Host ""
Write-Host "Error: Could not run the build tool." -ForegroundColor Red
Write-Host ""
Write-Host "The .NET 10 SDK is required. Download it from:" -ForegroundColor Yellow
Write-Host " https://dotnet.microsoft.com/download/dotnet/10.0" -ForegroundColor Cyan
Write-Host ""
exit 1

View file

@ -1,45 +1,93 @@
#!/bin/bash
config=$1
os=$2
arch=${3:-$(uname -m)}
set -e
if [[ -n $os ]]; then
os="-r $os"
elif [[ $(uname) = "Darwin" ]]; then
os="-r osx"
else
os="-r linux"
fi
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
if [[ $config ]]; then
config="$(tr '[:lower:]' '[:upper:]' <<< ${1:0:1})${1:1}"
config="-c $config"
else
config="-c Release"
fi
# Determine platform for native binary
detect_platform() {
local os arch
case "$(uname -s)" in
Darwin) os="osx" ;;
*) os="linux" ;;
esac
if [[ $arch == *'aarch'* || $arch == *'arm'* ]]; then
arch="arm64"
else
arch="x64"
fi
case "$(uname -m)" in
aarch64|arm64) arch="arm64" ;;
*) arch="x64" ;;
esac
if [[ $os == *'centos'* || $os == *'rhel'* ]]; then
echo "${os}-${arch}"
}
PLATFORM="$(detect_platform)"
TOOL_BINARY="$REPO_ROOT/tools/build-tool"
BUILD_TOOL_PROJECT="$REPO_ROOT/Projects/BuildTool/BuildTool.csproj"
# Try to download native binary from GitHub Release
download_build_tool() {
local asset_name="build-tool-${PLATFORM}"
local tools_dir="$REPO_ROOT/tools"
echo -e "\033[34mDownloading build tool...\033[0m"
# Get latest release asset URL
local release_url="https://api.github.com/repos/modernuo/ModernUO/releases/tags/build-tool-latest"
local release_json
release_json=$(curl -fsSL --connect-timeout 10 -H "User-Agent: ModernUO-BuildTool" "$release_url" 2>/dev/null) || return 1
local download_url
download_url=$(echo "$release_json" | grep -o "\"browser_download_url\"[[:space:]]*:[[:space:]]*\"[^\"]*${asset_name}[^\"]*\"" | head -1 | grep -o 'https://[^"]*') || return 1
if [ -z "$download_url" ]; then
return 1
fi
mkdir -p "$tools_dir"
curl -fsSL --connect-timeout 10 -o "$TOOL_BINARY" "$download_url" || return 1
chmod +x "$TOOL_BINARY"
return 0
}
has_dotnet() {
command -v dotnet >/dev/null 2>&1
}
# CentOS/RHEL globalization workaround
if [ -f /etc/os-release ]; then
. /etc/os-release
case "$ID" in
centos|rhel)
export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
;;
esac
fi
echo dotnet tool restore
dotnet tool restore
# Try native binary first
if [ -x "$TOOL_BINARY" ]; then
exec "$TOOL_BINARY" "$@"
fi
echo dotnet clean --verbosity quiet
dotnet clean --verbosity quiet
echo dotnet restore --force-evaluate --source https://api.nuget.org/v3/index.json
dotnet restore --force-evaluate --source https://api.nuget.org/v3/index.json
# Try to download native binary
if download_build_tool 2>/dev/null; then
exec "$TOOL_BINARY" "$@"
fi
echo dotnet publish ${config} ${os}-${arch} --no-restore --self-contained=false
dotnet publish ${config} ${os}-${arch} --no-restore --self-contained=false
# Fall back to dotnet run
if has_dotnet; then
if [ -f "$BUILD_TOOL_PROJECT" ]; then
echo -e "\033[33mUsing dotnet run fallback...\033[0m"
exec dotnet run --project "$BUILD_TOOL_PROJECT" -- "$@"
else
echo "Error: BuildTool project not found at: $BUILD_TOOL_PROJECT" >&2
exit 1
fi
fi
echo Generating serialization migration schema...
dotnet tool run ModernUOSchemaGenerator -- ModernUO.sln
exit $?
# Nothing works
echo ""
echo -e "\033[31mError: Could not run the build tool.\033[0m"
echo ""
echo -e "\033[33mThe .NET 10 SDK is required. Download it from:\033[0m"
echo -e "\033[36m https://dotnet.microsoft.com/download/dotnet/10.0\033[0m"
echo ""
exit 1

View file

@ -1,4 +1,4 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",
"version": "0.15.5"
"version": "0.15.6"
}