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)
{