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}[/]");