Ctrl K
ComunidadGuías…

BoardOil.Dev/DevOrchestrator.cs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
namespace BoardOil.Dev;

internal sealed class DevOrchestrator
{
    private readonly string repoRoot;
    private readonly DevDatabaseManager databaseManager;
    private readonly PortConflictResolver portConflictResolver = new();
    private readonly List<ManagedService> services;
    private readonly CancellationTokenSource shutdown = new();
    private Task? activeOperation;
    private int selectedServiceIndex;
    private int selectedLogIndex;
    private string statusMessage = "Ready.";

    public DevOrchestrator(string repoRoot, DevDatabaseManager databaseManager)
    {
        this.repoRoot = repoRoot;
        this.databaseManager = databaseManager;
        services =
        [
            CreateApiService(),
            CreateWebService(),
            CreateDemoService()
        ];
    }

    public async Task<int> RunAsync()
    {
        if (Console.IsInputRedirected || Console.IsOutputRedirected)
        {
            Console.Error.WriteLine("BoardOil dev orchestrator requires an interactive terminal. Use dev-startall for unattended startup.");
            return 1;
        }

        var originalCancelHandling = Console.TreatControlCAsInput;
        Console.TreatControlCAsInput = true;

        try
        {
            Console.CursorVisible = false;
            await DrawLoopAsync(shutdown.Token);
            return 0;
        }
        catch (OperationCanceledException)
        {
            return 0;
        }
        finally
        {
            shutdown.Cancel();
            await AwaitActiveOperationAsync();
            await StopAllAsync();
            Console.CursorVisible = true;
            Console.TreatControlCAsInput = originalCancelHandling;
            Console.ResetColor();
            Console.Clear();
        }
    }

    private async Task DrawLoopAsync(CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            Render();

            var delayTask = Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken);
            while (Console.KeyAvailable)
            {
                HandleKey(Console.ReadKey(intercept: true), cancellationToken);
                Render();
            }

            await delayTask;
        }
    }

    private void HandleKey(ConsoleKeyInfo key, CancellationToken cancellationToken)
    {
        if (key.Modifiers.HasFlag(ConsoleModifiers.Control) && key.Key == ConsoleKey.C)
        {
            shutdown.Cancel();
            return;
        }

        switch (key.Key)
        {
            case ConsoleKey.UpArrow:
            case ConsoleKey.K:
                SelectService(Math.Max(0, selectedServiceIndex - 1));
                break;
            case ConsoleKey.DownArrow:
            case ConsoleKey.J:
                SelectService(Math.Min(services.Count - 1, selectedServiceIndex + 1));
                break;
            case ConsoleKey.D1:
            case ConsoleKey.NumPad1:
                SelectService(0);
                break;
            case ConsoleKey.D2:
            case ConsoleKey.NumPad2:
                SelectService(1);
                break;
            case ConsoleKey.D3:
            case ConsoleKey.NumPad3:
                SelectService(2);
                break;
            case ConsoleKey.Spacebar:
                BeginOperation(() => ToggleSelectedAsync(cancellationToken));
                break;
            case ConsoleKey.R:
                BeginOperation(() => RestartSelectedAsync(cancellationToken));
                break;
            case ConsoleKey.A:
                BeginOperation(() => StartDefaultServicesAsync(cancellationToken));
                break;
            case ConsoleKey.X:
                BeginOperation(StopAllWithStatusAsync);
                break;
            case ConsoleKey.B:
                BeginOperation(() => UseDatabaseAsync(DevDatabaseMode.Branch, cancellationToken));
                break;
            case ConsoleKey.S:
                BeginOperation(() => UseDatabaseAsync(DevDatabaseMode.Shared, cancellationToken));
                break;
            case ConsoleKey.T:
                BeginOperation(() => UseDatabaseAsync(DevDatabaseMode.Temporary, cancellationToken));
                break;
            case ConsoleKey.L:
                selectedLogIndex = (selectedLogIndex + 1) % services.Count;
                break;
            case ConsoleKey.Q:
            case ConsoleKey.Escape:
                shutdown.Cancel();
                break;
        }
    }

    private void BeginOperation(Func<Task> operation)
    {
        if (activeOperation is { IsCompleted: false })
        {
            statusMessage = "A service operation is already in progress.";
            return;
        }

        activeOperation = RunOperationAsync(operation);
    }

    private async Task RunOperationAsync(Func<Task> operation)
    {
        try
        {
            await operation();
        }
        catch (OperationCanceledException) when (shutdown.IsCancellationRequested)
        {
        }
        catch (Exception exception)
        {
            statusMessage = $"Operation failed: {exception.Message}";
        }
    }

    private async Task AwaitActiveOperationAsync()
    {
        if (activeOperation is null)
        {
            return;
        }

        try
        {
            await activeOperation;
        }
        catch (OperationCanceledException)
        {
        }
    }

    private async Task StopAllWithStatusAsync()
    {
        await StopAllAsync();
        statusMessage = "Stopped all services.";
    }

    private void SelectService(int index)
    {
        selectedServiceIndex = index;
        selectedLogIndex = index;
    }

    private async Task UseDatabaseAsync(DevDatabaseMode mode, CancellationToken cancellationToken)
    {
        if (databaseManager.Current.Mode == mode && mode != DevDatabaseMode.Temporary)
        {
            statusMessage = $"Already using the {databaseManager.Current.DisplayName} database.";
            return;
        }

        var apiWasRunning = services[0].IsRunning;
        await services[0].StopAsync();
        databaseManager.Select(mode);
        services[0] = CreateApiService();
        statusMessage = $"Using the {databaseManager.Current.DisplayName} database.";

        if (apiWasRunning)
        {
            await StartServiceAsync(services[0], cancellationToken);
        }
    }

    private async Task ToggleSelectedAsync(CancellationToken cancellationToken)
    {
        var service = services[selectedServiceIndex];
        if (service.IsRunning)
        {
            await service.StopAsync();
            statusMessage = $"Stopped {service.Name}.";
            return;
        }

        await StartServiceAsync(service, cancellationToken);
    }

    private async Task RestartSelectedAsync(CancellationToken cancellationToken)
    {
        var service = services[selectedServiceIndex];
        await service.StopAsync();
        await StartServiceAsync(service, cancellationToken);
    }

    private async Task StartDefaultServicesAsync(CancellationToken cancellationToken)
    {
        foreach (var service in services.Where(service => service.StartsWithDefaultAction))
        {
            if (!service.IsRunning)
            {
                await StartServiceAsync(service, cancellationToken);
            }
        }
    }

    private async Task StopAllAsync()
    {
        foreach (var service in services)
        {
            await service.StopAsync();
        }
    }

    private async Task StartServiceAsync(ManagedService service, CancellationToken cancellationToken)
    {
        try
        {
            statusMessage = $"Starting {service.Name}...";
            await service.StartAsync(cancellationToken);
            statusMessage = $"Started {service.Name}.";
        }
        catch (Exception exception) when (exception is not OperationCanceledException)
        {
            statusMessage = $"Failed to start {service.Name}: {exception.Message}";
        }
    }

    private ManagedService CreateApiService()
    {
        var apiProject = Path.Combine(repoRoot, "BoardOil.Api", "BoardOil.Api.csproj");
        var logPath = Path.Combine(repoRoot, ".data", "dev", "logs", "api.log");
        var dotnet = ExecutableLocator.Find("dotnet") ?? "dotnet";
        var databasePath = databaseManager.Current.DatabasePath;
        var exposeLanValue = Environment.GetEnvironmentVariable("BoardOil__ExposeLan");
        var exposeLan = bool.TryParse(exposeLanValue, out var parsedExposeLan) && parsedExposeLan;
        var displayEndpoint = DevApiLaunchSettings.CreateDisplayEndpoint(exposeLan);

        return new ManagedService(
            "API",
            displayEndpoint,
            dotnet,
            DevApiLaunchSettings.CreateRunArguments(apiProject),
            repoRoot,
            logPath,
            DevApiLaunchSettings.CreateEnvironment(databasePath, exposeLan),
            startsWithDefaultAction: true,
            beforeStart: async (service, cancellationToken) =>
            {
                EnsureExecutableExists("dotnet", dotnet);
                databaseManager.PrepareCurrentDatabase();
                foreach (var port in DevApiLaunchSettings.Ports)
                {
                    await portConflictResolver.StopRecognisedListenerAsync(
                        port,
                        ["BoardOil.Api"],
                        cancellationToken);
                }

                await service.RunPreparationCommandAsync(
                    dotnet,
                    ["dev-certs", "https"],
                    repoRoot,
                    cancellationToken);
                await service.RunPreparationCommandAsync(
                    dotnet,
                    ["build", apiProject, "-maxcpucount:1", "-nodeReuse:false"],
                    repoRoot,
                    cancellationToken);
            });
    }

    private ManagedService CreateWebService()
    {
        var webDirectory = Path.Combine(repoRoot, "BoardOil.Web");
        var logPath = Path.Combine(repoRoot, ".data", "dev", "logs", "web.log");
        var npm = ExecutableLocator.Find("npm") ?? "npm";

        return new ManagedService(
            "Web",
            DevWebLaunchSettings.Endpoint,
            npm,
            ["run", "dev"],
            webDirectory,
            logPath,
            DevWebLaunchSettings.CreateEnvironment(),
            startsWithDefaultAction: true,
            beforeStart: async (_, cancellationToken) =>
            {
                EnsureWebDependencies(npm, webDirectory);
                await portConflictResolver.StopRecognisedListenerAsync(
                    5173,
                    ["vite"],
                    cancellationToken);
            });
    }

    private ManagedService CreateDemoService()
    {
        var webDirectory = Path.Combine(repoRoot, "BoardOil.Web");
        var logPath = Path.Combine(repoRoot, ".data", "dev", "logs", "demo.log");
        var npm = ExecutableLocator.Find("npm") ?? "npm";

        return new ManagedService(
            "Demo",
            DevDemoLaunchSettings.Endpoint,
            npm,
            ["run", "dev:demo"],
            webDirectory,
            logPath,
            new Dictionary<string, string>(),
            startsWithDefaultAction: false,
            beforeStart: async (_, cancellationToken) =>
            {
                EnsureWebDependencies(npm, webDirectory);
                await portConflictResolver.StopRecognisedListenerAsync(
                    DevDemoLaunchSettings.Port,
                    ["vite"],
                    cancellationToken);
            });
    }

    private static void EnsureWebDependencies(string npm, string webDirectory)
    {
        EnsureExecutableExists("npm", npm);
        var nodeModules = Path.Combine(webDirectory, "node_modules");
        if (!Directory.Exists(nodeModules))
        {
            throw new InvalidOperationException(
                "BoardOil.Web/node_modules is missing. Run 'cd BoardOil.Web && npm ci' first.");
        }
    }

    private static void EnsureExecutableExists(string displayName, string resolvedPath)
    {
        if (ExecutableLocator.Find(displayName) is null && !File.Exists(resolvedPath))
        {
            throw new InvalidOperationException($"{displayName} is required but was not found on PATH.");
        }
    }

    private void Render()
    {
        var width = Math.Max(1, Console.WindowWidth - 1);
        var height = Math.Max(1, Console.WindowHeight);
        var logHeight = Math.Max(5, height - 13 - services.Count);

        Console.SetCursorPosition(0, 0);
        WriteLine("BoardOil Dev Orchestrator", width, ConsoleColor.Cyan);
        WriteLine(new string('=', Math.Min(width, 120)), width, ConsoleColor.DarkGray);
        WriteLine(
            $"Database: {databaseManager.Current.DisplayName} [{databaseManager.GetDisplayPath()}]",
            width,
            DatabaseColour());
        WriteLine("Services", width, ConsoleColor.White);

        for (var index = 0; index < services.Count; index++)
        {
            var service = services[index];
            var selector = index == selectedServiceIndex ? ">" : " ";
            var marker = GetStateMarker(service.State);
            var processId = service.ProcessId?.ToString() ?? "-";
            var uptime = service.IsRunning ? FormatDuration(service.Uptime) : "--:--";
            var line = $"{selector} {index + 1}. {marker} {service.Name,-5} {service.State.Text,-10} pid {processId,-7} up {uptime,-8} {service.Endpoint}";
            WriteLine(line, width, ServiceColour(service.State));
        }

        WriteLine(string.Empty, width);
        WriteLine(
            "Keys: Up/Down  Space start/stop  R restart  A start API/Web  X stop all  B branch DB  S shared DB  T new temp DB  L logs  1-3 select  Q quit",
            width,
            ConsoleColor.Yellow);
        WriteLine($"Status: {statusMessage}", width, ConsoleColor.White);
        WriteLine(string.Empty, width);

        var logService = services[selectedLogIndex];
        WriteLine($"Logs: {logService.Name} ({logService.LogPath})", width, ConsoleColor.White);
        WriteLine(new string('-', Math.Min(width, 120)), width, ConsoleColor.DarkGray);

        foreach (var line in logService.GetLogLines(logHeight))
        {
            WriteLine(line, width, ConsoleColor.DarkGray);
        }

        for (var index = logService.VisibleLogLineCount(logHeight); index < logHeight; index++)
        {
            WriteLine(string.Empty, width);
        }
    }

    private ConsoleColor DatabaseColour()
    {
        if (databaseManager.Current.Mode == DevDatabaseMode.Temporary)
        {
            return ConsoleColor.Yellow;
        }

        return ConsoleColor.Gray;
    }

    private static string GetStateMarker(ServiceProcessState state)
    {
        if (state.HasFailed)
        {
            return "[!!]";
        }

        return state.Text == "running" ? "[OK]" : "[--]";
    }

    private static ConsoleColor ServiceColour(ServiceProcessState state)
    {
        if (state.HasFailed)
        {
            return ConsoleColor.Red;
        }

        return state.Text == "running" ? ConsoleColor.Green : ConsoleColor.Gray;
    }

    private static void WriteLine(string text, int width, ConsoleColor? colour = null)
    {
        if (colour is not null)
        {
            Console.ForegroundColor = colour.Value;
        }

        var clipped = text.Length > width ? text[..width] : text;
        Console.Write(clipped);
        if (clipped.Length < width)
        {
            Console.Write(new string(' ', width - clipped.Length));
        }

        Console.WriteLine();
        if (colour is not null)
        {
            Console.ResetColor();
        }
    }

    private static string FormatDuration(TimeSpan duration)
    {
        if (duration.TotalHours >= 1)
        {
            return $"{(int)duration.TotalHours:00}:{duration.Minutes:00}:{duration.Seconds:00}";
        }

        return $"{duration.Minutes:00}:{duration.Seconds:00}";
    }
}