From f4840199974a7f8cb86bfa9f6f6aecd57d802ce7 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Tue, 13 Oct 2026 14:17:00 +0200 Subject: [PATCH] fix: use one Tailwind CLI per machine and survive slow downloads init downloaded the 112 MB Tailwind CLI into every project, held it in memory and gave up after five minutes, so on a slow connection init failed and left the project uninitialised. The build's own download used MSBuild's default timeout and wrote straight into the project, and nothing kept the binary out of git. - The CLI lives once per machine in ~/.shellui/bin (or SHELLUI_CACHE_DIR) and init and the build run it from there; a copy in .shellui/bin still wins. Projects hold no binary. - Downloads stream to a .partial file, resume with Range, retry, and stop only after 60 s without data; the file appears once its size matches. - If the download fails, init finishes the rest and the build retries. - .shellui/.gitignore of "*" keeps the folder out of git. - update replaces a targets file any released CLI wrote, unless edited, and removes a project copy identical to the shared one. --- .../Targets/ShellUI-0.3.4.targets.txt | 23 ++ .../Targets/ShellUI-0.4.0-alpha.1.targets.txt | 38 +++ ShellUI.Tests/TailwindDownloaderTests.cs | 283 ++++++++++++++++++ docs/CLI_SYNTAX.md | 9 +- docs/FAQ.md | 4 +- docs/QUICKSTART.md | 4 +- docs/tailwind-setup.md | 8 +- src/ShellUI.CLI/Services/ComponentManager.cs | 3 + src/ShellUI.CLI/Services/InitService.cs | 77 ++++- .../Services/TailwindDownloader.cs | 181 +++++++++-- 10 files changed, 574 insertions(+), 56 deletions(-) create mode 100644 ShellUI.Tests/Fixtures/Targets/ShellUI-0.3.4.targets.txt create mode 100644 ShellUI.Tests/Fixtures/Targets/ShellUI-0.4.0-alpha.1.targets.txt create mode 100644 ShellUI.Tests/TailwindDownloaderTests.cs diff --git a/ShellUI.Tests/Fixtures/Targets/ShellUI-0.3.4.targets.txt b/ShellUI.Tests/Fixtures/Targets/ShellUI-0.3.4.targets.txt new file mode 100644 index 0000000..de3163d --- /dev/null +++ b/ShellUI.Tests/Fixtures/Targets/ShellUI-0.3.4.targets.txt @@ -0,0 +1,23 @@ + + + + $(MSBuildProjectDirectory)\.shellui\bin + $(ShellUIBinPath)\tailwindcss.exe + $(ShellUIBinPath)/tailwindcss + $(MSBuildProjectDirectory)\wwwroot\input.css + $(MSBuildProjectDirectory)\wwwroot\app.css + --minify + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ShellUI.Tests/Fixtures/Targets/ShellUI-0.4.0-alpha.1.targets.txt b/ShellUI.Tests/Fixtures/Targets/ShellUI-0.4.0-alpha.1.targets.txt new file mode 100644 index 0000000..3595079 --- /dev/null +++ b/ShellUI.Tests/Fixtures/Targets/ShellUI-0.4.0-alpha.1.targets.txt @@ -0,0 +1,38 @@ + + + + $(MSBuildProjectDirectory)\.shellui\bin + $(ShellUIBinPath)\tailwindcss.exe + $(ShellUIBinPath)/tailwindcss + $(MSBuildProjectDirectory)\wwwroot\input.css + $(MSBuildProjectDirectory)\wwwroot\app.css + --minify + + v4.3.2 + $([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()) + windows-$(TailwindArch).exe + linux-$(TailwindArch) + macos-$(TailwindArch) + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ShellUI.Tests/TailwindDownloaderTests.cs b/ShellUI.Tests/TailwindDownloaderTests.cs new file mode 100644 index 0000000..a8f5578 --- /dev/null +++ b/ShellUI.Tests/TailwindDownloaderTests.cs @@ -0,0 +1,283 @@ +using System.Net; +using System.Net.Http.Headers; +using ShellUI.CLI.Services; +using Xunit; + +namespace ShellUI.Tests; + +public class TailwindDownloaderTests : IDisposable +{ + private const string Url = "https://example.test/tailwindcss"; + private readonly string _dir = Path.Combine(Path.GetTempPath(), $"ShellUITailwind_{Guid.NewGuid():N}"); + private readonly byte[] _binary = Enumerable.Range(0, 300_000).Select(i => (byte)(i * 7)).ToArray(); + + private readonly string? _previousCache = Environment.GetEnvironmentVariable("SHELLUI_CACHE_DIR"); + + private string Destination => Path.Combine(_dir, "tailwindcss-test"); + + // Tests in a class run one at a time, so pointing the shared cache at a temp folder here can't affect the others. + public TailwindDownloaderTests() => Environment.SetEnvironmentVariable("SHELLUI_CACHE_DIR", Path.Combine(_dir, "cache")); + + public void Dispose() + { + Environment.SetEnvironmentVariable("SHELLUI_CACHE_DIR", _previousCache); + if (Directory.Exists(_dir)) Directory.Delete(_dir, true); + } + + [Fact] + public async Task Downloads_TheWholeFile() + { + var server = new FakeServer((_, _) => Full(_binary)); + + await TailwindDownloader.DownloadAsync(Url, Destination, handler: server, retryDelay: TimeSpan.Zero); + + Assert.Equal(_binary, File.ReadAllBytes(Destination)); + Assert.False(File.Exists(Destination + ".partial")); + } + + [Fact] + public async Task ResumesWithRange_AfterTheConnectionDrops() + { + var server = new FakeServer((request, attempt) => attempt == 1 + ? Full(_binary, new FailingStream(_binary[..100_000])) + : Partial(_binary, request.Headers.Range!.Ranges.Single().From!.Value)); + + await TailwindDownloader.DownloadAsync(Url, Destination, handler: server, retryDelay: TimeSpan.Zero); + + Assert.Equal(_binary, File.ReadAllBytes(Destination)); + Assert.Equal(100_000, server.Requests[1].Headers.Range!.Ranges.Single().From); + } + + [Fact] + public async Task RetriesAShortDownload_InsteadOfKeepingIt() + { + var server = new FakeServer((request, attempt) => attempt == 1 + ? Full(_binary, new MemoryStream(_binary[..50_000])) + : Partial(_binary, request.Headers.Range!.Ranges.Single().From!.Value)); + + await TailwindDownloader.DownloadAsync(Url, Destination, handler: server, retryDelay: TimeSpan.Zero); + + Assert.Equal(_binary, File.ReadAllBytes(Destination)); + Assert.Equal(2, server.Requests.Count); + } + + [Fact] + public async Task GivesUpOnAStall_AndRetries() + { + var server = new FakeServer((_, attempt) => attempt == 1 + ? Full(_binary, new StallingStream(_binary[..1000])) + : Full(_binary)); + + await TailwindDownloader.DownloadAsync(Url, Destination, handler: server, + stallTimeout: TimeSpan.FromMilliseconds(300), retryDelay: TimeSpan.Zero); + + Assert.Equal(_binary, File.ReadAllBytes(Destination)); + } + + [Fact] + public async Task StartsOver_WhenTheServerIgnoresRange() + { + var server = new FakeServer((_, attempt) => attempt == 1 + ? Full(_binary, new FailingStream(_binary[..100_000])) + : Full(_binary)); + + await TailwindDownloader.DownloadAsync(Url, Destination, handler: server, retryDelay: TimeSpan.Zero); + + Assert.Equal(_binary, File.ReadAllBytes(Destination)); + } + + [Fact] + public async Task NotFound_FailsAtOnce_AndLeavesNoFile() + { + var server = new FakeServer((_, _) => new HttpResponseMessage(HttpStatusCode.NotFound)); + + await Assert.ThrowsAsync(() => + TailwindDownloader.DownloadAsync(Url, Destination, handler: server, retryDelay: TimeSpan.Zero)); + + Assert.Single(server.Requests); + Assert.False(File.Exists(Destination)); + } + + [Fact] + public async Task KeepsFailing_ThrowsAfterTheLastAttempt_AndLeavesNoFile() + { + var server = new FakeServer((_, _) => Full(_binary, new FailingStream(_binary[..10]))); + + await Assert.ThrowsAsync(() => + TailwindDownloader.DownloadAsync(Url, Destination, handler: server, retryDelay: TimeSpan.Zero)); + + Assert.Equal(4, server.Requests.Count); + Assert.False(File.Exists(Destination)); + } + + [Fact] + public void CachePath_MatchesThePrecompiledCssScript() + { + var name = Path.GetFileName(TailwindDownloader.CachedPath()); + + Assert.StartsWith($"tailwindcss-{ShellUI.Core.TailwindConstants.Version}-", name); + } + + [Fact] + public void BuildTargets_UseTheSameCacheName() + { + var targets = InitService.GetTargetsFileContent("standalone"); + + Assert.Contains($"{ShellUI.Core.TailwindConstants.Version}", targets); + Assert.Contains("tailwindcss-$(TailwindVersion)-$(TailwindPlatform)", targets); + Assert.Contains("$(TailwindCached)", targets); + Assert.DoesNotContain(" n.Contains("Updated:")); + Assert.Equal(InitService.GetTargetsFileContent("standalone"), File.ReadAllText(path)); + Assert.Equal("*\n", File.ReadAllText(Path.Combine(_dir, ".shellui", ".gitignore"))); + } + + [Fact] + public void Update_LeavesAnEditedTargetsFile_AndSaysSo() + { + var edited = Fixture("ShellUI-0.4.0-alpha.1.targets.txt").Replace("--minify", "--minify --optimize"); + var path = WriteTargets(edited); + + var notes = InitService.RefreshTailwindSetup(_dir); + + Assert.Contains(notes, n => n.Contains("local changes")); + Assert.Equal(edited, File.ReadAllText(path)); + } + + [Fact] + public void Update_LeavesCurrentAndNpmTargetsAlone() + { + WriteTargets(InitService.GetTargetsFileContent("standalone")); + Assert.Empty(InitService.RefreshTailwindSetup(_dir)); + + var npm = WriteTargets(InitService.GetTargetsFileContent("npm")); + Assert.Empty(InitService.RefreshTailwindSetup(_dir)); + Assert.Equal(InitService.GetTargetsFileContent("npm"), File.ReadAllText(npm)); + } + + [Fact] + public void Update_DropsTheProjectCopy_WhenItIsTheSharedOne() + { + WriteTargets(Fixture("ShellUI-0.4.0-alpha.1.targets.txt")); + var cached = TailwindDownloader.CachedPath(); + Directory.CreateDirectory(Path.GetDirectoryName(cached)!); + File.WriteAllBytes(cached, _binary); + var projectCopy = TailwindDownloader.GetTailwindPath(_dir); + Directory.CreateDirectory(Path.GetDirectoryName(projectCopy)!); + File.WriteAllBytes(projectCopy, _binary); + + var notes = InitService.RefreshTailwindSetup(_dir); + + Assert.Contains(notes, n => n.Contains("Removed:")); + Assert.False(File.Exists(projectCopy)); + Assert.True(File.Exists(cached)); + } + + [Fact] + public void Update_KeepsAProjectCopyThatDiffersFromTheSharedOne() + { + WriteTargets(InitService.GetTargetsFileContent("standalone")); + var cached = TailwindDownloader.CachedPath(); + Directory.CreateDirectory(Path.GetDirectoryName(cached)!); + File.WriteAllBytes(cached, _binary); + var projectCopy = TailwindDownloader.GetTailwindPath(_dir); + Directory.CreateDirectory(Path.GetDirectoryName(projectCopy)!); + File.WriteAllBytes(projectCopy, _binary[..1000]); + + InitService.RefreshTailwindSetup(_dir); + + Assert.True(File.Exists(projectCopy)); + } + + [Fact] + public void ShellUiFolder_IgnoresItself_AndLeavesAnExistingIgnoreAlone() + { + TailwindDownloader.IgnoreShellUiFolder(_dir); + var ignore = Path.Combine(_dir, ".shellui", ".gitignore"); + Assert.Equal("*\n", File.ReadAllText(ignore)); + + File.WriteAllText(ignore, "bin/\n"); + TailwindDownloader.IgnoreShellUiFolder(_dir); + Assert.Equal("bin/\n", File.ReadAllText(ignore)); + } + + private string WriteTargets(string content) + { + var path = Path.Combine(_dir, "Build", "ShellUI.targets"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + return path; + } + + private static string Fixture(string name, [System.Runtime.CompilerServices.CallerFilePath] string thisFile = "") => + File.ReadAllText(Path.Combine(Path.GetDirectoryName(thisFile)!, "Fixtures", "Targets", name)); + + private static HttpResponseMessage Full(byte[] body, Stream? stream = null) + { + var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream ?? new MemoryStream(body)) }; + response.Content.Headers.ContentLength = body.Length; + return response; + } + + private static HttpResponseMessage Partial(byte[] body, long from) + { + var response = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent(body[(int)from..]) }; + response.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, body.Length - 1, body.Length); + return response; + } + + private sealed class FakeServer : HttpMessageHandler + { + private readonly Func _respond; + public List Requests { get; } = new(); + + public FakeServer(Func respond) => _respond = respond; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(_respond(request, Requests.Count)); + } + } + + // Sends its bytes, then fails like a dropped connection. + private sealed class FailingStream(byte[] data) : MemoryStream(data) + { + public override int Read(byte[] buffer, int offset, int count) + { + var read = base.Read(buffer, offset, count); + return read > 0 ? read : throw new IOException("connection reset"); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var read = await base.ReadAsync(buffer, cancellationToken); + return read > 0 ? read : throw new IOException("connection reset"); + } + } + + // Sends its bytes, then never sends anything again. + private sealed class StallingStream(byte[] data) : MemoryStream(data) + { + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var read = await base.ReadAsync(buffer, cancellationToken); + if (read > 0) return read; + await Task.Delay(Timeout.Infinite, cancellationToken); + return 0; + } + } +} diff --git a/docs/CLI_SYNTAX.md b/docs/CLI_SYNTAX.md index 1f6e1a1..6778f90 100644 --- a/docs/CLI_SYNTAX.md +++ b/docs/CLI_SYNTAX.md @@ -67,7 +67,7 @@ In apps created with `--auth Individual`, `init` renders the `/Account` pages st With `--dashboard`, `init` then runs `shellui add dashboard-02` (or `dashboard-01`), including the layout wiring described under [Dashboard layouts](#dashboard-layouts). -Standalone mode stores the Tailwind executable in `.shellui/bin/`. npm mode installs `tailwindcss@^4.3.2` and `@tailwindcss/cli@^4.3.2` and requires Node.js and npm. The current CLI invokes npm through `cmd`; use standalone mode on non-Windows systems or run npm manually. +Standalone mode downloads the Tailwind executable once per machine into `~/.shellui/bin` (set `SHELLUI_CACHE_DIR` to move it), and every project builds with that copy; a copy in a project's `.shellui/bin/` takes precedence. If the download fails, `init` still completes and the build retries it. npm mode installs `tailwindcss@^4.3.2` and `@tailwindcss/cli@^4.3.2` and requires Node.js and npm. The current CLI invokes npm through `cmd`; use standalone mode on non-Windows systems or run npm manually. ## `add` @@ -206,8 +206,9 @@ Run it from the project root after a theme has been applied. | `wwwroot/input.css` | Tailwind v4 input and theme variables | | `wwwroot/app.css` | Generated Tailwind output | | `tailwind.config.js` | Project content globs and dark-mode setting | -| `.shellui/bin/tailwindcss` | Standalone CLI on macOS/Linux | -| `.shellui/bin/tailwindcss.exe` | Standalone CLI on Windows | +| `~/.shellui/bin/tailwindcss--` | Standalone CLI, shared by every project on the machine | +| `.shellui/.gitignore` | Keeps `.shellui/` out of git | +| `.shellui/bin/tailwindcss(.exe)` | Optional per-project CLI, used instead of the shared one | | `Build/ShellUI.targets` | MSBuild integration | | `shellui.json` | ShellUI project configuration | | `shellui.theme.lock` | Theme source and integrity record | @@ -288,7 +289,7 @@ shellui add button,card - Run `shellui list` to verify a target name. - Confirm `wwwroot/input.css` exists and contains `@import "tailwindcss";`. - Run `dotnet build` to invoke the generated Tailwind build target. -- In standalone mode, check `.shellui/bin/`; in npm mode, run `npm install`. +- In standalone mode, check `~/.shellui/bin`; in npm mode, run `npm install`. ## Related documentation diff --git a/docs/FAQ.md b/docs/FAQ.md index af30451..7f20e71 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -65,7 +65,7 @@ Not for standalone mode: shellui init --yes --tailwind standalone ``` -Standalone mode downloads the Tailwind `4.3.2` CLI into `.shellui/bin/` and integrates it with the generated build target. +Standalone mode downloads the Tailwind `4.3.2` CLI once per machine (into `~/.shellui/bin`, or `SHELLUI_CACHE_DIR`) and the generated build target runs it from there. Projects hold no copy of the binary, and later projects set up offline. The binary is a build tool: it never ships with your app, which only gets the generated `app.css`. npm mode is also supported. It requires Node.js and npm: @@ -227,7 +227,7 @@ Check that `wwwroot/input.css` exists and starts with the Tailwind v4 import, th dotnet build ``` -For standalone mode, check `.shellui/bin/`. For npm mode, run `npm install`. +For standalone mode, check `~/.shellui/bin`. For npm mode, run `npm install`. ## Package updates and licensing diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 47da977..5812b10 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -44,7 +44,7 @@ shellui init --yes --tailwind standalone - `Components/_Imports.razor` when it is present - The host file used by the project -Standalone mode downloads Tailwind `4.3.2` into `.shellui/bin/` and does not require Node.js. To use npm instead: +Standalone mode downloads Tailwind `4.3.2` once per machine, into `~/.shellui/bin`, and does not require Node.js. To use npm instead: ```bash shellui init --yes --tailwind npm @@ -152,7 +152,7 @@ shellui theme update - Use `shellui` for a global tool and `dotnet shellui` for a local manifest. - Run `shellui list` to verify a target name. - Check that `wwwroot/input.css` contains `@import "tailwindcss";`. -- Check `.shellui/bin/` for standalone mode or run `npm install` for npm mode. +- Check `~/.shellui/bin` for standalone mode or run `npm install` for npm mode. - Run `dotnet build` to invoke the generated Tailwind target. ## Related documentation diff --git a/docs/tailwind-setup.md b/docs/tailwind-setup.md index e9a6ea5..27919c9 100644 --- a/docs/tailwind-setup.md +++ b/docs/tailwind-setup.md @@ -10,7 +10,9 @@ This guide targets **Tailwind CSS 4.3.2**, the version ShellUI uses. | **Standalone** | One platform-specific CLI binary | MSBuild target | No | A .NET/Blazor project without Node.js | | **npm** | `tailwindcss` plus `@tailwindcss/cli` | npm or MSBuild | Yes | Projects that already use Node.js or plugins | -The standalone CLI is cached inside the project at `.shellui/bin`. On Windows the executable is `.shellui/bin/tailwindcss.exe`; on macOS and Linux it is `.shellui/bin/tailwindcss`. The project-local `.shellui/bin` directory is the cache location. +The standalone CLI is downloaded once per machine into `~/.shellui/bin` (`%USERPROFILE%\.shellui\bin` on Windows) as `tailwindcss-4.3.2-`, and every project builds with that one copy, so projects hold no binary. Set `SHELLUI_CACHE_DIR` to keep it somewhere else. A project that needs its own copy can put it at `.shellui/bin/tailwindcss.exe` (Windows) or `.shellui/bin/tailwindcss` (macOS, Linux), and the build uses that instead. `.shellui/` contains a `.gitignore` of `*`, so nothing in it is committed. + +The download streams to a `.partial` file, resumes after a dropped connection, retries, and only gives up when no data arrives for a minute, so a slow connection is fine. If it still fails, `init` finishes the rest of the setup and warns, and the next `dotnet build` tries again. Its warning names the exact URL and the file to save it as if you want to download it yourself. For a production Blazor application, use either the standalone or npm workflow and build the CSS as part of the project. The Play CDN is a prototyping convenience and does not provide the same production build and caching behavior. @@ -264,7 +266,7 @@ Check that: - `wwwroot/app.css` contains generated utilities. - The host includes the generated stylesheet. - A Razor component using a ShellUI utility is styled in the browser. -- The standalone executable is at `.shellui/bin/tailwindcss.exe` on Windows or `.shellui/bin/tailwindcss` on macOS/Linux. +- The standalone executable is in `~/.shellui/bin` (`%USERPROFILE%\.shellui\bin` on Windows), or in the project's `.shellui/bin` if you put one there. - The npm project can run `npx @tailwindcss/cli -i wwwroot/input.css -o wwwroot/app.css`. ## Troubleshooting @@ -272,7 +274,7 @@ Check that: ### Tailwind CSS is not building - Confirm `wwwroot/input.css` contains `@import "tailwindcss";`. -- Confirm the standalone executable is in the project-local `.shellui/bin` directory. +- Confirm the standalone executable is in `~/.shellui/bin` (or `SHELLUI_CACHE_DIR`). If the build warns that it couldn't download it, save the file it names there. - Confirm `Build/ShellUI.targets` is imported by the project. - Confirm the input and output paths exist. - For npm, confirm `tailwindcss@4.3.2` and `@tailwindcss/cli@4.3.2` are installed and run `npx @tailwindcss/cli --help`. diff --git a/src/ShellUI.CLI/Services/ComponentManager.cs b/src/ShellUI.CLI/Services/ComponentManager.cs index 0ada26c..7635e68 100644 --- a/src/ShellUI.CLI/Services/ComponentManager.cs +++ b/src/ShellUI.CLI/Services/ComponentManager.cs @@ -196,6 +196,9 @@ public static async Task UpdateComponents(string[] componentNames, bool updateAl var config = JsonSerializer.Deserialize(File.ReadAllText(ConfigFileName)); var installed = config?.InstalledComponents.Select(c => c.Name).ToList() ?? new List(); + foreach (var note in InitService.RefreshTailwindSetup(Directory.GetCurrentDirectory())) + AnsiConsole.MarkupLine(note); + if (config == null || installed.Count == 0) { AnsiConsole.MarkupLine("[yellow]No components installed yet.[/]"); diff --git a/src/ShellUI.CLI/Services/InitService.cs b/src/ShellUI.CLI/Services/InitService.cs index c09095c..57d18ab 100644 --- a/src/ShellUI.CLI/Services/InitService.cs +++ b/src/ShellUI.CLI/Services/InitService.cs @@ -97,6 +97,7 @@ public static async Task InitializeAsync(string style, bool force, string? tailw } }; + string? tailwindPath = null; await LogoLoader.RunAsync("Installing ShellUI components...", async status => { status("Creating component folders..."); @@ -134,7 +135,7 @@ await LogoLoader.RunAsync("Installing ShellUI components...", async status => } else { - await SetupTailwindStandaloneAsync(); + tailwindPath = await SetupTailwindStandaloneAsync(status); } status("Wiring up theme and render mode..."); @@ -166,15 +167,19 @@ await LogoLoader.RunAsync("Installing ShellUI components...", async status => if (method == "npm") { await RunNpmTailwindBuildAsync(); + AnsiConsole.MarkupLine($"[green]Built:[/] Tailwind CSS"); } - else + else if (tailwindPath != null) { - var tailwindPath = await TailwindDownloader.EnsureTailwindCliAsync(Directory.GetCurrentDirectory()); var inputCssPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "input.css"); var appCssPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "app.css"); await RunTailwindBuildAsync(tailwindPath, inputCssPath, appCssPath); + AnsiConsole.MarkupLine($"[green]Built:[/] Tailwind CSS"); + } + else + { + AnsiConsole.MarkupLine("[yellow]Skipped:[/] Tailwind CSS build [dim](no Tailwind CLI yet; dotnet build tries the download again)[/]"); } - AnsiConsole.MarkupLine($"[green]Built:[/] Tailwind CSS"); }); var json = JsonSerializer.Serialize(config, new JsonSerializerOptions @@ -229,10 +234,9 @@ private static async Task SetupTailwindNpmAsync() AnsiConsole.MarkupLine($"[green]Created:[/] tailwind.config.js"); } - private static async Task SetupTailwindStandaloneAsync() + private static async Task SetupTailwindStandaloneAsync(Action status) { - AnsiConsole.MarkupLine("[cyan]Downloading Tailwind CSS standalone CLI...[/]"); - var tailwindPath = await TailwindDownloader.EnsureTailwindCliAsync(Directory.GetCurrentDirectory()); + var tailwindPath = await TailwindDownloader.EnsureTailwindCliAsync(Directory.GetCurrentDirectory(), status); AnsiConsole.MarkupLine("[cyan]Creating CSS files...[/]"); var wwwrootPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot"); @@ -249,6 +253,7 @@ private static async Task SetupTailwindStandaloneAsync() var tailwindConfigPath = Path.Combine(Directory.GetCurrentDirectory(), "tailwind.config.js"); File.WriteAllText(tailwindConfigPath, CssTemplates.TailwindConfigJs); AnsiConsole.MarkupLine($"[green]Created:[/] tailwind.config.js"); + return tailwindPath; } private static async Task IsNpmAvailableAsync() @@ -364,6 +369,40 @@ 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. + private static readonly string[] PreviousStandaloneTargets = + { + "eace414bc927d6274976e888cd1de742efd82ee3142f1cabaea3b1f3e188455e", + "f42811cbd3bdad588c3389de9a0f706b7ac59331848a027b99c30a5765aea5f9", + }; + + // Brings a standalone project set up by an earlier CLI onto the shared Tailwind CLI; returns what changed. + internal static List RefreshTailwindSetup(string projectRoot) + { + var notes = new List(); + var path = Path.Combine(projectRoot, "Build", "ShellUI.targets"); + if (!File.Exists(path)) return notes; + + var current = GetTargetsFileContent("standalone"); + var hash = ComponentInstaller.ContentHash(File.ReadAllText(path)); + if (PreviousStandaloneTargets.Contains(hash)) + { + File.WriteAllText(path, current); + notes.Add("[green]Updated:[/] Build/ShellUI.targets [dim](uses one shared Tailwind CLI per machine and retries its download)[/]"); + } + else if (hash != ComponentInstaller.ContentHash(current)) + { + if (File.ReadAllText(path).Contains("TailwindExecutable", StringComparison.Ordinal)) + notes.Add("[yellow]Note:[/] Build/ShellUI.targets has local changes, so it was not updated. Compare it with a new [bold]shellui init[/] to pick up the shared Tailwind CLI."); + return notes; + } + + TailwindDownloader.IgnoreShellUiFolder(projectRoot); + if (TailwindDownloader.RemoveDuplicateProjectCopy(projectRoot)) + notes.Add($"[green]Removed:[/] the project's copy of the Tailwind CLI [dim](builds use the shared one in {Markup.Escape(TailwindDownloader.CacheDirectory())})[/]"); + return notes; + } + internal static string GetTargetsFileContent(string method) { if (method == "npm") @@ -396,27 +435,37 @@ internal static string GetTargetsFileContent(string method) $(MSBuildProjectDirectory)\.shellui\bin - $(ShellUIBinPath)\tailwindcss.exe - $(ShellUIBinPath)/tailwindcss $(MSBuildProjectDirectory)\wwwroot\input.css $(MSBuildProjectDirectory)\wwwroot\app.css --minify __TAILWIND_TAG__ + __TAILWIND_VERSION__ $([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) + - - - + + + + + - + @@ -429,7 +478,7 @@ internal static string GetTargetsFileContent(string method) -".Replace("__TAILWIND_TAG__", TailwindConstants.GitHubTag); +".Replace("__TAILWIND_TAG__", TailwindConstants.GitHubTag).Replace("__TAILWIND_VERSION__", TailwindConstants.Version); } } diff --git a/src/ShellUI.CLI/Services/TailwindDownloader.cs b/src/ShellUI.CLI/Services/TailwindDownloader.cs index 2c7ef6e..8dfb6a9 100644 --- a/src/ShellUI.CLI/Services/TailwindDownloader.cs +++ b/src/ShellUI.CLI/Services/TailwindDownloader.cs @@ -1,4 +1,5 @@ -using System.IO.Compression; +using System.Net; +using System.Net.Http.Headers; using System.Runtime.InteropServices; using ShellUI.Core; using Spectre.Console; @@ -9,52 +10,171 @@ public class TailwindDownloader { private const string TailwindVersion = TailwindConstants.GitHubTag; private const string BaseUrl = "https://github.com/tailwindlabs/tailwindcss/releases/download"; - - public static async Task EnsureTailwindCliAsync(string projectRoot) - { - var binPath = Path.Combine(projectRoot, ".shellui", "bin"); - Directory.CreateDirectory(binPath); + private const int Attempts = 4; + private static readonly TimeSpan StallTimeout = TimeSpan.FromSeconds(60); - var executableName = GetExecutableName(); - var tailwindPath = Path.Combine(binPath, executableName); + // The CLI to run: a project's own copy in .shellui/bin if it has one, otherwise the shared per-machine copy, + // downloaded on first use. Projects hold no binary. Returns null when it can't be had, so init can carry on. + public static async Task EnsureTailwindCliAsync(string projectRoot, Action? progress = null) + { + IgnoreShellUiFolder(projectRoot); + var projectCopy = GetTailwindPath(projectRoot); + if (File.Exists(projectCopy)) return projectCopy; - if (File.Exists(tailwindPath)) + var cached = CachedPath(); + if (!File.Exists(cached)) + { + var url = $"{BaseUrl}/{TailwindVersion}/tailwindcss-{GetPlatformIdentifier()}"; + AnsiConsole.MarkupLine($"[cyan]Downloading Tailwind CLI {TailwindVersion}[/] [dim]to {Markup.Escape(cached)}[/]"); + try + { + await DownloadAsync(url, cached, progress); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[yellow]Warning:[/] Could not download the Tailwind CLI: {Markup.Escape(ex.Message)}"); + AnsiConsole.MarkupLine($"[dim]The build retries the download. To install it by hand, save[/] {Markup.Escape(url)} [dim]as[/] {Markup.Escape(cached)}"); + return null; + } + AnsiConsole.MarkupLine("[green]Tailwind CLI downloaded[/]"); + } + else { - AnsiConsole.MarkupLine("[dim]Tailwind CLI already installed[/]"); - return tailwindPath; + AnsiConsole.MarkupLine($"[dim]Using the Tailwind CLI in {Markup.Escape(CacheDirectory())}[/]"); } + return cached; + } + + // A .gitignore of "*" inside .shellui keeps the folder (and any binary in it) out of git without touching the project's own .gitignore. + public static void IgnoreShellUiFolder(string projectRoot) + { + var ignore = Path.Combine(projectRoot, ".shellui", ".gitignore"); + if (File.Exists(ignore)) return; + Directory.CreateDirectory(Path.GetDirectoryName(ignore)!); + File.WriteAllText(ignore, "*\n"); + } - await DownloadTailwindCliAsync(tailwindPath); - return tailwindPath; + // Projects set up before the shared cache carry their own copy; drop it when it is the same file as the shared one. + public static bool RemoveDuplicateProjectCopy(string projectRoot) + { + var projectCopy = GetTailwindPath(projectRoot); + var cached = CachedPath(); + if (!File.Exists(projectCopy) || !File.Exists(cached)) return false; + if (new FileInfo(projectCopy).Length != new FileInfo(cached).Length) return false; + + using (var a = File.OpenRead(projectCopy)) + using (var b = File.OpenRead(cached)) + { + if (!System.Security.Cryptography.SHA256.HashData(a).AsSpan().SequenceEqual(System.Security.Cryptography.SHA256.HashData(b))) + return false; + } + File.Delete(projectCopy); + return true; } - private static async Task DownloadTailwindCliAsync(string destinationPath) + // Streams to .partial and resumes it on retry; the file only appears once its size matches. + internal static async Task DownloadAsync(string url, string destination, Action? progress = null, + HttpMessageHandler? handler = null, TimeSpan? stallTimeout = null, TimeSpan? retryDelay = null) { - var platform = GetPlatformIdentifier(); - var downloadUrl = $"{BaseUrl}/{TailwindVersion}/tailwindcss-{platform}"; + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + var partial = destination + ".partial"; + using var client = handler == null ? new HttpClient() : new HttpClient(handler, disposeHandler: false); + client.Timeout = Timeout.InfiniteTimeSpan; + client.DefaultRequestHeaders.UserAgent.ParseAdd("ShellUI-CLI"); + + Exception? last = null; + for (var attempt = 1; attempt <= Attempts; attempt++) + { + // Another init may have finished the same download meanwhile. + if (attempt > 1 && File.Exists(destination)) return; + try + { + await DownloadOnceAsync(client, url, partial, progress, stallTimeout ?? StallTimeout); + File.Move(partial, destination, overwrite: true); + MakeExecutable(destination); + return; + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode code && (int)code is >= 400 and < 500 + && code is not (HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests)) + { + throw new IOException($"the server answered {(int)code} {code}", ex); + } + catch (Exception ex) when (ex is HttpRequestException or IOException or OperationCanceledException) + { + last = ex; + if (attempt == Attempts) break; + progress?.Invoke($"Tailwind CLI download interrupted, retrying ({attempt}/{Attempts - 1})..."); + await Task.Delay(retryDelay ?? TimeSpan.FromSeconds(2 * attempt)); + } + } - AnsiConsole.MarkupLine($"[cyan]Downloading Tailwind CLI {TailwindVersion}...[/]"); + throw new IOException($"gave up after {Attempts} attempts ({last?.Message})", last); + } - using var client = new HttpClient(); - client.Timeout = TimeSpan.FromMinutes(5); + private static async Task DownloadOnceAsync(HttpClient client, string url, string partial, Action? progress, TimeSpan stallTimeout) + { + var existing = File.Exists(partial) ? new FileInfo(partial).Length : 0; + using var request = new HttpRequestMessage(HttpMethod.Get, url); + if (existing > 0) request.Headers.Range = new RangeHeaderValue(existing, null); - var response = await client.GetAsync(downloadUrl); + using var stall = new CancellationTokenSource(stallTimeout); + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, stall.Token); - if (!response.IsSuccessStatusCode) + if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable) { - throw new Exception($"Failed to download Tailwind CLI: {response.StatusCode}"); + File.Delete(partial); + throw new IOException("partial download was invalid; starting over"); } + response.EnsureSuccessStatusCode(); - var bytes = await response.Content.ReadAsByteArrayAsync(); - await File.WriteAllBytesAsync(destinationPath, bytes); + // A server that ignores Range sends the whole file again. + var resuming = existing > 0 && response.StatusCode == HttpStatusCode.PartialContent; + var total = response.Content.Headers.ContentLength is long length ? length + (resuming ? existing : 0) : (long?)null; + var written = resuming ? existing : 0; - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + await using (var source = await response.Content.ReadAsStreamAsync(stall.Token)) + await using (var target = new FileStream(partial, resuming ? FileMode.Append : FileMode.Create, FileAccess.Write, FileShare.None)) { - var process = System.Diagnostics.Process.Start("chmod", $"+x {destinationPath}"); - process?.WaitForExit(); + var buffer = new byte[81920]; + var lastReport = DateTime.MinValue; + // Without a terminal each update is a log line, so report every 10% instead of continuously. + var step = AnsiConsole.Profile.Capabilities.Interactive ? 1 : 10; + var lastStep = -1L; + int read; + while ((read = await source.ReadAsync(buffer, stall.Token)) > 0) + { + stall.CancelAfter(stallTimeout); + await target.WriteAsync(buffer.AsMemory(0, read)); + written += read; + if (progress == null || DateTime.UtcNow - lastReport < TimeSpan.FromMilliseconds(250)) continue; + + var reached = total is long t && t > 0 ? written * 100 / t / step : written / 1048576 / (step * 2); + if (reached == lastStep) continue; + lastStep = reached; + lastReport = DateTime.UtcNow; + progress(total is long size && size > 0 + ? $"Downloading Tailwind CLI... {written * 100 / size}% ({written / 1048576} of {size / 1048576} MB)" + : $"Downloading Tailwind CLI... {written / 1048576} MB"); + } } - AnsiConsole.MarkupLine("[green]Tailwind CLI downloaded successfully![/]"); + if (total is long expected && written != expected) + throw new IOException($"download ended at {written} of {expected} bytes"); + } + + // The same location and name scripts/rebuild-precompiled-css.sh uses, so one copy serves every project. + public static string CacheDirectory() => + Environment.GetEnvironmentVariable("SHELLUI_CACHE_DIR") is { Length: > 0 } dir + ? dir + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".shellui", "bin"); + + public static string CachedPath() => + Path.Combine(CacheDirectory(), $"tailwindcss-{TailwindConstants.Version}-{GetPlatformIdentifier()}"); + + private static void MakeExecutable(string path) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + File.SetUnixFileMode(path, File.GetUnixFileMode(path) | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); } private static string GetPlatformIdentifier() @@ -93,8 +213,8 @@ private static string GetPlatformIdentifier() private static string GetExecutableName() { - return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? "tailwindcss.exe" + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "tailwindcss.exe" : "tailwindcss"; } @@ -105,4 +225,3 @@ public static string GetTailwindPath(string projectRoot) return Path.Combine(binPath, executableName); } } -