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
45 changes: 44 additions & 1 deletion src/Aspire.Cli/Projects/FallbackProjectParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,14 +161,57 @@ private static JsonDocument BuildJsonDocument(

private static string? ExtractAspireHostingSdkVersion(XElement projectRoot)
{
// Look for <Sdk Name="Aspire.AppHost.Sdk" Version="..." />
// Check new format first: <Project Sdk="Aspire.AppHost.Sdk/version">
// The Sdk attribute may contain multiple SDKs separated by semicolons
var sdkAttribute = projectRoot.Attribute("Sdk")?.Value;
if (sdkAttribute is not null)
{
var version = ExtractVersionFromSdkAttribute(sdkAttribute);
if (version is not null)
{
return version;
}
}

// Fall back to old format: <Sdk Name="Aspire.AppHost.Sdk" Version="..." />
var sdkElement = projectRoot
.Elements("Sdk")
.FirstOrDefault(e => e.Attribute("Name")?.Value == "Aspire.AppHost.Sdk");

return sdkElement?.Attribute("Version")?.Value;
}

/// <summary>
/// Extracts the Aspire.AppHost.Sdk version from an Sdk attribute value.
/// Handles formats like "Aspire.AppHost.Sdk/13.0.1" or "Aspire.AppHost.Sdk/13.0.1;Microsoft.NET.Sdk".
/// </summary>
private static string? ExtractVersionFromSdkAttribute(string sdkAttribute)
{
const string sdkPrefix = "Aspire.AppHost.Sdk";

// Split by semicolon in case of multiple SDKs
var sdks = sdkAttribute.Split(';');
foreach (var sdk in sdks)
{
var trimmedSdk = sdk.Trim();

// Check for exact match "Aspire.AppHost.Sdk" (no version)
if (trimmedSdk.Equals(sdkPrefix, StringComparison.OrdinalIgnoreCase))
{
return null; // SDK found but no version specified
}

// Check for "Aspire.AppHost.Sdk/version" format
if (trimmedSdk.StartsWith(sdkPrefix + "/", StringComparison.OrdinalIgnoreCase))
{
var version = trimmedSdk.Substring(sdkPrefix.Length + 1);
return string.IsNullOrEmpty(version) ? null : version;
}
}

return null;
}

private static PackageReferenceInfo[] ExtractPackageReferences(XElement projectRoot)
{
var packageReferences = new List<PackageReferenceInfo>();
Expand Down
199 changes: 192 additions & 7 deletions src/Aspire.Cli/Projects/ProjectUpdater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -266,21 +266,122 @@ private async Task AnalyzeAppHostSdkAsync(UpdateContext context, CancellationTok
return;
}

// Detect what migration actions will be performed for .csproj files
var migrationInfo = DetectMigrationActions(context.AppHostProjectFile);

var sdkUpdateStep = new PackageUpdateStep(
string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.UpdatePackageFormat, "Aspire.AppHost.Sdk", sdkVersion ?? "unknown", latestSdkPackage?.Version),
() => UpdateSdkVersionInAppHostAsync(context.AppHostProjectFile, latestSdkPackage!),
() => UpdateSdkVersionInAppHostAsync(context.AppHostProjectFile, latestSdkPackage!, interactionService, migrationInfo),
"Aspire.AppHost.Sdk",
sdkVersion ?? "unknown",
latestSdkPackage?.Version ?? "unknown",
context.AppHostProjectFile);
context.UpdateSteps.Enqueue(sdkUpdateStep);
}

private static async Task UpdateSdkVersionInAppHostAsync(FileInfo projectFile, NuGetPackageCli package)
private static SdkMigrationInfo DetectMigrationActions(FileInfo projectFile)
{
if (!string.Equals(projectFile.Extension, ".csproj", StringComparison.OrdinalIgnoreCase))
{
return new SdkMigrationInfo(false, false);
}

try
{
var projectDocument = new XmlDocument();
projectDocument.PreserveWhitespace = true;
projectDocument.Load(projectFile.FullName);

var projectNode = projectDocument.SelectSingleNode("/Project");
if (projectNode is null)
{
return new SdkMigrationInfo(false, false);
}

// Check if using old SDK format (needs migration to new format)
var sdkAttribute = projectNode.Attributes?["Sdk"];
var usesOldFormat = sdkAttribute is null || !ContainsAspireAppHostSdk(sdkAttribute.Value);

// Check if Aspire.Hosting.AppHost package reference exists (will be removed)
var hasAppHostPackage = projectNode.SelectSingleNode("//PackageReference[@Include='Aspire.Hosting.AppHost']") is not null;

return new SdkMigrationInfo(usesOldFormat, hasAppHostPackage);
}
catch
{
return new SdkMigrationInfo(false, false);
}
}

private const string AspireAppHostSdkName = "Aspire.AppHost.Sdk";

/// <summary>
/// Checks if the Sdk attribute contains the Aspire.AppHost.Sdk.
/// Handles formats like "Aspire.AppHost.Sdk/13.0.1" or "Aspire.AppHost.Sdk/13.0.1;Microsoft.NET.Sdk".
/// </summary>
private static bool ContainsAspireAppHostSdk(string sdkAttribute)
{
var sdks = sdkAttribute.Split(';');
foreach (var sdk in sdks)
{
var trimmedSdk = sdk.Trim();

// Check for exact match "Aspire.AppHost.Sdk" or "Aspire.AppHost.Sdk/version"
if (trimmedSdk.Equals(AspireAppHostSdkName, StringComparison.OrdinalIgnoreCase) ||
trimmedSdk.StartsWith(AspireAppHostSdkName + "/", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}

return false;
}

/// <summary>
/// Updates the Aspire.AppHost.Sdk version in the Sdk attribute, preserving any other SDKs.
/// </summary>
private static string UpdateAspireAppHostSdkVersion(string sdkAttribute, string newVersion)
{
var sdks = sdkAttribute.Split(';');
var updatedSdks = new List<string>();

foreach (var sdk in sdks)
{
var trimmedSdk = sdk.Trim();

// Check if this is the Aspire.AppHost.Sdk
if (trimmedSdk.Equals(AspireAppHostSdkName, StringComparison.OrdinalIgnoreCase) ||
trimmedSdk.StartsWith(AspireAppHostSdkName + "/", StringComparison.OrdinalIgnoreCase))
{
// Replace with new version
updatedSdks.Add($"{AspireAppHostSdkName}/{newVersion}");
}
else
{
updatedSdks.Add(trimmedSdk);
}
}

return string.Join(";", updatedSdks);
}

internal static async Task UpdateSdkVersionInAppHostAsync(FileInfo projectFile, NuGetPackageCli package, IInteractionService interactionService, SdkMigrationInfo migrationInfo)
{
if (string.Equals(projectFile.Extension, ".csproj", StringComparison.OrdinalIgnoreCase))
{
await UpdateSdkVersionInCsprojAppHostAsync(projectFile, package);

// Display migration feedback messages
if (migrationInfo.WillMigrateToNewFormat)
{
interactionService.DisplaySubtleMessage(string.Format(CultureInfo.InvariantCulture,
UpdateCommandStrings.MigratedToNewSdkFormat, package.Version));
}

if (migrationInfo.WillRemoveAppHostPackage)
{
interactionService.DisplaySubtleMessage(UpdateCommandStrings.RemovedObsoleteAppHostPackage);
}
}
else if (string.Equals(projectFile.Extension, ".cs", StringComparison.OrdinalIgnoreCase))
{
Expand All @@ -293,7 +394,7 @@ private static async Task UpdateSdkVersionInAppHostAsync(FileInfo projectFile, N
}
}

private static async Task UpdateSdkVersionInCsprojAppHostAsync(FileInfo projectFile, NuGetPackageCli package)
internal static async Task UpdateSdkVersionInCsprojAppHostAsync(FileInfo projectFile, NuGetPackageCli package)
{
var projectDocument = new XmlDocument();
projectDocument.PreserveWhitespace = true;
Expand All @@ -308,28 +409,104 @@ private static async Task UpdateSdkVersionInCsprojAppHostAsync(FileInfo projectF

// Check if the SDK is set via the Sdk attribute on the Project element (new format)
var sdkAttribute = projectNode.Attributes?["Sdk"];
if (sdkAttribute is not null && sdkAttribute.Value.StartsWith("Aspire.AppHost.Sdk", StringComparison.OrdinalIgnoreCase))
if (sdkAttribute is not null && ContainsAspireAppHostSdk(sdkAttribute.Value))
{
// New format: <Project Sdk="Aspire.AppHost.Sdk/version">
sdkAttribute.Value = $"Aspire.AppHost.Sdk/{package.Version}";
// Already using new format: <Project Sdk="Aspire.AppHost.Sdk/version">
// Update the version, preserving any other SDKs in the attribute
sdkAttribute.Value = UpdateAspireAppHostSdkVersion(sdkAttribute.Value, package.Version);
}
else
{
// Migrate from old format to new format
// Old format: <Sdk Name="Aspire.AppHost.Sdk" Version="..." />
var sdkNode = projectNode.SelectSingleNode("Sdk[@Name='Aspire.AppHost.Sdk']");
if (sdkNode is null)
{
throw new ProjectUpdaterException(string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.CouldNotFindSdkElementFormat, projectFile.FullName));
}

sdkNode.Attributes?["Version"]?.Value = package.Version;
// Set the new format: <Project Sdk="Aspire.AppHost.Sdk/version">
// The Aspire.AppHost.Sdk already includes the base .NET SDK, so we replace any existing SDK attribute
if (sdkAttribute is not null)
{
sdkAttribute.Value = $"{AspireAppHostSdkName}/{package.Version}";
}
else
{
var newSdkAttribute = projectDocument.CreateAttribute("Sdk");
newSdkAttribute.Value = $"{AspireAppHostSdkName}/{package.Version}";
projectNode.Attributes!.SetNamedItem(newSdkAttribute);
}

// Remove the old <Sdk Name="Aspire.AppHost.Sdk" /> element and any surrounding whitespace
RemoveNodeWithWhitespace(sdkNode);
}

// Remove the Aspire.Hosting.AppHost package reference if present (no longer needed with new SDK format)
RemovePackageReference(projectNode, "Aspire.Hosting.AppHost");

projectDocument.Save(projectFile.FullName);

await Task.CompletedTask;
}

private static void RemoveNodeWithWhitespace(XmlNode node)
{
var parent = node.ParentNode;
if (parent is null)
{
return;
}

// Remove preceding whitespace (text node with only whitespace)
var previousSibling = node.PreviousSibling;
if (previousSibling is XmlText textNode && string.IsNullOrWhiteSpace(textNode.Value))
{
parent.RemoveChild(previousSibling);
}

parent.RemoveChild(node);
}

private static void RemovePackageReference(XmlNode projectNode, string packageId)
{
var packageNode = projectNode.SelectSingleNode($"//PackageReference[@Include='{packageId}']");
if (packageNode?.ParentNode is null)
{
return;
}

var parentNode = packageNode.ParentNode;

// Remove the package reference and any preceding whitespace
RemoveNodeWithWhitespace(packageNode);

// If ItemGroup is now empty (only whitespace), remove it too
if (parentNode.Name == "ItemGroup" && IsEmptyOrWhitespace(parentNode))
{
RemoveNodeWithWhitespace(parentNode);
}
}

private static bool IsEmptyOrWhitespace(XmlNode node)
{
foreach (XmlNode child in node.ChildNodes)
{
if (child is XmlText textNode)
{
if (!string.IsNullOrWhiteSpace(textNode.Value))
{
return false;
}
}
else
{
return false;
}
}
return true;
}

private static async Task UpdateSdkVersionInSingleFileAppHostAsync(FileInfo projectFile, NuGetPackageCli package)
{
var fileContent = await File.ReadAllTextAsync(projectFile.FullName);
Expand Down Expand Up @@ -441,6 +618,12 @@ private async Task AnalyzeProjectAsync(FileInfo projectFile, UpdateContext conte

private static bool IsUpdatablePackage(string packageId)
{
// Skip Aspire.Hosting.AppHost - it's removed during SDK update (no longer needed with new SDK format)
if (string.Equals(packageId, "Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase))
{
return false;
}

return packageId.StartsWith("Aspire.");
}

Expand Down Expand Up @@ -920,3 +1103,5 @@ public ProjectUpdaterException(string message, System.Exception inner) : base(me
}

internal record CentralPackageManagementInfo(bool UsesCentralPackageManagement, FileInfo? DirectoryPackagesPropsFile);

internal record SdkMigrationInfo(bool WillMigrateToNewFormat, bool WillRemoveAppHostPackage);
2 changes: 2 additions & 0 deletions src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src/Aspire.Cli/Resources/UpdateCommandStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,10 @@
<data name="DotNetToolSelfUpdateMessage" xml:space="preserve">
<value>To update the Aspire CLI when installed as a .NET tool, run:</value>
</data>
<data name="MigratedToNewSdkFormat" xml:space="preserve">
<value>Migrated to new project format: &lt;Project Sdk="Aspire.AppHost.Sdk/{0}"&gt;</value>
</data>
<data name="RemovedObsoleteAppHostPackage" xml:space="preserve">
<value>Removed obsolete Aspire.Hosting.AppHost package reference</value>
</data>
</root>
10 changes: 10 additions & 0 deletions src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading