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); } } -