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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions eng/scripts/get-aspire-cli.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,41 @@ function Get-AspireCliUrl {
}
}

function Invoke-AspireCliBundleSetup {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory = $true)]
[string]$CliPath,
[Parameter(Mandatory = $true)]
[string]$TargetOS,
[Parameter(Mandatory = $true)]
[string]$TargetArchitecture
)

try {
$hostOS = Get-OperatingSystem
$hostArch = Get-CLIArchitectureFromArchitecture "<auto>"
}
catch {
Write-Message "Skipping Aspire CLI bundle setup because the current platform could not be detected: $($_.Exception.Message)" -Level Warning
return
}

if ($TargetOS -ne $hostOS -or $TargetArchitecture -ne $hostArch) {
Write-Message "Skipping Aspire CLI bundle setup for $TargetOS-$TargetArchitecture on $hostOS-$hostArch." -Level Info
return
}

if ($PSCmdlet.ShouldProcess($CliPath, "Set up Aspire CLI bundle")) {
# Keep native stdout visible without adding it to Install-AspireCli's success output,
# whose sole return value is the target OS consumed by PATH configuration.
& $CliPath setup | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "Aspire CLI bundle setup failed with exit code $LASTEXITCODE."
}
}
}

# Function to download and install the Aspire CLI
function Install-AspireCli {
[CmdletBinding(SupportsShouldProcess)]
Expand Down Expand Up @@ -1253,6 +1288,8 @@ function Install-AspireCli {
}
}

Invoke-AspireCliBundleSetup -CliPath $cliPath -TargetOS $targetOS -TargetArchitecture $targetArch

# Return the target OS for the caller to use
return $targetOS
}
Expand Down
37 changes: 37 additions & 0 deletions eng/scripts/get-aspire-cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ VERSION=""
QUALITY=""
OS=""
ARCH=""
INSTALLED_CLI_PATH=""
INSTALLED_CLI_OS=""
INSTALLED_CLI_ARCH=""
SHOW_HELP=false
VERBOSE=false
KEEP_ARCHIVE=false
Expand Down Expand Up @@ -995,6 +998,9 @@ download_and_install_archive() {
cli_exe="aspire"
fi
cli_path="${INSTALL_PATH}/${cli_exe}"
INSTALLED_CLI_PATH="$cli_path"
INSTALLED_CLI_OS="$os"
INSTALLED_CLI_ARCH="$arch"

say_info "Aspire CLI successfully installed to: ${GREEN}$cli_path${RESET}"

Expand All @@ -1020,6 +1026,33 @@ download_and_install_archive() {
fi
}

setup_cli_bundle() {
local host_os host_arch

if ! host_os=$(detect_os) || ! host_arch=$(get_cli_architecture_from_architecture "<auto>"); then
say_warn "Skipping Aspire CLI bundle setup because the current platform could not be detected."
return 0
fi

# Cross-target archive downloads are supported, but the downloaded executable cannot
# safely be run to extract its embedded bundle on a different host platform.
if [[ "$INSTALLED_CLI_OS" != "$host_os" || "$INSTALLED_CLI_ARCH" != "$host_arch" ]]; then
say_info "Skipping Aspire CLI bundle setup for ${INSTALLED_CLI_OS}-${INSTALLED_CLI_ARCH} on ${host_os}-${host_arch}."
return 0
fi

if [[ "$DRY_RUN" == true ]]; then
say_info "[DRY RUN] Would run: $INSTALLED_CLI_PATH setup"
return 0
fi

say_verbose "Running: $INSTALLED_CLI_PATH setup"
if ! "$INSTALLED_CLI_PATH" setup; then
say_error "Aspire CLI bundle setup failed"
return 1
fi
}

# Main entry point — wraps everything after function definitions.
# Guarded so that `source get-aspire-cli.sh` loads functions without
# executing the main flow (enables Tier-1 unit tests).
Expand Down Expand Up @@ -1106,6 +1139,10 @@ main() {
printf '{"source":"script"}\n' > "$sidecar_path"
fi

if ! setup_cli_bundle; then
exit 1
fi

# Skip PATH configuration if --skip-path is set
if [[ "$SKIP_PATH" != true ]]; then
# Handle GitHub Actions environment
Expand Down
5 changes: 3 additions & 2 deletions src/Aspire.Cli/Bundles/BundleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,7 @@ private bool TryRestoreLinks(string layoutPath, IReadOnlyDictionary<string, stri

/// <summary>
/// Returns <see langword="true"/> if <paramref name="versionDir"/> contains the
/// essential bundle components (<c>managed/aspire-managed</c> and a DCP directory).
/// essential bundle components (<c>managed/aspire-managed</c> and the DCP executable).
/// </summary>
internal static bool IsVersionedLayoutValid(string versionDir)
{
Expand Down Expand Up @@ -617,7 +617,8 @@ internal static bool IsVersionedLayoutValid(string versionDir)
}

var dcpDir = Path.Combine(versionDir, BundleDiscovery.DcpDirectoryName);
if (!Directory.Exists(dcpDir))
var dcpExe = BundleDiscovery.GetDcpExecutablePath(dcpDir);
if (!Directory.Exists(dcpDir) || !File.Exists(dcpExe))
{
return false;
}
Expand Down
14 changes: 10 additions & 4 deletions src/Aspire.Cli/Layout/LayoutDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ public bool IsBundleModeAvailable(string? projectDirectory = null)
var bundleManagedPath = Path.Combine(bundlePath, BundleDiscovery.ManagedDirectoryName);
var bundleDcpPath = Path.Combine(bundlePath, BundleDiscovery.DcpDirectoryName);
var managedExeName = BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName);
var bundleDcpExe = BundleDiscovery.GetDcpExecutablePath(bundleDcpPath);

_logger.LogDebug("TryInferLayout: Checking layout at {Path}", layoutPath);
_logger.LogDebug(" {Dir}/{Managed}/: {Exists}", BundleDiscovery.BundleDirectoryName, BundleDiscovery.ManagedDirectoryName, Directory.Exists(bundleManagedPath) ? "exists" : "MISSING");
Expand All @@ -260,8 +261,9 @@ public bool IsBundleModeAvailable(string? projectDirectory = null)
{
var bundleManagedExe = Path.Combine(bundleManagedPath, managedExeName);
_logger.LogDebug(" {Dir}/{Managed}/{Exe}: {Exists}", BundleDiscovery.BundleDirectoryName, BundleDiscovery.ManagedDirectoryName, managedExeName, File.Exists(bundleManagedExe) ? "exists" : "MISSING");
_logger.LogDebug(" {Dir}/{Dcp}/{Exe}: {Exists}", BundleDiscovery.BundleDirectoryName, BundleDiscovery.DcpDirectoryName, Path.GetFileName(bundleDcpExe), File.Exists(bundleDcpExe) ? "exists" : "MISSING");

if (File.Exists(bundleManagedExe))
if (File.Exists(bundleManagedExe) && File.Exists(bundleDcpExe))
{
_logger.LogDebug("TryInferLayout: New bundle/ layout is valid");
return new LayoutConfiguration
Expand All @@ -279,6 +281,7 @@ public bool IsBundleModeAvailable(string? projectDirectory = null)
// Legacy layout: top-level managed/ and dcp/ directories (or reparse points).
var managedPath = Path.Combine(layoutPath, BundleDiscovery.ManagedDirectoryName);
var dcpPath = Path.Combine(layoutPath, BundleDiscovery.DcpDirectoryName);
var dcpExePath = BundleDiscovery.GetDcpExecutablePath(dcpPath);

_logger.LogDebug(" {Dir}/: {Exists}", BundleDiscovery.ManagedDirectoryName, Directory.Exists(managedPath) ? "exists" : "MISSING");
_logger.LogDebug(" {Dir}/: {Exists}", BundleDiscovery.DcpDirectoryName, Directory.Exists(dcpPath) ? "exists" : "MISSING");
Expand All @@ -292,10 +295,11 @@ public bool IsBundleModeAvailable(string? projectDirectory = null)
// Check for aspire-managed executable
var managedExePath = Path.Combine(managedPath, managedExeName);
_logger.LogDebug(" managed/{ManagedExe}: {Exists}", managedExeName, File.Exists(managedExePath) ? "exists" : "MISSING");
_logger.LogDebug(" dcp/{DcpExe}: {Exists}", Path.GetFileName(dcpExePath), File.Exists(dcpExePath) ? "exists" : "MISSING");

if (!File.Exists(managedExePath))
if (!File.Exists(managedExePath) || !File.Exists(dcpExePath))
{
_logger.LogDebug("TryInferLayout: Layout rejected - aspire-managed not found");
_logger.LogDebug("TryInferLayout: Layout rejected - required executable not found");
return null;
}

Expand Down Expand Up @@ -338,7 +342,9 @@ private bool ValidateLayout(LayoutConfiguration layout)

// Require DCP for valid layouts
var dcpPath = layout.GetComponentPath(LayoutComponent.Dcp);
if (dcpPath is null || !Directory.Exists(dcpPath))
if (dcpPath is null ||
!Directory.Exists(dcpPath) ||
!File.Exists(BundleDiscovery.GetDcpExecutablePath(dcpPath)))
{
_logger.LogDebug("Layout validation failed: DCP not found");
return false;
Expand Down
6 changes: 3 additions & 3 deletions src/Aspire.Cli/Projects/AppHostInfoResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,9 @@ private async Task<AppHostProjectInfo> FetchAppHostInfoCoreAsync(FileInfo projec
var expectedCacheKey = diskCache.GetCacheKey(projectFile);

// Mirror the property/item shape used by DotNetCliRunner.GetAppHostInformationAsync and
// additionally request AspireUseCliBundle, UserSecretsId, and run metadata so the CLI
// bundle handoff, --isolated user-secrets clone, and post-build AppHost launch path do
// not require their own MSBuild evaluations.
// additionally request AspireUseCliBundle, UserSecretsId, and run metadata so the CLI bundle
// handoff, --isolated user-secrets clone, and post-build AppHost launch path do not require
// their own MSBuild evaluations.
// Adding extra -getProperty names is an evaluation-only cost.
//
// The Run* properties (RunCommand, RunArguments, RunWorkingDirectory) are only
Expand Down
66 changes: 31 additions & 35 deletions src/Aspire.Cli/Projects/DotNetAppHostProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1394,7 +1394,6 @@ public async Task<AppHostValidationResult> ValidateAppHostAsync(FileInfo appHost

// The resolver owns the cache/MSBuild fallback so validation and later run/publish
// decisions share a single source of truth for AppHost project metadata.
using var cliBundleLease = await AcquireCliBundleLayoutAsync(cancellationToken);
var information = await _appHostInfoResolver.GetAppHostInfoAsync(appHostFile, cancellationToken);

if (information.ExitCode == 0 && information.IsAspireHost)
Expand Down Expand Up @@ -1426,7 +1425,6 @@ public async Task<AppHostValidationResult> ValidateAppHostAsync(FileInfo appHost
// Use the same MSBuild-based inspection as validation so version resolution
// follows the project model that run/publish already rely on, including
// SDK-style projects, package references, and Central Package Management.
using var cliBundleLease = await AcquireCliBundleLayoutAsync(cancellationToken);
var information = await _appHostInfoResolver.GetAppHostInfoAsync(appHostFile, cancellationToken);
return information.ExitCode == 0 && information.IsAspireHost
? information.AspireHostingVersion
Expand Down Expand Up @@ -1511,8 +1509,10 @@ public async Task<int> RunAsync(AppHostProjectContext context, CancellationToken
// is fine for non-CliBundle AppHosts that don't use WithTerminal() — the lease
// is best-effort and a missing layout just means no terminal host env vars.
var canQueryCliBundleProperty = !isSingleFileAppHost || !context.NoBuild;
var injectDcpAndDashboard = canQueryCliBundleProperty
&& await IsUsingCliBundleAsync(effectiveAppHostFile, cancellationToken);
var appHostInfo = canQueryCliBundleProperty
? await _appHostInfoResolver.GetAppHostInfoAsync(effectiveAppHostFile, cancellationToken)
: null;
var injectDcpAndDashboard = appHostInfo?.IsUsingCliBundle == true;
ConfigureCliBundleEnvironment(env, cliBundleLease, injectDcpAndDashboard);

// RunCommand may display captured AppHost output as soon as BuildCompletionSource is signaled.
Expand Down Expand Up @@ -2278,9 +2278,6 @@ public async Task<int> PublishAsync(PublishContext context, CancellationToken ca
var effectiveAppHostFile = context.AppHostFile;
var isSingleFileAppHost = !IsProjectFile(effectiveAppHostFile) && IsValidSingleFileAppHost(effectiveAppHostFile);
var env = new Dictionary<string, string>(context.EnvironmentVariables);
var cliBundleLease = await AcquireCliBundleLayoutAsync(cancellationToken);
using var cliBundleLeaseScope = cliBundleLease;
ConfigureCliBundleEnvironment(env, cliBundleLease, injectDcpAndDashboard: false);

// Check compatibility for project-based apphosts
if (!isSingleFileAppHost)
Expand All @@ -2305,12 +2302,6 @@ public async Task<int> PublishAsync(PublishContext context, CancellationToken ca
}
}

// See RunAsync for the rationale: terminal host env vars are injected even when
// the AppHost did not opt into AspireUseCliBundle, but DCP/Dashboard env vars are
// not (they would clobber per-RID NuGet metadata).
var injectDcpAndDashboardForPublish = await IsUsingCliBundleAsync(effectiveAppHostFile, cancellationToken);
ConfigureCliBundleEnvironment(env, cliBundleLease, injectDcpAndDashboardForPublish);

// Build the apphost (unless --no-build is specified)
if (!isSingleFileAppHost && !context.NoBuild)
{
Expand Down Expand Up @@ -2470,14 +2461,6 @@ await _runner.InitUserSecretsAsync(
}
}

private async Task<bool> IsUsingCliBundleAsync(FileInfo projectFile, CancellationToken cancellationToken)
{
// Reuse the cached MSBuild result so `AspireUseCliBundle` is fetched alongside the
// IsAspireHost/version inspection rather than triggering a third project evaluation.
var info = await _appHostInfoResolver.GetAppHostInfoAsync(projectFile, cancellationToken);
return info.IsUsingCliBundle;
}

private Task<BundleLayoutLease?> AcquireCliBundleLayoutAsync(CancellationToken cancellationToken)
=> _bundleService.EnsureExtractedAndAcquireLayoutAsync("cli", "dotnet-apphost", cancellationToken);

Expand All @@ -2494,28 +2477,32 @@ private void ConfigureCliBundleEnvironment(
// disk) and would otherwise spam the debug log on every run.
if (injectDcpAndDashboard)
{
_logger.LogDebug("AspireUseCliBundle is enabled, but the Aspire CLI bundle layout was not available from this CLI process.");
_logger.LogDebug("AspireUseCliBundle is enabled, but the Aspire CLI bundle layout was not available from this CLI process. The AppHost will resolve configured, inherited, or assembly-metadata paths.");
}
// Don't return yet — repo-mode runs (DEBUG, `dotnet run --project src/Aspire.Cli`)
// can still inject the terminal host path from the just-built artifact even when
// no bundle layout exists at all (e.g. clean dev machine with no `aspire` install).
}

if (!env.ContainsKey("AspireCliBundlePath") && !string.IsNullOrEmpty(layout?.LayoutPath))
if (!HasEnvironmentOverride(env, "AspireCliBundlePath") && !string.IsNullOrEmpty(layout?.LayoutPath))
{
env["AspireCliBundlePath"] = layout.LayoutPath;
}

if (injectDcpAndDashboard && layout is not null)
{
if (!env.ContainsKey(BundleDiscovery.DcpPathEnvVar) && layout.GetDcpPath() is { } dcpPath)
if (!IsUsableDcpDirectory(GetEffectiveEnvironmentValue(env, BundleDiscovery.DcpPathEnvVar)) &&
layout.GetDcpPath() is { } layoutDcpPath &&
IsUsableDcpDirectory(layoutDcpPath))
{
env[BundleDiscovery.DcpPathEnvVar] = dcpPath;
env[BundleDiscovery.DcpPathEnvVar] = layoutDcpPath;
}

if (!env.ContainsKey(BundleDiscovery.DashboardPathEnvVar) && layout.GetManagedPath() is { } managedPath)
if (!IsUsableDashboardPath(GetEffectiveEnvironmentValue(env, BundleDiscovery.DashboardPathEnvVar)) &&
layout.GetManagedPath() is { } layoutManagedPath &&
IsUsableDashboardPath(layoutManagedPath))
{
env[BundleDiscovery.DashboardPathEnvVar] = managedPath;
env[BundleDiscovery.DashboardPathEnvVar] = layoutManagedPath;
}
}

Expand All @@ -2539,13 +2526,13 @@ private void ConfigureCliBundleEnvironment(
// "older CLI" diagnostic. Installed CLIs are unaffected because DetectRepositoryRoot
// only resolves via env var in release builds.
// 3) Bundle layout aspire-managed (normal `aspire run` install path).
if (!env.ContainsKey(BundleDiscovery.TerminalHostPathEnvVar))
if (!HasEnvironmentOverride(env, BundleDiscovery.TerminalHostPathEnvVar))
{
var terminalHostPath = TryGetRepoLocalManagedPath() ?? layout?.GetManagedPath();
if (terminalHostPath is not null)
if (terminalHostPath is not null && IsUsableDashboardPath(terminalHostPath))
{
env[BundleDiscovery.TerminalHostPathEnvVar] = terminalHostPath;
if (!env.ContainsKey(BundleDiscovery.TerminalHostInvocationArgsEnvVar))
if (!HasEnvironmentOverride(env, BundleDiscovery.TerminalHostInvocationArgsEnvVar))
{
env[BundleDiscovery.TerminalHostInvocationArgsEnvVar] = "terminalhost";
}
Expand All @@ -2555,11 +2542,20 @@ private void ConfigureCliBundleEnvironment(
layoutLease?.AddEnvironment(env);
}

/// <summary>
/// Resolves the repo-local <c>aspire-managed</c> binary when the CLI is running from
/// an Aspire repo checkout (typically <c>dotnet run --project src/Aspire.Cli</c>).
/// Returns <c>null</c> in release builds and when no repo-local build exists.
/// </summary>
private bool HasEnvironmentOverride(IReadOnlyDictionary<string, string> env, string name)
=> !string.IsNullOrWhiteSpace(GetEffectiveEnvironmentValue(env, name));

private string? GetEffectiveEnvironmentValue(IReadOnlyDictionary<string, string> env, string name)
=> env.TryGetValue(name, out var value) ? value : _environment.GetEnvironmentVariable(name);

private static bool IsUsableDcpDirectory(string? path)
=> !string.IsNullOrWhiteSpace(path) &&
Directory.Exists(path) &&
File.Exists(BundleDiscovery.GetDcpExecutablePath(path));

private static bool IsUsableDashboardPath(string? path)
=> !string.IsNullOrWhiteSpace(path) && File.Exists(path);

/// <summary>
/// Resolves the repo-local <c>aspire-managed</c> binary when the CLI is running from
/// an Aspire repo checkout (typically <c>dotnet run --project src/Aspire.Cli</c>).
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading