Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 53 additions & 34 deletions src/UniGetUI.PackageEngine.Managers.Bun/Bun.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,32 +54,29 @@ public Bun()

protected override IReadOnlyList<Package> 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);
// Bun has no `search` subcommand; it resolves packages from the npm registry.
// Query the registry's search endpoint directly (the same data npm search uses).
INativeTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.FindPackages);
string url = "https://registry.npmjs.org/-/v1/search?size=100&text=" + Uri.EscapeDataString(query);
logger.Log($"Querying the npm registry: {url}");

return ParseSearchOutput(strContents, DefaultSource, this);
try
{
using HttpClient client = new(CoreTools.GenericHttpClientParameters);
client.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString);
string strContents = client.GetStringAsync(url).GetAwaiter().GetResult();
logger.Log(strContents);

var packages = ParseSearchOutput(strContents, DefaultSource, this);
logger.Close(0);
return packages;
}
catch (Exception e)
{
logger.Error(e);
logger.Close(1);
return [];
}
}

protected override IReadOnlyList<Package> GetAvailableUpdates_UnSafe()
Expand Down Expand Up @@ -146,20 +143,32 @@ protected override IReadOnlyList<Package> GetAvailableUpdates_UnSafe()

protected override IReadOnlyList<Package> GetInstalledPackages_UnSafe()
{
// `bun pm ls --global` is a no-op for the --global flag: Bun ignores it and walks
// up from the working directory to the nearest lockfile. List the dedicated global
// package.json by running from that directory instead, mirroring GetAvailableUpdates_UnSafe.
string globalDir = GetGlobalPackagesDirectory(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));

if (!HasGlobalPackageManifest(globalDir))
{
Logger.Info($"Bun: Skipping installed package detection because {globalDir} is missing package.json");
return [];
}

List<Package> Packages = [];

using Process p = new()
{
StartInfo = new ProcessStartInfo
{
FileName = Status.ExecutablePath,
Arguments = Status.ExecutableCallArgs + " pm ls --global",
Arguments = Status.ExecutableCallArgs + " pm ls",
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
WorkingDirectory = globalDir,
StandardOutputEncoding = System.Text.Encoding.UTF8
}
};
Expand All @@ -186,7 +195,16 @@ protected override IReadOnlyList<Package> GetInstalledPackages_UnSafe()
}

public override IReadOnlyList<string> FindCandidateExecutableFiles()
=> CoreTools.WhichMultiple(OperatingSystem.IsWindows() ? "bun.exe" : "bun");
{
if (!OperatingSystem.IsWindows())
return CoreTools.WhichMultiple("bun");

// The official installer ships bun.exe, but `npm i -g bun` only puts a bun.cmd
// wrapper on the PATH (the real bun.exe stays inside node_modules). Search both.
List<string> candidates = [.. CoreTools.WhichMultiple("bun.exe")];
candidates.AddRange(CoreTools.WhichMultiple("bun.cmd"));
return candidates;
}

internal static string GetGlobalPackagesDirectory(string userProfile)
=> Path.Combine(userProfile, ".bun", "install", "global");
Expand All @@ -209,7 +227,7 @@ protected override void _loadManagerVersion(out string version)
StartInfo = new ProcessStartInfo
{
FileName = Status.ExecutablePath,
Arguments = Status.ExecutableCallArgs + "--version",
Arguments = Status.ExecutableCallArgs + " --version",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
Expand All @@ -225,8 +243,8 @@ protected override void _loadManagerVersion(out string version)
}

/// <summary>
/// Parses JSON search results from 'bun search &lt;query&gt; --json'.
/// Each result object contains 'name' and 'version' fields.
/// Parses the npm registry search response (https://registry.npmjs.org/-/v1/search).
/// The payload is { "objects": [ { "package": { "name", "version" } } ] }.
/// </summary>
internal static IReadOnlyList<Package> ParseSearchOutput(
string output,
Expand All @@ -239,11 +257,12 @@ internal static IReadOnlyList<Package> ParseSearchOutput(

try
{
JsonArray? results = JsonNode.Parse(output) as JsonArray;
JsonArray? results = (JsonNode.Parse(output) as JsonObject)?["objects"] as JsonArray;
foreach (JsonNode? entry in results ?? [])
{
string? id = entry?["name"]?.ToString();
string? version = entry?["version"]?.ToString();
JsonNode? pkg = entry?["package"];
string? id = pkg?["name"]?.ToString();
string? version = pkg?["version"]?.ToString();
if (id is not null && version is not null)
{
packages.Add(new Package(CoreTools.FormatAsName(id), id, version, source, manager));
Expand Down
10 changes: 5 additions & 5 deletions src/UniGetUI.PackageEngine.Tests/BunManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ private sealed class TestableBun : Bun
}

/// <summary>
/// Tests parsing of JSON search results from 'bun search &lt;query&gt; --json'.
/// Tests parsing of the npm registry search response (registry.npmjs.org/-/v1/search).
/// Verifies that multiple packages with different names and versions are correctly parsed.
/// </summary>
[Fact]
public void ParseSearchOutputParsesJsonArray()
public void ParseSearchOutputParsesRegistryResponse()
{
var manager = new Bun();

Expand Down Expand Up @@ -297,13 +297,13 @@ Invalid line without marker
}

/// <summary>
/// Tests that search returns empty list for empty JSON array input.
/// Tests that search returns empty list for an empty registry response.
/// </summary>
[Fact]
public void ParseSearchOutputReturnsEmptyForEmptyArray()
public void ParseSearchOutputReturnsEmptyForEmptyResults()
{
var manager = new Bun();
var packages = Bun.ParseSearchOutput("[]", manager.DefaultSource, manager);
var packages = Bun.ParseSearchOutput("""{"objects":[],"total":0}""", manager.DefaultSource, manager);

Assert.Empty(packages);
}
Expand Down
44 changes: 27 additions & 17 deletions src/UniGetUI.PackageEngine.Tests/Fixtures/Bun/search-results.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
[
{
"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"
}
]
{
"objects": [
{
"package": {
"name": "typescript",
"version": "5.3.3",
"description": "TypeScript is a language for application scale JavaScript development"
}
},
{
"package": {
"name": "lodash",
"version": "4.17.21",
"description": "Lodash modular utilities."
}
},
{
"package": {
"name": "@types/node",
"version": "20.10.6",
"description": "TypeScript definitions for Node.js"
}
}
],
"total": 3,
"time": "Mon Jun 04 2026 00:00:00 GMT+0000"
}
Loading