diff --git a/.editorconfig b/.editorconfig
index 497d0e8..41a8005 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -595,8 +595,14 @@ resharper_xmldoc_space_before_self_closing = true
resharper_xmldoc_wrap_tags_and_pi = false
resharper_xmldoc_wrap_text = true
+
[{*.har,*.inputactions,*.jsb2,*.jsb3,*.json,.babelrc,.eslintrc,.stylelintrc,bowerrc,jest.config}]
indent_size = 2
+# YAML files (match common ecosystem conventions)
+[*.{yml,yaml}]
+indent_size = 2
+tab_width = 2
+
[*.{appxmanifest,asax,ascx,aspx,axaml,build,c,c++,cc,cginc,compute,cp,cpp,cs,cshtml,cu,cuh,cxx,dtd,fs,fsi,fsscript,fsx,fx,fxh,h,hh,hlsl,hlsli,hlslinc,hpp,hxx,inc,inl,ino,ipp,master,ml,mli,mpp,mq4,mq5,mqh,nuspec,paml,razor,resw,resx,shader,skin,tpp,usf,ush,vb,xaml,xamlx,xoml,xsd}]
tab_width = 4
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e08d132
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,22 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Restore
+ run: dotnet restore
+
+ - name: Test
+ run: dotnet test ./tests/Keystone.Cli.UnitTests/Keystone.Cli.UnitTests.csproj -c Release
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..852ca3d
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,153 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*.*.*"
+
+jobs:
+ validate:
+ name: Validate
+ runs-on: ubuntu-latest
+
+ outputs:
+ version: ${{ steps.v.outputs.version }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Extract version from tag
+ id: v
+ shell: bash
+ run: |
+ echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
+
+ - name: Validate csproj version matches tag
+ shell: bash
+ run: |
+ CS_VERSION="$(sed -n 's:.*\(.*\).*:\1:p' ./src/Keystone.Cli/Keystone.Cli.csproj | head -n 1)"
+ if [[ -z "$CS_VERSION" ]]; then
+ echo "ERROR: Could not read from ./src/Keystone.Cli/Keystone.Cli.csproj" >&2
+ exit 1
+ fi
+
+ if [[ "$CS_VERSION" != "${{ steps.v.outputs.version }}" ]]; then
+ echo "ERROR: csproj ($CS_VERSION) does not match tag (v${{ steps.v.outputs.version }})" >&2
+ exit 1
+ fi
+
+ - name: Run unit tests (gate release)
+ run: dotnet test ./tests/Keystone.Cli.UnitTests/Keystone.Cli.UnitTests.csproj -c Release
+
+ build-assets:
+ name: Build assets (${{ matrix.rid }})
+ needs: validate
+ runs-on: ${{ matrix.runner }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - runner: macos-latest
+ rid: osx-arm64
+ - runner: macos-latest
+ rid: osx-x64
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Publish (${{ matrix.rid }})
+ run: dotnet publish ./src/Keystone.Cli/Keystone.Cli.csproj -c Release -r ${{ matrix.rid }}
+
+ - name: Package tarball (${{ matrix.rid }})
+ shell: bash
+ run: bash ./scripts/package-release.sh "${{ needs.validate.outputs.version }}" "${{ matrix.rid }}"
+
+ - name: Upload tarball artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: dist-${{ matrix.rid }}
+ path: artifacts/release/keystone-cli_${{ needs.validate.outputs.version }}_${{ matrix.rid }}.tar.gz
+ if-no-files-found: error
+
+ publish-release:
+ name: Publish Release
+ needs: [validate, build-assets]
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: write
+
+ steps:
+ - name: Download all tarballs
+ uses: actions/download-artifact@v4
+ with:
+ path: dist
+
+ - name: Generate checksums.txt
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ echo "Release assets:"
+ files=$(find dist -maxdepth 3 -type f -name '*.tar.gz' -print)
+ if [[ -z "$files" ]]; then
+ echo "ERROR: No .tar.gz files found under dist/" >&2
+ exit 1
+ fi
+
+ printf "%s\n" $files | sort
+
+ sha256sum $files | sort > checksums.txt
+
+ echo ""
+ echo "checksums.txt:"
+ cat checksums.txt
+
+ - name: Prepare release notes
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ cat > release-body.md <<'EOF'
+ Automated release for ${GITHUB_REF_NAME}.
+
+ SHA256 checksums:
+ ```
+ EOF
+
+ cat checksums.txt >> release-body.md
+
+ cat >> release-body.md <<'EOF'
+ ```
+ EOF
+
+ echo ""
+ echo "release-body.md:"
+ cat release-body.md
+
+ - name: Create GitHub Release and upload assets
+ uses: softprops/action-gh-release@v2
+ with:
+ name: "keystone-cli v${{ needs.validate.outputs.version }}"
+ body_path: release-body.md
+ files: |
+ dist/**/keystone-cli_${{ needs.validate.outputs.version }}_*.tar.gz
+ checksums.txt
+ release-body.md
diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml
new file mode 100644
index 0000000..b29c48e
--- /dev/null
+++ b/.github/workflows/tag-release.yml
@@ -0,0 +1,54 @@
+name: Tag release
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ tag:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # required to create/push tags reliably
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Read version from csproj
+ id: v
+ shell: bash
+ run: |
+ VERSION="$(sed -n 's:.*\(.*\).*:\1:p' ./src/Keystone.Cli/Keystone.Cli.csproj | head -n 1)"
+ if [[ -z "$VERSION" ]]; then
+ echo "ERROR: Could not read from ./src/Keystone.Cli/Keystone.Cli.csproj" >&2
+ exit 1
+ fi
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+
+ - name: Run unit tests (gate tagging)
+ run: dotnet test ./tests/Keystone.Cli.UnitTests/Keystone.Cli.UnitTests.csproj -c Release
+
+ - name: Ensure tag does not already exist
+ shell: bash
+ run: |
+ TAG="v${{ steps.v.outputs.version }}"
+ if git rev-parse "$TAG" >/dev/null 2>&1; then
+ echo "ERROR: Tag already exists: $TAG" >&2
+ exit 1
+ fi
+
+ - name: Create and push tag
+ shell: bash
+ run: |
+ TAG="v${{ steps.v.outputs.version }}"
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git tag -a "$TAG" -m "keystone-cli $TAG"
+ git push origin "$TAG"
+
+ - name: Summary
+ run: echo "Pushed tag v${{ steps.v.outputs.version }}. This will trigger the Release workflow."
diff --git a/README.md b/README.md
index e6e45f9..f35046e 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,9 @@
A command-line interface for Keystone.
+- 📦 [Releases](https://github.com/Knight-Owl-Dev/keystone-cli/releases) — binaries & checksums
+- 📄 [License & Notices](NOTICE.md)
+
This CLI is designed to operate alongside official Keystone templates, which define the structure and build behavior for
publishing books and documents. It is part of the broader Keystone ecosystem, which includes:
@@ -12,7 +15,28 @@ publishing books and documents. It is part of the broader Keystone ecosystem, wh
- [keystone-hello-world](https://github.com/knight-owl-dev/keystone-hello-world) – a "Hello World" sample project
based on the `core-slim` template, demonstrating Keystone capabilities with sample content
-For license details and third-party references, see [NOTICE.md](NOTICE.md).
+## Installation (macOS)
+
+Keystone CLI is distributed via Homebrew.
+
+First, add the Knight Owl Homebrew tap:
+
+```bash
+brew tap Knight-Owl-Dev/tap
+```
+
+Then install the CLI:
+
+```bash
+brew install keystone-cli
+```
+
+After installation, verify that everything is working:
+
+```bash
+keystone-cli info
+man keystone-cli
+```
## Project Structure
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
new file mode 100644
index 0000000..11ce726
--- /dev/null
+++ b/docs/RELEASE.md
@@ -0,0 +1,128 @@
+# How To Release
+
+Keystone CLI uses **tag-driven releases**.
+
+- Pull requests must have passing unit tests before merge.
+- Releases are created **only** when a version tag (e.g., `v0.1.0`) is pushed.
+- A GitHub Actions workflow builds and publishes release artifacts.
+
+---
+
+## Prerequisites
+
+- You have push access to the [Knight-Owl-Dev/keystone-cli](https://github.com/knight-owl-dev/keystone-cli) repository.
+- Unit tests are green on `main`.
+- Homebrew tap repository [Knight-Owl-Dev/homebrew-tap](https://github.com/knight-owl-dev/homebrew-tap) is available for
+ formula updates.
+
+---
+
+## Release flows
+
+Keystone CLI supports two release flows:
+
+1. **Automated GitHub release (preferred)** — push-button, reproducible, and audited
+2. **Manual release (backup)** — for emergencies or CI outages
+
+---
+
+## Automated release (via GitHub Actions)
+
+This is the **recommended** way to publish a new version.
+
+### Prerequisites
+
+- `` in `Keystone.Cli.csproj` is updated to the intended release version
+- All changes are merged into `main`
+- Unit tests are passing
+
+### Steps
+
+1. Update the project version:
+
+ ```xml
+ X.Y.Z
+ ```
+
+ This value **must match** the git tag that will be created (`vX.Y.Z`).
+
+2. Push changes to `main` (via PR as usual).
+
+3. Trigger the **Tag release** workflow in GitHub:
+
+ - Go to **Actions → Tag release**
+ - Click **Run workflow**
+
+ The workflow will:
+
+ - read `` from `Keystone.Cli.csproj`
+ - run unit tests (release gate)
+ - create and push the annotated tag `vX.Y.Z`
+
+4. The tag push automatically triggers the **Release** workflow.
+
+ That workflow will:
+
+ - validate `` matches the tag
+ - build and publish binaries (matrix-based by RID)
+ - package `.tar.gz` release assets
+ - compute SHA-256 checksums
+ - generate release notes
+ - create a GitHub Release and upload all assets
+
+5. (Optional) Update the Homebrew formula in `Knight-Owl-Dev/homebrew-tap`:
+
+ - Update the version and release URLs to `vX.Y.Z`
+ - Replace the `sha256` values using `checksums.txt` from the release
+ - Commit and push (or merge the automated PR, if enabled)
+
+6. Validate installation on macOS:
+
+ ```bash
+ brew update
+ brew install keystone-cli
+ keystone-cli info
+ man keystone-cli
+ ```
+
+---
+
+## Manual release (backup / emergency)
+
+Use this flow **only** if GitHub Actions is unavailable or requires debugging.
+
+### Steps
+
+1. Sync and validate locally:
+
+ ```bash
+ git checkout main
+ git pull
+ dotnet test ./tests/Keystone.Cli.UnitTests/Keystone.Cli.UnitTests.csproj -c Release
+ ```
+
+2. Ensure `` in `Keystone.Cli.csproj` matches the intended release.
+
+3. Build and package release assets locally:
+
+ ```bash
+ dotnet publish ./src/Keystone.Cli/Keystone.Cli.csproj -c Release -r osx-arm64
+ dotnet publish ./src/Keystone.Cli/Keystone.Cli.csproj -c Release -r osx-x64
+
+ ./scripts/package-release.sh X.Y.Z
+ ```
+
+4. Create and push the annotated tag:
+
+ ```bash
+ git tag -a vX.Y.Z -m "keystone-cli vX.Y.Z"
+ git push origin vX.Y.Z
+ ```
+
+5. Create the GitHub Release manually if it didn't get created automatically:
+
+ - Upload the generated `.tar.gz` files
+ - Include `checksums.txt` in the release assets
+ - Paste checksum contents into the release description
+
+6. Update the Homebrew formula as usual.
diff --git a/scripts/package-release.sh b/scripts/package-release.sh
index 6caf34b..619a268 100755
--- a/scripts/package-release.sh
+++ b/scripts/package-release.sh
@@ -6,8 +6,57 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${REPO_ROOT}"
-VERSION="${1:-0.1.0}"
+VERSION=""
+
+RID=""
+
+usage() {
+ echo "Usage: $(basename "$0") [version] [rid]" >&2
+ echo " version: Optional release version (e.g., 0.1.0)." >&2
+ echo " Defaults to the current value in Keystone.Cli.csproj if omitted." >&2
+ echo " rid: Optional runtime identifier (e.g., osx-arm64, osx-x64, linux-x64)." >&2
+ echo " If provided, only that RID archive will be produced." >&2
+ echo "" >&2
+ echo "Examples:" >&2
+ echo " $(basename "$0")" >&2
+ echo " $(basename "$0") 0.1.0" >&2
+ echo " $(basename "$0") 0.1.0 osx-arm64" >&2
+ echo " $(basename "$0") osx-arm64" >&2
+}
+
+if [[ $# -gt 2 ]]; then
+ usage
+ exit 2
+fi
+
+if [[ $# -eq 1 ]]; then
+ # Support either "version" OR "rid" as the sole argument.
+ if [[ "$1" =~ ^(osx|win|linux)(-|$) ]]; then
+ RID="$1"
+ else
+ VERSION="$1"
+ fi
+fi
+
+if [[ $# -eq 2 ]]; then
+ VERSION="$1"
+ RID="$2"
+fi
+
TFM="net10.0"
+
+if [[ -z "$VERSION" ]]; then
+ # Best-effort: extract ... from the CLI project file.
+ # (Keeps this script dependency-free; falls back to 0.1.0 if not found.)
+ if [[ -f "./src/Keystone.Cli/Keystone.Cli.csproj" ]]; then
+ VERSION="$(sed -n 's:.*\(.*\).*:\1:p' ./src/Keystone.Cli/Keystone.Cli.csproj | head -n 1)"
+ fi
+
+ if [[ -z "$VERSION" ]]; then
+ VERSION="0.1.0"
+ fi
+fi
+
OUT_DIR="artifacts/release"
mkdir -p "$OUT_DIR"
@@ -55,7 +104,11 @@ package() {
fi
}
-package osx-arm64
-package osx-x64
+if [[ -n "$RID" ]]; then
+ package "$RID"
+else
+ package osx-arm64
+ package osx-x64
+fi
echo "Done."
diff --git a/src/Keystone.Cli/Application/Console.cs b/src/Keystone.Cli/Application/Console.cs
new file mode 100644
index 0000000..0d1865f
--- /dev/null
+++ b/src/Keystone.Cli/Application/Console.cs
@@ -0,0 +1,16 @@
+using Keystone.Cli.Application.Utility;
+
+
+namespace Keystone.Cli.Application;
+
+///
+/// The default implementation of that uses the system console.
+///
+public class Console : IConsole
+{
+ ///
+ public TextWriter Out => System.Console.Out;
+
+ ///
+ public TextWriter Error => System.Console.Error;
+}
diff --git a/src/Keystone.Cli/Application/Utility/IConsole.cs b/src/Keystone.Cli/Application/Utility/IConsole.cs
new file mode 100644
index 0000000..e9b66eb
--- /dev/null
+++ b/src/Keystone.Cli/Application/Utility/IConsole.cs
@@ -0,0 +1,17 @@
+namespace Keystone.Cli.Application.Utility;
+
+///
+/// Abstraction over the console for easier testing.
+///
+public interface IConsole
+{
+ ///
+ /// The standard output writer.
+ ///
+ TextWriter Out { get; }
+
+ ///
+ /// The standard error writer.
+ ///
+ TextWriter Error { get; }
+}
diff --git a/src/Keystone.Cli/Configuration/DependenciesInstaller.cs b/src/Keystone.Cli/Configuration/DependenciesInstaller.cs
index 3571d66..37234ea 100644
--- a/src/Keystone.Cli/Configuration/DependenciesInstaller.cs
+++ b/src/Keystone.Cli/Configuration/DependenciesInstaller.cs
@@ -11,6 +11,7 @@
using Keystone.Cli.Application.Utility;
using Keystone.Cli.Application.Utility.Serialization;
using Microsoft.Extensions.DependencyInjection;
+using Console = Keystone.Cli.Application.Console;
namespace Keystone.Cli.Configuration;
@@ -28,6 +29,7 @@ public static void AddDependencies(this IServiceCollection services)
=> services
.AddHttpClient()
.AddSingleton()
+ .AddSingleton()
.AddSingleton()
.AddSingleton()
.AddSingleton()
diff --git a/src/Keystone.Cli/Presentation/BrowseCommandController.cs b/src/Keystone.Cli/Presentation/BrowseCommandController.cs
index a80db1c..abf18ed 100644
--- a/src/Keystone.Cli/Presentation/BrowseCommandController.cs
+++ b/src/Keystone.Cli/Presentation/BrowseCommandController.cs
@@ -1,5 +1,6 @@
using Cocona;
using Keystone.Cli.Application.Commands.Browse;
+using Keystone.Cli.Application.Utility;
using Keystone.Cli.Domain;
@@ -8,7 +9,7 @@ namespace Keystone.Cli.Presentation;
///
/// The "browse" command controller.
///
-public class BrowseCommandController(IBrowseCommand browseCommand)
+public class BrowseCommandController(IConsole console, IBrowseCommand browseCommand)
{
[Command("browse", Description = "Opens the template repository in the default browser")]
public int Browse([Argument(Description = "The template name")] string? templateName)
@@ -21,7 +22,7 @@ public int Browse([Argument(Description = "The template name")] string? template
}
catch (KeyNotFoundException ex)
{
- Console.Error.WriteLine(ex.Message);
+ console.Error.WriteLine(ex.Message);
return CliCommandResults.Error;
}
diff --git a/src/Keystone.Cli/Presentation/InfoCommandController.cs b/src/Keystone.Cli/Presentation/InfoCommandController.cs
index fb58ad9..c9823c4 100644
--- a/src/Keystone.Cli/Presentation/InfoCommandController.cs
+++ b/src/Keystone.Cli/Presentation/InfoCommandController.cs
@@ -1,5 +1,6 @@
using Cocona;
using Keystone.Cli.Application.Commands.Info;
+using Keystone.Cli.Application.Utility;
namespace Keystone.Cli.Presentation;
@@ -7,7 +8,7 @@ namespace Keystone.Cli.Presentation;
///
/// The "info" command controller.
///
-public class InfoCommandController(IInfoCommand infoCommand)
+public class InfoCommandController(IConsole console, IInfoCommand infoCommand)
{
[Command("info", Description = "Prints the template information")]
public void Info()
@@ -15,6 +16,6 @@ public void Info()
var info = infoCommand.GetInfo();
var text = info.GetFormattedText();
- Console.WriteLine(text);
+ console.Out.WriteLine(text);
}
}
diff --git a/src/Keystone.Cli/Presentation/NewCommandController.cs b/src/Keystone.Cli/Presentation/NewCommandController.cs
index 64628f4..840d927 100644
--- a/src/Keystone.Cli/Presentation/NewCommandController.cs
+++ b/src/Keystone.Cli/Presentation/NewCommandController.cs
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Cocona;
using Keystone.Cli.Application.Commands.New;
+using Keystone.Cli.Application.Utility;
using Keystone.Cli.Domain;
using Keystone.Cli.Domain.Policies;
using Keystone.Cli.Presentation.ComponentModel.DataAnnotations;
@@ -11,7 +12,7 @@ namespace Keystone.Cli.Presentation;
///
/// The "new" command controller.
///
-public class NewCommandController(INewCommand newCommand)
+public class NewCommandController(IConsole console, INewCommand newCommand)
{
[Command("new", Description = "Creates a new project from a template")]
public async Task NewAsync(
@@ -46,13 +47,13 @@ await newCommand.CreateNewAsync(
}
catch (InvalidOperationException ex)
{
- await Console.Error.WriteLineAsync(ex.Message);
+ await console.Error.WriteLineAsync(ex.Message);
return CliCommandResults.Error;
}
catch (KeyNotFoundException ex)
{
- await Console.Error.WriteLineAsync(ex.Message);
+ await console.Error.WriteLineAsync(ex.Message);
return CliCommandResults.Error;
}
diff --git a/src/Keystone.Cli/Presentation/Project/SwitchTemplateSubCommand.cs b/src/Keystone.Cli/Presentation/Project/SwitchTemplateSubCommand.cs
index fc10449..51f149f 100644
--- a/src/Keystone.Cli/Presentation/Project/SwitchTemplateSubCommand.cs
+++ b/src/Keystone.Cli/Presentation/Project/SwitchTemplateSubCommand.cs
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Cocona;
using Keystone.Cli.Application.Commands.Project;
+using Keystone.Cli.Application.Utility;
using Keystone.Cli.Domain;
using Keystone.Cli.Domain.Project;
using Keystone.Cli.Presentation.ComponentModel.DataAnnotations;
@@ -11,7 +12,7 @@ namespace Keystone.Cli.Presentation.Project;
///
/// The implementation of the "switch-template" sub-command for the project command.
///
-public class SwitchTemplateSubCommand(IProjectCommand projectCommand)
+public class SwitchTemplateSubCommand(IConsole console, IProjectCommand projectCommand)
{
public async Task SwitchTemplateAsync(
[Argument(Description = "The name of the new template to switch to"),
@@ -27,12 +28,12 @@ public async Task SwitchTemplateAsync(
? Path.GetFullPath(".")
: Path.GetFullPath(projectPath);
- Console.WriteLine($"Switching template to '{newTemplateName}' for project at '{fullPath}'.");
+ await console.Out.WriteLineAsync($"Switching template to '{newTemplateName}' for project at '{fullPath}'.");
try
{
var result = await projectCommand.SwitchTemplateAsync(newTemplateName, fullPath, cancellationToken);
- Console.WriteLine(
+ await console.Out.WriteLineAsync(
result
? $"Switched to template '{newTemplateName}' successfully."
: $"The project already uses template `{newTemplateName}`."
@@ -42,13 +43,13 @@ public async Task SwitchTemplateAsync(
}
catch (KeyNotFoundException exception)
{
- await Console.Error.WriteLineAsync(exception.Message);
+ await console.Error.WriteLineAsync(exception.Message);
return CliCommandResults.Error;
}
catch (ProjectNotLoadedException exception)
{
- await Console.Error.WriteLineAsync(exception.Message);
+ await console.Error.WriteLineAsync(exception.Message);
return CliCommandResults.Error;
}
diff --git a/tests/Keystone.Cli.UnitTests/Application/Utility/NullConsole.cs b/tests/Keystone.Cli.UnitTests/Application/Utility/NullConsole.cs
new file mode 100644
index 0000000..185816b
--- /dev/null
+++ b/tests/Keystone.Cli.UnitTests/Application/Utility/NullConsole.cs
@@ -0,0 +1,31 @@
+using Keystone.Cli.Application.Utility;
+
+
+namespace Keystone.Cli.UnitTests.Application.Utility;
+
+///
+/// The null implementation of that discards all output.
+///
+///
+/// Use for testing purposes.
+///
+public class NullConsole : IConsole
+{
+ ///
+ /// The only instance of .
+ ///
+ public static NullConsole Instance { get; } = new();
+
+ ///
+ /// The private constructor to prevent external instantiation.
+ ///
+ private NullConsole()
+ {
+ }
+
+ ///
+ public TextWriter Out => TextWriter.Null;
+
+ ///
+ public TextWriter Error => TextWriter.Null;
+}
diff --git a/tests/Keystone.Cli.UnitTests/Configuration/DependenciesInstallerTests.cs b/tests/Keystone.Cli.UnitTests/Configuration/DependenciesInstallerTests.cs
index b0e0d78..7ae776b 100644
--- a/tests/Keystone.Cli.UnitTests/Configuration/DependenciesInstallerTests.cs
+++ b/tests/Keystone.Cli.UnitTests/Configuration/DependenciesInstallerTests.cs
@@ -22,6 +22,7 @@ public class DependenciesInstallerTests
private static readonly Type[] ExpectedTypes =
[
typeof(IBrowseCommand),
+ typeof(IConsole),
typeof(IContentHashService),
typeof(IEnvironmentFileSerializer),
typeof(IFileSystemCopyService),
diff --git a/tests/Keystone.Cli.UnitTests/Presentation/BrowseCommandControllerTests.cs b/tests/Keystone.Cli.UnitTests/Presentation/BrowseCommandControllerTests.cs
index a77317c..4b3eeee 100644
--- a/tests/Keystone.Cli.UnitTests/Presentation/BrowseCommandControllerTests.cs
+++ b/tests/Keystone.Cli.UnitTests/Presentation/BrowseCommandControllerTests.cs
@@ -1,6 +1,7 @@
using Keystone.Cli.Application.Commands.Browse;
using Keystone.Cli.Domain;
using Keystone.Cli.Presentation;
+using Keystone.Cli.UnitTests.Application.Utility;
using NSubstitute;
@@ -10,7 +11,7 @@ namespace Keystone.Cli.UnitTests.Presentation;
public class BrowseCommandControllerTests
{
private static BrowseCommandController Ctor(IBrowseCommand? browseCommand = null)
- => new(browseCommand ?? Substitute.For());
+ => new(NullConsole.Instance, browseCommand ?? Substitute.For());
[Test]
public void Browse_OnSuccess_ReturnsCliSuccess()
diff --git a/tests/Keystone.Cli.UnitTests/Presentation/InfoCommandControllerTests.cs b/tests/Keystone.Cli.UnitTests/Presentation/InfoCommandControllerTests.cs
index 4ec55a8..6d0812b 100644
--- a/tests/Keystone.Cli.UnitTests/Presentation/InfoCommandControllerTests.cs
+++ b/tests/Keystone.Cli.UnitTests/Presentation/InfoCommandControllerTests.cs
@@ -1,6 +1,7 @@
using Keystone.Cli.Application.Commands.Info;
using Keystone.Cli.Domain;
using Keystone.Cli.Presentation;
+using Keystone.Cli.UnitTests.Application.Utility;
using NSubstitute;
@@ -10,7 +11,7 @@ namespace Keystone.Cli.UnitTests.Presentation;
public class InfoCommandControllerTests
{
private static InfoCommandController Ctor(IInfoCommand? infoCommand = null)
- => new(infoCommand ?? Substitute.For());
+ => new(NullConsole.Instance, infoCommand ?? Substitute.For());
[Test]
public void Info_ExecutesCommand()
diff --git a/tests/Keystone.Cli.UnitTests/Presentation/NewCommandControllerTests.cs b/tests/Keystone.Cli.UnitTests/Presentation/NewCommandControllerTests.cs
index 05418cb..ef8ab7e 100644
--- a/tests/Keystone.Cli.UnitTests/Presentation/NewCommandControllerTests.cs
+++ b/tests/Keystone.Cli.UnitTests/Presentation/NewCommandControllerTests.cs
@@ -2,6 +2,7 @@
using Keystone.Cli.Domain;
using Keystone.Cli.Domain.Policies;
using Keystone.Cli.Presentation;
+using Keystone.Cli.UnitTests.Application.Utility;
using NSubstitute;
@@ -11,7 +12,7 @@ namespace Keystone.Cli.UnitTests.Presentation;
public class NewCommandControllerTests
{
private static NewCommandController Ctor(INewCommand? newCommand = null)
- => new(newCommand ?? Substitute.For());
+ => new(NullConsole.Instance, newCommand ?? Substitute.For());
[Test]
public async Task NewAsync_OnSuccess_ReturnsCliSuccessAsync()
diff --git a/tests/Keystone.Cli.UnitTests/Presentation/Project/SwitchTemplateSubCommandTests.cs b/tests/Keystone.Cli.UnitTests/Presentation/Project/SwitchTemplateSubCommandTests.cs
index 10869ba..35356f7 100644
--- a/tests/Keystone.Cli.UnitTests/Presentation/Project/SwitchTemplateSubCommandTests.cs
+++ b/tests/Keystone.Cli.UnitTests/Presentation/Project/SwitchTemplateSubCommandTests.cs
@@ -2,6 +2,7 @@
using Keystone.Cli.Domain;
using Keystone.Cli.Domain.Project;
using Keystone.Cli.Presentation.Project;
+using Keystone.Cli.UnitTests.Application.Utility;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
@@ -12,7 +13,7 @@ namespace Keystone.Cli.UnitTests.Presentation.Project;
public class SwitchTemplateSubCommandTests
{
private static SwitchTemplateSubCommand Ctor(IProjectCommand? projectCommand = null)
- => new(projectCommand ?? Substitute.For());
+ => new(NullConsole.Instance, projectCommand ?? Substitute.For());
[Test]
public async Task SwitchTemplateAsync_OnSuccess_ReturnsCliSuccessAsync()