From be46306fa009316b325bbc8c6b5947188fe38d67 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 25 Mar 2026 12:43:47 +1100 Subject: [PATCH 1/5] Fix CLI bundle extraction to use ~/.aspire/ for non-standard install paths When the Aspire CLI is installed outside the standard ~/.aspire/bin/ layout (e.g., via Homebrew at /usr/local/bin/ or Winget at C:\Program Files\), the bundle extraction previously wrote .aspire-bundle-version and extracted managed/dcp directories to the parent of the CLI's parent directory, which could be a system directory like /usr/local/ or C:\Program Files\. This change: - Updates GetDefaultExtractDir to detect non-standard install locations and fall back to the well-known ~/.aspire/ directory - Adds ~/.aspire/ as a well-known layout discovery path in LayoutDiscovery so the CLI can find extracted content when installed elsewhere - Adds tests for the new path resolution logic - Documents the extraction directory resolution in the bundle spec Fixes #15454 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/bundle.md | 16 +++ src/Aspire.Cli/Bundles/BundleService.cs | 40 +++++- src/Aspire.Cli/Layout/LayoutDiscovery.cs | 19 +++ tests/Aspire.Cli.Tests/BundleServiceTests.cs | 40 +++++- .../Aspire.Cli.Tests/LayoutDiscoveryTests.cs | 128 ++++++++++++++++++ 5 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/LayoutDiscoveryTests.cs diff --git a/docs/specs/bundle.md b/docs/specs/bundle.md index ae15bb6b504..3fe23532fcd 100644 --- a/docs/specs/bundle.md +++ b/docs/specs/bundle.md @@ -245,6 +245,22 @@ 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()`: + +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..65a10a4ea1d 100644 --- a/src/Aspire.Cli/Bundles/BundleService.cs +++ b/src/Aspire.Cli/Bundles/BundleService.cs @@ -156,8 +156,10 @@ 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) { @@ -167,7 +169,39 @@ private async Task ExtractCoreAsync(string destinationPath, return null; } - return Path.GetDirectoryName(cliDir) ?? cliDir; + var parentDir = Path.GetDirectoryName(cliDir); + if (parentDir is null) + { + return GetWellKnownAspireDir(); + } + + // If the parent directory is the well-known .aspire directory (standard layout), + // use it directly so extraction lands alongside the CLI. + if (IsAspireDirectory(parentDir)) + { + return parentDir; + } + + // Non-standard install location — fall back to ~/.aspire/ to avoid writing into + // system directories like /usr/local/ or C:\Program Files\. + return GetWellKnownAspireDir(); + } + + /// + /// Gets the well-known ~/.aspire/ directory path. + /// + internal static string GetWellKnownAspireDir() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspire"); + } + + /// + /// Checks whether the given directory path is a .aspire directory. + /// + private static bool IsAspireDirectory(string directoryPath) + { + var dirName = Path.GetFileName(directoryPath); + return string.Equals(dirName, ".aspire", StringComparison.OrdinalIgnoreCase); } /// diff --git a/src/Aspire.Cli/Layout/LayoutDiscovery.cs b/src/Aspire.Cli/Layout/LayoutDiscovery.cs index 2fe62c819a6..529b462da18 100644 --- a/src/Aspire.Cli/Layout/LayoutDiscovery.cs +++ b/src/Aspire.Cli/Layout/LayoutDiscovery.cs @@ -64,6 +64,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; } @@ -180,6 +190,15 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) return null; } + private LayoutConfiguration? TryDiscoverWellKnownLayout() + { + var wellKnownPath = Bundles.BundleService.GetWellKnownAspireDir(); + + _logger.LogDebug("TryDiscoverWellKnownLayout: Checking well-known path {Path}...", wellKnownPath); + + return TryInferLayout(wellKnownPath); + } + private LayoutConfiguration? TryInferLayout(string layoutPath) { // Check for essential directories diff --git a/tests/Aspire.Cli.Tests/BundleServiceTests.cs b/tests/Aspire.Cli.Tests/BundleServiceTests.cs index 9d8f5f015f5..bc186e3b1a7 100644 --- a/tests/Aspire.Cli.Tests/BundleServiceTests.cs +++ b/tests/Aspire.Cli.Tests/BundleServiceTests.cs @@ -53,7 +53,7 @@ public void VersionMarker_ReturnsNull_WhenMissing() } [Fact] - public void GetDefaultExtractDir_ReturnsParentOfParent() + public void GetDefaultExtractDir_ReturnsAspireDir_ForStandardLayout() { if (OperatingSystem.IsWindows()) { @@ -67,6 +67,44 @@ public void GetDefaultExtractDir_ReturnsParentOfParent() } } + [Fact] + public void GetDefaultExtractDir_FallsBackToWellKnownDir_ForNonStandardLayout() + { + var expected = BundleService.GetWellKnownAspireDir(); + + if (OperatingSystem.IsWindows()) + { + Assert.Equal(expected, BundleService.GetDefaultExtractDir(@"C:\Program Files\WinGet\Links\aspire.exe")); + } + else + { + Assert.Equal(expected, BundleService.GetDefaultExtractDir("/usr/local/bin/aspire")); + Assert.Equal(expected, BundleService.GetDefaultExtractDir("/opt/homebrew/bin/aspire")); + } + } + + [Fact] + public void GetDefaultExtractDir_FallsBackToWellKnownDir_ForCustomInstallLocation() + { + var expected = BundleService.GetWellKnownAspireDir(); + + if (OperatingSystem.IsWindows()) + { + Assert.Equal(expected, BundleService.GetDefaultExtractDir(@"D:\tools\aspire\bin\aspire.exe")); + } + else + { + Assert.Equal(expected, BundleService.GetDefaultExtractDir("/opt/aspire/bin/aspire")); + } + } + + [Fact] + public void GetWellKnownAspireDir_ReturnsExpectedPath() + { + var expected = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspire"); + Assert.Equal(expected, BundleService.GetWellKnownAspireDir()); + } + [Fact] public void GetCurrentVersion_ReturnsNonNull() { diff --git a/tests/Aspire.Cli.Tests/LayoutDiscoveryTests.cs b/tests/Aspire.Cli.Tests/LayoutDiscoveryTests.cs new file mode 100644 index 00000000000..cf15dde2819 --- /dev/null +++ b/tests/Aspire.Cli.Tests/LayoutDiscoveryTests.cs @@ -0,0 +1,128 @@ +// 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_PrefersEnvVarOverWellKnownPath() + { + // Create a temp directory with a valid layout and set it via the env var. + // Even if a real layout exists at ~/.aspire/, the env var should take priority. + var fakeLayoutDir = CreateTempDirectory(); + CreateValidBundleLayout(fakeLayoutDir); + + var envBefore = Environment.GetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar); + try + { + Environment.SetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar, fakeLayoutDir); + + var discovery = new LayoutDiscovery(NullLogger.Instance); + var layout = discovery.DiscoverLayout(); + + Assert.NotNull(layout); + Assert.Equal(fakeLayoutDir, layout.LayoutPath); + } + finally + { + Environment.SetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar, envBefore); + } + } + + [Fact] + public void DiscoverLayout_IgnoresEnvVar_WhenPathHasNoValidLayout() + { + // Point the env var at an empty directory — it should be skipped. + var emptyDir = CreateTempDirectory(); + + var envBefore = Environment.GetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar); + try + { + Environment.SetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar, emptyDir); + + var discovery = new LayoutDiscovery(NullLogger.Instance); + var layout = discovery.DiscoverLayout(); + + // Layout may still be discovered via relative or well-known paths, + // but it must NOT come from the invalid env var path. + if (layout is not null) + { + Assert.NotEqual(emptyDir, layout.LayoutPath); + } + } + finally + { + Environment.SetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar, envBefore); + } + } + + [Fact] + public void DiscoverLayout_RejectsLayout_WhenManagedDirectoriesExistButExecutableIsMissing() + { + // Create directories but no aspire-managed executable. + var incompleteDir = CreateTempDirectory(); + Directory.CreateDirectory(Path.Combine(incompleteDir, BundleDiscovery.ManagedDirectoryName)); + Directory.CreateDirectory(Path.Combine(incompleteDir, BundleDiscovery.DcpDirectoryName)); + + var envBefore = Environment.GetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar); + try + { + Environment.SetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar, incompleteDir); + + var discovery = new LayoutDiscovery(NullLogger.Instance); + var layout = discovery.DiscoverLayout(); + + // The incomplete directory should not be selected as the layout path. + // The discovery may still find a valid layout elsewhere (relative or well-known). + if (layout is not null) + { + Assert.NotEqual(incompleteDir, layout.LayoutPath); + } + } + finally + { + Environment.SetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar, envBefore); + } + } + + private string CreateTempDirectory() + { + var dir = Directory.CreateTempSubdirectory("aspire-layout-test-").FullName; + _tempDirs.Add(dir); + return dir; + } + + 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 + } + } + } +} From c2007ab9ea796bc4d8fd49b07db4ef8676196371 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 25 Mar 2026 14:35:06 +1100 Subject: [PATCH 2/5] Address review feedback: use CliExecutionContext for aspire dir - Add AspireDirectory property to CliExecutionContext - Inject CliExecutionContext into BundleService and LayoutDiscovery - Remove static GetWellKnownAspireDir() and IsAspireDirectory() helpers - Use exact path comparison against context.AspireDirectory instead of fragile directory name check - Drop unnecessary null guards (internal API, always receives valid path) - Rewrite tests to use CliExecutionContext instead of env var mutations - Add GetDefaultExtractDir to IBundleService interface Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/bundle.md | 3 +- src/Aspire.Cli/Bundles/BundleService.cs | 46 ++----- src/Aspire.Cli/Bundles/IBundleService.cs | 8 ++ src/Aspire.Cli/CliExecutionContext.cs | 8 ++ src/Aspire.Cli/Commands/SetupCommand.cs | 2 +- src/Aspire.Cli/Layout/LayoutDiscovery.cs | 6 +- tests/Aspire.Cli.Tests/BundleServiceTests.cs | 100 ++++++++++----- .../Aspire.Cli.Tests/LayoutDiscoveryTests.cs | 118 ++++++++---------- tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 6 + 9 files changed, 164 insertions(+), 133 deletions(-) diff --git a/docs/specs/bundle.md b/docs/specs/bundle.md index 3fe23532fcd..445b8b17b14 100644 --- a/docs/specs/bundle.md +++ b/docs/specs/bundle.md @@ -247,7 +247,8 @@ When a polyglot project runs `aspire run`, `AppHostServerProjectFactory.CreateAs ### Extraction Directory Resolution -The default extraction directory is determined by `BundleService.GetDefaultExtractDir()`: +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. diff --git a/src/Aspire.Cli/Bundles/BundleService.cs b/src/Aspire.Cli/Bundles/BundleService.cs index 65a10a4ea1d..406380a71cb 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); @@ -161,47 +156,24 @@ private async Task ExtractCoreAsync(string destinationPath, /// 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 cliDir = Path.GetDirectoryName(processPath); - if (string.IsNullOrEmpty(cliDir)) - { - return null; - } + var aspireDir = executionContext.AspireDirectory.FullName; - var parentDir = Path.GetDirectoryName(cliDir); - if (parentDir is null) - { - return GetWellKnownAspireDir(); - } + var cliDir = Path.GetDirectoryName(processPath); + var parentDir = cliDir is not null ? Path.GetDirectoryName(cliDir) : null; - // If the parent directory is the well-known .aspire directory (standard layout), + // If the parent directory matches the well-known aspire directory (standard layout), // use it directly so extraction lands alongside the CLI. - if (IsAspireDirectory(parentDir)) + if (parentDir is not null && + string.Equals(Path.GetFullPath(parentDir), Path.GetFullPath(aspireDir), StringComparison.OrdinalIgnoreCase)) { return parentDir; } // Non-standard install location — fall back to ~/.aspire/ to avoid writing into // system directories like /usr/local/ or C:\Program Files\. - return GetWellKnownAspireDir(); - } - - /// - /// Gets the well-known ~/.aspire/ directory path. - /// - internal static string GetWellKnownAspireDir() - { - return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspire"); - } - - /// - /// Checks whether the given directory path is a .aspire directory. - /// - private static bool IsAspireDirectory(string directoryPath) - { - var dirName = Path.GetFileName(directoryPath); - return string.Equals(dirName, ".aspire", StringComparison.OrdinalIgnoreCase); + 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..839a2e99b62 100644 --- a/src/Aspire.Cli/CliExecutionContext.cs +++ b/src/Aspire.Cli/CliExecutionContext.cs @@ -29,6 +29,14 @@ 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 { get; } = new DirectoryInfo(Path.Combine( + (homeDirectory ?? new DirectoryInfo(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))).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..3b99f0b6666 100644 --- a/src/Aspire.Cli/Commands/SetupCommand.cs +++ b/src/Aspire.Cli/Commands/SetupCommand.cs @@ -59,7 +59,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // Determine extraction directory if (string.IsNullOrEmpty(installPath)) { - installPath = BundleService.GetDefaultExtractDir(processPath); + installPath = _bundleService.GetDefaultExtractDir(processPath); } if (string.IsNullOrEmpty(installPath)) diff --git a/src/Aspire.Cli/Layout/LayoutDiscovery.cs b/src/Aspire.Cli/Layout/LayoutDiscovery.cs index 529b462da18..a52bff295e3 100644 --- a/src/Aspire.Cli/Layout/LayoutDiscovery.cs +++ b/src/Aspire.Cli/Layout/LayoutDiscovery.cs @@ -36,9 +36,11 @@ 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; } @@ -192,7 +194,7 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) private LayoutConfiguration? TryDiscoverWellKnownLayout() { - var wellKnownPath = Bundles.BundleService.GetWellKnownAspireDir(); + var wellKnownPath = _executionContext.AspireDirectory.FullName; _logger.LogDebug("TryDiscoverWellKnownLayout: Checking well-known path {Path}...", wellKnownPath); diff --git a/tests/Aspire.Cli.Tests/BundleServiceTests.cs b/tests/Aspire.Cli.Tests/BundleServiceTests.cs index bc186e3b1a7..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; @@ -55,56 +57,77 @@ public void VersionMarker_ReturnsNull_WhenMissing() [Fact] 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_FallsBackToWellKnownDir_ForNonStandardLayout() + public void GetDefaultExtractDir_FallsBackToAspireDir_ForNonStandardLayout() { - var expected = BundleService.GetWellKnownAspireDir(); - - if (OperatingSystem.IsWindows()) + var fakeHome = Directory.CreateTempSubdirectory("aspire-test-home"); + try { - Assert.Equal(expected, BundleService.GetDefaultExtractDir(@"C:\Program Files\WinGet\Links\aspire.exe")); + 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")); + } } - else + finally { - Assert.Equal(expected, BundleService.GetDefaultExtractDir("/usr/local/bin/aspire")); - Assert.Equal(expected, BundleService.GetDefaultExtractDir("/opt/homebrew/bin/aspire")); + fakeHome.Delete(recursive: true); } } [Fact] - public void GetDefaultExtractDir_FallsBackToWellKnownDir_ForCustomInstallLocation() + public void GetDefaultExtractDir_FallsBackToAspireDir_ForCustomInstallLocation() { - var expected = BundleService.GetWellKnownAspireDir(); - - if (OperatingSystem.IsWindows()) + var fakeHome = Directory.CreateTempSubdirectory("aspire-test-home"); + try { - Assert.Equal(expected, BundleService.GetDefaultExtractDir(@"D:\tools\aspire\bin\aspire.exe")); + 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")); + } } - else + finally { - Assert.Equal(expected, BundleService.GetDefaultExtractDir("/opt/aspire/bin/aspire")); + fakeHome.Delete(recursive: true); } } - [Fact] - public void GetWellKnownAspireDir_ReturnsExpectedPath() - { - var expected = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspire"); - Assert.Equal(expected, BundleService.GetWellKnownAspireDir()); - } - [Fact] public void GetCurrentVersion_ReturnsNonNull() { @@ -112,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 index cf15dde2819..29f3cfbef36 100644 --- a/tests/Aspire.Cli.Tests/LayoutDiscoveryTests.cs +++ b/tests/Aspire.Cli.Tests/LayoutDiscoveryTests.cs @@ -12,83 +12,60 @@ public class LayoutDiscoveryTests : IDisposable private readonly List _tempDirs = []; [Fact] - public void DiscoverLayout_PrefersEnvVarOverWellKnownPath() + public void DiscoverLayout_FindsWellKnownPath_WhenValidLayoutExists() { - // Create a temp directory with a valid layout and set it via the env var. - // Even if a real layout exists at ~/.aspire/, the env var should take priority. - var fakeLayoutDir = CreateTempDirectory(); - CreateValidBundleLayout(fakeLayoutDir); - - var envBefore = Environment.GetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar); - try - { - Environment.SetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar, fakeLayoutDir); - - var discovery = new LayoutDiscovery(NullLogger.Instance); - var layout = discovery.DiscoverLayout(); - - Assert.NotNull(layout); - Assert.Equal(fakeLayoutDir, layout.LayoutPath); - } - finally - { - Environment.SetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar, envBefore); - } + // 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_IgnoresEnvVar_WhenPathHasNoValidLayout() + public void DiscoverLayout_ReturnsNull_WhenNoValidLayout() { - // Point the env var at an empty directory — it should be skipped. - var emptyDir = CreateTempDirectory(); - - var envBefore = Environment.GetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar); - try - { - Environment.SetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar, emptyDir); - - var discovery = new LayoutDiscovery(NullLogger.Instance); - var layout = discovery.DiscoverLayout(); - - // Layout may still be discovered via relative or well-known paths, - // but it must NOT come from the invalid env var path. - if (layout is not null) - { - Assert.NotEqual(emptyDir, layout.LayoutPath); - } - } - finally + // 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) { - Environment.SetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar, envBefore); + Assert.NotEqual(aspireDir, layout.LayoutPath); } } [Fact] public void DiscoverLayout_RejectsLayout_WhenManagedDirectoriesExistButExecutableIsMissing() { - // Create directories but no aspire-managed executable. - var incompleteDir = CreateTempDirectory(); - Directory.CreateDirectory(Path.Combine(incompleteDir, BundleDiscovery.ManagedDirectoryName)); - Directory.CreateDirectory(Path.Combine(incompleteDir, BundleDiscovery.DcpDirectoryName)); - - var envBefore = Environment.GetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar); - try - { - Environment.SetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar, incompleteDir); - - var discovery = new LayoutDiscovery(NullLogger.Instance); - var layout = discovery.DiscoverLayout(); - - // The incomplete directory should not be selected as the layout path. - // The discovery may still find a valid layout elsewhere (relative or well-known). - if (layout is not null) - { - Assert.NotEqual(incompleteDir, layout.LayoutPath); - } - } - finally + 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) { - Environment.SetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar, envBefore); + Assert.NotEqual(aspireDir, layout.LayoutPath); } } @@ -99,6 +76,19 @@ private string CreateTempDirectory() return dir; } + 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 void CreateValidBundleLayout(string layoutPath) { var managedDir = Path.Combine(layoutPath, BundleDiscovery.ManagedDirectoryName); 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 From d8fc9561fdd6779a6aa9b7c5dd39e41f6984ac71 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 25 Mar 2026 15:47:20 +1100 Subject: [PATCH 3/5] Fix DI: use factory for LayoutDiscovery with internal CliExecutionContext The LayoutDiscovery class is public but CliExecutionContext is internal, so DI reflection cannot locate a public constructor. Use a factory registration to construct LayoutDiscovery explicitly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Program.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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. From 7e055d45be184056c6872d01124e9c8e474858b3 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 26 Mar 2026 21:58:47 +1100 Subject: [PATCH 4/5] Address review feedback: normalize paths, derive AspireDirectory, remove dead code, use CliExecutionContext for env vars - Return Path.GetFullPath(parentDir) in GetDefaultExtractDir for consistent output - Derive AspireDirectory from HomeDirectory to avoid duplicated fallback logic - Remove unreachable string.IsNullOrEmpty(installPath) check in SetupCommand - Replace all direct Environment.GetEnvironmentVariable calls in LayoutDiscovery with _executionContext.GetEnvironmentVariable() for testability - Pass empty environmentVariables dict in LayoutDiscoveryTests for isolation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Bundles/BundleService.cs | 2 +- src/Aspire.Cli/CliExecutionContext.cs | 4 +--- src/Aspire.Cli/Commands/SetupCommand.cs | 6 ------ src/Aspire.Cli/Layout/LayoutDiscovery.cs | 12 ++++++------ tests/Aspire.Cli.Tests/LayoutDiscoveryTests.cs | 3 ++- 5 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/Aspire.Cli/Bundles/BundleService.cs b/src/Aspire.Cli/Bundles/BundleService.cs index 406380a71cb..79c53867b8a 100644 --- a/src/Aspire.Cli/Bundles/BundleService.cs +++ b/src/Aspire.Cli/Bundles/BundleService.cs @@ -168,7 +168,7 @@ public string GetDefaultExtractDir(string processPath) if (parentDir is not null && string.Equals(Path.GetFullPath(parentDir), Path.GetFullPath(aspireDir), StringComparison.OrdinalIgnoreCase)) { - return parentDir; + return Path.GetFullPath(parentDir); } // Non-standard install location — fall back to ~/.aspire/ to avoid writing into diff --git a/src/Aspire.Cli/CliExecutionContext.cs b/src/Aspire.Cli/CliExecutionContext.cs index 839a2e99b62..be269bf144d 100644 --- a/src/Aspire.Cli/CliExecutionContext.cs +++ b/src/Aspire.Cli/CliExecutionContext.cs @@ -33,9 +33,7 @@ internal sealed class CliExecutionContext(DirectoryInfo workingDirectory, Direct /// /// Gets the well-known ~/.aspire/ directory used for CLI data storage, bundles, and settings. /// - public DirectoryInfo AspireDirectory { get; } = new DirectoryInfo(Path.Combine( - (homeDirectory ?? new DirectoryInfo(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))).FullName, - ".aspire")); + 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 3b99f0b6666..6443ef203b0 100644 --- a/src/Aspire.Cli/Commands/SetupCommand.cs +++ b/src/Aspire.Cli/Commands/SetupCommand.cs @@ -62,12 +62,6 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell installPath = _bundleService.GetDefaultExtractDir(processPath); } - if (string.IsNullOrEmpty(installPath)) - { - InteractionService.DisplayError("Could not determine the installation path."); - return ExitCodeConstants.FailedToBuildArtifacts; - } - // Extract with spinner BundleExtractResult result = BundleExtractResult.NoPayload; var exitCode = await InteractionService.ShowStatusAsync( diff --git a/src/Aspire.Cli/Layout/LayoutDiscovery.cs b/src/Aspire.Cli/Layout/LayoutDiscovery.cs index a52bff295e3..72571fc5b62 100644 --- a/src/Aspire.Cli/Layout/LayoutDiscovery.cs +++ b/src/Aspire.Cli/Layout/LayoutDiscovery.cs @@ -47,7 +47,7 @@ internal LayoutDiscovery(CliExecutionContext executionContext, ILogger Environment.GetEnvironmentVariable(BundleDiscovery.DcpPathEnvVar), - LayoutComponent.Managed => Environment.GetEnvironmentVariable(BundleDiscovery.ManagedPathEnvVar), + LayoutComponent.Dcp => _executionContext.GetEnvironmentVariable(BundleDiscovery.DcpPathEnvVar), + LayoutComponent.Managed => _executionContext.GetEnvironmentVariable(BundleDiscovery.ManagedPathEnvVar), _ => null }; @@ -103,7 +103,7 @@ internal LayoutDiscovery(CliExecutionContext executionContext, ILogger? environmentVariables = null) { var aspireDir = Path.Combine(homeDir, ".aspire"); return new CliExecutionContext( @@ -86,6 +86,7 @@ private static CliExecutionContext CreateContext(string homeDir) new DirectoryInfo(Path.Combine(aspireDir, "sdks")), new DirectoryInfo(Path.Combine(aspireDir, "logs")), "test.log", + environmentVariables: environmentVariables ?? new Dictionary(), homeDirectory: new DirectoryInfo(homeDir)); } From c9ac43330432c3c187c53444ad9659ec76a113ff Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Mar 2026 09:20:46 +1100 Subject: [PATCH 5/5] Trigger CI rebuild Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Bundles/BundleService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Bundles/BundleService.cs b/src/Aspire.Cli/Bundles/BundleService.cs index 79c53867b8a..de40ce7aba7 100644 --- a/src/Aspire.Cli/Bundles/BundleService.cs +++ b/src/Aspire.Cli/Bundles/BundleService.cs @@ -171,8 +171,8 @@ public string GetDefaultExtractDir(string processPath) return Path.GetFullPath(parentDir); } - // Non-standard install location — fall back to ~/.aspire/ to avoid writing into - // system directories like /usr/local/ or C:\Program Files\. + // Non-standard install location — fall back to ~/.aspire/ to avoid writing + // into system directories like /usr/local/ or C:\Program Files\. return aspireDir; }