journal/Journal.DevTool/Runner/ProcessRunner.cs
stan44 5b383858ae feat(sdt): add Journal.DevTool TUI, devtool.json, and justfile
- Journal.DevTool: C# TUI dev tool (SDT) using Spectre.Console
  - Phosphor green (#00FF41) terminal aesthetic with amber/red accents
  - Grouped build target menu driven by devtool.json config
  - Topological dependency resolver for DependsOn chains
  - Live stdout/stderr streaming per target step
  - Interactive environment variable editor (dropdown + free-text)
  - Toolchain management screen: Python venv create/install/upgrade, Node npm install
  - Workspace switcher: reads sdt-workspace.json, hot-switches between projects
  - Outputs as 'sdt' executable (net10.0)

- devtool.json: project config for SDT
  - All build targets: sidecar, web, webgateway, tauri, tauri-nsis, all (virtual)
  - Dev targets: run-gateway
  - Test targets: test (smoke), gate (migration)
  - Cache targets: nuget-export, nuget-import
  - Toolchains: Python 3.14 (cpu/gpu/nlp profiles) + Node/npm (Journal.App)
  - Env vars: AI provider, log level, NLP backend, path overrides

- justfile: just command runner recipes wrapping existing scripts
  - Correct dependency ordering (sidecar before tauri, web before webgateway)
  - OS-aware runtime detection (win-x64 / linux-x64)
  - Recipes: sidecar, web, webgateway, tauri, tauri-nsis, all, run, dev-app, test, gate, build, nuget-export/import, sdt

- Journal.slnx: added Journal.DevTool project
2026-02-27 13:12:36 -06:00

61 lines
1.8 KiB
C#

using System.Diagnostics;
namespace Sdt.Runner;
public sealed record RunResult(int ExitCode, TimeSpan Elapsed)
{
public bool Success => ExitCode == 0;
}
public static class ProcessRunner
{
/// <summary>
/// Runs a command with the given args, streaming stdout/stderr via <paramref name="onOutput"/>.
/// onOutput receives (line, isStderr).
/// </summary>
public static async Task<RunResult> RunAsync(
string command,
IEnumerable<string> args,
string workingDir,
Action<string, bool> onOutput,
CancellationToken cancellationToken = default)
{
var psi = new ProcessStartInfo
{
FileName = command,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = workingDir,
};
foreach (var arg in args)
psi.ArgumentList.Add(arg);
var sw = Stopwatch.StartNew();
using var process = new Process { StartInfo = psi };
process.Start();
var stdoutTask = DrainAsync(process.StandardOutput, line => onOutput(line, false), cancellationToken);
var stderrTask = DrainAsync(process.StandardError, line => onOutput(line, true), cancellationToken);
await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
sw.Stop();
return new RunResult(process.ExitCode, sw.Elapsed);
}
private static async Task DrainAsync(StreamReader reader, Action<string> emit, CancellationToken ct)
{
string? line;
while ((line = await reader.ReadLineAsync(ct).ConfigureAwait(false)) is not null
&& !ct.IsCancellationRequested)
{
emit(line);
}
}
}