Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 6 additions & 1 deletion src/Aspire.Cli/Projects/GuestAppHostProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ namespace Aspire.Cli.Projects;
/// Handler for guest (non-.NET) AppHost projects.
/// Supports any language registered via <see cref="ILanguageDiscovery"/>.
/// </summary>
internal sealed class GuestAppHostProject : IAppHostProject
internal sealed class GuestAppHostProject : IAppHostProject, IGuestAppHostSdkGenerator
{
private const string GeneratedFolderName = ".modules";

Expand Down Expand Up @@ -244,6 +244,11 @@ await GenerateCodeViaRpcAsync(
}
}

Task<bool> IGuestAppHostSdkGenerator.BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken)
{
return BuildAndGenerateSdkAsync(directory, cancellationToken);
}

// ═══════════════════════════════════════════════════════════════
// EXECUTION
// ═══════════════════════════════════════════════════════════════
Expand Down
18 changes: 18 additions & 0 deletions src/Aspire.Cli/Projects/IGuestAppHostSdkGenerator.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Generates SDK artifacts for a guest AppHost project.
/// </summary>
internal interface IGuestAppHostSdkGenerator
{
/// <summary>
/// Builds any required server components and generates guest SDK artifacts.
/// </summary>
/// <param name="directory">The AppHost project directory.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns><see langword="true"/> if SDK generation succeeded; otherwise, <see langword="false"/>.</returns>
Task<bool> BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -61,23 +61,7 @@ private async Task<TemplateResult> 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);
Expand All @@ -88,6 +72,21 @@ private async Task<TemplateResult> 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);

Expand Down
74 changes: 3 additions & 71 deletions src/Aspire.Cli/Templating/CliTemplateFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -49,6 +49,7 @@ internal sealed partial class CliTemplateFactory : ITemplateFactory

public CliTemplateFactory(
ILanguageDiscovery languageDiscovery,
IAppHostProjectFactory projectFactory,
IScaffoldingService scaffoldingService,
INewCommandPrompter prompter,
CliExecutionContext executionContext,
Expand All @@ -58,6 +59,7 @@ public CliTemplateFactory(
ILogger<CliTemplateFactory> logger)
{
_languageDiscovery = languageDiscovery;
_projectFactory = projectFactory;
_scaffoldingService = scaffoldingService;
_prompter = prompter;
_executionContext = executionContext;
Expand Down Expand Up @@ -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<ProcessExecutionResult> 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;
Expand Down
18 changes: 18 additions & 0 deletions tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading