Initial commit: Nix WSL Wrapper project with DDD, Clean Code, and tests
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
||||
## Build outputs
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/dist/
|
||||
|
||||
## User-specific files
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
## Visual Studio cache and settings
|
||||
.vs/
|
||||
.vs*
|
||||
[Dd]ebug/
|
||||
[Rr]elease/
|
||||
|
||||
## Test results
|
||||
TestResults/
|
||||
*.trx
|
||||
|
||||
## Rider / JetBrains settings
|
||||
.idea/
|
||||
|
||||
## VS Code settings
|
||||
.vscode/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,10 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/NixWslWrapper.Cli/NixWslWrapper.Cli.csproj" />
|
||||
<Project Path="src/NixWslWrapper.Core/NixWslWrapper.Core.csproj" />
|
||||
<Project Path="src/NixWslWrapper.Infrastructure/NixWslWrapper.Infrastructure.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/NixWslWrapper.Tests/NixWslWrapper.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,62 @@
|
||||
# Nix WSL Wrapper (nix.exe)
|
||||
|
||||
A robust, transparent Windows executable wrapper designed to intercept native Windows commands and execute them seamlessly inside Nix Subsystem for Linux (WSL). This wrapper is particularly useful for allowing Windows-based MCP servers (like `utensils/mcp-nixos`), IDE integrations, and build tools to run `nix` commands natively on a Windows host without requiring full native Nix compile chains.
|
||||
|
||||
## Key Features
|
||||
* **Clean Architecture & SOLID**: Built in C# using Domain-Driven Design (DDD) to encapsulate domain validation (e.g. `WslTarget`, `NixArguments`).
|
||||
* **Deadlock-Free I/O Streaming**: Spawns background threads to redirect standard input, standard output, and standard error without blocking host tools.
|
||||
* **Premium CLI UI & Diagnostics**: Integrates `Spectre.Console` to render beautiful diagnostic dashboards, system statuses, and automated validation tests.
|
||||
* **Highly Configuration-driven**: Environment variables allow configuring the WSL distribution name, username, and path to the Nix profile.
|
||||
|
||||
---
|
||||
|
||||
## Architectural Layout
|
||||
* **NixWslWrapper.Core**: The domain layer defining value objects (`WslTarget`, `NixArguments`, `ExecutionResult`) and interfaces (`ICommandExecutor`, `IStreamCopier`).
|
||||
* **NixWslWrapper.Infrastructure**: Contains execution strategies (`WslCommandExecutor`), builder patterns (`ProcessBuilder`), and threaded stream piping (`ThreadedStreamCopier`).
|
||||
* **NixWslWrapper.Cli**: Presentation layer resolving services via Dependency Injection (`Microsoft.Extensions.DependencyInjection`), handling command forwarding, and presenting styled dashboard visuals with `Spectre.Console`.
|
||||
* **NixWslWrapper.Tests**: The testing suite compiling xUnit tests and mock assertions.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
1. **WSL2** must be active.
|
||||
2. A Linux distribution installed in WSL (e.g. `Debian` or `Ubuntu`).
|
||||
3. **Nix Package Manager** installed inside the WSL distribution.
|
||||
|
||||
### Build and Publish
|
||||
Compile the project to a single self-contained executable for Windows:
|
||||
```powershell
|
||||
dotnet publish src/NixWslWrapper.Cli/NixWslWrapper.Cli.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -o ./dist
|
||||
```
|
||||
This will generate `nix.exe` inside the `./dist` folder. Place this executable in your Windows `%PATH%` (e.g., in a WinGet Links directory or your IDE binary directory).
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure the wrapper using the following Windows environment variables:
|
||||
|
||||
| Environment Variable | Description | Default Value |
|
||||
| :--- | :--- | :--- |
|
||||
| `NIX_WSL_DISTRO` | Target WSL Linux distribution name. | `Debian` |
|
||||
| `NIX_WSL_USER` | The user to execute WSL commands under. | `root` |
|
||||
| `NIX_WSL_PROFILE` | Path to the nix-daemon profile script inside WSL. | `/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh` |
|
||||
|
||||
---
|
||||
|
||||
## Command Flags
|
||||
|
||||
When running the wrapper, standard arguments are forwarded directly to WSL. Two special flags are intercepted by the wrapper for system administration:
|
||||
|
||||
* `--wsl-status`: Renders a premium diagnostic panel checking your WSL execution and verifying the Nix version inside your distro.
|
||||
* `--wsl-help`: Displays wrapper-specific configuration details.
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
Run the xUnit test suite from the root of the project:
|
||||
```powershell
|
||||
dotnet test
|
||||
```
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using Moq;
|
||||
using NixWslWrapper.Cli;
|
||||
using NixWslWrapper.Core.Domain;
|
||||
using NixWslWrapper.Core.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace NixWslWrapper.Tests
|
||||
{
|
||||
public class CliTests
|
||||
{
|
||||
[Fact]
|
||||
public void CliController_ForwardArguments_ShouldReturnExecutorExitCode()
|
||||
{
|
||||
var mockExecutor = new Mock<ICommandExecutor>();
|
||||
var expectedResult = new ExecutionResult(42);
|
||||
mockExecutor
|
||||
.Setup(m => m.Execute(It.IsAny<NixArguments>(), It.IsAny<WslTarget>()))
|
||||
.Returns(expectedResult);
|
||||
|
||||
var controller = new CliController(mockExecutor.Object);
|
||||
string[] cliArgs = new[] { "run", "nixpkgs#hello" };
|
||||
|
||||
int exitCode = controller.Run(cliArgs);
|
||||
|
||||
Assert.Equal(42, exitCode);
|
||||
mockExecutor.Verify(m => m.Execute(
|
||||
It.Is<NixArguments>(a => a.RawArguments.Length == 2 && a.RawArguments[0] == "run"),
|
||||
It.IsAny<WslTarget>()
|
||||
), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CliController_HelpMenuFlag_ShouldReturnZeroImmediately()
|
||||
{
|
||||
var mockExecutor = new Mock<ICommandExecutor>();
|
||||
var controller = new CliController(mockExecutor.Object);
|
||||
string[] cliArgs = new[] { "--wsl-help" };
|
||||
|
||||
int exitCode = controller.Run(cliArgs);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
mockExecutor.Verify(m => m.Execute(It.IsAny<NixArguments>(), It.IsAny<WslTarget>()), Times.Never);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using NixWslWrapper.Core.Domain;
|
||||
using Xunit;
|
||||
|
||||
namespace NixWslWrapper.Tests
|
||||
{
|
||||
public class CoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void WslTarget_ValidParameters_ShouldConstructCorrectly()
|
||||
{
|
||||
var target = new WslTarget("Debian", "root", "/some/path");
|
||||
Assert.Equal("Debian", target.Distribution);
|
||||
Assert.Equal("root", target.User);
|
||||
Assert.Equal("/some/path", target.NixProfilePath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "root", "/path")]
|
||||
[InlineData("Debian", "", "/path")]
|
||||
[InlineData("Debian", "root", "")]
|
||||
[InlineData(null, "root", "/path")]
|
||||
[InlineData("Debian", null, "/path")]
|
||||
[InlineData("Debian", "root", null)]
|
||||
public void WslTarget_InvalidParameters_ShouldThrowArgumentException(string? distro, string? user, string? profile)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new WslTarget(distro!, user!, profile!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WslTarget_Equality_ShouldBeTrueForSameValues()
|
||||
{
|
||||
var target1 = new WslTarget("Debian", "root", "/path");
|
||||
var target2 = new WslTarget("debian", "ROOT", "/path"); // case-insensitive for distro/user
|
||||
Assert.Equal(target1, target2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NixArguments_EmptyArgs_ShouldReturnEmptyEscapedString()
|
||||
{
|
||||
var args = new NixArguments(Array.Empty<string>());
|
||||
Assert.Equal(string.Empty, args.ToEscapedString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NixArguments_WithSpecialCharacters_ShouldEscapeDoubleQuotes()
|
||||
{
|
||||
var args = new NixArguments(new[] { "run", "nixpkgs#hello", "--argstr", "msg", "hello \"world\"" });
|
||||
string escaped = args.ToEscapedString();
|
||||
Assert.Equal("\"run\" \"nixpkgs#hello\" \"--argstr\" \"msg\" \"hello \\\"world\\\"\"", escaped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutionResult_Success_ShouldSetCorrectProperties()
|
||||
{
|
||||
var result = ExecutionResult.Success();
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
Assert.Null(result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutionResult_Failure_ShouldSetCorrectProperties()
|
||||
{
|
||||
var result = ExecutionResult.Failure(5, "Access Denied");
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(5, result.ExitCode);
|
||||
Assert.Equal("Access Denied", result.ErrorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using NixWslWrapper.Infrastructure.IO;
|
||||
using NixWslWrapper.Infrastructure.Executors;
|
||||
using Xunit;
|
||||
|
||||
namespace NixWslWrapper.Tests
|
||||
{
|
||||
public class InfrastructureTests
|
||||
{
|
||||
[Fact]
|
||||
public void ThreadedStreamCopier_ValidStream_ShouldCopyEntireContent()
|
||||
{
|
||||
var content = "Hello, Nix WSL Wrapper! Stream copying should work flawlessly.";
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(content);
|
||||
using var input = new MemoryStream(bytes);
|
||||
using var output = new MemoryStream();
|
||||
|
||||
var copier = new ThreadedStreamCopier(64); // small buffer to force multiple reads
|
||||
copier.Copy(input, output);
|
||||
|
||||
byte[] resultBytes = output.ToArray();
|
||||
string result = Encoding.UTF8.GetString(resultBytes);
|
||||
|
||||
Assert.Equal(content, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessBuilder_ValidConfiguration_ShouldBuildCorrectProcessStartInfo()
|
||||
{
|
||||
var builder = new ProcessBuilder()
|
||||
.SetFileName("my-app.exe")
|
||||
.SetArguments("--arg1 val1")
|
||||
.RedirectInput(true)
|
||||
.RedirectOutput(true)
|
||||
.RedirectError(true)
|
||||
.UseShellExecute(false)
|
||||
.CreateNoWindow(true);
|
||||
|
||||
var startInfo = builder.Build();
|
||||
|
||||
Assert.Equal("my-app.exe", startInfo.FileName);
|
||||
Assert.Equal("--arg1 val1", startInfo.Arguments);
|
||||
Assert.True(startInfo.RedirectStandardInput);
|
||||
Assert.True(startInfo.RedirectStandardOutput);
|
||||
Assert.True(startInfo.RedirectStandardError);
|
||||
Assert.False(startInfo.UseShellExecute);
|
||||
Assert.True(startInfo.CreateNoWindow);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.4.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\NixWslWrapper.Core\NixWslWrapper.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\NixWslWrapper.Infrastructure\NixWslWrapper.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\src\NixWslWrapper.Cli\NixWslWrapper.Cli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user