Skip to content
Open
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
36 changes: 27 additions & 9 deletions src/Aspire.Cli/NuGet/BundleNuGetService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,13 +20,15 @@ public interface INuGetService
/// <param name="targetFramework">The target framework.</param>
/// <param name="sources">Additional NuGet sources.</param>
/// <param name="workingDirectory">Working directory for nuget.config discovery.</param>
/// <param name="nugetConfigPath">An explicit NuGet.config file to use during restore.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Path to the restored libs directory.</returns>
Task<string> RestorePackagesAsync(
IEnumerable<(string Id, string Version)> packages,
string targetFramework = "net10.0",
IEnumerable<string>? sources = null,
string? workingDirectory = null,
string? nugetConfigPath = null,
CancellationToken ct = default);
}

Expand All @@ -37,7 +40,6 @@ internal sealed class BundleNuGetService : INuGetService
private readonly ILayoutDiscovery _layoutDiscovery;
private readonly LayoutProcessRunner _layoutProcessRunner;
private readonly ILogger<BundleNuGetService> _logger;
private readonly string _cacheDirectory;

public BundleNuGetService(
ILayoutDiscovery layoutDiscovery,
Expand All @@ -47,14 +49,14 @@ public BundleNuGetService(
_layoutDiscovery = layoutDiscovery;
_layoutProcessRunner = layoutProcessRunner;
_logger = logger;
_cacheDirectory = GetCacheDirectory();
}

public async Task<string> RestorePackagesAsync(
IEnumerable<(string Id, string Version)> packages,
string targetFramework = "net10.0",
IEnumerable<string>? sources = null,
string? workingDirectory = null,
string? nugetConfigPath = null,
CancellationToken ct = default)
{
var layout = _layoutDiscovery.DiscoverLayout();
Expand All @@ -76,8 +78,9 @@ public async Task<string> 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");
Expand Down Expand Up @@ -123,6 +126,12 @@ public async Task<string> 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))
{
Expand Down Expand Up @@ -195,20 +204,29 @@ public async Task<string> 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<string>? 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");
}
}

2 changes: 1 addition & 1 deletion src/Aspire.Cli/Packaging/NuGetConfigMerger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
29 changes: 28 additions & 1 deletion src/Aspire.Cli/Packaging/TemporaryNuGetConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -17,12 +18,16 @@ private TemporaryNuGetConfig(FileInfo configFile)

public FileInfo ConfigFile => _configFile;

public static async Task<TemporaryNuGetConfig> CreateAsync(PackageMapping[] mappings)
public static async Task<TemporaryNuGetConfig> 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);
}

Expand Down Expand Up @@ -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)
Expand Down
22 changes: 1 addition & 21 deletions src/Aspire.Cli/Projects/GuestAppHostProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,27 +166,7 @@ private async Task<List<IntegrationReference>> GetIntegrationReferencesAsync(
/// Falls back to <paramref name="appHostDirectory"/> when no config file is found.
/// </summary>
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)
{
Expand Down
34 changes: 33 additions & 1 deletion src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -169,13 +170,15 @@ private async Task<string> 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(
packages,
DotNetBasedAppHostServerProject.TargetFramework,
sources: sources,
workingDirectory: _appDirectoryPath,
nugetConfigPath: temporaryNuGetConfig?.ConfigFile.FullName,
ct: cancellationToken);
}

Expand Down Expand Up @@ -398,6 +401,35 @@ internal static string GenerateIntegrationProjectFile(
return sources.Count > 0 ? sources : null;
}

private async Task<TemporaryNuGetConfig?> 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;
}
}

/// <inheritdoc />
public (string SocketPath, Process Process, OutputCollector OutputCollector) Run(
int hostPid,
Expand Down
107 changes: 106 additions & 1 deletion src/Aspire.Cli/Scaffolding/ScaffoldingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,17 @@ private async Task<bool> 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)
{
Expand All @@ -104,7 +115,15 @@ private async Task<bool> 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);
Expand Down Expand Up @@ -229,4 +248,90 @@ private async Task GenerateCodeViaRpcAsync(

_logger.LogDebug("Generated {Count} code files in {Path}", generatedFiles.Count, outputPath);
}

internal static IReadOnlyList<string> GetConflictingScaffoldFiles(string rootDirectory, IEnumerable<string> scaffoldFileNames)
{
ArgumentException.ThrowIfNullOrEmpty(rootDirectory);
ArgumentNullException.ThrowIfNull(scaffoldFileNames);

var conflicts = new List<string>();

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<string> 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<string> existingEntries, string entry)
{
if (existingEntries.Contains(entry))
{
return true;
}

return entry switch
{
"/.aspire/" => existingEntries.Contains(".aspire/"),
".aspire/" => existingEntries.Contains("/.aspire/"),
_ => false
};
}
}
3 changes: 3 additions & 0 deletions src/Aspire.Cli/Templating/Templates/java-starter/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.java-build/
.modules/
.aspire/
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Templating/Templates/py-starter/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
.modules/
dist/
.venv/
.aspire/
Loading
Loading