diff --git a/README.md b/README.md index 9dbb488..a991de6 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ bld init Add the Build submodule (defaults to the latest ta bld init --tag v1.2.3 Add the Build submodule pinned to a specific tag bld update Move an existing submodule to the latest tag bld update --tag v1.2.3 Move an existing submodule to a specific tag +bld self-update Reinstall the bld CLI from the latest main +bld self-update --ref v0.2.0 Install a specific BuildCLI branch or tag +bld upgrade Alias for self-update bld status Show the current submodule path, commit, and tags bld tags List tags advertised by the Build remote bld repair --strategy stash Stash local submodule changes, then restore the parent HEAD @@ -47,6 +50,8 @@ Common options: `init` and `update` stage `.gitmodules` and the submodule gitlink. They do not create a commit, so you can review the change in the parent repository first. +`self-update` (alias `upgrade`) republishes this CLI from [IngeniumSE/BuildCLI](https://github.com/IngeniumSE/BuildCLI) and replaces the installed `bld` binary. It does not change the Build submodule; use `update` for that. By default it clones `main` over HTTPS. Pass `--source` to publish an existing checkout, or `--ref` for a branch or tag. The install location matches `scripts/install.sh` / `scripts/install.ps1` (`~/.local/share/ingenium/bld` on Unix, `%LOCALAPPDATA%\Ingenium\bld` on Windows) and can be overridden with `BLD_INSTALL_DIR` or `--install-dir`. + Existing Ingenium repositories that already use `build` or `Build` as the submodule path are detected automatically. `repair` can prompt for a strategy when run interactively. `reset` and `reinit` are destructive and require `--yes` in non-interactive use. @@ -82,6 +87,12 @@ curl -sSL https://raw.githubusercontent.com/IngeniumSE/BuildCLI/main/scripts/ins The default install location is `~/.local/share/ingenium/bld`, with a symlink at `~/.local/bin/bld`. Add `~/.local/bin` to `PATH` if the installer reports that the command is not visible yet. +After `bld` is on PATH, later versions can be installed with: + +```bash +bld self-update +``` + ### Windows From a clone: @@ -96,7 +107,7 @@ Or later: irm https://raw.githubusercontent.com/IngeniumSE/BuildCLI/main/scripts/install.ps1 | iex ``` -The default install location is `%LOCALAPPDATA%\Ingenium\bld`. That directory is added to the user `PATH`. Open a new terminal before running `bld`. +The default install location is `%LOCALAPPDATA%\Ingenium\bld`. That directory is added to the user `PATH`. Open a new terminal before running `bld`. After `bld` is on PATH, later versions can be installed with `bld self-update`. ### .NET tool diff --git a/apps/Ingenium.BuildCli/BuildCliApplication.cs b/apps/Ingenium.BuildCli/BuildCliApplication.cs index ab4d218..605390d 100644 --- a/apps/Ingenium.BuildCli/BuildCliApplication.cs +++ b/apps/Ingenium.BuildCli/BuildCliApplication.cs @@ -8,6 +8,7 @@ using Ingenium.BuildCli.Infrastructure; using Ingenium.BuildCli.Execution; using Ingenium.BuildCli.Rendering; +using Ingenium.BuildCli.SelfUpdate; using Ingenium.BuildCli.Submodule; using Microsoft.Extensions.DependencyInjection; @@ -35,6 +36,7 @@ public static CommandApp Create(IAnsiConsole? console = null, Action(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); configureServices?.Invoke(services); var app = new CommandApp(new TypeRegistrar(services)); @@ -90,6 +92,16 @@ public static void Configure(IConfigurator config) .WithExample("update") .WithExample("update", "--tag", "v1.2.3"); + config.AddCommand("self-update") + .WithDescription("Update the installed bld CLI from the BuildCLI repository. Does not change the Build submodule.") + .WithExample("self-update") + .WithExample("self-update", "--ref", "main") + .WithExample("self-update", "--source", "."); + + config.AddCommand("upgrade") + .WithDescription("Alias for self-update.") + .WithExample("upgrade"); + config.AddCommand("status") .WithDescription("Show the current Build submodule state.") .WithExample("status"); diff --git a/apps/Ingenium.BuildCli/CommandLineDefaults.cs b/apps/Ingenium.BuildCli/CommandLineDefaults.cs index 2492c30..369f903 100644 --- a/apps/Ingenium.BuildCli/CommandLineDefaults.cs +++ b/apps/Ingenium.BuildCli/CommandLineDefaults.cs @@ -17,6 +17,8 @@ public static class CommandLineDefaults { "init", "update", + "self-update", + "upgrade", "status", "tags", "repair", @@ -43,7 +45,11 @@ public static class CommandLineDefaults "-t", "--tag", "-s", - "--strategy" + "--strategy", + "--ref", + "--source", + "--install-dir", + "--bin-dir" }; private static readonly HashSet CliFlagOptions = new(StringComparer.OrdinalIgnoreCase) @@ -53,7 +59,8 @@ public static class CommandLineDefaults "-f", "--force", "-y", - "--yes" + "--yes", + "--framework-dependent" }; /// diff --git a/apps/Ingenium.BuildCli/Commands/SelfUpdateCommand.cs b/apps/Ingenium.BuildCli/Commands/SelfUpdateCommand.cs new file mode 100644 index 0000000..7bc82f6 --- /dev/null +++ b/apps/Ingenium.BuildCli/Commands/SelfUpdateCommand.cs @@ -0,0 +1,103 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.ComponentModel; + +using Ingenium.BuildCli.Git; +using Ingenium.BuildCli.Rendering; +using Ingenium.BuildCli.SelfUpdate; + +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Ingenium.BuildCli.Commands; + +/// +/// Republishes and reinstalls the bld CLI itself. +/// +public sealed class SelfUpdateCommand : AsyncCommand +{ + private readonly IAnsiConsole _console; + private readonly ISelfUpdateService _service; + private readonly IGitTrace _trace; + + /// + /// Initializes a new instance of the class. + /// + public SelfUpdateCommand(IAnsiConsole console, ISelfUpdateService service, IGitTrace trace) + { + _console = console; + _service = service; + _trace = trace; + } + + /// + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + _trace.Enabled = settings.Verbose; + ConsoleWriter.WriteHeader(_console, "self-update"); + + var request = settings.ToRequest(); + var result = settings.Verbose + ? await _service.UpdateAsync(request) + : await _console.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Updating the installed bld CLI...", async _ => await _service.UpdateAsync(request)); + + _console.MarkupLine("[green]Updated[/] the installed bld CLI."); + _console.WriteLine(); + ConsoleWriter.WriteSelfUpdate(_console, result); + return ExitCodes.Success; + } + + /// + /// Settings for . + /// + public sealed class Settings : CommandSettings + { + [CommandOption("--ref ")] + [Description("Git branch or tag of BuildCLI to install. Defaults to main.")] + public string? Ref { get; init; } + + [CommandOption("--source ")] + [Description("Existing BuildCLI checkout to publish instead of cloning.")] + public string? Source { get; init; } + + [CommandOption("--url ")] + [Description("BuildCLI git URL used when cloning. Defaults to the public HTTPS repository.")] + public string? Url { get; init; } + + [CommandOption("--install-dir ")] + [Description("Directory that receives the published bld binary.")] + public string? InstallDirectory { get; init; } + + [CommandOption("--bin-dir ")] + [Description("Directory that receives the bld symlink on macOS and Linux.")] + public string? BinDirectory { get; init; } + + [CommandOption("--framework-dependent")] + [Description("Publish a framework-dependent binary instead of a self-contained single file.")] + public bool FrameworkDependent { get; init; } + + [CommandOption("--verbose")] + [Description("Write the git and dotnet commands that are executed.")] + public bool Verbose { get; init; } + + /// + /// Creates a service request from these settings. + /// + public SelfUpdateRequest ToRequest() + { + return new SelfUpdateRequest + { + Ref = Ref, + Source = Source, + Url = Url, + FrameworkDependent = FrameworkDependent, + Verbose = Verbose, + InstallDirectory = InstallDirectory, + BinDirectory = BinDirectory + }; + } + } +} diff --git a/apps/Ingenium.BuildCli/Rendering/ConsoleWriter.cs b/apps/Ingenium.BuildCli/Rendering/ConsoleWriter.cs index 1a6b113..d74629b 100644 --- a/apps/Ingenium.BuildCli/Rendering/ConsoleWriter.cs +++ b/apps/Ingenium.BuildCli/Rendering/ConsoleWriter.cs @@ -2,6 +2,7 @@ // For a copy, see . using Ingenium.BuildCli.Extensions; +using Ingenium.BuildCli.SelfUpdate; using Ingenium.BuildCli.Submodule; using Spectre.Console; @@ -135,6 +136,38 @@ public static void WriteExtension(IAnsiConsole console, BuildExtensionScaffold s console.MarkupLine("[grey]The Build host imports every project under build-extensions/ automatically.[/]"); } + /// + /// Writes a successful self-update summary. + /// + public static void WriteSelfUpdate(IAnsiConsole console, SelfUpdateResult result) + { + var table = new Table() + .Border(TableBorder.Rounded) + .HideHeaders() + .AddColumn(new TableColumn("Key").PadRight(2)) + .AddColumn("Value"); + + table.AddRow("[grey]Version[/]", Markup.Escape(result.Version)); + table.AddRow("[grey]Runtime[/]", Markup.Escape(result.RuntimeIdentifier)); + table.AddRow("[grey]Ref[/]", Markup.Escape(result.Ref)); + table.AddRow("[grey]Installed[/]", Markup.Escape(result.ExecutablePath)); + if (!string.IsNullOrEmpty(result.BinLink)) + { + table.AddRow("[grey]Link[/]", Markup.Escape(result.BinLink)); + } + + console.Write(table); + + if (!result.BinDirectoryOnPath) + { + var hint = OperatingSystem.IsWindows() + ? Path.GetDirectoryName(result.ExecutablePath) ?? result.ExecutablePath + : Path.GetDirectoryName(result.BinLink ?? result.ExecutablePath) ?? result.ExecutablePath; + console.WriteLine(); + console.MarkupLine($"[yellow]bld may not be on PATH.[/] Add {Markup.Escape(hint)} to PATH and reopen the terminal."); + } + } + /// /// Writes advertised remote tags. /// diff --git a/apps/Ingenium.BuildCli/SelfUpdate/ISelfUpdateService.cs b/apps/Ingenium.BuildCli/SelfUpdate/ISelfUpdateService.cs new file mode 100644 index 0000000..e5eb172 --- /dev/null +++ b/apps/Ingenium.BuildCli/SelfUpdate/ISelfUpdateService.cs @@ -0,0 +1,15 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.SelfUpdate; + +/// +/// Republishes and reinstalls the bld CLI. +/// +public interface ISelfUpdateService +{ + /// + /// Clones or uses a local checkout, publishes bld, and replaces the installed binary. + /// + Task UpdateAsync(SelfUpdateRequest request, CancellationToken cancellationToken = default); +} diff --git a/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdatePaths.cs b/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdatePaths.cs new file mode 100644 index 0000000..640c245 --- /dev/null +++ b/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdatePaths.cs @@ -0,0 +1,168 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.Runtime.InteropServices; + +namespace Ingenium.BuildCli.SelfUpdate; + +/// +/// Resolves install locations and publish settings used by bld self-update. +/// +public static class SelfUpdatePaths +{ + /// + /// The default HTTPS clone URL for this CLI repository. + /// + public const string DefaultRepositoryUrl = "https://github.com/IngeniumSE/BuildCLI.git"; + + /// + /// The default git ref installed by self-update. + /// + public const string DefaultRef = "main"; + + /// + /// Returns the BuildCLI clone URL, honoring BUILDCLI_REPO_URL. + /// + public static string GetRepositoryUrl(string? overrideUrl = null) + { + if (!string.IsNullOrWhiteSpace(overrideUrl)) + { + return overrideUrl.Trim(); + } + + var configured = Environment.GetEnvironmentVariable("BUILDCLI_REPO_URL"); + return string.IsNullOrWhiteSpace(configured) ? DefaultRepositoryUrl : configured.Trim(); + } + + /// + /// Returns the directory that holds the published bld binary. + /// + public static string GetInstallDirectory(string? overridePath = null) + { + if (!string.IsNullOrWhiteSpace(overridePath)) + { + return Path.GetFullPath(overridePath); + } + + var configured = Environment.GetEnvironmentVariable("BLD_INSTALL_DIR"); + if (string.IsNullOrWhiteSpace(configured)) + { + configured = Environment.GetEnvironmentVariable("BUILDCLI_INSTALL_DIR"); + } + + if (!string.IsNullOrWhiteSpace(configured)) + { + return Path.GetFullPath(configured); + } + + if (OperatingSystem.IsWindows()) + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(localAppData, "Ingenium", "bld"); + } + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(home, ".local", "share", "ingenium", "bld"); + } + + /// + /// Returns the directory that should contain a bld symlink on Unix. + /// + public static string GetBinDirectory(string? overridePath = null) + { + if (!string.IsNullOrWhiteSpace(overridePath)) + { + return Path.GetFullPath(overridePath); + } + + var configured = Environment.GetEnvironmentVariable("BUILDCLI_BIN_DIR"); + if (!string.IsNullOrWhiteSpace(configured)) + { + return Path.GetFullPath(configured); + } + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(home, ".local", "bin"); + } + + /// + /// Returns the published executable file name for the current OS. + /// + public static string GetExecutableFileName() + { + return OperatingSystem.IsWindows() ? $"{CliInfo.Name}.exe" : CliInfo.Name; + } + + /// + /// Returns the .NET runtime identifier used to publish the current machine. + /// + public static string GetRuntimeIdentifier() + { + var rid = RuntimeInformation.RuntimeIdentifier; + if (!string.IsNullOrWhiteSpace(rid) && rid.Contains('-', StringComparison.Ordinal)) + { + return rid; + } + + var os = OperatingSystem.IsWindows() + ? "win" + : OperatingSystem.IsMacOS() + ? "osx" + : OperatingSystem.IsLinux() + ? "linux" + : throw new BuildCliException("Unsupported operating system for self-update."); + + var arch = RuntimeInformation.OSArchitecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + _ => throw new BuildCliException($"Unsupported architecture: {RuntimeInformation.OSArchitecture}.") + }; + + return $"{os}-{arch}"; + } + + /// + /// Returns the CLI project path inside a BuildCLI checkout. + /// + public static string GetProjectPath(string sourceRoot) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourceRoot); + return Path.Combine(sourceRoot, "apps", "Ingenium.BuildCli", "Ingenium.BuildCli.csproj"); + } + + /// + /// Returns true when appears on PATH. + /// + public static bool IsDirectoryOnPath(string directory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + + var path = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + var full = Path.GetFullPath(directory); + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + foreach (var part in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + try + { + if (string.Equals(Path.GetFullPath(part), full, comparison)) + { + return true; + } + } + catch (ArgumentException) + { + } + } + + return false; + } +} diff --git a/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdatePublishArguments.cs b/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdatePublishArguments.cs new file mode 100644 index 0000000..497fe59 --- /dev/null +++ b/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdatePublishArguments.cs @@ -0,0 +1,50 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.SelfUpdate; + +/// +/// Builds the dotnet publish argument list used to install bld. +/// +public static class SelfUpdatePublishArguments +{ + /// + /// Creates publish arguments that match scripts/install.sh and scripts/install.ps1. + /// + public static IReadOnlyList Create( + string projectPath, + string runtimeIdentifier, + string outputDirectory, + bool frameworkDependent) + { + ArgumentException.ThrowIfNullOrWhiteSpace(projectPath); + ArgumentException.ThrowIfNullOrWhiteSpace(runtimeIdentifier); + ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory); + + var arguments = new List + { + "publish", + projectPath, + "-c", + "Release", + "-r", + runtimeIdentifier, + "-o", + outputDirectory, + "--nologo" + }; + + if (frameworkDependent) + { + arguments.Add("--self-contained"); + arguments.Add("false"); + return arguments; + } + + arguments.Add("--self-contained"); + arguments.Add("true"); + arguments.Add("-p:PublishSingleFile=true"); + arguments.Add("-p:IncludeNativeLibrariesForSelfExtract=true"); + return arguments; + } +} diff --git a/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdateRequest.cs b/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdateRequest.cs new file mode 100644 index 0000000..4adeac8 --- /dev/null +++ b/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdateRequest.cs @@ -0,0 +1,45 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.SelfUpdate; + +/// +/// Options for republishing and reinstalling the bld CLI. +/// +public sealed class SelfUpdateRequest +{ + /// + /// Git branch or tag of BuildCLI to install. Defaults to main. + /// + public string? Ref { get; init; } + + /// + /// Existing BuildCLI checkout to publish. When omitted, the repository is cloned. + /// + public string? Source { get; init; } + + /// + /// Clone URL used when is omitted. + /// + public string? Url { get; init; } + + /// + /// When true, publish a framework-dependent binary instead of a self-contained single file. + /// + public bool FrameworkDependent { get; init; } + + /// + /// When true, stream publish output to the console. + /// + public bool Verbose { get; init; } + + /// + /// Override for the published-binary directory. + /// + public string? InstallDirectory { get; init; } + + /// + /// Override for the Unix symlink directory. + /// + public string? BinDirectory { get; init; } +} diff --git a/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdateResult.cs b/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdateResult.cs new file mode 100644 index 0000000..255d31f --- /dev/null +++ b/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdateResult.cs @@ -0,0 +1,45 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.SelfUpdate; + +/// +/// The outcome of a successful bld self-update. +/// +public sealed class SelfUpdateResult +{ + /// + /// Gets the installed executable path. + /// + public required string ExecutablePath { get; init; } + + /// + /// Gets the version reported by the newly installed binary. + /// + public required string Version { get; init; } + + /// + /// Gets the runtime identifier that was published. + /// + public required string RuntimeIdentifier { get; init; } + + /// + /// Gets the git ref that was installed, when a clone was used. + /// + public required string Ref { get; init; } + + /// + /// Gets the source checkout that was published. + /// + public required string Source { get; init; } + + /// + /// Gets the Unix symlink path, when one was created. + /// + public string? BinLink { get; init; } + + /// + /// Gets a value indicating whether the bin directory is already on PATH. + /// + public bool BinDirectoryOnPath { get; init; } +} diff --git a/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdateService.cs b/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdateService.cs new file mode 100644 index 0000000..8273b45 --- /dev/null +++ b/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdateService.cs @@ -0,0 +1,271 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using Ingenium.BuildCli.Execution; +using Ingenium.BuildCli.Git; + +namespace Ingenium.BuildCli.SelfUpdate; + +/// +/// Publishes this repository and replaces the installed bld binary. +/// +public sealed class SelfUpdateService : ISelfUpdateService +{ + private readonly IGitClient _git; + private readonly IProcessRunner _processes; + + /// + /// Initializes a new instance of the class. + /// + public SelfUpdateService(IGitClient git, IProcessRunner processes) + { + _git = git; + _processes = processes; + } + + /// + public async Task UpdateAsync( + SelfUpdateRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + if (!_processes.IsAvailable("dotnet")) + { + throw new BuildCliException( + "dotnet was not found on PATH. Install the .NET SDK and try again.", + ExitCodes.BuildFailed); + } + + var gitRef = string.IsNullOrWhiteSpace(request.Ref) ? SelfUpdatePaths.DefaultRef : request.Ref.Trim(); + var installDir = SelfUpdatePaths.GetInstallDirectory(request.InstallDirectory); + var binDir = SelfUpdatePaths.GetBinDirectory(request.BinDirectory); + var rid = SelfUpdatePaths.GetRuntimeIdentifier(); + var cloned = false; + string source; + + if (!string.IsNullOrWhiteSpace(request.Source)) + { + source = Path.GetFullPath(request.Source); + if (!Directory.Exists(source)) + { + throw new BuildCliException($"Source directory '{source}' does not exist."); + } + } + else + { + if (!_git.IsGitAvailable()) + { + throw new BuildCliException( + "git was not found on PATH. Install Git and try again.", + ExitCodes.GitNotFound); + } + + source = Path.Combine(Path.GetTempPath(), "buildcli-src-" + Guid.NewGuid().ToString("N")); + cloned = true; + var url = SelfUpdatePaths.GetRepositoryUrl(request.Url); + await _git.RunRequiredAsync( + Path.GetTempPath(), + ["clone", "--depth", "1", "--branch", gitRef, url, source], + $"Failed to clone BuildCLI from '{url}' at '{gitRef}'.", + cancellationToken: cancellationToken); + } + + var publishDir = Path.Combine(Path.GetTempPath(), "buildcli-publish-" + Guid.NewGuid().ToString("N")); + try + { + var project = SelfUpdatePaths.GetProjectPath(source); + if (!File.Exists(project)) + { + throw new BuildCliException( + $"Could not find '{project}'. Pass --source to a BuildCLI checkout."); + } + + Directory.CreateDirectory(publishDir); + var publish = await _processes.RunAsync( + "dotnet", + SelfUpdatePublishArguments.Create(project, rid, publishDir, request.FrameworkDependent), + source, + inheritOutput: request.Verbose, + cancellationToken); + + if (!publish.IsSuccess) + { + var detail = string.IsNullOrWhiteSpace(publish.StandardError) + ? publish.StandardOutput + : publish.StandardError; + throw new BuildCliException( + $"dotnet publish failed.{(string.IsNullOrWhiteSpace(detail) ? string.Empty : Environment.NewLine + detail.Trim())}", + ExitCodes.BuildFailed); + } + + var executableName = SelfUpdatePaths.GetExecutableFileName(); + var publishedExecutable = Path.Combine(publishDir, executableName); + if (!File.Exists(publishedExecutable)) + { + throw new BuildCliException( + $"Publish succeeded but '{publishedExecutable}' was not produced.", + ExitCodes.BuildFailed); + } + + var installedExecutable = InstallPublishedFiles(publishDir, installDir, executableName); + var binLink = TryCreateBinLink(installedExecutable, binDir); + var version = await ReadInstalledVersionAsync(installedExecutable, installDir, cancellationToken); + + return new SelfUpdateResult + { + ExecutablePath = installedExecutable, + Version = version, + RuntimeIdentifier = rid, + Ref = gitRef, + Source = source, + BinLink = binLink, + BinDirectoryOnPath = SelfUpdatePaths.IsDirectoryOnPath(OperatingSystem.IsWindows() ? installDir : binDir) + }; + } + finally + { + TryDeleteDirectory(publishDir); + if (cloned) + { + TryDeleteDirectory(source); + } + } + } + + private async Task ReadInstalledVersionAsync( + string executablePath, + string workingDirectory, + CancellationToken cancellationToken) + { + try + { + var result = await _processes.RunAsync( + executablePath, + ["--version"], + workingDirectory, + inheritOutput: false, + cancellationToken); + if (result.IsSuccess) + { + var version = result.StandardOutput.Trim(); + if (!string.IsNullOrWhiteSpace(version)) + { + return version; + } + } + } + catch (BuildCliException) + { + } + + return "unknown"; + } + + private static string InstallPublishedFiles(string publishDir, string installDir, string executableName) + { + Directory.CreateDirectory(installDir); + CopyDirectory(publishDir, installDir); + + var installed = Path.Combine(installDir, executableName); + if (!File.Exists(installed)) + { + throw new BuildCliException( + $"Failed to install '{executableName}' into '{installDir}'.", + ExitCodes.BuildFailed); + } + + SetExecutable(installed); + return installed; + } + + private static string? TryCreateBinLink(string executablePath, string binDir) + { + if (OperatingSystem.IsWindows()) + { + return null; + } + + Directory.CreateDirectory(binDir); + var link = Path.Combine(binDir, CliInfo.Name); + try + { + if (File.Exists(link) || Directory.Exists(link)) + { + File.Delete(link); + } + + File.CreateSymbolicLink(link, executablePath); + return link; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return null; + } + } + + private static void CopyDirectory(string source, string destination) + { + foreach (var file in Directory.GetFiles(source, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(source, file); + var dest = Path.Combine(destination, relative); + var destDirectory = Path.GetDirectoryName(dest); + if (!string.IsNullOrEmpty(destDirectory)) + { + Directory.CreateDirectory(destDirectory); + } + + ReplaceFile(file, dest); + } + } + + private static void ReplaceFile(string source, string destination) + { + try + { + if (File.Exists(destination)) + { + File.Delete(destination); + } + + File.Copy(source, destination, overwrite: true); + } + catch (IOException) when (OperatingSystem.IsWindows()) + { + var pending = destination + ".new"; + File.Copy(source, pending, overwrite: true); + throw new BuildCliException( + $"Could not replace '{destination}' because the file is in use. The new file was written to '{pending}'. Close running bld processes and rename it."); + } + } + + private static void SetExecutable(string path) + { + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD()) + { + File.SetUnixFileMode( + path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + } + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} diff --git a/tests/Ingenium.BuildCli.Tests/CommandAppTests.cs b/tests/Ingenium.BuildCli.Tests/CommandAppTests.cs index 0b926e0..8a7b81b 100644 --- a/tests/Ingenium.BuildCli.Tests/CommandAppTests.cs +++ b/tests/Ingenium.BuildCli.Tests/CommandAppTests.cs @@ -2,6 +2,7 @@ // For a copy, see . using Ingenium.BuildCli.Git; +using Ingenium.BuildCli.SelfUpdate; using Ingenium.BuildCli.Submodule; using Ingenium.BuildCli.Tests.Support; @@ -32,6 +33,54 @@ public async Task Help_ListsPrimaryCommands() Assert.Contains("repair", output, StringComparison.OrdinalIgnoreCase); Assert.Contains("build", output, StringComparison.OrdinalIgnoreCase); Assert.Contains("extension", output, StringComparison.OrdinalIgnoreCase); + Assert.Contains("self-update", output, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task SelfUpdateHelp_DescribesRefOption() + { + var console = new TestConsole(); + var app = CreateApp(console); + var exitCode = await app.RunAsync(["self-update", "--help"]); + + Assert.Equal(0, exitCode); + Assert.Contains("--ref", console.Output, StringComparison.OrdinalIgnoreCase); + Assert.Contains("--source", console.Output, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task SelfUpdate_InvokesRegisteredService() + { + var console = new TestConsole(); + var service = new StubSelfUpdateService(); + var app = BuildCliApplication.Create(console, services => + { + services.AddSingleton(_ => service); + }); + + var exitCode = await app.RunAsync(["self-update", "--source", "/tmp/buildcli", "--install-dir", "/tmp/bld"]); + + Assert.Equal(0, exitCode); + Assert.NotNull(service.Request); + Assert.Equal("/tmp/buildcli", service.Request.Source); + Assert.Equal("/tmp/bld", service.Request.InstallDirectory); + Assert.Contains("Updated", console.Output, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Upgrade_IsAliasForSelfUpdate() + { + var console = new TestConsole(); + var service = new StubSelfUpdateService(); + var app = BuildCliApplication.Create(console, services => + { + services.AddSingleton(_ => service); + }); + + var exitCode = await app.RunAsync(["upgrade"]); + + Assert.Equal(0, exitCode); + Assert.NotNull(service.Request); } [Fact] @@ -134,4 +183,24 @@ private static CommandApp CreateApp(TestConsole console) services.AddSingleton(_ => GitTestWorkspace.CreateClient()); }); } + + private sealed class StubSelfUpdateService : ISelfUpdateService + { + public SelfUpdateRequest? Request { get; private set; } + + public Task UpdateAsync(SelfUpdateRequest request, CancellationToken cancellationToken = default) + { + Request = request; + return Task.FromResult(new SelfUpdateResult + { + ExecutablePath = "/tmp/bld/bld", + Version = "0.1.0", + RuntimeIdentifier = "linux-x64", + Ref = request.Ref ?? "main", + Source = request.Source ?? "/tmp/source", + BinLink = "/tmp/bin/bld", + BinDirectoryOnPath = true + }); + } + } } diff --git a/tests/Ingenium.BuildCli.Tests/CommandLineDefaultsTests.cs b/tests/Ingenium.BuildCli.Tests/CommandLineDefaultsTests.cs index 773e7ac..18cd76b 100644 --- a/tests/Ingenium.BuildCli.Tests/CommandLineDefaultsTests.cs +++ b/tests/Ingenium.BuildCli.Tests/CommandLineDefaultsTests.cs @@ -23,6 +23,8 @@ public void Apply_LeavesKnownCommandsAlone() { Assert.Equal(["init", "--tag", "v1.0.0"], CommandLineDefaults.Apply(["init", "--tag", "v1.0.0"])); Assert.Equal(["status"], CommandLineDefaults.Apply(["status"])); + Assert.Equal(["self-update", "--ref", "main"], CommandLineDefaults.Apply(["self-update", "--ref", "main"])); + Assert.Equal(["upgrade"], CommandLineDefaults.Apply(["upgrade"])); } [Fact] @@ -60,6 +62,8 @@ public void Apply_NormalizesExplicitBuildCommandCakeArguments() public void IsKnownCommand_RecognizesFirstClassCommands() { Assert.True(CommandLineDefaults.IsKnownCommand("init")); + Assert.True(CommandLineDefaults.IsKnownCommand("self-update")); + Assert.True(CommandLineDefaults.IsKnownCommand("upgrade")); Assert.False(CommandLineDefaults.IsKnownCommand("Test")); } } diff --git a/tests/Ingenium.BuildCli.Tests/SelfUpdatePathsTests.cs b/tests/Ingenium.BuildCli.Tests/SelfUpdatePathsTests.cs new file mode 100644 index 0000000..4e95df2 --- /dev/null +++ b/tests/Ingenium.BuildCli.Tests/SelfUpdatePathsTests.cs @@ -0,0 +1,59 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using Ingenium.BuildCli.SelfUpdate; + +namespace Ingenium.BuildCli.Tests; + +public sealed class SelfUpdatePathsTests +{ + [Fact] + public void GetInstallDirectory_UsesOverride() + { + var expected = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "custom-bld")); + Assert.Equal(expected, SelfUpdatePaths.GetInstallDirectory(expected)); + } + + [Fact] + public void GetBinDirectory_UsesOverride() + { + var expected = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "custom-bin")); + Assert.Equal(expected, SelfUpdatePaths.GetBinDirectory(expected)); + } + + [Fact] + public void GetRepositoryUrl_UsesOverride() + { + Assert.Equal( + "https://example.test/BuildCLI.git", + SelfUpdatePaths.GetRepositoryUrl("https://example.test/BuildCLI.git")); + } + + [Fact] + public void GetRuntimeIdentifier_IsWellFormed() + { + var rid = SelfUpdatePaths.GetRuntimeIdentifier(); + Assert.Contains('-', rid); + Assert.True( + rid.StartsWith("linux-", StringComparison.Ordinal) || + rid.StartsWith("osx-", StringComparison.Ordinal) || + rid.StartsWith("win-", StringComparison.Ordinal), + rid); + } + + [Fact] + public void GetProjectPath_UsesIngeniumLayout() + { + var root = Path.Combine(Path.GetTempPath(), "buildcli"); + Assert.Equal( + Path.Combine(root, "apps", "Ingenium.BuildCli", "Ingenium.BuildCli.csproj"), + SelfUpdatePaths.GetProjectPath(root)); + } + + [Fact] + public void GetExecutableFileName_MatchesCurrentOs() + { + var name = SelfUpdatePaths.GetExecutableFileName(); + Assert.Equal(OperatingSystem.IsWindows() ? "bld.exe" : "bld", name); + } +} diff --git a/tests/Ingenium.BuildCli.Tests/SelfUpdatePublishArgumentsTests.cs b/tests/Ingenium.BuildCli.Tests/SelfUpdatePublishArgumentsTests.cs new file mode 100644 index 0000000..16612e0 --- /dev/null +++ b/tests/Ingenium.BuildCli.Tests/SelfUpdatePublishArgumentsTests.cs @@ -0,0 +1,43 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using Ingenium.BuildCli.SelfUpdate; + +namespace Ingenium.BuildCli.Tests; + +public sealed class SelfUpdatePublishArgumentsTests +{ + [Fact] + public void Create_MatchesSelfContainedInstallScript() + { + var args = SelfUpdatePublishArguments.Create("project.csproj", "linux-x64", "/tmp/out", frameworkDependent: false); + + Assert.Equal( + [ + "publish", + "project.csproj", + "-c", + "Release", + "-r", + "linux-x64", + "-o", + "/tmp/out", + "--nologo", + "--self-contained", + "true", + "-p:PublishSingleFile=true", + "-p:IncludeNativeLibrariesForSelfExtract=true" + ], + args); + } + + [Fact] + public void Create_CanPublishFrameworkDependent() + { + var args = SelfUpdatePublishArguments.Create("project.csproj", "win-x64", "C:\\out", frameworkDependent: true); + + Assert.Contains("--self-contained", args); + Assert.Contains("false", args); + Assert.DoesNotContain("-p:PublishSingleFile=true", args); + } +} diff --git a/tests/Ingenium.BuildCli.Tests/SelfUpdateServiceTests.cs b/tests/Ingenium.BuildCli.Tests/SelfUpdateServiceTests.cs new file mode 100644 index 0000000..2a044d8 --- /dev/null +++ b/tests/Ingenium.BuildCli.Tests/SelfUpdateServiceTests.cs @@ -0,0 +1,182 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using Ingenium.BuildCli.SelfUpdate; +using Ingenium.BuildCli.Tests.Support; + +using Ingenium.BuildCli; + +namespace Ingenium.BuildCli.Tests; + +public sealed class SelfUpdateServiceTests +{ + [Fact] + public async Task Update_PublishesSourceAndInstallsBinary() + { + using var workspace = new TempWorkspace(); + FakeGitClient.WriteProject(workspace.Source); + var git = new FakeGitClient(); + var processes = new FakeProcessRunner { PublishedVersion = "0.2.0" }; + var service = new SelfUpdateService(git, processes); + + var result = await service.UpdateAsync(new SelfUpdateRequest + { + Source = workspace.Source, + InstallDirectory = workspace.Install, + BinDirectory = workspace.Bin + }); + + Assert.Empty(git.Commands); + Assert.Equal("0.2.0", result.Version); + Assert.Equal(Path.Combine(workspace.Install, SelfUpdatePaths.GetExecutableFileName()), result.ExecutablePath); + Assert.True(File.Exists(result.ExecutablePath)); + Assert.Contains(processes.Calls, call => call.FileName == "dotnet" && call.Arguments[0] == "publish"); + Assert.Contains(processes.Calls, call => call.Arguments.Contains("--version")); + + if (!OperatingSystem.IsWindows()) + { + Assert.Equal(Path.Combine(workspace.Bin, "bld"), result.BinLink); + Assert.True(File.Exists(result.BinLink)); + } + } + + [Fact] + public async Task Update_ClonesWhenSourceIsOmitted() + { + using var workspace = new TempWorkspace(); + var git = new FakeGitClient(); + var processes = new FakeProcessRunner(); + var service = new SelfUpdateService(git, processes); + + var result = await service.UpdateAsync(new SelfUpdateRequest + { + Ref = "release/1.0", + Url = "https://example.test/BuildCLI.git", + InstallDirectory = workspace.Install, + BinDirectory = workspace.Bin + }); + + Assert.Single(git.Commands); + Assert.Equal("clone", git.Commands[0][0]); + Assert.Contains("--depth", git.Commands[0]); + Assert.Contains("1", git.Commands[0]); + Assert.Contains("--branch", git.Commands[0]); + Assert.Contains("release/1.0", git.Commands[0]); + Assert.Contains("https://example.test/BuildCLI.git", git.Commands[0]); + Assert.Equal("release/1.0", result.Ref); + Assert.True(File.Exists(result.ExecutablePath)); + Assert.False(Directory.Exists(result.Source)); + } + + [Fact] + public async Task Update_ThrowsWhenDotnetIsMissing() + { + var service = new SelfUpdateService(new FakeGitClient(), new FakeProcessRunner { DotnetAvailable = false }); + + var error = await Assert.ThrowsAsync(() => service.UpdateAsync(new SelfUpdateRequest())); + Assert.Equal(ExitCodes.BuildFailed, error.ExitCode); + Assert.Contains("dotnet", error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Update_ThrowsWhenGitIsMissingAndCloneIsRequired() + { + var service = new SelfUpdateService(new FakeGitClient { Available = false }, new FakeProcessRunner()); + + var error = await Assert.ThrowsAsync(() => service.UpdateAsync(new SelfUpdateRequest())); + Assert.Equal(ExitCodes.GitNotFound, error.ExitCode); + } + + [Fact] + public async Task Update_ThrowsWhenSourceProjectIsMissing() + { + using var workspace = new TempWorkspace(); + Directory.CreateDirectory(workspace.Source); + var service = new SelfUpdateService(new FakeGitClient(), new FakeProcessRunner()); + + var error = await Assert.ThrowsAsync(() => service.UpdateAsync(new SelfUpdateRequest + { + Source = workspace.Source, + InstallDirectory = workspace.Install + })); + + Assert.Contains("Could not find", error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Update_ThrowsWhenPublishFails() + { + using var workspace = new TempWorkspace(); + FakeGitClient.WriteProject(workspace.Source); + var processes = new FakeProcessRunner + { + PublishExitCode = 1, + PublishError = "MSB1009" + }; + var service = new SelfUpdateService(new FakeGitClient(), processes); + + var error = await Assert.ThrowsAsync(() => service.UpdateAsync(new SelfUpdateRequest + { + Source = workspace.Source, + InstallDirectory = workspace.Install + })); + + Assert.Equal(ExitCodes.BuildFailed, error.ExitCode); + Assert.Contains("MSB1009", error.Message); + } + + [Fact] + public async Task Update_PassesFrameworkDependentToPublish() + { + using var workspace = new TempWorkspace(); + FakeGitClient.WriteProject(workspace.Source); + var processes = new FakeProcessRunner(); + var service = new SelfUpdateService(new FakeGitClient(), processes); + + await service.UpdateAsync(new SelfUpdateRequest + { + Source = workspace.Source, + InstallDirectory = workspace.Install, + BinDirectory = workspace.Bin, + FrameworkDependent = true + }); + + var publish = Assert.Single(processes.Calls, call => call.FileName == "dotnet" && call.Arguments[0] == "publish"); + Assert.Contains("--self-contained", publish.Arguments); + Assert.Contains("false", publish.Arguments); + } + + private sealed class TempWorkspace : IDisposable + { + public TempWorkspace() + { + Root = Path.Combine(Path.GetTempPath(), "buildcli-self-update", Guid.NewGuid().ToString("N")); + Source = Path.Combine(Root, "source"); + Install = Path.Combine(Root, "install"); + Bin = Path.Combine(Root, "bin"); + Directory.CreateDirectory(Root); + } + + public string Root { get; } + + public string Source { get; } + + public string Install { get; } + + public string Bin { get; } + + public void Dispose() + { + try + { + if (Directory.Exists(Root)) + { + Directory.Delete(Root, recursive: true); + } + } + catch (IOException) + { + } + } + } +} diff --git a/tests/Ingenium.BuildCli.Tests/Support/FakeGitClient.cs b/tests/Ingenium.BuildCli.Tests/Support/FakeGitClient.cs new file mode 100644 index 0000000..f7454aa --- /dev/null +++ b/tests/Ingenium.BuildCli.Tests/Support/FakeGitClient.cs @@ -0,0 +1,84 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using Ingenium.BuildCli.Git; + +using Ingenium.BuildCli; + +namespace Ingenium.BuildCli.Tests.Support; + +/// +/// Records git invocations and materializes a minimal BuildCLI layout on clone. +/// +internal sealed class FakeGitClient : IGitClient +{ + public bool Available { get; set; } = true; + + public List> Commands { get; } = []; + + public int CloneExitCode { get; set; } + + public string CloneError { get; set; } = "clone failed"; + + public bool WriteProjectOnClone { get; set; } = true; + + public bool IsGitAvailable() + { + return Available; + } + + public Task GetRepositoryRootAsync(string path, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task RunAsync( + string workingDirectory, + IReadOnlyList arguments, + CancellationToken cancellationToken = default) + { + Commands.Add(arguments.ToArray()); + + if (arguments.Count > 0 && arguments[0] == "clone") + { + if (CloneExitCode != 0) + { + return Task.FromResult(new GitResult(CloneExitCode, string.Empty, CloneError, "git clone")); + } + + var destination = arguments[^1]; + Directory.CreateDirectory(destination); + if (WriteProjectOnClone) + { + WriteProject(destination); + } + + return Task.FromResult(new GitResult(0, string.Empty, string.Empty, "git clone")); + } + + return Task.FromResult(new GitResult(0, string.Empty, string.Empty, "git")); + } + + public async Task RunRequiredAsync( + string workingDirectory, + IReadOnlyList arguments, + string failureMessage, + int exitCode = ExitCodes.GeneralError, + CancellationToken cancellationToken = default) + { + var result = await RunAsync(workingDirectory, arguments, cancellationToken); + if (!result.IsSuccess) + { + throw new BuildCliException($"{failureMessage}{Environment.NewLine}{result.ErrorMessage}", exitCode); + } + + return result; + } + + public static void WriteProject(string sourceRoot) + { + var projectDir = Path.Combine(sourceRoot, "apps", "Ingenium.BuildCli"); + Directory.CreateDirectory(projectDir); + File.WriteAllText(Path.Combine(projectDir, "Ingenium.BuildCli.csproj"), ""); + } +} diff --git a/tests/Ingenium.BuildCli.Tests/Support/FakeProcessRunner.cs b/tests/Ingenium.BuildCli.Tests/Support/FakeProcessRunner.cs new file mode 100644 index 0000000..7c76920 --- /dev/null +++ b/tests/Ingenium.BuildCli.Tests/Support/FakeProcessRunner.cs @@ -0,0 +1,79 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using Ingenium.BuildCli.Execution; + +namespace Ingenium.BuildCli.Tests.Support; + +/// +/// Records process invocations and optionally writes a fake published binary. +/// +internal sealed class FakeProcessRunner : IProcessRunner +{ + public List<(string FileName, IReadOnlyList Arguments, string WorkingDirectory)> Calls { get; } = []; + + public bool DotnetAvailable { get; set; } = true; + + public string PublishedVersion { get; set; } = "0.1.0"; + + public int PublishExitCode { get; set; } + + public string PublishError { get; set; } = string.Empty; + + public bool WritePublishedBinary { get; set; } = true; + + public bool IsAvailable(string fileName) + { + return fileName == "dotnet" ? DotnetAvailable : true; + } + + public Task RunAsync( + string fileName, + IReadOnlyList arguments, + string workingDirectory, + bool inheritOutput, + CancellationToken cancellationToken = default) + { + Calls.Add((fileName, arguments.ToArray(), workingDirectory)); + + if (fileName == "dotnet" && arguments.Count > 0 && arguments[0] == "publish") + { + if (PublishExitCode != 0) + { + return Task.FromResult(new ProcessRunResult(PublishExitCode, string.Empty, PublishError)); + } + + if (WritePublishedBinary) + { + var output = OutputDirectory(arguments); + Directory.CreateDirectory(output); + var executable = Path.Combine( + output, + OperatingSystem.IsWindows() ? "bld.exe" : "bld"); + File.WriteAllText(executable, "fake-bld"); + } + + return Task.FromResult(new ProcessRunResult(0, string.Empty, string.Empty)); + } + + if (arguments.Contains("--version")) + { + return Task.FromResult(new ProcessRunResult(0, PublishedVersion + Environment.NewLine, string.Empty)); + } + + return Task.FromResult(new ProcessRunResult(0, string.Empty, string.Empty)); + } + + private static string OutputDirectory(IReadOnlyList arguments) + { + for (var i = 0; i < arguments.Count - 1; i++) + { + if (arguments[i] == "-o") + { + return arguments[i + 1]; + } + } + + throw new InvalidOperationException("dotnet publish was invoked without -o."); + } +}