diff --git a/.gitignore b/.gitignore index 2ca4d91..1c42293 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ temp/ bin/ obj/ +.vscode/ +docs/ \ No newline at end of file diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 798a4c9..a7c8e64 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -6,18 +6,14 @@ "build": { "type": "object", "properties": { - "Configuration": { - "type": "string", - "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)", - "enum": [ - "Debug", - "Release" - ] - }, "Continue": { "type": "boolean", "description": "Indicates to continue a previously failed build attempt" }, + "CurrentListingUrl": { + "type": "string", + "description": "Path to existing index.json file, typically https://{owner}.github.io/{repo}/index.json" + }, "CurrentPackageName": { "type": "string", "description": "PackageName" @@ -51,14 +47,18 @@ "type": "string", "description": "Directory to save index into" }, - "LocalTestPackagesPath": { - "type": "string", - "description": "Path to Target Package" - }, "NoLogo": { "type": "boolean", "description": "Disables displaying the NUKE logo" }, + "PackageListingSourceFilename": { + "type": "string", + "description": "Filename of source json" + }, + "PackageListingSourceFolder": { + "type": "string", + "description": "Path to Target Listing Root" + }, "Partition": { "type": "string", "description": "Partition to use on CI" @@ -84,8 +84,8 @@ "items": { "type": "string", "enum": [ - "BuildRepoListing", - "RebuildHomePage" + "BuildMultiPackageListing", + "BuildRepoListing" ] } }, @@ -95,8 +95,8 @@ "items": { "type": "string", "enum": [ - "BuildRepoListing", - "RebuildHomePage" + "BuildMultiPackageListing", + "BuildRepoListing" ] } }, diff --git a/PackageBuilder/Build.cs b/PackageBuilder/Build.cs index b4657db..7b50690 100644 --- a/PackageBuilder/Build.cs +++ b/PackageBuilder/Build.cs @@ -4,270 +4,466 @@ using System.Linq; using System.Net.Http; using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; using System.Threading.Tasks; +using ICSharpCode.SharpZipLib.Zip; +using Newtonsoft.Json; using Nuke.Common; using Nuke.Common.CI.GitHubActions; using Nuke.Common.IO; using Octokit; using VRC.PackageManagement.Core.Types.Packages; using ProductHeaderValue = Octokit.ProductHeaderValue; +using ListingSource = VRC.PackageManagement.Automation.Multi.ListingSource; -[GitHubActions( - "GHTest", - GitHubActionsImage.UbuntuLatest, - On = new[] { GitHubActionsTrigger.WorkflowDispatch, GitHubActionsTrigger.Push }, - EnableGitHubToken = true, - AutoGenerate = false, - InvokedTargets = new[] { nameof(BuildRepoListing) })] -class Build : NukeBuild +namespace VRC.PackageManagement.Automation { - public static int Main () => Execute(x => x.BuildRepoListing); - - [Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")] - readonly Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release; - - GitHubActions GitHubActions => GitHubActions.Instance; - - const string PackageManifestFilename = "package.json"; - const string WebPageIndexFilename = "index.html"; - string CurrentPackageVersion; - const string VRCAgent = "VCCBootstrap/1.0"; - - [Parameter("Directory to save index into")] - AbsolutePath ListPublishDirectory = RootDirectory / "docs"; - - [Parameter("PackageName")] - private string CurrentPackageName = "com.vrchat.demo-template"; - - // assumes that "template-package" repo is checked out in sibling dir to this repo, can be overridden - [Parameter("Path to Target Package")] private AbsolutePath LocalTestPackagesPath => RootDirectory.Parent / "template-package" / "Packages"; - - protected GitHubClient Client + [GitHubActions( + "GHTest", + GitHubActionsImage.UbuntuLatest, + On = new[] { GitHubActionsTrigger.WorkflowDispatch, GitHubActionsTrigger.Push }, + EnableGitHubToken = true, + AutoGenerate = false, + InvokedTargets = new[] { nameof(BuildRepoListing) })] + partial class Build : NukeBuild { - get + public static int Main() => Execute(x => x.BuildRepoListing); + + GitHubActions GitHubActions => GitHubActions.Instance; + + const string PackageManifestFilename = "package.json"; + const string WebPageIndexFilename = "index.html"; + const string VRCAgent = "VCCBootstrap/1.0"; + const string PackageListingPublishFilename = "index.json"; + const string WebPageAppFilename = "app.js"; + + [Parameter("Directory to save index into")] + AbsolutePath ListPublishDirectory = RootDirectory / "docs"; + + [Parameter("PackageName")] + string CurrentPackageName = "com.vrchat.demo-template"; + + [Parameter("Filename of source json")] + string PackageListingSourceFilename = "source.json"; + + // assumes that "template-package-listings" repo is checked out in sibling dir for local testing, can be overriden + [Parameter("Path to Target Listing Root")] + AbsolutePath PackageListingSourceFolder = IsServerBuild + ? RootDirectory.Parent + : RootDirectory.Parent / "template-package-listing"; + + [Parameter("Path to existing index.json file, typically https://{owner}.github.io/{repo}/index.json")] + string CurrentListingUrl => + $"https://{GitHubActions.RepositoryOwner}.github.io/{GitHubActions.Repository.Split('/')[1]}/{PackageListingPublishFilename}"; + + // assumes that "template-package" repo is checked out in sibling dir to this repo, can be overridden + [Parameter("Path to Target Package")] + AbsolutePath LocalTestPackagesPath => RootDirectory.Parent / "template-package" / "Packages"; + + AbsolutePath PackageListingSourcePath => PackageListingSourceFolder / PackageListingSourceFilename; + AbsolutePath WebPageSourcePath => PackageListingSourceFolder / "Website"; + + #region Methods wrapped for GitHub / Local Parity + + string GetRepoName() { - if (_client == null) - { - _client = new GitHubClient(new ProductHeaderValue("VCC-Nuke"), - new Octokit.Internal.InMemoryCredentialStore(new Credentials(GitHubActions.Token))); - } - return _client; + return IsServerBuild + ? GitHubActions.Repository.Replace($"{GitHubActions.RepositoryOwner}/", "") + : CurrentPackageName; } - } - private GitHubClient _client; - #region Local Package Methods + string GetRepoOwner() + { + return IsServerBuild ? GitHubActions.RepositoryOwner : "LocalTestOwner"; + } - private IReadOnlyList GetLocalReleases(string repoName) - { - return new List() { GetLocalRelease(repoName) }; - } + #endregion - private Release GetLocalRelease(string packageName) - { - // Fills in most fields with the name of the field, we only need the URL for our purposes - return new Release("url", "htmlUrl", "assetsUrl", "uploadUrl", 0, - "nodeId", "tagName", "targetCommitish", "name", "body", - false, false, DateTimeOffset.Now, DateTimeOffset.Now, new Octokit.Author(), - "tarballUrl", "zipballUrl", - new[] + ListingSource MakeListingSourceFromManifest(VRCPackageManifest manifest) + { + var result = new ListingSource() { - GetLocalReleaseAsset(LocalTestPackagesPath / packageName, "package.json"), - GetLocalReleaseAsset(LocalTestPackagesPath / packageName,"package.zip"), - GetLocalReleaseAsset(LocalTestPackagesPath / packageName,"package.unitypackage") - }); - } - private ReleaseAsset GetLocalReleaseAsset(string path, string filename) - { - // Fills in most fields with the name of the field, we only need the URL for our purposes - return new ReleaseAsset( Path.Combine(path, filename), 0, "nodeId", filename, "label", "state", - "contentType", 0, 0, DateTimeOffset.Now, DateTimeOffset.Now, - $"https://local-test-wont-work/{filename}", new Octokit.Author()); - } + name = $"{manifest.displayName} Listing", + id = $"{manifest.name}.listing", + author = new VRC.PackageManagement.Automation.Multi.Author() + { + name = manifest.author.name ?? "", + url = manifest.author.url ?? "", + email = manifest.author.email ?? "" + }, + url = CurrentListingUrl, + description = $"Listing for {manifest.displayName}", + bannerUrl = "banner.png", + githubRepos = new List() + { + GitHubActions.Repository + } + }; + return result; + } + + Target BuildRepoListing => _ => _ + .Executes(async () => + { + ListingSource listSource; + + if (!FileSystemTasks.FileExists(PackageListingSourcePath)) + { + AbsolutePath packagePath = RootDirectory.Parent / "Packages" / CurrentPackageName / PackageManifestFilename; + if (!FileSystemTasks.FileExists(packagePath)) + { + Serilog.Log.Error($"Could not find Listing Source at {PackageListingSourcePath} or Package Manifest at {packagePath}, you need at least one of them."); + return; + } + + // Deserialize manifest from packagePath + var manifest = JsonConvert.DeserializeObject(File.ReadAllText(packagePath), JsonReadOptions); + listSource = MakeListingSourceFromManifest(manifest); + if (listSource == null) + { + Serilog.Log.Error($"Could not create listing source from manifest."); + return; + } + } + else + { + // Get listing source + var listSourceString = File.ReadAllText(PackageListingSourcePath); + listSource = JsonConvert.DeserializeObject(listSourceString, JsonReadOptions); + } - #endregion + if (string.IsNullOrWhiteSpace(listSource.id)) + { + listSource.id = $"io.github.{GetRepoOwner()}.{GetRepoName()}"; + Serilog.Log.Warning($"Your listing needs an id. We've autogenerated one for you: {listSource.id}. If you want to change it, edit {PackageListingSourcePath}."); + } + + // Get existing RepoList URLs or create empty one, so we can skip existing packages + var currentRepoListString = IsServerBuild ? await GetAuthenticatedString(CurrentListingUrl) : null; + var currentPackageUrls = currentRepoListString == null + ? new List() + : JsonConvert.DeserializeObject(currentRepoListString, JsonReadOptions).GetAll() + .Select(package => package.Url).ToList(); + + // Make collection for constructed packages + var packages = new List(); + var possibleReleaseUrls = new List(); + + // Add packages from listing source if included + if (listSource.packages != null) + { + possibleReleaseUrls.AddRange( + listSource.packages?.SelectMany(info => info.releases) + ); + } - #region Methods wrapped for GitHub / Local Parity + // Add GitHub repos if included + if (listSource.githubRepos != null && listSource.githubRepos.Count > 0) + { + foreach (string ownerSlashName in listSource.githubRepos) + { + possibleReleaseUrls.AddRange(await GetReleaseZipUrlsFromGitHubRepo(ownerSlashName)); + } + } - private async Task GetLatestRelease(string repoName) - { - if (IsServerBuild) - { - return await Client.Repository.Release.GetLatest(GitHubActions.RepositoryOwner, repoName); - } - else - { - return GetLocalRelease(repoName); // assumes we just have a single release available locally, for now. - } - } + // Add each release url to the packages collection if it's not already in the listing, and its zip is valid + foreach (string url in possibleReleaseUrls) + { + Serilog.Log.Information($"Looking at {url}"); + if (currentPackageUrls.Contains(url)) + { + Serilog.Log.Information($"Current listing already contains {url}, skipping"); + continue; + } + + var manifest = await HashZipAndReturnManifest(url); + if (manifest == null) + { + Serilog.Log.Information($"Could not find manifest in zip file {url}, skipping."); + continue; + } + + // Add package with updated manifest to collection + Serilog.Log.Information($"Found {manifest.Id} ({manifest.name}) {manifest.Version}, adding to listing."); + packages.Add(manifest); + } - private string GetRepoName() - { - return IsServerBuild - ? GitHubActions.Repository.Replace($"{GitHubActions.RepositoryOwner}/", "") - : CurrentPackageName; - } + // Copy listing-source.json to new Json Object + Serilog.Log.Information($"All packages prepared, generating Listing."); + var repoList = new VRCRepoList(packages) + { + name = listSource.name, + id = listSource.id, + author = listSource.author.name, + url = listSource.url + }; - private string GetRepoOwner() - { - return IsServerBuild ? GitHubActions.RepositoryOwner : "LocalTestOwner"; - } - - // On GitHub, we're running in the target package's repo. Locally, we run in the action dir. - AbsolutePath ListSourceDirectory => - IsServerBuild ? ListPublishDirectory : LocalTestPackagesPath.Parent / "Website"; + // Server builds write into the source directory itself + // So we dont need to clear it out + if (!IsServerBuild) { + FileSystemTasks.EnsureCleanDirectory(ListPublishDirectory); + } - #endregion + string savePath = ListPublishDirectory / PackageListingPublishFilename; + repoList.Save(savePath); + + var indexReadPath = WebPageSourcePath / WebPageIndexFilename; + var appReadPath = WebPageSourcePath / WebPageAppFilename; + var indexWritePath = ListPublishDirectory / WebPageIndexFilename; + var indexAppWritePath = ListPublishDirectory / WebPageAppFilename; + + string indexTemplateContent = File.ReadAllText(indexReadPath); + + var listingInfo = new { + Name = listSource.name, + Url = listSource.url, + Description = listSource.description, + InfoLink = new { + Text = listSource.infoLink?.text, + Url = listSource.infoLink?.url, + }, + Author = new { + Name = listSource.author.name, + Url = listSource.author.url, + Email = listSource.author.email + }, + BannerImage = !string.IsNullOrEmpty(listSource.bannerUrl), + BannerImageUrl = listSource.bannerUrl, + }; + + Serilog.Log.Information($"Made listingInfo {JsonConvert.SerializeObject(listingInfo, JsonWriteOptions)}"); + + var latestPackages = packages.OrderByDescending(p => p.Version).DistinctBy(p => p.Id).ToList(); + Serilog.Log.Information($"LatestPackages: {JsonConvert.SerializeObject(latestPackages, JsonWriteOptions)}"); + var formattedPackages = latestPackages.ConvertAll(p => new { + Name = p.Id, + Author = new { + Name = p.author?.name, + Url = p.author?.url, + }, + ZipUrl = p.url, + License = p.license, + LicenseUrl = p.licensesUrl, + Keywords = p.keywords, + Type = GetPackageType(p), + p.Description, + DisplayName = p.Title, + p.Version, + Dependencies = p.VPMDependencies.Select(dep => new { + Name = dep.Key, + Version = dep.Value + } + ).ToList(), + }); + + var rendered = Scriban.Template.Parse(indexTemplateContent).Render( + new { listingInfo, packages = formattedPackages }, member => member.Name + ); + + File.WriteAllText(indexWritePath, rendered); - // Assumes single package in this type of listing, make a different one for multi-package sets - Target BuildRepoListing => _ => _ - .Executes(async () => - { - var packages = new List(); + var appJsRendered = Scriban.Template.Parse(File.ReadAllText(appReadPath)).Render( + new { listingInfo, packages = formattedPackages }, member => member.Name + ); + File.WriteAllText(indexAppWritePath, appJsRendered); - var repoName = GetRepoName(); - var repoOwner = GetRepoOwner(); + if (!IsServerBuild) { + FileSystemTasks.CopyDirectoryRecursively(WebPageSourcePath, ListPublishDirectory, DirectoryExistsPolicy.Merge, FileExistsPolicy.Skip); + } + + Serilog.Log.Information($"Saved Listing to {savePath}."); + }); - var releases = IsServerBuild - ? await Client.Repository.Release.GetAll(GitHubActions.RepositoryOwner, repoName) - : GetLocalReleases(repoName); - - foreach (var release in releases) + GitHubClient _client; + GitHubClient Client + { + get { - // Release must have package.json and .zip file, or else it will throw an exception here - var manifest = await GetManifestFromRelease(release); - if (manifest == null) + if (_client == null) { - Serilog.Log.Error($"Could not get manifest from {release.Name}"); - return; + _client = new(new ProductHeaderValue("VRChat-Package-Manager-Automation")); + if (IsServerBuild) + { + _client.Credentials = new Credentials(GitHubActions.Token); + } } - - ReleaseAsset zipAsset = release.Assets.First(asset => asset.Name.EndsWith(".zip")); - // Set url to .zip asset, add latest package version - manifest.url = zipAsset.BrowserDownloadUrl; - packages.Add(manifest); + return _client; + } + } + + async Task> GetReleaseZipUrlsFromGitHubRepo(string ownerSlashName) + { + // Split string into owner and repo, or skip if invalid. + var parts = ownerSlashName.Split('/'); + if (parts.Length != 2) + { + Serilog.Log.Fatal($"Could not get owner and repository from included repo info {parts}."); + return null; } + string owner = parts[0]; + string name = parts[1]; - var latestRelease = await GetLatestRelease(repoName); + var targetRepo = await Client.Repository.Get(owner, name); + if (targetRepo == null) + { + Assert.Fail($"Could not get remote repo {owner}/{name}."); + return null; + } - // Assumes we're publishing both zip and unitypackage - var latestManifest = await GetManifestFromRelease(latestRelease); - if (latestManifest == null) + // Go through each release + var releases = await Client.Repository.Release.GetAll(owner, name); + if (releases.Count == 0) { - throw new Exception($"Could not get Manifest for release {latestRelease.Name}"); + Serilog.Log.Information($"Found no releases for {owner}/{name}"); + return null; } - var repoList = new VRCRepoList(packages) + var result = new List(); + + foreach (Octokit.Release release in releases) { - author = latestManifest.author?.name ?? repoOwner, - name = $"{latestManifest.name} Releases", - url = $"https://{repoOwner}.github.io/{repoName}/index.json" - }; + result.AddRange(release.Assets.Where(asset => asset.Name.EndsWith(".zip")).Select(asset => asset.BrowserDownloadUrl)); + } - string savePath = ListPublishDirectory / "index.json"; + return result; + } - FileSystemTasks.EnsureExistingParentDirectory(savePath); - repoList.Save(savePath); - }) - .Triggers(RebuildHomePage); + // Keeping this for now to ensure existing listings are not broken + Target BuildMultiPackageListing => _ => _ + .Triggers(BuildRepoListing); - async Task GetAuthenticatedResponse(string url) - { - using (var requestMessage = - new HttpRequestMessage(HttpMethod.Get, url)) + string GetPackageType(IVRCPackage p) { - requestMessage.Headers.Accept.ParseAdd("application/octet-stream"); - requestMessage.Headers.Authorization = - new AuthenticationHeaderValue("Bearer", GitHubActions.Token); - - return await Http.SendAsync(requestMessage); + string result = "Any"; + var manifest = p as VRCPackageManifest; + if (manifest == null) return result; + + if (manifest.ContainsAvatarDependencies()) result = "Avatar"; + else if (manifest.ContainsWorldDependencies()) result = "World"; + + return result; } - } - async Task GetAuthenticatedString(string url) - { - if (IsServerBuild) + async Task HashZipAndReturnManifest(string url) { - var result = await GetAuthenticatedResponse(url); - if (result.IsSuccessStatusCode) - { - return await result.Content.ReadAsStringAsync(); + using (var response = await Http.GetAsync(url)) + { + if (!response.IsSuccessStatusCode) + { + Assert.Fail($"Could not find valid zip file at {url}"); + } + + // Get manifest or return null + var bytes = await response.Content.ReadAsByteArrayAsync(); + var manifestBytes = GetFileFromZip(bytes, PackageManifestFilename); + if (manifestBytes == null) return null; + + var manifestString = Encoding.UTF8.GetString(manifestBytes); + var manifest = VRCPackageManifest.FromJson(manifestString); + var hash = GetHashForBytes(bytes); + manifest.zipSHA256 = hash; // putting the hash in here for now + // Point manifest towards release + manifest.url = url; + return manifest; } - else + } + + static byte[] GetFileFromZip(byte[] bytes, string fileName) + { + byte[] ret = null; + var stream = new MemoryStream(bytes); + ZipFile zf = new ZipFile(stream); + ZipEntry ze = zf.GetEntry(fileName); + + if (ze != null) { - Serilog.Log.Error($"Could not download manifest from {url}"); - return null; + Stream s = zf.GetInputStream(ze); + ret = new byte[ze.Size]; + s.Read(ret, 0, ret.Length); } + + return ret; } - else + + static string GetHashForBytes(byte[] bytes) { - // Treat like absolute path for local files - return File.ReadAllText(url); + using (var hash = SHA256.Create()) + { + return string.Concat(hash + .ComputeHash(bytes) + .Select(item => item.ToString("x2"))); + } } - } - async Task GetManifestFromRelease(Release release) - { - // Release must have package.json or else it will throw an exception here - ReleaseAsset manifestAsset = - release.Assets.First(asset => asset.Name.CompareTo(PackageManifestFilename) == 0); + async Task GetAuthenticatedResponse(string url) + { + using (var requestMessage = + new HttpRequestMessage(HttpMethod.Get, url)) + { + requestMessage.Headers.Accept.ParseAdd("application/octet-stream"); + if (IsServerBuild) + { + requestMessage.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", GitHubActions.Token); + } - // Will log an error if it fails, stopping the automation - return VRCPackageManifest.FromJson( await GetAuthenticatedString(manifestAsset.Url)); - } + return await Http.SendAsync(requestMessage); + } + } - Target RebuildHomePage => _ => _ - .Executes(async () => + async Task GetAuthenticatedString(string url) { - var repoName = GetRepoName(); - var repoOwner = GetRepoOwner(); - var release = await GetLatestRelease(repoName); - - // Assumes we're publishing both zip and unitypackage - var zipUrl = release.Assets.First(asset => asset.Name.EndsWith(".zip")).BrowserDownloadUrl; - var unityPackageUrl = release.Assets.First(asset => asset.Name.EndsWith(".unitypackage")).BrowserDownloadUrl; - var manifest = await GetManifestFromRelease(release); - if (manifest == null) + var result = await GetAuthenticatedResponse(url); + if (result.IsSuccessStatusCode) { - throw new Exception($"Could not get Manifest for release {release.Name}"); + return await result.Content.ReadAsStringAsync(); } - - var indexReadPath = ListSourceDirectory / WebPageIndexFilename; - var indexWritePath = ListPublishDirectory / WebPageIndexFilename; - string indexTemplateContent = File.ReadAllText(indexReadPath); - - if (manifest.author == null) { - manifest.author = new VRC.PackageManagement.Core.Types.Packages.Author{ - name = repoOwner, - url = $"https://github.com/{repoOwner}" - }; + else + { + Serilog.Log.Error($"Could not download manifest from {url}"); + return null; } + } - var rendered = Scriban.Template.Parse(indexTemplateContent).Render(new {manifest, assets=new{zip=zipUrl, unityPackage=unityPackageUrl}}, member => member.Name); - File.WriteAllText(indexWritePath, rendered); - Serilog.Log.Information($"Updated index page at {indexWritePath}"); - }); - - static HttpClient _http; + static HttpClient _http; - public static HttpClient Http - { - get + static HttpClient Http { - if (_http != null) + get { + if (_http != null) + { + return _http; + } + + _http = new HttpClient(); + _http.DefaultRequestHeaders.UserAgent.ParseAdd(VRCAgent); return _http; } - - _http = new HttpClient(); - _http.DefaultRequestHeaders.UserAgent.ParseAdd(VRCAgent); - return _http; } + + // https://www.newtonsoft.com/json/help/html/T_Newtonsoft_Json_JsonSerializerSettings.htm + static JsonSerializerSettings JsonWriteOptions = new() + { + NullValueHandling = NullValueHandling.Ignore, + Formatting = Formatting.Indented, + Converters = new List() + { + new PackageConverter(), + new VersionListConverter() + }, + }; + + static JsonSerializerSettings JsonReadOptions = new() + { + NullValueHandling = NullValueHandling.Ignore, + Converters = new List() + { + new PackageConverter(), + new VersionListConverter() + }, + }; } - - public static async Task GetRemoteString(string url) - { - return await Http.GetStringAsync(url); - } -} +} \ No newline at end of file diff --git a/PackageBuilder/ListingSource.cs b/PackageBuilder/ListingSource.cs new file mode 100644 index 0000000..1e12ee3 --- /dev/null +++ b/PackageBuilder/ListingSource.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using VRC.PackageManagement.Core.Types.Packages; + +namespace VRC.PackageManagement.Automation.Multi +{ + public class ListingSource + { + public string name { get; set; } + public string id { get; set; } + public Author author { get; set; } + public string url { get; set; } + public string description { get; set;} + public InfoLink infoLink { get; set; } + public string bannerUrl { get; set; } + public List packages { get; set; } + public List githubRepos { get; set; } + } + + public class InfoLink { + public string text { get; set; } + public string url { get; set; } + } + + public class Author { + public string name { get; set;} + public string url { get; set;} + public string email {get; set;} + } + + public class PackageInfo + { + public string id { get; set; } + public List releases { get; set; } + } +} \ No newline at end of file diff --git a/vpm-core-lib/vpm-core-lib.dll b/vpm-core-lib/vpm-core-lib.dll index 6f27d55..8b92b77 100644 Binary files a/vpm-core-lib/vpm-core-lib.dll and b/vpm-core-lib/vpm-core-lib.dll differ