diff --git a/docs/usage.md b/docs/usage.md index e547d7130..685f29efc 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -170,7 +170,7 @@ The pack ships two styles of WinUI app. **XAML** templates define the UI in mark > **Reactor templates are experimental.** They reference the prerelease `Microsoft.UI.Reactor` packages, whose APIs can change or be removed in a future release. `winapp new` marks them **(Experimental)** in `--list` and in the interactive picker, sets `"Experimental": true` in `--json`, and prints a warning after scaffolding one. They are never chosen as the default template. Reactor also requires the **.NET 10 SDK or newer**; on an older SDK `winapp new` fails up front with the version it needs rather than scaffolding a project you can't build. -Each template's canonical short name is the first alias `dotnet new` lists for it; any listed alias (e.g. `winui3`, `wasdk-single`, `winui-reactor`) is also accepted. When run inside an existing WinUI project, `dotnet new` also surfaces **item** templates (e.g. a blank page), which `winapp new` adds into the current project rather than creating a new one. +Each template's canonical short name is the first alias `dotnet new` lists for it; any listed alias (e.g. `winui3`, `wasdk-single`, `winui-reactor`) is also accepted. If another installed template pack registers the same short name (for example, `reactor` from `Microsoft.UI.Reactor.Templates`), `winapp new` still creates the WinUI pack's template by using one of its other aliases. If another pack registers every alias the template has, `winapp new` stops and names that pack. Remove it with `dotnet new uninstall ` and re-run. When run inside an existing WinUI project, `dotnet new` also surfaces **item** templates (e.g. a blank page), which `winapp new` adds into the current project rather than creating a new one. **Template pack versioning:** diff --git a/src/winapp-CLI/WinApp.Cli.Tests/NewCommandHandlerTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/NewCommandHandlerTests.cs index 0f9df3db2..514d50420 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/NewCommandHandlerTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/NewCommandHandlerTests.cs @@ -993,6 +993,91 @@ public async Task Handler_List_ExcludesTemplatesNotOwnedByTheMicrosoftPack() "A template from another installed pack must be filtered out of the catalog."); } + /// + /// Scripts the WinUI pack plus the standalone Reactor pack, whose template registers + /// . Only the WinUI pack's templates match dotnet new list winui. + /// + private void ScriptWithStandaloneReactorPack(string foreignAliases) + { + (string Name, string Short, string Lang, string Type, string Author, string Tags)[] templates = + [ + ("WinUI Blank App", "winui,winui3,wasdk-single", "[C#]", "project", "Microsoft", "Windows/WinUI/Desktop/XAML"), + ("Reactor Blank App (Experimental)", "reactor,reactor-blank,winui-reactor", "[C#]", "project", "Microsoft", "Windows/WinUI/Desktop/Reactor/Experimental"), + ]; + var list = BuildListTable(templates); + var uninstall = "Currently installed items:\n" + + " Microsoft.UI.Reactor.Templates\n" + + " Version: 0.1.0\n" + + " Templates:\n" + + $" Microsoft.UI.Reactor App ({foreignAliases}) C#\n" + + " Uninstall Command:\n" + + " dotnet new uninstall Microsoft.UI.Reactor.Templates\n" + + BuildUninstallOutput("0.0.6-alpha", templates).Replace("Currently installed items:\n", string.Empty, StringComparison.Ordinal); + _dotnet.RunDotnetArgumentListHandler = args => + { + if (args.Count >= 1 && args[0] == "--version") + { + return (0, "10.0.100\n", string.Empty); + } + if (args.Count >= 2 && args[0] == "new" && args[1] == "uninstall") + { + return (0, uninstall, string.Empty); + } + if (args.Count >= 2 && args[0] == "new" && args[1] == "list") + { + return (0, list, string.Empty); + } + if (args.Count >= 2 && args[0] == "new" && args[1] == "update") + { + return (0, "All template packages are up-to-date.", string.Empty); + } + if (args.Count >= 2 && args[0] == "new" && args[1] == "install") + { + return (0, "ok", string.Empty); + } + return (0, "The template was created successfully.", string.Empty); + }; + } + + [TestMethod] + public async Task Handler_ShortNameAlsoRegisteredByAnotherPack_ScaffoldsWithUniqueAlias() + { + // `dotnet new reactor` is ambiguous when Microsoft.UI.Reactor.Templates is also installed, so + // winapp must invoke the WinUI pack's template by an alias only that pack owns (#948). + ScriptWithStandaloneReactorPack("reactor"); + var command = GetRequiredService(); + + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["--use-defaults", "--json", "--template", "reactor", "--name", "MyApp"]); + + Assert.AreEqual(NewCommand.ExitSuccess, exitCode, TestAnsiConsole.Output); + var json = ParseJson(TestAnsiConsole.Output); + Assert.AreEqual("reactor", json.GetProperty("Template").GetString(), + "The reported template is the one the user picked, not the alias used to invoke it."); + + var scaffold = ScaffoldInvocation(); + Assert.IsNotNull(scaffold); + Assert.AreEqual("reactor-blank", scaffold[1], "dotnet new must receive an alias no other installed pack registers."); + } + + [TestMethod] + public async Task Handler_EveryAliasRegisteredByAnotherPack_FailsNamingThePack() + { + ScriptWithStandaloneReactorPack("reactor,reactor-blank,winui-reactor"); + var command = GetRequiredService(); + + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["--use-defaults", "--json", "--template", "reactor", "--name", "MyApp"]); + + Assert.AreEqual(NewCommand.ExitTemplatePackFailed, exitCode); + var json = ParseJson(TestAnsiConsole.Output); + Assert.IsFalse(json.GetProperty("Created").GetBoolean()); + var error = json.GetProperty("Error").GetString() ?? string.Empty; + StringAssert.Contains(error, "Microsoft.UI.Reactor.Templates"); + StringAssert.Contains(error, "dotnet new uninstall Microsoft.UI.Reactor.Templates"); + Assert.IsNull(ScaffoldInvocation(), "No scaffold may run when dotnet new can't select the template."); + } + [TestMethod] public async Task Handler_AppTemplate_PrintsWinappRunNextStep() diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WinUiTemplateCatalogTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WinUiTemplateCatalogTests.cs index 9711a648a..da1fc7d3b 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/WinUiTemplateCatalogTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/WinUiTemplateCatalogTests.cs @@ -364,6 +364,106 @@ public void RestrictToPack_NoPackRows_KeepsNothing() Assert.AreEqual(0, WinUiTemplateCatalog.RestrictToPack(listed, []).Count); } + private const string ReactorPackageId = "Microsoft.UI.Reactor.Templates"; + + private const string ConflictingUninstallOutput = + "Currently installed items:\n" + + " Microsoft.UI.Reactor.Templates\n" + + " Version: 0.1.0\n" + + " Details:\n" + + " Author: Microsoft\n" + + " Templates:\n" + + " Microsoft.UI.Reactor App (reactor) C#\n" + + " Uninstall Command:\n" + + " dotnet new uninstall Microsoft.UI.Reactor.Templates\n" + + " Microsoft.WindowsAppSDK.WinUI.CSharp.Templates\n" + + " Version: 0.0.6-alpha\n" + + " Details:\n" + + " Author: Microsoft\n" + + " Templates:\n" + + " WinUI Blank App (winui,winui3,wasdk-single) C#\n" + + " Reactor Blank App (Experimental) (reactor,reactor-blank,winui-reactor) C#\n" + + " Uninstall Command:\n" + + " dotnet new uninstall Microsoft.WindowsAppSDK.WinUI.CSharp.Templates\n" + + " Contoso.NoTemplates\n" + + " Version: 1.0.0\n" + + " Uninstall Command:\n" + + " dotnet new uninstall Contoso.NoTemplates\n"; + + private static readonly string[] ExpectedInstalledPackIds = [ReactorPackageId, PackageId]; + private static readonly string[] ExpectedReactorBlankAliases = ["reactor", "reactor-blank", "winui-reactor"]; + private static readonly string[] ExpectedSharedReactorAlias = ["reactor"]; + private static readonly string[] ExpectedConflictingPacks = [ReactorPackageId]; + + [TestMethod] + public void ParseInstalledPacks_ReturnsEveryPackWithItsTemplates() + { + var packs = WinUiTemplateCatalog.ParseInstalledPacks(ConflictingUninstallOutput); + + CollectionAssert.AreEqual(ExpectedInstalledPackIds, packs.Select(p => p.PackageId).ToArray(), + "A pack without a Templates block contributes no aliases and is omitted."); + CollectionAssert.AreEqual(ExpectedReactorBlankAliases, packs[1].Templates[1].Aliases.ToArray()); + Assert.AreEqual("Microsoft.UI.Reactor App", packs[0].Templates[0].DisplayName); + } + + [TestMethod] + public void ParsePackTemplates_OtherPacksBeforeIt_StillReturnsOnlyItsTemplates() + { + var rows = WinUiTemplateCatalog.ParsePackTemplates(ConflictingUninstallOutput, PackageId); + + Assert.AreEqual(2, rows.Count); + Assert.IsFalse(rows.Any(r => r.DisplayName == "Microsoft.UI.Reactor App")); + } + + [TestMethod] + public void MarkSharedAliases_AliasAlsoInAnotherPack_ScaffoldsWithFirstUniqueAlias() + { + // `dotnet new reactor` is ambiguous when the standalone Reactor pack is installed too, so the + // WinUI pack's template must be scaffolded by an alias only it owns. + var entries = new List + { + Entry("WinUI Blank App", "winui,winui3,wasdk-single"), + Entry("Reactor Blank App (Experimental)", "reactor,reactor-blank,winui-reactor"), + }; + + var marked = WinUiTemplateCatalog.MarkSharedAliases( + entries, WinUiTemplateCatalog.ParseInstalledPacks(ConflictingUninstallOutput), PackageId); + + Assert.AreEqual("winui", marked[0].ScaffoldShortName); + Assert.AreEqual(0, marked[0].ConflictingPackages.Count); + + Assert.AreEqual("reactor", marked[1].ShortName, "The canonical short name shown to the user is unchanged."); + Assert.AreEqual("reactor-blank", marked[1].ScaffoldShortName); + CollectionAssert.AreEqual(ExpectedSharedReactorAlias, marked[1].SharedAliases.ToArray()); + CollectionAssert.AreEqual(ExpectedConflictingPacks, marked[1].ConflictingPackages.ToArray()); + } + + [TestMethod] + public void MarkSharedAliases_EveryAliasShared_HasNoScaffoldShortName() + { + var output = ConflictingUninstallOutput.Replace( + "Microsoft.UI.Reactor App (reactor) C#", + "Microsoft.UI.Reactor App (reactor,reactor-blank,winui-reactor) C#", + StringComparison.Ordinal); + var entries = new List { Entry("Reactor Blank App (Experimental)", "reactor,reactor-blank,winui-reactor") }; + + var marked = WinUiTemplateCatalog.MarkSharedAliases(entries, WinUiTemplateCatalog.ParseInstalledPacks(output), PackageId); + + Assert.IsNull(marked[0].ScaffoldShortName); + CollectionAssert.AreEqual(ExpectedConflictingPacks, marked[0].ConflictingPackages.ToArray()); + } + + [TestMethod] + public void MarkSharedAliases_NoOtherPacks_LeavesEntriesUnchanged() + { + var entries = new List { Entry("WinUI Blank App", "winui,winui3,wasdk-single") }; + + var marked = WinUiTemplateCatalog.MarkSharedAliases(entries, WinUiTemplateCatalog.ParseInstalledPacks(UninstallOutput), PackageId); + + Assert.AreEqual("winui", marked[0].ScaffoldShortName); + Assert.AreEqual(0, marked[0].SharedAliases.Count); + } + [TestMethod] public void DeriveTfmOption_TemplateNotInThisCache_ReturnsNotFound() { diff --git a/src/winapp-CLI/WinApp.Cli/Commands/NewCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/NewCommand.cs index 5404b8442..8b2c7197e 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/NewCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/NewCommand.cs @@ -564,7 +564,35 @@ private async Task InvokeCoreAsync(ParseResult parseResult, InvocationTelem tel.Template = entry.ShortName; tel.TemplateIsItem = entry.IsItem; - // 1a. Resolve the target-framework pin now, before any name prompt, so a template the + // 1a. dotnet new selects a template by short name and refuses one that matches templates + // from more than one installed pack. When another pack registers every alias this template + // has, there is no name dotnet new can scaffold it by, so stop before prompting for a name. + var scaffoldShortName = entry.ScaffoldShortName; + if (scaffoldShortName is null) + { + var packs = string.Join(", ", entry.ConflictingPackages); + var uninstall = string.Join(" and ", entry.ConflictingPackages.Select(p => $"'dotnet new uninstall {p}'")); + var conflictError = $"Template '{entry.ShortName}' can't be created because every short name it has ({string.Join(", ", entry.ShortNames)}) " + + $"is also registered by another installed template pack ({packs}), so 'dotnet new' can't tell them apart. " + + $"Remove the other pack with {uninstall}, then re-run 'winapp new'."; + if (isJson) + { + PrintJson(false, entry.ShortName, name ?? string.Empty, (output ?? currentDir).FullName, conflictError, entry.IsExperimental); + } + else + { + logger.LogError("{Error} {Detail}", UiSymbols.Error, conflictError); + } + return ExitTemplatePackFailed; + } + + if (!string.Equals(scaffoldShortName, entry.ShortName, StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug("'{ShortName}' is also registered by {Packs}; scaffolding with '{Alias}' instead.", + entry.ShortName, string.Join(", ", entry.ConflictingPackages), scaffoldShortName); + } + + // 1b. Resolve the target-framework pin now, before any name prompt, so a template the // installed SDK is too old for (the Reactor templates require .NET 10) fails immediately // with an actionable message instead of scaffolding a project that cannot be built. var frameworkArgs = new List(); @@ -681,9 +709,9 @@ private async Task InvokeCoreAsync(ParseResult parseResult, InvocationTelem // Pass each token via ArgumentList (injection-safe) so a crafted --name or --output cannot // inject additional dotnet new options. - var args = new List { "new", entry.ShortName, "-n", name!, "-o", outputDir.FullName }; + var args = new List { "new", scaffoldShortName, "-n", name!, "-o", outputDir.FullName }; - // Target framework, resolved and validated in step 1a (empty for item templates, which take + // Target framework, resolved and validated in step 1b (empty for item templates, which take // no framework, and for project templates whose metadata declares nothing to pin). args.AddRange(frameworkArgs); @@ -1119,7 +1147,11 @@ internal static async Task AwaitWithDelayedActionAsync( return ([], $"Could not verify which templates '{TemplatePackageId}' owns: no template list for it in 'dotnet new uninstall' output. Re-run with --verbose to see that output."); } - return (WinUiTemplateCatalog.RestrictToPack(parsed, packRows), null); + // Another installed pack (e.g. the standalone Microsoft.UI.Reactor.Templates) may register + // the same aliases; dotnet new refuses a short name that matches templates from more than + // one pack, so record which aliases are shared and scaffold with one that isn't. + var owned = WinUiTemplateCatalog.RestrictToPack(parsed, packRows); + return (WinUiTemplateCatalog.MarkSharedAliases(owned, WinUiTemplateCatalog.ParseInstalledPacks(packOutput), TemplatePackageId), null); } private async Task PromptTemplateAsync(IReadOnlyList templates, CancellationToken cancellationToken) diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/WinUiTemplateCatalog.cs b/src/winapp-CLI/WinApp.Cli/Helpers/WinUiTemplateCatalog.cs index 4744a1f65..394ebb0ff 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/WinUiTemplateCatalog.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/WinUiTemplateCatalog.cs @@ -64,6 +64,23 @@ public bool HasTag(string segment) /// public bool IsExperimental => HasTag("Experimental") || DisplayName.Contains("(Experimental)", StringComparison.OrdinalIgnoreCase); + + /// + /// Aliases of this template that another installed template pack also registers. dotnet new + /// refuses a short name that matches templates from more than one pack, so these can't be used to + /// scaffold this template. + /// + public IReadOnlyList SharedAliases { get; init; } = []; + + /// The other installed packs that register any of . + public IReadOnlyList ConflictingPackages { get; init; } = []; + + /// + /// The short name to hand dotnet new: the first alias no other installed pack registers, or + /// null when every alias is shared (so dotnet new can't select this template by name). + /// + public string? ScaffoldShortName + => ShortNames.FirstOrDefault(a => !SharedAliases.Contains(a, StringComparer.OrdinalIgnoreCase)); } /// @@ -73,6 +90,9 @@ public bool IsExperimental /// internal sealed record PackTemplateRow(string DisplayName, IReadOnlyList Aliases); +/// An installed template pack and the templates it owns, parsed from dotnet new uninstall. +internal sealed record InstalledTemplatePack(string PackageId, IReadOnlyList Templates); + /// /// Pure parsing of dotnet template subcommand output. Enumerating a pack's templates and /// detecting a stale installed pack both require scraping localized, human-oriented tables (there is @@ -276,10 +296,8 @@ internal static IReadOnlyList ParsePackTemplates(string uninsta } // Scan this package's block (lines indented deeper than the header) for its "Templates:" - // sub-header, then collect the template rows nested under it (deeper still), stopping when - // the indent returns to the "Templates:" level (the sibling "Uninstall Command:" block). + // sub-header, then collect the template rows nested under it. var headerIndent = IndentWidth(lines[i]); - var templatesIndent = -1; for (var j = i + 1; j < lines.Length; j++) { var raw = lines[j]; @@ -288,35 +306,148 @@ internal static IReadOnlyList ParsePackTemplates(string uninsta continue; } - var indent = IndentWidth(raw); - if (indent <= headerIndent) + if (IndentWidth(raw) <= headerIndent) { break; // next package block } - if (templatesIndent < 0) + if (IsTemplatesHeader(raw)) { - if (raw.Trim().Equals("Templates:", StringComparison.OrdinalIgnoreCase)) - { - templatesIndent = indent; - } + rows.AddRange(ReadTemplateRows(lines, j)); + break; + } + } - continue; + break; + } + + return rows; + } + + /// + /// Parses every installed pack's "Templates:" block from a dotnet new uninstall listing. + /// The owning package of a block is the nearest preceding line indented less than its + /// "Templates:" sub-header (sibling lines such as "Version:" and "Details:" sit at the same + /// indent, and their children deeper). Packs without a Templates block are omitted. + /// + internal static IReadOnlyList ParseInstalledPacks(string uninstallListOutput) + { + var packs = new List(); + if (string.IsNullOrEmpty(uninstallListOutput)) + { + return packs; + } + + var lines = uninstallListOutput.Replace("\r\n", "\n").Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + if (!IsTemplatesHeader(lines[i])) + { + continue; + } + + var templatesIndent = IndentWidth(lines[i]); + string? packageId = null; + for (var j = i - 1; j >= 0; j--) + { + if (lines[j].Trim().Length > 0 && IndentWidth(lines[j]) < templatesIndent) + { + packageId = lines[j].Trim(); + break; + } + } + + if (!string.IsNullOrEmpty(packageId)) + { + packs.Add(new InstalledTemplatePack(packageId, ReadTemplateRows(lines, i))); + } + } + + return packs; + } + + /// + /// Records, on each of (templates owned by ), + /// which of its aliases another installed pack in also registers, + /// and which packs those are. dotnet new <alias> fails when an alias matches templates + /// from more than one pack, so the scaffold must use an alias no other pack claims + /// (). + /// + internal static IReadOnlyList MarkSharedAliases( + IReadOnlyList entries, IReadOnlyList installedPacks, string packageId) + { + var foreignOwners = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var pack in installedPacks) + { + if (pack.PackageId.Equals(packageId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + foreach (var alias in pack.Templates.SelectMany(t => t.Aliases)) + { + if (!foreignOwners.TryGetValue(alias, out var owners)) + { + owners = []; + foreignOwners[alias] = owners; } - if (indent <= templatesIndent) + if (!owners.Contains(pack.PackageId, StringComparer.OrdinalIgnoreCase)) { - break; // left the Templates block (e.g. "Uninstall Command:") + owners.Add(pack.PackageId); } + } + } - var aliases = ExtractAliases(raw); - if (aliases.Length > 0) + return entries + .Select(entry => + { + var shared = entry.ShortNames.Where(foreignOwners.ContainsKey).ToList(); + if (shared.Count == 0) { - rows.Add(new PackTemplateRow(ExtractDisplayName(raw), aliases)); + return entry; } + + var conflicting = shared + .SelectMany(a => foreignOwners[a]) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + return entry with { SharedAliases = shared, ConflictingPackages = conflicting }; + }) + .ToList(); + } + + /// True when is a pack's "Templates:" sub-header. + private static bool IsTemplatesHeader(string line) + => line.Trim().Equals("Templates:", StringComparison.OrdinalIgnoreCase); + + /// + /// Collects the template rows nested under the "Templates:" sub-header at + /// , stopping when the indent returns to that level (the sibling + /// "Uninstall Command:" block) or above. + /// + private static List ReadTemplateRows(string[] lines, int templatesIndex) + { + var rows = new List(); + var templatesIndent = IndentWidth(lines[templatesIndex]); + for (var j = templatesIndex + 1; j < lines.Length; j++) + { + var raw = lines[j]; + if (raw.Trim().Length == 0) + { + continue; } - break; + if (IndentWidth(raw) <= templatesIndent) + { + break; + } + + var aliases = ExtractAliases(raw); + if (aliases.Length > 0) + { + rows.Add(new PackTemplateRow(ExtractDisplayName(raw), aliases)); + } } return rows;