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
+[](https://github.com/thunderstore-io/thunderstore-cli/actions/workflows/test.yml)
[](https://codecov.io/gh/thunderstore-io/thunderstore-cli)
+[](https://www.nuget.org/packages/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