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 38aefe1..3229fc7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,26 +2,28 @@ name: Publish on: release: - types: [released, prereleased] + types: [ published ] jobs: nuget: + name: Publish NuGet Package runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-dotnet@v3 + - name: Checkout ref that triggered workflow + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: 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 f38d3b2..dfc09ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,26 +8,34 @@ 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.target }} runs-on: ${{ matrix.os }} strategy: matrix: @@ -43,20 +51,25 @@ jobs: target: osx-x64 os: macos-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-dotnet@v3 + - name: Checkout ref that triggered workflow + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: dotnet-version: '7' - - id: tag - uses: dawidd6/action-get-tag@v1 + - 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 }}" -f net7.0 -o "${release_name}" + 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}" @@ -66,39 +79,44 @@ jobs: 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@v3 + - name: Checkout ref that triggered workflow + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: dotnet-version: '7' - - id: tag - uses: dawidd6/action-get-tag@v1 + - 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:PublishSelfContained=false -p:PublishSingleFile=false -p:PublishTrimmed=false -p:PublishReadyToRun=false + 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 ${{ 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.') }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cf6a83b..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@v3 + - name: Checkout ref that triggered workflow + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: dotnet-version: '7' - - uses: actions/setup-python@v2 + + - 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@v3 + - name: Checkout ref that triggered workflow + uses: actions/checkout@v4 + + - name: setup .NET + uses: actions/setup-dotnet@v4 with: 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 -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/ThunderstoreCLI.Tests.csproj b/ThunderstoreCLI.Tests/ThunderstoreCLI.Tests.csproj index 13b2e78..9c4f2a8 100644 --- a/ThunderstoreCLI.Tests/ThunderstoreCLI.Tests.csproj +++ b/ThunderstoreCLI.Tests/ThunderstoreCLI.Tests.csproj @@ -8,23 +8,22 @@ - - - + + + 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/Commands/BuildCommand.cs b/ThunderstoreCLI/Commands/BuildCommand.cs index b37abb1..3cdbeac 100644 --- a/ThunderstoreCLI/Commands/BuildCommand.cs +++ b/ThunderstoreCLI/Commands/BuildCommand.cs @@ -309,7 +309,7 @@ public static string SerializeManifest(Config config) public static List ValidateConfig(Config config, bool throwIfErrors = true) { - var v = new Validator("build"); + 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"); diff --git a/ThunderstoreCLI/Configuration/Validator.cs b/ThunderstoreCLI/Commands/CommandValidator.cs similarity index 93% rename from ThunderstoreCLI/Configuration/Validator.cs rename to ThunderstoreCLI/Commands/CommandValidator.cs index f39b696..fbd7ac6 100644 --- a/ThunderstoreCLI/Configuration/Validator.cs +++ b/ThunderstoreCLI/Commands/CommandValidator.cs @@ -1,14 +1,14 @@ using ThunderstoreCLI.Utils; -namespace ThunderstoreCLI.Configuration; +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 index 8fe76a4..45a6bfb 100644 --- a/ThunderstoreCLI/Commands/ImportGameCommand.cs +++ b/ThunderstoreCLI/Commands/ImportGameCommand.cs @@ -9,21 +9,20 @@ public static class ImportGameCommand { public static int Run(Config config) { - R2mmGameDescription? desc; - try - { - desc = R2mmGameDescription.Deserialize(File.ReadAllText(config.GameImportConfig.FilePath!)); - } - catch (Exception e) - { - throw new CommandFatalException($"Failed to read game description file: {e}"); - } - if (desc is null) + 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("Game description file was empty"); + throw new CommandFatalException($"Could not find game with ID {config.GameImportConfig.GameId}"); } - var def = desc.ToGameDefintion(config); + var def = game.ToGameDefintion(config); if (def == null) { throw new CommandFatalException("Game not installed"); diff --git a/ThunderstoreCLI/Commands/InitCommand.cs b/ThunderstoreCLI/Commands/InitCommand.cs index 6aaa02f..a63bb57 100644 --- a/ThunderstoreCLI/Commands/InitCommand.cs +++ b/ThunderstoreCLI/Commands/InitCommand.cs @@ -76,7 +76,7 @@ public static string BuildReadme(Config config) private static void ValidateConfig(Config config) { - var v = new Validator("init"); + 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"); diff --git a/ThunderstoreCLI/Commands/InstallCommand.cs b/ThunderstoreCLI/Commands/InstallCommand.cs index 65f610c..1d7bb28 100644 --- a/ThunderstoreCLI/Commands/InstallCommand.cs +++ b/ThunderstoreCLI/Commands/InstallCommand.cs @@ -13,7 +13,7 @@ namespace ThunderstoreCLI.Commands; public static partial class InstallCommand { // will match either ab-cd or ab-cd-123.456.7890 - internal static Regex FullPackageNameRegex = new Regex(@"^(?(?[\w-\.]+)-(?\w+))(?:|-(?\d+\.\d+\.\d+))$"); + internal static readonly Regex FullPackageNameRegex = new Regex(@"^(?(?[\w-\.]+)-(?\w+))(?:|-(?\d+\.\d+\.\d+))$"); public static async Task Run(Config config) { @@ -37,7 +37,7 @@ public static async Task Run(Config config) Match packageMatch = FullPackageNameRegex.Match(package); if (File.Exists(package)) { - returnCode = await InstallZip(config, http, def, profile, package, null, null); + returnCode = await InstallZip(config, http, def, profile, package, null, null, false); } else if (packageMatch.Success) { @@ -65,21 +65,23 @@ private static async Task InstallFromRepository(Config config, HttpClient h if (version.Success) { var versionResponse = await http.SendAsync(config.Api.GetPackageVersionMetadata(ns.Value, name.Value, version.Value)); - versionResponse.EnsureSuccessStatusCode(); + 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)); - packageResponse.EnsureSuccessStatusCode(); + 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); + 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) + 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!"); @@ -88,45 +90,48 @@ private static async Task InstallZip(Config config, HttpClient http, GameDe manifest.Namespace ??= backupNamespace; - var dependenciesToInstall = ModDependencyTree.Generate(config, http, manifest, sourceCommunity) - .Where(dependency => !profile.InstalledModVersions.ContainsKey(dependency.Fullname!)) + 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 = MiscUtils.GetSizeString(dependenciesToInstall.Select(d => d.Versions![0].FileSize).Sum()); - Write.Light($"Total estimated download size: "); + 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 => - { - var version = mod.Versions![0]; - return config.Cache.GetFileOrDownload($"{mod.Fullname}-{version.VersionNumber}.zip", version.DownloadUrl!); - }).ToArray(); + config.Cache.GetFileOrDownload($"{mod.FullName}-{mod.VersionNumber}.zip", mod.DownloadUrl!) + ).ToArray(); var spinner = new ProgressSpinner("dependencies downloaded", downloadTasks); await spinner.Spin(); - foreach (var (tempZipPath, package) in downloadTasks.Select(x => x.Result).Zip(dependenciesToInstall)) + foreach (var (tempZipPath, pVersion) in downloadTasks.Select(x => x.Result).Zip(dependenciesToInstall)) { - var packageVersion = package.Versions![0]; - int returnCode = RunInstaller(game, profile, tempZipPath, package.Owner); + int returnCode = RunInstaller(game, profile, tempZipPath, pVersion.FullNameParts["namespace"].Value); if (returnCode == 0) { - Write.Success($"Installed mod: {package.Fullname}-{packageVersion.VersionNumber}"); + Write.Success($"Installed mod: {pVersion.FullName}"); } else { - Write.Error($"Failed to install mod: {package.Fullname}-{packageVersion.VersionNumber}"); + Write.Error($"Failed to install mod: {pVersion.FullName}"); return returnCode; } - profile.InstalledModVersions[package.Fullname!] = new PackageManifestV1(package, packageVersion); + 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] = manifest; + profile.InstalledModVersions[manifest.FullName] = new InstalledModVersion(manifest.FullName, manifest.VersionNumber!, manifest.Dependencies!); Write.Success($"Installed mod: {manifest.FullName}-{manifest.VersionNumber}"); } else 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 0c78956..de07e9b 100644 --- a/ThunderstoreCLI/Commands/PublishCommand.cs +++ b/ThunderstoreCLI/Commands/PublishCommand.cs @@ -239,11 +239,7 @@ private static void PublishPackageRequest(Config config, string uploadUuid) using var response = await HttpClient.SendAsync(request); - try - { - response.EnsureSuccessStatusCode(); - } - catch + if (!response.IsSuccessStatusCode) { Write.Empty(); Write.ErrorExit(await response.Content.ReadAsStringAsync()); @@ -294,7 +290,7 @@ private static void HandleRequestError( private static void ValidateConfig(Config config, bool justReturnErrors = false) { var buildConfigErrors = BuildCommand.ValidateConfig(config, false); - var v = new 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 index 066e2e8..d3b7de2 100644 --- a/ThunderstoreCLI/Commands/RunCommand.cs +++ b/ThunderstoreCLI/Commands/RunCommand.cs @@ -4,7 +4,6 @@ using ThunderstoreCLI.Configuration; using ThunderstoreCLI.Game; using ThunderstoreCLI.Utils; -using YamlDotNet.Core.Tokens; namespace ThunderstoreCLI.Commands; @@ -27,6 +26,8 @@ public static int Run(Config config) 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 = @@ -39,17 +40,17 @@ public static int Run(Config config) RedirectStandardError = true }; - var gameIsProton = SteamUtils.IsProtonGame(def.PlatformId); + var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + // TODO: Wine without Steam + var gameIsProton = isSteam && SteamUtils.IsProtonGame(def.PlatformId!); - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + startInfo.ArgumentList.Add("--game-platform"); + startInfo.ArgumentList.Add((isWindows, gameIsProton) switch { - startInfo.ArgumentList.Add("--game-platform"); - startInfo.ArgumentList.Add(gameIsProton switch - { - true => "windows", - false => "linux" - }); - } + (true, _) => "windows", + (false, true) => "proton", + (false, false) => "linux" + }); var installerProcess = Process.Start(startInfo)!; installerProcess.WaitForExit(); @@ -62,6 +63,7 @@ public static int Run(Config config) 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) @@ -85,32 +87,65 @@ public static int Run(Config config) } wineDlls = args.Split(':'); break; + case "ENVIRONMENT": + var parts = args.Split('='); + environ.Add(new(parts[0], parts[1])); + break; } } - var steamExePath = SteamUtils.FindSteamExecutable(); - if (steamExePath == null) - { - throw new CommandFatalException("Couldn't find steam install directory!"); - } + var allArgs = string.Join(' ', runArguments, config.RunGameConfig.UserArguments); - if (gameIsProton && wineDlls.Length > 0) + if (isSteam) { - if (!SteamUtils.ForceLoadProton(def.PlatformId, wineDlls)) + var steamExePath = SteamUtils.FindSteamExecutable(); + if (steamExePath == null) { - throw new CommandFatalException($"No compat files could be found for app id {def.PlatformId}, please run the game at least once."); + throw new CommandFatalException("Couldn't find steam install directory!"); } - } - ProcessStartInfo runSteamInfo = new(steamExePath) + 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) { - Arguments = $"-applaunch {def.PlatformId} {runArguments}" - }; + var exePath = def.ExePath!; + + if (!File.Exists(exePath)) + { + throw new CommandFatalException($"Executable {exePath} could not be found."); + } - Write.Note($"Starting appid {def.PlatformId} with arguments: {runArguments}"); - var steamProcess = Process.Start(runSteamInfo)!; - steamProcess.WaitForExit(); - Write.Success($"Started game with appid {def.PlatformId}"); + 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 index ed57c79..c510fc5 100644 --- a/ThunderstoreCLI/Commands/UninstallCommand.cs +++ b/ThunderstoreCLI/Commands/UninstallCommand.cs @@ -35,7 +35,7 @@ public static int Run(Config config) var searchWithDash = search + '-'; foreach (var mod in profile.InstalledModVersions.Values) { - if (mod.Dependencies!.Any(s => s.StartsWith(searchWithDash))) + if (mod.Dependencies.Any(s => s.StartsWith(searchWithDash))) { if (modsToRemove.Add(mod.FullName)) { diff --git a/ThunderstoreCLI/Configuration/CLIParameterConfig.cs b/ThunderstoreCLI/Configuration/CLIParameterConfig.cs index 851e760..759873e 100644 --- a/ThunderstoreCLI/Configuration/CLIParameterConfig.cs +++ b/ThunderstoreCLI/Configuration/CLIParameterConfig.cs @@ -101,7 +101,8 @@ public GameImportCommandConfig(GameImportOptions options) : base(options) { } { return new GameImportConfig() { - FilePath = options.FilePath + ExePath = options.ExePath, + GameId = options.GameId, }; } } @@ -115,7 +116,13 @@ public RunGameCommandConfig(RunGameOptions options) : base(options) { } return new RunGameConfig() { GameName = options.GameName, - ProfileName = options.Profile + 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 index 0e6b2d3..ff35a83 100644 --- a/ThunderstoreCLI/Configuration/Config.cs +++ b/ThunderstoreCLI/Configuration/Config.cs @@ -37,7 +37,7 @@ public static Config FromCLI(IConfigProvider cliConfig) providers.Add(new EnvironmentConfig()); if (cliConfig is CLIConfig) providers.Add(new ProjectFileConfig()); - providers.Add(new BaseConfig()); + providers.Add(new DefaultConfig()); return Parse(providers.ToArray()); } @@ -102,7 +102,10 @@ public PackageUploadMetadata GetUploadMetadata(string fileUuid) return new PackageUploadMetadata() { AuthorName = PackageConfig.Namespace, - Categories = PublishConfig.Categories, + 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 @@ -212,7 +215,7 @@ public class PublishConfig { public string? File { get; set; } public string[]? Communities { get; set; } - public string[]? Categories { get; set; } + public Dictionary? Categories { get; set; } } public class AuthConfig @@ -229,11 +232,13 @@ public class ModManagementConfig public class GameImportConfig { - public string? FilePath { get; set; } + 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/Configuration/BaseConfig.cs b/ThunderstoreCLI/Configuration/DefaultConfig.cs similarity index 97% rename from ThunderstoreCLI/Configuration/BaseConfig.cs rename to ThunderstoreCLI/Configuration/DefaultConfig.cs index 16f32a1..c78cef7 100644 --- a/ThunderstoreCLI/Configuration/BaseConfig.cs +++ b/ThunderstoreCLI/Configuration/DefaultConfig.cs @@ -1,6 +1,6 @@ namespace ThunderstoreCLI.Configuration; -class BaseConfig : EmptyConfig +class DefaultConfig : EmptyConfig { public override GeneralConfig? GetGeneralConfig() { diff --git a/ThunderstoreCLI/Configuration/ProjectFileConfig.cs b/ThunderstoreCLI/Configuration/ProjectFileConfig.cs index 2afec61..33323a3 100644 --- a/ThunderstoreCLI/Configuration/ProjectFileConfig.cs +++ b/ThunderstoreCLI/Configuration/ProjectFileConfig.cs @@ -62,7 +62,7 @@ public override void Parse(Config currentConfig) { return new PublishConfig() { - Categories = Project.Publish?.Categories, + Categories = Project.Publish?.Categories.Categories, Communities = Project.Publish?.Communities }; } diff --git a/ThunderstoreCLI/Game/GameDefinition.cs b/ThunderstoreCLI/Game/GameDefinition.cs index 7b54739..d3047cb 100644 --- a/ThunderstoreCLI/Game/GameDefinition.cs +++ b/ThunderstoreCLI/Game/GameDefinition.cs @@ -1,4 +1,9 @@ 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; @@ -9,15 +14,16 @@ 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 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) + internal GameDefinition(string id, string name, string installDirectory, GamePlatform platform, string? platformId, string tcliDirectory) { Identifier = id; Name = name; @@ -26,19 +32,37 @@ internal GameDefinition(string id, string name, string installDirectory, GamePla PlatformId = platformId; } - internal static GameDefinition? FromPlatformInstall(string tcliDir, GamePlatform platform, string platformId, string id, string name) + internal static GameDefinition? FromPlatformInstall(Config config, GamePlatform platform, string platformId, string id, string name) { var gameDir = platform switch { - GamePlatform.steam => SteamUtils.FindInstallDirectory(platformId), + GamePlatform.Steam => SteamUtils.FindInstallDirectory(platformId), _ => null }; if (gameDir == null) { return null; } - return new GameDefinition(id, name, gameDir, platform, platformId, tcliDir); + 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 @@ -69,9 +93,14 @@ public void Write() IEnumerator IEnumerable.GetEnumerator() => List.GetEnumerator(); } +[JsonConverter(typeof(StringEnumConverter), typeof(KebabCaseNamingStrategy))] public enum GamePlatform { - steam, - egs, - other + Steam, + SteamDirect, + EGS, + XboxGamePass, + Oculus, + Origin, + Other } diff --git a/ThunderstoreCLI/Game/ModProfile.cs b/ThunderstoreCLI/Game/ModProfile.cs index 89f5869..5a43fb0 100644 --- a/ThunderstoreCLI/Game/ModProfile.cs +++ b/ThunderstoreCLI/Game/ModProfile.cs @@ -4,11 +4,13 @@ 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(); + public Dictionary InstalledModVersions { get; } = new(); #pragma warning disable CS8618 private ModProfile() { } @@ -18,7 +20,7 @@ internal ModProfile(GameDefinition gameDef, string name, string tcliDirectory) { Name = name; - var directory = Path.Combine(tcliDirectory, "Profiles", gameDef.Identifier, name); + var directory = Path.GetFullPath(Path.Combine(tcliDirectory, "Profiles", gameDef.Identifier, name)); if (!Directory.Exists(directory)) { Directory.CreateDirectory(directory); diff --git a/ThunderstoreCLI/Models/BaseYaml.cs b/ThunderstoreCLI/Models/BaseYaml.cs deleted file mode 100644 index dc9e474..0000000 --- a/ThunderstoreCLI/Models/BaseYaml.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using YamlDotNet.Serialization; - -namespace ThunderstoreCLI.Models; - -public abstract class BaseYaml<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T> : ISerialize where T : BaseYaml -{ - public string Serialize() - { - return BaseYamlHelper.Serializer.Serialize(this); - } - public static T? Deserialize(string input) - { - return BaseYamlHelper.Deserializer.Deserialize(input); - } -} - -file static class BaseYamlHelper -{ - public static readonly Serializer Serializer = new(); - public static readonly Deserializer Deserializer = new(); -} diff --git a/ThunderstoreCLI/Models/PackageListingV1.cs b/ThunderstoreCLI/Models/PackageListingV1.cs index 4a6743d..b4b9f94 100644 --- a/ThunderstoreCLI/Models/PackageListingV1.cs +++ b/ThunderstoreCLI/Models/PackageListingV1.cs @@ -1,4 +1,6 @@ +using System.Text.RegularExpressions; using Newtonsoft.Json; +using ThunderstoreCLI.Commands; namespace ThunderstoreCLI.Models; @@ -102,7 +104,12 @@ public class PackageVersionV1 public string? Uuid4 { get; set; } [JsonProperty("file_size")] - public int FileSize { get; set; } + public long FileSize { get; set; } + + [JsonIgnore] + private GroupCollection? _fullNameParts; + [JsonIgnore] + public GroupCollection FullNameParts => _fullNameParts ??= InstallCommand.FullPackageNameRegex.Match(FullName!).Groups; public PackageVersionV1() { } diff --git a/ThunderstoreCLI/Models/PublishModels.cs b/ThunderstoreCLI/Models/PublishModels.cs index f4aa90d..cb2e0af 100644 --- a/ThunderstoreCLI/Models/PublishModels.cs +++ b/ThunderstoreCLI/Models/PublishModels.cs @@ -10,6 +10,8 @@ public class PackageUploadMetadata : BaseJson [JsonProperty("communities")] public string[]? Communities { get; set; } + [JsonProperty("community_categories")] public Dictionary? CommunityCategories { get; set; } + [JsonProperty("has_nsfw_content")] public bool HasNsfwContent { get; set; } [JsonProperty("upload_uuid")] public string? UploadUUID { get; set; } diff --git a/ThunderstoreCLI/Models/R2mmGameDescription.cs b/ThunderstoreCLI/Models/R2mmGameDescription.cs deleted file mode 100644 index 9f6996c..0000000 --- a/ThunderstoreCLI/Models/R2mmGameDescription.cs +++ /dev/null @@ -1,31 +0,0 @@ -using ThunderstoreCLI.Configuration; -using ThunderstoreCLI.Game; - -namespace ThunderstoreCLI.Models; - -public class R2mmGameDescription : BaseYaml -{ - 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 required object? legacy { get; set; } - - public GameDefinition? ToGameDefintion(Config config) - { - var platform = distributions.First(p => p.platform == GamePlatform.steam); - return GameDefinition.FromPlatformInstall(config.GeneralConfig.TcliConfig, 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; } -} 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 index f39ab60..6b09b7a 100644 --- a/ThunderstoreCLI/Models/ThunderstoreProject.cs +++ b/ThunderstoreCLI/Models/ThunderstoreProject.cs @@ -1,11 +1,36 @@ 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 { @@ -32,10 +57,8 @@ public class PackageData [TomlProperty("containsNsfwContent")] public bool ContainsNsfwContent { get; set; } = false; [TomlProperty("dependencies")] - public Dictionary Dependencies { get; set; } = new() - { - { "AuthorName-PackageName", "0.0.1" } - }; + [TomlDoNotInlineObject] + public Dictionary Dependencies { get; set; } = new() { { "AuthorName-PackageName", "0.0.1" } }; } [TomlProperty("package")] public PackageData? Package { get; set; } @@ -58,8 +81,9 @@ public class CopyPath [TomlProperty("target")] public string Target { get; set; } = ""; } + [TomlProperty("copy")] - public CopyPath[] CopyPaths { get; set; } = Array.Empty(); + public CopyPath[] CopyPaths { get; set; } = new CopyPath[] { new CopyPath() }; } [TomlProperty("build")] public BuildData? Build { get; set; } @@ -74,10 +98,15 @@ public class PublishData { "riskofrain2" }; + [TomlProperty("categories")] - public string[] Categories { get; set; } = + [TomlDoNotInlineObject] + public CategoryDictionary Categories { get; set; } = new() { - "items", "skills" + Categories = new Dictionary + { + { "riskofrain2", new[] { "items", "skills" } } + } }; } [TomlProperty("publish")] @@ -111,7 +140,7 @@ public ThunderstoreProject(Config config) }; Publish = new PublishData() { - Categories = config.PublishConfig.Categories!, + 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 d25fe8e..9a3a14f 100644 --- a/ThunderstoreCLI/Options.cs +++ b/ThunderstoreCLI/Options.cs @@ -75,11 +75,6 @@ public override bool Validate() return false; } - if (!Directory.Exists(TcliDirectory)) - { - Directory.CreateDirectory(TcliDirectory!); - } - return true; } } @@ -123,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)) @@ -147,13 +142,13 @@ public override int Execute() public abstract class ModManagementOptions : BaseOptions { - [Value(0, MetaName = "Game Name", Required = true, HelpText = "Can be any of: ror2, vrising, vrising_dedicated, vrising_builtin")] + [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")] + [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 = "Default")] + [Option(HelpText = "Profile to install to", Default = "DefaultProfile")] public string? Profile { get; set; } protected enum CommandInner @@ -176,29 +171,32 @@ public override int Execute() } } -[Verb("install")] +[Verb("install", HelpText = "Installs a mod to a profile")] public class InstallOptions : ModManagementOptions { protected override CommandInner CommandType => CommandInner.Install; } -[Verb("uninstall")] +[Verb("uninstall", HelpText = "Uninstalls a mod from a profile")] public class UninstallOptions : ModManagementOptions { protected override CommandInner CommandType => CommandInner.Uninstall; } -[Verb("import-game")] +[Verb("import-game", HelpText = "Imports a new game to use with TCLI")] public class GameImportOptions : BaseOptions { - [Value(0, MetaName = "File Path", Required = true, HelpText = "Path to game description file to import")] - public required string FilePath { get; set; } + [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 (!File.Exists(FilePath)) + if (!string.IsNullOrWhiteSpace(ExePath) && !File.Exists(ExePath)) { - Write.ErrorExit($"Could not locate game description file at {FilePath}"); + Write.ErrorExit($"Could not locate game exe at {ExePath}"); } return base.Validate(); @@ -211,18 +209,31 @@ public override int Execute() } } -[Verb("run")] +[Verb("run", HelpText = "Run a game modded")] public class RunGameOptions : BaseOptions { - [Value(0, MetaName = "Game Name", Required = true, HelpText = "Can be any of: ror2, vrising, vrising_dedicated, vrising_builtin")] - public required string GameName { get; set; } = null!; + [Value(0, MetaName = "Game", Required = true, HelpText = "The identifier of the game to run.")] + public required string GameName { get; set; } - [Option(HelpText = "Profile to install to", Default = "Default")] + [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/Program.cs b/ThunderstoreCLI/Program.cs index 6e99f00..3b5d9a4 100644 --- a/ThunderstoreCLI/Program.cs +++ b/ThunderstoreCLI/Program.cs @@ -15,10 +15,20 @@ private static int Main(string[] args) { } #endif + string? trailingArgs = null; + + 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]; + } + var updateChecker = UpdateChecker.CheckForUpdates(); var exitCode = Parser.Default.ParseArguments(args) .MapResult( @@ -29,7 +39,16 @@ private static int Main(string[] args) (InstallOptions o) => HandleParsed(o), (UninstallOptions o) => HandleParsed(o), (GameImportOptions o) => HandleParsed(o), - (RunGameOptions o) => HandleParsed(o), + (RunGameOptions o) => + { + if (trailingArgs != null) + { + o.Args = trailingArgs; + } + return HandleParsed(o); + }, + + (ListOptions o) => HandleParsed(o), #endif _ => 1 // failure to parse ); diff --git a/ThunderstoreCLI/ThunderstoreCLI.csproj b/ThunderstoreCLI/ThunderstoreCLI.csproj index 3b04b80..7fdeeb0 100644 --- a/ThunderstoreCLI/ThunderstoreCLI.csproj +++ b/ThunderstoreCLI/ThunderstoreCLI.csproj @@ -26,20 +26,19 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + @@ -62,7 +61,7 @@ - + true @@ -76,7 +75,7 @@ - + true 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 index c1ae67a..119b1e3 100644 --- a/ThunderstoreCLI/Utils/DownloadCache.cs +++ b/ThunderstoreCLI/Utils/DownloadCache.cs @@ -18,6 +18,11 @@ public DownloadCache(string 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) { diff --git a/ThunderstoreCLI/Utils/MiscUtils.cs b/ThunderstoreCLI/Utils/MiscUtils.cs index 84d3626..627863e 100644 --- a/ThunderstoreCLI/Utils/MiscUtils.cs +++ b/ThunderstoreCLI/Utils/MiscUtils.cs @@ -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) { @@ -80,6 +80,6 @@ public static string GetSizeString(long byteSize) finalSize /= 1024; suffixIndex++; } - return $"{byteSize:F2} {suffixes[suffixIndex]}"; + return $"{finalSize:F2} {suffixes[suffixIndex]}"; } } diff --git a/ThunderstoreCLI/Utils/ModDependencyTree.cs b/ThunderstoreCLI/Utils/ModDependencyTree.cs index 8d421f8..3ecde32 100644 --- a/ThunderstoreCLI/Utils/ModDependencyTree.cs +++ b/ThunderstoreCLI/Utils/ModDependencyTree.cs @@ -8,7 +8,7 @@ namespace ThunderstoreCLI.Utils; public static class ModDependencyTree { - public static IEnumerable Generate(Config config, HttpClient http, PackageManifestV1 root, string? sourceCommunity) + public static IEnumerable Generate(Config config, HttpClient http, PackageManifestV1 root, string? sourceCommunity, bool useExactVersions) { List? packages = null; @@ -32,67 +32,62 @@ public static IEnumerable Generate(Config config, HttpClient h packages = PackageListingV1.DeserializeList(packagesJson)!; } - HashSet visited = new(); - foreach (var originalDep in root.Dependencies!) + Queue toVisit = new(); + Dictionary dict = new(); + int currentId = 0; + foreach (var dep in root.Dependencies!) { - var match = InstallCommand.FullPackageNameRegex.Match(originalDep); + toVisit.Enqueue(dep); + } + while (toVisit.TryDequeue(out var packageString)) + { + var match = InstallCommand.FullPackageNameRegex.Match(packageString); var fullname = match.Groups["fullname"].Value; - var depPackage = packages?.Find(p => p.Fullname == fullname) ?? AttemptResolveExperimental(config, http, match, root.FullName); - if (depPackage == null) + if (dict.TryGetValue(fullname, out var current)) { + dict[fullname] = (currentId++, current.version); continue; } - foreach (var dependency in GenerateInner(packages, config, http, depPackage, p => visited.Contains(p.Fullname!))) + var package = packages?.Find(p => p.Fullname == fullname) ?? AttemptResolveExperimental(config, http, match); + if (package is null) + continue; + PackageVersionV1? version; + if (useExactVersions) { - // can happen on cycles, oh well - if (visited.Contains(dependency.Fullname!)) + string requiredVersion = match.Groups["version"].Value; + version = package.Versions!.FirstOrDefault(v => v.VersionNumber == requiredVersion); + if (version is null) { - continue; + Write.Warn($"Version {requiredVersion} could not be found for mod {fullname}, using latest instead"); + version = package.Versions!.First(); } - visited.Add(dependency.Fullname!); - yield return dependency; } - } - } - - private static IEnumerable GenerateInner(List? packages, Config config, HttpClient http, PackageListingV1 root, Predicate visited) - { - if (visited(root)) - { - yield break; - } - - foreach (var dependency in root.Versions!.First().Dependencies!) - { - var match = InstallCommand.FullPackageNameRegex.Match(dependency); - var fullname = match.Groups["fullname"].Value; - var package = packages?.Find(p => p.Fullname == fullname) ?? AttemptResolveExperimental(config, http, match, root.Fullname!); - if (package == null) + else { - continue; + version = package.Versions!.First(); } - foreach (var innerPackage in GenerateInner(packages, config, http, package, visited)) + dict[fullname] = (currentId++, version); + foreach (var dep in version.Dependencies!) { - yield return innerPackage; + toVisit.Enqueue(dep); } } - - yield return root; + return dict.Values.OrderByDescending(x => x.id).Select(x => x.version); } - private static PackageListingV1? AttemptResolveExperimental(Config config, HttpClient http, Match nameMatch, string neededBy) + 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} for {neededBy}, continuing without it."); + 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} (needed by {neededBy}) exists in different community, ignoring"); + Write.Warn($"Package {data!.Fullname} exists in different community, ignoring"); return null; } } 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/Cargo.lock b/tcli-bepinex-installer/Cargo.lock index 3c9ee1d..272a2b7 100644 --- a/tcli-bepinex-installer/Cargo.lock +++ b/tcli-bepinex-installer/Cargo.lock @@ -22,20 +22,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.66" +version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6" - -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi", - "libc", - "winapi", -] +checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61" [[package]] name = "base64ct" @@ -66,9 +55,9 @@ checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" [[package]] name = "bzip2" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6afcd980b5f3a45017c57e57a2fcccbb351cc43a356ce117ef760ef8052b89b0" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" dependencies = [ "bzip2-sys", "libc", @@ -87,9 +76,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.73" +version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" +checksum = "a20104e2335ce8a659d6dd92a51a767a0c062599c73b343fd152cb401e828c3d" dependencies = [ "jobserver", ] @@ -111,14 +100,14 @@ dependencies = [ [[package]] name = "clap" -version = "4.0.18" +version = "4.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335867764ed2de42325fafe6d18b8af74ba97ee0c590fa016f157535b42ab04b" +checksum = "4ec7a4128863c188deefe750ac1d1dfe66c236909f845af04beed823638dc1b2" dependencies = [ - "atty", "bitflags", "clap_derive", "clap_lex", + "is-terminal", "once_cell", "strsim", "termcolor", @@ -126,9 +115,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.0.18" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16a1b0f6422af32d5da0c58e2703320f379216ee70198241c84173a8c5ac28f3" +checksum = "684a277d672e91966334af371f1a7b5833f9aa00b07c84e92fbce95e00208ce8" dependencies = [ "heck", "proc-macro-error", @@ -139,9 +128,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d4198f73e42b4936b35b5bb248d81d2b595ecb170da0bac7655c54eedfa8da8" +checksum = "783fe232adfca04f90f56201b26d79682d4cd2625e0bc7290b95123afe558ade" dependencies = [ "os_str_bytes", ] @@ -172,9 +161,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.12" +version = "0.8.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edbafec5fa1f196ca66527c1b12c2ec4745ca14b50f1ad8f9f6f720b55d11fac" +checksum = "4fb766fa798726286dbbb842f174001dab8abc7b627a1dd86e0b7222a95d929f" dependencies = [ "cfg-if", ] @@ -191,20 +180,41 @@ dependencies = [ [[package]] name = "digest" -version = "0.10.5" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adfbc57365a37acbd2ebf2b64d7e69bb766e2fea813521ed536f5d0520dcf86c" +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.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6" +checksum = "a8a2db397cb1c8772f31494cb8917e48cd1e64f0fa7efac59fbd741a0a8ce841" dependencies = [ "crc32fast", "miniz_oxide", @@ -228,9 +238,9 @@ checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" [[package]] name = "hermit-abi" -version = "0.1.19" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" dependencies = [ "libc", ] @@ -245,10 +255,32 @@ dependencies = [ ] [[package]] -name = "itoa" +name = "io-lifetimes" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4217ad341ebadf8d8e724e264f13e593e0648f5b3e94b3896a5df283be015ecc" +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" @@ -261,33 +293,30 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.137" +version = "0.2.139" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7fcc620a3bff7cdd7a365be3376c97191aeaccc2a603e600951e452615bf89" +checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" [[package]] -name = "miniz_oxide" -version = "0.5.4" +name = "linux-raw-sys" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96590ba8f175222643a85693f33d26e9c8a015f599c216509b1a6894af675d34" -dependencies = [ - "adler", -] +checksum = "f051f77a7c8e6957c0696eac88f26b0117e54f52d3fc682ab19397a8812846a4" [[package]] -name = "num_threads" -version = "0.1.6" +name = "miniz_oxide" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa" dependencies = [ - "libc", + "adler", ] [[package]] name = "once_cell" -version = "1.15.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e82dad04139b71a90c080c8463fe0dc7902db5192d939bd0950f074d014339e1" +checksum = "6f61fba1741ea2b3d6a1e3178721804bb716a68a6aeba1149b5d52e3d464ea66" [[package]] name = "opaque-debug" @@ -297,9 +326,9 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" [[package]] name = "os_str_bytes" -version = "6.3.0" +version = "6.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ff7415e9ae3fff1225851df9e0d9e4e5479f947619774677a63572e55e80eff" +checksum = "9b7820b9daea5457c9f21c69448905d723fbd21136ccf521748f23fd49e723ee" [[package]] name = "password-hash" @@ -356,18 +385,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.47" +version = "1.0.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ea3d908b0e36316caf9e9e2c4625cdde190a7e6f440d794667ed17a1855e725" +checksum = "6ef7d57beacfaf2d8aee5937dab7b7f28de3cb8b1828479bb5de2a7106f2bae2" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" +checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b" dependencies = [ "proc-macro2", ] @@ -378,26 +407,40 @@ 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.11" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" +checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde" [[package]] name = "serde" -version = "1.0.147" +version = "1.0.152" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965" +checksum = "bb7d1f0d3021d347a83e556fc4683dea2ea09d87bccdf88ff5c12545d89d5efb" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.147" +version = "1.0.152" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f1d362ca8fc9c3e3a7484440752472d68a6caa98f1ab81d99b5dfe517cec852" +checksum = "af487d118eecd09402d70a5d72551860e788df87b464af30e5ea6a38c75c541e" dependencies = [ "proc-macro2", "quote", @@ -406,9 +449,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.87" +version = "1.0.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce777b7b150d76b9cf60d28b55f5847135a003f7d7350c6be7a773508ce7d45" +checksum = "877c235533714907a8c2464236f5c4b2a17262ef1bd71f38f35ea592c8da6883" dependencies = [ "itoa", "ryu", @@ -451,9 +494,9 @@ checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "syn" -version = "1.0.103" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a864042229133ada95abf3b54fdc62ef5ccabe9515b64717bcb9a1919e59445d" +checksum = "1f4064b5b16e03ae50984a5a8ed5d4f8803e6bc1fd170a3cda91a1be4b18e3f5" dependencies = [ "proc-macro2", "quote", @@ -474,27 +517,27 @@ dependencies = [ [[package]] name = "termcolor" -version = "1.1.3" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" +checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" dependencies = [ "winapi-util", ] [[package]] name = "thiserror" -version = "1.0.37" +version = "1.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10deb33631e3c9018b9baf9dcbbc4f737320d2b576bac10f6aefa048fa407e3e" +checksum = "6a9cd18aa97d5c45c6603caea1da6628790b37f7a34b6ca89522331c5180fed0" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.37" +version = "1.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "982d17546b47146b28f7c22e3d08465f6b8903d0ea13c1660d9d84a6e7adcdbb" +checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f" dependencies = [ "proc-macro2", "quote", @@ -503,13 +546,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fab5c8b9980850e06d92ddbe3ab839c062c801f3927c0fb8abd6fc8e918fbca" +checksum = "a561bf4617eebd33bca6434b988f39ed798e527f51a1e797d0ee4f61c0a38376" dependencies = [ "itoa", - "libc", - "num_threads", "serde", "time-core", "time-macros", @@ -523,24 +564,24 @@ checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" [[package]] name = "time-macros" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bb801831d812c562ae7d2bfb531f26e66e4e1f6b17307ba4149c5064710e5b" +checksum = "d967f99f534ca7e495c575c62638eebc2898a8c84c119b89e250477bc4ba16b2" dependencies = [ "time-core", ] [[package]] name = "typenum" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" +checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" [[package]] name = "unicode-ident" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3" +checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc" [[package]] name = "version_check" @@ -579,6 +620,63 @@ 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" @@ -620,10 +718,11 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.0.1+zstd.1.5.2" +version = "2.0.5+zstd.1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fd07cbbc53846d9145dbffdf6dd09a7a0aa52be46741825f5c97bdd4f73f12b" +checksum = "edc50ffce891ad571e9f9afe5039c4837bede781ac4bb13052ed7ae695518596" dependencies = [ "cc", "libc", + "pkg-config", ] 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 index 2df0504..70b2618 100644 --- a/tcli-bepinex-installer/src/main.rs +++ b/tcli-bepinex-installer/src/main.rs @@ -1,5 +1,6 @@ use std::{ collections::HashMap, + env, ffi::OsString, fs::{self, OpenOptions}, io::{self, Read, Seek}, @@ -7,7 +8,7 @@ use std::{ }; use anyhow::{bail, Result}; -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; use serde::Deserialize; use zip::ZipArchive; @@ -36,8 +37,8 @@ enum Commands { game_directory: PathBuf, bepinex_directory: PathBuf, #[arg(long)] - game_platform: Option, - } + game_platform: GamePlatform, + }, } #[derive(Debug, thiserror::Error)] @@ -68,6 +69,13 @@ struct ManifestV1 { pub website_url: String, } +#[derive(Debug, Deserialize, Clone, Copy, ValueEnum)] +enum GamePlatform { + Windows, + Proton, + Linux, +} + fn main() -> Result<()> { let args = ClapArgs::parse(); @@ -87,7 +95,12 @@ fn main() -> Result<()> { if !zip_path.exists() { bail!(Error::PathDoesNotExist(zip_path)); } - install(game_directory, bepinex_directory, zip_path, namespace_backup) + install( + game_directory, + bepinex_directory, + zip_path, + namespace_backup, + ) } Commands::Uninstall { game_directory, @@ -103,17 +116,23 @@ fn main() -> Result<()> { uninstall(game_directory, bepinex_directory, name) } Commands::StartInstructions { + game_directory, bepinex_directory, game_platform, .. } => { - output_instructions(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<()> { +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") { @@ -133,7 +152,8 @@ fn install(game_dir: PathBuf, bep_dir: PathBuf, zip_path: PathBuf, namespace_bac 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")) { + 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) @@ -145,7 +165,11 @@ fn install_bepinex( bep_dir: PathBuf, mut zip: ZipArchive, ) -> Result<()> { - let write_opts = OpenOptions::new().write(true).create(true).clone(); + 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)?; @@ -163,15 +187,14 @@ fn install_bepinex( continue; } - let dir_to_use = if filepath.ancestors().any(|part| { + let in_bep_folder = filepath.ancestors().any(|part| { part.file_name() .unwrap_or(&OsString::new()) - .to_string_lossy() == "BepInEx" - }) { - &bep_dir - } else { - &game_dir - }; + .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); @@ -196,7 +219,10 @@ fn install_mod( let full_name = format!( "{}-{}", - manifest.namespace.or(namespace_backup).ok_or(Error::MissingNamespace)?, + manifest + .namespace + .or(namespace_backup) + .ok_or(Error::MissingNamespace)?, manifest.name ); @@ -213,7 +239,10 @@ fn install_mod( 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")); + remaps.insert( + Path::new("BepInEx").join("config"), + Path::new("BepInEx").join("config"), + ); let default_remap = &remaps[&Path::new("BepInEx").join("plugins")]; @@ -227,7 +256,7 @@ fn install_mod( let filepath = file.enclosed_name().ok_or(Error::MalformedZip)?.to_owned(); let filename = match filepath.file_name() { Some(name) => name, - None => continue + None => continue, }; let mut out_path = None; @@ -253,7 +282,12 @@ fn install_mod( } fn uninstall(game_dir: PathBuf, bep_dir: PathBuf, name: String) -> Result<()> { - if name.split_once('-').ok_or(Error::InvalidModName)?.1.starts_with("BepInEx") { + if name + .split_once('-') + .ok_or(Error::InvalidModName)? + .1 + .starts_with("BepInEx") + { uninstall_bepinex(game_dir, bep_dir) } else { uninstall_mod(bep_dir, name) @@ -279,22 +313,70 @@ fn uninstall_mod(bep_dir: PathBuf, name: String) -> Result<()> { Ok(()) } -fn output_instructions(bep_dir: PathBuf, platform: Option) { - if platform.as_ref().map(|p| p == "windows").unwrap_or(true) { - let drive_prefix = match platform { - Some(_) => "Z:", - None => "" - }; - - println!("ARGUMENTS:--doorstop-enable true --doorstop-target {}{}", drive_prefix, bep_dir.join("BepInEx").join("core").join("BepInEx.Preloader.dll").to_string_lossy().replace('/', "\\")); - println!("WINEDLLOVERRIDE:winhttp") +fn output_instructions(game_dir: PathBuf, bep_dir: PathBuf, platform: GamePlatform) { + let drive_prefix = if matches!(platform, GamePlatform::Proton) { + "Z:\\" } else { - eprintln!("native linux not implmented"); + "" + }; + + 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()) + 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