Skip to content

Fix CLI bundle extraction to use ~/.aspire/ for non-standard install paths#15563

Open
mitchdenny wants to merge 3 commits intomainfrom
fix/bundle-version-path
Open

Fix CLI bundle extraction to use ~/.aspire/ for non-standard install paths#15563
mitchdenny wants to merge 3 commits intomainfrom
fix/bundle-version-path

Conversation

@mitchdenny
Copy link
Member

Description

Fixes #15454

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 PR changes the default extraction directory logic to detect non-standard install locations and fall back to the well-known ~/.aspire/ directory instead.

Changes

BundleService.cs

  • Updated GetDefaultExtractDir to check if the parent-of-parent is a .aspire directory (standard layout). If not, falls back to ~/.aspire/.
  • Added GetWellKnownAspireDir() helper that returns ~/.aspire/ cross-platform.
  • Added IsAspireDirectory() helper for the directory name check.

LayoutDiscovery.cs

  • Added ~/.aspire/ as a well-known fallback in DiscoverLayout() (step 3, after env var and relative-path checks).
  • This ensures the CLI can find extracted content even when installed outside ~/.aspire/bin/.

BundleServiceTests.cs

  • Renamed existing test to GetDefaultExtractDir_ReturnsAspireDir_ForStandardLayout.
  • Added GetDefaultExtractDir_FallsBackToWellKnownDir_ForNonStandardLayout (Homebrew, Winget).
  • Added GetDefaultExtractDir_FallsBackToWellKnownDir_ForCustomInstallLocation.
  • Added GetWellKnownAspireDir_ReturnsExpectedPath.

LayoutDiscoveryTests.cs (new)

  • Tests for layout discovery priority: env var > relative > well-known path.
  • Tests that invalid layouts at the env var path are skipped.

docs/specs/bundle.md

  • Documented the extraction directory resolution logic.

Backward Compatibility

For the standard install path (~/.aspire/bin/aspire), behavior is unchanged — GetDefaultExtractDir returns ~/.aspire/ in both the old and new code paths. The --install-path flag on aspire setup continues to override all default logic.

…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>
@github-actions
Copy link
Contributor

github-actions bot commented Mar 25, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 15563

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 15563"

@mitchdenny
Copy link
Member Author

/deployment-test

@github-actions
Copy link
Contributor

🚀 Deployment tests starting on PR #15563...

This will deploy to real Azure infrastructure. Results will be posted here when complete.

View workflow run

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes bundle extraction/layout discovery when the Aspire CLI binary is installed outside the standard ~/.aspire/bin/ layout (e.g., Homebrew/Winget), ensuring extraction artifacts land in the user-writable ~/.aspire/ directory instead of potentially system-owned parent directories.

Changes:

  • Update bundle extraction default path selection to detect the standard .aspire/bin layout and otherwise fall back to the well-known ~/.aspire/ directory.
  • Extend layout discovery to also probe the well-known ~/.aspire/ path after env-var and relative-path checks.
  • Add/adjust unit tests for extraction path logic and layout discovery behavior; document the resolution rules.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/Aspire.Cli.Tests/LayoutDiscoveryTests.cs New tests validating env var precedence and rejecting invalid env-var layouts.
tests/Aspire.Cli.Tests/BundleServiceTests.cs Updates/expands tests for standard vs non-standard install locations and well-known path computation.
src/Aspire.Cli/Layout/LayoutDiscovery.cs Adds well-known ~/.aspire/ fallback discovery step.
src/Aspire.Cli/Bundles/BundleService.cs Changes default extraction directory logic; introduces well-known path helper and .aspire directory check.
docs/specs/bundle.md Documents extraction directory resolution and matching discovery order.

@@ -167,7 +169,39 @@ private async Task<BundleExtractResult> ExtractCoreAsync(string destinationPath,
return null;
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

GetDefaultExtractDir returns null when processPath has no directory component (e.g., a relative/filename-only path). That contradicts the method’s documented intent to fall back to the well-known ~/.aspire/ directory for non-standard locations, and it can cause EnsureExtractedAsync to skip extraction or aspire setup to fail with "Could not determine the installation path." Consider returning GetWellKnownAspireDir() instead of null when cliDir is null/empty.

Suggested change
return null;
return GetWellKnownAspireDir();

Copilot uses AI. Check for mistakes.
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot had a problem deploying to deployment-testing March 25, 2026 01:52 Failure
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot had a problem deploying to deployment-testing March 25, 2026 01:52 Failure
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot had a problem deploying to deployment-testing March 25, 2026 01:52 Failure
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions github-actions bot temporarily deployed to deployment-testing March 25, 2026 01:52 Inactive
@github-actions
Copy link
Contributor

Deployment E2E Tests failed — 25 passed, 3 failed, 0 cancelled

View test results and recordings

View workflow run

Test Result Recording
Deployment.EndToEnd-VnetSqlServerConnectivityDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-VnetKeyVaultInfraDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-VnetKeyVaultConnectivityDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AcaCompactNamingDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-VnetSqlServerInfraDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AksStarterWithRedisDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AppServicePythonDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AppServiceReactDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AzureStorageDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AcaExistingRegistryDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AcaStarterDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AzureLogAnalyticsDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-VnetStorageBlobConnectivityDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AzureAppConfigDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AzureServiceBusDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AzureKeyVaultDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AuthenticationTests ✅ Passed
Deployment.EndToEnd-AzureContainerRegistryDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AksStarterDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AcaDeploymentErrorOutputTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-PythonFastApiDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AzureEventHubsDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AcaCustomRegistryDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-VnetStorageBlobInfraDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AcrPurgeTaskDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-TypeScriptExpressDeploymentTests ❌ Failed ▶️ View Recording
Deployment.EndToEnd-AcaCompactNamingUpgradeDeploymentTests ❌ Failed ▶️ View Recording
Deployment.EndToEnd-AcaManagedRedisDeploymentTests ❌ Failed ▶️ View Recording

/// </summary>
internal static string GetWellKnownAspireDir()
{
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspire");
Copy link
Member

Choose a reason for hiding this comment

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

Can this be reused from somewhere? We must have code to generate this path somewhere else.

private static bool IsAspireDirectory(string directoryPath)
{
var dirName = Path.GetFileName(directoryPath);
return string.Equals(dirName, ".aspire", StringComparison.OrdinalIgnoreCase);
Copy link
Member

Choose a reason for hiding this comment

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

What happens if someone specifies an install directory of C:\Program Files\WinGet\Links\.aspire\. Won't this return true and produce a bad result?

var fakeLayoutDir = CreateTempDirectory();
CreateValidBundleLayout(fakeLayoutDir);

var envBefore = Environment.GetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar);
Copy link
Member

Choose a reason for hiding this comment

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

These tests modify global env var state. Flaky.

Comment on lines +169 to +179
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.
Copy link
Member

Choose a reason for hiding this comment

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

The fallback behavior is inconsistent between degenerate inputs:

  • When Path.GetDirectoryName(processPath) is empty (e.g., bare "aspire" with no directory) → returns null, which causes callers to skip extraction entirely.
  • When cliDir exists but Path.GetDirectoryName(cliDir) is null (binary at filesystem root, e.g., /aspire) → returns GetWellKnownAspireDir(), which silently extracts to ~/.aspire/.

Both represent "we can't determine a meaningful install location from the process path." Should these be treated the same way? I'd expect either both to return null (skip extraction) or both to fall back to the well-known dir.

@github-actions
Copy link
Contributor

Re-running the failed jobs in the CI workflow for this pull request because 1 job was identified as retry-safe transient failures in the CI run attempt.
GitHub was asked to rerun all failed jobs for that attempt, and the rerun is being tracked in the rerun attempt.
The job links below point to the failed attempt jobs that matched the retry-safe transient failure rules.

Mitch Denny and others added 2 commits March 25, 2026 14:35
- 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>
…text

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>
@github-actions
Copy link
Contributor

🎬 CLI E2E Test Recordings — 49 recordings uploaded (commit 044f19c)

View recordings
Test Recording
AddPackageInteractiveWhileAppHostRunningDetached ▶️ View Recording
AddPackageWhileAppHostRunningDetached ▶️ View Recording
AgentCommands_AllHelpOutputs_AreCorrect ▶️ View Recording
AgentInitCommand_DefaultSelection_InstallsSkillOnly ▶️ View Recording
AgentInitCommand_MigratesDeprecatedConfig ▶️ View Recording
AspireAddPackageVersionToDirectoryPackagesProps ▶️ View Recording
AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesProps ▶️ View Recording
Banner_DisplayedOnFirstRun ▶️ View Recording
Banner_DisplayedWithExplicitFlag ▶️ View Recording
CertificatesClean_RemovesCertificates ▶️ View Recording
CertificatesTrust_WithNoCert_CreatesAndTrustsCertificate ▶️ View Recording
CertificatesTrust_WithUntrustedCert_TrustsCertificate ▶️ View Recording
ConfigSetGet_CreatesNestedJsonFormat ▶️ View Recording
CreateAndRunAspireStarterProject ▶️ View Recording
CreateAndRunAspireStarterProjectWithBundle ▶️ View Recording
CreateAndRunEmptyAppHostProject ▶️ View Recording
CreateAndRunJsReactProject ▶️ View Recording
CreateAndRunPythonReactProject ▶️ View Recording
CreateAndRunTypeScriptEmptyAppHostProject ▶️ View Recording
CreateAndRunTypeScriptStarterProject ▶️ View Recording
CreateStartAndStopAspireProject ▶️ View Recording
CreateTypeScriptAppHostWithViteApp ▶️ View Recording
DescribeCommandResolvesReplicaNames ▶️ View Recording
DescribeCommandShowsRunningResources ▶️ View Recording
DetachFormatJsonProducesValidJson ▶️ View Recording
DoctorCommand_DetectsDeprecatedAgentConfig ▶️ View Recording
DoctorCommand_WithSslCertDir_ShowsTrusted ▶️ View Recording
DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted ▶️ View Recording
GlobalMigration_HandlesCommentsAndTrailingCommas ▶️ View Recording
GlobalMigration_HandlesMalformedLegacyJson ▶️ View Recording
GlobalMigration_PreservesAllValueTypes ▶️ View Recording
GlobalMigration_SkipsWhenNewConfigExists ▶️ View Recording
GlobalSettings_MigratedFromLegacyFormat ▶️ View Recording
InvalidAppHostPathWithComments_IsHealedOnRun ▶️ View Recording
LogsCommandShowsResourceLogs ▶️ View Recording
PsCommandListsRunningAppHost ▶️ View Recording
PsFormatJsonOutputsOnlyJsonToStdout ▶️ View Recording
PublishWithDockerComposeServiceCallbackSucceeds ▶️ View Recording
RestoreGeneratesSdkFiles ▶️ View Recording
RunWithMissingAwaitShowsHelpfulError ▶️ View Recording
SecretCrudOnDotNetAppHost ▶️ View Recording
SecretCrudOnTypeScriptAppHost ▶️ View Recording
StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels ▶️ View Recording
StopAllAppHostsFromAppHostDirectory ▶️ View Recording
StopAllAppHostsFromUnrelatedDirectory ▶️ View Recording
StopNonInteractiveMultipleAppHostsShowsError ▶️ View Recording
StopNonInteractiveSingleAppHost ▶️ View Recording
StopWithNoRunningAppHostExitsSuccessfully ▶️ View Recording
TypeScriptAppHostWithProjectReferenceIntegration ▶️ View Recording

📹 Recordings uploaded automatically from CI run #23525395209

Copy link
Member

@JamesNK JamesNK left a comment

Choose a reason for hiding this comment

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

Overall this is a solid fix for the non-standard install path problem. Four issues to address.

// use it directly so extraction lands alongside the CLI.
if (parentDir is not null &&
string.Equals(Path.GetFullPath(parentDir), Path.GetFullPath(aspireDir), StringComparison.OrdinalIgnoreCase))
{
Copy link
Member

Choose a reason for hiding this comment

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

When the standard-layout branch matches, this returns the raw parentDir (from Path.GetDirectoryName), while the fallback branch returns aspireDir (from DirectoryInfo.FullName, which is normalized). Since both sides of the comparison are normalized with Path.GetFullPath(), returning Path.GetFullPath(parentDir) here would keep the output consistent across both branches.

Suggested change
{
return Path.GetFullPath(parentDir);

Comment on lines +33 to 35
".aspire"));

public bool DebugMode { get; } = debugMode;
Copy link
Member

Choose a reason for hiding this comment

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

This duplicates the homeDirectory ?? new DirectoryInfo(...) fallback that already lives in HomeDirectory (line 28). If the default-home logic ever changes, it would need updating in two places. Consider deriving from HomeDirectory instead:

public DirectoryInfo AspireDirectory => new DirectoryInfo(Path.Combine(HomeDirectory.FullName, ".aspire"));

(or cache it in the constructor after HomeDirectory is initialized).

if (string.IsNullOrEmpty(installPath))
{
installPath = BundleService.GetDefaultExtractDir(processPath);
installPath = _bundleService.GetDefaultExtractDir(processPath);
Copy link
Member

Choose a reason for hiding this comment

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

GetDefaultExtractDir now always returns a non-null, non-empty string (it falls back to aspireDir). The string.IsNullOrEmpty(installPath) check a few lines below (line 66) is now dead code that can never be reached.

public LayoutDiscovery(ILogger<LayoutDiscovery> logger)
internal LayoutDiscovery(CliExecutionContext executionContext, ILogger<LayoutDiscovery> logger)
{
_executionContext = executionContext;
Copy link
Member

Choose a reason for hiding this comment

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

CliExecutionContext is plumbed in here, but DiscoverLayout() and the other methods in this class still call Environment.GetEnvironmentVariable directly in 6 places (e.g., BundleDiscovery.LayoutPathEnvVar in DiscoverLayout, DcpPathEnvVar/ManagedPathEnvVar in GetComponentPath, UseGlobalDotNetEnvVar in IsBundleModeAvailable, and in LogEnvironmentOverrides). These should go through _executionContext.GetEnvironmentVariable() instead — otherwise the new LayoutDiscoveryTests can't control the environment. In particular, DiscoverLayout_ReturnsNull_WhenNoValidLayout and DiscoverLayout_RejectsLayout_WhenManagedDirectoriesExistButExecutableIsMissing can be short-circuited by a stale ASPIRE_LAYOUT_PATH env var in the test runner, passing vacuously without testing the well-known path logic.

Copy link
Member

Choose a reason for hiding this comment

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

I didn't realize that there was a way to override env vars using CliExecutionContext. Could the tests that previously set env vars using the execution context to override values per test, without changing env vars globally?

@davidfowl
Copy link
Contributor

This is a release/13.2 candidate. The goal is to get this out so that we can ship winget/brew installers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cli always generates a .aspire-bundle-version in its parent directory

4 participants