From 4d2bd1b08d59885a537323522ff08e7981334190 Mon Sep 17 00:00:00 2001 From: jarvis Date: Tue, 27 Jan 2026 03:27:41 +0000 Subject: [PATCH] feat(cli): implement upgrade apply (Sprint 3) - Add PackageUpdater service for updating Directory.Packages.props - Implement 'fsh upgrade --apply' functionality: - Fetches latest release and compares versions - Creates backup before making changes - Updates package versions in Directory.Packages.props - Adds new packages from latest release - Shows warnings for removed packages (manual review) - Updates manifest with new version and timestamp - Supports --dry-run for preview without changes - Supports --skip-breaking to skip breaking changes - Supports --force to skip confirmation - Offers rollback on failure Sprint 3 deliverables: - [x] Package version updater - [x] Safe (non-breaking) auto-apply with --skip-breaking - [x] Backup and restore functionality - [x] Interactive confirmation (skippable with --force) - [x] Dry run mode --- src/Tools/CLI/Commands/UpgradeCommand.cs | 274 +++++++++++++++++++++-- src/Tools/CLI/Services/PackageUpdater.cs | 260 +++++++++++++++++++++ 2 files changed, 521 insertions(+), 13 deletions(-) create mode 100644 src/Tools/CLI/Services/PackageUpdater.cs diff --git a/src/Tools/CLI/Commands/UpgradeCommand.cs b/src/Tools/CLI/Commands/UpgradeCommand.cs index 860a87461..e7d420ea0 100644 --- a/src/Tools/CLI/Commands/UpgradeCommand.cs +++ b/src/Tools/CLI/Commands/UpgradeCommand.cs @@ -255,28 +255,276 @@ await AnsiConsole.Status() private static async Task ApplyUpgradesAsync(FshManifest manifest, Settings settings, CancellationToken cancellationToken) { - // TODO: Sprint 3 - Implement upgrade apply - // 1. Fetch latest release - // 2. Update Directory.Packages.props - // 3. For code changes, show diff and ask confirmation - // 4. Update manifest with new versions + using var githubService = new GitHubReleaseService(); + + // Fetch latest release + GitHubRelease? latestRelease = null; + await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Fetching latest release...", async ctx => + { + if (settings.IncludePrerelease) + { + var releases = await githubService.GetReleasesAsync(10, cancellationToken); + latestRelease = releases.FirstOrDefault(); + } + else + { + latestRelease = await githubService.GetLatestReleaseAsync(cancellationToken); + } + }); + + if (latestRelease == null) + { + AnsiConsole.MarkupLine("[red]Error:[/] Could not fetch release information from GitHub."); + return 1; + } + + var latestVersion = latestRelease.Version; + var comparison = VersionComparer.CompareVersions(manifest.FshVersion, latestVersion); + + if (comparison >= 0) + { + AnsiConsole.MarkupLine("[green]✓[/] Already up to date!"); + return 0; + } + + AnsiConsole.MarkupLine($"[dim]Upgrading:[/] [yellow]{manifest.FshVersion}[/] → [green]{latestVersion}[/]"); + AnsiConsole.WriteLine(); + + // Get package diff + var currentPackagesProps = await GetLocalPackagesPropsAsync(settings.Path); + var latestPackagesProps = await githubService.GetPackagesPropsAsync(latestRelease.TagName, cancellationToken); + + if (currentPackagesProps == null) + { + AnsiConsole.MarkupLine("[red]Error:[/] Could not read Directory.Packages.props"); + return 1; + } + + if (latestPackagesProps == null) + { + AnsiConsole.MarkupLine("[red]Error:[/] Could not fetch latest Directory.Packages.props from GitHub"); + return 1; + } + + var currentVersions = VersionComparer.ParsePackagesProps(currentPackagesProps); + var latestVersions = VersionComparer.ParsePackagesProps(latestPackagesProps); + var diff = VersionComparer.Compare(currentVersions, latestVersions); + + if (!diff.HasChanges) + { + AnsiConsole.MarkupLine("[dim]No package changes detected.[/]"); + + // Still update manifest version + if (!settings.DryRun) + { + await PackageUpdater.UpdateManifestAsync(settings.Path, latestVersion, cancellationToken); + AnsiConsole.MarkupLine("[green]✓[/] Updated manifest version."); + } + return 0; + } - AnsiConsole.MarkupLine("[yellow]⚠ Upgrade apply not yet implemented[/]"); + // Show what will be changed + AnsiConsole.MarkupLine("[blue]Changes to apply:[/]"); AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[dim]Coming in Sprint 3:[/]"); - AnsiConsole.MarkupLine("[dim] • Package version updater[/]"); - AnsiConsole.MarkupLine("[dim] • Safe (non-breaking) auto-apply[/]"); - AnsiConsole.MarkupLine("[dim] • Interactive diff viewer[/]"); + + if (diff.Updated.Count > 0) + { + var updateTable = new Table() + .Border(TableBorder.Simple) + .AddColumn("Package") + .AddColumn("From") + .AddColumn("To") + .AddColumn("Status"); + + foreach (var update in diff.Updated.OrderBy(u => u.Package)) + { + var willSkip = settings.SkipBreaking && update.IsBreaking; + + string status; + if (!update.IsBreaking) + status = "[green]Safe[/]"; + else if (willSkip) + status = "[yellow]Skip (breaking)[/]"; + else + status = "[red]Breaking[/]"; + + var packageName = willSkip ? $"[strikethrough dim]{update.Package}[/]" : update.Package; + + updateTable.AddRow( + packageName, + update.FromVersion, + $"[green]{update.ToVersion}[/]", + status); + } + + AnsiConsole.Write(updateTable); + } + + if (diff.Added.Count > 0) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[green]+[/] {diff.Added.Count} new packages will be added"); + } + + if (diff.Removed.Count > 0) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[yellow]![/] {diff.Removed.Count} packages are no longer in the latest release (manual review needed)"); + } + AnsiConsole.WriteLine(); + // Dry run mode - stop here if (settings.DryRun) { - AnsiConsole.MarkupLine("[dim]Dry run mode - no changes would be made[/]"); + AnsiConsole.MarkupLine("[yellow]Dry run mode[/] - no changes were made."); + return 0; } - if (settings.SkipBreaking) + // Confirm unless forced + if (!settings.Force) { - AnsiConsole.MarkupLine("[dim]Skip breaking mode - would skip breaking changes[/]"); + var confirm = await AnsiConsole.ConfirmAsync("Apply these changes?", false, cancellationToken); + if (!confirm) + { + AnsiConsole.MarkupLine("[dim]Cancelled.[/]"); + return 0; + } + } + + // Create backup + AnsiConsole.MarkupLine("[dim]Creating backup...[/]"); + var backupPath = await PackageUpdater.CreateBackupAsync(settings.Path, cancellationToken); + + if (backupPath == null) + { + AnsiConsole.MarkupLine("[yellow]⚠[/] Could not create backup. Continue anyway?"); + if (!settings.Force) + { + var continueAnyway = await AnsiConsole.ConfirmAsync("Continue without backup?", false, cancellationToken); + if (!continueAnyway) + { + AnsiConsole.MarkupLine("[dim]Cancelled.[/]"); + return 0; + } + } + } + else + { + AnsiConsole.MarkupLine($"[dim]Backup created:[/] {backupPath}"); + } + + // Apply updates + var updateOptions = new UpdateOptions + { + DryRun = false, + SkipBreaking = settings.SkipBreaking, + Force = settings.Force + }; + + UpdateResult result; + await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Applying updates...", async ctx => + { + result = await PackageUpdater.UpdatePackagesPropsAsync( + settings.Path, + diff, + updateOptions, + cancellationToken); + }); + + result = await PackageUpdater.UpdatePackagesPropsAsync( + settings.Path, + diff, + updateOptions, + cancellationToken); + + // Show results + AnsiConsole.WriteLine(); + + if (result.Success) + { + AnsiConsole.MarkupLine("[green]✓[/] Packages updated successfully!"); + AnsiConsole.WriteLine(); + + if (result.Updated.Count > 0) + { + AnsiConsole.MarkupLine($"[green]Updated:[/] {result.Updated.Count} packages"); + } + + if (result.Added.Count > 0) + { + AnsiConsole.MarkupLine($"[green]Added:[/] {result.Added.Count} packages"); + } + + if (result.Skipped.Count > 0) + { + AnsiConsole.MarkupLine($"[yellow]Skipped:[/] {result.Skipped.Count} packages (breaking changes)"); + } + + // Update manifest + var manifestUpdated = await PackageUpdater.UpdateManifestAsync(settings.Path, latestVersion, cancellationToken); + if (manifestUpdated) + { + AnsiConsole.MarkupLine("[green]✓[/] Manifest updated."); + } + + // Show warnings + if (result.Warnings.Count > 0) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[yellow]Warnings:[/]"); + foreach (var warning in result.Warnings) + { + AnsiConsole.MarkupLine($" [yellow]![/] {warning}"); + } + } + + // Next steps + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[dim]Next steps:[/]"); + AnsiConsole.MarkupLine(" 1. Run [green]dotnet restore[/] to restore packages"); + AnsiConsole.MarkupLine(" 2. Run [green]dotnet build[/] to verify the upgrade"); + AnsiConsole.MarkupLine(" 3. Review and test your application"); + + if (backupPath != null) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[dim]To rollback:[/] restore from {backupPath}"); + } + } + else + { + AnsiConsole.MarkupLine("[red]✗[/] Upgrade failed!"); + + foreach (var error in result.Errors) + { + AnsiConsole.MarkupLine($" [red]Error:[/] {error}"); + } + + // Offer to restore backup + if (backupPath != null) + { + AnsiConsole.WriteLine(); + var restore = await AnsiConsole.ConfirmAsync("Restore from backup?", true, cancellationToken); + if (restore) + { + var restored = await PackageUpdater.RestoreBackupAsync(backupPath, cancellationToken); + if (restored) + { + AnsiConsole.MarkupLine("[green]✓[/] Restored from backup."); + } + else + { + AnsiConsole.MarkupLine($"[red]✗[/] Could not restore. Manual restore needed from: {backupPath}"); + } + } + } + + return 1; } return 0; diff --git a/src/Tools/CLI/Services/PackageUpdater.cs b/src/Tools/CLI/Services/PackageUpdater.cs new file mode 100644 index 000000000..c9190e00d --- /dev/null +++ b/src/Tools/CLI/Services/PackageUpdater.cs @@ -0,0 +1,260 @@ +using System.Xml.Linq; +using FSH.CLI.Models; + +namespace FSH.CLI.Services; + +/// +/// Service for updating package versions in a project. +/// +internal sealed class PackageUpdater +{ + /// + /// Update Directory.Packages.props with new package versions. + /// + public static async Task UpdatePackagesPropsAsync( + string projectPath, + VersionDiff diff, + UpdateOptions options, + CancellationToken cancellationToken = default) + { + var result = new UpdateResult(); + var packagesPropsPath = FindPackagesPropsPath(projectPath); + + if (packagesPropsPath == null) + { + result.Errors.Add("Could not find Directory.Packages.props"); + return result; + } + + if (options.DryRun) + { + // Just simulate what would happen + return SimulateUpdate(diff, options); + } + + try + { + // Read current file + var content = await File.ReadAllTextAsync(packagesPropsPath, cancellationToken); + var doc = XDocument.Parse(content); + + // Apply updates + foreach (var update in diff.Updated) + { + if (options.SkipBreaking && update.IsBreaking) + { + result.Skipped.Add($"{update.Package} (breaking change)"); + continue; + } + + var packageElement = doc.Descendants("PackageVersion") + .FirstOrDefault(e => string.Equals( + e.Attribute("Include")?.Value, + update.Package, + StringComparison.OrdinalIgnoreCase)); + + if (packageElement != null) + { + var versionAttr = packageElement.Attribute("Version"); + if (versionAttr != null) + { + versionAttr.Value = update.ToVersion; + result.Updated.Add($"{update.Package}: {update.FromVersion} → {update.ToVersion}"); + } + } + } + + // Add new packages + var itemGroup = doc.Descendants("ItemGroup") + .FirstOrDefault(ig => ig.Elements("PackageVersion").Any()); + + if (itemGroup != null) + { + foreach (var added in diff.Added) + { + // Check if package already exists + var exists = doc.Descendants("PackageVersion") + .Any(e => string.Equals( + e.Attribute("Include")?.Value, + added.Package, + StringComparison.OrdinalIgnoreCase)); + + if (!exists) + { + var newElement = new XElement("PackageVersion", + new XAttribute("Include", added.Package), + new XAttribute("Version", added.Version)); + itemGroup.Add(newElement); + result.Added.Add($"{added.Package} ({added.Version})"); + } + } + } + + // Note: We don't automatically remove packages as that could break the project + foreach (var removed in diff.Removed) + { + result.Warnings.Add($"Package {removed.Package} is no longer in the latest release. Consider removing it manually if not needed."); + } + + // Save the updated file + await File.WriteAllTextAsync(packagesPropsPath, doc.ToString(), cancellationToken); + result.Success = true; + } + catch (Exception ex) + { + result.Errors.Add($"Failed to update packages: {ex.Message}"); + } + + return result; + } + + /// + /// Update the FSH manifest after a successful upgrade. + /// + public static async Task UpdateManifestAsync( + string projectPath, + string newVersion, + CancellationToken cancellationToken = default) + { + var manifest = FshManifest.TryLoad(projectPath); + if (manifest == null) + return false; + + manifest.FshVersion = newVersion; + manifest.LastUpgradeAt = DateTimeOffset.UtcNow; + + // Update building blocks versions + foreach (var key in manifest.Tracking.BuildingBlocks.Keys.ToList()) + { + manifest.Tracking.BuildingBlocks[key] = newVersion; + } + + try + { + manifest.Save(projectPath); + return true; + } + catch + { + return false; + } + } + + /// + /// Create a backup of Directory.Packages.props before updating. + /// + public static async Task CreateBackupAsync(string projectPath, CancellationToken cancellationToken = default) + { + var packagesPropsPath = FindPackagesPropsPath(projectPath); + if (packagesPropsPath == null) + return null; + + var backupPath = packagesPropsPath + $".backup.{DateTime.UtcNow:yyyyMMddHHmmss}"; + + try + { + await File.WriteAllTextAsync( + backupPath, + await File.ReadAllTextAsync(packagesPropsPath, cancellationToken), + cancellationToken); + return backupPath; + } + catch + { + return null; + } + } + + /// + /// Restore from a backup file. + /// + public static async Task RestoreBackupAsync(string backupPath, CancellationToken cancellationToken = default) + { + if (!File.Exists(backupPath)) + return false; + + var originalPath = backupPath.Split(".backup.")[0]; + + try + { + await File.WriteAllTextAsync( + originalPath, + await File.ReadAllTextAsync(backupPath, cancellationToken), + cancellationToken); + File.Delete(backupPath); + return true; + } + catch + { + return false; + } + } + + private static string? FindPackagesPropsPath(string projectPath) + { + var srcPath = Path.Combine(projectPath, "src", "Directory.Packages.props"); + if (File.Exists(srcPath)) + return srcPath; + + var rootPath = Path.Combine(projectPath, "Directory.Packages.props"); + if (File.Exists(rootPath)) + return rootPath; + + return null; + } + + private static UpdateResult SimulateUpdate(VersionDiff diff, UpdateOptions options) + { + var result = new UpdateResult { Success = true, IsDryRun = true }; + + foreach (var update in diff.Updated) + { + if (options.SkipBreaking && update.IsBreaking) + { + result.Skipped.Add($"{update.Package} (breaking change)"); + } + else + { + result.Updated.Add($"{update.Package}: {update.FromVersion} → {update.ToVersion}"); + } + } + + foreach (var added in diff.Added) + { + result.Added.Add($"{added.Package} ({added.Version})"); + } + + foreach (var removed in diff.Removed) + { + result.Warnings.Add($"Package {removed.Package} would need manual removal if not needed."); + } + + return result; + } +} + +/// +/// Options for the package update operation. +/// +internal sealed class UpdateOptions +{ + public bool DryRun { get; set; } + public bool SkipBreaking { get; set; } + public bool Force { get; set; } +} + +/// +/// Result of a package update operation. +/// +internal sealed class UpdateResult +{ + public bool Success { get; set; } + public bool IsDryRun { get; set; } + public List Updated { get; } = []; + public List Added { get; } = []; + public List Skipped { get; } = []; + public List Warnings { get; } = []; + public List Errors { get; } = []; + + public bool HasChanges => Updated.Count > 0 || Added.Count > 0; +}