diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index cf214cd69..da66d8d0c 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "modernuoschemagenerator": { - "version": "2.14.2", + "version": "2.14.3", "commands": [ "ModernUOSchemaGenerator" ] diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index bf1100d9d..87309b849 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -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 diff --git a/.github/workflows/build-tool-release.yml b/.github/workflows/build-tool-release.yml new file mode 100644 index 000000000..1353f7607 --- /dev/null +++ b/.github/workflows/build-tool-release.yml @@ -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 diff --git a/.gitignore b/.gitignore index 169cf5b30..7bb942e52 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ /packages/* /Distribution/Configuration/server-access.json + +# BuildTool native binaries (downloaded from GitHub Releases) +/tools/ diff --git a/ModernUO.sln b/ModernUO.sln deleted file mode 100644 index 584626227..000000000 --- a/ModernUO.sln +++ /dev/null @@ -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 diff --git a/ModernUO.slnx b/ModernUO.slnx new file mode 100644 index 000000000..5697dface --- /dev/null +++ b/ModernUO.slnx @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/BuildTool/Branding.cs b/Projects/BuildTool/Branding.cs new file mode 100644 index 000000000..8764b20bf --- /dev/null +++ b/Projects/BuildTool/Branding.cs @@ -0,0 +1,55 @@ +using Spectre.Console; + +namespace BuildTool; + +/// +/// Static branding assets for the BuildTool CLI. +/// Colors sourced from ModernUO brand palette (docs/packets/template.html, docs/branding/logo.svg). +/// +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); + + /// + /// 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. + /// + 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"; + + /// + /// Subtitle shown below the banner. + /// + public const string Subtitle = "Ultima Online Server Emulator"; +} diff --git a/Projects/BuildTool/BuildOptions.cs b/Projects/BuildTool/BuildOptions.cs new file mode 100644 index 000000000..9aea5010d --- /dev/null +++ b/Projects/BuildTool/BuildOptions.cs @@ -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; } +} diff --git a/Projects/BuildTool/BuildTool.csproj b/Projects/BuildTool/BuildTool.csproj new file mode 100644 index 000000000..8ac070629 --- /dev/null +++ b/Projects/BuildTool/BuildTool.csproj @@ -0,0 +1,20 @@ + + + Exe + net10.0 + Preview + false + BuildTool + build-tool + enable + true + true + link + true + win-x64;win-arm64;osx-x64;osx-arm64;linux-x64;linux-arm64 + true + + + + + diff --git a/Projects/BuildTool/Interactive/CancellationTracker.cs b/Projects/BuildTool/Interactive/CancellationTracker.cs new file mode 100644 index 000000000..21bb2a1c0 --- /dev/null +++ b/Projects/BuildTool/Interactive/CancellationTracker.cs @@ -0,0 +1,25 @@ +namespace BuildTool.Interactive; + +/// +/// Tracks Ctrl+C press timing to detect double-press for exit. +/// +public sealed class CancellationTracker +{ + private DateTime _lastCancelTime = DateTime.MinValue; + private const int DoublePressMs = 500; + + /// + /// Checks if this is a double-press (within 500ms of last press). + /// Also updates the last press time. + /// + public bool IsDoublePress() + { + var now = DateTime.UtcNow; + var isDouble = (now - _lastCancelTime).TotalMilliseconds < DoublePressMs; + _lastCancelTime = now; + return isDouble; + } + + /// Global instance for app-wide tracking. + public static CancellationTracker Instance { get; } = new(); +} diff --git a/Projects/BuildTool/Interactive/GuidedMode.cs b/Projects/BuildTool/Interactive/GuidedMode.cs new file mode 100644 index 000000000..e86561d30 --- /dev/null +++ b/Projects/BuildTool/Interactive/GuidedMode.cs @@ -0,0 +1,365 @@ +using BuildTool.Platform; +using BuildTool.Prerequisites; +using BuildTool.Publishing; +using Spectre.Console; + +namespace BuildTool.Interactive; + +/// +/// 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. +/// +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)"; + + /// + /// Tracks menu depth. 0 = root menu, >0 = submenu. + /// Used by ShowPrompt to automatically add Back option in submenus. + /// + private static int _menuDepth; + + public static int Run(PlatformInfo platform, string repoRoot) + { + return ShowMainMenu(platform, repoRoot); + } + + /// + /// 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). + /// + 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() + .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; + } + } + } + + /// + /// 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. + /// + /// + /// 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. + /// + 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 + { + 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); + } +} diff --git a/Projects/BuildTool/Interactive/InteractiveCancellation.cs b/Projects/BuildTool/Interactive/InteractiveCancellation.cs new file mode 100644 index 000000000..d8d052f7d --- /dev/null +++ b/Projects/BuildTool/Interactive/InteractiveCancellation.cs @@ -0,0 +1,58 @@ +namespace BuildTool.Interactive; + +/// +/// 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. +/// +public sealed class InteractiveCancellation : IDisposable +{ + private CancellationTokenSource _cts = new(); + private readonly object _lock = new(); + + /// + /// Gets the current cancellation token to pass to prompts. + /// + public CancellationToken Token + { + get + { + lock (_lock) + { + return _cts.Token; + } + } + } + + /// + /// Signals cancellation (called from Ctrl+C handler). + /// + public void Cancel() + { + lock (_lock) + { + _cts.Cancel(); + } + } + + /// + /// Resets the cancellation token source so new prompts can work. + /// Call this after catching OperationCanceledException. + /// + public void Reset() + { + lock (_lock) + { + _cts.Dispose(); + _cts = new CancellationTokenSource(); + } + } + + /// Global instance for app-wide interactive cancellation. + public static InteractiveCancellation Instance { get; } = new(); + + public void Dispose() + { + _cts.Dispose(); + } +} diff --git a/Projects/BuildTool/Json/GlobalJsonContext.cs b/Projects/BuildTool/Json/GlobalJsonContext.cs new file mode 100644 index 000000000..d64fa29b1 --- /dev/null +++ b/Projects/BuildTool/Json/GlobalJsonContext.cs @@ -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; diff --git a/Projects/BuildTool/Platform/PlatformDetector.cs b/Projects/BuildTool/Platform/PlatformDetector.cs new file mode 100644 index 000000000..ac7e74dfd --- /dev/null +++ b/Projects/BuildTool/Platform/PlatformDetector.cs @@ -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; + } +} diff --git a/Projects/BuildTool/Platform/PlatformInfo.cs b/Projects/BuildTool/Platform/PlatformInfo.cs new file mode 100644 index 000000000..5d40827d1 --- /dev/null +++ b/Projects/BuildTool/Platform/PlatformInfo.cs @@ -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"; +} diff --git a/Projects/BuildTool/Prerequisites/DotNetSdkManager.cs b/Projects/BuildTool/Prerequisites/DotNetSdkManager.cs new file mode 100644 index 000000000..3659702d9 --- /dev/null +++ b/Projects/BuildTool/Prerequisites/DotNetSdkManager.cs @@ -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(); +} diff --git a/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs new file mode 100644 index 000000000..29f9b488c --- /dev/null +++ b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs @@ -0,0 +1,309 @@ +using BuildTool.Platform; +using BuildTool.Publishing; + +namespace BuildTool.Prerequisites; + +public static class NativeLibraryChecker +{ + /// + /// 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. + /// + 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 Check(PlatformInfo platform) + { + if (platform.IsWindows) + { + return CheckWindows(platform); + } + + if (platform.IsMacOS) + { + return CheckMacOS(); + } + + if (platform.IsLinux) + { + return CheckLinux(platform); + } + + return []; + } + + private static List CheckWindows(PlatformInfo platform) + { + var results = new List(); + + // 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 CheckMacOS() + { + var results = new List(); + + // 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( + listResult.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries), + StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + + var missing = new List(); + 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 CheckLinux(PlatformInfo platform) + { + return platform.PackageManager switch + { + PackageManager.Apt => CheckLinuxApt(), + PackageManager.Dnf => CheckLinuxDnf(platform), + _ => CheckLinuxGeneric(platform) + }; + } + + private static List CheckLinuxApt() + { + var results = new List(); + var packages = new[] { "libicu-dev", "libdeflate-dev", "zstd", "libargon2-dev", "liburing-dev" }; + var missing = new List(); + + 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 CheckLinuxDnf(PlatformInfo platform) + { + var results = new List(); + var packages = new[] { "libicu", "libdeflate-devel", "zstd", "libargon2-devel", "liburing-devel" }; + var missing = new List(); + + 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 CheckLinuxGeneric(PlatformInfo platform) + { + var results = new List(); + + // Use ldconfig to check for shared libraries + var ldResult = ProcessRunner.RunCaptured("ldconfig", "-p"); + var ldOutput = ldResult.Success ? ldResult.StandardOutput : ""; + + var libraries = new Dictionary + { + ["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; + } +} diff --git a/Projects/BuildTool/Prerequisites/PrerequisiteChecker.cs b/Projects/BuildTool/Prerequisites/PrerequisiteChecker.cs new file mode 100644 index 000000000..245bcee4c --- /dev/null +++ b/Projects/BuildTool/Prerequisites/PrerequisiteChecker.cs @@ -0,0 +1,165 @@ +using BuildTool.Platform; +using Spectre.Console; + +namespace BuildTool.Prerequisites; + +public static class PrerequisiteChecker +{ + /// + /// Runs all prerequisite checks (SDK + native libraries) and displays results. + /// Returns true if all critical prerequisites pass. + /// + 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); + } + + /// + /// Checks only the .NET SDK (sufficient for cross-compilation). + /// Returns true if the SDK is available. + /// + 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; + } + + /// + /// 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. + /// + 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}[/]"); + } + } +} diff --git a/Projects/BuildTool/Prerequisites/PrerequisiteResult.cs b/Projects/BuildTool/Prerequisites/PrerequisiteResult.cs new file mode 100644 index 000000000..e7b123730 --- /dev/null +++ b/Projects/BuildTool/Prerequisites/PrerequisiteResult.cs @@ -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; } +} diff --git a/Projects/BuildTool/Program.cs b/Projects/BuildTool/Program.cs new file mode 100644 index 000000000..3b36066da --- /dev/null +++ b/Projects/BuildTool/Program.cs @@ -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; +} diff --git a/Projects/BuildTool/Publishing/ProcessRunner.cs b/Projects/BuildTool/Publishing/ProcessRunner.cs new file mode 100644 index 000000000..861268032 --- /dev/null +++ b/Projects/BuildTool/Publishing/ProcessRunner.cs @@ -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 +{ + /// + /// Runs a command with output captured (for parsing results). + /// + 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() + }; + } + + /// + /// Runs a command with output passed through to the console (for build commands in non-interactive mode). + /// + 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; + } +} diff --git a/Projects/BuildTool/Publishing/PublishOrchestrator.cs b/Projects/BuildTool/Publishing/PublishOrchestrator.cs new file mode 100644 index 000000000..1a704b9b3 --- /dev/null +++ b/Projects/BuildTool/Publishing/PublishOrchestrator.cs @@ -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 + { + 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(); + } +} diff --git a/Projects/BuildTool/Publishing/SchemaMigrator.cs b/Projects/BuildTool/Publishing/SchemaMigrator.cs new file mode 100644 index 000000000..6bbe5291c --- /dev/null +++ b/Projects/BuildTool/Publishing/SchemaMigrator.cs @@ -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; + } +} diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index 02f6974eb..2612c203b 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -35,12 +35,12 @@ - + - + - + diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index 22b81d23e..1eaf9e807 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -39,15 +39,15 @@ - - + + - + diff --git a/README.md b/README.md index daec5f817..2a74b051c 100644 --- a/README.md +++ b/README.md @@ -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) - - `win` - [Windows](https://learn.microsoft.com/en-us/dotnet/core/install/windows) - - `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` - - `x64` - Intel/AMD 64-bit - - `arm64` - ARM 64-bit (Windows not supported) + +#### 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) + - `linux` - [Linux](https://learn.microsoft.com/en-us/dotnet/core/install/linux) +- `arch` + - `x64` - Intel/AMD 64-bit + - `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) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 0a0fb6a25..7c61877a6 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -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 diff --git a/global.json b/global.json index 6a288505a..fa8e68b5f 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.100", + "version": "10.0.201", "rollForward": "latestMajor", "allowPrerelease": false } diff --git a/publish.cmd b/publish.cmd index f8e490426..a15991ab0 100755 --- a/publish.cmd +++ b/publish.cmd @@ -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" "$@" diff --git a/publish.ps1 b/publish.ps1 new file mode 100644 index 000000000..8f26159eb --- /dev/null +++ b/publish.ps1 @@ -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 diff --git a/publish.sh b/publish.sh index 8b6f76f81..c4e728117 100755 --- a/publish.sh +++ b/publish.sh @@ -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" +REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" + +# Determine platform for native binary +detect_platform() { + local os arch + case "$(uname -s)" in + Darwin) os="osx" ;; + *) os="linux" ;; + esac + + case "$(uname -m)" in + aarch64|arm64) arch="arm64" ;; + *) arch="x64" ;; + esac + + 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 -if [[ $config ]]; then - config="$(tr '[:lower:]' '[:upper:]' <<< ${1:0:1})${1:1}" - config="-c $config" -else - config="-c Release" +# Try native binary first +if [ -x "$TOOL_BINARY" ]; then + exec "$TOOL_BINARY" "$@" fi -if [[ $arch == *'aarch'* || $arch == *'arm'* ]]; then - arch="arm64" -else - arch="x64" +# Try to download native binary +if download_build_tool 2>/dev/null; then + exec "$TOOL_BINARY" "$@" fi -if [[ $os == *'centos'* || $os == *'rhel'* ]]; then - export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 +# 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 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 - -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 diff --git a/version.json b/version.json index 89634f300..30cc19627 100644 --- a/version.json +++ b/version.json @@ -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" }