nuke-build / nuke

🏗 The AKEless Build System for C#/.NET
https://nuke.build
MIT License
3.07k stars 367 forks source link

Full interactive mode (ask for target) #1436

Open Danielku15 opened 1 month ago

Danielku15 commented 1 month ago

Description

Nuke is great and very flexible when it comes to CLI usage and is very flexible in the usage from CI/CD systems. What I am missing a bit, is a modern user experience when developers use the "_build" project locally.

If the project/build is started without any input, and we detect a "Terminal" host, we enter an interactive mode to ask the user what to do. e.g. we have 5 different entry points to our build depending on what you want to do.

Usage Example

When I launch the "_build.exe" (or dotnet run) Nuke should prompt the user:

​
███╗   ██╗██╗   ██╗██╗  ██╗███████╗
████╗  ██║██║   ██║██║ ██╔╝██╔════╝
██╔██╗ ██║██║   ██║█████╔╝ █████╗  
██║╚██╗██║██║   ██║██╔═██╗ ██╔══╝  
██║ ╚████║╚██████╔╝██║  ██╗███████╗
╚═╝  ╚═══╝ ╚═════╝ ╚═╝  ╚═╝╚══════╝
​
NUKE Execution Engine version 8.1.2 (Windows,.NETCoreApp,Version=v8.0)
​
Targets (with their direct dependencies):

  > PrepareEnviroment
      Checks for any missing environment configurations on your machine and sets up whatever is needed.
    Build
      Compile the whole project.
    Test
      Run the tests of this project 

Select which target you want to run with the arrow keys and run it with [ENTER].

Then it starts the respective target just as if nuke --target PrepareEnvironment is called. The way of asking could be either fully interactive like very modern console apps, or simply asking for the user to type it.

​
███╗   ██╗██╗   ██╗██╗  ██╗███████╗
████╗  ██║██║   ██║██║ ██╔╝██╔════╝
██╔██╗ ██║██║   ██║█████╔╝ █████╗  
██║╚██╗██║██║   ██║██╔═██╗ ██╔══╝  
██║ ╚████║╚██████╔╝██║  ██╗███████╗
╚═╝  ╚═══╝ ╚═════╝ ╚═╝  ╚═╝╚══════╝
​
NUKE Execution Engine version 8.1.2 (Windows,.NETCoreApp,Version=v8.0)
​
Targets (with their direct dependencies):

  PrepareEnviroment
    Checks for any missing environment configurations on your machine and sets up whatever is needed.
  Build
    Compile the whole project.
  Test
    Run the tests of this project 

Which target do you want to run (default: PrepareEnvironment): _

Alternative

I thought of making some unlisted target "interactive" the default one. Then I do any console interactivity myself (by loading the targets and stuff myself).

After the user has chosen the target, I would launch the target.

a) I cannot directly trigger another target within the same process. But it would be nice if I can have a dynamic "Triggers" in this target. b) I trigger the target via DotNetTasks.DotNetRun(<current assembly, target and parameters>)

Could you help with a pull-request?

Yes

Danielku15 commented 1 month ago

Note: I digged up some old code which was referenced on some other issues and tried to implement a custom interactive mode similar. But the build log seems to be locked by the current process when I launch the second build.

public class InteractiveBuildAttribute : BuildExtensionAttributeBase, IOnBuildInitialized
{
    public void OnBuildInitialized(
        IReadOnlyCollection<ExecutableTarget> executableTargets,
        IReadOnlyCollection<ExecutableTarget> executionPlan)
    {
        if (!Build.Help && executionPlan.Count == 0 && NukeBuild.Host is Terminal)
        {
            Log.Information("No target specified, please select the target you want to run:");

            foreach (var target in executableTargets.Where(x => x.Listed))
            {
                Log.Information("{TargetName}", target.Name);
                if (!string.IsNullOrWhiteSpace(target.Description))
                {
                    Log.Information("  {Description}", target.Description);
                }
            }

            var defaultTarget = nameof(MySharedNukeBuild.Compile);
            Log.Information(
                "Type the name of the target you want to execute and hit [ENTER] (default: {DefaultTarget})",
                defaultTarget);
            Console.Write("> ");
            var selectedTarget = Console.ReadLine();

            if (string.IsNullOrEmpty(selectedTarget))
            {
                selectedTarget = defaultTarget;
            }
            else if (!executableTargets.Any(t => selectedTarget.EqualsOrdinalIgnoreCase(t.Name)))
            {
                Log.Error("Unknown target '{SelectedTarget}' specified, exiting", selectedTarget);
                Environment.Exit(1);
            }

            Log.Information("Starting target {SelectedTarget}", selectedTarget);

            var assembly = Assembly.GetEntryAssembly()!.Location;
            var exitCode = 0;
            DotNetTasks.DotNet($"{assembly} -- --nologo --target {selectedTarget}",
                logInvocation: false,
                exitHandler: p => exitCode = p.ExitCode);
            Environment.Exit(exitCode);
        }
    }
}

// HandleHelpRequests has Priority 5, we want to be before it
[InteractiveBuild(Priority = 7)]
public class MySharedNukeBuild;
17:20:04 [DBG] The process cannot access the file 'D:\my-project\.nuke\temp\build.log' because it is being used by another process.

Even though it would still be great to have this built-in I'd be fine with implementing it myself via BuildExtensionAttributeBase. Any hints in how to avoid resource locks with multiple Nuke processes would be awesome.

matkoch commented 1 month ago

I would not implement it that way. I would rather extend BuildManager to become interactive if there's no default target, or if --interactive (new parameter) was passed. Then it could continue to operate in the same process, effectively providing what is usually passed in the Main method.

Danielku15 commented 1 month ago

Assuming we'd extend things here. How would you prefer to trigger the interactive flow? If we can agree on some path that also makes you happy, I'd could work on a PR 😉 Here some thoughts and proposals:

One idea could be an extension which allows handling this scenario in custom ways and devs implement the logic as they like:

var invokedTargets = ParameterService.GetParameter<string[]>(() => build.InvokedTargets);
if (build.IsInteractive || invokedTargets is not { Length: > 0 })
{
    var newTargetList = new List<ExecutableTarget>();
    build.ExecuteExtension<IOnBuildInteractiveTargets>(x => x.OnSelectTargetsInteractively(build.ExecutableTargets, newTargetList));
    if (newTargetList.Count > 0)
    {
        invokedTargets = newTargetList.Select(t => t.Name).ToArray();
    }
}

build.ExecutionPlan = ExecutionPlanner.GetExecutionPlan(
    build.ExecutableTargets,
    invokedTargets );

The alternative would be to go for a more direct call path to NukeBuild and Host:

In code similar to:

// BuildManager.cs
var invokedTargets = ParameterService.GetParameter<string[]>(() => build.InvokedTargets);
if (invokedTargets is not { Length: > 0 })
{
    invokedTargets = build.OnSelectTargetsInteractively(build.ExecutableTargets)?.Select(t => t.Name).ToArray();
}

build.ExecutionPlan = ExecutionPlanner.GetExecutionPlan(
    build.ExecutableTargets,
    invokedTargets);

// NukeBuild.cs
protected bool SelectTargetsInteractively { get; set; } // opt-in

protected virtual IReadOnlyCollection<ExecutableTarget>? OnSelectTargetsInteractively(IReadOnlyCollection<ExecutableTarget> executableTargets)
{
    if (IsInteractive || SelectTargetsInteractively)
    {
        return Host.OnSelectTargetsInteractively(executableTargets);  
    }
    else 
    {
        return null;
    }
}

// Host.cs
protected virtual IReadOnlyCollection<ExecutableTarget>? OnSelectTargetsInteractively(IReadOnlyCollection<ExecutableTarget> executableTargets)
{
    return null;
}

// Terminal.cs
protected override IReadOnlyCollection<ExecutableTarget>? OnSelectTargetsInteractively(IReadOnlyCollection<ExecutableTarget> executableTargets)
{
    PrintPrompt(executableTargets);
    var userInput = Console.ReadLine().Split(',');
    var target = userInput.Select(i => executableTargets.FirstOrDefault(t => i.EqualsOrdinalIgnoreCase(t.Name)).Where(t => t != null).ToReadOnlyCollection();
    if (target.Count != userInput.Length)
    {
        PrintError(userInput);
        return null;
    }
    return target;
}
matkoch commented 1 month ago

In the first iteration, I would keep it simpler and implement it without extension points. Without much research, I think ExecutableTargetFactory is a good place to do this. I already left a comment there, that ParameterService.GetParameter<string[]>(() => build.InvokedTargets) should be handled in that class. I also wouldn't mind adding a reference to SpectreConsole in Nuke.Build (there's a fork referenced in Nuke.GlobalTool already) to allow multi-selection.

Danielku15 commented 1 month ago

@matkoch I started with a proposal here https://github.com/nuke-build/nuke/pull/1437 let me know what you think 😁