diff --git a/ShellUI.Tests/PackageVersionTests.cs b/ShellUI.Tests/PackageVersionTests.cs new file mode 100644 index 0000000..63ff559 --- /dev/null +++ b/ShellUI.Tests/PackageVersionTests.cs @@ -0,0 +1,74 @@ +using ShellUI.CLI.Services; +using Xunit; + +namespace ShellUI.Tests; + +public class PackageVersionTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), $"ShellUIPackages_{Guid.NewGuid():N}"); + + public PackageVersionTests() => Directory.CreateDirectory(_dir); + + public void Dispose() => Directory.Delete(_dir, true); + + [Theory] + [InlineData("7.0.0", "6.0.2", true)] + [InlineData("6.0.2", "6.0.2", true)] + [InlineData("6.1", "6.0.2", true)] + [InlineData("6.0.1", "6.0.2", false)] + [InlineData("5.9.9", "6.0.2", false)] + [InlineData("6.0.2-beta.1", "6.0.2", false)] + [InlineData("6.0.2", "6.0.2-beta.1", true)] + [InlineData("6.0.2-rc.1", "6.0.2-beta.1", true)] + [InlineData("7.*", "6.0.2", true)] + [InlineData("[6.0,8.0)", "6.0.2", true)] + [InlineData("$(ApexVersion)", "6.0.2", true)] + [InlineData("", "6.0.2", true)] + public void SatisfiesMinimum_OnlyAddsOrRaises(string existing, string minimum, bool satisfied) => + Assert.Equal(satisfied, ComponentInstaller.SatisfiesMinimum(existing, minimum)); + + [Fact] + public void ReadsTheVersionAttribute_CaseInsensitively() + { + var project = Project(@""); + + Assert.Equal("7.0.0", ComponentInstaller.ExistingPackageVersion(project, "Blazor-ApexCharts")); + Assert.Null(ComponentInstaller.ExistingPackageVersion(project, "QRCoder")); + } + + [Fact] + public void ReadsAVersionElement() + { + var project = Project(@"1.9.0"); + + Assert.Equal("1.9.0", ComponentInstaller.ExistingPackageVersion(project, "QRCoder")); + } + + [Fact] + public void ReadsCentralPackageManagement() + { + File.WriteAllText(Path.Combine(_dir, "Directory.Packages.props"), + @""); + var project = Project(@"", subfolder: "src"); + + Assert.Equal("7.1.0", ComponentInstaller.ExistingPackageVersion(project, "Blazor-ApexCharts")); + } + + [Fact] + public void ReferenceWithoutAFindableVersion_IsKeptAsIs() + { + var project = Project(@""); + + Assert.Equal("", ComponentInstaller.ExistingPackageVersion(project, "QRCoder")); + Assert.True(ComponentInstaller.SatisfiesMinimum("", "1.8.0")); + } + + private string Project(string references, string? subfolder = null) + { + var dir = subfolder == null ? _dir : Path.Combine(_dir, subfolder); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "App.csproj"); + File.WriteAllText(path, $@"{references}"); + return path; + } +} diff --git a/docs/CLI_SYNTAX.md b/docs/CLI_SYNTAX.md index 3df2986..354db55 100644 --- a/docs/CLI_SYNTAX.md +++ b/docs/CLI_SYNTAX.md @@ -85,6 +85,8 @@ shellui add button --force `--force` overwrites an existing component file. Dependencies are installed automatically; there is no separate dependency option. +Some components need a NuGet package (`chart` needs `Blazor-ApexCharts`, `data-table` needs `System.Linq.Dynamic.Core`, `qr-code` needs `QRCoder`). `add` references it if the project doesn't, and raises an older version to the one ShellUI needs. A version that is already the same or newer is left as it is (including one set in `Directory.Packages.props`), as are floating versions such as `7.*`, ranges, and references whose version ShellUI can't find. + ### Dashboard layouts `shellui add dashboard-01` and `shellui add dashboard-02`, and `init --dashboard`, also wire the layout into the app: diff --git a/src/ShellUI.CLI/Services/ComponentInstaller.cs b/src/ShellUI.CLI/Services/ComponentInstaller.cs index fce99de..57cb0c5 100644 --- a/src/ShellUI.CLI/Services/ComponentInstaller.cs +++ b/src/ShellUI.CLI/Services/ComponentInstaller.cs @@ -380,6 +380,18 @@ private static bool InstallComponentWithDependencies( private static async Task> InstallNuGetDependenciesAsync(ProjectInfo projectInfo, List deps) { var failed = new List(); + + // dotnet add package --version sets that version even when the project has a newer one, so only add or raise. + var needed = new List(); + foreach (var dep in deps) + { + var existing = ExistingPackageVersion(projectInfo.ProjectPath, dep.PackageId); + if (existing != null && SatisfiesMinimum(existing, dep.Version)) + AnsiConsole.MarkupLine($"[dim]Kept {dep.PackageId} {(existing.Length > 0 ? existing : "(version set elsewhere)")}; ShellUI needs {dep.Version} or later[/]"); + else + needed.Add(dep); + } + deps = needed; if (deps.Count == 0) return failed; AnsiConsole.MarkupLine(""); @@ -430,6 +442,51 @@ private static async Task> InstallNuGetDependenciesAsync(P return failed; } + // The version a project already references: from its PackageReference, or Directory.Packages.props under central + // package management. "" when referenced without a version we can find, null when not referenced. + internal static string? ExistingPackageVersion(string projectPath, string packageId) + { + if (!File.Exists(projectPath)) return null; + bool Matches(System.Xml.Linq.XElement e) => + string.Equals((string?)e.Attribute("Include") ?? (string?)e.Attribute("Update"), packageId, StringComparison.OrdinalIgnoreCase); + string? VersionOf(System.Xml.Linq.XElement e) => + (string?)e.Attribute("VersionOverride") ?? (string?)e.Attribute("Version") + ?? e.Elements().FirstOrDefault(c => c.Name.LocalName is "VersionOverride" or "Version")?.Value; + + var reference = System.Xml.Linq.XDocument.Load(projectPath).Descendants() + .FirstOrDefault(e => e.Name.LocalName == "PackageReference" && Matches(e)); + if (reference == null) return null; + if (VersionOf(reference) is { Length: > 0 } version) return version.Trim(); + + for (var dir = Path.GetDirectoryName(Path.GetFullPath(projectPath)); dir != null; dir = Path.GetDirectoryName(dir)) + { + var props = Path.Combine(dir, "Directory.Packages.props"); + if (!File.Exists(props)) continue; + var central = System.Xml.Linq.XDocument.Load(props).Descendants() + .FirstOrDefault(e => e.Name.LocalName == "PackageVersion" && Matches(e)); + return central != null && VersionOf(central) is { Length: > 0 } pinned ? pinned.Trim() : ""; + } + return ""; + } + + // Floating versions, ranges and unknown versions count as satisfied: the project chose them, so ShellUI leaves them. + internal static bool SatisfiesMinimum(string existing, string minimum) + { + if (existing.Length == 0 || existing.IndexOfAny(new[] { '*', '[', '(', '$' }) >= 0) return true; + static (Version Release, string? Pre) Parse(string v) + { + var dash = v.IndexOf('-'); + var core = (dash < 0 ? v : v[..dash]).Split('+')[0]; + var parts = core.Split('.').Select(p => int.TryParse(p, out var n) ? n : 0).Concat(new[] { 0, 0, 0, 0 }).Take(4).ToArray(); + return (new Version(parts[0], parts[1], parts[2], parts[3]), dash < 0 ? null : v[(dash + 1)..]); + } + var (have, havePre) = Parse(existing); + var (need, needPre) = Parse(minimum); + if (have != need) return have > need; + if (havePre == null || needPre == null) return havePre == null; + return string.CompareOrdinal(havePre, needPre) >= 0; + } + // Pages use helper types such as ButtonVariant from sub-namespaces, imported once an installed file declares them. public static IEnumerable RequiredImports(string rootNamespace, IEnumerable templateContents) {