From 1f6da33b125241c8a1383af9f9ac816a213e7bc2 Mon Sep 17 00:00:00 2001 From: qwsdcvghyu89 <61093706+qwsdcvghyu89@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:23:37 +1000 Subject: [PATCH] Improved logic. --- aeqw89.tools.Publish/Destination.cs | 184 +++++++++++++++++++++++++++ aeqw89.tools.Publish/Result.cs | 37 ++++++ aeqw89.tools.Publish/Shell.cs | 67 ++++++++++ aeqw89.tools.Publish/Staging.cs | 47 +++++++ aeqw89.tools.Publish/UnionSupport.cs | 6 + 5 files changed, 341 insertions(+) create mode 100644 aeqw89.tools.Publish/Destination.cs create mode 100644 aeqw89.tools.Publish/Result.cs create mode 100644 aeqw89.tools.Publish/Shell.cs create mode 100644 aeqw89.tools.Publish/Staging.cs create mode 100644 aeqw89.tools.Publish/UnionSupport.cs diff --git a/aeqw89.tools.Publish/Destination.cs b/aeqw89.tools.Publish/Destination.cs new file mode 100644 index 0000000..650e234 --- /dev/null +++ b/aeqw89.tools.Publish/Destination.cs @@ -0,0 +1,184 @@ +using System.Diagnostics; +using System.Text; +using Renci.SshNet; +using Spectre.Console; + +namespace aeqw89.tools.Publish; + +sealed record DestinationContext( + ProgressTask Task, + ProgressContext ProgressContext, + MemoryStream Reader, + FileInfo PackageFile, + string Name, + long BufferSize, + long PackageSize +); + +interface IDestination { + Task> WaitForCompletion(CancellationToken ct = default); +} + +sealed record LocalDestination(DestinationContext Context) : IDestination { + public async Task> WaitForCompletion(CancellationToken ct = default) { + // Path = "$USER/local-%ctx.name/%pkg.filename" + var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), Context.Name, Context.PackageFile.Name); + try { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); // create the directory + await using var writer = File.OpenWrite(path); + var buffer = new byte[Context.BufferSize]; + int read; + do { + read = await Context.Reader.ReadAsync(buffer, ct); + writer.Write(buffer, 0, read); + Context.Task.Increment(read); + } while (read > 0); + return Success.AsResult(); + } catch (Exception e) { + return new ReadableError(string.Format(Exceptions.generic_error, e), false); + } + } +} + +sealed record CloudDestiantion(DestinationContext Context) : IDestination { + public async Task> WaitForCompletion(CancellationToken ct = default) { + var connectionTask = Context.ProgressContext.AddTaskBefore($"Preparing cloud-{Context.Name}", + new ProgressTaskSettings() { + MaxValue = 100 + }, Context.Task); + + if (!SshHosts.TryGetHost(Context.Name, out var host)) { + return new ReadableError(string.Format(Exceptions.cloud_host_not_found.EscapeMarkup(), Context.Name), false); + } + + var connectionInfo = SshHosts.GetConnection(Context.Name); + using var sshClient = new SshClient(connectionInfo); + + if (!sshClient.IsConnected) + await sshClient.ConnectAsync(ct); + + connectionTask.Increment(33); // ? One-Third of the way done (connection success). + + var winC = sshClient.RunCommand("cmd /c ver"); + var othC = sshClient.RunCommand("uname -s"); + + var os = (winC.ExitStatus, othC.ExitStatus) switch { + (0, _) => "windows", + (_, 0) => "linux", + _ => "unknown" + }; + + string remoteDirectory; + string packageFileDirectory; + + if (os == "windows") { + var userDirC = sshClient.RunCommand("cmd /c echo %USERPROFILE%"); + if (userDirC.ExitStatus != 0) { // * missing env variable case (could not work out where to place packages in remote). + return new ReadableError(string.Format(Exceptions.failed_to_prepare_server_directory, "n/a", Context.PackageFile.Name, os, + userDirC.Result), false); + } + + var userDir = userDirC.Result.Trim(); + remoteDirectory = RemotePath.Combine(RemoteOs.Windows, userDir, "dotnet-packages"); + packageFileDirectory = RemotePath.Combine(RemoteOs.Windows, remoteDirectory, Context.PackageFile.Name); + + var mkdirC = sshClient.RunCommand($"cmd /c if not exist \"{remoteDirectory}\" mkdir \"{remoteDirectory}\""); + if (mkdirC.ExitStatus != 0) { // * MKDir error case (make directory command failed on remote). + return new ReadableError(string.Format(Exceptions.failed_to_prepare_server_directory, remoteDirectory, Context.PackageFile.Name, os, + mkdirC.Result), false); + } + } + else if (os == "linux") { + var homeDirC = sshClient.RunCommand("printf %s \"$HOME\""); + if (homeDirC.ExitStatus != 0) { // * missing env variable case 2 (could not work out where to place packages in remote). + return new ReadableError(string.Format(Exceptions.failed_to_prepare_server_directory, "n/a", Context.PackageFile.Name, os, + homeDirC.Result), false); + } + + var homeDir = homeDirC.Result.Trim(); // no CRLF on unix, but Trim() is safest + remoteDirectory = RemotePath.Combine(RemoteOs.Unix, homeDir, ".dotnet-packages"); + packageFileDirectory = RemotePath.Combine(RemoteOs.Unix, remoteDirectory, + Context.PackageFile.Name); + + // Use -p and single quotes to handle spaces safely + var mkdirC = sshClient.RunCommand($"mkdir -p '{remoteDirectory}'"); + if (mkdirC.ExitStatus != 0) { // * MKDir error case (make directory command failed on remote). + return new ReadableError(string.Format(Exceptions.failed_to_prepare_server_directory, remoteDirectory, Context.PackageFile.Name, + os, mkdirC.Result), false); + } + } + else { + return new ReadableError(string.Format(Exceptions.failed_to_prepare_server_directory, "n/a", Context.PackageFile.Name, os, + "Unsupported OS"), false); // * MacOS failure path (or other unrecognizable oses). + } + + connectionTask.Increment(33); // ? Two-Thirds of the way done (directory located and ready). + + sshClient.Disconnect(); + + using var client = new SftpClient(connectionInfo); + if (!client.IsConnected) + await client.ConnectAsync(ct); + connectionTask.Increment(33); // ? Connection task complete (now proceeding to upload task). + connectionTask.StopTask(); + + await using var writer = client.OpenWrite(packageFileDirectory); + byte[] buffer = new byte[Context.BufferSize]; + int read; + do { + read = await Context.Reader.ReadAsync(buffer, ct); + writer.Write(buffer, 0, read); + Context.Task.Increment(read); + } while (read > 0); + + return Success.AsResult(); + } +} + +sealed record GithubDestination(DestinationContext Context, bool Verbose = false) : IDestination { + public async Task> WaitForCompletion(CancellationToken ct = default) { + var p = Process.Start(new ProcessStartInfo() { + FileName = "dotnet", + Arguments = $"nuget push \"{Context.PackageFile.FullName}\" --source github", + WorkingDirectory = Environment.CurrentDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + }); + + var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + StringBuilder errorLines = new(); + p?.ErrorDataReceived += (sender, eventArgs) => { + cts.Cancel(); + if (Verbose && eventArgs.Data != null) + AnsiConsole.WriteLine(eventArgs.Data); + errorLines.Append(eventArgs.Data); + }; + p?.OutputDataReceived += (sender, eventArgs) => { + if (eventArgs.Data?.ToLower().Contains("press any key") == true) + cts.Cancel(); + if (Verbose && eventArgs.Data != null) + AnsiConsole.WriteLine(eventArgs.Data); + }; + + p?.BeginOutputReadLine(); + p?.BeginErrorReadLine(); + + Context.Task.Increment(Context.PackageSize / 2); + if (p != null) + try { + await (p?.WaitForExitAsync(cts.Token) ?? Task.CompletedTask); + } + catch (TaskCanceledException) { + p?.Kill(); + } + + if (p?.ExitCode != 0) { + Context.Task.StopTask(); + return new ReadableError(errorLines.ToString().EscapeMarkup() + "\n" + string.Format(Exceptions.dotnet_nuget_push_failure, p?.ExitCode ?? -1), false); + } + + Context.Task.Increment(Context.BufferSize / 2); + return Success.AsResult(); + } +} \ No newline at end of file diff --git a/aeqw89.tools.Publish/Result.cs b/aeqw89.tools.Publish/Result.cs new file mode 100644 index 0000000..219e052 --- /dev/null +++ b/aeqw89.tools.Publish/Result.cs @@ -0,0 +1,37 @@ +using System.Diagnostics; + +namespace aeqw89.tools.Publish; + +record Success { + public static Ok AsResult() => new Ok(new Success()); +} +record Ok(T Value); +record ReadableError(string Reason, bool ShowHelp = true, bool RestoreContext = true); +union Result(Ok, E); + +internal static class ResultExtensions { + public static T Unwrap(this Result result, Program.RunContext? rctx = null) { + switch (result) { + case Ok r: + return r.Value; + case ReadableError error: + Program.ShowError(error.Reason); + if (error.ShowHelp) Program.ShowHelp(); + if (error.RestoreContext) rctx?.Restore(); + Environment.Exit(1); + throw new UnreachableException(); + } + + throw new UnreachableException(); + } + + public static async Task Unwrap(this Task> result, Program.RunContext? rctx = null) { + return (await result).Unwrap(rctx); + } +} + +internal static class ObjectExtensions { + public static Ok Ok(this T any) { + return new Ok(any); + } +} \ No newline at end of file diff --git a/aeqw89.tools.Publish/Shell.cs b/aeqw89.tools.Publish/Shell.cs new file mode 100644 index 0000000..70171fb --- /dev/null +++ b/aeqw89.tools.Publish/Shell.cs @@ -0,0 +1,67 @@ +using System.Diagnostics; +using System.Text; +using Spectre.Console; + +namespace aeqw89.tools.Publish; + +internal static class Shell { + internal record ShellResult(string Output, string Error, int ExitCode); + internal record PackageInfo(FileInfo FileInfo) { + byte[]? _inMemory = null; + public long Size() => FileInfo.Length; + public async Task ReadAsync() => _inMemory ??= await File.ReadAllBytesAsync(FileInfo.FullName); + public byte[] Read() => _inMemory ??= File.ReadAllBytes(FileInfo.FullName); + } + internal static class Dotnet { + internal static async Task> FindPackage(string dir) { + var package = Directory.GetFiles(dir, "*.nupkg").FirstOrDefault(); + if (package == null) return new ReadableError(Exceptions.generic_error.EscapeMarkup()); + return new PackageInfo(new FileInfo(package)).Ok(); + } + + internal static async Task> Pack(string tempdir, DataReceivedEventHandler? stderr, DataReceivedEventHandler? stdout) { + var p = Process.Start(new ProcessStartInfo() { + FileName = "dotnet", + Arguments = $"pack -o {tempdir}", + WorkingDirectory = Environment.CurrentDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + }); + + StringBuilder errorLines = new(); + StringBuilder outputLines = new(); + + CancellationTokenSource cts = new(); + p?.ErrorDataReceived += stderr; + p?.ErrorDataReceived += (_, e) => { + cts.Cancel(); + errorLines.Append(e.Data); + }; + + bool success = false; + + p?.OutputDataReceived += stdout; + p?.OutputDataReceived += (_, e) => { + if (e.Data?.ToLower().Contains("press any key") == true) + cts.Cancel(); + if (e.Data?.ToLower().Contains($"successfully created package '{Path.GetFullPath(tempdir)}") == true) { + success = true; + } + }; + + p?.BeginErrorReadLine(); + p?.BeginOutputReadLine(); + + try { + await (p?.WaitForExitAsync(cts.Token) ?? Task.CompletedTask); + } catch (TaskCanceledException) { + p?.Kill(); + } catch (Exception e) { + return new ReadableError(Exceptions.generic_error.EscapeMarkup(), false); + } + + return new ShellResult(outputLines.ToString(), errorLines.ToString(), p!.ExitCode).Ok(); + } + } +} \ No newline at end of file diff --git a/aeqw89.tools.Publish/Staging.cs b/aeqw89.tools.Publish/Staging.cs new file mode 100644 index 0000000..26c6e93 --- /dev/null +++ b/aeqw89.tools.Publish/Staging.cs @@ -0,0 +1,47 @@ +using System.Diagnostics; +using Spectre.Console; +using static aeqw89.tools.Publish.Program; + +namespace aeqw89.tools.Publish; + +internal record Stager(RunContext RunContext) { + public async Task Spinner(string name, Func>> factory) { + try { + var result = AnsiConsole.Status() + .Spinner(Spectre.Console.Spinner.Known.Dots) + .Start(name, async ctx => await factory(RunContext, ctx)); + return (await result).Unwrap(RunContext); + } catch (Exception e) { + ShowError(string.Format(Exceptions.generic_error.EscapeMarkup(), e.ToString().EscapeMarkup())); + RunContext.Restore(); + return default; + } + + throw new UnreachableException(); + } + + public ProgressStager Progress() { + return new ProgressStager(RunContext, [], AnsiConsole.Progress() + .AutoClear(true) + .HideCompleted(false) + .Columns([ + new TaskDescriptionColumn(), + new ProgressBarColumn() + .RemainingStyle(Style.Parse("dim gray slowblink")) + .CompletedStyle(Style.Parse("green strikethrough")) + .FinishedStyle("green strikethrough"), + new DownloadedColumn(), + new RemainingTimeColumn(), + new TransferSpeedColumn(), + ])); + } +} + +internal record TaskWithProgress(string Name, Task Value); +internal record ProgressStager(RunContext RunContext, Task[] Tasks, Progress Progress) { + public TaskWithProgress Run(string Name, Func> factory) { + return new(Name, Progress.StartAsync(async ctx => { + return await factory(RunContext, ctx); + })); + } +} \ No newline at end of file diff --git a/aeqw89.tools.Publish/UnionSupport.cs b/aeqw89.tools.Publish/UnionSupport.cs new file mode 100644 index 0000000..16deda7 --- /dev/null +++ b/aeqw89.tools.Publish/UnionSupport.cs @@ -0,0 +1,6 @@ +namespace System.Runtime.CompilerServices { + public interface IUnion { } + + [AttributeUsage(AttributeTargets.Class)] + public sealed class UnionAttribute : Attribute { } +}