Skip to content
Merged
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
70 changes: 70 additions & 0 deletions Source/Cli.Specs/for_CliUpdate/when_detecting_strategy.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
4 changes: 4 additions & 0 deletions Source/Cli/Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>

<PropertyGroup Condition="'$(SelfContained)' == 'true'">
<DefineConstants>$(DefineConstants);CRATIS_NATIVE</DefineConstants>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Cratis.Chronicle.Connections" />
<PackageReference Include="Cratis.Chronicle.Contracts" />
Expand Down
240 changes: 240 additions & 0 deletions Source/Cli/CliUpdate.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Represents the update strategy for the current CLI installation.
/// </summary>
public enum CliUpdateStrategy
{
/// <summary>
/// Update by running <c>dotnet tool update -g Cratis.Cli</c>.
/// </summary>
DotNetTool,

/// <summary>
/// Update by running <c>brew upgrade cratis</c>.
/// </summary>
Homebrew,

/// <summary>
/// Update manually by downloading and replacing the Linux native binary.
/// </summary>
ManualLinux,

/// <summary>
/// Update manually using the installation mechanism used by the user.
/// </summary>
Manual
}

/// <summary>
/// Provides update-strategy detection and update-related command guidance.
/// </summary>
public static class CliUpdate
{
#if CRATIS_NATIVE
const bool IsNativeBuild = true;
#else
const bool IsNativeBuild = false;
#endif

const string PackageId = "Cratis.Cli";

/// <summary>
/// Detects how this CLI instance should be updated.
/// </summary>
/// <returns>The update strategy.</returns>
public static CliUpdateStrategy DetectStrategy()
{
var processPath = GetEffectiveProcessPath();
var baseDirectory = AppContext.BaseDirectory;
return DetectStrategy(
processPath,
baseDirectory,
IsNativeBuild,
OperatingSystem.IsMacOS(),
OperatingSystem.IsLinux());
}

/// <summary>
/// Creates process launch settings that should run before the main update command.
/// </summary>
/// <param name="strategy">The detected update strategy.</param>
/// <param name="targetVersion">Optional target version.</param>
/// <returns>A process start info for supported auto-update paths, otherwise null.</returns>
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
};
}

/// <summary>
/// Creates the process launch settings for automatic update, when available.
/// </summary>
/// <param name="strategy">The detected update strategy.</param>
/// <param name="targetVersion">Optional target version.</param>
/// <returns>A process start info for supported auto-update paths, otherwise null.</returns>
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;
}

/// <summary>
/// Gets manual update guidance for non-automatic update strategies.
/// </summary>
/// <param name="strategy">The detected strategy.</param>
/// <returns>Manual guidance text, or null when auto-update is supported.</returns>
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
};

/// <summary>
/// Gets update hint text for interactive output when a newer version is available.
/// </summary>
/// <param name="strategy">The detected strategy.</param>
/// <param name="currentVersion">Current version.</param>
/// <param name="latestVersion">Latest available version.</param>
/// <returns>A user-facing hint message.</returns>
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;
}
}
34 changes: 0 additions & 34 deletions Source/Cli/Commands/Chronicle/ChronicleCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,11 +28,6 @@ protected sealed override async Task<int> 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)
{
Expand Down Expand Up @@ -65,7 +59,6 @@ protected sealed override async Task<int> 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))
Expand Down Expand Up @@ -179,31 +172,4 @@ static bool IsNetworkException(Exception? ex)

return false;
}

static async Task ShowUpdateHint(Task<string?> 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.
}
}
}
Loading
Loading