One of the great features of .NET Core is that it gives you a simple
way to create and distribute CLI tools via NuGet. You just create a Console
application, add a few entries to the .csproj file, and publish it to
NuGet. Then other people can install it with the dotnet tool install
command. I’ve published a couple of tools this way before, but I’ve just
published another one so I thought I’d take time to describe the steps
involved, as well as a handful of neat NuGet packages that I used along
the way.
nuke-from-orbit
You know how doing dotnet clean (or right-click > Clean solution in Visual Studio)
doesn’t actually delete all the bin and obj directories and the stuff in them?
And you know how sometimes you really, really have to do that, because something
hiding in one of those directories has got itself into such a muddle that the only
way to fix it is to nuke the site from orbit? Yeah. That’s why I wrote this.
The tool is published as RendleLabs.NukeFromOrbit, so you can install it by running:
1dotnet tool install -g RendleLabs.NukeFromOrbitThe -g tells dotnet to install the tool globally so you can use it from anywhere.
This should output:
1You can invoke the tool using the following command: nuke-from-orbit
2Tool 'rendlelabs.nukefromorbit' (version '1.0.3') was successfully installed.Which helpfully tells you that you can now call it as nuke-from-orbit. If you run it
with the --help argument, it will helpfully tell you how to use it:
1NukeFromOrbit:
2 Dust off and nuke bin and obj directories from orbit. It's the only way to be sure.
3
4Usage:
5 NukeFromOrbit [options] [<workingDirectory>]
6
7Arguments:
8 <workingDirectory> [default: D:\blog]
9
10Options:
11 -y, --yes Don't ask for confirmation, just nuke it. [default: False]
12 -n, --dry-run List items that will be nuked but don't nuke them. [default: False]
13 --version Show version information
14 -?, -h, --help Show help and usage informationThat little feature comes from a NuGet package called System.CommandLine, which is pre-release at the moment but dead useful for this kind of thing.
System.CommandLine
This package provides a command line argument parser which makes it easy to handle
flags, options and arguments, and even sub-commands if you’re building something big.
You just create a Command, assign a Handler to it, and then Invoke it with
the args parameter from your Main method.
For nuke-from-orbit the command looks like this:
1var command = new RootCommand
2{
3 new Option<bool>(new[]{"--yes", "-y"}, () => false,
4 "Don't ask for confirmation, just nuke it."),
5 new Option<bool>(new[]{"--dry-run", "-n"}, () => false,
6 "List items that will be nuked but don't nuke them."),
7 new Argument<string>("workingDirectory", () => Environment.CurrentDirectory)
8};
9
10command.Description = "Dust off and nuke bin and obj directories from orbit. It's the only way to be sure.";It’s using C#’s list initializer syntax so you can add as many Options and Arguments
as you need. The Arguments will be positional if there’s more than one, in the order
that they’re added in the list. The Options can be anywhere, before or after the
Arguments.
For each option, you can specify an array of aliases, like --yes or -y. You can
also specify a default value for both Options and Arguments by using a Func<T>
that returns the default; if you specify this, the Option or Argument will be
optional.
The Description property is output in the Help, just under the name of the command.
To add a handler, we just create a CommandHandler using an Action or Func, like this:
1command.Handler = CommandHandler.Create<bool, bool, string>(
2 async (yes, dryRun, workingDirectory) =>
3 {
4 await Nuke(yes, dryRun, workingDirectory);
5 }
6);System.CommandLine uses the names of the parameters on the delegate to match to the
Option and Argument names; Options with hyphens are converted to camelCase so
--dry-run matches dryRun.
Note: at the time of writing System.CommandLine is still in preview, so you’ll need to enable pre-release packages to use it in your project.
Finding bin and obj folders
Have you ever written an application that worked with files or directories and muttered
dark curses at whichever API designer decided that the File and Directory classes
should be static, so they’re impossible to test against? You are not alone.
Rejoice! for there is a NuGet package that fixes this, too:
System.IO.Abstractions provides
interfaces for all the static System.IO types, along with default implementations,
so you can write proper tests without contaminating the file system of whatever
machine the tests run on.
1foreach (var directory in _fileSystem.Directory.EnumerateDirectories(currentDirectory))
2{
3 var name = _fileSystem.Path.GetFileName(directory);
4 if (name is null) continue;It even allows you to mock Path so you can change things like DirectorySeparatorChar
and simulate Linux or Windows in your tests.
Here’s an example using NSubstitute
to mock the IDirectory interface:
1 private static IDirectory FakeDirectory(string[] directories, IPath path, string[] files)
2 {
3 var directory = Substitute.For<IDirectory>();
4
5 directory.EnumerateDirectories(Arg.Any<string>())
6 .Returns(c => directories.Where(d => path.GetDirectoryName(d) == c.Arg<string>()).Distinct());
7
8 directory.EnumerateFiles(Arg.Any<string>())
9 .Returns(c => files.Where(f => path.GetDirectoryName(f) == c.Arg<string>()).Distinct());
10
11 directory.Exists(Arg.Any<string>()).Returns(true);
12
13 return directory;
14 }The other really nice thing about System.IO.Abstractions is that you can add extension
methods to the interfaces, like this one to detect whether the file system is case
sensitive:
1public static class FileSystemExtensions
2{
3 public static bool IsCaseSensitive(this IFileSystem fileSystem, string directory)
4 {
5 if (!fileSystem.Directory.Exists(directory))
6 throw new InvalidOperationException("Directory does not exist.");
7
8 if (directory.Where(char.IsLetter).Any(char.IsLower))
9 {
10 if (fileSystem.Directory.Exists(directory.ToUpper())) return false;
11 }
12 else
13 {
14 if (fileSystem.Directory.Exists(directory.ToLower())) return false;
15 }
16
17 return true;
18 }
19}Not deleting Git-controlled files
There are bad people in the world. People who hate puppies, eat your last Jaffa Cake, and
add files in their bin and obj directories to source control. I’m sure they have very
good reasons for doing that, but they don’t. Let’s humour them anyway, and check whether
and bin or obj files are in Git before deleting them.
From the command line, you can get a complete list of files that are version controlled
using the git ls-files command. So we can run that, find any entries with /bin/ or
/obj/ in them, and add them to some kind of protected list.
I don’t know if you’ve ever written the code to run a process and capture its output, but it’s more complicated than it should be. You have to remember to redirect standard output, and not use ShellExecute (or is it use ShellExecute? I can never remember), and then handle the events and everything. It’s just annoying.
Enter another great NuGet package: CliWrap.
This package wraps all that ProcessInfo and Process.Start business up in a really
well-designed fluent API and takes care of remembering the hard stuff for you.
Here’s a call to git ls-files using CliWrap:
1private async Task<HashSet<string>> ListFileAsync()
2{
3 var set = new HashSet<string>(_stringComparer);
4
5 var result = await Cli.Wrap("git")
6 .WithArguments("ls-files")
7 .WithWorkingDirectory(_workingDirectory)
8 .WithValidation(CommandResultValidation.None)
9 .WithStandardOutputPipe(PipeTarget.ToDelegate(line =>
10 {
11 line = _fileSystem.Path.Normalize(line);
12
13 if (line.Contains(Bin) || line.Contains(Obj))
14 {
15 set.Add(Path.Combine(_workingDirectory, line));
16 }
17 }))
18 .ExecuteAsync();
19
20 if (result.ExitCode != 0)
21 {
22 set.Clear();
23 }
24 return set;
25}That WithStandardOutputPipe call works with PipeTarget objects: there are
implementations built-in for delegates, streams and StringBuilder and you
can implement your own, too.
Note the .WithValidation(CommandResultValidation.None) line: the default behaviour
for CliWrap is to throw an exception if the command returns a non-zero exit code, and
we’re expecting that might happen so we want to avoid an exception. With
CommandValidation.None, if git isn’t installed, or the working directory is not a
git repo, then result.ExitCode will be non-zero and we can just return an
empty HashSet<string>.
Making it a tool
A dotnet tool is just a NuGet package with a bit of extra metadata specified in the
project file:
1 <PropertyGroup>
2 <PackAsTool>true</PackAsTool>
3 <ToolCommandName>nuke-from-orbit</ToolCommandName>
4 <PackageId>RendleLabs.NukeFromOrbit</PackageId>
5 </PropertyGroup>The PackAsTool property sets the tool flag in the NuGet package, and the
ToolCommandName is the name used to invoke the command. I’d normally go for something
a bit shorter, but I’m generally so annoyed by the time I need this one that I want
the satisfaction of typing nuke-from-orbit and smashing the Enter key.
Publishing to NuGet
There are a bunch of ways to publish NuGet packages these days, but I wanted to try
GitHub Actions. This involves adding a workflow file to your git repo which is, of
course, YAML. This is the workflow for NukeFromOrbit, in a file at
.github/workflows/dotnet-core.yml:
1name: .NET Core
2
3on:
4 push:
5 branches: [ master ]
6 pull_request:
7 branches: [ master ]
8
9jobs:
10 build:
11
12 runs-on: windows-latest
13
14 steps:
15 - uses: actions/checkout@v2
16 with:
17 fetch-depth: 0
18 - name: Setup .NET Core
19 uses: actions/setup-dotnet@v1
20 with:
21 dotnet-version: 3.1.301
22 - name: Install dependencies
23 run: dotnet restore
24 - name: Nerdbank.GitVersioning
25 id: nbgv
26 uses: dotnet/nbgv@v0.3.1
27 - name: Build
28 run: dotnet build --configuration Release --no-restore
29 - name: Test
30 run: dotnet test --configuration Release --no-build --verbosity normal test\NukeFromOrbit.Tests
31 - name: Pack
32 run: dotnet pack --configuration Release --no-build -p:PackageVersion=${{ steps.nbgv.outputs.SimpleVersion }} --output . src\NukeFromOrbit\NukeFromOrbit.csproj
33 - name: Push
34 run: dotnet nuget push *.nupkg -s https://api.nuget.org/v3/index.json -k ${{ secrets.NUGET_API_KEY }} --skip-duplicateThis is mostly the default .NET Core workflow template, with a couple of additions.
NerdBank.GitVersioning
This step takes care of assigning version numbers to assemblies, using the “height” of
the git history (i.e. the number of commits) as the revision number, and taking the major
and minor version numbers from a version.json file in the root of the repo.
1{
2 "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",
3 "version": "1.0",
4 "assemblyVersion": {
5 "precision": "revision"
6 }
7}The assemblyVersion property tells NBGV to include the revision in the
AssemblyVersion value; without this the default is just {version}.0.0. I want it
to include the revision because System.CommandLine automatically adds a --version
flag that prints the AssemblyVersion value.
The workflow uses an output variable from the NBGV step to set the package version
in the dotnet pack step.
NerdBank.GitVersioning is in the GitHub Actions Marketplace, pulled in with the
uses: dotnet/nbgv@v0.3.1 line.
NUGET_API_KEY
GitHub Actions provides a Secrets store where you can put API keys, passwords and the like. You can store Secrets at the repo level, or at the organisation level, where you can specify which repos the secrets are available to.
That’s it!
Hopefully this post has shown you how easy it is to publish your own dotnet tool,
from dotnet new console to CI/CD. If you have any simple tools lying around then
why not share them? And if you do, be sure to drop by
github.com/natemcmaster/dotnet-tools
and submit a PR to let people know about it.
The source code for the tool, including the workflow stuff, is at github.com/RendleLabs/NukeFromOrbit. Let me know if you like it, or open an issue or submit a PR if it needs fixing.