From 2d3c764df215b6ed2747a4a18a83ce3472b5063b Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Thu, 27 Aug 2026 16:48:07 -0300 Subject: [PATCH] Treat bare package names as evergreen @* and support 1.* floats --- readme.md | 48 ++++++++++++++----- src/Tests/ArgParserTests.cs | 13 +++++ src/Tests/EvergreenTests.cs | 52 ++++++++++---------- src/Tests/RecordingProcessRunner.cs | 8 ++-- src/Tests/VersionRangeTests.cs | 74 ++++++++++++++++++++++++----- src/ndx/App.cs | 3 +- src/ndx/PackageVersion.cs | 42 ++++++++++++++++ 7 files changed, 185 insertions(+), 55 deletions(-) diff --git a/readme.md b/readme.md index 32de27f..67f43a5 100644 --- a/readme.md +++ b/readme.md @@ -9,7 +9,7 @@ `ndx` (*n*ative *d*otnet e*x*ecute) is [`dnx`](https://learn.microsoft.com/dotnet/core/tools/dotnet-tool-exec) for -native tools packaged and distributed as NuGet packages. Same one-shot CLI, same +native tools packaged and distributed as NuGet packages. Same `PACKAGE[@VERSION]` identity, same restore flags, same global packages folder. If you can `dnx stop`, you can `ndx stop`. `dotnet dnx` and `dotnet tool exec` @@ -95,12 +95,41 @@ service index (cached for the process) and lists versions only if that GET is 404. Packages written by `dnx` or `dotnet restore` are reused; packages written by ndx are visible to them. -A floating version — unspecified, `@*`, `@*-*`, or a NuGet range — stays -current. ndx starts the latest match, watches the feed, downloads a newer -matching version *before* stopping the child, then sends SIGINT / Ctrl+C -(and `WM_CLOSE` if the tool is a GUI) and restarts. Short tools still exit -as soon as the child exits. Pin an exact version (`tool@2.1.0`) to disable -that loop. +Shared flags: `--source`, `--add-source`, `--configfile`, `--version`, +`--prerelease`, `--yes`/`-y`, `--allow-roll-forward`, `--verbosity`/`-v`, +`--disable-parallel`, `--ignore-failed-sources`, `--no-http-cache`, +`--interactive`. + +## Evergreen + +Unlike `dnx`, omitting the version is `@*` — not a one-shot latest. +`ndx my-agent` and `ndx my-agent@*` are the same: start the latest match, +and while that process is still running, watch the feed, download a newer +matching version *before* stopping the child, then send SIGINT / Ctrl+C +(and `WM_CLOSE` if the tool is a GUI) and restart. + +`@*-*` includes prereleases. `@1.*` / `@1.1.*` pin a major or minor the +GitHub Actions way (float the rest). A NuGet range like `[1.0,2.0)` works +too. One-shot tools still exit as soon as the child exits; the watch loop +only continues while the child is alive. Pin an exact version +(`tool@2.1.0`) to disable it. + +```bash +ndx my-agent +ndx my-agent@* +ndx my-agent@1.* +ndx my-agent@1.1.* +ndx my-agent@[1.0,2.0) +``` + +That's the point for anything that is supposed to keep running: a daemon, +an agent, an MCP server, a watcher, a local gateway. You start it once. +When a newer matching package lands on the feed, ndx stages the bits, asks +the current process to exit cleanly, and starts the new one. No +`dotnet tool update -g`, no unit file to bounce, no cron that reinstalls. +The running process is always the latest version the range allows, so an +agent picks up new tools and bugfixes, a daemon picks up a patched build, +without anyone being there to restart it. The poll interval defaults to 5 seconds and can be set in `.netconfig`: @@ -112,11 +141,6 @@ The poll interval defaults to 5 seconds and can be set in `.netconfig`: ndx walks from the working directory up, then `~/.netconfig`. `--verbosity quiet` hides the `Updating …` line. -Shared flags: `--source`, `--add-source`, `--configfile`, `--version`, -`--prerelease`, `--yes`/`-y`, `--allow-roll-forward`, `--verbosity`/`-v`, -`--disable-parallel`, `--ignore-failed-sources`, `--no-http-cache`, -`--interactive`. - ## Startup time Cold is empty-cache download → start; cached is start only (`tool@version` so diff --git a/src/Tests/ArgParserTests.cs b/src/Tests/ArgParserTests.cs index 7092663..fbdb1d7 100644 --- a/src/Tests/ArgParserTests.cs +++ b/src/Tests/ArgParserTests.cs @@ -94,6 +94,19 @@ public void Version_option_and_at_version_conflict() Assert.Contains("--version", parsed.Error); } + [Theory] + [InlineData("pkg@1.*", "1.*")] + [InlineData("pkg@1.1.*", "1.1.*")] + [InlineData("pkg@1.1.1.*", "1.1.1.*")] + public void Star_floating_versions_are_accepted(string identity, string version) + { + var parsed = ArgParser.Parse(identity); + + Assert.True(parsed.Success); + Assert.Equal("pkg", parsed.PackageId); + Assert.Equal(version, parsed.Version); + } + [Fact] public void Equals_form_is_accepted_for_valued_options() { diff --git a/src/Tests/EvergreenTests.cs b/src/Tests/EvergreenTests.cs index ff15ceb..5d36c1b 100644 --- a/src/Tests/EvergreenTests.cs +++ b/src/Tests/EvergreenTests.cs @@ -13,33 +13,21 @@ public class EvergreenTests : IClassFixture public EvergreenTests(HelloToolFeed feed) => this.feed = feed; - [Fact] - public async Task Star_version_that_exits_returns_child_code_and_does_not_restart() + [Theory] + [InlineData(null)] + [InlineData("*")] + public async Task Floating_version_that_exits_returns_child_code_and_does_not_restart(string? version) { var runner = new RecordingProcessRunner { ExitCode = 7 }; var host = NewHost(runner); var code = await App.RunAsync( - [HelloToolFeed.PackageId + "@*", "--yes", "--source", IsolatedHelloFeed()], + [Identity(version), "--yes", "--source", IsolatedHelloFeed()], host); Assert.Equal(7, code); - Assert.Equal(1, runner.Calls); - } - - [Fact] - public async Task Unspecified_version_is_evergreen_and_uses_start() - { - var runner = new RecordingProcessRunner { ExitCode = 0 }; - var host = NewHost(runner); - - var code = await App.RunAsync( - [HelloToolFeed.PackageId, "--yes", "--source", IsolatedHelloFeed()], - host); - - Assert.Equal(0, code); - Assert.Equal(1, runner.Calls); - Assert.NotNull(runner.Last); + Assert.Equal(1, runner.StartCalls); + Assert.Equal(0, runner.RunCalls); } [Fact] @@ -53,11 +41,15 @@ public async Task Exact_version_still_uses_run() host); Assert.Equal(4, code); - Assert.Equal(1, runner.Calls); + Assert.Equal(1, runner.RunCalls); + Assert.Equal(0, runner.StartCalls); } - [Fact] - public async Task Stages_newer_package_then_stops_and_restarts() + [Theory] + [InlineData(null)] + [InlineData("*")] + [InlineData("1.*")] + public async Task Stages_newer_package_then_stops_and_restarts(string? version) { var feedDir = IsolatedHelloFeed(); var first = new FakeChildProcess(0, exited: false); @@ -79,14 +71,15 @@ public async Task Stages_newer_package_then_stops_and_restarts() }; var run = App.RunAsync( - [HelloToolFeed.PackageId + "@*", "--yes", "--source", feedDir], + [Identity(version), "--yes", "--source", feedDir], host); await WaitUntil(() => runner.Calls >= 1, TimeSpan.FromSeconds(10)); AddPackageVersion(feedDir, "1.0.1"); - await WaitUntil(() => first.StopCalled && runner.Calls >= 2, TimeSpan.FromSeconds(15)); + await WaitUntil(() => first.StopCalled && runner.StartCalls >= 2, TimeSpan.FromSeconds(15)); Assert.True(first.StopCalled); + Assert.Equal(0, runner.RunCalls); Assert.Contains("Updating hello-tool 1.0.0 → 1.0.1", host.Out.ToString()); Assert.Contains("1.0.1", Combined(runner.Starts[1]), StringComparison.OrdinalIgnoreCase); @@ -94,8 +87,10 @@ public async Task Stages_newer_package_then_stops_and_restarts() Assert.Equal(0, await run); } - [Fact] - public async Task Unchanged_feed_does_not_stop_the_running_child() + [Theory] + [InlineData(null)] + [InlineData("*")] + public async Task Unchanged_feed_does_not_stop_the_running_child(string? version) { var first = new FakeChildProcess(0, exited: false); var runner = new RecordingProcessRunner(); @@ -113,7 +108,7 @@ public async Task Unchanged_feed_does_not_stop_the_running_child() using var cts = new CancellationTokenSource(); var run = App.RunAsync( - [HelloToolFeed.PackageId + "@*", "--yes", "--source", IsolatedHelloFeed()], + [Identity(version), "--yes", "--source", IsolatedHelloFeed()], host, cts.Token); @@ -183,6 +178,9 @@ NdxHost NewHost(IProcessRunner runner, string? sourceDir = null) }; } + static string Identity(string? version) + => version is null ? HelloToolFeed.PackageId : HelloToolFeed.PackageId + "@" + version; + string IsolatedHelloFeed() { var dir = Path.Combine(Path.GetTempPath(), "ndx-evergreen-feed", Guid.NewGuid().ToString("n")); diff --git a/src/Tests/RecordingProcessRunner.cs b/src/Tests/RecordingProcessRunner.cs index 05737d2..d4052a9 100644 --- a/src/Tests/RecordingProcessRunner.cs +++ b/src/Tests/RecordingProcessRunner.cs @@ -6,14 +6,16 @@ sealed class RecordingProcessRunner : IProcessRunner { public ProcessStartSettings? Last { get; private set; } public List Starts { get; } = []; - public int Calls { get; private set; } + public int RunCalls { get; private set; } + public int StartCalls { get; private set; } + public int Calls => RunCalls + StartCalls; public int ExitCode { get; set; } public Queue Next { get; } = new(); public int Run(ProcessStartSettings settings) { Last = settings; - Calls++; + RunCalls++; Starts.Add(settings); return ExitCode; } @@ -21,7 +23,7 @@ public int Run(ProcessStartSettings settings) public IChildProcess Start(ProcessStartSettings settings) { Last = settings; - Calls++; + StartCalls++; Starts.Add(settings); return Next.Count > 0 ? Next.Dequeue() : new FakeChildProcess(ExitCode, exited: true); } diff --git a/src/Tests/VersionRangeTests.cs b/src/Tests/VersionRangeTests.cs index 5ee61dd..e73a13d 100644 --- a/src/Tests/VersionRangeTests.cs +++ b/src/Tests/VersionRangeTests.cs @@ -5,19 +5,13 @@ namespace Tests; public class VersionRangeTests { [Fact] - public void Unspecified_version_is_any() + public void Unspecified_version_is_the_same_range_as_star() { - var range = VersionRange.FromInvocation(ArgParser.Parse("dotnetsay")); - Assert.True(range.IsAny); - Assert.False(range.IsExact); - } - - [Fact] - public void Star_version_is_any() - { - var range = VersionRange.FromInvocation(ArgParser.Parse("dotnetsay@*")); - Assert.True(range.IsAny); - Assert.False(range.IsExact); + var bare = VersionRange.FromInvocation(ArgParser.Parse("dotnetsay")); + var star = VersionRange.FromInvocation(ArgParser.Parse("dotnetsay@*")); + Assert.Equal(star, bare); + Assert.True(bare.IsAny); + Assert.False(bare.IsExact); } [Theory] @@ -36,11 +30,67 @@ public void At_version_and_version_option_are_exact(params string[] args) [InlineData("dotnetsay")] [InlineData("dotnetsay@*")] [InlineData("dotnetsay@*-*")] + [InlineData("dotnetsay@1.*")] + [InlineData("dotnetsay@1.1.*")] [InlineData("dotnetsay", "--version", "*")] + [InlineData("dotnetsay", "--version", "1.*")] [InlineData("dotnetsay", "--version", "[1.0,2.0)")] public void Floating_identities_are_not_exact(params string[] args) { var range = VersionRange.FromInvocation(ArgParser.Parse(args)); Assert.False(range.IsExact); } + + [Fact] + public void Major_star_is_half_open_next_major() + { + var range = VersionRange.FromInvocation(ArgParser.Parse("dotnetsay@1.*")); + Assert.False(range.IsExact); + Assert.False(range.IsAny); + Assert.Equal(new PackageVersion(1, 0, 0, null), range.Min); + Assert.True(range.IncludeMin); + Assert.Equal(new PackageVersion(2, 0, 0, null), range.Max); + Assert.False(range.IncludeMax); + Assert.True(range.Matches(new PackageVersion(1, 0, 0, null))); + Assert.True(range.Matches(new PackageVersion(1, 9, 9, null))); + Assert.False(range.Matches(new PackageVersion(2, 0, 0, null))); + Assert.False(range.Matches(new PackageVersion(0, 9, 0, null))); + Assert.False(range.Matches(new PackageVersion(1, 0, 1, "beta"))); + } + + [Fact] + public void Minor_star_is_half_open_next_minor() + { + var range = VersionRange.FromInvocation(ArgParser.Parse("dotnetsay@1.1.*")); + Assert.Equal(new PackageVersion(1, 1, 0, null), range.Min); + Assert.True(range.IncludeMin); + Assert.Equal(new PackageVersion(1, 2, 0, null), range.Max); + Assert.False(range.IncludeMax); + Assert.True(range.Matches(new PackageVersion(1, 1, 0, null))); + Assert.True(range.Matches(new PackageVersion(1, 1, 99, null))); + Assert.False(range.Matches(new PackageVersion(1, 2, 0, null))); + Assert.False(range.Matches(new PackageVersion(1, 0, 9, null))); + } + + [Fact] + public void Patch_star_is_half_open_next_patch() + { + var range = VersionRange.FromInvocation(ArgParser.Parse("dotnetsay@1.1.1.*")); + Assert.Equal(new PackageVersion(1, 1, 1, null), range.Min); + Assert.Equal(new PackageVersion(1, 1, 2, null), range.Max); + Assert.False(range.IncludeMax); + Assert.True(range.Matches(new PackageVersion(1, 1, 1, null))); + Assert.False(range.Matches(new PackageVersion(1, 1, 2, null))); + Assert.False(range.Matches(new PackageVersion(1, 1, 0, null))); + } + + [Theory] + [InlineData("dotnetsay@1.1")] + [InlineData("dotnetsay@1.1.0")] + public void Two_and_three_part_versions_without_star_stay_exact(params string[] args) + { + var range = VersionRange.FromInvocation(ArgParser.Parse(args)); + Assert.True(range.IsExact); + Assert.Equal(new PackageVersion(1, 1, 0, null), range.Min); + } } diff --git a/src/ndx/App.cs b/src/ndx/App.cs index 461c799..813ef33 100644 --- a/src/ndx/App.cs +++ b/src/ndx/App.cs @@ -46,7 +46,7 @@ public static class App ndx --update [VERSION|ci] ndx --version - A floating version (unspecified, @*, @*-*, or a range) stays current: + Unlike dnx, a bare package name is @*. A floating version stays current: ndx watches the feed and restarts the tool when a newer match appears. Options: @@ -105,6 +105,7 @@ public static async Task RunAsync(string[] args, NdxHost? host = null, Canc var store = new ToolPackageStore(feed, host.StoreDirectory, log, muxer, host.RuntimeIdentifier); var command = await store.GetAsync(invocation, sources, cancellationToken).ConfigureAwait(false); var range = VersionRange.FromInvocation(invocation); + // Bare, @*, @1.*, and ranges stay current. Exact pins are one-shot (like dnx). if (!range.IsExact) { return await Evergreen.RunAsync( diff --git a/src/ndx/PackageVersion.cs b/src/ndx/PackageVersion.cs index cadd0d6..b0f169c 100644 --- a/src/ndx/PackageVersion.cs +++ b/src/ndx/PackageVersion.cs @@ -126,6 +126,9 @@ public static bool TryParse(string? value, out VersionRange range) return true; } + if (TryParseStarFloating(text, out range)) + return true; + if (text[0] is '[' or '(' && text[^1] is ']' or ')') { var includeMin = text[0] == '['; @@ -172,6 +175,45 @@ public static bool TryParse(string? value, out VersionRange range) return false; } + /// + /// GitHub Actions-style remaining-component float: 1.* is + /// [1.0.0, 2.0.0), 1.1.* is [1.1.0, 1.2.0), + /// 1.1.1.* is [1.1.1, 1.1.2). Bare 1.1 stays exact. + /// + static bool TryParseStarFloating(string text, out VersionRange range) + { + range = default; + if (!text.EndsWith(".*", StringComparison.Ordinal) || text.Length < 3) + return false; + + var parts = text[..^2].Split('.'); + if (parts.Length is < 1 or > 3) + return false; + + var nums = new int[parts.Length]; + for (var i = 0; i < parts.Length; i++) + { + if (!int.TryParse(parts[i], NumberStyles.None, CultureInfo.InvariantCulture, out nums[i])) + return false; + } + + var min = parts.Length switch + { + 1 => new PackageVersion(nums[0], 0, 0, null), + 2 => new PackageVersion(nums[0], nums[1], 0, null), + _ => new PackageVersion(nums[0], nums[1], nums[2], null), + }; + var max = parts.Length switch + { + 1 => new PackageVersion(nums[0] + 1, 0, 0, null), + 2 => new PackageVersion(nums[0], nums[1] + 1, 0, null), + _ => new PackageVersion(nums[0], nums[1], nums[2] + 1, null), + }; + + range = new VersionRange(min, true, max, false, false); + return true; + } + public static VersionRange FromInvocation(Invocation invocation) { if (invocation.Version is { } specified)