diff --git a/src/Aspire.Cli/NuGet/BundleNuGetService.cs b/src/Aspire.Cli/NuGet/BundleNuGetService.cs
index 03b3c5edc6a..7fe6b9cfd52 100644
--- a/src/Aspire.Cli/NuGet/BundleNuGetService.cs
+++ b/src/Aspire.Cli/NuGet/BundleNuGetService.cs
@@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Cli.Layout;
+using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.NuGet;
@@ -19,6 +20,7 @@ public interface INuGetService
/// The target framework.
/// Additional NuGet sources.
/// Working directory for nuget.config discovery.
+ /// An explicit NuGet.config file to use during restore.
/// Cancellation token.
/// Path to the restored libs directory.
Task RestorePackagesAsync(
@@ -26,6 +28,7 @@ Task RestorePackagesAsync(
string targetFramework = "net10.0",
IEnumerable? sources = null,
string? workingDirectory = null,
+ string? nugetConfigPath = null,
CancellationToken ct = default);
}
@@ -37,7 +40,6 @@ internal sealed class BundleNuGetService : INuGetService
private readonly ILayoutDiscovery _layoutDiscovery;
private readonly LayoutProcessRunner _layoutProcessRunner;
private readonly ILogger _logger;
- private readonly string _cacheDirectory;
public BundleNuGetService(
ILayoutDiscovery layoutDiscovery,
@@ -47,7 +49,6 @@ public BundleNuGetService(
_layoutDiscovery = layoutDiscovery;
_layoutProcessRunner = layoutProcessRunner;
_logger = logger;
- _cacheDirectory = GetCacheDirectory();
}
public async Task RestorePackagesAsync(
@@ -55,6 +56,7 @@ public async Task RestorePackagesAsync(
string targetFramework = "net10.0",
IEnumerable? sources = null,
string? workingDirectory = null,
+ string? nugetConfigPath = null,
CancellationToken ct = default)
{
var layout = _layoutDiscovery.DiscoverLayout();
@@ -76,8 +78,9 @@ public async Task RestorePackagesAsync(
}
// Compute a hash for the package set to create a unique restore location
- var packageHash = ComputePackageHash(packageList, targetFramework);
- var restoreDir = Path.Combine(_cacheDirectory, "restore", packageHash);
+ var packageHash = ComputePackageHash(packageList, targetFramework, sources);
+ var packagesDirectory = GetPackagesDirectory(workingDirectory);
+ var restoreDir = Path.Combine(packagesDirectory, "restore", packageHash);
var objDir = Path.Combine(restoreDir, "obj");
var libsDir = Path.Combine(restoreDir, "libs");
var assetsPath = Path.Combine(objDir, "project.assets.json");
@@ -123,6 +126,12 @@ public async Task RestorePackagesAsync(
restoreArgs.Add(workingDirectory);
}
+ if (!string.IsNullOrEmpty(nugetConfigPath))
+ {
+ restoreArgs.Add("--nuget-config");
+ restoreArgs.Add(nugetConfigPath);
+ }
+
// Enable verbose output for debugging
if (_logger.IsEnabled(LogLevel.Debug))
{
@@ -195,20 +204,29 @@ public async Task RestorePackagesAsync(
return libsDir;
}
- private static string ComputePackageHash(List<(string Id, string Version)> packages, string tfm)
+ private static string ComputePackageHash(List<(string Id, string Version)> packages, string tfm, IEnumerable? sources)
{
var content = string.Join(";", packages.OrderBy(p => p.Id).Select(p => $"{p.Id}:{p.Version}"));
content += $";tfm:{tfm}";
+ if (sources is not null)
+ {
+ content += $";sources:{string.Join("|", sources.OrderBy(s => s, StringComparer.OrdinalIgnoreCase))}";
+ }
// Use SHA256 for stable hash across processes/runtimes
var hashBytes = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(content));
return Convert.ToHexString(hashBytes)[..16]; // Use first 16 chars (64 bits) for reasonable uniqueness
}
- private static string GetCacheDirectory()
+ private static string GetPackagesDirectory(string? workingDirectory)
{
- var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
- return Path.Combine(home, ".aspire", "packages");
+ if (!string.IsNullOrWhiteSpace(workingDirectory))
+ {
+ var workspaceAspireDirectory = ConfigurationHelper.GetWorkspaceAspireDirectory(
+ new DirectoryInfo(Path.GetFullPath(workingDirectory)));
+ return Path.Combine(workspaceAspireDirectory.FullName, "packages");
+ }
+
+ return Path.Combine(CliPathHelper.GetAspireHomeDirectory(), "packages");
}
}
-
diff --git a/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs b/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs
index 618a0972cf8..3edee9096f1 100644
--- a/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs
+++ b/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs
@@ -960,7 +960,7 @@ private static void AddGlobalPackagesFolderConfiguration(NuGetConfigContext conf
AddGlobalPackagesFolderConfiguration(configContext.Configuration);
}
- private static void AddGlobalPackagesFolderConfiguration(XElement configuration)
+ internal static void AddGlobalPackagesFolderConfiguration(XElement configuration)
{
// Check if config section already exists
var config = configuration.Element("config");
diff --git a/src/Aspire.Cli/Packaging/TemporaryNuGetConfig.cs b/src/Aspire.Cli/Packaging/TemporaryNuGetConfig.cs
index 4ef920005fc..96d22af48c5 100644
--- a/src/Aspire.Cli/Packaging/TemporaryNuGetConfig.cs
+++ b/src/Aspire.Cli/Packaging/TemporaryNuGetConfig.cs
@@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Xml;
+using System.Xml.Linq;
namespace Aspire.Cli.Packaging;
@@ -17,12 +18,16 @@ private TemporaryNuGetConfig(FileInfo configFile)
public FileInfo ConfigFile => _configFile;
- public static async Task CreateAsync(PackageMapping[] mappings)
+ public static async Task CreateAsync(PackageMapping[] mappings, bool configureGlobalPackagesFolder = false)
{
var tempDirectory = Directory.CreateTempSubdirectory("aspire-nuget-config").FullName;
var tempFilePath = Path.Combine(tempDirectory, "nuget.config");
var configFile = new FileInfo(tempFilePath);
await GenerateNuGetConfigAsync(mappings, configFile);
+ if (configureGlobalPackagesFolder)
+ {
+ await AddGlobalPackagesFolderToConfigAsync(configFile);
+ }
return new TemporaryNuGetConfig(configFile);
}
@@ -105,6 +110,28 @@ private static async Task GenerateNuGetConfigAsync(PackageMapping[] mappings, Fi
await xmlWriter.WriteEndDocumentAsync();
}
+ private static async Task AddGlobalPackagesFolderToConfigAsync(FileInfo configFile)
+ {
+ XDocument document;
+ await using (var stream = configFile.OpenRead())
+ {
+ document = XDocument.Load(stream);
+ }
+
+ var configuration = document.Root ?? new XElement("configuration");
+ if (document.Root is null)
+ {
+ document.Add(configuration);
+ }
+
+ NuGetConfigMerger.AddGlobalPackagesFolderConfiguration(configuration);
+
+ var content = document.Declaration is null
+ ? document.ToString()
+ : $"{document.Declaration}{Environment.NewLine}{document}";
+ await File.WriteAllTextAsync(configFile.FullName, content);
+ }
+
public void Dispose()
{
if (!_disposed)
diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs
index 2fc509f3743..6be00ba720a 100644
--- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs
+++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs
@@ -166,27 +166,7 @@ private async Task> GetIntegrationReferencesAsync(
/// Falls back to when no config file is found.
///
private static DirectoryInfo GetConfigDirectory(DirectoryInfo appHostDirectory)
- {
- // Search from the apphost's directory upward to find the nearest config file.
- var nearAppHost = ConfigurationHelper.FindNearestConfigFilePath(appHostDirectory);
- if (nearAppHost is not null)
- {
- var configFile = new FileInfo(nearAppHost);
- if (configFile.Directory is { Exists: true })
- {
- // For legacy .aspire/settings.json, the config directory is the parent of .aspire/
- if (string.Equals(configFile.Directory.Name, ".aspire", StringComparison.OrdinalIgnoreCase)
- && configFile.Directory.Parent is not null)
- {
- return configFile.Directory.Parent;
- }
-
- return configFile.Directory;
- }
- }
-
- return appHostDirectory;
- }
+ => ConfigurationHelper.GetConfigRootDirectory(appHostDirectory);
private AspireConfigFile LoadConfiguration(DirectoryInfo directory)
{
diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs
index 39e25281e21..8bde037b4ce 100644
--- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs
+++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs
@@ -74,7 +74,8 @@ public PrebuiltAppHostServer(
// Create a working directory for this app host session
var pathHash = SHA256.HashData(Encoding.UTF8.GetBytes(_appDirectoryPath));
var pathDir = Convert.ToHexString(pathHash)[..12].ToLowerInvariant();
- _workingDirectory = Path.Combine(CliPathHelper.GetAspireHomeDirectory(), "bundle-hosts", pathDir);
+ var workspaceAspireDirectory = ConfigurationHelper.GetWorkspaceAspireDirectory(new DirectoryInfo(_appDirectoryPath));
+ _workingDirectory = Path.Combine(workspaceAspireDirectory.FullName, "bundle-hosts", pathDir);
Directory.CreateDirectory(_workingDirectory);
}
@@ -169,6 +170,7 @@ private async Task RestoreNuGetPackagesAsync(
_logger.LogDebug("Restoring {Count} integration packages via bundled NuGet", packageRefs.Count);
var packages = packageRefs.Select(r => (r.Name, r.Version!)).ToList();
+ using var temporaryNuGetConfig = await TryCreateTemporaryNuGetConfigAsync(channelName, cancellationToken);
var sources = await GetNuGetSourcesAsync(channelName, cancellationToken);
return await _nugetService.RestorePackagesAsync(
@@ -176,6 +178,7 @@ private async Task RestoreNuGetPackagesAsync(
DotNetBasedAppHostServerProject.TargetFramework,
sources: sources,
workingDirectory: _appDirectoryPath,
+ nugetConfigPath: temporaryNuGetConfig?.ConfigFile.FullName,
ct: cancellationToken);
}
@@ -398,6 +401,35 @@ internal static string GenerateIntegrationProjectFile(
return sources.Count > 0 ? sources : null;
}
+ private async Task TryCreateTemporaryNuGetConfigAsync(string? channelName, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrEmpty(channelName))
+ {
+ return null;
+ }
+
+ try
+ {
+ var channels = await _packagingService.GetChannelsAsync(cancellationToken);
+ var channel = channels.FirstOrDefault(c =>
+ c.Type == PackageChannelType.Explicit &&
+ c.Mappings is { Length: > 0 } &&
+ string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase));
+
+ if (channel?.Mappings is null)
+ {
+ return null;
+ }
+
+ return await TemporaryNuGetConfig.CreateAsync(channel.Mappings, channel.ConfigureGlobalPackagesFolder);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to create a temporary NuGet config for channel '{Channel}'", channelName);
+ return null;
+ }
+ }
+
///
public (string SocketPath, Process Process, OutputCollector OutputCollector) Run(
int hostPid,
diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs
index b7370f3e9a7..36b533ce2e4 100644
--- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs
+++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs
@@ -95,6 +95,17 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Can
context.ProjectName,
cancellationToken);
+ var conflictingFiles = GetConflictingScaffoldFiles(directory.FullName, scaffoldFiles.Keys);
+ if (conflictingFiles.Count > 0)
+ {
+ _logger.LogWarning(
+ "Scaffolding in '{Directory}' would overwrite existing files: {Files}",
+ directory.FullName,
+ string.Join(", ", conflictingFiles));
+ _interactionService.DisplayError(TemplatingStrings.ProjectAlreadyExists);
+ return false;
+ }
+
// Step 4: Write scaffold files to disk
foreach (var (fileName, content) in scaffoldFiles)
{
@@ -104,7 +115,15 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Can
{
Directory.CreateDirectory(fileDirectory);
}
- await File.WriteAllTextAsync(filePath, content, cancellationToken);
+
+ var contentToWrite = content;
+ if (IsGitIgnoreFile(fileName) && File.Exists(filePath))
+ {
+ var existingContent = await File.ReadAllTextAsync(filePath, cancellationToken);
+ contentToWrite = MergeGitIgnoreContent(existingContent, content);
+ }
+
+ await File.WriteAllTextAsync(filePath, contentToWrite, cancellationToken);
}
_logger.LogDebug("Wrote {Count} scaffold files", scaffoldFiles.Count);
@@ -229,4 +248,90 @@ private async Task GenerateCodeViaRpcAsync(
_logger.LogDebug("Generated {Count} code files in {Path}", generatedFiles.Count, outputPath);
}
+
+ internal static IReadOnlyList GetConflictingScaffoldFiles(string rootDirectory, IEnumerable scaffoldFileNames)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(rootDirectory);
+ ArgumentNullException.ThrowIfNull(scaffoldFileNames);
+
+ var conflicts = new List();
+
+ foreach (var fileName in scaffoldFileNames)
+ {
+ if (IsGitIgnoreFile(fileName))
+ {
+ continue;
+ }
+
+ var filePath = Path.Combine(rootDirectory, fileName);
+ if (File.Exists(filePath) || Directory.Exists(filePath))
+ {
+ conflicts.Add(fileName);
+ }
+ }
+
+ return conflicts;
+ }
+
+ internal static string MergeGitIgnoreContent(string existingContent, string scaffoldContent)
+ {
+ ArgumentNullException.ThrowIfNull(existingContent);
+ ArgumentNullException.ThrowIfNull(scaffoldContent);
+
+ if (string.IsNullOrEmpty(existingContent))
+ {
+ return scaffoldContent;
+ }
+
+ var existingEntries = ReadGitIgnoreEntries(existingContent).ToHashSet(StringComparer.Ordinal);
+ var missingEntries = ReadGitIgnoreEntries(scaffoldContent)
+ .Where(entry => !HasEquivalentGitIgnoreEntry(existingEntries, entry))
+ .ToArray();
+
+ if (missingEntries.Length == 0)
+ {
+ return existingContent;
+ }
+
+ var newline = existingContent.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
+ var mergedContent = existingContent;
+ if (!mergedContent.EndsWith("\n", StringComparison.Ordinal))
+ {
+ mergedContent += newline;
+ }
+
+ return mergedContent + string.Join(newline, missingEntries) + newline;
+ }
+
+ private static bool IsGitIgnoreFile(string fileName)
+ => Path.GetFileName(fileName).Equals(".gitignore", StringComparison.Ordinal);
+
+ private static IEnumerable ReadGitIgnoreEntries(string content)
+ {
+ using var reader = new StringReader(content);
+ string? line;
+
+ while ((line = reader.ReadLine()) is not null)
+ {
+ if (!string.IsNullOrWhiteSpace(line))
+ {
+ yield return line.TrimEnd();
+ }
+ }
+ }
+
+ private static bool HasEquivalentGitIgnoreEntry(HashSet existingEntries, string entry)
+ {
+ if (existingEntries.Contains(entry))
+ {
+ return true;
+ }
+
+ return entry switch
+ {
+ "/.aspire/" => existingEntries.Contains(".aspire/"),
+ ".aspire/" => existingEntries.Contains("/.aspire/"),
+ _ => false
+ };
+ }
}
diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore b/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore
new file mode 100644
index 00000000000..b6b3438a0d4
--- /dev/null
+++ b/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore
@@ -0,0 +1,3 @@
+.java-build/
+.modules/
+.aspire/
diff --git a/src/Aspire.Cli/Templating/Templates/py-starter/.gitignore b/src/Aspire.Cli/Templating/Templates/py-starter/.gitignore
new file mode 100644
index 00000000000..951b597a785
--- /dev/null
+++ b/src/Aspire.Cli/Templating/Templates/py-starter/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+.modules/
+dist/
+.venv/
+.aspire/
diff --git a/src/Aspire.Cli/Templating/Templates/ts-starter/.gitignore b/src/Aspire.Cli/Templating/Templates/ts-starter/.gitignore
new file mode 100644
index 00000000000..ee3654168e7
--- /dev/null
+++ b/src/Aspire.Cli/Templating/Templates/ts-starter/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+.modules/
+dist/
+.aspire/
diff --git a/src/Aspire.Cli/Utils/ConfigurationHelper.cs b/src/Aspire.Cli/Utils/ConfigurationHelper.cs
index 0875557b5c0..b1ac4953af3 100644
--- a/src/Aspire.Cli/Utils/ConfigurationHelper.cs
+++ b/src/Aspire.Cli/Utils/ConfigurationHelper.cs
@@ -69,6 +69,37 @@ internal static string BuildPathToSettingsJsonFile(string workingDirectory)
return Path.Combine(workingDirectory, ".aspire", "settings.json");
}
+ internal static DirectoryInfo GetConfigRootDirectory(DirectoryInfo startDirectory)
+ {
+ ArgumentNullException.ThrowIfNull(startDirectory);
+
+ var configPath = FindNearestConfigFilePath(startDirectory);
+ if (configPath is null)
+ {
+ return startDirectory;
+ }
+
+ var configFile = new FileInfo(configPath);
+ if (configFile.Directory is not { Exists: true } configDirectory)
+ {
+ return startDirectory;
+ }
+
+ if (string.Equals(configDirectory.Name, AspireJsonConfiguration.SettingsFolder, StringComparison.OrdinalIgnoreCase)
+ && configDirectory.Parent is not null)
+ {
+ return configDirectory.Parent;
+ }
+
+ return configDirectory;
+ }
+
+ internal static DirectoryInfo GetWorkspaceAspireDirectory(DirectoryInfo startDirectory)
+ {
+ var configRoot = GetConfigRootDirectory(startDirectory);
+ return new DirectoryInfo(Path.Combine(configRoot.FullName, AspireJsonConfiguration.SettingsFolder));
+ }
+
///
/// Searches upward from for the nearest
/// aspire.config.json or legacy .aspire/settings.json.
diff --git a/src/Aspire.Hosting.CodeGeneration.Java/JavaLanguageSupport.cs b/src/Aspire.Hosting.CodeGeneration.Java/JavaLanguageSupport.cs
index 07aa984954c..98425f3515d 100644
--- a/src/Aspire.Hosting.CodeGeneration.Java/JavaLanguageSupport.cs
+++ b/src/Aspire.Hosting.CodeGeneration.Java/JavaLanguageSupport.cs
@@ -32,6 +32,12 @@ public Dictionary Scaffold(ScaffoldRequest request)
{
var files = new Dictionary();
+ files[".gitignore"] = """
+ .java-build/
+ .modules/
+ .aspire/
+ """;
+
files["AppHost.java"] = """
// Aspire Java AppHost
// For more information, see: https://aspire.dev
diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs
index d858ad540af..5aaf3daa318 100644
--- a/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs
+++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs
@@ -53,6 +53,12 @@ public Dictionary Scaffold(ScaffoldRequest request)
// NOTE: The engines.node constraint must match ESLint 10's own requirement
// (^20.19.0 || ^22.13.0 || >=24) to avoid install/runtime failures on unsupported Node versions.
var packageName = request.ProjectName?.ToLowerInvariant() ?? "aspire-apphost";
+ files[".gitignore"] = """
+ node_modules/
+ .modules/
+ dist/
+ .aspire/
+ """;
files["package.json"] = $$"""
{
"name": "{{packageName}}",
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/GitIgnoreAssertions.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/GitIgnoreAssertions.cs
new file mode 100644
index 00000000000..179767a89c0
--- /dev/null
+++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/GitIgnoreAssertions.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.
+
+using Xunit;
+
+namespace Aspire.Cli.EndToEnd.Tests.Helpers;
+
+internal static class GitIgnoreAssertions
+{
+ public static void AssertContainsEntry(string projectRoot, string entry)
+ {
+ var gitIgnorePath = Path.Combine(projectRoot, ".gitignore");
+ Assert.True(File.Exists(gitIgnorePath), $"Expected generated .gitignore at {gitIgnorePath}");
+
+ var gitIgnoreLines = File.ReadAllLines(gitIgnorePath);
+ Assert.Contains(entry, gitIgnoreLines);
+ }
+}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaEmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaEmptyAppHostTemplateTests.cs
index 6e841e0817a..96a44116be6 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/JavaEmptyAppHostTemplateTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaEmptyAppHostTemplateTests.cs
@@ -35,6 +35,10 @@ public async Task CreateAndRunJavaEmptyAppHostProject()
await auto.AspireNewAsync("JavaEmptyApp", counter, template: AspireTemplate.JavaEmptyAppHost);
+ GitIgnoreAssertions.AssertContainsEntry(
+ Path.Combine(workspace.WorkspaceRoot.FullName, "JavaEmptyApp"),
+ ".aspire/");
+
await auto.TypeAsync("cd JavaEmptyApp");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs
index 9b80af4288f..93f50d191f3 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs
@@ -35,6 +35,10 @@ public async Task CreateAndRunPythonReactProject()
// Step 1: Create project using aspire new, selecting the FastAPI/React template
await auto.AspireNewAsync("AspirePyReactApp", counter, template: AspireTemplate.PythonReact, useRedisCache: false);
+ GitIgnoreAssertions.AssertContainsEntry(
+ Path.Combine(workspace.WorkspaceRoot.FullName, "AspirePyReactApp"),
+ ".aspire/");
+
// Step 2: Navigate into the project directory so config resolution finds the
// project-level aspire.config.json (which has the packages section).
// See https://github.com/microsoft/aspire/issues/15623
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs
index 3273d2b852b..f99f5e77267 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs
@@ -34,6 +34,10 @@ public async Task CreateAndRunTypeScriptEmptyAppHostProject()
await auto.AspireNewAsync("TsEmptyApp", counter, template: AspireTemplate.TypeScriptEmptyAppHost);
+ GitIgnoreAssertions.AssertContainsEntry(
+ Path.Combine(workspace.WorkspaceRoot.FullName, "TsEmptyApp"),
+ ".aspire/");
+
// Start the empty TypeScript AppHost to verify the scaffolded project works
await auto.TypeAsync("cd TsEmptyApp");
await auto.EnterAsync();
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs
index 3b709ee6665..6578d7d89dc 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs
@@ -37,6 +37,7 @@ public async Task CreateAndRunTypeScriptStarterProject()
// Step 1.5: Verify starter creation also restored the generated TypeScript SDK.
var projectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "TsStarterApp");
+ GitIgnoreAssertions.AssertContainsEntry(projectRoot, ".aspire/");
var modulesDir = Path.Combine(projectRoot, ".modules");
if (!Directory.Exists(modulesDir))
diff --git a/tests/Aspire.Cli.Tests/Configuration/ConfigurationHelperTests.cs b/tests/Aspire.Cli.Tests/Configuration/ConfigurationHelperTests.cs
index f65125ca022..5a142fa026f 100644
--- a/tests/Aspire.Cli.Tests/Configuration/ConfigurationHelperTests.cs
+++ b/tests/Aspire.Cli.Tests/Configuration/ConfigurationHelperTests.cs
@@ -141,4 +141,34 @@ public void TryNormalizeSettingsFile_PreservesBooleanTypes()
Assert.True(config.Features["polyglotSupportEnabled"]);
Assert.False(config.Features["showAllTemplates"]);
}
+
+ [Fact]
+ public void GetConfigRootDirectory_UsesNearestAspireConfigDirectory()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+
+ var configRoot = workspace.CreateDirectory("project");
+ Directory.CreateDirectory(Path.Combine(configRoot.FullName, "nested", "apphost"));
+ File.WriteAllText(Path.Combine(configRoot.FullName, AspireConfigFile.FileName), "{}");
+
+ var resolvedRoot = ConfigurationHelper.GetConfigRootDirectory(
+ new DirectoryInfo(Path.Combine(configRoot.FullName, "nested", "apphost")));
+
+ Assert.Equal(configRoot.FullName, resolvedRoot.FullName);
+ }
+
+ [Fact]
+ public void GetWorkspaceAspireDirectory_UsesLegacySettingsParentDirectory()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+
+ var appHostDirectory = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "nested", "apphost"));
+ appHostDirectory.Create();
+
+ var aspireDirectory = ConfigurationHelper.GetWorkspaceAspireDirectory(appHostDirectory);
+
+ Assert.Equal(
+ Path.Combine(workspace.WorkspaceRoot.FullName, AspireJsonConfiguration.SettingsFolder),
+ aspireDirectory.FullName);
+ }
}
diff --git a/tests/Aspire.Cli.Tests/NuGet/BundleNuGetServiceTests.cs b/tests/Aspire.Cli.Tests/NuGet/BundleNuGetServiceTests.cs
new file mode 100644
index 00000000000..f72ff744e58
--- /dev/null
+++ b/tests/Aspire.Cli.Tests/NuGet/BundleNuGetServiceTests.cs
@@ -0,0 +1,135 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Cli.Layout;
+using Aspire.Cli.NuGet;
+using Aspire.Cli.Tests.TestServices;
+using Aspire.Cli.Tests.Utils;
+using Aspire.Shared;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Aspire.Cli.Tests.NuGet;
+
+public class BundleNuGetServiceTests(ITestOutputHelper outputHelper)
+{
+ [Fact]
+ public async Task RestorePackagesAsync_UsesWorkspaceAspireDirectoryForRestoreArtifacts()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+
+ var appHostDirectory = workspace.CreateDirectory("apphost");
+ var layoutRoot = workspace.CreateDirectory("layout");
+ var managedDirectory = layoutRoot.CreateSubdirectory(BundleDiscovery.ManagedDirectoryName);
+ var managedPath = Path.Combine(
+ managedDirectory.FullName,
+ BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName));
+ File.WriteAllText(managedPath, string.Empty);
+
+ List invocations = [];
+ var executionFactory = new TestProcessExecutionFactory
+ {
+ AssertionCallback = (args, _, _, _) => invocations.Add(args.ToArray())
+ };
+
+ var service = new BundleNuGetService(
+ new FixedLayoutDiscovery(new LayoutConfiguration { LayoutPath = layoutRoot.FullName }),
+ new LayoutProcessRunner(executionFactory),
+ NullLogger.Instance);
+
+ var libsPath = await service.RestorePackagesAsync(
+ [("Aspire.Hosting.JavaScript", "9.4.0")],
+ workingDirectory: appHostDirectory.FullName);
+
+ var restoreRoot = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "packages", "restore");
+ var restoreDirectory = Directory.GetParent(libsPath)!.FullName;
+
+ Assert.StartsWith(restoreRoot, libsPath, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal(2, invocations.Count);
+ Assert.Equal(Path.Combine(restoreDirectory, "obj"), GetArgumentValue(invocations[0], "--output"));
+ Assert.Equal(libsPath, GetArgumentValue(invocations[1], "--output"));
+ Assert.Equal(Path.Combine(restoreDirectory, "obj", "project.assets.json"), GetArgumentValue(invocations[1], "--assets"));
+ }
+
+ [Fact]
+ public async Task RestorePackagesAsync_UsesDistinctCachePathsForDifferentSources()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+
+ var appHostDirectory = workspace.CreateDirectory("apphost");
+ var layoutRoot = workspace.CreateDirectory("layout");
+ var managedDirectory = layoutRoot.CreateSubdirectory(BundleDiscovery.ManagedDirectoryName);
+ var managedPath = Path.Combine(
+ managedDirectory.FullName,
+ BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName));
+ File.WriteAllText(managedPath, string.Empty);
+
+ var executionFactory = new TestProcessExecutionFactory();
+ var service = new BundleNuGetService(
+ new FixedLayoutDiscovery(new LayoutConfiguration { LayoutPath = layoutRoot.FullName }),
+ new LayoutProcessRunner(executionFactory),
+ NullLogger.Instance);
+
+ var libsPathA = await service.RestorePackagesAsync(
+ [("Aspire.Hosting.JavaScript", "9.4.0")],
+ sources: ["https://example.com/feed-a/index.json"],
+ workingDirectory: appHostDirectory.FullName);
+
+ var libsPathB = await service.RestorePackagesAsync(
+ [("Aspire.Hosting.JavaScript", "9.4.0")],
+ sources: ["https://example.com/feed-b/index.json"],
+ workingDirectory: appHostDirectory.FullName);
+
+ Assert.NotEqual(libsPathA, libsPathB);
+ }
+
+ [Fact]
+ public async Task RestorePackagesAsync_PassesNuGetConfigToRestore()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+
+ var appHostDirectory = workspace.CreateDirectory("apphost");
+ var layoutRoot = workspace.CreateDirectory("layout");
+ var managedDirectory = layoutRoot.CreateSubdirectory(BundleDiscovery.ManagedDirectoryName);
+ var managedPath = Path.Combine(
+ managedDirectory.FullName,
+ BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName));
+ File.WriteAllText(managedPath, string.Empty);
+
+ var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
+ File.WriteAllText(nugetConfigPath, "");
+
+ List invocations = [];
+ var executionFactory = new TestProcessExecutionFactory
+ {
+ AssertionCallback = (args, _, _, _) => invocations.Add(args.ToArray())
+ };
+
+ var service = new BundleNuGetService(
+ new FixedLayoutDiscovery(new LayoutConfiguration { LayoutPath = layoutRoot.FullName }),
+ new LayoutProcessRunner(executionFactory),
+ NullLogger.Instance);
+
+ await service.RestorePackagesAsync(
+ [("Aspire.Hosting.JavaScript", "9.4.0")],
+ workingDirectory: appHostDirectory.FullName,
+ nugetConfigPath: nugetConfigPath);
+
+ Assert.Equal(nugetConfigPath, GetArgumentValue(invocations[0], "--nuget-config"));
+ }
+
+ private static string GetArgumentValue(string[] arguments, string optionName)
+ {
+ var optionIndex = Array.IndexOf(arguments, optionName);
+ Assert.True(optionIndex >= 0 && optionIndex < arguments.Length - 1, $"Option '{optionName}' was not found.");
+ return arguments[optionIndex + 1];
+ }
+
+ private sealed class FixedLayoutDiscovery(LayoutConfiguration layout) : ILayoutDiscovery
+ {
+ public LayoutConfiguration? DiscoverLayout(string? projectDirectory = null) => layout;
+
+ public string? GetComponentPath(LayoutComponent component, string? projectDirectory = null) => layout.GetComponentPath(component);
+
+ public bool IsBundleModeAvailable(string? projectDirectory = null) => true;
+ }
+}
diff --git a/tests/Aspire.Cli.Tests/Packaging/TemporaryNuGetConfigTests.cs b/tests/Aspire.Cli.Tests/Packaging/TemporaryNuGetConfigTests.cs
index d3641ba4bfe..7bf5f2bf270 100644
--- a/tests/Aspire.Cli.Tests/Packaging/TemporaryNuGetConfigTests.cs
+++ b/tests/Aspire.Cli.Tests/Packaging/TemporaryNuGetConfigTests.cs
@@ -108,4 +108,20 @@ public async Task CreateAsync_WithNoMappings_CreatesValidConfig()
var packageSourceMappingNode = xmlDoc.SelectSingleNode("//packageSourceMapping");
Assert.Null(packageSourceMappingNode);
}
+
+ [Fact]
+ public async Task CreateAsync_WithConfiguredGlobalPackagesFolder_AddsConfigEntry()
+ {
+ using var tempConfig = await TemporaryNuGetConfig.CreateAsync(
+ [new PackageMapping("Aspire.*", "https://example.com/feed")],
+ configureGlobalPackagesFolder: true);
+
+ var configContent = await File.ReadAllTextAsync(tempConfig.ConfigFile.FullName);
+ var xmlDoc = new XmlDocument();
+ xmlDoc.LoadXml(configContent);
+
+ var globalPackagesFolder = xmlDoc.SelectSingleNode("//config/add[@key='globalPackagesFolder']");
+ Assert.NotNull(globalPackagesFolder);
+ Assert.Equal(".nugetpackages", globalPackagesFolder!.Attributes!["value"]!.Value);
+ }
}
diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs
index 65b89ed4d37..5eadad16147 100644
--- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs
+++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs
@@ -8,7 +8,6 @@
using Aspire.Cli.Projects;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
-using Aspire.Cli.Utils;
namespace Aspire.Cli.Tests.Projects;
@@ -151,13 +150,14 @@ public void GenerateIntegrationProjectFile_WithEmptyAdditionalSources_DoesNotSet
}
[Fact]
- public void Constructor_UsesUserAspireDirectoryForWorkingDirectory()
+ public void Constructor_UsesWorkspaceAspireDirectoryForWorkingDirectory()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var appHostDirectory = workspace.CreateDirectory("apphost");
var nugetService = new BundleNuGetService(new NullLayoutDiscovery(), new LayoutProcessRunner(new TestProcessExecutionFactory()), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
var server = new PrebuiltAppHostServer(
- workspace.WorkspaceRoot.FullName,
+ appHostDirectory.FullName,
"test.sock",
new LayoutConfiguration(),
nugetService,
@@ -172,7 +172,7 @@ public void Constructor_UsesUserAspireDirectoryForWorkingDirectory()
.GetField("_workingDirectory", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.GetValue(server));
- var rootDirectory = Path.Combine(CliPathHelper.GetAspireHomeDirectory(), "bundle-hosts");
+ var rootDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "bundle-hosts");
var isUnderRoot = workingDirectory.StartsWith(rootDirectory, StringComparison.OrdinalIgnoreCase);
var parentDirectory = Path.GetDirectoryName(workingDirectory);
var isDirectChildOfRoot = parentDirectory is not null &&
diff --git a/tests/Aspire.Cli.Tests/Scaffolding/ScaffoldingServiceTests.cs b/tests/Aspire.Cli.Tests/Scaffolding/ScaffoldingServiceTests.cs
new file mode 100644
index 00000000000..7a19e94f6ff
--- /dev/null
+++ b/tests/Aspire.Cli.Tests/Scaffolding/ScaffoldingServiceTests.cs
@@ -0,0 +1,56 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Cli.Scaffolding;
+
+namespace Aspire.Cli.Tests.Scaffolding;
+
+public class ScaffoldingServiceTests
+{
+ [Fact]
+ public void GetConflictingScaffoldFiles_IgnoresGitIgnoreButReturnsOtherExistingFiles()
+ {
+ var rootDirectory = Directory.CreateTempSubdirectory();
+
+ try
+ {
+ File.WriteAllText(Path.Combine(rootDirectory.FullName, ".gitignore"), "node_modules/\n");
+ File.WriteAllText(Path.Combine(rootDirectory.FullName, "package.json"), "{}");
+
+ var conflicts = ScaffoldingService.GetConflictingScaffoldFiles(
+ rootDirectory.FullName,
+ [".gitignore", "package.json", "apphost.ts"]);
+
+ Assert.Equal(["package.json"], conflicts);
+ }
+ finally
+ {
+ rootDirectory.Delete(recursive: true);
+ }
+ }
+
+ [Fact]
+ public void MergeGitIgnoreContent_AppendsMissingEntriesWithoutOverwritingExistingContent()
+ {
+ var existingContent = "node_modules/\ncustom/\n";
+ var scaffoldContent = "node_modules/\n.modules/\ndist/\n.aspire/\n";
+
+ var mergedContent = ScaffoldingService.MergeGitIgnoreContent(existingContent, scaffoldContent);
+ var lines = mergedContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ Assert.Equal(
+ ["node_modules/", "custom/", ".modules/", "dist/", ".aspire/"],
+ lines);
+ }
+
+ [Fact]
+ public void MergeGitIgnoreContent_DoesNotAddDuplicateAspireEntryWhenEquivalentEntryAlreadyExists()
+ {
+ var existingContent = "/.aspire/\n";
+ var scaffoldContent = ".aspire/\n";
+
+ var mergedContent = ScaffoldingService.MergeGitIgnoreContent(existingContent, scaffoldContent);
+
+ Assert.Equal(existingContent, mergedContent);
+ }
+}
diff --git a/tests/Aspire.Cli.Tests/Templating/TemplateGitIgnoreTests.cs b/tests/Aspire.Cli.Tests/Templating/TemplateGitIgnoreTests.cs
new file mode 100644
index 00000000000..f2f774aaa79
--- /dev/null
+++ b/tests/Aspire.Cli.Tests/Templating/TemplateGitIgnoreTests.cs
@@ -0,0 +1,24 @@
+// 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.Tests.Templating;
+
+public class TemplateGitIgnoreTests
+{
+ [Theory]
+ [InlineData("ts-starter")]
+ [InlineData("py-starter")]
+ [InlineData("java-starter")]
+ public void StarterTemplates_IgnoreWorkspaceAspireDirectory(string templateName)
+ {
+ var filePath = Path.Combine(GetRepoRoot(), "src", "Aspire.Cli", "Templating", "Templates", templateName, ".gitignore");
+
+ Assert.True(File.Exists(filePath), $"Expected template .gitignore at {filePath}");
+
+ var lines = File.ReadAllLines(filePath);
+ Assert.Contains(".aspire/", lines);
+ }
+
+ private static string GetRepoRoot()
+ => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
+}