Files
aeqw89.tools.Publish/aeqw89.tools.Publish/Program.cs
T

358 lines
16 KiB
C#

using System.Collections;
using System.Diagnostics;
using System.Text;
using Renci.SshNet;
using Spectre.Console;
using aeqw89.xml.ProjectFile;
namespace aeqw89.tools.Publish;
/*
* Structure of the program:
* - publish (executable)
* - overwrite
* - destinations
* - flags
* - increment
* - patch|minor|major
* - destinations
* - flags
* e.g. publish overwrite|increment [patch|minor|major] destinations [flags]
*/
public static class Program {
const long BufferSize = 80 * 1024; // 80 KB
internal record RunContext(List<Action> RestoreActions, ArgValues Args, string TempDir) {
public void Restore(bool deleteTempDir = true) {
RestoreActions.ForEach(x => x());
if (deleteTempDir) {
try {
if (!Directory.Exists(TempDir)) return;
Directory.Delete(TempDir, true);
AnsiConsole.MarkupLine("[yellow]Cleaned up temporary directory[/]");
}
catch (Exception e) {
ShowError(string.Format(Exceptions.failed_to_clean_up.EscapeMarkup(), TempDir.EscapeMarkup(),
e.ToString().EscapeMarkup()));
}
}
RestoreActions.Clear();
}
}
internal record ArgValues(Mode Mode, string[] Destinations, Dictionary<string, string[]> Flags, bool Verbose, IncrementTarget? Target);
static Result<ArgValues, ReadableError> ReadArgs(string[] args) {
if (args.Length < 1) // not enough args (min = 2)
return new ReadableError(Exceptions.missing_mode.EscapeMarkup());
var mode = args[0].ToLower() switch {
"overwrite" => Mode.Overwrite,
"increment" => Mode.Increment,
_ => (Mode)(-1) // mode must be one of two values
};
if (mode == (Mode)(-1)) // invalid mode
return new ReadableError(string.Format(Exceptions.could_not_parse_mode.EscapeMarkup(), args[0].EscapeMarkup()));
if (args.Length < 2) // not enough args (min = 2)
return new ReadableError(mode switch {
Mode.Increment => Exceptions.missing_increment_target.EscapeMarkup(),
Mode.Overwrite => Exceptions.missing_destinations.EscapeMarkup(),
_ => throw new UnreachableException()
});
var destinations = args[1..]; // destinations is variarg.
Dictionary<string, string[]> flags = []; // flags is parsed last
IncrementTarget? target = null;
if (mode == Mode.Increment) {
if (args.Length < 3) // increment mode requires target version in addition to destinations
return new ReadableError(Exceptions.missing_destinations.EscapeMarkup());
destinations = args[2..]; // target is args[1] (args[0] is mode)
target = args[1].ToLower() switch {
"patch" => IncrementTarget.Patch,
"minor" => IncrementTarget.Minor,
"major" => IncrementTarget.Major,
_ => (IncrementTarget)(-1)
};
if (target == (IncrementTarget)(-1)) // unrecognizable target entered
return new ReadableError(string.Format(Exceptions.could_not_parse_target.EscapeMarkup(), args[1].EscapeMarkup()));
}
string? firstFlag = destinations.FirstOrDefault(x => x.StartsWith('-')); // find the first arg that starts with '-' signifying a flag.
if (firstFlag == null) // no flags case - return early.
return new Ok<ArgValues>(new ArgValues(mode, destinations, flags, false, target));
string[] flagsRaw = destinations.SkipWhile(x => x != firstFlag).ToArray(); // extract flags from destinations.
flags = ReadFlags(flagsRaw); // get flags as dictionary to args (pattern is -flag0 [arg0] [arg1] -flag1 ...)
destinations = destinations.TakeWhile(x => x != firstFlag).ToArray(); // remove flags from destinations.
bool verbose = flags.ContainsKey("--verbose") || flags.ContainsKey("-v"); // verbosity switch is extracted early.
return new Ok<ArgValues>(new ArgValues(mode, destinations, flags, verbose, target)); // return with fully parsed flags.
}
private static Dictionary<string, string[]> ReadFlags(string[] flags) {
Dictionary<string, string[]> result = [];
List<string> collected = [];
string lastKey = flags[0];
if (flags.Length == 1)
result[lastKey] = [];
foreach (var flag in flags.Skip(1)) {
if (flag.StartsWith('-')) {
result[lastKey] = collected.ToArray();
collected = [];
lastKey = flag;
} else
collected.Add(flag);
}
result[lastKey] = collected.ToArray();
return result;
}
record ProjectResult(string PackageId, string Version, string RepoOwner);
static async Task<Result<ProjectResult, ReadableError>> PrepareProject(RunContext rctx, StatusContext ctx) {
ctx.Status = "Locating project file";
if (!ProjectFile.TryLoad(Environment.CurrentDirectory, out var projectFile, out var error))
return new ReadableError(error);
string packageId = projectFile.GetPackageId();
string version;
string repoOwner;
try {
projectFile.Backup();
rctx.RestoreActions.Add(() => {
projectFile.Restore();
AnsiConsole.MarkupLine("[yellow]Restored project file from backup.[/]");
});
if (rctx.Args.Verbose)
AnsiConsole.WriteLine(
$"Created project file backup at {projectFile.GetDefaultBackupLocation()}");
ctx.Status = "Repairing project file";
if (!rctx.Args.Flags.ContainsKey("--skip-repair"))
if (!projectFile.TryRepair(out error)) {
return new ReadableError(error.EscapeMarkup(), false);
}
if (rctx.Args.Mode == Mode.Increment && !rctx.Args.Flags.ContainsKey("--simulate")) {
int delta = 1;
if (rctx.Args.Flags.TryGetValue("--delta", out var deltaStrings)) {
if (deltaStrings.Length != 1) {
return new ReadableError(string.Format(Exceptions.flag_parameter_length_incorrect.EscapeMarkup(), "--delta", 1,
deltaStrings.Length));
}
if (!int.TryParse(deltaStrings[0], out delta)) {
return new ReadableError(string.Format(Exceptions.flag_parameter_type_incorrect.EscapeMarkup(), "--delta", 0,
nameof(Int32),
deltaStrings[0]));
}
}
ctx.Status = "Updating version";
version = projectFile.GetVersion();
version = ProjectFile.ChangeVersion(version, delta, rctx.Args.Target ?? IncrementTarget.Patch).Unwrap(rctx);
projectFile.SetVersion(version);
}
}
catch (Exception e) {
return new ReadableError(string.Format(Exceptions.generic_error.EscapeMarkup(), e.ToString().EscapeMarkup()), false);
}
version = projectFile.GetVersion();
repoOwner = projectFile.GetRepositoryOwner();
if (!rctx.Args.Flags.ContainsKey("--simulate")) {
try {
var packageReferences = projectFile.GetPackageReferences();
foreach (var reference in packageReferences.Where(x => !projectFile.IsTransitive(x)))
projectFile.SetPrivateAssets(reference, PrivateAssetsValue.All);
foreach (var reference in packageReferences.Where(x => projectFile.IsTransitive(x)))
projectFile.RemovePackage(reference);
HashSet<string> visited = [];
var projectReferences = new Queue<Item>(projectFile.GetProjectReferences().Cast<Item>());
while (projectReferences.Count != 0) {
var reference = projectReferences.Dequeue();
visited.Add(reference.Include);
if (rctx.Args.Verbose)
AnsiConsole.WriteLine(
$"Processing project reference {reference.Include} out of {visited.Count} so far");
// if (Flags.ContainsKey("--force-private"))
projectFile.SetPrivateAssets(reference, PrivateAssetsValue.All);
string pathToReferencedProjectFile = projectFile.GetAbsoluteIncludePath(reference);
if (!ProjectFile.TryLoad(pathToReferencedProjectFile, out var referencedProjectFile,
out error)) {
return new ReadableError(error.EscapeMarkup(), false);
}
var referencedPackageReferences = referencedProjectFile.GetPackageReferences();
foreach (var package in referencedPackageReferences) {
if (rctx.Args.Verbose)
AnsiConsole.WriteLine(
$"Hoisting package {package.Include} from {pathToReferencedProjectFile}");
var hoisted = projectFile.AddPackage(package);
projectFile.SetTransitive(hoisted, true);
projectFile.SetPrivateAssets(hoisted, PrivateAssetsValue.None);
referencedProjectFile.SetPrivateAssets(package, PrivateAssetsValue.All);
}
var referencedProjectReferences = referencedProjectFile.GetProjectReferences();
foreach (var project in referencedProjectReferences) {
if (!visited.Contains(project.Include))
projectReferences.Enqueue(project);
}
}
}
catch (Exception e) {
return new ReadableError(string.Format(Exceptions.generic_error.EscapeMarkup(), e.ToString().EscapeMarkup()), false);
}
}
projectFile.Save();
return new Ok<ProjectResult>(new ProjectResult(packageId, version, repoOwner));
}
static async Task<Result<Success, ReadableError>> PackProject(RunContext rctx, StatusContext ctx) {
DataReceivedEventHandler dataHandler = rctx.Args.Verbose switch {
false => (_, _) => {},
true => (_ , e) => {
AnsiConsole.MarkupLine("[Packing]: " + e.Data?.EscapeMarkup() ?? string.Empty);
}
};
var result = (await Shell.Dotnet.Pack(rctx.TempDir, dataHandler, dataHandler)).Unwrap(rctx);
if (result.ExitCode != 0)
return new ReadableError(result.Error + "\n" + string.Format(Exceptions.dotnet_pack_failure.EscapeMarkup(), result.ExitCode), false);
return Success.AsResult();
}
enum DestinationType {
Local,
Github,
Main,
Cloud
}
public static async Task Main(string[] args) {
ArgValues? parsed = ReadArgs(args).Unwrap();
if (parsed is null) return;
RunContext rctx = new([], parsed, Path.GetRandomFileName());
Console.CancelKeyPress += (sender, eventArgs) => {
rctx.Restore();
};
var stager = new Stager(rctx);
ProjectResult? project = await stager.Spinner("Preparing project", PrepareProject);
if (project is null) return;
int destinationsProcessed = 0;
var dotnetPackResult = await stager.Spinner("Creating package with 'dotnet pack'", PackProject);
if (dotnetPackResult is null) return;
if (rctx.Args.Verbose)
AnsiConsole.MarkupLine("Successfully created package with exit code [green]{0}[/]. Processing destinations.", 0);
var progress = stager.Progress();
var task = progress.Run("push to destinations", async (rctx, ctx) => {
var pkg = await Shell.Dotnet.FindPackage(rctx.TempDir).Unwrap(rctx);
await Parallel.ForEachAsync(rctx.Args.Destinations, new ParallelOptions() {
MaxDegreeOfParallelism = Environment.ProcessorCount,
}, async (dest, ct) => {
using var reader = new MemoryStream(pkg.Read());
var task = ctx.AddTask(dest, new ProgressTaskSettings() {
MaxValue = pkg.Size()
});
DestinationType destType;
if (dest.StartsWith("local-")) {
destType = DestinationType.Local;
} else if (dest.StartsWith("cloud-")) {
destType = DestinationType.Cloud;
} else if (dest == "github") {
destType = DestinationType.Github;
} else if (dest == "main") {
destType = DestinationType.Main;
}
else {
lock(rctx) {
ShowError(string.Format(Exceptions.destination_unrecognizable, dest));
ShowHelp();
}
task.StopTask();
return;
}
var dctx = new DestinationContext(task, ctx, reader, pkg.FileInfo, destType switch {
DestinationType.Cloud => dest["cloud-".Length..],
DestinationType.Github => dest["github".Length..],
DestinationType.Main => dest["main".Length..],
DestinationType.Local => dest["local-".Length..]
}, BufferSize, pkg.Size());
Result<IDestination, ReadableError> destinationResult = destType switch {
DestinationType.Local => new LocalDestination(dctx).Ok<IDestination>(),
DestinationType.Github => GitDestination.CreateForGithub(dctx, project.RepoOwner, rctx.Args.Verbose).UpcastSuccess<GitDestination, IDestination, ReadableError>(),
DestinationType.Main => GitDestination.CreateForGitea(dctx, project.RepoOwner, rctx.Args.Verbose).UpcastSuccess<GitDestination, IDestination, ReadableError>(),
DestinationType.Cloud => new CloudDestiantion(dctx).Ok<IDestination>(),
_ => throw new UnreachableException()
};
var destination = destinationResult.Unwrap(rctx);
var result = await destination.WaitForCompletion(ct);
lock(rctx) {
if (result.Unwrap(rctx) is not null) {
Interlocked.Increment(ref destinationsProcessed);
}
}
task.StopTask();
});
return Success.AsResult();
});
await task.Value;
if (destinationsProcessed == 0) {
AnsiConsole.MarkupLine("[bold red]No destinations were processed. Reverting changes to project file.[/]");
rctx.Restore();
}
else {
AnsiConsole.MarkupLine("Completed processing of all destinations.");
AnsiConsole.MarkupLine(
"Example usage:\n\t <PackageReference Include=\"{0}\" Version=\"{1}\" />".EscapeMarkup(), project.PackageId,
project.Version);
}
}
public static void ShowError(string message, params object[] args) {
AnsiConsole.MarkupLine($"[bold red]{message}[/]", args);
}
public static void ShowHelp() {
AnsiConsole.Markup(("Usage: publish overwrite|increment [patch|minor|major] destinations [flags]\n" +
"\t if mode: overwrite destinations [flags]\n" +
"\t if mode: increment patch|minor|major [flags]\n").EscapeMarkup());
}
}