Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/Aspire.Cli/Commands/NewCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -243,18 +243,22 @@ static string FormatPackageLabel((NuGetPackage Package, PackageChannel Channel)

public virtual async Task<string> PromptForOutputPath(string path, CancellationToken cancellationToken)
{
// Escape markup characters in the path to prevent Spectre.Console from trying to parse them as markup
// when displaying it as the default value in the prompt
return await interactionService.PromptForStringAsync(
NewCommandStrings.EnterTheOutputPath,
defaultValue: path,
defaultValue: path.EscapeMarkup(),
cancellationToken: cancellationToken
);
}

public virtual async Task<string> PromptForProjectNameAsync(string defaultName, CancellationToken cancellationToken)
{
// Escape markup characters in the default name to prevent Spectre.Console from trying to parse them as markup
// when displaying it as the default value in the prompt
return await interactionService.PromptForStringAsync(
NewCommandStrings.EnterTheProjectName,
defaultValue: defaultName,
defaultValue: defaultName.EscapeMarkup(),
validator: name => ProjectNameValidator.IsProjectNameValid(name)
? ValidationResult.Success()
: ValidationResult.Error(NewCommandStrings.InvalidProjectName),
Expand Down
12 changes: 6 additions & 6 deletions src/Aspire.Cli/Commands/PipelineCommandBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -746,13 +746,13 @@ private async Task HandlePromptActivityAsync(PublishingActivity activity, IAppHo
{
InputType.Text => await InteractionService.PromptForStringAsync(
promptText,
defaultValue: input.Value,
defaultValue: input.Value?.EscapeMarkup(),
required: input.Required,
cancellationToken: cancellationToken),

InputType.SecretText => await InteractionService.PromptForStringAsync(
promptText,
defaultValue: input.Value,
defaultValue: input.Value?.EscapeMarkup(),
isSecret: true,
required: input.Required,
cancellationToken: cancellationToken),
Expand All @@ -763,15 +763,15 @@ private async Task HandlePromptActivityAsync(PublishingActivity activity, IAppHo

InputType.Number => await HandleNumberInputAsync(input, promptText, cancellationToken),

_ => await InteractionService.PromptForStringAsync(promptText, defaultValue: input.Value, required: input.Required, cancellationToken: cancellationToken)
_ => await InteractionService.PromptForStringAsync(promptText, defaultValue: input.Value?.EscapeMarkup(), required: input.Required, cancellationToken: cancellationToken)
};
}

private async Task<string?> HandleSelectInputAsync(PublishingPromptInput input, string promptText, CancellationToken cancellationToken)
{
if (input.Options is null || input.Options.Count == 0)
{
return await InteractionService.PromptForStringAsync(promptText, defaultValue: input.Value, required: input.Required, cancellationToken: cancellationToken);
return await InteractionService.PromptForStringAsync(promptText, defaultValue: input.Value?.EscapeMarkup(), required: input.Required, cancellationToken: cancellationToken);
}

// If AllowCustomChoice is enabled then add an "Other" option to the list.
Expand All @@ -793,7 +793,7 @@ private async Task HandlePromptActivityAsync(PublishingActivity activity, IAppHo

if (value == CustomChoiceValue)
{
return await InteractionService.PromptForStringAsync(promptText, defaultValue: input.Value, required: input.Required, cancellationToken: cancellationToken);
return await InteractionService.PromptForStringAsync(promptText, defaultValue: input.Value?.EscapeMarkup(), required: input.Required, cancellationToken: cancellationToken);
}

AnsiConsole.MarkupLine($"{promptText} {displayText.EscapeMarkup()}");
Expand All @@ -815,7 +815,7 @@ static ValidationResult Validator(string value)

return await InteractionService.PromptForStringAsync(
promptText,
defaultValue: input.Value,
defaultValue: input.Value?.EscapeMarkup(),
validator: Validator,
required: input.Required,
cancellationToken: cancellationToken);
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Projects/ProjectUpdater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ public async Task<ProjectUpdateResult> UpdateProjectAsync(FileInfo projectFile,

var selectedPathForNewNuGetConfigFile = await interactionService.PromptForStringAsync(
promptText: UpdateCommandStrings.WhichDirectoryNuGetConfigPrompt,
defaultValue: recommendedNuGetConfigFileDirectory,
defaultValue: recommendedNuGetConfigFileDirectory.EscapeMarkup(),
validator: null,
isSecret: false,
required: true,
Expand Down
3 changes: 2 additions & 1 deletion src/Aspire.Cli/Templating/DotNetTemplateFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using Aspire.Cli.Utils;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
using Semver;
using Spectre.Console;

namespace Aspire.Cli.Templating;

Expand Down Expand Up @@ -448,7 +449,7 @@ private async Task<TemplateResult> ApplyTemplateAsync(CallbackTemplate template,
// working directory, create one in the newly created project's output directory.
await PromptToCreateOrUpdateNuGetConfigAsync(selectedTemplateDetails.Channel, outputPath, cancellationToken);

interactionService.DisplaySuccess(string.Format(CultureInfo.CurrentCulture, TemplatingStrings.ProjectCreatedSuccessfully, outputPath));
interactionService.DisplaySuccess(string.Format(CultureInfo.CurrentCulture, TemplatingStrings.ProjectCreatedSuccessfully, outputPath.EscapeMarkup()));

return new TemplateResult(ExitCodeConstants.Success, outputPath);
}
Expand Down
70 changes: 70 additions & 0 deletions tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,76 @@ public async Task NewCommandPromptsForTemplateVersionBeforeTemplateOptions()
$"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.Create(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
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
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states "The path parameter passed to the callback contains the unescaped markup characters", but this is misleading. The PromptForOutputPath method receives the unescaped path, escapes it internally, and then passes the escaped version to the interaction service. The callback captures the unescaped path because it's called with the original parameter before escaping happens inside the implementation.

Suggested change
// The path passed to this callback is the unescaped version
// The path parameter passed to this callback is the unescaped version;
// escaping is handled internally by PromptForOutputPath before passing the value to the interaction service.

Copilot uses AI. Check for mistakes.
prompter.PromptForOutputPathCallback = (path) =>
{
capturedOutputPathDefault = path;
// Return the path as-is - the escaping is handled internally by PromptForOutputPath
return path;
};

return prompter;
};

options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, 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;
};
});
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().WaitAsync(CliTestConstants.DefaultTimeout);
Assert.Equal(0, exitCode);

// Verify that the default output path was derived from the project name with markup characters
// The path parameter passed to the callback contains the unescaped markup characters
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states "The path parameter passed to the callback contains the unescaped markup characters", but this is misleading. The comment should clarify that the callback receives the original unescaped path parameter, which is then escaped internally before being displayed as a default value in the prompt.

Suggested change
// The path parameter passed to the callback contains the unescaped markup characters
// The callback receives the original unescaped path parameter; escaping is performed internally before displaying as the default value in the prompt.

Copilot uses AI. Check for mistakes.
var expectedPath = $"./[27;5;13~";
Assert.Equal(expectedPath, capturedOutputPathDefault);
}
}

internal sealed class TestNewCommandPrompter(IInteractionService interactionService) : NewCommandPrompter(interactionService)
Expand Down
Loading