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, "..", "..", "..", "..", "..")); +}