Skip to content
Open
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
17 changes: 17 additions & 0 deletions docs/specs/bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,23 @@ When a polyglot project runs `aspire run`, `AppHostServerProjectFactory.CreateAs

`aspire update --self` downloads the new self-extracting binary, swaps it, then calls `BundleService.ExtractAsync(force: true)` to proactively extract the updated payload.

### Extraction Directory Resolution

The default extraction directory is determined by `BundleService.GetDefaultExtractDir()`, which uses
`CliExecutionContext.AspireDirectory` as the well-known `~/.aspire/` path:

1. **Standard layout** (`~/.aspire/bin/aspire`): extracts to the parent directory (`~/.aspire/`), keeping
all components co-located in the user's Aspire directory.
2. **Non-standard install locations** (e.g., `/usr/local/bin/aspire` via Homebrew, or
`C:\Program Files\WinGet\Links\aspire.exe` via Winget): falls back to the well-known `~/.aspire/`
directory to avoid writing into system directories that may not be user-writable.

The `aspire setup --install-path <path>` flag overrides this logic entirely, allowing explicit control
of the extraction target for advanced scenarios.

Layout discovery (`LayoutDiscovery`) mirrors this hierarchy: it checks environment variables first,
then relative paths from the CLI binary, and finally the well-known `~/.aspire/` directory.

### Version Tracking

The file `.aspire-bundle-version` in the layout root contains the assembly informational version string (e.g., `13.2.0-pr.14398.gabc1234`). This enables:
Expand Down
30 changes: 18 additions & 12 deletions src/Aspire.Cli/Bundles/BundleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace Aspire.Cli.Bundles;
/// <summary>
/// Manages extraction of the embedded bundle payload from self-extracting CLI binaries.
/// </summary>
internal sealed class BundleService(ILayoutDiscovery layoutDiscovery, ILogger<BundleService> logger) : IBundleService
internal sealed class BundleService(ILayoutDiscovery layoutDiscovery, CliExecutionContext executionContext, ILogger<BundleService> logger) : IBundleService
{
private const string PayloadResourceName = "bundle.tar.gz";

Expand Down Expand Up @@ -62,11 +62,6 @@ public async Task EnsureExtractedAsync(CancellationToken cancellationToken = def
}

var extractDir = GetDefaultExtractDir(processPath);
if (extractDir is null)
{
logger.LogDebug("Could not determine extraction directory from {ProcessPath}, skipping.", processPath);
return;
}

logger.LogDebug("Ensuring bundle is extracted to {ExtractDir}.", extractDir);
var result = await ExtractAsync(extractDir, force: false, cancellationToken);
Expand Down Expand Up @@ -156,18 +151,29 @@ private async Task<BundleExtractResult> ExtractCoreAsync(string destinationPath,

/// <summary>
/// Determines the default extraction directory for the current CLI binary.
/// If CLI is at ~/.aspire/bin/aspire, returns ~/.aspire/ so layout discovery
/// finds components via the bin/ layout pattern.
/// When the CLI is under the well-known <c>~/.aspire/bin/</c> layout, returns the parent
/// (<c>~/.aspire/</c>). For non-standard install locations (e.g., Homebrew, Winget),
/// falls back to the well-known <c>~/.aspire/</c> directory so that extraction artifacts
/// stay in a user-writable, predictable location.
/// </summary>
internal static string? GetDefaultExtractDir(string processPath)
public string GetDefaultExtractDir(string processPath)
{
var aspireDir = executionContext.AspireDirectory.FullName;

var cliDir = Path.GetDirectoryName(processPath);
if (string.IsNullOrEmpty(cliDir))
var parentDir = cliDir is not null ? Path.GetDirectoryName(cliDir) : null;

// If the parent directory matches the well-known aspire directory (standard layout),
// use it directly so extraction lands alongside the CLI.
if (parentDir is not null &&
string.Equals(Path.GetFullPath(parentDir), Path.GetFullPath(aspireDir), StringComparison.OrdinalIgnoreCase))
{
return null;
return Path.GetFullPath(parentDir);
}

return Path.GetDirectoryName(cliDir) ?? cliDir;
// Non-standard install location — fall back to ~/.aspire/ to avoid writing
// into system directories like /usr/local/ or C:\Program Files\.
return aspireDir;
}

/// <summary>
Expand Down
8 changes: 8 additions & 0 deletions src/Aspire.Cli/Bundles/IBundleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ internal interface IBundleService
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The discovered layout, or <see langword="null"/> if no layout is found.</returns>
Task<LayoutConfiguration?> EnsureExtractedAndGetLayoutAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Determines the default extraction directory for the given CLI binary path.
/// Returns the well-known <c>~/.aspire/</c> directory for non-standard install locations.
/// </summary>
/// <param name="processPath">The path to the CLI binary.</param>
/// <returns>The directory to extract the bundle into.</returns>
string GetDefaultExtractDir(string processPath);
}

/// <summary>
Expand Down
6 changes: 6 additions & 0 deletions src/Aspire.Cli/CliExecutionContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ internal sealed class CliExecutionContext(DirectoryInfo workingDirectory, Direct
public string LogFilePath { get; } = logFilePath;

public DirectoryInfo HomeDirectory { get; } = homeDirectory ?? new DirectoryInfo(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));

/// <summary>
/// Gets the well-known <c>~/.aspire/</c> directory used for CLI data storage, bundles, and settings.
/// </summary>
public DirectoryInfo AspireDirectory => new DirectoryInfo(Path.Combine(HomeDirectory.FullName, ".aspire"));

public bool DebugMode { get; } = debugMode;

/// <summary>
Expand Down
8 changes: 1 addition & 7 deletions src/Aspire.Cli/Commands/SetupCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
// Determine extraction directory
if (string.IsNullOrEmpty(installPath))
{
installPath = BundleService.GetDefaultExtractDir(processPath);
}

if (string.IsNullOrEmpty(installPath))
{
InteractionService.DisplayError("Could not determine the installation path.");
return ExitCodeConstants.FailedToBuildArtifacts;
installPath = _bundleService.GetDefaultExtractDir(processPath);
}

// Extract with spinner
Expand Down
35 changes: 28 additions & 7 deletions src/Aspire.Cli/Layout/LayoutDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,18 @@ public interface ILayoutDiscovery
public sealed class LayoutDiscovery : ILayoutDiscovery
{
private readonly ILogger<LayoutDiscovery> _logger;
private readonly CliExecutionContext _executionContext;

public LayoutDiscovery(ILogger<LayoutDiscovery> logger)
internal LayoutDiscovery(CliExecutionContext executionContext, ILogger<LayoutDiscovery> logger)
{
_executionContext = executionContext;
_logger = logger;
}

public LayoutConfiguration? DiscoverLayout(string? projectDirectory = null)
{
// 1. Try environment variable for layout path
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LayoutDiscovery is a public sealed class but the constructor is now internal. Since CliExecutionContext is internal, the constructor can't be public — but this leaves a public type with no accessible constructor. Consider making LayoutDiscovery itself internal to match.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't matter because this is the CLI. But LayoutDiscovery class itself should be internal. If it's changed to internal then I think the constructor can then be made public and then you don't need to manually create the type with its resolved dependencies in a DI callback.

var envLayoutPath = Environment.GetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar);
var envLayoutPath = _executionContext.GetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar);
if (!string.IsNullOrEmpty(envLayoutPath))
{
_logger.LogDebug("Found ASPIRE_LAYOUT_PATH: {Path}", envLayoutPath);
Expand All @@ -64,6 +66,16 @@ public LayoutDiscovery(ILogger<LayoutDiscovery> logger)
return LogEnvironmentOverrides(relativeLayout);
}

// 3. Try the well-known ~/.aspire/ directory.
// When the CLI is installed outside the standard layout (e.g., via Homebrew or Winget),
// the bundle is extracted to ~/.aspire/ instead of adjacent to the binary.
var wellKnownLayout = TryDiscoverWellKnownLayout();
if (wellKnownLayout is not null)
{
_logger.LogDebug("Discovered layout at well-known path: {Path}", wellKnownLayout.LayoutPath);
return LogEnvironmentOverrides(wellKnownLayout);
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still reads Environment.ProcessPath directly instead of going through _executionContext. All the Environment.GetEnvironmentVariable calls were migrated to use the execution context, but this one was missed.

This makes LayoutDiscovery impossible to fully test in isolation — LayoutDiscoveryTests can't control ProcessPath, which is why the tests above need the weak if (layout is not null) guard instead of asserting Assert.Null(layout).

Consider adding a ProcessPath property to CliExecutionContext so this can be injected in tests too.

_logger.LogDebug("No bundle layout discovered");
return null;
}
Expand All @@ -73,8 +85,8 @@ public LayoutDiscovery(ILogger<LayoutDiscovery> logger)
// Check environment variable overrides first
var envPath = component switch
{
LayoutComponent.Dcp => Environment.GetEnvironmentVariable(BundleDiscovery.DcpPathEnvVar),
LayoutComponent.Managed => Environment.GetEnvironmentVariable(BundleDiscovery.ManagedPathEnvVar),
LayoutComponent.Dcp => _executionContext.GetEnvironmentVariable(BundleDiscovery.DcpPathEnvVar),
LayoutComponent.Managed => _executionContext.GetEnvironmentVariable(BundleDiscovery.ManagedPathEnvVar),
_ => null
};

Expand All @@ -91,7 +103,7 @@ public LayoutDiscovery(ILogger<LayoutDiscovery> logger)
public bool IsBundleModeAvailable(string? projectDirectory = null)
{
// Check if user explicitly wants SDK mode
var useSdk = Environment.GetEnvironmentVariable(BundleDiscovery.UseGlobalDotNetEnvVar);
var useSdk = _executionContext.GetEnvironmentVariable(BundleDiscovery.UseGlobalDotNetEnvVar);
if (string.Equals(useSdk, "true", StringComparison.OrdinalIgnoreCase) ||
string.Equals(useSdk, "1", StringComparison.OrdinalIgnoreCase))
{
Expand Down Expand Up @@ -180,6 +192,15 @@ public bool IsBundleModeAvailable(string? projectDirectory = null)
return null;
}

private LayoutConfiguration? TryDiscoverWellKnownLayout()
{
var wellKnownPath = _executionContext.AspireDirectory.FullName;

_logger.LogDebug("TryDiscoverWellKnownLayout: Checking well-known path {Path}...", wellKnownPath);

return TryInferLayout(wellKnownPath);
}

private LayoutConfiguration? TryInferLayout(string layoutPath)
{
// Check for essential directories
Expand Down Expand Up @@ -222,11 +243,11 @@ private LayoutConfiguration LogEnvironmentOverrides(LayoutConfiguration config)
// Environment variables for specific components take precedence
// These will be checked at GetComponentPath time, but we note them here for logging

if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(BundleDiscovery.DcpPathEnvVar)))
if (!string.IsNullOrEmpty(_executionContext.GetEnvironmentVariable(BundleDiscovery.DcpPathEnvVar)))
{
_logger.LogDebug("DCP path override from {EnvVar}", BundleDiscovery.DcpPathEnvVar);
}
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(BundleDiscovery.ManagedPathEnvVar)))
if (!string.IsNullOrEmpty(_executionContext.GetEnvironmentVariable(BundleDiscovery.ManagedPathEnvVar)))
{
_logger.LogDebug("Managed path override from {EnvVar}", BundleDiscovery.ManagedPathEnvVar);
}
Expand Down
3 changes: 2 additions & 1 deletion src/Aspire.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,8 @@ internal static async Task<IHost> BuildApplicationAsync(string[] args, CliStartu

// Bundle layout services (for polyglot apphost without .NET SDK).
// Registered before NuGetPackageCache so the factory can choose implementation.
builder.Services.AddSingleton<ILayoutDiscovery, LayoutDiscovery>();
builder.Services.AddSingleton<ILayoutDiscovery>(sp =>
new LayoutDiscovery(sp.GetRequiredService<CliExecutionContext>(), sp.GetRequiredService<ILogger<LayoutDiscovery>>()));
builder.Services.AddSingleton<BundleNuGetService>();

// Git repository operations.
Expand Down
96 changes: 89 additions & 7 deletions tests/Aspire.Cli.Tests/BundleServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Cli.Bundles;
using Aspire.Cli.Tests.Utils;
using Microsoft.Extensions.Logging.Abstractions;

namespace Aspire.Cli.Tests;

Expand Down Expand Up @@ -53,17 +55,76 @@ public void VersionMarker_ReturnsNull_WhenMissing()
}

[Fact]
public void GetDefaultExtractDir_ReturnsParentOfParent()
public void GetDefaultExtractDir_ReturnsAspireDir_ForStandardLayout()
{
if (OperatingSystem.IsWindows())
// Create a temp directory simulating {home}/.aspire/bin/aspire
var fakeHome = Directory.CreateTempSubdirectory("aspire-test-home");
try
{
var result = BundleService.GetDefaultExtractDir(@"C:\Users\test\.aspire\bin\aspire.exe");
Assert.Equal(@"C:\Users\test\.aspire", result);
var aspireDir = Path.Combine(fakeHome.FullName, ".aspire");
var binDir = Path.Combine(aspireDir, "bin");
Directory.CreateDirectory(binDir);
var processPath = Path.Combine(binDir, "aspire");

var context = CreateContext(fakeHome.FullName);
var service = CreateBundleService(context);

var result = service.GetDefaultExtractDir(processPath);
Assert.Equal(aspireDir, result);
}
else
finally
{
var result = BundleService.GetDefaultExtractDir("/home/test/.aspire/bin/aspire");
Assert.Equal("/home/test/.aspire", result);
fakeHome.Delete(recursive: true);
}
}

[Fact]
public void GetDefaultExtractDir_FallsBackToAspireDir_ForNonStandardLayout()
{
var fakeHome = Directory.CreateTempSubdirectory("aspire-test-home");
try
{
var expectedAspireDir = Path.Combine(fakeHome.FullName, ".aspire");
var context = CreateContext(fakeHome.FullName);
var service = CreateBundleService(context);

// Simulate Homebrew/Winget paths that are NOT under the aspire directory
Assert.Equal(expectedAspireDir, service.GetDefaultExtractDir("/usr/local/bin/aspire"));
Assert.Equal(expectedAspireDir, service.GetDefaultExtractDir("/opt/homebrew/bin/aspire"));

if (OperatingSystem.IsWindows())
{
Assert.Equal(expectedAspireDir, service.GetDefaultExtractDir(@"C:\Program Files\WinGet\Links\aspire.exe"));
}
}
finally
{
fakeHome.Delete(recursive: true);
}
}

[Fact]
public void GetDefaultExtractDir_FallsBackToAspireDir_ForCustomInstallLocation()
{
var fakeHome = Directory.CreateTempSubdirectory("aspire-test-home");
try
{
var expectedAspireDir = Path.Combine(fakeHome.FullName, ".aspire");
var context = CreateContext(fakeHome.FullName);
var service = CreateBundleService(context);

if (OperatingSystem.IsWindows())
{
Assert.Equal(expectedAspireDir, service.GetDefaultExtractDir(@"D:\tools\aspire\bin\aspire.exe"));
}
else
{
Assert.Equal(expectedAspireDir, service.GetDefaultExtractDir("/opt/aspire/bin/aspire"));
}
}
finally
{
fakeHome.Delete(recursive: true);
}
}

Expand All @@ -74,4 +135,25 @@ public void GetCurrentVersion_ReturnsNonNull()
Assert.NotNull(version);
Assert.NotEqual("unknown", version);
}

private static CliExecutionContext CreateContext(string homeDir)
{
var aspireDir = Path.Combine(homeDir, ".aspire");
return new CliExecutionContext(
new DirectoryInfo("."),
new DirectoryInfo(Path.Combine(aspireDir, "hives")),
new DirectoryInfo(Path.Combine(aspireDir, "cache")),
new DirectoryInfo(Path.Combine(aspireDir, "sdks")),
new DirectoryInfo(Path.Combine(aspireDir, "logs")),
"test.log",
homeDirectory: new DirectoryInfo(homeDir));
}

private static BundleService CreateBundleService(CliExecutionContext context)
{
return new BundleService(
new NullLayoutDiscovery(),
context,
NullLogger<BundleService>.Instance);
}
}
Loading
Loading