Add 'winapp new' command for creating WinUI 3 projects and items#454
Add 'winapp new' command for creating WinUI 3 projects and items#454nmetulev wants to merge 2 commits into
Conversation
Reverts the revert of PR #411, re-adding the 'winapp new' command for creating WinUI 3 projects and items from templates. Regenerated all auto-generated docs and npm wrappers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Re-lands the winapp new command in the native CLI, along with regenerated CLI schema/docs and the npm TypeScript wrappers, to scaffold WinUI 3 projects and items from the Microsoft.WindowsAppSDK.WinUI.CSharp.Templates package.
Changes:
- Added
winapp newcommand implementation with interactive + non-interactive flows and project/item context detection. - Introduced a template abstraction layer (
ITemplateProvider/ITemplateService) and a .NET provider that discovers templates from an installed.nupkg. - Regenerated CLI schema, docs, skills content, and npm command wrappers to reflect the new command.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/winapp-npm/src/winapp-commands.ts | Adds newCommand() wrapper and NewOptions type to the npm API surface. |
| src/winapp-CLI/WinApp.Cli/Services/TemplateService.cs | Adds composite template service to aggregate providers and route template creation. |
| src/winapp-CLI/WinApp.Cli/Services/ITemplateService.cs | Defines the composite template service contract. |
| src/winapp-CLI/WinApp.Cli/Services/ITemplateProvider.cs | Defines per-language template provider contract. |
| src/winapp-CLI/WinApp.Cli/Services/DotNetTemplateProvider.cs | Implements template discovery/creation via dotnet new + .nupkg inspection. |
| src/winapp-CLI/WinApp.Cli/Models/TemplateInfo.cs | Adds template/parameter models and template.json source-gen deserialization types. |
| src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs | Registers template services/providers and wires NewCommand handler in DI. |
| src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs | Adds new into root subcommands and help categorization. |
| src/winapp-CLI/WinApp.Cli/Commands/NewCommand.cs | Implements winapp new behavior, prompting, and project setup steps. |
| src/winapp-CLI/WinApp.Cli.Tests/NewCommandTests.cs | Adds tests for schema presence, deserialization, template discovery, routing, and naming. |
| docs/usage.md | Documents winapp new CLI usage and examples. |
| docs/npm-usage.md | Documents npm wrapper newCommand() and NewOptions. |
| docs/guides/dotnet.md | Updates .NET guide to mention winapp new as a recommended WinUI path. |
| docs/fragments/skills/winapp-cli/setup.md | Updates setup skill fragment to include winapp new scenarios and examples. |
| docs/cli-schema.json | Updates generated CLI schema with the new top-level command. |
| README.md | Adds new to the Setup Commands list and clarifies init is for existing projects. |
| .github/plugin/skills/winapp-cli/ui-automation/SKILL.md | Regenerated content (text normalization in --value description). |
| .github/plugin/skills/winapp-cli/setup/SKILL.md | Regenerated skill content to include winapp new guidance. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| var workingDir = outputDir ?? new DirectoryInfo(Directory.GetCurrentDirectory()); | ||
| if (!workingDir.Exists) | ||
| { | ||
| workingDir = workingDir.Parent ?? new DirectoryInfo(Directory.GetCurrentDirectory()); | ||
| } | ||
|
|
||
| return await dotNetService.RunDotnetCommandAsync(workingDir, args, cancellationToken); |
There was a problem hiding this comment.
When creating item templates with --project, workingDir is still set to the current directory (because outputDir is null). If the user invokes winapp new from outside the project folder but points --project to a specific .csproj, the generated item files may be created in the wrong directory. Consider using projectFile.Directory as the working directory (or explicitly passing an output directory) when projectFile is provided.
| /** | ||
| * Create a new WinUI 3 project or add an item to an existing project. Uses the latest Microsoft.WindowsAppSDK.WinUI.CSharp.Templates (automatically installed/updated). When run inside a .csproj directory, shows item templates (pages, windows, controls). Otherwise, shows project templates. Pass additional dotnet new arguments after --. | ||
| */ | ||
| export async function newCommand(options: NewOptions = {}): Promise<WinappResult> { | ||
| const args: string[] = ['new']; | ||
| if (options.template) args.push(options.template); | ||
| if (options.name) args.push('--name', options.name); | ||
| if (options.output) args.push('--output', options.output); | ||
| if (options.project) args.push('--project', options.project); | ||
| return execCommand(args, options); | ||
| } |
There was a problem hiding this comment.
The CLI supports passing additional dotnet new arguments after --, but the npm wrapper currently has no way to supply those passthrough args (unlike tool() which exposes toolArgs). This makes the wrapper API incomplete for winapp new compared to the underlying CLI. Consider adding an option like dotnetNewArgs?: string[] and ensuring common flags (--quiet/--verbose) are placed before -- so they’re not forwarded to dotnet new.
| ### `newCommand()` | ||
|
|
||
| Create a new WinUI 3 project or add an item to an existing project. Uses the latest Microsoft.WindowsAppSDK.WinUI.CSharp.Templates (automatically installed/updated). When run inside a .csproj directory, shows item templates (pages, windows, controls). Otherwise, shows project templates. Pass additional dotnet new arguments after --. | ||
|
|
||
| ```typescript | ||
| function newCommand(options?: NewOptions): Promise<WinappResult> | ||
| ``` |
There was a problem hiding this comment.
This npm-usage entry says you can “Pass additional dotnet new arguments after --”, but the generated TypeScript wrapper (newCommand(options)) does not expose any field for passthrough args. Either add passthrough support to the wrapper API or adjust this docstring to avoid advertising unsupported functionality in the npm package.
| { | ||
| var langTemplates = templates.Where(t => t.Language == language).ToList(); | ||
| prompt.AddChoiceGroup( | ||
| ($"[bold yellow]{Markup.Escape(language)}[/]", (TemplateInfo?)null), |
There was a problem hiding this comment.
SelectionPrompt<T>.AddChoiceGroup(...) in Spectre.Console expects the group title as a string (non-selectable header), but this code is passing a tuple value as the group. This likely won’t compile against Spectre.Console 0.54.0, and even if an overload exists it risks allowing the group header to be selected (returning null and failing). Use the AddChoiceGroup(string title, IEnumerable<T> choices) overload and pass only the template choices as selectable items.
| ($"[bold yellow]{Markup.Escape(language)}[/]", (TemplateInfo?)null), | |
| $"[bold yellow]{Markup.Escape(language)}[/]", |
| // Phase 1: Load templates (simple spinner, no residual output) | ||
| IReadOnlyList<TemplateInfo>? allTemplates = null; | ||
| try | ||
| { | ||
| var spinnerTask = Task.Run(async () => | ||
| { | ||
| await templateService.EnsureAllProvidersAsync(cancellationToken); | ||
| return await templateService.GetAvailableTemplatesAsync(cancellationToken); | ||
| }, cancellationToken); | ||
|
|
||
| if (isInteractive) | ||
| { | ||
| var spinnerChars = new[] { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" }; | ||
| int i = 0; | ||
| while (!spinnerTask.IsCompleted) | ||
| { | ||
| Console.Write($"\r\x1b[33m{spinnerChars[i++ % spinnerChars.Length]}\x1b[0m Loading templates..."); | ||
| await Task.WhenAny(spinnerTask, Task.Delay(100, cancellationToken)); | ||
| } | ||
| Console.Write("\r\x1b[2K"); | ||
| } | ||
|
|
||
| allTemplates = await spinnerTask; |
There was a problem hiding this comment.
The template-loading spinner writes directly to Console and will still render even when --quiet is set, despite --quiet being documented as “Suppress progress messages” and enforced elsewhere via log level/status rendering. Consider gating this spinner behind logger.IsEnabled(LogLevel.Information) / the WinAppRootCommand.QuietOption value, or using IStatusService for the load phase so quiet mode behaves consistently.
| // Phase 1: Load templates (simple spinner, no residual output) | |
| IReadOnlyList<TemplateInfo>? allTemplates = null; | |
| try | |
| { | |
| var spinnerTask = Task.Run(async () => | |
| { | |
| await templateService.EnsureAllProvidersAsync(cancellationToken); | |
| return await templateService.GetAvailableTemplatesAsync(cancellationToken); | |
| }, cancellationToken); | |
| if (isInteractive) | |
| { | |
| var spinnerChars = new[] { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" }; | |
| int i = 0; | |
| while (!spinnerTask.IsCompleted) | |
| { | |
| Console.Write($"\r\x1b[33m{spinnerChars[i++ % spinnerChars.Length]}\x1b[0m Loading templates..."); | |
| await Task.WhenAny(spinnerTask, Task.Delay(100, cancellationToken)); | |
| } | |
| Console.Write("\r\x1b[2K"); | |
| } | |
| allTemplates = await spinnerTask; | |
| // Phase 1: Load templates | |
| IReadOnlyList<TemplateInfo>? allTemplates = null; | |
| try | |
| { | |
| if (isInteractive) | |
| { | |
| allTemplates = await ansiConsole.Status() | |
| .StartAsync("Loading templates...", async _ => | |
| { | |
| await templateService.EnsureAllProvidersAsync(cancellationToken); | |
| return await templateService.GetAvailableTemplatesAsync(cancellationToken); | |
| }); | |
| } | |
| else | |
| { | |
| await templateService.EnsureAllProvidersAsync(cancellationToken); | |
| allTemplates = await templateService.GetAvailableTemplatesAsync(cancellationToken); | |
| } |
| // Parse version from filename and sort semantically | ||
| // Filename format: Microsoft.WindowsAppSDK.WinUI.CSharp.Templates.0.0.3-alpha.nupkg | ||
| return matchingFiles | ||
| .Select(f => | ||
| { | ||
| var fileName = Path.GetFileNameWithoutExtension(f); | ||
| var versionStr = fileName[prefix.Length..]; | ||
| var parsed = Version.TryParse(versionStr.Split('-')[0], out var version); | ||
| return (Path: f, Version: parsed ? version! : new Version(0, 0, 0), Raw: versionStr); | ||
| }) | ||
| .OrderByDescending(x => x.Version) | ||
| .ThenByDescending(x => x.Raw) // Pre-release tie-breaker | ||
| .Select(x => x.Path) | ||
| .FirstOrDefault(); |
There was a problem hiding this comment.
FindInstalledNupkg() prefers the highest System.Version and then uses ThenByDescending(x.Raw) as a prerelease tie-breaker. For the same numeric version, this will incorrectly select a prerelease (e.g. 1.0.0-beta sorts after 1.0.0) because the raw string is longer/greater. Prefer stable versions over prerelease for the same numeric version, or switch to NuGet’s version comparer (e.g., NuGetVersion) for correct SemVer precedence.
Build Metrics ReportBinary Sizes
Test Results✅ 737 passed out of 737 tests in 377.8s (+15 tests, +23.8s vs. baseline) Test Coverage❌ 20.9% line coverage, 34.6% branch coverage · ✅ +0.3% vs. baseline CLI Startup Time42ms median (x64, Updated 2026-04-17 00:44:38 UTC · commit |
Adds the \winapp new\ command for creating new WinUI 3 projects and adding items to existing projects from templates.
What it does:
Options: