Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c36f16a
Add git-based template system spec
Feb 27, 2026
ab8184f
Add aspire template command tree (Phase 1)
Feb 27, 2026
bcc9ba7
Fix TemplateCommand DI resolution ambiguity
Feb 27, 2026
f4d4396
Add git template schema models and scaffolding commands (Phase 2)
Feb 27, 2026
2e8dde7
Simplify new-index: remove publisher prompts
Feb 27, 2026
a52d270
Remove displayName/description from models for now
Feb 27, 2026
f6f1297
Add index resolution, caching, and live commands (Phase 3)
Feb 27, 2026
852c189
Add template application engine (Phase 4)
Feb 27, 2026
091f3b3
Integrate GitTemplateFactory into aspire new (Phase 5)
Feb 27, 2026
900f851
Support local paths in template index sources
Feb 27, 2026
fda67d9
Fix config key format in index source discovery
Feb 27, 2026
06fa720
Phase 6: Add telemetry, error handling, and sample template index
Feb 27, 2026
90e0c87
Fix local template application: copy files directly instead of git clone
Feb 27, 2026
c2d76dc
Add GitHub CLI auto-discovery of aspire-templates repos
Feb 27, 2026
15c2bb2
Fix default ref for discovered template repos
Feb 27, 2026
8fc0d6e
Fix markdown lint: add language to fenced code blocks
Mar 2, 2026
d8ff1a4
Add type-aware variable prompting, template scope, and localization s…
Mar 2, 2026
899bccb
Fix CI: add missing DI registrations for template commands and git te…
Mar 2, 2026
b7256ec
Enable CLI variable binding for git template variables
Mar 2, 2026
fd83356
Prompt for project name and output directory when not provided
Mar 2, 2026
0ca2103
Add postInstructions rendering for git templates
Mar 2, 2026
f21805d
Add aspire template test command for matrix testing
Mar 2, 2026
a51fa40
Enable interactive template selection for template test command
Mar 2, 2026
3165367
Simplify template test output folder naming to {templateName}{index}
Mar 2, 2026
b5b89bc
Randomize ports per test variant in template test command
Mar 2, 2026
78623d0
Remove BaseTemplateSubCommand, inherit BaseCommand directly
Mar 2, 2026
8ffa25c
Update git-templates spec with postInstructions, testValues, template…
Mar 2, 2026
3ae8557
Add comprehensive git template language test suite
Mar 2, 2026
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
12 changes: 12 additions & 0 deletions aspire-template-index.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "https://aka.ms/aspire/template-index-schema/v1",
"version": 1,
"templates": [
{
"name": "aspire-starter",
"path": "templates/aspire-starter",
"language": "csharp",
"tags": ["starter", "web", "api"]
}
]
}
1,426 changes: 1,426 additions & 0 deletions docs/specs/git-templates.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions src/Aspire.Cli/Commands/RootCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ public RootCommand(
DocsCommand docsCommand,
SecretCommand secretCommand,
SdkCommand sdkCommand,
Template.TemplateCommand templateCommand,
SetupCommand setupCommand,
#if DEBUG
RenderCommand renderCommand,
Expand Down Expand Up @@ -237,6 +238,11 @@ public RootCommand(

Subcommands.Add(sdkCommand);

if (featureFlags.IsFeatureEnabled(KnownFeatures.GitTemplatesEnabled, false))
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

Adding the template command group introduces new DI requirements for RootCommand. The CLI host (Program.cs) registers these new command types, but the unit-test service registration helper (tests/Aspire.Cli.Tests/Utils/CliTestHelper) currently mirrors command registrations and will fail to resolve RootCommand unless it is updated to also register the new template command + subcommands. Please update the test DI registrations (or make this dependency optional/lazy) to keep CLI unit tests passing.

Suggested change
if (featureFlags.IsFeatureEnabled(KnownFeatures.GitTemplatesEnabled, false))
if (featureFlags.IsFeatureEnabled(KnownFeatures.GitTemplatesEnabled, false) &&
templateCommand is not null)

Copilot uses AI. Check for mistakes.
{
Subcommands.Add(templateCommand);
}

// Replace the default --help action with grouped help output.
// Add -v as a short alias for --version.
foreach (var option in Options)
Expand Down
46 changes: 46 additions & 0 deletions src/Aspire.Cli/Commands/Template/TemplateCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;
using System.CommandLine.Help;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;

namespace Aspire.Cli.Commands.Template;

internal sealed class TemplateCommand : BaseCommand
{
internal override HelpGroup HelpGroup => HelpGroup.ToolsAndConfiguration;

public TemplateCommand(
TemplateListCommand listCommand,
TemplateSearchCommand searchCommand,
TemplateRefreshCommand refreshCommand,
TemplateNewManifestCommand newManifestCommand,
TemplateNewIndexCommand newIndexCommand,
TemplateTestCommand testCommand,
IFeatures features,
ICliUpdateNotifier updateNotifier,
CliExecutionContext executionContext,
IInteractionService interactionService,
AspireCliTelemetry telemetry)
: base("template", "Manage git-based Aspire templates", features, updateNotifier, executionContext, interactionService, telemetry)
{
Subcommands.Add(listCommand);
Subcommands.Add(searchCommand);
Subcommands.Add(refreshCommand);
Subcommands.Add(newManifestCommand);
Subcommands.Add(newIndexCommand);
Subcommands.Add(testCommand);
}

protected override bool UpdateNotificationsEnabled => false;

protected override Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
new HelpAction().Invoke(parseResult);
return Task.FromResult(ExitCodeConstants.InvalidCommand);
}
}
70 changes: 70 additions & 0 deletions src/Aspire.Cli/Commands/Template/TemplateListCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Templating.Git;
using Aspire.Cli.Utils;
using Spectre.Console;

namespace Aspire.Cli.Commands.Template;

internal sealed class TemplateListCommand(
IGitTemplateIndexService indexService,
IFeatures features,
ICliUpdateNotifier updateNotifier,
CliExecutionContext executionContext,
IInteractionService interactionService,
AspireCliTelemetry telemetry)
: BaseCommand("list", "List available templates from all configured sources", features, updateNotifier, executionContext, interactionService, telemetry)
{
protected override bool UpdateNotificationsEnabled => false;

protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
using var activity = Telemetry.StartDiagnosticActivity("template-list");

IReadOnlyList<ResolvedTemplate> templates;
try
{
templates = await InteractionService.ShowStatusAsync(
":magnifying_glass_tilted_right: Fetching templates...",
() => indexService.GetTemplatesAsync(cancellationToken: cancellationToken));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Telemetry.RecordError("Failed to fetch template list", ex);
InteractionService.DisplayError("Failed to fetch templates. Check your network connection and try again.");
return 1;
}

activity?.AddTag("template.count", templates.Count);

if (templates.Count == 0)
{
InteractionService.DisplayMessage(KnownEmojis.Information, "No templates found. Try 'aspire template refresh' to update the index cache.");
return ExitCodeConstants.Success;
}

var table = new Table();
table.Border(TableBorder.Rounded);
table.AddColumn(new TableColumn("[bold]Name[/]").NoWrap());
table.AddColumn(new TableColumn("[bold]Source[/]"));
table.AddColumn(new TableColumn("[bold]Language[/]"));
table.AddColumn(new TableColumn("[bold]Tags[/]"));

foreach (var t in templates)
{
table.AddRow(
t.Entry.Name.EscapeMarkup(),
t.Source.Name.EscapeMarkup(),
(t.Entry.Language ?? "").EscapeMarkup(),
t.Entry.Tags is { Count: > 0 } ? string.Join(", ", t.Entry.Tags).EscapeMarkup() : "");
}

AnsiConsole.Write(table);
return ExitCodeConstants.Success;
}
}
78 changes: 78 additions & 0 deletions src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;
using System.Text.Json;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Templating.Git;
using Aspire.Cli.Utils;

namespace Aspire.Cli.Commands.Template;

internal sealed class TemplateNewIndexCommand : BaseCommand
{
private static readonly Argument<string?> s_pathArgument = new("path")
{
Description = "Directory to create aspire-template-index.json in (defaults to current directory)",
Arity = ArgumentArity.ZeroOrOne
};

public TemplateNewIndexCommand(
IFeatures features,
ICliUpdateNotifier updateNotifier,
CliExecutionContext executionContext,
IInteractionService interactionService,
AspireCliTelemetry telemetry)
: base("new-index", "Scaffold a new aspire-template-index.json index file", features, updateNotifier, executionContext, interactionService, telemetry)
{
Arguments.Add(s_pathArgument);
}

protected override bool UpdateNotificationsEnabled => false;

protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
var targetDir = parseResult.GetValue(s_pathArgument) ?? Directory.GetCurrentDirectory();
targetDir = Path.GetFullPath(targetDir);

var outputPath = Path.Combine(targetDir, "aspire-template-index.json");

if (File.Exists(outputPath))
{
var overwrite = await InteractionService.ConfirmAsync(
$"aspire-template-index.json already exists in {targetDir}. Overwrite?",
defaultValue: false,
cancellationToken: cancellationToken).ConfigureAwait(false);

if (!overwrite)
{
InteractionService.DisplayMessage(KnownEmojis.Information, "Cancelled.");
return ExitCodeConstants.Success;
}
}

var index = new GitTemplateIndex
{
Schema = "https://aka.ms/aspire/template-index-schema/v1",
Templates =
[
new GitTemplateIndexEntry
{
Name = "my-template",
Path = "templates/my-template"
}
]
};

Directory.CreateDirectory(targetDir);

var json = JsonSerializer.Serialize(index, GitTemplateJsonContext.RelaxedEscaping.GitTemplateIndex);
await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false);

InteractionService.DisplaySuccess($"Created {outputPath}");
InteractionService.DisplayMessage(KnownEmojis.Information, "Edit the file to add your templates, then run 'aspire template new' in each template directory.");
return ExitCodeConstants.Success;
}
}
117 changes: 117 additions & 0 deletions src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;
using System.Text.Json;
using System.Text.RegularExpressions;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Templating.Git;
using Aspire.Cli.Utils;
using Spectre.Console;

namespace Aspire.Cli.Commands.Template;

internal sealed partial class TemplateNewManifestCommand : BaseCommand
{
private static readonly Argument<string?> s_pathArgument = new("path")
{
Description = "Directory to create aspire-template.json in (defaults to current directory)",
Arity = ArgumentArity.ZeroOrOne
};

public TemplateNewManifestCommand(
IFeatures features,
ICliUpdateNotifier updateNotifier,
CliExecutionContext executionContext,
IInteractionService interactionService,
AspireCliTelemetry telemetry)
: base("new", "Scaffold a new aspire-template.json manifest", features, updateNotifier, executionContext, interactionService, telemetry)
{
Arguments.Add(s_pathArgument);
}

protected override bool UpdateNotificationsEnabled => false;

protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
var targetDir = parseResult.GetValue(s_pathArgument) ?? Directory.GetCurrentDirectory();
targetDir = Path.GetFullPath(targetDir);

var outputPath = Path.Combine(targetDir, "aspire-template.json");

if (File.Exists(outputPath))
{
var overwrite = await InteractionService.ConfirmAsync(
$"aspire-template.json already exists in {targetDir}. Overwrite?",
defaultValue: false,
cancellationToken: cancellationToken).ConfigureAwait(false);

if (!overwrite)
{
InteractionService.DisplayMessage(KnownEmojis.Information, "Cancelled.");
return ExitCodeConstants.Success;
}
}

var name = await InteractionService.PromptForStringAsync(
"Template name (kebab-case identifier)",
defaultValue: Path.GetFileName(targetDir)?.ToLowerInvariant(),
validator: value => KebabCasePattern().IsMatch(value)
? ValidationResult.Success()
: ValidationResult.Error("Must be lowercase kebab-case (e.g. my-template)"),
required: true,
cancellationToken: cancellationToken).ConfigureAwait(false);

var canonicalName = ToPascalCase(name);

var manifest = new GitTemplateManifest
{
Schema = "https://aka.ms/aspire/template-schema/v1",
Name = name,
Variables = new Dictionary<string, GitTemplateVariable>
{
["projectName"] = new()
{
Type = "string",
Required = true,
DefaultValue = canonicalName,
Validation = new GitTemplateVariableValidation
{
Pattern = "^[A-Za-z][A-Za-z0-9_.]*$",
}
}
},
Substitutions = new GitTemplateSubstitutions
{
Filenames = new Dictionary<string, string>
{
[canonicalName] = "{{projectName}}"
},
Content = new Dictionary<string, string>
{
[canonicalName] = "{{projectName}}",
[canonicalName.ToLowerInvariant()] = "{{projectName | lowercase}}"
}
}
};

Directory.CreateDirectory(targetDir);

var json = JsonSerializer.Serialize(manifest, GitTemplateJsonContext.RelaxedEscaping.GitTemplateManifest);
await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false);

InteractionService.DisplaySuccess($"Created {outputPath}");
return ExitCodeConstants.Success;
}

private static string ToPascalCase(string kebab)
{
return string.Concat(kebab.Split('-').Select(part =>
part.Length == 0 ? "" : char.ToUpperInvariant(part[0]) + part[1..]));
}

[GeneratedRegex("^[a-z][a-z0-9]*(-[a-z0-9]+)*$")]
private static partial Regex KebabCasePattern();
}
48 changes: 48 additions & 0 deletions src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Templating.Git;
using Aspire.Cli.Utils;

namespace Aspire.Cli.Commands.Template;

internal sealed class TemplateRefreshCommand(
IGitTemplateIndexService indexService,
IFeatures features,
ICliUpdateNotifier updateNotifier,
CliExecutionContext executionContext,
IInteractionService interactionService,
AspireCliTelemetry telemetry)
: BaseCommand("refresh", "Force refresh the template index cache", features, updateNotifier, executionContext, interactionService, telemetry)
{
protected override bool UpdateNotificationsEnabled => false;

protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
using var activity = Telemetry.StartDiagnosticActivity("template-refresh");

try
{
await InteractionService.ShowStatusAsync(
":counterclockwise_arrows_button: Refreshing template index cache...",
async () =>
{
await indexService.RefreshAsync(cancellationToken);
return 0;
});
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Telemetry.RecordError("Failed to refresh template cache", ex);
InteractionService.DisplayError("Failed to refresh template cache. Check your network connection and try again.");
return 1;
}

InteractionService.DisplaySuccess("Template index cache refreshed.");
return ExitCodeConstants.Success;
}
}
Loading