Migrating to CliInvoke 3.0.0

This guide consolidates every breaking change shipping in 3.0.0.

The breaking-change set is final. Section 8 (DI registration split) was added in the late 3.0.0 betas and is the last change to this guide; it covers every change shipping in 3.0.0.

TL;DR

Removed / changed Migrate to
CliRun.UseExternalProcessFactory IProcessInvoker (or DI)
CliRun.UseFilePathResolver IProcessInvoker (or DI)
ExitConfiguration setter Pass ProcessExitConfiguration to the constructor
ProcessInvoker(factory, items) ProcessInvoker(factory, middleware, items)
ProcessInvoker(factory, middleware) ProcessInvoker(factory, middleware, items: null)
ExternalProcess(resolver, targetPath) ExternalProcess(resolver, configuration, exitConfig)
ExternalProcess(config, exitConfig) ExternalProcess(resolver, configuration, exitConfig)
Subclassing ExternalProcess Not supported — the class is sealed
Subclassing ProcessConfigurationBuilder Not supported — the class is sealed
ProcessConfigurationFactory removed Construct ProcessConfiguration directly with init setters
TargetFilePath becomes required [SetsRequiredMembers] on the convenience constructor
ArgumentsList merged into init-only ArgumentList Set ArgumentList property via init setter
Working-directory validation moved into ProcessConfiguration construction Throws on non-existent directory at init time
Builder kept, positioned advanced Argument escaping, UserCredentialSpec and resource-policy callback flows
ProcessConfigurationBuilder init setters delegate where applicable; DI registration unchanged Use builder for advanced scenarios only
AddCliInvoke moved to the CliInvoke package (late beta) Same namespace; chain AddCliInvokeSpecializations() for PowerShell/Cmd middleware

1. CliRun is now stateless

CliRun.UseExternalProcessFactory and CliRun.UseFilePathResolver have been removed along with all backing static state. CliRun is a batteries-included defaults facade: every Run*/FireAndForget call allocates a fresh ProcessInvocationPipeline (and a fresh ExternalProcessFactory with a default FilePathResolver) per call.

Before (v2.x):

CliRun.UseExternalProcessFactory(myFactory);
CliRun.UseFilePathResolver(myResolver);

ProcessResult result = await CliRun.RunAsync("dotnet", "--version");

After (3.0.0):

// For custom factories/resolvers, use IProcessInvoker directly (or DI):
IExternalProcessFactory factory = myFactory;
IProcessInvoker invoker = new ProcessInvoker(factory);

ProcessResult result = await invoker.ExecuteAsync(
    new ProcessConfiguration("dotnet", "--version"));

No [Obsolete] shim or bridge method was added — this is a direct cutover.

2. InvocationContext.Result / .Middleware ownership

No API change. The Result and Middleware properties are owned by specific middleware. The only legitimate mutators are the MiddlewareChain walker, the terminal delegate that bridges the chain to the pipeline, and any propagating middleware that short-circuits the chain. Do not read or write these properties outside those mutators; Result is null until the chain completes.

3. ExitConfiguration is read-only

IExternalProcess.ExitConfiguration and ExternalProcess.ExitConfiguration are now read-only { get; } and supplied at construction. There is no WithExitConfiguration method.

Before (v2.x):

ExternalProcess process = /* ... */;
process.ExitConfiguration = ProcessExitConfiguration.CreateGraceful();

After (3.0.0):

IFilePathResolver resolver = new FilePathResolver();
ExternalProcess process = new ExternalProcess(
    resolver,
    configuration,
    ProcessExitConfiguration.CreateGraceful());

4. ProcessInvoker has two constructors

The two partial overloads were removed.

Removed Use
ProcessInvoker(factory, MiddlewareItems?) ProcessInvoker(factory, Array.Empty<IProcessMiddleware>(), items)
ProcessInvoker(factory, IEnumerable<IProcessMiddleware>) ProcessInvoker(factory, middleware, items: null)

The surviving constructors are ProcessInvoker(IExternalProcessFactory) and ProcessInvoker(IExternalProcessFactory, IEnumerable<IProcessMiddleware>, MiddlewareItems?). DI configure bindings and the PowershellProcessInvoker / CmdProcessInvoker specializations now use the four-argument form with sharedItems: null.

5. ExternalProcess keeps only constructor C

ExternalProcess is now (IFilePathResolver, ProcessConfiguration, ProcessExitConfiguration?). The (IFilePathResolver, string) and (ProcessConfiguration, ProcessExitConfiguration?) constructors were removed.

After (3.0.0):

IFilePathResolver resolver = new FilePathResolver();
using ExternalProcess process = new ExternalProcess(resolver, configuration);

The class is also sealed, so it can no longer be subclassed.

6. ProcessConfigurationBuilder is sealed

ProcessConfigurationBuilder is now sealed. Fluent chaining via the IProcessConfigurationBuilder interface is unaffected. If you subclassed the builder, switch to composing an IProcessConfigurationBuilder instead.

7. ProcessResult equality is symmetric

ProcessResult.Equals(object?) now performs exact runtime-type matching so that a.Equals(b) == b.Equals(a) holds across the ProcessResult and BufferedProcessResult hierarchy. ProcessResult remains unsealed in this release.

Breaking change: PipedProcessResult was removed in 3.0.0. It previously held live StandardOutput/StandardError streams and was disposable. For streaming output, use IExternalProcess, which exposes the live Process with StandardOutput/StandardError streams, stdin, and lifecycle control. The ExecutePipedAsync / RunPipedAsync / CapturePipedResultAsync methods no longer exist.

8. AddCliInvoke ships in the main CliInvoke package (late beta)

AddCliInvoke and its service registrations moved from CliInvoke.Specializations into the main CliInvoke package. The namespace is unchanged (CliInvoke.Extensions), so using CliInvoke.Extensions; call sites keep working.

Before (early 3.0.0 betas):

// CliInvoke.Specializations defined the registration entry point
services.AddCliInvoke(builder => builder.UsePowerShell());

After (3.0.0):

// AddCliInvoke ships in the main CliInvoke package:
services.AddCliInvoke(builder => builder.UsePowerShell())
    .AddCliInvokeSpecializations(); // registers the PowerShell/Cmd/DefaultShell middleware types
  • AddCliInvoke (either overload) registers the core services and the core built-in middleware (LoggingMiddleware, RetryMiddleware).
  • AddCliInvokeSpecializations (CliInvoke.Specializations package) registers only PowerShellMiddleware, CmdMiddleware, DefaultShellMiddleware, and ShellMiddlewareOptions.
  • The two calls are independent and can be chained in either order, but both must use the same ServiceLifetime — middleware lifetimes are matched to the invoker lifetime to avoid captive scoped-in-singleton dependencies.
  • AddCliInvoke(builder => builder.UsePowerShell()) without AddCliInvokeSpecializations() compiles but throws InvalidOperationException ("No service of type ... PowerShellMiddleware ...") when the invoker is first resolved. The same applies to UseCmd() and UseDefaultShell().

9. ProcessConfiguration and ExternalProcess are immutable after construction

In v3, ProcessConfiguration is never mutated after construction. Previously, ExternalProcess.Start() / StartAsync() would rewrite Configuration.TargetFilePath with the runtime-resolved file path. That mutation is gone — the caller's Configuration instance stays unchanged for the lifetime of the process.

Immutability rules

Property v2 v3
ProcessConfiguration.TargetFilePath set init
ExternalProcess.Configuration / IExternalProcess.Configuration set init

Post-construction assignment to any of these is now a compile error.

Before (v2.x):

ProcessConfiguration config = new ProcessConfiguration("dotnet");
config.TargetFilePath = @"C:\resolved\dotnet.exe"; // compiled in v2

ExternalProcess process = new ExternalProcess(resolver, config);
process.Configuration = new ProcessConfiguration("dotnet"); // compiled in v2

After (3.0.0):

// TargetFilePath is init-only — set it at construction:
ProcessConfiguration config = new ProcessConfiguration("dotnet")
{
    TargetFilePath = @"C:\resolved\dotnet.exe"
};

// ExternalProcess.Configuration is init-only — pass via constructor:
ExternalProcess process = new ExternalProcess(resolver, config);

IFilePathResolver dropped from PowerShell specializations

The IFilePathResolver parameter was removed from three PowerShell-related constructors. File resolution is now handled internally by ExternalProcess at start time.

Type v2 constructor v3 constructor
PowershellProcessConfiguration (IFilePathResolver, string, bool) (string, bool)
PowershellProcessInvoker (IFilePathResolver, IExternalProcessFactory) (IExternalProcessFactory)
PowerShellMiddleware (IFilePathResolver) (ShellMiddlewareOptions?)

Obtaining the resolved file path

The resolved file path is no longer written back to Configuration.TargetFilePath. Instead, use the ExecutedFilePath property on the result objects:

  • ProcessResult.ExecutedFilePath — the resolved path that was actually executed.
  • BufferedProcessResult.ExecutedFilePath — same, for buffered results.
IFilePathResolver resolver = new FilePathResolver();
ExternalProcess process = new ExternalProcess(resolver, new ProcessConfiguration("dotnet"));

process.Start();
BufferedProcessResult result = await process.CaptureBufferedResultAsync(CancellationToken.None);

string resolvedPath = result.ExecutedFilePath;
// e.g. "C:\Program Files\dotnet\dotnet.exe"

// Configuration.TargetFilePath is still "dotnet" (unchanged):
string originalPath = process.Configuration.TargetFilePath;
// "dotnet"

Walkthroughs

v2-style code is defined in GLOSSARY.md as code that uses prior-major-version APIs or defaults to v3-advanced construction styles.

Shared Construction (v3 default)

In v3, ProcessConfiguration is directly constructible via init setters with a required TargetFilePath. A convenience constructor marked [SetsRequiredMembers] covers the common case. The builder is reserved for advanced scenarios (argument escaping, UserCredentialSpec, resource-policy callback flows).

Before (v2-style):

// v2: ProcessConfigurationFactory or mutable TargetFilePath
ProcessConfiguration config = new ProcessConfiguration("dotnet")
{
    TargetFilePath = "dotnet",
    Arguments = "--version"
};

After (v3):

// v3: convenience constructor — TargetFilePath is required
ProcessConfiguration config = new ProcessConfiguration("dotnet", "--version");

// v3: object initializer — TargetFilePath is required via init setter
ProcessConfiguration config = new ProcessConfiguration
{
    TargetFilePath = "dotnet",
    Arguments = "--version"
};

// v3: ArgumentList replaces the old ArgumentsList property
ProcessConfiguration config = new ProcessConfiguration("dotnet")
{
    ArgumentList = ["--list-sdks", "--verbosity", "minimal"]
};

// v3: working-directory validation throws at construction time
ProcessConfiguration config = new ProcessConfiguration("dotnet", "--version")
{
    WorkingDirectoryPath = "/nonexistent" // throws DirectoryNotFoundException
};

CliRun Static-Call Users

Before (v2.x):

// v2: CliRun with factory/resolver configuration
CliRun.UseExternalProcessFactory(myFactory);
CliRun.UseFilePathResolver(myResolver);
ProcessResult result = await CliRun.RunAsync("dotnet", "--version");

After (v3):

// v3: CliRun is stateless — use IProcessInvoker for custom factories
IProcessInvoker invoker = new ProcessInvoker(myFactory);
ProcessResult result = await invoker.ExecuteAsync(
    new ProcessConfiguration("dotnet", "--version"));

IProcessInvoker Users

Before (v2.x):

// v2: ProcessInvoker with two-arg constructor
ProcessInvoker invoker = new ProcessInvoker(factory, middleware);
BufferedProcessResult result = await invoker.ExecuteBufferedAsync(
    new ProcessConfiguration("dotnet", "--version"));

After (v3):

// v3: ProcessInvoker requires three arguments
ProcessInvoker invoker = new ProcessInvoker(factory, middleware, null);
BufferedProcessResult result = await invoker.ExecuteBufferedAsync(
    new ProcessConfiguration("dotnet", "--version"));

IExternalProcess Users

Before (v2.x):

// v2: ExternalProcess with two-arg constructor and mutable ExitConfiguration
IFilePathResolver resolver = new FilePathResolver();
ExternalProcess process = new ExternalProcess(resolver, "dotnet");
process.ExitConfiguration = ProcessExitConfiguration.CreateGraceful();
await process.StartAsync(CancellationToken.None);
await process.WaitForExitOrTimeoutAsync(CancellationToken.None);

After (v3):

// v3: ExternalProcess is sealed, constructor C only, ExitConfiguration at construction
IFilePathResolver resolver = new FilePathResolver();
using ExternalProcess process = new ExternalProcess(
    resolver,
    new ProcessConfiguration("dotnet", "--version"),
    ProcessExitConfiguration.CreateGraceful());
await process.StartAsync(CancellationToken.None);
await process.WaitForExitOrTimeoutAsync(CancellationToken.None);

Need more detail?