From b8394dda18fa1a214b7bb75aa4852f8d48a21e02 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 11:49:23 +0000 Subject: [PATCH] Forward unknown bld commands to the Build host. Treat tokens such as Test or Publish as Cake targets, keep bld options like --path on the CLI, and pass remaining arguments through to the submodule host. Co-authored-by: Matthew Abbott --- README.md | 6 +- apps/Ingenium.BuildCli/BuildCliApplication.cs | 6 +- apps/Ingenium.BuildCli/CommandLineDefaults.cs | 143 ++++++++++++++++-- .../CommandAppTests.cs | 15 ++ .../CommandLineDefaultsTests.cs | 30 ++++ 5 files changed, 187 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7c1a86c..9dbb488 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,12 @@ Run `bld` from any git repository that should host the Build submodule. A bare `bld` (no command) runs `build` after verifying that the Build submodule is initialized. If it is missing, the CLI stops and tells you to run `bld init`. +Any unknown command is forwarded to the Build host as a Cake target, so `bld Test` is the same as `bld build Test`. Extra Cake arguments are passed through; `bld`'s own options (`--path`, `--configuration`, and so on) are still parsed by the CLI. + ```text bld Run the Default Cake target (same as `bld build`) +bld Test Run the Cake Test target (unknown commands are forwarded) +bld Publish --publish --nuget Run a Cake target and pass extra host arguments bld init Add the Build submodule (defaults to the latest tag) bld init --tag v1.2.3 Add the Build submodule pinned to a specific tag bld update Move an existing submodule to the latest tag @@ -47,7 +51,7 @@ Existing Ingenium repositories that already use `build` or `Build` as the submod `repair` can prompt for a strategy when run interactively. `reset` and `reinit` are destructive and require `--yes` in non-interactive use. -`build` first checks that the Build submodule is registered and checked out. It then restores .NET local tools when `.config/dotnet-tools.json` is present, and runs `apps/Build` the same way `./build.sh` does. +`build` first checks that the Build submodule is registered and checked out. It then restores .NET local tools when `.config/dotnet-tools.json` is present, and runs `apps/Build` the same way `./build.sh` does. Unknown commands such as `Test`, `Pack`, or a build-extension task name are forwarded to that host as `--target` values. `extension` writes the layout the Build host already imports: diff --git a/apps/Ingenium.BuildCli/BuildCliApplication.cs b/apps/Ingenium.BuildCli/BuildCliApplication.cs index acedc81..ab4d218 100644 --- a/apps/Ingenium.BuildCli/BuildCliApplication.cs +++ b/apps/Ingenium.BuildCli/BuildCliApplication.cs @@ -106,10 +106,10 @@ public static void Configure(IConfigurator config) .WithExample("repair", "--strategy", "reinit", "--tag", "v1.2.3", "--yes"); config.AddCommand("build") - .WithDescription("Run a Cake target through the Build submodule. This is the default when no command is passed.") + .WithDescription("Run a Cake target through the Build submodule. This is the default when no command is passed, and unknown commands are forwarded here.") .WithExample("build") - .WithExample("build", "TestProjects") - .WithExample("build", "Default", "--configuration", "Release"); + .WithExample("build", "Test") + .WithExample("build", "Publish", "--", "--publish", "--nuget"); config.AddCommand("extension") .WithDescription("Create a Cake build-extension project in build-extensions/.") diff --git a/apps/Ingenium.BuildCli/CommandLineDefaults.cs b/apps/Ingenium.BuildCli/CommandLineDefaults.cs index 802cc30..2492c30 100644 --- a/apps/Ingenium.BuildCli/CommandLineDefaults.cs +++ b/apps/Ingenium.BuildCli/CommandLineDefaults.cs @@ -4,7 +4,7 @@ namespace Ingenium.BuildCli; /// -/// Applies default command routing so a bare invocation runs build. +/// Applies default command routing so a bare or unknown invocation runs the Build host. /// public static class CommandLineDefaults { @@ -32,8 +32,41 @@ public static class CommandLineDefaults "--version" }; + private static readonly HashSet CliValueOptions = new(StringComparer.OrdinalIgnoreCase) + { + "-p", + "--path", + "--submodule-path", + "--url", + "-c", + "--configuration", + "-t", + "--tag", + "-s", + "--strategy" + }; + + private static readonly HashSet CliFlagOptions = new(StringComparer.OrdinalIgnoreCase) + { + "--https", + "--verbose", + "-f", + "--force", + "-y", + "--yes" + }; + + /// + /// Returns true when is a first-class bld command. + /// + public static bool IsKnownCommand(string? name) + { + return !string.IsNullOrWhiteSpace(name) && Commands.Contains(name); + } + /// - /// Inserts build when no command was supplied. + /// Inserts build when no command was supplied, and forwards unknown commands + /// to the Build submodule as Cake targets. /// public static string[] Apply(IReadOnlyList args) { @@ -45,23 +78,115 @@ public static string[] Apply(IReadOnlyList args) } var first = args[0]; - if (MetaOptions.Contains(first) || Commands.Contains(first)) + if (MetaOptions.Contains(first)) + { + return ToArray(args); + } + + if (first.Equals(DefaultCommand, StringComparison.OrdinalIgnoreCase)) { - return args as string[] ?? args.ToArray(); + return NormalizeBuildArguments(args); + } + + if (Commands.Contains(first)) + { + return ToArray(args); } if (first.StartsWith('-')) { - var routed = new string[args.Count + 1]; - routed[0] = DefaultCommand; - for (var i = 0; i < args.Count; i++) + return Prepend(DefaultCommand, args); + } + + return NormalizeBuildArguments(Prepend(DefaultCommand, args)); + } + + private static string[] NormalizeBuildArguments(IReadOnlyList args) + { + var result = new List { DefaultCommand }; + var index = 1; + + if (index < args.Count && !args[index].StartsWith('-')) + { + result.Add(args[index]); + index++; + } + + var ours = new List(); + var cake = new List(); + SplitCliAndCakeArguments(args, index, ours, cake); + + result.AddRange(ours); + if (cake.Count > 0) + { + result.Add("--"); + result.AddRange(cake); + } + + return result.ToArray(); + } + + private static void SplitCliAndCakeArguments( + IReadOnlyList args, + int startIndex, + List cliArguments, + List cakeArguments) + { + for (var i = startIndex; i < args.Count; i++) + { + var arg = args[i]; + if (arg == "--") + { + for (var j = i + 1; j < args.Count; j++) + { + cakeArguments.Add(args[j]); + } + + return; + } + + var name = OptionName(arg); + if (CliFlagOptions.Contains(name)) + { + cliArguments.Add(arg); + continue; + } + + if (CliValueOptions.Contains(name)) { - routed[i + 1] = args[i]; + cliArguments.Add(arg); + if (!arg.Contains('=') && i + 1 < args.Count) + { + cliArguments.Add(args[++i]); + } + + continue; } - return routed; + cakeArguments.Add(arg); } + } + private static string OptionName(string argument) + { + var equals = argument.IndexOf('='); + return equals < 0 ? argument : argument[..equals]; + } + + private static string[] Prepend(string command, IReadOnlyList args) + { + var routed = new string[args.Count + 1]; + routed[0] = command; + for (var i = 0; i < args.Count; i++) + { + routed[i + 1] = args[i]; + } + + return routed; + } + + private static string[] ToArray(IReadOnlyList args) + { return args as string[] ?? args.ToArray(); } } diff --git a/tests/Ingenium.BuildCli.Tests/CommandAppTests.cs b/tests/Ingenium.BuildCli.Tests/CommandAppTests.cs index fce489e..0b926e0 100644 --- a/tests/Ingenium.BuildCli.Tests/CommandAppTests.cs +++ b/tests/Ingenium.BuildCli.Tests/CommandAppTests.cs @@ -84,6 +84,21 @@ public async Task InitThenStatus_ShowsCurrentTag() Assert.Contains("v1.0.0", console.Output); } + [Fact] + public async Task UnknownCommand_ForwardsToBuildTarget() + { + using var workspace = GitTestWorkspace.Create(); + var console = new TestConsole(); + var exitCode = await BuildCliApplication.RunAsync( + ["Test", "--path", workspace.ParentRepo, "--url", workspace.BuildRepo], + console, + services => services.AddSingleton(_ => GitTestWorkspace.CreateClient())); + + Assert.Equal(ExitCodes.SubmoduleNotFound, exitCode); + Assert.Contains("bld init", console.Output, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Unknown command", console.Output, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task NoCommand_DefaultsToBuild_AndRequiresInitializedSubmodule() { diff --git a/tests/Ingenium.BuildCli.Tests/CommandLineDefaultsTests.cs b/tests/Ingenium.BuildCli.Tests/CommandLineDefaultsTests.cs index f249e02..773e7ac 100644 --- a/tests/Ingenium.BuildCli.Tests/CommandLineDefaultsTests.cs +++ b/tests/Ingenium.BuildCli.Tests/CommandLineDefaultsTests.cs @@ -32,4 +32,34 @@ public void Apply_TreatsLeadingOptionsAsBuildOptions() ["build", "--path", "./src", "--configuration", "Release"], CommandLineDefaults.Apply(["--path", "./src", "--configuration", "Release"])); } + + [Fact] + public void Apply_ForwardsUnknownCommandAsCakeTarget() + { + Assert.Equal(["build", "Test"], CommandLineDefaults.Apply(["Test"])); + Assert.Equal(["build", "Publish"], CommandLineDefaults.Apply(["Publish"])); + } + + [Fact] + public void Apply_KeepsCliOptionsAndForwardsCakeArguments() + { + Assert.Equal( + ["build", "Publish", "--path", "./src", "--", "--publish", "--nuget", "--token", "abc"], + CommandLineDefaults.Apply(["Publish", "--path", "./src", "--publish", "--nuget", "--token", "abc"])); + } + + [Fact] + public void Apply_NormalizesExplicitBuildCommandCakeArguments() + { + Assert.Equal( + ["build", "Test", "--configuration", "Release", "--", "--verbosity", "Diagnostic"], + CommandLineDefaults.Apply(["build", "Test", "--configuration", "Release", "--verbosity", "Diagnostic"])); + } + + [Fact] + public void IsKnownCommand_RecognizesFirstClassCommands() + { + Assert.True(CommandLineDefaults.IsKnownCommand("init")); + Assert.False(CommandLineDefaults.IsKnownCommand("Test")); + } }