Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 15 additions & 18 deletions ThunderstoreCLI/Commands/InstallCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public static async Task<int> 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)
{
Expand Down Expand Up @@ -77,11 +77,11 @@ private static async Task<int> 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<int> InstallZip(Config config, HttpClient http, GameDefinition game, ModProfile profile, string zipPath, string? backupNamespace, string? sourceCommunity)
private static async Task<int> 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!");
Expand All @@ -90,51 +90,48 @@ private static async Task<int> 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)
{
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
Expand Down
2 changes: 1 addition & 1 deletion ThunderstoreCLI/Commands/UninstallCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
Expand Down
4 changes: 3 additions & 1 deletion ThunderstoreCLI/Game/ModProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

namespace ThunderstoreCLI.Game;

public record InstalledModVersion(string FullName, string VersionNumber, string[] Dependencies);

public class ModProfile : BaseJson<ModProfile>
{
public string Name { get; set; }
public string ProfileDirectory { get; set; }
public Dictionary<string, PackageManifestV1> InstalledModVersions { get; } = new();
public Dictionary<string, InstalledModVersion> InstalledModVersions { get; } = new();

#pragma warning disable CS8618
private ModProfile() { }
Expand Down
7 changes: 7 additions & 0 deletions ThunderstoreCLI/Models/PackageListingV1.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using ThunderstoreCLI.Commands;

namespace ThunderstoreCLI.Models;

Expand Down Expand Up @@ -104,6 +106,11 @@ public class PackageVersionV1
[JsonProperty("file_size")]
public int FileSize { get; set; }

[JsonIgnore]
private GroupCollection? _fullNameParts;
[JsonIgnore]
public GroupCollection FullNameParts => _fullNameParts ??= InstallCommand.FullPackageNameRegex.Match(FullName!).Groups;

public PackageVersionV1() { }

public PackageVersionV1(PackageVersionData version)
Expand Down
67 changes: 31 additions & 36 deletions ThunderstoreCLI/Utils/ModDependencyTree.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace ThunderstoreCLI.Utils;

public static class ModDependencyTree
{
public static IEnumerable<PackageListingV1> Generate(Config config, HttpClient http, PackageManifestV1 root, string? sourceCommunity)
public static IEnumerable<PackageVersionV1> Generate(Config config, HttpClient http, PackageManifestV1 root, string? sourceCommunity, bool useExactVersions)
{
List<PackageListingV1>? packages = null;

Expand All @@ -32,67 +32,62 @@ public static IEnumerable<PackageListingV1> Generate(Config config, HttpClient h
packages = PackageListingV1.DeserializeList(packagesJson)!;
}

HashSet<string> visited = new();
foreach (var originalDep in root.Dependencies!)
Queue<string> toVisit = new();
Dictionary<string, (int id, PackageVersionV1 version)> 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<PackageListingV1> GenerateInner(List<PackageListingV1>? packages, Config config, HttpClient http, PackageListingV1 root, Predicate<PackageListingV1> 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;
}
}