Part 02: The CLI-First Thesis — Testable by Construction
"Interactive tools are harder to test, harder to reproduce, and harder to debug. A CLI command that fails has a clear exit code, a clear error message, and a clear invocation to reproduce." —
HomeLab/doc/PHILOSOPHY.md
Why
Part 01 ended with seven file formats, six drifts, and two work-weeks a year of operational tax. The temptation, when faced with that mess, is to write a tool. And the temptation, when writing a tool, is to give it a UI. A web dashboard. An interactive wizard. A TUI with arrow-key navigation. A "first-time setup" flow with checkboxes.
This is the wrong instinct. It is the first instinct, and it is wrong every time, and the reason it is wrong is testability.
Every UI is a test surface that does not exist. You cannot put homelab init in a CI pipeline if homelab init opens a browser tab. You cannot retry homelab vos up after a flake if the retry needs a human to click a button. You cannot diff the output of homelab compose deploy against a golden file if the output is rendered as a progress bar that flashes for 12 seconds and disappears.
The thesis of this part is brutally simple: every HomeLab action is a CLI command, and the CLI is the only test surface that exists. There is no second mode. There is no "but what if you want a UI". The CLI is the API. The CLI is the test harness. The CLI is the documentation. The CLI is the contract.
This sounds extreme until you internalise the consequence. The consequence is that the test for a feature is the same as the demo for the feature. The README is the test. The CI pipeline is the test. The man page is the test. There is no second artefact to maintain.
The shape
A CLI-first tool has exactly one entry point. That entry point parses arguments, dispatches to the lib, prints the result, and exits with an integer. Nothing else.
// HomeLab.Cli/Program.cs (the entire CLI program — yes, really)
using FrenchExDev.Net.HomeLab;
using FrenchExDev.Net.HomeLab.Cli;
using Microsoft.Extensions.DependencyInjection;
using System.CommandLine;
var services = new ServiceCollection()
.AddHomeLab() // generated by [Injectable] source generator
.AddHomeLabCli() // CLI-specific bindings (console, logger, exit codes)
.BuildServiceProvider();
var rootCommand = services.GetRequiredService<HomeLabRootCommand>().Build();
return await rootCommand.InvokeAsync(args);// HomeLab.Cli/Program.cs (the entire CLI program — yes, really)
using FrenchExDev.Net.HomeLab;
using FrenchExDev.Net.HomeLab.Cli;
using Microsoft.Extensions.DependencyInjection;
using System.CommandLine;
var services = new ServiceCollection()
.AddHomeLab() // generated by [Injectable] source generator
.AddHomeLabCli() // CLI-specific bindings (console, logger, exit codes)
.BuildServiceProvider();
var rootCommand = services.GetRequiredService<HomeLabRootCommand>().Build();
return await rootCommand.InvokeAsync(args);Eleven lines. The lib is loaded. The CLI is built. The command is invoked. The exit code is returned. There is no other entry point. There is no Program.Main overload that takes a Form. There is no ApplicationContext. There is no dotnet homelab serve that opens a port. The only thing this binary does is parse args, dispatch to the lib, and return an integer.
The HomeLabRootCommand is itself trivial:
[Injectable(ServiceLifetime.Singleton)]
public sealed class HomeLabRootCommand
{
private readonly IEnumerable<IHomeLabVerbCommand> _verbs;
public HomeLabRootCommand(IEnumerable<IHomeLabVerbCommand> verbs)
=> _verbs = verbs;
public RootCommand Build()
{
var root = new RootCommand("HomeLab — local infrastructure orchestrator");
foreach (var verb in _verbs)
root.Add(verb.Build());
return root;
}
}[Injectable(ServiceLifetime.Singleton)]
public sealed class HomeLabRootCommand
{
private readonly IEnumerable<IHomeLabVerbCommand> _verbs;
public HomeLabRootCommand(IEnumerable<IHomeLabVerbCommand> verbs)
=> _verbs = verbs;
public RootCommand Build()
{
var root = new RootCommand("HomeLab — local infrastructure orchestrator");
foreach (var verb in _verbs)
root.Add(verb.Build());
return root;
}
}Every verb (init, validate, packer, box, vos, compose, dns, tls, gitlab, cost, backup) is an IHomeLabVerbCommand. Each one is registered via [Injectable]. The root command discovers them via DI, not via reflection. Adding a new verb means adding a new class with [Injectable] on it — no registry to edit, no dictionary to update, no switch statement to extend.
A verb is itself a thin shell. Here is what homelab vos up looks like:
[Injectable(ServiceLifetime.Singleton)]
public sealed class VosUpCommand : IHomeLabVerbCommand
{
private readonly IHomeLabPipeline _pipeline;
private readonly IHomeLabConsole _console;
public VosUpCommand(IHomeLabPipeline pipeline, IHomeLabConsole console)
{
_pipeline = pipeline;
_console = console;
}
public Command Build()
{
var name = new Argument<string?>("name") { Arity = ArgumentArity.ZeroOrOne };
var config = new Option<FileInfo>("--config", () => new FileInfo("./config-homelab.yaml"));
var cmd = new Command("up", "Boot one or all VMs in the lab");
cmd.AddArgument(name);
cmd.AddOption(config);
cmd.SetHandler(async (string? n, FileInfo c) =>
{
var request = new VosUpRequest(c, MachineName: n);
var result = await _pipeline.RunAsync(request);
_console.Render(result);
Environment.ExitCode = result.IsSuccess ? 0 : 1;
}, name, config);
return cmd;
}
}[Injectable(ServiceLifetime.Singleton)]
public sealed class VosUpCommand : IHomeLabVerbCommand
{
private readonly IHomeLabPipeline _pipeline;
private readonly IHomeLabConsole _console;
public VosUpCommand(IHomeLabPipeline pipeline, IHomeLabConsole console)
{
_pipeline = pipeline;
_console = console;
}
public Command Build()
{
var name = new Argument<string?>("name") { Arity = ArgumentArity.ZeroOrOne };
var config = new Option<FileInfo>("--config", () => new FileInfo("./config-homelab.yaml"));
var cmd = new Command("up", "Boot one or all VMs in the lab");
cmd.AddArgument(name);
cmd.AddOption(config);
cmd.SetHandler(async (string? n, FileInfo c) =>
{
var request = new VosUpRequest(c, MachineName: n);
var result = await _pipeline.RunAsync(request);
_console.Render(result);
Environment.ExitCode = result.IsSuccess ? 0 : 1;
}, name, config);
return cmd;
}
}Twenty-five lines. The verb declares its arguments and options. The handler builds a strongly-typed VosUpRequest, hands it to the pipeline, lets the lib do the work, prints the result, sets the exit code. There is no business logic in the CLI project. This is part of what thin CLI, fat lib means, and we will say it again in Part 03.
The wiring
The CLI is wired into the lib via two IServiceCollection extensions, both generated:
// HomeLab/HomeLabServiceCollectionExtensions.g.cs
// (generated by FrenchExDev.Net.Injectable.SourceGenerator)
public static partial class HomeLabServiceCollectionExtensions
{
public static IServiceCollection AddHomeLab(this IServiceCollection services)
{
services.AddSingleton<IHomeLabPipeline, HomeLabPipeline>();
services.AddSingleton<IHomeLabEventBus, HomeLabEventBus>();
services.AddSingleton<IPluginHost, PluginHost>();
services.AddSingleton<IClock>(SystemClock.Instance);
// ... 200 more lines, one per [Injectable] type discovered in the assembly
return services;
}
}
// HomeLab.Cli/HomeLabCliServiceCollectionExtensions.g.cs
// (also generated)
public static partial class HomeLabCliServiceCollectionExtensions
{
public static IServiceCollection AddHomeLabCli(this IServiceCollection services)
{
services.AddSingleton<HomeLabRootCommand>();
services.AddSingleton<IHomeLabConsole, AnsiHomeLabConsole>();
services.AddSingleton<IHomeLabVerbCommand, InitCommand>();
services.AddSingleton<IHomeLabVerbCommand, ValidateCommand>();
services.AddSingleton<IHomeLabVerbCommand, VosUpCommand>();
// ... one per IHomeLabVerbCommand discovered in the assembly
return services;
}
}// HomeLab/HomeLabServiceCollectionExtensions.g.cs
// (generated by FrenchExDev.Net.Injectable.SourceGenerator)
public static partial class HomeLabServiceCollectionExtensions
{
public static IServiceCollection AddHomeLab(this IServiceCollection services)
{
services.AddSingleton<IHomeLabPipeline, HomeLabPipeline>();
services.AddSingleton<IHomeLabEventBus, HomeLabEventBus>();
services.AddSingleton<IPluginHost, PluginHost>();
services.AddSingleton<IClock>(SystemClock.Instance);
// ... 200 more lines, one per [Injectable] type discovered in the assembly
return services;
}
}
// HomeLab.Cli/HomeLabCliServiceCollectionExtensions.g.cs
// (also generated)
public static partial class HomeLabCliServiceCollectionExtensions
{
public static IServiceCollection AddHomeLabCli(this IServiceCollection services)
{
services.AddSingleton<HomeLabRootCommand>();
services.AddSingleton<IHomeLabConsole, AnsiHomeLabConsole>();
services.AddSingleton<IHomeLabVerbCommand, InitCommand>();
services.AddSingleton<IHomeLabVerbCommand, ValidateCommand>();
services.AddSingleton<IHomeLabVerbCommand, VosUpCommand>();
// ... one per IHomeLabVerbCommand discovered in the assembly
return services;
}
}You never write either of those files. They are emitted by the [Injectable] source generator from the attributes on the types themselves. Adding [Injectable(ServiceLifetime.Singleton)] to a new class is enough to register it. Forgetting to register a service does not compile, because the generator scans the assembly and emits the registration without you having to think about it.
This matters for CLI testing, because the test harness needs the same composition root the production CLI uses. If you have to remember to register your test doubles, your test harness will eventually drift out of sync with production. With [Injectable], the test harness builds the same IServiceCollection and then replaces specific services with fakes. The replacement is explicit; the registration is not.
The test
Here is the test for homelab vos up. Not a unit test of VosUpCommand. The end-to-end test, run through the same Program.Main the user runs:
[Fact]
public async Task vos_up_boots_a_single_vm_and_exits_zero()
{
using var lab = await TestLab.NewAsync(topology: "single");
await lab.WriteConfig(@"
name: ci-vos-up
topology: single
packer: { distro: alpine, version: '3.21', kind: dockerhost }
vos: { box: ci/alpine-3.21-dockerhost, memory: 1024, cpus: 2 }
compose:{ traefik: false, gitlab: false }
");
var result = await lab.Cli("vos", "up", "--config", lab.ConfigPath);
result.ExitCode.Should().Be(0);
result.StdOut.Should().Contain("Booted 1 VM");
lab.VagrantState("ci-vos-up-main-01").Should().Be("running");
var events = lab.RecordedEvents.OfType<VosUpCompleted>().ToList();
events.Should().HaveCount(1);
events[0].MachineName.Should().Be("main-01");
events[0].DurationMs.Should().BeGreaterThan(0);
}[Fact]
public async Task vos_up_boots_a_single_vm_and_exits_zero()
{
using var lab = await TestLab.NewAsync(topology: "single");
await lab.WriteConfig(@"
name: ci-vos-up
topology: single
packer: { distro: alpine, version: '3.21', kind: dockerhost }
vos: { box: ci/alpine-3.21-dockerhost, memory: 1024, cpus: 2 }
compose:{ traefik: false, gitlab: false }
");
var result = await lab.Cli("vos", "up", "--config", lab.ConfigPath);
result.ExitCode.Should().Be(0);
result.StdOut.Should().Contain("Booted 1 VM");
lab.VagrantState("ci-vos-up-main-01").Should().Be("running");
var events = lab.RecordedEvents.OfType<VosUpCompleted>().ToList();
events.Should().HaveCount(1);
events[0].MachineName.Should().Be("main-01");
events[0].DurationMs.Should().BeGreaterThan(0);
}That test invokes the real CLI. It writes a real config file. It runs the real pipeline. It hits the real Vagrant binary (in the CI executor's VM-in-VM, which the TestLab fixture provisions). It asserts on the real exit code, the real stdout, and the real Vagrant state. It also asserts on the events the lib published — because the event bus is a first-class observability surface, not a logging side effect, and we will see it again in Part 09.
The fixture is small:
public sealed class TestLab : IAsyncDisposable
{
public static async Task<TestLab> NewAsync(string topology, [CallerMemberName] string? name = null)
{
var dir = Directory.CreateTempSubdirectory($"homelab-{name}-");
var lab = new TestLab(dir);
await lab.InitAsync(topology);
return lab;
}
public async Task<CliResult> Cli(params string[] args)
{
var psi = new ProcessStartInfo
{
FileName = "dotnet",
ArgumentList = { "run", "--project", "src/HomeLab.Cli", "--", .. args },
WorkingDirectory = _dir.FullName,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
var p = Process.Start(psi)!;
await p.WaitForExitAsync();
return new CliResult(p.ExitCode, await p.StandardOutput.ReadToEndAsync(), await p.StandardError.ReadToEndAsync());
}
// …
}public sealed class TestLab : IAsyncDisposable
{
public static async Task<TestLab> NewAsync(string topology, [CallerMemberName] string? name = null)
{
var dir = Directory.CreateTempSubdirectory($"homelab-{name}-");
var lab = new TestLab(dir);
await lab.InitAsync(topology);
return lab;
}
public async Task<CliResult> Cli(params string[] args)
{
var psi = new ProcessStartInfo
{
FileName = "dotnet",
ArgumentList = { "run", "--project", "src/HomeLab.Cli", "--", .. args },
WorkingDirectory = _dir.FullName,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
var p = Process.Start(psi)!;
await p.WaitForExitAsync();
return new CliResult(p.ExitCode, await p.StandardOutput.ReadToEndAsync(), await p.StandardError.ReadToEndAsync());
}
// …
}In production E2E, that's it. The test is the demo. The demo is the test. The same invocation a user types into a terminal is the same invocation the test runs. There is no second harness to maintain.
For unit tests of individual stages (which we will see in Part 14), we go through the lib without spawning a process — but the verb-level tests always run the CLI. This is intentional. It is the bargain CLI-first makes with you: a slightly slower test (3 seconds instead of 30 milliseconds) in exchange for a guarantee that what the test verifies is exactly what the user runs.
What this gives you that bash doesn't
A bash script returns 0 or non-zero. If you forget set -e, even that lies. A bash script does not have a typed input. A bash script does not have a typed output. A bash script does not publish events. A bash script does not have unit tests. A bash script does not compose. A bash script that calls another bash script that calls a third bash script has no way to surface where the failure happened, because the only thing each script returns is a number. A bash script's "API" is "what arguments did I parse with getopts last time I edited it". A bash script's "tests" are "I ran it on my laptop once and it worked".
CLI-first with System.CommandLine and [Injectable] gives you, for the same surface area:
- Typed arguments validated at parse time, with help text, with default values, with conflict detection
- Typed exit codes that propagate from
Result<T>through the verb handler - A test harness that is the same as the production binary (same composition root, same arguments, same output format)
- Events that the lib publishes via
IHomeLabEventBus, asserted in the test - Composition — one verb can call another via
IHomeLabPipeline, in-process, with shared services and shared events - A single source of truth for "what does this tool do": the verbs in the assembly, discovered by DI, listed by
--help, executed by the same code in production and in test
The bargain is real. It costs more upfront (you write IHomeLabVerbCommand instead of function init { … }). It pays back the moment you onboard a colleague, the moment you run CI, the moment you have to refactor a verb without breaking another verb, the moment you have to reproduce a bug.
A note on UIs
I am not arguing that UIs are bad. I am arguing that the UI cannot be the contract. If you want a TUI on top of HomeLab — a homelab dashboard that watches the event bus and renders progress bars — by all means write one, ship it as a separate binary, subscribe to IHomeLabEventBus, and have a great time. It will be ~300 lines of Spectre.Console. The point is that the TUI consumes the same lib the CLI consumes; it is not a parallel implementation. The lib has one entry point per use case. The CLI exposes those entry points as commands. The TUI, if you write one, exposes those same entry points as keystrokes. The web dashboard, if anyone is foolish enough to write one, exposes those same entry points as HTTP routes. None of those surfaces are the surface. The lib is the surface. Everything else is a presentation layer.
This is the same argument Part 03 makes about thin CLI, fat lib. The CLI is a presentation layer. The TUI is a presentation layer. The web dashboard is a presentation layer. The lib is the only thing that contains business logic. The presentation layers exist to serve the lib to humans (CLI), to keyboard users (TUI), to browsers (web), and to other tools (HTTP, gRPC, MCP, whatever the next decade brings). If your lib is the contract, you can grow new presentation layers without rewriting the core. If your CLI is the lib, you cannot.
Cross-links
- Part 01: The Problem — Why Every Dev Rebuilds the Same Homelab
- Part 03: Thin CLI, Fat Lib — One Verb, One Method Call
- Part 07: The Pipeline — Six Stages, One
Result<T> - Part 09: The Event Bus
- Part 14: The Test Pyramid
- Wrapping Docker — the CLI-first pattern applied to Docker
- Hardening the Test Pipeline — how the test harness collapses