diff --git a/src/Tools/CLI/Commands/UpgradeCommand.cs b/src/Tools/CLI/Commands/UpgradeCommand.cs new file mode 100644 index 0000000000..6249940308 --- /dev/null +++ b/src/Tools/CLI/Commands/UpgradeCommand.cs @@ -0,0 +1,180 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using FSH.CLI.Models; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace FSH.CLI.Commands; + +/// +/// Check for and apply FSH framework upgrades. +/// +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by Spectre.Console.Cli via reflection")] +internal sealed class UpgradeCommand : AsyncCommand +{ + [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by Spectre.Console.Cli via reflection")] + internal sealed class Settings : CommandSettings + { + [CommandOption("-p|--path")] + [Description("Path to the FSH project (defaults to current directory)")] + [DefaultValue(".")] + public string Path { get; set; } = "."; + + [CommandOption("--check")] + [Description("Check for available upgrades without applying")] + [DefaultValue(false)] + public bool CheckOnly { get; set; } + + [CommandOption("--apply")] + [Description("Apply available upgrades")] + [DefaultValue(false)] + public bool Apply { get; set; } + + [CommandOption("--skip-breaking")] + [Description("Skip breaking changes during upgrade")] + [DefaultValue(false)] + public bool SkipBreaking { get; set; } + + [CommandOption("--force")] + [Description("Force upgrade even if customizations detected")] + [DefaultValue(false)] + public bool Force { get; set; } + + [CommandOption("--dry-run")] + [Description("Show what would be changed without making modifications")] + [DefaultValue(false)] + public bool DryRun { get; set; } + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) + { + // Validate project has manifest + var manifest = FshManifest.TryLoad(settings.Path); + if (manifest == null) + { + AnsiConsole.MarkupLine("[red]Error:[/] No FSH project found at this location."); + AnsiConsole.MarkupLine("[dim]This command requires a project created with FSH CLI 10.0.0 or later.[/]"); + AnsiConsole.MarkupLine("[dim]The project must have a [yellow].fsh/manifest.json[/] file.[/]"); + return 1; + } + + // Show current status + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[blue]FSH Upgrade[/]"); + AnsiConsole.MarkupLine($"[dim]Project:[/] {Path.GetFullPath(settings.Path)}"); + AnsiConsole.MarkupLine($"[dim]Current version:[/] [yellow]{manifest.FshVersion}[/]"); + AnsiConsole.WriteLine(); + + // Determine mode + if (!settings.CheckOnly && !settings.Apply) + { + // Default: show help + ShowUsageHelp(); + return 0; + } + + if (settings.CheckOnly) + { + return await CheckForUpgradesAsync(manifest, settings, cancellationToken); + } + + if (settings.Apply) + { + return await ApplyUpgradesAsync(manifest, settings, cancellationToken); + } + + return 0; + } + + private static void ShowUsageHelp() + { + AnsiConsole.MarkupLine("[yellow]Usage:[/]"); + AnsiConsole.MarkupLine(" [green]fsh upgrade --check[/] Check for available upgrades"); + AnsiConsole.MarkupLine(" [green]fsh upgrade --apply[/] Apply available upgrades"); + AnsiConsole.MarkupLine(" [green]fsh upgrade --apply --skip-breaking[/] Apply safe updates only"); + AnsiConsole.MarkupLine(" [green]fsh upgrade --apply --dry-run[/] Preview changes without applying"); + AnsiConsole.WriteLine(); + } + + private static Task CheckForUpgradesAsync(FshManifest manifest, Settings settings, CancellationToken cancellationToken) + { + // TODO: Sprint 2 - Implement upgrade check + // 1. Fetch latest release info from GitHub API + // 2. Compare versions + // 3. Show available changes + + AnsiConsole.MarkupLine("[yellow]⚠ Upgrade check not yet implemented[/]"); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[dim]Coming in Sprint 2:[/]"); + AnsiConsole.MarkupLine("[dim] • GitHub API integration for release fetching[/]"); + AnsiConsole.MarkupLine("[dim] • Version comparison logic[/]"); + AnsiConsole.MarkupLine("[dim] • Package diff detection[/]"); + AnsiConsole.WriteLine(); + + // Placeholder output showing what it will look like + AnsiConsole.MarkupLine("[blue]Preview of planned output:[/]"); + AnsiConsole.WriteLine(); + + var panel = new Panel( + """ + [green]FSH Upgrade Check[/] + + Current: [yellow]10.0.0[/] + Latest: [green]10.1.0[/] + + [blue]Changes available:[/] + + BuildingBlocks/Web: + [green]+[/] Added RateLimitingMiddleware + [yellow]~[/] Modified ExceptionHandler (non-breaking) + + Modules/Identity: + [green]+[/] Added MFA support + [red]![/] Breaking: IUserService signature changed + + Directory.Packages.props: + [yellow]~[/] 12 package updates + + Run '[green]fsh upgrade --apply[/]' to upgrade. + Run '[green]fsh upgrade --apply --skip-breaking[/]' for safe updates only. + """) + { + Border = BoxBorder.Rounded, + Padding = new Padding(2, 1) + }; + + AnsiConsole.Write(panel); + AnsiConsole.WriteLine(); + + return Task.FromResult(0); + } + + private static 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 + + AnsiConsole.MarkupLine("[yellow]⚠ Upgrade apply not yet implemented[/]"); + 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[/]"); + AnsiConsole.WriteLine(); + + if (settings.DryRun) + { + AnsiConsole.MarkupLine("[dim]Dry run mode - no changes would be made[/]"); + } + + if (settings.SkipBreaking) + { + AnsiConsole.MarkupLine("[dim]Skip breaking mode - would skip breaking changes[/]"); + } + + return Task.FromResult(0); + } +} diff --git a/src/Tools/CLI/Commands/VersionCommand.cs b/src/Tools/CLI/Commands/VersionCommand.cs new file mode 100644 index 0000000000..2c5b8f3dcc --- /dev/null +++ b/src/Tools/CLI/Commands/VersionCommand.cs @@ -0,0 +1,136 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using FSH.CLI.Models; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace FSH.CLI.Commands; + +/// +/// Display CLI and project version information. +/// +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by Spectre.Console.Cli via reflection")] +internal sealed class VersionCommand : AsyncCommand +{ + [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by Spectre.Console.Cli via reflection")] + internal sealed class Settings : CommandSettings + { + [CommandOption("-p|--path")] + [Description("Path to the FSH project (defaults to current directory)")] + [DefaultValue(".")] + public string Path { get; set; } = "."; + + [CommandOption("--json")] + [Description("Output as JSON")] + [DefaultValue(false)] + public bool Json { get; set; } + } + + public override Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) + { + var cliVersion = GetCliVersion(); + var manifest = FshManifest.TryLoad(settings.Path); + + if (settings.Json) + { + OutputJson(cliVersion, manifest); + } + else + { + OutputTable(cliVersion, manifest, settings.Path); + } + + return Task.FromResult(0); + } + + private static void OutputTable(string cliVersion, FshManifest? manifest, string path) + { + AnsiConsole.WriteLine(); + + var table = new Table() + .Border(TableBorder.Rounded) + .AddColumn("[blue]Component[/]") + .AddColumn("[blue]Version[/]"); + + table.AddRow("FSH CLI", $"[green]{cliVersion}[/]"); + + if (manifest != null) + { + table.AddRow("Project FSH Version", $"[green]{manifest.FshVersion}[/]"); + table.AddRow("Project Created", $"[dim]{manifest.CreatedAt:yyyy-MM-dd HH:mm}[/]"); + table.AddRow("Project Type", $"[dim]{manifest.Options.Type}[/]"); + table.AddRow("Architecture", $"[dim]{manifest.Options.Architecture}[/]"); + table.AddRow("Database", $"[dim]{manifest.Options.Database}[/]"); + + if (manifest.Options.Modules.Count > 0) + { + table.AddRow("Modules", $"[dim]{string.Join(", ", manifest.Options.Modules)}[/]"); + } + + if (manifest.LastUpgradeAt.HasValue) + { + table.AddRow("Last Upgrade", $"[dim]{manifest.LastUpgradeAt:yyyy-MM-dd HH:mm}[/]"); + } + + // Show building blocks versions + AnsiConsole.Write(table); + + if (manifest.Tracking.BuildingBlocks.Count > 0) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[blue]Building Blocks:[/]"); + var bbTable = new Table() + .Border(TableBorder.Simple) + .AddColumn("Package") + .AddColumn("Version"); + + foreach (var (package, version) in manifest.Tracking.BuildingBlocks) + { + bbTable.AddRow($"[dim]{package}[/]", version); + } + AnsiConsole.Write(bbTable); + } + } + else + { + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[dim]No FSH project found at:[/] [yellow]{Path.GetFullPath(path)}[/]"); + AnsiConsole.MarkupLine("[dim]Run [green]fsh new[/] to create a new project.[/]"); + } + + AnsiConsole.WriteLine(); + } + + private static void OutputJson(string cliVersion, FshManifest? manifest) + { + var output = new + { + cliVersion, + project = manifest != null ? new + { + fshVersion = manifest.FshVersion, + createdAt = manifest.CreatedAt, + type = manifest.Options.Type, + architecture = manifest.Options.Architecture, + database = manifest.Options.Database, + modules = manifest.Options.Modules, + buildingBlocks = manifest.Tracking.BuildingBlocks, + lastUpgradeAt = manifest.LastUpgradeAt + } : null + }; + + AnsiConsole.WriteLine(System.Text.Json.JsonSerializer.Serialize(output, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase + })); + } + + private static string GetCliVersion() + { + var assembly = typeof(VersionCommand).Assembly; + var version = assembly.GetName().Version; + return version?.ToString(3) ?? "1.0.0"; + } +} diff --git a/src/Tools/CLI/Models/FshManifest.cs b/src/Tools/CLI/Models/FshManifest.cs new file mode 100644 index 0000000000..3971bded14 --- /dev/null +++ b/src/Tools/CLI/Models/FshManifest.cs @@ -0,0 +1,143 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FSH.CLI.Models; + +/// +/// Manifest file stored in .fsh/manifest.json to track project configuration and versions. +/// Used by the upgrade system to detect changes and apply updates. +/// +internal sealed class FshManifest +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + /// + /// Version of the FullStackHero framework packages used. + /// + [JsonPropertyName("fshVersion")] + public required string FshVersion { get; set; } + + /// + /// Timestamp when the project was created. + /// + [JsonPropertyName("createdAt")] + public required DateTimeOffset CreatedAt { get; set; } + + /// + /// Version of the CLI that created this project. + /// + [JsonPropertyName("cliVersion")] + public required string CliVersion { get; set; } + + /// + /// Project configuration options used during scaffolding. + /// + [JsonPropertyName("options")] + public required FshManifestOptions Options { get; set; } + + /// + /// Version tracking for building blocks and customizations. + /// + [JsonPropertyName("tracking")] + public required FshManifestTracking Tracking { get; set; } + + /// + /// Timestamp of the last upgrade applied. + /// + [JsonPropertyName("lastUpgradeAt")] + public DateTimeOffset? LastUpgradeAt { get; set; } + + /// + /// Serialize the manifest to JSON. + /// + public string ToJson() => JsonSerializer.Serialize(this, JsonOptions); + + /// + /// Deserialize a manifest from JSON. + /// + public static FshManifest? FromJson(string json) => JsonSerializer.Deserialize(json, JsonOptions); + + /// + /// Try to load a manifest from a project directory. + /// + public static FshManifest? TryLoad(string projectPath) + { + var manifestPath = Path.Combine(projectPath, ".fsh", "manifest.json"); + if (!File.Exists(manifestPath)) + return null; + + try + { + var json = File.ReadAllText(manifestPath); + return FromJson(json); + } + catch + { + return null; + } + } + + /// + /// Save the manifest to a project directory. + /// + public void Save(string projectPath) + { + var fshDir = Path.Combine(projectPath, ".fsh"); + Directory.CreateDirectory(fshDir); + File.WriteAllText(Path.Combine(fshDir, "manifest.json"), ToJson()); + } +} + +/// +/// Project configuration options stored in the manifest. +/// +internal sealed class FshManifestOptions +{ + [JsonPropertyName("type")] + public required string Type { get; set; } + + [JsonPropertyName("architecture")] + public required string Architecture { get; set; } + + [JsonPropertyName("database")] + public required string Database { get; set; } + + [JsonPropertyName("modules")] + public required List Modules { get; set; } + + [JsonPropertyName("includeDocker")] + public bool IncludeDocker { get; set; } + + [JsonPropertyName("includeAspire")] + public bool IncludeAspire { get; set; } + + [JsonPropertyName("includeTerraform")] + public bool IncludeTerraform { get; set; } + + [JsonPropertyName("includeGitHubActions")] + public bool IncludeGitHubActions { get; set; } +} + +/// +/// Version tracking information for upgrades. +/// +internal sealed class FshManifestTracking +{ + /// + /// Versions of each building block package. + /// + [JsonPropertyName("buildingBlocks")] + public required Dictionary BuildingBlocks { get; set; } + + /// + /// List of files that have been customized by the user (detected via hash comparison). + /// These files will be skipped or handled specially during upgrades. + /// + [JsonPropertyName("customizations")] + public List Customizations { get; set; } = []; +} diff --git a/src/Tools/CLI/Program.cs b/src/Tools/CLI/Program.cs index c3214e8c26..8fd838656f 100644 --- a/src/Tools/CLI/Program.cs +++ b/src/Tools/CLI/Program.cs @@ -14,6 +14,19 @@ .WithExample("new", "MyApp") .WithExample("new", "MyApp", "--preset", "quickstart") .WithExample("new", "MyApp", "--type", "api-blazor", "--arch", "monolith", "--db", "postgres"); + + config.AddCommand("version") + .WithDescription("Display CLI and project version information") + .WithExample("version") + .WithExample("version", "--path", "./MyApp") + .WithExample("version", "--json"); + + config.AddCommand("upgrade") + .WithDescription("Check for and apply FSH framework upgrades") + .WithExample("upgrade", "--check") + .WithExample("upgrade", "--apply") + .WithExample("upgrade", "--apply", "--skip-breaking") + .WithExample("upgrade", "--apply", "--dry-run"); }); return await app.RunAsync(args); diff --git a/src/Tools/CLI/Scaffolding/SolutionGenerator.cs b/src/Tools/CLI/Scaffolding/SolutionGenerator.cs index 15184bd49f..0a609ccc67 100644 --- a/src/Tools/CLI/Scaffolding/SolutionGenerator.cs +++ b/src/Tools/CLI/Scaffolding/SolutionGenerator.cs @@ -86,6 +86,10 @@ public static async Task GenerateAsync(ProjectOptions options, CancellationToken ConsoleTheme.WriteStep("Common files"); await CreateCommonFilesAsync(projectPath, options); + // Create FSH manifest for upgrade tracking + ConsoleTheme.WriteStep("FSH manifest"); + await CreateFshManifestAsync(projectPath, options); + // Initialize git repository if enabled if (options.InitializeGit) { @@ -104,6 +108,7 @@ private static Task CreateDirectoryStructureAsync(string projectPath, ProjectOpt { var directories = new List { + ".fsh", "src", $"src/{options.Name}.Api", $"src/{options.Name}.Api/Properties", @@ -421,6 +426,58 @@ private static async Task RunDotnetRestoreAsync(string projectPath, ProjectOptio } } + private static Task CreateFshManifestAsync(string projectPath, ProjectOptions options) + { + var cliVersion = GetCliVersion(); + var fshVersion = options.FrameworkVersion ?? cliVersion; + + // Determine which modules are included + var modules = new List { "identity", "multitenancy", "auditing" }; + if (options.IncludeSampleModule) + { + modules.Add("catalog"); + } + + var manifest = new FshManifest + { + FshVersion = fshVersion, + CreatedAt = DateTimeOffset.UtcNow, + CliVersion = cliVersion, + Options = new FshManifestOptions + { + Type = options.Type.ToString().ToLowerInvariant(), + Architecture = options.Architecture.ToString().ToLowerInvariant(), + Database = options.Database.ToString().ToLowerInvariant(), + Modules = modules, + IncludeDocker = options.IncludeDocker, + IncludeAspire = options.IncludeAspire, + IncludeTerraform = options.IncludeTerraform, + IncludeGitHubActions = options.IncludeGitHubActions + }, + Tracking = new FshManifestTracking + { + BuildingBlocks = new Dictionary + { + ["Core"] = fshVersion, + ["Web"] = fshVersion, + ["Persistence"] = fshVersion, + ["Infrastructure"] = fshVersion + }, + Customizations = [] + } + }; + + manifest.Save(projectPath); + return Task.CompletedTask; + } + + private static string GetCliVersion() + { + var assembly = typeof(SolutionGenerator).Assembly; + var version = assembly.GetName().Version; + return version?.ToString(3) ?? "10.0.0"; + } + private static void ShowNextSteps(ProjectOptions options) { ConsoleTheme.WriteDone($"Created [bold]{options.Name}[/]");