diff --git a/docs/specs/bundle.md b/docs/specs/bundle.md index ae15bb6b504..445b8b17b14 100644 --- a/docs/specs/bundle.md +++ b/docs/specs/bundle.md @@ -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 ` 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: diff --git a/src/Aspire.Cli/Bundles/BundleService.cs b/src/Aspire.Cli/Bundles/BundleService.cs index f356fe97c92..de40ce7aba7 100644 --- a/src/Aspire.Cli/Bundles/BundleService.cs +++ b/src/Aspire.Cli/Bundles/BundleService.cs @@ -14,7 +14,7 @@ namespace Aspire.Cli.Bundles; /// /// Manages extraction of the embedded bundle payload from self-extracting CLI binaries. /// -internal sealed class BundleService(ILayoutDiscovery layoutDiscovery, ILogger logger) : IBundleService +internal sealed class BundleService(ILayoutDiscovery layoutDiscovery, CliExecutionContext executionContext, ILogger logger) : IBundleService { private const string PayloadResourceName = "bundle.tar.gz"; @@ -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); @@ -156,18 +151,29 @@ private async Task ExtractCoreAsync(string destinationPath, /// /// 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 ~/.aspire/bin/ layout, returns the parent + /// (~/.aspire/). For non-standard install locations (e.g., Homebrew, Winget), + /// falls back to the well-known ~/.aspire/ directory so that extraction artifacts + /// stay in a user-writable, predictable location. /// - 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; } /// diff --git a/src/Aspire.Cli/Bundles/IBundleService.cs b/src/Aspire.Cli/Bundles/IBundleService.cs index a486ef788a1..b45bc48712f 100644 --- a/src/Aspire.Cli/Bundles/IBundleService.cs +++ b/src/Aspire.Cli/Bundles/IBundleService.cs @@ -39,6 +39,14 @@ internal interface IBundleService /// The cancellation token. /// The discovered layout, or if no layout is found. Task EnsureExtractedAndGetLayoutAsync(CancellationToken cancellationToken = default); + + /// + /// Determines the default extraction directory for the given CLI binary path. + /// Returns the well-known ~/.aspire/ directory for non-standard install locations. + /// + /// The path to the CLI binary. + /// The directory to extract the bundle into. + string GetDefaultExtractDir(string processPath); } /// diff --git a/src/Aspire.Cli/CliExecutionContext.cs b/src/Aspire.Cli/CliExecutionContext.cs index 59c31c93a89..be269bf144d 100644 --- a/src/Aspire.Cli/CliExecutionContext.cs +++ b/src/Aspire.Cli/CliExecutionContext.cs @@ -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)); + + /// + /// Gets the well-known ~/.aspire/ directory used for CLI data storage, bundles, and settings. + /// + public DirectoryInfo AspireDirectory => new DirectoryInfo(Path.Combine(HomeDirectory.FullName, ".aspire")); + public bool DebugMode { get; } = debugMode; /// diff --git a/src/Aspire.Cli/Commands/SetupCommand.cs b/src/Aspire.Cli/Commands/SetupCommand.cs index 29a78288cc8..6443ef203b0 100644 --- a/src/Aspire.Cli/Commands/SetupCommand.cs +++ b/src/Aspire.Cli/Commands/SetupCommand.cs @@ -59,13 +59,7 @@ protected override async Task 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 diff --git a/src/Aspire.Cli/Layout/LayoutDiscovery.cs b/src/Aspire.Cli/Layout/LayoutDiscovery.cs index 2fe62c819a6..72571fc5b62 100644 --- a/src/Aspire.Cli/Layout/LayoutDiscovery.cs +++ b/src/Aspire.Cli/Layout/LayoutDiscovery.cs @@ -36,16 +36,18 @@ public interface ILayoutDiscovery public sealed class LayoutDiscovery : ILayoutDiscovery { private readonly ILogger _logger; + private readonly CliExecutionContext _executionContext; - public LayoutDiscovery(ILogger logger) + internal LayoutDiscovery(CliExecutionContext executionContext, ILogger logger) { + _executionContext = executionContext; _logger = logger; } public LayoutConfiguration? DiscoverLayout(string? projectDirectory = null) { // 1. Try environment variable for layout path - var envLayoutPath = Environment.GetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar); + var envLayoutPath = _executionContext.GetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar); if (!string.IsNullOrEmpty(envLayoutPath)) { _logger.LogDebug("Found ASPIRE_LAYOUT_PATH: {Path}", envLayoutPath); @@ -64,6 +66,16 @@ public LayoutDiscovery(ILogger 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); + } + _logger.LogDebug("No bundle layout discovered"); return null; } @@ -73,8 +85,8 @@ public LayoutDiscovery(ILogger 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 }; @@ -91,7 +103,7 @@ public LayoutDiscovery(ILogger 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)) { @@ -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 @@ -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); } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index dc5fa35a5c9..a67f5cd91a3 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -369,7 +369,8 @@ internal static async Task 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(); + builder.Services.AddSingleton(sp => + new LayoutDiscovery(sp.GetRequiredService(), sp.GetRequiredService>())); builder.Services.AddSingleton(); // Git repository operations. diff --git a/tests/Aspire.Cli.Tests/BundleServiceTests.cs b/tests/Aspire.Cli.Tests/BundleServiceTests.cs index 9d8f5f015f5..49d9a9c500d 100644 --- a/tests/Aspire.Cli.Tests/BundleServiceTests.cs +++ b/tests/Aspire.Cli.Tests/BundleServiceTests.cs @@ -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; @@ -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); } } @@ -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.Instance); + } } diff --git a/tests/Aspire.Cli.Tests/LayoutDiscoveryTests.cs b/tests/Aspire.Cli.Tests/LayoutDiscoveryTests.cs new file mode 100644 index 00000000000..e14a5590eb1 --- /dev/null +++ b/tests/Aspire.Cli.Tests/LayoutDiscoveryTests.cs @@ -0,0 +1,119 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Layout; +using Aspire.Shared; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Cli.Tests; + +public class LayoutDiscoveryTests : IDisposable +{ + private readonly List _tempDirs = []; + + [Fact] + public void DiscoverLayout_FindsWellKnownPath_WhenValidLayoutExists() + { + // Create a temp dir simulating ~/.aspire/ with a valid layout + var fakeHome = CreateTempDirectory(); + var aspireDir = Path.Combine(fakeHome, ".aspire"); + Directory.CreateDirectory(aspireDir); + CreateValidBundleLayout(aspireDir); + + var context = CreateContext(fakeHome); + var discovery = new LayoutDiscovery(context, NullLogger.Instance); + var layout = discovery.DiscoverLayout(); + + Assert.NotNull(layout); + Assert.Equal(aspireDir, layout.LayoutPath); + } + + [Fact] + public void DiscoverLayout_ReturnsNull_WhenNoValidLayout() + { + // Create a temp dir with no valid layout anywhere + var fakeHome = CreateTempDirectory(); + var aspireDir = Path.Combine(fakeHome, ".aspire"); + Directory.CreateDirectory(aspireDir); + // No managed/dcp directories + + var context = CreateContext(fakeHome); + var discovery = new LayoutDiscovery(context, NullLogger.Instance); + var layout = discovery.DiscoverLayout(); + + // Layout could still be found via relative path (unlikely in test), but + // it should NOT find one at the well-known path because it's incomplete + if (layout is not null) + { + Assert.NotEqual(aspireDir, layout.LayoutPath); + } + } + + [Fact] + public void DiscoverLayout_RejectsLayout_WhenManagedDirectoriesExistButExecutableIsMissing() + { + var fakeHome = CreateTempDirectory(); + var aspireDir = Path.Combine(fakeHome, ".aspire"); + Directory.CreateDirectory(Path.Combine(aspireDir, BundleDiscovery.ManagedDirectoryName)); + Directory.CreateDirectory(Path.Combine(aspireDir, BundleDiscovery.DcpDirectoryName)); + // No aspire-managed executable + + var context = CreateContext(fakeHome); + var discovery = new LayoutDiscovery(context, NullLogger.Instance); + var layout = discovery.DiscoverLayout(); + + // The incomplete directory should not be selected + if (layout is not null) + { + Assert.NotEqual(aspireDir, layout.LayoutPath); + } + } + + private string CreateTempDirectory() + { + var dir = Directory.CreateTempSubdirectory("aspire-layout-test-").FullName; + _tempDirs.Add(dir); + return dir; + } + + private static CliExecutionContext CreateContext(string homeDir, IReadOnlyDictionary? environmentVariables = null) + { + 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", + environmentVariables: environmentVariables ?? new Dictionary(), + homeDirectory: new DirectoryInfo(homeDir)); + } + + private static void CreateValidBundleLayout(string layoutPath) + { + var managedDir = Path.Combine(layoutPath, BundleDiscovery.ManagedDirectoryName); + var dcpDir = Path.Combine(layoutPath, BundleDiscovery.DcpDirectoryName); + Directory.CreateDirectory(managedDir); + Directory.CreateDirectory(dcpDir); + + // Create a dummy aspire-managed executable + var managedExeName = BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName); + File.WriteAllText(Path.Combine(managedDir, managedExeName), "dummy"); + } + + public void Dispose() + { + foreach (var dir in _tempDirs) + { + try + { + Directory.Delete(dir, recursive: true); + } + catch + { + // Best effort cleanup + } + } + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 8b7b9c97af0..e2806b4651f 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -595,6 +595,9 @@ public Task ExtractAsync(string destinationPath, bool force public Task EnsureExtractedAndGetLayoutAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); + + public string GetDefaultExtractDir(string processPath) + => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspire"); } /// @@ -611,6 +614,9 @@ public Task ExtractAsync(string destinationPath, bool force public Task EnsureExtractedAndGetLayoutAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); + + public string GetDefaultExtractDir(string processPath) + => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspire"); } internal sealed class TestOutputTextWriter : TextWriter