// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// This file is source-linked into multiple projects:
// - Aspire.Hosting
// - Aspire.Cli
// - Aspire.Managed
// Do not add project-specific dependencies.
using System.Runtime.InteropServices;
namespace Aspire.Shared;
/// <summary>
/// Shared logic for discovering Aspire bundle components.
/// Used by both CLI and Aspire.Hosting to ensure consistent discovery behavior.
/// </summary>
internal static class BundleDiscovery
{
// ═══════════════════════════════════════════════════════════════════════
// ENVIRONMENT VARIABLE CONSTANTS
// ═══════════════════════════════════════════════════════════════════════
/// <summary>
/// Environment variable for the root of the bundle layout.
/// </summary>
public const string LayoutPathEnvVar = "ASPIRE_LAYOUT_PATH";
/// <summary>
/// Environment variable for overriding the DCP path.
/// </summary>
public const string DcpPathEnvVar = "ASPIRE_DCP_PATH";
/// <summary>
/// Environment variable for overriding the Dashboard path.
/// Used by DcpOptions and DashboardEventHandlers to locate the native Dashboard executable.
/// </summary>
public const string DashboardPathEnvVar = "ASPIRE_DASHBOARD_PATH";
/// <summary>
/// Environment variable for overriding the aspire-managed path.
/// </summary>
public const string ManagedPathEnvVar = "ASPIRE_MANAGED_PATH";
/// <summary>
/// Environment variable for the terminal host binary path. Read by Aspire.Hosting
/// to resolve the binary that backs <c>WithTerminal()</c> resources. Injected by
/// the CLI at launch time pointing at the standalone host or a compatibility forwarder.
/// </summary>
public const string TerminalHostPathEnvVar = "ASPIRE_TERMINAL_HOST_PATH";
/// <summary>
/// Environment variable for the invocation args prepended when launching the
/// terminal host binary. Set to <c>"terminalhost"</c> when the binary is the
/// multi-mode <c>aspire-managed</c> exe so the dispatcher routes to the
/// terminal host subcommand. Treated as a pair with
/// <see cref="TerminalHostPathEnvVar"/>: callers that synthesize one without
/// the other can produce a launch failure.
/// </summary>
public const string TerminalHostInvocationArgsEnvVar = "ASPIRE_TERMINAL_HOST_INVOCATION_ARGS";
/// <summary>
/// Environment variable containing the leased version directory for bundle-owned child processes.
/// </summary>
public const string BundleVersionDirectoryEnvVar = "ASPIRE_BUNDLE_VERSION_DIR";
/// <summary>
/// Environment variable to force SDK mode (skip bundle detection).
/// </summary>
public const string UseGlobalDotNetEnvVar = "ASPIRE_USE_GLOBAL_DOTNET";
/// <summary>
/// Environment variable indicating development mode (Aspire repo checkout).
/// </summary>
public const string RepoRootEnvVar = "ASPIRE_REPO_ROOT";
// ═══════════════════════════════════════════════════════════════════════
// BUNDLE LAYOUT DIRECTORY NAMES
// ═══════════════════════════════════════════════════════════════════════
/// <summary>
/// Directory name for DCP in the bundle layout.
/// </summary>
public const string DcpDirectoryName = "dcp";
/// <summary>
/// Directory name for the managed binary in the bundle layout.
/// </summary>
public const string ManagedDirectoryName = "managed";
/// <summary>
/// Directory name for the Dashboard executable and static assets in the bundle layout.
/// </summary>
public const string DashboardDirectoryName = "dashboard";
/// <summary>
/// Directory name for the standalone terminal host and its native dependencies.
/// </summary>
public const string TerminalHostDirectoryName = "terminalhost";
/// <summary>
/// Directory name for the single top-level reparse point that links to the
/// active versioned bundle directory. Components (<c>managed/</c>, <c>dashboard/</c>, <c>terminalhost/</c>, and <c>dcp/</c>)
/// are resolved as subdirectories of this link target.
/// </summary>
public const string BundleDirectoryName = "bundle";
// ═══════════════════════════════════════════════════════════════════════
// EXECUTABLE NAMES (without path, just the file name)
// ═══════════════════════════════════════════════════════════════════════
/// <summary>
/// Executable name for the unified managed binary.
/// </summary>
public const string ManagedExecutableName = "aspire-managed";
/// <summary>
/// Executable name for the Native AOT Dashboard.
/// </summary>
public const string DashboardExecutableName = "Aspire.Dashboard";
/// <summary>
/// Executable name for the standalone terminal host.
/// </summary>
public const string TerminalHostExecutableName = "Aspire.TerminalHost";
// ═══════════════════════════════════════════════════════════════════════
// DISCOVERY METHODS
// ═══════════════════════════════════════════════════════════════════════
/// <summary>
/// Attempts to discover DCP from a base directory.
/// Checks for the expected bundle layout structure.
/// </summary>
/// <param name="baseDirectory">The base directory to search from (e.g., CLI location or entry assembly directory).</param>
/// <param name="dcpCliPath">The full path to the DCP executable if found.</param>
/// <param name="dcpExtensionsPath">The full path to the DCP extensions directory if found.</param>
/// <param name="dcpBinPath">The full path to the DCP bin directory if found.</param>
/// <returns>True if DCP was found, false otherwise.</returns>
public static bool TryDiscoverDcpFromDirectory(
string baseDirectory,
out string? dcpCliPath,
out string? dcpExtensionsPath,
out string? dcpBinPath)
{
dcpCliPath = null;
dcpExtensionsPath = null;
dcpBinPath = null;
if (string.IsNullOrEmpty(baseDirectory) || !Directory.Exists(baseDirectory))
{
return false;
}
var dcpDir = Path.Combine(baseDirectory, DcpDirectoryName);
var dcpExePath = GetDcpExecutablePath(dcpDir);
if (File.Exists(dcpExePath))
{
dcpCliPath = dcpExePath;
dcpExtensionsPath = Path.Combine(dcpDir, "ext");
dcpBinPath = Path.Combine(dcpExtensionsPath, "bin");
return true;
}
return false;
}
/// <summary>
/// Attempts to discover the aspire-managed binary from a base directory.
/// </summary>
/// <param name="baseDirectory">The base directory to search from.</param>
/// <param name="managedPath">The full path to the aspire-managed executable if found.</param>
/// <returns>True if aspire-managed was found, false otherwise.</returns>
public static bool TryDiscoverManagedFromDirectory(
string baseDirectory,
out string? managedPath)
{
managedPath = null;
if (string.IsNullOrEmpty(baseDirectory) || !Directory.Exists(baseDirectory))
{
return false;
}
var managedDir = Path.Combine(baseDirectory, ManagedDirectoryName);
var managedExe = Path.Combine(managedDir, GetExecutableFileName(ManagedExecutableName));
if (File.Exists(managedExe))
{
managedPath = managedExe;
return true;
}
return false;
}
/// <summary>
/// Attempts to discover DCP relative to the entry assembly.
/// This is used by Aspire.Hosting when no environment variables are set.
/// </summary>
public static bool TryDiscoverDcpFromEntryAssembly(
out string? dcpCliPath,
out string? dcpExtensionsPath,
out string? dcpBinPath)
{
dcpCliPath = null;
dcpExtensionsPath = null;
dcpBinPath = null;
var baseDir = GetEntryAssemblyDirectory();
if (baseDir is null)
{
return false;
}
return TryDiscoverDcpFromDirectory(baseDir, out dcpCliPath, out dcpExtensionsPath, out dcpBinPath);
}
/// <summary>
/// Attempts to discover aspire-managed relative to the entry assembly.
/// This is used by Aspire.Hosting when no environment variables are set.
/// </summary>
public static bool TryDiscoverManagedFromEntryAssembly(out string? managedPath)
{
managedPath = null;
var baseDir = GetEntryAssemblyDirectory();
if (baseDir is null)
{
return false;
}
return TryDiscoverManagedFromDirectory(baseDir, out managedPath);
}
/// <summary>
/// Attempts to discover DCP relative to the current process.
/// This is used by CLI to find DCP in the bundle layout.
/// </summary>
public static bool TryDiscoverDcpFromProcessPath(
out string? dcpCliPath,
out string? dcpExtensionsPath,
out string? dcpBinPath)
{
dcpCliPath = null;
dcpExtensionsPath = null;
dcpBinPath = null;
var baseDir = GetProcessDirectory();
if (baseDir is null)
{
return false;
}
return TryDiscoverDcpFromDirectory(baseDir, out dcpCliPath, out dcpExtensionsPath, out dcpBinPath);
}
/// <summary>
/// Attempts to discover aspire-managed relative to the current process.
/// </summary>
public static bool TryDiscoverManagedFromProcessPath(out string? managedPath)
{
managedPath = null;
var baseDir = GetProcessDirectory();
if (baseDir is null)
{
return false;
}
return TryDiscoverManagedFromDirectory(baseDir, out managedPath);
}
/// <summary>
/// Returns the path to the standalone terminal host inside an Aspire repo checkout.
/// Used by callers that want to point dev-mode child processes at the repo's just-built
/// terminal host instead of the user's installed CLI bundle (which may be stale).
/// Returns <c>null</c> when <paramref name="repoRoot"/> is empty or the artifact is missing.
/// </summary>
/// <remarks>
/// Hardcoded to Debug/net11.0 to keep behavior predictable — Release configurations are
/// rarely used during inner-loop dev, and probing every TFM/config combination makes the
/// outcome depend on stale build outputs from earlier sessions.
/// </remarks>
public static string? TryGetRepoLocalTerminalHostPath(string? repoRoot)
{
if (string.IsNullOrEmpty(repoRoot))
{
return null;
}
var terminalHostPath = Path.Combine(
repoRoot,
"artifacts",
"bin",
"Aspire.TerminalHost",
"Debug",
"net11.0",
GetExecutableFileName(TerminalHostExecutableName));
return File.Exists(terminalHostPath) ? terminalHostPath : null;
}
// ═══════════════════════════════════════════════════════════════════════
// HELPER METHODS
// ═══════════════════════════════════════════════════════════════════════
/// <summary>
/// Gets the full path to the DCP executable given a DCP directory.
/// </summary>
public static string GetDcpExecutablePath(string dcpDirectory)
{
var exeName = GetDcpExecutableName();
return Path.Combine(dcpDirectory, exeName);
}
/// <summary>
/// Gets the platform-specific DCP executable name.
/// </summary>
public static string GetDcpExecutableName()
{
return OperatingSystem.IsWindows() ? "dcp.exe" : "dcp";
}
/// <summary>
/// Gets the platform-specific executable name with extension.
/// </summary>
/// <param name="baseName">The base executable name without extension (e.g., "aspire-managed").</param>
/// <returns>The executable name with platform-appropriate extension.</returns>
public static string GetExecutableFileName(string baseName)
{
return OperatingSystem.IsWindows() ? $"{baseName}.exe" : baseName;
}
/// <summary>
/// Gets the platform-specific DLL name.
/// </summary>
/// <param name="baseName">The base name without extension (e.g., "aspire-server").</param>
/// <returns>The DLL name (e.g., "aspire-server.dll").</returns>
public static string GetDllFileName(string baseName)
{
return $"{baseName}.dll";
}
/// <summary>
/// Determines if the given file path points to an aspire-managed binary.
/// </summary>
public static bool IsAspireManagedBinary(string path)
{
var fileName = Path.GetFileNameWithoutExtension(path);
return string.Equals(fileName, ManagedExecutableName, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Gets the current platform's runtime identifier.
/// </summary>
public static string GetCurrentRuntimeIdentifier()
{
return RuntimeInformation.RuntimeIdentifier;
}
/// <summary>
/// Gets the archive extension for the current platform.
/// </summary>
public static string GetArchiveExtension()
{
return OperatingSystem.IsWindows() ? ".zip" : ".tar.gz";
}
/// <summary>
/// Gets the directory containing the entry assembly, if available.
/// For native AOT or single-file apps, uses AppContext.BaseDirectory or ProcessPath fallback.
/// </summary>
private static string? GetEntryAssemblyDirectory()
{
// For native AOT and single-file apps, Assembly.Location returns empty
// Use AppContext.BaseDirectory as the primary fallback
var baseDir = AppContext.BaseDirectory;
if (!string.IsNullOrEmpty(baseDir) && Directory.Exists(baseDir))
{
// Remove trailing separator if present
return baseDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
// Final fallback: try process path
return GetProcessDirectory();
}
/// <summary>
/// Gets the directory containing the current process executable.
/// </summary>
private static string? GetProcessDirectory()
{
var processPath = Environment.ProcessPath;
if (string.IsNullOrEmpty(processPath))
{
return null;
}
return Path.GetDirectoryName(processPath);
}
}