File: Commands\InitCommand.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.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.CommandLine;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.Agents;
using Aspire.Cli.Certificates;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Exceptions;
using Aspire.Cli.Interaction;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Scaffolding;
using Aspire.Cli.Templating;
using Aspire.Hosting;
using Aspire.Hosting.Utils;
using Aspire.Shared;
 
namespace Aspire.Cli.Commands;
 
/// <summary>
/// Drops a skeleton AppHost and, when applicable, an <c>aspire.config.json</c>, then
/// installs the appropriate init skill for an agent to complete the wiring. This is a
/// thin launcher — the heavy lifting (project discovery, dependency configuration,
/// validation) is delegated to the <c>aspireify</c> skill.
/// </summary>
internal sealed class InitCommand : BaseCommand
{
    internal override HelpGroup HelpGroup => HelpGroup.AppCommands;
 
    protected override bool UpdateNotificationsEnabled => true;
 
    internal override bool PrefetchesTemplatePackageMetadata => true;
 
    private readonly CliExecutionContext _executionContext;
    private readonly ILanguageService _languageService;
    private readonly ISolutionLocator _solutionLocator;
    private readonly AgentInitCommand _agentInitCommand;
    private readonly IDotNetCliRunner _runner;
    private readonly ICertificateService _certificateService;
    private readonly IScaffoldingService _scaffoldingService;
    private readonly ILanguageDiscovery _languageDiscovery;
    private readonly TemplateNuGetConfigService _templateNuGetConfigService;
    private readonly IPackagingService _packagingService;
 
    private static readonly Option<string?> s_sourceOption = new("--source", "-s")
    {
        Description = "Deprecated. Accepted for compatibility but no longer affects `aspire init`; this option will be removed in a future version.",
        Recursive = true,
        Hidden = true
    };
 
    private static readonly Option<string?> s_versionOption = new("--version")
    {
        Description = "Deprecated. Accepted for compatibility but no longer affects `aspire init`; this option will be removed in a future version.",
        Recursive = true,
        Hidden = true
    };
 
    private readonly Option<string?> _channelOption;
    private readonly Option<string?> _languageOption;
 
    public InitCommand(
        ILanguageService languageService,
        ISolutionLocator solutionLocator,
        AgentInitCommand agentInitCommand,
        IDotNetCliRunner runner,
        ICertificateService certificateService,
        IScaffoldingService scaffoldingService,
        ILanguageDiscovery languageDiscovery,
        TemplateNuGetConfigService templateNuGetConfigService,
        IPackagingService packagingService,
        CommonCommandServices services)
        : base("init", InitCommandStrings.Description, services)
    {
        _executionContext = services.ExecutionContext;
        _languageService = languageService;
        _solutionLocator = solutionLocator;
        _agentInitCommand = agentInitCommand;
        _runner = runner;
        _certificateService = certificateService;
        _scaffoldingService = scaffoldingService;
        _languageDiscovery = languageDiscovery;
        _templateNuGetConfigService = templateNuGetConfigService;
        _packagingService = packagingService;
 
        _channelOption = new Option<string?>("--channel")
        {
            Description = "Deprecated. Accepted for compatibility but no longer affects `aspire init`; this option will be removed in a future version.",
            Recursive = true,
            Hidden = true
        };
 
        _languageOption = new Option<string?>("--language")
        {
            Description = InitCommandStrings.LanguageOptionDescription
        };
        Options.Add(s_sourceOption);
        Options.Add(s_versionOption);
        Options.Add(_channelOption);
        Options.Add(_languageOption);
        Options.Add(NewCommand.s_suppressAgentInitOption);
        Options.Add(AgentInitCommand.s_skillLocationsOption);
        Options.Add(AgentInitCommand.s_skillsOption);
    }
 
    protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        using var activity = Telemetry.StartDiagnosticActivity(this.Name);
 
        // Step 1: Get the language selection.
        var explicitLanguage = parseResult.GetValue(_languageOption);
        DisplayDeprecatedOptionWarnings(parseResult);
 
        var projectSelection = await _languageService.GetOrPromptForProjectSelectionAsync(explicitLanguage, saveLanguageSelection: false, cancellationToken);
        var selectedProject = projectSelection.Project;
 
        var isCSharp = selectedProject.LanguageId == KnownLanguageId.CSharp;
        var workingDirectory = _executionContext.WorkingDirectory;
 
        // Step 2: Detect solution (C# only — determines single-file vs full project).
        FileInfo? solutionFile = null;
        if (isCSharp)
        {
            solutionFile = await _solutionLocator.FindSolutionFileAsync(workingDirectory, cancellationToken);
        }
 
        // Step 3: Drop the skeleton AppHost and any related config files needed for that mode.
        var dropResult = isCSharp
            ? await DropCSharpSkeletonAsync(workingDirectory, solutionFile, cancellationToken)
            : await DropPolyglotSkeletonAsync(selectedProject.LanguageId, workingDirectory, cancellationToken);
 
        if (dropResult != CliExitCodes.Success)
        {
            return CommandResult.Failure(dropResult, InteractionServiceStrings.ProjectCouldNotBeCreated);
        }
 
        // Persist the prompted language selection now that the skeleton drop succeeded.
        if (projectSelection.ShouldPersistSelection)
        {
            await _languageService.SetLanguageAsync(selectedProject, cancellationToken: cancellationToken);
        }
 
        // Trust the dev certificate so the first `aspire start` doesn't hit cert errors.
        // The skeleton AppHost / aspire.config.json profiles default to HTTPS, and the
        // aspireify skill guidance prefers HTTPS for service endpoints. Best-effort —
        // ignore failures since `aspire doctor` / `aspire certs trust` provide a fallback.
        if (isCSharp)
        {
            _ = await _certificateService.EnsureCertificatesTrustedAsync(cancellationToken);
        }
 
        // Step 4: Chain to aspire agent init for MCP server + skill configuration.
        // This prompt lets users choose which skills to install — including aspireify.
        var workspaceRoot = solutionFile?.Directory ?? workingDirectory;
        var agentInitBinding = PromptBinding.CreateInvertedBoolConfirm(parseResult, NewCommand.s_suppressAgentInitOption, defaultValue: true);
        var skillLocationsBinding = PromptBinding.Create(parseResult, AgentInitCommand.s_skillLocationsOption);
        var skillsBinding = PromptBinding.Create(parseResult, AgentInitCommand.s_skillsOption);
        // aspire init creates an AppHost in an existing repo, so pre-select every bundle skill
        // (which includes aspireify as the natural follow-up wiring skill).
        var agentInitResult = await _agentInitCommand.PromptAndChainAsync(
            InteractionService,
            CliExitCodes.Success,
            workspaceRoot,
            agentInitBinding,
            skillLocationsBinding,
            skillsBinding,
            null,
            cancellationToken);
 
        // Step 5: Print follow-up commands only when the user selected the one-time init skill.
        if (agentInitResult.ExitCode == CliExitCodes.Success &&
            agentInitResult.SelectedSkills.Any(static skill => skill.HasName(CommonAgentApplicators.AspireifySkillName)))
        {
            var commands = GetAspireifyCommands(agentInitResult.SelectedLocations);
            if (commands.Count > 0)
            {
                InteractionService.DisplayEmptyLine();
                InteractionService.DisplayMessage(
                    KnownEmojis.Dizzy,
                    commands.Count == 1
                        ? InitCommandStrings.AppHostCreatedRunOne
                        : InitCommandStrings.AppHostCreatedRunOneOf);
                InteractionService.DisplayEmptyLine();
 
                foreach (var command in commands)
                {
                    InteractionService.DisplaySubtleMessage($"  {command}");
                }
            }
        }
 
        return CommandResult.FromExitCode(agentInitResult.ExitCode);
    }
 
    private void DisplayDeprecatedOptionWarnings(ParseResult parseResult)
    {
        DisplayDeprecatedOptionWarningIfProvided(parseResult.GetValue(s_sourceOption), "--source");
        DisplayDeprecatedOptionWarningIfProvided(parseResult.GetValue(s_versionOption), "--version");
        DisplayDeprecatedOptionWarningIfProvided(parseResult.GetValue(_channelOption), "--channel");
    }
 
    private void DisplayDeprecatedOptionWarningIfProvided(string? value, string optionName)
    {
        if (value is not null)
        {
            InteractionService.DisplayMessage(
                KnownEmojis.Warning,
                string.Format(CultureInfo.CurrentCulture, InitCommandStrings.DeprecatedOptionWarning, optionName));
        }
    }
 
    private static IReadOnlyList<string> GetAspireifyCommands(IReadOnlyList<SkillLocation> selectedLocations)
    {
        var commands = new List<string>();
 
        if (selectedLocations.Contains(SkillLocation.ClaudeCode))
        {
            commands.Add("claude \"run the aspireify skill\"");
        }
 
        if (selectedLocations.Contains(SkillLocation.OpenCode))
        {
            commands.Add("opencode --prompt \"run the aspireify skill\"");
        }
 
        return commands;
    }
 
    private async Task<int> DropCSharpSkeletonAsync(DirectoryInfo workingDirectory, FileInfo? solutionFile, CancellationToken cancellationToken)
    {
        if (solutionFile is not null)
        {
            return await DropCSharpProjectSkeletonAsync(solutionFile, cancellationToken);
        }
 
        return await DropCSharpSingleFileSkeletonAsync(workingDirectory, cancellationToken);
    }
 
    private async Task<int> DropCSharpSingleFileSkeletonAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken)
    {
        // Ensure the workspace has a NuGet.config that exposes the running CLI binary's
        // identity-channel package sources (CliExecutionContext.IdentityChannel — stable,
        // staging, daily, pr-<N>, or local). Run this BEFORE the apphost.cs-already-exists
        // early return so re-running `aspire init` against a workspace produced by a
        // previous broken CLI (which left apphost.cs without a workspace NuGet.config)
        // recovers cleanly. The config is also required so MSBuild can resolve
        // `#:sdk Aspire.AppHost.Sdk@<version>` from the SDK directive — both for
        // `aspire add` (`dotnet package add --file apphost.cs`) and for
        // `dotnet run --file apphost.cs`. Without it, any non-stable channel (PR/run
        // hives, locally-built `local-*`/`dev-*` hives, the staging channel, etc.) is
        // invisible and SDK resolution fails. `NuGetConfigMerger` underneath creates a
        // new file or merges missing sources into an existing one.
        var createdNuGetConfig = await _templateNuGetConfigService.CreateOrUpdateNuGetConfigWithoutPromptAsync(
            channelName: _executionContext.IdentityChannel,
            outputPath: workingDirectory.FullName,
            cancellationToken).ConfigureAwait(false);
        if (createdNuGetConfig)
        {
            InteractionService.DisplayMessage(KnownEmojis.Package, TemplatingStrings.NuGetConfigCreatedOrUpdatedConfirmationMessage);
        }
 
        var appHostPath = Path.Combine(workingDirectory.FullName, "apphost.cs");
        if (File.Exists(appHostPath))
        {
            InteractionService.DisplayMessage(KnownEmojis.Information, string.Format(CultureInfo.CurrentCulture, InitCommandStrings.FileAlreadyExistsSkipping, "apphost.cs"));
            return CliExitCodes.Success;
        }
 
        // Drop bare single-file apphost. Pin the SDK version so later operations
        // (project updating, version parsing in ProjectUpdater/FallbackProjectParser)
        // can locate and update the directive — they expect the @<version> form.
        // Use IdentitySdkVersion (build-metadata stripped) rather than IdentityVersion:
        // the directive references the published Aspire.AppHost.Sdk NuGet package, whose
        // version never carries a +<sha> suffix. This also matches the empty-apphost
        // template path (CliTemplateFactory.EmptyTemplate) so both emit the same form.
        var aspireVersion = _executionContext.IdentitySdkVersion;
        var appHostContent = $$"""
            #:sdk Aspire.AppHost.Sdk@{{aspireVersion}}
            #:property AspireUseCliBundle=true
 
            var builder = DistributedApplication.CreateBuilder(args);
 
            // The aspireify skill will wire up your projects here.
 
            builder.Build().Run();
            """;
        File.WriteAllText(appHostPath, appHostContent);
        InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, string.Format(CultureInfo.CurrentCulture, InitCommandStrings.CreatedFile, "apphost.cs"));
 
        // Generate one set of ports so aspire.config.json (used by `aspire run`) and
        // apphost.run.json (used by `dotnet run apphost.cs`) agree on the dashboard /
        // OTLP / resource service endpoints.
        var ports = AppHostProfilePortGenerator.Generate(Random.Shared);
 
        // Drop aspire.config.json. The returned ports are whatever ended up effective
        // in aspire.config.json — newly generated, or pre-existing if the file already
        // had a `profiles` section. Use the SAME ports for apphost.run.json so the two
        // files always agree on dashboard / OTLP / resource service endpoints.
        //
        // Persist the running CLI's identity channel (e.g. `daily`, `staging`, `pr-<N>`)
        // so subsequent commands like `aspire add` resolve packages against the matching
        // channel. Resolve through PackagingService and only persist when the identity
        // matches a registered Explicit channel — mirrors `NewCommand.cs:316-402`.
        //
        // `ResolvePersistableChannelNameAsync` filters out identities that aren't
        // registered as channels on this CLI install (e.g. `local`, `staging` on a CLI
        // without the staging feature flag, stale `pr-<N>` after the hive is gone),
        // the Implicit `default` channel that no CLI identity ever has, and `stable`
        // because the public-feed behavior is already the default. Non-default
        // Explicit channels are persisted so subsequent commands can match a PSM rule.
        // See https://github.com/microsoft/aspire/issues/17295.
        var resolvedChannel = await ResolvePersistableChannelNameAsync(cancellationToken);
        var (configResult, effectivePorts) = DropAspireConfig(workingDirectory, "apphost.cs", language: null, resolvedChannel, ports);
        if (configResult != CliExitCodes.Success)
        {
            return configResult;
        }
 
        // Drop apphost.run.json so `dotnet run apphost.cs` picks up the dashboard /
        // OTLP / resource service env vars from the file-based launch profile. Without
        // this file the AppHost crashes at startup because DashboardOptions validation
        // requires ASPNETCORE_URLS and ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL to be set
        // (these env vars are otherwise injected by the Aspire CLI when running via
        // `aspire run`, but `dotnet run apphost.cs` does not go through that path).
        DropAppHostRunJson(workingDirectory, effectivePorts);
 
        return CliExitCodes.Success;
    }
 
    private async Task<int> DropCSharpProjectSkeletonAsync(FileInfo solutionFile, CancellationToken cancellationToken)
    {
        var solutionDir = solutionFile.Directory!;
        var solutionName = Path.GetFileNameWithoutExtension(solutionFile.Name);
        var appHostDirName = $"{solutionName}.AppHost";
        var appHostDirPath = Path.Combine(solutionDir.FullName, appHostDirName);
 
        // Drop the solution-directory NuGet.config BEFORE the AppHost-dir-already-exists
        // early return so re-running `aspire init` against a workspace produced by a
        // previous broken CLI (which left a `<sln>.AppHost/` without a workspace
        // NuGet.config) recovers cleanly. Writing here is also required BEFORE
        // `_runner.NewProjectAsync` so the aspire-apphost template's built-in `restore`
        // post-action (template.json post-action id "restore", conditioned on
        // !skipRestore which defaults to false) can resolve the
        // `Aspire.AppHost.Sdk/<version>` reference from the channel-matched hive. The
        // post-action currently runs with continueOnError=true so a missing nuget.config
        // wouldn't fail init, but its restore would still emit confusing errors and waste
        // work — and a future template change that drops continueOnError would break init
        // outright.
        //
        // Source: CliExecutionContext.IdentityChannel (stable / staging / daily / pr-<N> /
        // local). NuGetConfigMerger underneath creates a new file or merges missing
        // sources into an existing one, so adding hives later is handled the same way as
        // for templates. Mirrors what DropCSharpSingleFileSkeletonAsync already does for
        // the apphost.cs path on every channel.
        var createdNuGetConfig = await _templateNuGetConfigService.CreateOrUpdateNuGetConfigWithoutPromptAsync(
            channelName: _executionContext.IdentityChannel,
            outputPath: solutionDir.FullName,
            cancellationToken).ConfigureAwait(false);
        if (createdNuGetConfig)
        {
            InteractionService.DisplayMessage(KnownEmojis.Package, TemplatingStrings.NuGetConfigCreatedOrUpdatedConfirmationMessage);
        }
 
        if (Directory.Exists(appHostDirPath))
        {
            InteractionService.DisplayMessage(KnownEmojis.Information, string.Format(CultureInfo.CurrentCulture, InitCommandStrings.FileAlreadyExistsSkipping, $"{appHostDirName}/"));
            return CliExitCodes.Success;
        }
 
        // Resolve the channel-aware template package version + feed mapping. The running
        // CLI binary's identity channel (CliExecutionContext.IdentityChannel — stable, staging,
        // daily, pr-<N>, or local) drives the selection so a developer scaffolding with a
        // pr-<N> CLI gets a project wired to the matching pr-<N> hive. PR hives are
        // intentionally excluded — init should produce the same template on every machine
        // for a given CLI build.
        TemplatePackageSelection selection;
        try
        {
            var query = new TemplatePackageQuery(
                RequestedChannel: _executionContext.IdentityChannel,
                VersionOverride: null,
                SourceOverride: null,
                IncludePrHives: false);
 
            selection = await _templateNuGetConfigService.ResolveTemplatePackageAsync(query, cancellationToken);
        }
        catch (ChannelNotFoundException) when
            (string.Equals(_executionContext.IdentityChannel, PackageChannelNames.Local, StringComparison.OrdinalIgnoreCase))
        {
            // Locally-built CLI (identity=local) on a machine where ~/.aspire/hives/local
            // isn't installed. The PackagingService produces no "local" channel in that
            // case, but init's contract is that identity-as-request is implicit — so fall
            // back to the implicit channel (ambient NuGet) instead of failing. This branch
            // lives here, NOT in the resolver, so that an explicit `aspire new --channel local`
            // without the hive correctly errors instead of silently switching feeds.
            var fallbackQuery = new TemplatePackageQuery(
                RequestedChannel: null,
                VersionOverride: null,
                SourceOverride: null,
                IncludePrHives: false);
 
            selection = await _templateNuGetConfigService.ResolveTemplatePackageAsync(fallbackQuery, cancellationToken);
        }
        catch (ChannelNotFoundException ex)
        {
            InteractionService.DisplayError(ex.Message);
            return CliExitCodes.FailedToInstallTemplates;
        }
        catch (EmptyChoicesException ex)
        {
            InteractionService.DisplayError(ex.Message);
            return CliExitCodes.FailedToInstallTemplates;
        }
        catch (NuGetPackageCacheException ex)
        {
            // Surface NuGet feed search failures (offline, inaccessible feed, etc.) with a friendly error
            // instead of letting them bubble up to the top-level "unexpected error" handler. The pre-extraction
            // init code went straight to `dotnet new install` and never invoked a NuGet search, so this catch
            // restores parity with the prior init failure mode for these scenarios.
            InteractionService.DisplayError(ex.Message);
            return CliExitCodes.FailedToInstallTemplates;
        }
 
        // The aspire-apphost template ships in the Aspire.ProjectTemplates package.
        // `dotnet new` does not install templates implicitly, so on a fresh machine
        // (or after a CLI update) the template will be missing. Install first.
        var installOutcome = await _templateNuGetConfigService.InstallTemplatePackageAsync(
            selection,
            sourceOverride: null,
            _runner,
            InitCommandStrings.InstallingAspireProjectTemplates,
            statusEmoji: null,
            cancellationToken);
 
        if (installOutcome.ExitCode != 0)
        {
            InteractionService.DisplayLines(installOutcome.OutputLines);
            InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TemplatingStrings.TemplateInstallationFailed, installOutcome.ExitCode));
            return CliExitCodes.FailedToInstallTemplates;
        }
 
        // Use the aspire-apphost template to generate a correct AppHost project
        // with proper launchSettings.json, .csproj, and Program.cs.
        var result = await InteractionService.ShowStatusAsync(
            InitCommandStrings.CreatingAppHostFromTemplate,
            async () =>
            {
                return await _runner.NewProjectAsync(
                    "aspire-apphost",
                    appHostDirName,
                    appHostDirPath,
                    extraArgs: [],
                    options: new ProcessInvocationOptions(),
                    cancellationToken: cancellationToken);
            });
 
        if (result != 0)
        {
            InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InitCommandStrings.FailedToCreateAppHostFromTemplate, result));
            return CliExitCodes.FailedToCreateNewProject;
        }
 
        InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, string.Format(CultureInfo.CurrentCulture, InitCommandStrings.CreatedFile, $"{appHostDirName}/"));
 
        return CliExitCodes.Success;
    }
 
    private async Task<int> DropPolyglotSkeletonAsync(string languageId, DirectoryInfo workingDirectory, CancellationToken cancellationToken)
    {
        var language = _languageDiscovery.GetLanguageById(languageId)
            ?? throw new NotSupportedException($"Polyglot skeleton not yet supported for language: {languageId}");
 
        var existingAppHostFileName = language.DetectionPatterns
            .Where(pattern => !pattern.Contains('*', StringComparison.Ordinal))
            .FirstOrDefault(pattern => File.Exists(Path.Combine(workingDirectory.FullName, pattern)));
        if (existingAppHostFileName is not null)
        {
            InteractionService.DisplayMessage(KnownEmojis.Information, string.Format(CultureInfo.CurrentCulture, InitCommandStrings.FileAlreadyExistsSkipping, existingAppHostFileName));
            return CliExitCodes.Success;
        }
 
        var appHostPath = ScaffoldingService.GetAppHostPath(workingDirectory, language);
        var displayPath = PathNormalizer.NormalizePathForStorage(Path.GetRelativePath(workingDirectory.FullName, appHostPath));
        if (File.Exists(appHostPath))
        {
            InteractionService.DisplayMessage(KnownEmojis.Information, string.Format(CultureInfo.CurrentCulture, InitCommandStrings.FileAlreadyExistsSkipping, displayPath));
            return CliExitCodes.Success;
        }
 
        // Resolve and pass the running CLI's identity channel through to the scaffolder
        // so it lands in aspire.config.json#channel. Only persist when the identity
        // resolves to a persistable registered Explicit channel — see
        // `ResolvePersistableChannelNameAsync` for the full rationale. Additionally,
        // if aspire.config.json already carries a channel value, suppress the pass-through:
        // `ScaffoldingService.cs:93-95` writes
        // `config.Channel = context.Channel` unconditionally when non-empty, so without
        // this guard a user-edited channel would be silently overwritten.
        var resolvedChannel = await ResolvePersistableChannelNameAsync(cancellationToken);
        if (!string.IsNullOrEmpty(resolvedChannel))
        {
            var existing = TryLoadExistingChannel(workingDirectory);
            if (!string.IsNullOrEmpty(existing))
            {
                resolvedChannel = null;
            }
        }
 
        var context = new ScaffoldContext(language, workingDirectory, workingDirectory.Name, Channel: resolvedChannel);
        var scaffolded = await _scaffoldingService.ScaffoldAsync(context, cancellationToken);
        if (!scaffolded)
        {
            return CliExitCodes.FailedToCreateNewProject;
        }
 
        InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, string.Format(CultureInfo.CurrentCulture, InitCommandStrings.CreatedFile, displayPath));
        return CliExitCodes.Success;
    }
 
    private (int ExitCode, AppHostProfilePorts EffectivePorts) DropAspireConfig(DirectoryInfo directory, string appHostPath, string? language, string? channel, AppHostProfilePorts? ports = null)
    {
        var configPath = Path.Combine(directory.FullName, AspireConfigFile.FileName);
 
        JsonObject settings;
 
        if (File.Exists(configPath))
        {
            // Merge into existing file (e.g. language selection already wrote it)
            var existingContent = File.ReadAllText(configPath);
            if (string.IsNullOrWhiteSpace(existingContent))
            {
                settings = new JsonObject();
            }
            else
            {
                try
                {
                    settings = JsonNode.Parse(existingContent)?.AsObject() ?? new JsonObject();
                }
                catch (JsonException ex)
                {
                    InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InitCommandStrings.FailedToParseExistingConfig, AspireConfigFile.FileName, configPath, ex.Message));
                    InteractionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, InitCommandStrings.FixOrRemoveConfigAndRerun, AspireConfigFile.FileName));
                    return (CliExitCodes.FailedToCreateNewProject, default);
                }
            }
        }
        else
        {
            settings = new JsonObject();
        }
 
        // Ensure appHost section exists
        if (settings["appHost"] is not JsonObject appHost)
        {
            appHost = new JsonObject();
            settings["appHost"] = appHost;
        }
 
        // Set path (always — this is the primary purpose of DropAspireConfig)
        appHost["path"] = appHostPath;
 
        // Set language if provided and not already present
        if (language is not null && appHost["language"] is null)
        {
            appHost["language"] = language;
        }
 
        // Persist the channel at the top level so `aspire add` / `integration list` /
        // `integration search` resolve packages against the channel the CLI scaffolded
        // for. Only write when not already present so a user-edited value wins. Leaving
        // the channel unset on a non-stable CLI causes downstream commands to fall back
        // to implicit nuget.org versions that don't line up with the CLI build.
        if (!string.IsNullOrEmpty(channel) && settings["channel"] is null)
        {
            settings["channel"] = channel;
        }
 
        // Resolve the effective ports. Three cases:
        //   1. profiles is null → write fresh profiles, return those ports
        //   2. profiles exists and parses cleanly → adopt those ports, return them (so
        //      apphost.run.json stays in sync with what `aspire run` will use)
        //   3. profiles exists but doesn't match the expected 6-port shape (user-customized
        //      or older format) → PRESERVE the existing profiles untouched and just generate
        //      fresh ports for apphost.run.json. This is strictly safer than overwriting,
        //      even if the two files end up disagreeing on dashboard ports — the user has
        //      already opted into a custom config and we shouldn't trash their data.
        AppHostProfilePorts effectivePorts;
        var existingProfilesObject = settings["profiles"] as JsonObject;
        if (existingProfilesObject is not null && TryReadAppHostProfilePorts(existingProfilesObject, out var readPorts))
        {
            effectivePorts = readPorts;
        }
        else if (existingProfilesObject is not null)
        {
            // Existing profiles can't be parsed into our expected shape — leave them alone
            // and just generate fresh ports for apphost.run.json. We deliberately don't
            // overwrite the user's customizations, even though it means the two files may
            // bind to different dashboard URLs in this edge case.
            effectivePorts = ports ?? AppHostProfilePortGenerator.Generate(Random.Shared);
        }
        else
        {
            // Matches the profile structure used by `aspire new` templates (see Templates/*/aspire.config.json).
            // Normally scaffolding + codegen creates these, but our thin init skips scaffolding.
            effectivePorts = ports ?? AppHostProfilePortGenerator.Generate(Random.Shared);
 
            // Two profiles (https + http) so `aspire run` can pick either based on user choice.
            // Each carries the dashboard URL (applicationUrl) plus the OTLP and resource-service
            // endpoint env vars consumed by DashboardOptionsValidator at AppHost startup.
            settings["profiles"] = new JsonObject
            {
                ["https"] = new JsonObject
                {
                    ["applicationUrl"] = $"https://localhost:{effectivePorts.DashboardHttpsPort};http://localhost:{effectivePorts.DashboardHttpPort}",
                    ["environmentVariables"] = new JsonObject
                    {
                        [KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = $"https://localhost:{effectivePorts.OtlpHttpsPort}",
                        [KnownConfigNames.ResourceServiceEndpointUrl] = $"https://localhost:{effectivePorts.ResourceServiceHttpsPort}"
                    }
                },
                ["http"] = new JsonObject
                {
                    ["applicationUrl"] = $"http://localhost:{effectivePorts.DashboardHttpPort}",
                    ["environmentVariables"] = new JsonObject
                    {
                        [KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = $"http://localhost:{effectivePorts.OtlpHttpPort}",
                        [KnownConfigNames.ResourceServiceEndpointUrl] = $"http://localhost:{effectivePorts.ResourceServiceHttpPort}",
                        [KnownConfigNames.AllowUnsecuredTransport] = "true"
                    }
                }
            };
        }
 
        File.WriteAllText(configPath, JsonSerializer.Serialize(settings, JsonSourceGenerationContext.RelaxedEscaping.JsonObject));
 
        InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, string.Format(CultureInfo.CurrentCulture, InitCommandStrings.CreatedFile, AspireConfigFile.FileName));
        return (CliExitCodes.Success, effectivePorts);
    }
 
    /// <summary>
    /// Resolves the <see cref="CliExecutionContext.IdentityChannel"/> into a channel name
    /// safe to persist into <c>aspire.config.json#channel</c>. Returns <c>null</c> when:
    /// <list type="bullet">
    /// <item><description>The identity is empty (no channel context).</description></item>
    /// <item><description>The identity doesn't match any registered channel (e.g. <c>local</c>,
    /// <c>staging</c> on a CLI without the staging feature flag, or a stale <c>pr-{N}</c> on a
    /// machine without the matching hive). Persisting these would pin a name no PSM rule can
    /// satisfy and zero out polyglot <c>aspire add</c> discovery via
    /// <c>IntegrationPackageSearchService.cs</c> line 28-30.</description></item>
    /// <item><description>The matched channel is <see cref="PackageChannelType.Implicit"/>.
    /// In production the only Implicit channel created by <c>PackagingService.GetChannelsAsync</c>
    /// is <c>default</c> (the unscoped nuget.org aggregator), which no CLI identity ever
    /// resolves to — this branch exists defensively in case a future <c>PackagingService</c>
    /// adds another Implicit channel whose name happens to collide with a CLI identity.</description></item>
    /// <item><description>The matched channel is <see cref="PackageChannelNames.Stable"/>.
    /// Stable uses the same public-feed package set users get by default, but pinning it
    /// per-project makes package discovery use only the synthetic NuGet.org config and
    /// hides packages from ambient private feeds.</description></item>
    /// </list>
    /// Mirrors the resolution logic in <c>NewCommand.cs:316-402</c> and the warning in
    /// <c>ScaffoldingService.cs:84-92</c> against falling back to <c>IdentityChannel</c> blindly.
    /// </summary>
    private async Task<string?> ResolvePersistableChannelNameAsync(CancellationToken cancellationToken)
    {
        var identityChannel = _executionContext.IdentityChannel;
        if (string.IsNullOrWhiteSpace(identityChannel))
        {
            return null;
        }
 
        IEnumerable<PackageChannel> channels;
        try
        {
            channels = await _packagingService.GetChannelsAsync(cancellationToken, identityChannel);
        }
        catch (Exception)
        {
            // Channel discovery is best-effort here — failing to resolve must not break
            // `aspire init`. Skip persistence and let downstream commands re-resolve.
            return null;
        }
 
        var match = channels.FirstOrDefault(c => string.Equals(c.Name, identityChannel, StringComparisons.ChannelName));
        return match?.ShouldPersistChannelName() is true ? match.Name : null;
    }
 
    /// <summary>
    /// Best-effort read of the persisted channel from <c>aspire.config.json</c> in
    /// <paramref name="directory"/>. Used by the polyglot path to avoid overwriting a
    /// user-edited value via <c>ScaffoldingService</c>, which writes the context channel
    /// unconditionally. Returns <c>null</c> if the file is absent, unparseable, or has
    /// no <c>channel</c> key — those cases all mean "no user-set value to preserve".
    /// </summary>
    private static string? TryLoadExistingChannel(DirectoryInfo directory)
    {
        try
        {
            return AspireConfigFile.Load(directory.FullName)?.Channel;
        }
        catch
        {
            return null;
        }
    }
 
    // Best-effort extraction of the dashboard / OTLP / resource service ports from an
    // existing `profiles` section. Returns true only if every expected port can be parsed
    // from the https + http profiles, otherwise the caller falls back to fresh ports.
    private static bool TryReadAppHostProfilePorts(JsonObject profiles, out AppHostProfilePorts ports)
    {
        ports = default;
 
        if (profiles["https"] is not JsonObject https || profiles["http"] is not JsonObject http)
        {
            return false;
        }
 
        var httpsEnv = https["environmentVariables"] as JsonObject;
        var httpEnv = http["environmentVariables"] as JsonObject;
        if (httpsEnv is null || httpEnv is null)
        {
            return false;
        }
 
        if (!TryParseHostPort(https["applicationUrl"]?.GetValue<string>(), "https", out var dashboardHttps)
            || !TryParseHostPort(http["applicationUrl"]?.GetValue<string>(), "http", out var dashboardHttp)
            || !TryParseHostPort(httpsEnv[KnownConfigNames.DashboardOtlpGrpcEndpointUrl]?.GetValue<string>(), "https", out var otlpHttps)
            || !TryParseHostPort(httpEnv[KnownConfigNames.DashboardOtlpGrpcEndpointUrl]?.GetValue<string>(), "http", out var otlpHttp)
            || !TryParseHostPort(httpsEnv[KnownConfigNames.ResourceServiceEndpointUrl]?.GetValue<string>(), "https", out var resourceServiceHttps)
            || !TryParseHostPort(httpEnv[KnownConfigNames.ResourceServiceEndpointUrl]?.GetValue<string>(), "http", out var resourceServiceHttp))
        {
            return false;
        }
 
        ports = new AppHostProfilePorts(
            DashboardHttpsPort: dashboardHttps,
            DashboardHttpPort: dashboardHttp,
            OtlpHttpsPort: otlpHttps,
            OtlpHttpPort: otlpHttp,
            ResourceServiceHttpsPort: resourceServiceHttps,
            ResourceServiceHttpPort: resourceServiceHttp);
        return true;
    }
 
    // Parses the first `<scheme>://host:<port>` segment from a (possibly semicolon-
    // separated) URL list. Returns false if no segment with the requested scheme is found
    // or the port can't be parsed.
    private static bool TryParseHostPort(string? value, string scheme, out int port)
    {
        port = 0;
        if (string.IsNullOrWhiteSpace(value))
        {
            return false;
        }
 
        foreach (var raw in value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
        {
            if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri))
            {
                continue;
            }
 
            if (string.Equals(uri.Scheme, scheme, StringComparison.OrdinalIgnoreCase) && uri.Port > 0)
            {
                port = uri.Port;
                return true;
            }
        }
 
        return false;
    }
 
    // Writes apphost.run.json next to the single-file AppHost so that
    // `dotnet run apphost.cs` (.NET file-based runner) picks up the dashboard / OTLP /
    // resource service launch profile env vars. Mirrors the structure shipped by the
    // aspire-apphost-singlefile MSBuild template. Skips if the file already exists.
    private void DropAppHostRunJson(DirectoryInfo directory, AppHostProfilePorts ports)
    {
        const string fileName = "apphost.run.json";
        var path = Path.Combine(directory.FullName, fileName);
        if (File.Exists(path))
        {
            return;
        }
 
        // Shape mirrors a Properties/launchSettings.json (the schema the .NET file-based
        // runner inherits for `[file].run.json`): a `profiles` map with `commandName: Project`
        // entries. The https / http pair gives `dotnet run apphost.cs` a working dashboard
        // URL plus the OTLP and resource-service endpoint env vars that DashboardOptionsValidator
        // requires — without these the AppHost crashes at startup (see #15986).
        var settings = new JsonObject
        {
            ["$schema"] = "https://json.schemastore.org/launchsettings.json",
            ["profiles"] = new JsonObject
            {
                ["https"] = new JsonObject
                {
                    ["commandName"] = "Project",
                    ["dotnetRunMessages"] = true,
                    ["launchBrowser"] = true,
                    ["applicationUrl"] = $"https://localhost:{ports.DashboardHttpsPort};http://localhost:{ports.DashboardHttpPort}",
                    ["environmentVariables"] = new JsonObject
                    {
                        [KnownAspNetCoreConfigNames.Environment] = "Development",
                        [KnownAspNetCoreConfigNames.DotNetEnvironment] = "Development",
                        [KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = $"https://localhost:{ports.OtlpHttpsPort}",
                        [KnownConfigNames.ResourceServiceEndpointUrl] = $"https://localhost:{ports.ResourceServiceHttpsPort}"
                    }
                },
                ["http"] = new JsonObject
                {
                    ["commandName"] = "Project",
                    ["dotnetRunMessages"] = true,
                    ["launchBrowser"] = true,
                    ["applicationUrl"] = $"http://localhost:{ports.DashboardHttpPort}",
                    ["environmentVariables"] = new JsonObject
                    {
                        [KnownAspNetCoreConfigNames.Environment] = "Development",
                        [KnownAspNetCoreConfigNames.DotNetEnvironment] = "Development",
                        [KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = $"http://localhost:{ports.OtlpHttpPort}",
                        [KnownConfigNames.ResourceServiceEndpointUrl] = $"http://localhost:{ports.ResourceServiceHttpPort}",
                        [KnownConfigNames.AllowUnsecuredTransport] = "true"
                    }
                }
            }
        };
 
        File.WriteAllText(path, JsonSerializer.Serialize(settings, JsonSourceGenerationContext.RelaxedEscaping.JsonObject));
 
        InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, string.Format(CultureInfo.CurrentCulture, InitCommandStrings.CreatedFile, fileName));
    }
 
}