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 e141a4d..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,19 +51,24 @@ 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 }}" + 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 @@ -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: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 8cd8eeb..4101faa 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # 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) 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. 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/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 7d688d2..1d7bb28 100644 --- a/ThunderstoreCLI/Commands/InstallCommand.cs +++ b/ThunderstoreCLI/Commands/InstallCommand.cs @@ -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) { @@ -77,11 +77,11 @@ private static async Task InstallFromRepository(Config config, HttpClient h 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!"); @@ -90,15 +90,15 @@ 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 = dependenciesToInstall - .Where(d => !config.Cache.ContainsFile($"{d.Fullname}-{d.Versions![0].VersionNumber}.zip")) - .Select(d => d.Versions![0].FileSize) + .Where(d => !config.Cache.ContainsFile($"{d.FullName}-{d.VersionNumber}.zip")) + .Select(d => d.FileSize) .Sum(); if (totalSize != 0) { @@ -106,35 +106,32 @@ private static async Task InstallZip(Config config, HttpClient http, GameDe } 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/PublishCommand.cs b/ThunderstoreCLI/Commands/PublishCommand.cs index 4658229..de07e9b 100644 --- a/ThunderstoreCLI/Commands/PublishCommand.cs +++ b/ThunderstoreCLI/Commands/PublishCommand.cs @@ -290,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/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/Config.cs b/ThunderstoreCLI/Configuration/Config.cs index af7ba0d..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 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/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/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/ThunderstoreProject.cs b/ThunderstoreCLI/Models/ThunderstoreProject.cs index 2b7bbba..6b09b7a 100644 --- a/ThunderstoreCLI/Models/ThunderstoreProject.cs +++ b/ThunderstoreCLI/Models/ThunderstoreProject.cs @@ -8,6 +8,29 @@ 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 { @@ -75,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")] @@ -112,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 b442cf1..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; } } 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/MiscUtils.cs b/ThunderstoreCLI/Utils/MiscUtils.cs index 3e93f21..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) { 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/src/main.rs b/tcli-bepinex-installer/src/main.rs index 546de59..70b2618 100644 --- a/tcli-bepinex-installer/src/main.rs +++ b/tcli-bepinex-installer/src/main.rs @@ -345,21 +345,24 @@ fn output_instructions(game_dir: PathBuf, bep_dir: PathBuf, platform: GamePlatfo ); let mut ld_library = OsString::from(game_dir.join("doorstop_libs")); - if let Some(orig) = env::var_os("LD_LIBRARY") { + if let Some(orig) = env::var_os("LD_LIBRARY_PATH") { ld_library.push(":"); ld_library.push(orig); } - println!("ENVIRONMENT:LD_LIBRARY={}", ld_library.to_string_lossy()); + println!( + "ENVIRONMENT:LD_LIBRARY_PATH={}", + ld_library.to_string_lossy() + ); - let mut ld_preload = OsString::from(game_dir.join("doorstop_libs").join({ + 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);