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
47 changes: 47 additions & 0 deletions .github/workflows/pr-preview-packages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Publish Preview NuGet Packages

on:
pull_request:
branches:
- master

env:
DOTNET_VERSION: '10.0.x'
PACKAGE_SOURCE: https://nuget.pkg.github.com/WiSave/index.json

jobs:
publish-preview:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read

steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}

- name: Restore
run: dotnet restore WiSave.Console.slnx

- name: Build
run: dotnet build WiSave.Console.slnx -c Release --no-restore

- name: Test
run: dotnet test WiSave.Console.slnx -c Release --no-build

- name: Pack preview packages
run: >
dotnet pack WiSave.Console.slnx
-c Release
--no-build
-o ./nupkg
-p:MinVerDefaultPreReleaseIdentifiers=preview.${{ github.event.pull_request.number }}.${{ github.run_number }}

- name: Push preview packages
run: dotnet nuget push ./nupkg/*.nupkg --source "${{ env.PACKAGE_SOURCE }}" --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate
70 changes: 70 additions & 0 deletions .github/workflows/publish-packages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: Publish Stable NuGet Packages

on:
push:
branches:
- master
workflow_dispatch:

env:
DOTNET_VERSION: '10.0.x'
PACKAGE_SOURCE: https://nuget.pkg.github.com/WiSave/index.json

jobs:
publish:
runs-on: ubuntu-latest
permissions:
packages: write
contents: write

steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Ensure release tag for this commit
id: release_tag
run: |
git fetch --tags
existing_tag="$(git tag --points-at HEAD 'v*' | sort -V | tail -n 1)"
if [ -n "$existing_tag" ]; then
echo "tag=$existing_tag" >> "$GITHUB_OUTPUT"
exit 0
fi

latest_tag="$(git tag -l 'v*' | sort -V | tail -n 1)"
latest_version="${latest_tag#v}"
if [ -z "$latest_tag" ] || [ "${latest_version%%.*}" -lt 1 ]; then
tag="v1.0.0"
else
major="$(echo "$latest_version" | cut -d. -f1)"
minor="$(echo "$latest_version" | cut -d. -f2)"
patch="$(echo "$latest_version" | cut -d. -f3)"
tag="v${major}.${minor}.$((patch + 1))"
fi

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag "$tag"
git push origin "$tag"
echo "tag=$tag" >> "$GITHUB_OUTPUT"

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}

- name: Restore
run: dotnet restore WiSave.Console.slnx

- name: Build
run: dotnet build WiSave.Console.slnx -c Release --no-restore

- name: Test
run: dotnet test WiSave.Console.slnx -c Release --no-build

- name: Pack stable packages
run: dotnet pack WiSave.Console.slnx -c Release --no-build -o ./nupkg

- name: Push stable packages
run: dotnet nuget push ./nupkg/*.nupkg --source "${{ env.PACKAGE_SOURCE }}" --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
bin/
obj/
TestResults/
.vs/
.idea/
*.user
*.suo
*.nupkg
*.snupkg
nupkg/
11 changes: 11 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<MinVerTagPrefix>v</MinVerTagPrefix>
<MinVerMinimumMajorMinor>1.0</MinVerMinimumMajorMinor>
</PropertyGroup>
</Project>
8 changes: 8 additions & 0 deletions NuGet.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="github-wisave" value="https://nuget.pkg.github.com/WiSave/index.json" />
</packageSources>
</configuration>
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# WiSave.Console

Reusable console command infrastructure for WiSave services.

The package provides:

- command contracts and parameter metadata
- command-line parsing
- command catalog and runner
- interactive shell hosting
- console output abstraction for tests
- dependency injection registration helpers

## Usage

```csharp
services.AddWiSaveConsole(
options => options.Title = "WiSave Expenses Console",
typeof(Program).Assembly);
```

Commands implement `IConsoleCommand` and are discovered from the assemblies passed to `AddWiSaveConsole`.
8 changes: 8 additions & 0 deletions WiSave.Console.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/WiSave.Console/WiSave.Console.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/WiSave.Console.Tests/WiSave.Console.Tests.csproj" />
</Folder>
</Solution>
25 changes: 25 additions & 0 deletions src/WiSave.Console/Execution/CommandCatalog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Microsoft.Extensions.DependencyInjection;

namespace WiSave.Console.Execution;

public interface ICommandCatalog
{
IReadOnlyList<CommandDescriptor> List();
CommandDescriptor? Find(string name);
}

public sealed class CommandCatalog(IServiceScopeFactory scopeFactory) : ICommandCatalog
{
public IReadOnlyList<CommandDescriptor> List()
{
using var scope = scopeFactory.CreateScope();

return scope.ServiceProvider.GetServices<IConsoleCommand>()
.Select(command => new CommandDescriptor(command.Name, command.Description, command.ParameterDefinitions))
.OrderBy(command => command.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
}

public CommandDescriptor? Find(string name)
=> List().FirstOrDefault(command => command.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
}
6 changes: 6 additions & 0 deletions src/WiSave.Console/Execution/CommandDescriptor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace WiSave.Console.Execution;

public sealed record CommandDescriptor(
string Name,
string Description,
IReadOnlyList<CommandParameter> Parameters);
11 changes: 11 additions & 0 deletions src/WiSave.Console/Execution/CommandExecutionContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace WiSave.Console.Execution;

public sealed class CommandExecutionContext(IReadOnlyDictionary<string, string?> arguments, bool allowPrompting = false)
{
public IReadOnlyDictionary<string, string?> Arguments { get; } = new Dictionary<string, string?>(arguments, StringComparer.OrdinalIgnoreCase);

public bool AllowPrompting { get; } = allowPrompting;

public string? GetArgument(string name)
=> Arguments.GetValueOrDefault(name);
}
14 changes: 14 additions & 0 deletions src/WiSave.Console/Execution/CommandInvocation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace WiSave.Console.Execution;

public sealed class CommandInvocation
{
public CommandInvocation(string commandName, IReadOnlyDictionary<string, string?> arguments)
{
CommandName = commandName;
Arguments = new Dictionary<string, string?>(arguments, StringComparer.OrdinalIgnoreCase);
}

public string CommandName { get; }

public IReadOnlyDictionary<string, string?> Arguments { get; }
}
23 changes: 23 additions & 0 deletions src/WiSave.Console/Execution/CommandLineParseResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace WiSave.Console.Execution;

public sealed class CommandLineParseResult
{
private CommandLineParseResult(bool isInteractive, CommandInvocation? invocation, string? errorMessage)
{
IsInteractive = isInteractive;
Invocation = invocation;
ErrorMessage = errorMessage;
}

public bool IsInteractive { get; }

public CommandInvocation? Invocation { get; }

public string? ErrorMessage { get; }

public static CommandLineParseResult Interactive() => new(true, null, null);

public static CommandLineParseResult Success(CommandInvocation invocation) => new(false, invocation, null);

public static CommandLineParseResult Failure(string errorMessage) => new(false, null, errorMessage);
}
50 changes: 50 additions & 0 deletions src/WiSave.Console/Execution/CommandLineParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
namespace WiSave.Console.Execution;

public interface ICommandLineParser
{
CommandLineParseResult Parse(string[] args);
}

public sealed class CommandLineParser : ICommandLineParser
{
public CommandLineParseResult Parse(string[] args)
{
if (args.Length == 0)
{
return CommandLineParseResult.Interactive();
}

var commandName = args[0].Trim();
if (string.IsNullOrWhiteSpace(commandName))
{
return CommandLineParseResult.Failure("Command name cannot be empty.");
}

var arguments = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);

for (var index = 1; index < args.Length; index++)
{
var token = args[index];
if (!token.StartsWith("--", StringComparison.Ordinal))
{
return CommandLineParseResult.Failure($"Unexpected argument '{token}'. Expected '--name value'.");
}

var parameterName = token[2..].Trim();
if (string.IsNullOrWhiteSpace(parameterName))
{
return CommandLineParseResult.Failure("Parameter name after '--' cannot be empty.");
}

string? parameterValue = "true";
if (index + 1 < args.Length && !args[index + 1].StartsWith("--", StringComparison.Ordinal))
{
parameterValue = args[++index];
}

arguments[parameterName] = parameterValue;
}

return CommandLineParseResult.Success(new CommandInvocation(commandName, arguments));
}
}
7 changes: 7 additions & 0 deletions src/WiSave.Console/Execution/CommandParameter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace WiSave.Console.Execution;

public sealed record CommandParameter(
string Name,
string Description,
bool Required,
string? DefaultValue = null);
Loading
Loading