Initial commit: Nix WSL Wrapper project with DDD, Clean Code, and tests

This commit is contained in:
Marek Novák
2026-07-01 02:04:43 +02:00
commit 161eb7d387
22 changed files with 932 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
using System;
using System.Diagnostics;
using System.Linq;
using NixWslWrapper.Core.Domain;
using NixWslWrapper.Core.Interfaces;
using Spectre.Console;
namespace NixWslWrapper.Cli
{
public class CliController
{
private readonly ICommandExecutor _commandExecutor;
public CliController(ICommandExecutor commandExecutor)
{
_commandExecutor = commandExecutor ?? throw new ArgumentNullException(nameof(commandExecutor));
}
public int Run(string[] args)
{
// Intercept special configuration/status options
if (args.Contains("--wsl-status", StringComparer.OrdinalIgnoreCase))
{
RenderStatusDashboard();
return 0;
}
if (args.Contains("--wsl-help", StringComparer.OrdinalIgnoreCase))
{
RenderHelp();
return 0;
}
// Get configuration from environment variables or use defaults
string distro = Environment.GetEnvironmentVariable("NIX_WSL_DISTRO") ?? "Debian";
string user = Environment.GetEnvironmentVariable("NIX_WSL_USER") ?? "root";
string profile = Environment.GetEnvironmentVariable("NIX_WSL_PROFILE") ?? "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh";
WslTarget target;
try
{
target = new WslTarget(distro, user, profile);
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Configuration Error:[/] {ex.Message}");
return 1;
}
var nixArgs = new NixArguments(args);
var result = _commandExecutor.Execute(nixArgs, target);
if (!result.IsSuccess)
{
if (result.ErrorMessage != null)
{
AnsiConsole.MarkupLine($"[bold red]Execution Error:[/] {result.ErrorMessage}");
}
return result.ExitCode;
}
return result.ExitCode;
}
private static void RenderStatusDashboard()
{
AnsiConsole.Write(
new FigletText("Nix WSL Wrapper")
.Color(Color.DeepSkyBlue1));
var panel = new Panel(
new Markup(
"[bold]Nix WSL Wrapper Diagnostic Dashboard[/]\n" +
"Wraps native Windows MCP / CLI tool inputs and redirects them into Nix running inside WSL.\n\n" +
$"[grey]Generated at: {DateTime.Now:yyyy-MM-dd HH:mm:ss}[/]"
))
{
Border = BoxBorder.Rounded,
Padding = new Padding(1, 1, 1, 1),
Header = new PanelHeader("System Info")
};
AnsiConsole.Write(panel);
// Fetch Environment details
string distro = Environment.GetEnvironmentVariable("NIX_WSL_DISTRO") ?? "Debian (Default)";
string user = Environment.GetEnvironmentVariable("NIX_WSL_USER") ?? "root (Default)";
string profile = Environment.GetEnvironmentVariable("NIX_WSL_PROFILE") ?? "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh (Default)";
var table = new Table()
.Border(TableBorder.Rounded)
.BorderColor(Color.Grey)
.AddColumn("[bold]Setting[/]")
.AddColumn("[bold]Value[/]")
.AddColumn("[bold]Source[/]");
table.AddRow("WSL Target Distro", distro, Environment.GetEnvironmentVariable("NIX_WSL_DISTRO") != null ? "[green]Env Var[/]" : "[yellow]Fallback[/]");
table.AddRow("WSL Execution User", user, Environment.GetEnvironmentVariable("NIX_WSL_USER") != null ? "[green]Env Var[/]" : "[yellow]Fallback[/]");
table.AddRow("Nix Profile Path", profile, Environment.GetEnvironmentVariable("NIX_WSL_PROFILE") != null ? "[green]Env Var[/]" : "[yellow]Fallback[/]");
AnsiConsole.Write(new Rule("[yellow]Configured Parameters[/]") { Justification = Justify.Left });
AnsiConsole.Write(table);
// Run WSL check
AnsiConsole.Write(new Rule("[yellow]WSL Integration Status[/]") { Justification = Justify.Left });
AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.Start("Verifying WSL and Nix installation...", ctx =>
{
bool wslOk = false;
string wslVersion = "Unknown";
try
{
var psi = new ProcessStartInfo("wsl.exe", "--status")
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var p = Process.Start(psi);
p?.WaitForExit(3000);
wslOk = p?.ExitCode == 0;
wslVersion = wslOk ? "WSL Active" : "WSL Error";
}
catch
{
wslVersion = "wsl.exe not found in PATH";
}
ctx.Status("Checking Nix inside WSL Distro...");
bool nixOk = false;
string nixVersion = "Not Found";
if (wslOk)
{
try
{
string targetDistro = Environment.GetEnvironmentVariable("NIX_WSL_DISTRO") ?? "Debian";
string targetUser = Environment.GetEnvironmentVariable("NIX_WSL_USER") ?? "root";
string targetProfile = Environment.GetEnvironmentVariable("NIX_WSL_PROFILE") ?? "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh";
var psi = new ProcessStartInfo("wsl.exe", $"-d {targetDistro} -u {targetUser} -- sh -c \". {targetProfile} && nix --version\"")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var p = Process.Start(psi);
p?.WaitForExit(5000);
if (p?.ExitCode == 0)
{
nixOk = true;
nixVersion = p.StandardOutput.ReadToEnd().Trim();
}
else
{
nixVersion = "Nix execution failed inside WSL";
}
}
catch (Exception ex)
{
nixVersion = $"Error querying: {ex.Message}";
}
}
// Render Checks
var grid = new Grid().AddColumn().AddColumn();
grid.AddRow(new Markup("wsl.exe Executable Status:"), wslOk ? new Markup("[green]✔ OK[/]") : new Markup($"[red]✘ FAILED ({wslVersion})[/]"));
grid.AddRow(new Markup("Nix Engine in WSL Distro:"), nixOk ? new Markup($"[green]✔ OK ({nixVersion})[/]") : new Markup($"[red]✘ FAILED ({nixVersion})[/]"));
AnsiConsole.Write(new Panel(grid) { Border = BoxBorder.None });
});
}
private static void RenderHelp()
{
AnsiConsole.Write(
new FigletText("Nix WSL Wrapper")
.Color(Color.DeepSkyBlue1));
AnsiConsole.MarkupLine("[bold]Usage:[/] nix.exe [[nix-arguments-to-forward]]");
AnsiConsole.MarkupLine("[bold]Alternative flags (Wrapper specific):[/]");
AnsiConsole.MarkupLine(" [yellow]--wsl-status[/] Displays diagnostic status of WSL and Nix integration.");
AnsiConsole.MarkupLine(" [yellow]--wsl-help[/] Displays this help menu.");
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[bold]Configuration Environment Variables:[/]");
AnsiConsole.MarkupLine(" [green]NIX_WSL_DISTRO[/] The target WSL distribution name (default: [grey]Debian[/]).");
AnsiConsole.MarkupLine(" [green]NIX_WSL_USER[/] The user to execute commands under (default: [grey]root[/]).");
AnsiConsole.MarkupLine(" [green]NIX_WSL_PROFILE[/] The path to the nix-daemon profile script (default: [grey]/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh[/]).");
}
}
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\NixWslWrapper.Infrastructure\NixWslWrapper.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageReference Include="Spectre.Console" Version="0.57.1" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+27
View File
@@ -0,0 +1,27 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using NixWslWrapper.Core.Interfaces;
using NixWslWrapper.Infrastructure.IO;
using NixWslWrapper.Infrastructure.Executors;
namespace NixWslWrapper.Cli
{
internal class Program
{
private static int Main(string[] args)
{
var serviceProvider = ConfigureServices();
var controller = serviceProvider.GetRequiredService<CliController>();
return controller.Run(args);
}
private static IServiceProvider ConfigureServices()
{
return new ServiceCollection()
.AddSingleton<IStreamCopier, ThreadedStreamCopier>()
.AddSingleton<ICommandExecutor, WslCommandExecutor>()
.AddSingleton<CliController>()
.BuildServiceProvider();
}
}
}
@@ -0,0 +1,36 @@
using System;
namespace NixWslWrapper.Core.Domain
{
public class ExecutionResult
{
public int ExitCode { get; }
public bool IsSuccess => ExitCode == 0;
public string? ErrorMessage { get; }
public ExecutionResult(int exitCode, string? errorMessage = null)
{
ExitCode = exitCode;
ErrorMessage = errorMessage;
}
public static ExecutionResult Success() => new ExecutionResult(0);
public static ExecutionResult Failure(int exitCode, string errorMessage) => new ExecutionResult(exitCode, errorMessage);
public override bool Equals(object? obj)
{
if (obj is not ExecutionResult other) return false;
return ExitCode == other.ExitCode && string.Equals(ErrorMessage, other.ErrorMessage, StringComparison.Ordinal);
}
public override int GetHashCode()
{
return HashCode.Combine(ExitCode, ErrorMessage);
}
public override string ToString()
{
return IsSuccess ? "Success" : $"Failed with Exit Code {ExitCode}. Error: {ErrorMessage}";
}
}
}
@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace NixWslWrapper.Core.Domain
{
public class NixArguments
{
public string[] RawArguments { get; }
public NixArguments(string[]? rawArguments)
{
RawArguments = rawArguments ?? Array.Empty<string>();
}
public string ToEscapedString()
{
if (RawArguments.Length == 0) return string.Empty;
var escapedList = new List<string>();
foreach (var arg in RawArguments)
{
// Escape existing double quotes
string escaped = arg.Replace("\"", "\\\"");
// Wrap in double quotes to prevent word splitting in bash
escapedList.Add($"\"{escaped}\"");
}
return string.Join(" ", escapedList);
}
public override bool Equals(object? obj)
{
if (obj is not NixArguments other) return false;
if (RawArguments.Length != other.RawArguments.Length) return false;
return RawArguments.SequenceEqual(other.RawArguments);
}
public override int GetHashCode()
{
int hash = 17;
foreach (var arg in RawArguments)
{
hash = hash * 23 + (arg?.GetHashCode() ?? 0);
}
return hash;
}
public override string ToString()
{
return string.Join(" ", RawArguments);
}
}
}
@@ -0,0 +1,52 @@
using System;
namespace NixWslWrapper.Core.Domain
{
public class WslTarget
{
public string Distribution { get; }
public string User { get; }
public string NixProfilePath { get; }
public WslTarget(string distribution, string user, string nixProfilePath = "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh")
{
if (string.IsNullOrWhiteSpace(distribution))
throw new ArgumentException("WSL Distribution name cannot be null or empty.", nameof(distribution));
if (string.IsNullOrWhiteSpace(user))
throw new ArgumentException("WSL User cannot be null or empty.", nameof(user));
if (string.IsNullOrWhiteSpace(nixProfilePath))
throw new ArgumentException("Nix Profile Path cannot be null or empty.", nameof(nixProfilePath));
Distribution = distribution.Trim();
User = user.Trim();
NixProfilePath = nixProfilePath.Trim();
}
public override bool Equals(object? obj)
{
if (obj is not WslTarget other) return false;
return string.Equals(Distribution, other.Distribution, StringComparison.OrdinalIgnoreCase) &&
string.Equals(User, other.User, StringComparison.OrdinalIgnoreCase) &&
string.Equals(NixProfilePath, other.NixProfilePath, StringComparison.Ordinal);
}
public override int GetHashCode()
{
return HashCode.Combine(
Distribution.ToLowerOrdinal(),
User.ToLowerOrdinal(),
NixProfilePath
);
}
public override string ToString()
{
return $"WSL Distro: {Distribution}, User: {User}, Profile: {NixProfilePath}";
}
}
internal static class StringExtensions
{
public static string ToLowerOrdinal(this string str) => str.ToLowerInvariant();
}
}
@@ -0,0 +1,9 @@
using NixWslWrapper.Core.Domain;
namespace NixWslWrapper.Core.Interfaces
{
public interface ICommandExecutor
{
ExecutionResult Execute(NixArguments arguments, WslTarget target);
}
}
@@ -0,0 +1,9 @@
using System.IO;
namespace NixWslWrapper.Core.Interfaces
{
public interface IStreamCopier
{
void Copy(Stream input, Stream output);
}
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,61 @@
using System.Diagnostics;
namespace NixWslWrapper.Infrastructure.Executors
{
public class ProcessBuilder
{
private readonly ProcessStartInfo _startInfo = new();
public ProcessBuilder SetFileName(string fileName)
{
_startInfo.FileName = fileName;
return this;
}
public ProcessBuilder SetArguments(string arguments)
{
_startInfo.Arguments = arguments;
return this;
}
public ProcessBuilder RedirectInput(bool redirect = true)
{
_startInfo.RedirectStandardInput = redirect;
return this;
}
public ProcessBuilder RedirectOutput(bool redirect = true)
{
_startInfo.RedirectStandardOutput = redirect;
return this;
}
public ProcessBuilder RedirectError(bool redirect = true)
{
_startInfo.RedirectStandardError = redirect;
return this;
}
public ProcessBuilder UseShellExecute(bool useShell = false)
{
_startInfo.UseShellExecute = useShell;
return this;
}
public ProcessBuilder CreateNoWindow(bool noWindow = true)
{
_startInfo.CreateNoWindow = noWindow;
return this;
}
public ProcessStartInfo Build()
{
return _startInfo;
}
public Process CreateProcess()
{
return new Process { StartInfo = _startInfo };
}
}
}
@@ -0,0 +1,85 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using NixWslWrapper.Core.Domain;
using NixWslWrapper.Core.Interfaces;
namespace NixWslWrapper.Infrastructure.Executors
{
public class WslCommandExecutor : ICommandExecutor
{
private readonly IStreamCopier _streamCopier;
private readonly Stream _hostIn;
private readonly Stream _hostOut;
private readonly Stream _hostErr;
public WslCommandExecutor(
IStreamCopier streamCopier,
Stream? hostIn = null,
Stream? hostOut = null,
Stream? hostErr = null)
{
_streamCopier = streamCopier ?? throw new ArgumentNullException(nameof(streamCopier));
_hostIn = hostIn ?? Console.OpenStandardInput();
_hostOut = hostOut ?? Console.OpenStandardOutput();
_hostErr = hostErr ?? Console.OpenStandardError();
}
public ExecutionResult Execute(NixArguments arguments, WslTarget target)
{
if (arguments == null) throw new ArgumentNullException(nameof(arguments));
if (target == null) throw new ArgumentNullException(nameof(target));
string wslCmd = $". {target.NixProfilePath} && nix {arguments.ToEscapedString()}";
string wslArgs = $"-d {target.Distribution} -u {target.User} -- sh -c \"{wslCmd.Replace("\"", "\\\"")}\"";
var processBuilder = new ProcessBuilder()
.SetFileName("wsl.exe")
.SetArguments(wslArgs)
.RedirectInput(true)
.RedirectOutput(true)
.RedirectError(true)
.UseShellExecute(false)
.CreateNoWindow(true);
using (Process process = processBuilder.CreateProcess())
{
try
{
process.Start();
// Standard streams copy tasks
Thread tOut = new(() => _streamCopier.Copy(process.StandardOutput.BaseStream, _hostOut))
{
IsBackground = true
};
Thread tErr = new(() => _streamCopier.Copy(process.StandardError.BaseStream, _hostErr))
{
IsBackground = true
};
Thread tIn = new(() => _streamCopier.Copy(_hostIn, process.StandardInput.BaseStream))
{
IsBackground = true
};
tOut.Start();
tErr.Start();
tIn.Start();
process.WaitForExit();
// Wait up to 1 second for stream copier threads to finalize flushing buffer content
tOut.Join(1000);
tErr.Join(1000);
return new ExecutionResult(process.ExitCode);
}
catch (Exception ex)
{
return ExecutionResult.Failure(1, $"Error executing Nix wrapper: {ex.Message}");
}
}
}
}
}
@@ -0,0 +1,44 @@
using System;
using System.IO;
using NixWslWrapper.Core.Interfaces;
namespace NixWslWrapper.Infrastructure.IO
{
public class ThreadedStreamCopier : IStreamCopier
{
private readonly int _bufferSize;
public ThreadedStreamCopier(int bufferSize = 8192)
{
if (bufferSize <= 0)
throw new ArgumentOutOfRangeException(nameof(bufferSize), "Buffer size must be positive.");
_bufferSize = bufferSize;
}
public void Copy(Stream input, Stream output)
{
byte[] buffer = new byte[_bufferSize];
int read;
try
{
while ((read = input.Read(buffer, 0, buffer.Length)) > 0)
{
output.Write(buffer, 0, read);
output.Flush();
}
}
catch (IOException)
{
// Clean exit when stream is closed or process terminates
}
catch (ObjectDisposedException)
{
// Clean exit when streams are disposed
}
catch (Exception)
{
// General catch to ensure thread doesn't crash the host process
}
}
}
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\NixWslWrapper.Core\NixWslWrapper.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>