358 lines
16 KiB
C#
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());
|
|
|
|
}
|
|
} |