From 9a4a992f7973f534709185d30857458924eb98fa Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sun, 28 Jun 2026 13:38:53 +1200 Subject: [PATCH 01/10] Rename generated global solution from nuke-global to fallout-global Debrand the dogfooding global-solution file: the generated solution and its gitignore pattern are renamed nuke-global.* -> fallout-global.* across the smart package->project reference target, the GenerateGlobalSolution build target, .gitignore, and the conventions doc. Also drop the redundant Windows-style backslash in the SolutionPath (MSBuildThisFileDirectory already ends with a separator). The .sln is generated, never committed, so there is nothing on disk to migrate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 2 +- Directory.Build.targets | 2 +- build/Build.GlobalSolution.cs | 2 +- docs/agents/conventions.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index be0d1c140..06a71992f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ external -nuke-global.* +fallout-global.* # Build Folders (you can keep bin if you'd like, to store dlls and pdbs) [Bb]in/ diff --git a/Directory.Build.targets b/Directory.Build.targets index 88ac0bd7c..8dadf10e0 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -4,7 +4,7 @@ true - $(MSBuildThisFileDirectory)\nuke-global.sln + $(MSBuildThisFileDirectory)fallout-global.sln diff --git a/build/Build.GlobalSolution.cs b/build/Build.GlobalSolution.cs index 7efdea75f..f6878b4b3 100644 --- a/build/Build.GlobalSolution.cs +++ b/build/Build.GlobalSolution.cs @@ -16,7 +16,7 @@ partial class Build { [Parameter] readonly bool UseHttps; - AbsolutePath GlobalSolution => RootDirectory / "nuke-global.sln"; + AbsolutePath GlobalSolution => RootDirectory / "fallout-global.sln"; AbsolutePath ExternalRepositoriesDirectory => RootDirectory / "external"; AbsolutePath ExternalRepositoriesFile => ExternalRepositoriesDirectory / "repositories.yml"; diff --git a/docs/agents/conventions.md b/docs/agents/conventions.md index 4bd698142..fee972aec 100644 --- a/docs/agents/conventions.md +++ b/docs/agents/conventions.md @@ -70,7 +70,7 @@ Shaped by [milestone #18](https://github.com/Fallout-build/Fallout/milestone/18) - Don't add `secret`/`default` defaults to tool JSON files (see CONTRIBUTING.md). - Don't introduce a new test framework or assertion library — stay on xUnit + FluentAssertions + Verify. - Don't commit `output/` or any `bin/`/`obj/` directory. -- Don't commit `nuke-global.sln` or other `nuke-global.*` files — they're generated by `GenerateGlobalSolution`. +- Don't commit `fallout-global.sln` or other `fallout-global.*` files — they're generated by `GenerateGlobalSolution`. - Don't bypass `Directory.Packages.props` or `Directory.Build.targets`. - Don't reintroduce `.editorconfig` or `*.DotSettings` without a maintainer-level decision — they were intentionally removed. From 668e6ace10e1dd7cf4a22d8a1a3325cb03229b7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anz=CC=8Ce=20Videnic=CC=8C?= Date: Thu, 25 Jun 2026 13:57:26 +0200 Subject: [PATCH 02/10] Add DefaultShell to pin the shell for GitHub Actions run steps Cross-platform matrix jobs silently use a different default shell per OS (bash on Linux/macOS, pwsh on Windows), which changes script semantics. The generator had no way to emit an explicit shell. Add a public string DefaultShell to GitHubActionsAttribute. When set, the workflow emits a top-level defaults.run.shell block (after concurrency, before jobs), pinning one shell for every run: step across all matrix jobs. Free-string value; unset or whitespace-only emits no block. Per-step shell was deliberately scoped out (one run step per job; no granularity gain). Covered by two Verify snapshot cases (default-shell, and a default-shell-with-permissions ordering guard). --- .../GitHubActionsConfiguration.cs | 16 +++++ .../GitHubActions/GitHubActionsAttribute.cs | 10 +++ ...ribute=GitHubActionsAttribute.verified.txt | 68 +++++++++++++++++++ ...ribute=GitHubActionsAttribute.verified.txt | 60 ++++++++++++++++ .../CI/ConfigurationGenerationSpecs.cs | 27 ++++++++ 5 files changed, 181 insertions(+) create mode 100644 tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell-with-permissions_attribute=GitHubActionsAttribute.verified.txt create mode 100644 tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell_attribute=GitHubActionsAttribute.verified.txt diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsConfiguration.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsConfiguration.cs index 7c336a6f7..4af60abbd 100644 --- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsConfiguration.cs +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsConfiguration.cs @@ -15,6 +15,7 @@ public class GitHubActionsConfiguration : ConfigurationEntity public (GitHubActionsPermissions Type, string Permission)[] Permissions { get; set; } public string ConcurrencyGroup { get; set; } public bool ConcurrencyCancelInProgress { get; set; } + public string DefaultShell { get; set; } public GitHubActionsJob[] Jobs { get; set; } public override void Write(CustomFileWriter writer) @@ -75,6 +76,21 @@ public override void Write(CustomFileWriter writer) } } + if (!DefaultShell.IsNullOrWhiteSpace()) + { + writer.WriteLine(); + // defaults.run currently carries only shell; further run defaults (e.g. working-directory) slot in here + writer.WriteLine("defaults:"); + using (writer.Indent()) + { + writer.WriteLine("run:"); + using (writer.Indent()) + { + writer.WriteLine($"shell: {DefaultShell}"); + } + } + } + writer.WriteLine(); writer.WriteLine("jobs:"); diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs index 54bf1f241..0051b3d34 100644 --- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs @@ -91,6 +91,15 @@ public GitHubActionsAttribute( public string JobConcurrencyGroup { get; set; } public bool JobConcurrencyCancelInProgress { get; set; } + /// + /// Pins the shell for every run: step via a workflow-level defaults.run.shell block, + /// so cross-platform matrix jobs use one consistent shell instead of the per-OS default (bash + /// on Linux/macOS, pwsh on Windows). Accepts any value GitHub allows — a built-in (bash, + /// pwsh, sh, cmd, powershell, python) or a custom command {0} + /// template. Unset or whitespace-only emits no defaults: block. + /// + public string DefaultShell { get; set; } + public string[] InvokedTargets { get; set; } = new string[0]; /// @@ -179,6 +188,7 @@ public override ConfigurationEntity GetConfiguration(IReadOnlyCollection (x, "read"))).ToArray(), ConcurrencyGroup = ConcurrencyGroup, ConcurrencyCancelInProgress = ConcurrencyCancelInProgress, + DefaultShell = DefaultShell, Jobs = _images.Select(x => GetJobs(x, relevantTargets)).ToArray() }; diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell-with-permissions_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell-with-permissions_attribute=GitHubActionsAttribute.verified.txt new file mode 100644 index 000000000..6b8e51885 --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell-with-permissions_attribute=GitHubActionsAttribute.verified.txt @@ -0,0 +1,68 @@ +# ------------------------------------------------------------------------------ +# +# +# This code was generated. +# +# - To turn off auto-generation set: +# +# [TestGitHubActions (AutoGenerate = false)] +# +# - To trigger manual generation invoke: +# +# fallout --generate-configuration GitHubActions_test --host GitHubActions +# +# +# ------------------------------------------------------------------------------ + +name: test + +on: [push] + +permissions: + contents: write + actions: read + +concurrency: + group: ${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.run_id }} + cancel-in-progress: true + +defaults: + run: + shell: pwsh + +jobs: + ubuntu-latest: + name: ubuntu-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: 'Cache: .fallout/temp, ~/.nuget/packages' + uses: actions/cache@v4 + with: + path: | + .fallout/temp + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Setup: .NET SDK' + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - name: 'Restore: dotnet tools' + run: dotnet tool restore + - name: 'Run: Test' + run: dotnet fallout Test + - name: 'Publish: src' + uses: actions/upload-artifact@v5 + with: + name: src + path: src + - name: 'Publish: test-results' + uses: actions/upload-artifact@v5 + with: + name: test-results + path: output/test-results + - name: 'Publish: coverage-report.zip' + uses: actions/upload-artifact@v5 + with: + name: coverage-report.zip + path: output/coverage-report.zip diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell_attribute=GitHubActionsAttribute.verified.txt new file mode 100644 index 000000000..858b288bf --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell_attribute=GitHubActionsAttribute.verified.txt @@ -0,0 +1,60 @@ +# ------------------------------------------------------------------------------ +# +# +# This code was generated. +# +# - To turn off auto-generation set: +# +# [TestGitHubActions (AutoGenerate = false)] +# +# - To trigger manual generation invoke: +# +# fallout --generate-configuration GitHubActions_test --host GitHubActions +# +# +# ------------------------------------------------------------------------------ + +name: test + +on: [push] + +defaults: + run: + shell: pwsh + +jobs: + ubuntu-latest: + name: ubuntu-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: 'Cache: .fallout/temp, ~/.nuget/packages' + uses: actions/cache@v4 + with: + path: | + .fallout/temp + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Setup: .NET SDK' + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - name: 'Restore: dotnet tools' + run: dotnet tool restore + - name: 'Run: Test' + run: dotnet fallout Test + - name: 'Publish: src' + uses: actions/upload-artifact@v5 + with: + name: src + path: src + - name: 'Publish: test-results' + uses: actions/upload-artifact@v5 + with: + name: test-results + path: output/test-results + - name: 'Publish: coverage-report.zip' + uses: actions/upload-artifact@v5 + with: + name: coverage-report.zip + path: output/coverage-report.zip diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs index 0c11339a5..dbb6b13ba 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs @@ -223,6 +223,33 @@ public class TestBuild : FalloutBuild } ); + yield return + ( + "default-shell", + new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + On = new[] { GitHubActionsTrigger.Push }, + InvokedTargets = new[] { nameof(Test) }, + DefaultShell = "pwsh" + } + ); + + // Ordering guard: with DefaultShell, permissions, and concurrency all set, the defaults: + // block must be emitted after concurrency: and before jobs:, with correct blank lines. + yield return + ( + "default-shell-with-permissions", + new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + On = new[] { GitHubActionsTrigger.Push }, + InvokedTargets = new[] { nameof(Test) }, + DefaultShell = "pwsh", + WritePermissions = new[] { GitHubActionsPermissions.Contents }, + ReadPermissions = new[] { GitHubActionsPermissions.Actions }, + ConcurrencyCancelInProgress = true + } + ); + yield return ( null, From 08d1bf099c5b89b112403a7a544207b8e64d255d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anz=CC=8Ce=20Videnic=CC=8C?= Date: Thu, 25 Jun 2026 16:14:53 +0200 Subject: [PATCH 03/10] Add CheckoutWith escape hatch for extra actions/checkout inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHubActionsAttribute gains a string[] CheckoutWith property whose entries are emitted verbatim inside the checkout step's with: block, after the typed keys (submodules, lfs, fetch-depth, progress, filter, ref/repository) and in the order supplied. This unblocks actions/checkout inputs the typed knobs don't cover (token, ssh-key, path, clean, persist-credentials, sparse-checkout, set-safe-directory). Raw passthrough, no validation — the build author owns the YAML, which also lets multi-line block scalars like `sparse-checkout: |` work, something a KEY: value validator would reject. Defaults to an empty array and emits nothing when unset, so existing workflows are byte-for-byte unchanged. Closes #389. --- .../GitHubActionsCheckoutStep.cs | 7 ++- .../GitHubActions/GitHubActionsAttribute.cs | 16 ++++- ...ribute=GitHubActionsAttribute.verified.txt | 58 ++++++++++++++++++ ...ribute=GitHubActionsAttribute.verified.txt | 60 ++++++++++++++++++ ...ribute=GitHubActionsAttribute.verified.txt | 61 +++++++++++++++++++ .../CI/ConfigurationGenerationSpecs.cs | 49 +++++++++++++++ 6 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-only_attribute=GitHubActionsAttribute.verified.txt create mode 100644 tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-sparse_attribute=GitHubActionsAttribute.verified.txt create mode 100644 tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with_attribute=GitHubActionsAttribute.verified.txt diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCheckoutStep.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCheckoutStep.cs index 2f2cdb624..6c7c06e66 100644 --- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCheckoutStep.cs +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCheckoutStep.cs @@ -24,12 +24,14 @@ public class GitHubActionsCheckoutStep : GitHubActionsStep /// public string Ref { get; set; } + public string[] CheckoutWith { get; set; } = new string[0]; + public override void Write(CustomFileWriter writer) { writer.WriteLine("- uses: actions/checkout@v6"); if (Submodules.HasValue || Lfs.HasValue || FetchDepth.HasValue || Progress.HasValue || - !Filter.IsNullOrWhiteSpace() || !Ref.IsNullOrWhiteSpace()) + !Filter.IsNullOrWhiteSpace() || !Ref.IsNullOrWhiteSpace() || CheckoutWith.Length > 0) { using (writer.Indent()) { @@ -57,6 +59,9 @@ public override void Write(CustomFileWriter writer) writer.WriteLine("repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}"); writer.WriteLine($"ref: {Ref}"); } + + foreach (var line in CheckoutWith) + writer.WriteLine(line); } } } diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs index 0051b3d34..14d961302 100644 --- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs @@ -158,6 +158,19 @@ public string CheckoutRef get => throw new NotSupportedException(); } + /// + /// Extra actions/checkout inputs, emitted verbatim inside the step's with: block after + /// the typed keys (submodules, lfs, fetch-depth, progress, filter, + /// ref/repository). An escape hatch for inputs the typed knobs don't cover — token, + /// ssh-key, path, clean, persist-credentials, sparse-checkout, + /// set-safe-directory. + /// + /// Each entry is one raw line — passed through unvalidated, so the caller owns correct YAML. Multi-line + /// block scalars work by supplying the key (e.g. sparse-checkout: |) and each continuation line + /// as separate entries, with the caller's own indentation preserved. Empty emits nothing. + /// + public string[] CheckoutWith { get; set; } = new string[0]; + public override CustomFileWriter CreateWriter(StreamWriter streamWriter) { return new CustomFileWriter(streamWriter, indentationFactor: 2, commentPrefix: "#"); @@ -229,7 +242,8 @@ private IEnumerable GetSteps(IReadOnlyCollection +# +# This code was generated. +# +# - To turn off auto-generation set: +# +# [TestGitHubActions (AutoGenerate = false)] +# +# - To trigger manual generation invoke: +# +# fallout --generate-configuration GitHubActions_test --host GitHubActions +# +# +# ------------------------------------------------------------------------------ + +name: test + +on: [push] + +jobs: + ubuntu-latest: + name: ubuntu-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + path: src + - name: 'Cache: .fallout/temp, ~/.nuget/packages' + uses: actions/cache@v4 + with: + path: | + .fallout/temp + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Setup: .NET SDK' + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - name: 'Restore: dotnet tools' + run: dotnet tool restore + - name: 'Run: Test' + run: dotnet fallout Test + - name: 'Publish: src' + uses: actions/upload-artifact@v5 + with: + name: src + path: src + - name: 'Publish: test-results' + uses: actions/upload-artifact@v5 + with: + name: test-results + path: output/test-results + - name: 'Publish: coverage-report.zip' + uses: actions/upload-artifact@v5 + with: + name: coverage-report.zip + path: output/coverage-report.zip diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-sparse_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-sparse_attribute=GitHubActionsAttribute.verified.txt new file mode 100644 index 000000000..f6d1be72b --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-sparse_attribute=GitHubActionsAttribute.verified.txt @@ -0,0 +1,60 @@ +# ------------------------------------------------------------------------------ +# +# +# This code was generated. +# +# - To turn off auto-generation set: +# +# [TestGitHubActions (AutoGenerate = false)] +# +# - To trigger manual generation invoke: +# +# fallout --generate-configuration GitHubActions_test --host GitHubActions +# +# +# ------------------------------------------------------------------------------ + +name: test + +on: [push] + +jobs: + ubuntu-latest: + name: ubuntu-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: | + src + build + - name: 'Cache: .fallout/temp, ~/.nuget/packages' + uses: actions/cache@v4 + with: + path: | + .fallout/temp + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Setup: .NET SDK' + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - name: 'Restore: dotnet tools' + run: dotnet tool restore + - name: 'Run: Test' + run: dotnet fallout Test + - name: 'Publish: src' + uses: actions/upload-artifact@v5 + with: + name: src + path: src + - name: 'Publish: test-results' + uses: actions/upload-artifact@v5 + with: + name: test-results + path: output/test-results + - name: 'Publish: coverage-report.zip' + uses: actions/upload-artifact@v5 + with: + name: coverage-report.zip + path: output/coverage-report.zip diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with_attribute=GitHubActionsAttribute.verified.txt new file mode 100644 index 000000000..1a694dd61 --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with_attribute=GitHubActionsAttribute.verified.txt @@ -0,0 +1,61 @@ +# ------------------------------------------------------------------------------ +# +# +# This code was generated. +# +# - To turn off auto-generation set: +# +# [TestGitHubActions (AutoGenerate = false)] +# +# - To trigger manual generation invoke: +# +# fallout --generate-configuration GitHubActions_test --host GitHubActions +# +# +# ------------------------------------------------------------------------------ + +name: test + +on: [push] + +jobs: + ubuntu-latest: + name: ubuntu-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.CI_PAT }} + path: src + persist-credentials: false + - name: 'Cache: .fallout/temp, ~/.nuget/packages' + uses: actions/cache@v4 + with: + path: | + .fallout/temp + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Setup: .NET SDK' + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - name: 'Restore: dotnet tools' + run: dotnet tool restore + - name: 'Run: Test' + run: dotnet fallout Test + - name: 'Publish: src' + uses: actions/upload-artifact@v5 + with: + name: src + path: src + - name: 'Publish: test-results' + uses: actions/upload-artifact@v5 + with: + name: test-results + path: output/test-results + - name: 'Publish: coverage-report.zip' + uses: actions/upload-artifact@v5 + with: + name: coverage-report.zip + path: output/coverage-report.zip diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs index dbb6b13ba..f1ce14ccc 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs @@ -212,6 +212,55 @@ public class TestBuild : FalloutBuild } ); + // Ordering guard: extra CheckoutWith inputs emit verbatim inside the with: block, after + // every typed key (here fetch-depth) and in the order supplied. + yield return + ( + "checkout-with", + new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + On = new[] { GitHubActionsTrigger.Push }, + InvokedTargets = new[] { nameof(Test) }, + FetchDepth = 0, + CheckoutWith = new[] + { + "token: ${{ secrets.CI_PAT }}", + "path: src", + "persist-credentials: false" + } + } + ); + + // Guard: CheckoutWith with no typed checkout key must still open the with: block. + yield return + ( + "checkout-with-only", + new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + On = new[] { GitHubActionsTrigger.Push }, + InvokedTargets = new[] { nameof(Test) }, + CheckoutWith = new[] { "path: src" } + } + ); + + // Multi-line block scalar: the case a 'KEY: value' validator would reject. Proves raw + // verbatim emission preserves the caller-supplied continuation lines and indentation. + yield return + ( + "checkout-with-sparse", + new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + On = new[] { GitHubActionsTrigger.Push }, + InvokedTargets = new[] { nameof(Test) }, + CheckoutWith = new[] + { + "sparse-checkout: |", + " src", + " build" + } + } + ); + yield return ( "runs-on-labels", From 696c376d337508a5159c4161763415a57214c322 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 30 Jun 2026 21:40:05 +1200 Subject: [PATCH 04/10] Point Repobeats embed at fallout-build/fallout The embed hash was still bound to the old chrisonsimtian/fallout repo. Regenerated against the fallout-build org so the activity image tracks the canonical repo's commits, issues, and PRs. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 08d2d197a..f173fdc2d 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Multi-provider CI support (Azure Pipelines, GitLab, TeamCity, AppVeyor) was remo ### Commits, issues, PRs (rolling 30 days) -![Repobeats analytics image](https://repobeats.axiom.co/api/embed/13bc62ac87b75cea7be4bdbbcba90af620665333.svg "Repobeats analytics image") +![Repobeats analytics image](https://repobeats.axiom.co/api/embed/c4ea2e2211409a86c7dba874c3ed6aa629efe700.svg "Repobeats analytics image") Generated by [Repobeats](https://repobeats.axiom.co). From f57634c5636bf5ba4fd0f49eaf0f8360697abcb3 Mon Sep 17 00:00:00 2001 From: Lukas Gasselsberger | alu-one Date: Thu, 25 Jun 2026 07:13:03 +0200 Subject: [PATCH 05/10] Allow UTF-8 console input Move the valid console key check into an extension and add test cases All credits going to: @rus-art Taken from here: https://github.com/nuke-build/nuke/pull/1321 --- src/Fallout.Build/Utilities/ConsoleUtility.cs | 6 ++-- .../Utilities/ConsoleUtilityExtensions.cs | 17 ++++++++++ .../ConsoleUtilityExtensionsSpecs.cs | 33 +++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 src/Fallout.Build/Utilities/ConsoleUtilityExtensions.cs create mode 100644 tests/Fallout.Build.Tests/Utilities/ConsoleUtilityExtensionsSpecs.cs diff --git a/src/Fallout.Build/Utilities/ConsoleUtility.cs b/src/Fallout.Build/Utilities/ConsoleUtility.cs index 1e59cb972..7d166d767 100644 --- a/src/Fallout.Build/Utilities/ConsoleUtility.cs +++ b/src/Fallout.Build/Utilities/ConsoleUtility.cs @@ -47,15 +47,13 @@ public static string PromptForInput(string question, string defaultValue) } key = Console.ReadKey(intercept: true); - if (ConsoleKey.A <= key.Key && key.Key <= ConsoleKey.Z - || ConsoleKey.D0 <= key.Key && key.Key <= ConsoleKey.D9 - || new[] { '.', '/', '\\', '_', '-' }.Any(x => x == key.KeyChar)) + if (key.IsValidInputKey()) input.Append(key.KeyChar); else if (key.Key == ConsoleKey.Backspace && input.Length > 0) input.Remove(input.Length - 1, length: 1); else if (key.Key == InterruptKey) s_interrupted = true; - } while (!(key.Key == ConfirmationKey || key.Key == InterruptKey)); + } while (key.Key is not (ConfirmationKey or InterruptKey)); var result = input.Length > 0 ? input.ToString() : defaultValue; Console.CursorLeft = 0; diff --git a/src/Fallout.Build/Utilities/ConsoleUtilityExtensions.cs b/src/Fallout.Build/Utilities/ConsoleUtilityExtensions.cs new file mode 100644 index 000000000..b080d8a93 --- /dev/null +++ b/src/Fallout.Build/Utilities/ConsoleUtilityExtensions.cs @@ -0,0 +1,17 @@ +using System; +using System.Linq; + +namespace Fallout.Common.Utilities; + +public static class ConsoleUtilityExtensions +{ + private static readonly char[] AllowedSpecialCharacters = ['.', '/', '\\', '_', '-']; + + public static bool IsValidInputKey(this ConsoleKeyInfo key) + { + return key.Key is >= ConsoleKey.A and <= ConsoleKey.Z + || key.Key is >= ConsoleKey.D0 and <= ConsoleKey.D9 + || AllowedSpecialCharacters.Any(x => x == key.KeyChar) + || char.IsLetterOrDigit(key.KeyChar); + } +} diff --git a/tests/Fallout.Build.Tests/Utilities/ConsoleUtilityExtensionsSpecs.cs b/tests/Fallout.Build.Tests/Utilities/ConsoleUtilityExtensionsSpecs.cs new file mode 100644 index 000000000..8c19b3db7 --- /dev/null +++ b/tests/Fallout.Build.Tests/Utilities/ConsoleUtilityExtensionsSpecs.cs @@ -0,0 +1,33 @@ +using Fallout.Common.Utilities; +using Xunit; +using System; +using FluentAssertions; + +namespace Fallout.Build.Tests.Utilities; + +public class ConsoleUtilityExtensionsSpecs +{ + [Theory] + [InlineData(ConsoleKey.A, 'a', true)] + [InlineData(ConsoleKey.Z, 'z', true)] + [InlineData(ConsoleKey.D0, '0', true)] + [InlineData(ConsoleKey.D9, '9', true)] + [InlineData(ConsoleKey.Enter, '\r', false)] + [InlineData(ConsoleKey.Backspace, '\b', false)] + [InlineData(ConsoleKey.F8, '\0', false)] + [InlineData(ConsoleKey.Spacebar, ' ', false)] + [InlineData(ConsoleKey.OemPeriod, '.', true)] + [InlineData(ConsoleKey.Divide, '/', true)] + [InlineData(ConsoleKey.OemMinus, '-', true)] + [InlineData(ConsoleKey.NoName, 'ä', true)] + [InlineData(ConsoleKey.NoName, 'ö', true)] + [InlineData(ConsoleKey.NoName, '中', true)] + [InlineData(ConsoleKey.NoName, 'д', true)] + [InlineData(ConsoleKey.NoName, 'α', true)] + public void Valid_characters_are_accepted_as_input(ConsoleKey key, char keyChar, bool expected) + { + var keyInfo = new ConsoleKeyInfo(keyChar, key, false, false, false); + var result = keyInfo.IsValidInputKey(); + result.Should().Be(expected); + } +} From 9b6260c6f7970e34c159714302e88088a79befdd Mon Sep 17 00:00:00 2001 From: Lukas Gasselsberger | alu-one Date: Wed, 1 Jul 2026 13:31:32 +0200 Subject: [PATCH 06/10] Support for Windows not on the C drive --- src/Fallout.Tooling/ToolPathResolver.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Fallout.Tooling/ToolPathResolver.cs b/src/Fallout.Tooling/ToolPathResolver.cs index 15dc0bb43..956253c4d 100644 --- a/src/Fallout.Tooling/ToolPathResolver.cs +++ b/src/Fallout.Tooling/ToolPathResolver.cs @@ -24,7 +24,7 @@ public static string GetPathExecutable(string pathExecutable) return Path.GetFullPath(pathExecutable); var locateExecutable = EnvironmentInfo.IsWin - ? @"C:\Windows\System32\where.exe" + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "where.exe") : "/usr/bin/which"; if (!File.Exists(locateExecutable)) From 088ed19edabe52e80d4d227b5cfad5860f48031e Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Wed, 17 Jun 2026 21:42:56 +1200 Subject: [PATCH 07/10] Introduce IFalloutCommand dispatch with DI for Fallout.Cli Replace the reflection-over-Program command dispatch (the partial god-class described in #392) with a typed command abstraction resolved through Microsoft.Extensions.DependencyInjection. This is the foundation PR: it lands the abstraction, the dispatcher, the prompt service, and the first real command conversion, with the remaining handlers converted one per follow-up PR. - Add public IFalloutCommand (Name + Execute) and a CommandDispatcher that resolves by name, dash- and case-insensitively, preserving every spelling the old reflection accepted (:add-package == :addpackage, :PopDirectory, ...). - Move the Spectre prompt/render helpers off Program into an injectable IConsolePrompts / SpectreConsolePrompts (namespace Fallout.Cli.Prompts to avoid colliding with System.Console). Program keeps thin static delegators so the not-yet-extracted handlers compile; the last conversion deletes them. - Convert Run into a real RunCommand type; delete Program.Run.cs. - Adapt the 13 still-legacy handlers via a transitional DelegateCommand so the registry and dispatch are uniform from day one. Each future PR deletes one registration line plus its Program.X.cs partial. - Delete the reflection dispatch and its "add assertions about return type and parameters" TODO; typed commands make signature-mismatch dispatch impossible. - Add CommandDispatcherTests (first-ever dispatch coverage): name matching, dash/case insensitivity, exit-code passthrough, unknown-command listing, empty token, and all default-routing branches. - Add .vscode launch/build tasks for debugging the global tool from source. Co-Authored-By: Claude Opus 4.8 (1M context) --- .vscode/launch.json | 47 ++++ .vscode/tasks.json | 17 ++ Directory.Packages.props | 1 + src/Fallout.Cli/CommandDispatcher.cs | 69 ++++++ src/Fallout.Cli/Commands/DelegateCommand.cs | 27 +++ src/Fallout.Cli/Commands/IFalloutCommand.cs | 30 +++ .../RunCommand.cs} | 14 +- src/Fallout.Cli/Fallout.Cli.csproj | 1 + src/Fallout.Cli/Program.cs | 172 +++++--------- src/Fallout.Cli/Prompts/IConsolePrompts.cs | 39 ++++ .../Prompts/SpectreConsolePrompts.cs | 95 ++++++++ .../CommandDispatcherSpecs.cs | 217 ++++++++++++++++++ 12 files changed, 608 insertions(+), 121 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 src/Fallout.Cli/CommandDispatcher.cs create mode 100644 src/Fallout.Cli/Commands/DelegateCommand.cs create mode 100644 src/Fallout.Cli/Commands/IFalloutCommand.cs rename src/Fallout.Cli/{Program.Run.cs => Commands/RunCommand.cs} (87%) create mode 100644 src/Fallout.Cli/Prompts/IConsolePrompts.cs create mode 100644 src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs create mode 100644 tests/Fallout.Cli.Specs/CommandDispatcherSpecs.cs diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..577166d43 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,47 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Prompts you for the command/args each launch, e.g. ":get-configuration" or ":bogus". + "name": "fallout (ask for args)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-cli", + "program": "${workspaceFolder}/src/Fallout.Cli/bin/Debug/net10.0/Fallout.Cli.dll", + "args": "${input:falloutArgs}", + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false + }, + { + // Dispatch error path — prints the command manifest, no side effects. + "name": "fallout :bogus", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-cli", + "program": "${workspaceFolder}/src/Fallout.Cli/bin/Debug/net10.0/Fallout.Cli.dll", + "args": [":bogus"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal" + }, + { + // Real command via the DelegateCommand path — read-only. + "name": "fallout :get-configuration", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-cli", + "program": "${workspaceFolder}/src/Fallout.Cli/bin/Debug/net10.0/Fallout.Cli.dll", + "args": [":get-configuration"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal" + } + ], + "inputs": [ + { + "id": "falloutArgs", + "type": "promptString", + "description": "Arguments to pass to fallout (space-separated), e.g. ':get-configuration'", + "default": ":bogus" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..e19bb9547 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,17 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build-cli", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/Fallout.Cli/Fallout.Cli.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/Directory.Packages.props b/Directory.Packages.props index 644d92315..5a1a8e0e3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ + diff --git a/src/Fallout.Cli/CommandDispatcher.cs b/src/Fallout.Cli/CommandDispatcher.cs new file mode 100644 index 000000000..86ce2c430 --- /dev/null +++ b/src/Fallout.Cli/CommandDispatcher.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Fallout.Cli.Commands; +using Fallout.Cli.Prompts; +using Fallout.Common; +using Fallout.Common.IO; +using Fallout.Common.Utilities; +using Fallout.Common.Utilities.Collections; + +namespace Fallout.Cli; + +/// +/// Routes a command-line invocation to the matching . Commands are +/// resolved by from the registered set — dash- and +/// case-insensitively, preserving every spelling the historical reflection dispatch accepted +/// (e.g. :add-package and :addpackage, :PopDirectory and :popdirectory). +/// +internal sealed class CommandDispatcher +{ + private const char CommandPrefix = ':'; + + private readonly IReadOnlyList _commands; + private readonly IConsolePrompts _prompts; + + public CommandDispatcher(IEnumerable commands, IConsolePrompts prompts) + { + _commands = commands.ToList(); + _prompts = prompts; + } + + public int Dispatch(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + var hasCommand = args.FirstOrDefault()?.StartsWithOrdinalIgnoreCase(CommandPrefix.ToString()) ?? false; + if (hasCommand) + { + var token = args.First().Trim(CommandPrefix); + if (string.IsNullOrWhiteSpace(token)) + Assert.Fail($"No command specified. Usage is: fallout {CommandPrefix} [args]"); + + var command = Resolve(token); + return command.Execute(args.Skip(count: 1).ToArray(), rootDirectory, buildScript); + } + + if (rootDirectory == null) + { + return _prompts.PromptForConfirmation( + $"Could not find {Constants.FalloutDirectoryName} directory/file. Do you want to setup a build?") + ? GetRequired("setup").Execute(Array.Empty(), rootDirectory: null, buildScript: null) + : 0; + } + + // TODO: docker + + return GetRequired("run").Execute(args, rootDirectory, BuildProjectResolver.Resolve(rootDirectory)); + } + + private IFalloutCommand Resolve(string token) + { + return _commands.SingleOrDefault(x => Normalize(x.Name).EqualsOrdinalIgnoreCase(Normalize(token))) + .NotNull(new[] { $"Command '{token}' is not supported, available commands are:" } + .Concat(_commands.Select(x => $" - {x.Name}").OrderBy(x => x)).JoinNewLine()); + } + + private IFalloutCommand GetRequired(string name) + => _commands.Single(x => x.Name.EqualsOrdinalIgnoreCase(name)); + + private static string Normalize(string value) => value.Replace("-", string.Empty); +} diff --git a/src/Fallout.Cli/Commands/DelegateCommand.cs b/src/Fallout.Cli/Commands/DelegateCommand.cs new file mode 100644 index 000000000..5a60e8559 --- /dev/null +++ b/src/Fallout.Cli/Commands/DelegateCommand.cs @@ -0,0 +1,27 @@ +using System; +using Fallout.Common.IO; + +namespace Fallout.Cli.Commands; + +/// +/// Transitional adapter that exposes a not-yet-extracted Program.X handler as an +/// , so the dispatcher can route every command uniformly through the +/// registry while the per-command conversion (issue #392) lands one PR at a time. Each conversion +/// replaces one registration of this adapter with a real command type; the adapter is deleted once +/// the last legacy handler is gone. +/// +internal sealed class DelegateCommand : IFalloutCommand +{ + private readonly Func _handler; + + public DelegateCommand(string name, Func handler) + { + Name = name; + _handler = handler; + } + + public string Name { get; } + + public int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => _handler(args, rootDirectory, buildScript); +} diff --git a/src/Fallout.Cli/Commands/IFalloutCommand.cs b/src/Fallout.Cli/Commands/IFalloutCommand.cs new file mode 100644 index 000000000..04f027d7d --- /dev/null +++ b/src/Fallout.Cli/Commands/IFalloutCommand.cs @@ -0,0 +1,30 @@ +using Fallout.Common.IO; + +namespace Fallout.Cli.Commands; + +/// +/// A single global-tool command (e.g. fallout :run, fallout :setup). +/// One command = one type, resolved by from the dependency-injection +/// container — replacing the historical reflection-over-Program dispatch. +/// +/// +/// This surface is intentionally minimal. It is not a stable public plugin contract yet; +/// when a public command SDK lands (milestone #7) the API will be annotated and versioned +/// explicitly. Until then, treat additions here as internal-by-convention. +/// +public interface IFalloutCommand +{ + /// + /// The canonical command name as typed after the : prefix, in dash form + /// (e.g. "run", "add-package", "cake-convert"). Matched case-insensitively. + /// + string Name { get; } + + /// + /// Executes the command and returns the process exit code. + /// + /// The arguments following the command token. + /// The resolved repository root, or null when none was found. + /// The resolved build script / project file, or null when none applies. + int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript); +} diff --git a/src/Fallout.Cli/Program.Run.cs b/src/Fallout.Cli/Commands/RunCommand.cs similarity index 87% rename from src/Fallout.Cli/Program.Run.cs rename to src/Fallout.Cli/Commands/RunCommand.cs index a9aefc998..28982c927 100644 --- a/src/Fallout.Cli/Program.Run.cs +++ b/src/Fallout.Cli/Commands/RunCommand.cs @@ -8,11 +8,17 @@ using Fallout.Common.Utilities; using static Fallout.Common.Constants; -namespace Fallout.Cli; +namespace Fallout.Cli.Commands; -partial class Program +/// +/// fallout :run (and the default, command-less invocation): builds the build project and +/// runs it, forwarding any remaining arguments to the build. +/// +public sealed class RunCommand : IFalloutCommand { - private static int Run(string[] forwardedArgs, AbsolutePath rootDirectory, AbsolutePath buildProjectFile) + public string Name => "run"; + + public int Execute(string[] forwardedArgs, AbsolutePath rootDirectory, AbsolutePath buildProjectFile) { var dotnet = ResolveDotnet(rootDirectory); @@ -63,7 +69,7 @@ private static int StartDotnet(string dotnet, IEnumerable arguments) startInfo.Environment["DOTNET_NOLOGO"] = "1"; startInfo.Environment["DOTNET_ROLL_FORWARD"] = "Major"; startInfo.Environment["FALLOUT_TELEMETRY_OPTOUT"] = "1"; - startInfo.Environment[GlobalToolVersionEnvironmentKey] = typeof(Program).Assembly.GetVersionText(); + startInfo.Environment[GlobalToolVersionEnvironmentKey] = typeof(RunCommand).Assembly.GetVersionText(); startInfo.Environment[GlobalToolStartTimeEnvironmentKey] = DateTime.Now.ToString("O"); var process = Process.Start(startInfo).NotNull(); diff --git a/src/Fallout.Cli/Fallout.Cli.csproj b/src/Fallout.Cli/Fallout.Cli.csproj index 996a07f13..60071de75 100644 --- a/src/Fallout.Cli/Fallout.Cli.csproj +++ b/src/Fallout.Cli/Fallout.Cli.csproj @@ -17,6 +17,7 @@ + diff --git a/src/Fallout.Cli/Program.cs b/src/Fallout.Cli/Program.cs index 828b31870..6702b0380 100644 --- a/src/Fallout.Cli/Program.cs +++ b/src/Fallout.Cli/Program.cs @@ -1,18 +1,18 @@ -using System; +using System; using System.IO; using System.Linq; using System.Text; +using Fallout.Cli.Commands; +using Fallout.Cli.Prompts; using Fallout.Common; using Fallout.Common.IO; using Fallout.Common.Utilities; -using Spectre.Console; +using Microsoft.Extensions.DependencyInjection; namespace Fallout.Cli; public partial class Program { - private const char CommandPrefix = ':'; - private static string CurrentBuildScriptName => EnvironmentInfo.IsWin ? "build.ps1" : "build.sh"; private static int Main(string[] args) @@ -28,7 +28,8 @@ private static int Main(string[] args) .FirstOrDefault(x => Constants.TryGetRootDirectoryFrom(x.Parent) == rootDirectory) : null; - return Handle(args, rootDirectory, buildScript); + using var services = BuildServiceProvider(); + return services.GetRequiredService().Dispatch(args, rootDirectory, buildScript); } catch (Exception exception) { @@ -37,6 +38,40 @@ private static int Main(string[] args) } } + private static ServiceProvider BuildServiceProvider() + { + var services = new ServiceCollection(); + + services.AddSingleton(); + services.AddSingleton(); + RegisterCommands(services); + + return services.BuildServiceProvider(); + } + + private static void RegisterCommands(IServiceCollection services) + { + // Real command types — issue #392 converts one legacy handler per PR. + services.AddSingleton(); + + // Legacy handlers still living on Program, adapted until they are extracted into command + // types. Each conversion deletes one line here plus its Program.X.cs partial. + services.AddSingleton(new DelegateCommand("setup", Setup)); + services.AddSingleton(new DelegateCommand("update", Update)); + services.AddSingleton(new DelegateCommand("add-package", AddPackage)); + services.AddSingleton(new DelegateCommand("cake-convert", CakeConvert)); + services.AddSingleton(new DelegateCommand("cake-clean", CakeClean)); + services.AddSingleton(new DelegateCommand("complete", Complete)); + services.AddSingleton(new DelegateCommand("get-configuration", GetConfiguration)); + services.AddSingleton(new DelegateCommand("secrets", Secrets)); + services.AddSingleton(new DelegateCommand("trigger", Trigger)); + services.AddSingleton(new DelegateCommand("GetNextDirectory", (_, _, _) => GetNextDirectory())); + services.AddSingleton(new DelegateCommand("PopDirectory", (_, _, _) => PopDirectory())); + services.AddSingleton(new DelegateCommand("PushWithCurrentRootDirectory", (_, rootDirectory, _) => PushWithCurrentRootDirectory(rootDirectory))); + services.AddSingleton(new DelegateCommand("PushWithParentRootDirectory", (_, rootDirectory, _) => PushWithParentRootDirectory(rootDirectory))); + services.AddSingleton(new DelegateCommand("PushWithChosenRootDirectory", (_, _, _) => PushWithChosenRootDirectory())); + } + private static void PrintInfo() { Host.Information($"Fallout Global Tool 🌐 {typeof(Program).Assembly.GetInformationalText()}"); @@ -45,7 +80,7 @@ private static void PrintInfo() private static AbsolutePath TryGetRootDirectory() { // TODO: copied in FalloutBuild.GetRootDirectory - var parameterValue = EnvironmentInfo.GetNamedArgument(Constants.RootDirectoryParameterName); + AbsolutePath parameterValue = EnvironmentInfo.GetNamedArgument(Constants.RootDirectoryParameterName); if (parameterValue != null) return parameterValue; @@ -55,115 +90,18 @@ private static AbsolutePath TryGetRootDirectory() return Constants.TryGetRootDirectoryFrom(Directory.GetCurrentDirectory()); } - private static int Handle(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) - { - var hasCommand = args.FirstOrDefault()?.StartsWithOrdinalIgnoreCase(CommandPrefix.ToString()) ?? false; - if (hasCommand) - { - var command = args.First().Trim(CommandPrefix).Replace("-", string.Empty); - if (string.IsNullOrWhiteSpace(command)) - Assert.Fail($"No command specified. Usage is: nuke {CommandPrefix} [args]"); - - var availableCommands = typeof(Program).GetMethods(ReflectionUtility.Static).Where(x => x.ReturnType == typeof(int)).ToList(); - var commandHandler = availableCommands.SingleOrDefault(x => x.Name.EqualsOrdinalIgnoreCase(command)) - .NotNull(new[] { $"Command '{command}' is not supported, available commands are:" } - .Concat(availableCommands.Where(x => x.IsPublic).Select(x => $" - {x.Name}").OrderBy(x => x)).JoinNewLine()); - // TODO: add assertions about return type and parameters - - var commandArguments = new object[] { args.Skip(count: 1).ToArray(), rootDirectory, buildScript }; - return (int)commandHandler.Invoke(obj: null, commandArguments).NotNull($"Command '{command}' did not return exit code"); - } - - if (rootDirectory == null) - { - return PromptForConfirmation($"Could not find {Constants.FalloutDirectoryName} directory/file. Do you want to setup a build?") - ? Setup(new string[0], rootDirectory: null, buildScript: null) - : 0; - } - - // TODO: docker - - return Run(args, rootDirectory, BuildProjectResolver.Resolve(rootDirectory)); - } - - private static void ShowInput(string emoji, string title, string value) - { - AnsiConsole.MarkupLine($":{emoji}: {$"{title}:",-25} [turquoise2 bold]{value}[/]"); - } - - private static void ShowCompletion(string title) - { - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($"[bold green]{title} completed![/] :party_popper:"); - } - - private static void ClearPreviousLine() - { - AnsiConsole.Cursor.MoveUp(); - Console.WriteLine(' '.Repeat(Console.WindowWidth)); - AnsiConsole.Cursor.MoveUp(); - } - - private static bool PromptForConfirmation(string question) - { - return AnsiConsole.Confirm(question); - } - - private static string PromptForInput(string question, string defaultValue = null) - { - return AnsiConsole.Prompt( - new TextPrompt(question) - .DefaultValue(defaultValue)); - } - - private static string PromptForSecret(string title, int? minLength = null) - { - Assert.False(title.EndsWith(':')); - - return AnsiConsole.Prompt( - new TextPrompt($"{title}:") - .Secret() - .Validate(x => minLength == null || x.Length >= minLength, - message: $"Secret must be at least {minLength} characters long")); - } - - private static T PromptForChoice(string question, params (T Value, string Description)[] choices) - { - var choice = AnsiConsole.Prompt( - new SelectionPrompt() - .Title(question) - .HighlightStyle(new Style(Color.Turquoise2)) - .UseConverter(x => choices.Single(y => Equals(x, y.Value)).Description) - .AddChoices(choices.Select(x => x.Value))); - return choice; - } - - private static void ConfirmExecution(string title, Action action) - { - Assert.False(title.EndsWith('?')); - - var confirmation = PromptForConfirmation($"{title}?"); - ClearPreviousLine(); - - if (confirmation) - { - AnsiConsole.MarkupLine($":hourglass_not_done: {title} ..."); - try - { - action.Invoke(); - } - catch (Exception) - { - confirmation = false; - title = $"{title} (failed)"; - } - finally - { - ClearPreviousLine(); - } - } - - var (emoji, color) = confirmation ? ("check_mark", "green") : ("multiply", "red"); - AnsiConsole.MarkupLine($"[{color}]:{emoji}:[/] {title}"); - } + // ── Transitional Spectre prompt delegators ────────────────────────────────────────────────── + // The implementations now live in SpectreConsolePrompts; these forwards keep the not-yet-extracted + // Program.X.cs command handlers compiling. As each handler becomes a command type taking + // IConsolePrompts via the constructor, its use of these disappears; the last conversion deletes them. + private static readonly IConsolePrompts s_prompts = new SpectreConsolePrompts(); + + private static void ShowInput(string emoji, string title, string value) => s_prompts.ShowInput(emoji, title, value); + private static void ShowCompletion(string title) => s_prompts.ShowCompletion(title); + private static void ClearPreviousLine() => s_prompts.ClearPreviousLine(); + private static bool PromptForConfirmation(string question) => s_prompts.PromptForConfirmation(question); + private static string PromptForInput(string question, string defaultValue = null) => s_prompts.PromptForInput(question, defaultValue); + private static string PromptForSecret(string title, int? minLength = null) => s_prompts.PromptForSecret(title, minLength); + private static T PromptForChoice(string question, params (T Value, string Description)[] choices) => s_prompts.PromptForChoice(question, choices); + private static void ConfirmExecution(string title, Action action) => s_prompts.ConfirmExecution(title, action); } diff --git a/src/Fallout.Cli/Prompts/IConsolePrompts.cs b/src/Fallout.Cli/Prompts/IConsolePrompts.cs new file mode 100644 index 000000000..465e1fd81 --- /dev/null +++ b/src/Fallout.Cli/Prompts/IConsolePrompts.cs @@ -0,0 +1,39 @@ +using System; + +namespace Fallout.Cli.Prompts; + +/// +/// Interactive console prompts and status rendering used by commands. Injected into commands +/// so their interaction logic can be unit-tested with a fake, instead of reaching the historical +/// static Spectre helpers on Program. The default implementation is +/// . +/// +public interface IConsolePrompts +{ + /// Renders a labelled, read-only input line (used to echo resolved setup choices). + void ShowInput(string emoji, string title, string value); + + /// Renders a " completed!" banner. + void ShowCompletion(string title); + + /// Clears the previously written console line. + void ClearPreviousLine(); + + /// Asks a yes/no question and returns the answer. + bool PromptForConfirmation(string question); + + /// Asks for free-text input, optionally with a default value. + string PromptForInput(string question, string defaultValue = null); + + /// Asks for a masked secret value, optionally enforcing a minimum length. + string PromptForSecret(string title, int? minLength = null); + + /// Asks the user to pick one of the supplied . + T PromptForChoice(string question, params (T Value, string Description)[] choices); + + /// + /// Confirms, then runs with progress/completion rendering, + /// reporting success or failure without throwing. + /// + void ConfirmExecution(string title, Action action); +} diff --git a/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs b/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs new file mode 100644 index 000000000..5f688a751 --- /dev/null +++ b/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs @@ -0,0 +1,95 @@ +using System; +using System.Linq; +using Fallout.Common; +using Fallout.Common.Utilities; +using Spectre.Console; + +namespace Fallout.Cli.Prompts; + +/// +/// backed by . This is the single home for +/// the interactive prompt logic that previously lived as static helpers on Program. +/// +public sealed class SpectreConsolePrompts : IConsolePrompts +{ + public void ShowInput(string emoji, string title, string value) + { + AnsiConsole.MarkupLine($":{emoji}: {$"{title}:",-25} [turquoise2 bold]{value}[/]"); + } + + public void ShowCompletion(string title) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[bold green]{title} completed![/] :party_popper:"); + } + + public void ClearPreviousLine() + { + AnsiConsole.Cursor.MoveUp(); + System.Console.WriteLine(' '.Repeat(System.Console.WindowWidth)); + AnsiConsole.Cursor.MoveUp(); + } + + public bool PromptForConfirmation(string question) + { + return AnsiConsole.Confirm(question); + } + + public string PromptForInput(string question, string defaultValue = null) + { + return AnsiConsole.Prompt( + new TextPrompt(question) + .DefaultValue(defaultValue)); + } + + public string PromptForSecret(string title, int? minLength = null) + { + Assert.False(title.EndsWith(':')); + + return AnsiConsole.Prompt( + new TextPrompt($"{title}:") + .Secret() + .Validate(x => minLength == null || x.Length >= minLength, + message: $"Secret must be at least {minLength} characters long")); + } + + public T PromptForChoice(string question, params (T Value, string Description)[] choices) + { + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title(question) + .HighlightStyle(new Style(Color.Turquoise2)) + .UseConverter(x => choices.Single(y => Equals(x, y.Value)).Description) + .AddChoices(choices.Select(x => x.Value))); + return choice; + } + + public void ConfirmExecution(string title, Action action) + { + Assert.False(title.EndsWith('?')); + + var confirmation = PromptForConfirmation($"{title}?"); + ClearPreviousLine(); + + if (confirmation) + { + AnsiConsole.MarkupLine($":hourglass_not_done: {title} ..."); + try + { + action.Invoke(); + } + catch (Exception) + { + confirmation = false; + title = $"{title} (failed)"; + } + finally + { + ClearPreviousLine(); + } + } + + var (emoji, color) = confirmation ? ("check_mark", "green") : ("multiply", "red"); + AnsiConsole.MarkupLine($"[{color}]:{emoji}:[/] {title}"); + } +} diff --git a/tests/Fallout.Cli.Specs/CommandDispatcherSpecs.cs b/tests/Fallout.Cli.Specs/CommandDispatcherSpecs.cs new file mode 100644 index 000000000..58a18590f --- /dev/null +++ b/tests/Fallout.Cli.Specs/CommandDispatcherSpecs.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Fallout.Cli.Commands; +using Fallout.Cli.Prompts; +using Fallout.Common.IO; +using FluentAssertions; +using Xunit; + +namespace Fallout.Cli.Specs; + +public class CommandDispatcherSpecs +{ + private static readonly AbsolutePath SomeRoot = (AbsolutePath)Path.Combine(Path.GetTempPath(), "fallout-dispatch-root"); + private static readonly AbsolutePath SomeScript = SomeRoot / "build.sh"; + + [Fact] + public void Dispatch_ColonCommand_InvokesMatchingCommandWithForwardedArgs() + { + var run = new RecordingCommand("run"); + var setup = new RecordingCommand("setup"); + var dispatcher = new CommandDispatcher(new IFalloutCommand[] { run, setup }, new FakePrompts()); + + var exitCode = dispatcher.Dispatch([":setup", "alpha", "beta"], SomeRoot, SomeScript); + + exitCode.Should().Be(0); + setup.WasCalled.Should().BeTrue(); + setup.ReceivedArgs.Should().Equal("alpha", "beta"); + setup.ReceivedRoot.Should().Be(SomeRoot); + setup.ReceivedBuildScript.Should().Be(SomeScript); + run.WasCalled.Should().BeFalse(); + } + + [Theory] + [InlineData(":setup")] + [InlineData(":SETUP")] + [InlineData(":Setup")] + public void Dispatch_MatchesNameCaseInsensitively(string token) + { + var setup = new RecordingCommand("setup"); + var dispatcher = new CommandDispatcher(new IFalloutCommand[] { setup }, new FakePrompts()); + + dispatcher.Dispatch([token], SomeRoot, SomeScript); + + setup.WasCalled.Should().BeTrue(); + } + + [Theory] + [InlineData(":add-package")] + [InlineData(":addpackage")] + [InlineData(":ADD-PACKAGE")] + public void Dispatch_MatchesNameIgnoringDashes(string token) + { + // Preserves every spelling the historical reflection dispatch accepted. + var addPackage = new RecordingCommand("add-package"); + var dispatcher = new CommandDispatcher(new IFalloutCommand[] { addPackage }, new FakePrompts()); + + dispatcher.Dispatch([token], SomeRoot, SomeScript); + + addPackage.WasCalled.Should().BeTrue(); + } + + [Fact] + public void Dispatch_ReturnsCommandExitCode() + { + var trigger = new RecordingCommand("trigger", exitCode: 42); + var dispatcher = new CommandDispatcher(new IFalloutCommand[] { trigger }, new FakePrompts()); + + dispatcher.Dispatch([":trigger"], SomeRoot, SomeScript).Should().Be(42); + } + + [Fact] + public void Dispatch_UnknownCommand_ThrowsWithAvailableCommandListing() + { + var dispatcher = new CommandDispatcher( + new IFalloutCommand[] { new RecordingCommand("run"), new RecordingCommand("setup") }, + new FakePrompts()); + + var action = () => dispatcher.Dispatch([":bogus"], SomeRoot, SomeScript); + + action.Should().Throw() + .WithMessage("*'bogus' is not supported*") + .And.Message.Should().ContainAll("- run", "- setup"); + } + + [Fact] + public void Dispatch_EmptyCommandToken_Fails() + { + var dispatcher = new CommandDispatcher(new IFalloutCommand[] { new RecordingCommand("run") }, new FakePrompts()); + + var action = () => dispatcher.Dispatch([":"], SomeRoot, SomeScript); + + action.Should().Throw().WithMessage("*No command specified*"); + } + + [Fact] + public void Dispatch_NoCommand_NullRoot_Confirmed_InvokesSetup() + { + var setup = new RecordingCommand("setup"); + var dispatcher = new CommandDispatcher( + new IFalloutCommand[] { new RecordingCommand("run"), setup }, + new FakePrompts(confirm: true)); + + dispatcher.Dispatch(["whatever"], rootDirectory: null, buildScript: null); + + setup.WasCalled.Should().BeTrue(); + setup.ReceivedArgs.Should().BeEmpty(); + setup.ReceivedRoot.Should().BeNull(); + } + + [Fact] + public void Dispatch_NoCommand_NullRoot_Declined_ReturnsZeroWithoutSetup() + { + var setup = new RecordingCommand("setup"); + var dispatcher = new CommandDispatcher( + new IFalloutCommand[] { new RecordingCommand("run"), setup }, + new FakePrompts(confirm: false)); + + var exitCode = dispatcher.Dispatch(["whatever"], rootDirectory: null, buildScript: null); + + exitCode.Should().Be(0); + setup.WasCalled.Should().BeFalse(); + } + + [Fact] + public void Dispatch_NoCommand_WithRoot_InvokesRunWithResolvedBuildProject() + { + using var root = TempRoot.Create(); + var buildProject = root.WriteBuildProjectAtConvention(); + var run = new RecordingCommand("run"); + var dispatcher = new CommandDispatcher( + new IFalloutCommand[] { run, new RecordingCommand("setup") }, + new FakePrompts()); + + dispatcher.Dispatch(["compile", "--verbose"], root.Path, buildScript: null); + + run.WasCalled.Should().BeTrue(); + run.ReceivedArgs.Should().Equal("compile", "--verbose"); + run.ReceivedBuildScript.Should().Be(buildProject); + } + + private sealed class RecordingCommand : IFalloutCommand + { + private readonly int _exitCode; + + public RecordingCommand(string name, int exitCode = 0) + { + Name = name; + _exitCode = exitCode; + } + + public string Name { get; } + public bool WasCalled { get; private set; } + public string[] ReceivedArgs { get; private set; } + public AbsolutePath ReceivedRoot { get; private set; } + public AbsolutePath ReceivedBuildScript { get; private set; } + + public int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + WasCalled = true; + ReceivedArgs = args; + ReceivedRoot = rootDirectory; + ReceivedBuildScript = buildScript; + return _exitCode; + } + } + + private sealed class FakePrompts : IConsolePrompts + { + private readonly bool _confirm; + + public FakePrompts(bool confirm = false) => _confirm = confirm; + + public bool PromptForConfirmation(string question) => _confirm; + + public void ShowInput(string emoji, string title, string value) { } + public void ShowCompletion(string title) { } + public void ClearPreviousLine() { } + public string PromptForInput(string question, string defaultValue = null) => defaultValue; + public string PromptForSecret(string title, int? minLength = null) => string.Empty; + public T PromptForChoice(string question, params (T Value, string Description)[] choices) => default; + public void ConfirmExecution(string title, Action action) => action(); + } + + private sealed class TempRoot : IDisposable + { + public AbsolutePath Path { get; } + + private TempRoot(AbsolutePath path) + { + Path = path; + (path / ".fallout").CreateDirectory(); + } + + public static TempRoot Create() + { + var dir = (AbsolutePath)System.IO.Path.Combine(System.IO.Path.GetTempPath(), "fallout-dispatch-" + Guid.NewGuid().ToString("N")); + dir.CreateDirectory(); + return new TempRoot(dir); + } + + public AbsolutePath WriteBuildProjectAtConvention() + { + var buildDir = Path / "build"; + buildDir.CreateDirectory(); + var projectFile = buildDir / "_build.csproj"; + File.WriteAllText(projectFile, string.Empty); + return projectFile; + } + + public void Dispose() + { + if (Directory.Exists(Path)) + Directory.Delete(Path, recursive: true); + } + } +} From f540301e4f1b3cace5fdd6bd00aedbdbe77a941f Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Wed, 17 Jun 2026 21:52:37 +1200 Subject: [PATCH 08/10] Make shared CLI helpers internal for incremental command extraction Bump the cross-command helpers (GetConfiguration(buildScript, evaluate), AddOrReplacePackage, WriteBuildScripts, WriteConfigurationFile, GetTemplate, PrintInfo, CurrentBuildScriptName, BUILD_PROJECT_FILE) from private to internal so the per-command IFalloutCommand types extracted in the #392 follow-up PRs can call them during the transition. These move into dedicated services in the final collapse PR; this is the minimal enabler that lets each command be converted in an independent, conflict-free PR. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Fallout.Cli/Program.AddPackage.cs | 2 +- src/Fallout.Cli/Program.GetConfiguration.cs | 6 ++++-- src/Fallout.Cli/Program.Setup.cs | 6 +++--- src/Fallout.Cli/Program.cs | 4 ++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Fallout.Cli/Program.AddPackage.cs b/src/Fallout.Cli/Program.AddPackage.cs index ea6d28cf5..29efb84be 100644 --- a/src/Fallout.Cli/Program.AddPackage.cs +++ b/src/Fallout.Cli/Program.AddPackage.cs @@ -45,7 +45,7 @@ public static int AddPackage(string[] args, AbsolutePath rootDirectory, Absolute return 0; } - private static void AddOrReplacePackage(string packageId, string packageVersion, string packageType, string buildProjectFile) + internal static void AddOrReplacePackage(string packageId, string packageVersion, string packageType, string buildProjectFile) { var buildProject = ProjectModelTasks.ParseProject(buildProjectFile).NotNull(); diff --git a/src/Fallout.Cli/Program.GetConfiguration.cs b/src/Fallout.Cli/Program.GetConfiguration.cs index 6600b3273..9a9875d95 100644 --- a/src/Fallout.Cli/Program.GetConfiguration.cs +++ b/src/Fallout.Cli/Program.GetConfiguration.cs @@ -11,7 +11,9 @@ namespace Fallout.Cli; partial class Program { - private const string BUILD_PROJECT_FILE = nameof(BUILD_PROJECT_FILE); + // internal (not private): shared with AddPackage/Update/Cake; will move into a configuration + // service in the final #392 collapse PR. + internal const string BUILD_PROJECT_FILE = nameof(BUILD_PROJECT_FILE); private const string TEMP_DIRECTORY = nameof(TEMP_DIRECTORY); private const string DOTNET_GLOBAL_FILE = nameof(DOTNET_GLOBAL_FILE); private const string DOTNET_INSTALL_URL = nameof(DOTNET_INSTALL_URL); @@ -27,7 +29,7 @@ public static int GetConfiguration(string[] args, AbsolutePath rootDirectory, Ab return 0; } - private static Dictionary GetConfiguration(AbsolutePath buildScript, bool evaluate) + internal static Dictionary GetConfiguration(AbsolutePath buildScript, bool evaluate) { string ReplaceScriptDirectory(string value) => evaluate diff --git a/src/Fallout.Cli/Program.Setup.cs b/src/Fallout.Cli/Program.Setup.cs index 819afebce..ae74774ca 100644 --- a/src/Fallout.Cli/Program.Setup.cs +++ b/src/Fallout.Cli/Program.Setup.cs @@ -176,13 +176,13 @@ internal static void UpdateSolutionFileContent( "EndProject"); } - private static string[] GetTemplate(string templateName) + internal static string[] GetTemplate(string templateName) { return ResourceUtility.GetResourceAllLines($"templates.{templateName}"); } - private static void WriteBuildScripts( + internal static void WriteBuildScripts( AbsolutePath scriptDirectory, AbsolutePath rootDirectory) { @@ -239,7 +239,7 @@ void MakeExecutable(AbsolutePath scriptFile) } } - private static void WriteConfigurationFile(AbsolutePath rootDirectory, AbsolutePath solutionFile) + internal static void WriteConfigurationFile(AbsolutePath rootDirectory, AbsolutePath solutionFile) { var parametersFile = GetDefaultParametersFile(rootDirectory); var dictionary = new Dictionary { ["$schema"] = BuildSchemaFileName }; diff --git a/src/Fallout.Cli/Program.cs b/src/Fallout.Cli/Program.cs index 6702b0380..1c36b99ca 100644 --- a/src/Fallout.Cli/Program.cs +++ b/src/Fallout.Cli/Program.cs @@ -13,7 +13,7 @@ namespace Fallout.Cli; public partial class Program { - private static string CurrentBuildScriptName => EnvironmentInfo.IsWin ? "build.ps1" : "build.sh"; + internal static string CurrentBuildScriptName => EnvironmentInfo.IsWin ? "build.ps1" : "build.sh"; private static int Main(string[] args) { @@ -72,7 +72,7 @@ private static void RegisterCommands(IServiceCollection services) services.AddSingleton(new DelegateCommand("PushWithChosenRootDirectory", (_, _, _) => PushWithChosenRootDirectory())); } - private static void PrintInfo() + internal static void PrintInfo() { Host.Information($"Fallout Global Tool 🌐 {typeof(Program).Assembly.GetInformationalText()}"); } From 49d50cb878dc76adfec88a29afe338500e071064 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sat, 27 Jun 2026 11:57:59 +1200 Subject: [PATCH 09/10] Make Cli interfaces internal for now we currently dont plan to expose any of those, so we can make these internal. If we need to expose those inside of fallout, we can do so with `` or at some point make a conscious decision to publish those interfaces for wider, public use Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Fallout.Cli/Commands/IFalloutCommand.cs | 8 ++++---- src/Fallout.Cli/Prompts/IConsolePrompts.cs | 2 +- src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Fallout.Cli/Commands/IFalloutCommand.cs b/src/Fallout.Cli/Commands/IFalloutCommand.cs index 04f027d7d..d591ae554 100644 --- a/src/Fallout.Cli/Commands/IFalloutCommand.cs +++ b/src/Fallout.Cli/Commands/IFalloutCommand.cs @@ -12,12 +12,12 @@ namespace Fallout.Cli.Commands; /// when a public command SDK lands (milestone #7) the API will be annotated and versioned /// explicitly. Until then, treat additions here as internal-by-convention. /// -public interface IFalloutCommand +internal interface IFalloutCommand { /// - /// The canonical command name as typed after the : prefix, in dash form - /// (e.g. "run", "add-package", "cake-convert"). Matched case-insensitively. - /// + /// The command name as typed after the : prefix (prefer dash form, e.g. "add-package").\ + /// Matched case-insensitively and with dashes ignored (so :addpackage also matches).\ + /// Legacy commands may still use PascalCase names (e.g. "GetNextDirectory"). string Name { get; } /// diff --git a/src/Fallout.Cli/Prompts/IConsolePrompts.cs b/src/Fallout.Cli/Prompts/IConsolePrompts.cs index 465e1fd81..99b383b9b 100644 --- a/src/Fallout.Cli/Prompts/IConsolePrompts.cs +++ b/src/Fallout.Cli/Prompts/IConsolePrompts.cs @@ -8,7 +8,7 @@ namespace Fallout.Cli.Prompts; /// static Spectre helpers on Program. The default implementation is /// . /// -public interface IConsolePrompts +internal interface IConsolePrompts { /// Renders a labelled, read-only input line (used to echo resolved setup choices). void ShowInput(string emoji, string title, string value); diff --git a/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs b/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs index 5f688a751..8013f2105 100644 --- a/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs +++ b/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs @@ -10,7 +10,7 @@ namespace Fallout.Cli.Prompts; /// backed by . This is the single home for /// the interactive prompt logic that previously lived as static helpers on Program. /// -public sealed class SpectreConsolePrompts : IConsolePrompts +internal sealed class SpectreConsolePrompts : IConsolePrompts { public void ShowInput(string emoji, string title, string value) { From a1e9c8f374f0c5544bc4f4baae5332eb442030ad Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Wed, 1 Jul 2026 14:13:35 +1200 Subject: [PATCH 10/10] Make Cli command dispatch async and apply review conventions Address review feedback on #448: - IFalloutCommand.Execute -> Task ExecuteAsync; RunCommand awaits Process.WaitForExitAsync; DelegateCommand adapts the still-sync legacy handlers via Task.FromResult; CommandDispatcher.DispatchAsync and Main become async. No behavior change and no public-API break (Fallout.Cli is the tool Exe, not a consumed library). - Drop the `_` field prefix on the new types (bare field names, this. only for ctor assignment collisions). - Always brace if/for bodies in the new code. Test method names keep the MethodUnderTest_Scenario_Result style of the sibling *Specs files; a repo-wide test-naming convention is left to a dedicated cleanup. Fallout.Cli.Specs: 30 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Fallout.Cli/CommandDispatcher.cs | 27 +++++---- src/Fallout.Cli/Commands/DelegateCommand.cs | 11 ++-- src/Fallout.Cli/Commands/IFalloutCommand.cs | 3 +- src/Fallout.Cli/Commands/RunCommand.cs | 17 ++++-- src/Fallout.Cli/Program.cs | 5 +- .../CommandDispatcherSpecs.cs | 59 ++++++++++--------- 6 files changed, 70 insertions(+), 52 deletions(-) diff --git a/src/Fallout.Cli/CommandDispatcher.cs b/src/Fallout.Cli/CommandDispatcher.cs index 86ce2c430..11948a394 100644 --- a/src/Fallout.Cli/CommandDispatcher.cs +++ b/src/Fallout.Cli/CommandDispatcher.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Fallout.Cli.Commands; using Fallout.Cli.Prompts; using Fallout.Common; @@ -20,50 +21,52 @@ internal sealed class CommandDispatcher { private const char CommandPrefix = ':'; - private readonly IReadOnlyList _commands; - private readonly IConsolePrompts _prompts; + private readonly IReadOnlyList commands; + private readonly IConsolePrompts prompts; public CommandDispatcher(IEnumerable commands, IConsolePrompts prompts) { - _commands = commands.ToList(); - _prompts = prompts; + this.commands = commands.ToList(); + this.prompts = prompts; } - public int Dispatch(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + public async Task DispatchAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) { var hasCommand = args.FirstOrDefault()?.StartsWithOrdinalIgnoreCase(CommandPrefix.ToString()) ?? false; if (hasCommand) { var token = args.First().Trim(CommandPrefix); if (string.IsNullOrWhiteSpace(token)) + { Assert.Fail($"No command specified. Usage is: fallout {CommandPrefix} [args]"); + } var command = Resolve(token); - return command.Execute(args.Skip(count: 1).ToArray(), rootDirectory, buildScript); + return await command.ExecuteAsync(args.Skip(count: 1).ToArray(), rootDirectory, buildScript); } if (rootDirectory == null) { - return _prompts.PromptForConfirmation( + return prompts.PromptForConfirmation( $"Could not find {Constants.FalloutDirectoryName} directory/file. Do you want to setup a build?") - ? GetRequired("setup").Execute(Array.Empty(), rootDirectory: null, buildScript: null) + ? await GetRequired("setup").ExecuteAsync(Array.Empty(), rootDirectory: null, buildScript: null) : 0; } // TODO: docker - return GetRequired("run").Execute(args, rootDirectory, BuildProjectResolver.Resolve(rootDirectory)); + return await GetRequired("run").ExecuteAsync(args, rootDirectory, BuildProjectResolver.Resolve(rootDirectory)); } private IFalloutCommand Resolve(string token) { - return _commands.SingleOrDefault(x => Normalize(x.Name).EqualsOrdinalIgnoreCase(Normalize(token))) + return commands.SingleOrDefault(x => Normalize(x.Name).EqualsOrdinalIgnoreCase(Normalize(token))) .NotNull(new[] { $"Command '{token}' is not supported, available commands are:" } - .Concat(_commands.Select(x => $" - {x.Name}").OrderBy(x => x)).JoinNewLine()); + .Concat(commands.Select(x => $" - {x.Name}").OrderBy(x => x)).JoinNewLine()); } private IFalloutCommand GetRequired(string name) - => _commands.Single(x => x.Name.EqualsOrdinalIgnoreCase(name)); + => commands.Single(x => x.Name.EqualsOrdinalIgnoreCase(name)); private static string Normalize(string value) => value.Replace("-", string.Empty); } diff --git a/src/Fallout.Cli/Commands/DelegateCommand.cs b/src/Fallout.Cli/Commands/DelegateCommand.cs index 5a60e8559..97089fab1 100644 --- a/src/Fallout.Cli/Commands/DelegateCommand.cs +++ b/src/Fallout.Cli/Commands/DelegateCommand.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using Fallout.Common.IO; namespace Fallout.Cli.Commands; @@ -12,16 +13,18 @@ namespace Fallout.Cli.Commands; /// internal sealed class DelegateCommand : IFalloutCommand { - private readonly Func _handler; + private readonly Func handler; public DelegateCommand(string name, Func handler) { Name = name; - _handler = handler; + this.handler = handler; } public string Name { get; } - public int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) - => _handler(args, rootDirectory, buildScript); + // The adapted legacy handlers are still synchronous; each #392 conversion replaces one + // registration of this adapter with a command type that overrides ExecuteAsync for real. + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => Task.FromResult(handler(args, rootDirectory, buildScript)); } diff --git a/src/Fallout.Cli/Commands/IFalloutCommand.cs b/src/Fallout.Cli/Commands/IFalloutCommand.cs index d591ae554..f583aa87d 100644 --- a/src/Fallout.Cli/Commands/IFalloutCommand.cs +++ b/src/Fallout.Cli/Commands/IFalloutCommand.cs @@ -1,3 +1,4 @@ +using System.Threading.Tasks; using Fallout.Common.IO; namespace Fallout.Cli.Commands; @@ -26,5 +27,5 @@ internal interface IFalloutCommand /// The arguments following the command token. /// The resolved repository root, or null when none was found. /// The resolved build script / project file, or null when none applies. - int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript); + Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript); } diff --git a/src/Fallout.Cli/Commands/RunCommand.cs b/src/Fallout.Cli/Commands/RunCommand.cs index 28982c927..dd6b66218 100644 --- a/src/Fallout.Cli/Commands/RunCommand.cs +++ b/src/Fallout.Cli/Commands/RunCommand.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading.Tasks; using Fallout.Common; using Fallout.Common.IO; using Fallout.Common.Utilities; @@ -18,22 +19,26 @@ public sealed class RunCommand : IFalloutCommand { public string Name => "run"; - public int Execute(string[] forwardedArgs, AbsolutePath rootDirectory, AbsolutePath buildProjectFile) + public async Task ExecuteAsync(string[] forwardedArgs, AbsolutePath rootDirectory, AbsolutePath buildProjectFile) { var dotnet = ResolveDotnet(rootDirectory); - var buildExitCode = StartDotnet(dotnet, GetBuildArguments(buildProjectFile)); + var buildExitCode = await StartDotnetAsync(dotnet, GetBuildArguments(buildProjectFile)); if (buildExitCode != 0) + { return buildExitCode; + } - return StartDotnet(dotnet, GetRunArguments(buildProjectFile, forwardedArgs)); + return await StartDotnetAsync(dotnet, GetRunArguments(buildProjectFile, forwardedArgs)); } private static string ResolveDotnet(AbsolutePath rootDirectory) { var pathDotnet = TryGetDotnetFromPath(); if (pathDotnet != null) + { return pathDotnet; + } var shimDirectoryName = EnvironmentInfo.IsWin ? "dotnet-win" : "dotnet-unix"; var shimExecutableName = EnvironmentInfo.IsWin ? "dotnet.exe" : "dotnet"; @@ -54,7 +59,7 @@ private static string TryGetDotnetFromPath() .FirstOrDefault(File.Exists); } - private static int StartDotnet(string dotnet, IEnumerable arguments) + private static async Task StartDotnetAsync(string dotnet, IEnumerable arguments) { var startInfo = new ProcessStartInfo { @@ -63,7 +68,9 @@ private static int StartDotnet(string dotnet, IEnumerable arguments) }; foreach (var argument in arguments) + { startInfo.ArgumentList.Add(argument); + } startInfo.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"; startInfo.Environment["DOTNET_NOLOGO"] = "1"; @@ -73,7 +80,7 @@ private static int StartDotnet(string dotnet, IEnumerable arguments) startInfo.Environment[GlobalToolStartTimeEnvironmentKey] = DateTime.Now.ToString("O"); var process = Process.Start(startInfo).NotNull(); - process.WaitForExit(); + await process.WaitForExitAsync(); return process.ExitCode; } diff --git a/src/Fallout.Cli/Program.cs b/src/Fallout.Cli/Program.cs index 1c36b99ca..2221f1d9a 100644 --- a/src/Fallout.Cli/Program.cs +++ b/src/Fallout.Cli/Program.cs @@ -2,6 +2,7 @@ using System.IO; using System.Linq; using System.Text; +using System.Threading.Tasks; using Fallout.Cli.Commands; using Fallout.Cli.Prompts; using Fallout.Common; @@ -15,7 +16,7 @@ public partial class Program { internal static string CurrentBuildScriptName => EnvironmentInfo.IsWin ? "build.ps1" : "build.sh"; - private static int Main(string[] args) + private static async Task Main(string[] args) { Console.OutputEncoding = Encoding.UTF8; @@ -29,7 +30,7 @@ private static int Main(string[] args) : null; using var services = BuildServiceProvider(); - return services.GetRequiredService().Dispatch(args, rootDirectory, buildScript); + return await services.GetRequiredService().DispatchAsync(args, rootDirectory, buildScript); } catch (Exception exception) { diff --git a/tests/Fallout.Cli.Specs/CommandDispatcherSpecs.cs b/tests/Fallout.Cli.Specs/CommandDispatcherSpecs.cs index 58a18590f..7bb97405c 100644 --- a/tests/Fallout.Cli.Specs/CommandDispatcherSpecs.cs +++ b/tests/Fallout.Cli.Specs/CommandDispatcherSpecs.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading.Tasks; using Fallout.Cli.Commands; using Fallout.Cli.Prompts; using Fallout.Common.IO; @@ -15,13 +16,13 @@ public class CommandDispatcherSpecs private static readonly AbsolutePath SomeScript = SomeRoot / "build.sh"; [Fact] - public void Dispatch_ColonCommand_InvokesMatchingCommandWithForwardedArgs() + public async Task Dispatch_ColonCommand_InvokesMatchingCommandWithForwardedArgs() { var run = new RecordingCommand("run"); var setup = new RecordingCommand("setup"); var dispatcher = new CommandDispatcher(new IFalloutCommand[] { run, setup }, new FakePrompts()); - var exitCode = dispatcher.Dispatch([":setup", "alpha", "beta"], SomeRoot, SomeScript); + var exitCode = await dispatcher.DispatchAsync([":setup", "alpha", "beta"], SomeRoot, SomeScript); exitCode.Should().Be(0); setup.WasCalled.Should().BeTrue(); @@ -35,12 +36,12 @@ public void Dispatch_ColonCommand_InvokesMatchingCommandWithForwardedArgs() [InlineData(":setup")] [InlineData(":SETUP")] [InlineData(":Setup")] - public void Dispatch_MatchesNameCaseInsensitively(string token) + public async Task Dispatch_MatchesNameCaseInsensitively(string token) { var setup = new RecordingCommand("setup"); var dispatcher = new CommandDispatcher(new IFalloutCommand[] { setup }, new FakePrompts()); - dispatcher.Dispatch([token], SomeRoot, SomeScript); + await dispatcher.DispatchAsync([token], SomeRoot, SomeScript); setup.WasCalled.Should().BeTrue(); } @@ -49,59 +50,59 @@ public void Dispatch_MatchesNameCaseInsensitively(string token) [InlineData(":add-package")] [InlineData(":addpackage")] [InlineData(":ADD-PACKAGE")] - public void Dispatch_MatchesNameIgnoringDashes(string token) + public async Task Dispatch_MatchesNameIgnoringDashes(string token) { // Preserves every spelling the historical reflection dispatch accepted. var addPackage = new RecordingCommand("add-package"); var dispatcher = new CommandDispatcher(new IFalloutCommand[] { addPackage }, new FakePrompts()); - dispatcher.Dispatch([token], SomeRoot, SomeScript); + await dispatcher.DispatchAsync([token], SomeRoot, SomeScript); addPackage.WasCalled.Should().BeTrue(); } [Fact] - public void Dispatch_ReturnsCommandExitCode() + public async Task Dispatch_ReturnsCommandExitCode() { var trigger = new RecordingCommand("trigger", exitCode: 42); var dispatcher = new CommandDispatcher(new IFalloutCommand[] { trigger }, new FakePrompts()); - dispatcher.Dispatch([":trigger"], SomeRoot, SomeScript).Should().Be(42); + (await dispatcher.DispatchAsync([":trigger"], SomeRoot, SomeScript)).Should().Be(42); } [Fact] - public void Dispatch_UnknownCommand_ThrowsWithAvailableCommandListing() + public async Task Dispatch_UnknownCommand_ThrowsWithAvailableCommandListing() { var dispatcher = new CommandDispatcher( new IFalloutCommand[] { new RecordingCommand("run"), new RecordingCommand("setup") }, new FakePrompts()); - var action = () => dispatcher.Dispatch([":bogus"], SomeRoot, SomeScript); + var action = () => dispatcher.DispatchAsync([":bogus"], SomeRoot, SomeScript); - action.Should().Throw() - .WithMessage("*'bogus' is not supported*") + (await action.Should().ThrowAsync() + .WithMessage("*'bogus' is not supported*")) .And.Message.Should().ContainAll("- run", "- setup"); } [Fact] - public void Dispatch_EmptyCommandToken_Fails() + public async Task Dispatch_EmptyCommandToken_Fails() { var dispatcher = new CommandDispatcher(new IFalloutCommand[] { new RecordingCommand("run") }, new FakePrompts()); - var action = () => dispatcher.Dispatch([":"], SomeRoot, SomeScript); + var action = () => dispatcher.DispatchAsync([":"], SomeRoot, SomeScript); - action.Should().Throw().WithMessage("*No command specified*"); + await action.Should().ThrowAsync().WithMessage("*No command specified*"); } [Fact] - public void Dispatch_NoCommand_NullRoot_Confirmed_InvokesSetup() + public async Task Dispatch_NoCommand_NullRoot_Confirmed_InvokesSetup() { var setup = new RecordingCommand("setup"); var dispatcher = new CommandDispatcher( new IFalloutCommand[] { new RecordingCommand("run"), setup }, new FakePrompts(confirm: true)); - dispatcher.Dispatch(["whatever"], rootDirectory: null, buildScript: null); + await dispatcher.DispatchAsync(["whatever"], rootDirectory: null, buildScript: null); setup.WasCalled.Should().BeTrue(); setup.ReceivedArgs.Should().BeEmpty(); @@ -109,21 +110,21 @@ public void Dispatch_NoCommand_NullRoot_Confirmed_InvokesSetup() } [Fact] - public void Dispatch_NoCommand_NullRoot_Declined_ReturnsZeroWithoutSetup() + public async Task Dispatch_NoCommand_NullRoot_Declined_ReturnsZeroWithoutSetup() { var setup = new RecordingCommand("setup"); var dispatcher = new CommandDispatcher( new IFalloutCommand[] { new RecordingCommand("run"), setup }, new FakePrompts(confirm: false)); - var exitCode = dispatcher.Dispatch(["whatever"], rootDirectory: null, buildScript: null); + var exitCode = await dispatcher.DispatchAsync(["whatever"], rootDirectory: null, buildScript: null); exitCode.Should().Be(0); setup.WasCalled.Should().BeFalse(); } [Fact] - public void Dispatch_NoCommand_WithRoot_InvokesRunWithResolvedBuildProject() + public async Task Dispatch_NoCommand_WithRoot_InvokesRunWithResolvedBuildProject() { using var root = TempRoot.Create(); var buildProject = root.WriteBuildProjectAtConvention(); @@ -132,7 +133,7 @@ public void Dispatch_NoCommand_WithRoot_InvokesRunWithResolvedBuildProject() new IFalloutCommand[] { run, new RecordingCommand("setup") }, new FakePrompts()); - dispatcher.Dispatch(["compile", "--verbose"], root.Path, buildScript: null); + await dispatcher.DispatchAsync(["compile", "--verbose"], root.Path, buildScript: null); run.WasCalled.Should().BeTrue(); run.ReceivedArgs.Should().Equal("compile", "--verbose"); @@ -141,12 +142,12 @@ public void Dispatch_NoCommand_WithRoot_InvokesRunWithResolvedBuildProject() private sealed class RecordingCommand : IFalloutCommand { - private readonly int _exitCode; + private readonly int exitCode; public RecordingCommand(string name, int exitCode = 0) { Name = name; - _exitCode = exitCode; + this.exitCode = exitCode; } public string Name { get; } @@ -155,23 +156,23 @@ public RecordingCommand(string name, int exitCode = 0) public AbsolutePath ReceivedRoot { get; private set; } public AbsolutePath ReceivedBuildScript { get; private set; } - public int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) { WasCalled = true; ReceivedArgs = args; ReceivedRoot = rootDirectory; ReceivedBuildScript = buildScript; - return _exitCode; + return Task.FromResult(exitCode); } } private sealed class FakePrompts : IConsolePrompts { - private readonly bool _confirm; + private readonly bool confirm; - public FakePrompts(bool confirm = false) => _confirm = confirm; + public FakePrompts(bool confirm = false) => this.confirm = confirm; - public bool PromptForConfirmation(string question) => _confirm; + public bool PromptForConfirmation(string question) => confirm; public void ShowInput(string emoji, string title, string value) { } public void ShowCompletion(string title) { } @@ -211,7 +212,9 @@ public AbsolutePath WriteBuildProjectAtConvention() public void Dispose() { if (Directory.Exists(Path)) + { Directory.Delete(Path, recursive: true); + } } } }