Moved the build script to System.CommandLine (#1159)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-04 14:57:37 +01:00
1 parent f0fa1411c8
commit 54c3f0b3e0
12 files changed
+232 -70

No files matched your search

+1 -4
View File
@@ -11,10 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cocona" Version="2.2.0" />
<!-- Pins Cocona's transitive Microsoft.Extensions.Hosting 6.0.0, which pulled in the vulnerable System.Text.Json 6.0.0 (GHSA-8g4q-xg66-9fp4) -->
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.14" />
<PackageReference Include="System.CommandLine" Version="2.0.14" />
</ItemGroup>
<ItemGroup>
@@ -4,10 +4,19 @@ namespace Build.Commands;
public sealed class AssistantPluginHashCommand
{
[Command("assistant-plugin-hash", Description = "Compute the canonical assistant-plugin hash for a plugin directory")]
public void ComputeAssistantPluginHash(
[Argument(Description = "Path to the assistant plugin directory")] string pluginDir,
[Option("lua-snippet", Description = "Also print a Lua snippet for CONFIG[\"SETTINGS\"]")] bool luaSnippet = false)
/// <summary>
/// Defines the command "assistant-plugin-hash".
/// </summary>
public static Command CreateCommand()
{
var pluginDir = new Argument<string>("pluginDir") { Description = "Path to the assistant plugin directory" };
var luaSnippet = new Option<bool>("--lua-snippet") { Description = "Also print a Lua snippet for CONFIG[\"SETTINGS\"]" };
var command = new Command("assistant-plugin-hash", "Compute the canonical assistant-plugin hash for a plugin directory") { pluginDir, luaSnippet };
command.SetAction(parseResult => new AssistantPluginHashCommand().ComputeAssistantPluginHash(parseResult.GetRequiredValue(pluginDir), parseResult.GetValue(luaSnippet)));
return command;
}
public void ComputeAssistantPluginHash(string pluginDir, bool luaSnippet = false)
{
if (!Environment.IsWorkingDirectoryValid())
return;
+10 -4
View File
@@ -1,11 +1,17 @@
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedType.Global
// ReSharper disable UnusedMember.Global
namespace Build.Commands;
public sealed class CheckRidsCommand
{
[Command("check-rids", Description = "Check the RIDs for the current OS")]
/// <summary>
/// Defines the command "check-rids".
/// </summary>
public static Command CreateCommand()
{
var command = new Command("check-rids", "Check the RIDs for the current OS");
command.SetAction(_ => new CheckRidsCommand().GetRids());
return command;
}
public void GetRids()
{
if(!Environment.IsWorkingDirectoryValid())
+10 -5
View File
@@ -2,10 +2,6 @@ using System.Text.RegularExpressions;
using SharedTools;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedType.Global
// ReSharper disable UnusedMember.Global
namespace Build.Commands;
public sealed partial class CollectI18NKeysCommand
@@ -43,7 +39,16 @@ public sealed partial class CollectI18NKeysCommand
END_TAG2
];
[Command("collect-i18n", Description = "Collect I18N keys")]
/// <summary>
/// Defines the command "collect-i18n".
/// </summary>
public static Command CreateCommand()
{
var command = new Command("collect-i18n", "Collect I18N keys");
command.SetAction((_, _) => new CollectI18NKeysCommand().CollectI18NKeys());
return command;
}
public async Task CollectI18NKeys()
{
if(!Environment.IsWorkingDirectoryValid())
+100 -25
View File
@@ -9,17 +9,38 @@ using SharedTools;
namespace Build.Commands;
// ReSharper disable MemberCanBePrivate.Global
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedType.Global
// ReSharper disable UnusedMember.Global
public sealed partial class UpdateMetadataCommands
{
[Command("release", Description = "Prepare & build the next release")]
public async Task Release(
[Option("action", ['a'], Description = "The release action: patch, minor, or major")] PrepareAction action = PrepareAction.NONE,
[Option("version", ['v'], Description = "Set a specific version directly, e.g., 26.1.2")] string? version = null,
[Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false)
/// <summary>
/// Creates the option "--action" of the commands which prepare a release.
/// </summary>
private static Option<PrepareAction> CreateActionOption() => new("--action", "-a") { Description = "The release action: build, month, or year, which raise the release, the month, or the year of the version, e.g., 26.11.1 becomes 26.11.2, 26.12.1, or 27.1.1", DefaultValueFactory = _ => PrepareAction.NONE };
/// <summary>
/// Creates the option "--version" of the commands which prepare a release.
/// </summary>
private static Option<string> CreateVersionOption() => new("--version", "-v") { Description = "Set a specific version directly, e.g., 26.1.2" };
/// <summary>
/// Creates the option "--offline" of the commands which build the app.
/// </summary>
private static Option<bool> CreateOfflineOption() => new("--offline") { Description = "Skip downloads and use locally available build dependencies" };
/// <summary>
/// Defines the command "release".
/// </summary>
public static Command CreateReleaseCommand()
{
var action = CreateActionOption();
var version = CreateVersionOption();
var offline = CreateOfflineOption();
var command = new Command("release", "Prepare & build the next release") { action, version, offline };
command.SetAction((parseResult, _) => new UpdateMetadataCommands().Release(parseResult.GetValue(action), parseResult.GetValue(version), parseResult.GetValue(offline)));
return command;
}
public async Task Release(PrepareAction action = PrepareAction.NONE, string? version = null, bool offline = false)
{
if(!Environment.IsWorkingDirectoryValid())
throw new CommandExitedException(1);
@@ -57,9 +78,18 @@ public sealed partial class UpdateMetadataCommands
Console.WriteLine(" \"Release Process\" in app/Build/AGENTS.md.");
}
[Command("rebuild-release", Description = "Prepare & build a new build of the current release")]
public async Task RebuildRelease(
[Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false)
/// <summary>
/// Defines the command "rebuild-release".
/// </summary>
public static Command CreateRebuildReleaseCommand()
{
var offline = CreateOfflineOption();
var command = new Command("rebuild-release", "Prepare & build a new build of the current release") { offline };
command.SetAction((parseResult, _) => new UpdateMetadataCommands().RebuildRelease(parseResult.GetValue(offline)));
return command;
}
public async Task RebuildRelease(bool offline = false)
{
if(!Environment.IsWorkingDirectoryValid())
throw new CommandExitedException(1);
@@ -106,10 +136,19 @@ public sealed partial class UpdateMetadataCommands
await this.Build(offline, skipVerify: true);
}
[Command("update-metainfo", Description = "Update the AppStream metainfo entry of one release from its changelog")]
public async Task UpdateMetainfo(
[Option("version", ['v'], Description = "The release version, e.g., 26.1.2. Defaults to the version from the metadata")] string? version = null,
[Option("date", ['d'], Description = "The release date as yyyy-MM-dd. Defaults to the build time from the metadata")] string? date = null)
/// <summary>
/// Defines the command "update-metainfo".
/// </summary>
public static Command CreateUpdateMetainfoCommand()
{
var version = new Option<string>("--version", "-v") { Description = "The release version, e.g., 26.1.2. Defaults to the version from the metadata" };
var date = new Option<string>("--date", "-d") { Description = "The release date as yyyy-MM-dd. Defaults to the build time from the metadata" };
var command = new Command("update-metainfo", "Update the AppStream metainfo entry of one release from its changelog") { version, date };
command.SetAction((parseResult, _) => new UpdateMetadataCommands().UpdateMetainfo(parseResult.GetValue(version), parseResult.GetValue(date)));
return command;
}
public async Task UpdateMetainfo(string? version = null, string? date = null)
{
const int APP_VERSION_INDEX = 0;
const int BUILD_TIME_INDEX = 1;
@@ -140,7 +179,16 @@ public sealed partial class UpdateMetadataCommands
}
}
[Command("update-versions", Description = "The command will update the package versions in the metadata file")]
/// <summary>
/// Defines the command "update-versions".
/// </summary>
public static Command CreateUpdateVersionsCommand()
{
var command = new Command("update-versions", "The command will update the package versions in the metadata file");
command.SetAction((_, _) => new UpdateMetadataCommands().UpdateVersions());
return command;
}
public async Task UpdateVersions()
{
if(!Environment.IsWorkingDirectoryValid())
@@ -156,10 +204,19 @@ public sealed partial class UpdateMetadataCommands
await this.UpdateVectorStoreVersion();
}
[Command("prepare", Description = "Prepare the metadata for the next release")]
public async Task Prepare(
[Option("action", ['a'], Description = "The release action: patch, minor, or major")] PrepareAction action = PrepareAction.NONE,
[Option("version", ['v'], Description = "Set a specific version directly, e.g., 26.1.2")] string? version = null)
/// <summary>
/// Defines the command "prepare".
/// </summary>
public static Command CreatePrepareCommand()
{
var action = CreateActionOption();
var version = CreateVersionOption();
var command = new Command("prepare", "Prepare the metadata for the next release") { action, version };
command.SetAction((parseResult, _) => new UpdateMetadataCommands().Prepare(parseResult.GetValue(action), parseResult.GetValue(version)));
return command;
}
public async Task Prepare(PrepareAction action = PrepareAction.NONE, string? version = null)
{
if(!Environment.IsWorkingDirectoryValid())
return;
@@ -236,10 +293,19 @@ public sealed partial class UpdateMetadataCommands
await this.UpdateLicenceYear(Path.GetFullPath(Path.Combine(Environment.GetAIStudioDirectory(), "Pages", "Information.razor.cs")));
}
[Command("build", Description = "Build MindWork AI Studio")]
public async Task Build(
[Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false,
[Option("skip-verify", Description = "Skip the quality gate which otherwise runs before anything is built")] bool skipVerify = false)
/// <summary>
/// Defines the command "build".
/// </summary>
public static Command CreateBuildCommand()
{
var offline = CreateOfflineOption();
var skipVerify = new Option<bool>("--skip-verify") { Description = "Skip the quality gate which otherwise runs before anything is built" };
var command = new Command("build", "Build MindWork AI Studio") { offline, skipVerify };
command.SetAction((parseResult, _) => new UpdateMetadataCommands().Build(parseResult.GetValue(offline), parseResult.GetValue(skipVerify)));
return command;
}
public async Task Build(bool offline = false, bool skipVerify = false)
{
if(!Environment.IsWorkingDirectoryValid())
throw new CommandExitedException(1);
@@ -863,7 +929,16 @@ public sealed partial class UpdateMetadataCommands
return shortVersion;
}
[Command("update-project-hash", Description = "Update the project commit hash")]
/// <summary>
/// Defines the command "update-project-hash".
/// </summary>
public static Command CreateUpdateProjectHashCommand()
{
var command = new Command("update-project-hash", "Update the project commit hash");
command.SetAction((_, _) => new UpdateMetadataCommands().UpdateProjectCommitHash());
return command;
}
public async Task UpdateProjectCommitHash()
{
const int COMMIT_HASH_INDEX = 8;
+10 -5
View File
@@ -1,7 +1,3 @@
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedType.Global
// ReSharper disable UnusedMember.Global
using SharedTools;
namespace Build.Commands;
@@ -14,7 +10,16 @@ public sealed class UpdateWebAssetsCommand
/// </summary>
private static readonly HashSet<string> SKIPPED_EXTENSIONS = new(StringComparer.OrdinalIgnoreCase) { ".br", ".gz", ".map" };
[Command("update-web", Description = "Update web assets")]
/// <summary>
/// Defines the command "update-web".
/// </summary>
public static Command CreateCommand()
{
var command = new Command("update-web", "Update web assets");
command.SetAction(_ => new UpdateWebAssetsCommand().UpdateWebAssets());
return command;
}
public void UpdateWebAssets()
{
if(!Environment.IsWorkingDirectoryValid())
+10 -4
View File
@@ -2,9 +2,6 @@ using Build.Tools;
using SharedTools;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedType.Global
// ReSharper disable UnusedMember.Global
namespace Build.Commands;
/// <summary>
@@ -22,7 +19,16 @@ public sealed class VerifyCommand
/// </summary>
private const string SIDECAR_PREFIX = "mindworkAIStudioServer-";
[Command("verify", Description = "Run the quality gate: .NET tests, the trim smoke test, Rust tests, Clippy, and the model sources")]
/// <summary>
/// Defines the command "verify".
/// </summary>
public static Command CreateCommand()
{
var command = new Command("verify", "Run the quality gate: .NET tests, the trim smoke test, Rust tests, Clippy, and the model sources");
command.SetAction((_, _) => new VerifyCommand().Verify());
return command;
}
public async Task<int> Verify()
{
if(!Environment.IsWorkingDirectoryValid())
+12 -6
View File
@@ -1,8 +1,5 @@
using System.Text.RegularExpressions;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedType.Global
// ReSharper disable UnusedMember.Global
namespace Build.Commands;
/// <summary>
@@ -41,9 +38,18 @@ public sealed partial class VerifyModelsCommand
/// </remarks>
private const string DAY_MARKER = "new DateOnly(";
[Command("verify-models", Description = "Report how long ago the pages behind the model rules were read")]
public int VerifyModels(
[Option("months", Description = "How long a page may go unread before it is reported")] int months = DEFAULT_MONTHS)
/// <summary>
/// Defines the command "verify-models".
/// </summary>
public static Command CreateCommand()
{
var months = new Option<int>("--months") { Description = "How long a page may go unread before it is reported", DefaultValueFactory = _ => DEFAULT_MONTHS };
var command = new Command("verify-models", "Report how long ago the pages behind the model rules were read") { months };
command.SetAction(parseResult => new VerifyModelsCommand().VerifyModels(parseResult.GetValue(months)));
return command;
}
public int VerifyModels(int months = DEFAULT_MONTHS)
{
if(!Environment.IsWorkingDirectoryValid())
return 1;
+1 -1
View File
@@ -2,6 +2,6 @@
global using System.Text;
global using Cocona;
global using System.CommandLine;
global using Environment = Build.Tools.Environment;
+45 -11
View File
@@ -1,12 +1,46 @@
using Build.Commands;
using System.CommandLine.Help;
var builder = CoconaApp.CreateBuilder();
var app = builder.Build();
app.AddCommands<CheckRidsCommand>();
app.AddCommands<UpdateMetadataCommands>();
app.AddCommands<UpdateWebAssetsCommand>();
app.AddCommands<CollectI18NKeysCommand>();
app.AddCommands<AssistantPluginHashCommand>();
app.AddCommands<VerifyModelsCommand>();
app.AddCommands<VerifyCommand>();
app.Run();
using Build.Commands;
using Build.Tools;
//
// A RootCommand would take the name of the executable, which is "build", and System.CommandLine refuses
// a root with the same name as one of its commands. Hence, the root is a command of its own, which
// brings only the help option of a RootCommand along:
//
var rootCommand = new Command("build-script", "Build, check, and release MindWork AI Studio. Run the commands from app/Build with 'dotnet run <command>'.")
{
new HelpOption(),
CheckRidsCommand.CreateCommand(),
UpdateMetadataCommands.CreateReleaseCommand(),
UpdateMetadataCommands.CreateRebuildReleaseCommand(),
UpdateMetadataCommands.CreateUpdateMetainfoCommand(),
UpdateMetadataCommands.CreateUpdateVersionsCommand(),
UpdateMetadataCommands.CreatePrepareCommand(),
UpdateMetadataCommands.CreateBuildCommand(),
UpdateMetadataCommands.CreateUpdateProjectHashCommand(),
UpdateWebAssetsCommand.CreateCommand(),
CollectI18NKeysCommand.CreateCommand(),
AssistantPluginHashCommand.CreateCommand(),
VerifyModelsCommand.CreateCommand(),
VerifyCommand.CreateCommand()
};
//
// A command which fails deep inside, e.g., the build within a release, ends the whole script through
// a CommandExitedException, once it has said why. System.CommandLine would print that exception with
// its stack trace, so we handle the exceptions ourselves and print only those nobody expected:
//
try
{
return await rootCommand.Parse(args).InvokeAsync(new InvocationConfiguration { EnableDefaultExceptionHandler = false });
}
catch (CommandExitedException exception)
{
return exception.ExitCode;
}
catch (Exception exception)
{
Console.Error.WriteLine($"Unhandled exception: {exception}");
return 1;
}
+19
View File
@@ -0,0 +1,19 @@
namespace Build.Tools;
/// <summary>
/// Ends the build script with an exit code, after the command has said what went wrong.
/// </summary>
/// <remarks>
/// A release runs several commands one after another, and a failure deep inside one of them has to end
/// the whole release, so that nothing is built on top of half-prepared metadata or broken artifacts.
/// Program.cs turns the exception into the exit code and prints nothing more: the command already
/// explained the failure, and a stack trace would only bury that explanation.
/// </remarks>
/// <param name="exitCode">The exit code of the build script, where anything but zero is a failure.</param>
public sealed class CommandExitedException(int exitCode) : Exception
{
/// <summary>
/// The exit code the build script ends with.
/// </summary>
public int ExitCode { get; } = exitCode;
}