diff --git a/Source/Cli.Specs/for_CliUpdate/when_detecting_strategy.cs b/Source/Cli.Specs/for_CliUpdate/when_detecting_strategy.cs new file mode 100644 index 0000000..a25967b --- /dev/null +++ b/Source/Cli.Specs/for_CliUpdate/when_detecting_strategy.cs @@ -0,0 +1,70 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_CliUpdate; + +public class when_detecting_strategy : Specification +{ + [Fact] + void should_detect_dotnet_tool_from_path() + { + var strategy = CliUpdate.DetectStrategy( + "/home/user/.dotnet/tools/cratis", + "/home/user/.dotnet/tools/.store/cratis.cli/", + isNativeBuild: false, + isMacOS: false, + isLinux: true); + + strategy.ShouldEqual(CliUpdateStrategy.DotNetTool); + } + + [Fact] + void should_detect_homebrew_when_native_on_macos() + { + var strategy = CliUpdate.DetectStrategy( + "/opt/homebrew/Cellar/cratis/1.2.3/bin/cratis", + "/opt/homebrew/Cellar/cratis/1.2.3/bin/", + isNativeBuild: true, + isMacOS: true, + isLinux: false); + + strategy.ShouldEqual(CliUpdateStrategy.Homebrew); + } + + [Fact] + void should_detect_manual_linux_when_native_on_linux() + { + var strategy = CliUpdate.DetectStrategy( + "/usr/local/bin/cratis", + "/usr/local/bin/", + isNativeBuild: true, + isMacOS: false, + isLinux: true); + + strategy.ShouldEqual(CliUpdateStrategy.ManualLinux); + } + + [Fact] + void should_use_cratis_update_hint_for_auto_update_strategies() + { + var hint = CliUpdate.GetUpdateHint(CliUpdateStrategy.Homebrew, "1.0.0", "1.1.0"); + hint.ShouldContain("run 'cratis update'"); + hint.ShouldContain("1.0.0 -> 1.1.0"); + } + + [Fact] + void should_prepare_brew_update_before_upgrade_for_homebrew() + { + var startInfo = CliUpdate.CreatePreUpdateProcessStartInfo(CliUpdateStrategy.Homebrew); + startInfo.ShouldNotBeNull(); + startInfo!.FileName.ShouldEqual("brew"); + startInfo.Arguments.ShouldEqual("update"); + } + + [Fact] + void should_not_prepare_brew_update_when_target_version_is_set() + { + var startInfo = CliUpdate.CreatePreUpdateProcessStartInfo(CliUpdateStrategy.Homebrew, "1.2.3"); + startInfo.ShouldBeNull(); + } +} diff --git a/Source/Cli/Cli.csproj b/Source/Cli/Cli.csproj index 1bac8ee..008d56a 100644 --- a/Source/Cli/Cli.csproj +++ b/Source/Cli/Cli.csproj @@ -14,6 +14,10 @@ README.md + + $(DefineConstants);CRATIS_NATIVE + + diff --git a/Source/Cli/CliUpdate.cs b/Source/Cli/CliUpdate.cs new file mode 100644 index 0000000..faf37c4 --- /dev/null +++ b/Source/Cli/CliUpdate.cs @@ -0,0 +1,240 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Diagnostics; + +namespace Cratis.Cli; + +/// +/// Represents the update strategy for the current CLI installation. +/// +public enum CliUpdateStrategy +{ + /// + /// Update by running dotnet tool update -g Cratis.Cli. + /// + DotNetTool, + + /// + /// Update by running brew upgrade cratis. + /// + Homebrew, + + /// + /// Update manually by downloading and replacing the Linux native binary. + /// + ManualLinux, + + /// + /// Update manually using the installation mechanism used by the user. + /// + Manual +} + +/// +/// Provides update-strategy detection and update-related command guidance. +/// +public static class CliUpdate +{ +#if CRATIS_NATIVE + const bool IsNativeBuild = true; +#else + const bool IsNativeBuild = false; +#endif + + const string PackageId = "Cratis.Cli"; + + /// + /// Detects how this CLI instance should be updated. + /// + /// The update strategy. + public static CliUpdateStrategy DetectStrategy() + { + var processPath = GetEffectiveProcessPath(); + var baseDirectory = AppContext.BaseDirectory; + return DetectStrategy( + processPath, + baseDirectory, + IsNativeBuild, + OperatingSystem.IsMacOS(), + OperatingSystem.IsLinux()); + } + + /// + /// Creates process launch settings that should run before the main update command. + /// + /// The detected update strategy. + /// Optional target version. + /// A process start info for supported auto-update paths, otherwise null. + public static ProcessStartInfo? CreatePreUpdateProcessStartInfo(CliUpdateStrategy strategy, string? targetVersion = null) + { + if (strategy != CliUpdateStrategy.Homebrew || !string.IsNullOrWhiteSpace(targetVersion)) + { + return null; + } + + return new ProcessStartInfo + { + FileName = "brew", + Arguments = "update", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + } + + /// + /// Creates the process launch settings for automatic update, when available. + /// + /// The detected update strategy. + /// Optional target version. + /// A process start info for supported auto-update paths, otherwise null. + public static ProcessStartInfo? CreateUpdateProcessStartInfo(CliUpdateStrategy strategy, string? targetVersion = null) + { + if (strategy == CliUpdateStrategy.DotNetTool) + { + var arguments = $"tool update -g {PackageId}"; + if (!string.IsNullOrWhiteSpace(targetVersion)) + { + arguments += $" --version {targetVersion}"; + } + + return new ProcessStartInfo + { + FileName = "dotnet", + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + } + + if (strategy == CliUpdateStrategy.Homebrew) + { + if (!string.IsNullOrWhiteSpace(targetVersion)) + { + return null; + } + + return new ProcessStartInfo + { + FileName = "brew", + Arguments = "upgrade cratis", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + } + + return null; + } + + /// + /// Gets manual update guidance for non-automatic update strategies. + /// + /// The detected strategy. + /// Manual guidance text, or null when auto-update is supported. + public static string? GetManualUpdateInstructions(CliUpdateStrategy strategy) => + strategy switch + { + CliUpdateStrategy.ManualLinux => + "Manual update (Linux):\n" + + "curl -Lo cratis.tar.gz https://github.com/Cratis/cli/releases/latest/download/cratis-linux-x64.tar.gz\n" + + "# arm64: curl -Lo cratis.tar.gz https://github.com/Cratis/cli/releases/latest/download/cratis-linux-arm64.tar.gz\n" + + "tar -xzf cratis.tar.gz\n" + + "sudo mv cratis /usr/local/bin/cratis", + CliUpdateStrategy.Manual => "This installation method cannot be auto-updated by the CLI. Please upgrade using the same method you used to install it.", + _ => null + }; + + /// + /// Gets update hint text for interactive output when a newer version is available. + /// + /// The detected strategy. + /// Current version. + /// Latest available version. + /// A user-facing hint message. + public static string GetUpdateHint(CliUpdateStrategy strategy, string currentVersion, string latestVersion) + { + var baseMessage = $"Update available: {currentVersion} -> {latestVersion}"; + return strategy switch + { + CliUpdateStrategy.ManualLinux => $"{baseMessage} — manual update required (download the latest Linux tarball and replace the 'cratis' binary)", + CliUpdateStrategy.Manual => $"{baseMessage} — manual update required (use your original installation method)", + _ => $"{baseMessage} — run 'cratis update'" + }; + } + + internal static CliUpdateStrategy DetectStrategy( + string? processPath, + string? baseDirectory, + bool isNativeBuild, + bool isMacOS, + bool isLinux) + { + if (IsDotNetToolPath(processPath) || IsDotNetToolPath(baseDirectory)) + { + return CliUpdateStrategy.DotNetTool; + } + + if (!isNativeBuild) + { + return CliUpdateStrategy.DotNetTool; + } + + if (isMacOS && IsHomebrewPath(processPath)) + { + return CliUpdateStrategy.Homebrew; + } + + if (isLinux) + { + return CliUpdateStrategy.ManualLinux; + } + + return CliUpdateStrategy.Manual; + } + + static bool IsDotNetToolPath(string? path) => + !string.IsNullOrWhiteSpace(path) && + (path.Contains(".dotnet/tools", StringComparison.OrdinalIgnoreCase) || + path.Contains(".dotnet\\tools", StringComparison.OrdinalIgnoreCase)); + + static bool IsHomebrewPath(string? path) => + !string.IsNullOrWhiteSpace(path) && + (path.Contains("/Cellar/cratis/", StringComparison.OrdinalIgnoreCase) || + path.Contains("/Homebrew/Cellar/cratis/", StringComparison.OrdinalIgnoreCase)); + + static string? GetEffectiveProcessPath() + { + var path = Environment.ProcessPath; + if (string.IsNullOrWhiteSpace(path)) + { + return path; + } + + var currentPath = path; + for (var i = 0; i < 8; i++) + { + try + { + var linkTarget = File.ResolveLinkTarget(currentPath, false); + if (linkTarget is null) + { + return currentPath; + } + + currentPath = linkTarget.FullName; + } + catch + { + return currentPath; + } + } + + return currentPath; + } +} diff --git a/Source/Cli/Commands/Chronicle/ChronicleCommand.cs b/Source/Cli/Commands/Chronicle/ChronicleCommand.cs index 779d351..2a14aa4 100644 --- a/Source/Cli/Commands/Chronicle/ChronicleCommand.cs +++ b/Source/Cli/Commands/Chronicle/ChronicleCommand.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using System.Net.Sockets; using System.Text.RegularExpressions; -using Cratis.Cli.Commands.Version; using Grpc.Core; namespace Cratis.Cli.Commands.Chronicle; @@ -29,11 +28,6 @@ protected sealed override async Task ExecuteAsync(CommandContext context, T WriteDebugInfo(settings); } - // Start update check in the background — never blocks the command. - using var updateCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - updateCts.CancelAfter(TimeSpan.FromSeconds(5)); - var updateCheckTask = UpdateChecker.CheckForUpdate(VersionCommand.GetCliVersion(), updateCts.Token); - var tokenRefreshAttempted = false; while (true) { @@ -65,7 +59,6 @@ protected sealed override async Task ExecuteAsync(CommandContext context, T await Console.Error.WriteLineAsync($"[debug] command completed in {sw.ElapsedMilliseconds}ms, exit code {exitCode}"); } - await ShowUpdateHint(updateCheckTask, format); return exitCode; } catch (RpcException ex) when (!tokenRefreshAttempted && IsHttpUnauthorized(ex)) @@ -179,31 +172,4 @@ static bool IsNetworkException(Exception? ex) return false; } - - static async Task ShowUpdateHint(Task updateCheckTask, string format) - { - try - { - if (!updateCheckTask.IsCompleted) - { - return; - } - - var latestVersion = await updateCheckTask; - if (latestVersion is null) - { - return; - } - - if (string.Equals(format, OutputFormats.Table, StringComparison.Ordinal)) - { - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($" [{OutputFormatter.Warning.ToMarkup()}]\u2191 Update available:[/] {latestVersion.EscapeMarkup()} \u2014 run [bold]cratis update[/] to upgrade"); - } - } - catch - { - // Update check failures are non-critical. - } - } } diff --git a/Source/Cli/Commands/Version/SelfUpdateCommand.cs b/Source/Cli/Commands/Version/SelfUpdateCommand.cs index ad8e9bb..c6543df 100644 --- a/Source/Cli/Commands/Version/SelfUpdateCommand.cs +++ b/Source/Cli/Commands/Version/SelfUpdateCommand.cs @@ -6,9 +6,9 @@ namespace Cratis.Cli.Commands.Version; /// -/// Updates the Cratis CLI to the latest (or a specific) version using dotnet tool update. +/// Updates the Cratis CLI to the latest version using the detected installation method. /// -[LlmDescription("Updates the cratis CLI to the latest version from NuGet. Use instead of remembering the NuGet package name.")] +[LlmDescription("Updates the cratis CLI to the latest version using the appropriate installation method (dotnet tool or Homebrew).")] [CliCommand("update", "Update the Cratis CLI to the latest version")] [CliExample("update")] [CliExample("update", "--version", "1.2.3")] @@ -16,51 +16,60 @@ namespace Cratis.Cli.Commands.Version; [LlmOption("--version", "string", "Specific version to install (default: latest)")] public class SelfUpdateCommand : AsyncCommand { - const string PackageId = "Cratis.Cli"; - /// protected override async Task ExecuteAsync(CommandContext context, SelfUpdateSettings settings, CancellationToken cancellationToken) { var format = ResolveFormat(settings.Output); var currentVersion = VersionCommand.GetCliVersion(); - - var arguments = $"tool update -g {PackageId}"; - if (!string.IsNullOrWhiteSpace(settings.TargetVersion)) - { - arguments += $" --version {settings.TargetVersion}"; - } + var strategy = CliUpdate.DetectStrategy(); if (string.Equals(format, OutputFormats.Table, StringComparison.Ordinal)) { AnsiConsole.MarkupLine($"[bold]Updating Cratis CLI...[/] (current: {currentVersion.EscapeMarkup()})"); } - var startInfo = new ProcessStartInfo - { - FileName = "dotnet", - Arguments = arguments, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = Process.Start(startInfo); - if (process is null) + var preUpdateStartInfo = CliUpdate.CreatePreUpdateProcessStartInfo(strategy, settings.TargetVersion); + if (preUpdateStartInfo is not null) { - OutputFormatter.WriteError(format, "Failed to start dotnet process", "Ensure the .NET SDK is installed and 'dotnet' is on your PATH", ExitCodes.ServerErrorCode); - return ExitCodes.ServerError; + var preUpdateResult = await RunProcess(preUpdateStartInfo); + if (preUpdateResult is not null) + { + return preUpdateResult.Value; + } } - var stdout = await process.StandardOutput.ReadToEndAsync(); - var stderr = await process.StandardError.ReadToEndAsync(); - await process.WaitForExitAsync(); + var startInfo = CliUpdate.CreateUpdateProcessStartInfo(strategy, settings.TargetVersion); + if (startInfo is null) + { + if (!string.IsNullOrWhiteSpace(settings.TargetVersion) && strategy == CliUpdateStrategy.Homebrew) + { + OutputFormatter.WriteError(format, "Target version is not supported for Homebrew updates", "Run 'cratis update' to upgrade to the latest Homebrew version", ExitCodes.ValidationErrorCode); + return ExitCodes.ValidationError; + } + + var instructions = CliUpdate.GetManualUpdateInstructions(strategy) ?? "Manual update required."; + if (string.Equals(format, OutputFormats.Json, StringComparison.Ordinal) || string.Equals(format, OutputFormats.JsonCompact, StringComparison.Ordinal)) + { + OutputFormatter.WriteObject(format, new + { + PreviousVersion = currentVersion, + CurrentVersion = currentVersion, + Updated = false, + Strategy = strategy.ToString(), + Message = instructions + }); + } + else + { + AnsiConsole.MarkupLine($"[yellow]{instructions.EscapeMarkup()}[/]"); + } + return ExitCodes.Success; + } - if (process.ExitCode != 0) + var updateResult = await RunProcess(startInfo); + if (updateResult is not null) { - var errorMessage = !string.IsNullOrWhiteSpace(stderr) ? stderr.Trim() : stdout.Trim(); - OutputFormatter.WriteError(format, $"Update failed: {errorMessage}", errorCode: ExitCodes.ServerErrorCode); - return ExitCodes.ServerError; + return updateResult.Value; } var newVersion = VersionCommand.GetCliVersion(); @@ -84,6 +93,35 @@ protected override async Task ExecuteAsync(CommandContext context, SelfUpda } return ExitCodes.Success; + + async Task RunProcess(ProcessStartInfo processStartInfo) + { + using var process = Process.Start(processStartInfo); + if (process is null) + { + var hint = strategy switch + { + CliUpdateStrategy.DotNetTool => "Ensure the .NET SDK is installed and 'dotnet' is on your PATH", + CliUpdateStrategy.Homebrew => "Ensure Homebrew is installed and 'brew' is on your PATH", + _ => null + }; + OutputFormatter.WriteError(format, "Failed to start update process", hint, ExitCodes.ServerErrorCode); + return ExitCodes.ServerError; + } + + var stdout = await process.StandardOutput.ReadToEndAsync(); + var stderr = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + if (process.ExitCode != 0) + { + var errorMessage = !string.IsNullOrWhiteSpace(stderr) ? stderr.Trim() : stdout.Trim(); + OutputFormatter.WriteError(format, $"Update failed: {errorMessage}", errorCode: ExitCodes.ServerErrorCode); + return ExitCodes.ServerError; + } + + return null; + } } static string ResolveFormat(string output) diff --git a/Source/Cli/Commands/Version/VersionCommand.cs b/Source/Cli/Commands/Version/VersionCommand.cs index 33cf5e5..a28159a 100644 --- a/Source/Cli/Commands/Version/VersionCommand.cs +++ b/Source/Cli/Commands/Version/VersionCommand.cs @@ -113,7 +113,7 @@ protected override async Task ExecuteAsync(CommandContext context, Chronicl if (latestCli is not null) { - AnsiConsole.MarkupLine($"[yellow]Update available:[/] {latestCli.EscapeMarkup()} (run 'cratis update' to upgrade)"); + AnsiConsole.MarkupLine($"[yellow]Update available:[/] {cliVersion.EscapeMarkup()} -> {latestCli.EscapeMarkup()} (run 'cratis update' to upgrade)"); } if (serverInfo is not null) diff --git a/Source/Cli/Program.cs b/Source/Cli/Program.cs index dc439a5..b2f8a1d 100644 --- a/Source/Cli/Program.cs +++ b/Source/Cli/Program.cs @@ -2,6 +2,11 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Cratis.Cli; +using Cratis.Cli.Commands.Version; + +using var updateCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); +var currentVersion = VersionCommand.GetCliVersion(); +var updateCheckTask = UpdateChecker.CheckForUpdate(currentVersion, updateCts.Token); if (args.Length == 0 && !Console.IsOutputRedirected && !GlobalSettings.IsAiAgentEnvironment()) { @@ -19,5 +24,32 @@ AnsiConsole.WriteLine(); } -return await CliApp.Create().RunAsync(args); +var exitCode = await CliApp.Create().RunAsync(args); + +if (!ShouldSkipUpdateHint(args) && + !Console.IsOutputRedirected && + !GlobalSettings.IsAiAgentEnvironment() && + updateCheckTask.IsCompleted) +{ + try + { + var latestVersion = await updateCheckTask; + if (latestVersion is not null) + { + var strategy = CliUpdate.DetectStrategy(); + var hint = CliUpdate.GetUpdateHint(strategy, currentVersion, latestVersion); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($" [{OutputFormatter.Warning.ToMarkup()}]\u2191 {hint.EscapeMarkup()}[/]"); + } + } + catch + { + // Update check failures are non-critical. + } +} + +return exitCode; +static bool ShouldSkipUpdateHint(string[] args) => + args.Length > 0 && (string.Equals(args[0], "update", StringComparison.OrdinalIgnoreCase) || + string.Equals(args[0], "version", StringComparison.OrdinalIgnoreCase));