From a0caf9f0140437bde8983bc86f0077d49e274f7f Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Fri, 30 Oct 2026 07:27:38 +0200 Subject: [PATCH 1/4] fix: stop the CLI hanging on dotnet and Tailwind output The CLI waited for dotnet add package, npm and Tailwind to exit before reading their output. Once a process wrote more than a pipe buffer, both sides waited on each other, so shellui add all hung on .NET 8 and 9 apps while restoring Blazor-ApexCharts. ProcessRunner reads stdout and stderr while the process runs, and every call site uses it. --- ShellUI.Tests/ProcessRunnerTests.cs | 23 +++++ .../Services/ComponentInstaller.cs | 16 +--- src/ShellUI.CLI/Services/InitService.cs | 91 +++---------------- src/ShellUI.CLI/Services/ProcessRunner.cs | 25 +++++ 4 files changed, 67 insertions(+), 88 deletions(-) create mode 100644 ShellUI.Tests/ProcessRunnerTests.cs create mode 100644 src/ShellUI.CLI/Services/ProcessRunner.cs 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/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/InitService.cs b/src/ShellUI.CLI/Services/InitService.cs index db5ee27..de63b6e 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,55 +290,23 @@ 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}"); } } 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); + } +} From 496c68443aa40b44acbfb64dc678a7ee373755f5 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Fri, 30 Oct 2026 07:27:38 +0200 Subject: [PATCH 2/4] fix: run the CLI on any .NET 8 or newer runtime The tool ships net8.0 and net10.0 builds without a roll-forward policy, so a machine with only .NET 9 (or a future .NET 11) couldn't start it. RollForward=Major lets the net8.0 build run on newer runtimes. --- src/ShellUI.CLI/ShellUI.CLI.csproj | 2 ++ 1 file changed, 2 insertions(+) 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 From 74301277989a2f3a5e7cd79cba25bdaa44b4d7eb Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Fri, 30 Oct 2026 07:27:38 +0200 Subject: [PATCH 3/4] fix: count every target in the list summary With --installed the summary said "0 available", and with --available "0 installed", because the counts skipped filtered rows. They are now counted before filtering. --- src/ShellUI.CLI/Services/ComponentManager.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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); From 97dbcf261a03b4687191f5430db60a997d0cd8e5 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Fri, 30 Oct 2026 07:27:38 +0200 Subject: [PATCH 4/4] fix: keep doctor from calling a fresh app.css stale Tailwind 4 doesn't rewrite app.css when its output is unchanged, so after editing a component without new classes app.css kept an old timestamp and doctor said to run dotnet build even right after a build. The build targets now touch app.css after Tailwind runs. update replaces the targets file written by 0.4.0-alpha.2 and alpha.3 when it hasn't been edited. --- .../Targets/ShellUI-0.4.0-alpha.3.targets.txt | 48 +++++++++++++++++++ ShellUI.Tests/TailwindDownloaderTests.cs | 1 + src/ShellUI.CLI/Services/InitService.cs | 7 ++- 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 ShellUI.Tests/Fixtures/Targets/ShellUI-0.4.0-alpha.3.targets.txt 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/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/InitService.cs b/src/ShellUI.CLI/Services/InitService.cs index de63b6e..1fd884b 100644 --- a/src/ShellUI.CLI/Services/InitService.cs +++ b/src/ShellUI.CLI/Services/InitService.cs @@ -310,11 +310,12 @@ private static async Task RunTailwindBuildAsync(string tailwindPath, string inpu } } - // 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. @@ -361,6 +362,8 @@ internal static string GetTargetsFileContent(string method) + + @@ -412,6 +415,8 @@ internal static string GetTargetsFileContent(string method) + +