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..f910e0c21e3 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' is unavailable for the new TypeScript starter project because no TypeScript AppHost SDK generator was found.");
+ 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. Run 'aspire restore' in the project directory for more details.");
+ 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..1ae966d7936 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,143 @@ 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 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()
{
@@ -1506,3 +1644,101 @@ 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)
+ {
+ ArgumentNullException.ThrowIfNull(language);
+
+ if (!string.Equals(language.LanguageId, KnownLanguageId.TypeScript, StringComparison.Ordinal))
+ {
+ throw new NotSupportedException($"No handler available for language '{language.LanguageId}'.");
+ }
+
+ 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]);
};