diff --git a/.editorconfig b/.editorconfig index fd10fe0..72bd5fd 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,6 +8,9 @@ charset = utf-8 indent_style = space indent_size = 4 +[*.yml] +indent_size = 2 + [*.{cs,vb}] # https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/formatting-rules csharp_new_line_before_open_brace = all diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index af3760c..3229fc7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,26 +2,28 @@ name: Publish on: release: - types: [released] + types: [ published ] jobs: nuget: + name: Publish NuGet Package runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-dotnet@v1 + - name: Checkout ref that triggered workflow + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: - dotnet-version: '6.0.x' + dotnet-version: '7' - name: Fetch Latest .nupkg - uses: dsaltares/fetch-gh-release-asset@0efe227dedb360b09ea0e533795d584b61c461a9 + uses: dsaltares/fetch-gh-release-asset@1.1.1 with: - token: "${{ secrets.GITHUB_TOKEN }}" version: "tags/${{ github.ref_name }}" file: "tcli.${{ github.ref_name }}.nupkg" target: "tcli.nupkg" - - name: Publish to NuGet shell: bash run: dotnet nuget push tcli.nupkg -s https://api.nuget.org/v3/index.json -k ${{ secrets.NUGET_API_KEY }} --skip-duplicate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 94e9af3..dfc09ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,94 +8,115 @@ on: jobs: # Validate tag with proper regex since the check above is very limited. validate-tag: + name: Validate tag semantic version runs-on: ubuntu-latest + outputs: + tag: ${{ steps.tag.outputs.tag }} steps: - - uses: actions/checkout@v2 - - id: tag - uses: dawidd6/action-get-tag@v1 + - name: Checkout ref that triggered workflow + uses: actions/checkout@v4 + + - name: Ensure triggering ref is a tag + id: tag + uses: devops-actions/action-get-tag@v1.0.2 + - id: regex-match uses: actions-ecosystem/action-regex-match@v2 with: text: ${{ steps.tag.outputs.tag }} - regex: '^([1-9][0-9]*|[0-9])\.([1-9][0-9]*|[0-9])\.([1-9][0-9]*|[0-9])$' + regex: '^([1-9][0-9]*|0)\.([1-9][0-9]*|0)\.([1-9][0-9]*|0)$' + - id: fail-fast if: ${{ steps.regex-match.outputs.match == '' }} - uses: actions/github-script@v3 + uses: actions/github-script@v7 with: script: core.setFailed('Tag is invalid') platform-binary: + name: Build binaries for ${{ matrix.target }} needs: validate-tag if: github.event.base_ref == 'refs/heads/master' - name: Create binary ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: matrix: kind: ['linux', 'windows', 'macOS'] include: - kind: linux - os: ubuntu-latest target: linux-x64 + os: ubuntu-latest - kind: windows - os: windows-latest target: win-x64 + os: windows-latest - kind: macOS - os: macos-latest target: osx-x64 + os: macos-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-dotnet@v1 + - name: Checkout ref that triggered workflow + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: - dotnet-version: '6.0.x' - - id: tag - uses: dawidd6/action-get-tag@v1 + dotnet-version: '7' + - name: Install dependencies run: dotnet restore + - name: Setup Cargo/Rust + uses: moonrepo/setup-rust@v1 + - name: Build shell: bash run: | - release_name="tcli-${{ steps.tag.outputs.tag }}-${{ matrix.target }}" - dotnet publish ThunderstoreCLI/ThunderstoreCLI.csproj -c Release -r "${{ matrix.target }}" -o "$release_name" -p:PublishReadyToRun=true + release_name="tcli-${{ needs.validate-tag.outputs.tag }}-${{ matrix.target }}" + dotnet publish ThunderstoreCLI/ThunderstoreCLI.csproj -c Release -r "${{ matrix.target }}" --self-contained true -f net7.0 -o "${release_name}" if [ "${{ matrix.target }}" == "win-x64" ]; then - 7z a -tzip "${release_name}.zip" "./${release_name}/*" + 7z a -tzip "${release_name}.zip" "./${release_name}" else - tar czvf "${release_name}.tar.gz" "$release_name" + tar czvf "${release_name}.tar.gz" "./${release_name}" fi - rm -r "$release_name" + rm -r ${release_name} - - name: Publish to GitHub + - name: Add build artifacts to draft GitHub release uses: softprops/action-gh-release@v1 with: files: "tcli*" - name: "Thunderstore CLI ${{ steps.tag.outputs.tag }}" + name: "Thunderstore CLI ${{ needs.validate-tag.outputs.tag }}" body_path: ${{ github.workspace }}/.github/RELEASE_TEMPLATE.md draft: true - prerelease: ${{ startsWith(steps.tag.outputs.tag, '0.') }} + prerelease: ${{ startsWith(needs.validate-tag.outputs.tag, '0.') }} nupkg: + name: Build NuGet Package needs: validate-tag if: github.event.base_ref == 'refs/heads/master' - name: Create .nupkg runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-dotnet@v1 + - name: Checkout ref that triggered workflow + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: - dotnet-version: '6.0.x' - - id: tag - uses: dawidd6/action-get-tag@v1 + dotnet-version: '7' + - name: Install dependencies run: dotnet restore + - name: Setup Cargo/Rust + uses: moonrepo/setup-rust@v1 + - name: Build shell: bash - run: dotnet pack ThunderstoreCLI/ThunderstoreCLI.csproj -c Release -o "." -p:SelfContained=false -p:PublishTrimmed=false -p:PublishSingleFile=false -p:StartupObject="" -p:RuntimeIdentifier="" -p:PublishReadyToRun=false -p:PackAsTool=true + run: dotnet pack ThunderstoreCLI/ThunderstoreCLI.csproj -c Release -o "." -p:EnableInstallers=false -p:PublishSelfContained=false -p:PublishSingleFile=false -p:PublishTrimmed=false -p:PublishReadyToRun=false - - name: Publish to GitHub + - name: Add build artifacts to draft GitHub release uses: softprops/action-gh-release@v1 with: files: "tcli*" + name: "Thunderstore CLI ${{ needs.validate-tag.outputs.tag }}" + body_path: ${{ github.workspace }}/.github/RELEASE_TEMPLATE.md draft: true + prerelease: ${{ startsWith(needs.validate-tag.outputs.tag, '0.') }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1486f95..9c234ee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,23 +1,36 @@ name: Build & Test -on: [push] +on: + # Trigger on pushes to the main branch + push: + branches: [ master ] + # Trigger on any pull request + pull_request: jobs: pre-commit: name: Code style check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-dotnet@v1 + - name: Checkout ref that triggered workflow + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: - dotnet-version: '6.0.x' - - uses: actions/setup-python@v2 + dotnet-version: '7' + + - name: Setup Python + uses: actions/setup-python@v2 with: python-version: '3.8' - - name: Install pre-commit + + - name: Install pre-commit framework run: curl https://pre-commit.com/install-local.py | python - + - name: Install dotnet-format run: dotnet tool install -g dotnet-format + - name: Run pre-commit run: ~/bin/pre-commit run --show-diff-on-failure --color=always --all-files @@ -30,19 +43,30 @@ jobs: env: OS: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 - - uses: actions/setup-dotnet@v1 + - name: Checkout ref that triggered workflow + uses: actions/checkout@v4 + + - name: setup .NET + uses: actions/setup-dotnet@v4 with: - dotnet-version: '6.0.x' + dotnet-version: '7' + + - name: Setup Cargo/Rust + uses: moonrepo/setup-rust@v1 + - name: Install dependencies run: dotnet restore + - name: Build run: dotnet build --configuration Release --no-restore + - name: Run xUnit tests - run: dotnet test --collect:"XPlat Code Coverage" + run: dotnet test -p:EnableInstallers=false --collect:"XPlat Code Coverage" + - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v3 with: directory: ./ThunderstoreCLI.Tests/TestResults/ env_vars: OS fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index a32bd24..4101faa 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,69 @@ # thunderstore-cli +[![Build & Test](https://github.com/thunderstore-io/thunderstore-cli/actions/workflows/test.yml/badge.svg)](https://github.com/thunderstore-io/thunderstore-cli/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/thunderstore-io/thunderstore-cli/branch/master/graph/badge.svg)](https://codecov.io/gh/thunderstore-io/thunderstore-cli) +[![NuGet Package](https://img.shields.io/nuget/v/tcli)](https://www.nuget.org/packages/tcli) +[![downloads](https://img.shields.io/nuget/dt/tcli)](https://www.nuget.org/packages/tcli) -Command line tool for building and uploading mod packages to -[Thunderstore](https://thunderstore.io/) mod database. +Thunderstore CLI (just "TCLI" from here on) is a command line tool for building and uploading mod packages to +[Thunderstore](https://thunderstore.io/) mod database, and installing mods via the command line. -## pre-commit +## Installation +If all you're interested in is the package building/publishing capabilities of TCLI, then you can simply do: +``` +dotnet tool install -g tcli +``` +In your command line of choice, which will install the tool via NuGet. +This version doesn't come with the mod installation functionality however. + +Otherwise, just download the latest release from [here](https://github.com/thunderstore-io/thunderstore-cli/releases) and extract the ZIP wherever you'll be using TCLI. + +## Usage +For building packages, see [the wiki](https://github.com/thunderstore-io/thunderstore-cli/wiki). + +For managing mods via TCLI, see the next section. +## Mod Management + +### Installation +TCLI will automatically download and install mods in the correct format for you, and will run the game with whatever arguments your mod loader requires. + +For all of these commands, `tcli.exe` can be swapped for `./tcli` on Linux. Everything else should be the same. + +To get started, import your game from the Thunderstore API using: +``` +tcli.exe import-game {game identifier, e.g. ror2 or valheim} +``` +To run the game from a specific file instead of say, through Steam (you probably want this for servers!) use `--exepath {path/to/server/launcher.exe}`. Passing in a script file also works fine. + +To install mods, use the command: +``` +tcli.exe install {game identifier} {namespace-modname(-version)} +``` +You can also add `--profile ProfileName` to set a custom name for the profile. By default it uses the name `DefaultProfile`. + +Mod uninstalls are done in a similar fashion: +``` +tcli.exe uninstall {game identifier} {namespace-modname} +``` +And running the game is done with: +``` +tcli.exe run {game identifier} +``` +The `--profile` snippet from above still applies to both of those commands. + +If you want to run the game with a specific set of arguments, you can use `--args "--flag parameter1 parameter2"` + +The installed mods by default will go into `%APPDATA%\ThunderstoreCLI` on Windows and `~/.config/ThunderstoreCLI` on Linux. This is configurable by using `--tcli-directory` with any command. + +The ThunderstoreCLI directory will contain a file called `GameDefintions.json`, which contains metadata about the configured games, the profiles for each game, and a copy of the manifests of each installed mod. You shouldn't normally be editing this manually. + +The same directory also contains a cache for mod ZIPS named `ModCache`, the actual profile files in a folder called `Profiles`, and a caches for the API responses from the Thunderstore API for packages. + +All in all, the structure is very similar to that of TMM/r2mm, but on the command line! + +## Contributing + +### pre-commit This project uses [Pre-commit](https://pre-commit.com/) to enforce code style practices. In addition to having .NET and pre-commit installed locally, you'll @@ -16,7 +74,7 @@ with: dotnet tool install -g dotnet-format ``` -## Versioning +### Versioning This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Versioning is handled with [MinVer](https://github.com/adamralph/minver) via Git diff --git a/ThunderstoreCLI.Tests/Models/BaseJson.cs b/ThunderstoreCLI.Tests/Models/BaseJson.cs index dec6b61..190791a 100644 --- a/ThunderstoreCLI.Tests/Models/BaseJson.cs +++ b/ThunderstoreCLI.Tests/Models/BaseJson.cs @@ -1,13 +1,12 @@ -using System; -using System.Text.Json; using System.Text.Json.Serialization; -using NuGet.Frameworks; +using Newtonsoft.Json; +using ThunderstoreCLI.Configuration; using ThunderstoreCLI.Models; using Xunit; -namespace ThunderstoreCLI.Tests; +namespace ThunderstoreCLI.Tests.Models; -public class TestJson : BaseJson +public class TestJson : BaseJson { public class Location { @@ -19,9 +18,6 @@ public class Location public Location home { get; set; } } -[JsonSerializable(typeof(TestJson))] -public partial class TestJsonContext : JsonSerializerContext { } - public class ThunderstoreCLI_BaseJson { [Fact] @@ -77,9 +73,9 @@ public void Serialize_WhenGivenFilled_ReturnsExpected() [Fact] public void Serialize_WhenAskedToIndent_Indents() { - var options = new JsonSerializerOptions() + var options = new JsonSerializerSettings { - WriteIndented = true + Formatting = Formatting.Indented }; Assert.Equal( diff --git a/ThunderstoreCLI.Tests/NoParallel.cs b/ThunderstoreCLI.Tests/NoParallel.cs new file mode 100644 index 0000000..ba411c3 --- /dev/null +++ b/ThunderstoreCLI.Tests/NoParallel.cs @@ -0,0 +1,6 @@ +using Xunit; + +namespace ThunderstoreCLI.Tests; + +[CollectionDefinition(nameof(NoParallel), DisableParallelization = true)] +public sealed class NoParallel { } diff --git a/ThunderstoreCLI.Tests/ThunderstoreCLI.Tests.csproj b/ThunderstoreCLI.Tests/ThunderstoreCLI.Tests.csproj index a7fa5b7..9c4f2a8 100644 --- a/ThunderstoreCLI.Tests/ThunderstoreCLI.Tests.csproj +++ b/ThunderstoreCLI.Tests/ThunderstoreCLI.Tests.csproj @@ -1,20 +1,26 @@ - net6.0 + net7.0;net6.0 + Major + 11 false - win-x64 - latest - - - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/ThunderstoreCLI.Tests/Utils/Comparers.cs b/ThunderstoreCLI.Tests/Utils/Comparers.cs index c043be2..c6e5bd1 100644 --- a/ThunderstoreCLI.Tests/Utils/Comparers.cs +++ b/ThunderstoreCLI.Tests/Utils/Comparers.cs @@ -1,10 +1,11 @@ +using ThunderstoreCLI.Utils; using Xunit; -namespace ThunderstoreCLI.Tests; +namespace ThunderstoreCLI.Tests.Utils; public class ThunderstoreCLI_Comparers_SemVer { - private readonly ThunderstoreCLI.Comparers.SemVer _semVer = new ThunderstoreCLI.Comparers.SemVer(); + private readonly SemVer _semVer = new SemVer(); public static TheoryData EqualValues => new TheoryData { diff --git a/ThunderstoreCLI.Tests/Utils/MiscUtils.cs b/ThunderstoreCLI.Tests/Utils/MiscUtils.cs index f82a9f9..371357b 100644 --- a/ThunderstoreCLI.Tests/Utils/MiscUtils.cs +++ b/ThunderstoreCLI.Tests/Utils/MiscUtils.cs @@ -1,7 +1,8 @@ using System; +using ThunderstoreCLI.Utils; using Xunit; -namespace ThunderstoreCLI.Tests; +namespace ThunderstoreCLI.Tests.Utils; public class ThunderstoreCLI_MiscUtils { diff --git a/ThunderstoreCLI.Tests/Utils/Spinner.cs b/ThunderstoreCLI.Tests/Utils/Spinner.cs index e2f3da0..fae60e2 100644 --- a/ThunderstoreCLI.Tests/Utils/Spinner.cs +++ b/ThunderstoreCLI.Tests/Utils/Spinner.cs @@ -1,9 +1,11 @@ using System; using System.Threading.Tasks; +using ThunderstoreCLI.Utils; using Xunit; -namespace ThunderstoreCLI.Tests; +namespace ThunderstoreCLI.Tests.Utils; +[Collection(nameof(NoParallel))] public class ThunderstoreCLI_ProgresSpinner { private async Task CreateTask(bool isSuccess, int delay = 1) @@ -29,7 +31,7 @@ public async Task WhenTaskFails_ThrowsSpinnerException() CreateTask(false) }); - await Assert.ThrowsAsync(async () => await spinner.Start()); + await Assert.ThrowsAsync(async () => await spinner.Spin()); } [Fact] @@ -39,7 +41,7 @@ public async Task WhenReceivesSingleTask_ItJustWorks() CreateTask(true) }); - await spinner.Start(); + await spinner.Spin(); } [Fact] @@ -51,6 +53,6 @@ public async Task WhenReceivesMultipleTasks_ItJustWorks() CreateTask(true) }); - await spinner.Start(); + await spinner.Spin(); } } diff --git a/ThunderstoreCLI.Tests/Utils/StringUtils.cs b/ThunderstoreCLI.Tests/Utils/StringUtils.cs index 47ceae9..da1b5e0 100644 --- a/ThunderstoreCLI.Tests/Utils/StringUtils.cs +++ b/ThunderstoreCLI.Tests/Utils/StringUtils.cs @@ -1,6 +1,7 @@ +using ThunderstoreCLI.Utils; using Xunit; -namespace ThunderstoreCLI.Tests; +namespace ThunderstoreCLI.Tests.Utils; public class ThunderstoreCLI_StringUtils { diff --git a/ThunderstoreCLI.Tests/Utils/TomlUtils.cs b/ThunderstoreCLI.Tests/Utils/TomlUtils.cs deleted file mode 100644 index a39674c..0000000 --- a/ThunderstoreCLI.Tests/Utils/TomlUtils.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System.IO; -using Tommy; -using Xunit; - -namespace ThunderstoreCLI.Tests; - -public class ThunderstoreCLI_TomlUtils -{ - public static string _key = "key"; - - public static TheoryData InvalidValues => new TheoryData - { - $"{_key} = 0", - $"{_key} = +3.1415", - $"{_key} = -2E-2", - $"{_key} = nan", - $"{_key} = 1970-01-01T00:00:00Z", - $"{_key} = 1970-01-01", - $"{_key} = 00:00:00", - $"{_key} = {{ x = 1, y = 2 }}" - }; - - public static TomlTable CreateTomlTable(string input) - { - using var reader = new StringReader(input); - return TOML.Parse(reader); - } - - [Fact] - public void SafegetString_WhenKeyIsNotFound_ReturnsNull() - { - var table = CreateTomlTable(""); - - var actual = TomlUtils.SafegetString(table, _key); - - Assert.Null(actual); - } - - public static TheoryData ValidStringValues => new TheoryData - { - { $"{_key} = \"value\"", "value" }, - { $"{_key} = \"\"", "" }, - { $"foo = \"foo\"\n{_key} = \"value\"\nbar = \"bar\"", "value" }, - { $"{_key} = 'literal string'", "literal string" }, - }; - - [Theory] - [MemberData(nameof(ValidStringValues))] - public void SafegetString_WhenValueIsString_ReturnsValue(string input, string expected) - { - var table = CreateTomlTable(input); - - var actual = TomlUtils.SafegetString(table, _key); - - Assert.Equal(expected, actual); - } - - [Theory] - [MemberData(nameof(InvalidValues))] - [InlineData("key = true")] - [InlineData("key = [\"foo\", \"bar\"]")] - public void SafegetString_WhenValueIsNotString_ReturnsNull(string input) - { - var table = CreateTomlTable(input); - - var actual = TomlUtils.SafegetString(table, _key); - - Assert.Null(actual); - } - - [Fact] - public void SafegetBool_WhenKeyIsNotFound_ReturnsNull() - { - var table = CreateTomlTable(""); - - var actual = TomlUtils.SafegetBool(table, _key); - - Assert.Null(actual); - } - - public static TheoryData ValidBoolValues => new TheoryData - { - { $"{_key} = true", true }, - { $"{_key} = false", false } - }; - - [Theory] - [MemberData(nameof(ValidBoolValues))] - public void SafegetBool_WhenValueIsBool_ReturnsValue(string input, bool expected) - { - var table = CreateTomlTable(input); - - var actual = TomlUtils.SafegetBool(table, _key); - - Assert.Equal(expected, actual); - } - - [Theory] - [MemberData(nameof(InvalidValues))] - [InlineData("key = \"true\"")] - [InlineData("key = [\"foo\", \"bar\"]")] - public void SafegetBool_WhenValueIsNotBool_ReturnsNull(string input) - { - var table = CreateTomlTable(input); - - var actual = TomlUtils.SafegetBool(table, _key); - - Assert.Null(actual); - } - - [Fact] - public void SafegetStringArray_WhenKeyIsNotFound_ReturnsNull() - { - var table = CreateTomlTable(""); - - var actual = TomlUtils.SafegetBool(table, _key); - - Assert.Null(actual); - } - - public static TheoryData ValidStringArrayValues => new TheoryData - { - { $"{_key} = []", new string[] { } }, - { $"{_key} = [\"\"]", new [] { "" } }, - { $"{_key} = [\"value\"]", new [] { "value" } }, - { $"{_key} = [\"value1\", \"value2\"]", new [] { "value1", "value2" } }, - { $"{_key} = ['literal']", new [] { "literal" } } - }; - - [Theory] - [MemberData(nameof(ValidStringArrayValues))] - public void SafegetStringArray_WhenValueIsStringArray_ReturnsValue(string input, string[] expected) - { - var table = CreateTomlTable(input); - - var actual = TomlUtils.SafegetStringArray(table, _key); - - Assert.Equal(expected, actual); - } - - [Theory] - [MemberData(nameof(InvalidValues))] - [InlineData("key = \"value\"")] - [InlineData("key = true")] - public void SafegetStringArray_WhenValueIsNotStringArray_ReturnsNull(string input) - { - var table = CreateTomlTable(input); - - var actual = TomlUtils.SafegetStringArray(table, _key); - - Assert.Null(actual); - } -} diff --git a/ThunderstoreCLI.Tests/Utils/Write.cs b/ThunderstoreCLI.Tests/Utils/Write.cs index 64c88e6..65d3d79 100644 --- a/ThunderstoreCLI.Tests/Utils/Write.cs +++ b/ThunderstoreCLI.Tests/Utils/Write.cs @@ -1,8 +1,9 @@ using System; using System.IO; +using ThunderstoreCLI.Utils; using Xunit; -namespace ThunderstoreCLI.Tests; +namespace ThunderstoreCLI.Tests.Utils; public class FakeConsole : IDisposable { @@ -26,6 +27,7 @@ public void Dispose() } } +[Collection(nameof(NoParallel))] public class ThunderstoreCLI_Write { // ANSI escape codes diff --git a/ThunderstoreCLI/API/ApiHelper.cs b/ThunderstoreCLI/API/ApiHelper.cs index 6b272cb..afd05ef 100644 --- a/ThunderstoreCLI/API/ApiHelper.cs +++ b/ThunderstoreCLI/API/ApiHelper.cs @@ -1,5 +1,6 @@ using System.Net.Http.Headers; using System.Text; +using ThunderstoreCLI.Configuration; using ThunderstoreCLI.Models; using ThunderstoreCLI.Utils; @@ -7,17 +8,17 @@ namespace ThunderstoreCLI.API; public class ApiHelper { - private Config.Config Config { get; } + private Config Config { get; } private RequestBuilder BaseRequestBuilder { get; } private readonly Lazy authHeader; private AuthenticationHeaderValue AuthHeader => authHeader.Value; - public ApiHelper(Config.Config config) + public ApiHelper(Config config) { Config = config; - BaseRequestBuilder = new RequestBuilder(config.PublishConfig.Repository ?? throw new Exception("The target repository cannot be empty")); + BaseRequestBuilder = new RequestBuilder(config.GeneralConfig.Repository ?? throw new Exception("Repository can't be empty")); authHeader = new Lazy(() => { if (string.IsNullOrEmpty(Config.AuthConfig.AuthToken)) @@ -28,6 +29,7 @@ public ApiHelper(Config.Config config) private const string V1 = "api/v1/"; private const string EXPERIMENTAL = "api/experimental/"; + private const string COMMUNITY = "c/"; public HttpRequestMessage SubmitPackage(string fileUuid) { @@ -72,6 +74,38 @@ public HttpRequestMessage AbortUploadMedia(string uuid) .GetRequest(); } + public HttpRequestMessage GetPackageMetadata(string author, string name) + { + return BaseRequestBuilder + .StartNew() + .WithEndpoint(EXPERIMENTAL + $"package/{author}/{name}/") + .GetRequest(); + } + + public HttpRequestMessage GetPackageVersionMetadata(string author, string name, string version) + { + return BaseRequestBuilder + .StartNew() + .WithEndpoint(EXPERIMENTAL + $"package/{author}/{name}/{version}/") + .GetRequest(); + } + + public HttpRequestMessage GetPackagesV1() + { + return BaseRequestBuilder + .StartNew() + .WithEndpoint(V1 + "package/") + .GetRequest(); + } + + public HttpRequestMessage GetPackagesV1(string community) + { + return BaseRequestBuilder + .StartNew() + .WithEndpoint(COMMUNITY + community + "/api/v1/package/") + .GetRequest(); + } + private static string SerializeFileData(string filePath) { return new FileData() diff --git a/ThunderstoreCLI/Commands/BuildCommand.cs b/ThunderstoreCLI/Commands/BuildCommand.cs index ddb9289..3cdbeac 100644 --- a/ThunderstoreCLI/Commands/BuildCommand.cs +++ b/ThunderstoreCLI/Commands/BuildCommand.cs @@ -1,6 +1,8 @@ using System.IO.Compression; using System.Text; -using System.Text.Json; +using ThunderstoreCLI.Configuration; +using ThunderstoreCLI.Models; +using ThunderstoreCLI.Utils; using static Crayon.Output; namespace ThunderstoreCLI.Commands; @@ -9,7 +11,7 @@ public static class BuildCommand { public class ArchivePlan { - public Config.Config Config { get; protected set; } + public Config Config { get; protected set; } public bool HasWarnings { get; protected set; } public bool HasErrors { get; protected set; } @@ -18,7 +20,7 @@ public class ArchivePlan protected HashSet directories; protected HashSet files; - public ArchivePlan(Config.Config config) + public ArchivePlan(Config config) { Config = config; plan = new(); @@ -102,7 +104,7 @@ public Dictionary>.Enumerator GetEnumerator() } } - public static int Run(Config.Config config) + public static int Run(Config config) { try { @@ -116,7 +118,7 @@ public static int Run(Config.Config config) return DoBuild(config); } - public static int DoBuild(Config.Config config) + public static int DoBuild(Config config) { var packageId = config.GetPackageId(); Write.WithNL($"Building {Cyan(packageId)}", after: true); @@ -289,31 +291,28 @@ public static string FormatArchivePath(string path, bool validate = true) return result; } - public static string SerializeManifest(Config.Config config) + public static string SerializeManifest(Config config) { - var dependencies = config.PackageMeta.Dependencies ?? new Dictionary(); + var dependencies = config.PackageConfig.Dependencies ?? new Dictionary(); var manifest = new PackageManifestV1() { - Namespace = config.PackageMeta.Namespace, - Name = config.PackageMeta.Name, - Description = config.PackageMeta.Description, - VersionNumber = config.PackageMeta.VersionNumber, - WebsiteUrl = config.PackageMeta.WebsiteUrl, + Namespace = config.PackageConfig.Namespace, + Name = config.PackageConfig.Name, + Description = config.PackageConfig.Description, + VersionNumber = config.PackageConfig.VersionNumber, + WebsiteUrl = config.PackageConfig.WebsiteUrl, Dependencies = dependencies.Select(x => $"{x.Key}-{x.Value}").ToArray() }; - var serializerOptions = new JsonSerializerOptions - { - WriteIndented = true - }; - return manifest.Serialize(serializerOptions); + + return manifest.Serialize(BaseJson.IndentedSettings); } - public static List ValidateConfig(Config.Config config, bool throwIfErrors = true) + public static List ValidateConfig(Config config, bool throwIfErrors = true) { - var v = new Config.Validator("build"); - v.AddIfEmpty(config.PackageMeta.Namespace, "Package Namespace"); - v.AddIfEmpty(config.PackageMeta.Name, "Package Name"); - v.AddIfNotSemver(config.PackageMeta.VersionNumber, "Package VersionNumber"); + var v = new CommandValidator("build"); + v.AddIfEmpty(config.PackageConfig.Namespace, "Package Namespace"); + v.AddIfEmpty(config.PackageConfig.Name, "Package Name"); + v.AddIfNotSemver(config.PackageConfig.VersionNumber, "Package VersionNumber"); v.AddIfEmpty(config.BuildConfig.OutDir, "Build OutDir"); if (throwIfErrors) diff --git a/ThunderstoreCLI/Config/Validator.cs b/ThunderstoreCLI/Commands/CommandValidator.cs similarity index 92% rename from ThunderstoreCLI/Config/Validator.cs rename to ThunderstoreCLI/Commands/CommandValidator.cs index 9c1a67a..fbd7ac6 100644 --- a/ThunderstoreCLI/Config/Validator.cs +++ b/ThunderstoreCLI/Commands/CommandValidator.cs @@ -1,12 +1,14 @@ -namespace ThunderstoreCLI.Config; +using ThunderstoreCLI.Utils; + +namespace ThunderstoreCLI.Commands; /// Helper for validating command-specific configurations -public class Validator +public class CommandValidator { private List _errors; private string _name; - public Validator(string commandName, List? errors = null) + public CommandValidator(string commandName, List? errors = null) { _name = commandName; _errors = errors ?? new List(); diff --git a/ThunderstoreCLI/Commands/ImportGameCommand.cs b/ThunderstoreCLI/Commands/ImportGameCommand.cs new file mode 100644 index 0000000..45a6bfb --- /dev/null +++ b/ThunderstoreCLI/Commands/ImportGameCommand.cs @@ -0,0 +1,39 @@ +using ThunderstoreCLI.Configuration; +using ThunderstoreCLI.Game; +using ThunderstoreCLI.Models; +using ThunderstoreCLI.Utils; + +namespace ThunderstoreCLI.Commands; + +public static class ImportGameCommand +{ + public static int Run(Config config) + { + var http = new HttpClient(); + + var response = http.Send(new HttpRequestMessage(HttpMethod.Get, "https://gcdn.thunderstore.io/static/dev/schema/ecosystem-schema.0.0.2.json")); + + response.EnsureSuccessStatusCode(); + + var schema = SchemaResponse.Deserialize(response.Content.ReadAsStream())!; + + if (!schema.games.TryGetValue(config.GameImportConfig.GameId!, out var game)) + { + throw new CommandFatalException($"Could not find game with ID {config.GameImportConfig.GameId}"); + } + + var def = game.ToGameDefintion(config); + if (def == null) + { + throw new CommandFatalException("Game not installed"); + } + + var collection = GameDefinitionCollection.FromDirectory(config.GeneralConfig.TcliConfig); + collection.List.Add(def); + collection.Write(); + + Write.Success($"Successfully imported {def.Name} ({def.Identifier}) with install folder \"{def.InstallDirectory}\""); + + return 0; + } +} diff --git a/ThunderstoreCLI/Commands/InitCommand.cs b/ThunderstoreCLI/Commands/InitCommand.cs index 458806d..a63bb57 100644 --- a/ThunderstoreCLI/Commands/InitCommand.cs +++ b/ThunderstoreCLI/Commands/InitCommand.cs @@ -1,11 +1,12 @@ -using ThunderstoreCLI.Config; -using ThunderstoreCLI.Options; +using ThunderstoreCLI.Configuration; +using ThunderstoreCLI.Models; +using ThunderstoreCLI.Utils; namespace ThunderstoreCLI.Commands; public static class InitCommand { - public static int Run(Config.Config config) + public static int Run(Config config) { try { @@ -37,7 +38,7 @@ public static int Run(Config.Config config) { Write.Line($"Project configuration already exists, overwriting"); } - ProjectFileConfig.Write(config, path); + File.WriteAllText(path, new ThunderstoreProject(true).Serialize()); var iconPath = config.GetPackageIconPath(); if (File.Exists(iconPath)) @@ -64,29 +65,29 @@ public static int Run(Config.Config config) } } - public static string BuildReadme(Config.Config config) + public static string BuildReadme(Config config) { return $@" -# {config.PackageMeta.Namespace}-{config.PackageMeta.Name} +# {config.PackageConfig.Namespace}-{config.PackageConfig.Name} -{config.PackageMeta.Description} +{config.PackageConfig.Description} ".Trim(); } - private static void ValidateConfig(Config.Config config) + private static void ValidateConfig(Config config) { - var v = new Config.Validator("init"); - v.AddIfEmpty(config.PackageMeta.Namespace, "Package Namespace"); - v.AddIfEmpty(config.PackageMeta.Name, "Package Name"); - v.AddIfNotSemver(config.PackageMeta.VersionNumber, "Package VersionNumber"); - v.AddIfNull(config.PackageMeta.Description, "Package Description"); - v.AddIfNull(config.PackageMeta.WebsiteUrl, "Package WebsiteUrl"); - v.AddIfNull(config.PackageMeta.ContainsNsfwContent, "Package ContainsNsfwContent"); - v.AddIfNull(config.PackageMeta.Dependencies, "Package Dependencies"); + var v = new CommandValidator("init"); + v.AddIfEmpty(config.PackageConfig.Namespace, "Package Namespace"); + v.AddIfEmpty(config.PackageConfig.Name, "Package Name"); + v.AddIfNotSemver(config.PackageConfig.VersionNumber, "Package VersionNumber"); + v.AddIfNull(config.PackageConfig.Description, "Package Description"); + v.AddIfNull(config.PackageConfig.WebsiteUrl, "Package WebsiteUrl"); + v.AddIfNull(config.PackageConfig.ContainsNsfwContent, "Package ContainsNsfwContent"); + v.AddIfNull(config.PackageConfig.Dependencies, "Package Dependencies"); v.AddIfEmpty(config.BuildConfig.IconPath, "Build IconPath"); v.AddIfEmpty(config.BuildConfig.ReadmePath, "Build ReadmePath"); v.AddIfEmpty(config.BuildConfig.OutDir, "Build OutDir"); - v.AddIfEmpty(config.PublishConfig.Repository, "Publish Repository"); + v.AddIfEmpty(config.GeneralConfig.Repository, "Publish Repository"); v.ThrowIfErrors(); } } diff --git a/ThunderstoreCLI/Commands/InstallCommand.cs b/ThunderstoreCLI/Commands/InstallCommand.cs new file mode 100644 index 0000000..1d7bb28 --- /dev/null +++ b/ThunderstoreCLI/Commands/InstallCommand.cs @@ -0,0 +1,179 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Net.Http.Headers; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using ThunderstoreCLI.Configuration; +using ThunderstoreCLI.Game; +using ThunderstoreCLI.Models; +using ThunderstoreCLI.Utils; + +namespace ThunderstoreCLI.Commands; + +public static partial class InstallCommand +{ + // will match either ab-cd or ab-cd-123.456.7890 + internal static readonly Regex FullPackageNameRegex = new Regex(@"^(?(?[\w-\.]+)-(?\w+))(?:|-(?\d+\.\d+\.\d+))$"); + + public static async Task Run(Config config) + { + var defCollection = GameDefinitionCollection.FromDirectory(config.GeneralConfig.TcliConfig); + var defs = defCollection.List; + GameDefinition? def = defs.FirstOrDefault(x => x.Identifier == config.ModManagementConfig.GameIdentifer); + if (def == null) + { + Write.ErrorExit($"Not configured for the game: {config.ModManagementConfig.GameIdentifer}"); + return 1; + } + + ModProfile? profile = def.Profiles.FirstOrDefault(x => x.Name == config.ModManagementConfig.ProfileName); + profile ??= new ModProfile(def, config.ModManagementConfig.ProfileName!, config.GeneralConfig.TcliConfig); + + string package = config.ModManagementConfig.Package!; + + HttpClient http = new(); + + int returnCode; + Match packageMatch = FullPackageNameRegex.Match(package); + if (File.Exists(package)) + { + returnCode = await InstallZip(config, http, def, profile, package, null, null, false); + } + else if (packageMatch.Success) + { + returnCode = await InstallFromRepository(config, http, def, profile, packageMatch); + } + else + { + throw new CommandFatalException($"Package given does not exist as a zip and is not a valid package identifier (namespace-name): {package}"); + } + + if (returnCode == 0) + defCollection.Write(); + + return returnCode; + } + + private static async Task InstallFromRepository(Config config, HttpClient http, GameDefinition game, ModProfile profile, Match packageMatch) + { + PackageVersionData? versionData = null; + Write.Light($"Downloading main package: {packageMatch.Groups["fullname"].Value}"); + + var ns = packageMatch.Groups["namespace"]; + var name = packageMatch.Groups["name"]; + var version = packageMatch.Groups["version"]; + if (version.Success) + { + var versionResponse = await http.SendAsync(config.Api.GetPackageVersionMetadata(ns.Value, name.Value, version.Value)); + if (!versionResponse.IsSuccessStatusCode) + throw new CommandFatalException($"Couldn't find version {version} of mod {ns}-{name}"); + versionData = (await PackageVersionData.DeserializeAsync(await versionResponse.Content.ReadAsStreamAsync()))!; + } + var packageResponse = await http.SendAsync(config.Api.GetPackageMetadata(ns.Value, name.Value)); + if (!packageResponse.IsSuccessStatusCode) + throw new CommandFatalException($"Could not find package with the name {ns}-{name}"); + var packageData = await PackageData.DeserializeAsync(await packageResponse.Content.ReadAsStreamAsync()); + + versionData ??= packageData!.LatestVersion!; + + var zipPath = await config.Cache.GetFileOrDownload($"{versionData.FullName}.zip", versionData.DownloadUrl!); + var returnCode = await InstallZip(config, http, game, profile, zipPath, versionData.Namespace!, packageData!.CommunityListings!.First().Community, packageData.CommunityListings!.First().Categories!.Contains("Modpacks")); + return returnCode; + } + + private static async Task InstallZip(Config config, HttpClient http, GameDefinition game, ModProfile profile, string zipPath, string? backupNamespace, string? sourceCommunity, bool isModpack) + { + using var zip = ZipFile.OpenRead(zipPath); + var manifestFile = zip.GetEntry("manifest.json") ?? throw new CommandFatalException("Package zip needs a manifest.json!"); + var manifest = await PackageManifestV1.DeserializeAsync(manifestFile.Open()) + ?? throw new CommandFatalException("Package manifest.json is invalid! Please check against https://thunderstore.io/tools/manifest-v1-validator/"); + + manifest.Namespace ??= backupNamespace; + + var dependenciesToInstall = ModDependencyTree.Generate(config, http, manifest, sourceCommunity, isModpack) + .Where(dependency => !profile.InstalledModVersions.ContainsKey(dependency.FullNameParts["fullname"].Value)) + .ToArray(); + + if (dependenciesToInstall.Length > 0) + { + var totalSize = dependenciesToInstall + .Where(d => !config.Cache.ContainsFile($"{d.FullName}-{d.VersionNumber}.zip")) + .Select(d => d.FileSize) + .Sum(); + if (totalSize != 0) + { + Write.Light($"Total estimated download size: {MiscUtils.GetSizeString(totalSize)}"); + } + + var downloadTasks = dependenciesToInstall.Select(mod => + config.Cache.GetFileOrDownload($"{mod.FullName}-{mod.VersionNumber}.zip", mod.DownloadUrl!) + ).ToArray(); + + var spinner = new ProgressSpinner("dependencies downloaded", downloadTasks); + await spinner.Spin(); + + foreach (var (tempZipPath, pVersion) in downloadTasks.Select(x => x.Result).Zip(dependenciesToInstall)) + { + int returnCode = RunInstaller(game, profile, tempZipPath, pVersion.FullNameParts["namespace"].Value); + if (returnCode == 0) + { + Write.Success($"Installed mod: {pVersion.FullName}"); + } + else + { + Write.Error($"Failed to install mod: {pVersion.FullName}"); + return returnCode; + } + profile.InstalledModVersions[pVersion.FullNameParts["fullname"].Value] = new InstalledModVersion(pVersion.FullNameParts["fullname"].Value, pVersion.VersionNumber!, pVersion.Dependencies!); + } + } + + var exitCode = RunInstaller(game, profile, zipPath, backupNamespace); + if (exitCode == 0) + { + profile.InstalledModVersions[manifest.FullName] = new InstalledModVersion(manifest.FullName, manifest.VersionNumber!, manifest.Dependencies!); + Write.Success($"Installed mod: {manifest.FullName}-{manifest.VersionNumber}"); + } + else + { + Write.Error($"Failed to install mod: {manifest.FullName}-{manifest.VersionNumber}"); + } + return exitCode; + } + + // TODO: conflict handling + private static int RunInstaller(GameDefinition game, ModProfile profile, string zipPath, string? backupNamespace) + { + // TODO: how to decide which installer to run? + string installerName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "tcli-bepinex-installer.exe" : "tcli-bepinex-installer"; + var bepinexInstallerPath = Path.Combine(AppContext.BaseDirectory, installerName); + + ProcessStartInfo installerInfo = new(bepinexInstallerPath) + { + ArgumentList = + { + "install", + game.InstallDirectory, + profile.ProfileDirectory, + zipPath + }, + RedirectStandardError = true + }; + if (backupNamespace != null) + { + installerInfo.ArgumentList.Add("--namespace-backup"); + installerInfo.ArgumentList.Add(backupNamespace); + } + + var installerProcess = Process.Start(installerInfo)!; + installerProcess.WaitForExit(); + + string errors = installerProcess.StandardError.ReadToEnd(); + if (!string.IsNullOrWhiteSpace(errors)) + { + Write.Error(errors); + } + + return installerProcess.ExitCode; + } +} diff --git a/ThunderstoreCLI/Commands/ListCommand.cs b/ThunderstoreCLI/Commands/ListCommand.cs new file mode 100644 index 0000000..a2afd10 --- /dev/null +++ b/ThunderstoreCLI/Commands/ListCommand.cs @@ -0,0 +1,30 @@ +using ThunderstoreCLI.Configuration; +using ThunderstoreCLI.Game; +using ThunderstoreCLI.Utils; + +namespace ThunderstoreCLI.Commands; + +public static class ListCommand +{ + public static void Run(Config config) + { + var collection = GameDefinitionCollection.FromDirectory(config.GeneralConfig.TcliConfig); + + foreach (var game in collection) + { + Write.Line($"{game.Identifier}:"); + Write.Line($" Name: {game.Name}"); + Write.Line($" Path: {game.InstallDirectory}"); + Write.Line($" Profiles:"); + foreach (var profile in game.Profiles) + { + Write.Line($" Name: {profile.Name}"); + Write.Line($" Mods:"); + foreach (var mod in profile.InstalledModVersions.Values) + { + Write.Line($" {mod.FullName}-{mod.VersionNumber}"); + } + } + } + } +} diff --git a/ThunderstoreCLI/Commands/PublishCommand.cs b/ThunderstoreCLI/Commands/PublishCommand.cs index 810b8c5..de07e9b 100644 --- a/ThunderstoreCLI/Commands/PublishCommand.cs +++ b/ThunderstoreCLI/Commands/PublishCommand.cs @@ -1,7 +1,9 @@ using System.Net; using System.Security.Cryptography; using System.Text; +using ThunderstoreCLI.Configuration; using ThunderstoreCLI.Models; +using ThunderstoreCLI.Utils; using static Crayon.Output; namespace ThunderstoreCLI.Commands; @@ -18,7 +20,7 @@ static PublishCommand() HttpClient.Timeout = TimeSpan.FromHours(1); } - public static int Run(Config.Config config) + public static int Run(Config config) { try { @@ -47,7 +49,7 @@ public static int Run(Config.Config config) return PublishFile(config, packagePath); } - public static int PublishFile(Config.Config config, string filepath) + public static int PublishFile(Config config, string filepath) { Write.WithNL($"Publishing {Cyan(filepath)}", before: true, after: true); @@ -89,7 +91,7 @@ public static int PublishFile(Config.Config config, string filepath) try { var spinner = new ProgressSpinner("chunks uploaded", uploadTasks); - spinner.Start().GetAwaiter().GetResult(); + spinner.Spin().GetAwaiter().GetResult(); } catch (SpinnerException) { @@ -131,7 +133,7 @@ public static int PublishFile(Config.Config config, string filepath) return 0; } - private static UploadInitiateData InitiateUploadRequest(Config.Config config, string filepath) + private static UploadInitiateData InitiateUploadRequest(Config config, string filepath) { var response = HttpClient.Send(config.Api.StartUploadMedia(filepath)); @@ -159,22 +161,13 @@ private static UploadInitiateData InitiateUploadRequest(Config.Config config, st throw new PublishCommandException(); } - string[] suffixes = { "B", "KB", "MB", "GB", "TB" }; - int suffixIndex = 0; - long size = uploadData.Metadata.Size; - while (size >= 1024 && suffixIndex < suffixes.Length) - { - size /= 1024; - suffixIndex++; - } - - var details = $"({size}{suffixes[suffixIndex]}) in {uploadData.UploadUrls.Length} chunks..."; + var details = $"({MiscUtils.GetSizeString(uploadData.Metadata.Size)}) in {uploadData.UploadUrls.Length} chunks..."; Write.WithNL($"Uploading {Cyan(uploadData.Metadata.Filename)} {details}", after: true); return uploadData; } - private static void PublishPackageRequest(Config.Config config, string uploadUuid) + private static void PublishPackageRequest(Config config, string uploadUuid) { var response = HttpClient.Send(config.Api.SubmitPackage(uploadUuid)); @@ -193,7 +186,7 @@ private static void PublishPackageRequest(Config.Config config, string uploadUui throw new PublishCommandException(); } - Write.Success($"Successfully published {Cyan($"{config.PackageMeta.Namespace}-{config.PackageMeta.Name}")}"); + Write.Success($"Successfully published {Cyan($"{config.PackageConfig.Namespace}-{config.PackageConfig.Name}")}"); Write.Line($"It's available at {Cyan(jsonData.PackageVersion.DownloadUrl)}"); } @@ -246,11 +239,7 @@ private static void PublishPackageRequest(Config.Config config, string uploadUui using var response = await HttpClient.SendAsync(request); - try - { - response.EnsureSuccessStatusCode(); - } - catch + if (!response.IsSuccessStatusCode) { Write.Empty(); Write.ErrorExit(await response.Content.ReadAsStringAsync()); @@ -298,10 +287,10 @@ private static void HandleRequestError( throw new PublishCommandException(); } - private static void ValidateConfig(Config.Config config, bool justReturnErrors = false) + private static void ValidateConfig(Config config, bool justReturnErrors = false) { var buildConfigErrors = BuildCommand.ValidateConfig(config, false); - var v = new Config.Validator("publish", buildConfigErrors); + var v = new CommandValidator("publish", buildConfigErrors); v.AddIfEmpty(config.AuthConfig.AuthToken, "Auth AuthToken"); v.ThrowIfErrors(); } diff --git a/ThunderstoreCLI/Commands/RunCommand.cs b/ThunderstoreCLI/Commands/RunCommand.cs new file mode 100644 index 0000000..d3b7de2 --- /dev/null +++ b/ThunderstoreCLI/Commands/RunCommand.cs @@ -0,0 +1,152 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using ThunderstoreCLI.Configuration; +using ThunderstoreCLI.Game; +using ThunderstoreCLI.Utils; + +namespace ThunderstoreCLI.Commands; + +public static class RunCommand +{ + public static int Run(Config config) + { + var collection = GameDefinitionCollection.FromDirectory(config.GeneralConfig.TcliConfig); + var def = collection.FirstOrDefault(g => g.Identifier == config.RunGameConfig.GameName); + + if (def == null) + { + throw new CommandFatalException($"No mods installed for game {config.RunGameConfig.GameName}"); + } + + var profile = def.Profiles.FirstOrDefault(p => p.Name == config.RunGameConfig.ProfileName); + + if (profile == null) + { + throw new CommandFatalException($"No profile found with the name {config.RunGameConfig.ProfileName}"); + } + + var isSteam = def.Platform == GamePlatform.Steam; + + ProcessStartInfo startInfo = new(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "tcli-bepinex-installer.exe" : "tcli-bepinex-installer") + { + ArgumentList = + { + "start-instructions", + def.InstallDirectory, + profile.ProfileDirectory + }, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + // TODO: Wine without Steam + var gameIsProton = isSteam && SteamUtils.IsProtonGame(def.PlatformId!); + + startInfo.ArgumentList.Add("--game-platform"); + startInfo.ArgumentList.Add((isWindows, gameIsProton) switch + { + (true, _) => "windows", + (false, true) => "proton", + (false, false) => "linux" + }); + + var installerProcess = Process.Start(startInfo)!; + installerProcess.WaitForExit(); + + string errors = installerProcess.StandardError.ReadToEnd(); + if (!string.IsNullOrWhiteSpace(errors) || installerProcess.ExitCode != 0) + { + throw new CommandFatalException($"Installer failed with errors:\n{errors}"); + } + + string runArguments = ""; + string[] wineDlls = Array.Empty(); + List> environ = new(); + + string[] outputLines = installerProcess.StandardOutput.ReadToEnd().Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in outputLines) + { + var firstColon = line.IndexOf(':'); + if (firstColon == -1) + { + continue; + } + var command = line[..firstColon]; + var args = line[(firstColon + 1)..]; + switch (command) + { + case "ARGUMENTS": + runArguments = args; + break; + case "WINEDLLOVERRIDE": + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + break; + } + wineDlls = args.Split(':'); + break; + case "ENVIRONMENT": + var parts = args.Split('='); + environ.Add(new(parts[0], parts[1])); + break; + } + } + + var allArgs = string.Join(' ', runArguments, config.RunGameConfig.UserArguments); + + if (isSteam) + { + var steamExePath = SteamUtils.FindSteamExecutable(); + if (steamExePath == null) + { + throw new CommandFatalException("Couldn't find steam install directory!"); + } + + if (gameIsProton && wineDlls.Length > 0) + { + if (!SteamUtils.ForceLoadProton(def.PlatformId!, wineDlls)) + { + throw new CommandFatalException($"No compat files could be found for app id {def.PlatformId}, please run the game at least once."); + } + } + + ProcessStartInfo runSteamInfo = new(steamExePath) + { + Arguments = $"-applaunch {def.PlatformId} {allArgs}" + }; + + Write.Note($"Starting appid {def.PlatformId} with arguments: {allArgs}"); + var steamProcess = Process.Start(runSteamInfo)!; + steamProcess.WaitForExit(); + Write.Success($"Started game with appid {def.PlatformId}"); + } + else if (def.Platform == GamePlatform.Other) + { + var exePath = def.ExePath!; + + if (!File.Exists(exePath)) + { + throw new CommandFatalException($"Executable {exePath} could not be found."); + } + + ProcessStartInfo process = new(exePath) + { + Arguments = allArgs, + WorkingDirectory = def.InstallDirectory, + }; + foreach (var (key, val) in environ) + { + Write.Line($"{key}: {val}"); + process.Environment.Add(key, val); + } + + Write.Note($"Starting {exePath} with arguments: {allArgs}"); + + Process.Start(process)!.WaitForExit(); + } + + return 0; + } +} diff --git a/ThunderstoreCLI/Commands/UninstallCommand.cs b/ThunderstoreCLI/Commands/UninstallCommand.cs new file mode 100644 index 0000000..c510fc5 --- /dev/null +++ b/ThunderstoreCLI/Commands/UninstallCommand.cs @@ -0,0 +1,106 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using ThunderstoreCLI.Configuration; +using ThunderstoreCLI.Game; +using ThunderstoreCLI.Utils; + +namespace ThunderstoreCLI.Commands; + +public static class UninstallCommand +{ + public static int Run(Config config) + { + var defCollection = GameDefinitionCollection.FromDirectory(config.GeneralConfig.TcliConfig); + GameDefinition? def = defCollection.FirstOrDefault(def => def.Identifier == config.ModManagementConfig.GameIdentifer); + if (def == null) + { + throw new CommandFatalException($"No installed mods for game ${config.ModManagementConfig.GameIdentifer}"); + } + ModProfile? profile = def.Profiles.FirstOrDefault(p => p.Name == config.ModManagementConfig.ProfileName); + if (profile == null) + { + throw new CommandFatalException($"No profile with the name {config.ModManagementConfig.ProfileName}"); + } + + if (!profile.InstalledModVersions.ContainsKey(config.ModManagementConfig.Package!)) + { + throw new CommandFatalException($"The package {config.ModManagementConfig.Package} is not installed in the profile {profile.Name}"); + } + + HashSet modsToRemove = new() { config.ModManagementConfig.Package! }; + Queue modsToSearch = new(); + modsToSearch.Enqueue(config.ModManagementConfig.Package!); + while (modsToSearch.TryDequeue(out var search)) + { + var searchWithDash = search + '-'; + foreach (var mod in profile.InstalledModVersions.Values) + { + if (mod.Dependencies.Any(s => s.StartsWith(searchWithDash))) + { + if (modsToRemove.Add(mod.FullName)) + { + modsToSearch.Enqueue(mod.FullName); + } + } + } + } + + foreach (var mod in modsToRemove) + { + profile.InstalledModVersions.Remove(mod); + } + + Write.Line($"The following mods will be uninstalled:\n{string.Join('\n', modsToRemove)}"); + char key; + do + { + Write.NoLine("Continue? (y/n): "); + key = Console.ReadKey().KeyChar; + Write.Empty(); + } + while (key is not 'y' and not 'n'); + + if (key == 'n') + return 0; + + List failedMods = new(); + foreach (var toRemove in modsToRemove) + { + string installerName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "tcli-bepinex-installer.exe" : "tcli-bepinex-installer"; + var bepinexInstallerPath = Path.Combine(AppContext.BaseDirectory, installerName); + + ProcessStartInfo installerInfo = new(bepinexInstallerPath) + { + ArgumentList = + { + "uninstall", + def.InstallDirectory, + profile.ProfileDirectory, + toRemove + }, + RedirectStandardError = true + }; + + var installerProcess = Process.Start(installerInfo)!; + installerProcess.WaitForExit(); + + Write.Success($"Uninstalled mod: {toRemove}"); + + string errors = installerProcess.StandardError.ReadToEnd(); + if (!string.IsNullOrWhiteSpace(errors) || installerProcess.ExitCode != 0) + { + Write.Error(errors); + failedMods.Add(toRemove); + } + } + + if (failedMods.Count != 0) + { + throw new CommandFatalException($"The following mods failed to uninstall:\n{string.Join('\n', failedMods)}"); + } + + defCollection.Write(); + + return 0; + } +} diff --git a/ThunderstoreCLI/Config/CLIParameterConfig.cs b/ThunderstoreCLI/Config/CLIParameterConfig.cs deleted file mode 100644 index 8825f7e..0000000 --- a/ThunderstoreCLI/Config/CLIParameterConfig.cs +++ /dev/null @@ -1,73 +0,0 @@ -using ThunderstoreCLI.Options; - -namespace ThunderstoreCLI.Config; - -public abstract class CLIParameterConfig : EmptyConfig where T : PackageOptions -{ - protected T options; - - public CLIParameterConfig(T options) - { - this.options = options; - } - - public override GeneralConfig GetGeneralConfig() - { - return new GeneralConfig() - { - ProjectConfigPath = options.ConfigPath - }; - } - - public override PackageMeta? GetPackageMeta() - { - if (options == null) - return null; - return new PackageMeta() - { - Namespace = options.Namespace, - Name = options.Name, - VersionNumber = options.VersionNumber - }; - } -} - -public class CLIInitCommandConfig : CLIParameterConfig -{ - public CLIInitCommandConfig(InitOptions options) : base(options) { } - - public override InitConfig GetInitConfig() - { - return new InitConfig() - { - Overwrite = options.Overwrite - }; - } -} - -public class CLIBuildCommandConfig : CLIParameterConfig -{ - public CLIBuildCommandConfig(BuildOptions options) : base(options) { } -} - -public class CLIPublishCommandConfig : CLIParameterConfig -{ - public CLIPublishCommandConfig(PublishOptions options) : base(options) { } - - public override PublishConfig GetPublishConfig() - { - return new PublishConfig() - { - File = options.File, - Repository = options.Repository - }; - } - - public override AuthConfig GetAuthConfig() - { - return new AuthConfig() - { - AuthToken = options.Token - }; - } -} diff --git a/ThunderstoreCLI/Config/Config.cs b/ThunderstoreCLI/Config/Config.cs deleted file mode 100644 index 73d7203..0000000 --- a/ThunderstoreCLI/Config/Config.cs +++ /dev/null @@ -1,200 +0,0 @@ -using System.Net.Http.Headers; -using ThunderstoreCLI.API; -using ThunderstoreCLI.Models; - -namespace ThunderstoreCLI.Config; - -public class Config -{ - // ReSharper disable AutoPropertyCanBeMadeGetOnly.Local - public GeneralConfig GeneralConfig { get; private set; } - public PackageMeta PackageMeta { get; private set; } - public InitConfig InitConfig { get; private set; } - public BuildConfig BuildConfig { get; private set; } - public PublishConfig PublishConfig { get; private set; } - public AuthConfig AuthConfig { get; private set; } - // ReSharper restore AutoPropertyCanBeMadeGetOnly.Local - - private readonly Lazy api; - public ApiHelper Api => api.Value; - - private Config(GeneralConfig generalConfig, PackageMeta packageMeta, InitConfig initConfig, BuildConfig buildConfig, PublishConfig publishConfig, AuthConfig authConfig) - { - api = new Lazy(() => new ApiHelper(this)); - GeneralConfig = generalConfig; - PackageMeta = packageMeta; - InitConfig = initConfig; - BuildConfig = buildConfig; - PublishConfig = publishConfig; - AuthConfig = authConfig; - } - - public string? GetProjectBasePath() - { - return Path.GetDirectoryName(GetProjectConfigPath()); - } - - public string GetProjectRelativePath(string path) - { - return Path.GetFullPath(Path.Join(GetProjectBasePath(), path)); - } - - public string GetPackageIconPath() - { - if (BuildConfig.IconPath is null) - { - throw new Exception("BuildConfig.IconPath can't be null"); - } - return GetProjectRelativePath(BuildConfig.IconPath); - } - - public string GetPackageReadmePath() - { - if (BuildConfig.ReadmePath is null) - { - throw new Exception("BuildConfig.ReadmePath can't be null"); - } - return GetProjectRelativePath(BuildConfig.ReadmePath); - } - - public string GetProjectConfigPath() - { - if (GeneralConfig.ProjectConfigPath is null) - { - throw new Exception("GeneralConfig.ProjectConfigPath can't be null"); - } - return Path.GetFullPath(GeneralConfig.ProjectConfigPath); - } - - public string GetBuildOutputDir() - { - if (BuildConfig.OutDir is null) - { - throw new Exception("BuildConfig.OutDir can't be null"); - } - return GetProjectRelativePath(BuildConfig.OutDir); - } - - public string GetPackageId() - { - return $"{PackageMeta.Namespace}-{PackageMeta.Name}-{PackageMeta.VersionNumber}"; - } - - public string GetBuildOutputFile() - { - return Path.GetFullPath(Path.Join(GetBuildOutputDir(), $"{GetPackageId()}.zip")); - } - - public PackageUploadMetadata GetUploadMetadata(string fileUuid) - { - return new PackageUploadMetadata() - { - AuthorName = PackageMeta.Namespace, - Categories = PublishConfig.Categories, - Communities = PublishConfig.Communities, - HasNsfwContent = PackageMeta.ContainsNsfwContent ?? false, - UploadUUID = fileUuid - }; - } - - public static Config Parse(params IConfigProvider[] configProviders) - { - var generalConfig = new GeneralConfig(); - var packageMeta = new PackageMeta(); - var initConfig = new InitConfig(); - var buildConfig = new BuildConfig(); - var publishConfig = new PublishConfig(); - var authConfig = new AuthConfig(); - var result = new Config(generalConfig, packageMeta, initConfig, buildConfig, publishConfig, authConfig); - foreach (var provider in configProviders) - { - provider.Parse(result); - Merge(generalConfig, provider.GetGeneralConfig(), false); - Merge(packageMeta, provider.GetPackageMeta(), false); - Merge(initConfig, provider.GetInitConfig(), false); - Merge(buildConfig, provider.GetBuildConfig(), false); - Merge(publishConfig, provider.GetPublishConfig(), false); - Merge(authConfig, provider.GetAuthConfig(), false); - } - return result; - } - - public static void Merge(T target, T source, bool overwrite) - { - if (source == null) - return; - - var t = typeof(T); - var properties = t.GetProperties(); - - foreach (var prop in properties.Where(x => x.SetMethod is not null)) - { - var sourceVal = prop.GetValue(source, null); - if (sourceVal != null) - { - var targetVal = prop.GetValue(target, null); - if (targetVal == null || overwrite) - prop.SetValue(target, sourceVal, null); - } - } - } -} - -public class GeneralConfig -{ - public string? ProjectConfigPath { get; set; } -} - -public class PackageMeta -{ - public string? Namespace { get; set; } - public string? Name { get; set; } - public string? VersionNumber { get; set; } - public string? Description { get; set; } - public string? WebsiteUrl { get; set; } - public bool? ContainsNsfwContent { get; set; } - public Dictionary? Dependencies { get; set; } -} - -public class InitConfig -{ - public bool? Overwrite { get; set; } - - public bool ShouldOverwrite() - { - return Overwrite ?? false; - } -} - -public struct CopyPathMap -{ - public readonly string From; - public readonly string To; - - public CopyPathMap(string from, string to) - { - From = from; - To = to; - } -} - -public class BuildConfig -{ - public string? IconPath { get; set; } - public string? ReadmePath { get; set; } - public string? OutDir { get; set; } - public List? CopyPaths { get; set; } -} - -public class PublishConfig -{ - public string? File { get; set; } - public string? Repository { get; set; } - public string[]? Communities { get; set; } - public string[]? Categories { get; set; } -} - -public class AuthConfig -{ - public string? AuthToken { get; set; } -} diff --git a/ThunderstoreCLI/Config/ProjectFileConfig.cs b/ThunderstoreCLI/Config/ProjectFileConfig.cs deleted file mode 100644 index d63fb00..0000000 --- a/ThunderstoreCLI/Config/ProjectFileConfig.cs +++ /dev/null @@ -1,200 +0,0 @@ -using Tommy; -using static Crayon.Output; - -namespace ThunderstoreCLI.Config; - -class ProjectFileConfig : EmptyConfig -{ - - private PackageMeta? PackageMeta { get; set; } - - private BuildConfig? BuildConfig { get; set; } - - private PublishConfig? PublishConfig { get; set; } - - public override void Parse(Config currentConfig) - { - var tomlData = Read(currentConfig); - if (tomlData == null) - return; - - if (!tomlData.HasKey("config") || !tomlData["config"].HasKey("schemaVersion")) - { - ThunderstoreCLI.Write.Warn( - "Project configuration is lacking schema version", - "Might not be able to parse configuration as expected" - ); - } - if (tomlData["config"]["schemaVersion"] != "0.0.1") - { - ThunderstoreCLI.Write.Warn( - "Unknown project configuration schema version", - "Might not be able to parse configuration as expected" - ); - } - - PackageMeta = ParsePackageMeta(tomlData); - BuildConfig = ParseBuildConfig(tomlData); - PublishConfig = ParsePublishConfig(tomlData); - } - - protected static PackageMeta? ParsePackageMeta(TomlTable tomlData) - { - if (!tomlData.HasKey("package")) - return null; - - var packageMeta = tomlData["package"]; - - // TODO: Add warnings on missing values - var result = new PackageMeta() - { - Namespace = TomlUtils.SafegetString(packageMeta, "namespace"), - Name = TomlUtils.SafegetString(packageMeta, "name"), - VersionNumber = TomlUtils.SafegetString(packageMeta, "versionNumber"), - Description = TomlUtils.SafegetString(packageMeta, "description"), - WebsiteUrl = TomlUtils.SafegetString(packageMeta, "websiteUrl"), - ContainsNsfwContent = TomlUtils.SafegetBool(packageMeta, "containsNsfwContent"), - Dependencies = new() - }; - - if (packageMeta.HasKey("dependencies")) - { - var packageDependencies = packageMeta["dependencies"]; - foreach (var packageName in packageDependencies.Keys) - { - // TODO: Validate both are strings if needed? - result.Dependencies[packageName] = packageDependencies[packageName]; - } - } - - return result; - } - - protected static BuildConfig? ParseBuildConfig(TomlTable tomlData) - { - if (!tomlData.HasKey("build")) - return null; - - var buildConfig = tomlData["build"]; - - var result = new BuildConfig - { - IconPath = TomlUtils.SafegetString(buildConfig, "icon"), - ReadmePath = TomlUtils.SafegetString(buildConfig, "readme"), - OutDir = TomlUtils.SafegetString(buildConfig, "outdir"), - CopyPaths = new() - }; - - if (buildConfig.HasKey("copy")) - { - var pathSets = buildConfig["copy"]; - foreach (var entry in pathSets) - { - if (!(entry is TomlNode)) - { - ThunderstoreCLI.Write.Warn($"Unable to properly parse build config: {entry}", "Skipping entry"); - continue; - } - - var node = (TomlNode) entry; - if (!node.HasKey("source") || !node.HasKey("target")) - { - ThunderstoreCLI.Write.Warn( - $"Build config instruction is missing parameters: {node}", - "Make sure both 'source' and 'target' are defined", - "Skipping entry" - ); - continue; - } - - result.CopyPaths.Add(new CopyPathMap(node["source"], node["target"])); - } - } - return result; - } - - protected static PublishConfig? ParsePublishConfig(TomlTable tomlData) - { - if (!tomlData.HasKey("publish")) - return null; - - var publishConfig = tomlData["publish"]; - - return new PublishConfig - { - Repository = TomlUtils.SafegetString(publishConfig, "repository"), - Communities = TomlUtils.SafegetStringArray(publishConfig, "communities", Array.Empty()), - Categories = TomlUtils.SafegetStringArray(publishConfig, "categories", Array.Empty()) - }; - } - - public override PackageMeta? GetPackageMeta() - { - return PackageMeta; - } - - public override BuildConfig? GetBuildConfig() - { - return BuildConfig; - } - - public override PublishConfig? GetPublishConfig() - { - return PublishConfig; - } - - public static TomlTable? Read(Config config) - { - var configPath = config.GetProjectConfigPath(); - if (!File.Exists(configPath)) - { - ThunderstoreCLI.Write.Warn( - "Unable to find project configuration file", - $"Looked from {Dim(configPath)}" - ); - return null; - } - using var reader = new StreamReader(File.OpenRead(configPath)); - return TOML.Parse(reader); - } - - public static void Write(Config config, string path) - { - var dependencies = config.PackageMeta.Dependencies ?? new Dictionary(); - var copyPaths = config.BuildConfig.CopyPaths ?? new List(); - var toml = new TomlTable - { - ["config"] = - { - ["schemaVersion"] = "0.0.1" - }, - - ["package"] = new TomlTable - { - ["namespace"] = config.PackageMeta.Namespace, - ["name"] = config.PackageMeta.Name, - ["versionNumber"] = config.PackageMeta.VersionNumber, - ["description"] = config.PackageMeta.Description, - ["websiteUrl"] = config.PackageMeta.WebsiteUrl, - ["containsNsfwContent"] = config.PackageMeta.ContainsNsfwContent, - ["dependencies"] = TomlUtils.DictToTomlTable(dependencies) - }, - - ["build"] = new TomlTable - { - ["icon"] = config.BuildConfig.IconPath, - ["readme"] = config.BuildConfig.ReadmePath, - ["outdir"] = config.BuildConfig.OutDir, - ["copy"] = TomlUtils.BuildCopyPathTable(copyPaths) - }, - - ["publish"] = new TomlTable - { - ["repository"] = config.PublishConfig.Repository, - ["communities"] = TomlUtils.FromArray(config.PublishConfig.Communities ?? new string[0]), - ["categories"] = TomlUtils.FromArray(config.PublishConfig.Categories ?? new string[0]) - } - }; - File.WriteAllText(path, TomlUtils.FormatToml(toml)); - } -} diff --git a/ThunderstoreCLI/Configuration/CLIParameterConfig.cs b/ThunderstoreCLI/Configuration/CLIParameterConfig.cs new file mode 100644 index 0000000..759873e --- /dev/null +++ b/ThunderstoreCLI/Configuration/CLIParameterConfig.cs @@ -0,0 +1,128 @@ +namespace ThunderstoreCLI.Configuration; + +public abstract class BaseConfig : EmptyConfig where T : BaseOptions +{ + protected T options; + + public BaseConfig(T options) + { + this.options = options; + } + + public override GeneralConfig GetGeneralConfig() + { + return new GeneralConfig() + { + TcliConfig = options.TcliDirectory, + Repository = options.Repository + }; + } +} + +public interface CLIConfig { } + +public abstract class CLIParameterConfig : BaseConfig, CLIConfig where T : PackageOptions +{ + public CLIParameterConfig(T opts) : base(opts) { } + + public override PackageConfig? GetPackageMeta() + { + if (options == null) + return null; + return new PackageConfig() + { + ProjectConfigPath = options.ConfigPath, + Namespace = options.Namespace, + Name = options.Name, + VersionNumber = options.VersionNumber + }; + } +} + +public class CLIInitCommandConfig : CLIParameterConfig +{ + public CLIInitCommandConfig(InitOptions options) : base(options) { } + + public override InitConfig GetInitConfig() + { + return new InitConfig() + { + Overwrite = options.Overwrite + }; + } +} + +public class CLIBuildCommandConfig : CLIParameterConfig +{ + public CLIBuildCommandConfig(BuildOptions options) : base(options) { } +} + +public class CLIPublishCommandConfig : CLIParameterConfig +{ + public CLIPublishCommandConfig(PublishOptions options) : base(options) { } + + public override PublishConfig GetPublishConfig() + { + return new PublishConfig() + { + File = options.File + }; + } + + public override AuthConfig GetAuthConfig() + { + return new AuthConfig() + { + AuthToken = options.Token + }; + } +} + +public class ModManagementCommandConfig : BaseConfig +{ + public ModManagementCommandConfig(ModManagementOptions options) : base(options) { } + + public override ModManagementConfig? GetModManagementConfig() + { + return new ModManagementConfig() + { + GameIdentifer = options.GameName, + ProfileName = options.Profile, + Package = options.Package + }; + } +} + +public class GameImportCommandConfig : BaseConfig +{ + public GameImportCommandConfig(GameImportOptions options) : base(options) { } + + public override GameImportConfig? GetGameImportConfig() + { + return new GameImportConfig() + { + ExePath = options.ExePath, + GameId = options.GameId, + }; + } +} + +public class RunGameCommandConfig : BaseConfig +{ + public RunGameCommandConfig(RunGameOptions options) : base(options) { } + + public override RunGameConfig? GetRunGameConfig() + { + return new RunGameConfig() + { + GameName = options.GameName, + ProfileName = options.Profile, + UserArguments = options.Args, + }; + } +} + +public class ListConfig : BaseConfig +{ + public ListConfig(ListOptions options) : base(options) { } +} diff --git a/ThunderstoreCLI/Configuration/Config.cs b/ThunderstoreCLI/Configuration/Config.cs new file mode 100644 index 0000000..ff35a83 --- /dev/null +++ b/ThunderstoreCLI/Configuration/Config.cs @@ -0,0 +1,244 @@ +using System.Diagnostics.CodeAnalysis; +using ThunderstoreCLI.API; +using ThunderstoreCLI.Models; +using ThunderstoreCLI.Utils; + +namespace ThunderstoreCLI.Configuration; + +public class Config +{ + // ReSharper disable AutoPropertyCanBeMadeGetOnly.Local + public required GeneralConfig GeneralConfig { get; init; } + public required PackageConfig PackageConfig { get; init; } + public required InitConfig InitConfig { get; init; } + public required BuildConfig BuildConfig { get; init; } + public required PublishConfig PublishConfig { get; init; } + public required AuthConfig AuthConfig { get; init; } + public required ModManagementConfig ModManagementConfig { get; init; } + public required GameImportConfig GameImportConfig { get; init; } + public required RunGameConfig RunGameConfig { get; init; } + // ReSharper restore AutoPropertyCanBeMadeGetOnly.Local + + private readonly Lazy api; + public ApiHelper Api => api.Value; + + private readonly Lazy cache; + public DownloadCache Cache => cache.Value; + + private Config() + { + api = new Lazy(() => new ApiHelper(this)); + cache = new Lazy(() => new DownloadCache(Path.Combine(GeneralConfig!.TcliConfig, "ModCache"))); + } + public static Config FromCLI(IConfigProvider cliConfig) + { + List providers = new(); + providers.Add(cliConfig); + providers.Add(new EnvironmentConfig()); + if (cliConfig is CLIConfig) + providers.Add(new ProjectFileConfig()); + providers.Add(new DefaultConfig()); + return Parse(providers.ToArray()); + } + + public string? GetProjectBasePath() + { + return Path.GetDirectoryName(GetProjectConfigPath()); + } + + public string GetProjectRelativePath(string path) + { + return Path.Join(GetProjectBasePath(), path); + } + + public string GetPackageIconPath() + { + if (BuildConfig.IconPath is null) + { + throw new Exception("BuildConfig.IconPath can't be null"); + } + return GetProjectRelativePath(BuildConfig.IconPath); + } + + public string GetPackageReadmePath() + { + if (BuildConfig.ReadmePath is null) + { + throw new Exception("BuildConfig.ReadmePath can't be null"); + } + return GetProjectRelativePath(BuildConfig.ReadmePath); + } + + public string GetProjectConfigPath() + { + if (PackageConfig.ProjectConfigPath is null) + { + throw new Exception("GeneralConfig.ProjectConfigPath can't be null"); + } + return Path.GetFullPath(PackageConfig.ProjectConfigPath); + } + + public string GetBuildOutputDir() + { + if (BuildConfig.OutDir is null) + { + throw new Exception("BuildConfig.OutDir can't be null"); + } + return GetProjectRelativePath(BuildConfig.OutDir); + } + + public string GetPackageId() + { + return $"{PackageConfig.Namespace}-{PackageConfig.Name}-{PackageConfig.VersionNumber}"; + } + + public string GetBuildOutputFile() + { + return Path.GetFullPath(Path.Join(GetBuildOutputDir(), $"{GetPackageId()}.zip")); + } + + public PackageUploadMetadata GetUploadMetadata(string fileUuid) + { + return new PackageUploadMetadata() + { + AuthorName = PackageConfig.Namespace, + Categories = PublishConfig.Categories!.GetOrDefault("") ?? Array.Empty(), + CommunityCategories = PublishConfig.Categories! + .Where(kvp => kvp.Key != "") + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value), + Communities = PublishConfig.Communities, + HasNsfwContent = PackageConfig.ContainsNsfwContent ?? false, + UploadUUID = fileUuid + }; + } + + public static Config Parse(IConfigProvider[] configProviders) + { + Config result = new() + { + GeneralConfig = new GeneralConfig(), + PackageConfig = new PackageConfig(), + InitConfig = new InitConfig(), + BuildConfig = new BuildConfig(), + PublishConfig = new PublishConfig(), + AuthConfig = new AuthConfig(), + ModManagementConfig = new ModManagementConfig(), + GameImportConfig = new GameImportConfig(), + RunGameConfig = new RunGameConfig(), + }; + foreach (var provider in configProviders) + { + provider.Parse(result); + Merge(result.GeneralConfig, provider.GetGeneralConfig(), false); + Merge(result.PackageConfig, provider.GetPackageMeta(), false); + Merge(result.InitConfig, provider.GetInitConfig(), false); + Merge(result.BuildConfig, provider.GetBuildConfig(), false); + Merge(result.PublishConfig, provider.GetPublishConfig(), false); + Merge(result.AuthConfig, provider.GetAuthConfig(), false); + Merge(result.ModManagementConfig, provider.GetModManagementConfig(), false); + Merge(result.GameImportConfig, provider.GetGameImportConfig(), false); + Merge(result.RunGameConfig, provider.GetRunGameConfig(), false); + } + return result; + } + + public static void Merge<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T target, T source, bool overwrite) + { + if (source == null) + return; + + var t = typeof(T); + var properties = t.GetProperties(); + + foreach (var prop in properties.Where(x => x.SetMethod is not null)) + { + var sourceVal = prop.GetValue(source, null); + if (sourceVal != null) + { + var targetVal = prop.GetValue(target, null); + if (targetVal == null || overwrite) + prop.SetValue(target, sourceVal, null); + } + } + } +} + +public class GeneralConfig +{ + public string TcliConfig { get; set; } = null!; + public string Repository { get; set; } = null!; +} + +public class PackageConfig +{ + public string? ProjectConfigPath { get; set; } + public string? Namespace { get; set; } + public string? Name { get; set; } + public string? VersionNumber { get; set; } + public string? Description { get; set; } + public string? WebsiteUrl { get; set; } + public bool? ContainsNsfwContent { get; set; } + public Dictionary? Dependencies { get; set; } +} + +public class InitConfig +{ + public bool? Overwrite { get; set; } + + public bool ShouldOverwrite() + { + return Overwrite ?? false; + } +} + +public struct CopyPathMap +{ + public readonly string From; + public readonly string To; + + public CopyPathMap(string from, string to) + { + From = from; + To = to; + } +} + +public class BuildConfig +{ + public string? IconPath { get; set; } + public string? ReadmePath { get; set; } + public string? OutDir { get; set; } + public List? CopyPaths { get; set; } +} + +public class PublishConfig +{ + public string? File { get; set; } + public string[]? Communities { get; set; } + public Dictionary? Categories { get; set; } +} + +public class AuthConfig +{ + public string? AuthToken { get; set; } +} + +public class ModManagementConfig +{ + public string? GameIdentifer { get; set; } + public string? Package { get; set; } + public string? ProfileName { get; set; } +} + +public class GameImportConfig +{ + public string? ExePath { get; set; } + public string? GameId { get; set; } +} + +public class RunGameConfig +{ + public string? GameName { get; set; } + public string? ProfileName { get; set; } + public string? UserArguments { get; set; } +} diff --git a/ThunderstoreCLI/Config/BaseConfig.cs b/ThunderstoreCLI/Configuration/DefaultConfig.cs similarity index 78% rename from ThunderstoreCLI/Config/BaseConfig.cs rename to ThunderstoreCLI/Configuration/DefaultConfig.cs index 5e261ef..c78cef7 100644 --- a/ThunderstoreCLI/Config/BaseConfig.cs +++ b/ThunderstoreCLI/Configuration/DefaultConfig.cs @@ -1,19 +1,20 @@ -namespace ThunderstoreCLI.Config; +namespace ThunderstoreCLI.Configuration; -class BaseConfig : EmptyConfig +class DefaultConfig : EmptyConfig { - public override GeneralConfig GetGeneralConfig() + public override GeneralConfig? GetGeneralConfig() { return new GeneralConfig() { - ProjectConfigPath = Defaults.PROJECT_CONFIG_PATH + Repository = Defaults.REPOSITORY_URL }; } - public override PackageMeta GetPackageMeta() + public override PackageConfig GetPackageMeta() { - return new PackageMeta() + return new PackageConfig() { + ProjectConfigPath = Defaults.PROJECT_CONFIG_PATH, Namespace = "AuthorName", Name = "PackageName", VersionNumber = "0.0.1", @@ -53,8 +54,7 @@ public override PublishConfig GetPublishConfig() { return new PublishConfig() { - File = null, - Repository = Defaults.REPOSITORY_URL + File = null }; } } diff --git a/ThunderstoreCLI/Config/EmptyConfig.cs b/ThunderstoreCLI/Configuration/EmptyConfig.cs similarity index 63% rename from ThunderstoreCLI/Config/EmptyConfig.cs rename to ThunderstoreCLI/Configuration/EmptyConfig.cs index c0071a0..c3074b5 100644 --- a/ThunderstoreCLI/Config/EmptyConfig.cs +++ b/ThunderstoreCLI/Configuration/EmptyConfig.cs @@ -1,4 +1,4 @@ -namespace ThunderstoreCLI.Config; +namespace ThunderstoreCLI.Configuration; [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute] public abstract class EmptyConfig : IConfigProvider @@ -9,7 +9,7 @@ public virtual void Parse(Config currentConfig) { } return null; } - public virtual PackageMeta? GetPackageMeta() + public virtual PackageConfig? GetPackageMeta() { return null; } @@ -33,4 +33,19 @@ public virtual void Parse(Config currentConfig) { } { return null; } + + public virtual ModManagementConfig? GetModManagementConfig() + { + return null; + } + + public virtual GameImportConfig? GetGameImportConfig() + { + return null; + } + + public virtual RunGameConfig? GetRunGameConfig() + { + return null; + } } diff --git a/ThunderstoreCLI/Config/EnvironmentConfig.cs b/ThunderstoreCLI/Configuration/EnvironmentConfig.cs similarity index 96% rename from ThunderstoreCLI/Config/EnvironmentConfig.cs rename to ThunderstoreCLI/Configuration/EnvironmentConfig.cs index cf08dcf..3d64dbf 100644 --- a/ThunderstoreCLI/Config/EnvironmentConfig.cs +++ b/ThunderstoreCLI/Configuration/EnvironmentConfig.cs @@ -1,6 +1,6 @@ using static System.Environment; -namespace ThunderstoreCLI.Config; +namespace ThunderstoreCLI.Configuration; class EnvironmentConfig : EmptyConfig { diff --git a/ThunderstoreCLI/Config/IConfigProvider.cs b/ThunderstoreCLI/Configuration/IConfigProvider.cs similarity index 54% rename from ThunderstoreCLI/Config/IConfigProvider.cs rename to ThunderstoreCLI/Configuration/IConfigProvider.cs index 16c736b..89b2e70 100644 --- a/ThunderstoreCLI/Config/IConfigProvider.cs +++ b/ThunderstoreCLI/Configuration/IConfigProvider.cs @@ -1,13 +1,16 @@ -namespace ThunderstoreCLI.Config; +namespace ThunderstoreCLI.Configuration; public interface IConfigProvider { void Parse(Config currentConfig); GeneralConfig? GetGeneralConfig(); - PackageMeta? GetPackageMeta(); + PackageConfig? GetPackageMeta(); InitConfig? GetInitConfig(); BuildConfig? GetBuildConfig(); PublishConfig? GetPublishConfig(); AuthConfig? GetAuthConfig(); + ModManagementConfig? GetModManagementConfig(); + GameImportConfig? GetGameImportConfig(); + RunGameConfig? GetRunGameConfig(); } diff --git a/ThunderstoreCLI/Configuration/ProjectFileConfig.cs b/ThunderstoreCLI/Configuration/ProjectFileConfig.cs new file mode 100644 index 0000000..33323a3 --- /dev/null +++ b/ThunderstoreCLI/Configuration/ProjectFileConfig.cs @@ -0,0 +1,74 @@ +using ThunderstoreCLI.Models; +using ThunderstoreCLI.Utils; +using static Crayon.Output; + +namespace ThunderstoreCLI.Configuration; + +internal class ProjectFileConfig : EmptyConfig +{ + private string SourcePath = null!; + private ThunderstoreProject Project = null!; + + public override void Parse(Config currentConfig) + { + SourcePath = currentConfig.GetProjectConfigPath(); + if (!File.Exists(SourcePath)) + { + Utils.Write.Warn( + "Unable to find project configuration file", + $"Looked from {Dim(SourcePath)}" + ); + Project = new ThunderstoreProject(false); + return; + } + Project = ThunderstoreProject.Deserialize(File.ReadAllText(SourcePath))!; + } + + public override GeneralConfig? GetGeneralConfig() + { + return new GeneralConfig() + { + Repository = Project.Publish?.Repository! + }; + } + + public override PackageConfig? GetPackageMeta() + { + return new PackageConfig() + { + Namespace = Project.Package?.Namespace, + Name = Project.Package?.Name, + VersionNumber = Project.Package?.VersionNumber, + ProjectConfigPath = SourcePath, + Description = Project.Package?.Description, + Dependencies = Project.Package?.Dependencies, + ContainsNsfwContent = Project.Package?.ContainsNsfwContent, + WebsiteUrl = Project.Package?.WebsiteUrl + }; + } + + public override BuildConfig? GetBuildConfig() + { + return new BuildConfig() + { + CopyPaths = Project.Build?.CopyPaths.Select(static path => new CopyPathMap(path.Source, path.Target)).ToList(), + IconPath = Project.Build?.Icon, + OutDir = Project.Build?.OutDir, + ReadmePath = Project.Build?.Readme + }; + } + + public override PublishConfig? GetPublishConfig() + { + return new PublishConfig() + { + Categories = Project.Publish?.Categories.Categories, + Communities = Project.Publish?.Communities + }; + } + + public static void Write(Config config, string path) + { + File.WriteAllText(path, new ThunderstoreProject(config).Serialize()); + } +} diff --git a/ThunderstoreCLI/Game/GameDefinition.cs b/ThunderstoreCLI/Game/GameDefinition.cs new file mode 100644 index 0000000..d3047cb --- /dev/null +++ b/ThunderstoreCLI/Game/GameDefinition.cs @@ -0,0 +1,106 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; +using ThunderstoreCLI.Configuration; +using ThunderstoreCLI.Models; +using ThunderstoreCLI.Utils; + +namespace ThunderstoreCLI.Game; + +public class GameDefinition : BaseJson +{ + public string Identifier { get; set; } + public string Name { get; set; } + public string InstallDirectory { get; set; } + public string? ExePath { get; set; } + public GamePlatform Platform { get; set; } + public string? PlatformId { get; set; } + public List Profiles { get; private set; } = new(); + +#pragma warning disable CS8618 + private GameDefinition() { } +#pragma warning restore CS8618 + + internal GameDefinition(string id, string name, string installDirectory, GamePlatform platform, string? platformId, string tcliDirectory) + { + Identifier = id; + Name = name; + InstallDirectory = installDirectory; + Platform = platform; + PlatformId = platformId; + } + + internal static GameDefinition? FromPlatformInstall(Config config, GamePlatform platform, string platformId, string id, string name) + { + var gameDir = platform switch + { + GamePlatform.Steam => SteamUtils.FindInstallDirectory(platformId), + _ => null + }; + if (gameDir == null) + { + return null; + } + return new GameDefinition(id, name, gameDir, platform, platformId, config.GeneralConfig.TcliConfig); + } + + internal static GameDefinition? FromNativeInstall(Config config, string id, string name) + { + if (!File.Exists(config.GameImportConfig.ExePath)) + { + return null; + } + + return new GameDefinition(id, name, Path.GetDirectoryName(Path.GetFullPath(config.GameImportConfig.ExePath))!, GamePlatform.Other, null, config.GeneralConfig.TcliConfig) + { + ExePath = Path.GetFullPath(config.GameImportConfig.ExePath) + }; + } + + [MemberNotNullWhen(true, nameof(ExePath))] + [MemberNotNullWhen(false, nameof(PlatformId))] + [JsonIgnore] + public bool IsNativeGame => Platform == GamePlatform.Other; +} + +public sealed class GameDefinitionCollection : IEnumerable +{ + private const string FILE_NAME = "GameDefinitions.json"; + + private readonly string tcliDirectory; + public List List { get; } + + internal static GameDefinitionCollection FromDirectory(string tcliDirectory) => new(tcliDirectory); + + private GameDefinitionCollection(string tcliDir) + { + tcliDirectory = tcliDir; + var filename = Path.Combine(tcliDirectory, FILE_NAME); + if (File.Exists(filename)) + List = GameDefinition.DeserializeList(File.ReadAllText(filename)) ?? new(); + else + List = new(); + } + + public void Write() + { + File.WriteAllText(Path.Combine(tcliDirectory, FILE_NAME), List.SerializeList(BaseJson.IndentedSettings)); + } + + public IEnumerator GetEnumerator() => List.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => List.GetEnumerator(); +} + +[JsonConverter(typeof(StringEnumConverter), typeof(KebabCaseNamingStrategy))] +public enum GamePlatform +{ + Steam, + SteamDirect, + EGS, + XboxGamePass, + Oculus, + Origin, + Other +} diff --git a/ThunderstoreCLI/Game/ModProfile.cs b/ThunderstoreCLI/Game/ModProfile.cs new file mode 100644 index 0000000..5a43fb0 --- /dev/null +++ b/ThunderstoreCLI/Game/ModProfile.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using ThunderstoreCLI.Models; + +namespace ThunderstoreCLI.Game; + +public record InstalledModVersion(string FullName, string VersionNumber, string[] Dependencies); + +public class ModProfile : BaseJson +{ + public string Name { get; set; } + public string ProfileDirectory { get; set; } + public Dictionary InstalledModVersions { get; } = new(); + +#pragma warning disable CS8618 + private ModProfile() { } +#pragma warning restore CS8618 + + internal ModProfile(GameDefinition gameDef, string name, string tcliDirectory) + { + Name = name; + + var directory = Path.GetFullPath(Path.Combine(tcliDirectory, "Profiles", gameDef.Identifier, name)); + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + ProfileDirectory = directory; + gameDef.Profiles.Add(this); + } +} diff --git a/ThunderstoreCLI/Models/BaseJson.cs b/ThunderstoreCLI/Models/BaseJson.cs index cdac1a6..2d509cc 100644 --- a/ThunderstoreCLI/Models/BaseJson.cs +++ b/ThunderstoreCLI/Models/BaseJson.cs @@ -1,29 +1,55 @@ using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using System.Text.Json.Serialization; +using Newtonsoft.Json; namespace ThunderstoreCLI.Models; -public abstract class BaseJson - where T : BaseJson - where Context : JsonSerializerContext +public abstract class BaseJson<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T> : ISerialize + where T : BaseJson { - public string Serialize(JsonSerializerOptions? options = null) + public string Serialize() => Serialize(null); + public string Serialize(JsonSerializerSettings? options) { - var context = (Context) Activator.CreateInstance(typeof(Context), options)!; + return JsonConvert.SerializeObject(this, options); + } - return JsonSerializer.Serialize(this, typeof(T), context); + public static T? Deserialize(string json) => Deserialize(json, null); + public static T? Deserialize(Stream stream) + { + using var reader = new StreamReader(stream); + return JsonConvert.DeserializeObject(reader.ReadToEnd()); } - public static T? Deserialize(string json, JsonSerializerOptions? options = null) + public static T? Deserialize(string json, JsonSerializerSettings? options) { - var context = (Context) Activator.CreateInstance(typeof(Context), options)!; + return JsonConvert.DeserializeObject(json, options); + } - return (T?) JsonSerializer.Deserialize(json, typeof(T), context); + public static ValueTask DeserializeAsync(string json) => new(Deserialize(json)); + public static ValueTask DeserializeAsync(Stream json) => new(DeserializeAsync(json, null)); + public static async Task DeserializeAsync(Stream json, JsonSerializerSettings? options) + { + using StreamReader reader = new(json); + return Deserialize(await reader.ReadToEndAsync(), options); } - public static T? Deserialize(Stream json, JsonSerializerOptions? options) + + public static List? DeserializeList(string json, JsonSerializerSettings? options = null) { - var context = (Context) Activator.CreateInstance(typeof(Context), options)!; + return JsonConvert.DeserializeObject>(json, options); + } +} - return (T?) JsonSerializer.Deserialize(json, typeof(T), context); +public static class BaseJson +{ + public static readonly JsonSerializerSettings IndentedSettings = new() + { + Formatting = Formatting.Indented + }; +} + +public static class BaseJsonExtensions +{ + public static string SerializeList<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(this List list, JsonSerializerSettings? options = null) + where T : BaseJson + { + return JsonConvert.SerializeObject(list, options); } } diff --git a/ThunderstoreCLI/Models/BaseToml.cs b/ThunderstoreCLI/Models/BaseToml.cs new file mode 100644 index 0000000..b69d16c --- /dev/null +++ b/ThunderstoreCLI/Models/BaseToml.cs @@ -0,0 +1,12 @@ +using System.Diagnostics.CodeAnalysis; +using Tomlet; + +namespace ThunderstoreCLI.Models; + +public abstract class BaseToml<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T> : ISerialize + where T : BaseToml +{ + public string Serialize() => TomletMain.TomlStringFrom(this); + + public static T? Deserialize(string toml) => TomletMain.To(toml); +} diff --git a/ThunderstoreCLI/Models/ISerialize.cs b/ThunderstoreCLI/Models/ISerialize.cs new file mode 100644 index 0000000..43fe459 --- /dev/null +++ b/ThunderstoreCLI/Models/ISerialize.cs @@ -0,0 +1,21 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ThunderstoreCLI.Models; + +public interface ISerialize<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T> + where T : ISerialize +{ + public string Serialize(); +#if NET7_0 + public static abstract T? Deserialize(string input); + public static virtual ValueTask DeserializeAsync(string input) + { + return new(T.Deserialize(input)); + } + public static virtual async ValueTask DeserializeAsync(Stream input) + { + using StreamReader reader = new(input); + return T.Deserialize(await reader.ReadToEndAsync()); + } +#endif +} diff --git a/ThunderstoreCLI/Models/PackageListingV1.cs b/ThunderstoreCLI/Models/PackageListingV1.cs new file mode 100644 index 0000000..b4b9f94 --- /dev/null +++ b/ThunderstoreCLI/Models/PackageListingV1.cs @@ -0,0 +1,132 @@ +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using ThunderstoreCLI.Commands; + +namespace ThunderstoreCLI.Models; + +public class PackageListingV1 : BaseJson +{ + [JsonProperty("name")] + public string? Name { get; set; } + + [JsonProperty("full_name")] + public string? Fullname { get; set; } + + [JsonProperty("owner")] + public string? Owner { get; set; } + + [JsonProperty("package_url")] + public string? PackageUrl { get; set; } + + [JsonProperty("date_created")] + public DateTime DateCreated { get; set; } + + [JsonProperty("date_updated")] + public DateTime DateUpdated { get; set; } + + [JsonProperty("uuid4")] + public string? Uuid4 { get; set; } + + [JsonProperty("rating_score")] + public int RatingScore { get; set; } + + [JsonProperty("is_pinned")] + public bool IsPinned { get; set; } + + [JsonProperty("is_deprecated")] + public bool IsDeprecated { get; set; } + + [JsonProperty("has_nsfw_content")] + public bool HasNsfwContent { get; set; } + + [JsonProperty("categories")] + public string[]? Categories { get; set; } + + [JsonProperty("versions")] + public PackageVersionV1[]? Versions { get; set; } + + public PackageListingV1() { } + + public PackageListingV1(PackageData package) + { + Name = package.Name; + Fullname = package.Fullname; + Owner = package.Namespace; + PackageUrl = package.PackageUrl; + DateCreated = package.DateCreated; + DateUpdated = package.DateUpdated; + Uuid4 = null; + RatingScore = int.Parse(package.RatingScore!); + IsPinned = package.IsPinned; + IsDeprecated = package.IsDeprecated; + HasNsfwContent = package.CommunityListings!.Any(l => l.HasNsfwContent); + Categories = Array.Empty(); + Versions = new[] { new PackageVersionV1(package.LatestVersion!) }; + } +} + +public class PackageVersionV1 +{ + [JsonProperty("name")] + public string? Name { get; set; } + + [JsonProperty("full_name")] + public string? FullName { get; set; } + + [JsonProperty("description")] + public string? Description { get; set; } + + [JsonProperty("icon")] + public string? Icon { get; set; } + + [JsonProperty("version_number")] + public string? VersionNumber { get; set; } + + [JsonProperty("dependencies")] + public string[]? Dependencies { get; set; } + + [JsonProperty("download_url")] + public string? DownloadUrl { get; set; } + + [JsonProperty("downloads")] + public int Downloads { get; set; } + + [JsonProperty("date_created")] + public DateTime DateCreated { get; set; } + + [JsonProperty("website_url")] + public string? WebsiteUrl { get; set; } + + [JsonProperty("is_active")] + public bool IsActive { get; set; } + + [JsonProperty("uuid4")] + public string? Uuid4 { get; set; } + + [JsonProperty("file_size")] + public long FileSize { get; set; } + + [JsonIgnore] + private GroupCollection? _fullNameParts; + [JsonIgnore] + public GroupCollection FullNameParts => _fullNameParts ??= InstallCommand.FullPackageNameRegex.Match(FullName!).Groups; + + public PackageVersionV1() { } + + public PackageVersionV1(PackageVersionData version) + { + Name = version.Name; + FullName = version.FullName; + Description = version.Description; + Icon = version.Icon; + VersionNumber = version.VersionNumber; + Dependencies = version.Dependencies; + DownloadUrl = version.DownloadUrl; + Downloads = version.Downloads; + DateCreated = version.DateCreated; + WebsiteUrl = version.WebsiteUrl; + IsActive = version.IsActive; + Uuid4 = null; + FileSize = 0; + } +} diff --git a/ThunderstoreCLI/Models/PublishModels.cs b/ThunderstoreCLI/Models/PublishModels.cs index 9da5d97..cb2e0af 100644 --- a/ThunderstoreCLI/Models/PublishModels.cs +++ b/ThunderstoreCLI/Models/PublishModels.cs @@ -1,202 +1,176 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; +using Newtonsoft.Json; namespace ThunderstoreCLI.Models; -[ExcludeFromCodeCoverageAttribute] -public class PackageUploadMetadata : BaseJson +public class PackageUploadMetadata : BaseJson { - [JsonPropertyName("author_name")] - public string? AuthorName { get; set; } + [JsonProperty("author_name")] public string? AuthorName { get; set; } - [JsonPropertyName("categories")] - public string[]? Categories { get; set; } + [JsonProperty("categories")] public string[]? Categories { get; set; } - [JsonPropertyName("communities")] - public string[]? Communities { get; set; } + [JsonProperty("communities")] public string[]? Communities { get; set; } - [JsonPropertyName("has_nsfw_content")] - public bool HasNsfwContent { get; set; } + [JsonProperty("community_categories")] public Dictionary? CommunityCategories { get; set; } - [JsonPropertyName("upload_uuid")] - public string? UploadUUID { get; set; } -} + [JsonProperty("has_nsfw_content")] public bool HasNsfwContent { get; set; } -[JsonSerializable(typeof(PackageUploadMetadata))] -[ExcludeFromCodeCoverageAttribute] -public partial class PackageUploadMetadataContext : JsonSerializerContext { } + [JsonProperty("upload_uuid")] public string? UploadUUID { get; set; } +} -[ExcludeFromCodeCoverageAttribute] -public class UploadInitiateData : BaseJson +public class UploadInitiateData : BaseJson { public class UserMediaData { - [JsonPropertyName("uuid")] - public string? UUID { get; set; } + [JsonProperty("uuid")] public string? UUID { get; set; } - [JsonPropertyName("filename")] - public string? Filename { get; set; } + [JsonProperty("filename")] public string? Filename { get; set; } - [JsonPropertyName("size")] - public long Size { get; set; } + [JsonProperty("size")] public long Size { get; set; } - [JsonPropertyName("datetime_created")] - public DateTime TimeCreated { get; set; } + [JsonProperty("datetime_created")] public DateTime TimeCreated { get; set; } - [JsonPropertyName("expiry")] - public DateTime? ExpireTime { get; set; } + [JsonProperty("expiry")] public DateTime? ExpireTime { get; set; } - [JsonPropertyName("status")] - public string? Status { get; set; } + [JsonProperty("status")] public string? Status { get; set; } } public class UploadPartData { - [JsonPropertyName("part_number")] - public int PartNumber { get; set; } + [JsonProperty("part_number")] public int PartNumber { get; set; } - [JsonPropertyName("url")] - public string? Url { get; set; } + [JsonProperty("url")] public string? Url { get; set; } - [JsonPropertyName("offset")] - public long Offset { get; set; } + [JsonProperty("offset")] public long Offset { get; set; } - [JsonPropertyName("length")] - public int Length { get; set; } + [JsonProperty("length")] public int Length { get; set; } } - [JsonPropertyName("user_media")] - public UserMediaData? Metadata { get; set; } + [JsonProperty("user_media")] public UserMediaData? Metadata { get; set; } - [JsonPropertyName("upload_urls")] - public UploadPartData[]? UploadUrls { get; set; } + [JsonProperty("upload_urls")] public UploadPartData[]? UploadUrls { get; set; } } -[JsonSerializable(typeof(UploadInitiateData))] -[ExcludeFromCodeCoverage] -public partial class UploadInitiateDataContext : JsonSerializerContext { } - -[ExcludeFromCodeCoverageAttribute] -public class FileData : BaseJson +public class FileData : BaseJson { - [JsonPropertyName("filename")] - public string? Filename { get; set; } + [JsonProperty("filename")] public string? Filename { get; set; } - [JsonPropertyName("file_size_bytes")] - public long Filesize { get; set; } + [JsonProperty("file_size_bytes")] public long Filesize { get; set; } } -[JsonSerializable(typeof(FileData))] -[ExcludeFromCodeCoverage] -public partial class FileDataContext : JsonSerializerContext { } - -[ExcludeFromCodeCoverageAttribute] -public class CompletedUpload : BaseJson +public class CompletedUpload : BaseJson { public class CompletedPartData { - [JsonPropertyName("ETag")] - public string? ETag { get; set; } + [JsonProperty("ETag")] public string? ETag { get; set; } - [JsonPropertyName("PartNumber")] - public int PartNumber { get; set; } + [JsonProperty("PartNumber")] public int PartNumber { get; set; } } - [JsonPropertyName("parts")] - public CompletedPartData[]? Parts { get; set; } + [JsonProperty("parts")] public CompletedPartData[]? Parts { get; set; } } -[JsonSerializable(typeof(CompletedUpload))] -public partial class CompletedUploadContext : JsonSerializerContext { } - // JSON response structure for publish package request. -[ExcludeFromCodeCoverageAttribute] -public class PublishData : BaseJson +public class PublishData : BaseJson { public class AvailableCommunityData { public class CommunityData { - [JsonPropertyName("identifier")] - public string? Identifier { get; set; } + [JsonProperty("identifier")] public string? Identifier { get; set; } - [JsonPropertyName("name")] - public string? Name { get; set; } + [JsonProperty("name")] public string? Name { get; set; } - [JsonPropertyName("discord_url")] - public string? DiscordUrl { get; set; } + [JsonProperty("discord_url")] public string? DiscordUrl { get; set; } - [JsonPropertyName("wiki_url")] - public object? WikiUrl { get; set; } + [JsonProperty("wiki_url")] public object? WikiUrl { get; set; } - [JsonPropertyName("require_package_listing_approval")] + [JsonProperty("require_package_listing_approval")] public bool RequirePackageListingApproval { get; set; } } public class CategoryData { - [JsonPropertyName("name")] - public string? Name { get; set; } + [JsonProperty("name")] public string? Name { get; set; } - [JsonPropertyName("slug")] - public string? Slug { get; set; } + [JsonProperty("slug")] public string? Slug { get; set; } } - [JsonPropertyName("community")] - public CommunityData? Community { get; set; } + [JsonProperty("community")] public CommunityData? Community { get; set; } - [JsonPropertyName("categories")] - public List? Categories { get; set; } + [JsonProperty("categories")] public List? Categories { get; set; } - [JsonPropertyName("url")] - public string? Url { get; set; } + [JsonProperty("url")] public string? Url { get; set; } } - public class PackageVersionData - { - [JsonPropertyName("namespace")] - public string? Namespace { get; set; } + [JsonProperty("available_communities")] + public List? AvailableCommunities { get; set; } + + [JsonProperty("package_version")] public PackageVersionData? PackageVersion { get; set; } +} - [JsonPropertyName("name")] - public string? Name { get; set; } +public class PackageData : BaseJson +{ + [JsonProperty("namespace")] public string? Namespace { get; set; } - [JsonPropertyName("version_number")] - public string? VersionNumber { get; set; } + [JsonProperty("name")] public string? Name { get; set; } - [JsonPropertyName("full_name")] - public string? FullName { get; set; } + [JsonProperty("full_name")] public string? Fullname { get; set; } - [JsonPropertyName("description")] - public string? Description { get; set; } + [JsonProperty("owner")] public string? Owner { get; set; } - [JsonPropertyName("icon")] - public string? Icon { get; set; } + [JsonProperty("package_url")] public string? PackageUrl { get; set; } - [JsonPropertyName("dependencies")] - public List? Dependencies { get; set; } + [JsonProperty("date_created")] public DateTime DateCreated { get; set; } - [JsonPropertyName("download_url")] - public string? DownloadUrl { get; set; } + [JsonProperty("date_updated")] public DateTime DateUpdated { get; set; } - [JsonPropertyName("downloads")] - public int Downloads { get; set; } + [JsonProperty("rating_score")] public string? RatingScore { get; set; } - [JsonPropertyName("date_created")] - public DateTime DateCreated { get; set; } + [JsonProperty("is_pinned")] public bool IsPinned { get; set; } - [JsonPropertyName("website_url")] - public string? WebsiteUrl { get; set; } + [JsonProperty("is_deprecated")] public bool IsDeprecated { get; set; } - [JsonPropertyName("is_active")] - public bool IsActive { get; set; } - } + [JsonProperty("total_downloads")] public string? TotalDownloads { get; set; } - [JsonPropertyName("available_communities")] - public List? AvailableCommunities { get; set; } + [JsonProperty("latest")] public PackageVersionData? LatestVersion { get; set; } - [JsonPropertyName("package_version")] - public PackageVersionData? PackageVersion { get; set; } + [JsonProperty("community_listings")] public PackageListingData[]? CommunityListings { get; set; } } -[JsonSerializable(typeof(PublishData))] -public partial class PublishDataContext : JsonSerializerContext { } +public class PackageVersionData : BaseJson +{ + [JsonProperty("namespace")] public string? Namespace { get; set; } + + [JsonProperty("name")] public string? Name { get; set; } + + [JsonProperty("version_number")] public string? VersionNumber { get; set; } + + [JsonProperty("full_name")] public string? FullName { get; set; } + + [JsonProperty("description")] public string? Description { get; set; } + + [JsonProperty("icon")] public string? Icon { get; set; } + + [JsonProperty("dependencies")] public string[]? Dependencies { get; set; } + + [JsonProperty("download_url")] public string? DownloadUrl { get; set; } + + [JsonProperty("downloads")] public int Downloads { get; set; } + + [JsonProperty("date_created")] public DateTime DateCreated { get; set; } + + [JsonProperty("website_url")] public string? WebsiteUrl { get; set; } + + [JsonProperty("is_active")] public bool IsActive { get; set; } +} + +public class PackageListingData : BaseJson +{ + [JsonProperty("has_nsfw_content")] public bool HasNsfwContent { get; set; } + + [JsonProperty("categories")] public string[]? Categories { get; set; } + + [JsonProperty("community")] public string? Community { get; set; } + + [JsonProperty("review_status")] public string? ReviewStatus { get; set; } +} diff --git a/ThunderstoreCLI/Models/SchemaResponse.cs b/ThunderstoreCLI/Models/SchemaResponse.cs new file mode 100644 index 0000000..ab607d8 --- /dev/null +++ b/ThunderstoreCLI/Models/SchemaResponse.cs @@ -0,0 +1,48 @@ +using ThunderstoreCLI.Configuration; +using ThunderstoreCLI.Game; +using ThunderstoreCLI.Utils; + +namespace ThunderstoreCLI.Models; + +public class SchemaResponse : BaseJson +{ + public required Dictionary games { get; set; } +} + +public class SchemaGame +{ + public required string uuid { get; set; } + public required string label { get; set; } + public required DescriptionMetadata meta { get; set; } + public required PlatformDistribution[] distributions { get; set; } + public R2modmanInfo? r2modman { get; set; } + + public GameDefinition? ToGameDefintion(Config config) + { + var isServer = r2modman?.gameInstanceType == "server"; + var allowedDirectExe = distributions.Any(d => d.platform == GamePlatform.Other) || isServer; + if (allowedDirectExe && config.GameImportConfig.ExePath != null) + { + return GameDefinition.FromNativeInstall(config, label, meta.displayName); + } + var platform = distributions.First(p => p.platform == GamePlatform.Steam); + return GameDefinition.FromPlatformInstall(config, platform.platform, platform.identifier, label, meta.displayName); + } +} + +public class PlatformDistribution +{ + public required GamePlatform platform { get; set; } + public required string identifier { get; set; } +} + +public class DescriptionMetadata +{ + public required string displayName { get; set; } + public required string iconUrl { get; set; } +} + +public class R2modmanInfo +{ + public required string gameInstanceType { get; set; } +} diff --git a/ThunderstoreCLI/Models/ThunderstoreProject.cs b/ThunderstoreCLI/Models/ThunderstoreProject.cs new file mode 100644 index 0000000..6b09b7a --- /dev/null +++ b/ThunderstoreCLI/Models/ThunderstoreProject.cs @@ -0,0 +1,148 @@ +using ThunderstoreCLI.Configuration; +using Tomlet; +using Tomlet.Attributes; +using Tomlet.Models; + +namespace ThunderstoreCLI.Models; + +[TomlDoNotInlineObject] +public class ThunderstoreProject : BaseToml +{ + public struct CategoryDictionary + { + public Dictionary Categories; + } + + static ThunderstoreProject() + { + TomletMain.RegisterMapper( + dict => TomletMain.ValueFrom(dict.Categories), + toml => toml switch + { + TomlArray arr => new CategoryDictionary + { + Categories = new Dictionary + { + { "", arr.ArrayValues.Select(v => v.StringValue).ToArray() } + } + }, + TomlTable table => new CategoryDictionary { Categories = TomletMain.To>(table) }, + _ => throw new NotSupportedException() + }); + } + + [TomlDoNotInlineObject] + public class ConfigData + { + [TomlProperty("schemaVersion")] + public string SchemaVersion { get; set; } = "0.0.1"; + } + + [TomlProperty("config")] + public ConfigData? Config { get; set; } = new(); + + [TomlDoNotInlineObject] + public class PackageData + { + [TomlProperty("namespace")] + public string Namespace { get; set; } = "AuthorName"; + [TomlProperty("name")] + public string Name { get; set; } = "PackageName"; + [TomlProperty("versionNumber")] + public string VersionNumber { get; set; } = "0.0.1"; + [TomlProperty("description")] + public string Description { get; set; } = "Example mod description"; + [TomlProperty("websiteUrl")] + public string WebsiteUrl { get; set; } = "https://thunderstore.io"; + [TomlProperty("containsNsfwContent")] + public bool ContainsNsfwContent { get; set; } = false; + [TomlProperty("dependencies")] + [TomlDoNotInlineObject] + public Dictionary Dependencies { get; set; } = new() { { "AuthorName-PackageName", "0.0.1" } }; + } + [TomlProperty("package")] + public PackageData? Package { get; set; } + + [TomlDoNotInlineObject] + public class BuildData + { + [TomlProperty("icon")] + public string Icon { get; set; } = "./icon.png"; + [TomlProperty("readme")] + public string Readme { get; set; } = "./README.md"; + [TomlProperty("outdir")] + public string OutDir { get; set; } = "./build"; + + [TomlDoNotInlineObject] + public class CopyPath + { + [TomlProperty("source")] + public string Source { get; set; } = "./dist"; + [TomlProperty("target")] + public string Target { get; set; } = ""; + } + + [TomlProperty("copy")] + public CopyPath[] CopyPaths { get; set; } = new CopyPath[] { new CopyPath() }; + } + [TomlProperty("build")] + public BuildData? Build { get; set; } + + [TomlDoNotInlineObject] + public class PublishData + { + [TomlProperty("repository")] + public string Repository { get; set; } = "https://thunderstore.io"; + [TomlProperty("communities")] + public string[] Communities { get; set; } = + { + "riskofrain2" + }; + + [TomlProperty("categories")] + [TomlDoNotInlineObject] + public CategoryDictionary Categories { get; set; } = new() + { + Categories = new Dictionary + { + { "riskofrain2", new[] { "items", "skills" } } + } + }; + } + [TomlProperty("publish")] + public PublishData? Publish { get; set; } + + public ThunderstoreProject() { } + + public ThunderstoreProject(bool initialize) + { + if (!initialize) + return; + + Package = new PackageData(); + Build = new BuildData(); + Publish = new PublishData(); + } + + public ThunderstoreProject(Config config) + { + Package = new PackageData() + { + Namespace = config.PackageConfig.Namespace!, + Name = config.PackageConfig.Name! + }; + Build = new BuildData() + { + Icon = config.GetPackageIconPath(), + OutDir = config.GetBuildOutputDir(), + Readme = config.GetPackageReadmePath(), + CopyPaths = config.BuildConfig.CopyPaths!.Select(x => new BuildData.CopyPath { Source = x.From, Target = x.To }).ToArray()! + }; + Publish = new PublishData() + { + Categories = new CategoryDictionary { Categories = config.PublishConfig.Categories! }, + Communities = config.PublishConfig.Communities!, + Repository = config.GeneralConfig.Repository + }; + } +} diff --git a/ThunderstoreCLI/Options.cs b/ThunderstoreCLI/Options.cs index ad6b435..9a3a14f 100644 --- a/ThunderstoreCLI/Options.cs +++ b/ThunderstoreCLI/Options.cs @@ -1,14 +1,46 @@ using CommandLine; +using ThunderstoreCLI.Commands; +using ThunderstoreCLI.Configuration; +using ThunderstoreCLI.Utils; using static Crayon.Output; -/// Options are arguments passed from command line. -namespace ThunderstoreCLI.Options; +namespace ThunderstoreCLI; -public class PackageOptions +/// Options are arguments passed from command line. +public abstract class BaseOptions { + [Option("tcli-directory", Required = false, HelpText = "Directory where TCLI keeps its data, %APPDATA%/ThunderstoreCLI on Windows and ~/.config/ThunderstoreCLI on Linux")] + // will be initialized in Init if null + public string TcliDirectory { get; set; } = null!; + + [Option("repository", Required = false, HelpText = "URL of the default repository")] + public string Repository { get; set; } = null!; + [Option("config-path", Required = false, Default = Defaults.PROJECT_CONFIG_PATH, HelpText = "Path for the project configuration file")] public string? ConfigPath { get; set; } + public virtual void Init() + { + // ReSharper disable once ConstantNullCoalescingCondition + // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract + TcliDirectory ??= Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ThunderstoreCLI"); + } + + public virtual bool Validate() + { + if (!Directory.Exists(TcliDirectory)) + { + Directory.CreateDirectory(TcliDirectory!); + } + + return true; + } + + public abstract int Execute(); +} + +public abstract class PackageOptions : BaseOptions +{ [Option("package-name", SetName = "build", Required = false, HelpText = "Name for the package")] public string? Name { get; set; } @@ -18,9 +50,14 @@ public class PackageOptions [Option("package-version", SetName = "build", Required = false, HelpText = "Version number for the package")] public string? VersionNumber { get; set; } - public virtual bool Validate() + public override bool Validate() { - if (String.IsNullOrWhiteSpace(ConfigPath)) + if (!base.Validate()) + { + return false; + } + + if (string.IsNullOrWhiteSpace(ConfigPath)) { Write.ErrorExit("Invalid value for --config-path argument"); return false; @@ -49,10 +86,21 @@ public class InitOptions : PackageOptions [Option(OVERWRITE_FLAG, Required = false, Default = false, HelpText = "If present, overwrite current configuration")] public bool Overwrite { get; set; } + + public override int Execute() + { + return InitCommand.Run(Config.FromCLI(new CLIInitCommandConfig(this))); + } } [Verb("build", HelpText = "Build a package")] -public class BuildOptions : PackageOptions { } +public class BuildOptions : PackageOptions +{ + public override int Execute() + { + return BuildCommand.Run(Config.FromCLI(new CLIBuildCommandConfig(this))); + } +} [Verb("publish", HelpText = "Publish a package. By default will also build a new package.")] public class PublishOptions : PackageOptions @@ -63,9 +111,6 @@ public class PublishOptions : PackageOptions [Option("token", Required = false, HelpText = "Authentication token to use for publishing.")] public string? Token { get; set; } - [Option("repository", Required = false, HelpText = "URL of the repository where to publish.")] - public string? Repository { get; set; } - public override bool Validate() { if (!base.Validate()) @@ -73,7 +118,7 @@ public override bool Validate() return false; } - if (!(File is null)) + if (File is not null) { var filePath = Path.GetFullPath(File); if (!System.IO.File.Exists(filePath)) @@ -88,4 +133,107 @@ public override bool Validate() return true; } + + public override int Execute() + { + return PublishCommand.Run(Config.FromCLI(new CLIPublishCommandConfig(this))); + } +} + +public abstract class ModManagementOptions : BaseOptions +{ + [Value(0, MetaName = "Game Name", Required = true, HelpText = "The identifier of the game to manage mods for")] + public string GameName { get; set; } = null!; + + [Value(1, MetaName = "Package", Required = true, HelpText = "Path to package zip or package name in the format namespace-name(-version)")] + public string Package { get; set; } = null!; + + [Option(HelpText = "Profile to install to", Default = "DefaultProfile")] + public string? Profile { get; set; } + + protected enum CommandInner + { + Install, + Uninstall + } + + protected abstract CommandInner CommandType { get; } + + public override int Execute() + { + var config = Config.FromCLI(new ModManagementCommandConfig(this)); + return CommandType switch + { + CommandInner.Install => InstallCommand.Run(config).GetAwaiter().GetResult(), + CommandInner.Uninstall => UninstallCommand.Run(config), + _ => throw new NotSupportedException() + }; + } +} + +[Verb("install", HelpText = "Installs a mod to a profile")] +public class InstallOptions : ModManagementOptions +{ + protected override CommandInner CommandType => CommandInner.Install; +} + +[Verb("uninstall", HelpText = "Uninstalls a mod from a profile")] +public class UninstallOptions : ModManagementOptions +{ + protected override CommandInner CommandType => CommandInner.Uninstall; +} + +[Verb("import-game", HelpText = "Imports a new game to use with TCLI")] +public class GameImportOptions : BaseOptions +{ + [Option(HelpText = "Path to game exe to use when launching the game. Only works with servers.")] + public required string? ExePath { get; set; } + + [Value(0, Required = true, HelpText = "The identifier for the game to import.")] + public required string GameId { get; set; } + + public override bool Validate() + { + if (!string.IsNullOrWhiteSpace(ExePath) && !File.Exists(ExePath)) + { + Write.ErrorExit($"Could not locate game exe at {ExePath}"); + } + + return base.Validate(); + } + + public override int Execute() + { + var config = Config.FromCLI(new GameImportCommandConfig(this)); + return ImportGameCommand.Run(config); + } +} + +[Verb("run", HelpText = "Run a game modded")] +public class RunGameOptions : BaseOptions +{ + [Value(0, MetaName = "Game", Required = true, HelpText = "The identifier of the game to run.")] + public required string GameName { get; set; } + + [Option(HelpText = "Which profile to run the game under", Default = "DefaultProfile")] + public required string Profile { get; set; } + + [Option(HelpText = "Arguments to run the game with. Anything after a trailing -- will be prioritized over this argument.")] + public string? Args { get; set; } + + public override int Execute() + { + var config = Config.FromCLI(new RunGameCommandConfig(this)); + return RunCommand.Run(config); + } +} + +[Verb("list", HelpText = "List configured games, profiles, and mods")] +public class ListOptions : BaseOptions +{ + public override int Execute() + { + ListCommand.Run(Config.FromCLI(new ListConfig(this))); + return 0; + } } diff --git a/ThunderstoreCLI/PackageManifestV1.cs b/ThunderstoreCLI/PackageManifestV1.cs index b865472..3ea2b1b 100644 --- a/ThunderstoreCLI/PackageManifestV1.cs +++ b/ThunderstoreCLI/PackageManifestV1.cs @@ -1,29 +1,50 @@ -using System.Text.Json.Serialization; +using Newtonsoft.Json; using ThunderstoreCLI.Models; namespace ThunderstoreCLI; -[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute] -public class PackageManifestV1 : BaseJson +public class PackageManifestV1 : BaseJson { - [JsonPropertyName("namespace")] + [JsonProperty("namespace")] public string? Namespace { get; set; } - [JsonPropertyName("name")] + [JsonProperty("name")] public string? Name { get; set; } - [JsonPropertyName("description")] + [JsonProperty("description")] public string? Description { get; set; } - [JsonPropertyName("version_number")] + [JsonProperty("version_number")] public string? VersionNumber { get; set; } - [JsonPropertyName("dependencies")] + [JsonProperty("dependencies")] public string[]? Dependencies { get; set; } - [JsonPropertyName("website_url")] + [JsonProperty("website_url")] public string? WebsiteUrl { get; set; } -} -[JsonSerializable(typeof(PackageManifestV1))] -public partial class PackageManifestV1Context : JsonSerializerContext { } + private string? fullName; + public string FullName => fullName ??= $"{Namespace}-{Name}"; + + public PackageManifestV1() { } + + public PackageManifestV1(PackageVersionData version) + { + Namespace = version.Namespace; + Name = version.Name; + Description = version.Description; + VersionNumber = version.VersionNumber; + Dependencies = version.Dependencies?.ToArray() ?? Array.Empty(); + WebsiteUrl = version.WebsiteUrl; + } + + public PackageManifestV1(PackageListingV1 listing, PackageVersionV1 version) + { + Namespace = listing.Owner; + Name = listing.Name; + Description = version.Description; + VersionNumber = version.VersionNumber; + Dependencies = version.Dependencies?.ToArray() ?? Array.Empty(); + WebsiteUrl = version.WebsiteUrl; + } +} diff --git a/ThunderstoreCLI/Plugins/PluginManager.cs b/ThunderstoreCLI/Plugins/PluginManager.cs new file mode 100644 index 0000000..c89a8eb --- /dev/null +++ b/ThunderstoreCLI/Plugins/PluginManager.cs @@ -0,0 +1,25 @@ +using ThunderstoreCLI.Configuration; + +namespace ThunderstoreCLI.Plugins; + +public class PluginManager +{ + private class Plugin + { + + } + + private class PluginInfo + { + + } + + public string PluginDirectory { get; } + + private List LoadedPlugins = new(); + + public PluginManager(GeneralConfig config) + { + PluginDirectory = Path.Combine(config.TcliConfig, "Plugins"); + } +} diff --git a/ThunderstoreCLI/Program.cs b/ThunderstoreCLI/Program.cs index 1c0b6ec..3b5d9a4 100644 --- a/ThunderstoreCLI/Program.cs +++ b/ThunderstoreCLI/Program.cs @@ -1,106 +1,86 @@ +using System.Diagnostics; using CommandLine; -using ThunderstoreCLI.Commands; -using ThunderstoreCLI.Options; +using ThunderstoreCLI.Models; +using ThunderstoreCLI.Utils; namespace ThunderstoreCLI; -class Program +internal static class Program { - static int Main(string[] args) + private static int Main(string[] args) { - return Parser.Default.ParseArguments(args) - .MapResult( - (InitOptions o) => o.Validate() ? Init(o) : 1, - (BuildOptions o) => o.Validate() ? Build(o) : 1, - (PublishOptions o) => o.Validate() ? Publish(o) : 1, - errs => HandleError(errs) - ); - } +#if DEBUG + if (Environment.GetEnvironmentVariable("TCLI_WAIT_DEBUGGER") == "1") + while (!Debugger.IsAttached) + { } +#endif - static Config.Config GetConfig(Config.IConfigProvider cliConfig) - { - return Config.Config.Parse( - cliConfig, - new Config.EnvironmentConfig(), - new Config.ProjectFileConfig(), - new Config.BaseConfig() - ); - } + string? trailingArgs = null; - static int HandleError(IEnumerable errors) - { - return 1; - } - - static int Init(InitOptions options) - { - var updateChecker = CheckForUpdates(); - var exitCode = InitCommand.Run(GetConfig(new Config.CLIInitCommandConfig(options))); - WriteUpdateNotification(updateChecker); - return exitCode; - } + var argsDelimeterIndex = Array.IndexOf(args, "--"); + if (argsDelimeterIndex != -1) + { + var trailingWithQuotes = args.Skip(argsDelimeterIndex + 1).Select(arg => arg.Contains(' ') ? $"\"{arg}\"" : arg); + trailingArgs = string.Join(' ', trailingWithQuotes); + args = args[..argsDelimeterIndex]; + } - static int Build(BuildOptions options) - { - var updateChecker = CheckForUpdates(); - var exitCode = BuildCommand.Run(GetConfig(new Config.CLIBuildCommandConfig(options))); - WriteUpdateNotification(updateChecker); - return exitCode; - } + var updateChecker = UpdateChecker.CheckForUpdates(); + var exitCode = Parser.Default.ParseArguments(args) + .MapResult( + (InitOptions o) => HandleParsed(o), + (BuildOptions o) => HandleParsed(o), + (PublishOptions o) => HandleParsed(o), +#if INSTALLERS + (InstallOptions o) => HandleParsed(o), + (UninstallOptions o) => HandleParsed(o), + (GameImportOptions o) => HandleParsed(o), + (RunGameOptions o) => + { + if (trailingArgs != null) + { + o.Args = trailingArgs; + } + return HandleParsed(o); + }, - static int Publish(PublishOptions options) - { - var updateChecker = CheckForUpdates(); - var exitCode = PublishCommand.Run(GetConfig(new Config.CLIPublishCommandConfig(options))); - WriteUpdateNotification(updateChecker); + (ListOptions o) => HandleParsed(o), +#endif + _ => 1 // failure to parse + ); + UpdateChecker.WriteUpdateNotification(updateChecker); return exitCode; } - private static async Task CheckForUpdates() + // TODO: replace return codes with exceptions completely + private static int HandleParsed(BaseOptions parsed) { - var current = MiscUtils.GetCurrentVersion(); - int[] latest; - - try - { - var responseContent = await MiscUtils.FetchReleaseInformation(); - latest = MiscUtils.ParseLatestVersion(responseContent); - } - catch (Exception) - { - return ""; - } - - if ( - latest[0] > current[0] || - (latest[0] == current[0] && latest[1] > current[1]) || - (latest[0] == current[0] && latest[1] == current[1] && latest[2] > current[2]) - ) + parsed.Init(); + if (!parsed.Validate()) { - var version = $"{latest[0]}.{latest[1]}.{latest[2]}"; - return $"Newer version {version} of Thunderstore CLI is available"; + return 1; } - - return ""; - } - - private static void WriteUpdateNotification(Task checkTask) - { - if (!checkTask.IsCompleted) + try { - return; + return parsed.Execute(); } - - var notification = checkTask.GetAwaiter().GetResult(); - - if (notification != "") + catch (CommandFatalException cfe) { - Write.Note(notification); + Write.Error(cfe.ErrorMessage); +#if DEBUG + throw; +#else + return 1; +#endif } } } -class CommandException : Exception +internal class CommandException : Exception { public CommandException(string message) : base(message) { } } diff --git a/ThunderstoreCLI/Properties/launchSettings.json b/ThunderstoreCLI/Properties/launchSettings.json deleted file mode 100644 index 9453f85..0000000 --- a/ThunderstoreCLI/Properties/launchSettings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "profiles": { - "ThunderstoreCLI": { - "commandName": "Project", - "commandLineArgs": "publish --config-path test/thunderstore.toml" - } - } -} diff --git a/ThunderstoreCLI/ThunderstoreCLI.csproj b/ThunderstoreCLI/ThunderstoreCLI.csproj index 0b004b0..7fdeeb0 100644 --- a/ThunderstoreCLI/ThunderstoreCLI.csproj +++ b/ThunderstoreCLI/ThunderstoreCLI.csproj @@ -2,7 +2,9 @@ Exe - net6.0 + net7.0;net6.0 + 11 + Major ThunderstoreCLI tcli ThunderstoreCLI.Program @@ -10,27 +12,33 @@ Thunderstore CLI https://thunderstore.io/ Thunderstore CLI is a command-line utility for building and uploading packages to Thunderstore - win-x64 - true true - true + partial true + true + true true enable None - latest + true $(AssemblyName) enable - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + @@ -53,4 +61,29 @@ + + true + + + + + + --release + debug + release + + + + + + true + + + true + + + + + INSTALLERS + diff --git a/ThunderstoreCLI/UpdateChecker.cs b/ThunderstoreCLI/UpdateChecker.cs new file mode 100644 index 0000000..909b16e --- /dev/null +++ b/ThunderstoreCLI/UpdateChecker.cs @@ -0,0 +1,49 @@ +using ThunderstoreCLI.Utils; + +namespace ThunderstoreCLI; + +public static class UpdateChecker +{ + public static async Task CheckForUpdates() + { + var current = MiscUtils.GetCurrentVersion(); + int[] latest; + + try + { + var responseContent = await MiscUtils.FetchReleaseInformation(); + latest = MiscUtils.ParseLatestVersion(responseContent); + } + catch (Exception) + { + return ""; + } + + if ( + latest[0] > current[0] || + (latest[0] == current[0] && latest[1] > current[1]) || + (latest[0] == current[0] && latest[1] == current[1] && latest[2] > current[2]) + ) + { + var version = $"{latest[0]}.{latest[1]}.{latest[2]}"; + return $"Newer version {version} of Thunderstore CLI is available"; + } + + return ""; + } + + public static void WriteUpdateNotification(Task checkTask) + { + if (!checkTask.IsCompleted) + { + return; + } + + var notification = checkTask.GetAwaiter().GetResult(); + + if (notification != "") + { + Write.Note(notification); + } + } +} diff --git a/ThunderstoreCLI/Utils/ActionUtils.cs b/ThunderstoreCLI/Utils/ActionUtils.cs new file mode 100644 index 0000000..2e61fc8 --- /dev/null +++ b/ThunderstoreCLI/Utils/ActionUtils.cs @@ -0,0 +1,42 @@ +namespace ThunderstoreCLI.Utils; + +public static class ActionUtils +{ + public static void Retry(int maxTryCount, Action action) + { + for (int i = 1; i <= maxTryCount; i++) + { + try + { + action(); + return; + } + catch + { + if (i == maxTryCount) + { + throw; + } + } + } + } + + public static async Task RetryAsync(int maxTryCount, Func action) + { + for (int i = 1; i <= maxTryCount; i++) + { + try + { + await action(); + return; + } + catch + { + if (i == maxTryCount) + { + throw; + } + } + } + } +} diff --git a/ThunderstoreCLI/Utils/CommandFatalException.cs b/ThunderstoreCLI/Utils/CommandFatalException.cs new file mode 100644 index 0000000..8e9434b --- /dev/null +++ b/ThunderstoreCLI/Utils/CommandFatalException.cs @@ -0,0 +1,10 @@ +namespace ThunderstoreCLI.Utils; + +public sealed class CommandFatalException : Exception +{ + public string ErrorMessage { get; } + public CommandFatalException(string errorMessage) : base(errorMessage) + { + ErrorMessage = errorMessage; + } +} diff --git a/ThunderstoreCLI/Utils/Comparers.cs b/ThunderstoreCLI/Utils/Comparers.cs index 9657df9..465bb08 100644 --- a/ThunderstoreCLI/Utils/Comparers.cs +++ b/ThunderstoreCLI/Utils/Comparers.cs @@ -1,4 +1,4 @@ -namespace ThunderstoreCLI.Comparers; +namespace ThunderstoreCLI.Utils; public class SemVer : IComparer { diff --git a/ThunderstoreCLI/Utils/DictionaryExtensions.cs b/ThunderstoreCLI/Utils/DictionaryExtensions.cs new file mode 100644 index 0000000..6257624 --- /dev/null +++ b/ThunderstoreCLI/Utils/DictionaryExtensions.cs @@ -0,0 +1,9 @@ +namespace ThunderstoreCLI.Utils; + +public static class DictionaryExtensions +{ + public static TValue? GetOrDefault(this Dictionary dict, TKey key) where TKey : notnull + { + return dict.TryGetValue(key, out var value) ? value : default; + } +} diff --git a/ThunderstoreCLI/Utils/DownloadCache.cs b/ThunderstoreCLI/Utils/DownloadCache.cs new file mode 100644 index 0000000..119b1e3 --- /dev/null +++ b/ThunderstoreCLI/Utils/DownloadCache.cs @@ -0,0 +1,53 @@ +namespace ThunderstoreCLI.Utils; + +public sealed class DownloadCache +{ + public string CacheDirectory { get; } + + private HttpClient Client { get; } = new() + { + Timeout = TimeSpan.FromHours(1) + }; + + public DownloadCache(string cacheDirectory) + { + CacheDirectory = cacheDirectory; + if (!Directory.Exists(CacheDirectory)) + { + Directory.CreateDirectory(cacheDirectory); + } + } + + public bool ContainsFile(string filename) + { + return File.Exists(Path.Combine(CacheDirectory, filename)); + } + + // Task instead of ValueTask here because these Tasks will be await'd multiple times (ValueTask does not allow that) + public Task GetFileOrDownload(string filename, string downloadUrl) + { + string fullPath = Path.Combine(CacheDirectory, filename); + if (File.Exists(fullPath)) + { + return Task.FromResult(fullPath); + } + + return DownloadFile(fullPath, downloadUrl); + } + + private async Task DownloadFile(string fullpath, string downloadUrl) + { + var tempPath = fullpath + ".tmp"; + + await ActionUtils.RetryAsync(5, async () => + { + // copy into memory first to prevent canceled downloads creating files on the disk + await using FileStream tempStream = new(tempPath, FileMode.Create, FileAccess.Write); + await using var downloadStream = await Client.GetStreamAsync(downloadUrl); + await downloadStream.CopyToAsync(tempStream); + }); + + File.Move(tempPath, fullpath); + return fullpath; + } +} diff --git a/ThunderstoreCLI/Utils/MiscUtils.cs b/ThunderstoreCLI/Utils/MiscUtils.cs index 71990cb..627863e 100644 --- a/ThunderstoreCLI/Utils/MiscUtils.cs +++ b/ThunderstoreCLI/Utils/MiscUtils.cs @@ -1,7 +1,7 @@ using System.Reflection; using System.Text.RegularExpressions; -namespace ThunderstoreCLI; +namespace ThunderstoreCLI.Utils; public static class MiscUtils { @@ -14,7 +14,7 @@ public static int[] GetCurrentVersion() try { - version = Assembly.GetEntryAssembly()! + version = typeof(Program).Assembly .GetCustomAttribute()! .InformationalVersion; } @@ -23,8 +23,8 @@ public static int[] GetCurrentVersion() throw new Exception("Reading app version from assembly failed"); } - // Drop possible pre-release cruft ("-alpha.0.1") from the end. - var versionParts = version.Split('-')[0].Split('.'); + // Drop possible pre-release or build metadata cruft ("-alpha.0.1", "+abcde") from the end. + var versionParts = version.Split('-', '+')[0].Split('.'); if (versionParts is null || versionParts.Length != 3) { @@ -49,7 +49,7 @@ public static int[] ParseLatestVersion(string releaseJsonData) return matches .Select(match => match.Groups[1].ToString().Split('.')) .Select(ver => ver.Select(part => Int32.Parse(part)).ToArray()) - .OrderByDescending(ver => ver, new Comparers.SemVer()) + .OrderByDescending(ver => ver, new SemVer()) .First(); } @@ -69,4 +69,17 @@ public static async Task FetchReleaseInformation() return await response.Content.ReadAsStringAsync(); } + + public static string GetSizeString(long byteSize) + { + double finalSize = byteSize; + string[] suffixes = { "B", "KB", "MB", "GB", "TB" }; + int suffixIndex = 0; + while (finalSize >= 1024 && suffixIndex < suffixes.Length) + { + finalSize /= 1024; + suffixIndex++; + } + return $"{finalSize:F2} {suffixes[suffixIndex]}"; + } } diff --git a/ThunderstoreCLI/Utils/ModDependencyTree.cs b/ThunderstoreCLI/Utils/ModDependencyTree.cs new file mode 100644 index 0000000..3ecde32 --- /dev/null +++ b/ThunderstoreCLI/Utils/ModDependencyTree.cs @@ -0,0 +1,93 @@ +using System.Net; +using System.Text.RegularExpressions; +using ThunderstoreCLI.Commands; +using ThunderstoreCLI.Configuration; +using ThunderstoreCLI.Models; + +namespace ThunderstoreCLI.Utils; + +public static class ModDependencyTree +{ + public static IEnumerable Generate(Config config, HttpClient http, PackageManifestV1 root, string? sourceCommunity, bool useExactVersions) + { + List? packages = null; + + if (sourceCommunity != null) + { + var cachePath = Path.Combine(config.GeneralConfig.TcliConfig, $"package-{sourceCommunity}.json"); + string packagesJson; + if (!File.Exists(cachePath) || new FileInfo(cachePath).LastWriteTime.AddMinutes(5) < DateTime.Now) + { + var packageResponse = http.Send(config.Api.GetPackagesV1(sourceCommunity)); + packageResponse.EnsureSuccessStatusCode(); + using var responseReader = new StreamReader(packageResponse.Content.ReadAsStream()); + packagesJson = responseReader.ReadToEnd(); + File.WriteAllText(cachePath, packagesJson); + } + else + { + packagesJson = File.ReadAllText(cachePath); + } + + packages = PackageListingV1.DeserializeList(packagesJson)!; + } + + Queue toVisit = new(); + Dictionary dict = new(); + int currentId = 0; + foreach (var dep in root.Dependencies!) + { + toVisit.Enqueue(dep); + } + while (toVisit.TryDequeue(out var packageString)) + { + var match = InstallCommand.FullPackageNameRegex.Match(packageString); + var fullname = match.Groups["fullname"].Value; + if (dict.TryGetValue(fullname, out var current)) + { + dict[fullname] = (currentId++, current.version); + continue; + } + var package = packages?.Find(p => p.Fullname == fullname) ?? AttemptResolveExperimental(config, http, match); + if (package is null) + continue; + PackageVersionV1? version; + if (useExactVersions) + { + string requiredVersion = match.Groups["version"].Value; + version = package.Versions!.FirstOrDefault(v => v.VersionNumber == requiredVersion); + if (version is null) + { + Write.Warn($"Version {requiredVersion} could not be found for mod {fullname}, using latest instead"); + version = package.Versions!.First(); + } + } + else + { + version = package.Versions!.First(); + } + dict[fullname] = (currentId++, version); + foreach (var dep in version.Dependencies!) + { + toVisit.Enqueue(dep); + } + } + return dict.Values.OrderByDescending(x => x.id).Select(x => x.version); + } + + private static PackageListingV1? AttemptResolveExperimental(Config config, HttpClient http, Match nameMatch) + { + var response = http.Send(config.Api.GetPackageMetadata(nameMatch.Groups["namespace"].Value, nameMatch.Groups["name"].Value)); + if (response.StatusCode == HttpStatusCode.NotFound) + { + Write.Warn($"Failed to resolve dependency {nameMatch.Groups["fullname"].Value}, continuing without it."); + return null; + } + response.EnsureSuccessStatusCode(); + using var reader = new StreamReader(response.Content.ReadAsStream()); + var data = PackageData.Deserialize(reader.ReadToEnd()); + + Write.Warn($"Package {data!.Fullname} exists in different community, ignoring"); + return null; + } +} diff --git a/ThunderstoreCLI/Utils/RequestBuilder.cs b/ThunderstoreCLI/Utils/RequestBuilder.cs index 18cd77b..1fcf8d7 100644 --- a/ThunderstoreCLI/Utils/RequestBuilder.cs +++ b/ThunderstoreCLI/Utils/RequestBuilder.cs @@ -40,6 +40,10 @@ public HttpRequestMessage GetRequest() public RequestBuilder WithEndpoint(string endpoint) { + if (!endpoint.EndsWith('/')) + { + endpoint += '/'; + } builder.Path = endpoint; return this; } diff --git a/ThunderstoreCLI/Utils/Spinner.cs b/ThunderstoreCLI/Utils/Spinner.cs index 004b7e0..4360044 100644 --- a/ThunderstoreCLI/Utils/Spinner.cs +++ b/ThunderstoreCLI/Utils/Spinner.cs @@ -1,6 +1,6 @@ using static Crayon.Output; -namespace ThunderstoreCLI; +namespace ThunderstoreCLI.Utils; public class ProgressSpinner { @@ -21,7 +21,7 @@ public ProgressSpinner(string label, Task[] tasks) _tasks = tasks; } - public async Task Start() + public async Task Spin() { // Cursor operations are not always available e.g. in GitHub Actions environment. // Done up here to minimize exception usage (throws and catches are expensive and all) @@ -66,6 +66,7 @@ public async Task Start() if (completed == _tasks.Length) { Write.Empty(); + await Task.WhenAll(_tasks); return; } diff --git a/ThunderstoreCLI/Utils/SteamUtils.cs b/ThunderstoreCLI/Utils/SteamUtils.cs new file mode 100644 index 0000000..d7f703b --- /dev/null +++ b/ThunderstoreCLI/Utils/SteamUtils.cs @@ -0,0 +1,290 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Text.RegularExpressions; +using Microsoft.Win32; + +namespace ThunderstoreCLI.Utils; + +public static class SteamUtils +{ + public static string? FindInstallDirectory(string steamAppId) + { + var path = GetAcfPath(steamAppId); + if (path == null) + { + return null; + } + + var folderName = ManifestInstallLocationRegex.Match(File.ReadAllText(path)).Groups[1].Value; + + return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(path)!, "common", folderName)); + } + + public static bool IsProtonGame(string steamAppId) + { + var path = GetAcfPath(steamAppId); + if (path == null) + { + throw new ArgumentException($"{steamAppId} is not installed!"); + } + + var source = PlatformOverrideSourceRegex.Match(File.ReadAllText(path)); + if (!source.Success) + { + return Directory.Exists(Path.Combine(Path.GetDirectoryName(path)!, "compatdata", steamAppId)); + } + return source.Groups[1].Value switch + { + "" => false, + "linux" => false, + _ => true + }; + } + + private static string? GetAcfPath(string steamAppId) + { + string? primarySteamApps = FindSteamAppsDirectory(); + if (primarySteamApps == null) + { + return null; + } + List libraryPaths = new() { primarySteamApps }; + foreach (var file in Directory.EnumerateFiles(primarySteamApps)) + { + if (!Path.GetFileName(file).Equals("libraryfolders.vdf", StringComparison.OrdinalIgnoreCase)) + continue; + libraryPaths.AddRange(SteamAppsPathsRegex.Matches(File.ReadAllText(file)).Select(x => x.Groups[1].Value).Select(x => Path.Combine(x, "steamapps"))); + break; + } + + string acfName = $"appmanifest_{steamAppId}.acf"; + foreach (var library in libraryPaths) + { + foreach (var file in Directory.EnumerateFiles(library)) + { + if (Path.GetFileName(file).Equals(acfName, StringComparison.OrdinalIgnoreCase)) + { + return file; + } + } + } + return null; + } + + private static readonly Regex SteamAppsPathsRegex = new(@"""path""\s+""(.+)"""); + private static readonly Regex ManifestInstallLocationRegex = new(@"""installdir""\s+""(.+)"""); + private static readonly Regex PlatformOverrideSourceRegex = new(@"""platform_override_source""\s+""(.+)"""); + + public static string? FindSteamExecutable() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + var mainDir = FindSteamDirectory(); + if (mainDir == null) + { + return null; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return Path.Combine(mainDir, "steam.exe"); + } + else + { + return Path.Combine(mainDir, "steam.sh"); + } + } + + string appDir; + string rooted = Path.Combine("/", "Applications", "Steam.app"); + string user = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Applications", "Steam.app"); + if (Directory.Exists(user)) + { + appDir = user; + } + else if (Directory.Exists(rooted)) + { + appDir = rooted; + } + else + { + return null; + } + + return Path.Combine(appDir, "Contents", "MacOS", "steam_osx"); + } + + public static string? FindSteamDirectory() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return FindSteamDirectoryWin(); + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return FindSteamDirectoryOsx(); + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return FindSteamDirectoryLinux(); + else + throw new NotSupportedException("Unknown operating system"); + } + + public static string? FindSteamAppsDirectory() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return FindSteamAppsDirectoryWin(); + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return FindSteamAppsDirectoryOsx(); + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return FindSteamAppsDirectoryLinux(); + else + throw new NotSupportedException("Unknown operating system"); + } + + [SupportedOSPlatform("Windows")] + private static string? FindSteamDirectoryWin() + { + return Registry.LocalMachine.OpenSubKey(@"Software\WOW6432Node\Valve\Steam", false)?.GetValue("InstallPath") as string; + } + + private static string? FindSteamAppsDirectoryWin() + { + var steamDir = FindSteamDirectory(); + if (steamDir == null) + { + return null; + } + return Path.Combine(steamDir, "steamapps"); + } + + private static string? FindSteamDirectoryOsx() + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", + "Application Support", + "Steam" + ); + } + + private static string? FindSteamAppsDirectoryOsx() + { + var steamDir = FindSteamDirectory(); + if (steamDir == null) + { + return null; + } + return Path.Combine(steamDir, "steamapps"); + } + + private static string? FindSteamDirectoryLinux() + { + string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string[] possiblePaths = { + Path.Combine(homeDir, ".local", "share", "Steam"), + Path.Combine(homeDir, ".steam", "steam"), + Path.Combine(homeDir, ".steam", "root"), + Path.Combine(homeDir, ".steam"), + Path.Combine(homeDir, ".var", "app", "com.valvesoftware.Steam", ".local", "share", "Steam"), + Path.Combine(homeDir, ".var", "app", "com.valvesoftware.Steam", ".steam", "steam"), + Path.Combine(homeDir, ".var", "app", "com.valvesoftware.Steam", ".steam", "root"), + Path.Combine(homeDir, ".var", "app", "com.valvesoftware.Steam", ".steam") + }; + string? steamPath = null; + foreach (var path in possiblePaths) + { + if (Directory.Exists(path)) + { + steamPath = path; + break; + } + } + return steamPath; + } + + private static string? FindSteamAppsDirectoryLinux() + { + var steamPath = FindSteamDirectory(); + if (steamPath == null) + { + return null; + } + + var possiblePaths = new[] + { + Path.Combine(steamPath, "steamapps"), // most distros + Path.Combine(steamPath, "steam", "steamapps"), // ubuntu apparently + Path.Combine(steamPath, "root", "steamapps"), // no idea + }; + string? steamAppsPath = null; + foreach (var path in possiblePaths) + { + if (Directory.Exists(path)) + { + steamAppsPath = path; + break; + } + } + + return steamAppsPath; + } + + public static bool ForceLoadProton(string steamAppId, string[] dllsToEnable) + { + var path = Path.Combine(Path.GetDirectoryName(GetAcfPath(steamAppId))!, "compatdata", steamAppId, "pfx", "user.reg"); + if (!File.Exists(path)) + { + return false; + } + + string[] lines = File.ReadAllLines(path); + + int start = Array.FindIndex(lines, l => l.StartsWith(@"[Software\\Wine\\DllOverrides]")); + if (start == -1) + { + return false; + } + start += 2; + + int end = Array.FindIndex(lines, start, l => l.Length == 0); + + if (end == -1) + { + end = lines.Length - 1; + } + + bool written = false; + foreach (var dll in dllsToEnable) + { + string wineOverride = $@"""{dll}""=""native,builtin"""; + bool existed = false; + for (int i = start; i < end; i++) + { + if (lines[i].StartsWith($@"""{dll}""")) + { + existed = true; + if (lines[i] != wineOverride) + { + lines[i] = wineOverride; + written = true; + } + break; + } + } + + if (!existed) + { + // resizes then moves the end and all lines past it over by 1, this is basically a manual List.Insert on an array + Array.Resize(ref lines, lines.Length + 1); + lines.AsSpan()[end..^1].CopyTo(lines.AsSpan()[(end + 1)..]); + lines[end] = wineOverride; + written = true; + } + } + + if (written) + { + File.Move(path, path + ".bak", true); + File.WriteAllLines(path, lines); + } + + return true; + } +} diff --git a/ThunderstoreCLI/Utils/StringUtils.cs b/ThunderstoreCLI/Utils/StringUtils.cs index be2c35b..5a53a1f 100644 --- a/ThunderstoreCLI/Utils/StringUtils.cs +++ b/ThunderstoreCLI/Utils/StringUtils.cs @@ -1,9 +1,11 @@ using System.Text.RegularExpressions; -namespace ThunderstoreCLI; +namespace ThunderstoreCLI.Utils; public static class StringUtils { + private static readonly Regex SemVerRegex = new(@"^[0-9]+\.[0-9]+\.[0-9]+$"); + /// /// Validate the given string adheres to MAJOR.MINOR.PATCH format /// @@ -13,7 +15,6 @@ public static class StringUtils /// public static bool IsSemVer(string version) { - var regex = new Regex(@"^[0-9]+\.[0-9]+\.[0-9]+$"); - return regex.IsMatch(version); + return SemVerRegex.IsMatch(version); } } diff --git a/ThunderstoreCLI/Utils/TomlUtils.cs b/ThunderstoreCLI/Utils/TomlUtils.cs deleted file mode 100644 index b455ebc..0000000 --- a/ThunderstoreCLI/Utils/TomlUtils.cs +++ /dev/null @@ -1,93 +0,0 @@ -using ThunderstoreCLI.Config; -using Tommy; - -namespace ThunderstoreCLI; - -public static class TomlUtils -{ - public static TomlTable DictToTomlTable(Dictionary dict) - { - var result = new TomlTable(); - foreach (var kvp in dict) - { - result.Add(kvp.Key, kvp.Value); - } - return result; - } - - public static TomlArray BuildCopyPathTable(List list) - { - var result = new TomlArray() { IsTableArray = true }; - foreach (var entry in list) - { - result.Add(DictToTomlTable(new() - { - { "source", entry.From }, - { "target", entry.To } - })); - } - return result; - } - - public static string FormatToml(TomlTable toml) - { - using (var writer = new StringWriter()) - { - writer.NewLine = "\n"; - toml.WriteTo(writer); - writer.Flush(); - return writer.ToString().Trim(); - } - } - - public static string? SafegetString(TomlNode parentNode, string key) - { - try - { - var textNode = parentNode[key]; - return textNode.IsString ? textNode.ToString() : null; - } - catch (NullReferenceException) - { - return null; - } - } - - public static bool? SafegetBool(TomlNode parentNode, string key) - { - try - { - var boolNode = parentNode[key]; - return boolNode.IsBoolean ? boolNode : null; - } - catch (NullReferenceException) - { - return null; - } - } - - public static string[]? SafegetStringArray(TomlNode parentNode, string key, string[]? defaultValue = null) - { - try - { - var arrayNode = parentNode[key]; - return arrayNode.IsArray - ? arrayNode.AsArray.RawArray.Select(x => x.AsString.Value).ToArray() - : defaultValue; - } - catch (NullReferenceException) - { - return defaultValue; - } - } - - public static TomlArray FromArray(string[] array) - { - var ret = new TomlArray(); - if (array == null) - return ret; - foreach (var val in array) - ret.Add(val); - return ret; - } -} diff --git a/ThunderstoreCLI/Utils/Write.cs b/ThunderstoreCLI/Utils/Write.cs index caea958..b682c3c 100644 --- a/ThunderstoreCLI/Utils/Write.cs +++ b/ThunderstoreCLI/Utils/Write.cs @@ -1,6 +1,6 @@ using static Crayon.Output; -namespace ThunderstoreCLI; +namespace ThunderstoreCLI.Utils; public static class Write { @@ -9,6 +9,7 @@ public static class Write private static void _Regular(string msg) => Console.WriteLine(msg); private static void _Success(string msg) => Console.WriteLine(Green(msg)); private static void _Warn(string msg) => Console.WriteLine(Yellow(msg)); + private static void _NoLine(string msg) => Console.Write(msg); private static void _WriteMultiline(Action write, string msg, string[] submsgs) { @@ -49,6 +50,9 @@ public static void Light(string message, params string[] submessages) /// Write regular line to stdout public static void Line(string message) => _Regular(message); + /// Write a string to stdout with no newline + public static void NoLine(string message) => _NoLine(message); + /// Write message with highlight color to stdout public static void Note(string message, params string[] submessages) { diff --git a/ThunderstoreCLI/dsp.yml b/ThunderstoreCLI/dsp.yml new file mode 100644 index 0000000..8e96649 --- /dev/null +++ b/ThunderstoreCLI/dsp.yml @@ -0,0 +1,19 @@ +uuid: "b4ee10ce-d22c-4da3-b084-e97ced4fec85" +label: "dsp" +meta: + displayName: "Dyson Sphere Program" + iconUrl: "DysonSphereProgram.jpg" +distributions: + - platform: "steam" + identifier: "1366540" +legacy: + internalFolderName: "DysonSphereProgram" + dataFolderName: "DSPGAME_Data" + settingsIdentifier: "DysonSphereProgram" + packageIndex: "https://dsp.thunderstore.io/api/v1/package/" + exclusionsUrl: "https://raw.githubusercontent.com/ebkr/r2modmanPlus/master/modExclusions.md" + steamFolderName: "Dyson Sphere Program" + exeNames: + - "DSPGAME.exe" + gameInstancetype: "game" + gameSelectionDisplayMode: "visible" diff --git a/ThunderstoreCLI/ror2.yml b/ThunderstoreCLI/ror2.yml new file mode 100644 index 0000000..1849582 --- /dev/null +++ b/ThunderstoreCLI/ror2.yml @@ -0,0 +1,8 @@ +uuid: "a672812c-5a54-4fb2-bcb1-0bf4b9ca1781" +label: "ror2" +meta: + displayName: "Risk of Rain 2" + iconUrl: "RiskOfRain2.jpg" +distributions: + - platform: "steam" + identifier: "632360" diff --git a/tcli-bepinex-installer/.cargo/config.toml b/tcli-bepinex-installer/.cargo/config.toml new file mode 100644 index 0000000..4ae665f --- /dev/null +++ b/tcli-bepinex-installer/.cargo/config.toml @@ -0,0 +1 @@ +target.'x86_64-pc-windows-msvc'.rustflags = "-C target-feature=+crt-static" diff --git a/tcli-bepinex-installer/.gitignore b/tcli-bepinex-installer/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/tcli-bepinex-installer/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tcli-bepinex-installer/Cargo.lock b/tcli-bepinex-installer/Cargo.lock new file mode 100644 index 0000000..272a2b7 --- /dev/null +++ b/tcli-bepinex-installer/Cargo.lock @@ -0,0 +1,728 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "aes" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", + "opaque-debug", +] + +[[package]] +name = "anyhow" +version = "1.0.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61" + +[[package]] +name = "base64ct" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b645a089122eccb6111b4f81cbc1a49f5900ac4666bb93ac027feaecf15607bf" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "block-buffer" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cce20737498f97b993470a6e536b8523f0af7892a4f928cceb1ac5e52ebe7e" +dependencies = [ + "generic-array", +] + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.11+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "cc" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20104e2335ce8a659d6dd92a51a767a0c062599c73b343fd152cb401e828c3d" +dependencies = [ + "jobserver", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + +[[package]] +name = "clap" +version = "4.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec7a4128863c188deefe750ac1d1dfe66c236909f845af04beed823638dc1b2" +dependencies = [ + "bitflags", + "clap_derive", + "clap_lex", + "is-terminal", + "once_cell", + "strsim", + "termcolor", +] + +[[package]] +name = "clap_derive" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "684a277d672e91966334af371f1a7b5833f9aa00b07c84e92fbce95e00208ce8" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "783fe232adfca04f90f56201b26d79682d4cd2625e0bc7290b95123afe558ade" +dependencies = [ + "os_str_bytes", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "cpufeatures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d997bd5e24a5928dd43e46dc529867e207907fe0b239c3477d924f7f2ca320" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb766fa798726286dbbb842f174001dab8abc7b627a1dd86e0b7222a95d929f" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "errno" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" +dependencies = [ + "errno-dragonfly", + "libc", + "winapi", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "flate2" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a2db397cb1c8772f31494cb8917e48cd1e64f0fa7efac59fbd741a0a8ce841" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "generic-array" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bff49e947297f3312447abdca79f45f4738097cc82b06e72054d2223f601f1b9" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" + +[[package]] +name = "hermit-abi" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" +dependencies = [ + "libc", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7d6c6f8c91b4b9ed43484ad1a938e393caf35960fce7f82a040497207bd8e9e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "is-terminal" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dfb6c8100ccc63462345b67d1bbc3679177c75ee4bf59bf29c8b1d110b8189" +dependencies = [ + "hermit-abi", + "io-lifetimes", + "rustix", + "windows-sys", +] + +[[package]] +name = "itoa" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad582f4b9e86b6caa621cabeb0963332d92eea04729ab12892c2533951e6440" + +[[package]] +name = "jobserver" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "068b1ee6743e4d11fb9c6a1e6064b3693a1b600e7f5f5988047d98b3dc9fb90b" +dependencies = [ + "libc", +] + +[[package]] +name = "libc" +version = "0.2.139" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" + +[[package]] +name = "linux-raw-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f051f77a7c8e6957c0696eac88f26b0117e54f52d3fc682ab19397a8812846a4" + +[[package]] +name = "miniz_oxide" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa" +dependencies = [ + "adler", +] + +[[package]] +name = "once_cell" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f61fba1741ea2b3d6a1e3178721804bb716a68a6aeba1149b5d52e3d464ea66" + +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + +[[package]] +name = "os_str_bytes" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7820b9daea5457c9f21c69448905d723fbd21136ccf521748f23fd49e723ee" + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "pkg-config" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef7d57beacfaf2d8aee5937dab7b7f28de3cb8b1828479bb5de2a7106f2bae2" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rustix" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fdebc4b395b7fbb9ab11e462e20ed9051e7b16e42d24042c776eca0ac81b03" +dependencies = [ + "bitflags", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "ryu" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde" + +[[package]] +name = "serde" +version = "1.0.152" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb7d1f0d3021d347a83e556fc4683dea2ea09d87bccdf88ff5c12545d89d5efb" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.152" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af487d118eecd09402d70a5d72551860e788df87b464af30e5ea6a38c75c541e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c235533714907a8c2464236f5c4b2a17262ef1bd71f38f35ea592c8da6883" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + +[[package]] +name = "syn" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f4064b5b16e03ae50984a5a8ed5d4f8803e6bc1fd170a3cda91a1be4b18e3f5" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tcli-bepinex-installer" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "serde", + "serde_json", + "thiserror", + "zip", +] + +[[package]] +name = "termcolor" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a9cd18aa97d5c45c6603caea1da6628790b37f7a34b6ca89522331c5180fed0" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a561bf4617eebd33bca6434b988f39ed798e527f51a1e797d0ee4f61c0a38376" +dependencies = [ + "itoa", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" + +[[package]] +name = "time-macros" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d967f99f534ca7e495c575c62638eebc2898a8c84c119b89e250477bc4ba16b2" +dependencies = [ + "time-core", +] + +[[package]] +name = "typenum" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" + +[[package]] +name = "unicode-ident" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9864e83243fdec7fc9c5444389dcbbfd258f745e7853198f365e3c4968a608" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c8b1b673ffc16c47a9ff48570a9d85e25d265735c503681332589af6253c6c7" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3887528ad530ba7bdbb1faa8275ec7a1155a45ffa57c37993960277145d640" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4d1122317eddd6ff351aa852118a2418ad4214e6613a50e0191f7004372605" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1040f221285e17ebccbc2591ffdc2d44ee1f9186324dd3e84e99ac68d699c45" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628bfdf232daa22b0d64fdb62b09fcc36bb01f05a3939e20ab73aaf9470d0463" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447660ad36a13288b1db4d4248e857b510e8c3a225c822ba4fb748c0aafecffd" + +[[package]] +name = "zip" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537ce7411d25e54e8ae21a7ce0b15840e7bfcff15b51d697ec3266cc76bdf080" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2", + "sha1", + "time", + "zstd", +] + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.5+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc50ffce891ad571e9f9afe5039c4837bede781ac4bb13052ed7ae695518596" +dependencies = [ + "cc", + "libc", + "pkg-config", +] diff --git a/tcli-bepinex-installer/Cargo.toml b/tcli-bepinex-installer/Cargo.toml new file mode 100644 index 0000000..93f0acb --- /dev/null +++ b/tcli-bepinex-installer/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "tcli-bepinex-installer" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0.65" +zip = "0.6.2" +thiserror = "1.0.37" +serde_json = "1.0.85" +serde = { version = "1.0.145", features = ["derive"] } + +[dependencies.clap] +version = "4.0.9" +features = ["derive", "cargo"] + +[profile.release] +lto = true +strip = true diff --git a/tcli-bepinex-installer/rustfmt.toml b/tcli-bepinex-installer/rustfmt.toml new file mode 100644 index 0000000..435a6ab --- /dev/null +++ b/tcli-bepinex-installer/rustfmt.toml @@ -0,0 +1,4 @@ +unstable_features = true + +group_imports = "StdExternalCrate" +imports_layout = "HorizontalVertical" diff --git a/tcli-bepinex-installer/src/main.rs b/tcli-bepinex-installer/src/main.rs new file mode 100644 index 0000000..70b2618 --- /dev/null +++ b/tcli-bepinex-installer/src/main.rs @@ -0,0 +1,414 @@ +use std::{ + collections::HashMap, + env, + ffi::OsString, + fs::{self, OpenOptions}, + io::{self, Read, Seek}, + path::{Path, PathBuf}, +}; + +use anyhow::{bail, Result}; +use clap::{Parser, Subcommand, ValueEnum}; +use serde::Deserialize; +use zip::ZipArchive; + +#[derive(Parser)] +#[clap(author, version, about)] +struct ClapArgs { + #[clap(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + Install { + game_directory: PathBuf, + bepinex_directory: PathBuf, + zip_path: PathBuf, + #[arg(long)] + namespace_backup: Option, + }, + Uninstall { + game_directory: PathBuf, + bepinex_directory: PathBuf, + name: String, + }, + StartInstructions { + game_directory: PathBuf, + bepinex_directory: PathBuf, + #[arg(long)] + game_platform: GamePlatform, + }, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Path does not exist: {0}")] + PathDoesNotExist(PathBuf), + #[error("ZIP file does not contain a Thunderstore manifest")] + NoZipManifest, + #[error("Invalid manifest in ZIP, serde_json error: {0}")] + InvalidManifest(serde_json::Error), + #[error("Malformed zip")] + MalformedZip, + #[error("Manifest does not contain a namespace and no backup was given, namespaces are required for mod installs")] + MissingNamespace, + #[error("Mod name is invalid (eg doesn't use a - between namespace and name)")] + InvalidModName, +} + +#[derive(Deserialize)] +#[allow(unused)] +struct ManifestV1 { + pub namespace: Option, + pub name: String, + pub description: String, + pub version_number: String, + #[serde(default)] + pub dependencies: Vec, + pub website_url: String, +} + +#[derive(Debug, Deserialize, Clone, Copy, ValueEnum)] +enum GamePlatform { + Windows, + Proton, + Linux, +} + +fn main() -> Result<()> { + let args = ClapArgs::parse(); + + match args.command { + Commands::Install { + game_directory, + bepinex_directory, + zip_path, + namespace_backup, + } => { + if !game_directory.exists() { + bail!(Error::PathDoesNotExist(game_directory)); + } + if !bepinex_directory.exists() { + bail!(Error::PathDoesNotExist(bepinex_directory)); + } + if !zip_path.exists() { + bail!(Error::PathDoesNotExist(zip_path)); + } + install( + game_directory, + bepinex_directory, + zip_path, + namespace_backup, + ) + } + Commands::Uninstall { + game_directory, + bepinex_directory, + name, + } => { + if !game_directory.exists() { + bail!(Error::PathDoesNotExist(game_directory)); + } + if !bepinex_directory.exists() { + bail!(Error::PathDoesNotExist(bepinex_directory)); + } + uninstall(game_directory, bepinex_directory, name) + } + Commands::StartInstructions { + game_directory, + bepinex_directory, + game_platform, + .. + } => { + output_instructions(game_directory, bepinex_directory, game_platform); + Ok(()) + } + } +} + +fn install( + game_dir: PathBuf, + bep_dir: PathBuf, + zip_path: PathBuf, + namespace_backup: Option, +) -> Result<()> { + let mut zip = ZipArchive::new(std::fs::File::open(zip_path)?)?; + + if !zip.file_names().any(|name| name == "manifest.json") { + bail!(Error::NoZipManifest); + } + + let mut manifest_file = zip.by_name("manifest.json")?; + + let mut manifest_text = String::new(); + manifest_file.read_to_string(&mut manifest_text).unwrap(); + if manifest_text.starts_with('\u{FEFF}') { + manifest_text.remove(0); + } + + drop(manifest_file); + + let manifest: ManifestV1 = + serde_json::from_str(&manifest_text).map_err(Error::InvalidManifest)?; + + if manifest.name.starts_with("BepInEx") && zip.file_names().any(|f| f.ends_with("winhttp.dll")) + { + install_bepinex(game_dir, bep_dir, zip) + } else { + install_mod(bep_dir, zip, manifest, namespace_backup) + } +} + +fn install_bepinex( + game_dir: PathBuf, + bep_dir: PathBuf, + mut zip: ZipArchive, +) -> Result<()> { + let write_opts = { + let mut opts = OpenOptions::new(); + opts.write(true).create(true); + opts + }; + + for i in 0..zip.len() { + let mut file = zip.by_index(i)?; + + if file.is_dir() { + continue; + } + + let filepath = file.enclosed_name().ok_or(Error::MalformedZip)?.to_owned(); + + if !top_level_directory_name(&filepath) + .unwrap_or("") + .starts_with("BepInExPack") + { + continue; + } + + let in_bep_folder = filepath.ancestors().any(|part| { + part.file_name() + .unwrap_or(&OsString::new()) + .to_string_lossy() + == "BepInEx" + }); + + let dir_to_use = if in_bep_folder { &bep_dir } else { &game_dir }; + + // this removes the BepInExPack*/ from the path + let resolved_path = remove_first_n_directories(&filepath, 1); + + fs::create_dir_all(dir_to_use.join(resolved_path.parent().unwrap()))?; + io::copy( + &mut file, + &mut write_opts.open(dir_to_use.join(resolved_path))?, + )?; + } + + Ok(()) +} + +fn install_mod( + bep_dir: PathBuf, + mut zip: ZipArchive, + manifest: ManifestV1, + namespace_backup: Option, +) -> Result<()> { + let write_opts = OpenOptions::new().write(true).create(true).clone(); + + let full_name = format!( + "{}-{}", + manifest + .namespace + .or(namespace_backup) + .ok_or(Error::MissingNamespace)?, + manifest.name + ); + + let mut remaps = HashMap::new(); + remaps.insert( + Path::new("BepInEx").join("plugins"), + Path::new("BepInEx").join("plugins").join(&full_name), + ); + remaps.insert( + Path::new("BepInEx").join("patchers"), + Path::new("BepInEx").join("patchers").join(&full_name), + ); + remaps.insert( + Path::new("BepInEx").join("monomod"), + Path::new("BepInEx").join("monomod").join(&full_name), + ); + remaps.insert( + Path::new("BepInEx").join("config"), + Path::new("BepInEx").join("config"), + ); + + let default_remap = &remaps[&Path::new("BepInEx").join("plugins")]; + + for i in 0..zip.len() { + let mut file = zip.by_index(i)?; + + if file.is_dir() { + continue; + } + + let filepath = file.enclosed_name().ok_or(Error::MalformedZip)?.to_owned(); + let filename = match filepath.file_name() { + Some(name) => name, + None => continue, + }; + + let mut out_path = None; + 'outer: for remap in remaps.keys() { + for variant in get_path_variants(remap) { + if let Ok(p) = filepath.strip_prefix(variant) { + out_path = Some(remaps[remap].join(p)); + break 'outer; + } + } + } + if out_path.is_none() { + out_path = Some(default_remap.join(filename)); + } + + let full_out_path = bep_dir.join(out_path.unwrap()); + + fs::create_dir_all(full_out_path.parent().unwrap())?; + io::copy(&mut file, &mut write_opts.open(full_out_path)?)?; + } + + Ok(()) +} + +fn uninstall(game_dir: PathBuf, bep_dir: PathBuf, name: String) -> Result<()> { + if name + .split_once('-') + .ok_or(Error::InvalidModName)? + .1 + .starts_with("BepInEx") + { + uninstall_bepinex(game_dir, bep_dir) + } else { + uninstall_mod(bep_dir, name) + } +} + +fn uninstall_bepinex(game_dir: PathBuf, bep_dir: PathBuf) -> Result<()> { + delete_file_if_not_deleted(game_dir.join("winhttp.dll"))?; + delete_file_if_not_deleted(game_dir.join("doorstop_config.ini"))?; + delete_file_if_not_deleted(game_dir.join("run_bepinex.sh"))?; + delete_dir_if_not_deleted(game_dir.join("doorstop_libs"))?; + delete_dir_if_not_deleted(bep_dir.join("BepInEx"))?; + + Ok(()) +} + +fn uninstall_mod(bep_dir: PathBuf, name: String) -> Result<()> { + let actual_bep = bep_dir.join("BepInEx"); + delete_dir_if_not_deleted(actual_bep.join("plugins").join(&name))?; + delete_dir_if_not_deleted(actual_bep.join("patchers").join(&name))?; + delete_dir_if_not_deleted(actual_bep.join("monomod").join(&name))?; + + Ok(()) +} + +fn output_instructions(game_dir: PathBuf, bep_dir: PathBuf, platform: GamePlatform) { + let drive_prefix = if matches!(platform, GamePlatform::Proton) { + "Z:\\" + } else { + "" + }; + + let bep_preloader_dll = bep_dir + .join("BepInEx") + .join("core") + .join("BepInEx.Preloader.dll") + .to_string_lossy() + .into_owned(); + + match platform { + GamePlatform::Windows | GamePlatform::Proton => { + println!( + "ARGUMENTS:--doorstop-enable true --doorstop-target {}{}", + drive_prefix, + bep_preloader_dll.replace('/', "\\") + ); + println!("WINEDLLOVERRIDE:winhttp"); + } + GamePlatform::Linux => { + println!("ENVIRONMENT:DOORSTOP_ENABLE=TRUE"); + println!("ENVIRONMENT:DOORSTOP_INVOKE_DLL_PATH={}", bep_preloader_dll); + println!( + "ENVIRONMENT:DOORSTOP_CORLIB_OVERRIDE_PATH={}", + game_dir.join("unstripped_corlib").to_string_lossy() + ); + + let mut ld_library = OsString::from(game_dir.join("doorstop_libs")); + if let Some(orig) = env::var_os("LD_LIBRARY_PATH") { + ld_library.push(":"); + ld_library.push(orig); + } + + println!( + "ENVIRONMENT:LD_LIBRARY_PATH={}", + ld_library.to_string_lossy() + ); + + let mut ld_preload = OsString::from({ + // FIXME: properly determine arch of the game exe, instead of assuming its the same as this exe + if cfg!(target_arch = "x86_64") { + "libdoorstop_x64.so" + } else { + "libdoorstop_x86.so" + } + }); + if let Some(orig) = env::var_os("LD_PRELOAD") { + ld_preload.push(":"); + ld_preload.push(orig); + } + + println!("ENVIRONMENT:LD_PRELOAD={}", ld_preload.to_string_lossy()); + } + } +} + +fn top_level_directory_name(path: &Path) -> Option<&str> { + path.components() + .next() + .and_then(|n| n.as_os_str().to_str()) +} + +/// removes the first n directories from a path, eg a/b/c/d.txt with an n of 2 gives c/d.txt +fn remove_first_n_directories(path: &Path, n: usize) -> PathBuf { + PathBuf::from_iter(path.iter().skip(n)) +} + +fn get_path_variants(path: &Path) -> Vec { + let mut res = vec![path.into()]; + let components: Vec<_> = path.components().collect(); + for i in 1usize..components.len() { + res.push(PathBuf::from_iter(components.iter().skip(i))) + } + res +} + +fn delete_file_if_not_deleted>(path: T) -> io::Result<()> { + match fs::remove_file(path) { + Ok(_) => Ok(()), + Err(e) => match e.kind() { + io::ErrorKind::NotFound => Ok(()), + _ => Err(e), + }, + } +} + +fn delete_dir_if_not_deleted>(path: T) -> io::Result<()> { + match fs::remove_dir_all(path) { + Ok(_) => Ok(()), + Err(e) => match e.kind() { + io::ErrorKind::NotFound => Ok(()), + _ => Err(e), + }, + } +}