namespace BoardOil.Dev; internal sealed class DevOrchestrator { private readonly string repoRoot; private readonly DevDatabaseManager databaseManager; private readonly PortConflictResolver portConflictResolver = new(); private readonly List 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 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 operation) { if (activeOperation is { IsCompleted: false }) { statusMessage = "A service operation is already in progress."; return; } activeOperation = RunOperationAsync(operation); } private async Task RunOperationAsync(Func 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(), 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}"; } }