From 53073952009fabe4df38bf70ef79bae1844b52f0 Mon Sep 17 00:00:00 2001 From: David Pine Date: Thu, 12 Mar 2026 10:49:33 -0500 Subject: [PATCH 1/2] feat: Implement guest SDK generation for TypeScript starter projects --- .../Projects/GuestAppHostProject.cs | 7 +- .../Projects/IGuestAppHostSdkGenerator.cs | 18 ++ ...mplateFactory.TypeScriptStarterTemplate.cs | 35 ++-- .../Templating/CliTemplateFactory.cs | 74 +------- .../TypeScriptStarterTemplateTests.cs | 18 ++ .../Commands/NewCommandTests.cs | 166 ++++++++++++++++++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 3 +- 7 files changed, 230 insertions(+), 91 deletions(-) create mode 100644 src/Aspire.Cli/Projects/IGuestAppHostSdkGenerator.cs diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 032470d5cec..8b1ed3a14f8 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -25,7 +25,7 @@ namespace Aspire.Cli.Projects; /// Handler for guest (non-.NET) AppHost projects. /// Supports any language registered via . /// -internal sealed class GuestAppHostProject : IAppHostProject +internal sealed class GuestAppHostProject : IAppHostProject, IGuestAppHostSdkGenerator { private const string GeneratedFolderName = ".modules"; @@ -244,6 +244,11 @@ await GenerateCodeViaRpcAsync( } } + Task IGuestAppHostSdkGenerator.BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken) + { + return BuildAndGenerateSdkAsync(directory, cancellationToken); + } + // ═══════════════════════════════════════════════════════════════ // EXECUTION // ═══════════════════════════════════════════════════════════════ diff --git a/src/Aspire.Cli/Projects/IGuestAppHostSdkGenerator.cs b/src/Aspire.Cli/Projects/IGuestAppHostSdkGenerator.cs new file mode 100644 index 00000000000..ba89749d4c7 --- /dev/null +++ b/src/Aspire.Cli/Projects/IGuestAppHostSdkGenerator.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Projects; + +/// +/// Generates SDK artifacts for a guest AppHost project. +/// +internal interface IGuestAppHostSdkGenerator +{ + /// + /// Builds any required server components and generates guest SDK artifacts. + /// + /// The AppHost project directory. + /// A cancellation token. + /// if SDK generation succeeded; otherwise, . + Task BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs index a2d354a1d57..a9629331988 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs @@ -3,8 +3,8 @@ using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; +using Aspire.Cli.Projects; using Aspire.Cli.Resources; -using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; using Spectre.Console; @@ -61,23 +61,7 @@ private async Task ApplyTypeScriptStarterTemplateAsync(CallbackT _logger.LogDebug("Copying embedded TypeScript starter template files to '{OutputPath}'.", outputPath); await CopyTemplateTreeToDiskAsync("ts-starter", outputPath, ApplyAllTokens, cancellationToken); - if (!CommandPathResolver.TryResolveCommand("npm", out var npmPath, out var errorMessage)) - { - _interactionService.DisplayError(errorMessage!); - return new TemplateResult(ExitCodeConstants.InvalidCommand); - } - - // Run npm install in the output directory (non-fatal — package may not be published yet) - _logger.LogDebug("Running npm install for TypeScript starter in '{OutputPath}'.", outputPath); - var npmInstallResult = await RunProcessAsync(npmPath!, "install", outputPath, cancellationToken); - if (npmInstallResult.ExitCode != 0) - { - _interactionService.DisplaySubtleMessage("npm install had warnings or errors. You may need to run 'npm install' manually after dependencies are available."); - DisplayProcessOutput(npmInstallResult, treatStandardErrorAsError: false); - } - - // Write channel to settings.json if available so that aspire add - // knows which channel to use for package resolution + // Write channel to settings.json before restore so package resolution uses the selected channel. if (!string.IsNullOrEmpty(inputs.Channel)) { var config = AspireJsonConfiguration.Load(outputPath); @@ -88,6 +72,21 @@ private async Task ApplyTypeScriptStarterTemplateAsync(CallbackT } } + var appHostProject = _projectFactory.TryGetProject(new FileInfo(Path.Combine(outputPath, "apphost.ts"))); + if (appHostProject is not IGuestAppHostSdkGenerator guestProject) + { + _interactionService.DisplayError("Automatic 'aspire restore' failed for the new TypeScript starter project."); + return new TemplateResult(ExitCodeConstants.FailedToBuildArtifacts, outputPath); + } + + _logger.LogDebug("Generating SDK code for TypeScript starter in '{OutputPath}'.", outputPath); + var restoreSucceeded = await guestProject.BuildAndGenerateSdkAsync(new DirectoryInfo(outputPath), cancellationToken); + if (!restoreSucceeded) + { + _interactionService.DisplayError("Automatic 'aspire restore' failed for the new TypeScript starter project."); + return new TemplateResult(ExitCodeConstants.FailedToBuildArtifacts, outputPath); + } + return new TemplateResult(ExitCodeConstants.Success, outputPath); }, emoji: KnownEmojis.Rocket); diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.cs index 64634de5ae3..fce808f4a04 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using System.Diagnostics; using System.Globalization; using System.Text; using Aspire.Cli.Commands; @@ -39,6 +38,7 @@ internal sealed partial class CliTemplateFactory : ITemplateFactory }; private readonly ILanguageDiscovery _languageDiscovery; + private readonly IAppHostProjectFactory _projectFactory; private readonly IScaffoldingService _scaffoldingService; private readonly INewCommandPrompter _prompter; private readonly CliExecutionContext _executionContext; @@ -49,6 +49,7 @@ internal sealed partial class CliTemplateFactory : ITemplateFactory public CliTemplateFactory( ILanguageDiscovery languageDiscovery, + IAppHostProjectFactory projectFactory, IScaffoldingService scaffoldingService, INewCommandPrompter prompter, CliExecutionContext executionContext, @@ -58,6 +59,7 @@ public CliTemplateFactory( ILogger logger) { _languageDiscovery = languageDiscovery; + _projectFactory = projectFactory; _scaffoldingService = scaffoldingService; _prompter = prompter; _executionContext = executionContext; @@ -209,76 +211,6 @@ private async Task CopyTemplateTreeToDiskAsync(string templateRoot, string outpu } } - private void DisplayProcessOutput(ProcessExecutionResult result, bool treatStandardErrorAsError) - { - if (!string.IsNullOrWhiteSpace(result.StandardOutput)) - { - _interactionService.DisplaySubtleMessage(result.StandardOutput.TrimEnd()); - } - - if (!string.IsNullOrWhiteSpace(result.StandardError)) - { - var message = result.StandardError.TrimEnd(); - if (treatStandardErrorAsError) - { - _interactionService.DisplayError(message); - } - else - { - _interactionService.DisplaySubtleMessage(message); - } - } - } - - private static async Task RunProcessAsync(string fileName, string arguments, string workingDirectory, CancellationToken cancellationToken) - { - var startInfo = new ProcessStartInfo(fileName, arguments) - { - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = workingDirectory - }; - - using var process = new Process { StartInfo = startInfo }; - process.Start(); - process.StandardInput.Close(); // Prevent hanging on prompts - - // Drain output streams to prevent deadlocks - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - try - { - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - await Task.WhenAll(outputTask, errorTask).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - try - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - } - } - catch (InvalidOperationException) - { - } - - throw; - } - - return new ProcessExecutionResult( - process.ExitCode, - await outputTask.ConfigureAwait(false), - await errorTask.ConfigureAwait(false)); - } - - private sealed record ProcessExecutionResult(int ExitCode, string StandardOutput, string StandardError); - private void DisplayPostCreationInstructions(string outputPath) { var currentDir = _executionContext.WorkingDirectory.FullName; diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs index 38fde104a2e..243aa237a83 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs @@ -39,6 +39,24 @@ public async Task CreateAndRunTypeScriptStarterProject() // Step 1: Create project using aspire new, selecting the Express/React template sequenceBuilder.AspireNew("TsStarterApp", counter, template: AspireTemplate.ExpressReact); + // Step 1.5: Verify starter creation also restored the generated TypeScript SDK. + sequenceBuilder.ExecuteCallback(() => + { + var projectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "TsStarterApp"); + var modulesDir = Path.Combine(projectRoot, ".modules"); + + if (!Directory.Exists(modulesDir)) + { + throw new InvalidOperationException($".modules directory was not created at {modulesDir}"); + } + + var aspireModulePath = Path.Combine(modulesDir, "aspire.ts"); + if (!File.Exists(aspireModulePath)) + { + throw new InvalidOperationException($"Expected generated file not found: {aspireModulePath}"); + } + }); + // Step 2: Navigate into the project and start it in background with JSON output sequenceBuilder .Type("cd TsStarterApp") diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index afbf6f97885..e22d32a2f73 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -4,6 +4,7 @@ using Aspire.Cli.Utils; using Aspire.Cli.Certificates; using Aspire.Cli.Commands; +using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; @@ -1273,6 +1274,80 @@ await File.WriteAllTextAsync(Path.Combine(context.TargetDirectory.FullName, "app Assert.DoesNotContain("://localhost", runProfile); } + [Fact] + public async Task NewCommandWithTypeScriptStarterGeneratesSdkArtifacts() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var buildAndGenerateCalled = false; + string? channelSeenByProject = null; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner + { + SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, runnerOptions, cancellationToken) => + { + var package = new NuGetPackage + { + Id = "Aspire.ProjectTemplates", + Source = "nuget", + Version = "9.2.0" + }; + + return (0, new NuGetPackage[] { package }); + } + }; + + options.PackagingServiceFactory = _ => new NewCommandTestPackagingService + { + GetChannelsAsyncCallback = cancellationToken => + { + var dailyCache = new NewCommandTestFakeNuGetPackageCache + { + GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) => + { + var package = new NuGetPackage + { + Id = "Aspire.ProjectTemplates", + Source = "nuget", + Version = "9.2.0" + }; + + return Task.FromResult>([package]); + } + }; + + var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache); + return Task.FromResult>([dailyChannel]); + } + }; + }); + + services.AddSingleton(new TestTypeScriptStarterProjectFactory((directory, cancellationToken) => + { + buildAndGenerateCalled = true; + var config = AspireJsonConfiguration.Load(directory.FullName); + channelSeenByProject = config?.Channel; + + var modulesDir = Directory.CreateDirectory(Path.Combine(directory.FullName, ".modules")); + File.WriteAllText(Path.Combine(modulesDir.FullName, "aspire.ts"), "// generated sdk"); + + return Task.FromResult(true); + })); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("new aspire-ts-starter --name TestApp --output . --channel daily --localhost-tld false"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(0, exitCode); + Assert.True(buildAndGenerateCalled); + Assert.Equal("daily", channelSeenByProject); + Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".modules", "aspire.ts"))); + } + [Fact] public async Task NewCommandNonInteractiveDoesNotPrompt() { @@ -1506,3 +1581,94 @@ public Task ScaffoldAsync(ScaffoldContext context, CancellationToken cancellatio return Task.CompletedTask; } } + +internal sealed class TestTypeScriptStarterProjectFactory(Func> buildAndGenerateSdkAsync) : IAppHostProjectFactory +{ + private readonly TestTypeScriptStarterProject _project = new(buildAndGenerateSdkAsync); + + public IAppHostProject GetProject(LanguageInfo language) + { + return _project; + } + + public IAppHostProject? TryGetProject(FileInfo appHostFile) + { + return appHostFile.Name.Equals("apphost.ts", StringComparison.OrdinalIgnoreCase) ? _project : null; + } + + public IAppHostProject GetProject(FileInfo appHostFile) + { + return TryGetProject(appHostFile) ?? throw new NotSupportedException($"No handler available for AppHost file '{appHostFile.Name}'."); + } +} + +internal sealed class TestTypeScriptStarterProject(Func> buildAndGenerateSdkAsync) : IAppHostProject, IGuestAppHostSdkGenerator +{ + public bool IsUnsupported { get; set; } + + public string LanguageId => KnownLanguageId.TypeScript; + + public string DisplayName => "TypeScript (Node.js)"; + + public string? AppHostFileName => "apphost.ts"; + + public Task GetDetectionPatternsAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(["apphost.ts"]); + } + + public bool CanHandle(FileInfo appHostFile) + { + return appHostFile.Name.Equals("apphost.ts", StringComparison.OrdinalIgnoreCase); + } + + public bool IsUsingProjectReferences(FileInfo appHostFile) + { + return false; + } + + public Task RunAsync(AppHostProjectContext context, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task PublishAsync(PublishContext context, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task ValidateAppHostAsync(FileInfo appHostFile, CancellationToken cancellationToken) + { + return Task.FromResult(new AppHostValidationResult(IsValid: CanHandle(appHostFile))); + } + + public Task AddPackageAsync(AddPackageContext context, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task UpdatePackagesAsync(UpdatePackagesContext context, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task FindAndStopRunningInstanceAsync(FileInfo appHostFile, DirectoryInfo homeDirectory, CancellationToken cancellationToken) + { + return Task.FromResult(RunningInstanceResult.NoRunningInstance); + } + + public Task GetUserSecretsIdAsync(FileInfo appHostFile, bool autoInit, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task> GetPackageReferencesAsync(FileInfo appHostFile, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken) + { + return buildAndGenerateSdkAsync(directory, cancellationToken); + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 5f733deb2f0..5ff107ab92a 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -480,7 +480,8 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser var cliTemplateLogger = serviceProvider.GetRequiredService>(); var templateNuGetConfigService = new TemplateNuGetConfigService(interactionService, executionContext, packagingService, configurationService); var dotNetFactory = new DotNetTemplateFactory(interactionService, runner, certificateService, packagingService, prompter, templateVersionPrompter, executionContext, sdkInstaller, features, configurationService, telemetry, hostEnvironment, templateNuGetConfigService); - var cliFactory = new CliTemplateFactory(languageDiscovery, scaffoldingService, prompter, executionContext, interactionService, hostEnvironment, templateNuGetConfigService, cliTemplateLogger); + var projectFactory = serviceProvider.GetRequiredService(); + var cliFactory = new CliTemplateFactory(languageDiscovery, projectFactory, scaffoldingService, prompter, executionContext, interactionService, hostEnvironment, templateNuGetConfigService, cliTemplateLogger); return new TemplateProvider([dotNetFactory, cliFactory]); }; From 315984e6dc3256ae93f8ce2ab91211ed14045439 Mon Sep 17 00:00:00 2001 From: David Pine Date: Thu, 12 Mar 2026 11:20:18 -0500 Subject: [PATCH 2/2] test: address TypeScript starter review feedback --- ...mplateFactory.TypeScriptStarterTemplate.cs | 4 +- .../Commands/NewCommandTests.cs | 70 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs index a9629331988..f910e0c21e3 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs @@ -75,7 +75,7 @@ private async Task ApplyTypeScriptStarterTemplateAsync(CallbackT var appHostProject = _projectFactory.TryGetProject(new FileInfo(Path.Combine(outputPath, "apphost.ts"))); if (appHostProject is not IGuestAppHostSdkGenerator guestProject) { - _interactionService.DisplayError("Automatic 'aspire restore' failed for the new TypeScript starter project."); + _interactionService.DisplayError("Automatic 'aspire restore' is unavailable for the new TypeScript starter project because no TypeScript AppHost SDK generator was found."); return new TemplateResult(ExitCodeConstants.FailedToBuildArtifacts, outputPath); } @@ -83,7 +83,7 @@ private async Task ApplyTypeScriptStarterTemplateAsync(CallbackT var restoreSucceeded = await guestProject.BuildAndGenerateSdkAsync(new DirectoryInfo(outputPath), cancellationToken); if (!restoreSucceeded) { - _interactionService.DisplayError("Automatic 'aspire restore' failed for the new TypeScript starter project."); + _interactionService.DisplayError("Automatic 'aspire restore' failed for the new TypeScript starter project. Run 'aspire restore' in the project directory for more details."); return new TemplateResult(ExitCodeConstants.FailedToBuildArtifacts, outputPath); } diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index e22d32a2f73..1ae966d7936 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -1348,6 +1348,69 @@ public async Task NewCommandWithTypeScriptStarterGeneratesSdkArtifacts() Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".modules", "aspire.ts"))); } + [Fact] + public async Task NewCommandWithTypeScriptStarterReturnsFailedToBuildArtifactsWhenSdkGenerationFails() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var interactionService = new TestInteractionService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner + { + SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, runnerOptions, cancellationToken) => + { + var package = new NuGetPackage + { + Id = "Aspire.ProjectTemplates", + Source = "nuget", + Version = "9.2.0" + }; + + return (0, new NuGetPackage[] { package }); + } + }; + + options.PackagingServiceFactory = _ => new NewCommandTestPackagingService + { + GetChannelsAsyncCallback = cancellationToken => + { + var dailyCache = new NewCommandTestFakeNuGetPackageCache + { + GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) => + { + var package = new NuGetPackage + { + Id = "Aspire.ProjectTemplates", + Source = "nuget", + Version = "9.2.0" + }; + + return Task.FromResult>([package]); + } + }; + + var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache); + return Task.FromResult>([dailyChannel]); + } + }; + }); + + services.AddSingleton(interactionService); + services.AddSingleton(new TestTypeScriptStarterProjectFactory((directory, cancellationToken) => Task.FromResult(false))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("new aspire-ts-starter --name TestApp --output . --channel daily --localhost-tld false"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.FailedToBuildArtifacts, exitCode); + Assert.Single(interactionService.DisplayedErrors); + Assert.Equal("Automatic 'aspire restore' failed for the new TypeScript starter project. Run 'aspire restore' in the project directory for more details.", interactionService.DisplayedErrors[0]); + } + [Fact] public async Task NewCommandNonInteractiveDoesNotPrompt() { @@ -1588,6 +1651,13 @@ internal sealed class TestTypeScriptStarterProjectFactory(Func