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 ""