File: Configuration\AspireConfigFile.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.ComponentModel;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Configuration;
 
/// <summary>
/// Represents the aspire.config.json configuration file.
/// Consolidates apphost location, launch settings, and CLI config into one file.
/// </summary>
/// <remarks>
/// <para>The new unified format (<c>aspire.config.json</c>) replaces the legacy split across
/// <c>.aspire/settings.json</c> (local settings) and <c>apphost.run.json</c> (launch profiles).</para>
/// <para>Example <c>aspire.config.json</c>:</para>
/// <code>
/// {
///   "appHost": { "path": "app.ts", "language": "typescript/nodejs" },
///   "sdk": { "version": "9.2.0" },
///   "channel": "stable",
///   "features": { "polyglotSupportEnabled": true },
///   "profiles": {
///     "default": {
///       "applicationUrl": "https://localhost:17000;http://localhost:15000",
///       "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }
///     }
///   },
///   "packages": { "Aspire.Hosting.Redis": "9.2.0" }
/// }
/// </code>
/// <para>Legacy <c>.aspire/settings.json</c> (flat keys):</para>
/// <code>
/// { "appHostPath": "app.ts", "language": "typescript/nodejs", "sdkVersion": "9.2.0" }
/// </code>
/// <para>Legacy <c>apphost.run.json</c> (launch profiles):</para>
/// <code>
/// { "profiles": { "default": { "applicationUrl": "https://localhost:17000" } } }
/// </code>
/// </remarks>
internal sealed class AspireConfigFile
{
    public const string FileName = "aspire.config.json";
 
    /// <summary>
    /// The JSON Schema URL for this configuration file.
    /// </summary>
    [JsonPropertyName("$schema")]
    public string? Schema { get; set; }
 
    /// <summary>
    /// AppHost entry point configuration.
    /// </summary>
    [JsonPropertyName("appHost")]
    [Description("AppHost entry point configuration.")]
    [LocalAspireJsonConfigurationProperty]
    public AspireConfigAppHost? AppHost { get; set; }
 
    /// <summary>
    /// Aspire SDK version configuration.
    /// </summary>
    [JsonPropertyName("sdk")]
    [Description("Aspire SDK version configuration.")]
    public AspireConfigSdk? Sdk { get; set; }
 
    /// <summary>
    /// Convenience accessor for the Aspire SDK version.
    /// Gets or sets <see cref="AspireConfigSdk.Version"/> on the <see cref="Sdk"/> object,
    /// creating the nested object when setting a value.
    /// </summary>
    [JsonIgnore]
    public string? SdkVersion
    {
        get => Sdk?.Version;
        set => (Sdk ??= new AspireConfigSdk()).Version = value;
    }
 
    /// <summary>
    /// Aspire channel for package resolution.
    /// </summary>
    [JsonPropertyName("channel")]
    [Description("The Aspire channel to use for package resolution (e.g., \"stable\", \"preview\", \"staging\", \"daily\"). Used by aspire add to determine which NuGet feed to use.")]
    public string? Channel { get; set; }
 
    /// <summary>
    /// Feature flags.
    /// </summary>
    [JsonPropertyName("features")]
    [Description("Feature flags for enabling/disabling experimental or optional features. Key is feature name, value is enabled (true) or disabled (false).")]
    public Dictionary<string, bool>? Features { get; set; }
 
    /// <summary>
    /// Launch profiles (ports, env vars). Replaces apphost.run.json.
    /// </summary>
    [JsonPropertyName("profiles")]
    [Description("Launch profiles (ports, environment variables). Replaces apphost.run.json.")]
    public Dictionary<string, AspireConfigProfile>? Profiles { get; set; }
 
    /// <summary>
    /// Package references for non-first-class languages.
    /// </summary>
    [JsonPropertyName("packages")]
    [Description("Package references for non-first-class languages. Key is package name, value is version. A value ending in \".csproj\" is treated as a project reference.")]
    public Dictionary<string, string>? Packages { get; set; }
 
    /// <summary>
    /// Loads aspire.config.json from the specified directory.
    /// </summary>
    /// <returns>The deserialized config, or <c>null</c> if the file does not exist.</returns>
    /// <exception cref="InvalidOperationException">Thrown when the file exists but contains invalid JSON.</exception>
    public static AspireConfigFile? Load(string directory)
    {
        var filePath = Path.Combine(directory, FileName);
        if (!File.Exists(filePath))
        {
            return null;
        }
 
        try
        {
            var json = File.ReadAllText(filePath);
            return JsonSerializer.Deserialize(json, JsonSourceGenerationContext.Default.AspireConfigFile)
                ?? new AspireConfigFile();
        }
        catch (JsonException ex)
        {
            throw new JsonException(
                string.Format(CultureInfo.CurrentCulture, ErrorStrings.InvalidJsonInConfigFile, filePath, ex.Message),
                ex.Path, ex.LineNumber, ex.BytePositionInLine, ex);
        }
    }
 
    /// <summary>
    /// Saves aspire.config.json to the specified directory.
    /// Uses relaxed JSON escaping so non-ASCII characters (CJK, etc.) are preserved as-is.
    /// </summary>
    public void Save(string directory)
    {
        Directory.CreateDirectory(directory);
        var filePath = Path.Combine(directory, FileName);
        var json = JsonSerializer.Serialize(this, JsonSourceGenerationContext.RelaxedEscaping.AspireConfigFile);
        File.WriteAllText(filePath, json);
    }
 
    /// <summary>
    /// Loads aspire.config.json from the specified directory, falling back to legacy
    /// .aspire/settings.json + apphost.run.json and migrating if needed.
    /// </summary>
    public static AspireConfigFile LoadOrCreate(string directory, string? defaultSdkVersion = null)
    {
        // Prefer aspire.config.json
        var config = Load(directory);
        if (config is not null)
        {
            if (defaultSdkVersion is not null)
            {
                config.SdkVersion ??= defaultSdkVersion;
            }
 
            return config;
        }
 
        // TODO: Remove legacy .aspire/settings.json + apphost.run.json fallback once confident
        // most users have migrated. Tracked by https://github.com/dotnet/aspire/issues/15239
        // Fall back to .aspire/settings.json + apphost.run.json → migrate
        var legacyConfig = AspireJsonConfiguration.Load(directory);
        if (legacyConfig is not null)
        {
            var profiles = ReadApphostRunProfiles(Path.Combine(directory, "apphost.run.json"));
            config = FromLegacy(legacyConfig, profiles);
 
            // Persist the migrated config (legacy files are kept for older CLI versions)
            config.Save(directory);
        }
        else
        {
            config = new AspireConfigFile();
        }
 
        if (defaultSdkVersion is not null)
        {
            config.SdkVersion ??= defaultSdkVersion;
        }
 
        return config;
    }
 
    /// <summary>
    /// Reads launch profiles from an apphost.run.json file.
    /// </summary>
    /// <remarks>
    /// This is legacy migration code that reads the old apphost.run.json format and converts
    /// it to <see cref="AspireConfigProfile"/> entries. Will be removed once legacy files are
    /// no longer supported. Tracked by https://github.com/dotnet/aspire/issues/15239
    /// </remarks>
    internal static Dictionary<string, AspireConfigProfile>? ReadApphostRunProfiles(string apphostRunPath, ILogger? logger = null)
    {
        try
        {
            if (!File.Exists(apphostRunPath))
            {
                return null;
            }
 
            var json = File.ReadAllText(apphostRunPath);
            using var doc = JsonDocument.Parse(json, ConfigurationHelper.ParseOptions);
 
            if (!doc.RootElement.TryGetProperty("profiles", out var profilesElement))
            {
                return null;
            }
 
            var profiles = new Dictionary<string, AspireConfigProfile>();
            foreach (var prop in profilesElement.EnumerateObject())
            {
                var profile = new AspireConfigProfile();
 
                if (prop.Value.TryGetProperty("applicationUrl", out var appUrl) &&
                    appUrl.ValueKind == JsonValueKind.String)
                {
                    profile.ApplicationUrl = appUrl.GetString();
                }
 
                if (prop.Value.TryGetProperty("environmentVariables", out var envVars) &&
                    envVars.ValueKind == JsonValueKind.Object)
                {
                    profile.EnvironmentVariables = new Dictionary<string, string>();
                    foreach (var envProp in envVars.EnumerateObject())
                    {
                        var envValue = envProp.Value.ValueKind switch
                        {
                            JsonValueKind.String => envProp.Value.GetString()!,
                            JsonValueKind.True => "true",
                            JsonValueKind.False => "false",
                            JsonValueKind.Number => envProp.Value.GetRawText(),
                            JsonValueKind.Null => "",
                            _ => null
                        };
 
                        if (envValue is not null)
                        {
                            if (envProp.Value.ValueKind != JsonValueKind.String)
                            {
                                logger?.LogWarning(
                                    "Environment variable '{Name}' has a non-string value ({ValueKind}). Converting to string \"{Value}\".",
                                    envProp.Name, envProp.Value.ValueKind, envValue);
                            }
 
                            profile.EnvironmentVariables[envProp.Name] = envValue;
                        }
                        else
                        {
                            logger?.LogWarning(
                                "Environment variable '{Name}' has an unsupported value type ({ValueKind}) and will be ignored.",
                                envProp.Name, envProp.Value.ValueKind);
                        }
                    }
                }
 
                profiles[prop.Name] = profile;
            }
 
            return profiles.Count > 0 ? profiles : null;
        }
        catch (Exception ex)
        {
            logger?.LogDebug(ex, "Failed to read launch profiles from {Path}", apphostRunPath);
            return null;
        }
    }
 
    /// <summary>
    /// Gets the effective SDK version for package-based AppHost preparation.
    /// Falls back to <paramref name="defaultSdkVersion"/> when no SDK version is configured.
    /// </summary>
    public string GetEffectiveSdkVersion(string defaultSdkVersion)
    {
        return string.IsNullOrWhiteSpace(Sdk?.Version) ? defaultSdkVersion : Sdk.Version;
    }
 
    /// <summary>
    /// Adds a package reference, updating the version if it already exists.
    /// </summary>
    public void AddOrUpdatePackage(string packageId, string version)
    {
        Packages ??= [];
        Packages[packageId] = version;
    }
 
    /// <summary>
    /// Removes a package reference.
    /// </summary>
    public bool RemovePackage(string packageId)
    {
        if (Packages is null)
        {
            return false;
        }
 
        return Packages.Remove(packageId);
    }
 
    /// <summary>
    /// Gets all integration references (both NuGet packages and project references)
    /// including the base Aspire.Hosting package.
    /// A value ending in ".csproj" is treated as a project reference; otherwise as a NuGet version.
    /// Empty package versions are resolved to the effective SDK version.
    /// </summary>
    /// <param name="defaultSdkVersion">Default SDK version to use when not configured.</param>
    /// <param name="configDirectory">The directory containing aspire.config.json, used to resolve relative project paths.</param>
    public IEnumerable<IntegrationReference> GetIntegrationReferences(string defaultSdkVersion, string configDirectory)
    {
        var sdkVersion = GetEffectiveSdkVersion(defaultSdkVersion);
 
        // Base package always included
        yield return IntegrationReference.FromPackage("Aspire.Hosting", sdkVersion);
 
        if (Packages is null)
        {
            yield break;
        }
 
        foreach (var (packageName, value) in Packages)
        {
            // Skip base packages and SDK-only packages
            if (string.Equals(packageName, "Aspire.Hosting", StringComparison.OrdinalIgnoreCase) ||
                string.Equals(packageName, "Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }
 
            var trimmedValue = value?.Trim();
 
            if (string.IsNullOrEmpty(trimmedValue))
            {
                // NuGet package reference with no explicit version — fall back to the SDK version
                yield return IntegrationReference.FromPackage(packageName, sdkVersion);
                continue;
            }
 
            if (trimmedValue.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase))
            {
                // Project reference — resolve relative path to absolute
                var absolutePath = Path.GetFullPath(Path.Combine(configDirectory, trimmedValue));
                yield return IntegrationReference.FromProject(packageName, absolutePath);
            }
            else
            {
                // NuGet package reference with explicit version
                yield return IntegrationReference.FromPackage(packageName, trimmedValue);
            }
        }
    }
 
    /// <summary>
    /// Checks if aspire.config.json exists in the specified directory.
    /// </summary>
    public static bool Exists(string directory)
    {
        return File.Exists(Path.Combine(directory, FileName));
    }
 
    /// <summary>
    /// Creates from a legacy AspireJsonConfiguration + apphost.run.json.
    /// </summary>
    public static AspireConfigFile FromLegacy(AspireJsonConfiguration? settings, Dictionary<string, AspireConfigProfile>? profiles)
    {
        var config = new AspireConfigFile();
 
        if (settings is not null)
        {
            config.AppHost = new AspireConfigAppHost
            {
                Path = settings.AppHostPath,
                Language = settings.Language
            };
 
            if (!string.IsNullOrEmpty(settings.SdkVersion))
            {
                config.Sdk = new AspireConfigSdk { Version = settings.SdkVersion };
            }
 
            config.Channel = settings.Channel;
            config.Features = settings.Features;
            config.Packages = settings.Packages;
        }
 
        config.Profiles = profiles;
 
        return config;
    }
}
 
/// <summary>
/// AppHost entry point configuration within aspire.config.json.
/// </summary>
internal sealed class AspireConfigAppHost
{
    /// <summary>
    /// Relative path to the AppHost entry point file.
    /// </summary>
    [JsonPropertyName("path")]
    [Description("Relative path to the AppHost entry point file (e.g., \"Program.cs\", \"app.ts\"). Relative to the directory containing aspire.config.json.")]
    public string? Path { get; set; }
 
    /// <summary>
    /// Language identifier (e.g., "typescript/nodejs", "python").
    /// </summary>
    [JsonPropertyName("language")]
    [Description("Language identifier (e.g., \"typescript/nodejs\", \"python\"). Used to determine which runtime to use for execution.")]
    public string? Language { get; set; }
}
 
/// <summary>
/// SDK version configuration within aspire.config.json.
/// </summary>
internal sealed class AspireConfigSdk
{
    /// <summary>
    /// The Aspire SDK version.
    /// </summary>
    [JsonPropertyName("version")]
    [Description("The Aspire SDK version. Determines the version of Aspire.Hosting packages to use.")]
    public string? Version { get; set; }
}
 
/// <summary>
/// Launch profile within aspire.config.json.
/// </summary>
internal sealed class AspireConfigProfile
{
    /// <summary>
    /// Application URLs (e.g., "https://localhost:17000;http://localhost:15000").
    /// </summary>
    [JsonPropertyName("applicationUrl")]
    [Description("Application URLs (e.g., \"https://localhost:17000;http://localhost:15000\").")]
    public string? ApplicationUrl { get; set; }
 
    /// <summary>
    /// Environment variables for this profile.
    /// </summary>
    [JsonPropertyName("environmentVariables")]
    [Description("Environment variables for this profile.")]
    public Dictionary<string, string>? EnvironmentVariables { get; set; }
}