diff --git a/ShellUI.Tests/Fixtures/Targets/ShellUI-0.4.0-alpha.3.targets.txt b/ShellUI.Tests/Fixtures/Targets/ShellUI-0.4.0-alpha.3.targets.txt
new file mode 100644
index 0000000..6fa6d09
--- /dev/null
+++ b/ShellUI.Tests/Fixtures/Targets/ShellUI-0.4.0-alpha.3.targets.txt
@@ -0,0 +1,48 @@
+
+
+
+ $(MSBuildProjectDirectory)\.shellui\bin
+ $(MSBuildProjectDirectory)\wwwroot\input.css
+ $(MSBuildProjectDirectory)\wwwroot\app.css
+ --minify
+
+ v4.3.2
+ 4.3.2
+ $([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant())
+ windows-$(TailwindArch).exe
+ linux-$(TailwindArch)
+ macos-$(TailwindArch)
+
+ $(SHELLUI_CACHE_DIR)
+ $([System.IO.Path]::Combine($([System.Environment]::GetFolderPath(SpecialFolder.UserProfile)), '.shellui', 'bin'))
+ $([System.IO.Path]::Combine('$(ShellUICacheDir)', 'tailwindcss-$(TailwindVersion)-$(TailwindPlatform)'))
+
+ $(ShellUIBinPath)\tailwindcss.exe
+ $(ShellUIBinPath)/tailwindcss
+ $(TailwindCached)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ShellUI.Tests/ProcessRunnerTests.cs b/ShellUI.Tests/ProcessRunnerTests.cs
new file mode 100644
index 0000000..ec4113b
--- /dev/null
+++ b/ShellUI.Tests/ProcessRunnerTests.cs
@@ -0,0 +1,23 @@
+using System.Diagnostics;
+using ShellUI.CLI.Services;
+
+namespace ShellUI.Tests;
+
+public class ProcessRunnerTests
+{
+ [Fact]
+ public async Task RunAsync_ReturnsLargeOutputWithoutDeadlocking()
+ {
+ // Far more than a pipe buffer, on both streams; waiting for exit before reading used to hang here.
+ var startInfo = OperatingSystem.IsWindows()
+ ? new ProcessStartInfo("cmd", "/c for /l %i in (1,1,6000) do @(echo out %i & echo err %i 1>&2)")
+ : new ProcessStartInfo("sh", "-c \"for i in $(seq 1 6000); do echo out $i; echo err $i >&2; done\"");
+
+ var result = await ProcessRunner.RunAsync(startInfo).WaitAsync(TimeSpan.FromSeconds(60));
+
+ Assert.NotNull(result);
+ Assert.Equal(0, result.ExitCode);
+ Assert.Contains("out 6000", result.Output);
+ Assert.Contains("err 6000", result.Error);
+ }
+}
diff --git a/ShellUI.Tests/TailwindDownloaderTests.cs b/ShellUI.Tests/TailwindDownloaderTests.cs
index a8f5578..05e72bd 100644
--- a/ShellUI.Tests/TailwindDownloaderTests.cs
+++ b/ShellUI.Tests/TailwindDownloaderTests.cs
@@ -134,6 +134,7 @@ public void BuildTargets_UseTheSameCacheName()
[Theory]
[InlineData("ShellUI-0.3.4.targets.txt")]
[InlineData("ShellUI-0.4.0-alpha.1.targets.txt")]
+ [InlineData("ShellUI-0.4.0-alpha.3.targets.txt")]
public void Update_ReplacesATargetsFileAnEarlierCliWrote(string fixture)
{
var path = WriteTargets(Fixture(fixture));
diff --git a/src/ShellUI.CLI/Services/ComponentInstaller.cs b/src/ShellUI.CLI/Services/ComponentInstaller.cs
index ea4dd9e..7217bfc 100644
--- a/src/ShellUI.CLI/Services/ComponentInstaller.cs
+++ b/src/ShellUI.CLI/Services/ComponentInstaller.cs
@@ -438,32 +438,26 @@ private static async Task> InstallNuGetDependenciesAsync(P
var startInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "dotnet",
- ArgumentList = { "add", projectInfo.ProjectPath, "package", dep.PackageId, "--version", dep.Version },
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- UseShellExecute = false,
- CreateNoWindow = true
+ ArgumentList = { "add", projectInfo.ProjectPath, "package", dep.PackageId, "--version", dep.Version }
};
try
{
- using var process = System.Diagnostics.Process.Start(startInfo);
- if (process == null)
+ var result = await ProcessRunner.RunAsync(startInfo);
+ if (result == null)
{
AnsiConsole.MarkupLine($"[yellow]Warning:[/] could not start `dotnet add package` for {dep.PackageId}");
failed.Add(dep);
continue;
}
- await process.WaitForExitAsync();
-
- if (process.ExitCode == 0)
+ if (result.ExitCode == 0)
{
AnsiConsole.MarkupLine($"[green]Added:[/] {dep.PackageId} {dep.Version}");
}
else
{
- var err = (await process.StandardError.ReadToEndAsync()).Trim();
+ var err = result.Error.Trim();
AnsiConsole.MarkupLine($"[yellow]Warning:[/] failed to add {dep.PackageId}: {err.Replace("[", "[[").Replace("]", "]]")}");
failed.Add(dep);
}
diff --git a/src/ShellUI.CLI/Services/ComponentManager.cs b/src/ShellUI.CLI/Services/ComponentManager.cs
index 7635e68..48db07b 100644
--- a/src/ShellUI.CLI/Services/ComponentManager.cs
+++ b/src/ShellUI.CLI/Services/ComponentManager.cs
@@ -55,7 +55,10 @@ public static void ListComponents(bool showOnlyInstalled, bool showOnlyAvailable
if (!component.IsAvailable) continue;
bool isInstalled = installed.Contains(component.Name);
-
+ // Counted before filtering so the summary is the same with --installed or --available.
+ if (isInstalled) installedCount++;
+ else availableCount++;
+
if (showOnlyInstalled && !isInstalled) continue;
if (showOnlyAvailable && isInstalled) continue;
@@ -72,9 +75,6 @@ public static void ListComponents(bool showOnlyInstalled, bool showOnlyAvailable
cells.Add($"[blue]{component.Category}[/]");
if (showDescription) cells.Add($"[dim]{Markup.Escape(component.Description)}[/]");
table.AddRow(cells.ToArray());
-
- if (isInstalled) installedCount++;
- else availableCount++;
}
AnsiConsole.Write(table);
diff --git a/src/ShellUI.CLI/Services/InitService.cs b/src/ShellUI.CLI/Services/InitService.cs
index db5ee27..1fd884b 100644
--- a/src/ShellUI.CLI/Services/InitService.cs
+++ b/src/ShellUI.CLI/Services/InitService.cs
@@ -265,23 +265,8 @@ private static async Task IsNpmAvailableAsync()
try
{
// cmd /c is the reliable way to run npm on Windows
- var startInfo = new System.Diagnostics.ProcessStartInfo
- {
- FileName = "cmd",
- Arguments = "/c npm --version",
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- UseShellExecute = false,
- CreateNoWindow = true
- };
-
- using var process = System.Diagnostics.Process.Start(startInfo);
- if (process == null) return false;
-
- await process.WaitForExitAsync();
- var output = await process.StandardOutput.ReadToEndAsync();
-
- return process.ExitCode == 0 && !string.IsNullOrEmpty(output.Trim());
+ var result = await ProcessRunner.RunAsync(new System.Diagnostics.ProcessStartInfo("cmd", "/c npm --version"));
+ return result is { ExitCode: 0 } && !string.IsNullOrEmpty(result.Output.Trim());
}
catch
{
@@ -291,28 +276,12 @@ private static async Task IsNpmAvailableAsync()
private static async Task RunNpmCommandAsync(params string[] args)
{
- var startInfo = new System.Diagnostics.ProcessStartInfo
- {
- FileName = "cmd",
- Arguments = $"/c npm {string.Join(" ", args)}",
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- UseShellExecute = false,
- CreateNoWindow = true
- };
+ var result = await ProcessRunner.RunAsync(new System.Diagnostics.ProcessStartInfo("cmd", $"/c npm {string.Join(" ", args)}"))
+ ?? throw new Exception("Failed to start npm process");
- using var process = System.Diagnostics.Process.Start(startInfo);
- if (process == null)
+ if (result.ExitCode != 0)
{
- throw new Exception("Failed to start npm process");
- }
-
- await process.WaitForExitAsync();
-
- if (process.ExitCode != 0)
- {
- var error = await process.StandardError.ReadToEndAsync();
- throw new Exception($"npm command failed: {error}");
+ throw new Exception($"npm command failed: {result.Error}");
}
}
@@ -321,63 +290,32 @@ private static async Task RunNpmTailwindBuildAsync()
var inputCssPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "input.css");
var outputCssPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "app.css");
- var startInfo = new System.Diagnostics.ProcessStartInfo
- {
- FileName = "cmd",
- Arguments = $"/c npx @tailwindcss/cli -i \"{inputCssPath}\" -o \"{outputCssPath}\" --minify",
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- UseShellExecute = false,
- CreateNoWindow = true
- };
-
- using var process = System.Diagnostics.Process.Start(startInfo);
- if (process == null)
- {
- throw new Exception("Failed to start npx process");
- }
-
- await process.WaitForExitAsync();
+ var result = await ProcessRunner.RunAsync(new System.Diagnostics.ProcessStartInfo("cmd", $"/c npx @tailwindcss/cli -i \"{inputCssPath}\" -o \"{outputCssPath}\" --minify"))
+ ?? throw new Exception("Failed to start npx process");
- if (process.ExitCode != 0)
+ if (result.ExitCode != 0)
{
- var error = await process.StandardError.ReadToEndAsync();
- throw new Exception($"npx @tailwindcss/cli failed: {error}");
+ throw new Exception($"npx @tailwindcss/cli failed: {result.Error}");
}
}
private static async Task RunTailwindBuildAsync(string tailwindPath, string inputPath, string outputPath)
{
- var startInfo = new System.Diagnostics.ProcessStartInfo
- {
- FileName = tailwindPath,
- Arguments = $"-i \"{inputPath}\" -o \"{outputPath}\" --minify",
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- UseShellExecute = false,
- CreateNoWindow = true
- };
-
- using var process = System.Diagnostics.Process.Start(startInfo);
- if (process == null)
- {
- throw new Exception("Failed to start Tailwind CSS process");
- }
-
- await process.WaitForExitAsync();
+ var result = await ProcessRunner.RunAsync(new System.Diagnostics.ProcessStartInfo(tailwindPath, $"-i \"{inputPath}\" -o \"{outputPath}\" --minify"))
+ ?? throw new Exception("Failed to start Tailwind CSS process");
- if (process.ExitCode != 0)
+ if (result.ExitCode != 0)
{
- var error = await process.StandardError.ReadToEndAsync();
- throw new Exception($"Tailwind CSS build failed: {error}");
+ throw new Exception($"Tailwind CSS build failed: {result.Error}");
}
}
- // Build/ShellUI.targets exactly as earlier CLIs wrote it for standalone Tailwind (LF-normalized SHA-256): 0.0.1–0.3.4, then 0.4.0-alpha.1.
+ // Build/ShellUI.targets exactly as earlier CLIs wrote it for standalone Tailwind (LF-normalized SHA-256): 0.0.1–0.3.4, 0.4.0-alpha.1, then 0.4.0-alpha.2–alpha.3.
private static readonly string[] PreviousStandaloneTargets =
{
"eace414bc927d6274976e888cd1de742efd82ee3142f1cabaea3b1f3e188455e",
"f42811cbd3bdad588c3389de9a0f706b7ac59331848a027b99c30a5765aea5f9",
+ "0f2b51772790f4ffb3c461c70c8517222c4cc726ee9b099b748c5809443bd3e0",
};
// Brings a standalone project set up by an earlier CLI onto the shared Tailwind CLI; returns what changed.
@@ -424,6 +362,8 @@ internal static string GetTargetsFileContent(string method)
+
+
@@ -475,6 +415,8 @@ internal static string GetTargetsFileContent(string method)
+
+
diff --git a/src/ShellUI.CLI/Services/ProcessRunner.cs b/src/ShellUI.CLI/Services/ProcessRunner.cs
new file mode 100644
index 0000000..30c216f
--- /dev/null
+++ b/src/ShellUI.CLI/Services/ProcessRunner.cs
@@ -0,0 +1,25 @@
+using System.Diagnostics;
+
+namespace ShellUI.CLI.Services;
+
+public record ProcessResult(int ExitCode, string Output, string Error);
+
+public static class ProcessRunner
+{
+ // Reads both streams while the process runs; waiting for exit first deadlocks once output fills the pipe buffer.
+ public static async Task RunAsync(ProcessStartInfo startInfo)
+ {
+ startInfo.RedirectStandardOutput = true;
+ startInfo.RedirectStandardError = true;
+ startInfo.UseShellExecute = false;
+ startInfo.CreateNoWindow = true;
+
+ using var process = Process.Start(startInfo);
+ if (process == null) return null;
+
+ var output = process.StandardOutput.ReadToEndAsync();
+ var error = process.StandardError.ReadToEndAsync();
+ await process.WaitForExitAsync();
+ return new ProcessResult(process.ExitCode, await output, await error);
+ }
+}
diff --git a/src/ShellUI.CLI/ShellUI.CLI.csproj b/src/ShellUI.CLI/ShellUI.CLI.csproj
index 19fcd70..f6ac48f 100644
--- a/src/ShellUI.CLI/ShellUI.CLI.csproj
+++ b/src/ShellUI.CLI/ShellUI.CLI.csproj
@@ -17,6 +17,8 @@
enable
true
+
+ Major
shellui
ShellUI.CLI
README.md