diff --git a/README.md b/README.md index d53d95b7e..ac3bc26b1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![Release Version Badge](https://img.shields.io/github/v/release/Devolutions/UniGetUI?style=for-the-badge)](https://github.com/Devolutions/UniGetUI/releases) [![Issues Badge](https://img.shields.io/github/issues/Devolutions/UniGetUI?style=for-the-badge)](https://github.com/Devolutions/UniGetUI/issues) [![Closed Issues Badge](https://img.shields.io/github/issues-closed/Devolutions/UniGetUI?color=%238256d0&style=for-the-badge)](https://github.com/Devolutions/UniGetUI/issues?q=is%3Aissue+is%3Aclosed)
-UniGetUI is an intuitive GUI for the most common CLI package managers on Windows 10 and 11, including [WinGet](https://learn.microsoft.com/en-us/windows/package-manager/), [Scoop](https://scoop.sh/), [Chocolatey](https://chocolatey.org/), [pip](https://pypi.org/), [npm](https://www.npmjs.com/), [.NET Tool](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-tool-install), [PowerShell Gallery](https://www.powershellgallery.com/), and more. +UniGetUI is an intuitive GUI for the most common CLI package managers on Windows 10 and 11, including [WinGet](https://learn.microsoft.com/en-us/windows/package-manager/), [Scoop](https://scoop.sh/), [Chocolatey](https://chocolatey.org/), [pip](https://pypi.org/), [npm](https://www.npmjs.com/), [Bun](https://bun.sh/), [.NET Tool](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-tool-install), [PowerShell Gallery](https://www.powershellgallery.com/), and more. With UniGetUI, you can discover, install, update, and uninstall software from multiple package managers through one interface. ![image](https://github.com/user-attachments/assets/7cb447ca-ee8b-4bce-8561-b9332fb0139a) @@ -92,7 +92,7 @@ UniGetUI has a built-in autoupdater. However, it can also be updated like any ot ## Features - - Install, update, and remove software from your system easily at one click: UniGetUI combines the packages from the most used package managers for windows: Winget, Chocolatey, Scoop, Pip, Npm and .NET Tool. + - Install, update, and remove software from your system easily at one click: UniGetUI combines the packages from the most used package managers for windows: Winget, Chocolatey, Scoop, Pip, Npm, Bun and .NET Tool. - Discover new packages and filter them to easily find the package you want. - View detailed metadata about any package before installing it. Get the direct download URL or the name of the publisher, as well as the size of the download. - Easily bulk-install, update, or uninstall multiple packages at once selecting multiple packages before performing an operation diff --git a/docs/CLI.md b/docs/CLI.md index d58c05f16..b7c9211b5 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -51,7 +51,7 @@ Related environment variables: - `--source` maps to `--package-source` - Boolean options use explicit values such as `--enabled true` or `--wait false`. - `--detach` is shorthand for asynchronous package operations (`--wait false`). -- `--manager` uses stable manager ids, not GUI labels. Current ids: `apt`, `cargo`, `chocolatey`, `dnf`, `dotnet-tool`, `flatpak`, `homebrew`, `npm`, `pacman`, `pip`, `pwsh`, `scoop`, `snap`, `vcpkg`, `winget`, and `winps`. +- `--manager` uses stable manager ids, not GUI labels. Current ids: `apt`, `bun`, `cargo`, `chocolatey`, `dnf`, `dotnet-tool`, `flatpak`, `homebrew`, `npm`, `pacman`, `pip`, `pwsh`, `scoop`, `snap`, `vcpkg`, `winget`, and `winps`. ## Command reference diff --git a/src/UniGetUI.Avalonia.slnx b/src/UniGetUI.Avalonia.slnx index da1519975..9d0b8ab27 100644 --- a/src/UniGetUI.Avalonia.slnx +++ b/src/UniGetUI.Avalonia.slnx @@ -135,6 +135,12 @@ + + + + + + diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/PackageManagerPage.axaml.cs b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/PackageManagerPage.axaml.cs index bbb2ced2f..8840c5b26 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/PackageManagerPage.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/PackageManagerPage.axaml.cs @@ -459,6 +459,20 @@ private void BuildExtraControls(CheckboxCard_Dict disableNotifsCard) }); break; + case "Bun": + disableNotifsCard.CornerRadius = new CornerRadius(8, 8, 0, 0); + disableNotifsCard.BorderThickness = new Thickness(1, 1, 1, 0); + ExtraControls.Children.Add(disableNotifsCard); + + ExtraControls.Children.Add(new CheckboxCard + { + CornerRadius = new CornerRadius(0, 0, 8, 8), + BorderThickness = new Thickness(1, 0, 1, 1), + SettingName = CoreSettings.K.BunPreferLatestVersions, + Text = CoreTools.Translate("Prefer latest versions (may include breaking changes) instead of recommended safe updates"), + }); + break; + case "vcpkg": disableNotifsCard.CornerRadius = new CornerRadius(8, 8, 0, 0); disableNotifsCard.BorderThickness = new Thickness(1, 1, 1, 0); diff --git a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs index c5839853c..601c1865f 100644 --- a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs +++ b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs @@ -91,6 +91,7 @@ public enum K WinGetComApiPolicy, DisableClassicMode, DisableInstallerHostChangeWarning, + BunPreferLatestVersions, Test1, Test2, @@ -193,6 +194,7 @@ public static string ResolveKey(K key) K.WinGetComApiPolicy => "WinGetComApiPolicy", K.DisableClassicMode => "DisableClassicMode", K.DisableInstallerHostChangeWarning => "DisableInstallerHostChangeWarning", + K.BunPreferLatestVersions => "BunPreferLatestVersions", K.Test1 => "TestSetting1", K.Test2 => "TestSetting2", diff --git a/src/UniGetUI.Interface.Enums/Enums.cs b/src/UniGetUI.Interface.Enums/Enums.cs index 7d813080c..09e25a4e9 100644 --- a/src/UniGetUI.Interface.Enums/Enums.cs +++ b/src/UniGetUI.Interface.Enums/Enums.cs @@ -90,6 +90,7 @@ public enum IconType Pacman = '\uE946', Snap = '\uE947', Flatpak = '\uE948', + Bun = '\uE949', } public class NotificationArguments diff --git a/src/UniGetUI.PackageEngine.Managers.Bun/Bun.cs b/src/UniGetUI.PackageEngine.Managers.Bun/Bun.cs new file mode 100644 index 000000000..8e5f90f7a --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Bun/Bun.cs @@ -0,0 +1,378 @@ +using System.Diagnostics; +using System.Text.Json.Nodes; +using UniGetUI.Core.Data; +using UniGetUI.Core.Logging; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.Tools; +using UniGetUI.Interface.Enums; +using UniGetUI.PackageEngine.Classes.Manager; +using UniGetUI.PackageEngine.Classes.Manager.ManagerHelpers; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.ManagerClasses.Classes; +using UniGetUI.PackageEngine.ManagerClasses.Manager; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.Structs; + +namespace UniGetUI.PackageEngine.Managers.BunManager +{ + public class Bun : PackageManager + { + public Bun() + { + Capabilities = new ManagerCapabilities + { + CanRunAsAdmin = true, + SupportsCustomVersions = true, + CanDownloadInstaller = true, + SupportsCustomScopes = false, + CanListDependencies = true, + SupportsPreRelease = true, + SupportsProxy = ProxySupport.No, + SupportsProxyAuth = false + }; + + Properties = new ManagerProperties + { + Id = "bun", + Name = "Bun", + Description = CoreTools.Translate("Fast JavaScript runtime, bundler, and package manager"), + IconId = IconType.Bun, + ColorIconId = "bun_color", + ExecutableFriendlyName = "bun", + InstallVerb = "add", + UninstallVerb = "remove", + UpdateVerb = "add", + DefaultSource = new ManagerSource(this, "Bun", new Uri("https://www.npmjs.com/")), + KnownSources = [new ManagerSource(this, "Bun", new Uri("https://www.npmjs.com/"))], + }; + + DetailsHelper = new BunPkgDetailsHelper(this); + OperationHelper = new BunPkgOperationHelper(this); + } + + protected override IReadOnlyList FindPackages_UnSafe(string query) + { + using Process p = new() + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = Status.ExecutableCallArgs + " search \"" + query + "\" --json", + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + StandardOutputEncoding = System.Text.Encoding.UTF8 + } + }; + + IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.FindPackages, p); + p.Start(); + + string strContents = p.StandardOutput.ReadToEnd(); + logger.AddToStdOut(strContents); + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + + return ParseSearchOutput(strContents, DefaultSource, this); + } + + protected override IReadOnlyList GetAvailableUpdates_UnSafe() + { + // bun outdated checks the project in the current directory, not a --global flag. + // Until Bun supports per-project working directories in UniGetUI, expose Bun as + // a global-only manager and query the dedicated global package.json. + string globalDir = GetGlobalPackagesDirectory( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); + + if (!HasGlobalPackageManifest(globalDir)) + { + Logger.Info($"Bun: Skipping global update detection because {globalDir} is missing package.json"); + return []; + } + + using Process p = new() + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = Status.ExecutableCallArgs + " outdated", + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = globalDir, + StandardOutputEncoding = System.Text.Encoding.UTF8 + } + }; + + IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.ListUpdates, p); + p.Start(); + + // Read both streams concurrently to avoid deadlock when the process writes + // to both. Bun may write the table to stderr when stdout is not a TTY. + var stdoutTask = p.StandardOutput.ReadToEndAsync(); + var stderrTask = p.StandardError.ReadToEndAsync(); + Task.WaitAll(stdoutTask, stderrTask); + + string strOut = stdoutTask.Result; + string strErr = stderrTask.Result; + logger.AddToStdOut(strOut); + logger.AddToStdErr(strErr); + + // Read the preference first + bool preferLatest = Settings.Get(Settings.K.BunPreferLatestVersions); + + // Parse stdout first; fall back to stderr if stdout has no table rows. + string tableSrc = ParseBunOutdatedTable(strOut, preferLatest).Any() ? strOut : strErr; + var result = ParseAvailableUpdates(tableSrc, DefaultSource, this, preferLatest); + + Logger.Info($"Bun: Found {result.Count} packages with available updates (preferLatest={preferLatest})"); + foreach (var pkg in result) + { + Logger.Info($" - {pkg.Id}: {pkg.VersionString} → {pkg.NewVersionString}"); + } + + p.WaitForExit(); + logger.Close(p.ExitCode); + return result; + } + + protected override IReadOnlyList GetInstalledPackages_UnSafe() + { + List Packages = []; + + using Process p = new() + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = Status.ExecutableCallArgs + " pm ls --global", + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + StandardOutputEncoding = System.Text.Encoding.UTF8 + } + }; + + IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.ListInstalledPackages, p); + p.Start(); + + // Read both streams concurrently to avoid deadlock when the process writes + // to both. Bun may write to stderr when stdout is not a TTY. + var stdoutTask = p.StandardOutput.ReadToEndAsync(); + var stderrTask = p.StandardError.ReadToEndAsync(); + Task.WaitAll(stdoutTask, stderrTask); + + string strContents = stdoutTask.Result; + logger.AddToStdOut(strContents); + + Packages.AddRange(ParseInstalledPackages(strContents, DefaultSource, this, new OverridenInstallationOptions(PackageScope.Global))); + + logger.AddToStdErr(stderrTask.Result); + p.WaitForExit(); + logger.Close(p.ExitCode); + + return Packages; + } + + public override IReadOnlyList FindCandidateExecutableFiles() + => CoreTools.WhichMultiple(OperatingSystem.IsWindows() ? "bun.exe" : "bun"); + + internal static string GetGlobalPackagesDirectory(string userProfile) + => Path.Combine(userProfile, ".bun", "install", "global"); + + internal static bool HasGlobalPackageManifest(string globalDir) + => Directory.Exists(globalDir) && File.Exists(Path.Combine(globalDir, "package.json")); + + protected override void _loadManagerExecutableFile(out bool found, out string path, out string callArguments) + { + var (_found, _executablePath) = GetExecutableFile(); + found = _found; + path = _executablePath; + callArguments = ""; + } + + protected override void _loadManagerVersion(out string version) + { + Process process = new() + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = Status.ExecutableCallArgs + "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + CreateNoWindow = true, + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + StandardOutputEncoding = System.Text.Encoding.UTF8 + } + }; + process.Start(); + version = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(); + } + + /// + /// Parses JSON search results from 'bun search <query> --json'. + /// Each result object contains 'name' and 'version' fields. + /// + internal static IReadOnlyList ParseSearchOutput( + string output, + IManagerSource source, + IPackageManager manager) + { + List packages = []; + + if (!output.Any()) return packages; + + try + { + JsonArray? results = JsonNode.Parse(output) as JsonArray; + foreach (JsonNode? entry in results ?? []) + { + string? id = entry?["name"]?.ToString(); + string? version = entry?["version"]?.ToString(); + if (id is not null && version is not null) + { + packages.Add(new Package(CoreTools.FormatAsName(id), id, version, source, manager)); + } + } + } + catch (Exception e) + { + Logger.Warn($"Failed to parse Bun search results: {e.Message}"); + } + + return packages; + } + + /// + /// Parses the outdated packages table from 'bun outdated'. + /// Creates packages with current and available versions and sets scope to Global. + /// + internal static IReadOnlyList ParseAvailableUpdates( + string output, + IManagerSource source, + IPackageManager manager, + bool preferLatest = false) + { + List packages = []; + + foreach (var (packageId, version, newVersion) in ParseBunOutdatedTable(output, preferLatest)) + { + packages.Add(new Package(CoreTools.FormatAsName(packageId), packageId, version, newVersion, + source, manager, new(PackageScope.Global))); + } + + return packages; + } + + /// + /// Parses the installed packages tree from 'bun pm ls --global'. + /// Each package entry is formatted as: [├/└]── [@scope/]name@version + /// + internal static IReadOnlyList ParseInstalledPackages( + string output, + IManagerSource source, + IPackageManager manager, + OverridenInstallationOptions options) + { + List packages = []; + + // bun pm ls --global outputs a tree: + // /home/user/.bun/install/global node_modules (3) + // ├── @devcontainers/cli@0.81.1 + // └── typescript@5.7.3 + foreach (string line in output.Split('\n')) + { + if (!line.Contains("──")) continue; + string entry = line[(line.IndexOf("──") + 2)..].Trim(); + + // Use LastIndexOf to handle scoped packages: @scope/name@version + int atIdx = entry.LastIndexOf('@'); + if (atIdx <= 0) continue; + + string packageName = entry[..atIdx]; + string version = entry[(atIdx + 1)..]; + + if (string.IsNullOrWhiteSpace(packageName) || string.IsNullOrWhiteSpace(version)) continue; + + packages.Add(new Package(CoreTools.FormatAsName(packageName), packageName, version, + source, manager, options)); + } + + return packages; + } + + /// + /// Parses the outdated packages table from 'bun outdated'. + /// Supports both ASCII pipe format (|) and Unicode box-drawing format (│). + /// Each yielded tuple contains (packageId, currentVersion, recommendedUpdateVersion). + /// Columns: [Package | Current | Update | Latest] + /// If preferLatest is true, uses "Latest" (parts[4], may have breaking changes). + /// If preferLatest is false (default), uses "Update" (parts[3], safe semantic version). + /// + // TODO: Replace table parsing with JSON deserialization when bun outdated adds --json flag. + // Track: https://github.com/oven-sh/bun/issues — once --json is available, this entire + // method should be swapped for a simple JsonNode.Parse() call. + internal static IEnumerable<(string Id, string Version, string NewVersion)> ParseBunOutdatedTable( + string output, + bool preferLatest = false) + { + int columnIndex = preferLatest ? 4 : 3; // 4 = Latest, 3 = Update + string columnName = preferLatest ? "Latest" : "Update"; + + foreach (string line in output.Split('\n')) + { + string trimmed = line.TrimStart(); + // Skip lines that don't contain package data (headers, separators, etc.) + if (!trimmed.StartsWith('│') && !trimmed.StartsWith('|')) + { + continue; + + } + // Split by either Unicode box-drawing or ASCII pipe characters + string[] parts = line.Split(new[] { '│', '|' }, StringSplitOptions.None); + if (parts.Length < columnIndex + 1) + { + continue; + } + + string id = parts[1].Trim(); + string version = parts[2].Trim(); + string recommendedUpdate = parts[columnIndex].Trim(); + + // Skip header row, empty rows, and border lines (which contain only dashes or box-drawing chars) + if (id is "Package" || string.IsNullOrWhiteSpace(id) + || string.IsNullOrWhiteSpace(version) || string.IsNullOrWhiteSpace(recommendedUpdate) + || id.All(c => c == '-' || c == '─' || c == '┬' || c == '┼' || c == '┴' || c == '├' || c == '┤' || c == '┌' || c == '└' || c == '┘' || c == '┐') + || version.All(c => c == '-' || c == '─' || c == '┬' || c == '┼' || c == '┴' || c == '├' || c == '┤' || c == '┌' || c == '└' || c == '┘' || c == '┐')) + { + continue; + } + + // Only include packages that have a different update version + if (version != recommendedUpdate) + { + Logger.Debug($"Bun: Found update for {id}: {version} → {recommendedUpdate} (using {columnName} column)"); + yield return (id, version, recommendedUpdate); + } + else + { + Logger.Debug($"Bun: Skipping {id} (no update available: {version} == {recommendedUpdate})"); + } + } + } + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.Bun/Helpers/BunPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Bun/Helpers/BunPkgDetailsHelper.cs new file mode 100644 index 000000000..59de3d4dd --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Bun/Helpers/BunPkgDetailsHelper.cs @@ -0,0 +1,190 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text.Json.Nodes; +using UniGetUI.Core.IconEngine; +using UniGetUI.Core.Logging; +using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.ManagerClasses.Classes; + +namespace UniGetUI.PackageEngine.Managers.BunManager +{ + internal sealed class BunPkgDetailsHelper : BasePkgDetailsHelper + { + public BunPkgDetailsHelper(Bun manager) : base(manager) { } + + protected override void GetDetails_UnSafe(IPackageDetails details) + { + try + { + details.InstallerType = "Tarball"; + details.ManifestUrl = new Uri($"https://www.npmjs.com/package/{details.Package.Id}"); + details.ReleaseNotesUrl = new Uri($"https://www.npmjs.com/package/{details.Package.Id}?activeTab=versions"); + + using Process p = new(); + p.StartInfo = new ProcessStartInfo + { + FileName = Manager.Status.ExecutablePath, + Arguments = Manager.Status.ExecutableCallArgs + " info " + details.Package.Id + " --json --global", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + CreateNoWindow = true, + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + StandardOutputEncoding = System.Text.Encoding.UTF8 + }; + + IProcessTaskLogger logger = Manager.TaskLogger.CreateNew(LoggableTaskType.LoadPackageDetails, p); + p.Start(); + + var stdoutTask = p.StandardOutput.ReadToEndAsync(); + var stderrTask = p.StandardError.ReadToEndAsync(); + Task.WaitAll(stdoutTask, stderrTask); + + string strContents = stdoutTask.Result; + logger.AddToStdOut(strContents); + JsonObject? contents = JsonNode.Parse(strContents) as JsonObject; + + details.License = contents?["license"]?.ToString(); + details.Description = contents?["description"]?.ToString(); + + if (Uri.TryCreate(contents?["homepage"]?.ToString() ?? "", UriKind.RelativeOrAbsolute, out var homepageUrl)) + details.HomepageUrl = homepageUrl; + + details.Publisher = (contents?["maintainers"] as JsonArray)?[0]?.ToString(); + // Handle author which can be string or object with "name" property + var authorNode = contents?["author"]; + details.Author = authorNode is JsonObject authorObj ? authorObj["name"]?.ToString() : authorNode?.ToString(); + details.UpdateDate = contents?["time"]?[contents?["dist-tags"]?["latest"]?.ToString() ?? details.Package.VersionString]?.ToString(); + + if (Uri.TryCreate(contents?["dist"]?["tarball"]?.ToString() ?? "", UriKind.RelativeOrAbsolute, out var installerUrl)) + details.InstallerUrl = installerUrl; + + if (int.TryParse(contents?["dist"]?["unpackedSize"]?.ToString() ?? "", NumberStyles.Any, CultureInfo.InvariantCulture, out int installerSize)) + details.InstallerSize = installerSize; + + details.InstallerHash = contents?["dist"]?["integrity"]?.ToString(); + + details.Dependencies.Clear(); + HashSet addedDeps = new(); + foreach (var rawDep in (contents?["dependencies"]?.AsObject() ?? [])) + { + if (addedDeps.Contains(rawDep.Key)) continue; + addedDeps.Add(rawDep.Key); + + details.Dependencies.Add(new() + { + Name = rawDep.Key, + Version = rawDep.Value?.GetValue() ?? "", + Mandatory = true, + }); + } + + foreach (var rawDep in (contents?["devDependencies"]?.AsObject() ?? [])) + { + if (addedDeps.Contains(rawDep.Key)) continue; + addedDeps.Add(rawDep.Key); + + details.Dependencies.Add(new() + { + Name = rawDep.Key, + Version = rawDep.Value?.GetValue() ?? "", + Mandatory = false, + }); + } + + foreach (var rawDep in (contents?["peerDependencies"]?.AsObject() ?? [])) + { + if (addedDeps.Contains(rawDep.Key)) continue; + addedDeps.Add(rawDep.Key); + + details.Dependencies.Add(new() + { + Name = rawDep.Key, + Version = rawDep.Value?.GetValue() ?? "", + Mandatory = false, + }); + } + + logger.AddToStdErr(stderrTask.Result); + p.WaitForExit(); + logger.Close(p.ExitCode); + } + catch (Exception e) + { + Logger.Error(e); + } + + return; + } + + protected override CacheableIcon? GetIcon_UnSafe(IPackage package) + { + return null; + } + + protected override IReadOnlyList GetScreenshots_UnSafe(IPackage package) + { + return []; + } + + protected override string? GetInstallLocation_UnSafe(IPackage package) + { + return GetInstallLocation( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + package.OverridenOptions.Scope, + package.Id); + } + + internal static string GetInstallLocation(string userProfile, string? scope, string packageId) + { + if (scope is PackageScope.Local) + return Path.Join(userProfile, "node_modules", packageId); + + return Path.Join(Bun.GetGlobalPackagesDirectory(userProfile), "node_modules", packageId); + } + + protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage package) + { + using Process p = new() + { + StartInfo = new ProcessStartInfo + { + FileName = Manager.Status.ExecutablePath, + Arguments = + Manager.Status.ExecutableCallArgs + " info " + package.Id + " --json --global", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + CreateNoWindow = true, + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + StandardOutputEncoding = System.Text.Encoding.UTF8 + } + }; + + IProcessTaskLogger logger = Manager.TaskLogger.CreateNew(LoggableTaskType.LoadPackageVersions, p); + p.Start(); + + string strContents = p.StandardOutput.ReadToEnd(); + logger.AddToStdOut(strContents); + JsonObject? contents = JsonNode.Parse(strContents) as JsonObject; + JsonArray? rawVersions = contents?["versions"] as JsonArray; + + List versions = []; + foreach (JsonNode? raw_ver in rawVersions ?? []) + { + if (raw_ver is not null) + versions.Add(raw_ver.ToString()); + } + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + + return versions; + } + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.Bun/Helpers/BunPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.Bun/Helpers/BunPkgOperationHelper.cs new file mode 100644 index 000000000..82ec061ab --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Bun/Helpers/BunPkgOperationHelper.cs @@ -0,0 +1,57 @@ +using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Serializable; + +namespace UniGetUI.PackageEngine.Managers.BunManager; + +internal sealed class BunPkgOperationHelper : BasePkgOperationHelper +{ + public BunPkgOperationHelper(Bun manager) : base(manager) { } + + protected override IReadOnlyList _getOperationParameters(IPackage package, + InstallOptions options, OperationType operation) + { + // Bun is called directly (not through PowerShell), so we do NOT use quotes. + // Quotes would be passed literally to bun.exe, which doesn't understand them. + // Unlike Npm which goes through PowerShell on Windows, Bun always executes directly. + + List parameters = operation switch + { + OperationType.Install => + [ + Manager.Properties.InstallVerb, + $"{package.Id}@{(options.Version == string.Empty ? package.VersionString : options.Version)}", + ], + OperationType.Update => + [ + Manager.Properties.UpdateVerb, + $"{package.Id}@{package.NewVersionString}", + ], + OperationType.Uninstall => [Manager.Properties.UninstallVerb, package.Id], + _ => throw new InvalidDataException("Invalid package operation") + }; + + string effectiveScope = package.OverridenOptions.Scope ?? options.InstallationScope; + if (effectiveScope is not PackageScope.Local) + parameters.Add("--global"); + + parameters.AddRange(operation switch + { + OperationType.Update => options.CustomParameters_Update, + OperationType.Uninstall => options.CustomParameters_Uninstall, + _ => options.CustomParameters_Install, + }); + + return parameters; + } + + protected override OperationVeredict _getOperationResult( + IPackage package, + OperationType operation, + IReadOnlyList processOutput, + int returnCode) + { + return returnCode == 0 ? OperationVeredict.Success : OperationVeredict.Failure; + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.Bun/InternalsVisibleTo.cs b/src/UniGetUI.PackageEngine.Managers.Bun/InternalsVisibleTo.cs new file mode 100644 index 000000000..eeb63dad1 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Bun/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.PackageEngine.Tests")] diff --git a/src/UniGetUI.PackageEngine.Managers.Bun/UniGetUI.PackageEngine.Managers.Bun.csproj b/src/UniGetUI.PackageEngine.Managers.Bun/UniGetUI.PackageEngine.Managers.Bun.csproj new file mode 100644 index 000000000..74aff9094 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Bun/UniGetUI.PackageEngine.Managers.Bun.csproj @@ -0,0 +1,29 @@ + + + $(SharedTargetFrameworks) + UniGetUI.PackageEngine.Managers.Bun + UniGetUI.PackageEngine.Managers.BunManager + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs b/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs index 16af27c48..b114fc305 100644 --- a/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs +++ b/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using UniGetUI.Core.Logging; using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Managers.BunManager; using UniGetUI.PackageEngine.Managers.CargoManager; using UniGetUI.PackageEngine.Managers.DotNetManager; using UniGetUI.PackageEngine.Managers.NpmManager; @@ -37,6 +38,7 @@ public static class PEInterface public static readonly Chocolatey Chocolatey = new(); #endif public static readonly Npm Npm = new(); + public static readonly Bun Bun = new(); public static readonly Pip Pip = new(); public static readonly DotNet DotNet = new(); public static readonly PowerShell7 PowerShell7 = new(); @@ -58,7 +60,7 @@ public static class PEInterface private static IPackageManager[] CreateManagers() { - List managers = [Npm, Pip, Cargo, Vcpkg, DotNet, PowerShell7]; + List managers = [Npm, Bun, Pip, Cargo, Vcpkg, DotNet, PowerShell7]; #if WINDOWS managers.InsertRange(0, [WinGet, Scoop, Chocolatey]); managers.Add(PowerShell); diff --git a/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj b/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj index 9a8df247c..ccfcb2527 100644 --- a/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj +++ b/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj @@ -11,6 +11,7 @@ + diff --git a/src/UniGetUI.PackageEngine.Tests/BunManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/BunManagerTests.cs new file mode 100644 index 000000000..660228c1f --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/BunManagerTests.cs @@ -0,0 +1,809 @@ +using UniGetUI.Interface.Enums; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.ManagerClasses.Manager; +using UniGetUI.PackageEngine.Managers.BunManager; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.Serializable; +using UniGetUI.PackageEngine.Structs; +using UniGetUI.PackageEngine.Tests.Infrastructure.Assertions; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; +using UniGetUI.PackageEngine.Tests.Infrastructure.Helpers; + +namespace UniGetUI.PackageEngine.Tests; + +/// +/// Unit and integration tests for the Bun package manager. +/// Tests cover search, package listing, updates detection, and package operations. +/// +public sealed class BunManagerTests +{ + private sealed class TestableBun : Bun + { + public IReadOnlyList GetAvailableUpdatesUnsafe() => base.GetAvailableUpdates_UnSafe(); + } + + /// + /// Tests parsing of JSON search results from 'bun search <query> --json'. + /// Verifies that multiple packages with different names and versions are correctly parsed. + /// + [Fact] + public void ParseSearchOutputParsesJsonArray() + { + var manager = new Bun(); + + var searchOutput = PackageEngineFixtureFiles.ReadAllText(Path.Combine("Bun", "search-results.json")); + var packages = Bun.ParseSearchOutput(searchOutput, manager.DefaultSource, manager); + + var packageList = packages.ToArray(); + Assert.Equal(3, packageList.Length); + + // Verify first package + PackageAssert.BelongsTo(packageList[0], manager, manager.DefaultSource); + Assert.Equal("typescript", packageList[0].Id); + Assert.Equal("5.3.3", packageList[0].VersionString); + + // Verify second package + PackageAssert.BelongsTo(packageList[1], manager, manager.DefaultSource); + Assert.Equal("lodash", packageList[1].Id); + Assert.Equal("4.17.21", packageList[1].VersionString); + + // Verify scoped package + PackageAssert.BelongsTo(packageList[2], manager, manager.DefaultSource); + Assert.Equal("@types/node", packageList[2].Id); + Assert.Equal("20.10.6", packageList[2].VersionString); + } + + /// + /// Tests parsing of the outdated packages table output from 'bun outdated'. + /// Verifies that the Unicode box-drawing table format is correctly parsed. + /// + [Fact] + public void ParseBunOutdatedTableParsesUnicodeTable() + { + var outdatedOutput = PackageEngineFixtureFiles.ReadAllText(Path.Combine("Bun", "outdated-table.txt")); + + var results = Bun.ParseBunOutdatedTable(outdatedOutput).ToList(); + + Assert.Equal(3, results.Count); + + // Verify first package (typescript) + Assert.Equal("typescript", results[0].Id); + Assert.Equal("5.2.0", results[0].Version); + Assert.Equal("5.3.0", results[0].NewVersion); + + // Verify scoped package (@types/node) + Assert.Equal("@types/node", results[1].Id); + Assert.Equal("20.8.0", results[1].Version); + Assert.Equal("20.9.0", results[1].NewVersion); + + // Verify third package (vite) + Assert.Equal("vite", results[2].Id); + Assert.Equal("4.5.0", results[2].Version); + Assert.Equal("4.5.1", results[2].NewVersion); + } + + /// + /// Tests that ParseBunOutdatedTable skips the header row and empty lines. + /// + [Fact] + public void ParseBunOutdatedTableSkipsHeaderAndEmptyLines() + { + var output = """ + |-------------------------------------------------| + | Package | Current | Update | Latest | + |--------------------------------------------------| + | typescript | 5.2.0 | 5.3.0 | 5.4.0 | + |-------------------------------------------------| + """; + + var results = Bun.ParseBunOutdatedTable(output).ToList(); + + Assert.Single(results); + Assert.Equal("typescript", results[0].Id); + } + + /// + /// Tests that ParseBunOutdatedTable returns empty list for invalid input. + /// + [Fact] + public void ParseBunOutdatedTableReturnsEmptyForInvalidInput() + { + var output = "Invalid output format\nNo table here"; + + var results = Bun.ParseBunOutdatedTable(output).ToList(); + + Assert.Empty(results); + } + + /// + /// Tests that ParseBunOutdatedTable skips border lines (all dashes or box-drawing chars). + /// Verifies that lines like |---| or ├─┤ are not parsed as packages. + /// + [Fact] + public void ParseBunOutdatedTableSkipsBorderLines() + { + var output = """ + bun outdated v1.3.9 + |-------------------------------------------------| + | Package | Current | Update | Latest | + |--------------------------------------------------| + | typescript | 5.2.0 | 5.3.0 | 5.4.0 | + |-------------------------------------------------| + | lodash | 4.17.0 | 4.17.21 | 4.17.21 | + |-------------------------------------------------| + """; + + var results = Bun.ParseBunOutdatedTable(output).ToList(); + + // Should find both typescript and lodash with updates, and skip all border lines + Assert.Equal(2, results.Count); + Assert.Equal("typescript", results[0].Id); + Assert.Equal("lodash", results[1].Id); + } + + /// + /// Tests that ParseBunOutdatedTable skips Unicode box-drawing borders. + /// + [Fact] + public void ParseBunOutdatedTableSkipsUnicodeBorders() + { + var output = """ + bun outdated v1.3.10 + ┌────────────────┬─────────┬────────┬────────┐ + │ Package │ Current │ Update │ Latest │ + ├────────────────┼─────────┼────────┼────────┤ + │ typescript │ 5.2.0 │ 5.3.0 │ 5.4.0 │ + ├────────────────┼─────────┼────────┼────────┤ + │ lodash │ 4.17.0 │ 4.17.21│ 4.17.21│ + └────────────────┴─────────┴────────┴────────┘ + """; + + var results = Bun.ParseBunOutdatedTable(output).ToList(); + + // Should find both packages with updates, and skip Unicode border lines + Assert.Equal(2, results.Count); + Assert.Equal("typescript", results[0].Id); + Assert.Equal("lodash", results[1].Id); + } + + /// + /// Tests that ParseBunOutdatedTable uses "Update" column by default (preferLatest=false). + /// This provides safe, semantic-versioning compatible updates. + /// + [Fact] + public void ParseBunOutdatedTableUsesUpdateColumnByDefault() + { + var output = """ + |-------------------------------------------------| + | Package | Current | Update | Latest | + |--------------------------------------------------| + | typescript | 5.2.0 | 5.3.0 | 6.0.0 | + | lodash | 4.17.0 | 4.17.21 | 4.17.21 | + |-------------------------------------------------| + """; + + var results = Bun.ParseBunOutdatedTable(output, preferLatest: false).ToList(); + + Assert.Equal(2, results.Count); + // typescript: uses Update column (5.3.0), not Latest (6.0.0) + Assert.Equal("typescript", results[0].Id); + Assert.Equal("5.3.0", results[0].NewVersion); + // lodash: Update == Latest, so only one entry + Assert.Equal("lodash", results[1].Id); + Assert.Equal("4.17.21", results[1].NewVersion); + } + + /// + /// Tests that ParseBunOutdatedTable uses "Latest" column when preferLatest=true. + /// This allows users to upgrade to the absolute latest, even with breaking changes. + /// + [Fact] + public void ParseBunOutdatedTableUsesLatestColumnWhenPreferLatest() + { + var output = """ + |-------------------------------------------------| + | Package | Current | Update | Latest | + |--------------------------------------------------| + | typescript | 5.2.0 | 5.3.0 | 6.0.0 | + | lodash | 4.17.0 | 4.17.21 | 4.17.21 | + |-------------------------------------------------| + """; + + var results = Bun.ParseBunOutdatedTable(output, preferLatest: true).ToList(); + + Assert.Equal(2, results.Count); + // typescript: uses Latest column (6.0.0), not Update (5.3.0) + Assert.Equal("typescript", results[0].Id); + Assert.Equal("6.0.0", results[0].NewVersion); + // lodash: Update == Latest, so included + Assert.Equal("lodash", results[1].Id); + Assert.Equal("4.17.21", results[1].NewVersion); + } + + /// + /// Tests parsing of the tree output from 'bun pm ls --global'. + /// Verifies that globally installed packages are correctly extracted from the tree structure. + /// + [Fact] + public void ParseInstalledPackagesOutputParsesTreeFormat() + { + var manager = new Bun(); + var installedOutput = PackageEngineFixtureFiles.ReadAllText(Path.Combine("Bun", "installed.txt")); + + var packages = Bun.ParseInstalledPackages(installedOutput, manager.DefaultSource, manager, + new OverridenInstallationOptions(PackageScope.Global)); + + var packageList = packages.ToArray(); + Assert.Equal(5, packageList.Length); + + // Verify first package + Assert.Equal("typescript", packageList[0].Id); + Assert.Equal("5.7.3", packageList[0].VersionString); + Assert.Equal(PackageScope.Global, packageList[0].OverridenOptions.Scope); + + // Verify scoped package + Assert.Equal("@devcontainers/cli", packageList[1].Id); + Assert.Equal("0.81.1", packageList[1].VersionString); + + // Verify last package + Assert.Equal("bunx", packageList[4].Id); + Assert.Equal("1.0.24", packageList[4].VersionString); + } + + /// + /// Tests that scoped packages (with @ prefix) are correctly handled. + /// + [Fact] + public void ParseInstalledPackagesHandlesScopedPackagesCorrectly() + { + var manager = new Bun(); + var output = """ + /home/user/.bun/install/global node_modules (2) + ├── @scope/package@1.0.0 + └── @another-scope/tool@2.5.3 + """; + + var packages = Bun.ParseInstalledPackages(output, manager.DefaultSource, manager, + new OverridenInstallationOptions(PackageScope.Global)); + + var packageList = packages.ToArray(); + Assert.Equal(2, packageList.Length); + Assert.Equal("@scope/package", packageList[0].Id); + Assert.Equal("1.0.0", packageList[0].VersionString); + Assert.Equal("@another-scope/tool", packageList[1].Id); + Assert.Equal("2.5.3", packageList[1].VersionString); + } + + /// + /// Tests that invalid or malformed tree lines are skipped during parsing. + /// + [Fact] + public void ParseInstalledPackagesSkipsInvalidLines() + { + var manager = new Bun(); + var output = """ + /home/user/.bun/install/global node_modules + Invalid line without marker + ├── valid-package@1.0.0 + └── another@2.0.0 + └── @invalid-version + """; + + var packages = Bun.ParseInstalledPackages(output, manager.DefaultSource, manager, + new OverridenInstallationOptions(PackageScope.Global)); + + // Should only parse the two valid packages + Assert.Equal(2, packages.Count); + } + + /// + /// Tests that search returns empty list for empty JSON array input. + /// + [Fact] + public void ParseSearchOutputReturnsEmptyForEmptyArray() + { + var manager = new Bun(); + var packages = Bun.ParseSearchOutput("[]", manager.DefaultSource, manager); + + Assert.Empty(packages); + } + + /// + /// Tests OperationHelper builds correct install parameters for basic install. + /// + [Fact] + public void OperationHelperBuildsInstallParameters() + { + var manager = new Bun(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("typescript") + .WithVersion("5.3.3") + .Build(); + + var parameters = manager.OperationHelper.GetParameters(package, new InstallOptions(), OperationType.Install); + + Assert.Contains("add", parameters); + Assert.Contains("typescript@5.3.3", parameters); + Assert.Contains("--global", parameters); + } + + /// + /// Tests OperationHelper correctly adds --global flag for global scope. + /// + [Fact] + public void OperationHelperAddsGlobalFlagForGlobalScope() + { + var manager = new Bun(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("typescript") + .WithVersion("5.3.3") + .Build(); + + var options = new InstallOptions { InstallationScope = PackageScope.Global }; + var parameters = manager.OperationHelper.GetParameters(package, options, OperationType.Install); + + Assert.Contains("--global", parameters); + } + + /// + /// Tests OperationHelper uses custom version when specified in options. + /// + [Fact] + public void OperationHelperUsesCustomVersionFromOptions() + { + var manager = new Bun(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("lodash") + .WithVersion("4.17.20") + .Build(); + + var options = new InstallOptions { Version = "4.17.21" }; + var parameters = manager.OperationHelper.GetParameters(package, options, OperationType.Install); + + Assert.Contains("lodash@4.17.21", parameters); + } + + /// + /// Tests OperationHelper builds correct uninstall parameters. + /// + [Fact] + public void OperationHelperBuildsUninstallParameters() + { + var manager = new Bun(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("typescript") + .WithVersion("5.3.3") + .Build(); + + var parameters = manager.OperationHelper.GetParameters(package, new InstallOptions(), OperationType.Uninstall); + + Assert.Contains("remove", parameters); + Assert.Contains("typescript", parameters); + } + + /// + /// Tests OperationHelper builds correct update parameters. + /// + [Fact] + public void OperationHelperBuildsUpdateParameters() + { + var manager = new Bun(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("typescript") + .WithVersion("5.2.0") + .WithNewVersion("5.4.0") + .Build(); + + var parameters = manager.OperationHelper.GetParameters(package, new InstallOptions(), OperationType.Update); + + Assert.Contains("add", parameters); + Assert.Contains("typescript@5.4.0", parameters); + } + + /// + /// Tests OperationHelper respects package's overridden scope over options scope. + /// + [Fact] + public void OperationHelperRespectsPackageScopeOverOption() + { + var manager = new Bun(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("typescript") + .WithVersion("5.3.3") + .WithOptions(new OverridenInstallationOptions(PackageScope.Global)) + .Build(); + + var options = new InstallOptions { InstallationScope = PackageScope.Local }; + var parameters = manager.OperationHelper.GetParameters(package, options, OperationType.Install); + + Assert.Contains("--global", parameters); + } + + /// + /// Tests OperationHelper allows explicit local installs when scope is overridden. + /// + [Fact] + public void OperationHelperDoesNotAddGlobalFlagForExplicitLocalScope() + { + var manager = new Bun(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("typescript") + .WithVersion("5.3.3") + .WithOptions(new OverridenInstallationOptions(PackageScope.Local)) + .Build(); + + var parameters = manager.OperationHelper.GetParameters(package, new InstallOptions(), OperationType.Install); + + Assert.DoesNotContain("--global", parameters); + } + + /// + /// Tests OperationHelper includes custom install parameters. + /// + [Fact] + public void OperationHelperIncludesCustomParameters() + { + var manager = new Bun(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("typescript") + .WithVersion("5.3.3") + .Build(); + + var options = new InstallOptions(); + options.CustomParameters_Install.Add("--save-dev"); + options.CustomParameters_Install.Add("--no-save"); + + var parameters = manager.OperationHelper.GetParameters(package, options, OperationType.Install); + + Assert.Contains("--save-dev", parameters); + Assert.Contains("--no-save", parameters); + } + + /// + /// Tests OperationHelper returns Success for zero exit code. + /// + [Fact] + public void OperationHelperReturnsSuccessForZeroExitCode() + { + var manager = new Bun(); + var package = new PackageBuilder().WithManager(manager).Build(); + + var result = manager.OperationHelper.GetResult(package, OperationType.Install, [], 0); + + Assert.Equal(OperationVeredict.Success, result); + } + + /// + /// Tests OperationHelper returns Failure for non-zero exit code. + /// + [Fact] + public void OperationHelperReturnsFailureForNonZeroExitCode() + { + var manager = new Bun(); + var package = new PackageBuilder().WithManager(manager).Build(); + + var result = manager.OperationHelper.GetResult(package, OperationType.Install, [], 1); + + Assert.Equal(OperationVeredict.Failure, result); + } + + /// + /// Tests that manager has correct capabilities configured. + /// + [Fact] + public void BunManagerHasCorrectCapabilities() + { + var manager = new Bun(); + + Assert.True(manager.Capabilities.CanRunAsAdmin); + Assert.True(manager.Capabilities.SupportsCustomVersions); + Assert.True(manager.Capabilities.CanDownloadInstaller); + Assert.False(manager.Capabilities.SupportsCustomScopes); + Assert.True(manager.Capabilities.CanListDependencies); + Assert.True(manager.Capabilities.SupportsPreRelease); + Assert.Equal(ProxySupport.No, manager.Capabilities.SupportsProxy); + Assert.False(manager.Capabilities.SupportsProxyAuth); + } + + /// + /// Tests that manager properties are correctly configured. + /// + [Fact] + public void BunManagerHasCorrectProperties() + { + var manager = new Bun(); + + Assert.Equal("bun", manager.Properties.Id); + Assert.Equal("Bun", manager.Properties.Name); + Assert.Equal("add", manager.Properties.InstallVerb); + Assert.Equal("remove", manager.Properties.UninstallVerb); + Assert.Equal("add", manager.Properties.UpdateVerb); + Assert.NotNull(manager.Properties.DefaultSource); + Assert.Equal("https://www.npmjs.com/", manager.Properties.DefaultSource.Url.ToString()); + } + + /// + /// Tests that scoped packages with multiple @ symbols are handled correctly. + /// This is a regression test for packages like @scope/name@version. + /// + [Fact] + public void ParseInstalledPackagesHandlesMultipleAtSymbols() + { + var manager = new Bun(); + var output = """ + /home/user/.bun/install/global node_modules (1) + └── @babel/core@7.23.5 + """; + + var packages = Bun.ParseInstalledPackages(output, manager.DefaultSource, manager, + new OverridenInstallationOptions(PackageScope.Global)); + + Assert.Single(packages); + Assert.Equal("@babel/core", packages[0].Id); + Assert.Equal("7.23.5", packages[0].VersionString); + } + + /// + /// Tests that ParseBunOutdatedTable correctly identifies and skips the Package header. + /// + [Fact] + public void ParseBunOutdatedTableSkipsPackageHeader() + { + var output = """ + bun outdated v1.3.9 + |-------------------------------------------------| + | Package | Current | Update | Latest | + |--------------------------------------------------| + | typescript | 5.2.0 | 5.3.0 | 5.4.0 | + |-------------------------------------------------| + """; + + var results = Bun.ParseBunOutdatedTable(output).ToList(); + + Assert.Single(results); + Assert.Equal("typescript", results[0].Id); + Assert.Equal("5.2.0", results[0].Version); + Assert.Equal("5.3.0", results[0].NewVersion); + } + + /// + /// Tests parsing of Unicode box-drawing table format from 'bun outdated' (v1.3.10+). + /// Real-world output uses Unicode box-drawing characters instead of ASCII pipes. + /// Verifies that the parser correctly handles both formats. + /// + [Fact] + public void ParseBunOutdatedTableHandlesUnicodeBoxDrawingFormat() + { + var output = """ + bun outdated v1.3.10 (30e609e0) + ┌───────────────────────────┬─────────┬────────┬────────┐ + │ Package │ Current │ Update │ Latest │ + ├───────────────────────────┼─────────┼────────┼────────┤ + │ @types/jest │ 29.6.0 │ 29.6.0 │ 30.0.0 │ + ├───────────────────────────┼─────────┼────────┼────────┤ + │ @org/shared-lib │ 3.2.0 │ 3.2.0 │ 4.0.0 │ + └───────────────────────────┴─────────┴────────┴────────┘ + """; + + var results = Bun.ParseBunOutdatedTable(output).ToList(); + + // Both packages have Current == Update, so they should NOT be included (no updates available) + Assert.Empty(results); + } + + /// + /// Tests that ParseBunOutdatedTable correctly handles Unicode format with actual updates available. + /// + [Fact] + public void ParseBunOutdatedTableHandlesUnicodeBoxDrawingWithUpdates() + { + var output = """ + bun outdated v1.3.10 + ┌────────────────┬─────────┬────────┬────────┐ + │ Package │ Current │ Update │ Latest │ + ├────────────────┼─────────┼────────┼────────┤ + │ typescript │ 5.2.0 │ 5.3.0 │ 5.4.0 │ + ├────────────────┼─────────┼────────┼────────┤ + │ @types/node │ 20.8.0 │ 20.9.0 │ 20.10.0│ + └────────────────┴─────────┴────────┴────────┘ + """; + + var results = Bun.ParseBunOutdatedTable(output).ToList(); + + Assert.Equal(2, results.Count); + Assert.Equal("typescript", results[0].Id); + Assert.Equal("5.2.0", results[0].Version); + Assert.Equal("5.3.0", results[0].NewVersion); + Assert.Equal("@types/node", results[1].Id); + Assert.Equal("20.8.0", results[1].Version); + Assert.Equal("20.9.0", results[1].NewVersion); + } + + /// + /// Tests that Unicode box-drawing format works with preferLatest=true. + /// This scenario matches when a user enables BunPreferLatestVersions setting + /// and runs bun outdated with the new Unicode table format. + /// + [Fact] + public void ParseBunOutdatedTableHandlesUnicodeWithPreferLatest() + { + var output = """ + bun outdated v1.3.10 (30e609e0) + ┌───────────────────────────┬─────────┬────────┬────────┐ + │ Package │ Current │ Update │ Latest │ + ├───────────────────────────┼─────────┼────────┼────────┤ + │ @types/jest │ 29.6.0 │ 29.6.0 │ 30.0.0 │ + ├───────────────────────────┼─────────┼────────┼────────┤ + │ @org/shared-lib │ 3.2.0 │ 3.2.0 │ 4.0.0 │ + └───────────────────────────┴─────────┴────────┴────────┘ + """; + + // With preferLatest=false (default), no updates because Update column = Current + var resultsDefault = Bun.ParseBunOutdatedTable(output, preferLatest: false).ToList(); + Assert.Empty(resultsDefault); + + // With preferLatest=true, both packages should show as having updates from Latest column + var resultsPreferLatest = Bun.ParseBunOutdatedTable(output, preferLatest: true).ToList(); + Assert.Equal(2, resultsPreferLatest.Count); + Assert.Equal("@types/jest", resultsPreferLatest[0].Id); + Assert.Equal("29.6.0", resultsPreferLatest[0].Version); + Assert.Equal("30.0.0", resultsPreferLatest[0].NewVersion); + Assert.Equal("@org/shared-lib", resultsPreferLatest[1].Id); + Assert.Equal("3.2.0", resultsPreferLatest[1].Version); + Assert.Equal("4.0.0", resultsPreferLatest[1].NewVersion); + } + + /// + /// Tests that manager returns public interface correctly (IPackageManager compliance). + /// + [Fact] + public void ParseAvailableUpdatesRespectsPreferlatesParameter() + { + var manager = new Bun(); + var output = """ + |-------------------------------------------------| + | Package | Current | Update | Latest | + |--------------------------------------------------| + | typescript | 5.2.0 | 5.3.0 | 6.0.0 | + |-------------------------------------------------| + """; + + // Test with preferLatest=false (default): should use Update column (5.3.0) + var resultsUpdate = Bun.ParseAvailableUpdates(output, manager.DefaultSource, manager, preferLatest: false); + Assert.Single(resultsUpdate); + Assert.Equal("typescript", resultsUpdate[0].Id); + Assert.Equal("5.3.0", resultsUpdate[0].NewVersionString); + + // Test with preferLatest=true: should use Latest column (6.0.0) + var resultsLatest = Bun.ParseAvailableUpdates(output, manager.DefaultSource, manager, preferLatest: true); + Assert.Single(resultsLatest); + Assert.Equal("typescript", resultsLatest[0].Id); + Assert.Equal("6.0.0", resultsLatest[0].NewVersionString); + } + + /// + /// Tests Bun global updates are skipped when there is no global package manifest. + /// + [Fact] + public void HasGlobalPackageManifestRequiresPackageJson() + { + string globalDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + + try + { + Directory.CreateDirectory(globalDir); + + Assert.False(Bun.HasGlobalPackageManifest(globalDir)); + + File.WriteAllText(Path.Combine(globalDir, "package.json"), "{}"); + + Assert.True(Bun.HasGlobalPackageManifest(globalDir)); + } + finally + { + if (Directory.Exists(globalDir)) + Directory.Delete(globalDir, recursive: true); + } + } + + /// + /// Tests Bun update detection returns no results when the global manifest is missing. + /// + [Fact] + public void GetAvailableUpdatesReturnsEmptyWhenGlobalManifestIsMissing() + { + string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string globalDir = Bun.GetGlobalPackagesDirectory(userProfile); + string manifestPath = Path.Combine(globalDir, "package.json"); + string backupManifestPath = manifestPath + ".bun-test-backup"; + + if (!Directory.Exists(globalDir)) + { + Assert.Empty(new TestableBun().GetAvailableUpdatesUnsafe()); + return; + } + + bool restoreManifest = false; + + try + { + if (File.Exists(backupManifestPath)) + File.Delete(backupManifestPath); + + if (File.Exists(manifestPath)) + { + File.Move(manifestPath, backupManifestPath); + restoreManifest = true; + } + + Assert.Empty(new TestableBun().GetAvailableUpdatesUnsafe()); + } + finally + { + if (restoreManifest) + { + if (File.Exists(manifestPath)) + File.Delete(manifestPath); + File.Move(backupManifestPath, manifestPath); + } + } + } + + /// + /// Tests Bun install location defaults to the global Bun node_modules directory. + /// + [Fact] + public void GetInstallLocationDefaultsToBunGlobalNodeModules() + { + string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string location = BunPkgDetailsHelper.GetInstallLocation(userProfile, null, "typescript"); + + Assert.Equal( + Path.Join(userProfile, ".bun", "install", "global", "node_modules", "typescript"), + location); + } + + /// + /// Tests Bun install location honors explicit local scope. + /// + [Fact] + public void GetInstallLocationUsesLocalNodeModulesForLocalScope() + { + string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string location = BunPkgDetailsHelper.GetInstallLocation(userProfile, PackageScope.Local, "typescript"); + + Assert.Equal(Path.Join(userProfile, "node_modules", "typescript"), location); + } + + /// + /// Tests that manager returns public interface correctly (IPackageManager compliance). + /// + [Fact] + public void BunImplementsIPackageManager() + { + var manager = new Bun(); + + // Verify that the manager implements IPackageManager interface + Assert.NotNull(manager); + Assert.NotNull(manager.DefaultSource); + Assert.NotNull(manager.OperationHelper); + } + + /// + /// Tests that package info fixture exists for details helper testing. + /// + [Fact] + public void PackageInfoFixtureExists() + { + string fixturePath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "Bun", "package-info.json"); + Assert.True(File.Exists(fixturePath), $"Fixture file not found at {fixturePath}"); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Bun/installed.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Bun/installed.txt new file mode 100644 index 000000000..c785bb5c4 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Bun/installed.txt @@ -0,0 +1,6 @@ +/home/user/.bun/install/global node_modules (5) +├── typescript@5.7.3 +├── @devcontainers/cli@0.81.1 +├── esbuild@0.19.11 +├── @swc/cli@0.1.62 +└── bunx@1.0.24 diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Bun/outdated-table.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Bun/outdated-table.txt new file mode 100644 index 000000000..bdb50eaa3 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Bun/outdated-table.txt @@ -0,0 +1,10 @@ +bun outdated v1.3.9 (cf6cdbbb) +|-------------------------------------------------| +| Package | Current | Update | Latest | +|--------------------------------------------------| +| typescript | 5.2.0 | 5.3.0 | 5.4.0 | +|--------------------------------------------------| +| @types/node | 20.8.0 | 20.9.0 | 20.10.6 | +|--------------------------------------------------| +| vite | 4.5.0 | 4.5.1 | 5.0.0 | +|-------------------------------------------------| diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Bun/package-info.json b/src/UniGetUI.PackageEngine.Tests/Fixtures/Bun/package-info.json new file mode 100644 index 000000000..5f5e46484 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Bun/package-info.json @@ -0,0 +1,28 @@ +{ + "name": "example-lib", + "version": "2.0.0", + "description": "An example library for testing", + "license": "MIT", + "homepage": "https://example.com/lib", + "author": "Example Author", + "maintainers": ["maintainer1"], + "time": { + "2.0.0": "2025-01-15T00:00:00.000Z" + }, + "dist-tags": { + "latest": "2.0.0" + }, + "dist": { + "tarball": "https://registry.example.com/example-lib/-/example-lib-2.0.0.tgz", + "unpackedSize": 12345, + "integrity": "sha512-exampleIntegrityHash==" + }, + "dependencies": { + "helper-lib": "^1.0.0" + }, + "versions": [ + "1.0.0", + "1.1.0", + "2.0.0" + ] +} diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Bun/search-results.json b/src/UniGetUI.PackageEngine.Tests/Fixtures/Bun/search-results.json new file mode 100644 index 000000000..4e3622977 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Bun/search-results.json @@ -0,0 +1,17 @@ +[ + { + "name": "typescript", + "version": "5.3.3", + "description": "TypeScript is a language for application scale JavaScript development" + }, + { + "name": "lodash", + "version": "4.17.21", + "description": "Lodash modular utilities." + }, + { + "name": "@types/node", + "version": "20.10.6", + "description": "TypeScript definitions for Node.js" + } +] diff --git a/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj b/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj index 4d3f0352d..e004b3de4 100644 --- a/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj +++ b/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/src/UniGetUI.Windows.slnx b/src/UniGetUI.Windows.slnx index 0cb1eb039..d3ecc29b0 100644 --- a/src/UniGetUI.Windows.slnx +++ b/src/UniGetUI.Windows.slnx @@ -140,6 +140,10 @@ + + + + diff --git a/src/UniGetUI/Assets/Symbols/bun.svg b/src/UniGetUI/Assets/Symbols/bun.svg new file mode 100644 index 000000000..278b63c52 --- /dev/null +++ b/src/UniGetUI/Assets/Symbols/bun.svg @@ -0,0 +1 @@ +Bun \ No newline at end of file diff --git a/src/UniGetUI/Pages/SettingsPages/ManagersPages/PackageManager.xaml.cs b/src/UniGetUI/Pages/SettingsPages/ManagersPages/PackageManager.xaml.cs index 41ea995b5..eddb4a0a5 100644 --- a/src/UniGetUI/Pages/SettingsPages/ManagersPages/PackageManager.xaml.cs +++ b/src/UniGetUI/Pages/SettingsPages/ManagersPages/PackageManager.xaml.cs @@ -11,6 +11,7 @@ using UniGetUI.Interface.Widgets; using UniGetUI.PackageEngine; using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Managers.BunManager; using UniGetUI.PackageEngine.Managers.CargoManager; using UniGetUI.PackageEngine.Managers.ChocolateyManager; using UniGetUI.PackageEngine.Managers.DotNetManager; @@ -77,6 +78,8 @@ protected override void OnNavigatedTo(NavigationEventArgs e) Manager = PEInterface.PowerShell7; else if (Manager_T == typeof(Cargo)) Manager = PEInterface.Cargo; + else if (Manager_T == typeof(Bun)) + Manager = PEInterface.Bun; else if (Manager_T == typeof(Vcpkg)) Manager = PEInterface.Vcpkg; else if (Manager_T == typeof(DotNet)) @@ -325,6 +328,23 @@ protected override void OnNavigatedTo(NavigationEventArgs e) }; ExtraControls.Children.Add(Scoop_CleanupOnStart); } + // -------------------------------- BUN EXTRA SETTINGS ---------------------------------- + + else if (Manager is Bun) + { + DisableNotifsCard.CornerRadius = new CornerRadius(8, 8, 0, 0); + DisableNotifsCard.BorderThickness = new Thickness(1, 1, 1, 0); + ExtraControls.Children.Add(DisableNotifsCard); + + CheckboxCard Bun_PreferLatestVersions = new() + { + CornerRadius = new CornerRadius(0, 0, 8, 8), + BorderThickness = new Thickness(1, 0, 1, 1), + SettingName = Settings.K.BunPreferLatestVersions, + Text = CoreTools.Translate("Prefer latest versions (may include breaking changes) instead of recommended safe updates"), + }; + ExtraControls.Children.Add(Bun_PreferLatestVersions); + } // -------------------------------- VCPKG EXTRA SETTINGS -------------------------------------- else if (Manager is Vcpkg)