File: Commands\InitCommandTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json.Nodes;
using System.Xml.Linq;
using Aspire.Cli.Agents;
using Aspire.Cli.Commands;
using Aspire.Cli.Configuration;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Scaffolding;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Hosting;
using Aspire.Shared;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.AspNetCore.InternalTesting;
 
namespace Aspire.Cli.Tests.Commands;
 
public class InitCommandTests(ITestOutputHelper outputHelper)
{
    /// <summary>
    /// Configures the test packaging service factory to return a single implicit channel
    /// whose template package cache yields one Aspire.ProjectTemplates entry, and pins the
    /// running CLI's identity channel to <c>default</c> so the resolver matches that implicit
    /// channel by name. Init's project-mode path uses
    /// <see cref="CliExecutionContext.IdentityChannel"/> as the channel override; this helper keeps
    /// tests that don't care about channel selection on a single, predictable channel.
    /// </summary>
    private static void ConfigureImplicitTemplateChannel(CliServiceCollectionTestOptions options, string version = "13.3.0")
    {
        options.CliExecutionContextFactory = _ =>
            BuildExecutionContext(options.WorkingDirectory, channel: "default");
 
        options.PackagingServiceFactory = _ =>
        {
            var fakeCache = new FakeNuGetPackageCache
            {
                GetTemplatePackagesAsyncCallback = (_, _, _, _) =>
                    Task.FromResult<IEnumerable<NuGetPackageCli>>(
                        [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "nuget.org", Version = version }])
            };
 
            var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures(), NullLogger.Instance);
 
            var packagingService = new TestPackagingService
            {
                GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([implicitChannel])
            };
 
            return packagingService;
        };
    }
 
    [Theory]
    [InlineData("Test.csproj")]
    [InlineData("Test.fsproj")]
    [InlineData("Test.vbproj")]
    public async Task InitCommand_WhenSolutionAndProjectInSameDirectory_CreatesProjectModeAppHost(string projectFileName)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln"));
        File.WriteAllText(solutionFile.FullName, "Fake solution file");
 
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, projectFileName));
        File.WriteAllText(projectFile.FullName, "<Project />");
 
        string? capturedTemplateName = null;
        string? capturedName = null;
        string? capturedOutputPath = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            ConfigureImplicitTemplateChannel(options);
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                runner.GetSolutionProjectsAsyncCallback = (_, _, _) =>
                {
                    throw new InvalidOperationException("GetSolutionProjectsAsync should not be called by init.");
                };
                runner.NewProjectAsyncCallback = (templateName, name, outputPath, _, _) =>
                {
                    capturedTemplateName = templateName;
                    capturedName = name;
                    capturedOutputPath = outputPath;
                    // Simulate template creating the directory
                    Directory.CreateDirectory(outputPath);
                    return 0;
                };
                return runner;
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal("aspire-apphost", capturedTemplateName);
        Assert.Equal("Test.AppHost", capturedName);
        Assert.Contains("Test.AppHost", capturedOutputPath);
        Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json")));
    }
 
    [Fact]
    public async Task InitCommand_WhenSolutionDirectoryHasNoProjectFiles_CreatesProjectModeAppHost()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln"));
        File.WriteAllText(solutionFile.FullName, "Fake solution file");
 
        string? capturedTemplateName = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            ConfigureImplicitTemplateChannel(options);
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                runner.GetSolutionProjectsAsyncCallback = (_, _, _) =>
                {
                    throw new InvalidOperationException("GetSolutionProjectsAsync should not be called by init.");
                };
                runner.NewProjectAsyncCallback = (templateName, name, outputPath, _, _) =>
                {
                    capturedTemplateName = templateName;
                    Directory.CreateDirectory(outputPath);
                    return 0;
                };
                return runner;
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal("aspire-apphost", capturedTemplateName);
        Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json")));
    }
 
    [Fact]
    public async Task InitCommand_WhenNoSolutionExists_CreatesSingleFileAppHostAndAspireConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs")));
 
        var config = JsonNode.Parse(File.ReadAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json")))!.AsObject();
        var appHost = config["appHost"]!.AsObject();
        Assert.Equal("apphost.cs", appHost["path"]!.GetValue<string>());
        Assert.Null(appHost["language"]);
    }
 
    [Fact]
    public async Task InitCommand_SingleFileSkeleton_CreatesAppHostRunJsonWithDashboardEnvVars()
    {
        // Regression for https://github.com/microsoft/aspire/issues/15986: without
        // apphost.run.json, `dotnet run apphost.cs` after `aspire init` crashes because
        // the dashboard env vars (ASPNETCORE_URLS, ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL)
        // are not set. Init must emit apphost.run.json alongside aspire.config.json so
        // the file-based runner picks up a launch profile.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var runJsonPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.run.json");
        Assert.True(File.Exists(runJsonPath), "apphost.run.json should be created so `dotnet run apphost.cs` works.");
 
        var runJson = JsonNode.Parse(File.ReadAllText(runJsonPath))!.AsObject();
        var profiles = runJson["profiles"]!.AsObject();
 
        var https = profiles["https"]!.AsObject();
        Assert.Equal("Project", https["commandName"]!.GetValue<string>());
        Assert.True(https["dotnetRunMessages"]!.GetValue<bool>());
        var httpsUrls = https["applicationUrl"]!.GetValue<string>();
        Assert.StartsWith("https://localhost:", httpsUrls);
        var httpsEnv = https["environmentVariables"]!.AsObject();
        Assert.Equal("Development", httpsEnv[KnownAspNetCoreConfigNames.Environment]!.GetValue<string>());
        Assert.Equal("Development", httpsEnv[KnownAspNetCoreConfigNames.DotNetEnvironment]!.GetValue<string>());
        Assert.StartsWith("https://localhost:", httpsEnv["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]!.GetValue<string>());
        Assert.StartsWith("https://localhost:", httpsEnv["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]!.GetValue<string>());
 
        var http = profiles["http"]!.AsObject();
        Assert.Equal("Project", http["commandName"]!.GetValue<string>());
        var httpEnv = http["environmentVariables"]!.AsObject();
        Assert.Equal("Development", httpEnv[KnownAspNetCoreConfigNames.Environment]!.GetValue<string>());
        Assert.StartsWith("http://localhost:", httpEnv["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]!.GetValue<string>());
        Assert.StartsWith("http://localhost:", httpEnv["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]!.GetValue<string>());
        Assert.Equal("true", httpEnv["ASPIRE_ALLOW_UNSECURED_TRANSPORT"]!.GetValue<string>());
 
        // The two files must agree on ports — otherwise `aspire run` and
        // `dotnet run apphost.cs` would bind to different dashboard URLs.
        var aspireConfig = JsonNode.Parse(File.ReadAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json")))!.AsObject();
        var aspireProfiles = aspireConfig["profiles"]!.AsObject();
        Assert.Equal(
            aspireProfiles["https"]!["applicationUrl"]!.GetValue<string>(),
            httpsUrls);
        Assert.Equal(
            aspireProfiles["http"]!["applicationUrl"]!.GetValue<string>(),
            http["applicationUrl"]!.GetValue<string>());
    }
 
    [Fact]
    public async Task InitCommand_SingleFileSkeleton_AppHostRunJsonAdoptsPortsFromExistingAspireConfig()
    {
        // If aspire.config.json already exists with a `profiles` section (e.g. user
        // re-ran `aspire init` after editing it, or copied a stale file in), the new
        // apphost.run.json must adopt those same ports — the two files should never
        // disagree on dashboard / OTLP / resource service endpoints.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        const string existingAspireConfig = """
            {
              "appHost": {
                "path": "apphost.cs"
              },
              "profiles": {
                "https": {
                  "applicationUrl": "https://localhost:18000;http://localhost:18001",
                  "environmentVariables": {
                    "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:18002",
                    "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:18003"
                  }
                },
                "http": {
                  "applicationUrl": "http://localhost:18001",
                  "environmentVariables": {
                    "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:18005",
                    "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:18006",
                    "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true"
                  }
                }
              }
            }
            """;
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"), existingAspireConfig);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var runJson = JsonNode.Parse(File.ReadAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.run.json")))!.AsObject();
        var profiles = runJson["profiles"]!.AsObject();
        var https = profiles["https"]!.AsObject();
        var http = profiles["http"]!.AsObject();
 
        Assert.Equal("https://localhost:18000;http://localhost:18001", https["applicationUrl"]!.GetValue<string>());
        Assert.Equal("https://localhost:18002", https["environmentVariables"]!["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]!.GetValue<string>());
        Assert.Equal("https://localhost:18003", https["environmentVariables"]!["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]!.GetValue<string>());
        Assert.Equal("http://localhost:18001", http["applicationUrl"]!.GetValue<string>());
        Assert.Equal("http://localhost:18005", http["environmentVariables"]!["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]!.GetValue<string>());
        Assert.Equal("http://localhost:18006", http["environmentVariables"]!["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]!.GetValue<string>());
    }
 
    [Fact]
    public async Task InitCommand_SingleFileSkeleton_PreservesUnparseableExistingProfiles()
    {
        // Regression guard for behavioral safety: if aspire.config.json already has a
        // `profiles` section that doesn't match the expected 6-port shape (e.g. user-customized,
        // missing one of the env vars, or an https-only setup), `aspire init` must NOT
        // overwrite those profiles. The user has clearly customized their config and we
        // shouldn't trash their data — even at the cost of apphost.run.json potentially
        // binding to different dashboard ports.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        const string customAspireConfig = """
            {
              "appHost": {
                "path": "apphost.cs"
              },
              "profiles": {
                "https": {
                  "applicationUrl": "https://localhost:18000",
                  "environmentVariables": {
                    "MY_CUSTOM_VAR": "custom-value"
                  }
                }
              }
            }
            """;
        var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json");
        File.WriteAllText(aspireConfigPath, customAspireConfig);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        // The user's customizations must be preserved verbatim — only the appHost.path
        // is allowed to be touched (since that's the primary purpose of DropAspireConfig).
        var aspireConfig = JsonNode.Parse(File.ReadAllText(aspireConfigPath))!.AsObject();
        var preservedProfiles = aspireConfig["profiles"]!.AsObject();
        Assert.False(preservedProfiles.ContainsKey("http"), "http profile should NOT have been added.");
        var preservedHttps = preservedProfiles["https"]!.AsObject();
        Assert.Equal("https://localhost:18000", preservedHttps["applicationUrl"]!.GetValue<string>());
        var preservedEnv = preservedHttps["environmentVariables"]!.AsObject();
        Assert.Equal("custom-value", preservedEnv["MY_CUSTOM_VAR"]!.GetValue<string>());
        Assert.False(preservedEnv.ContainsKey("ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"), "OTLP env var should NOT have been added to user's custom profile.");
 
        // apphost.run.json still gets written so `dotnet run apphost.cs` works (with fresh
        // ports — accepted divergence in this edge case).
        Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.run.json")));
    }
 
    [Fact]
    public async Task InitCommand_WhenDeprecatedCompatibilityOptionsProvided_SucceedsAndWarns()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var interactionService = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
        });
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        Assert.True(initCommand.Options.Single(o => o.Name == "--source").Hidden);
        Assert.True(initCommand.Options.Single(o => o.Name == "--version").Hidden);
        Assert.True(initCommand.Options.Single(o => o.Name == "--channel").Hidden);
 
        var parseResult = initCommand.Parse("init --source https://example.test/v3/index.json --version 13.0.0 --channel daily --suppress-agent-init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs")));
        Assert.Contains(interactionService.DisplayedMessages, m => m.Message.Contains("`aspire init --source` is deprecated", StringComparison.Ordinal));
        Assert.Contains(interactionService.DisplayedMessages, m => m.Message.Contains("`aspire init --version` is deprecated", StringComparison.Ordinal));
        Assert.Contains(interactionService.DisplayedMessages, m => m.Message.Contains("`aspire init --channel` is deprecated", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task InitCommand_WhenTypeScriptSelected_CreatesAppHostAndAspireConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.LanguageServiceFactory = (sp) =>
            {
                var projectFactory = sp.GetRequiredService<IAppHostProjectFactory>();
                var tsProject = projectFactory.GetProject(new LanguageInfo(
                    LanguageId: new LanguageId(KnownLanguageId.TypeScript),
                    DisplayName: "TypeScript (Node.js)",
                    PackageName: "@aspire/app-host",
                    DetectionPatterns: ["apphost.mts", "apphost.ts"],
                    CodeGenerator: "TypeScript",
                    AppHostFileName: "apphost.mts"));
                return new TestLanguageService { DefaultProject = tsProject };
            };
            options.ScaffoldingServiceFactory = _ => new TestScaffoldingService();
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.mts")));
 
        var config = JsonNode.Parse(File.ReadAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json")))!.AsObject();
        var appHost = config["appHost"]!.AsObject();
        Assert.Equal("apphost.mts", appHost["path"]!.GetValue<string>());
        Assert.Equal("typescript/nodejs", appHost["language"]!.GetValue<string>());
    }
 
    [Fact]
    public async Task InitCommand_WhenLegacyTypeScriptAppHostExists_DoesNotCreateMtsAppHost()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var existingAppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts");
        const string existingAppHostContent = "console.log('existing commonjs-compatible project');";
        File.WriteAllText(existingAppHostPath, existingAppHostContent);
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), """{ "type": "commonjs" }""");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ScaffoldingServiceFactory = _ => new TestScaffoldingService();
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init --language typescript");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(existingAppHostContent, File.ReadAllText(existingAppHostPath));
        Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.mts")));
        Assert.Equal("""{ "type": "commonjs" }""", File.ReadAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json")));
    }
 
    [Fact]
    public async Task InitCommand_WhenBrownfieldTypeScriptSelected_DisplaysNestedAppHostPath()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{}");
 
        var interactionService = new TestInteractionService();
        var scaffoldingService = new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (context, _) =>
            {
                var scaffoldDirectory = ScaffoldingService.GetScaffoldDirectory(context.TargetDirectory, context.Language);
                Directory.CreateDirectory(scaffoldDirectory.FullName);
                File.WriteAllText(Path.Combine(scaffoldDirectory.FullName, context.Language.AppHostFileName!), string.Empty);
                return Task.FromResult(true);
            }
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.LanguageServiceFactory = (sp) =>
            {
                var projectFactory = sp.GetRequiredService<IAppHostProjectFactory>();
                var tsProject = projectFactory.GetProject(new LanguageInfo(
                    LanguageId: new LanguageId(KnownLanguageId.TypeScript),
                    DisplayName: "TypeScript (Node.js)",
                    PackageName: "@aspire/app-host",
                    DetectionPatterns: ["apphost.mts", "apphost.ts"],
                    CodeGenerator: "TypeScript",
                    AppHostFileName: "apphost.mts"));
                return new TestLanguageService { DefaultProject = tsProject };
            };
            options.ScaffoldingServiceFactory = _ => scaffoldingService;
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init --language typescript");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.mts")));
        Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-apphost", "apphost.mts")));
        Assert.Contains(interactionService.DisplayedMessages, m => m.Message == "Created aspire-apphost/apphost.mts");
    }
 
    [Fact]
    public async Task InitCommand_WhenAspireifySkillSelected_PrintsToolSpecificFollowUpCommands()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var interactionService = new TestInteractionService
        {
            ConfirmCallback = (_, _) => true
        };
 
        var subtleMessages = new List<string>();
        interactionService.DisplaySubtleMessageCallback = subtleMessages.Add;
        interactionService.PromptForSelectionsCallback = (_, choices, _, _) =>
        {
            var items = choices.Cast<object>().ToList();
 
            if (items.FirstOrDefault() is SkillLocation)
            {
                return [SkillLocation.Standard, SkillLocation.ClaudeCode, SkillLocation.OpenCode];
            }
 
            return items
                .OfType<SkillDefinition>()
                .Where(static skill => skill.HasName(CommonAgentApplicators.AspireifySkillName))
                .Cast<object>()
                .ToList();
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.CliHostEnvironmentFactory = _ => global::Aspire.Cli.Tests.TestHelpers.CreateInteractiveHostEnvironment();
            options.ScaffoldingServiceFactory = _ => new TestScaffoldingService();
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init --language typescript");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Contains(interactionService.DisplayedMessages, m => m.Message == "Aspire AppHost created! To complete setup, run one of:");
        Assert.DoesNotContain(subtleMessages, m => m.Contains("copilot", StringComparison.OrdinalIgnoreCase));
        Assert.Contains("  claude \"run the aspireify skill\"", subtleMessages);
        Assert.Contains("  opencode --prompt \"run the aspireify skill\"", subtleMessages);
    }
 
    [Fact]
    public async Task InitCommand_WhenAspireifySkillNotSelected_DoesNotPrintFollowUpCommands()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var interactionService = new TestInteractionService
        {
            ConfirmCallback = (_, _) => true
        };
 
        var subtleMessages = new List<string>();
        interactionService.DisplaySubtleMessageCallback = subtleMessages.Add;
        interactionService.PromptForSelectionsCallback = (_, choices, _, _) =>
        {
            var items = choices.Cast<object>().ToList();
 
            if (items.FirstOrDefault() is SkillLocation)
            {
                return [SkillLocation.Standard];
            }
 
            return items
                .OfType<SkillDefinition>()
                .Where(static skill => skill.HasName(CommonAgentApplicators.AspireSkillName))
                .Cast<object>()
                .ToList();
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.CliHostEnvironmentFactory = _ => global::Aspire.Cli.Tests.TestHelpers.CreateInteractiveHostEnvironment();
            options.ScaffoldingServiceFactory = _ => new TestScaffoldingService();
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init --language typescript");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.DoesNotContain(interactionService.DisplayedMessages, m => m.Message.Contains("To complete setup", StringComparison.Ordinal));
        Assert.DoesNotContain(subtleMessages, m => m.Contains("run the aspireify skill", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task InitCommand_NonInteractive_WithNoneSkills_DoesNotInstallAgentSkills()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var serviceProvider = services.BuildServiceProvider();
        var command = serviceProvider.GetRequiredService<RootCommand>();
 
        var parseResult = command.Parse("init --non-interactive --skills none");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs")));
 
        var aspireSkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", CommonAgentApplicators.AspireSkillName);
        Assert.False(Directory.Exists(aspireSkillPath), $"Expected no aspire skill directory but found {aspireSkillPath}");
 
        var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", CommonAgentApplicators.AspireifySkillName);
        Assert.False(Directory.Exists(aspireifySkillPath), $"Expected no aspireify skill directory but found {aspireifySkillPath}");
    }
 
    [Fact]
    public async Task InitCommand_NonInteractive_WithSkillLocationsNone_DoesNotInstallAgentSkills()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var serviceProvider = services.BuildServiceProvider();
        var command = serviceProvider.GetRequiredService<RootCommand>();
 
        var parseResult = command.Parse("init --non-interactive --skill-locations none");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs")));
 
        // With --skill-locations none, no skill files should be installed regardless of skill selection.
        var agentsDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills");
        Assert.False(Directory.Exists(agentsDir), $"Expected no agents/skills directory but found {agentsDir}");
    }
 
    [Fact]
    public async Task InitCommand_NonInteractive_WithSkillLocationsAndSkills_InstallsOnlySpecifiedSkills()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var serviceProvider = services.BuildServiceProvider();
        var command = serviceProvider.GetRequiredService<RootCommand>();
 
        var parseResult = command.Parse($"init --non-interactive --skill-locations standard --skills {CommonAgentApplicators.AspireSkillName}");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs")));
 
        var aspireSkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md");
        Assert.True(File.Exists(aspireSkillPath), $"Expected aspire skill file at {aspireSkillPath}");
 
        // aspireify was not requested, so it should not be installed.
        var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", CommonAgentApplicators.AspireifySkillName);
        Assert.False(Directory.Exists(aspireifySkillPath), $"Expected no aspireify skill directory but found {aspireifySkillPath}");
    }
 
    [Fact]
    public async Task InitCommand_WhenNoSolutionExists_SingleFileSkeletonPinsSdkVersion()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
        var appHostContent = await File.ReadAllTextAsync(appHostPath);
 
        // The single-file skeleton must pin the SDK version with @<version> so downstream
        // CLI operations (ProjectUpdater / FallbackProjectParser) can locate and update
        // the directive.
        var firstLine = appHostContent.Split('\n')[0].TrimEnd('\r');
        Assert.StartsWith("#:sdk Aspire.AppHost.Sdk@", firstLine, StringComparison.Ordinal);
        Assert.NotEqual("#:sdk Aspire.AppHost.Sdk@", firstLine);
        Assert.Contains("#:property AspireUseCliBundle=true", appHostContent);
    }
 
    [Fact]
    public async Task InitCommand_WhenAspireConfigAlreadyExists_MergesAppHostSection()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Pre-write an aspire.config.json with custom properties a user might have edited in.
        var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        var existingConfig = new JsonObject
        {
            ["channel"] = "stable",
            ["features"] = new JsonObject { ["polyglotSupportEnabled"] = true }
        };
        await File.WriteAllTextAsync(configPath, existingConfig.ToJsonString());
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var merged = JsonNode.Parse(await File.ReadAllTextAsync(configPath))!.AsObject();
        Assert.Equal("apphost.cs", merged["appHost"]!.AsObject()["path"]!.GetValue<string>());
        // Pre-existing properties must be preserved.
        Assert.Equal("stable", merged["channel"]!.GetValue<string>());
        Assert.True(merged["features"]!.AsObject()["polyglotSupportEnabled"]!.GetValue<bool>());
    }
 
    [Fact]
    public async Task InitCommand_WhenAspireConfigIsMalformed_FailsCleanly()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Write a malformed aspire.config.json. The CLI configuration layer
        // (ConfigurationHelper.AddSettingsFile) should surface a friendly
        // InvalidOperationException identifying the offending file rather than a raw
        // JsonReaderException stack trace.
        var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        await File.WriteAllTextAsync(configPath, "{ this is not json");
 
        var ex = Assert.ThrowsAny<InvalidOperationException>(() =>
        {
            var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
            using var serviceProvider = services.BuildServiceProvider();
            return serviceProvider.GetRequiredService<InitCommand>();
        });
 
        Assert.Contains(AspireConfigFile.FileName, ex.Message, StringComparison.Ordinal);
        await Task.CompletedTask;
    }
 
    [Fact]
    public async Task InitCommand_WhenAppHostAlreadyExists_DoesNotOverwriteIt()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
        const string preExistingContent = "// user-authored apphost\n";
        await File.WriteAllTextAsync(appHostPath, preExistingContent);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(preExistingContent, await File.ReadAllTextAsync(appHostPath));
    }
 
    [Fact]
    public async Task InitCommand_WhenSolutionExistsAndChannelIsImplicit_LeavesNuGetConfigNull()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln"));
        File.WriteAllText(solutionFile.FullName, "Fake solution file");
 
        FileInfo? capturedNuGetConfigFile = new FileInfo("sentinel");
        string? capturedNuGetSource = "sentinel";
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            ConfigureImplicitTemplateChannel(options);
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                runner.InstallTemplateAsyncCallback = (_, version, nugetConfigFile, nugetSource, _, _, _) =>
                {
                    capturedNuGetConfigFile = nugetConfigFile;
                    capturedNuGetSource = nugetSource;
                    return (0, version);
                };
                runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) =>
                {
                    Directory.CreateDirectory(outputPath);
                    return 0;
                };
                return runner;
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Null(capturedNuGetConfigFile);
        // The implicit channel surfaces the package's Source field as the nugetSource even when no
        // temporary config is generated, so nugetSource may be non-null. The contract this test guards
        // is that nugetConfigFile stays null on the implicit channel.
 
        // The fix must also leave the solution-directory NuGet.config alone on the implicit
        // channel — TemplateNuGetConfigService.CreateOrUpdateNuGetConfigWithoutPromptAsync
        // short-circuits when the matched channel is not Explicit. A regression that dropped
        // the channel-type guard would silently create a workspace NuGet.config here.
        Assert.False(
            File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config")),
            "implicit channel must not write a workspace nuget.config in the solution directory.");
    }
 
    [Fact]
    public async Task InitCommand_WhenSolutionExistsAndPrHivesPresent_DoesNotWidenToAllChannels()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln"));
        File.WriteAllText(solutionFile.FullName, "Fake solution file");
 
        // Simulate a stale PR hive on disk so executionContext.GetHiveCount() returns > 0.
        var hivesDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives"));
        Directory.CreateDirectory(Path.Combine(hivesDir.FullName, "pr-12345", "packages"));
 
        string? capturedTemplateVersion = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ =>
                BuildExecutionContext(options.WorkingDirectory, channel: "default");
 
            options.PackagingServiceFactory = _ =>
            {
                // Implicit channel offers the expected stable version; PR hive channel offers a much
                // newer version that should NOT be selected because init opts out of PR-hive widening.
                var implicitCache = new FakeNuGetPackageCache
                {
                    GetTemplatePackagesAsyncCallback = (_, _, _, _) =>
                        Task.FromResult<IEnumerable<NuGetPackageCli>>(
                            [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "nuget.org", Version = "13.3.0" }])
                };
                var prHiveCache = new FakeNuGetPackageCache
                {
                    GetTemplatePackagesAsyncCallback = (_, _, _, _) =>
                        Task.FromResult<IEnumerable<NuGetPackageCli>>(
                            [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "pr-hive", Version = "99.0.0-pr.12345" }])
                };
 
                var implicitChannel = PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures(), NullLogger.Instance);
                var prHiveChannel = PackageChannel.CreateExplicitChannel(
                    "pr-12345",
                    PackageChannelQuality.Both,
                    [new PackageMapping("Aspire*", hivesDir.FullName + "/pr-12345/packages")],
                    prHiveCache,
                    features: new TestFeatures(), NullLogger.Instance);
 
                return new TestPackagingService
                {
                    GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([implicitChannel, prHiveChannel])
                };
            };
 
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                runner.InstallTemplateAsyncCallback = (_, version, _, _, _, _, _) =>
                {
                    capturedTemplateVersion = version;
                    return (0, version);
                };
                runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) =>
                {
                    Directory.CreateDirectory(outputPath);
                    return 0;
                };
                return runner;
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal("13.3.0", capturedTemplateVersion);
    }
 
    [Fact]
    public async Task InitCommand_WhenChannelTemplateSearchFails_DisplaysFriendlyError()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln"));
        File.WriteAllText(solutionFile.FullName, "Fake solution file");
 
        var interactionService = new TestInteractionService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ =>
                BuildExecutionContext(options.WorkingDirectory, channel: "default");
 
            options.InteractionServiceFactory = _ => interactionService;
 
            // Fake cache throws NuGetPackageCacheException to simulate offline / inaccessible feed.
            options.PackagingServiceFactory = _ =>
            {
                var fakeCache = new FakeNuGetPackageCache
                {
                    GetTemplatePackagesAsyncCallback = (_, _, _, _) =>
                        throw new NuGetPackageCacheException("Package search failed: simulated network failure")
                };
                var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures(), NullLogger.Instance);
                return new TestPackagingService
                {
                    GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([implicitChannel])
                };
            };
 
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                runner.InstallTemplateAsyncCallback = (_, _, _, _, _, _, _) =>
                {
                    throw new InvalidOperationException("InstallTemplateAsync should not run when channel search fails.");
                };
                return runner;
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToInstallTemplates, exitCode);
        Assert.Contains(interactionService.DisplayedErrors, e => e.Contains("simulated network failure", StringComparison.Ordinal));
    }
 
    /// <summary>
    /// When the user does not pass <c>--channel</c>, the project-mode init path must resolve
    /// its template package against the channel baked into the running CLI binary (exposed
    /// as <see cref="CliExecutionContext.IdentityChannel"/>). One named explicit channel is registered
    /// per theory row and uniquely sourced; the assertion captures the package source seen by
    /// <c>dotnet new install</c> and verifies it came from the matching channel — proving the
    /// resolver picked the binary's identity channel rather than the implicit default or any
    /// other registered channel.
    /// </summary>
    [Theory]
    [InlineData("stable")]
    [InlineData("staging")]
    [InlineData("daily")]
    [InlineData("pr-12345")]
    public async Task InitCommand_ProjectMode_NoChannelOverride_ResolvesAgainstCliExecutionContextChannel(string contextChannel)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln"));
        File.WriteAllText(solutionFile.FullName, "Fake solution file");
 
        string? capturedNuGetSource = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, contextChannel);
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService(contextChannel);
 
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                runner.InstallTemplateAsyncCallback = (_, version, _, nugetSource, _, _, _) =>
                {
                    capturedNuGetSource = nugetSource;
                    return (0, version);
                };
                runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) =>
                {
                    Directory.CreateDirectory(outputPath);
                    return 0;
                };
                return runner;
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(SourceForChannel(contextChannel), capturedNuGetSource);
    }
 
    /// <summary>
    /// Exercises the full produce → bake → resolve pipeline for PR builds: a CLI binary built
    /// with identity channel <c>pr</c> and PR number 12345 must resolve its init-time template
    /// against the <c>pr-12345</c> hive. This is a joint-contract test — per-layer unit tests
    /// can pass while the producer emits <c>pr</c>, the consumer expects <c>pr-12345</c>, and
    /// only an end-to-end run catches the mismatch.
    /// </summary>
    [Fact]
    public async Task InitCommand_ProjectMode_PrBuildResolvesToPrNumberedHive()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln"));
        File.WriteAllText(solutionFile.FullName, "Fake solution file");
 
        string? capturedNuGetSource = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => BuildExecutionContext(workspace.WorkspaceRoot, channel: "pr-12345");
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService("pr-12345");
 
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                runner.InstallTemplateAsyncCallback = (_, version, _, nugetSource, _, _, _) =>
                {
                    capturedNuGetSource = nugetSource;
                    return (0, version);
                };
                runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) =>
                {
                    Directory.CreateDirectory(outputPath);
                    return 0;
                };
                return runner;
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(SourceForChannel("pr-12345"), capturedNuGetSource);
    }
 
    /// <summary>
    /// When the user does not pass <c>--channel</c>, the single-file init path must wire the
    /// workspace <c>nuget.config</c> to the channel baked into the running CLI binary
    /// (exposed as <see cref="CliExecutionContext.IdentityChannel"/>). One named explicit channel is
    /// registered per theory row with a uniquely-sourced feed; the assertion reads the
    /// workspace <c>nuget.config</c> emitted by <c>NuGetConfigMerger</c> and verifies it
    /// carries the matching feed URL — proving the resolver picked the binary's identity
    /// channel rather than skipping the merge or selecting a different registered channel.
    /// The <c>stable</c> channel is intentionally excluded: its packages live on nuget.org
    /// (the ambient default source), so it drops no <c>nuget.config</c>; that case is covered
    /// by <see cref="InitCommand_SingleFileMode_StableIdentity_DoesNotCreateNuGetConfig"/>.
    /// </summary>
    [Theory]
    [InlineData("staging")]
    [InlineData("daily")]
    [InlineData("pr-12345")]
    public async Task InitCommand_SingleFileMode_NoChannelOverride_WiresNuGetConfigToCliExecutionContextChannel(string contextChannel)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, contextChannel);
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService(contextChannel);
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs")));
 
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        Assert.True(File.Exists(nugetConfigPath), $"nuget.config should be created in workspace for channel '{contextChannel}'.");
 
        AssertNuGetConfigHasChannelShape(nugetConfigPath, contextChannel);
    }
 
    /// <summary>
    /// Inverse of <see cref="InitCommand_SingleFileMode_NoChannelOverride_WiresNuGetConfigToCliExecutionContextChannel"/>
    /// for the <c>stable</c> channel: a stable-identity single-file <c>aspire init</c> must NOT
    /// drop a workspace <c>nuget.config</c>. The stable channel's packages live on nuget.org —
    /// the ambient default source — so a <c>&lt;clear/&gt;</c>-based config would be redundant
    /// and would wipe any additional feeds the user already relies on. The packaging service
    /// still registers a <c>stable</c> channel with a (test) feed source, proving the skip is
    /// driven by the channel name rather than an absence of mappings.
    /// </summary>
    [Fact]
    public async Task InitCommand_SingleFileMode_StableIdentity_DoesNotCreateNuGetConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, PackageChannelNames.Stable);
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService(PackageChannelNames.Stable);
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs")));
 
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        Assert.False(File.Exists(nugetConfigPath), "stable-identity init must not drop a nuget.config (stable packages resolve from ambient nuget.org).");
    }
 
    /// <summary>
    /// Regression for the daily-CLI scenario: when `aspire init` runs under a CLI identity
    /// that matches a registered non-stable Explicit channel (<c>daily</c>, <c>staging</c>, <c>pr-{N}</c>),
    /// the produced <c>aspire.config.json</c> must carry that channel at the top level so
    /// subsequent <c>aspire add</c> / <c>integration list</c> / <c>integration search</c>
    /// calls resolve packages against the matching channel rather than the implicit feed.
    /// </summary>
    [Theory]
    [InlineData("stable")]
    [InlineData("staging")]
    [InlineData("daily")]
    [InlineData("pr-12345")]
    [InlineData("local")]
    public async Task InitCommand_SingleFileMode_WritesIdentityChannelIntoAspireConfig(string contextChannel)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, contextChannel);
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService(contextChannel);
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        Assert.True(File.Exists(configPath));
 
        var config = JsonNode.Parse(File.ReadAllText(configPath))!.AsObject();
        if (string.Equals(contextChannel, PackageChannelNames.Stable, StringComparisons.ChannelName))
        {
            Assert.Null(config["channel"]);
        }
        else
        {
            Assert.Equal(contextChannel, config["channel"]!.GetValue<string>());
        }
    }
 
    /// <summary>
    /// A pre-existing `aspire.config.json#channel` must not be overwritten by init. Users
    /// who hand-edit the channel (or migrate a project from a different CLI build) own
    /// that value; init should only fill it in when absent.
    /// </summary>
    [Fact]
    public async Task InitCommand_SingleFileMode_PreservesExistingChannelInAspireConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        var existing = new JsonObject { ["channel"] = "pr-99999" };
        await File.WriteAllTextAsync(configPath, existing.ToJsonString());
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, "daily");
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService("daily");
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var config = JsonNode.Parse(await File.ReadAllTextAsync(configPath))!.AsObject();
        Assert.Equal("pr-99999", config["channel"]!.GetValue<string>());
    }
 
    /// <summary>
    /// When the running CLI's <see cref="CliExecutionContext.IdentityChannel"/> doesn't match
    /// any registered channel (e.g. <c>local</c> on a production CLI, or a stale <c>pr-{N}</c>
    /// without the matching hive), init must NOT persist a channel value. Persisting a
    /// name no package source mapping satisfies would zero out polyglot
    /// <c>aspire add</c> discovery via <c>IntegrationPackageSearchService</c>'s name filter.
    /// </summary>
    [Fact]
    public async Task InitCommand_SingleFileMode_DoesNotPersistChannelWhenIdentityUnregistered()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, "pr-99999");
            // Only `daily` is registered; identity `pr-99999` has no match.
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService("daily");
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        var config = JsonNode.Parse(File.ReadAllText(configPath))!.AsObject();
        Assert.Null(config["channel"]);
    }
 
    /// <summary>
    /// When the running CLI's identity matches a registered Implicit channel (production
    /// <c>default</c> → nuget.org), init must NOT persist a channel value. Implicit channels
    /// are the default fallback; pinning them at the project level is redundant and would
    /// restrict polyglot <c>aspire add</c> discovery to a single channel —
    /// the regression tracked by https://github.com/microsoft/aspire/issues/17295.
    /// </summary>
    [Fact]
    public async Task InitCommand_SingleFileMode_DoesNotPersistChannelWhenIdentityMatchesImplicit()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            // PackageChannel.CreateImplicitChannel names the channel "default".
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, "default");
            options.PackagingServiceFactory = _ =>
            {
                var fakeCache = new FakeNuGetPackageCache
                {
                    GetTemplatePackagesAsyncCallback = (_, _, _, _) =>
                        Task.FromResult<IEnumerable<NuGetPackageCli>>([])
                };
                var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures(), NullLogger.Instance);
                return new TestPackagingService
                {
                    GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([implicitChannel])
                };
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        var config = JsonNode.Parse(File.ReadAllText(configPath))!.AsObject();
        Assert.Null(config["channel"]);
    }
 
    /// <summary>
    /// Polyglot equivalent of <see cref="InitCommand_SingleFileMode_WritesIdentityChannelIntoAspireConfig"/>:
    /// when the identity matches a registered non-default Explicit channel, init must propagate
    /// the resolved channel name through <see cref="ScaffoldContext.Channel"/> so the scaffolder
    /// persists it into <c>aspire.config.json#channel</c>. Without this, polyglot
    /// <c>aspire add</c> falls back to the implicit feed regardless of the CLI build.
    /// </summary>
    [Fact]
    public async Task InitCommand_PolyglotMode_PassesResolvedNonDefaultChannelToScaffolder()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        ScaffoldContext? capturedContext = null;
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, "daily");
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService("daily");
            options.LanguageServiceFactory = sp =>
            {
                var projectFactory = sp.GetRequiredService<IAppHostProjectFactory>();
                var tsProject = projectFactory.GetProject(new LanguageInfo(
                    LanguageId: new LanguageId(KnownLanguageId.TypeScript),
                    DisplayName: "TypeScript (Node.js)",
                    PackageName: "@aspire/app-host",
                    DetectionPatterns: ["apphost.mts", "apphost.ts"],
                    CodeGenerator: "TypeScript",
                    AppHostFileName: "apphost.mts"));
                return new TestLanguageService { DefaultProject = tsProject };
            };
            options.ScaffoldingServiceFactory = _ => new TestScaffoldingService
            {
                ScaffoldAsyncCallback = (ctx, _) =>
                {
                    capturedContext = ctx;
                    return Task.FromResult(true);
                }
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.NotNull(capturedContext);
        Assert.Equal("daily", capturedContext!.Channel);
    }
 
    /// <summary>
    /// The stable channel is an explicit PackagingService channel, but it is also the
    /// default public-feed behavior. Persisting it would make package discovery use
    /// only the synthetic NuGet.org config and hide packages from ambient private feeds.
    /// </summary>
    [Fact]
    public async Task InitCommand_PolyglotMode_DoesNotPassStableChannelToScaffolder()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        ScaffoldContext? capturedContext = null;
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, "stable");
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService("stable");
            options.LanguageServiceFactory = sp =>
            {
                var projectFactory = sp.GetRequiredService<IAppHostProjectFactory>();
                var tsProject = projectFactory.GetProject(new LanguageInfo(
                    LanguageId: new LanguageId(KnownLanguageId.TypeScript),
                    DisplayName: "TypeScript (Node.js)",
                    PackageName: "@aspire/app-host",
                    DetectionPatterns: ["apphost.mts", "apphost.ts"],
                    CodeGenerator: "TypeScript",
                    AppHostFileName: "apphost.mts"));
                return new TestLanguageService { DefaultProject = tsProject };
            };
            options.ScaffoldingServiceFactory = _ => new TestScaffoldingService
            {
                ScaffoldAsyncCallback = (ctx, _) =>
                {
                    capturedContext = ctx;
                    return Task.FromResult(true);
                }
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.NotNull(capturedContext);
        Assert.Null(capturedContext!.Channel);
    }
 
    /// <summary>
    /// Polyglot pre-existing-channel preservation. <c>ScaffoldingService.cs:93-95</c> writes
    /// <c>config.Channel = context.Channel</c> unconditionally when non-empty, so if init
    /// passed the resolved identity channel into <see cref="ScaffoldContext.Channel"/> without
    /// first checking the file, a user-edited polyglot <c>aspire.config.json#channel</c>
    /// would be silently overwritten on subsequent <c>aspire init</c> runs.
    /// </summary>
    [Fact]
    public async Task InitCommand_PolyglotMode_PreservesExistingChannelInAspireConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        var existing = new JsonObject { ["channel"] = "pr-99999" };
        await File.WriteAllTextAsync(configPath, existing.ToJsonString());
 
        ScaffoldContext? capturedContext = null;
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, "daily");
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService("daily");
            options.LanguageServiceFactory = sp =>
            {
                var projectFactory = sp.GetRequiredService<IAppHostProjectFactory>();
                var tsProject = projectFactory.GetProject(new LanguageInfo(
                    LanguageId: new LanguageId(KnownLanguageId.TypeScript),
                    DisplayName: "TypeScript (Node.js)",
                    PackageName: "@aspire/app-host",
                    DetectionPatterns: ["apphost.mts", "apphost.ts"],
                    CodeGenerator: "TypeScript",
                    AppHostFileName: "apphost.mts"));
                return new TestLanguageService { DefaultProject = tsProject };
            };
            options.ScaffoldingServiceFactory = _ => new TestScaffoldingService
            {
                ScaffoldAsyncCallback = (ctx, _) =>
                {
                    capturedContext = ctx;
                    return Task.FromResult(true);
                }
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.NotNull(capturedContext);
        // Init must suppress the channel pass-through so the scaffolder doesn't overwrite
        // the user-edited value via `config.Channel = context.Channel`.
        Assert.Null(capturedContext!.Channel);
    }
 
    /// <summary>
    /// Polyglot equivalent of the unregistered-identity guard: when identity doesn't match
    /// any registered channel, init must NOT pass it through to the scaffolder. Otherwise
    /// the scaffolder would pin a name no PSM rule satisfies and polyglot <c>aspire add</c>
    /// would return zero packages.
    /// </summary>
    [Fact]
    public async Task InitCommand_PolyglotMode_DoesNotPassChannelWhenIdentityUnregistered()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        ScaffoldContext? capturedContext = null;
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, "pr-99999");
            // Only `daily` is registered; identity `pr-99999` has no match.
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService("daily");
            options.LanguageServiceFactory = sp =>
            {
                var projectFactory = sp.GetRequiredService<IAppHostProjectFactory>();
                var tsProject = projectFactory.GetProject(new LanguageInfo(
                    LanguageId: new LanguageId(KnownLanguageId.TypeScript),
                    DisplayName: "TypeScript (Node.js)",
                    PackageName: "@aspire/app-host",
                    DetectionPatterns: ["apphost.mts", "apphost.ts"],
                    CodeGenerator: "TypeScript",
                    AppHostFileName: "apphost.mts"));
                return new TestLanguageService { DefaultProject = tsProject };
            };
            options.ScaffoldingServiceFactory = _ => new TestScaffoldingService
            {
                ScaffoldAsyncCallback = (ctx, _) =>
                {
                    capturedContext = ctx;
                    return Task.FromResult(true);
                }
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.NotNull(capturedContext);
        Assert.Null(capturedContext!.Channel);
    }
 
    /// <summary>
    /// Negative-shape tripwire: <c>aspire init</c> must never read the <c>channel</c> key from
    /// the global <see cref="IConfigurationService"/>. The injected configuration service throws
    /// on any <c>GetConfigurationAsync(key, ...)</c> or <c>GetConfigurationFromDirectoryAsync</c>
    /// call where the key is <c>channel</c>; if init invokes either, the test fails with the
    /// thrown message. Runs in project mode (with a solution file present) so the
    /// template-package resolver is exercised.
    /// </summary>
    [Fact]
    public async Task InitCommand_DoesNotConsultGlobalConfigurationServiceForChannelKey()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln"));
        File.WriteAllText(solutionFile.FullName, "Fake solution file");
 
        var tripwireConfigService = new global::Aspire.Cli.Tests.TestServices.TestConfigurationService
        {
            OnGetConfiguration = key =>
            {
                if (string.Equals(key, "channel", StringComparison.Ordinal))
                {
                    throw new InvalidOperationException(
                        "aspire init must not consult IConfigurationService for the 'channel' key. " +
                        "Channel resolution sources from CliExecutionContext.IdentityChannel only.");
                }
                return null;
            },
            OnGetConfigurationFromDirectory = (key, _) =>
            {
                if (string.Equals(key, "channel", StringComparison.Ordinal))
                {
                    throw new InvalidOperationException(
                        "aspire init must not consult IConfigurationService.GetConfigurationFromDirectoryAsync " +
                        "for the 'channel' key. Channel resolution sources from CliExecutionContext.IdentityChannel only.");
                }
                return null;
            }
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, "stable");
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService("stable");
            options.ConfigurationServiceFactory = _ => tripwireConfigService;
 
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                runner.InstallTemplateAsyncCallback = (_, version, _, _, _, _, _) => (0, version);
                runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) =>
                {
                    Directory.CreateDirectory(outputPath);
                    return 0;
                };
                return runner;
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    /// <summary>
    /// Fresh-machine regression. On a developer machine that has never used Aspire,
    /// <c>~/.aspire/hives/</c> does not exist (so no per-hive channels are registered).
    /// A locally-built CLI bakes <c>local</c> as its identity channel via
    /// <c>[AssemblyMetadata("AspireCliChannel", "local")]</c>, and <see cref="CliExecutionContext.IdentityChannel"/>
    /// returns that value verbatim. <c>aspire init</c> currently passes
    /// <see cref="CliExecutionContext.IdentityChannel"/> as the channel-override into
    /// <c>TemplateNuGetConfigService.ResolveTemplatePackageAsync</c>, which name-matches against
    /// the channels produced by <see cref="PackagingService.GetChannelsAsync"/>: <c>default</c>
    /// (implicit), <c>stable</c>, <c>daily</c>, optional <c>staging</c>, and one entry per hive
    /// directory. With no <c>local</c> hive on disk, the lookup would otherwise throw
    /// <see cref="Aspire.Cli.Exceptions.ChannelNotFoundException"/> and clean-machine
    /// <c>aspire init</c> would fail.
    ///
    /// Pinned behavior: when the running CLI's identity channel is <c>local</c> AND no matching
    /// named channel is registered, init falls back to the implicit channel rather than throwing.
    /// This mirrors how the <c>local</c> channel gracefully degrades to public-feed package
    /// resolution when no local hive has been scaffolded yet.
    /// </summary>
    [Fact]
    public async Task InitCommand_OnLocalChannelCli_WithNoLocalHive_FallsBackToImplicitChannel()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln"));
        File.WriteAllText(solutionFile.FullName, "Fake solution file");
 
        // Simulate a fresh machine: hives directory does not exist, no per-hive channels.
        var hivesDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives"));
        Assert.False(hivesDir.Exists, "Test precondition: hives directory must not exist on a fresh machine.");
 
        string? capturedTemplateVersion = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            // Pin the running CLI's identity channel to "local" — the value baked into a CLI
            // built without an explicit /p:AspireCliChannel= override.
            options.CliExecutionContextFactory = _ =>
                BuildExecutionContext(options.WorkingDirectory, channel: "local");
 
            options.PackagingServiceFactory = _ =>
            {
                // Only the implicit channel is registered — no `local` named channel, no PR hives.
                // This matches what PackagingService.GetChannelsAsync returns on a clean machine
                // for a CLI whose channel is `local` (where stable/daily/staging are also present
                // but irrelevant — they don't match the `local` channel name).
                var fakeCache = new FakeNuGetPackageCache
                {
                    GetTemplatePackagesAsyncCallback = (_, _, _, _) =>
                        Task.FromResult<IEnumerable<NuGetPackageCli>>(
                            [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "nuget.org", Version = "13.3.0" }])
                };
                var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures(), NullLogger.Instance);
                return new TestPackagingService
                {
                    GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([implicitChannel])
                };
            };
 
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                runner.InstallTemplateAsyncCallback = (_, version, _, _, _, _, _) =>
                {
                    capturedTemplateVersion = version;
                    return (0, version);
                };
                runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) =>
                {
                    Directory.CreateDirectory(outputPath);
                    return 0;
                };
                return runner;
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal("13.3.0", capturedTemplateVersion);
    }
 
    /// <summary>
    /// Project-mode counterpart to
    /// <see cref="InitCommand_SingleFileMode_NoChannelOverride_WiresNuGetConfigToCliExecutionContextChannel"/>.
    /// When the user does not pass <c>--channel</c>, the project-mode init path must wire a
    /// <c>NuGet.config</c> in the solution directory to the channel baked into the running CLI
    /// binary (exposed as <see cref="CliExecutionContext.IdentityChannel"/>) BEFORE invoking
    /// <c>dotnet new aspire-apphost</c>, so the aspire-apphost template's built-in
    /// <c>restore</c> post-action (template.json, conditioned on <c>!skipRestore</c>) can
    /// resolve <c>Aspire.AppHost.Sdk/&lt;version&gt;</c> from the channel-matched hive
    /// instead of probing only the user-level feeds. Without the right ordering the
    /// post-action restore silently fails (it runs with <c>continueOnError=true</c>),
    /// wasting work and emitting confusing errors. The capture inside
    /// <c>NewProjectAsyncCallback</c> guards both the ordering and the file content; the
    /// final structural assertion guards <c>&lt;clear/&gt;</c> + <c>Aspire*</c> mapping
    /// correctness. The <c>stable</c> channel is intentionally excluded: its packages live on
    /// nuget.org (the ambient default source), so it drops no <c>NuGet.config</c>; that case is
    /// covered by <see cref="InitCommand_ProjectMode_StableIdentity_DoesNotCreateNuGetConfig"/>.
    /// </summary>
    [Theory]
    [InlineData("staging")]
    [InlineData("daily")]
    [InlineData("pr-12345")]
    public async Task InitCommand_ProjectMode_NoChannelOverride_WiresNuGetConfigInSolutionDirToCliExecutionContextChannel(string contextChannel)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln"));
        File.WriteAllText(solutionFile.FullName, "Fake solution file");
 
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        string? nugetConfigContentAtNewProjectTime = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, contextChannel);
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService(contextChannel);
 
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                runner.InstallTemplateAsyncCallback = (_, version, _, _, _, _, _) => (0, version);
                runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) =>
                {
                    // The aspire-apphost template's restore post-action depends on the
                    // workspace nuget.config being on disk AND containing the channel
                    // source before `dotnet new` runs; capture the actual file content
                    // (not just existence) so a regression that writes a stale file first
                    // and updates it afterward would still be caught.
                    nugetConfigContentAtNewProjectTime = File.Exists(nugetConfigPath)
                        ? File.ReadAllText(nugetConfigPath)
                        : null;
                    Directory.CreateDirectory(outputPath);
                    return 0;
                };
                return runner;
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        Assert.True(File.Exists(nugetConfigPath), $"nuget.config should be created in the solution directory for channel '{contextChannel}'.");
        Assert.NotNull(nugetConfigContentAtNewProjectTime);
        Assert.Contains(SourceForChannel(contextChannel), nugetConfigContentAtNewProjectTime!);
 
        AssertNuGetConfigHasChannelShape(nugetConfigPath, contextChannel);
    }
 
    /// <summary>
    /// Inverse of <see cref="InitCommand_ProjectMode_NoChannelOverride_WiresNuGetConfigInSolutionDirToCliExecutionContextChannel"/>
    /// for the <c>stable</c> channel: a stable-identity project-mode <c>aspire init</c> must NOT
    /// drop a solution-directory <c>NuGet.config</c>. The stable channel's packages live on
    /// nuget.org — the ambient default source — so a <c>&lt;clear/&gt;</c>-based config would be
    /// redundant and would wipe any additional feeds the user already relies on. The
    /// aspire-apphost template's restore post-action resolves from nuget.org directly, so no
    /// channel-pinning config is needed.
    /// </summary>
    [Fact]
    public async Task InitCommand_ProjectMode_StableIdentity_DoesNotCreateNuGetConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln"));
        File.WriteAllText(solutionFile.FullName, "Fake solution file");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, PackageChannelNames.Stable);
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService(PackageChannelNames.Stable);
 
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                runner.InstallTemplateAsyncCallback = (_, version, _, _, _, _, _) => (0, version);
                runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) =>
                {
                    Directory.CreateDirectory(outputPath);
                    return 0;
                };
                return runner;
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        Assert.False(File.Exists(nugetConfigPath), "stable-identity init must not drop a nuget.config (stable packages resolve from ambient nuget.org).");
    }
 
    /// <summary>
    /// Guards the recover-on-rerun contract: a user who ran a previously broken CLI (which
    /// produced an AppHost project but did NOT write a workspace NuGet.config) reruns
    /// <c>aspire init</c> against the fixed CLI and now has a working workspace. The fix
    /// in <c>DropCSharpProjectSkeletonAsync</c> writes the workspace NuGet.config BEFORE the
    /// AppHost-dir-already-exists early return — moving the write below the guard would
    /// silently regress this scenario because the early return reports Success without
    /// running channel-aware setup.
    /// </summary>
    [Fact]
    public async Task InitCommand_ProjectMode_RerunWithExistingAppHostDirAndMissingNuGetConfig_CreatesNuGetConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln"));
        File.WriteAllText(solutionFile.FullName, "Fake solution file");
 
        // Simulate the post-broken-init starting state: AppHost dir already exists, no workspace nuget.config.
        var appHostDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.AppHost"));
        appHostDir.Create();
        Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config")));
 
        var contextChannel = "staging";
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, contextChannel);
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService(contextChannel);
            // No DotNetCliRunner override — InstallTemplate / NewProject must NOT be invoked
            // because the AppHost dir already exists. The default TestDotNetCliRunner throws
            // on any unhandled callback, so this also guards the contract that nothing
            // template-related runs on the rerun-recovery path.
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        Assert.True(File.Exists(nugetConfigPath), "rerun must create the workspace nuget.config even though the AppHost dir already exists.");
        AssertNuGetConfigHasChannelShape(nugetConfigPath, contextChannel);
    }
 
    /// <summary>
    /// Single-file counterpart of
    /// <see cref="InitCommand_ProjectMode_RerunWithExistingAppHostDirAndMissingNuGetConfig_CreatesNuGetConfig"/>.
    /// When <c>apphost.cs</c> already exists from a previous broken CLI but no workspace
    /// NuGet.config was written, the rerun must still drop the NuGet.config in the working
    /// directory so the existing apphost can resolve the channel-pinned SDK.
    /// </summary>
    [Fact]
    public async Task InitCommand_SingleFileMode_RerunWithExistingAppHostFileAndMissingNuGetConfig_CreatesNuGetConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Simulate the post-broken-init starting state: apphost.cs exists, no nuget.config.
        var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
        File.WriteAllText(appHostPath, "#:sdk Aspire.AppHost.Sdk@13.4.0-staging.x\nbuilder.Build().Run();");
        Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config")));
 
        var contextChannel = "staging";
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, contextChannel);
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService(contextChannel);
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        Assert.True(File.Exists(nugetConfigPath), "rerun must create the workspace nuget.config even though apphost.cs already exists.");
        AssertNuGetConfigHasChannelShape(nugetConfigPath, contextChannel);
    }
 
    /// <summary>
    /// When the solution file lives in a subdirectory of the CLI's working directory,
    /// <c>SolutionLocator</c> still finds it (it searches with <c>SearchOption.AllDirectories</c>),
    /// and the workspace NuGet.config must be written next to the solution — not at the
    /// working directory root. A mutation that passed <c>workingDirectory.FullName</c> instead
    /// of <c>solutionDir.FullName</c> to <c>CreateOrUpdateNuGetConfigWithoutPromptAsync</c>
    /// would put the file in the wrong place and the AppHost wouldn't resolve.
    /// </summary>
    [Fact]
    public async Task InitCommand_ProjectMode_SolutionInSubdirectory_WritesNuGetConfigNextToSolutionNotInWorkingDirectory()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var solutionDir = workspace.WorkspaceRoot.CreateSubdirectory("nested");
        var solutionFile = new FileInfo(Path.Combine(solutionDir.FullName, "Test.sln"));
        File.WriteAllText(solutionFile.FullName, "Fake solution file");
 
        var contextChannel = "staging";
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, contextChannel);
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService(contextChannel);
 
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                runner.InstallTemplateAsyncCallback = (_, version, _, _, _, _, _) => (0, version);
                runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) =>
                {
                    Directory.CreateDirectory(outputPath);
                    return 0;
                };
                return runner;
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(File.Exists(Path.Combine(solutionDir.FullName, "nuget.config")),
            "nuget.config must be written in the solution directory, not the working directory.");
        Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config")),
            "nuget.config must NOT be written in the working directory when the solution lives in a subdirectory.");
        AssertNuGetConfigHasChannelShape(Path.Combine(solutionDir.FullName, "nuget.config"), contextChannel);
    }
 
    /// <summary>
    /// When a NuGet.config already exists in the solution directory with the user's own
    /// package source, <c>aspire init</c> must merge the channel feed in (via
    /// <c>NuGetConfigMerger.UpdateExistingNuGetConfigAsync</c>) instead of clobbering the
    /// file. The merger has its own unit tests; this test guards the InitCommand-level
    /// integration so a regression in how the helper is invoked from the command path
    /// (wrong arg, missing call, accidental overwrite) doesn't slip through.
    /// </summary>
    [Fact]
    public async Task InitCommand_ProjectMode_WithPreExistingNuGetConfig_PreservesUserSourcesAndAddsChannelSource()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln"));
        File.WriteAllText(solutionFile.FullName, "Fake solution file");
 
        const string userFeedUrl = "https://contoso.example.invalid/v3/index.json";
        const string nugetConfigContent = $$"""
            <?xml version="1.0" encoding="utf-8"?>
            <configuration>
              <packageSources>
                <add key="contoso" value="{{userFeedUrl}}" />
              </packageSources>
            </configuration>
            """;
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        File.WriteAllText(nugetConfigPath, nugetConfigContent);
 
        var contextChannel = "staging";
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, contextChannel);
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService(contextChannel);
 
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                runner.InstallTemplateAsyncCallback = (_, version, _, _, _, _, _) => (0, version);
                runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) =>
                {
                    Directory.CreateDirectory(outputPath);
                    return 0;
                };
                return runner;
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var merged = File.ReadAllText(nugetConfigPath);
        Assert.Contains(userFeedUrl, merged);
        Assert.Contains(SourceForChannel(contextChannel), merged);
    }
 
    /// <summary>
    /// When multiple explicit channels are registered (mirroring real
    /// <c>PackagingService</c> which exposes stable + optional staging + daily + PR hives
    /// simultaneously), the helper must select the channel whose name matches
    /// <c>CliExecutionContext.IdentityChannel</c> — not just "the first explicit channel".
    /// A mutation that took the first explicit channel would silently wire a daily-channel
    /// CLI to stable sources.
    /// </summary>
    [Fact]
    public async Task InitCommand_ProjectMode_MultipleExplicitChannels_PicksChannelMatchingIdentity()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln"));
        File.WriteAllText(solutionFile.FullName, "Fake solution file");
 
        var contextChannel = "daily";
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, contextChannel);
            // Register stable + staging + daily simultaneously. The matching channel ("daily")
            // is not the first one in the list.
            options.PackagingServiceFactory = _ => CreateMultiChannelPackagingService("stable", "staging", "daily");
 
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                runner.InstallTemplateAsyncCallback = (_, version, _, _, _, _, _) => (0, version);
                runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) =>
                {
                    Directory.CreateDirectory(outputPath);
                    return 0;
                };
                return runner;
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        var content = File.ReadAllText(nugetConfigPath);
 
        Assert.Contains(SourceForChannel("daily"), content);
        Assert.DoesNotContain(SourceForChannel("stable"), content);
        Assert.DoesNotContain(SourceForChannel("staging"), content);
        AssertNuGetConfigHasChannelShape(nugetConfigPath, "daily");
    }
 
    /// <summary>
    /// Locally-built CLI (IdentityChannel == <c>local</c>) on a machine where no <c>local</c>
    /// channel is registered (e.g. <c>~/.aspire/hives/local</c> isn't materialized). The fix
    /// helper must short-circuit (return <see langword="false"/>) instead of throwing — a
    /// regression that threw on the no-matching-channel branch would crash every locally-built
    /// CLI run. <c>DropCSharpProjectSkeletonAsync</c> additionally falls back to the implicit
    /// channel for template install via its <c>ChannelNotFoundException</c> handler, so init
    /// must still complete successfully.
    /// </summary>
    [Fact]
    public async Task InitCommand_ProjectMode_LocalIdentityChannelWithNoLocalChannelRegistered_DoesNotWriteNuGetConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln"));
        File.WriteAllText(solutionFile.FullName, "Fake solution file");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ =>
                BuildExecutionContext(workspace.WorkspaceRoot, channel: PackageChannelNames.Local);
 
            // Packaging service exposes only the implicit channel — no "local" channel.
            options.PackagingServiceFactory = _ =>
            {
                var fakeCache = new FakeNuGetPackageCache
                {
                    GetTemplatePackagesAsyncCallback = (_, _, _, _) =>
                        Task.FromResult<IEnumerable<NuGetPackageCli>>(
                            [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "nuget.org", Version = "13.3.0" }])
                };
                var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures(), NullLogger.Instance);
                return new TestPackagingService
                {
                    GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([implicitChannel])
                };
            };
 
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                runner.InstallTemplateAsyncCallback = (_, version, _, _, _, _, _) => (0, version);
                runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) =>
                {
                    Directory.CreateDirectory(outputPath);
                    return 0;
                };
                return runner;
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(
            File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config")),
            "no workspace nuget.config should be written when no matching channel exists for IdentityChannel='local'.");
    }
 
    /// <summary>
    /// <c>SolutionLocator</c> handles both <c>.sln</c> and <c>.slnx</c> formats
    /// (<c>SolutionLocator.GetSolutionFilesInDirectoryAndSubfoldersAsync</c>). The
    /// project-mode theory above covers <c>.sln</c>; this guards that the <c>.slnx</c>
    /// path produces an equivalently-wired workspace NuGet.config so a future divergence
    /// (e.g. a switch on extension that skipped the nuget.config write) would be caught.
    /// </summary>
    [Fact]
    public async Task InitCommand_ProjectMode_WithSlnxSolutionFile_WiresWorkspaceNuGetConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.slnx"));
        File.WriteAllText(solutionFile.FullName, """<Solution />""");
 
        var contextChannel = "staging";
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, contextChannel);
            options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService(contextChannel);
 
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                runner.InstallTemplateAsyncCallback = (_, version, _, _, _, _, _) => (0, version);
                runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) =>
                {
                    Directory.CreateDirectory(outputPath);
                    return 0;
                };
                return runner;
            };
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var initCommand = serviceProvider.GetRequiredService<InitCommand>();
 
        var parseResult = initCommand.Parse("init");
        var exitCode = await parseResult.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        Assert.True(File.Exists(nugetConfigPath));
        AssertNuGetConfigHasChannelShape(nugetConfigPath, contextChannel);
    }
 
    private static CliExecutionContext CreateExecutionContextForChannel(DirectoryInfo workingDirectory, string contextChannel)
    {
        return BuildExecutionContext(workingDirectory, channel: contextChannel);
    }
 
    private static CliExecutionContext BuildExecutionContext(DirectoryInfo workingDirectory, string channel)
    {
        return TestExecutionContextHelper.CreateExecutionContext(
            workingDirectory,
            identityChannel: channel);
    }
 
    private static TestPackagingService CreateNamedChannelPackagingService(string channelName)
        => CreateMultiChannelPackagingService(channelName);
 
    /// <summary>
    /// Returns a <see cref="TestPackagingService"/> exposing one named explicit channel per
    /// entry in <paramref name="channelNames"/>, each with two mappings (<c>Aspire* → channelSource</c>
    /// and <c>* → fallbackSource</c>) so consumers can assert <c>&lt;packageSourceMapping&gt;</c>
    /// shape — not just that one URL appears in the file. The real <see cref="PackagingService"/>
    /// exposes multiple explicit channels simultaneously (stable, optional staging, daily,
    /// PR hives), so registering more than one here lets a test prove the resolver picks the
    /// channel matching <see cref="CliExecutionContext.IdentityChannel"/> rather than just
    /// taking the first explicit channel.
    /// </summary>
    private static TestPackagingService CreateMultiChannelPackagingService(params string[] channelNames)
    {
        var channels = channelNames.Select(channelName =>
        {
            var channelSource = SourceForChannel(channelName);
            var fallbackSource = FallbackSourceForChannel(channelName);
            var version = "13.3.0";
 
            var fakeCache = new FakeNuGetPackageCache
            {
                GetTemplatePackagesAsyncCallback = (_, _, _, _) =>
                    Task.FromResult<IEnumerable<NuGetPackageCli>>(
                        [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = channelSource, Version = version }])
            };
 
            return PackageChannel.CreateExplicitChannel(
                channelName,
                PackageChannelQuality.Both,
                [new PackageMapping("Aspire*", channelSource), new PackageMapping(PackageMapping.AllPackages, fallbackSource)],
                fakeCache,
                features: new TestFeatures(), NullLogger.Instance);
        }).ToArray();
 
        return new TestPackagingService
        {
            GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>(channels)
        };
    }
 
    private static string SourceForChannel(string channelName)
        => $"https://feeds.test.invalid/{channelName}/v3/index.json";
 
    private static string FallbackSourceForChannel(string channelName)
        => $"https://feeds.test.invalid/{channelName}-fallback/v3/index.json";
 
    /// <summary>
    /// Asserts that the workspace NuGet.config written by
    /// <c>TemplateNuGetConfigService.CreateOrUpdateNuGetConfigWithoutPromptAsync</c>
    /// carries the structural elements the fix depends on: the channel feed URL is registered
    /// as a package source, a <c>&lt;clear/&gt;</c> element neutralizes any inherited
    /// parent-directory NuGet config (without which a user-level config disabling our hive
    /// would shadow the mapping), and the <c>Aspire*</c> pattern routes to the channel
    /// feed via <c>&lt;packageSourceMapping&gt;</c>.
    /// </summary>
    private static void AssertNuGetConfigHasChannelShape(string nugetConfigPath, string channelName)
    {
        var channelSource = SourceForChannel(channelName);
        var root = XDocument.Load(nugetConfigPath).Root ?? throw new InvalidOperationException("Empty NuGet.config.");
 
        var packageSources = root.Element("packageSources");
        Assert.NotNull(packageSources);
        Assert.NotNull(packageSources!.Element("clear"));
        Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == channelSource);
 
        var packageSourceMapping = root.Element("packageSourceMapping");
        Assert.NotNull(packageSourceMapping);
        var aspirePatternSource = packageSourceMapping!.Elements("packageSource")
            .FirstOrDefault(ps => ps.Elements("package").Any(p => (string?)p.Attribute("pattern") == "Aspire*"));
        Assert.NotNull(aspirePatternSource);
        Assert.Equal(channelSource, (string?)aspirePatternSource!.Attribute("key"));
    }
 
    private sealed class TestScaffoldingService : IScaffoldingService
    {
        public Func<ScaffoldContext, CancellationToken, Task<bool>>? ScaffoldAsyncCallback { get; set; }
 
        public Task<bool> ScaffoldAsync(ScaffoldContext context, CancellationToken cancellationToken)
        {
            if (ScaffoldAsyncCallback is not null)
            {
                return ScaffoldAsyncCallback(context, cancellationToken);
            }
 
            var appHostFileName = context.Language.AppHostFileName ?? "apphost";
            File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, appHostFileName), string.Empty);
 
            var config = new JsonObject
            {
                ["appHost"] = new JsonObject
                {
                    ["path"] = appHostFileName,
                    ["language"] = context.Language.LanguageId.Value
                }
            };
            File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, AspireConfigFile.FileName), config.ToJsonString());
 
            return Task.FromResult(true);
        }
    }
}