using ControlPlane.Core.Models;
using ControlPlane.Core.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
namespace ControlPlane.Api.Services;
///
/// Runs dotnet build or npm run build for individual projects in the repo.
/// Used by the Build Monitor tab in the control plane UI.
///
public class ProjectBuildService(
IConfiguration config,
BuildHistoryService history,
ILogger logger)
{
public string RepoRoot => config["Docker:RepoRoot"] ?? string.Empty;
/// Known projects in the solution, returned to the UI for the build monitor grid.
public IReadOnlyList GetProjects()
{
if (string.IsNullOrWhiteSpace(RepoRoot)) return [];
return
[
new("Clarity.Server", BuildKind.DotnetProject, "Clarity.Server/Clarity.Server.csproj"),
new("Clarity.ServiceDefaults", BuildKind.DotnetProject, "Clarity.ServiceDefaults/Clarity.ServiceDefaults.csproj"),
new("frontend (Clarity.Server)", BuildKind.NpmProject, "frontend"),
];
}
///
/// Builds a single project and streams output to .
///
public async Task BuildProjectAsync(
string projectName,
Action onLine,
CancellationToken ct)
{
var projects = GetProjects();
var def = projects.FirstOrDefault(p => p.Name == projectName);
if (def is null)
{
var err = new BuildRecord { Kind = BuildKind.DotnetProject, Target = projectName, Status = BuildStatus.Failed };
err.Log.Add($"Unknown project: {projectName}");
return err;
}
var record = await history.CreateBuildAsync(def.Kind, def.RelativePath);
record.Log.Add($"▶ Building {def.Name} [{def.Kind}]");
record.Log.Add($" Path: {def.RelativePath}");
record.Log.Add("──────────────────────────────────────");
onLine($"▶ Building {def.Name}");
try
{
var (exe, args, workDir) = def.Kind == BuildKind.NpmProject
? BuildNpmCommand(def.RelativePath)
: BuildDotnetCommand(def.RelativePath);
var psi = new ProcessStartInfo(exe, args)
{
WorkingDirectory = workDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
void HandleLine(string? line)
{
if (line is null) return;
record.Log.Add(line);
onLine(line);
// Non-blocking fire-and-forget flush
_ = history.AppendBuildLogAsync(record, line);
}
proc.OutputDataReceived += (_, e) => HandleLine(e.Data);
proc.ErrorDataReceived += (_, e) => HandleLine(e.Data);
proc.Start();
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
await proc.WaitForExitAsync(ct);
var status = proc.ExitCode == 0 ? BuildStatus.Succeeded : BuildStatus.Failed;
var summary = proc.ExitCode == 0 ? "✔ Build succeeded." : $"✖ Build failed (exit {proc.ExitCode}).";
onLine("──────────────────────────────────────");
onLine(summary);
record.Log.Add(summary);
await history.CompleteBuildAsync(record, status);
logger.LogInformation("Project build [{Name}] {Status}", def.Name, status);
return record;
}
catch (Exception ex)
{
onLine($"✖ Exception: {ex.Message}");
record.Log.Add($"Exception: {ex.Message}");
await history.CompleteBuildAsync(record, BuildStatus.Failed);
logger.LogError(ex, "Project build [{Name}] threw.", def.Name);
return record;
}
}
private (string exe, string args, string workDir) BuildDotnetCommand(string relativePath)
{
var fullPath = Path.Combine(RepoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
return ("dotnet", $"build \"{fullPath}\" --configuration Release --nologo", RepoRoot);
}
private (string exe, string args, string workDir) BuildNpmCommand(string relativePath)
{
var workDir = Path.Combine(RepoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
// npm on Windows needs cmd /c
return (OperatingSystem.IsWindows() ? "cmd" : "sh",
OperatingSystem.IsWindows() ? "/c npm run build" : "-c \"npm run build\"",
workDir);
}
}
public record ProjectDefinition(string Name, BuildKind Kind, string RelativePath);