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
48 changes: 36 additions & 12 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

<!-- #content -->
`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`
Expand Down Expand Up @@ -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`:

Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/Tests/ArgParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
52 changes: 25 additions & 27 deletions src/Tests/EvergreenTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,21 @@ public class EvergreenTests : IClassFixture<HelloToolFeed>

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]
Expand All @@ -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);
Expand All @@ -79,23 +71,26 @@ 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);

second.Exit(0);
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();
Expand All @@ -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);

Expand Down Expand Up @@ -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"));
Expand Down
8 changes: 5 additions & 3 deletions src/Tests/RecordingProcessRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,24 @@ sealed class RecordingProcessRunner : IProcessRunner
{
public ProcessStartSettings? Last { get; private set; }
public List<ProcessStartSettings> 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<IChildProcess> Next { get; } = new();

public int Run(ProcessStartSettings settings)
{
Last = settings;
Calls++;
RunCalls++;
Starts.Add(settings);
return ExitCode;
}

public IChildProcess Start(ProcessStartSettings settings)
{
Last = settings;
Calls++;
StartCalls++;
Starts.Add(settings);
return Next.Count > 0 ? Next.Dequeue() : new FakeChildProcess(ExitCode, exited: true);
}
Expand Down
74 changes: 62 additions & 12 deletions src/Tests/VersionRangeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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);
}
}
3 changes: 2 additions & 1 deletion src/ndx/App.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -105,6 +105,7 @@ public static async Task<int> 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(
Expand Down
42 changes: 42 additions & 0 deletions src/ndx/PackageVersion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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] == '[';
Expand Down Expand Up @@ -172,6 +175,45 @@ public static bool TryParse(string? value, out VersionRange range)
return false;
}

/// <summary>
/// GitHub Actions-style remaining-component float: <c>1.*</c> is
/// <c>[1.0.0, 2.0.0)</c>, <c>1.1.*</c> is <c>[1.1.0, 1.2.0)</c>,
/// <c>1.1.1.*</c> is <c>[1.1.1, 1.1.2)</c>. Bare <c>1.1</c> stays exact.
/// </summary>
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)
Expand Down
Loading