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
8 changes: 4 additions & 4 deletions src/Aspire.Cli/Scaffolding/PackageJsonMerger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ internal static class PackageJsonMerger
/// <summary>
/// Merges scaffold-generated package.json content with existing content.
/// Preserves all existing properties and scripts. Scaffold scripts that conflict
/// with existing names are added under the <c>aspire:</c> prefix. Non-conflicting
/// with existing names are added under the <c>aspire:</c> prefix. Existing scripts,
/// including <c>aspire:</c>-prefixed scripts, are preserved. Non-conflicting
/// <c>aspire:X</c> scripts get a convenience alias <c>X</c> pointing to
/// <c>{toolchain} run aspire:X</c>.
/// </summary>
Expand Down Expand Up @@ -140,7 +141,7 @@ private static void MergeObjects(JsonObject existing, JsonObject scaffold, ILogg
/// <remarks>
/// For each scaffold script:
/// <list type="bullet">
/// <item>Already <c>aspire:</c> prefixed → always added/updated</item>
/// <item>Already <c>aspire:</c> prefixed → added only when missing</item>
/// <item>Not prefixed, conflicts with existing → added as <c>aspire:{name}</c></item>
/// <item>Not prefixed, no conflict → added with the original name</item>
/// </list>
Expand All @@ -158,8 +159,7 @@ internal static void MergeScripts(JsonObject existingScripts, JsonObject scaffol

if (name.StartsWith(AspirePrefix, StringComparison.Ordinal))
{
// Already prefixed — always set it
existingScripts[name] = command;
existingScripts[name] ??= command;
}
else if (existingScripts[name] is not null)
{
Expand Down
48 changes: 44 additions & 4 deletions src/Aspire.Cli/Scaffolding/ScaffoldingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -288,17 +288,37 @@ private async Task AddRootTypeScriptAppHostScriptsAsync(DirectoryInfo rootDirect
}

var scripts = EnsureJsonObject(packageJson, "scripts");
var toolchain = TypeScriptAppHostToolchainResolver.Resolve(rootDirectory, _logger);
var relativeAppHostDirectory = PathNormalizer.NormalizePathForStorage(Path.GetRelativePath(rootDirectory.FullName, appHostDirectory.FullName));
var preservedScriptNames = AddRootTypeScriptAppHostDelegateScripts(scripts, appHostDirectory, relativeAppHostDirectory, _logger);

scripts["aspire:start"] = CreateRootDelegateScript(toolchain, relativeAppHostDirectory, "aspire:start");
scripts["aspire:build"] = CreateRootDelegateScript(toolchain, relativeAppHostDirectory, "aspire:build");
scripts["aspire:dev"] = CreateRootDelegateScript(toolchain, relativeAppHostDirectory, "aspire:dev");
if (preservedScriptNames.Count > 0)
{
_interactionService.DisplayMessage(
KnownEmojis.Warning,
$"Preserved existing package.json script(s) {string.Join(", ", preservedScriptNames)}. Run the AppHost directly from {relativeAppHostDirectory} or remove the existing script(s) and rerun 'aspire init' to regenerate the root delegates.");
}

var serializedPackageJson = SerializePackageJson(packageJson, existingContent);
await File.WriteAllTextAsync(packageJsonPath, serializedPackageJson, cancellationToken);
}

internal static IReadOnlyList<string> AddRootTypeScriptAppHostDelegateScripts(JsonObject scripts, TypeScriptAppHostToolchain toolchain, string relativeAppHostDirectory)
{
List<string>? preservedScriptNames = null;

AddRootTypeScriptAppHostDelegateScript(scripts, toolchain, relativeAppHostDirectory, "aspire:start", ref preservedScriptNames);
AddRootTypeScriptAppHostDelegateScript(scripts, toolchain, relativeAppHostDirectory, "aspire:build", ref preservedScriptNames);
AddRootTypeScriptAppHostDelegateScript(scripts, toolchain, relativeAppHostDirectory, "aspire:dev", ref preservedScriptNames);

return preservedScriptNames ?? [];
}

internal static IReadOnlyList<string> AddRootTypeScriptAppHostDelegateScripts(JsonObject scripts, DirectoryInfo appHostDirectory, string relativeAppHostDirectory, ILogger? logger)
{
var toolchain = TypeScriptAppHostToolchainResolver.Resolve(appHostDirectory, logger);
return AddRootTypeScriptAppHostDelegateScripts(scripts, toolchain, relativeAppHostDirectory);
}

internal static string SerializePackageJson(JsonObject packageJson, string existingContent)
{
var serializedPackageJson = packageJson.ToJsonString(s_packageJsonSerializerOptions);
Expand Down Expand Up @@ -353,6 +373,26 @@ private static string CreateRootDelegateScript(TypeScriptAppHostToolchain toolch
};
}

private static void AddRootTypeScriptAppHostDelegateScript(JsonObject scripts, TypeScriptAppHostToolchain toolchain, string relativeAppHostDirectory, string scriptName, ref List<string>? preservedScriptNames)
{
var delegateScript = CreateRootDelegateScript(toolchain, relativeAppHostDirectory, scriptName);
if (scripts[scriptName] is JsonValue existingScriptValue &&
existingScriptValue.TryGetValue<string>(out var existingScript) &&
string.Equals(existingScript, delegateScript, StringComparison.Ordinal))
{
return;
}

if (scripts[scriptName] is not null)
{
preservedScriptNames ??= [];
preservedScriptNames.Add(scriptName);
return;
}

scripts[scriptName] = delegateScript;
}

private async Task<int> InstallDependenciesAsync(
DirectoryInfo directory,
LanguageInfo language,
Expand Down
117 changes: 113 additions & 4 deletions tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.Cli.Tests.Utils;
Expand Down Expand Up @@ -160,6 +161,100 @@ await auto.WaitUntilAsync(s =>
await pendingRun;
}

[Fact]
[CaptureWorkspaceOnFailure]
public async Task CreateTypeScriptAppHostWithViteApp_AllowsGuestAppPackageManagerToDiffer()
{
const string appHostToolchain = "pnpm";
const string guestToolchain = "npm";

var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var workspace = TemporaryWorkspace.Create(output);
var localChannel = CliE2ETestHelpers.PrepareLocalChannel(repoRoot, strategy,
["Aspire.Hosting.CodeGeneration.TypeScript.", "Aspire.Hosting.JavaScript."]);
var channelArgument = localChannel is not null ? " --channel local" : string.Empty;

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, mountDockerSocket: true, workspace: workspace);

var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));

await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);

await auto.TypeAsync($"aspire init --language typescript --non-interactive{channelArgument}");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("Created apphost.mts", timeout: TimeSpan.FromMinutes(2));
await auto.DeclineAgentInitPromptAsync(counter);

TypeScriptAppHostToolchainTestHelpers.SetPackageManager(workspace.WorkspaceRoot.FullName, appHostToolchain, cleanInstallState: true);

if (localChannel is not null)
{
CliE2ETestHelpers.WriteLocalChannelSettings(workspace.WorkspaceRoot.FullName, localChannel.SdkVersion);
}

await auto.TypeAsync("npm create -y vite@latest viteapp -- --template vanilla-ts --no-interactive");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));

var viteProjectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "viteapp");
TypeScriptAppHostToolchainTestHelpers.SetPackageManager(viteProjectRoot, guestToolchain, cleanInstallState: true);

await auto.TypeAsync($"cd viteapp && {TypeScriptAppHostToolchainTestHelpers.GetInstallCommand(guestToolchain)} && cd ..");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));

await auto.TypeAsync("aspire add Aspire.Hosting.JavaScript");
await auto.EnterAsync();
await auto.WaitForAspireAddSuccessAsync(counter, TimeSpan.FromMinutes(2));

var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.mts");
File.WriteAllText(appHostPath, """
// Aspire TypeScript AppHost
// For more information, see: https://aspire.dev

import { createBuilder } from './.aspire/modules/aspire.mjs';

const builder = await createBuilder();

await builder.addViteApp("viteapp", "./viteapp");

await builder.build().run();
""");

await auto.TypeAsync("aspire restore");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("SDK code restored successfully", timeout: TimeSpan.FromMinutes(3));
await auto.WaitForSuccessPromptAsync(counter);

var appHostLockFilePath = Path.Combine(
workspace.WorkspaceRoot.FullName,
TypeScriptAppHostToolchainTestHelpers.GetLockFileName(appHostToolchain));
Assert.True(
File.Exists(appHostLockFilePath),
$"Expected {TypeScriptAppHostToolchainTestHelpers.GetDisplayName(appHostToolchain)} restore to create '{appHostLockFilePath}'.");

var guestLockFilePath = Path.Combine(
viteProjectRoot,
TypeScriptAppHostToolchainTestHelpers.GetLockFileName(guestToolchain));
Assert.True(
File.Exists(guestLockFilePath),
$"Expected {TypeScriptAppHostToolchainTestHelpers.GetDisplayName(guestToolchain)} install to create '{guestLockFilePath}'.");

await auto.TypeAsync(TypeScriptAppHostToolchainTestHelpers.GetTypeCheckCommand(appHostToolchain, "tsconfig.apphost.json"));
await auto.EnterAsync();
await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromMinutes(2));

await auto.TypeAsync("exit");
await auto.EnterAsync();

await pendingRun;
}

[Theory]
[MemberData(nameof(SupportedToolchains))]
[CaptureWorkspaceOnFailure]
Expand Down Expand Up @@ -222,7 +317,7 @@ await auto.WaitUntilAsync(
}

[Fact]
public async Task InitTypeScriptAppHost_AugmentsExistingViteRepoAtRoot()
public async Task InitTypeScriptAppHost_AugmentsExistingViteRepoInWorkspaceSubdirectory()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
Expand Down Expand Up @@ -254,8 +349,20 @@ public async Task InitTypeScriptAppHost_AugmentsExistingViteRepoAtRoot()
await auto.InstallAspireCliAsync(strategy, counter);
await auto.EnablePolyglotSupportAsync(counter);

File.WriteAllText(
Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"),
"""
{
"name": "workspace-root",
"private": true,
"workspaces": [
"packages/*"
]
}
""");

// Create brownfield Vite project
await auto.TypeAsync("mkdir brownfield && cd brownfield");
await auto.TypeAsync("mkdir -p packages/brownfield && cd packages/brownfield");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);

Expand All @@ -264,14 +371,16 @@ public async Task InitTypeScriptAppHost_AugmentsExistingViteRepoAtRoot()
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));

// Capture original package.json scripts and tsconfig before aspire init
var projectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "brownfield");
var projectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "packages", "brownfield");
var packageJson = JsonNode.Parse(File.ReadAllText(Path.Combine(projectRoot, "package.json")))!.AsObject();
var scripts = packageJson["scripts"]!.AsObject();
originalDevScript = scripts["dev"]?.GetValue<string>();
originalBuildScript = scripts["build"]?.GetValue<string>();
originalPreviewScript = scripts["preview"]?.GetValue<string>();
originalPackageType = packageJson["type"]?.GetValue<string>();
originalTsConfig = File.ReadAllText(Path.Combine(projectRoot, "tsconfig.json"));
scripts["aspire:start"] = "custom-apphost-start";
File.WriteAllText(Path.Combine(projectRoot, "package.json"), packageJson.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));

// LocalHive strategy only: PrepareLocalChannel returned a real channel,
// so write the per-project aspire.config.json to point at the in-repo
Expand Down Expand Up @@ -300,7 +409,7 @@ public async Task InitTypeScriptAppHost_AugmentsExistingViteRepoAtRoot()
Assert.Equal(originalDevScript, scripts["dev"]?.GetValue<string>());
Assert.Equal(originalBuildScript, scripts["build"]?.GetValue<string>());
Assert.Equal(originalPreviewScript, scripts["preview"]?.GetValue<string>());
Assert.Equal("npm --prefix aspire-apphost run aspire:start", scripts["aspire:start"]?.GetValue<string>());
Assert.Equal("custom-apphost-start", scripts["aspire:start"]?.GetValue<string>());
Assert.Equal("npm --prefix aspire-apphost run aspire:build", scripts["aspire:build"]?.GetValue<string>());
Assert.Equal("npm --prefix aspire-apphost run aspire:dev", scripts["aspire:dev"]?.GetValue<string>());
Assert.Equal(originalPackageType, packageJson["type"]?.GetValue<string>());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,37 @@ public void Resolve_WhenParentDirectoryDefinesToolchain_ReturnsParentToolchain()
Assert.Equal(TypeScriptAppHostToolchain.Pnpm, toolchain);
}

[Fact]
public void Resolve_WhenAppHostAndParentDefineDifferentToolchains_ReturnsAppHostToolchain()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);

var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("apps").CreateSubdirectory("apphost");
File.WriteAllText(Path.Combine(appHostDirectory.Parent!.FullName, "package.json"), "{ \"packageManager\": \"pnpm@10.12.1\" }");
File.WriteAllText(Path.Combine(appHostDirectory.FullName, "package.json"), "{ \"packageManager\": \"bun@1.2.0\" }");

var resolution = TypeScriptAppHostToolchainResolver.ResolveWithReason(appHostDirectory);

Assert.Equal(TypeScriptAppHostToolchain.Bun, resolution.Toolchain);
Assert.Equal($"packageManager 'bun@1.2.0' found in {Path.Combine(appHostDirectory.FullName, "package.json")}", resolution.Reason);
}

[Fact]
public void Resolve_WhenAppHostLockFileAndParentPackageManagerDiffer_ReturnsAppHostLockFileToolchain()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);

var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("apps").CreateSubdirectory("apphost");
File.WriteAllText(Path.Combine(appHostDirectory.Parent!.FullName, "package.json"), "{ \"packageManager\": \"yarn@4.14.1\" }");
File.WriteAllText(Path.Combine(appHostDirectory.FullName, "package.json"), "{ \"name\": \"apphost\" }");
File.WriteAllText(Path.Combine(appHostDirectory.FullName, "pnpm-lock.yaml"), "lockfileVersion: '9.0'");

var resolution = TypeScriptAppHostToolchainResolver.ResolveWithReason(appHostDirectory);

Assert.Equal(TypeScriptAppHostToolchain.Pnpm, resolution.Toolchain);
Assert.Equal($"pnpm-lock.yaml found in {appHostDirectory.FullName}", resolution.Reason);
}

[Fact]
public void Resolve_WhenGrandparentDirectoryDefinesToolchain_ReturnsNpm()
{
Expand Down
9 changes: 5 additions & 4 deletions tests/Aspire.Cli.Tests/Scaffolding/PackageJsonMergerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,15 @@ public void NonConflictingScripts_AddedDirectly()
}

[Fact]
public void PrefixedScripts_AlwaysAdded()
public void PrefixedScripts_PreserveExistingValues()
{
var existing = """
{
"name": "my-app",
"scripts": {
"dev": "vite",
"build": "vite build"
"build": "vite build",
"aspire:start": "custom start"
}
}
""";
Expand All @@ -130,8 +131,8 @@ public void PrefixedScripts_AlwaysAdded()
Assert.Equal("vite", scripts["dev"]?.GetValue<string>());
Assert.Equal("vite build", scripts["build"]?.GetValue<string>());

// All aspire: scripts added
Assert.Equal("aspire run", scripts["aspire:start"]?.GetValue<string>());
// Existing aspire: scripts are preserved; missing ones are added
Assert.Equal("custom start", scripts["aspire:start"]?.GetValue<string>());
Assert.Equal("tsc -p tsconfig.apphost.json", scripts["aspire:build"]?.GetValue<string>());
Assert.Equal("tsc --watch -p tsconfig.apphost.json", scripts["aspire:dev"]?.GetValue<string>());
Assert.Equal("eslint apphost.ts", scripts["aspire:lint"]?.GetValue<string>());
Expand Down
Loading
Loading