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