diff --git a/TShock.sln b/TShock.sln
index 27b8665b..36256729 100644
--- a/TShock.sln
+++ b/TShock.sln
@@ -28,6 +28,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TShockLauncher.Tests", "TSh
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TShockInstaller", "TShockInstaller\TShockInstaller.csproj", "{17AC4DD0-8334-4B5C-ABED-77EAF52D75FA}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TShockPluginManager", "TShockPluginManager\TShockPluginManager.csproj", "{9FFABC7D-B042-4B58-98F5-7FA787B9A757}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -120,6 +122,22 @@ Global
{17AC4DD0-8334-4B5C-ABED-77EAF52D75FA}.Release|x64.Build.0 = Release|Any CPU
{17AC4DD0-8334-4B5C-ABED-77EAF52D75FA}.Release|x86.ActiveCfg = Release|Any CPU
{17AC4DD0-8334-4B5C-ABED-77EAF52D75FA}.Release|x86.Build.0 = Release|Any CPU
+ {9FFABC7D-B042-4B58-98F5-7FA787B9A757}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9FFABC7D-B042-4B58-98F5-7FA787B9A757}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9FFABC7D-B042-4B58-98F5-7FA787B9A757}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {9FFABC7D-B042-4B58-98F5-7FA787B9A757}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
+ {9FFABC7D-B042-4B58-98F5-7FA787B9A757}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9FFABC7D-B042-4B58-98F5-7FA787B9A757}.Debug|x64.Build.0 = Debug|Any CPU
+ {9FFABC7D-B042-4B58-98F5-7FA787B9A757}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9FFABC7D-B042-4B58-98F5-7FA787B9A757}.Debug|x86.Build.0 = Debug|Any CPU
+ {9FFABC7D-B042-4B58-98F5-7FA787B9A757}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9FFABC7D-B042-4B58-98F5-7FA787B9A757}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9FFABC7D-B042-4B58-98F5-7FA787B9A757}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
+ {9FFABC7D-B042-4B58-98F5-7FA787B9A757}.Release|Mixed Platforms.Build.0 = Release|Any CPU
+ {9FFABC7D-B042-4B58-98F5-7FA787B9A757}.Release|x64.ActiveCfg = Release|Any CPU
+ {9FFABC7D-B042-4B58-98F5-7FA787B9A757}.Release|x64.Build.0 = Release|Any CPU
+ {9FFABC7D-B042-4B58-98F5-7FA787B9A757}.Release|x86.ActiveCfg = Release|Any CPU
+ {9FFABC7D-B042-4B58-98F5-7FA787B9A757}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/TShockLauncher/.gitignore b/TShockLauncher/.gitignore
new file mode 100644
index 00000000..f36bf66a
--- /dev/null
+++ b/TShockLauncher/.gitignore
@@ -0,0 +1 @@
+/packages
diff --git a/TShockLauncher/Program.cs b/TShockLauncher/Program.cs
index 1f47c837..1f7e853c 100644
--- a/TShockLauncher/Program.cs
+++ b/TShockLauncher/Program.cs
@@ -22,11 +22,24 @@ along with this program. If not, see .
* - to copy/move around TShockAPI.dll (the TShock plugin to TSAPI)
* - to publish TShock releases.
* - move dependencies to a ./bin folder
- *
+ *
* The assembly name of this launcher (TShock.exe) was decided on by a community poll.
*/
using System.Reflection;
+using TShockPluginManager;
+
+if (args.Length > 0 && args[0].ToLower() == "plugins")
+{
+ var items = args.ToList();
+ items.RemoveAt(0);
+ await NugetCLI.Main(items);
+ return;
+}
+else
+{
+ Start();
+}
Dictionary _cache = new Dictionary();
diff --git a/TShockLauncher/TShockLauncher.csproj b/TShockLauncher/TShockLauncher.csproj
index 28709632..e3c4ac32 100644
--- a/TShockLauncher/TShockLauncher.csproj
+++ b/TShockLauncher/TShockLauncher.csproj
@@ -14,6 +14,7 @@
+
..\prebuilts\HttpServer.dll
@@ -96,4 +97,4 @@
-
\ No newline at end of file
+
diff --git a/TShockPluginManager/CLIHelpers.cs b/TShockPluginManager/CLIHelpers.cs
new file mode 100644
index 00000000..621709f6
--- /dev/null
+++ b/TShockPluginManager/CLIHelpers.cs
@@ -0,0 +1,114 @@
+/*
+TShock, a server mod for Terraria
+Copyright (C) 2022 Janet Blackquill
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+using System.Text;
+
+namespace TShockPluginManager
+{
+ static class CLIHelpers
+ {
+ static public bool YesNo()
+ {
+ System.Console.Write("[y/n] ");
+ bool accept;
+ bool confirm = false;
+ do
+ {
+ ConsoleKey response = Console.ReadKey(true).Key;
+ (accept, confirm) = response switch {
+ ConsoleKey.Y => (true, true),
+ ConsoleKey.N => (false, true),
+ _ => (false, false)
+ };
+ } while (!confirm);
+ if (accept)
+ System.Console.WriteLine("yes");
+ else
+ System.Console.WriteLine("no");
+ return accept;
+ }
+ public enum Answers {
+ Yes,
+ No,
+ Explain
+ }
+ static public Answers YesNoExplain()
+ {
+ System.Console.Write("[y/n/e] ");
+ Answers ans;
+ bool confirm = false;
+ do
+ {
+ ConsoleKey response = Console.ReadKey(true).Key;
+ (ans, confirm) = response switch {
+ ConsoleKey.Y => (Answers.Yes, true),
+ ConsoleKey.N => (Answers.No, true),
+ ConsoleKey.E => (Answers.Explain, true),
+ _ => (Answers.Explain, false)
+ };
+ } while (!confirm);
+ if (ans == Answers.Yes)
+ System.Console.WriteLine("yes");
+ else if (ans == Answers.No)
+ System.Console.WriteLine("no");
+ else
+ System.Console.WriteLine("explain");
+ return ans;
+ }
+ static private string[] ColorNames = Enum.GetNames(typeof(ConsoleColor));
+ static public void Write(string text)
+ {
+ var initial = Console.ForegroundColor;
+
+ var buffer = new StringBuilder();
+ var chars = text.ToCharArray().ToList();
+ while (chars.Count > 0)
+ {
+ var ch = chars.First();
+ if (ch == '<')
+ {
+ var possibleColor = new string(chars.Skip(1).TakeWhile(c => c != '>').ToArray());
+ Func predicate = x => string.Equals(x, possibleColor, StringComparison.CurrentCultureIgnoreCase);
+ if (!ColorNames.Any(predicate))
+ goto breakFromIf;
+ var color = ColorNames.First(predicate);
+ if (buffer.Length > 0)
+ {
+ Console.Write(buffer.ToString());
+ buffer.Clear();
+ }
+ Console.ForegroundColor = Enum.Parse(color);
+ chars = chars.Skip(2 + possibleColor.Length).ToList();
+ continue;
+ }
+ breakFromIf:
+ buffer.Append(ch);
+ chars.RemoveAt(0);
+ }
+
+ if (buffer.Length > 0)
+ Console.Write(buffer.ToString());
+
+ Console.ForegroundColor = initial;
+ }
+ static public void WriteLine(string text)
+ {
+ Write(text + "\n");
+ }
+ }
+}
diff --git a/TShockPluginManager/I18n.cs b/TShockPluginManager/I18n.cs
new file mode 100644
index 00000000..810392e9
--- /dev/null
+++ b/TShockPluginManager/I18n.cs
@@ -0,0 +1,11 @@
+using GetText;
+
+namespace TShockPluginManager
+{
+ static class I18n
+ {
+ static string TranslationsDirectory => Path.Combine(AppContext.BaseDirectory, "i18n");
+ // we share the same translations catalog as TShockAPI
+ public static Catalog C = new Catalog("TShockAPI", TranslationsDirectory);
+ }
+}
diff --git a/TShockPluginManager/Nuget.cs b/TShockPluginManager/Nuget.cs
new file mode 100644
index 00000000..9fdefb6b
--- /dev/null
+++ b/TShockPluginManager/Nuget.cs
@@ -0,0 +1,328 @@
+/*
+TShock, a server mod for Terraria
+Copyright (C) 2022 Janet Blackquill
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+using System.Reflection;
+using System.Runtime.InteropServices;
+using NuGet.Common;
+using NuGet.Configuration;
+using NuGet.Frameworks;
+using NuGet.Packaging;
+using NuGet.Packaging.Core;
+using NuGet.Packaging.Signing;
+using NuGet.Protocol.Core.Types;
+using NuGet.Resolver;
+using NuGet.Versioning;
+
+namespace TShockPluginManager
+{
+
+ public class Nugetter
+ {
+ // this object can figure out the right framework folders to use from a set of packages
+ private FrameworkReducer FrameworkReducer;
+ // the package framework we want to install
+ private NuGetFramework NuGetFramework;
+ // nuget settings
+ private ISettings Settings;
+ // this is responsible for bookkeeping the folders that nuget touches
+ private NuGetPathContext PathContext;
+ // this is responsible for managing the package sources
+ private PackageSourceProvider PackageSourceProvider;
+ // this is responsible for managing the repositories of packages from all of the package sources
+ private SourceRepositoryProvider SourceRepositoryProvider;
+ // this can tell us the paths of local packages
+ private PackagePathResolver PackagePathResolver;
+ // this is possible for bookkeeping the extraction state of packages
+ private PackageExtractionContext PackageExtractionContext;
+
+ public Nugetter()
+ {
+ FrameworkReducer = new FrameworkReducer();
+ NuGetFramework = NuGetFramework.ParseFolder("net6.0");
+ Settings = NuGet.Configuration.Settings.LoadDefaultSettings(root: null);
+ PathContext = NuGetPathContext.Create(Settings);
+ PackageSourceProvider = new PackageSourceProvider(Settings);
+ SourceRepositoryProvider = new SourceRepositoryProvider(PackageSourceProvider, Repository.Provider.GetCoreV3());
+ PackagePathResolver = new PackagePathResolver(Path.GetFullPath("packages"));
+ PackageExtractionContext = new PackageExtractionContext(
+ PackageSaveMode.Defaultv3,
+ XmlDocFileSaveMode.Skip,
+ ClientPolicyContext.GetClientPolicy(Settings, NullLogger.Instance),
+ NullLogger.Instance);
+ }
+
+ async Task GetPackageDependencies(
+ PackageIdentity package,
+ NuGetFramework framework,
+ SourceCacheContext cacheContext,
+ ILogger logger,
+ IEnumerable repositories,
+ ISet availablePackages)
+ {
+ // if we've already gotten dependencies for this package, don't
+ if (availablePackages.Contains(package)) return;
+
+ foreach (var sourceRepository in repositories)
+ {
+ // make sure the source repository can actually tell us about dependencies
+ var dependencyInfoResource = await sourceRepository.GetResourceAsync();
+ // get the try and dependencies
+ // (the above function returns a nullable value, but doesn't properly indicate it as such)
+ #pragma warning disable CS8602
+ var dependencyInfo = await dependencyInfoResource?.ResolvePackage(
+ package, framework, cacheContext, logger, CancellationToken.None);
+ #pragma warning restore CS8602
+
+ // oop, we don't have the ability to get dependency info from this repository, or
+ // it wasn't found. let's try the next source repository!
+ if (dependencyInfo == null) continue;
+
+ availablePackages.Add(dependencyInfo);
+ foreach (var dependency in dependencyInfo.Dependencies)
+ {
+ // make sure we get the dependencies of the dependencies of the dependencies ... as well
+ await GetPackageDependencies(
+ new PackageIdentity(dependency.Id, dependency.VersionRange.MinVersion),
+ framework, cacheContext, logger, repositories, availablePackages);
+ }
+ }
+ }
+
+ /// all the packages representing dependencies bundled with TShock.Server
+ public async Task> GetAllBuiltinDependencies()
+ {
+ // this is a convenient approximation of what dependencies will be included with TShock.Server
+ // and really only needs to be updated if new third-party dependencies are added
+
+ var knownBundles = new[] {
+ new PackageIdentity("GetText.NET", NuGetVersion.Parse("1.6.6")),
+ new PackageIdentity("OTAPI.Upcoming", NuGetVersion.Parse("3.1.8-alpha")),
+ new PackageIdentity("TSAPI", NuGetVersion.Parse("5.0.0-beta")),
+ new PackageIdentity("TShock", NuGetVersion.Parse("5.0.0-beta")),
+ };
+
+ return await GetAllDependenciesFor(knownBundles);
+ }
+
+ /// all the dependencies for the provided package identities
+ public async Task> GetAllDependenciesFor(IEnumerable targets)
+ {
+ using var cacheContext = new SourceCacheContext();
+
+ // get all of the possible packages in our dependency tree
+ var possiblePackages = new HashSet(PackageIdentityComparer.Default);
+ foreach (var target in targets)
+ {
+ await GetPackageDependencies(
+ target,
+ NuGetFramework,
+ cacheContext,
+ NullLogger.Instance,
+ SourceRepositoryProvider.GetRepositories(),
+ possiblePackages
+ );
+ }
+
+ var resolverContext = new PackageResolverContext(
+ // select minimum possible versions
+ DependencyBehavior.Lowest,
+ // these are the packages the user wanted
+ targets.Select(x => x.Id),
+ // we don't hard-require anything
+ Enumerable.Empty(),
+ // we don't have a lockfile
+ Enumerable.Empty(),
+ // we don't have fancy versioning
+ Enumerable.Empty(),
+ // these are the packages that we figured out are in the dependency tree from nuget
+ possiblePackages,
+ // all the package sources
+ SourceRepositoryProvider.GetRepositories().Select(s => s.PackageSource),
+ NullLogger.Instance
+ );
+
+ var resolver = new PackageResolver();
+ var packagesToInstall =
+ // get the resolved versioning info from the resolver
+ resolver.Resolve(resolverContext, CancellationToken.None)
+ // and use that to select the specific packages to install from the possible packages
+ .Select(p => possiblePackages.Single(x => PackageIdentityComparer.Default.Equals(x, p)));
+
+ return packagesToInstall;
+ }
+
+ /// whether or not subPath is a subpath of basePath
+ public static bool IsSubPathOf(string subPath, string basePath)
+ {
+ var rel = Path.GetRelativePath(basePath, subPath);
+ return rel != "."
+ && rel != ".."
+ && !rel.StartsWith("../")
+ && !rel.StartsWith(@"..\")
+ && !Path.IsPathRooted(rel);
+ }
+
+ /// items required for end-user running of a package
+ public IEnumerable ItemsToInstall(PackageReaderBase packageReader)
+ {
+ var libItems = packageReader.GetLibItems();
+ var libnearest = FrameworkReducer.GetNearest(NuGetFramework, libItems.Select(x => x.TargetFramework));
+ libItems = libItems.Where(x => x.TargetFramework.Equals(libnearest));
+
+ var frameworkItems = packageReader.GetFrameworkItems();
+ var fwnearest = FrameworkReducer.GetNearest(NuGetFramework, frameworkItems.Select(x => x.TargetFramework));
+ frameworkItems = frameworkItems.Where(x => x.TargetFramework.Equals(fwnearest));
+
+ return libItems.Concat(frameworkItems);
+ }
+
+ /// path to package folder and metadata reader
+ public async Task<(string, PackageReaderBase)> GetOrDownloadPackage(SourcePackageDependencyInfo pkg)
+ {
+ using var cacheContext = new SourceCacheContext();
+
+ PackageReaderBase packageReader;
+ string pkgPath;
+ // already installed?
+ if (PackagePathResolver.GetInstalledPath(pkg) is string path)
+ {
+ // we're gaming
+ packageReader = new PackageFolderReader(path);
+ pkgPath = path;
+ }
+ else
+ {
+ // gotta download it...
+ var downloadResource = await pkg.Source.GetResourceAsync(CancellationToken.None);
+ Console.WriteLine($"Downloading {pkg.Id}...");
+ var downloadResult = await downloadResource.GetDownloadResourceResultAsync(
+ pkg,
+ new PackageDownloadContext(cacheContext),
+ SettingsUtility.GetGlobalPackagesFolder(Settings),
+ NullLogger.Instance, CancellationToken.None);
+ packageReader = downloadResult.PackageReader;
+ Console.WriteLine($"Extracting {pkg.Id}...");
+ // and extract the package
+ await PackageExtractor.ExtractPackageAsync(
+ downloadResult.PackageSource,
+ downloadResult.PackageStream,
+ PackagePathResolver,
+ PackageExtractionContext,
+ CancellationToken.None);
+
+ if (PackagePathResolver.GetInstalledPath(pkg) is string loc)
+ {
+ pkgPath = loc;
+ }
+ else
+ {
+ pkgPath = null;
+ // die somehow
+ }
+ }
+ return (pkgPath, packageReader);
+ }
+
+ /// resolved packages to be installed for what the user requested
+ public async Task> GetPackagesToInstallFor(PackageIdentity[] userRequest)
+ {
+ using var cacheContext = new SourceCacheContext();
+ return (await GetAllDependenciesFor(userRequest)).OrderBy(v => v.Id);
+ }
+
+ /// installs a locally downloaded package
+ public void InstallPackage(SourcePackageDependencyInfo pkg, string pkgPath, PackageReaderBase packageReader)
+ {
+ // objects to help us detect if packages already come with the .NET distribution
+ string[] runtimeAssemblies = Directory.GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll");
+ var paResolver = new PathAssemblyResolver(runtimeAssemblies);
+ using var mlc = new MetadataLoadContext(paResolver);
+
+ // packages can declare themselves as plugin via the TShockPlugin package type
+ var isPlugin = packageReader.NuspecReader.GetPackageTypes().Any(v => v.Name == "TShockPlugin");
+
+ Console.WriteLine($"Installing {pkg.Id}...");
+
+ foreach (var item in ItemsToInstall(packageReader))
+ {
+ var files = item.Items;
+ if (item.Items.Count() == 0)
+ continue;
+
+ // the common ancestor directory of all files in the package.
+ // if a package has the following files:
+ // - /home/orwell/packages/FooBar/hi.dll
+ // - /home/orwell/packages/FooBar/en-US/hi.resources.dll
+ // - /home/orwell/packages/FooBar/de-DE/hi.resources.dll
+ // - /home/orwell/packages/FooBar/ar-AR/hi.resources.dll
+ // this will be /home/orwell/packages/FooBar
+ var rootmostPath = files
+ .Select(x => Path.Join(pkgPath, x))
+ .Aggregate(Path.GetDirectoryName(Path.Join(pkgPath, files.First())), (acc, x) =>
+ IsSubPathOf(acc!, Path.GetDirectoryName(x)!) ?
+ Path.GetDirectoryName(x) :
+ acc);
+
+ foreach (var file in files)
+ {
+ // the absolute path of the package on the filesystem
+ var filePath = Path.Join(pkgPath, file);
+ // the path of the package relative to the package root
+ var packageRelativeFilePath = filePath.Substring(rootmostPath!.Length);
+ bool alreadyExists;
+ // if it's a .dll, we try to detect if we already have the assemblies
+ // (e.g. because we bundle them in TShock.Server or the .NET runtime comes)
+ // with them
+ if (file.EndsWith(".dll"))
+ {
+ var asms = AppDomain.CurrentDomain.GetAssemblies();
+ var asm = mlc.LoadFromAssemblyPath(filePath);
+ alreadyExists = asms.Any(a => a.GetName().Name == asm.GetName().Name);
+ }
+ else alreadyExists = false;
+
+ // if it already exists, skip. but only if it's not an explicitly requested plugin.
+ if (alreadyExists && !isPlugin)
+ continue;
+
+ var relativeFolder = Path.GetDirectoryName(packageRelativeFilePath);
+ var targetFolder = Path.Join(isPlugin ? "./ServerPlugins" : "./bin", relativeFolder);
+ Directory.CreateDirectory(targetFolder);
+ File.Copy(filePath, Path.Join(targetFolder, Path.GetFileName(filePath)), true);
+ }
+ }
+ }
+
+ /// downloads and installs the given packages
+ public async Task DownloadAndInstall(PackageIdentity[] userRequest)
+ {
+ var packagesToInstall = await GetAllDependenciesFor(userRequest);
+ var builtins = await GetAllBuiltinDependencies();
+
+ foreach (var pkg in packagesToInstall)
+ {
+ var bundled = builtins!.Where(x => x.Id == pkg.Id).FirstOrDefault();
+ if (bundled != null)
+ continue;
+
+ (string pkgPath, PackageReaderBase packageReader) = await GetOrDownloadPackage(pkg);
+ InstallPackage(pkg, pkgPath, packageReader);
+ }
+ }
+ }
+}
diff --git a/TShockPluginManager/NugetCLI.cs b/TShockPluginManager/NugetCLI.cs
new file mode 100644
index 00000000..66fcea18
--- /dev/null
+++ b/TShockPluginManager/NugetCLI.cs
@@ -0,0 +1,198 @@
+/*
+TShock, a server mod for Terraria
+Copyright (C) 2022 Janet Blackquill
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+global using static TShockPluginManager.I18n;
+
+using System.CommandLine;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using NuGet.Packaging.Core;
+using NuGet.Protocol.Core.Types;
+using NuGet.Versioning;
+
+namespace TShockPluginManager
+{
+ public static class NugetCLI
+ {
+ static public async Task Main(List args)
+ {
+ RootCommand root = new RootCommand(
+ description: C.GetString("Manage plugins and their requirements")
+ );
+ Command cmdSync = new Command(
+ name: "sync",
+ description: C.GetString("Install the plugins as specified in the plugins.json")
+ );
+ cmdSync.SetHandler(Sync);
+ root.Add(cmdSync);
+ return await root.InvokeAsync(args.ToArray());
+ }
+ class SyncManifest
+ {
+ [JsonPropertyName("packages")]
+ public Dictionary Packages { get; set; } = new();
+ public PackageIdentity[] GetPackageIdentities() =>
+ Packages.Select((kvp) => new PackageIdentity(kvp.Key, kvp.Value))
+ .OrderBy(kvp => kvp.Id)
+ .ToArray();
+ }
+ public class NuGetVersionConverter : JsonConverter
+ {
+ public override NuGetVersion? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ return NuGetVersion.Parse(reader.GetString()!);
+ }
+
+ public override void Write(Utf8JsonWriter writer, NuGetVersion value, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(value.ToNormalizedString());
+ }
+ }
+ static async Task Sync()
+ {
+ var opts = new JsonSerializerOptions
+ {
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ Converters =
+ {
+ new NuGetVersionConverter()
+ }
+ };
+ SyncManifest manifest;
+ try
+ {
+ string txt = await File.ReadAllTextAsync("packages.json");
+ manifest = JsonSerializer.Deserialize(txt, opts)!;
+ }
+ catch (System.IO.FileNotFoundException)
+ {
+ CLIHelpers.WriteLine(C.GetString("You're trying to sync, but you don't have a packages.json file."));
+ CLIHelpers.WriteLine(C.GetString("Without a list of plugins to install, no plugins can be installed."));
+ return;
+ }
+ catch (System.Text.Json.JsonException e)
+ {
+ CLIHelpers.WriteLine(C.GetString("There was an issue reading the packages.json."));
+ CLIHelpers.WriteLine($"{e.Message}");
+ return;
+ }
+ foreach (var item in manifest.GetPackageIdentities())
+ {
+ CLIHelpers.WriteLine($"{item.Id} [{item.Version}]");
+ }
+ var numWanted = manifest.GetPackageIdentities().Count();
+ CLIHelpers.WriteLine(C.GetPluralString("This is the plugin you requested to install.", "These are the plugins you requested to install", numWanted));
+ CLIHelpers.WriteLine(C.GetString("Connect to the internet to figure out what to download?"));
+ if (!CLIHelpers.YesNo())
+ return;
+ CLIHelpers.WriteLine(C.GetString("One moment..."));
+
+ var nugetter = new Nugetter();
+ PackageIdentity[] userRequests;
+ IEnumerable packagesToInstall;
+ IEnumerable builtinDependencies;
+ IEnumerable directlyRequestedPackages;
+ IEnumerable indirectlyRequiredPackages;
+ try
+ {
+ userRequests = manifest.GetPackageIdentities();
+ packagesToInstall = await nugetter.GetPackagesToInstallFor(manifest.GetPackageIdentities());
+ builtinDependencies = await nugetter.GetAllBuiltinDependencies();
+ directlyRequestedPackages = packagesToInstall.Where(x => userRequests.Any(y => x.Id == y.Id));
+ indirectlyRequiredPackages = packagesToInstall.Where(x => !userRequests.Any(y => x.Id == y.Id));
+ }
+ catch (NuGet.Resolver.NuGetResolverInputException e)
+ {
+ CLIHelpers.WriteLine(C.GetString("There was an issue figuring out what to download."));
+ CLIHelpers.WriteLine($"{e.Message}");
+ return;
+ }
+ catch (NuGet.Resolver.NuGetResolverConstraintException e)
+ {
+ CLIHelpers.WriteLine(C.GetString("The versions of plugins you requested aren't compatible with eachother."));
+ CLIHelpers.WriteLine(C.GetString("Read the message below to find out more."));
+ CLIHelpers.WriteLine($"{e.Message}");
+ return;
+ }
+
+ CLIHelpers.WriteLine(C.GetPluralString("=== Requested Plugin ===", "=== Requested Plugins ===", directlyRequestedPackages.Count()));
+ foreach (var item in directlyRequestedPackages)
+ DumpOne(item, builtinDependencies);
+ CLIHelpers.WriteLine(C.GetPluralString("=== Dependency ===", "=== Dependencies ===", indirectlyRequiredPackages.Count()));
+ foreach (var item in indirectlyRequiredPackages)
+ DumpOne(item, builtinDependencies);
+
+ CLIHelpers.WriteLine(C.GetString("Download and install the given packages?"));
+ CLIHelpers.WriteLine(C.GetString("Make sure that you trust the plugins you're installing."));
+ CLIHelpers.WriteLine(C.GetString("If you want to know which plugins need which dependencies, press E."));
+
+ bool ok = false;
+ do
+ {
+ switch (CLIHelpers.YesNoExplain())
+ {
+ case CLIHelpers.Answers.Yes:
+ ok = true;
+ break;
+
+ case CLIHelpers.Answers.No:
+ return;
+
+ case CLIHelpers.Answers.Explain:
+ foreach (var pkg in directlyRequestedPackages)
+ {
+ DumpGraph(pkg, packagesToInstall, builtinDependencies, 0);
+ }
+ CLIHelpers.WriteLine(C.GetString("Download and install the given packages?"));
+ CLIHelpers.WriteLine(C.GetString("If you'd like to see which plugins need which dependencies again, press E."));
+ break;
+ }
+ } while (!ok);
+
+ await nugetter.DownloadAndInstall(userRequests);
+
+ CLIHelpers.WriteLine(C.GetString("All done! :)"));
+ }
+ static public void DumpOne(SourcePackageDependencyInfo pkg, IEnumerable builtins)
+ {
+ if (builtins.Any(x => x.Id == pkg.Id))
+ return;
+
+ var initial = Console.ForegroundColor;
+
+ CLIHelpers.WriteLine(C.GetString($"{pkg.Id} from {pkg.Source.PackageSource.Name} [{pkg.Source.PackageSource.Source}]"));
+
+ Console.ForegroundColor = initial;
+ }
+ static public void DumpGraph(SourcePackageDependencyInfo from, IEnumerable data, IEnumerable builtins, int level)
+ {
+ var indent = new String('\t', level);
+ Console.Write(indent);
+
+ CLIHelpers.WriteLine(C.GetString($"{from.Id} from {from.Source.PackageSource.Name} [{from.Source.PackageSource.Source}]"));
+
+ foreach (var dep in from.Dependencies)
+ {
+ if (!builtins.Any(x => x.Id == dep.Id))
+ {
+ DumpGraph(data.Single(x => x.Id == dep.Id), data, builtins, level + 1);
+ }
+ }
+ }
+ }
+}
diff --git a/TShockPluginManager/TShockPluginManager.csproj b/TShockPluginManager/TShockPluginManager.csproj
new file mode 100644
index 00000000..33c503fd
--- /dev/null
+++ b/TShockPluginManager/TShockPluginManager.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net6.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/changelog.md b/docs/changelog.md
index 72c1d358..90ea0990 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -84,6 +84,7 @@ Use past tense when adding new entries; sign your name off when you add or chang
* Improved rejection message and code duplication in `OnPlayerBuff` (@drunderscore)
* This will make it so Bouncer rejections regarding `PlayerAddBuff` will now always include the sender index, buff type, receiver index, and time in ticks, allowing much faster triage of buff whitelist issues.
* Allowed Digging Molecart and bomb fish to break tiles and place tracks (@sgkoishi)
+* Add built-in package management capabilities for plugins
## TShock 5.1.3
* Added support for Terraria 1.4.4.9 via OTAPI 3.1.20. (@SignatureBeef)
diff --git a/docs/packages-help.txt b/docs/packages-help.txt
new file mode 100644
index 00000000..f1de4739
--- /dev/null
+++ b/docs/packages-help.txt
@@ -0,0 +1,13 @@
+Description:
+ Manage plugins and their requirements
+
+Usage:
+ TShock.Server [command] [options]
+
+Options:
+ --version Show version information
+ -?, -h, --help Show help and usage information
+
+Commands:
+ sync Install the plugins as specified in the plugins.json
+
diff --git a/docs/packages.md b/docs/packages.md
new file mode 100644
index 00000000..e0242ec6
--- /dev/null
+++ b/docs/packages.md
@@ -0,0 +1,32 @@
+TShock supports downloading and installing plugins from NuGet repositories.
+This allows it to automatically download the plugin as well as all of the extra things that the plugin needs.
+For developers, this makes distributing plugins easier.
+
+This functionality is accessible via the TShock.Server executable used to run the server normally.
+
+Under Linux:
+```
+./TShock.Server plugins
+```
+
+Under Windows (cmd.exe):
+```
+TShock.Server plugins
+```
+
+The documentation for the commands is included in the help functionality.
+A copy of the help output in English can be found in [packages-help.txt](packages-help.txt).
+This file primarily exists to document the `packages.json`.
+
+The file format is currently simple, including only a single object, containing a key `packages` that has a map of package IDs to their versions.
+
+An example `packages.json` is shown below:
+```
+{
+ "packages": {
+ "Commandy.20.10.22.Test": "0.0.1"
+ }
+}
+```
+
+The name of the plugin is specified as the key, with the version as the value.
diff --git a/i18n/template.pot b/i18n/template.pot
index 97b2d60e..625c97e4 100644
--- a/i18n/template.pot
+++ b/i18n/template.pot
@@ -1,8 +1,8 @@
msgid ""
msgstr ""
"Project-Id-Version: TShock\n"
-"POT-Creation-Date: 2022-11-28 07:52:13+0000\n"
-"PO-Revision-Date: 2022-11-28 07:52:13+0000\n"
+"POT-Creation-Date: 2022-11-28 08:14:57-0500\n"
+"PO-Revision-Date: 2022-11-28 08:14:58-0500\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
@@ -1172,12 +1172,36 @@ msgstr ""
msgid " {1}"
msgstr ""
+#: ../../TShockPluginManager/NugetCLI.cs:187
+#, csharp-format
+msgid "{0} from {1} [{2}]"
+msgstr ""
+
+#: ../../TShockPluginManager/NugetCLI.cs:178
+#, csharp-format
+msgid "{0} from {1} [{2}]"
+msgstr ""
+
#: ../../TShockAPI/Commands.cs:5551
#: ../../TShockAPI/Commands.cs:5582
#, csharp-format
msgid " {1}"
msgstr ""
+#: ../../TShockPluginManager/NugetCLI.cs:136
+#, csharp-format
+msgid "=== Dependency ==="
+msgid_plural "=== Dependencies ==="
+msgstr[0] ""
+msgstr[1] ""
+
+#: ../../TShockPluginManager/NugetCLI.cs:133
+#, csharp-format
+msgid "=== Requested Plugin ==="
+msgid_plural "=== Requested Plugins ==="
+msgstr[0] ""
+msgstr[1] ""
+
#: ../../TShockAPI/Commands.cs:2818
msgid "a Deerclops"
msgstr ""
@@ -1333,6 +1357,10 @@ msgstr ""
msgid "all bosses"
msgstr ""
+#: ../../TShockPluginManager/NugetCLI.cs:169
+msgid "All done! :)"
+msgstr ""
+
#: ../../TShockAPI/Commands.cs:2104
msgid "All REST tokens have been destroyed."
msgstr ""
@@ -2798,6 +2826,10 @@ msgid ""
"require a server restart."
msgstr ""
+#: ../../TShockPluginManager/NugetCLI.cs:100
+msgid "Connect to the internet to figure out what to download?"
+msgstr ""
+
#: ../../TShockAPI/TShock.cs:1329
msgid "Connecting via a proxy is not allowed."
msgstr ""
@@ -2819,8 +2851,8 @@ msgid ""
"{0}"
msgstr ""
-#: ../../TShockAPI/DB/ResearchDatastore.cs:54
#: ../../TShockAPI/DB/BanManager.cs:82
+#: ../../TShockAPI/DB/ResearchDatastore.cs:54
msgid "Could not find a database library (probably Sqlite3.dll)"
msgstr ""
@@ -3034,6 +3066,11 @@ msgstr ""
msgid "disallow - Disallows a group from place a tile."
msgstr ""
+#: ../../TShockPluginManager/NugetCLI.cs:140
+#: ../../TShockPluginManager/NugetCLI.cs:161
+msgid "Download and install the given packages?"
+msgstr ""
+
#: ../../TShockAPI/Commands.cs:2648
msgid "Duke Fishron"
msgstr ""
@@ -3720,8 +3757,8 @@ msgstr ""
msgid "Group {0} deleted successfully"
msgstr ""
-#: ../../TShockAPI/DB/UserManager.cs:638
#: ../../TShockAPI/DB/GroupManager.cs:725
+#: ../../TShockAPI/DB/UserManager.cs:638
#, csharp-format
msgid "Group {0} does not exist"
msgstr ""
@@ -3943,6 +3980,15 @@ msgid ""
"{0}setup."
msgstr ""
+#: ../../TShockPluginManager/NugetCLI.cs:142
+msgid "If you want to know which plugins need which dependencies, press E."
+msgstr ""
+
+#: ../../TShockPluginManager/NugetCLI.cs:162
+msgid ""
+"If you'd like to see which plugins need which dependencies again, press E."
+msgstr ""
+
#: ../../TShockAPI/Bouncer.cs:980
msgid ""
"If you're seeing this message and you know what that player did, please "
@@ -3976,8 +4022,8 @@ msgstr ""
msgid "Incorrect setup code. This incident has been logged."
msgstr ""
-#: ../../TShockAPI/DB/TileManager.cs:223
#: ../../TShockAPI/DB/ProjectileManager.cs:223
+#: ../../TShockAPI/DB/TileManager.cs:223
#, csharp-format
msgid "Infinite group parenting ({0})"
msgstr ""
@@ -4000,6 +4046,10 @@ msgstr ""
msgid "Inserting the ban into the database failed."
msgstr ""
+#: ../../TShockPluginManager/NugetCLI.cs:39
+msgid "Install the plugins as specified in the plugins.json"
+msgstr ""
+
#: ../../TShockAPI/Rest/Rest.cs:426
msgid "Internal server error."
msgstr ""
@@ -4810,10 +4860,18 @@ msgstr ""
msgid "Machine name: {0}"
msgstr ""
+#: ../../TShockPluginManager/NugetCLI.cs:141
+msgid "Make sure that you trust the plugins you're installing."
+msgstr ""
+
#: ../../TShockAPI/Bouncer.cs:2489
msgid "Malicious portal attempt."
msgstr ""
+#: ../../TShockPluginManager/NugetCLI.cs:35
+msgid "Manage plugins and their requirements"
+msgstr ""
+
#: ../../TShockAPI/Commands.cs:280
msgid "Manages groups."
msgstr ""
@@ -5057,6 +5115,10 @@ msgstr ""
msgid "NPC damage exceeded {0}."
msgstr ""
+#: ../../TShockPluginManager/NugetCLI.cs:103
+msgid "One moment..."
+msgstr ""
+
#: ../../TShockAPI/DB/RegionManager.cs:102
#, csharp-format
msgid "One of your UserIDs is not a usable integer: {0}"
@@ -5260,8 +5322,8 @@ msgstr ""
msgid "Port overridden by startup argument. Set to {0}"
msgstr ""
-#: ../../TShockAPI/DB/ResearchDatastore.cs:53
#: ../../TShockAPI/DB/BanManager.cs:81
+#: ../../TShockAPI/DB/ResearchDatastore.cs:53
msgid "Possible problem with your database - is Sqlite3.dll present?"
msgstr ""
@@ -5384,6 +5446,10 @@ msgstr ""
msgid "Reached TilePlace threshold."
msgstr ""
+#: ../../TShockPluginManager/NugetCLI.cs:128
+msgid "Read the message below to find out more."
+msgstr ""
+
#: ../../TShockAPI/Commands.cs:1495
#, csharp-format
msgid "Reason: {0}."
@@ -6327,6 +6393,10 @@ msgstr ""
msgid "The value has to be greater than zero."
msgstr ""
+#: ../../TShockPluginManager/NugetCLI.cs:127
+msgid "The versions of plugins you requested aren't compatible with eachother."
+msgstr ""
+
#: ../../TShockAPI/Commands.cs:2722
msgid "the Wall of Flesh"
msgstr ""
@@ -6393,6 +6463,21 @@ msgid ""
"There was an error processing your login or authentication related request."
msgstr ""
+#: ../../TShockPluginManager/NugetCLI.cs:121
+msgid "There was an issue figuring out what to download."
+msgstr ""
+
+#: ../../TShockPluginManager/NugetCLI.cs:90
+msgid "There was an issue reading the packages.json."
+msgstr ""
+
+#: ../../TShockPluginManager/NugetCLI.cs:99
+#, csharp-format
+msgid "This is the plugin you requested to install."
+msgid_plural "These are the plugins you requested to install"
+msgstr[0] ""
+msgstr[1] ""
+
#: ../../TShockAPI/TShock.cs:1013
#: ../../TShockAPI/TShock.cs:1023
#, csharp-format
@@ -6951,6 +7036,10 @@ msgstr ""
msgid "Willow Tree"
msgstr ""
+#: ../../TShockPluginManager/NugetCLI.cs:85
+msgid "Without a list of plugins to install, no plugins can be installed."
+msgstr ""
+
#: ../../TShockAPI/BackupManager.cs:80
#, csharp-format
msgid "World backed up ({0})."
@@ -7590,6 +7679,10 @@ msgstr ""
msgid "You're not allowed to change tiles here!"
msgstr ""
+#: ../../TShockPluginManager/NugetCLI.cs:84
+msgid "You're trying to sync, but you don't have a packages.json file."
+msgstr ""
+
#: ../../TShockAPI/Commands.cs:1987
msgid "Your account has been elevated to superadmin for 10 minutes."
msgstr ""