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.
This commit is contained in:
Janet Blackquill 2022-10-20 19:15:57 -04:00
parent 4e59087e7c
commit be8e51959f
13 changed files with 849 additions and 8 deletions

View file

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

1
TShockLauncher/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/packages

View file

@ -22,11 +22,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
* - 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<string, Assembly> _cache = new Dictionary<string, Assembly>();

View file

@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\TerrariaServerAPI\TerrariaServerAPI\TerrariaServerAPI.csproj" ExcludeFromSingleFile="true" />
<ProjectReference Include="..\TShockAPI\TShockAPI.csproj" ExcludeFromSingleFile="true" ReferenceOutputAssembly="false" /> <!-- allow api to rebuilt with this project, so ServerPlugins are refreshed -->
<ProjectReference Include="..\TShockPluginManager\TShockPluginManager.csproj"/>
<Reference Include="HttpServer" ExcludeFromSingleFile="true">
<HintPath>..\prebuilts\HttpServer.dll</HintPath>
</Reference>
@ -96,4 +97,4 @@
</ItemGroup>
<Move SourceFiles="@(MoveBinaries)" DestinationFolder="$(PublishDir)bin" ContinueOnError="true" />
</Target>
</Project>
</Project>

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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<string, bool> 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<ConsoleColor>(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");
}
}
}

View file

@ -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);
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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<SourceRepository> repositories,
ISet<SourcePackageDependencyInfo> 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<DependencyInfoResource>();
// 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);
}
}
}
/// <returns>all the packages representing dependencies bundled with TShock.Server</returns>
public async Task<IEnumerable<SourcePackageDependencyInfo>> 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);
}
/// <returns>all the dependencies for the provided package identities</returns>
public async Task<IEnumerable<SourcePackageDependencyInfo>> GetAllDependenciesFor(IEnumerable<PackageIdentity> targets)
{
using var cacheContext = new SourceCacheContext();
// get all of the possible packages in our dependency tree
var possiblePackages = new HashSet<SourcePackageDependencyInfo>(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<string>(),
// we don't have a lockfile
Enumerable.Empty<PackageReference>(),
// we don't have fancy versioning
Enumerable.Empty<PackageIdentity>(),
// 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;
}
/// <returns>whether or not subPath is a subpath of basePath</returns>
public static bool IsSubPathOf(string subPath, string basePath)
{
var rel = Path.GetRelativePath(basePath, subPath);
return rel != "."
&& rel != ".."
&& !rel.StartsWith("../")
&& !rel.StartsWith(@"..\")
&& !Path.IsPathRooted(rel);
}
/// <returns>items required for end-user running of a package</returns>
public IEnumerable<FrameworkSpecificGroup> 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);
}
/// <returns>path to package folder and metadata reader</returns>
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<DownloadResource>(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);
}
/// <returns>resolved packages to be installed for what the user requested</returns>
public async Task<IEnumerable<SourcePackageDependencyInfo>> GetPackagesToInstallFor(PackageIdentity[] userRequest)
{
using var cacheContext = new SourceCacheContext();
return (await GetAllDependenciesFor(userRequest)).OrderBy(v => v.Id);
}
/// <summary>installs a locally downloaded package</summary>
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);
}
}
}
/// <summary>downloads and installs the given packages</summary>
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);
}
}
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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<int> Main(List<string> 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<string, NuGetVersion> 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<NuGetVersion>
{
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<SyncManifest>(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($"<green>{item.Id}<black> [{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<SourcePackageDependencyInfo> packagesToInstall;
IEnumerable<SourcePackageDependencyInfo> builtinDependencies;
IEnumerable<SourcePackageDependencyInfo> directlyRequestedPackages;
IEnumerable<SourcePackageDependencyInfo> 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<SourcePackageDependencyInfo> builtins)
{
if (builtins.Any(x => x.Id == pkg.Id))
return;
var initial = Console.ForegroundColor;
CLIHelpers.WriteLine(C.GetString($"<green>{pkg.Id}<black> from <blue>{pkg.Source.PackageSource.Name} <black>[{pkg.Source.PackageSource.Source}]"));
Console.ForegroundColor = initial;
}
static public void DumpGraph(SourcePackageDependencyInfo from, IEnumerable<SourcePackageDependencyInfo> data, IEnumerable<SourcePackageDependencyInfo> builtins, int level)
{
var indent = new String('\t', level);
Console.Write(indent);
CLIHelpers.WriteLine(C.GetString($"<green>{from.Id} <black>from <blue>{from.Source.PackageSource.Name} <black>[{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);
}
}
}
}
}

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NuGet.Packaging" Version="6.3.1" />
<PackageReference Include="NuGet.Protocol" Version="6.3.1" />
<PackageReference Include="NuGet.Resolver" Version="6.3.1" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageReference Include="System.Reflection.MetadataLoadContext" Version="6.0.0" />
<PackageReference Include="GetText.NET" Version="1.7.14" />
</ItemGroup>
</Project>

View file

@ -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)

13
docs/packages-help.txt Normal file
View file

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

32
docs/packages.md Normal file
View file

@ -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.

View file

@ -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 "<From {0}> {1}"
msgstr ""
#: ../../TShockPluginManager/NugetCLI.cs:187
#, csharp-format
msgid "<green>{0} <black>from <blue>{1} <black>[{2}]"
msgstr ""
#: ../../TShockPluginManager/NugetCLI.cs:178
#, csharp-format
msgid "<green>{0}<black> from <blue>{1} <black>[{2}]"
msgstr ""
#: ../../TShockAPI/Commands.cs:5551
#: ../../TShockAPI/Commands.cs:5582
#, csharp-format
msgid "<To {0}> {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 <tile ID> <group> - 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 ""