File: Projects\AppHostServerProject.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.Tool.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Xml.Linq;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Projects;
 
/// <summary>
/// Factory for creating AppHostServerProject instances with required dependencies.
/// </summary>
internal interface IAppHostServerProjectFactory
{
    AppHostServerProject Create(string appPath);
}
 
/// <summary>
/// Factory implementation that creates AppHostServerProject instances with IPackagingService and IConfigurationService.
/// </summary>
internal sealed class AppHostServerProjectFactory(
    IDotNetCliRunner dotNetCliRunner,
    IPackagingService packagingService,
    IConfigurationService configurationService,
    ILogger<AppHostServerProject> logger) : IAppHostServerProjectFactory
{
    public AppHostServerProject Create(string appPath) => new AppHostServerProject(appPath, dotNetCliRunner, packagingService, configurationService, logger);
}
 
/// <summary>
/// Manages the AppHost server project that hosts the Aspire.Hosting runtime for polyglot apphosts.
/// This project is dynamically generated and built to provide the Aspire infrastructure
/// (distributed application builder, resource management, dashboard, etc.) that polyglot apphosts
/// (TypeScript, Python, etc.) connect to via JSON-RPC to define and manage their resources.
/// </summary>
internal sealed class AppHostServerProject
{
    private const string ProjectHashFileName = ".projecthash";
    private const string FolderPrefix = ".aspire";
    private const string AppsFolder = "hosts";
    public const string ProjectFileName = "AppHostServer.csproj";
    private const string ProjectDllName = "AppHostServer.dll";
    private const string TargetFramework = "net10.0";
 
    /// <summary>
    /// Gets the default Aspire SDK version based on the CLI version.
    /// </summary>
    public static string DefaultSdkVersion => GetEffectiveVersion();
 
    private static string GetEffectiveVersion()
    {
        var version = VersionHelper.GetDefaultTemplateVersion();
 
        // Strip the commit SHA suffix (e.g., "9.2.0+abc123" -> "9.2.0")
        var plusIndex = version.IndexOf('+');
        if (plusIndex > 0)
        {
            version = version[..plusIndex];
        }
 
        // Dev versions (e.g., "13.2.0-dev") don't exist on NuGet, fall back to latest stable
        if (version.EndsWith("-dev", StringComparison.OrdinalIgnoreCase))
        {
            // Use the latest stable version available on NuGet
            // This should be updated when new stable versions are released
            return "13.1.0";
        }
        return version;
    }
 
    /// <summary>
    /// Path to local Aspire repo root (e.g., /path/to/aspire).
    /// When set, uses direct project references instead of NuGet packages.
    /// </summary>
    public static string? LocalAspirePath = Environment.GetEnvironmentVariable("ASPIRE_REPO_ROOT");
 
    public const string BuildFolder = "build";
    private const string AssemblyName = "AppHostServer";
    private readonly string _projectModelPath;
    private readonly string _appPath;
    private readonly string _userSecretsId;
    private readonly IDotNetCliRunner _dotNetCliRunner;
    private readonly IPackagingService _packagingService;
    private readonly IConfigurationService _configurationService;
    private readonly ILogger<AppHostServerProject> _logger;
 
    /// <summary>
    /// Initializes a new instance of the AppHostServerProject class.
    /// </summary>
    /// <param name="appPath">Specifies the application path for the custom language.</param>
    /// <param name="dotNetCliRunner">The .NET CLI runner for executing dotnet commands.</param>
    /// <param name="packagingService">The packaging service for channel resolution.</param>
    /// <param name="configurationService">The configuration service for reading global settings.</param>
    /// <param name="logger">The logger for diagnostic output.</param>
    /// <param name="projectModelPath">Optional custom path for the project model directory. If not specified, uses a temp directory based on appPath hash.</param>
    public AppHostServerProject(string appPath, IDotNetCliRunner dotNetCliRunner, IPackagingService packagingService, IConfigurationService configurationService, ILogger<AppHostServerProject> logger, string? projectModelPath = null)
    {
        _appPath = Path.GetFullPath(appPath);
        _appPath = new Uri(_appPath).LocalPath;
        _appPath = OperatingSystem.IsWindows() ? _appPath.ToLowerInvariant() : _appPath;
        _dotNetCliRunner = dotNetCliRunner;
        _packagingService = packagingService;
        _configurationService = configurationService;
        _logger = logger;
 
        var pathHash = SHA256.HashData(Encoding.UTF8.GetBytes(_appPath));
 
        if (projectModelPath is not null)
        {
            _projectModelPath = projectModelPath;
        }
        else
        {
            var pathDir = Convert.ToHexString(pathHash)[..12].ToLowerInvariant();
            _projectModelPath = Path.Combine(Path.GetTempPath(), FolderPrefix, AppsFolder, pathDir);
        }
 
        // Create a stable UserSecretsId based on the app path hash
        _userSecretsId = new Guid(pathHash[..16]).ToString();
 
        Directory.CreateDirectory(_projectModelPath);
    }
 
    public string ProjectModelPath => _projectModelPath;
    public string AppPath => _appPath;
    public string UserSecretsId => _userSecretsId;
    public string BuildPath => Path.Combine(_projectModelPath, BuildFolder);
 
    /// <summary>
    /// Gets the full path to the AppHost server project file.
    /// </summary>
    public string GetProjectFilePath() => Path.Combine(_projectModelPath, ProjectFileName);
 
    public string GetProjectHash()
    {
        var hashFilePath = Path.Combine(_projectModelPath, ProjectHashFileName);
 
        if (File.Exists(hashFilePath))
        {
            return File.ReadAllText(hashFilePath);
        }
 
        return string.Empty;
    }
 
    public void SaveProjectHash(string hash)
    {
        var hashFilePath = Path.Combine(_projectModelPath, ProjectHashFileName);
        File.WriteAllText(hashFilePath, hash);
    }
 
    /// <summary>
    /// Scaffolds the project files.
    /// </summary>
    /// <param name="sdkVersion">The Aspire SDK version to use.</param>
    /// <param name="packages">The package references to include.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <param name="additionalProjectReferences">Optional additional project references to include (e.g., integration projects for SDK generation).</param>
    /// <returns>A tuple containing the full path to the project file and the channel name used (if any).</returns>
    public async Task<(string ProjectPath, string? ChannelName)> CreateProjectFilesAsync(
        string sdkVersion,
        IEnumerable<(string Name, string Version)> packages,
        CancellationToken cancellationToken = default,
        IEnumerable<string>? additionalProjectReferences = null)
    {
        // Clean obj folder to ensure fresh NuGet restore (avoids stale cache when channel/SDK changes)
        var objPath = Path.Combine(_projectModelPath, "obj");
        if (Directory.Exists(objPath))
        {
            try
            {
                Directory.Delete(objPath, recursive: true);
            }
            catch (Exception ex)
            {
                _logger.LogDebug(ex, "Failed to delete obj folder at {ObjPath}", objPath);
            }
        }
 
        // Create Program.cs that starts the RemoteHost server
        // The server reads AtsAssemblies from appsettings.json to load integration assemblies
        var programCs = """
            await Aspire.Hosting.RemoteHost.RemoteHostServer.RunAsync(args);
            """;
 
        File.WriteAllText(Path.Combine(_projectModelPath, "Program.cs"), programCs);
 
        // Create appsettings.json with the list of ATS assemblies
        // These are the assemblies that will be scanned for [AspireExport] capabilities
        // Include all packages since any package could contribute capabilities via [AspireExport]
        // The code generation package for the language is already included in packages
        var atsAssemblies = new List<string> { "Aspire.Hosting" };
        foreach (var pkg in packages)
        {
            if (!atsAssemblies.Contains(pkg.Name, StringComparer.OrdinalIgnoreCase))
            {
                atsAssemblies.Add(pkg.Name);
            }
        }
 
        // Add additional project references' assembly names
        if (additionalProjectReferences is not null)
        {
            foreach (var projectPath in additionalProjectReferences)
            {
                var assemblyName = Path.GetFileNameWithoutExtension(projectPath);
                if (!atsAssemblies.Contains(assemblyName, StringComparer.OrdinalIgnoreCase))
                {
                    atsAssemblies.Add(assemblyName);
                }
            }
        }
 
        var assembliesJson = string.Join(",\n      ", atsAssemblies.Select(a => $"\"{a}\""));
        var appSettingsJson = $$"""
            {
              "Logging": {
                "LogLevel": {
                  "Default": "Information",
                  "Microsoft.AspNetCore": "Warning",
                  "Aspire.Hosting.Dcp": "Warning"
                }
              },
              "AtsAssemblies": [
                {{assembliesJson}}
              ]
            }
            """;
 
        var appSettingsJsonPath = Path.Combine(_projectModelPath, "appsettings.json");
        File.WriteAllText(appSettingsJsonPath, appSettingsJson);
 
        // Handle nuget.config - copy user's config and merge channel sources
        var nugetConfigPath = Path.Combine(_projectModelPath, "nuget.config");
        string? channelName = null;
 
        // First, copy user's nuget.config if it exists (to preserve private feeds/auth)
        var userNugetConfig = FindNuGetConfig(_appPath);
        if (userNugetConfig is not null)
        {
            File.Copy(userNugetConfig, nugetConfigPath, overwrite: true);
        }
 
        // Get the appropriate channel from the packaging service (same logic as aspire new/init)
        var channels = await _packagingService.GetChannelsAsync(cancellationToken);
 
        // Check for channel setting - project-local .aspire/settings.json takes precedence over global config.
        // This is important for `aspire update` scenarios where the user switches channels:
        // UpdatePackagesAsync saves the new channel to project-local settings, then calls BuildAndGenerateSdkAsync
        // which eventually calls this method. We must read from project-local to use the newly selected channel.
        var localConfigPath = AspireJsonConfiguration.GetFilePath(_appPath);
        var localConfig = AspireJsonConfiguration.Load(_appPath);
        var configuredChannelName = localConfig?.Channel;
 
        _logger.LogDebug("Channel resolution: localConfigPath={LocalConfigPath}, exists={Exists}, channel={Channel}",
            localConfigPath, File.Exists(localConfigPath), configuredChannelName ?? "(null)");
 
        // Fall back to global config if no project-local channel is set
        if (string.IsNullOrEmpty(configuredChannelName))
        {
            configuredChannelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken);
            _logger.LogDebug("Fell back to global config channel: {Channel}", configuredChannelName ?? "(null)");
        }
 
        PackageChannel? channel;
        if (!string.IsNullOrEmpty(configuredChannelName))
        {
            // Use the configured channel if specified
            channel = channels.FirstOrDefault(c => string.Equals(c.Name, configuredChannelName, StringComparison.OrdinalIgnoreCase));
            _logger.LogDebug("Looking for channel '{ChannelName}' in {Count} channels, found={Found}",
                configuredChannelName, channels.Count(), channel is not null);
        }
        else
        {
            // Fall back to first explicit channel (staging/PR)
            channel = channels.FirstOrDefault(c => c.Type == PackageChannelType.Explicit);
            _logger.LogDebug("No configured channel, using first explicit channel: {Channel}", channel?.Name ?? "(none)");
        }
 
        // NuGetConfigMerger creates or updates the config with channel sources/mappings
        if (channel is not null)
        {
            await NuGetConfigMerger.CreateOrUpdateAsync(
                new DirectoryInfo(_projectModelPath),
                channel,
                cancellationToken: cancellationToken);
 
            // Track the channel name to return to caller
            channelName = channel.Name;
        }
 
        // Note: We don't create launchSettings.json here. Environment variables
        // (ports, OTLP endpoints, etc.) are read from the user's apphost.run.json
        // and passed directly to Run() at runtime.
 
        // Create the project file based on mode (local dev vs production)
        XDocument doc;
        if (LocalAspirePath is not null)
        {
            doc = CreateDevModeProjectFile(packages);
        }
        else
        {
            doc = CreateProductionProjectFile(sdkVersion, packages);
        }
 
        // Add additional project references (e.g., integration projects for SDK generation)
        if (additionalProjectReferences is not null)
        {
            var additionalProjectRefs = additionalProjectReferences
                .Select(path => new XElement("ProjectReference",
                    new XAttribute("Include", path),
                    new XElement("IsAspireProjectResource", "false")))
                .ToList();
 
            if (additionalProjectRefs.Count > 0)
            {
                doc.Root!.Add(new XElement("ItemGroup", additionalProjectRefs));
            }
        }
 
        // Add appsettings.json to be copied to output directory
        // This is required for the RemoteHostServer to find AtsAssemblies configuration
        doc.Root!.Add(new XElement("ItemGroup",
            new XElement("None",
                new XAttribute("Include", "appsettings.json"),
                new XAttribute("CopyToOutputDirectory", "PreserveNewest"))));
 
        // For dev mode, create Directory.Packages.props to enable central package management
        // This ensures transitive dependencies use versions from the repo's Directory.Packages.props
        if (LocalAspirePath is not null)
        {
            var repoRoot = Path.GetFullPath(LocalAspirePath);
            var repoDirectoryPackagesProps = Path.Combine(repoRoot, "Directory.Packages.props");
            var directoryPackagesProps = $"""
                <Project>
                  <PropertyGroup>
                    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
                    <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
                  </PropertyGroup>
                  <Import Project="{repoDirectoryPackagesProps}" />
                </Project>
                """;
            var directoryPackagesPropsPath = Path.Combine(_projectModelPath, "Directory.Packages.props");
            File.WriteAllText(directoryPackagesPropsPath, directoryPackagesProps);
        }
 
        var projectFileName = Path.Combine(_projectModelPath, ProjectFileName);
        doc.Save(projectFileName);
 
        return (projectFileName, channelName);
    }
 
    /// <summary>
    /// Creates a project file for local development using project references.
    /// Used when ASPIRE_REPO_ROOT is set.
    /// </summary>
    private XDocument CreateDevModeProjectFile(IEnumerable<(string Name, string Version)> packages)
    {
        var repoRoot = Path.GetFullPath(LocalAspirePath!) + Path.DirectorySeparatorChar;
 
        // Determine OS/architecture for DCP package name (matches Directory.Build.props logic)
        var (buildOs, buildArch) = GetBuildPlatform();
        var dcpPackageName = $"microsoft.developercontrolplane.{buildOs}-{buildArch}";
        var dcpVersion = GetDcpVersionFromRepo(repoRoot, buildOs, buildArch);
 
        var template = $"""
            <Project Sdk="Microsoft.NET.Sdk">
                <PropertyGroup>
                    <OutputType>exe</OutputType>
                    <TargetFramework>{TargetFramework}</TargetFramework>
                    <AssemblyName>{AssemblyName}</AssemblyName>
                    <OutDir>{BuildFolder}</OutDir>
                    <UserSecretsId>{_userSecretsId}</UserSecretsId>
                    <IsAspireHost>true</IsAspireHost>
                    <IsPublishable>false</IsPublishable>
                    <SelfContained>false</SelfContained>
                    <ImplicitUsings>enable</ImplicitUsings>
                    <Nullable>enable</Nullable>
                    <WarningLevel>0</WarningLevel>
                    <EnableNETAnalyzers>false</EnableNETAnalyzers>
                    <EnableRoslynAnalyzers>false</EnableRoslynAnalyzers>
                    <RunAnalyzers>false</RunAnalyzers>
                    <NoWarn>$(NoWarn);1701;1702;1591;CS8019;CS1591;CS1573;CS0168;CS0219;CS8618;CS8625;CS1998;CS1999</NoWarn>
                    <!-- Properties for in-repo building -->
                    <RepoRoot>{repoRoot}</RepoRoot>
                    <SkipValidateAspireHostProjectResources>true</SkipValidateAspireHostProjectResources>
                    <SkipAddAspireDefaultReferences>true</SkipAddAspireDefaultReferences>
                    <AspireHostingSDKVersion>42.42.42</AspireHostingSDKVersion>
                    <!-- DCP and Dashboard paths for local development -->
                    <DcpDir>$([MSBuild]::EnsureTrailingSlash('$(NuGetPackageRoot)')){dcpPackageName}/{dcpVersion}/tools/</DcpDir>
                    <AspireDashboardDir>{repoRoot}artifacts/bin/Aspire.Dashboard/Debug/net8.0/</AspireDashboardDir>
                </PropertyGroup>
                <ItemGroup>
                    <PackageReference Include="StreamJsonRpc" />
                    <PackageReference Include="Google.Protobuf" />
                </ItemGroup>
            </Project>
            """;
 
        var doc = XDocument.Parse(template);
 
        // Add project references for Aspire.Hosting.* packages, NuGet for others
        var projectRefGroup = new XElement("ItemGroup");
        var addedProjects = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var otherPackages = new List<(string Name, string Version)>();
 
        foreach (var pkg in packages)
        {
            if (!pkg.Name.StartsWith("Aspire.Hosting", StringComparison.OrdinalIgnoreCase))
            {
                otherPackages.Add(pkg);
                continue;
            }
 
            if (addedProjects.Contains(pkg.Name))
            {
                continue;
            }
 
            // Look for the project in src/
            var projectPath = Path.Combine(repoRoot, "src", pkg.Name, $"{pkg.Name}.csproj");
            if (File.Exists(projectPath))
            {
                addedProjects.Add(pkg.Name);
                projectRefGroup.Add(new XElement("ProjectReference",
                    new XAttribute("Include", projectPath),
                    new XElement("IsAspireProjectResource", "false")));
            }
            else
            {
                _logger.LogWarning("Could not find local project for {PackageName}, falling back to NuGet", pkg.Name);
                otherPackages.Add(pkg);
            }
        }
 
        if (projectRefGroup.HasElements)
        {
            doc.Root!.Add(projectRefGroup);
        }
 
        if (otherPackages.Count > 0)
        {
            doc.Root!.Add(new XElement("ItemGroup",
                otherPackages.Select(p => new XElement("PackageReference",
                    new XAttribute("Include", p.Name),
                    new XAttribute("Version", p.Version)))));
        }
 
        // Add imports for in-repo AppHost building
        var appHostInTargets = Path.Combine(repoRoot, "src", "Aspire.Hosting.AppHost", "build", "Aspire.Hosting.AppHost.in.targets");
        var sdkInTargets = Path.Combine(repoRoot, "src", "Aspire.AppHost.Sdk", "SDK", "Sdk.in.targets");
 
        if (File.Exists(appHostInTargets))
        {
            doc.Root!.Add(new XElement("Import", new XAttribute("Project", appHostInTargets)));
        }
        if (File.Exists(sdkInTargets))
        {
            doc.Root!.Add(new XElement("Import", new XAttribute("Project", sdkInTargets)));
        }
 
        // Add Dashboard and RemoteHost project references
        var dashboardProject = Path.Combine(repoRoot, "src", "Aspire.Dashboard", "Aspire.Dashboard.csproj");
        if (File.Exists(dashboardProject))
        {
            doc.Root!.Add(new XElement("ItemGroup",
                new XElement("ProjectReference", new XAttribute("Include", dashboardProject))));
        }
 
        var remoteHostProject = Path.Combine(repoRoot, "src", "Aspire.Hosting.RemoteHost", "Aspire.Hosting.RemoteHost.csproj");
        if (File.Exists(remoteHostProject))
        {
            doc.Root!.Add(new XElement("ItemGroup",
                new XElement("ProjectReference", new XAttribute("Include", remoteHostProject))));
        }
 
        // Disable Aspire SDK code generation (must come after imports)
        doc.Root!.Add(new XElement("Target", new XAttribute("Name", "_CSharpWriteHostProjectMetadataSources")));
        doc.Root!.Add(new XElement("Target", new XAttribute("Name", "_CSharpWriteProjectMetadataSources")));
 
        return doc;
    }
 
    /// <summary>
    /// Creates a project file for production using NuGet packages.
    /// </summary>
    private XDocument CreateProductionProjectFile(string sdkVersion, IEnumerable<(string Name, string Version)> packages)
    {
        var template = $"""
            <Project Sdk="Aspire.AppHost.Sdk/{sdkVersion}">
                <PropertyGroup>
                    <OutputType>exe</OutputType>
                    <TargetFramework>{TargetFramework}</TargetFramework>
                    <AssemblyName>{AssemblyName}</AssemblyName>
                    <OutDir>{BuildFolder}</OutDir>
                    <UserSecretsId>{_userSecretsId}</UserSecretsId>
                    <IsAspireHost>true</IsAspireHost>
                </PropertyGroup>
                <!-- Disable Aspire SDK code generation -->
                <Target Name="_CSharpWriteHostProjectMetadataSources" />
                <Target Name="_CSharpWriteProjectMetadataSources" />
            </Project>
            """;
 
        var doc = XDocument.Parse(template);
 
        // Add package references - SDK provides Aspire.Hosting.AppHost (which brings Aspire.Hosting)
        // We need to add: RemoteHost, code gen package, and any integration packages
        var explicitPackages = packages
            .Where(p => !p.Name.Equals("Aspire.Hosting", StringComparison.OrdinalIgnoreCase) &&
                        !p.Name.Equals("Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase))
            .ToList();
 
        // Always add RemoteHost - required for the RPC server
        explicitPackages.Add(("Aspire.Hosting.RemoteHost", sdkVersion));
 
        var packageRefs = explicitPackages.Select(p => new XElement("PackageReference",
            new XAttribute("Include", p.Name),
            new XAttribute("Version", p.Version)));
        doc.Root!.Add(new XElement("ItemGroup", packageRefs));
 
        return doc;
    }
 
    /// <summary>
    /// Restores and builds the project dependencies.
    /// </summary>
    /// <returns>A tuple containing the success status and an OutputCollector with build output.</returns>
    public async Task<(bool Success, OutputCollector Output)> BuildAsync(CancellationToken cancellationToken = default)
    {
        var outputCollector = new OutputCollector();
        var projectFile = new FileInfo(Path.Combine(_projectModelPath, ProjectFileName));
 
        var options = new DotNetCliRunnerInvocationOptions
        {
            StandardOutputCallback = outputCollector.AppendOutput,
            StandardErrorCallback = outputCollector.AppendError
        };
 
        var exitCode = await _dotNetCliRunner.BuildAsync(projectFile, options, cancellationToken);
 
        return (exitCode == 0, outputCollector);
    }
 
    /// <summary>
    /// Runs the AppHost server.
    /// </summary>
    /// <param name="socketPath">The Unix domain socket path for JSON-RPC communication.</param>
    /// <param name="hostPid">The PID of the host process for orphan detection.</param>
    /// <param name="launchSettingsEnvVars">Optional environment variables from apphost.run.json or launchSettings.json.</param>
    /// <param name="additionalArgs">Optional additional command-line arguments (e.g., for publish/deploy).</param>
    /// <param name="debug">Whether to enable debug logging in the AppHost server.</param>
    /// <returns>A tuple containing the started process and an OutputCollector for capturing output.</returns>
    public (Process Process, OutputCollector OutputCollector) Run(string socketPath, int hostPid, IReadOnlyDictionary<string, string>? launchSettingsEnvVars = null, string[]? additionalArgs = null, bool debug = false)
    {
        var assemblyPath = Path.Combine(BuildPath, ProjectDllName);
        var dotnetExe = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet";
 
        var startInfo = new ProcessStartInfo(dotnetExe)
        {
            WorkingDirectory = _projectModelPath,
            WindowStyle = ProcessWindowStyle.Minimized,
            UseShellExecute = false,
            CreateNoWindow = true
        };
        startInfo.ArgumentList.Add("exec");
        startInfo.ArgumentList.Add(assemblyPath);
 
        // Add the separator and any additional arguments (for publish/deploy)
        if (additionalArgs is { Length: > 0 })
        {
            startInfo.ArgumentList.Add("--");
            foreach (var arg in additionalArgs)
            {
                startInfo.ArgumentList.Add(arg);
            }
        }
 
        // Pass environment variables for socket path and parent PID
        startInfo.Environment["REMOTE_APP_HOST_SOCKET_PATH"] = socketPath;
        startInfo.Environment["REMOTE_APP_HOST_PID"] = hostPid.ToString(System.Globalization.CultureInfo.InvariantCulture);
        // Also set ASPIRE_CLI_PID so the auxiliary backchannel can report it for stop command
        startInfo.Environment[KnownConfigNames.CliProcessId] = hostPid.ToString(System.Globalization.CultureInfo.InvariantCulture);
 
        // Apply environment variables from apphost.run.json / launchSettings.json
        if (launchSettingsEnvVars != null)
        {
            foreach (var (key, value) in launchSettingsEnvVars)
            {
                startInfo.Environment[key] = value;
            }
        }
 
        // Enable debug logging if requested
        if (debug)
        {
            startInfo.Environment["Logging__LogLevel__Default"] = "Debug";
            _logger.LogDebug("Enabling debug logging for AppHostServer");
        }
 
        startInfo.RedirectStandardOutput = true;
        startInfo.RedirectStandardError = true;
 
        var process = Process.Start(startInfo)!;
 
        // Collect output for error diagnostics and log at debug level
        var outputCollector = new OutputCollector();
        process.OutputDataReceived += (sender, e) =>
        {
            if (e.Data is not null)
            {
                _logger.LogDebug("AppHostServer({ProcessId}) stdout: {Line}", process.Id, e.Data);
                outputCollector.AppendOutput(e.Data);
            }
        };
        process.ErrorDataReceived += (sender, e) =>
        {
            if (e.Data is not null)
            {
                _logger.LogDebug("AppHostServer({ProcessId}) stderr: {Line}", process.Id, e.Data);
                outputCollector.AppendError(e.Data);
            }
        };
        process.BeginOutputReadLine();
        process.BeginErrorReadLine();
 
        return (process, outputCollector);
    }
 
    /// <summary>
    /// Gets the socket path for the AppHost server based on the app path.
    /// On Windows, returns just the pipe name (named pipes don't use file paths).
    /// On Unix/macOS, returns the full socket file path.
    /// </summary>
    public string GetSocketPath()
    {
        var pathHash = SHA256.HashData(Encoding.UTF8.GetBytes(_appPath));
        var socketName = Convert.ToHexString(pathHash)[..12].ToLowerInvariant() + ".sock";
 
        // On Windows, named pipes use just a name, not a file path.
        // The .NET NamedPipeServerStream and clients will automatically
        // use the \\.\pipe\ prefix.
        if (OperatingSystem.IsWindows())
        {
            return socketName;
        }
 
        // On Unix/macOS, use Unix domain sockets with a file path
        var socketDir = Path.Combine(Path.GetTempPath(), FolderPrefix, "sockets");
        Directory.CreateDirectory(socketDir);
 
        return Path.Combine(socketDir, socketName);
    }
 
    /// <summary>
    /// Gets a project-level NuGet config path using dotnet nuget config paths command.
    /// Only returns configs that are within the project directory tree, not global user configs.
    /// </summary>
    private static string? FindNuGetConfig(string workingDirectory)
    {
        try
        {
            var startInfo = new ProcessStartInfo("dotnet")
            {
                Arguments = "nuget config paths",
                WorkingDirectory = workingDirectory,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true
            };
 
            using var process = Process.Start(startInfo);
            if (process is null)
            {
                return null;
            }
 
            var output = process.StandardOutput.ReadToEnd();
            process.WaitForExit();
 
            if (process.ExitCode != 0)
            {
                return null;
            }
 
            // Find a config that's in the project directory or a parent directory (not global user config).
            // Global configs (e.g., ~/.nuget/NuGet/NuGet.Config) will be found by dotnet anyway.
            var configPaths = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
            var workingDirFullPath = Path.GetFullPath(workingDirectory);
 
            // Get user profile path to exclude global NuGet configs
            var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
            var globalNuGetPath = Path.Combine(userProfile, ".nuget");
 
            foreach (var configPath in configPaths)
            {
                if (File.Exists(configPath))
                {
                    var configFullPath = Path.GetFullPath(configPath);
                    var configDir = Path.GetDirectoryName(configFullPath);
 
                    // Skip global NuGet configs (they're in ~/.nuget)
                    if (configFullPath.StartsWith(globalNuGetPath, StringComparison.OrdinalIgnoreCase))
                    {
                        continue;
                    }
 
                    // Check if the working directory is within or below the config's directory
                    // (i.e., the config is in a parent directory of the project)
                    if (configDir is not null && workingDirFullPath.StartsWith(configDir, StringComparison.OrdinalIgnoreCase))
                    {
                        return configPath;
                    }
                }
            }
 
            return null;
        }
        catch
        {
            return null;
        }
    }
 
    /// <summary>
    /// Gets the OS and architecture identifiers for the DCP package name.
    /// </summary>
    private static (string Os, string Arch) GetBuildPlatform()
    {
        // OS mapping (matches MSBuild logic in Directory.Build.props)
        var os = OperatingSystem.IsLinux() ? "linux"
            : OperatingSystem.IsMacOS() ? "darwin"
            : "windows";
 
        // Architecture mapping
        var arch = RuntimeInformation.OSArchitecture switch
        {
            Architecture.X86 => "386",
            Architecture.Arm64 => "arm64",
            _ => "amd64"
        };
 
        return (os, arch);
    }
 
    /// <summary>
    /// Reads the DCP version from eng/Versions.props in the repo.
    /// </summary>
    private static string GetDcpVersionFromRepo(string repoRoot, string buildOs, string buildArch)
    {
        const string fallbackVersion = "0.21.1";
 
        try
        {
            var versionsPropsPath = Path.Combine(repoRoot, "eng", "Versions.props");
            if (!File.Exists(versionsPropsPath))
            {
                return fallbackVersion;
            }
 
            var doc = XDocument.Load(versionsPropsPath);
 
            // Property name format: MicrosoftDeveloperControlPlane{os}{arch}Version
            // e.g., MicrosoftDeveloperControlPlanedarwinarm64Version
            var propertyName = $"MicrosoftDeveloperControlPlane{buildOs}{buildArch}Version";
 
            var version = doc.Descendants(propertyName).FirstOrDefault()?.Value;
            return version ?? fallbackVersion;
        }
        catch
        {
            return fallbackVersion;
        }
    }
}