diff --git a/API/Choco/Choco.cs b/API/Choco/Choco.cs deleted file mode 100644 index 9a79d94b..00000000 --- a/API/Choco/Choco.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Chocolatey.Models; -using Flurl; -using Flurl.Http; -using Flurl.Http.Xml; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using System.Xml.Linq; - -namespace Chocolatey -{ - public static class Choco - { - /// - /// A method that parses a into the specified . - /// - public delegate T Parse(string str); - - public static async Task> SearchAsync(string query, string targetFramework = "", bool includePrerelease = false, int top = 30, int skip = 0) - { - var doc = await Constants.CHOCOLATEY_API_HOST.AppendPathSegment("Search()") - .SetQueryParam("$filter", "IsLatestVersion").SetQueryParam("$top", top).SetQueryParam("$skip", skip) - .SetQueryParam("searchTerm", "'" + query + "'") - .SetQueryParam("targetFramework", "'" + targetFramework + "'") - .SetQueryParam("includePrerelease", includePrerelease.ToLowerString()) - .GetXDocumentAsync(); - IEnumerable entries = doc.Root.Elements().Where(elem => elem.Name.LocalName == "entry"); - - // Parse Atom entries into Package model - return entries.Select(entry => new Package(entry)).ToList(); - } - - public static async Task GetPackageAsync(string id, Version version) - { - var entry = await Constants.CHOCOLATEY_API_HOST.AppendPathSegment($"Packages(Id='{id}',Version='{version}')") - .GetXDocumentAsync(); - return new Package(entry.Root); - } - - public static async Task GetPackagePropertyAsync(string id, Version version, string propertyName) - { - var entry = await Constants.CHOCOLATEY_API_HOST.AppendPathSegment($"Packages(Id='{id}', Version='{version}')") - .AppendPathSegment(propertyName) - .GetXDocumentAsync(); - return entry.Root.Value; - } - - public static async Task GetPackagePropertyAsync(string id, Version version, string propertyName, Parse parse) - { - return parse(await GetPackagePropertyAsync(id, version, propertyName)); - } - - #region GetPackage*PropertyAsync - - public static async Task GetPackageDatePropertyAsync(string id, Version version, string propertyName) - { - return await GetPackagePropertyAsync(id, version, propertyName, DateTimeOffset.Parse); - } - public static async Task GetPackageBooleanPropertyAsync(string id, Version version, string propertyName) - { - return await GetPackagePropertyAsync(id, version, propertyName, bool.Parse); - } - public static async Task GetPackageInt32PropertyAsync(string id, Version version, string propertyName) - { - return await GetPackagePropertyAsync(id, version, propertyName, int.Parse); - } - public static async Task GetPackageInt64PropertyAsync(string id, Version version, string propertyName) - { - return await GetPackagePropertyAsync(id, version, propertyName, long.Parse); - } - - #endregion - } -} diff --git a/API/Choco/ChocoCommunityWebClient.cs b/API/Choco/ChocoCommunityWebClient.cs new file mode 100644 index 00000000..ac1eb62a --- /dev/null +++ b/API/Choco/ChocoCommunityWebClient.cs @@ -0,0 +1,42 @@ +using Chocolatey.Models; +using Flurl; +using Flurl.Http.Xml; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Xml.Linq; +using NuGet.Versioning; + +namespace Chocolatey; + +public class ChocoCommunityWebClient : IChocoSearchService +{ + public async Task> SearchAsync(string query, string targetFramework = "", bool includePrerelease = false, int top = 30, int skip = 0) + { + var doc = await Constants.CHOCOLATEY_API_HOST.AppendPathSegment("Search()") + .SetQueryParam("$filter", "IsLatestVersion").SetQueryParam("$top", top).SetQueryParam("$skip", skip) + .SetQueryParam("searchTerm", "'" + query + "'") + .SetQueryParam("targetFramework", "'" + targetFramework + "'") + .SetQueryParam("includePrerelease", includePrerelease.ToLowerString()) + .GetXDocumentAsync(); + IEnumerable entries = doc.Root.Elements().Where(elem => elem.Name.LocalName == "entry"); + + // Parse Atom entries into Package model + return entries.Select(entry => new Package(entry)).ToList(); + } + + public async Task GetPackageAsync(string id, NuGetVersion version) + { + var entry = await Constants.CHOCOLATEY_API_HOST.AppendPathSegment($"Packages(Id='{id}',Version='{version}')") + .GetXDocumentAsync(); + return new Package(entry.Root); + } + + public async Task GetPackagePropertyAsync(string id, NuGetVersion version, string propertyName) + { + var entry = await Constants.CHOCOLATEY_API_HOST.AppendPathSegment($"Packages(Id='{id}',Version='{version}')") + .AppendPathSegment(propertyName) + .GetXDocumentAsync(); + return entry.Root.Value; + } +} diff --git a/API/Choco/Chocolatey.csproj b/API/Choco/Chocolatey.csproj index 4bce8806..08350cbb 100644 --- a/API/Choco/Chocolatey.csproj +++ b/API/Choco/Chocolatey.csproj @@ -8,8 +8,10 @@ + + diff --git a/API/Choco/Cli/ChocoAdminCliClient.cs b/API/Choco/Cli/ChocoAdminCliClient.cs new file mode 100644 index 00000000..64e30787 --- /dev/null +++ b/API/Choco/Cli/ChocoAdminCliClient.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using Chocolatey.Models; +using NuGet.Versioning; + +namespace Chocolatey.Cli; + +public class ChocoAdminCliClient : IChocoPackageService +{ + public bool NoOp { get; set; } = false; + + public async Task InstallAsync(string id, NuGetVersion? version = null, IProgress? progress = null) + { + return await Task.Run(delegate + { + List args = ["install", id]; + + ChocoArgumentsBuilder argBuilder = new() + { + Version = version, + Yes = true, + LimitOutput = true, + NoOp = NoOp + }; + + argBuilder.Build(args); + + var process = RunChocoAsAdmin(args); + + return process.ExitCode is 0; + }); + } + + public IAsyncEnumerable<(string Id, NuGetVersion Version)> ListAsync() + { + throw new NotImplementedException(); + } + + public async Task UninstallAsync(string id, NuGetVersion? version = null, IProgress? progress = null) + { + return await Task.Run(delegate + { + List args = ["uninstall", id]; + + ChocoArgumentsBuilder argBuilder = new() + { + Version = version, + Yes = true, + LimitOutput = true, + NoOp = NoOp + }; + + argBuilder.Build(args); + + var process = RunChocoAsAdmin(args); + + return process.ExitCode is 0; + }); + } + + public Task UpgradeAllAsync(IProgress? progress = null) + { + throw new NotImplementedException(); + } + + public Task UpgradeAsync(string id, IProgress? progress = null) + { + throw new NotImplementedException(); + } + + private Process RunChocoAsAdmin(List args) + { + ProcessStartInfo info = new(ChocoArgumentsBuilder.CHOCO_EXE) + { + Arguments = string.Join(" ", args), + + // Required to run as admin + UseShellExecute = true, + Verb = "runas", + }; + + var process = Process.Start(info); + process.WaitForExit(); + + return process; + } +} diff --git a/API/Choco/Cli/ChocoArgumentsBuilder.cs b/API/Choco/Cli/ChocoArgumentsBuilder.cs new file mode 100644 index 00000000..1276eae7 --- /dev/null +++ b/API/Choco/Cli/ChocoArgumentsBuilder.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using NuGet.Versioning; + +namespace Chocolatey.Cli; + +internal class ChocoArgumentsBuilder +{ + public const string CHOCO_EXE = "choco"; + + public NuGetVersion? Version { get; set; } + public bool Yes { get; set; } + public bool LimitOutput { get; set; } + public bool NoOp { get; set; } + + public void Build(List args) => args.AddRange(Build()); + + [Pure] + public IEnumerable Build() + { + if (Version is not null) + yield return $"--version=\"'{Version}'\""; + + if (Yes) + yield return "-y"; + + if (LimitOutput) + yield return "--limit-output"; + + if (NoOp) + yield return "--noop"; + } +} \ No newline at end of file diff --git a/API/Choco/Cli/ChocoCliClient.cs b/API/Choco/Cli/ChocoCliClient.cs new file mode 100644 index 00000000..64d3aa9e --- /dev/null +++ b/API/Choco/Cli/ChocoCliClient.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Chocolatey.Models; +using CliWrap; +using NuGet.Versioning; + +namespace Chocolatey.Cli; + +public partial class ChocoCliClient : IChocoPackageService +{ + private static readonly Regex _rxProgress = new(@"Progress: Downloading (?[\w.\-_]+) (?\d+(\.\d+){0,3}(-[\w\d]+)?)\.\.\. (?\d{1,3})%", RegexOptions.Compiled); + + public bool NoOp { get; set; } = false; + + public async Task InstallAsync(string id, NuGetVersion? version = null, IProgress? progress = null) + { + List args = ["install", id]; + + ChocoArgumentsBuilder argBuilder = new() + { + Version = version, + Yes = true, + LimitOutput = true, + NoOp = NoOp + }; + + argBuilder.Build(args); + + var result = await CliWrap.Cli.Wrap(ChocoArgumentsBuilder.CHOCO_EXE) + .WithArguments(args) + .WithStandardOutputPipe(PipeTarget.ToDelegate(HandleStdOut)) + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(); + + return result.ExitCode is 0; + + void HandleStdOut(string line) + { + if (progress is null) + return; + + var match = _rxProgress.Match(line); + if (!match.Success) + return; + + var id = match.Groups["id"].Value; + var version = NuGetVersion.Parse(match.Groups["ver"].Value); + var percentage = int.Parse(match.Groups["prog"].Value); + progress.Report(new(id, version, percentage)); + } + } + + public async Task UninstallAsync(string id, NuGetVersion? version = null, IProgress? progress = null) + { + List args = ["uninstall", id]; + + ChocoArgumentsBuilder argBuilder = new() + { + Version = version, + Yes = true, + LimitOutput = true, + NoOp = NoOp + }; + + argBuilder.Build(args); + + var result = await CliWrap.Cli.Wrap(ChocoArgumentsBuilder.CHOCO_EXE) + .WithArguments(args) + .WithStandardOutputPipe(PipeTarget.ToDelegate(HandleStdOut)) + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(); + + return result.ExitCode is 0; + + void HandleStdOut(string line) + { + if (progress is null) + return; + + var match = _rxProgress.Match(line); + if (!match.Success) + return; + + var id = match.Groups["id"].Value; + var version = NuGetVersion.Parse(match.Groups["ver"].Value); + var percentage = int.Parse(match.Groups["prog"].Value); + progress.Report(new(id, version, percentage)); + } + } + + public async Task UpgradeAllAsync(IProgress? progress = null) + { + throw new NotImplementedException(); + } + + public async Task UpgradeAsync(string id, IProgress? progress = null) + { + throw new NotImplementedException(); + } + + public async IAsyncEnumerable<(string Id, NuGetVersion Version)> ListAsync() + { + ChocoArgumentsBuilder argBuilder = new() + { + LimitOutput = true, + }; + + argBuilder.Build(["list"]); + + List<(string, NuGetVersion)> installedPackages = []; + + var result = await CliWrap.Cli.Wrap(ChocoArgumentsBuilder.CHOCO_EXE) + .WithArguments((List)["list"]) + .WithStandardOutputPipe(PipeTarget.ToDelegate(HandleStdOut)) + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(); + + foreach (var p in installedPackages) + yield return p; + + void HandleStdOut(string line) + { + int separatorIdx = line.IndexOf('|'); + var id = line.Substring(0, separatorIdx).Trim(); + var version = NuGetVersion.Parse(line.Substring(separatorIdx + 1).Trim()); + installedPackages.Add((id, version)); + } + } +} diff --git a/API/Choco/Constants.cs b/API/Choco/Constants.cs index e7daebc1..f2c0979a 100644 --- a/API/Choco/Constants.cs +++ b/API/Choco/Constants.cs @@ -4,7 +4,8 @@ namespace Chocolatey { public static class Constants { - private const string CHOCOLATEY_API_BASE = "community.chocolatey.org/api/v2"; + private const string CHOCOLATEY_DOMAIN = "chocolatey.org"; + private const string CHOCOLATEY_API_BASE = CHOCOLATEY_DOMAIN + "/api/v2"; public const string CHOCOLATEY_API_HOST = "https://" + CHOCOLATEY_API_BASE; public static readonly XNamespace XMLNS_ATOM = "http://www.w3.org/2005/Atom"; diff --git a/API/Choco/IChocoPackageService.cs b/API/Choco/IChocoPackageService.cs new file mode 100644 index 00000000..05cbf1ca --- /dev/null +++ b/API/Choco/IChocoPackageService.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Chocolatey.Models; +using NuGet.Versioning; + +namespace Chocolatey; + +public interface IChocoPackageService +{ + Task InstallAsync(string id, NuGetVersion? version = null, IProgress? progress = null); + + Task UninstallAsync(string id, NuGetVersion? version = null, IProgress? progress = null); + + Task UpgradeAsync(string id, IProgress? progress = null); + + Task UpgradeAllAsync(IProgress? progress = null); + + IAsyncEnumerable<(string Id, NuGetVersion Version)> ListAsync(); +} \ No newline at end of file diff --git a/API/Choco/IChocoSearchService.cs b/API/Choco/IChocoSearchService.cs new file mode 100644 index 00000000..31a4193b --- /dev/null +++ b/API/Choco/IChocoSearchService.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Chocolatey.Models; +using NuGet.Versioning; + +namespace Chocolatey; + +public interface IChocoSearchService +{ + Task> SearchAsync(string query, string targetFramework = "", bool includePrerelease = false, int top = 30, int skip = 0); + + Task GetPackageAsync(string id, NuGetVersion version); + + Task GetPackagePropertyAsync(string id, NuGetVersion version, string propertyName); +} + +public static class ChocoSearchServiceExtensions +{ + public static async Task GetPackagePropertyAsync(this IChocoSearchService service, string id, + NuGetVersion version, string propertyName, Parse parse) + { + string str = await service.GetPackagePropertyAsync(id, version, propertyName); + return parse(str); + } + + public static async Task GetPackageDatePropertyAsync(this IChocoSearchService service, string id, + NuGetVersion version, string propertyName) + { + return await service.GetPackagePropertyAsync(id, version, propertyName, DateTimeOffset.Parse); + } + public static async Task GetPackageBooleanPropertyAsync(this IChocoSearchService service, string id, + NuGetVersion version, string propertyName) + { + return await service.GetPackagePropertyAsync(id, version, propertyName, bool.Parse); + } + public static async Task GetPackageInt32PropertyAsync(this IChocoSearchService service, string id, + NuGetVersion version, string propertyName) + { + return await service.GetPackagePropertyAsync(id, version, propertyName, int.Parse); + } + public static async Task GetPackageInt64PropertyAsync(this IChocoSearchService service, string id, + NuGetVersion version, string propertyName) + { + return await service.GetPackagePropertyAsync(id, version, propertyName, long.Parse); + } +} diff --git a/API/Choco/Models/PackageProgress.cs b/API/Choco/Models/PackageProgress.cs new file mode 100644 index 00000000..1f91357a --- /dev/null +++ b/API/Choco/Models/PackageProgress.cs @@ -0,0 +1,14 @@ +using NuGet.Versioning; + +namespace Chocolatey.Models; + +public class PackageProgress(string id, NuGetVersion version, int percentage) +{ + public string Id { get; } = id; + + public NuGetVersion Version { get; } = version; + + public int Percentage { get; } = percentage; + + public override string ToString() => $"{Id} {Version} {Percentage:##}%"; +} diff --git a/API/Choco/ParseDelegate.cs b/API/Choco/ParseDelegate.cs new file mode 100644 index 00000000..4fd9829e --- /dev/null +++ b/API/Choco/ParseDelegate.cs @@ -0,0 +1,6 @@ +namespace Chocolatey; + +/// +/// A method that parses a into the specified . +/// +public delegate T Parse(string str); diff --git a/FluentStore.SDK/Models/Link.cs b/FluentStore.SDK/Models/Link.cs index 847de154..d8d4c12c 100644 --- a/FluentStore.SDK/Models/Link.cs +++ b/FluentStore.SDK/Models/Link.cs @@ -1,5 +1,6 @@ using CommunityToolkit.Diagnostics; using System; +using System.Diagnostics.Contracts; namespace FluentStore.SDK.Models { @@ -38,6 +39,7 @@ public string TextContent set => _TextContent = value; } + [Pure] public override string ToString() => $"[{TextContent}]({Uri})"; /// @@ -48,6 +50,7 @@ public string TextContent /// to , or null if /// is null. /// + [Pure] public static Link Create(Uri uri, string textContent = null) => uri is null ? null : new(uri, textContent); @@ -59,6 +62,7 @@ public static Link Create(Uri uri, string textContent = null) /// to , or null if /// is null. /// + [Pure] public static Link Create(string url, string textContent = null) { if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri)) diff --git a/FluentStore.SDK/Plugins/PluginLoader.cs b/FluentStore.SDK/Plugins/PluginLoader.cs index fdb97d52..83ec3bd4 100644 --- a/FluentStore.SDK/Plugins/PluginLoader.cs +++ b/FluentStore.SDK/Plugins/PluginLoader.cs @@ -116,7 +116,13 @@ public async Task LoadPlugins(bool withAutoUpdate = false) var updateStatus = PluginInstallStatus.NoAction; if (withAutoUpdate) - updateStatus = await UpdatePlugin(plugin.PackageIdentity); + { + try + { + updateStatus = await UpdatePlugin(plugin.PackageIdentity); + } + catch { } + } if (updateStatus.IsLessThan(PluginInstallStatus.AppRestartRequired)) await LoadPlugin(plugin.PackageIdentity.Id); diff --git a/PublishTool/Properties/launchSettings.json b/PublishTool/Properties/launchSettings.json index bf4b952a..babfe8a0 100644 --- a/PublishTool/Properties/launchSettings.json +++ b/PublishTool/Properties/launchSettings.json @@ -3,7 +3,7 @@ "PublishTool": { "commandName": "Project", //"commandLineArgs": "--repo=E:\\Repos\\yoshiask\\FluentStore -f" - "commandLineArgs": "--repo=E:\\Repos\\yoshiask\\FluentStore --id=FluentStore.Sources.FluentStore -v -f --install" + "commandLineArgs": "--repo=E:\\Repos\\yoshiask\\FluentStore --id=FluentStore.Sources.Microsoft -v -f --install" } } } \ No newline at end of file diff --git a/Sources/FluentStore.Sources.Chocolatey/ChocolateyHandler.cs b/Sources/FluentStore.Sources.Chocolatey/ChocolateyHandler.cs index e3edf505..77c07a57 100644 --- a/Sources/FluentStore.Sources.Chocolatey/ChocolateyHandler.cs +++ b/Sources/FluentStore.Sources.Chocolatey/ChocolateyHandler.cs @@ -1,13 +1,14 @@ using Chocolatey; +using Chocolatey.Cli; using CommunityToolkit.Diagnostics; using FluentStore.SDK; using FluentStore.SDK.Images; using FluentStore.Services; using Flurl; using Garfoot.Utilities.FluentUrn; +using NuGet.Versioning; using System; using System.Collections.Generic; -using System.Text.RegularExpressions; using System.Threading.Tasks; namespace FluentStore.Sources.Chocolatey @@ -16,14 +17,16 @@ public partial class ChocolateyHandler : PackageHandlerBase { public const string NAMESPACE_CHOCO = "choco"; + private readonly IChocoSearchService _search; + private readonly IChocoPackageService _pkgMan; + public ChocolateyHandler(IPasswordVaultService passwordVaultService) : base(passwordVaultService) { + _search = new ChocoCommunityWebClient(); + _pkgMan = new ChocoAdminCliClient(); } - public override HashSet HandledNamespaces => new() - { - NAMESPACE_CHOCO - }; + public override HashSet HandledNamespaces => [NAMESPACE_CHOCO]; public override string DisplayName => "Chocolatey"; @@ -31,44 +34,49 @@ public override async Task GetPackage(Urn packageUrn, PackageStatus { Guard.IsEqualTo(packageUrn.NamespaceIdentifier, NAMESPACE_CHOCO, nameof(packageUrn)); - // TODO: Support getting packages without specifying a version var urnStr = packageUrn.GetContent().UnEscapedValue; + + string id; + NuGetVersion version = null; + int versionIdx = urnStr.LastIndexOf(':'); - if (versionIdx <= 0) - throw new NotSupportedException("The choco client library does not currently support fetching package info without specifying a version."); - var package = await Choco.GetPackageAsync(urnStr[..versionIdx], Version.Parse(urnStr[(versionIdx + 1)..])); + if (versionIdx > 0) + { + _ = NuGetVersion.TryParse(urnStr[(versionIdx + 1)..], out version); + id = urnStr[..versionIdx]; + } + else + { + id = urnStr; + } - return new ChocolateyPackage(this, package); + var package = await _search.GetPackageAsync(id, version); + + return new ChocolateyPackage(this, _pkgMan, package); } public override async IAsyncEnumerable SearchAsync(string query) { - var results = await Choco.SearchAsync(query); + var results = await _search.SearchAsync(query); foreach (var chocoPackage in results) - yield return new ChocolateyPackage(this, chocoPackage); + yield return new ChocolateyPackage(this, _pkgMan, chocoPackage); } - public override ImageBase GetImage() - { - return new TextImage - { - Text = "Ch", - FontFamily = "Segoe UI Variable Display" - }; - } + public override ImageBase GetImage() => new FileImage("https://github.com/chocolatey/choco-theme/blob/main/images/global-shared/logo.png?raw=true"); public override async Task GetPackageFromUrl(Url url) { - Regex rx = ChocoRx(); - Match m = rx.Match(url.ToString()); - if (!m.Success) + if (!url.Authority.Equals("community.chocolatey.org", StringComparison.InvariantCultureIgnoreCase) + || url.PathSegments.Count < 2 + || !url.PathSegments[0].Equals("packages", StringComparison.InvariantCultureIgnoreCase)) return null; - string urn = $"urn:{NAMESPACE_CHOCO}:{m.Groups["id"]}"; - var versionGroup = m.Groups["version"]; - if (versionGroup.Success) - urn += "." + versionGroup.Value; + string id = url.PathSegments[1]; + string urn = $"urn:{NAMESPACE_CHOCO}:{id}"; + + if (url.PathSegments.Count >= 3) + urn += $":{url.PathSegments[2]}"; return await GetPackage(Urn.Parse(urn)); } @@ -81,12 +89,9 @@ public override Url GetUrlFromPackage(PackageBase package) string url = $"https://community.chocolatey.org/packages/{chocoPackage.PackageId}"; if (chocoPackage.Version != null) - url += "/" + chocoPackage.Version; + url += $"/{chocoPackage.Version}"; return url; } - - [GeneratedRegex("^https?:\\/\\/community\\.chocolatey\\.org\\/packages\\/(?[^\\/\\s]+)(?:\\/(?[\\d.]+))?", RegexOptions.IgnoreCase, "en-US")] - private static partial Regex ChocoRx(); } } diff --git a/Sources/FluentStore.Sources.Chocolatey/ChocolateyPackage.cs b/Sources/FluentStore.Sources.Chocolatey/ChocolateyPackage.cs index 57190da5..e06a9b73 100644 --- a/Sources/FluentStore.Sources.Chocolatey/ChocolateyPackage.cs +++ b/Sources/FluentStore.Sources.Chocolatey/ChocolateyPackage.cs @@ -1,24 +1,31 @@ -using Chocolatey.Models; +using Chocolatey; +using Chocolatey.Models; using CommunityToolkit.Diagnostics; using CommunityToolkit.Mvvm.Messaging; using FluentStore.SDK; +using FluentStore.SDK.Attributes; using FluentStore.SDK.Helpers; using FluentStore.SDK.Images; using FluentStore.SDK.Messages; using FluentStore.SDK.Models; using Garfoot.Utilities.FluentUrn; +using Humanizer; using System; using System.Collections.Generic; using System.IO; -using System.Management.Automation; +using System.Linq; using System.Threading.Tasks; namespace FluentStore.Sources.Chocolatey { public class ChocolateyPackage : PackageBase { - public ChocolateyPackage(PackageHandlerBase packageHandler, Package pack = null) : base(packageHandler) + private readonly IChocoPackageService _pkgMan; + + public ChocolateyPackage(PackageHandlerBase packageHandler, IChocoPackageService pkgMan, Package pack = null) : base(packageHandler) { + _pkgMan = pkgMan; + if (pack != null) Update(pack); } @@ -44,32 +51,41 @@ public void Update(Package pack) Website = Link.Create(pack.ProjectUrl, "Project website"); // Set Choco package properties - Links = new[] - { - Link.Create(pack.DocsUrl, ShortTitle + " docs"), - Link.Create(pack.BugTrackerUrl, ShortTitle + " bug tracker"), - Link.Create(pack.PackageSourceUrl, ShortTitle + " source"), - Link.Create(pack.MailingListUrl, ShortTitle + " mailing list"), - }; + DownloadCountDisplay = pack.DownloadCount.ToMetric(); + Tags = pack.Tags.ToList(); + + Links.Clear(); + + if (!string.IsNullOrEmpty(pack.DocsUrl)) + Links.Add(Link.Create(pack.DocsUrl, "Docs")); + + if (!string.IsNullOrEmpty(pack.BugTrackerUrl)) + Links.Add(Link.Create(pack.BugTrackerUrl, "Bug tracker")); + + if (!string.IsNullOrEmpty(pack.PackageSourceUrl)) + Links.Add(Link.Create(pack.PackageSourceUrl, "Source")); + + if (!string.IsNullOrEmpty(pack.MailingListUrl)) + Links.Add(Link.Create(pack.MailingListUrl, "Mailing list")); + } public override async Task DownloadAsync(DirectoryInfo folder = null) { // Find the package URI await PopulatePackageUri(); - if (!Status.IsAtLeast(SDK.PackageStatus.DownloadReady)) + if (PackageUri is null) return null; // Download package await StorageHelper.BackgroundDownloadPackage(this, PackageUri, folder); - if (!Status.IsAtLeast(SDK.PackageStatus.Downloaded)) + if (!IsDownloaded) return null; // Set the proper file name - DownloadItem = ((FileInfo)DownloadItem).CopyRename($"{Model.Id}.{Model.Version}.nupkg"); + DownloadItem = ((FileInfo)DownloadItem).CopyRename($"{Model.Id}_{Model.Version}.nupkg"); WeakReferenceMessenger.Default.Send(SuccessMessage.CreateForPackageDownloadCompleted(this)); - Status = SDK.PackageStatus.Downloaded; return DownloadItem; } @@ -78,11 +94,9 @@ private async Task PopulatePackageUri() WeakReferenceMessenger.Default.Send(new PackageFetchStartedMessage(this)); try { - if (PackageUri == null) - PackageUri = new(Model.DownloadUrl); + PackageUri = new(Model.DownloadUrl); WeakReferenceMessenger.Default.Send(new SuccessMessage(null, this, SuccessType.PackageFetchCompleted)); - Status = SDK.PackageStatus.DownloadReady; } catch (Exception ex) { @@ -105,80 +119,72 @@ public override async Task CacheAppIcon() return icon ?? TextImage.CreateFromName(Model.Title); } - public override async Task CacheHeroImage() - { - return null; - } + public override async Task CacheHeroImage() => null; - public override async Task> CacheScreenshots() - { - return new List(); - } + public override async Task> CacheScreenshots() => new List(); public override async Task InstallAsync() { - // Make sure installer is downloaded - Guard.IsTrue(Status.IsAtLeast(SDK.PackageStatus.Downloaded), nameof(Status)); - bool isSuccess = false; - try { - // Extract nupkg and locate PowerShell install script - var dir = StorageHelper.ExtractArchiveToDirectory((FileInfo)DownloadItem, true); - FileInfo installer = new(Path.Combine(dir.FullName, "tools", "chocolateyInstall.ps1")); - DownloadItem = installer; - - // Run install script - // Cannot find a provider with the name '$ErrorActionPreference = 'Stop'; - using PowerShell ps = PowerShell.Create(); - var results = await ps.AddScript(File.ReadAllText(installer.FullName)) - .InvokeAsync(); - + WeakReferenceMessenger.Default.Send(new PackageInstallStartedMessage(this)); + + Progress progress = new(p => + { + WeakReferenceMessenger.Default.Send( + new PackageDownloadProgressMessage(this, p.Percentage, 100)); + }); + + bool isSuccess = await _pkgMan.InstallAsync(PackageId, progress: progress); + if (!isSuccess) + throw new Exception(); + + WeakReferenceMessenger.Default.Send(SuccessMessage.CreateForPackageInstallCompleted(this)); + return true; } catch (Exception ex) { WeakReferenceMessenger.Default.Send(new ErrorMessage(ex, this, ErrorType.PackageInstallFailed)); return false; } - - if (isSuccess) - { - WeakReferenceMessenger.Default.Send(SuccessMessage.CreateForPackageInstallCompleted(this)); - Status = SDK.PackageStatus.Installed; - } - return isSuccess; } - public override Task CanLaunchAsync() - { - return Task.FromResult(false); - } + public override Task CanDownloadAsync() => Task.FromResult(Model?.DownloadUrl is not null); - public override Task LaunchAsync() - { - return Task.CompletedTask; - } + public override Task CanLaunchAsync() => Task.FromResult(false); - private InstallerType? _PackagedInstallerType; - public InstallerType? PackagedInstallerType - { - get => _PackagedInstallerType; - set => SetProperty(ref _PackagedInstallerType, value); - } - public bool HasPackagedInstallerType => PackagedInstallerType == null; + public override Task LaunchAsync() => Task.CompletedTask; private string _PackageId; + [DisplayAdditionalInformation("Package ID", "\uE625")] public string PackageId { get => _PackageId; set => SetProperty(ref _PackageId, value); } - private Link[] _Links; - public Link[] Links + private string _DownloadCountDisplay; + [DisplayAdditionalInformation("Download count", "\uE896")] + public string DownloadCountDisplay + { + get => _DownloadCountDisplay; + set => SetProperty(ref _DownloadCountDisplay, value); + } + + private List _Links = []; + [DisplayAdditionalInformation("Links", "\uE71B")] + public List Links { get => _Links; set => SetProperty(ref _Links, value); } + + private List _Tags = []; + [DisplayAdditionalInformation(Icon = "\uE8EC")] + public List Tags + { + get => _Tags; + set => SetProperty(ref _Tags, value); + } } } diff --git a/Sources/FluentStore.Sources.Chocolatey/FluentStore.Sources.Chocolatey.csproj b/Sources/FluentStore.Sources.Chocolatey/FluentStore.Sources.Chocolatey.csproj index 67d73164..c189c4d1 100644 --- a/Sources/FluentStore.Sources.Chocolatey/FluentStore.Sources.Chocolatey.csproj +++ b/Sources/FluentStore.Sources.Chocolatey/FluentStore.Sources.Chocolatey.csproj @@ -7,7 +7,7 @@ Chocolatey - $(FluentStoreMajorMinorVersion).0.2 + $(FluentStoreMajorMinorVersion).1.0 $(Version)-alpha Provides support for the Chocolatey Community repository. Requires the Chocolatey CLI to be intalled. diff --git a/Tests/ChocolateyTests/ChocolateyTests.csproj b/Tests/ChocolateyTests/ChocolateyTests.csproj index 2b87dcab..dffa8e06 100644 --- a/Tests/ChocolateyTests/ChocolateyTests.csproj +++ b/Tests/ChocolateyTests/ChocolateyTests.csproj @@ -1,26 +1,26 @@  - - net6.0 + + net8.0 - false - + false + - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + - - - + + + diff --git a/Tests/ChocolateyTests/GetPackageProperty.cs b/Tests/ChocolateyTests/GetPackageProperty.cs index f6f27c5f..7719329f 100644 --- a/Tests/ChocolateyTests/GetPackageProperty.cs +++ b/Tests/ChocolateyTests/GetPackageProperty.cs @@ -2,26 +2,29 @@ using Chocolatey.Models; using System; using System.Threading.Tasks; +using NuGet.Versioning; using Xunit; namespace ChocolateyTests { public class GetPackageProperty { + private readonly IChocoSearchService _client = new ChocoCommunityWebClient(); + [Fact] public async Task GetPackagePropertyAsync() { string id = "git"; - Version v = new(2, 35, 1, 2); + NuGetVersion v = new(2, 35, 1, 2); string actual; - actual = await Choco.GetPackagePropertyAsync(id, v, "Title"); + actual = await _client.GetPackagePropertyAsync(id, v, "Title"); Assert.Equal("Git", actual); - actual = await Choco.GetPackagePropertyAsync(id, v, "Id"); + actual = await _client.GetPackagePropertyAsync(id, v, "Id"); Assert.Equal(id, actual); - actual = await Choco.GetPackagePropertyAsync(id, v, "GalleryDetailsUrl"); + actual = await _client.GetPackagePropertyAsync(id, v, "GalleryDetailsUrl"); Assert.Equal($"https://community.chocolatey.org/packages/{id}/{v}", actual); } @@ -29,16 +32,16 @@ public async Task GetPackagePropertyAsync() public async Task GetPackageDatePropertyAsync() { string id = "git"; - Version v = new(2, 35, 1, 2); + NuGetVersion v = new(2, 35, 1, 2); DateTimeOffset actual; - actual = await Choco.GetPackageDatePropertyAsync(id, v, "Created"); + actual = await _client.GetPackageDatePropertyAsync(id, v, "Created"); Assert.Equal(DateTimeOffset.Parse("2022-02-01T18:09:34.013"), actual); - actual = await Choco.GetPackageDatePropertyAsync(id, v, "Published"); + actual = await _client.GetPackageDatePropertyAsync(id, v, "Published"); Assert.Equal(DateTimeOffset.Parse("2022-02-01T18:09:34.013"), actual); - actual = await Choco.GetPackageDatePropertyAsync(id, v, "PackageReviewedDate"); + actual = await _client.GetPackageDatePropertyAsync(id, v, "PackageReviewedDate"); Assert.Equal(DateTimeOffset.Parse("2022-02-02T01:46:13.997"), actual); } @@ -46,16 +49,16 @@ public async Task GetPackageDatePropertyAsync() public async Task GetPackageBooleanPropertyAsync() { string id = "git"; - Version v = new(2, 35, 1, 2); + NuGetVersion v = new(2, 35, 1, 2); bool actual; - actual = await Choco.GetPackageBooleanPropertyAsync(id, v, "IsPrerelease"); + actual = await _client.GetPackageBooleanPropertyAsync(id, v, "IsPrerelease"); Assert.False(actual); - actual = await Choco.GetPackageBooleanPropertyAsync(id, v, "IsApproved"); + actual = await _client.GetPackageBooleanPropertyAsync(id, v, "IsApproved"); Assert.True(actual); - actual = await Choco.GetPackageBooleanPropertyAsync(id, v, "RequireLicenseAcceptance"); + actual = await _client.GetPackageBooleanPropertyAsync(id, v, "RequireLicenseAcceptance"); Assert.False(actual); } @@ -63,23 +66,23 @@ public async Task GetPackageBooleanPropertyAsync() public async Task GetPackageInt32PropertyAsync() { string id = "git"; - Version v = new(2, 35, 1, 2); + NuGetVersion v = new(2, 35, 1, 2); int actual; // Don't assert an exact value, download counts are subject to change - actual = await Choco.GetPackageInt32PropertyAsync(id, v, "DownloadCount"); + actual = await _client.GetPackageInt32PropertyAsync(id, v, "DownloadCount"); - actual = await Choco.GetPackageInt32PropertyAsync(id, v, "VersionDownloadCount"); + actual = await _client.GetPackageInt32PropertyAsync(id, v, "VersionDownloadCount"); } [Fact] public async Task GetPackageInt64PropertyAsync() { string id = "git"; - Version v = new(2, 35, 1, 2); + NuGetVersion v = new(2, 35, 1, 2); long actual; - actual = await Choco.GetPackageInt64PropertyAsync(id, v, "PackageSize"); + actual = await _client.GetPackageInt64PropertyAsync(id, v, "PackageSize"); Assert.Equal(8170L, actual); } @@ -87,10 +90,10 @@ public async Task GetPackageInt64PropertyAsync() public async Task GetPackagePropertyAsync_Enum() { string id = "git"; - Version v = new(2, 35, 1, 2); + NuGetVersion v = new(2, 35, 1, 2); PackageStatus actual; - actual = await Choco.GetPackagePropertyAsync(id, v, "PackageStatus", s => Enum.Parse(s)); + actual = await _client.GetPackagePropertyAsync(id, v, "PackageStatus", Enum.Parse); Assert.Equal(PackageStatus.Approved, actual); } } diff --git a/Tests/ChocolateyTests/PackageManagement.cs b/Tests/ChocolateyTests/PackageManagement.cs new file mode 100644 index 00000000..1ae9c360 --- /dev/null +++ b/Tests/ChocolateyTests/PackageManagement.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; +using Chocolatey; +using Chocolatey.Cli; +using Chocolatey.Models; +using Xunit; +using Xunit.Abstractions; + +namespace ChocolateyTests; + +public class PackageManagement(ITestOutputHelper output) +{ + private readonly IChocoPackageService _pkgMan = new ChocoCliClient + { + NoOp = true + }; + + [Theory] + [InlineData("git")] + [InlineData("dotnet-windowshosting")] + [InlineData("notepadplusplus")] + public async Task InstallLatestAsync(string id) + { + Progress progress = new(p => output.WriteLine(p.ToString())); + await _pkgMan.InstallAsync(id, progress: progress); + } + + [Theory] + [InlineData("git")] + [InlineData("dotnet-windowshosting")] + public async Task UninstallLatestAsync(string id) + { + Progress progress = new(p => output.WriteLine(p.ToString())); + await _pkgMan.UninstallAsync(id, progress: progress); + } +} \ No newline at end of file diff --git a/Tests/ChocolateyTests/Search.cs b/Tests/ChocolateyTests/Search.cs index ba131e88..83b8f912 100644 --- a/Tests/ChocolateyTests/Search.cs +++ b/Tests/ChocolateyTests/Search.cs @@ -1,57 +1,71 @@ using Chocolatey; using System.Threading.Tasks; using Xunit; +using Xunit.Abstractions; -namespace ChocolateyTests +namespace ChocolateyTests; + +public class Search(ITestOutputHelper output) { - public class Search + private readonly IChocoSearchService _client = new ChocoCommunityWebClient(); + + [Fact] + public async Task SearchAsync_Minimum1() + { + var actualResults = await _client.SearchAsync("git"); + foreach (var result in actualResults) + output.WriteLine($"{result.Title} {result.Version}"); + + Assert.Equal(30, actualResults.Count); + + var firstResult = actualResults[0]; + Assert.Equal("git", firstResult.Id); + Assert.Equal("Git", firstResult.Title); + Assert.StartsWith("https://community.chocolatey.org/api/v2/package/git/", firstResult.DownloadUrl); + } + + [Fact] + public async Task SearchAsync_MinimumWithSpace() { - [Fact] - public async Task SearchAsync_Minimum1() + var actualResults = await _client.SearchAsync("google chrome"); + foreach (var result in actualResults) + output.WriteLine($"{result.Title} {result.Version}"); + + Assert.Equal(30, actualResults.Count); + foreach (var result in actualResults) { - var actualResult = await Choco.SearchAsync("git"); - Assert.Equal(30, actualResult.Count); - - var firstResult = actualResult[0]; - Assert.Equal("git", firstResult.Id); - Assert.Equal("Git", firstResult.Title); - Assert.StartsWith("https://community.chocolatey.org/api/v2/package/git/", firstResult.DownloadUrl); + Assert.NotNull(result.Id); + Assert.NotNull(result.Title); } + } + + [Fact] + public async Task SearchAsync_Minimum2Pages() + { + string query = "python"; + int pageSize = 10; + + var results1 = await _client.SearchAsync(query, top: pageSize, skip: 0); + foreach (var result in results1) + output.WriteLine($"{result.Title} {result.Version}"); - [Fact] - public async Task SearchAsync_MinimumWithSpace() + Assert.Equal(pageSize, results1.Count); + foreach (var result in results1) { - var actualResult = await Choco.SearchAsync("google chrome"); - Assert.Equal(30, actualResult.Count); - foreach (var result in actualResult) - { - Assert.NotNull(result.Id); - Assert.NotNull(result.Title); - } + Assert.NotNull(result.Id); + Assert.NotNull(result.Title); } - [Fact] - public async Task SearchAsync_Minimum2Pages() + var results2 = await _client.SearchAsync(query, top: pageSize, skip: pageSize); + foreach (var result in results2) + output.WriteLine($"{result.Title} {result.Version}"); + + Assert.Equal(pageSize, results2.Count); + foreach (var result in results2) { - string query = "python"; - int pageSize = 10; - - var results1 = await Choco.SearchAsync(query, top: pageSize, skip: 0); - Assert.Equal(pageSize, results1.Count); - foreach (var result in results1) - { - Assert.NotNull(result.Id); - Assert.NotNull(result.Title); - } - - var results2 = await Choco.SearchAsync(query, top: pageSize, skip: pageSize); - Assert.Equal(pageSize, results2.Count); - foreach (var result in results2) - { - Assert.NotNull(result.Id); - Assert.NotNull(result.Title); - Assert.DoesNotContain(results1, r1 => r1.Id == result.Id); - } + Assert.NotNull(result.Id); + Assert.NotNull(result.Title); + Assert.DoesNotContain(results1, r1 => r1.Id == result.Id); } } }