Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions ShellUI.Tests/Fixtures/Targets/ShellUI-0.4.0-alpha.3.targets.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<ShellUIBinPath Condition="'$(ShellUIBinPath)' == ''">$(MSBuildProjectDirectory)\.shellui\bin</ShellUIBinPath>
<TailwindInputCss Condition="'$(TailwindInputCss)' == ''">$(MSBuildProjectDirectory)\wwwroot\input.css</TailwindInputCss>
<TailwindOutputCss Condition="'$(TailwindOutputCss)' == ''">$(MSBuildProjectDirectory)\wwwroot\app.css</TailwindOutputCss>
<TailwindMinify Condition="'$(Configuration)' == 'Release'">--minify</TailwindMinify>
<TailwindMinify Condition="'$(Configuration)' != 'Release'"></TailwindMinify>
<TailwindTag>v4.3.2</TailwindTag>
<TailwindVersion>4.3.2</TailwindVersion>
<TailwindArch>$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant())</TailwindArch>
<TailwindPlatform Condition="$([MSBuild]::IsOSPlatform('Windows'))">windows-$(TailwindArch).exe</TailwindPlatform>
<TailwindPlatform Condition="$([MSBuild]::IsOSPlatform('Linux'))">linux-$(TailwindArch)</TailwindPlatform>
<TailwindPlatform Condition="$([MSBuild]::IsOSPlatform('OSX'))">macos-$(TailwindArch)</TailwindPlatform>
<!-- One copy per machine, shared with the ShellUI CLI, so projects hold no binary; SHELLUI_CACHE_DIR moves it. -->
<ShellUICacheDir Condition="'$(ShellUICacheDir)' == ''">$(SHELLUI_CACHE_DIR)</ShellUICacheDir>
<ShellUICacheDir Condition="'$(ShellUICacheDir)' == ''">$([System.IO.Path]::Combine($([System.Environment]::GetFolderPath(SpecialFolder.UserProfile)), '.shellui', 'bin'))</ShellUICacheDir>
<TailwindCached>$([System.IO.Path]::Combine('$(ShellUICacheDir)', 'tailwindcss-$(TailwindVersion)-$(TailwindPlatform)'))</TailwindCached>
<!-- A copy in the project's .shellui/bin wins over the shared one. -->
<TailwindExecutable Condition="'$(TailwindExecutable)' == '' AND '$(OS)' == 'Windows_NT' AND Exists('$(ShellUIBinPath)\tailwindcss.exe')">$(ShellUIBinPath)\tailwindcss.exe</TailwindExecutable>
<TailwindExecutable Condition="'$(TailwindExecutable)' == '' AND '$(OS)' != 'Windows_NT' AND Exists('$(ShellUIBinPath)/tailwindcss')">$(ShellUIBinPath)/tailwindcss</TailwindExecutable>
<TailwindExecutable Condition="'$(TailwindExecutable)' == ''">$(TailwindCached)</TailwindExecutable>
</PropertyGroup>

<!-- Downloads to .partial and renames only on success, so an interrupted download never looks like a working CLI. -->
<Target Name="DownloadTailwindCLI" BeforeTargets="BeforeBuild" Condition="!Exists('$(TailwindExecutable)') AND Exists('$(TailwindInputCss)') AND '$(TailwindPlatform)' != ''">
<Message Importance="high" Text="Downloading the Tailwind CLI $(TailwindTag) to $(ShellUICacheDir)..." />
<DownloadFile SourceUrl="https://github.com/tailwindlabs/tailwindcss/releases/download/$(TailwindTag)/tailwindcss-$(TailwindPlatform)" DestinationFolder="$(ShellUICacheDir)" DestinationFileName="tailwindcss-$(TailwindVersion)-$(TailwindPlatform).partial" Retries="3" RetryDelayMilliseconds="5000" Timeout="1800000" ContinueOnError="true" />
<Move SourceFiles="$(TailwindCached).partial" DestinationFiles="$(TailwindCached)" Condition="'$(MSBuildLastTaskResult)' == 'true' AND Exists('$(TailwindCached).partial')" />
<Delete Files="$(TailwindCached).partial" ContinueOnError="true" />
<Exec Command="chmod +x &quot;$(TailwindCached)&quot;" Condition="'$(OS)' != 'Windows_NT' AND Exists('$(TailwindCached)')" />
</Target>

<Target Name="WarnMissingTailwindCLI" BeforeTargets="BeforeBuild" Condition="!Exists('$(TailwindExecutable)') AND Exists('$(TailwindInputCss)')">
<Warning Text="ShellUI: the Tailwind CLI could not be downloaded, so wwwroot/app.css was not rebuilt. Save https://github.com/tailwindlabs/tailwindcss/releases/download/$(TailwindTag)/tailwindcss-$(TailwindPlatform) as $(TailwindCached), or build again to retry." />
</Target>

<Target Name="BuildTailwindCSS" BeforeTargets="BeforeBuild" Condition="Exists('$(TailwindExecutable)') AND Exists('$(TailwindInputCss)')">
<Message Importance="high" Text="Building Tailwind CSS..." />
<Exec Command="&quot;$(TailwindExecutable)&quot; -i &quot;$(TailwindInputCss)&quot; -o &quot;$(TailwindOutputCss)&quot; $(TailwindMinify)" />
<Message Importance="high" Text="Tailwind CSS built successfully!" />
</Target>

<Target Name="CleanTailwindCSS" AfterTargets="Clean" Condition="Exists('$(TailwindOutputCss)')">
<Message Importance="high" Text="Cleaning Tailwind CSS output..." />
<Delete Files="$(TailwindOutputCss)" />
</Target>
</Project>
23 changes: 23 additions & 0 deletions ShellUI.Tests/ProcessRunnerTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
1 change: 1 addition & 0 deletions ShellUI.Tests/TailwindDownloaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
16 changes: 5 additions & 11 deletions src/ShellUI.CLI/Services/ComponentInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -438,32 +438,26 @@ private static async Task<List<NuGetDependency>> 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);
}
Expand Down
8 changes: 4 additions & 4 deletions src/ShellUI.CLI/Services/ComponentManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
Expand Down
98 changes: 20 additions & 78 deletions src/ShellUI.CLI/Services/InitService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -265,23 +265,8 @@ private static async Task<bool> 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
{
Expand All @@ -291,28 +276,12 @@ private static async Task<bool> 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}");
}
}

Expand All @@ -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.
Expand Down Expand Up @@ -424,6 +362,8 @@ internal static string GetTargetsFileContent(string method)
<Target Name=""BuildTailwindCSS"" BeforeTargets=""BeforeBuild"" Condition=""Exists('$(TailwindInputCss)')"">
<Message Importance=""high"" Text=""Building Tailwind CSS with npm..."" />
<Exec Command=""$(NpmExecutable) @tailwindcss/cli -i &quot;$(TailwindInputCss)&quot; -o &quot;$(TailwindOutputCss)&quot; $(TailwindMinify)"" />
<!-- Tailwind skips writing unchanged output; touching it shows the CSS is current. -->
<Touch Files=""$(TailwindOutputCss)"" ContinueOnError=""true"" />
<Message Importance=""high"" Text=""Tailwind CSS built successfully!"" />
</Target>

Expand Down Expand Up @@ -475,6 +415,8 @@ internal static string GetTargetsFileContent(string method)
<Target Name=""BuildTailwindCSS"" BeforeTargets=""BeforeBuild"" Condition=""Exists('$(TailwindExecutable)') AND Exists('$(TailwindInputCss)')"">
<Message Importance=""high"" Text=""Building Tailwind CSS..."" />
<Exec Command=""&quot;$(TailwindExecutable)&quot; -i &quot;$(TailwindInputCss)&quot; -o &quot;$(TailwindOutputCss)&quot; $(TailwindMinify)"" />
<!-- Tailwind skips writing unchanged output; touching it shows the CSS is current. -->
<Touch Files=""$(TailwindOutputCss)"" ContinueOnError=""true"" />
<Message Importance=""high"" Text=""Tailwind CSS built successfully!"" />
</Target>

Expand Down
25 changes: 25 additions & 0 deletions src/ShellUI.CLI/Services/ProcessRunner.cs
Original file line number Diff line number Diff line change
@@ -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<ProcessResult?> 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);
}
}
2 changes: 2 additions & 0 deletions src/ShellUI.CLI/ShellUI.CLI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
<Nullable>enable</Nullable>

<PackAsTool>true</PackAsTool>
<!-- Runs on .NET 9 and newer runtimes too, not only the 8 and 10 it is built for. -->
<RollForward>Major</RollForward>
<ToolCommandName>shellui</ToolCommandName>
<PackageId>ShellUI.CLI</PackageId>
<PackageReadmeFile>README.md</PackageReadmeFile>
Expand Down
Loading