Skip to content
Closed
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
2 changes: 1 addition & 1 deletion docs/specs/cli-config-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ aspire new

Only `templateSources` participates in this runtime overlay. Other remote settings retain their existing allowlisted project-materialization behavior. Project-level `templateSources` are not used. Sources must satisfy the same complete-definition validation as global template discovery; malformed or inaccessible init configuration fails explicitly. Template repository discovery failures retain the normal per-source warnings.

The optional `displayName` supplies the terminal selector's source heading. Missing or blank values use the alias; aliases remain the source and group identity even when headings match.
The optional `displayName` supplies the friendly name in the terminal selector's source heading. Missing or blank values use the alias. Git headings also show the configured repository and ref (`default branch` when omitted); directory headings show the local path instead. Aliases remain the source and group identity even when headings match.

Merge source names case-insensitively with global `templateSources`. The user's entire global definition wins on a collision: do not retain an inherited `repository`, `ref`, `path`, or `displayName` when the user replaces that source with a local directory or a different repository. Non-colliding global and inherited sources are both searchable and listed, including their branch refs.

Expand Down
7 changes: 7 additions & 0 deletions docs/specs/git-templating.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ and control characters are rejected. Set an existing source's heading with
`aspire config set templateSources.company.displayName "Company templates" --global`.
The alias remains the lookup key and group identity, so identical display names do
not merge sources or alter explicit invocations, warnings, or flat-client labels.
Git source headings append the configured repository and ref, for example
`Company templates (https://github.com/example/templates @ release/templates)`.
An omitted ref is shown as `default branch`, not a guessed branch name or the
resolved commit. Directory sources append their local path instead, for example
`Company templates (/work/templates)`. Configured source headings are green;
built-in headings keep their default styling. Headings are escaped as literal text and
cropped to the available terminal-cell width, just like template labels.
Global overrides replace the entire inherited definition, including `displayName`;
inherited values are never saved to global or generated-project configuration.

Expand Down
18 changes: 17 additions & 1 deletion src/Aspire.Cli/Commands/NewCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -394,10 +394,26 @@ private static void AddConfiguredTemplateChoices(
var label = GetUniqueTemplateLabel(
$"{result.Template.Description} ({result.Template.Name}) - {result.SourceName}", labels);
choices.Add(new(label, result.Template, true,
new SelectionChoiceGroup($"source:{result.SourceName}", result.SourceDisplayName ?? result.SourceName)));
CreateTemplateSourceGroup(result)));
}
}

internal static SelectionChoiceGroup CreateTemplateSourceGroup(ConfiguredTemplate template)
{
var name = template.SourceDisplayName ?? template.SourceName;
var source = template.Source;
// Use the configured ref, not the acquired commit: an omitted ref means the
// repository's default branch. Configuration validation rejects embedded credentials.
var location = source.Repository is null
? source.Path
: $"{source.Repository} @ {source.RepositoryRef ?? ConfigSourceStrings.DefaultBranch}";

return new($"source:{template.SourceName}", $"{name} ({location})")
{
Style = new Style(Color.Green)
};
}

internal static string FormatTemplateDisplayLabel(ITemplate template) =>
string.IsNullOrWhiteSpace(template.SearchDescription)
? template.Description
Expand Down
14 changes: 11 additions & 3 deletions src/Aspire.Cli/Interaction/ConsoleInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,15 @@ private async Task<T> PromptForSelectionCoreAsync<T>(string promptText, IEnumera
// two more cells for grouped children. Its converter also controls
// search, so only the visible prefix remains searchable after cropping.
// https://github.com/spectreconsole/spectre.console/blob/0.57.2/src/Spectre.Console/Prompts/SelectionPrompt.cs
.UseConverter(item => FormatSelectionLabel(item.Label, MessageConsole.Profile.Width - item.Indent - 3))
.UseConverter(item =>
{
var label = FormatSelectionLabel(item.Label, MessageConsole.Profile.Width - item.Indent - 3);
// Apply the trusted presentation style after cropping and escaping the
// source label so source-provided markup cannot change its appearance.
return item is SelectionPromptGroup { Style: { } style }
? $"[{style.ToMarkup()}]{label}[/]"
: label;
})
.PageSize(10)
.EnableSearch();
prompt.Mode = SelectionMode.Leaf;
Expand All @@ -375,7 +383,7 @@ private async Task<T> PromptForSelectionCoreAsync<T>(string promptText, IEnumera
{
// Only create headers for populated groups. An empty Spectre group is
// a leaf and would otherwise become a selectable value.
header = prompt.AddChoice(new SelectionPromptGroup(group.Label.EscapeMarkup()));
header = prompt.AddChoice(new SelectionPromptGroup(group.Label.EscapeMarkup(), group.Style));
groups.Add(group.Id, header);
}
header.AddChild(item);
Expand Down Expand Up @@ -948,5 +956,5 @@ internal IReadOnlyList<T> MatchChoicesOrThrow<T>(string value, PromptBinding<str

private abstract record SelectionPromptItem(string Label, int Indent);
private sealed record SelectionPromptValue<T>(T Value, string Text, bool Grouped) : SelectionPromptItem(Text, Grouped ? 2 : 0) where T : notnull;
private sealed record SelectionPromptGroup(string Text) : SelectionPromptItem(Text, 0);
private sealed record SelectionPromptGroup(string Text, Style? Style) : SelectionPromptItem(Text, 0);
}
7 changes: 6 additions & 1 deletion src/Aspire.Cli/Interaction/SelectionChoiceGroup.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Spectre.Console;

namespace Aspire.Cli.Interaction;

internal sealed record SelectionChoiceGroup(string Id, string Label);
internal sealed record SelectionChoiceGroup(string Id, string Label)
{
public Style? Style { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public async Task<IReadOnlyList<ConfiguredTemplate>> SearchAsync(string query, C
.Where(template => template.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
template.Description.Contains(query, StringComparison.OrdinalIgnoreCase) ||
template.SearchDescription?.Contains(query, StringComparison.OrdinalIgnoreCase) is true)
.Select(template => new ConfiguredTemplate(name, template, configuredSource.DisplayName)));
.Select(template => new ConfiguredTemplate(name, template, configuredSource.DisplayName, source)));
}
catch (Exception ex) when (ex is TemplateDiscoveryException or IOException or UnauthorizedAccessException or ArgumentException)
{
Expand All @@ -110,4 +110,4 @@ private void Warn(string source, string message) =>
CultureInfo.CurrentCulture, NewCommandStrings.TemplateSourceSearchFailed, source, message));
}

internal sealed record ConfiguredTemplate(string SourceName, ITemplate Template, string? SourceDisplayName);
internal sealed record ConfiguredTemplate(string SourceName, ITemplate Template, string? SourceDisplayName, TemplateSource Source);
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,52 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging.Abstractions;
using Spectre.Console;
using RootCommand = Aspire.Cli.Commands.RootCommand;

namespace Aspire.Cli.Tests.Commands;

public partial class NewCommandLocalTemplateTests
{
[Theory]
[InlineData(null, null)]
[InlineData("release/templates", null)]
[InlineData("refs/heads/release/templates", "Company [templates]")]
[InlineData(TestGitCommandRunner.DefaultCommitSha, "Company [templates]")]
public async Task ConfiguredGitSources_PickerShowsConfiguredRepositoryAndRef(string? reference, string? displayName)
{
const string repository = "https://example.test/templates.git";
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var interaction = new TestInteractionService
{
PromptForSelectionCallback = (_, choices, _, _) => choices.Cast<object>().Last()
};
interaction.SetupStringPromptResponse("Hello");
var git = TestGitCommandRunner.CreateForTemplateCheckout((_, destination, _, _) =>
CreateClonedSearchTemplateAsync(workspace, destination, "Remote"));
var services = CreateServices(workspace, interaction, interactive: true);
services.AddSingleton<IGitCommandRunner>(git);
using var provider = services.BuildServiceProvider();
await WriteConfiguredSourcesAsync(provider, new()
{
["company"] = new { repository, @ref = reference, displayName }
});

var exitCode = await provider.GetRequiredService<RootCommand>()
.Parse("new --name Demo --output out --suppress-agent-init").InvokeAsync().DefaultTimeout();

Assert.Equal(CliExitCodes.Success, exitCode);
var choice = Assert.Single(interaction.GroupedSelectionPromptCalls)[^1];
Assert.Equal(new SelectionChoiceGroup("source:company",
$"{displayName ?? "company"} ({repository} @ {reference ?? ConfigSourceStrings.DefaultBranch})")
{
Style = new Style(Color.Green)
}, choice.Group);
Assert.Equal("Sample template", choice.DisplayLabel);
Assert.Equal("Remote: Hello", await File.ReadAllTextAsync(Path.Combine(workspace.Path, "out", "Demo.txt")));
Assert.Single(GetGitFetches(git));
}

[Fact]
public async Task ConfiguredGitSources_WarnsOnEachInvocationWithCachedTemplates()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Spectre.Console;
using RootCommand = Aspire.Cli.Commands.RootCommand;

namespace Aspire.Cli.Tests.Commands;
Expand Down Expand Up @@ -107,6 +108,7 @@ await Assert.ThrowsAsync<JsonException>(() =>
[InlineData("""{ "repository": "http://example.test/templates" }""")]
[InlineData("""{ "repository": "git@example.test:templates" }""")]
[InlineData("""{ "repository": "https://token@example.test/templates" }""")]
[InlineData("""{ "repository": "ssh://git:password@example.test/templates" }""")]
[InlineData("""{ "repository": "https://example.test/templates?q=search" }""")]
[InlineData("""{ "repository": "https://example.test/templates#main" }""")]
[InlineData("""{ "repository": "https://example.test/templates", "ref": 42 }""")]
Expand Down Expand Up @@ -335,8 +337,8 @@ public async Task ConfiguredSources_SelectsExactTemplateWithDuplicateNamesAndPro
Assert.Equal(1, selectionCount);
var groupedChoices = Assert.Single(interaction.GroupedSelectionPromptCalls);
Assert.Equal(
[new SelectionChoiceGroup("source:first-source", "Company [templates]"),
new SelectionChoiceGroup("source:second-source", "Company [templates]")],
[new SelectionChoiceGroup("source:first-source", $"Company [templates] ({Path.Combine(workspace.Path, "first")})") { Style = new Style(Color.Green) },
new SelectionChoiceGroup("source:second-source", $"Company [templates] ({Path.Combine(workspace.Path, "second")})") { Style = new Style(Color.Green) }],
groupedChoices.TakeLast(2).Select(choice => choice.Group));
Assert.Equal(["Same template", "Same template"], groupedChoices.TakeLast(2).Select(choice => choice.DisplayLabel));
Assert.All(groupedChoices.SkipLast(2), choice =>
Expand Down Expand Up @@ -371,6 +373,8 @@ public async Task ConfiguredSources_DescriptionAppearsInPickerWithoutSeparateSea
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal(1, selectionCount);
Assert.Equal("Sample (Observable services)", Assert.Single(interaction.GroupedSelectionPromptCalls)[^1].DisplayLabel);
Assert.Equal(new SelectionChoiceGroup("source:company", $"company ({Path.Combine(workspace.Path, "template")})") { Style = new Style(Color.Green) },
Assert.Single(interaction.GroupedSelectionPromptCalls)[^1].Group);
Assert.Empty(interaction.DisplayedMessages);
Assert.Equal(["Greeting text"], interaction.StringPromptCalls.Select(call => call.PromptText));
Assert.Equal([true], interaction.StringPromptLogResponses);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using Aspire.Cli.Tests.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console;
using RootCommand = Aspire.Cli.Commands.RootCommand;

namespace Aspire.Cli.Tests.Commands;
Expand Down Expand Up @@ -162,7 +163,8 @@ public async Task InitConfigSources_SearchAndGenerateWithGlobalSourcesWithoutPer
Assert.Equal(1, selectionCount);
Assert.Equal(1, git.Calls);
Assert.Equal(
[new SelectionChoiceGroup("source:enterprise", "Team [templates]"), new SelectionChoiceGroup("source:personal", "Team [templates]")],
[new SelectionChoiceGroup("source:enterprise", $"Team [templates] ({Path.Combine(workspace.Path, "enterprise")})") { Style = new Style(Color.Green) },
new SelectionChoiceGroup("source:personal", $"Team [templates] ({Path.Combine(workspace.Path, "personal")})") { Style = new Style(Color.Green) }],
Assert.Single(interaction.GroupedSelectionPromptCalls).TakeLast(2).Select(choice => choice.Group));
Assert.Equal(originalGlobal, await File.ReadAllTextAsync(globalPath));
var projectConfig = Path.Combine(workspace.Path, "out", AspireConfigFile.FileName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,32 @@ public async Task PromptForSelectionAsync_GroupedLabelsFitWidthAndNormalizeMetad
await Verify(output.ToString()).ScrubLinesWithReplace(line => line.TrimEnd());
}

[Theory]
[InlineData(false, 120)]
[InlineData(true, 120)]
[InlineData(false, 40)]
[InlineData(true, 40)]
public async Task PromptForSelectionAsync_TemplateSourceHeadingsAreLiteralAndFitWidth(bool gitSource, int width)
{
var output = new StringBuilder();
var console = CreateSelectionConsole(output, [new('\r', ConsoleKey.Enter, false, false, false)]);
console.Profile.Width = width;
console.Profile.Capabilities.ColorSystem = ColorSystem.Standard;
var interaction = CreateInteractionService(console);
var template = CreateSearchableTemplate("first", null);
var source = gitSource
? new TemplateSource(".", "https://example.test/organization/[templates]/long-location", "release/[blue]")
: new TemplateSource("/work/[templates]/long-directory-location", null, null);
var configured = new ConfiguredTemplate("source", template, "Team [templates]", source);

var selected = await interaction.PromptForSelectionAsync("Select:", new[] { template }, item => item.Name,
_ => NewCommand.CreateTemplateSourceGroup(configured),
item => NewCommand.FormatTemplateDisplayLabel(item).EscapeMarkup());

Assert.Same(template, selected);
await Verify(output.ToString()).UseParameters(gitSource, width).ScrubLinesWithReplace(line => line.TrimEnd());
}

[Theory]
[InlineData(6)]
[InlineData(10)]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[?25lSelect:

 Team [templates] (/work/[templates]/long-directory-location)
 > Same

(Type to search)
[?25hSelect: Same
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[?25lSelect:

 Team [templates] (/work/[templates]/…
 > Same

(Type to search)
[?25hSelect: Same
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[?25lSelect:

 Team [templates] (https://example.test/organization/[templates]/long-location @ release/[blue])
 > Same

(Type to search)
[?25hSelect: Same
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[?25lSelect:

 Team [templates] (https://example.te…
 > Same

(Type to search)
[?25hSelect: Same
Loading