diff --git a/README.md b/README.md
index d53d95b7e..ac3bc26b1 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
[](https://github.com/Devolutions/UniGetUI/releases)
[](https://github.com/Devolutions/UniGetUI/issues)
[](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.

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