Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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' 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);
}
Comment thread
IEvangelist marked this conversation as resolved.
Comment thread
IEvangelist marked this conversation as resolved.

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