File: Commands\NewCommandTests.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.Globalization;
using System.Xml.Linq;
using Aspire.Cli.Agents;
using Aspire.Cli.Utils;
using Aspire.Cli.Commands;
using RootCommand = Aspire.Cli.Commands.RootCommand;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Scaffolding;
using Aspire.Cli.Templating;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
 
namespace Aspire.Cli.Tests.Commands;
 
[Collection(LocalizedResourceMutatingTestCollection.Name)]
public class NewCommandTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task NewCommandWithHelpArgumentReturnsZero()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("new --help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public void NewCommandWithPolyglotEnabled_ExposesTemplateSubcommands()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.FeatureFlagsFactory = _ =>
            {
                var features = new TestFeatures();
                features.SetFeature(KnownFeatures.ExperimentalPolyglotGo, true);
                features.SetFeature(KnownFeatures.ExperimentalPolyglotJava, true);
                features.SetFeature(KnownFeatures.ExperimentalPolyglotPython, true);
                features.SetFeature(KnownFeatures.ExperimentalPolyglotRust, true);
                return features;
            };
 
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        Assert.NotEmpty(command.Subcommands);
        Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.CSharpEmptyAppHost && subcommand.Description == "Empty AppHost (Choose language...)");
        Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.TypeScriptEmptyAppHost && subcommand.Description == "Empty (TypeScript AppHost)");
        Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.PythonEmptyAppHost && subcommand.Description == "Empty (Python AppHost)");
        Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.JavaEmptyAppHost && subcommand.Description == "Empty (Java AppHost)");
        Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.GoEmptyAppHost && subcommand.Description == "Empty (Go AppHost)");
        Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.RustEmptyAppHost && subcommand.Description == "Empty (Rust AppHost)");
    }
 
    [Fact]
    public void NewCommandWithPolyglotDisabled_ExposesTemplateSubcommands()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        Assert.NotEmpty(command.Subcommands);
        Assert.DoesNotContain(command.Options, option => option.Aliases.Contains("--language", StringComparer.OrdinalIgnoreCase));
    }
 
    [Fact]
    public void NewCommand_WhenIdentityChannelIsStaging_DescribesStagingChannelOption()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Staging);
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
 
        var channelOption = command.Options.Single(option => option.Name == "--channel");
        Assert.Equal(NewCommandStrings.ChannelOptionDescriptionWithStaging, channelOption.Description);
    }
 
    [Fact]
    public async Task NewCommand_CSharpEmptyTemplateUnderStagingIdentity_WritesStagingConfiguration()
    {
        const string stagingFeed = "https://example.com/staging/v3/index.json";
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var configServices = CreateServiceCollection(workspace);
        using (var configProvider = configServices.BuildServiceProvider())
        {
            var configCommand = configProvider.GetRequiredService<RootCommand>();
            var configResult = configCommand.Parse($"config set -g overrideStagingFeed {stagingFeed}");
 
            var configExitCode = await configResult.InvokeAsync().DefaultTimeout();
 
            Assert.Equal(CliExitCodes.Success, configExitCode);
        }
 
        var cache = new FakeNuGetPackageCache
        {
            GetTemplatePackagesAsyncCallback = (_, _, _, _) =>
                Task.FromResult<IEnumerable<NuGetPackage>>(
                [
                    new NuGetPackage
                    {
                        Id = TemplateNuGetConfigService.TemplatesPackageName,
                        Source = stagingFeed,
                        Version = "13.4.0-preview.1.12345"
                    }
                ])
        };
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Staging);
            options.NuGetPackageCacheFactory = _ => cache;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"new {KnownTemplateId.CSharpEmptyAppHost} --language csharp --name TemplateOut --output ./TemplateOut --localhost-tld false");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var outputDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "TemplateOut");
        var nugetConfigPath = Path.Combine(outputDirectory, "nuget.config");
        Assert.True(File.Exists(nugetConfigPath));
 
        var nugetConfig = await File.ReadAllTextAsync(nugetConfigPath);
        Assert.Contains(stagingFeed, nugetConfig);
        Assert.Contains("Aspire*", nugetConfig);
 
        var config = AspireConfigFile.Load(outputDirectory);
        Assert.NotNull(config);
        Assert.Equal(PackageChannelNames.Staging, config.Channel);
        Assert.Equal(VersionHelper.GetDefaultSdkVersion(), config.SdkVersion);
    }
 
    [Fact]
    public async Task NewCommandInteractiveFlowSmokeTest()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Theory]
    [InlineData("None", null)]
    [InlineData("Ninguno", null)]
    [InlineData("MSTest", "MSTest")]
    public async Task NewCommandForwardsLocalizedTestFrameworkSelection(string testFramework, string? expectedTestFramework)
    {
        var originalCulture = TemplatingStrings.Culture;
 
        try
        {
            TemplatingStrings.Culture = CultureInfo.GetCultureInfo("es-ES");
 
            using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
            var runner = CreateTestRunnerWithStandardPackages();
            var services = CreateServiceCollection(workspace, options => options.DotNetCliRunnerFactory = _ => runner);
            using var provider = services.BuildServiceProvider();
 
            var command = provider.GetRequiredService<NewCommand>();
            var result = command.Parse($"new aspire-starter --use-redis-cache --test-framework {testFramework}");
 
            var exitCode = await result.InvokeAsync().DefaultTimeout();
 
            Assert.Equal(CliExitCodes.Success, exitCode);
            var extraArgs = Assert.IsType<string[]>(runner.LastNewProjectExtraArgs);
            var testFrameworkArgumentIndex = Array.IndexOf(extraArgs, "--test-framework");
            var forwardedTestFramework = testFrameworkArgumentIndex >= 0
                ? extraArgs[testFrameworkArgumentIndex + 1]
                : null;
            Assert.Equal(expectedTestFramework, forwardedTestFramework);
        }
        finally
        {
            TemplatingStrings.Culture = originalCulture;
        }
    }
 
    [CollectionDefinition(Name, DisableParallelization = true)]
    public sealed class LocalizedResourceMutatingTestCollection
    {
        public const string Name = nameof(LocalizedResourceMutatingTestCollection);
    }
 
    [Fact]
    // Quarantined due to flakiness. See linked issue for details.
    public async Task NewCommandDerivesProjectNameFromTemplateNameForStarterTemplate()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        string? capturedDefaultProjectName = null;
        string? capturedDefaultOutputPath = null;
        var services = CreateServiceCollection(workspace, options =>
        {
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
 
                prompter.PromptForProjectNameCallback = (defaultName) =>
                {
                    capturedDefaultProjectName = defaultName;
                    return "CustomName";
                };
 
                prompter.PromptForOutputPathCallback = (path) =>
                {
                    capturedDefaultOutputPath = path;
                    return path;
                };
 
                return prompter;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal("aspire-starter", capturedDefaultProjectName);
        Assert.Equal("./CustomName", capturedDefaultOutputPath);
    }
 
    [Fact]
    public async Task NewCommandDoesNotPromptForProjectNameIfSpecifiedOnCommandLine()
    {
        var promptedForName = false;
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
 
                prompter.PromptForProjectNameCallback = (defaultName) =>
                {
                    promptedForName = true;
                    throw new InvalidOperationException("This should not be called");
                };
 
                return prompter;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-starter --name MyApp --output ./output --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptedForName);
    }
 
    [Fact]
    public async Task NewCommandDoesNotPromptForOutputPathIfSpecifiedOnCommandLine()
    {
        bool promptedForPath = false;
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
 
                prompter.PromptForOutputPathCallback = (path) =>
                {
                    promptedForPath = true;
                    throw new InvalidOperationException("This should not be called");
                };
 
                return prompter;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-starter --output notsrc --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptedForPath);
    }
 
    [Fact]
    public async Task NewCommandWithChannelOptionUsesSpecifiedChannel()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        
        string? channelNameUsed = null;
        bool promptedForVersion = false;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
                
                prompter.PromptForTemplatesVersionCallback = (packages) =>
                {
                    promptedForVersion = true;
                    throw new InvalidOperationException("Should not prompt for version when --channel is specified");
                };
                
                return prompter;
            };
 
            options.PackagingServiceFactory = (sp) =>
            {
                var packagingService = new TestPackagingService();
                packagingService.GetChannelsAsyncCallback = (ct) =>
                {
                    var stableCache = new FakeNuGetPackageCache();
                    stableCache.GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) =>
                    {
                        channelNameUsed = "stable";
                        var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" };
                        return Task.FromResult<IEnumerable<NuGetPackage>>([package]);
                    };
                    
                    var dailyCache = new FakeNuGetPackageCache();
                    dailyCache.GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) =>
                    {
                        channelNameUsed = "daily";
                        var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "10.0.0-dev" };
                        return Task.FromResult<IEnumerable<NuGetPackage>>([package]);
                    };
                    
                    var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Both, [], stableCache, new TestFeatures(), NullLogger.Instance);
                    var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache, new TestFeatures(), NullLogger.Instance);
                    
                    return Task.FromResult<IEnumerable<PackageChannel>>([stableChannel, dailyChannel]);
                };
                
                return packagingService;
            };
 
            options.DotNetCliRunnerFactory = (sp) =>
            {
                var runner = new TestDotNetCliRunner();
                runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) =>
                {
                    return (0, version);
                };
                runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
                {
                    return 0;
                };
                return runner;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-starter --channel stable --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        
        // Assert
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal("stable", channelNameUsed); // Verify the stable channel was used
        Assert.False(promptedForVersion); // Should not prompt when --channel is specified
    }
 
    [Fact]
    public async Task NewCommandWithChannelOptionAutoSelectsHighestVersion()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        
        string? selectedVersion = null;
        bool promptedForVersion = false;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
                
                prompter.PromptForTemplatesVersionCallback = (packages) =>
                {
                    promptedForVersion = true;
                    throw new InvalidOperationException("Should not prompt for version when --channel is specified");
                };
                
                return prompter;
            };
 
            options.PackagingServiceFactory = (sp) =>
            {
                var packagingService = new TestPackagingService();
                packagingService.GetChannelsAsyncCallback = (ct) =>
                {
                    var fakeCache = new FakeNuGetPackageCache();
                    fakeCache.GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) =>
                    {
                        // Return multiple versions to test auto-selection of highest
                        var packages = new[]
                        {
                            new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.0.0" },
                            new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" },
                            new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.1.0" },
                        };
                        return Task.FromResult<IEnumerable<NuGetPackage>>(packages);
                    };
                    
                    var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Both, [], fakeCache, new TestFeatures(), NullLogger.Instance);
                    return Task.FromResult<IEnumerable<PackageChannel>>([stableChannel]);
                };
                
                return packagingService;
            };
            
            options.DotNetCliRunnerFactory = (sp) =>
            {
                var runner = new TestDotNetCliRunner();
                runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) =>
                {
                    selectedVersion = version;
                    return (0, version);
                };
                runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
                {
                    return 0; // Success
                };
                return runner;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-starter --channel stable --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        
        // Assert
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal("9.2.0", selectedVersion); // Should auto-select highest version (9.2.0)
        Assert.False(promptedForVersion); // Should not prompt when --channel is specified
    }
 
    [Fact]
    public async Task NewCommandWithPrChannelPrefersCurrentCliVersion()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var cliVersion = VersionHelper.GetDefaultSdkVersion();
        string? selectedVersion = null;
        bool promptedForVersion = false;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
 
                prompter.PromptForTemplatesVersionCallback = (packages) =>
                {
                    promptedForVersion = true;
                    throw new InvalidOperationException("Should not prompt for version when a PR channel contains the current CLI version.");
                };
 
                return prompter;
            };
 
            options.PackagingServiceFactory = (sp) =>
            {
                var packagingService = new TestPackagingService();
                packagingService.GetChannelsAsyncCallback = (ct) =>
                {
                    var fakeCache = new FakeNuGetPackageCache();
                    fakeCache.GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) =>
                    {
                        var packages = new[]
                        {
                            new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "pr-hive", Version = cliVersion },
                            new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "pr-hive", Version = "99.0.0" },
                        };
 
                        return Task.FromResult<IEnumerable<NuGetPackage>>(packages);
                    };
 
                    var prChannel = PackageChannel.CreateExplicitChannel("pr-12345", PackageChannelQuality.Both, [], fakeCache, new TestFeatures(), NullLogger.Instance);
                    return Task.FromResult<IEnumerable<PackageChannel>>([prChannel]);
                };
 
                return packagingService;
            };
 
            options.DotNetCliRunnerFactory = (sp) =>
            {
                var runner = new TestDotNetCliRunner();
                runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) =>
                {
                    selectedVersion = version;
                    return (0, version);
                };
                runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
                {
                    return 0;
                };
                return runner;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-starter --channel pr-12345 --name TestApp --output ./output --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(0, exitCode);
        Assert.Equal(cliVersion, selectedVersion);
        Assert.False(promptedForVersion);
        var config = AspireConfigFile.Load(Path.Combine(workspace.WorkspaceRoot.FullName, "output"));
        Assert.NotNull(config);
        Assert.Equal("pr-12345", config.Channel);
        Assert.Equal(cliVersion, config.SdkVersion);
    }
 
    [Fact]
    // Quarantined due to flakiness. See linked issue for details.
    public async Task NewCommandDoesNotPromptForTemplateIfSpecifiedOnCommandLine()
    {
        bool promptedForTemplate = false;
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
 
                prompter.PromptForTemplateCallback = (path) =>
                {
                    promptedForTemplate = true;
                    throw new InvalidOperationException("This should not be called");
                };
 
                return prompter;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-starter --name MyApp --output ./output --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptedForTemplate);
    }
 
    [Fact]
    public async Task NewCommandDoesNotPromptForTemplateVersionIfSpecifiedOnCommandLine()
    {
        bool promptedForTemplateVersion = false;
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
 
                prompter.PromptForTemplatesVersionCallback = (packages) =>
                {
                    promptedForTemplateVersion = true;
                    throw new InvalidOperationException("This should not be called");
                };
 
                return prompter;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-starter --name MyApp --output ./output --use-redis-cache --test-framework None --version 9.2.0");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout(TestConstants.LongTimeoutDuration);
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptedForTemplateVersion);
    }
 
    [Fact]
    public async Task NewCommand_EmptyPackageList_DisplaysErrorMessage()
    {
        TestInteractionService? testInteractionService = null;
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options => {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
            options.InteractionServiceFactory = (sp) => {
                testInteractionService = new TestInteractionService();
                return testInteractionService;
            };
 
            options.DotNetCliRunnerFactory = (sp) => {
                var runner = new TestDotNetCliRunner();
                runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => {
                    return (0, Array.Empty<NuGetPackage>());
                };
                return runner;
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToCreateNewProject, exitCode);
        Assert.NotNull(testInteractionService);
        Assert.Contains(testInteractionService.DisplayedErrors, e => e.Contains(TemplatingStrings.NoTemplateVersionsFound));
    }
 
    [Fact]
    public async Task NewCommandWithExitCode73ShowsUserFriendlyError()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                return new TestNewCommandPrompter(interactionService);
            };
 
            options.DotNetCliRunnerFactory = (sp) =>
            {
                var runner = CreateTestRunnerWithStandardPackages();
 
                runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, options, cancellationToken) =>
                {
                    return (0, version); // Success, return the template version
                };
 
                runner.NewProjectAsyncCallback = (templateName, name, outputPath, options, cancellationToken) =>
                {
                    return 73; // Simulate exit code 73 (directory already contains files)
                };
 
                return runner;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.FailedToCreateNewProject, exitCode);
    }
 
    private IServiceCollection CreateServiceCollection(
        TemporaryWorkspace workspace,
        Action<CliServiceCollectionTestOptions>? configure = null)
    {
        return CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Stable);
            options.DotNetCliRunnerFactory = _ => CreateTestRunnerWithStandardPackages();
            configure?.Invoke(options);
        });
    }
 
    private static TestDotNetCliRunner CreateTestRunnerWithStandardPackages()
    {
        var runner = new TestDotNetCliRunner();
        runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
        {
            var package = new NuGetPackage()
            {
                Id = "Aspire.ProjectTemplates",
                Source = "nuget",
                Version = "9.2.0"
            };
 
            return (0, new NuGetPackage[] { package });
        };
        return runner;
    }
 
    private static void AssertSourceOverrideNuGetConfig(string outputPath, string sourceOverride)
    {
        var doc = XDocument.Load(Path.Combine(outputPath, "nuget.config"));
        var packageSources = doc.Root!.Element("packageSources")!;
 
        Assert.Contains(packageSources.Elements("clear"), _ => true);
        Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == sourceOverride);
        Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == PackageSources.NuGetOrg);
        Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, sourceOverride));
        Assert.Equal([PackageMapping.AllPackages], GetPackagePatternsForSource(doc, PackageSources.NuGetOrg));
    }
 
    private static string[] GetPackagePatternsForSource(XDocument doc, string source)
    {
        var packageSourceMapping = doc.Root!.Element("packageSourceMapping");
        if (packageSourceMapping is null)
        {
            return [];
        }
 
        return packageSourceMapping
            .Elements("packageSource")
            .Where(e => string.Equals((string?)e.Attribute("key"), source, StringComparison.OrdinalIgnoreCase))
            .Elements("package")
            .Select(e => (string?)e.Attribute("pattern"))
            .Where(pattern => pattern is not null)
            .Select(pattern => pattern!)
            .ToArray();
    }
 
    [Fact]
    public async Task NewCommandPromptsForTemplateVersionBeforeTemplateOptions()
    {
        var operationOrder = new List<string>();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
 
                prompter.PromptForTemplatesVersionCallback = (packages) =>
                {
                    operationOrder.Add("TemplateVersion");
                    return packages.First();
                };
 
                return prompter;
            };
 
            options.InteractionServiceFactory = (sp) =>
            {
                var testInteractionService = new TestInteractionService();
                testInteractionService.PromptForSelectionCallback = (promptText, choices, formatter, ct) =>
                {
                    // Track template option prompts
                    if (promptText?.Contains("Redis") == true ||
                        promptText?.Contains("test framework") == true ||
                        promptText?.Contains("Create a test project") == true ||
                        promptText?.Contains("xUnit") == true)
                    {
                        operationOrder.Add("TemplateOption");
                    }
 
                    return choices.Cast<object>().First();
                };
                return testInteractionService;
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-starter --name TestApp --output ./output");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        // Verify that template version was prompted before template options
        Assert.Contains("TemplateVersion", operationOrder);
 
        // If template options were prompted, they should come after version selection
        var versionIndex = operationOrder.IndexOf("TemplateVersion");
        var optionIndex = operationOrder.IndexOf("TemplateOption");
 
        if (optionIndex >= 0)
        {
            Assert.True(versionIndex < optionIndex,
                $"Template version should be prompted before template options. Order: {string.Join(", ", operationOrder)}");
        }
    }
 
    [Fact]
    public async Task NewCommandEscapesMarkupInProjectNameAndOutputPath()
    {
        // This test validates that project names containing Spectre markup characters
        // (like '[' and ']') are properly escaped when displayed as default values in prompts.
        // This prevents crashes when the markup parser encounters malformed markup.
 
        var projectNameWithMarkup = "[27;5;13~";  // Example of input that could crash the markup parser
        var capturedProjectNameDefault = string.Empty;
        var capturedOutputPathDefault = string.Empty;
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
            options.InteractionServiceFactory = _ => new TestInteractionService();
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
 
                // Simulate user entering a project name with markup characters
                prompter.PromptForProjectNameCallback = (defaultName) =>
                {
                    capturedProjectNameDefault = defaultName;
                    return projectNameWithMarkup;
                };
 
                // Capture what default value is passed for the output path
                // The path passed to this callback is the unescaped version
                prompter.PromptForOutputPathCallback = (path) =>
                {
                    capturedOutputPathDefault = path;
                    // Return a path with markup characters to verify it doesn't crash
                    return projectNameWithMarkup;
                };
 
                return prompter;
            };
 
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"new aspire-starter --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        // Verify that the default output path is derived from the project name (which contains markup characters)
        var expectedPath = $"./{projectNameWithMarkup}";
        Assert.Equal(expectedPath, capturedOutputPathDefault);
    }
 
    [Fact]
    public async Task NewCommandWithoutTemplateCanCreateTypeScriptEmptyTemplate()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var scaffoldedLanguageId = string.Empty;
        (string Name, string Description)[]? promptedTemplates = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
            options.FeatureFlagsFactory = _ =>
            {
                var features = new TestFeatures();
                features.SetFeature(KnownFeatures.ExperimentalPolyglotJava, true);
                return features;
            };
            options.InteractionServiceFactory = _ => new TestInteractionService
            {
                PromptForSelectionCallback = (promptText, choices, choiceFormatter, cancellationToken) =>
                    promptText == "Which language would you like to use?"
                        ? choices.Cast<object>().Single(choice => choiceFormatter(choice).Contains("TypeScript", StringComparison.Ordinal))
                        : choices.Cast<object>().First()
            };
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
                prompter.PromptForTemplateCallback = templates =>
                {
                    promptedTemplates = templates.Select(t => (t.Name, t.Description)).ToArray();
                    return templates.Single(t => t.Name.Equals(KnownTemplateId.CSharpEmptyAppHost, StringComparison.OrdinalIgnoreCase));
                };
 
                return prompter;
            };
        });
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (context, cancellationToken) =>
            {
                scaffoldedLanguageId = context.Language.LanguageId.Value;
                File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.mts"), "// test apphost");
                return Task.FromResult(true);
            }
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new --name TestApp --output ./output");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(KnownLanguageId.TypeScript, scaffoldedLanguageId);
        Assert.NotNull(promptedTemplates);
        Assert.Contains((KnownTemplateId.CSharpEmptyAppHost, "Empty AppHost (Choose language...)"), promptedTemplates);
        Assert.DoesNotContain((KnownTemplateId.TypeScriptEmptyAppHost, "Empty (TypeScript AppHost)"), promptedTemplates);
        Assert.DoesNotContain((KnownTemplateId.JavaEmptyAppHost, "Empty (Java AppHost)"), promptedTemplates);
        Assert.Contains((KnownTemplateId.TypeScriptStarter, "Starter App (Express/React, TypeScript AppHost)"), promptedTemplates);
        Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", "apphost.mts")));
        Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", "aspire.config.json")));
    }
 
    [Fact]
    public void NewCommandTemplateSubcommandsListTechnicalNamesForNonInteractiveFlows()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.FeatureFlagsFactory = _ => new TestFeatures().SetFeature(KnownFeatures.ShowAllTemplates, true);
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<NewCommand>();
 
        Assert.Contains(command.Subcommands, subcommand => subcommand.Name == "aspire-test");
        Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.DotNetEmptyAppHost && subcommand.Description == "Empty (C# AppHost, dotnet template)");
        Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.CSharpEmptyAppHost && subcommand.Description == "Empty AppHost (Choose language...)");
        Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.TypeScriptEmptyAppHost && subcommand.Description == "Empty (TypeScript AppHost)");
    }
 
    [Fact]
    public async Task NewCommandWithoutTemplatePromptsWithSingleGenericEmptyTemplate()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        string[]? promptedTemplateDescriptions = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
            options.FeatureFlagsFactory = _ =>
            {
                var features = new TestFeatures();
                features.SetFeature(KnownFeatures.ExperimentalPolyglotGo, true);
                features.SetFeature(KnownFeatures.ExperimentalPolyglotJava, true);
                features.SetFeature(KnownFeatures.ExperimentalPolyglotPython, true);
                features.SetFeature(KnownFeatures.ExperimentalPolyglotRust, true);
                return features;
            };
            options.InteractionServiceFactory = _ => new TestInteractionService();
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
                prompter.PromptForTemplateCallback = templates =>
                {
                    promptedTemplateDescriptions = templates
                        .Where(t => t.IsEmpty)
                        .Select(t => t.Description)
                        .ToArray();
                    return templates.Single(t => t.Name.Equals(KnownTemplateId.CSharpEmptyAppHost, StringComparison.OrdinalIgnoreCase));
                };
 
                return prompter;
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new --name TestApp --output ./output");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.NotNull(promptedTemplateDescriptions);
        Assert.Equal(["Empty AppHost (Choose language...)"], promptedTemplateDescriptions);
    }
 
    [Fact]
    public async Task NewCommandWithEmptyTemplateOmitsDisabledLanguagesFromLanguagePrompt()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        string[]? promptedLanguages = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
            options.InteractionServiceFactory = _ => new TestInteractionService
            {
                PromptForSelectionCallback = (promptText, choices, choiceFormatter, cancellationToken) =>
                {
                    if (promptText == "Which language would you like to use?")
                    {
                        promptedLanguages = choices.Cast<object>()
                            .Select(choice => choiceFormatter(choice))
                            .ToArray();
                    }
 
                    return choices.Cast<object>().First();
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-empty --name TestApp --output ./output");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.NotNull(promptedLanguages);
        Assert.Contains(KnownLanguageId.CSharpDisplayName, promptedLanguages);
        Assert.Contains("TypeScript (Node.js)", promptedLanguages);
        Assert.DoesNotContain(KnownLanguageId.PythonDisplayName, promptedLanguages);
        Assert.DoesNotContain(KnownLanguageId.JavaDisplayName, promptedLanguages);
        Assert.DoesNotContain(KnownLanguageId.GoDisplayName, promptedLanguages);
        Assert.DoesNotContain(KnownLanguageId.RustDisplayName, promptedLanguages);
    }
 
    [Fact]
    public async Task NewCommandWithEmptyTemplatePromptsForEnabledLanguages()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        string[]? promptedLanguages = null;
        string? scaffoldedLanguageId = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
            options.FeatureFlagsFactory = _ =>
            {
                var features = new TestFeatures();
                features.SetFeature(KnownFeatures.ExperimentalPolyglotJava, true);
                features.SetFeature(KnownFeatures.ExperimentalPolyglotPython, true);
                return features;
            };
            options.InteractionServiceFactory = _ => new TestInteractionService
            {
                PromptForSelectionCallback = (promptText, choices, choiceFormatter, cancellationToken) =>
                {
                    var formattedChoices = choices.Cast<object>()
                        .Select(choice => choiceFormatter(choice))
                        .ToArray();
 
                    if (promptText == "Which language would you like to use?")
                    {
                        promptedLanguages = formattedChoices;
                        return choices.Cast<object>().Single(choice => string.Equals(choiceFormatter(choice), KnownLanguageId.JavaDisplayName, StringComparison.Ordinal));
                    }
 
                    return choices.Cast<object>().First();
                }
            };
        });
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (context, cancellationToken) =>
            {
                scaffoldedLanguageId = context.Language.LanguageId.Value;
                File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "AppHost.java"), "package aspire;");
                return Task.FromResult(true);
            }
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-empty --name TestApp --output ./output");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(KnownLanguageId.Java, scaffoldedLanguageId);
        Assert.NotNull(promptedLanguages);
        Assert.Contains(KnownLanguageId.CSharpDisplayName, promptedLanguages);
        Assert.Contains("TypeScript (Node.js)", promptedLanguages);
        Assert.Contains(KnownLanguageId.PythonDisplayName, promptedLanguages);
        Assert.Contains(KnownLanguageId.JavaDisplayName, promptedLanguages);
        Assert.DoesNotContain(KnownLanguageId.GoDisplayName, promptedLanguages);
        Assert.DoesNotContain(KnownLanguageId.RustDisplayName, promptedLanguages);
    }
 
    [Fact]
    public async Task NewCommandWithEmptyTemplateIgnoresConfiguredLanguage()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"), """
            {
              "language": "typescript/nodejs"
            }
            """);
 
        var languagePrompted = false;
        string? scaffoldedLanguageId = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
            options.FeatureFlagsFactory = _ =>
            {
                var features = new TestFeatures();
                features.SetFeature(KnownFeatures.ExperimentalPolyglotJava, true);
                return features;
            };
            options.InteractionServiceFactory = _ => new TestInteractionService
            {
                PromptForSelectionCallback = (promptText, choices, choiceFormatter, cancellationToken) =>
                {
                    if (promptText == "Which language would you like to use?")
                    {
                        languagePrompted = true;
                        return choices.Cast<object>().Single(choice => string.Equals(choiceFormatter(choice), KnownLanguageId.JavaDisplayName, StringComparison.Ordinal));
                    }
 
                    return choices.Cast<object>().First();
                }
            };
        });
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (context, cancellationToken) =>
            {
                scaffoldedLanguageId = context.Language.LanguageId.Value;
                File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "AppHost.java"), "package aspire;");
                return Task.FromResult(true);
            }
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-empty --name TestApp --output ./output");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(languagePrompted);
        Assert.Equal(KnownLanguageId.Java, scaffoldedLanguageId);
    }
 
    [Fact]
    public async Task NewCommandWithExplicitLanguageAfterEmptyTemplateSubcommandCreatesTypeScriptAppHost()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        string? scaffoldedLanguageId = null;
 
        var services = CreateServiceCollection(workspace);
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (context, cancellationToken) =>
            {
                scaffoldedLanguageId = context.Language.LanguageId.Value;
                File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.mts"), "// test apphost");
                return Task.FromResult(true);
            }
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-empty --name TestApp --output ./output --language typescript --localhost-tld false --suppress-agent-init");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(KnownLanguageId.TypeScript, scaffoldedLanguageId);
        Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", "apphost.mts")));
    }
 
    [Fact]
    public async Task NewCommandWithCSharpEmptyTemplateAndSourceOverrideUsesSourceForTemplateDiscovery()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        const string sourceOverride = "https://proxy.example/v3/index.json";
        var expectedSource = sourceOverride;
 
        string? discoveryAspireSource = null;
        string? discoveryFallbackSource = null;
 
        var cache = new FakeNuGetPackageCache
        {
            GetTemplatePackagesAsyncCallback = (_, _, nugetConfig, _) =>
            {
                Assert.NotNull(nugetConfig);
 
                var document = XDocument.Load(nugetConfig.FullName);
                var sourceMappings = document.Root!
                    .Element("packageSourceMapping")!
                    .Elements("packageSource");
                discoveryAspireSource = (string?)sourceMappings
                    .Single(source => source
                        .Elements("package")
                        .Any(package => (string?)package.Attribute("pattern") == "Aspire*"))
                    .Attribute("key");
                discoveryFallbackSource = (string?)sourceMappings
                    .Single(source => source
                        .Elements("package")
                        .Any(package => (string?)package.Attribute("pattern") == PackageMapping.AllPackages))
                    .Attribute("key");
 
                return Task.FromResult<IEnumerable<NuGetPackage>>(
                    [new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = expectedSource, Version = "9.2.0" }]);
            }
        };
        var channel = PackageChannel.CreateExplicitChannel(
            PackageChannelNames.Staging,
            PackageChannelQuality.Stable,
            [
                new PackageMapping("Aspire*", "https://channel.example/v3/index.json"),
                new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg)
            ],
            cache,
            new TestFeatures(),
            NullLogger.Instance);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.PackagingServiceFactory = _ => new TestPackagingService
            {
                GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse($"new aspire-empty --name TestApp --output ./output --language csharp --localhost-tld false --suppress-agent-init --channel staging --source {sourceOverride}");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(expectedSource, discoveryAspireSource);
        Assert.Equal(expectedSource, discoveryFallbackSource);
        AssertSourceOverrideNuGetConfig(Path.Combine(workspace.WorkspaceRoot.FullName, "output"), expectedSource);
    }
 
    [Fact]
    public async Task NewCommandWithCSharpEmptyTemplateAndRelativeLocalSourceOverrideDiscoversTemplatesFromResolvedDirectory()
    {
        // A local directory passed to --source is enumerated directly instead of going through
        // `dotnet package search`, which cannot see hierarchical local feeds. The relative path must
        // still be resolved against the invocation directory before it is used or persisted.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var expectedSource = Path.Combine(workspace.WorkspaceRoot.FullName, "relative-feed");
        var nestedDirectory = Directory.CreateDirectory(Path.Combine(expectedSource, "aspire.projecttemplates", "9.2.0"));
        File.WriteAllText(Path.Combine(nestedDirectory.FullName, "Aspire.ProjectTemplates.9.2.0.nupkg"), string.Empty);
 
        var cache = new FakeNuGetPackageCache
        {
            GetTemplatePackagesAsyncCallback = (_, _, _, _) => throw new InvalidOperationException("Local package sources should be enumerated directly.")
        };
        var channel = PackageChannel.CreateExplicitChannel(
            PackageChannelNames.Staging,
            PackageChannelQuality.Stable,
            [
                new PackageMapping("Aspire*", "https://channel.example/v3/index.json"),
                new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg)
            ],
            cache,
            new TestFeatures(),
            NullLogger.Instance);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.PackagingServiceFactory = _ => new TestPackagingService
            {
                GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-empty --name TestApp --output ./output --language csharp --localhost-tld false --suppress-agent-init --channel staging --source relative-feed");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        AssertSourceOverrideNuGetConfig(Path.Combine(workspace.WorkspaceRoot.FullName, "output"), expectedSource);
    }
 
    [Theory]
    [InlineData("typescript", null, "apphost.mts")]
    [InlineData("java", "experimentalPolyglot:java", "AppHost.java")]
    [InlineData("python", "experimentalPolyglot:python", "apphost.py")]
    [InlineData("go", "experimentalPolyglot:go", "apphost.go")]
    [InlineData("rust", "experimentalPolyglot:rust", "apphost.rs")]
    public async Task NewCommandWithEmptyTemplateAndSourceOverridePersistsSourceForLaterRestore(string language, string? featureFlag, string scaffoldFileName)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var sourceOverride = Path.Combine(workspace.WorkspaceRoot.FullName, "source-feed");
        Directory.CreateDirectory(sourceOverride);
        File.WriteAllText(Path.Combine(sourceOverride, "Aspire.ProjectTemplates.9.2.0.nupkg"), string.Empty);
        string? capturedPackageSourceOverride = null;
        TestInteractionService? interactionService = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.InteractionServiceFactory = _ => interactionService = new TestInteractionService();
            if (featureFlag is not null)
            {
                options.FeatureFlagsFactory = _ =>
                {
                    var features = new TestFeatures();
                    features.SetFeature(featureFlag, true);
                    return features;
                };
            }
        });
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (context, _) =>
            {
                capturedPackageSourceOverride = context.PackageSourceOverride;
                File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, scaffoldFileName), "// test apphost");
                return Task.FromResult(true);
            }
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse($"new aspire-empty --name TestApp --output ./output --language {language} --localhost-tld false --suppress-agent-init --source \"{sourceOverride}\"");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(sourceOverride, capturedPackageSourceOverride);
        AssertSourceOverrideNuGetConfig(Path.Combine(workspace.WorkspaceRoot.FullName, "output"), sourceOverride);
        Assert.NotNull(interactionService);
        Assert.DoesNotContain(
            interactionService!.DisplayedMessages,
            entry => entry.Message == TemplatingStrings.SourceOverrideNotPersistedWarning);
    }
 
    [Fact]
    public async Task NewCommandWithCSharpEmptyTemplateAndSourceOverridePersistsSourceForLaterRestore()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var sourceOverride = Path.Combine(workspace.WorkspaceRoot.FullName, "source-feed");
        Directory.CreateDirectory(sourceOverride);
        File.WriteAllText(Path.Combine(sourceOverride, "Aspire.ProjectTemplates.9.2.0.nupkg"), string.Empty);
 
        var services = CreateServiceCollection(workspace);
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse($"new aspire-empty --name TestApp --output ./output --language csharp --localhost-tld false --suppress-agent-init --source \"{sourceOverride}\"");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        AssertSourceOverrideNuGetConfig(Path.Combine(workspace.WorkspaceRoot.FullName, "output"), sourceOverride);
    }
 
    [Theory]
    [InlineData("https://user:token@example.invalid/v3/index.json")]
    [InlineData("https://example.invalid/v3/index.json?sig=token")]
    [InlineData("https://example.invalid/v3/index.json#token")]
    public async Task NewCommandWithCredentialBearingHttpSourceFailsBeforeCreatingProject(string sourceOverride)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var scaffoldingInvoked = false;
        TestInteractionService? interactionService = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.InteractionServiceFactory = _ => interactionService = new TestInteractionService();
        });
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (_, _) =>
            {
                scaffoldingInvoked = true;
                return Task.FromResult(true);
            }
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse($"new aspire-empty --name TestApp --output ./output --language typescript --localhost-tld false --suppress-agent-init --source {sourceOverride}");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        Assert.False(scaffoldingInvoked);
        Assert.False(Directory.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output")));
        Assert.NotNull(interactionService);
        Assert.Contains(NewCommandStrings.SourceWithCredentialsCannotBePersisted, interactionService!.DisplayedErrors);
    }
 
    [Fact]
    public async Task NewCommandWithMissingLocalSourceFailsBeforeCreatingProject()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var scaffoldingInvoked = false;
        TestInteractionService? interactionService = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.InteractionServiceFactory = _ => interactionService = new TestInteractionService();
        });
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (_, _) =>
            {
                scaffoldingInvoked = true;
                return Task.FromResult(true);
            }
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-empty --name TestApp --output ./output --language typescript --localhost-tld false --suppress-agent-init --source nuget.org");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        var expectedSource = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.org");
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        Assert.False(scaffoldingInvoked);
        Assert.False(Directory.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output")));
        Assert.NotNull(interactionService);
        Assert.Contains(interactionService!.DisplayedErrors, error => error.Contains(expectedSource, StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task NewCommandWithEmptyTemplateWithoutSourceOverrideDoesNotWarn()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        TestInteractionService? interactionService = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.InteractionServiceFactory = _ => interactionService = new TestInteractionService();
        });
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (context, _) =>
            {
                File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.mts"), "// test apphost");
                return Task.FromResult(true);
            }
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-empty --name TestApp --output ./output --language typescript --localhost-tld false --suppress-agent-init");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.NotNull(interactionService);
        Assert.DoesNotContain(
            interactionService!.DisplayedMessages,
            entry => entry.Message == TemplatingStrings.SourceOverrideNotPersistedWarning);
    }
 
    [Fact]
    public async Task NewCommandWithExplicitJavaEmptyTemplateCreatesJavaAppHost()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        string? scaffoldedLanguageId = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.FeatureFlagsFactory = _ =>
            {
                var features = new TestFeatures();
                features.SetFeature(KnownFeatures.ExperimentalPolyglotJava, true);
                return features;
            };
 
        });
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (context, cancellationToken) =>
            {
                scaffoldedLanguageId = context.Language.LanguageId.Value;
                File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "AppHost.java"), "package aspire;");
                return Task.FromResult(true);
            }
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-java-empty --name TestApp --output ./output --localhost-tld false");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(KnownLanguageId.Java, scaffoldedLanguageId);
        Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", "AppHost.java")));
    }
 
    [Fact]
    public async Task NewCommandWithExplicitPythonEmptyTemplateCreatesPythonAppHost()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        string? scaffoldedLanguageId = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.FeatureFlagsFactory = _ =>
            {
                var features = new TestFeatures();
                features.SetFeature(KnownFeatures.ExperimentalPolyglotPython, true);
                return features;
            };
        });
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (context, cancellationToken) =>
            {
                scaffoldedLanguageId = context.Language.LanguageId.Value;
                File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.py"), "# test apphost");
                return Task.FromResult(true);
            }
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-py-empty --name TestApp --output ./output --localhost-tld false");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(KnownLanguageId.Python, scaffoldedLanguageId);
        Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", "apphost.py")));
    }
 
    [Fact]
    public async Task NewCommandWithExplicitCSharpEmptyTemplateCreatesCSharpAppHost()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CreateServiceCollection(workspace);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-empty --name TestApp --output ./output --localhost-tld false");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", "apphost.cs")));
    }
 
    [Fact]
    public async Task NewCommandWaitsForBundleExtractionAfterCreatingAppHost()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var extractionStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var allowExtraction = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var bundleService = new TestBundleService(isBundle: true)
        {
            EnsureExtractedAsyncCallback = async cancellationToken =>
            {
                extractionStarted.SetResult();
                await allowExtraction.Task.WaitAsync(cancellationToken);
            }
        };
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.BundleServiceFactory = _ => bundleService;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-empty --name TestApp --output ./output --localhost-tld false --suppress-agent-init");
        var invocationTask = result.InvokeAsync();
 
        await extractionStarted.Task.DefaultTimeout();
 
        var appHostCreated = File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", "apphost.cs"));
        var commandCompletedBeforeExtraction = invocationTask.IsCompleted;
 
        allowExtraction.SetResult();
        var exitCode = await invocationTask.DefaultTimeout();
 
        Assert.True(appHostCreated);
        Assert.False(commandCompletedBeforeExtraction);
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task NewCommandWithCSharpEmptyTemplateEmitsAppHostRunJsonAndAspireConfigJsonWithoutDuplicateProfiles()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CreateServiceCollection(workspace);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-empty --name TestApp --output ./output --localhost-tld false --suppress-agent-init");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output");
        var aspireConfigPath = Path.Combine(outputDir, "aspire.config.json");
        var appHostRunJsonPath = Path.Combine(outputDir, "apphost.run.json");
 
        Assert.True(File.Exists(aspireConfigPath));
        Assert.True(File.Exists(appHostRunJsonPath), "apphost.run.json must be emitted alongside aspire.config.json so dotnet run picks up matching URLs.");
 
        var aspireConfig = await File.ReadAllTextAsync(aspireConfigPath);
        var appHostRunJson = await File.ReadAllTextAsync(appHostRunJsonPath);
 
        // Launch profile shape (applicationUrl / commandName / environmentVariables) must live in
        // apphost.run.json so that `dotnet run apphost.cs` and the C# Dev Kit can pick it up.
        Assert.Contains("://localhost:", appHostRunJson);
        Assert.Contains("\"commandName\": \"Project\"", appHostRunJson);
 
        // aspire.config.json must NOT carry a duplicated `profiles` block — that content belongs to
        // apphost.run.json only. See https://github.com/microsoft/aspire/issues/17660.
        AssertAspireConfigHasNoProfiles(aspireConfig);
    }
 
    [Fact]
    public async Task NewCommandWithCSharpEmptyTemplateAndLocalhostTldEmitsAppHostRunJsonWithDevLocalhostUrls()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CreateServiceCollection(workspace);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-empty --name TestApp --output ./output --localhost-tld --suppress-agent-init");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output");
        var aspireConfigPath = Path.Combine(outputDir, "aspire.config.json");
        var appHostRunJsonPath = Path.Combine(outputDir, "apphost.run.json");
 
        Assert.True(File.Exists(aspireConfigPath));
        Assert.True(File.Exists(appHostRunJsonPath), "apphost.run.json must be emitted alongside aspire.config.json so dotnet run picks up matching URLs.");
 
        var aspireConfig = await File.ReadAllTextAsync(aspireConfigPath);
        var appHostRunJson = await File.ReadAllTextAsync(appHostRunJsonPath);
 
        Assert.Contains("testapp.dev.localhost", appHostRunJson);
        Assert.DoesNotContain("://localhost", appHostRunJson);
 
        // aspire.config.json must NOT carry a duplicated `profiles` block — that content belongs to
        // apphost.run.json only. See https://github.com/microsoft/aspire/issues/17660.
        AssertAspireConfigHasNoProfiles(aspireConfig);
    }
 
    private static void AssertAspireConfigHasNoProfiles(string aspireConfigJson)
    {
        using var aspireDoc = System.Text.Json.JsonDocument.Parse(aspireConfigJson);
        Assert.False(
            aspireDoc.RootElement.TryGetProperty("profiles", out _),
            "aspire.config.json must not contain a 'profiles' block for the empty C# template; profiles live in apphost.run.json.");
 
        // Pin the expected minimal shape: aspire.config.json for the C# Empty template should only
        // identify the AppHost file. See https://github.com/microsoft/aspire/issues/17660.
        Assert.True(aspireDoc.RootElement.TryGetProperty("appHost", out var appHost), "aspire.config.json is missing the required 'appHost' object.");
        Assert.True(appHost.TryGetProperty("path", out var path), "aspire.config.json#appHost is missing the 'path' property.");
        Assert.Equal("apphost.cs", path.GetString());
    }
 
    [Fact]
    public async Task NewCommandWithEmptyTemplateAndCSharpPromptsForLocalhostTldAndUsesConfirmation()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var localhostPrompted = false;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
 
            options.InteractionServiceFactory = _ => new TestInteractionService
            {
                ConfirmCallback = (promptText, defaultValue) =>
                {
                    if (string.Equals(promptText, TemplatingStrings.UseLocalhostTld_Prompt, StringComparison.Ordinal))
                    {
                        localhostPrompted = true;
                        Assert.False(defaultValue);
                        return true;
                    }
 
                    return false;
                }
            };
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
                prompter.PromptForTemplateCallback = templates =>
                    templates.Single(t => t.Name.Equals("aspire-empty", StringComparison.OrdinalIgnoreCase));
 
                return prompter;
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-empty --name TestApp --output ./output");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(localhostPrompted);
 
        var outputRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "output");
 
        var aspireConfigPath = Path.Combine(outputRoot, "aspire.config.json");
        Assert.True(File.Exists(aspireConfigPath));
        var aspireConfig = await File.ReadAllTextAsync(aspireConfigPath);
        // aspire.config.json must NOT contain the localhost-TLD URLs — those belong in
        // apphost.run.json. See https://github.com/microsoft/aspire/issues/17660.
        Assert.DoesNotContain("testapp.dev.localhost", aspireConfig);
 
        var appHostRunJsonPath = Path.Combine(outputRoot, "apphost.run.json");
        Assert.True(File.Exists(appHostRunJsonPath));
        var appHostRunJson = await File.ReadAllTextAsync(appHostRunJsonPath);
        Assert.Contains("testapp.dev.localhost", appHostRunJson);
        Assert.DoesNotContain("://localhost", appHostRunJson);
    }
 
    [Fact]
    public async Task NewCommandWithTypeScriptEmptyTemplateUsesScaffolding()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var scaffoldingInvoked = false;
 
        var services = CreateServiceCollection(workspace);
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (context, cancellationToken) =>
            {
                scaffoldingInvoked = true;
                return Task.FromResult(true);
            }
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("new aspire-ts-empty --name TestApp --output ./output --localhost-tld false");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(scaffoldingInvoked);
    }
 
    [Fact]
    public async Task NewCommandWithTypeScriptEmptyTemplatePassesResolvedVersionAndChannelToScaffolding()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        string? scaffoldSdkVersion = null;
        string? scaffoldChannel = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.PackagingServiceFactory = (sp) =>
            {
                var packagingService = new TestPackagingService();
                packagingService.GetChannelsAsyncCallback = (ct) =>
                {
                    var stableCache = new FakeNuGetPackageCache();
                    stableCache.GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) =>
                    {
                        var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" };
                        return Task.FromResult<IEnumerable<NuGetPackage>>([package]);
                    };
 
                    var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Both, [], stableCache, new TestFeatures(), NullLogger.Instance);
                    return Task.FromResult<IEnumerable<PackageChannel>>([stableChannel]);
                };
 
                return packagingService;
            };
        });
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (context, cancellationToken) =>
            {
                scaffoldSdkVersion = context.SdkVersion;
                scaffoldChannel = context.Channel;
                return Task.FromResult(true);
            }
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("new aspire-ts-empty --name TestApp --output ./output --channel stable --localhost-tld false");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal("9.2.0", scaffoldSdkVersion);
        Assert.Equal("stable", scaffoldChannel);
    }
 
    [Fact]
    public async Task NewCommandWithEmptyTemplateNormalizesDefaultOutputPath()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        string? capturedTargetDirectory = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
 
                // Accept the default path from the prompt
                prompter.PromptForOutputPathCallback = (path) => path;
 
                return prompter;
            };
 
        });
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (context, cancellationToken) =>
            {
                capturedTargetDirectory = context.TargetDirectory.FullName;
                return Task.FromResult(true);
            }
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        // Do not pass --output so the default project-name path is used via the prompter
        var result = command.Parse("new aspire-ts-empty --name TestApp --localhost-tld false");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.NotNull(capturedTargetDirectory);
 
        // The output path should be properly normalized without "./" segments
        Assert.DoesNotContain("./", capturedTargetDirectory);
        Assert.DoesNotContain(".\\", capturedTargetDirectory);
 
        var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "TestApp");
        Assert.Equal(expectedPath, capturedTargetDirectory);
    }
 
    [Fact]
    public async Task NewCommandWithEmptyTemplateAndTypeScriptPromptsForLocalhostTldAndUsesConfirmation()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var scaffoldingInvoked = false;
        var localhostPrompted = false;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
 
            options.InteractionServiceFactory = _ => new TestInteractionService
            {
                ConfirmCallback = (promptText, defaultValue) =>
                {
                    if (string.Equals(promptText, TemplatingStrings.UseLocalhostTld_Prompt, StringComparison.Ordinal))
                    {
                        localhostPrompted = true;
                        Assert.False(defaultValue);
                        return true;
                    }
 
                    return false;
                }
            };
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
                prompter.PromptForTemplateCallback = templates =>
                    templates.Single(t => t.Name.Equals("aspire-ts-empty", StringComparison.OrdinalIgnoreCase));
 
                return prompter;
            };
        });
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = async (context, cancellationToken) =>
            {
                scaffoldingInvoked = true;
                await File.WriteAllTextAsync(Path.Combine(context.TargetDirectory.FullName, "aspire.config.json"), """
                    {
                      "appHost": {
                        "path": "apphost.mts",
                        "language": "typescript/nodejs"
                      },
                      "profiles": {
                        "https": {
                          "applicationUrl": "https://localhost:1234;http://localhost:5678",
                          "environmentVariables": {
                            "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:8765",
                            "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:4321"
                          }
                        }
                      }
                    }
                    """, cancellationToken);
                return true;
            }
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-ts-empty --name TestApp --output ./output");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(scaffoldingInvoked);
        Assert.True(localhostPrompted);
 
        var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", "aspire.config.json");
        var configContent = await File.ReadAllTextAsync(configPath);
        Assert.Contains("testapp.dev.localhost", configContent);
        Assert.DoesNotContain("://localhost", configContent);
    }
 
    [Fact]
    public async Task NewCommandWithTypeScriptStarterGeneratesSdkArtifacts()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var buildAndGenerateCalled = false;
        string? channelSeenByProject = null;
        string? sdkVersionSeenByProject = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner
            {
                SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, runnerOptions, cancellationToken) =>
                {
                    var package = new NuGetPackage
                    {
                        Id = "Aspire.ProjectTemplates",
                        Source = "nuget",
                        Version = "9.2.0"
                    };
 
                    return (0, new NuGetPackage[] { package });
                }
            };
 
            options.PackagingServiceFactory = _ => new TestPackagingService
            {
                GetChannelsAsyncCallback = cancellationToken =>
                {
                    var dailyCache = new FakeNuGetPackageCache
                    {
                        GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) =>
                        {
                            var package = new NuGetPackage
                            {
                                Id = "Aspire.ProjectTemplates",
                                Source = "nuget",
                                Version = "9.2.0"
                            };
 
                            return Task.FromResult<IEnumerable<NuGetPackage>>([package]);
                        }
                    };
 
                    var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache, new TestFeatures(), NullLogger.Instance);
                    return Task.FromResult<IEnumerable<PackageChannel>>([dailyChannel]);
                }
            };
        });
 
        services.AddSingleton<IAppHostProjectFactory>(new TestTypeScriptStarterProjectFactory((directory, cancellationToken, _) =>
        {
            buildAndGenerateCalled = true;
            var config = AspireConfigFile.Load(directory.FullName);
            channelSeenByProject = config?.Channel;
            sdkVersionSeenByProject = config?.SdkVersion;
 
            var modulesDir = Directory.CreateDirectory(Path.Combine(directory.FullName, LanguageInfo.GeneratedFolderName));
            File.WriteAllText(Path.Combine(modulesDir.FullName, "aspire.mts"), "// generated sdk");
 
            return Task.FromResult(true);
        }));
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("new aspire-ts-starter --name TestApp --output ./output --channel daily --localhost-tld false");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(buildAndGenerateCalled);
        Assert.Equal("daily", channelSeenByProject);
        Assert.Equal(VersionHelper.GetDefaultSdkVersion(), sdkVersionSeenByProject);
        Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", LanguageInfo.GeneratedFolderName, "aspire.mts")));
    }
 
    [Fact]
    public async Task NewCommandWithTypeScriptStarterReturnsFailedToBuildArtifactsWhenSdkGenerationFails()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var interactionService = new TestInteractionService();
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner
            {
                SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, runnerOptions, cancellationToken) =>
                {
                    var package = new NuGetPackage
                    {
                        Id = "Aspire.ProjectTemplates",
                        Source = "nuget",
                        Version = "9.2.0"
                    };
 
                    return (0, new NuGetPackage[] { package });
                }
            };
 
            options.PackagingServiceFactory = _ => new TestPackagingService
            {
                GetChannelsAsyncCallback = cancellationToken =>
                {
                    var dailyCache = new FakeNuGetPackageCache
                    {
                        GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) =>
                        {
                            var package = new NuGetPackage
                            {
                                Id = "Aspire.ProjectTemplates",
                                Source = "nuget",
                                Version = "9.2.0"
                            };
 
                            return Task.FromResult<IEnumerable<NuGetPackage>>([package]);
                        }
                    };
 
                    var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache, new TestFeatures(), NullLogger.Instance);
                    return Task.FromResult<IEnumerable<PackageChannel>>([dailyChannel]);
                }
            };
        });
 
        services.AddSingleton<IInteractionService>(interactionService);
        services.AddSingleton<IAppHostProjectFactory>(new TestTypeScriptStarterProjectFactory((directory, cancellationToken, _) => Task.FromResult(false)));
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("new aspire-ts-starter --name TestApp --output ./output --channel daily --localhost-tld false");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode);
        Assert.Collection(interactionService.DisplayedErrors,
            error => Assert.Equal("Automatic 'aspire restore' failed for the new TypeScript starter project. Run 'aspire restore' in the project directory for more details.", error));
    }
 
    [Fact]
    public async Task NewCommandWithTypeScriptStarterAndSourceOverridePersistsSourceAndPlumbsOverride()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var sourceOverride = Path.Combine(workspace.WorkspaceRoot.FullName, "source-feed");
        Directory.CreateDirectory(sourceOverride);
        // A local --source directory is enumerated directly rather than searched, so the template
        // package has to exist on disk for version resolution to succeed.
        File.WriteAllText(Path.Combine(sourceOverride, "Aspire.ProjectTemplates.9.2.0.nupkg"), string.Empty);
        TestInteractionService? interactionService = null;
        var services = CreateServiceCollection(workspace, options =>
        {
            options.InteractionServiceFactory = _ => interactionService = new TestInteractionService();
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner
            {
                SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, runnerOptions, cancellationToken) =>
                {
                    var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" };
                    return (0, new NuGetPackage[] { package });
                }
            };
 
            options.PackagingServiceFactory = _ => new TestPackagingService
            {
                GetChannelsAsyncCallback = cancellationToken =>
                {
                    var dailyCache = new FakeNuGetPackageCache
                    {
                        GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) =>
                        {
                            var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" };
                            return Task.FromResult<IEnumerable<NuGetPackage>>([package]);
                        }
                    };
                    var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache, new TestFeatures(), NullLogger.Instance);
                    return Task.FromResult<IEnumerable<PackageChannel>>([dailyChannel]);
                }
            };
        });
 
        var projectFactory = new TestTypeScriptStarterProjectFactory((directory, cancellationToken, _) =>
        {
            var modulesDir = Directory.CreateDirectory(Path.Combine(directory.FullName, ".aspire", "modules"));
            File.WriteAllText(Path.Combine(modulesDir.FullName, "aspire.ts"), "// generated sdk");
            return Task.FromResult(true);
        });
        services.AddSingleton<IAppHostProjectFactory>(projectFactory);
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"new aspire-ts-starter --name TestApp --output ./output --channel daily --localhost-tld false --source \"{sourceOverride}\"");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(sourceOverride, projectFactory.Project.LastPackageSourceOverride);
        AssertSourceOverrideNuGetConfig(Path.Combine(workspace.WorkspaceRoot.FullName, "output"), sourceOverride);
        Assert.NotNull(interactionService);
        Assert.DoesNotContain(
            interactionService!.DisplayedMessages,
            entry => entry.Message == TemplatingStrings.SourceOverrideNotPersistedWarning);
    }
 
    [Fact]
    public async Task NewCommandWithDotNetTemplateAndSourceOverridePersistsSourceForLaterRestore()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        const string sourceOverride = "https://proxy.example/v3/index.json";
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = CreateTestRunnerWithStandardPackages();
                runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, cancellationToken) =>
                {
                    Assert.NotNull(nugetConfigFile);
 
                    var document = XDocument.Load(nugetConfigFile.FullName);
                    var installPackageSources = document.Root!
                        .Element("packageSources")!
                        .Elements("add")
                        .Select(element => (string)element.Attribute("value")!)
                        .ToArray();
 
                    Assert.Equal([sourceOverride], installPackageSources);
 
                    return (0, version);
                };
                runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, cancellationToken) =>
                {
                    return 0;
                };
                return runner;
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"new aspire-starter --name TestApp --output ./output --source {sourceOverride} --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        AssertSourceOverrideNuGetConfig(Path.Combine(workspace.WorkspaceRoot.FullName, "output"), sourceOverride);
    }
 
    [Fact]
    public async Task NewCommandWithTypeScriptStarterAndFailedRestoreDoesNotWarnAboutSourceOverride()
    {
        // The warning is only meaningful when the scaffold succeeded — surfacing it on a failed
        // restore would just add noise behind a more prominent error. Pin that the starter path
        // mirrors the empty-template path here.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var sourceOverride = Path.Combine(workspace.WorkspaceRoot.FullName, "source-feed");
        Directory.CreateDirectory(sourceOverride);
        // A local --source directory is enumerated directly rather than searched, so the template
        // package has to exist on disk for version resolution to succeed.
        File.WriteAllText(Path.Combine(sourceOverride, "Aspire.ProjectTemplates.9.2.0.nupkg"), string.Empty);
 
        TestInteractionService? interactionService = null;
        var services = CreateServiceCollection(workspace, options =>
        {
            options.InteractionServiceFactory = _ => interactionService = new TestInteractionService();
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner
            {
                SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, runnerOptions, cancellationToken) =>
                {
                    var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" };
                    return (0, new NuGetPackage[] { package });
                }
            };
 
            options.PackagingServiceFactory = _ => new TestPackagingService
            {
                GetChannelsAsyncCallback = cancellationToken =>
                {
                    var dailyCache = new FakeNuGetPackageCache
                    {
                        GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) =>
                        {
                            var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" };
                            return Task.FromResult<IEnumerable<NuGetPackage>>([package]);
                        }
                    };
                    var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache, new TestFeatures(), NullLogger.Instance);
                    return Task.FromResult<IEnumerable<PackageChannel>>([dailyChannel]);
                }
            };
        });
 
        services.AddSingleton<IAppHostProjectFactory>(new TestTypeScriptStarterProjectFactory((directory, cancellationToken, _) => Task.FromResult(false)));
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"new aspire-ts-starter --name TestApp --output ./output --channel daily --localhost-tld false --source {sourceOverride}");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode);
        Assert.NotNull(interactionService);
        Assert.DoesNotContain(
            interactionService!.DisplayedMessages,
            entry => entry.Message == TemplatingStrings.SourceOverrideNotPersistedWarning);
    }
 
    [Fact]
    public async Task NewCommandNonInteractiveDoesNotPrompt()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            // Configure non-interactive host environment
            options.CliHostEnvironmentFactory = (sp) =>
            {
                var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
                return new CliHostEnvironment(configuration, nonInteractive: true);
            };
 
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-empty --name TestApp --output ./output");
 
        // Before the fix, this would throw InvalidOperationException with
        // "Interactive input is not supported in this environment" because
        // GetTemplates() did not pass the nonInteractive flag, causing
        // the template to try to prompt for options.
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task NewCommandNonInteractive_WithSkillLocationsNone_DoesNotInstallAgentSkills()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = (sp) =>
            {
                var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
                return new CliHostEnvironment(configuration, nonInteractive: true);
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-empty --name TestApp --output ./output --skill-locations none");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output");
        var agentsDir = Path.Combine(outputDir, ".agents", "skills");
        Assert.False(Directory.Exists(agentsDir), $"Expected no agents/skills directory but found {agentsDir}");
    }
 
    [Fact]
    public async Task NewCommandNonInteractive_WithSkillLocationsAndSkills_InstallsOnlySpecifiedSkills()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = (sp) =>
            {
                var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
                return new CliHostEnvironment(configuration, nonInteractive: true);
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse($"new aspire-empty --name TestApp --output ./output --skill-locations standard --skills {CommonAgentApplicators.AspireSkillName}");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output");
        var aspireSkillPath = Path.Combine(outputDir, ".agents", "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md");
        Assert.True(File.Exists(aspireSkillPath), $"Expected aspire skill file at {aspireSkillPath}");
 
        var aspireifySkillPath = Path.Combine(outputDir, ".agents", "skills", CommonAgentApplicators.AspireifySkillName);
        Assert.False(Directory.Exists(aspireifySkillPath), $"Expected no aspireify skill directory but found {aspireifySkillPath}");
    }
 
    [Fact]
    public async Task NewCommandNonInteractiveWithoutTemplate_DisplaysErrorWithAvailableTemplates()
    {
        TestInteractionService? testInteractionService = null;
        string? availableTemplatesMessage = null;
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = (sp) =>
            {
                var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
                return new CliHostEnvironment(configuration, nonInteractive: true);
            };
 
            options.InteractionServiceFactory = (sp) =>
            {
                testInteractionService = new TestInteractionService
                {
                    DisplaySubtleMessageCallback = message => availableTemplatesMessage = message
                };
                return testInteractionService;
            };
 
            options.FeatureFlagsFactory = _ =>
            {
                var features = new TestFeatures();
                features.SetFeature(KnownFeatures.ExperimentalPolyglotJava, true);
                return features;
            };
 
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.MissingRequiredArgument, exitCode);
        Assert.NotNull(testInteractionService);
        Assert.Contains(testInteractionService.DisplayedErrors,
            e => string.Equals(e, NewCommandStrings.NonInteractiveTemplateRequired, StringComparison.Ordinal));
        Assert.NotNull(availableTemplatesMessage);
        Assert.Contains(KnownTemplateId.TypeScriptEmptyAppHost, availableTemplatesMessage);
        Assert.Contains(KnownTemplateId.JavaEmptyAppHost, availableTemplatesMessage);
    }
 
    [Fact]
    public async Task NewCommandNonInteractiveUsesDefaultNameWhenNotProvided()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        string? capturedProjectName = null;
        string? capturedOutputPath = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = (sp) =>
            {
                var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
                return new CliHostEnvironment(configuration, nonInteractive: true);
            };
 
            options.DotNetCliRunnerFactory = (sp) =>
            {
                var runner = CreateTestRunnerWithStandardPackages();
 
                runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
                {
                    capturedProjectName = projectName;
                    capturedOutputPath = outputPath;
                    return 0;
                };
 
                return runner;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        // Neither --name nor --output is provided, so both use their defaults
        var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        // The default project name is derived from the template name
        Assert.Equal("aspire-starter", capturedProjectName);
        // The default output path is derived from the template name
        Assert.Equal(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-starter"), capturedOutputPath);
    }
 
    [Fact]
    public async Task NewCommandNonInteractiveWithAllOptions_Succeeds()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        string? capturedProjectName = null;
        string? capturedOutputPath = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = (sp) =>
            {
                var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
                return new CliHostEnvironment(configuration, nonInteractive: true);
            };
 
            options.DotNetCliRunnerFactory = (sp) =>
            {
                var runner = CreateTestRunnerWithStandardPackages();
 
                runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
                {
                    capturedProjectName = projectName;
                    capturedOutputPath = outputPath;
                    return 0;
                };
 
                return runner;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse($"new aspire-starter --name MyProject --output {Path.Combine(workspace.WorkspaceRoot.FullName, "my-project")} --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal("MyProject", capturedProjectName);
        Assert.NotNull(capturedOutputPath);
        Assert.Contains("my-project", capturedOutputPath);
 
        // Agent init runs by default after project creation
        var skillPath = Path.Combine(capturedOutputPath, ".agents", "skills", "aspire", "SKILL.md");
        Assert.True(File.Exists(skillPath));
    }
 
    [Fact]
    public async Task NewCommandNonInteractiveWithAllOptions_SuppressAgentInitTrue_SkipsAgentInit()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = (sp) =>
            {
                var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
                return new CliHostEnvironment(configuration, nonInteractive: true);
            };
 
            options.DotNetCliRunnerFactory = (sp) =>
            {
                var runner = CreateTestRunnerWithStandardPackages();
 
                runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
                {
                    return 0;
                };
 
                return runner;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output");
        var result = command.Parse($"new aspire-starter --name MyProject --output {outputDir} --use-redis-cache --test-framework None --suppress-agent-init");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        // Agent init should not have run — no skill files should exist
        var skillPath = Path.Combine(outputDir, ".agents", "skills", "aspire", "SKILL.md");
        Assert.False(File.Exists(skillPath));
    }
 
    [Fact]
    public async Task NewCommand_WhenCSharpTemplateApplyFails_DisplaysCreationErrorMessage()
    {
        TestInteractionService? testInteractionService = null;
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.InteractionServiceFactory = (sp) =>
            {
                testInteractionService = new TestInteractionService();
                return testInteractionService;
            };
 
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                return new TestNewCommandPrompter(interactionService);
            };
 
            options.DotNetCliRunnerFactory = (sp) =>
            {
                var runner = CreateTestRunnerWithStandardPackages();
 
                runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
                {
                    return 1; // Simulate failure
                };
 
                return runner;
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        var executionContext = provider.GetRequiredService<CliExecutionContext>();
        var expectedMessage = string.Format(CultureInfo.CurrentCulture, TemplatingStrings.ProjectCreationFailed, 1, executionContext.LogFilePath);
 
        Assert.NotEqual(0, exitCode);
        Assert.NotNull(testInteractionService);
        Assert.Contains(expectedMessage, testInteractionService.DisplayedErrors);
    }
 
    [Fact]
    public async Task NewCommand_WhenTypeScriptTemplateApplyFails_ReturnsNonZeroExitCode()
    {
        TestInteractionService? testInteractionService = null;
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.InteractionServiceFactory = (sp) =>
            {
                testInteractionService = new TestInteractionService();
                return testInteractionService;
            };
 
        });
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (context, cancellationToken) =>
            {
                return Task.FromResult(false); // Simulate failure for TypeScript template
            }
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-ts-empty --name TestApp --output ./output");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.NotEqual(0, exitCode);
        Assert.NotNull(testInteractionService);
    }
 
    [Fact]
    public async Task NewCommandInExtensionModeAppendsProjectNameToOutputPath()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        string? capturedOutputPath = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
            options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
            options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
            {
                HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
            };
 
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
 
                prompter.PromptForProjectNameCallback = (_) => "MyFirstApp";
 
                // Simulate the user picking a parent folder (not named after the project)
                prompter.PromptForOutputPathCallback = (_) =>
                    Path.Combine(workspace.WorkspaceRoot.FullName, "source");
 
                return prompter;
            };
 
            options.DotNetCliRunnerFactory = (sp) =>
            {
                var runner = CreateTestRunnerWithStandardPackages();
                runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) =>
                {
                    return (0, version);
                };
                runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
                {
                    capturedOutputPath = outputPath;
                    return 0;
                };
                return runner;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.NotNull(capturedOutputPath);
 
        // Output path should have the project name appended as a subdirectory
        var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "source", "MyFirstApp");
        Assert.Equal(expectedPath, capturedOutputPath);
    }
 
    [Fact]
    public async Task NewCommandInExtensionModeDoesNotDoubleAppendProjectName()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        string? capturedOutputPath = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
            options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
            options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
            {
                HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
            };
 
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
 
                prompter.PromptForProjectNameCallback = (_) => "MyFirstApp";
 
                // Simulate the user picking a folder already named after the project
                prompter.PromptForOutputPathCallback = (_) =>
                    Path.Combine(workspace.WorkspaceRoot.FullName, "source", "MyFirstApp");
 
                return prompter;
            };
 
            options.DotNetCliRunnerFactory = (sp) =>
            {
                var runner = CreateTestRunnerWithStandardPackages();
                runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) =>
                {
                    return (0, version);
                };
                runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
                {
                    capturedOutputPath = outputPath;
                    return 0;
                };
                return runner;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.NotNull(capturedOutputPath);
 
        // Output path should NOT have the project name double-appended
        var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "source", "MyFirstApp");
        Assert.Equal(expectedPath, capturedOutputPath);
    }
 
    [Fact]
    public async Task NewCommandInConsoleModeDoesNotAppendProjectName()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        string? capturedOutputPath = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
            // Use TestInteractionService (not ExtensionInteractionService) to stay in console/non-extension mode
            options.InteractionServiceFactory = _ => new TestInteractionService();
            // Default InteractionServiceFactory creates ConsoleInteractionService (not extension mode)
 
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
 
                prompter.PromptForProjectNameCallback = (_) => "MyFirstApp";
 
                // Simulate user accepting default path or selecting parent folder
                prompter.PromptForOutputPathCallback = (_) =>
                    Path.Combine(workspace.WorkspaceRoot.FullName, "source");
 
                return prompter;
            };
 
            options.DotNetCliRunnerFactory = (sp) =>
            {
                var runner = CreateTestRunnerWithStandardPackages();
                runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) =>
                {
                    return (0, version);
                };
                runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
                {
                    capturedOutputPath = outputPath;
                    return 0;
                };
                return runner;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.NotNull(capturedOutputPath);
 
        // In console mode, the output path should NOT have project name appended
        var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "source");
        Assert.Equal(expectedPath, capturedOutputPath);
    }
 
    [Fact]
    public async Task NewCommandInExtensionModeHandlesTrailingDirectorySeparator()
    {
        const string projectName = "MyFirstApp";
 
        async Task AssertOutputPathAsync(Func<string, string> selectedPathFactory, Func<string, string> expectedPathFactory)
        {
            using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
            string? capturedOutputPath = null;
 
            var services = CreateServiceCollection(workspace, options =>
            {
                options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
                options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
                options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
                {
                    HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
                };
 
                options.NewCommandPrompterFactory = (sp) =>
                {
                    var interactionService = sp.GetRequiredService<IInteractionService>();
                    var prompter = new TestNewCommandPrompter(interactionService);
 
                    prompter.PromptForProjectNameCallback = (_) => projectName;
 
                    prompter.PromptForOutputPathCallback = (_) =>
                        selectedPathFactory(workspace.WorkspaceRoot.FullName);
 
                    return prompter;
                };
 
                options.DotNetCliRunnerFactory = (sp) =>
                {
                    var runner = CreateTestRunnerWithStandardPackages();
                    runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) =>
                    {
                        return (0, version);
                    };
                    runner.NewProjectAsyncCallback = (templateName, pName, outputPath, invocationOptions, ct) =>
                    {
                        capturedOutputPath = outputPath;
                        return 0;
                    };
                    return runner;
                };
            });
            using var provider = services.BuildServiceProvider();
 
            var command = provider.GetRequiredService<RootCommand>();
            var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
 
            var exitCode = await result.InvokeAsync().DefaultTimeout();
 
            Assert.Equal(CliExitCodes.Success, exitCode);
            Assert.NotNull(capturedOutputPath);
            Assert.Equal(expectedPathFactory(workspace.WorkspaceRoot.FullName), capturedOutputPath);
        }
 
        // Trailing separator on a parent folder should still append the project name once.
        await AssertOutputPathAsync(
            workspaceRoot => Path.Combine(workspaceRoot, "source") + Path.DirectorySeparatorChar,
            workspaceRoot => Path.Combine(workspaceRoot, "source", projectName));
 
        // Trailing separator on a folder already named after the project should not double-append.
        await AssertOutputPathAsync(
            workspaceRoot => Path.Combine(workspaceRoot, projectName) + Path.DirectorySeparatorChar,
            workspaceRoot => Path.Combine(workspaceRoot, projectName));
    }
 
    [Fact]
    public async Task NewCommandInExtensionModeAppendsProjectNameToCliTemplateOutputPath()
    {
        const string projectName = "MyFirstApp";
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        DirectoryInfo? capturedTargetDirectory = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
            options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
            options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
            {
                HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
            };
 
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
 
                prompter.PromptForOutputPathCallback = (_) =>
                    Path.Combine(workspace.WorkspaceRoot.FullName, "source");
 
                return prompter;
            };
        });
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (context, cancellationToken) =>
            {
                capturedTargetDirectory = context.TargetDirectory;
                File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.ts"), "// test apphost");
                return Task.FromResult(true);
            }
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"new {KnownTemplateId.TypeScriptEmptyAppHost} --name {projectName} --version 9.2.0 --localhost-tld false");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.NotNull(capturedTargetDirectory);
        var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "source", projectName);
        Assert.Equal(expectedPath, capturedTargetDirectory.FullName);
        Assert.True(File.Exists(Path.Combine(expectedPath, "apphost.ts")));
    }
 
    [Fact]
    public async Task NewCommandInExtensionModeValidatesAdjustedCliTemplateOutputPath()
    {
        const string projectName = "MyFirstApp";
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var selectedParent = workspace.CreateDirectory("source");
        File.WriteAllText(Path.Combine(selectedParent.FullName, "existing.txt"), "existing content");
 
        DirectoryInfo? capturedTargetDirectory = null;
        var selectedParentWasValidated = false;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
            options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
            options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
            {
                HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
            };
 
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
 
                prompter.PromptForOutputPathWithValidatorCallback = (_, validator) =>
                {
                    Assert.NotNull(validator);
                    var validationResult = validator(selectedParent.FullName);
                    selectedParentWasValidated = true;
                    Assert.True(validationResult.Successful, validationResult.Message);
                    return selectedParent.FullName;
                };
 
                return prompter;
            };
        });
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (context, cancellationToken) =>
            {
                capturedTargetDirectory = context.TargetDirectory;
                File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.ts"), "// test apphost");
                return Task.FromResult(true);
            }
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"new {KnownTemplateId.TypeScriptEmptyAppHost} --name {projectName} --version 9.2.0 --localhost-tld false");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(selectedParentWasValidated);
        Assert.NotNull(capturedTargetDirectory);
        var expectedPath = Path.Combine(selectedParent.FullName, projectName);
        Assert.Equal(expectedPath, capturedTargetDirectory.FullName);
        Assert.True(File.Exists(Path.Combine(expectedPath, "apphost.ts")));
    }
 
    [Fact]
    public async Task NewCommandInExtensionModeRetriesFolderPickerAfterProjectSubdirectoryCollision()
    {
        const string projectName = "aspire-starter";
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var collidingParent = workspace.CreateDirectory("colliding-parent");
        var collidingProject = Directory.CreateDirectory(Path.Combine(collidingParent.FullName, projectName));
        File.WriteAllText(Path.Combine(collidingProject.FullName, "existing.txt"), "existing content");
        var validParent = workspace.CreateDirectory("valid-parent");
        var selectedParents = new Queue<string?>([collidingParent.FullName, validParent.FullName]);
        var displayedErrors = new List<string>();
        var promptCount = 0;
        string? capturedOutputPath = null;
 
        var backchannel = new TestExtensionBackchannel
        {
            HasCapabilityAsyncCallback = (capability, _) => Task.FromResult(
                capability is KnownCapabilities.Baseline or KnownCapabilities.FilePickers),
            PromptForFilePathAsyncCallback = (_, _, _) =>
            {
                promptCount++;
                return Task.FromResult(selectedParents.Dequeue());
            },
            DisplayErrorAsyncCallback = error =>
            {
                displayedErrors.Add(error);
                return Task.CompletedTask;
            }
        };
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
            options.ExtensionBackchannelFactory = _ => backchannel;
            options.InteractionServiceFactory = sp =>
            {
                var consoleInteractionService = new ConsoleInteractionService(
                    sp.GetRequiredService<ConsoleEnvironment>(),
                    sp.GetRequiredService<CliExecutionContext>(),
                    sp.GetRequiredService<ICliHostEnvironment>(),
                    sp.GetRequiredService<IProcessPathProvider>(),
                    NullLoggerFactory.Instance,
                    sp.GetRequiredService<ConsoleLogBufferContext>());
 
                return new ExtensionInteractionService(
                    consoleInteractionService,
                    backchannel,
                    extensionPromptEnabled: true,
                    logger: NullLogger<ExtensionInteractionService>.Instance);
            };
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = CreateTestRunnerWithStandardPackages();
                runner.InstallTemplateAsyncCallback = (_, version, _, _, _, _, _) => (0, version);
                runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) =>
                {
                    capturedOutputPath = outputPath;
                    return 0;
                };
                return runner;
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse(
            $"new aspire-starter --name {projectName} --version 9.2.0 --use-redis-cache --test-framework None --suppress-agent-init");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(2, promptCount);
        Assert.Equal(Path.Combine(validParent.FullName, projectName), capturedOutputPath);
        var expectedError = string.Format(
            CultureInfo.CurrentCulture,
            NewCommandStrings.OutputDirectoryNotEmptyInteractive,
            collidingProject.FullName);
        Assert.Equal([expectedError], displayedErrors);
    }
 
    [Fact]
    public async Task NewCommandInExtensionModePromptsBeforeFolderPickerForCliTemplateSubdirectory()
    {
        const string projectName = "MyFirstApp";
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var selectedParent = workspace.CreateDirectory("source");
        DirectoryInfo? capturedTargetDirectory = null;
        var selectionPrompted = false;
        const string expectedPrompt = "Where do you want the project to be created?";
        var expectedSubdirectoryChoice = $"In a subdirectory named '{projectName}' in the selected folder";
        const string expectedDirectChoice = "Directly in the selected folder";
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
            options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp)
            {
                ConfirmCallback = (_, defaultValue) => defaultValue,
                SelectionCallback = (promptText, choices) =>
                {
                    Assert.Equal(expectedPrompt, promptText);
                    Assert.Equal([expectedSubdirectoryChoice, expectedDirectChoice], choices);
                    selectionPrompted = true;
                    return expectedSubdirectoryChoice;
                }
            };
            options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
            {
                HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
            };
 
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
 
                prompter.PromptForOutputPathCallback = (_) =>
                {
                    Assert.True(selectionPrompted);
                    return selectedParent.FullName;
                };
 
                return prompter;
            };
        });
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (context, cancellationToken) =>
            {
                capturedTargetDirectory = context.TargetDirectory;
                File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.ts"), "// test apphost");
                return Task.FromResult(true);
            }
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"new {KnownTemplateId.TypeScriptEmptyAppHost} --name {projectName} --version 9.2.0 --localhost-tld false");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(selectionPrompted);
        Assert.NotNull(capturedTargetDirectory);
        var expectedPath = Path.Combine(selectedParent.FullName, projectName);
        Assert.Equal(expectedPath, capturedTargetDirectory.FullName);
        Assert.True(File.Exists(Path.Combine(expectedPath, "apphost.ts")));
    }
 
    [Fact]
    public async Task NewCommandInExtensionModeUsesSelectedCliTemplateOutputPathWhenSubdirectoryDeclined()
    {
        const string projectName = "MyFirstApp";
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var selectedOutputPath = workspace.CreateDirectory("source");
        DirectoryInfo? capturedTargetDirectory = null;
        var selectionPrompted = false;
        const string expectedPrompt = "Where do you want the project to be created?";
        var expectedSubdirectoryChoice = $"In a subdirectory named '{projectName}' in the selected folder";
        const string expectedDirectChoice = "Directly in the selected folder";
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
            options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp)
            {
                ConfirmCallback = (_, defaultValue) => defaultValue,
                SelectionCallback = (promptText, choices) =>
                {
                    Assert.Equal(expectedPrompt, promptText);
                    Assert.Equal([expectedSubdirectoryChoice, expectedDirectChoice], choices);
                    selectionPrompted = true;
                    return expectedDirectChoice;
                }
            };
            options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
            {
                HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
            };
 
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
 
                prompter.PromptForOutputPathCallback = (_) =>
                    selectedOutputPath.FullName;
 
                return prompter;
            };
        });
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (context, cancellationToken) =>
            {
                capturedTargetDirectory = context.TargetDirectory;
                File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.ts"), "// test apphost");
                return Task.FromResult(true);
            }
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"new {KnownTemplateId.TypeScriptEmptyAppHost} --name {projectName} --version 9.2.0 --localhost-tld false");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(selectionPrompted);
        Assert.NotNull(capturedTargetDirectory);
        Assert.Equal(selectedOutputPath.FullName, capturedTargetDirectory.FullName);
        Assert.True(File.Exists(Path.Combine(selectedOutputPath.FullName, "apphost.ts")));
        Assert.False(File.Exists(Path.Combine(selectedOutputPath.FullName, projectName, "apphost.ts")));
    }
 
    [Fact]
    public async Task NewCommandInExtensionModeDoesNotDoubleAppendProjectNameToCliTemplateOutputPath()
    {
        const string projectName = "MyFirstApp";
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        DirectoryInfo? capturedTargetDirectory = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
            options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
            options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
            {
                HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
            };
 
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
 
                prompter.PromptForOutputPathCallback = (_) =>
                    Path.Combine(workspace.WorkspaceRoot.FullName, "source", projectName);
 
                return prompter;
            };
        });
 
        services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
        {
            ScaffoldAsyncCallback = (context, cancellationToken) =>
            {
                capturedTargetDirectory = context.TargetDirectory;
                File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.ts"), "// test apphost");
                return Task.FromResult(true);
            }
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"new {KnownTemplateId.TypeScriptEmptyAppHost} --name {projectName} --version 9.2.0 --localhost-tld false");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.NotNull(capturedTargetDirectory);
        var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "source", projectName);
        Assert.Equal(expectedPath, capturedTargetDirectory.FullName);
        Assert.True(File.Exists(Path.Combine(expectedPath, "apphost.ts")));
    }
 
    [Fact]
    public async Task NewCommandNonInteractive_SuppressAgentInitTrue_SkipsAgentInit()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = (sp) =>
            {
                var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
                return new CliHostEnvironment(configuration, nonInteractive: true);
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-empty --name TestApp --output ./output --suppress-agent-init");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        // Agent init should not have run — no skill files should exist
        var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md");
        Assert.False(File.Exists(skillPath));
    }
 
    [Fact]
    public async Task NewCommandNonInteractive_SuppressAgentInitFalse_RunsAgentInit()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = (sp) =>
            {
                var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
                return new CliHostEnvironment(configuration, nonInteractive: true);
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-empty --name TestApp --output ./output --suppress-agent-init=false");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        // Agent init should have run — default skill files should exist
        var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md");
        Assert.True(File.Exists(skillPath));
        var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireifySkillName, "SKILL.md");
        Assert.False(File.Exists(aspireifySkillPath));
    }
 
    [Fact]
    public async Task NewCommandNonInteractive_NoSuppressAgentInitOption_DefaultsToRunAgentInit()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliHostEnvironmentFactory = (sp) =>
            {
                var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
                return new CliHostEnvironment(configuration, nonInteractive: true);
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-empty --name TestApp --output ./output");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        // Default is to run agent init
        var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md");
        Assert.True(File.Exists(skillPath));
        var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireifySkillName, "SKILL.md");
        Assert.False(File.Exists(aspireifySkillPath));
    }
 
    [Fact]
    public async Task NewCommandRejectsExplicitOutputToNonEmptyDirectory()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Create a non-empty directory at the output path
        var existingDir = workspace.CreateDirectory("existing-output");
        File.WriteAllText(Path.Combine(existingDir.FullName, "file.txt"), "content");
 
        TestInteractionService? testInteractionService = null;
        var services = CreateServiceCollection(workspace, options =>
        {
            options.InteractionServiceFactory = (sp) =>
            {
                testInteractionService = new TestInteractionService();
                return testInteractionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse($"new aspire-starter --name TestApp --output {existingDir.FullName} --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.FailedToCreateNewProject, exitCode);
        Assert.NotNull(testInteractionService);
        var expectedError = string.Format(CultureInfo.CurrentCulture, NewCommandStrings.OutputDirectoryNotEmptyNonInteractive, existingDir.FullName);
        var e = Assert.Single(testInteractionService.DisplayedErrors);
        Assert.Equal(expectedError, e);
    }
 
    [Fact]
    public async Task NewCommandAllowsExplicitOutputToEmptyDirectory()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Create an empty directory at the output path
        var emptyDir = workspace.CreateDirectory("empty-output");
 
        var services = CreateServiceCollection(workspace);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse($"new aspire-starter --name TestApp --output {emptyDir.FullName} --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task NewCommandDefaultOutputPathUsesUniqueProjectNameWhenDirectoryExists()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Create a non-empty directory matching the default project name (template name)
        var existingDir = workspace.CreateDirectory("aspire-starter");
        File.WriteAllText(Path.Combine(existingDir.FullName, "file.txt"), "content");
 
        string? capturedDefaultPath = null;
        var services = CreateServiceCollection(workspace, options =>
        {
            options.NewCommandPrompterFactory = (sp) =>
            {
                var interactionService = sp.GetRequiredService<IInteractionService>();
                var prompter = new TestNewCommandPrompter(interactionService);
 
                prompter.PromptForOutputPathCallback = (path) =>
                {
                    capturedDefaultPath = path;
                    return path;
                };
 
                return prompter;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal("./aspire-starter-2", capturedDefaultPath);
    }
 
    [Fact]
    public async Task NewCommandRejectsExplicitOutputWithInvalidPathCharacters()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        TestInteractionService? testInteractionService = null;
        var services = CreateServiceCollection(workspace, options =>
        {
            options.InteractionServiceFactory = (sp) =>
            {
                testInteractionService = new TestInteractionService();
                return testInteractionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var invalidPath = "output\0path";
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse($"new aspire-starter --name TestApp --output {invalidPath} --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.FailedToCreateNewProject, exitCode);
        Assert.NotNull(testInteractionService);
        var expectedError = string.Format(CultureInfo.CurrentCulture, NewCommandStrings.OutputPathContainsInvalidCharacters, invalidPath);
        var e = Assert.Single(testInteractionService.DisplayedErrors);
        Assert.Equal(expectedError, e);
    }
 
    [Fact]
    public async Task NewCommandCreatesProjectInCurrentDirectoryWithOutputDot()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Create an empty subdirectory to use as the CLI working directory.
        // Keep options.WorkingDirectory as the workspace root so test infra
        // (.aspire/logs, settings files) doesn't pollute the project dir.
        var projectDir = workspace.CreateDirectory("my-project");
        string? capturedOutputPath = null;
 
        var services = CreateServiceCollection(workspace, options =>
        {
            options.CliExecutionContextFactory = _ =>
            {
                // Use projectDir as the working directory but root the .aspire/*
                // directories under the workspace so test infra doesn't pollute the project dir.
                return TestExecutionContextHelper.CreateExecutionContext(
                    projectDir,
                    identityChannel: PackageChannelNames.Stable);
            };
 
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = CreateTestRunnerWithStandardPackages();
                runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
                {
                    capturedOutputPath = outputPath;
                    return 0;
                };
                return runner;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<NewCommand>();
        var result = command.Parse("new aspire-starter --name TestApp --output . --use-redis-cache --test-framework None");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(projectDir.FullName, capturedOutputPath);
    }
 
    [Fact]
    public void OutputPathValidatorRejectsPathWithInvalidCharacters()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var validator = OutputPathHelper.CreateOutputPathValidator(workspace.WorkspaceRoot.FullName);
 
        var invalidPath = "output\0path";
        var validationResult = validator(invalidPath);
 
        var expectedMessage = string.Format(CultureInfo.CurrentCulture, NewCommandStrings.OutputPathContainsInvalidCharacters, invalidPath);
        Assert.Equal(expectedMessage, validationResult.Message);
    }
 
    [Fact]
    public async Task NewCommandWhenChannelTemplateSearchFailsDisplaysFriendlyError()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var interactionService = new TestInteractionService();
 
        var services = CreateServiceCollection(workspace, options =>
        {
            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 provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("new aspire-starter");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToCreateNewProject, exitCode);
        Assert.Contains(interactionService.DisplayedErrors, e => e.Contains("simulated network failure", StringComparison.Ordinal));
    }
}