Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions ShellUI.Tests/PackageVersionTests.cs
Original file line number Diff line number Diff line change
@@ -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(@"<PackageReference Include=""blazor-apexcharts"" Version=""7.0.0"" />");

Assert.Equal("7.0.0", ComponentInstaller.ExistingPackageVersion(project, "Blazor-ApexCharts"));
Assert.Null(ComponentInstaller.ExistingPackageVersion(project, "QRCoder"));
}

[Fact]
public void ReadsAVersionElement()
{
var project = Project(@"<PackageReference Include=""QRCoder""><Version>1.9.0</Version></PackageReference>");

Assert.Equal("1.9.0", ComponentInstaller.ExistingPackageVersion(project, "QRCoder"));
}

[Fact]
public void ReadsCentralPackageManagement()
{
File.WriteAllText(Path.Combine(_dir, "Directory.Packages.props"),
@"<Project><ItemGroup><PackageVersion Include=""Blazor-ApexCharts"" Version=""7.1.0"" /></ItemGroup></Project>");
var project = Project(@"<PackageReference Include=""Blazor-ApexCharts"" />", subfolder: "src");

Assert.Equal("7.1.0", ComponentInstaller.ExistingPackageVersion(project, "Blazor-ApexCharts"));
}

[Fact]
public void ReferenceWithoutAFindableVersion_IsKeptAsIs()
{
var project = Project(@"<PackageReference Include=""QRCoder"" />");

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, $@"<Project Sdk=""Microsoft.NET.Sdk.Web""><ItemGroup>{references}</ItemGroup></Project>");
return path;
}
}
2 changes: 2 additions & 0 deletions docs/CLI_SYNTAX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
57 changes: 57 additions & 0 deletions src/ShellUI.CLI/Services/ComponentInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,18 @@ private static bool InstallComponentWithDependencies(
private static async Task<List<NuGetDependency>> InstallNuGetDependenciesAsync(ProjectInfo projectInfo, List<NuGetDependency> deps)
{
var failed = new List<NuGetDependency>();

// dotnet add package --version sets that version even when the project has a newer one, so only add or raise.
var needed = new List<NuGetDependency>();
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("");
Expand Down Expand Up @@ -430,6 +442,51 @@ private static async Task<List<NuGetDependency>> 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<string> RequiredImports(string rootNamespace, IEnumerable<string> templateContents)
{
Expand Down
Loading