From be8e51959f2e58b2c02cca905b966fb1e370459b Mon Sep 17 00:00:00 2001 From: Janet Blackquill Date: Thu, 20 Oct 2022 19:15:57 -0400 Subject: [PATCH] Introduce integrated package manager This commit introduces an integrated package manager into TShock for the purpose of fetching and installing plugins and their dependencies from NuGet repositories. This makes getting new plugins easier for users, as well as simplifiying more advanced deployment scenarios. --- TShock.sln | 18 + TShockLauncher/.gitignore | 1 + TShockLauncher/Program.cs | 15 +- TShockLauncher/TShockLauncher.csproj | 3 +- TShockPluginManager/CLIHelpers.cs | 114 ++++++ TShockPluginManager/I18n.cs | 11 + TShockPluginManager/Nuget.cs | 328 ++++++++++++++++++ TShockPluginManager/NugetCLI.cs | 198 +++++++++++ .../TShockPluginManager.csproj | 18 + docs/changelog.md | 1 + docs/packages-help.txt | 13 + docs/packages.md | 32 ++ i18n/template.pot | 105 +++++- 13 files changed, 849 insertions(+), 8 deletions(-) create mode 100644 TShockLauncher/.gitignore create mode 100644 TShockPluginManager/CLIHelpers.cs create mode 100644 TShockPluginManager/I18n.cs create mode 100644 TShockPluginManager/Nuget.cs create mode 100644 TShockPluginManager/NugetCLI.cs create mode 100644 TShockPluginManager/TShockPluginManager.csproj create mode 100644 docs/packages-help.txt create mode 100644 docs/packages.md 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 ""